chore: sync GovOPlaN module split state
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user