chore: sync GovOPlaN module split state

This commit is contained in:
2026-07-10 12:51:18 +02:00
parent 57f6066bf7
commit 52e6ed49c5
66 changed files with 8868 additions and 4072 deletions

View File

@@ -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 {