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

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

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 {

View File

@@ -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");

View File

@@ -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, "");
}
}

View 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 ?? ""));
}