chore: sync GovOPlaN module split state
This commit is contained in:
@@ -112,7 +112,7 @@ export function parseManagedAttachmentSource(value: unknown): ManagedAttachmentS
|
||||
if (typeof value !== "string" || !value.startsWith(MANAGED_ATTACHMENT_SOURCE_PREFIX)) return null;
|
||||
const [, ownerType, ...ownerIdParts] = value.split(":");
|
||||
const ownerId = ownerIdParts.join(":").trim();
|
||||
if ((ownerType !== "user" && ownerType !== "group") || !ownerId) return null;
|
||||
if (ownerType !== "user" && ownerType !== "group" || !ownerId) return null;
|
||||
return { ownerType, ownerId };
|
||||
}
|
||||
|
||||
@@ -135,12 +135,12 @@ export type MockAttachmentPathOption = Partial<AttachmentBasePath> & {
|
||||
};
|
||||
|
||||
export const mockAttachmentPathOptions: MockAttachmentPathOption[] = [
|
||||
{ label: "Campaign attachments", path: "attachments" },
|
||||
{ label: "Campaign root", path: "." },
|
||||
{ label: "Shared group files", path: "group/shared" },
|
||||
{ label: "Tenant templates", path: "tenant/templates" },
|
||||
{ label: "Personal upload area", path: "user/uploads" }
|
||||
];
|
||||
{ label: "i18n:govoplan-campaign.campaign_attachments.926bcbe6", path: "attachments" },
|
||||
{ label: "i18n:govoplan-campaign.campaign_root.7eccd949", path: "." },
|
||||
{ label: "i18n:govoplan-campaign.shared_group_files.fffa6e27", path: "group/shared" },
|
||||
{ label: "i18n:govoplan-campaign.tenant_templates.ac6653bf", path: "tenant/templates" },
|
||||
{ label: "i18n:govoplan-campaign.personal_upload_area.babd7b7f", path: "user/uploads" }];
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -205,11 +205,11 @@ export function attachmentRuleUsesBasePath(rule: AttachmentRule, basePath: Attac
|
||||
|
||||
export function countIndividualAttachmentRulesForBasePath(entriesValue: unknown, basePath: AttachmentBasePath): number {
|
||||
const entries = asRecord(entriesValue);
|
||||
return asArray(entries.inline)
|
||||
.map(asRecord)
|
||||
.flatMap((entry) => normalizeAttachmentRules(entry.attachments))
|
||||
.filter((rule) => attachmentRuleUsesBasePath(rule, basePath))
|
||||
.length;
|
||||
return asArray(entries.inline).
|
||||
map(asRecord).
|
||||
flatMap((entry) => normalizeAttachmentRules(entry.attachments)).
|
||||
filter((rule) => attachmentRuleUsesBasePath(rule, basePath)).
|
||||
length;
|
||||
}
|
||||
|
||||
export function removeIndividualAttachmentRulesForBasePath(entriesValue: unknown, basePath: AttachmentBasePath): Record<string, unknown> {
|
||||
@@ -261,10 +261,10 @@ export function summarizeAttachmentRules(rules: AttachmentRule[]): AttachmentSum
|
||||
|
||||
export function countIndividualAttachmentRules(entriesValue: unknown): number {
|
||||
const entries = asRecord(entriesValue);
|
||||
return asArray(entries.inline)
|
||||
.map(asRecord)
|
||||
.flatMap((entry) => asArray(entry.attachments))
|
||||
.length;
|
||||
return asArray(entries.inline).
|
||||
map(asRecord).
|
||||
flatMap((entry) => asArray(entry.attachments)).
|
||||
length;
|
||||
}
|
||||
|
||||
export function isDirectAttachmentRule(rule: AttachmentRule): boolean {
|
||||
|
||||
@@ -177,9 +177,9 @@ export function buildImportTable(text: string, options: CsvParseOptions): Recipi
|
||||
}
|
||||
|
||||
export function buildImportTableFromRows(
|
||||
sourceRows: string[][],
|
||||
options: { headerRows: number; delimiter?: "," | ";" | "\t" }
|
||||
): RecipientImportTable {
|
||||
sourceRows: string[][],
|
||||
options: {headerRows: number;delimiter?: "," | ";" | "\t";})
|
||||
: RecipientImportTable {
|
||||
const rows = sourceRows.map((row) => row.map((cell) => String(cell ?? "")));
|
||||
const normalizedHeaderRows = Math.max(0, Math.min(Math.floor(options.headerRows || 0), rows.length));
|
||||
const headerRows = rows.slice(0, normalizedHeaderRows);
|
||||
@@ -269,37 +269,37 @@ export function recipientImportHeaderFingerprints(headers: string[]): Pick<Recip
|
||||
|
||||
export function matchRecipientMappingProfiles(table: RecipientImportTable, profiles: RecipientMappingProfile[]): RecipientMappingProfileMatch[] {
|
||||
const fingerprints = recipientImportHeaderFingerprints(table.headers);
|
||||
return profiles
|
||||
.map((profile): RecipientMappingProfileMatch | null => {
|
||||
const diff = headerSetDiff(fingerprints.normalizedHeaders, profile.normalizedHeaders);
|
||||
const exactOrdered = fingerprints.orderedHeaderFingerprint === profile.orderedHeaderFingerprint;
|
||||
const exactUnordered = fingerprints.unorderedHeaderFingerprint === profile.unorderedHeaderFingerprint;
|
||||
const mode: RecipientMappingProfileMatchMode = exactOrdered ? "ordered" : exactUnordered ? "unordered" : "similar";
|
||||
const baseScore = exactOrdered ? 1 : exactUnordered ? 0.96 : diff.matchedHeaders / Math.max(fingerprints.normalizedHeaders.length, profile.normalizedHeaders.length, 1);
|
||||
const mappedMissingCount = missingMappedHeaders(profile, fingerprints.normalizedHeaders).length;
|
||||
const score = mode === "similar" ? Math.max(0, baseScore - Math.min(0.25, mappedMissingCount * 0.05)) : baseScore;
|
||||
if (mode === "similar" && score < 0.45) return null;
|
||||
return {
|
||||
profile,
|
||||
mode,
|
||||
score,
|
||||
matchedHeaders: diff.matchedHeaders,
|
||||
missingHeaders: diff.missingHeaders,
|
||||
extraHeaders: diff.extraHeaders
|
||||
};
|
||||
})
|
||||
.filter((match): match is RecipientMappingProfileMatch => match !== null)
|
||||
.sort((left, right) => {
|
||||
if (right.score !== left.score) return right.score - left.score;
|
||||
return right.profile.updatedAt.localeCompare(left.profile.updatedAt);
|
||||
});
|
||||
return profiles.
|
||||
map((profile): RecipientMappingProfileMatch | null => {
|
||||
const diff = headerSetDiff(fingerprints.normalizedHeaders, profile.normalizedHeaders);
|
||||
const exactOrdered = fingerprints.orderedHeaderFingerprint === profile.orderedHeaderFingerprint;
|
||||
const exactUnordered = fingerprints.unorderedHeaderFingerprint === profile.unorderedHeaderFingerprint;
|
||||
const mode: RecipientMappingProfileMatchMode = exactOrdered ? "ordered" : exactUnordered ? "unordered" : "similar";
|
||||
const baseScore = exactOrdered ? 1 : exactUnordered ? 0.96 : diff.matchedHeaders / Math.max(fingerprints.normalizedHeaders.length, profile.normalizedHeaders.length, 1);
|
||||
const mappedMissingCount = missingMappedHeaders(profile, fingerprints.normalizedHeaders).length;
|
||||
const score = mode === "similar" ? Math.max(0, baseScore - Math.min(0.25, mappedMissingCount * 0.05)) : baseScore;
|
||||
if (mode === "similar" && score < 0.45) return null;
|
||||
return {
|
||||
profile,
|
||||
mode,
|
||||
score,
|
||||
matchedHeaders: diff.matchedHeaders,
|
||||
missingHeaders: diff.missingHeaders,
|
||||
extraHeaders: diff.extraHeaders
|
||||
};
|
||||
}).
|
||||
filter((match): match is RecipientMappingProfileMatch => match !== null).
|
||||
sort((left, right) => {
|
||||
if (right.score !== left.score) return right.score - left.score;
|
||||
return right.profile.updatedAt.localeCompare(left.profile.updatedAt);
|
||||
});
|
||||
}
|
||||
|
||||
export function applyRecipientMappingProfile(
|
||||
table: RecipientImportTable,
|
||||
profile: RecipientMappingProfile,
|
||||
existingFields: ImportFieldDefinition[]
|
||||
): RecipientColumnMapping[] {
|
||||
table: RecipientImportTable,
|
||||
profile: RecipientMappingProfile,
|
||||
existingFields: ImportFieldDefinition[])
|
||||
: RecipientColumnMapping[] {
|
||||
const defaults = defaultColumnMappings(table.headers, existingFields);
|
||||
const fingerprints = recipientImportHeaderFingerprints(table.headers);
|
||||
const profileMappings = normalizeMappingList(profile.mappings, profile.columnCount || profile.headers.length);
|
||||
@@ -325,10 +325,10 @@ export function applyRecipientMappingProfile(
|
||||
}
|
||||
|
||||
export function buildRecipientImportPreview(
|
||||
table: RecipientImportTable,
|
||||
mappings: RecipientColumnMapping[],
|
||||
options: RecipientImportPreviewOptions
|
||||
): RecipientImportPreview {
|
||||
table: RecipientImportTable,
|
||||
mappings: RecipientColumnMapping[],
|
||||
options: RecipientImportPreviewOptions)
|
||||
: RecipientImportPreview {
|
||||
const existingFields = options.existingFields;
|
||||
const existingFieldNames = new Set(existingFields.map((field) => field.name));
|
||||
const existingIds = new Set((options.existingEntries ?? []).map((entry) => String(entry.id || "")).filter(Boolean));
|
||||
@@ -337,45 +337,45 @@ export function buildRecipientImportPreview(
|
||||
const mappingsByColumn = new Map(mappings.map((mapping) => [mapping.columnIndex, mapping]));
|
||||
const valueSeparators = options.valueSeparators || ",;|";
|
||||
|
||||
const rows = table.dataRows
|
||||
.map((cells, index): ImportedRecipientRow | null => {
|
||||
if (cells.every((cell) => !cell.trim())) return null;
|
||||
const rowNumber = table.firstDataRowNumber + index;
|
||||
const addresses: ImportedRecipientRow["addresses"] = { from: [], to: [], cc: [], bcc: [], reply_to: [] };
|
||||
const fields: Record<string, string> = {};
|
||||
const patterns: string[] = [];
|
||||
const issues: string[] = [];
|
||||
let preferredId = "";
|
||||
let name = "";
|
||||
let active = true;
|
||||
const rows = table.dataRows.
|
||||
map((cells, index): ImportedRecipientRow | null => {
|
||||
if (cells.every((cell) => !cell.trim())) return null;
|
||||
const rowNumber = table.firstDataRowNumber + index;
|
||||
const addresses: ImportedRecipientRow["addresses"] = { from: [], to: [], cc: [], bcc: [], reply_to: [] };
|
||||
const fields: Record<string, string> = {};
|
||||
const patterns: string[] = [];
|
||||
const issues: string[] = [];
|
||||
let preferredId = "";
|
||||
let name = "";
|
||||
let active = true;
|
||||
|
||||
cells.forEach((rawValue, columnIndex) => {
|
||||
const value = String(rawValue ?? "").trim();
|
||||
if (!value) return;
|
||||
const mapping = mappingsByColumn.get(columnIndex) ?? { columnIndex, kind: "ignore" as const };
|
||||
switch (mapping.kind) {
|
||||
case "id":
|
||||
preferredId = value;
|
||||
break;
|
||||
case "active":
|
||||
active = parseOptionalBoolean(value, true);
|
||||
break;
|
||||
case "name":
|
||||
name = value;
|
||||
break;
|
||||
case "from":
|
||||
case "to":
|
||||
case "cc":
|
||||
case "bcc":
|
||||
case "reply_to":
|
||||
addresses[mapping.kind].push(...parseAddressCell(value, valueSeparators));
|
||||
break;
|
||||
case "field": {
|
||||
cells.forEach((rawValue, columnIndex) => {
|
||||
const value = String(rawValue ?? "").trim();
|
||||
if (!value) return;
|
||||
const mapping = mappingsByColumn.get(columnIndex) ?? { columnIndex, kind: "ignore" as const };
|
||||
switch (mapping.kind) {
|
||||
case "id":
|
||||
preferredId = value;
|
||||
break;
|
||||
case "active":
|
||||
active = parseOptionalBoolean(value, true);
|
||||
break;
|
||||
case "name":
|
||||
name = value;
|
||||
break;
|
||||
case "from":
|
||||
case "to":
|
||||
case "cc":
|
||||
case "bcc":
|
||||
case "reply_to":
|
||||
addresses[mapping.kind].push(...parseAddressCell(value, valueSeparators));
|
||||
break;
|
||||
case "field":{
|
||||
const fieldName = mapping.fieldName?.trim();
|
||||
if (fieldName) fields[fieldName] = value;
|
||||
break;
|
||||
}
|
||||
case "new_field": {
|
||||
case "new_field":{
|
||||
const fieldName = sanitizeFieldName(mapping.newFieldName || table.headers[columnIndex] || `column_${columnIndex + 1}`);
|
||||
if (fieldName) {
|
||||
fields[fieldName] = value;
|
||||
@@ -383,41 +383,41 @@ export function buildRecipientImportPreview(
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "attachment_pattern":
|
||||
patterns.push(...splitCell(value, valueSeparators));
|
||||
break;
|
||||
case "ignore":
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
if (name && addresses.to.length === 1 && !addresses.to[0].name) addresses.to[0].name = name;
|
||||
const primaryAddress = addresses.to[0] ?? addresses.cc[0] ?? addresses.bcc[0] ?? addresses.reply_to[0] ?? addresses.from[0] ?? null;
|
||||
const email = primaryAddress?.email ?? "";
|
||||
const id = uniqueId(preferredId || idFromEmail(email) || `recipient-${rowNumber}`, usedIds);
|
||||
usedIds.add(id);
|
||||
|
||||
for (const [key, list] of Object.entries(addresses)) {
|
||||
for (const address of list) {
|
||||
if (!address.email.includes("@")) issues.push(`${address.email || key} must contain @`);
|
||||
}
|
||||
case "attachment_pattern":
|
||||
patterns.push(...splitCell(value, valueSeparators));
|
||||
break;
|
||||
case "ignore":
|
||||
default:
|
||||
break;
|
||||
}
|
||||
if (addresses.to.length === 0) issues.push("Missing To address");
|
||||
});
|
||||
|
||||
return {
|
||||
rowNumber,
|
||||
id,
|
||||
name: name || primaryAddress?.name || "",
|
||||
email,
|
||||
active,
|
||||
addresses,
|
||||
fields,
|
||||
patterns,
|
||||
issues: [...new Set(issues)]
|
||||
};
|
||||
})
|
||||
.filter((row): row is ImportedRecipientRow => row !== null);
|
||||
if (name && addresses.to.length === 1 && !addresses.to[0].name) addresses.to[0].name = name;
|
||||
const primaryAddress = addresses.to[0] ?? addresses.cc[0] ?? addresses.bcc[0] ?? addresses.reply_to[0] ?? addresses.from[0] ?? null;
|
||||
const email = primaryAddress?.email ?? "";
|
||||
const id = uniqueId(preferredId || idFromEmail(email) || `recipient-${rowNumber}`, usedIds);
|
||||
usedIds.add(id);
|
||||
|
||||
for (const [key, list] of Object.entries(addresses)) {
|
||||
for (const address of list) {
|
||||
if (!address.email.includes("@")) issues.push(`${address.email || key} must contain @`);
|
||||
}
|
||||
}
|
||||
if (addresses.to.length === 0) issues.push("i18n:govoplan-campaign.missing_to_address.d4ff53b4");
|
||||
|
||||
return {
|
||||
rowNumber,
|
||||
id,
|
||||
name: name || primaryAddress?.name || "",
|
||||
email,
|
||||
active,
|
||||
addresses,
|
||||
fields,
|
||||
patterns,
|
||||
issues: [...new Set(issues)]
|
||||
};
|
||||
}).
|
||||
filter((row): row is ImportedRecipientRow => row !== null);
|
||||
|
||||
return {
|
||||
table,
|
||||
@@ -430,16 +430,16 @@ export function buildRecipientImportPreview(
|
||||
}
|
||||
|
||||
export function materializeRecipientImport(
|
||||
draft: Record<string, unknown>,
|
||||
preview: RecipientImportPreview,
|
||||
options: MaterializeImportOptions
|
||||
): Record<string, unknown> {
|
||||
draft: Record<string, unknown>,
|
||||
preview: RecipientImportPreview,
|
||||
options: MaterializeImportOptions)
|
||||
: Record<string, unknown> {
|
||||
const currentEntries = asRecord(draft.entries);
|
||||
const currentInlineEntries = asArray(currentEntries.inline).map(asRecord);
|
||||
const previousImports = asArray(currentEntries.imports).map(asRecord);
|
||||
const importedEntries = preview.rows
|
||||
.filter((row) => row.issues.length === 0)
|
||||
.map((row) => importedRowToEntry(row, options.attachmentBasePath));
|
||||
const importedEntries = preview.rows.
|
||||
filter((row) => row.issues.length === 0).
|
||||
map((row) => importedRowToEntry(row, options.attachmentBasePath));
|
||||
const nextInlineEntries = options.mode === "append" ? [...currentInlineEntries, ...importedEntries] : importedEntries;
|
||||
const nextEntries: ImportRecord = { ...currentEntries, inline: nextInlineEntries };
|
||||
if (options.provenance) nextEntries.imports = [...previousImports, options.provenance];
|
||||
@@ -537,15 +537,15 @@ function importedRowToEntry(row: ImportedRecipientRow, attachmentBasePath?: Impo
|
||||
function mergeFieldDefinitions(fieldsValue: unknown, fieldNamesToCreate: string[]): Record<string, unknown>[] {
|
||||
const existing = asArray(fieldsValue).map(asRecord);
|
||||
const existingNames = new Set(existing.map((field) => getText(field, "name") || getText(field, "id")).filter(Boolean));
|
||||
const additions = fieldNamesToCreate
|
||||
.filter((name) => !existingNames.has(name))
|
||||
.map((name) => ({
|
||||
name,
|
||||
label: humanizeFieldName(name),
|
||||
type: "string",
|
||||
required: false,
|
||||
can_override: true
|
||||
}));
|
||||
const additions = fieldNamesToCreate.
|
||||
filter((name) => !existingNames.has(name)).
|
||||
map((name) => ({
|
||||
name,
|
||||
label: humanizeFieldName(name),
|
||||
type: "string",
|
||||
required: false,
|
||||
can_override: true
|
||||
}));
|
||||
return [...existing, ...additions];
|
||||
}
|
||||
|
||||
@@ -594,9 +594,9 @@ export function parseDelimitedText(text: string, delimiter: "," | ";" | "\t", qu
|
||||
function detectDelimiter(text: string, quoted: boolean): "," | ";" | "\t" {
|
||||
const firstLine = text.split(/\r?\n/, 1)[0] ?? "";
|
||||
const candidates: Array<"," | ";" | "\t"> = [",", ";", "\t"];
|
||||
return candidates
|
||||
.map((delimiter) => ({ delimiter, cells: parseDelimitedText(firstLine, delimiter, quoted)[0]?.length ?? 1 }))
|
||||
.sort((left, right) => right.cells - left.cells)[0]?.delimiter ?? ",";
|
||||
return candidates.
|
||||
map((delimiter) => ({ delimiter, cells: parseDelimitedText(firstLine, delimiter, quoted)[0]?.length ?? 1 })).
|
||||
sort((left, right) => right.cells - left.cells)[0]?.delimiter ?? ",";
|
||||
}
|
||||
|
||||
function headerForColumn(headerRows: string[][], columnIndex: number): string {
|
||||
@@ -636,7 +636,7 @@ function stableHash(value: string): string {
|
||||
return (hash >>> 0).toString(36);
|
||||
}
|
||||
|
||||
function headerSetDiff(currentHeaders: string[], profileHeaders: string[]): { matchedHeaders: number; missingHeaders: string[]; extraHeaders: string[] } {
|
||||
function headerSetDiff(currentHeaders: string[], profileHeaders: string[]): {matchedHeaders: number;missingHeaders: string[];extraHeaders: string[];} {
|
||||
const currentCounts = countHeaders(currentHeaders);
|
||||
const profileCounts = countHeaders(profileHeaders);
|
||||
let matchedHeaders = 0;
|
||||
@@ -664,10 +664,10 @@ function countHeaders(headers: string[]): Map<string, number> {
|
||||
|
||||
function missingMappedHeaders(profile: RecipientMappingProfile, currentHeaders: string[]): string[] {
|
||||
const currentCounts = countHeaders(currentHeaders);
|
||||
return normalizeMappingList(profile.mappings, profile.columnCount || profile.headers.length)
|
||||
.filter((mapping) => mapping.kind !== "ignore")
|
||||
.map((mapping) => profile.normalizedHeaders[mapping.columnIndex] ?? "")
|
||||
.filter((header) => header && !currentCounts.has(header));
|
||||
return normalizeMappingList(profile.mappings, profile.columnCount || profile.headers.length).
|
||||
filter((mapping) => mapping.kind !== "ignore").
|
||||
map((mapping) => profile.normalizedHeaders[mapping.columnIndex] ?? "").
|
||||
filter((header) => header && !currentCounts.has(header));
|
||||
}
|
||||
|
||||
function normalizeMappingList(mappings: RecipientColumnMapping[], columnCount: number): RecipientColumnMapping[] {
|
||||
@@ -684,12 +684,12 @@ function normalizeMappingShape(mapping: RecipientColumnMapping | undefined, colu
|
||||
}
|
||||
|
||||
function normalizeProfileMapping(
|
||||
mapping: RecipientColumnMapping | undefined,
|
||||
columnIndex: number,
|
||||
header: string,
|
||||
existingFields: ImportFieldDefinition[],
|
||||
fallback: RecipientColumnMapping | undefined
|
||||
): RecipientColumnMapping {
|
||||
mapping: RecipientColumnMapping | undefined,
|
||||
columnIndex: number,
|
||||
header: string,
|
||||
existingFields: ImportFieldDefinition[],
|
||||
fallback: RecipientColumnMapping | undefined)
|
||||
: RecipientColumnMapping {
|
||||
const fallbackMapping = fallback ? { ...fallback, columnIndex } : { columnIndex, kind: "ignore" as const };
|
||||
if (!mapping) return fallbackMapping;
|
||||
const existingFieldNames = new Set(existingFields.map((field) => field.name));
|
||||
@@ -707,25 +707,25 @@ function normalizeProfileMapping(
|
||||
}
|
||||
|
||||
function isPatternColumn(key: string): boolean {
|
||||
return key === "pattern"
|
||||
|| key === "patterns"
|
||||
|| key === "file"
|
||||
|| key === "files"
|
||||
|| key === "file_pattern"
|
||||
|| key === "file_patterns"
|
||||
|| key === "attachment"
|
||||
|| key === "attachments"
|
||||
|| key === "attachment_pattern"
|
||||
|| key === "attachment_patterns"
|
||||
|| /^pattern_\d+$/.test(key)
|
||||
|| /^file_\d+$/.test(key)
|
||||
|| /^attachment_\d+$/.test(key);
|
||||
return key === "pattern" ||
|
||||
key === "patterns" ||
|
||||
key === "file" ||
|
||||
key === "files" ||
|
||||
key === "file_pattern" ||
|
||||
key === "file_patterns" ||
|
||||
key === "attachment" ||
|
||||
key === "attachments" ||
|
||||
key === "attachment_pattern" ||
|
||||
key === "attachment_patterns" ||
|
||||
/^pattern_\d+$/.test(key) ||
|
||||
/^file_\d+$/.test(key) ||
|
||||
/^attachment_\d+$/.test(key);
|
||||
}
|
||||
|
||||
function parseAddressCell(value: string, separators: string): ImportedAddress[] {
|
||||
return splitCell(value, separators)
|
||||
.map((item) => parseAddress(item))
|
||||
.filter((address) => Boolean(address.email));
|
||||
return splitCell(value, separators).
|
||||
map((item) => parseAddress(item)).
|
||||
filter((address) => Boolean(address.email));
|
||||
}
|
||||
|
||||
function parseAddress(value: string): ImportedAddress {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { formatDateTime as formatPlatformDateTime } from "@govoplan/core-webui";
|
||||
import { formatDateTime as formatPlatformDateTime, i18nMessage } from "@govoplan/core-webui";
|
||||
import type { CampaignListItem } from "../../../types";
|
||||
import type { CampaignSummary, CampaignVersionDetail, CampaignVersionListItem } from "../../../api/campaigns";
|
||||
|
||||
@@ -54,8 +54,8 @@ export function getFields(version: CampaignVersionDetail | null): unknown[] {
|
||||
}
|
||||
|
||||
export function userLockState(
|
||||
version: CampaignVersionDetail | CampaignVersionListItem | null
|
||||
): "temporary" | "permanent" | null {
|
||||
version: CampaignVersionDetail | CampaignVersionListItem | null)
|
||||
: "temporary" | "permanent" | null {
|
||||
if (!version) return null;
|
||||
if (version.user_lock_state === "temporary" || version.user_lock_state === "permanent") {
|
||||
return version.user_lock_state;
|
||||
@@ -79,18 +79,18 @@ export function isUserLockedVersion(version: CampaignVersionDetail | CampaignVer
|
||||
export function isFinalLockedVersion(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
|
||||
if (!version) return false;
|
||||
return [
|
||||
"queued",
|
||||
"sending",
|
||||
"sent",
|
||||
"completed",
|
||||
"partially_completed",
|
||||
"outcome_unknown",
|
||||
"failed",
|
||||
"failed_partial",
|
||||
"partially_sent",
|
||||
"archived",
|
||||
"cancelled"
|
||||
].includes((version.workflow_state ?? "").toLowerCase());
|
||||
"queued",
|
||||
"sending",
|
||||
"sent",
|
||||
"completed",
|
||||
"partially_completed",
|
||||
"outcome_unknown",
|
||||
"failed",
|
||||
"failed_partial",
|
||||
"partially_sent",
|
||||
"archived",
|
||||
"cancelled"].
|
||||
includes((version.workflow_state ?? "").toLowerCase());
|
||||
}
|
||||
|
||||
export function isVersionReadyForDelivery(version: CampaignVersionDetail | CampaignVersionListItem | null): boolean {
|
||||
@@ -108,16 +108,16 @@ export function canUnlockValidationVersion(version: CampaignVersionDetail | Camp
|
||||
}
|
||||
|
||||
export function isHistoricalCampaignVersion(
|
||||
version: CampaignVersionDetail | CampaignVersionListItem | null,
|
||||
currentVersionId?: string | null,
|
||||
): boolean {
|
||||
version: CampaignVersionDetail | CampaignVersionListItem | null,
|
||||
currentVersionId?: string | null)
|
||||
: boolean {
|
||||
return Boolean(version && currentVersionId && version.id !== currentVersionId);
|
||||
}
|
||||
|
||||
export function isAuditLockedVersion(
|
||||
version: CampaignVersionDetail | CampaignVersionListItem | null,
|
||||
currentVersionId?: string | null,
|
||||
): boolean {
|
||||
version: CampaignVersionDetail | CampaignVersionListItem | null,
|
||||
currentVersionId?: string | null)
|
||||
: boolean {
|
||||
if (!version) return false;
|
||||
if (isHistoricalCampaignVersion(version, currentVersionId)) return true;
|
||||
if (version.locked_at || isUserLockedVersion(version)) return true;
|
||||
@@ -125,31 +125,31 @@ export function isAuditLockedVersion(
|
||||
}
|
||||
|
||||
export function versionLockReason(
|
||||
version: CampaignVersionDetail | CampaignVersionListItem | null,
|
||||
currentVersionId?: string | null,
|
||||
): string {
|
||||
if (!version) return "No campaign version is loaded.";
|
||||
version: CampaignVersionDetail | CampaignVersionListItem | null,
|
||||
currentVersionId?: string | null)
|
||||
: string {
|
||||
if (!version) return "i18n:govoplan-campaign.no_campaign_version_is_loaded.93f4835b";
|
||||
if (isHistoricalCampaignVersion(version, currentVersionId)) {
|
||||
return "Historical campaign versions are review-only. Continue work in the current version or create a new working copy after the current version becomes immutable.";
|
||||
return "i18n:govoplan-campaign.historical_campaign_versions_are_review_only_con.c35b96af";
|
||||
}
|
||||
if (isTemporaryUserLockedVersion(version)) {
|
||||
return `Temporarily user-locked at ${formatDateTime(version.user_locked_at)}. Authorized users may unlock it or make the lock permanent.`;
|
||||
return i18nMessage("i18n:govoplan-campaign.temporarily_user_locked_at_value_authorized_user.7154865b", { value0: formatDateTime(version.user_locked_at) });
|
||||
}
|
||||
if (isPermanentUserLockedVersion(version)) {
|
||||
return `Permanently user-locked at ${formatDateTime(version.user_locked_at ?? version.published_at)}. It cannot be unlocked; create an editable copy.`;
|
||||
return i18nMessage("i18n:govoplan-campaign.permanently_user_locked_at_value_it_cannot_be_un.bf843a0f", { value0: formatDateTime(version.user_locked_at ?? version.published_at) });
|
||||
}
|
||||
if (canUnlockValidationVersion(version)) {
|
||||
return `Temporarily locked by validation at ${formatDateTime(version.locked_at)}. Unlocking invalidates validation, build and queue state.`;
|
||||
return i18nMessage("i18n:govoplan-campaign.temporarily_locked_by_validation_at_value_unlock.2f1ace87", { value0: formatDateTime(version.locked_at) });
|
||||
}
|
||||
if (isFinalLockedVersion(version)) return `Permanently locked by delivery/final state: ${humanize(version.workflow_state ?? "locked")}.`;
|
||||
if (version.locked_at) return `Temporarily locked at ${formatDateTime(version.locked_at)}.`;
|
||||
return "Editable working version.";
|
||||
if (isFinalLockedVersion(version)) return i18nMessage("i18n:govoplan-campaign.permanently_locked_by_delivery_final_state_value.e31278ae", { value0: humanize(version.workflow_state ?? "locked") });
|
||||
if (version.locked_at) return i18nMessage("i18n:govoplan-campaign.temporarily_locked_at_value.3ce9192e", { value0: formatDateTime(version.locked_at) });
|
||||
return "i18n:govoplan-campaign.editable_working_version.379f898a";
|
||||
}
|
||||
|
||||
export function currentStepLabel(version: CampaignVersionDetail | CampaignVersionListItem | null): string {
|
||||
if (!version) return "—";
|
||||
const flow = version.current_flow || "manual";
|
||||
const step = version.current_step || "not set";
|
||||
const step = version.current_step || "i18n:govoplan-campaign.not_set.ef374c57";
|
||||
return `${humanize(flow)} / ${humanize(step)}`;
|
||||
}
|
||||
|
||||
@@ -197,10 +197,10 @@ export function stringifyPreview(value: unknown, maxLength = 220): string {
|
||||
}
|
||||
|
||||
export function cloneCampaignJsonForCopy(
|
||||
source: Record<string, unknown>,
|
||||
campaign: CampaignListItem | null,
|
||||
stamp: string
|
||||
): { externalId: string; name: string; description: string; rawJson: Record<string, unknown> } {
|
||||
source: Record<string, unknown>,
|
||||
campaign: CampaignListItem | null,
|
||||
stamp: string)
|
||||
: {externalId: string;name: string;description: string;rawJson: Record<string, unknown>;} {
|
||||
const rawJson = JSON.parse(JSON.stringify(source)) as Record<string, unknown>;
|
||||
const campaignSection = asRecord(rawJson.campaign);
|
||||
const baseId = String(campaignSection.id || campaign?.external_id || campaign?.id || "campaign");
|
||||
|
||||
@@ -14,7 +14,7 @@ export type ImportFileLinkCapability = Pick<Required<FilesFileExplorerUiCapabili
|
||||
export type ImportFileLinkResolution = {
|
||||
basePath: AttachmentBasePath;
|
||||
patterns: RenderedImportPattern[];
|
||||
matchedPatterns: { pattern: RenderedImportPattern; matches: FilesManagedFile[] }[];
|
||||
matchedPatterns: {pattern: RenderedImportPattern;matches: FilesManagedFile[];}[];
|
||||
unmatchedPatterns: RenderedImportPattern[];
|
||||
files: FilesManagedFile[];
|
||||
linkedFiles: FilesManagedFile[];
|
||||
@@ -22,15 +22,15 @@ export type ImportFileLinkResolution = {
|
||||
};
|
||||
|
||||
export async function resolveImportedAttachmentLinks(
|
||||
filesCapability: ImportFileLinkCapability,
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
basePath: AttachmentBasePath,
|
||||
preview: RecipientImportPreview
|
||||
): Promise<ImportFileLinkResolution> {
|
||||
filesCapability: ImportFileLinkCapability,
|
||||
settings: ApiSettings,
|
||||
campaignId: string,
|
||||
basePath: AttachmentBasePath,
|
||||
preview: RecipientImportPreview)
|
||||
: Promise<ImportFileLinkResolution> {
|
||||
const parsedSource = parseManagedAttachmentSource(basePath.source);
|
||||
if (!parsedSource) {
|
||||
throw new Error("The selected attachment source is not connected to a managed file space.");
|
||||
throw new Error("i18n:govoplan-campaign.the_selected_attachment_source_is_not_connected_.965a0056");
|
||||
}
|
||||
|
||||
const patterns = renderedImportPatterns(preview);
|
||||
@@ -40,25 +40,25 @@ export async function resolveImportedAttachmentLinks(
|
||||
|
||||
const root = normalizedPathPrefix(basePath.path);
|
||||
const [resolved, visibleFiles] = await Promise.all([
|
||||
filesCapability.resolveFilePatterns(settings, {
|
||||
patterns: patterns.map((item) => item.renderedPattern),
|
||||
owner_type: parsedSource.ownerType,
|
||||
owner_id: parsedSource.ownerId,
|
||||
path_prefix: root || undefined,
|
||||
include_unmatched: false
|
||||
}),
|
||||
filesCapability.listFiles(settings, {
|
||||
owner_type: parsedSource.ownerType,
|
||||
owner_id: parsedSource.ownerId,
|
||||
path_prefix: root || undefined
|
||||
})
|
||||
]);
|
||||
filesCapability.resolveFilePatterns(settings, {
|
||||
patterns: patterns.map((item) => item.renderedPattern),
|
||||
owner_type: parsedSource.ownerType,
|
||||
owner_id: parsedSource.ownerId,
|
||||
path_prefix: root || undefined,
|
||||
include_unmatched: false
|
||||
}),
|
||||
filesCapability.listFiles(settings, {
|
||||
owner_type: parsedSource.ownerType,
|
||||
owner_id: parsedSource.ownerId,
|
||||
path_prefix: root || undefined
|
||||
})]
|
||||
);
|
||||
|
||||
const visibleById = new Map(visibleFiles.files.map((file) => [file.id, file]));
|
||||
const fileById = new Map<string, FilesManagedFile>();
|
||||
const matchedPatterns = patterns.map((pattern, index) => {
|
||||
const matches = (resolved.patterns[index]?.matches ?? [])
|
||||
.map((file) => visibleById.get(file.id) ?? file);
|
||||
const matches = (resolved.patterns[index]?.matches ?? []).
|
||||
map((file) => visibleById.get(file.id) ?? file);
|
||||
matches.forEach((file) => fileById.set(file.id, file));
|
||||
return { pattern, matches };
|
||||
});
|
||||
@@ -86,16 +86,16 @@ export async function bulkLinkFilesToCampaign(filesCapability: ImportFileLinkCap
|
||||
|
||||
export function renderedImportPatterns(preview: RecipientImportPreview): RenderedImportPattern[] {
|
||||
const byKey = new Map<string, RenderedImportPattern>();
|
||||
preview.rows
|
||||
.filter((row) => row.issues.length === 0)
|
||||
.forEach((row) => {
|
||||
row.patterns.forEach((sourcePattern) => {
|
||||
const renderedPattern = renderPatternForImportedRow(sourcePattern, row).trim();
|
||||
if (!renderedPattern) return;
|
||||
const key = `${row.rowNumber}:${sourcePattern}:${renderedPattern}`;
|
||||
if (!byKey.has(key)) byKey.set(key, { key, rowNumber: row.rowNumber, sourcePattern, renderedPattern });
|
||||
});
|
||||
preview.rows.
|
||||
filter((row) => row.issues.length === 0).
|
||||
forEach((row) => {
|
||||
row.patterns.forEach((sourcePattern) => {
|
||||
const renderedPattern = renderPatternForImportedRow(sourcePattern, row).trim();
|
||||
if (!renderedPattern) return;
|
||||
const key = `${row.rowNumber}:${sourcePattern}:${renderedPattern}`;
|
||||
if (!byKey.has(key)) byKey.set(key, { key, rowNumber: row.rowNumber, sourcePattern, renderedPattern });
|
||||
});
|
||||
});
|
||||
return [...byKey.values()];
|
||||
}
|
||||
|
||||
@@ -142,4 +142,4 @@ function normalizedPathPrefix(path: string): string {
|
||||
const trimmed = path.trim();
|
||||
if (!trimmed || trimmed === "." || trimmed === "/") return "";
|
||||
return trimmed.replace(/^[\/]+|[\/]+$/g, "");
|
||||
}
|
||||
}
|
||||
53
webui/src/features/campaigns/utils/jobDeltas.ts
Normal file
53
webui/src/features/campaigns/utils/jobDeltas.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { mergeDeltaRows } from "@govoplan/core-webui";
|
||||
import type { CampaignJobsDeltaResponse, CampaignJobsResponse } from "../../../api/campaigns";
|
||||
|
||||
export function emptyCampaignJobsResponse(): CampaignJobsResponse {
|
||||
return {
|
||||
jobs: [],
|
||||
page: 1,
|
||||
page_size: 50,
|
||||
total: 0,
|
||||
total_unfiltered: 0,
|
||||
pages: 0,
|
||||
cursor: null,
|
||||
next_cursor: null,
|
||||
counts: {},
|
||||
filtered_counts: {},
|
||||
review: {}
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeCampaignJobsDelta(current: CampaignJobsResponse, response: CampaignJobsDeltaResponse): CampaignJobsResponse {
|
||||
if (response.full) return campaignJobsResponseFromDelta(response);
|
||||
return {
|
||||
...campaignJobsResponseFromDelta(response),
|
||||
jobs: mergeDeltaRows(current.jobs, response.jobs, response.deleted, (row) => String(row.id ?? ""), {
|
||||
deletedResourceType: "campaign_job",
|
||||
sort: sortJobsByEntry
|
||||
}),
|
||||
cursor: response.cursor ?? current.cursor,
|
||||
next_cursor: response.next_cursor ?? current.next_cursor
|
||||
};
|
||||
}
|
||||
|
||||
export function campaignJobsResponseFromDelta(response: CampaignJobsDeltaResponse): CampaignJobsResponse {
|
||||
return {
|
||||
jobs: response.jobs,
|
||||
page: response.page,
|
||||
page_size: response.page_size,
|
||||
total: response.total,
|
||||
total_unfiltered: response.total_unfiltered,
|
||||
pages: response.pages,
|
||||
cursor: response.cursor,
|
||||
next_cursor: response.next_cursor,
|
||||
counts: response.counts,
|
||||
filtered_counts: response.filtered_counts,
|
||||
review: response.review
|
||||
};
|
||||
}
|
||||
|
||||
function sortJobsByEntry(left: Record<string, unknown>, right: Record<string, unknown>): number {
|
||||
const indexDelta = Number(left.entry_index ?? 0) - Number(right.entry_index ?? 0);
|
||||
if (indexDelta !== 0) return indexDelta;
|
||||
return String(left.id ?? "").localeCompare(String(right.id ?? ""));
|
||||
}
|
||||
Reference in New Issue
Block a user