Files
govoplan-campaign/webui/src/features/campaigns/RecipientDataPage.tsx

2404 lines
104 KiB
TypeScript

import { useEffect, useMemo, useRef, useState, type ClipboardEvent } from "react";
import { ArrowDown, ArrowUp, Copy, Pencil, Plus, Trash2 } from "lucide-react";
import type { ApiSettings } from "../../types";
import {
createRecipientImportMappingProfile,
listCampaignRecipientAddressSources,
listRecipientImportMappingProfiles,
snapshotCampaignRecipientAddressSource,
updateRecipientImportMappingProfile,
type CampaignRecipientAddressSource,
type CampaignRecipientAddressSourceSnapshot,
type RecipientImportMappingProfilePayload } from
"../../api/campaigns";
import { Button } from "@govoplan/core-webui";
import { Card } from "@govoplan/core-webui";
import { FileDropZone } from "@govoplan/core-webui";
import { FormField } from "@govoplan/core-webui";
import { usePlatformModuleInstalled, usePlatformUiCapability, type FilesFileExplorerUiCapability } from "@govoplan/core-webui";
import CampaignDraftPageScaffold from "./components/CampaignDraftPageScaffold";
import { ToggleSwitch } from "@govoplan/core-webui";
import { DismissibleAlert } from "@govoplan/core-webui";
import { Dialog } from "@govoplan/core-webui";
import { SegmentedControl } from "@govoplan/core-webui";
import DataGrid, { DataGridEmptyAction, DataGridRowActions, type DataGridColumn } from "../../components/table/DataGrid";
import { useCampaignWorkspaceData } from "./hooks/useCampaignWorkspaceData";
import { useCampaignDraftEditor } from "./hooks/useCampaignDraftEditor";
import { asArray, asRecord, isAuditLockedVersion } from "./utils/campaignView";
import { getBool } from "./utils/draftEditor";
import { getDraftFields } from "./utils/fieldDefinitions";
import FieldValueInput from "./components/FieldValueInput";
import AttachmentRulesOverlay from "./components/AttachmentRulesOverlay";
import { buildTemplatePreviewContext } from "./utils/templatePlaceholders";
import { getIndividualAttachmentBasePaths, normalizeAttachmentBasePaths, normalizeAttachmentRules, normalizeAttachmentZipCollection, type AttachmentBasePath, type AttachmentRule, type AttachmentZipCollection } from "./utils/attachments";
import {
applyRecipientMappingProfile,
buildImportTable,
buildImportTableFromRows,
buildRecipientImportPreview,
createRecipientImportProvenance,
createRecipientMappingProfile as buildRecipientMappingProfile,
defaultColumnMappings,
matchRecipientMappingProfiles,
materializeRecipientImportWithAttachmentDefaults,
recipientImportHeaderFingerprints,
xlsxSheetsFromArrayBuffer,
type CsvDelimiter,
type ImportedAddress,
type ImportedRecipientRow,
type RecipientColumnKind,
type RecipientColumnMapping,
type RecipientImportMode,
type RecipientImportPreview,
type RecipientImportTable,
type RecipientImportProvenance,
type RecipientImportSourceType,
type RecipientImportSpreadsheetSheet,
type RecipientMappingProfile,
type RecipientMappingProfileMatch } from
"./utils/bulkImport";
import {
bulkLinkFilesToCampaign,
type ImportFileLinkCapability,
renderedImportPatterns,
resolveImportedAttachmentLinks,
type ImportFileLinkResolution } from
"./utils/fileLinking";
import {
addressesFromValue,
dedupeAddresses,
parseMailboxAddressText,
type MailboxAddress } from
"@govoplan/core-webui";
import { insertAfter, moveArrayItem, i18nMessage, useGuardedNavigate, usePlatformLanguage } from "@govoplan/core-webui";
const recipientHeaderRows = [
{ key: "to", label: "i18n:govoplan-campaign.to.ae79ea1e", toggleKey: "allow_individual_to", toggleLabel: "i18n:govoplan-campaign.allow_individual_to.966cb33e", addLabel: "i18n:govoplan-campaign.add_recipient.a989d1f1", emptyText: "i18n:govoplan-campaign.no_global_recipients_configured.d4e20e92" },
{ key: "cc", label: "i18n:govoplan-campaign.cc.c5a976de", toggleKey: "allow_individual_cc", toggleLabel: "i18n:govoplan-campaign.allow_individual_cc.0457c0e2", addLabel: "i18n:govoplan-campaign.add_cc.bcb39ea3", emptyText: "i18n:govoplan-campaign.no_global_cc_recipients_configured.8afb23c1" },
{ key: "bcc", label: "i18n:govoplan-campaign.bcc.4c0145a3", toggleKey: "allow_individual_bcc", toggleLabel: "i18n:govoplan-campaign.allow_individual_bcc.a932d94b", addLabel: "i18n:govoplan-campaign.add_bcc.ae9feacc", emptyText: "i18n:govoplan-campaign.no_global_bcc_recipients_configured.86ef64ab" }] as
const;
type RecipientAddressKey = "to" | "cc" | "bcc";
type AddressFieldKey = RecipientAddressKey | "from" | "reply_to";
type AddressSourceFilter = "all" | "books" | "lists";
type HeaderAddressEditorState = {
title: string;
columns: EntryAddressColumn[];
} | null;
type EntryAddressColumn = {
key: AddressFieldKey;
label: string;
allowMultiple: boolean;
addLabel: string;
emptyText: string;
mergeKey?: "merge_to" | "merge_cc" | "merge_bcc" | "merge_reply_to";
};
const recipientAddressOverlayColumns: EntryAddressColumn[] = [
{ key: "from", label: "i18n:govoplan-campaign.from.3f66052a", allowMultiple: false, addLabel: "i18n:govoplan-campaign.set_from.11fe7396", emptyText: "i18n:govoplan-campaign.uses_global_from.4cf8e6ce" },
{ key: "reply_to", label: "i18n:govoplan-campaign.reply_to.c1733667", allowMultiple: true, addLabel: "i18n:govoplan-campaign.add_reply_to.84b195f4", emptyText: "i18n:govoplan-campaign.uses_global_reply_to.72c8d931", mergeKey: "merge_reply_to" },
{ key: "to", label: "i18n:govoplan-campaign.to.ae79ea1e", allowMultiple: true, addLabel: "i18n:govoplan-campaign.add_to.768a148b", emptyText: "i18n:govoplan-campaign.no_recipient_address.1301a488", mergeKey: "merge_to" },
{ key: "cc", label: "i18n:govoplan-campaign.cc.c5a976de", allowMultiple: true, addLabel: "i18n:govoplan-campaign.add_cc.bcb39ea3", emptyText: "i18n:govoplan-campaign.no_cc.5463a081", mergeKey: "merge_cc" },
{ key: "bcc", label: "i18n:govoplan-campaign.bcc.4c0145a3", allowMultiple: true, addLabel: "i18n:govoplan-campaign.add_bcc.ae9feacc", emptyText: "i18n:govoplan-campaign.no_bcc.48cade21", mergeKey: "merge_bcc" }];
export default function RecipientDataPage({ settings, campaignId }: {settings: ApiSettings;campaignId: string;}) {
const { translateText } = usePlatformLanguage();
const filesModuleInstalled = usePlatformModuleInstalled("files");
const { data, loading, error, reload, setError } = useCampaignWorkspaceData(settings, campaignId);
const [importOpen, setImportOpen] = useState(false);
const [addressSourceImportOpen, setAddressSourceImportOpen] = useState(false);
const [addressSourceImportInitialId, setAddressSourceImportInitialId] = useState("");
const [addressSourcesAvailable, setAddressSourcesAvailable] = useState(false);
const [addressSourcesLoading, setAddressSourcesLoading] = useState(false);
const [addressSources, setAddressSources] = useState<CampaignRecipientAddressSource[]>([]);
const [recipientProfilesPage, setRecipientProfilesPage] = useState(1);
const [recipientProfilesPageSize, setRecipientProfilesPageSize] = useState(10);
const [recipientAddressEditorIndex, setRecipientAddressEditorIndex] = useState<number | null>(null);
const [headerAddressEditor, setHeaderAddressEditor] = useState<HeaderAddressEditorState>(null);
const version = data.currentVersion;
const locked = isAuditLockedVersion(version, data.campaign?.current_version_id);
const { draft, setDraft, displayDraft, dirty, saveState, localError, patch, markDirty, discardDraft, saveDraft } = useCampaignDraftEditor({
settings,
campaignId,
version,
locked,
reload,
setError,
currentStep: "recipients",
unsavedTitle: "i18n:govoplan-campaign.unsaved_recipient_changes.4184b4ed",
unsavedMessage: "i18n:govoplan-campaign.recipient_address_data_has_unsaved_changes_save_.563c3b94"
});
const recipientsSection = asRecord(displayDraft.recipients);
const entries = asRecord(displayDraft.entries);
const attachments = asRecord(displayDraft.attachments);
const entryDefaults = asRecord(entries.defaults);
const inlineEntries = asArray(entries.inline).map(asRecord);
const source = asRecord(entries.source);
const fieldDefinitions = useMemo(() => getDraftFields(displayDraft), [displayDraft]);
const attachmentBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachments.base_paths, attachments), [attachments]);
const individualAttachmentBasePaths = useMemo(() => getIndividualAttachmentBasePaths(attachmentBasePaths), [attachmentBasePaths]);
const importBasePaths = useMemo(() => normalizeAttachmentBasePaths(attachments.base_paths, attachments, true), [attachments]);
const defaultImportBasePath = useMemo(() => importBasePaths.find((basePath) => basePath.allow_individual) ?? importBasePaths[0] ?? null, [importBasePaths]);
const zipConfig = useMemo(() => normalizeAttachmentZipCollection(attachments.zip), [attachments.zip]);
const addressSourceRevisionById = useMemo(
() => new Map(addressSources.map((addressSource) => [addressSource.source_id, addressSource.source_revision])),
[addressSources]
);
const staleAddressImports = useMemo(() => {
return asArray(entries.imports).
map(asRecord).
filter((record) => record.source_type === "addresses").
map((record) => {
const sourceId = typeof record.source_id === "string" ? record.source_id : "";
const importedRevision = typeof record.source_revision === "string" ? record.source_revision : "";
const currentRevision = sourceId ? addressSourceRevisionById.get(sourceId) ?? "" : "";
return {
sourceId,
sourceLabel: typeof record.source_label === "string" ? record.source_label : sourceId,
importedRevision,
currentRevision
};
}).
filter((record) => record.sourceId && record.currentRevision && record.importedRevision && record.currentRevision !== record.importedRevision);
}, [addressSourceRevisionById, entries.imports]);
const defaultFrom = addressesFromValue(recipientsSection.from).slice(0, 1);
const globalReplyTo = addressesFromValue(recipientsSection.reply_to);
const globalRecipientValues: Record<string, MailboxAddress[]> = {
to: addressesFromValue(recipientsSection.to),
cc: addressesFromValue(recipientsSection.cc),
bcc: addressesFromValue(recipientsSection.bcc)
};
const fromAddressColumn = getAddressColumn("from");
const replyToAddressColumn = getAddressColumn("reply_to");
useEffect(() => {
let cancelled = false;
setAddressSourcesLoading(true);
void listCampaignRecipientAddressSources(settings, campaignId).
then((response) => {
if (cancelled) return;
setAddressSourcesAvailable(response.available);
setAddressSources(response.available ? response.sources ?? [] : []);
}).
catch(() => {
if (cancelled) return;
setAddressSourcesAvailable(false);
setAddressSources([]);
}).
finally(() => {
if (!cancelled) setAddressSourcesLoading(false);
});
return () => {cancelled = true;};
}, [campaignId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
useEffect(() => {
setRecipientAddressEditorIndex((current) => {
if (current === null) return null;
if (inlineEntries.length === 0) return null;
return Math.max(0, Math.min(current, inlineEntries.length - 1));
});
}, [inlineEntries.length]);
function replaceInlineEntries(nextEntries: Record<string, unknown>[]) {
patch(["entries", "inline"], nextEntries);
}
function addRecipient(afterIndex = inlineEntries.length - 1) {
const nextIndex = inlineEntries.length + 1;
const insertIndex = Math.max(0, Math.min(afterIndex + 1, inlineEntries.length));
const newEntry = {
id: uniqueEntryId(inlineEntries, `recipient-${nextIndex}`),
active: true,
to: [],
cc: [],
bcc: [],
name: "",
email: "",
from: [],
reply_to: [],
merge_to: false,
merge_cc: true,
merge_bcc: true,
merge_reply_to: true
};
replaceInlineEntries(insertAfter(inlineEntries, afterIndex, newEntry));
setRecipientAddressEditorIndex(insertIndex);
}
function updateEntry(index: number, updater: (entry: Record<string, unknown>) => Record<string, unknown>) {
const nextEntries = inlineEntries.map((entry, currentIndex) => currentIndex === index ? updater(entry) : entry);
replaceInlineEntries(nextEntries);
}
function updateEntryAddressList(index: number, key: EntryAddressColumn["key"], addresses: MailboxAddress[]) {
updateEntry(index, (entry) => entryWithAddressList(entry, key, addresses));
}
function updateEntryAddressGroups(index: number, groups: Array<{key: AddressFieldKey;addresses: MailboxAddress[];}>) {
updateEntry(index, (entry) => {
let nextEntry = entry;
for (const group of groups) {
const currentAddresses = getEntryAddresses(nextEntry, group.key);
nextEntry = entryWithAddressList(nextEntry, group.key, dedupeAddresses([...currentAddresses, ...group.addresses]));
}
return nextEntry;
});
}
function updateEntryMerge(index: number, mergeKey: NonNullable<EntryAddressColumn["mergeKey"]>, checked: boolean) {
updateEntry(index, (entry) => {
const next = { ...entry, [mergeKey]: checked };
delete next[mergeKey.replace("merge_", "combine_")];
return next;
});
}
function updateEntryField(index: number, field: string, value: unknown) {
updateEntry(index, (entry) => ({
...entry,
fields: {
...asRecord(entry.fields),
[field]: value
}
}));
}
function updateEntryAttachments(index: number, attachments: AttachmentRule[]) {
updateEntry(index, (entry) => ({ ...entry, attachments }));
}
function removeEntry(index: number) {
replaceInlineEntries(inlineEntries.filter((_, currentIndex) => currentIndex !== index));
setRecipientAddressEditorIndex((current) => {
if (current === null) return null;
if (current === index) return null;
return current > index ? current - 1 : current;
});
}
function moveEntry(index: number, targetIndex: number) {
if (locked || index === targetIndex) return;
replaceInlineEntries(moveArrayItem(inlineEntries, index, targetIndex));
setRecipientAddressEditorIndex((current) => {
if (current === null) return null;
if (current === index) return targetIndex;
if (index < current && targetIndex >= current) return current - 1;
if (index > current && targetIndex <= current) return current + 1;
return current;
});
}
function applyRecipientImport(preview: RecipientImportPreview, mode: RecipientImportMode, provenance?: RecipientImportProvenance | null) {
if (locked || !draft) return;
setDraft(materializeRecipientImportWithAttachmentDefaults(draft, preview, { mode, provenance }));
markDirty();
setImportOpen(false);
}
function applyAddressSourceImport(snapshot: CampaignRecipientAddressSourceSnapshot, mode: RecipientImportMode) {
if (locked || !draft) return;
const preview = buildAddressSourceImportPreview(snapshot, inlineEntries);
const provenance = createAddressSourceImportProvenance(snapshot, preview, mode);
applyRecipientImport(preview, mode, provenance);
setAddressSourceImportOpen(false);
}
function updateHeaderAddressList(key: AddressFieldKey, addresses: MailboxAddress[]) {
patch(["recipients", key], key === "from" ? addresses.slice(0, 1) : addresses);
}
async function copyHeaderAddresses(columns: EntryAddressColumn[]) {
const text = formatAddressCollectionForClipboard(columns, headerAddressValues(columns, recipientsSection));
if (!text) return;
await navigator.clipboard?.writeText(text);
}
return (
<>
<CampaignDraftPageScaffold
settings={settings}
campaignId={campaignId}
title="i18n:govoplan-campaign.sender_recipients.922c6d24"
loading={loading}
version={version}
versions={data.versions}
saveState={saveState}
onReload={discardDraft}
onSave={() => saveDraft("manual")}
dirty={dirty}
locked={locked}
draft={draft}
error={error}
localError={localError}
currentVersionId={data.campaign?.current_version_id}>
<Card title="i18n:govoplan-campaign.campaign_sender.b12e8ae2" collapsible>
<div className="campaign-header-stack">
<div className="campaign-header-grid">
<FormField label="i18n:govoplan-campaign.default_from_address.b9ee6d77">
<AddressHeaderControl
columns={[fromAddressColumn]}
values={{ from: defaultFrom }}
emptyText="i18n:govoplan-campaign.no_global_from_address_configured.28141d2b"
disabled={locked}
onEdit={() => { if (!locked) setHeaderAddressEditor({ title: "Default sender", columns: [fromAddressColumn] }); }}
onCopy={() => void copyHeaderAddresses([fromAddressColumn])} />
</FormField>
<div className="campaign-header-toggle">
<ToggleSwitch
label="i18n:govoplan-campaign.allow_individual_senders.f85fda12"
checked={getBool(recipientsSection, "allow_individual_from")}
disabled={locked}
onChange={(checked) => patch(["recipients", "allow_individual_from"], checked)} />
</div>
</div>
<div className="campaign-header-grid">
<FormField label="i18n:govoplan-campaign.global_reply_to_address.bec1a89b">
<AddressHeaderControl
columns={[replyToAddressColumn]}
values={{ reply_to: globalReplyTo }}
emptyText="i18n:govoplan-campaign.no_global_reply_to_address_configured.d62a3b9b"
disabled={locked}
onEdit={() => { if (!locked) setHeaderAddressEditor({ title: "Global Reply-To", columns: [replyToAddressColumn] }); }}
onCopy={() => void copyHeaderAddresses([replyToAddressColumn])} />
</FormField>
<div className="campaign-header-toggle">
<ToggleSwitch
label="i18n:govoplan-campaign.allow_individual_reply_to.028f8044"
checked={getBool(recipientsSection, "allow_individual_reply_to")}
disabled={locked}
onChange={(checked) => patch(["recipients", "allow_individual_reply_to"], checked)} />
</div>
</div>
</div>
</Card>
<Card title="Global recipients" collapsible>
<div className="campaign-header-stack">
{recipientHeaderRows.map((row) => {
const column = getAddressColumn(row.key);
return (
<div className="campaign-header-grid" key={row.key}>
<FormField label={row.label}>
<AddressHeaderControl
columns={[column]}
values={{ [row.key]: globalRecipientValues[row.key] ?? [] }}
emptyText={row.emptyText}
disabled={locked}
onEdit={() => { if (!locked) setHeaderAddressEditor({ title: row.label, columns: [column] }); }}
onCopy={() => void copyHeaderAddresses([column])} />
</FormField>
<div className="campaign-header-toggle">
<ToggleSwitch
label={row.toggleLabel}
checked={getBool(recipientsSection, row.toggleKey, row.key === "to")}
disabled={locked}
onChange={(checked) => patch(["recipients", row.toggleKey], checked)} />
</div>
</div>
);
})}
</div>
</Card>
<Card
title="i18n:govoplan-campaign.recipient_data.c2baaf10"
actions={
<div className="button-row compact-actions">
{(addressSourcesAvailable || addressSourcesLoading) &&
<Button disabled={locked || addressSourcesLoading} onClick={() => {setAddressSourceImportInitialId("");setAddressSourceImportOpen(true);}}>
Import address book/list
</Button>
}
<Button disabled={locked} onClick={() => setImportOpen(true)}>i18n:govoplan-campaign.import.d6fbc9d2</Button>
</div>
}>
{inlineEntries.length === 0 && Boolean(source.type) &&
<DismissibleAlert tone="info">i18n:govoplan-campaign.this_campaign_references_an_external_recipient_s.bdae9fb4</DismissibleAlert>
}
{staleAddressImports.length > 0 &&
<DismissibleAlert tone="warning" dismissible={false}>
<div className="stale-address-import-warning">
<span>Address imports are older than their source: {staleAddressImports.map((item) => item.sourceLabel || item.sourceId).join(", ")}.</span>
<div className="button-row compact-actions">
{staleAddressImports.map((item) =>
<Button
key={item.sourceId}
type="button"
onClick={() => {setAddressSourceImportInitialId(item.sourceId);setAddressSourceImportOpen(true);}}>
Re-import {item.sourceLabel || "source"}
</Button>
)}
</div>
</div>
</DismissibleAlert>
}
{!source.type &&
<div className="admin-table-surface recipient-profiles-table-surface">
<DataGrid
id={`campaign-${campaignId}-recipient-profiles`}
rows={inlineEntries}
columns={recipientProfileColumns({
settings,
campaignId,
draft: displayDraft,
locked,
filesModuleInstalled,
entries: inlineEntries,
fieldDefinitions,
individualAttachmentBasePaths,
zipConfig,
translateText,
openAddressEditor: setRecipientAddressEditorIndex,
updateEntry,
updateEntryAttachments,
updateEntryField,
addRecipient,
moveEntry,
removeEntry
})}
getRowKey={(entry, index) => String(entry.id || index)}
emptyText="i18n:govoplan-campaign.no_recipient_profiles_are_stored_in_the_current_.478b8026"
emptyAction={<DataGridEmptyAction onAdd={() => addRecipient(-1)} disabled={locked} label="i18n:govoplan-campaign.add_first_recipient.e540d52a" />}
className="recipient-table-wrap recipient-address-table recipient-merged-data-table"
pagination={{
page: recipientProfilesPage,
pageSize: recipientProfilesPageSize,
pageSizeOptions: [10, 25, 50, 100, 250],
onPageChange: setRecipientProfilesPage,
onPageSizeChange: (pageSize) => {
setRecipientProfilesPageSize(pageSize);
setRecipientProfilesPage(1);
}
}} />
</div>
}
</Card>
</CampaignDraftPageScaffold>
{importOpen &&
<RecipientImportDialog
settings={settings}
campaignId={campaignId}
existingEntries={inlineEntries}
existingFields={fieldDefinitions}
defaultAttachmentBasePath={defaultImportBasePath}
onCancel={() => setImportOpen(false)}
onImport={applyRecipientImport} />
}
{addressSourceImportOpen &&
<AddressSourceImportDialog
settings={settings}
campaignId={campaignId}
sources={addressSources}
initialSourceId={addressSourceImportInitialId}
onCancel={() => setAddressSourceImportOpen(false)}
onImport={applyAddressSourceImport} />
}
{recipientAddressEditorIndex !== null && inlineEntries[recipientAddressEditorIndex] &&
<RecipientAddressEditorDialog
entry={inlineEntries[recipientAddressEditorIndex]}
index={recipientAddressEditorIndex}
locked={locked}
recipientsSection={recipientsSection}
entryDefaults={entryDefaults}
updateEntryAddressList={updateEntryAddressList}
updateEntryAddressGroups={updateEntryAddressGroups}
updateEntryMerge={updateEntryMerge}
onClose={() => setRecipientAddressEditorIndex(null)} />
}
{headerAddressEditor &&
<HeaderAddressEditorDialog
title={headerAddressEditor.title}
columns={headerAddressEditor.columns}
values={headerAddressValues(headerAddressEditor.columns, recipientsSection)}
locked={locked}
onAddressesChange={updateHeaderAddressList}
onClose={() => setHeaderAddressEditor(null)} />
}
</>);
}
type RecipientAddressEditorDialogProps = {
entry: Record<string, unknown>;
index: number;
locked: boolean;
recipientsSection: Record<string, unknown>;
entryDefaults: Record<string, unknown>;
updateEntryAddressList: (index: number, key: EntryAddressColumn["key"], addresses: MailboxAddress[]) => void;
updateEntryAddressGroups: (index: number, groups: Array<{key: AddressFieldKey;addresses: MailboxAddress[];}>) => void;
updateEntryMerge: (index: number, mergeKey: NonNullable<EntryAddressColumn["mergeKey"]>, checked: boolean) => void;
onClose: () => void;
};
type HeaderAddressValues = Partial<Record<AddressFieldKey, MailboxAddress[]>>;
type AddressHeaderControlProps = {
columns: EntryAddressColumn[];
values: HeaderAddressValues;
emptyText: string;
disabled: boolean;
onEdit: () => void;
onCopy: () => void;
};
function AddressHeaderControl({ columns, values, emptyText, disabled, onEdit, onCopy }: AddressHeaderControlProps) {
const { translateText } = usePlatformLanguage();
const textRef = useRef<HTMLDivElement | null>(null);
const [availableWidth, setAvailableWidth] = useState(0);
const segments = useMemo(() => addressDisplaySegments(columns, values), [columns, values]);
const visibleSegments = useMemo(() => fitAddressSegments(segments, availableWidth), [availableWidth, segments]);
const hiddenCount = Math.max(0, segments.length - visibleSegments.length);
const hasAddresses = segments.length > 0;
const copiedText = formatAddressCollectionForClipboard(columns, values);
useEffect(() => {
const element = textRef.current;
if (!element) return;
const updateWidth = () => setAvailableWidth(element.clientWidth);
updateWidth();
const observer = new ResizeObserver(updateWidth);
observer.observe(element);
return () => observer.disconnect();
}, []);
return (
<div className="recipient-address-header-control">
<div className={`recipient-address-header-summary ${hasAddresses ? "" : "is-empty"}`}>
<div className="recipient-address-header-text" ref={textRef}>
{hasAddresses ?
<>
<span>{visibleSegments.join(", ")}</span>
{hiddenCount > 0 && <span className="recipient-extra-bubble">+{hiddenCount}</span>}
</> :
<span>{translateText(emptyText)}</span>
}
</div>
<Button
type="button"
className="admin-icon-button recipient-address-inline-button"
disabled={disabled}
aria-label="Edit addresses"
title="Edit addresses"
onClick={onEdit}>
<Pencil aria-hidden="true" />
</Button>
<Button
type="button"
className="admin-icon-button recipient-address-inline-button"
disabled={!copiedText}
aria-label="Copy addresses"
title="Copy addresses"
onClick={onCopy}>
<Copy aria-hidden="true" />
</Button>
</div>
</div>);
}
type HeaderAddressEditorDialogProps = {
title: string;
columns: EntryAddressColumn[];
values: HeaderAddressValues;
locked: boolean;
onAddressesChange: (key: AddressFieldKey, addresses: MailboxAddress[]) => void;
onClose: () => void;
};
function HeaderAddressEditorDialog({ title, columns, values, locked, onAddressesChange, onClose }: HeaderAddressEditorDialogProps) {
const columnKeys = new Set(columns.map((column) => column.key));
function applyPastedAddresses(targetKey: AddressFieldKey, text: string): boolean {
const groups = parsePastedAddressGroups(targetKey, text).filter((group) => columnKeys.has(group.key));
if (groups.length === 0) return false;
const nextValues: HeaderAddressValues = { ...values };
for (const group of groups) {
nextValues[group.key] = dedupeAddresses([...(nextValues[group.key] ?? []), ...group.addresses]);
}
for (const group of groups) {
onAddressesChange(group.key, nextValues[group.key] ?? []);
}
return true;
}
return (
<Dialog
open
title={title}
className="recipient-address-editor-modal"
bodyClassName="recipient-address-editor-body"
onClose={onClose}
footer={<Button onClick={onClose}>i18n:govoplan-campaign.close.bbfa773e</Button>}>
<div className="recipient-address-editor">
{columns.map((column) =>
<RecipientAddressCategoryEditor
key={column.key}
column={column}
addresses={values[column.key] ?? []}
merge={false}
locked={locked}
onAddressesChange={(addresses) => onAddressesChange(column.key, addresses)}
onPasteAddresses={applyPastedAddresses} />
)}
</div>
</Dialog>);
}
function RecipientAddressEditorDialog({
entry,
index,
locked,
recipientsSection,
entryDefaults,
updateEntryAddressList,
updateEntryAddressGroups,
updateEntryMerge,
onClose
}: RecipientAddressEditorDialogProps) {
const availableColumns = recipientAddressOverlayColumns.filter((column) => entryAddressEnabled(recipientsSection, column.key));
const availableKeys = new Set(availableColumns.map((column) => column.key));
function applyPastedAddresses(targetKey: AddressFieldKey, text: string): boolean {
const groups = parsePastedAddressGroups(targetKey, text).filter((group) => availableKeys.has(group.key));
if (groups.length === 0) return false;
updateEntryAddressGroups(index, groups);
return true;
}
return (
<Dialog
open
title={`Recipient ${index + 1} - addresses`}
className="recipient-address-editor-modal"
bodyClassName="recipient-address-editor-body"
onClose={onClose}
footer={<Button onClick={onClose}>i18n:govoplan-campaign.close.bbfa773e</Button>}>
<div className="recipient-address-editor">
{availableColumns.length === 0 &&
<p className="muted">No individual recipient address fields are enabled.</p>
}
{availableColumns.map((column) =>
<RecipientAddressCategoryEditor
key={column.key}
column={column}
addresses={getEntryAddresses(entry, column.key)}
merge={column.mergeKey ? getEntryMerge(entry, entryDefaults, column.mergeKey) : false}
locked={locked}
onAddressesChange={(addresses) => updateEntryAddressList(index, column.key, addresses)}
onMergeChange={column.mergeKey ? (merge) => updateEntryMerge(index, column.mergeKey!, merge) : undefined}
onPasteAddresses={applyPastedAddresses} />
)}
</div>
</Dialog>);
}
type RecipientAddressCategoryEditorProps = {
column: EntryAddressColumn;
addresses: MailboxAddress[];
merge: boolean;
locked: boolean;
onAddressesChange: (addresses: MailboxAddress[]) => void;
onMergeChange?: (merge: boolean) => void;
onPasteAddresses?: (targetKey: AddressFieldKey, text: string) => boolean;
};
function RecipientAddressCategoryEditor({ column, addresses, merge, locked, onAddressesChange, onMergeChange, onPasteAddresses }: RecipientAddressCategoryEditorProps) {
const { translateText } = usePlatformLanguage();
const [draftAddresses, setDraftAddresses] = useState<MailboxAddress[]>(() => normalizeEditorAddressRows(column, addresses));
const canAdd = !locked && column.allowMultiple;
const translatedAddLabel = translateText(column.addLabel);
const translatedMoveUpLabel = "Move address up";
const translatedMoveDownLabel = "Move address down";
const translatedRemoveLabel = "Remove address";
useEffect(() => {
setDraftAddresses(normalizeEditorAddressRows(column, addresses));
}, [addresses, column]);
function commitAddresses(nextAddresses: MailboxAddress[]) {
setDraftAddresses(nextAddresses);
onAddressesChange(nextAddresses.filter((address) => String(address.email ?? "").trim() || String(address.name ?? "").trim()));
}
function patchAddress(addressIndex: number, patch: Partial<MailboxAddress>) {
commitAddresses(draftAddresses.map((address, currentIndex) => currentIndex === addressIndex ? { ...address, ...patch } : address));
}
function addAddress(afterIndex = draftAddresses.length - 1) {
if (!column.allowMultiple) return;
const insertIndex = Math.max(0, Math.min(afterIndex + 1, draftAddresses.length));
const nextAddresses = [
...draftAddresses.slice(0, insertIndex),
{ name: "", email: "" },
...draftAddresses.slice(insertIndex)];
setDraftAddresses(nextAddresses);
}
function removeAddress(addressIndex: number) {
commitAddresses(draftAddresses.filter((_, currentIndex) => currentIndex !== addressIndex));
}
function moveAddress(addressIndex: number, targetIndex: number) {
if (addressIndex === targetIndex) return;
commitAddresses(moveArrayItem(draftAddresses, addressIndex, targetIndex));
}
function handlePaste(event: ClipboardEvent) {
if (locked || !onPasteAddresses) return;
const text = event.clipboardData.getData("text/plain");
if (onPasteAddresses(column.key, text)) event.preventDefault();
}
return (
<section className="recipient-address-category" tabIndex={locked ? undefined : 0} onPaste={handlePaste}>
<div className="recipient-address-category-header">
<h3>{column.label}</h3>
<div className="recipient-address-category-controls">
{onMergeChange ?
<ToggleSwitch
label="Merge mode"
inactiveLabel="Overwrite"
activeLabel="Merge"
checked={merge}
disabled={locked}
onChange={onMergeChange} /> :
null}
</div>
</div>
<div className="recipient-address-lines">
{column.allowMultiple && draftAddresses.length === 0 &&
<div className="recipient-address-empty-row">
<p className="muted recipient-address-empty">{column.emptyText}</p>
<div className="data-grid-row-actions data-grid-empty-row-actions">
<Button
type="button"
variant="primary"
className="data-grid-row-action is-add"
aria-label={translatedAddLabel}
title={translatedAddLabel}
disabled={!canAdd}
onClick={() => addAddress()}>
<Plus size={16} aria-hidden="true" />
</Button>
</div>
</div>
}
{draftAddresses.map((address, addressIndex) =>
<div className="recipient-address-edit-line" key={`${column.key}-${addressIndex}`}>
<input
value={address.name ?? ""}
disabled={locked}
placeholder="Name"
aria-label={`${column.label} name ${addressIndex + 1}`}
onChange={(event) => patchAddress(addressIndex, { name: event.target.value })} />
<input
value={address.email}
disabled={locked}
placeholder="Email"
type="email"
aria-label={`${column.label} email ${addressIndex + 1}`}
onChange={(event) => patchAddress(addressIndex, { email: event.target.value })} />
{column.allowMultiple &&
<div className="data-grid-row-actions recipient-address-line-actions">
<Button
type="button"
variant="primary"
className="data-grid-row-action is-add"
aria-label={translatedAddLabel}
title={translatedAddLabel}
disabled={!canAdd}
onClick={() => addAddress(addressIndex)}>
<Plus size={16} aria-hidden="true" />
</Button>
<Button
type="button"
variant="secondary"
className="data-grid-row-action is-reorder"
aria-label={translatedMoveUpLabel}
title={translatedMoveUpLabel}
disabled={locked || addressIndex === 0}
onClick={() => moveAddress(addressIndex, addressIndex - 1)}>
<ArrowUp size={16} aria-hidden="true" />
</Button>
<Button
type="button"
variant="secondary"
className="data-grid-row-action is-reorder"
aria-label={translatedMoveDownLabel}
title={translatedMoveDownLabel}
disabled={locked || addressIndex >= draftAddresses.length - 1}
onClick={() => moveAddress(addressIndex, addressIndex + 1)}>
<ArrowDown size={16} aria-hidden="true" />
</Button>
<Button
type="button"
variant="danger"
className="data-grid-row-action is-remove"
aria-label={translatedRemoveLabel}
title={translatedRemoveLabel}
disabled={locked}
onClick={() => removeAddress(addressIndex)}>
<Trash2 size={16} aria-hidden="true" />
</Button>
</div>
}
</div>
)}
</div>
</section>);
}
type AddressSourceImportDialogProps = {
settings: ApiSettings;
campaignId: string;
sources: CampaignRecipientAddressSource[];
initialSourceId?: string;
onCancel: () => void;
onImport: (snapshot: CampaignRecipientAddressSourceSnapshot, mode: RecipientImportMode) => void;
};
function addressSourceType(source: CampaignRecipientAddressSource): "book" | "list" {
return source.source_id.startsWith("addresses:address_list:") || source.source_kind === "address_list" ? "list" : "book";
}
function addressSourceTypeLabel(source: CampaignRecipientAddressSource): string {
if (addressSourceType(source) === "list") return "Address list";
if (source.source_kind && source.source_kind !== "local") return `${source.source_kind} address book`;
return "Address book";
}
function addressSourceScopeLabel(source: CampaignRecipientAddressSource): string {
const provenance = asRecord(source.provenance);
const scopeType = String(provenance.scope_type ?? "").trim();
const scopeId = String(provenance.scope_id ?? "").trim();
if (!scopeType) return "Unknown scope";
const label = scopeType.charAt(0).toUpperCase() + scopeType.slice(1);
return scopeId && scopeId !== scopeType ? `${label} - ${scopeId}` : label;
}
function addressSourceSearchText(source: CampaignRecipientAddressSource): string {
const provenance = asRecord(source.provenance);
return [
source.source_label,
source.source_kind,
source.source_id,
addressSourceTypeLabel(source),
addressSourceScopeLabel(source),
provenance.address_book_id,
provenance.address_list_id,
provenance.scope_type,
provenance.scope_id,
provenance.tenant_id
].map((value) => String(value ?? "").toLowerCase()).join(" ");
}
function shortSourceRevision(value: string): string {
if (!value) return "no revision";
if (value.length <= 24) return value;
return `${value.slice(0, 19)}...`;
}
function AddressSourceImportDialog({ settings, campaignId, sources, initialSourceId = "", onCancel, onImport }: AddressSourceImportDialogProps) {
const navigate = useGuardedNavigate();
const [selectedSourceId, setSelectedSourceId] = useState(initialSourceId || sources[0]?.source_id || "");
const [sourceFilter, setSourceFilter] = useState<AddressSourceFilter>("all");
const [sourceQuery, setSourceQuery] = useState("");
const [mode, setMode] = useState<RecipientImportMode>("append");
const [snapshot, setSnapshot] = useState<CampaignRecipientAddressSourceSnapshot | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const sourceCounts = useMemo(() => {
return sources.reduce(
(counts, source) => {
counts[addressSourceType(source)] += 1;
return counts;
},
{ book: 0, list: 0 }
);
}, [sources]);
const filteredSources = useMemo(() => {
const query = sourceQuery.trim().toLowerCase();
return sources.filter((source) => {
const type = addressSourceType(source);
if (sourceFilter === "books" && type !== "book") return false;
if (sourceFilter === "lists" && type !== "list") return false;
if (!query) return true;
return addressSourceSearchText(source).includes(query);
});
}, [sourceFilter, sourceQuery, sources]);
const selectedSource = useMemo(
() => sources.find((source) => source.source_id === selectedSourceId) ?? null,
[selectedSourceId, sources]
);
useEffect(() => {
if (filteredSources.some((source) => source.source_id === selectedSourceId)) return;
setSelectedSourceId(filteredSources[0]?.source_id ?? "");
}, [filteredSources, selectedSourceId]);
useEffect(() => {
if (initialSourceId && sources.some((source) => source.source_id === initialSourceId)) {
setSourceFilter("all");
setSourceQuery("");
setSelectedSourceId(initialSourceId);
}
}, [initialSourceId, sources]);
useEffect(() => {
if (!selectedSourceId) {
setSnapshot(null);
return;
}
let cancelled = false;
setLoading(true);
setError("");
void snapshotCampaignRecipientAddressSource(settings, campaignId, selectedSourceId).
then((nextSnapshot) => {
if (!cancelled) setSnapshot(nextSnapshot);
}).
catch((err) => {
if (cancelled) return;
setSnapshot(null);
setError(err instanceof Error ? err.message : String(err));
}).
finally(() => {
if (!cancelled) setLoading(false);
});
return () => {cancelled = true;};
}, [campaignId, selectedSourceId, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
function openAddressBookModule() {
onCancel();
navigate("/address-book");
}
return (
<Dialog
open
title="Import recipients from addresses"
className="recipient-import-modal"
bodyClassName="recipient-import-body"
closeDisabled={loading}
closeOnBackdrop={!loading}
onClose={onCancel}
footer={
<>
<Button onClick={onCancel} disabled={loading}>i18n:govoplan-campaign.cancel.77dfd213</Button>
<Button variant="primary" disabled={loading || !snapshot || snapshot.recipients.length === 0} onClick={() => snapshot && onImport(snapshot, mode)}>
Import recipients
</Button>
</>
}>
<div className="address-source-import-controls">
<div>
<SegmentedControl
ariaLabel="Address source type"
value={sourceFilter}
onChange={setSourceFilter}
size="content"
width="inline"
disabled={loading || sources.length === 0}
options={[
{ id: "all", label: `All (${sources.length})` },
{ id: "books", label: `Books (${sourceCounts.book})` },
{ id: "lists", label: `Lists (${sourceCounts.list})` }
]}
/>
</div>
<input
type="search"
value={sourceQuery}
disabled={loading || sources.length === 0}
placeholder="Search address sources"
aria-label="Search address sources"
onChange={(event) => setSourceQuery(event.target.value)}
/>
<Button type="button" onClick={openAddressBookModule}>
Manage address books
</Button>
</div>
<div className="campaign-header-grid recipient-import-upload-grid">
<div className="address-source-picker" role="radiogroup" aria-label="Address source">
{filteredSources.map((source) =>
<button
type="button"
key={source.source_id}
className={`address-source-option ${source.source_id === selectedSourceId ? "is-selected" : ""}`}
disabled={loading}
role="radio"
aria-checked={source.source_id === selectedSourceId}
onClick={() => setSelectedSourceId(source.source_id)}>
<span className="address-source-option-main">
<strong>{source.source_label}</strong>
<span>{addressSourceTypeLabel(source)} - {addressSourceScopeLabel(source)}</span>
</span>
<span className="address-source-option-meta">
<span>{source.recipient_count} recipients</span>
<code>{shortSourceRevision(source.source_revision)}</code>
</span>
</button>
)}
{sources.length > 0 && filteredSources.length === 0 &&
<div className="empty-state compact-empty">No address sources match the current filter.</div>
}
</div>
<FormField label="Import mode">
<select value={mode} onChange={(event) => setMode(event.target.value === "replace" ? "replace" : "append")}>
<option value="append">i18n:govoplan-campaign.append_to_current_profiles.0b434d6f</option>
<option value="replace">i18n:govoplan-campaign.replace_current_profiles.3b3be5e2</option>
</select>
</FormField>
</div>
{error && <DismissibleAlert tone="danger" compact resetKey={error}>{error}</DismissibleAlert>}
{loading && <DismissibleAlert tone="info" compact dismissible={false}>Loading address recipients...</DismissibleAlert>}
{!loading && sources.length === 0 && <DismissibleAlert tone="info" dismissible={false}>No address sources are available to this campaign. Create an address book or address list in the addresses module first.</DismissibleAlert>}
{snapshot &&
<>
<dl className="detail-list recipient-import-summary">
<div><dt>Source</dt><dd>{snapshot.source_label}</dd></div>
<div><dt>Type</dt><dd>{selectedSource ? addressSourceTypeLabel(selectedSource) : "Address source"}</dd></div>
<div><dt>Scope</dt><dd>{selectedSource ? addressSourceScopeLabel(selectedSource) : "Unknown"}</dd></div>
<div><dt>Recipients</dt><dd>{snapshot.recipients.length}</dd></div>
<div><dt>Revision</dt><dd className="mono-small">{snapshot.source_revision}</dd></div>
</dl>
<div className="admin-table-surface recipient-import-preview-surface">
<table className="recipient-import-preview-table">
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Email</th>
<th>Fields</th>
</tr>
</thead>
<tbody>
{snapshot.recipients.slice(0, 20).map((recipient, index) =>
<tr key={`${recipient.contact_id}-${recipient.email}`}>
<td>{index + 1}</td>
<td>{recipient.display_name}</td>
<td>{recipient.email}</td>
<td>{Object.keys(recipient.fields ?? {}).filter((key) => fieldValueToString((recipient.fields ?? {})[key])).length}</td>
</tr>
)}
</tbody>
</table>
</div>
{snapshot.recipients.length > 20 && <p className="muted small-note">{snapshot.recipients.length - 20} more recipients will be imported.</p>}
</>
}
</Dialog>);
}
type RecipientImportDialogProps = {
settings: ApiSettings;
campaignId: string;
existingEntries: Record<string, unknown>[];
existingFields: ReturnType<typeof getDraftFields>;
defaultAttachmentBasePath: AttachmentBasePath | null;
onCancel: () => void;
onImport: (preview: RecipientImportPreview, mode: RecipientImportMode, provenance?: RecipientImportProvenance | null) => void;
};
type RecipientImportStepId = "upload" | "parse" | "map" | "preview" | "files";
const allRecipientImportSteps: Array<{id: RecipientImportStepId;label: string;}> = [
{ id: "upload", label: "i18n:govoplan-campaign.upload.8bdf057f" },
{ id: "parse", label: "i18n:govoplan-campaign.parse.b7e45a36" },
{ id: "map", label: "i18n:govoplan-campaign.map.ab478f3e" },
{ id: "preview", label: "i18n:govoplan-campaign.preview.f1fbb2b4" },
{ id: "files", label: "i18n:govoplan-campaign.files.6ce6c512" }];
const recipientColumnKindOptions: Array<{value: RecipientColumnKind;label: string;}> = [
{ value: "ignore", label: "i18n:govoplan-campaign.ignore.98f55db5" },
{ value: "id", label: "i18n:govoplan-campaign.id.89f89c02" },
{ value: "active", label: "i18n:govoplan-campaign.active.a733b809" },
{ value: "name", label: "i18n:govoplan-campaign.display_name.c7874aaa" },
{ value: "from", label: "i18n:govoplan-campaign.from.3f66052a" },
{ value: "to", label: "i18n:govoplan-campaign.to.ae79ea1e" },
{ value: "cc", label: "i18n:govoplan-campaign.cc.c5a976de" },
{ value: "bcc", label: "i18n:govoplan-campaign.bcc.4c0145a3" },
{ value: "reply_to", label: "i18n:govoplan-campaign.reply_to.c1733667" },
{ value: "field", label: "i18n:govoplan-campaign.existing_field.681f2b09" },
{ value: "new_field", label: "i18n:govoplan-campaign.new_field.57fc8a2e" },
{ value: "attachment_pattern", label: "i18n:govoplan-campaign.attachment_pattern.59649690" }];
const AUTOMATIC_MAPPING_PROFILE_MIN_SCORE = 0.55;
const RECIPIENT_IMPORT_ENCODINGS = [
{ value: "utf-8", label: "i18n:govoplan-campaign.utf_8.663b90c8" },
{ value: "windows-1252", label: "i18n:govoplan-campaign.windows_1252.6944986d" },
{ value: "iso-8859-1", label: "i18n:govoplan-campaign.iso_8859_1.e4d77380" },
{ value: "utf-16le", label: "i18n:govoplan-campaign.utf_16_le.8d74294c" },
{ value: "utf-16be", label: "i18n:govoplan-campaign.utf_16_be.1de115c7" }];
export function RecipientImportDialog({ settings, campaignId, existingEntries, existingFields, defaultAttachmentBasePath, onCancel, onImport }: RecipientImportDialogProps) {
const filesFileExplorer = usePlatformUiCapability<FilesFileExplorerUiCapability>("files.fileExplorer");
const fileLinkCapability = useMemo<ImportFileLinkCapability | null>(() => {
if (!filesFileExplorer?.listFiles || !filesFileExplorer.resolveFilePatterns || !filesFileExplorer.shareFilesWithTarget) return null;
return {
listFiles: filesFileExplorer.listFiles,
resolveFilePatterns: filesFileExplorer.resolveFilePatterns,
shareFilesWithTarget: filesFileExplorer.shareFilesWithTarget
};
}, [filesFileExplorer]);
const recipientImportSteps = useMemo(
() => fileLinkCapability ? allRecipientImportSteps : allRecipientImportSteps.filter((step) => step.id !== "files"),
[fileLinkCapability]
);
const [activeStep, setActiveStep] = useState<RecipientImportStepId>("upload");
const [csvText, setCsvText] = useState("");
const [sourceType, setSourceType] = useState<RecipientImportSourceType>("text");
const [filename, setFilename] = useState("");
const [fileBuffer, setFileBuffer] = useState<ArrayBuffer | null>(null);
const [encoding, setEncoding] = useState("utf-8");
const [workbookSheets, setWorkbookSheets] = useState<RecipientImportSpreadsheetSheet[]>([]);
const [selectedSheetName, setSelectedSheetName] = useState("");
const [mode, setMode] = useState<RecipientImportMode>("append");
const [delimiter, setDelimiter] = useState<CsvDelimiter>("auto");
const [headerRows, setHeaderRows] = useState(1);
const [quoted, setQuoted] = useState(true);
const [valueSeparators, setValueSeparators] = useState(",;|");
const [mappings, setMappings] = useState<RecipientColumnMapping[]>([]);
const [mappingProfiles, setMappingProfiles] = useState<RecipientMappingProfile[]>([]);
const [mappingManuallyChanged, setMappingManuallyChanged] = useState(false);
const [mappingProfileError, setMappingProfileError] = useState("");
const [mappingProfileNotice, setMappingProfileNotice] = useState("");
const [fileError, setFileError] = useState("");
const [fileLinkResolution, setFileLinkResolution] = useState<ImportFileLinkResolution | null>(null);
const [fileLinkResolving, setFileLinkResolving] = useState(false);
const [fileLinking, setFileLinking] = useState(false);
const [fileLinkError, setFileLinkError] = useState("");
const [fileLinkNotice, setFileLinkNotice] = useState("");
const selectedSheet = useMemo(
() => workbookSheets.find((sheet) => sheet.name === selectedSheetName) ?? workbookSheets[0] ?? null,
[selectedSheetName, workbookSheets]
);
const hasContent = sourceType === "xlsx" ?
Boolean(selectedSheet && selectedSheet.rows.some((row) => row.some((cell) => cell.trim()))) :
csvText.trim().length > 0;
const table = useMemo(
() => {
if (!hasContent) return null;
if (sourceType === "xlsx") return selectedSheet ? buildImportTableFromRows(selectedSheet.rows, { headerRows }) : null;
return buildImportTable(csvText, { delimiter, headerRows, quoted });
},
[csvText, delimiter, hasContent, headerRows, quoted, selectedSheet, sourceType]
);
const tableHeaderKey = table ? `${table.delimiter}:${table.firstDataRowNumber}:${table.headers.join("\u001f")}` : "";
const mappingProfileMatches = useMemo(
() => table ? matchRecipientMappingProfiles(table, mappingProfiles) : [],
[mappingProfiles, table]
);
const parseReady = Boolean(table && table.dataRows.some((row) => row.some((cell) => cell.trim())));
const preview = useMemo(
() => table ? buildRecipientImportPreview(table, mappings, { existingFields, existingEntries, valueSeparators }) : null,
[existingEntries, existingFields, mappings, table, valueSeparators]
);
const activeStepIndex = recipientImportSteps.findIndex((step) => step.id === activeStep);
const nextStep = recipientImportSteps[activeStepIndex + 1]?.id ?? null;
const previousStep = recipientImportSteps[activeStepIndex - 1]?.id ?? null;
const isLastStep = activeStepIndex === recipientImportSteps.length - 1;
const mappedColumnCount = mappings.filter((mapping) => mapping.kind !== "ignore").length;
const patternRows = preview?.rows.filter((row) => row.patterns.length > 0).length ?? 0;
const renderedPatterns = useMemo(() => preview ? renderedImportPatterns(preview) : [], [preview]);
useEffect(() => {
let cancelled = false;
setMappingProfileError("");
void listRecipientImportMappingProfiles(settings).
then((profiles) => {
if (!cancelled) setMappingProfiles(profiles);
}).
catch((err) => {
if (!cancelled) setMappingProfileError(err instanceof Error ? err.message : String(err));
});
return () => {cancelled = true;};
}, [settings.apiBaseUrl, settings.apiKey, settings.accessToken]);
useEffect(() => {
if (!fileBuffer || sourceType !== "csv") return;
try {
setCsvText(decodeImportText(fileBuffer, encoding));
setFileError("");
} catch (err) {
setCsvText("");
setFileError(err instanceof Error ? err.message : String(err));
}
}, [encoding, fileBuffer, sourceType]);
useEffect(() => {
setMappingManuallyChanged(false);
}, [tableHeaderKey]);
useEffect(() => {
if (!table) {
setMappings([]);
setMappingProfileNotice("");
return;
}
if (mappingManuallyChanged) return;
const automaticMatch = mappingProfileMatches.find(isAutomaticMappingProfileMatch);
if (automaticMatch) {
setMappings(applyRecipientMappingProfile(table, automaticMatch.profile, existingFields));
setValueSeparators(automaticMatch.profile.valueSeparators || ",;|");
setMappingProfileNotice(mappingProfileMatchNotice(automaticMatch));
return;
}
setMappings(defaultColumnMappings(table.headers, existingFields));
setMappingProfileNotice("");
}, [existingFields, mappingManuallyChanged, mappingProfileMatches, table, tableHeaderKey]);
useEffect(() => {
if (!hasContent) setActiveStep("upload");
}, [hasContent]);
useEffect(() => {
if (activeStep === "files" && !fileLinkCapability) setActiveStep("preview");
}, [activeStep, fileLinkCapability]);
useEffect(() => {
if (activeStep === "files" && fileLinkCapability) void refreshFileLinks();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeStep, defaultAttachmentBasePath?.id, defaultAttachmentBasePath?.path, defaultAttachmentBasePath?.source, fileLinkCapability, renderedPatterns.length]);
async function readFile(file: File | undefined) {
if (!file) return;
setFilename(file.name);
setFileError("");
setWorkbookSheets([]);
setSelectedSheetName("");
try {
const buffer = await file.arrayBuffer();
if (isXlsxFile(file)) {
const sheets = await xlsxSheetsFromArrayBuffer(buffer);
if (sheets.length === 0) {
throw new Error("i18n:govoplan-campaign.workbook_contains_no_readable_sheets.02c18603");
}
setSourceType("xlsx");
setFileBuffer(null);
setCsvText("");
setWorkbookSheets(sheets);
setSelectedSheetName(sheets[0]?.name ?? "");
} else {
setSourceType("csv");
setFileBuffer(buffer);
setCsvText(decodeImportText(buffer, encoding));
}
setActiveStep("parse");
} catch (err) {
setFileError(err instanceof Error ? err.message : String(err));
}
}
function canOpenStep(stepId: RecipientImportStepId): boolean {
if (stepId === "upload") return true;
if (stepId === "parse") return hasContent;
if (stepId === "map") return parseReady;
if (stepId === "files" && !fileLinkCapability) return false;
return parseReady && mappings.length > 0;
}
function stepStatus(stepId: RecipientImportStepId): string {
if (stepId === "upload") return filename || (hasContent ? "i18n:govoplan-campaign.pasted_data.5b58080b" : "i18n:govoplan-campaign.waiting.33d30632");
if (stepId === "parse") return table ? `${table.dataRows.length} rows` : "i18n:govoplan-campaign.set_parsing.be7b05fd";
if (stepId === "map") return `${mappedColumnCount} mapped`;
if (stepId === "preview") return preview ? `${preview.validCount} valid` : "i18n:govoplan-campaign.review.e29a79fe";
if (!preview || preview.patternCount === 0) return "i18n:govoplan-campaign.no_patterns.5e1e46fa";
if (fileLinkResolving) return "i18n:govoplan-campaign.checking.97876b83";
if (fileLinkResolution) return i18nMessage("i18n:govoplan-campaign.value_to_link.50cdc659", { value0: fileLinkResolution.linkableFiles.length });
if (fileLinkError) return "i18n:govoplan-campaign.needs_setup.522ebae4";
return `${renderedPatterns.length} patterns`;
}
function goNext() {
if (nextStep && canOpenStep(nextStep)) setActiveStep(nextStep);
}
async function refreshFileLinks() {
setFileLinkNotice("");
if (!fileLinkCapability) {
setFileLinkResolution(null);
setFileLinkError("");
return;
}
if (!preview || preview.patternCount === 0) {
setFileLinkResolution(null);
setFileLinkError("");
return;
}
if (!defaultAttachmentBasePath?.source) {
setFileLinkResolution(null);
setFileLinkError("i18n:govoplan-campaign.the_selected_attachment_source_is_not_connected_.9c528afa");
return;
}
setFileLinkResolving(true);
setFileLinkError("");
try {
setFileLinkResolution(await resolveImportedAttachmentLinks(fileLinkCapability, settings, campaignId, defaultAttachmentBasePath, preview));
} catch (err) {
setFileLinkResolution(null);
setFileLinkError(err instanceof Error ? err.message : String(err));
} finally {
setFileLinkResolving(false);
}
}
async function linkImportedFiles() {
if (!fileLinkCapability || !fileLinkResolution || fileLinkResolution.linkableFiles.length === 0) return;
setFileLinking(true);
setFileLinkError("");
setFileLinkNotice("");
try {
const linkedCount = await bulkLinkFilesToCampaign(fileLinkCapability, settings, campaignId, fileLinkResolution.linkableFiles);
await refreshFileLinks();
setFileLinkNotice(`Linked ${linkedCount} file${linkedCount === 1 ? "" : "s"} to this campaign.`);
} catch (err) {
setFileLinkError(err instanceof Error ? err.message : String(err));
} finally {
setFileLinking(false);
}
}
function rememberCurrentMappingProfile() {
if (!table || mappings.length === 0) return;
const tableSnapshot = table;
const mappingsSnapshot = mappings;
const fallbackProfiles = mappingProfiles;
const filenameSnapshot = filename;
const parseOptions = { headerRows, quoted };
const valueSeparatorsSnapshot = valueSeparators;
void (async () => {
try {
const latestProfiles = await listRecipientImportMappingProfiles(settings).catch(() => fallbackProfiles);
const reusableProfile = findReusableMappingProfile(tableSnapshot, latestProfiles);
const draftProfile = buildRecipientMappingProfile({
id: reusableProfile?.id,
name: reusableProfile?.name ?? defaultMappingProfileName(filenameSnapshot, tableSnapshot),
table: tableSnapshot,
mappings: mappingsSnapshot,
parseOptions,
valueSeparators: valueSeparatorsSnapshot,
createdAt: reusableProfile?.createdAt,
updatedAt: new Date().toISOString()
});
const payload = mappingProfilePayload(draftProfile);
await (reusableProfile ?
updateRecipientImportMappingProfile(settings, reusableProfile.id, payload) :
createRecipientImportMappingProfile(settings, payload));
} catch {
// Import acceptance must not be blocked by passive profile learning.
}})();}function confirmRecipientImport(nextPreview: RecipientImportPreview) {rememberCurrentMappingProfile();
const provenance = createRecipientImportProvenance({
preview: nextPreview,
mappings,
mode,
sourceType,
filename,
sheetName: sourceType === "xlsx" ? selectedSheet?.name ?? null : null,
encoding: sourceType === "xlsx" ? null : encoding,
delimiter: sourceType === "xlsx" ? null : formatDelimiter(nextPreview.table.delimiter),
headerRows,
quoted: sourceType === "xlsx" ? null : quoted,
valueSeparators
});
onImport(nextPreview, mode, provenance);
}
function replaceColumnMapping(nextMapping: RecipientColumnMapping) {
const columnCount = table?.headers.length ?? 0;
setMappingManuallyChanged(true);
setMappings((current) => {
const byColumn = new Map(current.map((mapping) => [mapping.columnIndex, mapping]));
byColumn.set(nextMapping.columnIndex, nextMapping);
return Array.from({ length: columnCount }, (_value, columnIndex) => byColumn.get(columnIndex) ?? { columnIndex, kind: "ignore" });
});
}
function changeColumnKind(columnIndex: number, kind: RecipientColumnKind) {
const header = table?.headers[columnIndex] ?? `Column ${columnIndex + 1}`;
const current = mappings.find((mapping) => mapping.columnIndex === columnIndex);
const nextMapping: RecipientColumnMapping = { columnIndex, kind };
if (kind === "field") nextMapping.fieldName = current?.fieldName || existingFields[0]?.name || "";
if (kind === "new_field") nextMapping.newFieldName = current?.newFieldName || suggestImportFieldName(header);
replaceColumnMapping(nextMapping);
}
const stepContent = activeStep === "upload" ?
<>
<div className="campaign-header-grid recipient-import-upload-grid">
<FormField label="i18n:govoplan-campaign.recipient_file.693007d2">
<FileDropZone
accept=".csv,.tsv,.txt,.xlsx,text/csv,text/tab-separated-values,text/plain,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
multiple={false}
label="i18n:govoplan-campaign.drop_recipient_file_here.c7c2bddf"
actionLabel="i18n:govoplan-campaign.or_click_to_select_file.0e72f25d"
note={filename || "i18n:govoplan-campaign.csv_tsv_text_or_xlsx.5dcdac76"}
onFiles={(files) => readFile(files[0])} />
</FormField>
<FormField label="i18n:govoplan-campaign.import_mode.7d161dff">
<select value={mode} onChange={(event) => setMode(event.target.value === "replace" ? "replace" : "append")}>
<option value="append">i18n:govoplan-campaign.append_to_current_profiles.0b434d6f</option>
<option value="replace">i18n:govoplan-campaign.replace_current_profiles.3b3be5e2</option>
</select>
</FormField>
</div>
{fileError && <DismissibleAlert tone="danger" compact resetKey={fileError}>{fileError}</DismissibleAlert>}
{sourceType === "xlsx" ?
<DismissibleAlert tone="info" compact dismissible={false}>
i18n:govoplan-campaign.workbook_loaded.cfaf40eb {workbookSheets.length} sheet{workbookSheets.length === 1 ? "" : "s"}.
</DismissibleAlert> :
<FormField label={filename ? i18nMessage("i18n:govoplan-campaign.raw_content_value.15e098b8", { value0: filename }) : "i18n:govoplan-campaign.raw_content.c687fb57"}>
<textarea
className="json-editor recipient-import-textarea"
value={csvText}
onChange={(event) => {
setFileBuffer(null);
setSourceType(filename ? "csv" : "text");
setCsvText(event.target.value);
if (!event.target.value.trim()) setFilename("");
}}
spellCheck={false} />
</FormField>
}
</> :
activeStep === "parse" ?
<>
<div className="campaign-header-grid">
{sourceType === "xlsx" &&
<FormField label="i18n:govoplan-campaign.sheet.53bc47a7">
<select value={selectedSheet?.name ?? ""} onChange={(event) => setSelectedSheetName(event.target.value)}>
{workbookSheets.map((sheet) =>
<option key={sheet.name} value={sheet.name}>{sheet.name}</option>
)}
</select>
</FormField>
}
<FormField label="i18n:govoplan-campaign.header_rows.9814b9e3">
<input
type="number"
min={0}
max={10}
value={headerRows}
onChange={(event) => setHeaderRows(Math.max(0, Number.parseInt(event.target.value, 10) || 0))} />
</FormField>
{sourceType !== "xlsx" &&
<>
<FormField label="i18n:govoplan-campaign.encoding.5821fec7">
<select value={encoding} onChange={(event) => setEncoding(event.target.value)}>
{RECIPIENT_IMPORT_ENCODINGS.map((option) =>
<option key={option.value} value={option.value}>{option.label}</option>
)}
</select>
</FormField>
<FormField label="i18n:govoplan-campaign.separator.b4b289a7">
<select value={delimiter} onChange={(event) => setDelimiter(event.target.value as CsvDelimiter)}>
<option value="auto">i18n:govoplan-campaign.auto.c614ba7c</option>
<option value=",">i18n:govoplan-campaign.comma.b9ee3dea</option>
<option value=";">i18n:govoplan-campaign.semicolon.727ceca9</option>
<option value={"\t"}>i18n:govoplan-campaign.tab.fe06eb64</option>
</select>
</FormField>
<div className="campaign-header-toggle recipient-import-toggles">
<ToggleSwitch label="i18n:govoplan-campaign.quoted_contents.f7fd335a" checked={quoted} onChange={setQuoted} />
</div>
</>
}
</div>
{table &&
<>
<dl className="detail-list recipient-import-summary">
<div><dt>{sourceType === "xlsx" ? "i18n:govoplan-campaign.sheet.53bc47a7" : "i18n:govoplan-campaign.separator.b4b289a7"}</dt><dd>{sourceType === "xlsx" ? selectedSheet?.name ?? "i18n:govoplan-campaign.workbook.b4418e62" : formatDelimiter(table.delimiter)}</dd></div>
<div><dt>i18n:govoplan-campaign.header_rows.9814b9e3</dt><dd>{table.headerRows.length}</dd></div>
<div><dt>i18n:govoplan-campaign.data_rows.3734724c</dt><dd>{table.dataRows.length}</dd></div>
<div><dt>i18n:govoplan-campaign.columns.cf723c59</dt><dd>{table.headers.length}</dd></div>
</dl>
<RecipientImportRawTable tableRows={table.rows} headerRowCount={table.headerRows.length} columnCount={table.headers.length} />
</>
}
</> :
activeStep === "map" ?
<>
{mappingProfileError && <DismissibleAlert tone="warning" compact resetKey={mappingProfileError}>{mappingProfileError}</DismissibleAlert>}
{mappingProfileNotice && <DismissibleAlert tone="info" compact resetKey={mappingProfileNotice}>{mappingProfileNotice}</DismissibleAlert>}
<div className="campaign-header-grid recipient-import-map-controls">
<FormField label="i18n:govoplan-campaign.multi_value_separators.6dd66f70">
<input
value={valueSeparators}
onChange={(event) => {
setMappingManuallyChanged(true);
setValueSeparators(event.target.value);
}} />
</FormField>
<FormField label="i18n:govoplan-campaign.mode.a7b93d21">
<select value={mode} onChange={(event) => setMode(event.target.value === "replace" ? "replace" : "append")}>
<option value="append">i18n:govoplan-campaign.append.6b3a6022</option>
<option value="replace">i18n:govoplan-campaign.replace.a7cf7b25</option>
</select>
</FormField>
</div>
<div className="admin-table-surface recipient-import-preview-surface">
<table className="recipient-import-preview-table recipient-import-mapping-table">
<thead>
<tr>
<th>i18n:govoplan-campaign.column.65ba00e9</th>
<th>i18n:govoplan-campaign.sample.58fabfa7</th>
<th>i18n:govoplan-campaign.content.4f9be057</th>
<th>i18n:govoplan-campaign.field.c326a466</th>
</tr>
</thead>
<tbody>
{table?.headers.map((header, columnIndex) => {
const mapping = mappings.find((item) => item.columnIndex === columnIndex) ?? { columnIndex, kind: "ignore" as const };
return (
<tr key={`${columnIndex}-${header}`}>
<td><strong>{header}</strong></td>
<td className="mono-small">{sampleColumnValue(table.dataRows, columnIndex)}</td>
<td>
<select value={mapping.kind} onChange={(event) => changeColumnKind(columnIndex, event.target.value as RecipientColumnKind)}>
{recipientColumnKindOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</td>
<td>
{mapping.kind === "field" &&
<select
value={mapping.fieldName ?? ""}
onChange={(event) => replaceColumnMapping({ ...mapping, fieldName: event.target.value, newFieldName: undefined })}>
<option value="">i18n:govoplan-campaign.select_field.bb7e63d5</option>
{existingFields.map((field) => <option key={field.name} value={field.name}>{field.label || field.name}</option>)}
</select>
}
{mapping.kind === "new_field" &&
<input
value={mapping.newFieldName ?? ""}
onChange={(event) => replaceColumnMapping({ ...mapping, newFieldName: event.target.value, fieldName: undefined })} />
}
</td>
</tr>);
})}
</tbody>
</table>
</div>
</> :
activeStep === "preview" ?
<>
{preview &&
<>
<dl className="detail-list recipient-import-summary">
<div><dt>i18n:govoplan-campaign.rows.52d0b352</dt><dd>{preview.validCount} i18n:govoplan-campaign.valid.210cece7 {preview.invalidCount} invalid</dd></div>
<div><dt>i18n:govoplan-campaign.fields.e8b68527</dt><dd>{preview.fieldNamesToCreate.length} new</dd></div>
<div><dt>i18n:govoplan-campaign.patterns.4d34f7a2</dt><dd>{preview.patternCount} from {patternRows} i18n:govoplan-campaign.row_s.61464061</dd></div>
<div><dt>i18n:govoplan-campaign.source.6da13add</dt><dd>{preview.patternCount ? defaultAttachmentBasePath?.name ?? "i18n:govoplan-campaign.campaign_files.96e7004b" : "i18n:govoplan-campaign.no_patterns.5e1e46fa"}</dd></div>
</dl>
{preview.fieldNamesToCreate.length > 0 &&
<p className="muted small-note">i18n:govoplan-campaign.new_fields.c61e20c0 {preview.fieldNamesToCreate.join(", ")}</p>
}
<div className="admin-table-surface recipient-import-preview-surface">
<table className="recipient-import-preview-table">
<thead>
<tr>
<th>#</th>
<th>i18n:govoplan-campaign.to.ae79ea1e</th>
<th>i18n:govoplan-campaign.name.709a2322</th>
<th>i18n:govoplan-campaign.fields.e8b68527</th>
<th>i18n:govoplan-campaign.patterns.4d34f7a2</th>
<th>i18n:govoplan-campaign.status.bae7d5be</th>
</tr>
</thead>
<tbody>
{preview.rows.slice(0, 20).map((row) =>
<tr key={row.rowNumber} className={row.issues.length ? "is-invalid" : undefined}>
<td>{row.rowNumber}</td>
<td>{formatAddressList(row.addresses.to)}</td>
<td>{row.name}</td>
<td>{Object.keys(row.fields).length}</td>
<td>{row.patterns.length}</td>
<td>{row.issues.length ? row.issues.join(", ") : "i18n:govoplan-campaign.ready.20c7c552"}</td>
</tr>
)}
</tbody>
</table>
</div>
</>
}
</> :
<RecipientImportFileLinkStep
preview={preview}
basePath={defaultAttachmentBasePath}
resolution={fileLinkResolution}
resolving={fileLinkResolving}
linking={fileLinking}
error={fileLinkError}
notice={fileLinkNotice}
onRefresh={() => void refreshFileLinks()}
onLink={() => void linkImportedFiles()} />;
return (
<Dialog
open
title="i18n:govoplan-campaign.import_recipients.9d6dbe45"
className="recipient-import-modal"
bodyClassName="recipient-import-body"
closeDisabled={fileLinking || fileLinkResolving}
closeOnBackdrop={!fileLinking && !fileLinkResolving}
onClose={onCancel}
footer={
<>
<Button onClick={onCancel} disabled={fileLinking || fileLinkResolving}>i18n:govoplan-campaign.cancel.77dfd213</Button>
{previousStep && <Button onClick={() => setActiveStep(previousStep)} disabled={fileLinking || fileLinkResolving}>i18n:govoplan-campaign.back.b52b36b7</Button>}
{!isLastStep ?
<Button variant="primary" disabled={!nextStep || !canOpenStep(nextStep)} onClick={goNext}>i18n:govoplan-campaign.next.bc981983</Button> :
<Button variant="primary" disabled={!preview || preview.validCount === 0 || fileLinking || fileLinkResolving} onClick={() => preview && confirmRecipientImport(preview)}>i18n:govoplan-campaign.import_valid_rows.c3b2642b</Button>
}
</>
}>
<nav className="recipient-import-steps" aria-label="i18n:govoplan-campaign.recipient_import_steps.35dcf028">
<div className="recipient-import-step-track">
{recipientImportSteps.map((step, index) => {
const stepIndex = recipientImportSteps.findIndex((item) => item.id === step.id);
const stepState = stepIndex < activeStepIndex ? "is-complete" : step.id === activeStep ? "is-current" : "";
return (
<div className="recipient-import-step-group" key={step.id}>
<button
type="button"
className={`recipient-import-step-item ${stepState}`.trim()}
disabled={!canOpenStep(step.id)}
aria-current={step.id === activeStep ? "step" : undefined}
onClick={() => setActiveStep(step.id)}>
<span className="recipient-import-step-icon">{index + 1}</span>
<span className="recipient-import-step-copy">
<strong>{step.label}</strong>
<small>{stepStatus(step.id)}</small>
</span>
</button>
{index < recipientImportSteps.length - 1 && <span className="recipient-import-step-line" aria-hidden="true" />}
</div>);
})}
</div>
</nav>
<section className="recipient-import-step-panel">
{stepContent}
</section>
</Dialog>);
}
type RecipientImportFileLinkStepProps = {
preview: RecipientImportPreview | null;
basePath: AttachmentBasePath | null;
resolution: ImportFileLinkResolution | null;
resolving: boolean;
linking: boolean;
error: string;
notice: string;
onRefresh: () => void;
onLink: () => void;
};
function RecipientImportFileLinkStep({ preview, basePath, resolution, resolving, linking, error, notice, onRefresh, onLink }: RecipientImportFileLinkStepProps) {
const linkableIds = new Set(resolution?.linkableFiles.map((file) => file.id) ?? []);
if (!preview || preview.patternCount === 0) {
return <DismissibleAlert tone="info" dismissible={false}>i18n:govoplan-campaign.no_attachment_patterns_were_imported_there_are_n.c01c8266</DismissibleAlert>;
}
return (
<div className="recipient-import-file-step">
<div className="recipient-import-file-actions">
<div>
<strong>{basePath?.name ?? "i18n:govoplan-campaign.campaign_files.96e7004b"}</strong>
<p className="muted small-note">i18n:govoplan-campaign.files_matched_by_imported_recipient_attachment_p.9b97aca3</p>
</div>
<div className="button-row compact-actions">
<Button onClick={onRefresh} disabled={resolving || linking}>{resolving ? "i18n:govoplan-campaign.checking.820d6004" : "i18n:govoplan-campaign.refresh_matches.11e36411"}</Button>
<Button variant="primary" onClick={onLink} disabled={resolving || linking || !resolution || resolution.linkableFiles.length === 0}>{linking ? "i18n:govoplan-campaign.linking.6f640897" : i18nMessage("i18n:govoplan-campaign.link_value_file_s.ca800d96", { value0: resolution?.linkableFiles.length ?? 0 })}</Button>
</div>
</div>
{error && <DismissibleAlert tone="warning" compact resetKey={error}>{error}</DismissibleAlert>}
{notice && <DismissibleAlert tone="success" compact resetKey={notice}>{notice}</DismissibleAlert>}
{resolving && <DismissibleAlert tone="info" compact dismissible={false}>i18n:govoplan-campaign.resolving_imported_file_patterns.3aea140f</DismissibleAlert>}
{resolution &&
<>
<dl className="detail-list recipient-import-summary">
<div><dt>i18n:govoplan-campaign.patterns.4d34f7a2</dt><dd>{resolution.patterns.length}</dd></div>
<div><dt>i18n:govoplan-campaign.matched_files.f79c63bb</dt><dd>{resolution.files.length}</dd></div>
<div><dt>i18n:govoplan-campaign.already_linked.7d6c3dd5</dt><dd>{resolution.linkedFiles.length}</dd></div>
<div><dt>i18n:govoplan-campaign.need_linking.a7617722</dt><dd>{resolution.linkableFiles.length}</dd></div>
</dl>
{resolution.unmatchedPatterns.length > 0 &&
<div className="recipient-import-unmatched-patterns">
<strong>i18n:govoplan-campaign.patterns_without_matches.172afdde</strong>
<ul>
{resolution.unmatchedPatterns.slice(0, 8).map((pattern) =>
<li key={pattern.key}>i18n:govoplan-campaign.row.9bf7a8e8 {pattern.rowNumber}: <code>{pattern.renderedPattern}</code></li>
)}
</ul>
{resolution.unmatchedPatterns.length > 8 && <p className="muted small-note">{resolution.unmatchedPatterns.length - 8} i18n:govoplan-campaign.more_unmatched_pattern_s.1dda9807</p>}
</div>
}
<div className="admin-table-surface recipient-import-preview-surface">
<table className="recipient-import-preview-table recipient-import-file-link-table">
<thead>
<tr>
<th>i18n:govoplan-campaign.file.2c3cafa4</th>
<th>i18n:govoplan-campaign.path.519e3913</th>
<th>i18n:govoplan-campaign.size.b7152342</th>
<th>i18n:govoplan-campaign.status.bae7d5be</th>
</tr>
</thead>
<tbody>
{resolution.files.length === 0 ?
<tr><td colSpan={4}>i18n:govoplan-campaign.no_files_currently_match_the_imported_patterns.6b599e8b</td></tr> :
resolution.files.map((file) =>
<tr key={file.id}>
<td><strong>{file.filename}</strong></td>
<td>{file.display_path}</td>
<td>{formatImportBytes(file.size_bytes)}</td>
<td>{linkableIds.has(file.id) ? "i18n:govoplan-campaign.needs_linking.a0fc8341" : "i18n:govoplan-campaign.linked.a089f600"}</td>
</tr>
)}
</tbody>
</table>
</div>
</>
}
</div>);
}
type RecipientImportRawTableProps = {
tableRows: string[][];
headerRowCount: number;
columnCount: number;
};
function RecipientImportRawTable({ tableRows, headerRowCount, columnCount }: RecipientImportRawTableProps) {
const visibleRows = tableRows.slice(0, 15);
const safeColumnCount = Math.max(1, columnCount, ...visibleRows.map((row) => row.length));
return (
<div className="admin-table-surface recipient-import-preview-surface">
<table className="recipient-import-preview-table recipient-import-raw-table">
<thead>
<tr>
<th>#</th>
{Array.from({ length: safeColumnCount }, (_value, index) => <th key={index}>{index + 1}</th>)}
</tr>
</thead>
<tbody>
{visibleRows.length === 0 ?
<tr><td colSpan={safeColumnCount + 1}>i18n:govoplan-campaign.no_rows_parsed.a7ccc3de</td></tr> :
visibleRows.map((row, rowIndex) =>
<tr key={rowIndex} className={rowIndex < headerRowCount ? "is-header-row" : undefined}>
<td>{rowIndex + 1}</td>
{Array.from({ length: safeColumnCount }, (_value, columnIndex) => <td key={columnIndex}>{row[columnIndex] ?? ""}</td>)}
</tr>
)}
</tbody>
</table>
</div>);
}
function formatDelimiter(delimiter: "," | ";" | "\t"): string {
if (delimiter === "\t") return "i18n:govoplan-campaign.tab.fe06eb64";
if (delimiter === ";") return "i18n:govoplan-campaign.semicolon.727ceca9";
return "i18n:govoplan-campaign.comma.b9ee3dea";
}
function isXlsxFile(file: File): boolean {
const name = file.name.toLowerCase();
return name.endsWith(".xlsx") || file.type === "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
}
function decodeImportText(buffer: ArrayBuffer, encoding: string): string {
try {
return new TextDecoder(encoding).decode(buffer).replace(/^\uFEFF/, "");
} catch (err) {
throw new Error(`Could not decode file as ${encoding}: ${err instanceof Error ? err.message : String(err)}`);
}
}
function mappingProfilePayload(profile: RecipientMappingProfile): RecipientImportMappingProfilePayload {
return {
name: profile.name,
columnCount: profile.columnCount,
headers: profile.headers,
normalizedHeaders: profile.normalizedHeaders,
orderedHeaderFingerprint: profile.orderedHeaderFingerprint,
unorderedHeaderFingerprint: profile.unorderedHeaderFingerprint,
delimiter: profile.delimiter,
headerRows: profile.headerRows,
quoted: profile.quoted,
valueSeparators: profile.valueSeparators,
mappings: profile.mappings
};
}
function defaultMappingProfileName(filename: string, table: {headers: string[];}): string {
const trimmedFilename = filename.trim().replace(/\.[^.]+$/, "");
if (trimmedFilename) return trimmedFilename;
const headerLabel = table.headers.slice(0, 3).map((header) => header.trim()).filter(Boolean).join(", ");
return headerLabel ? i18nMessage("i18n:govoplan-campaign.mapping_value", { value0: headerLabel }) : "i18n:govoplan-campaign.recipient_import_mapping.5fe80f6c";
}
function formatMappingProfileMatch(match: RecipientMappingProfileMatch): string {
if (match.mode === "ordered") return "i18n:govoplan-campaign.exact";
if (match.mode === "unordered") return "i18n:govoplan-campaign.same_headers.e523bbe4";
return `${Math.round(match.score * 100)}%`;
}
function isAutomaticMappingProfileMatch(match: RecipientMappingProfileMatch): boolean {
return match.mode === "ordered" || match.mode === "unordered" || match.score >= AUTOMATIC_MAPPING_PROFILE_MIN_SCORE;
}
function findReusableMappingProfile(table: {headers: string[];}, profiles: RecipientMappingProfile[]): RecipientMappingProfile | null {
const fingerprints = recipientImportHeaderFingerprints(table.headers);
return profiles.find((profile) => profile.orderedHeaderFingerprint === fingerprints.orderedHeaderFingerprint) ??
profiles.find((profile) => profile.unorderedHeaderFingerprint === fingerprints.unorderedHeaderFingerprint) ??
null;
}
function mappingProfileMatchNotice(match: RecipientMappingProfileMatch): string {
if (match.mode === "ordered") return i18nMessage("i18n:govoplan-campaign.using_mapping_from_previous_import_value.f86f53fd", { value0: match.profile.name });
if (match.mode === "unordered") return i18nMessage("i18n:govoplan-campaign.using_mapping_from_previous_import_value_matched.08910120", { value0: match.profile.name });
return i18nMessage("i18n:govoplan-campaign.using_mapping_derived_from_previous_import_value.008a66a3", { value0: match.profile.name, value1: formatMappingProfileMatch(match) });
}
function sampleColumnValue(rows: string[][], columnIndex: number): string {
return rows.map((row) => String(row[columnIndex] ?? "").trim()).find(Boolean) ?? "";
}
function formatAddressList(addresses: ImportedAddress[]): string {
return addresses.map((address) => address.name ? `${address.name} <${address.email}>` : address.email).join(", ");
}
function formatImportBytes(value?: number | null): string {
if (!value) return "";
if (value < 1024) return i18nMessage("i18n:govoplan-campaign.bytes_b", { value0: value });
if (value < 1024 * 1024) return i18nMessage("i18n:govoplan-campaign.bytes_kb", { value0: (value / 1024).toFixed(1) });
return i18nMessage("i18n:govoplan-campaign.bytes_mb", { value0: (value / 1024 / 1024).toFixed(1) });
}
function buildAddressSourceImportPreview(snapshot: CampaignRecipientAddressSourceSnapshot, existingEntries: Record<string, unknown>[]): RecipientImportPreview {
const headers = ["display_name", "email", "given_name", "family_name", "organization", "role_title", "phone", "tags"];
const tableRows = [
headers,
...snapshot.recipients.map((recipient) => headers.map((header) => {
if (header === "display_name") return recipient.display_name;
if (header === "email") return recipient.email;
return fieldValueToString((recipient.fields ?? {})[header]);
}))];
const table: RecipientImportTable = {
delimiter: ",",
headers,
headerRows: [headers],
rows: tableRows,
dataRows: tableRows.slice(1),
firstDataRowNumber: 2
};
const usedIds = new Set(existingEntries.map((entry) => String(entry.id || "")).filter(Boolean));
const fieldNamesToCreate = new Set<string>();
const rows: ImportedRecipientRow[] = snapshot.recipients.map((recipient, index) => {
const email = recipient.email.trim();
const name = recipient.display_name.trim();
const fields = stringFieldsFromAddressSource(recipient.fields);
Object.keys(fields).forEach((fieldName) => fieldNamesToCreate.add(fieldName));
const id = uniqueImportId(
`address-${recipient.contact_id || idFragmentFromEmail(email) || index + 1}`,
usedIds
);
const issues: string[] = [];
if (!email.includes("@")) issues.push(`${email || "email"} must contain @`);
return {
rowNumber: table.firstDataRowNumber + index,
id,
name,
email,
active: true,
addresses: {
from: [],
to: email ? [{ name, email }] : [],
cc: [],
bcc: [],
reply_to: []
},
fields,
patterns: [],
issues
};
});
return {
table,
rows,
fieldNamesToCreate: [...fieldNamesToCreate].sort(),
validCount: rows.filter((row) => row.issues.length === 0).length,
invalidCount: rows.filter((row) => row.issues.length > 0).length,
patternCount: 0
};
}
function createAddressSourceImportProvenance(
snapshot: CampaignRecipientAddressSourceSnapshot,
preview: RecipientImportPreview,
mode: RecipientImportMode)
: RecipientImportProvenance {
const now = new Date().toISOString();
return {
id: `recipient-import-addresses-${safeImportIdFragment(snapshot.source_id)}-${Date.now().toString(36)}`,
imported_at: now,
mode,
source_type: "addresses",
source_id: snapshot.source_id,
source_label: snapshot.source_label,
source_revision: snapshot.source_revision,
source_provenance: snapshot.provenance,
filename: null,
sheet_name: null,
encoding: null,
delimiter: null,
header_rows: 0,
quoted: null,
value_separators: null,
rows_total: preview.rows.length,
valid_rows: preview.validCount,
invalid_rows: preview.invalidCount,
imported_rows: preview.validCount,
field_names_created: preview.fieldNamesToCreate.slice(),
attachment_patterns: 0,
mapping: []
};
}
function stringFieldsFromAddressSource(value: Record<string, unknown> | undefined): Record<string, string> {
const fields: Record<string, string> = {};
for (const [key, rawValue] of Object.entries(value ?? {})) {
const fieldValue = fieldValueToString(rawValue);
if (fieldValue) fields[key] = fieldValue;
}
return fields;
}
function fieldValueToString(value: unknown): string {
if (value === null || value === undefined) return "";
if (Array.isArray(value)) return value.map(fieldValueToString).filter(Boolean).join(", ");
if (typeof value === "object") {
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
return String(value).trim();
}
function idFragmentFromEmail(value: string): string {
return safeImportIdFragment(value.split("@")[0] ?? "");
}
function safeImportIdFragment(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9_.-]+/g, "-").replace(/^-+|-+$/g, "") || "source";
}
function uniqueImportId(preferred: string, usedIds: Set<string>): string {
const base = safeImportIdFragment(preferred) || "recipient";
let candidate = base;
let counter = 2;
while (usedIds.has(candidate)) {
candidate = `${base}-${counter}`;
counter += 1;
}
usedIds.add(candidate);
return candidate;
}
function suggestImportFieldName(value: string): string {
const cleaned = value.trim().replace(/^fields[._-]+/i, "").replace(/\s+/g, "_").replace(/[^a-zA-Z0-9_.-]+/g, "_").replace(/^_+|_+$/g, "");
return cleaned || "field";
}
type RecipientProfileColumnContext = {
settings: ApiSettings;
campaignId: string;
draft: Record<string, unknown>;
locked: boolean;
filesModuleInstalled: boolean;
entries: Record<string, unknown>[];
fieldDefinitions: ReturnType<typeof getDraftFields>;
individualAttachmentBasePaths: ReturnType<typeof getIndividualAttachmentBasePaths>;
zipConfig: AttachmentZipCollection;
translateText: (value: string) => string;
openAddressEditor: (index: number) => void;
updateEntry: (index: number, updater: (entry: Record<string, unknown>) => Record<string, unknown>) => void;
updateEntryAttachments: (index: number, attachments: AttachmentRule[]) => void;
updateEntryField: (index: number, field: string, value: unknown) => void;
addRecipient: (afterIndex?: number) => void;
moveEntry: (index: number, targetIndex: number) => void;
removeEntry: (index: number) => void;
};
function recipientProfileColumns({ settings, campaignId, draft, locked, filesModuleInstalled, entries, fieldDefinitions, individualAttachmentBasePaths, zipConfig, translateText, openAddressEditor, updateEntry, updateEntryAttachments, updateEntryField, addRecipient, moveEntry, removeEntry }: RecipientProfileColumnContext): DataGridColumn<Record<string, unknown>>[] {
return [
{
id: "number",
header: "#",
width: 72,
sortable: true,
filterType: "integer",
sticky: "start",
render: (_entry, index) =>
<span className="recipient-data-number">
{index + 1}
</span>,
value: (_entry, index) => index + 1
},
{
id: "recipients",
header: "Recipient(s)",
width: "minmax(320px, 1.4fr)",
resizable: true,
filterable: true,
render: (entry, index) => {
const summary = recipientAddressSummary(entry, translateText);
const content = (
<>
<span className="recipient-address-editor-main">
<span className={`recipient-data-address ${summary.empty ? "is-empty" : ""}`}>{summary.primary}</span>
{summary.badges.map((badge) =>
<span className="recipient-extra-bubble" key={badge}>{badge}</span>
)}
</span>
{!locked && <Pencil aria-hidden="true" />}
</>
);
if (locked) {
return <div className="recipient-address-editor-trigger is-readonly">{content}</div>;
}
return (
<button
type="button"
className="recipient-address-editor-trigger"
onClick={() => openAddressEditor(index)}>
{content}
</button>);
},
value: recipientAddressFilterValue
},
{ id: "active", header: "i18n:govoplan-campaign.active.a733b809", width: 130, sortable: true, filterable: true, columnType: "from-list", list: { options: [{ value: "active", label: "i18n:govoplan-campaign.active.a733b809" }, { value: "inactive", label: "i18n:govoplan-campaign.inactive.09af574c" }] }, render: (entry, index) => <ToggleSwitch label="i18n:govoplan-campaign.active.a733b809" checked={entry.active !== false} disabled={locked} onChange={(checked) => updateEntry(index, (current) => ({ ...current, active: checked }))} />, value: (entry) => entry.active !== false ? "active" : "inactive" },
...(individualAttachmentBasePaths.length > 0 ? [{
id: "attachments",
header: "i18n:govoplan-campaign.attachments.6771ade6",
width: 180,
filterable: true,
render: (entry, index) => {
const attachments = normalizeAttachmentRules(entry.attachments);
return (
<AttachmentRulesOverlay
title={i18nMessage("i18n:govoplan-campaign.attachments_for_recipient_value.9a8df82d", { value0: index + 1 })}
rules={attachments}
settings={settings}
campaignId={campaignId}
disabled={locked}
buttonLabel={`entries: ${attachments.length}`}
basePaths={individualAttachmentBasePaths}
zipConfig={zipConfig}
filesModuleInstalled={filesModuleInstalled}
previewContext={buildTemplatePreviewContext(draft, entry)}
onChange={(rules) => updateEntryAttachments(index, rules)} />);
},
value: (entry) => normalizeAttachmentRules(entry.attachments).map((rule) => `${rule.label ?? ""} ${rule.file_filter ?? ""}`).join(", ")
}] : []),
...fieldDefinitions.filter((field) => field.can_override !== false).map((field): DataGridColumn<Record<string, unknown>> => ({
id: `field-${field.name}`,
header: field.label || field.name,
width: 190,
resizable: true,
sortable: true,
filterable: true,
filterType: field.type === "integer" ? "integer" : field.type === "double" ? "number" : field.type === "date" ? "date" : "text",
render: (entry, index) => {
const fields = asRecord(entry.fields);
return (
<FieldValueInput
className="recipient-field-input"
fieldType={field.type}
value={fields[field.name]}
disabled={locked}
onChange={(value) => updateEntryField(index, field.name, value)} />);
},
value: (entry) => String(asRecord(entry.fields)[field.name] ?? "")
})),
{
id: "actions",
header: "i18n:govoplan-campaign.actions.c3cd636a",
width: 180,
sticky: "end",
render: (_entry, index) =>
<DataGridRowActions
disabled={locked}
onAddBelow={() => addRecipient(index)}
onRemove={() => removeEntry(index)}
onMoveUp={index > 0 ? () => moveEntry(index, index - 1) : undefined}
onMoveDown={index < entries.length - 1 ? () => moveEntry(index, index + 1) : undefined}
addLabel="i18n:govoplan-campaign.add_recipient_below.52fb47d8"
removeLabel="i18n:govoplan-campaign.remove_recipient.d1bc9f53"
moveUpLabel="i18n:govoplan-campaign.move_recipient_up.90c6bccc"
moveDownLabel="i18n:govoplan-campaign.move_recipient_down.ead5466c" />
}];
}
function recipientAddressSummary(entry: Record<string, unknown>, translateText: (value: string) => string): {primary: string;badges: string[];empty: boolean;} {
const to = getEntryAddresses(entry, "to");
const primaryAddress = to[0] ?? null;
const badges = [
addressCountBadge(Math.max(0, to.length - 1), "To"),
addressCountBadge(getEntryAddresses(entry, "cc").length, "CC"),
addressCountBadge(getEntryAddresses(entry, "bcc").length, "BCC"),
addressCountBadge(getEntryAddresses(entry, "reply_to").length, "Reply-To"),
addressCountBadge(getEntryAddresses(entry, "from").length, "From")].
filter((badge): badge is string => Boolean(badge));
if (!primaryAddress) {
return {
primary: translateText("i18n:govoplan-campaign.no_recipient_address.1301a488"),
badges,
empty: true
};
}
return {
primary: formatMailboxAddress(primaryAddress),
badges,
empty: false
};
}
function recipientAddressFilterValue(entry: Record<string, unknown>): string {
return recipientAddressOverlayColumns.
flatMap((column) => getEntryAddresses(entry, column.key)).
map(formatMailboxAddress).
join(", ");
}
function addressCountBadge(count: number, label: string): string | null {
if (count <= 0) return null;
return `+${count} ${label}`;
}
function getAddressColumn(key: AddressFieldKey): EntryAddressColumn {
const column = recipientAddressOverlayColumns.find((item) => item.key === key);
if (!column) throw new Error(`Unknown address column ${key}`);
return column;
}
function normalizeEditorAddressRows(column: EntryAddressColumn, addresses: MailboxAddress[]): MailboxAddress[] {
if (column.allowMultiple) return addresses;
return addresses.length > 0 ? addresses.slice(0, 1) : [{ name: "", email: "" }];
}
function formatMailboxAddress(address: MailboxAddress): string {
const name = String(address.name ?? "").trim();
const email = String(address.email ?? "").trim();
if (name && email) return `${name} <${email}>`;
return email || name;
}
function entryWithAddressList(entry: Record<string, unknown>, key: AddressFieldKey, addresses: MailboxAddress[]): Record<string, unknown> {
if (key === "from") return { ...entry, from: addresses.slice(0, 1) };
const nextEntry = { ...entry, [key]: addresses };
if (key === "to") {
const address = addresses[0] ?? { name: "", email: "" };
return {
...nextEntry,
name: address.name ?? "",
email: address.email
};
}
return nextEntry;
}
function headerAddressValues(columns: EntryAddressColumn[], recipientsSection: Record<string, unknown>): HeaderAddressValues {
return Object.fromEntries(columns.map((column) => [
column.key,
column.key === "from" ? addressesFromValue(recipientsSection.from).slice(0, 1) : addressesFromValue(recipientsSection[column.key])]
)) as HeaderAddressValues;
}
function addressDisplaySegments(columns: EntryAddressColumn[], values: HeaderAddressValues): string[] {
return columns.flatMap((column) =>
(values[column.key] ?? []).map((address) => `${addressDisplayPrefix(column.key)}${formatMailboxAddress(address)}`)
).filter(Boolean);
}
function fitAddressSegments(segments: string[], availableWidth: number): string[] {
if (segments.length <= 1 || availableWidth <= 0) return segments;
if (measureAddressTextWidth(segments.join(", ")) <= availableWidth) return segments;
const maxWidth = Math.max(120, availableWidth - 42);
let usedWidth = 0;
const visible: string[] = [];
for (const segment of segments) {
const estimatedWidth = measureAddressTextWidth(`${visible.length > 0 ? ", " : ""}${segment}`);
if (visible.length > 0 && usedWidth + estimatedWidth > maxWidth) break;
visible.push(segment);
usedWidth += estimatedWidth;
}
return visible.length > 0 ? visible : segments.slice(0, 1);
}
function measureAddressTextWidth(value: string): number {
if (typeof document === "undefined") return value.length * 6.6;
const canvas = document.createElement("canvas");
const context = canvas.getContext("2d");
if (!context) return value.length * 6.6;
context.font = "13px system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", sans-serif";
return context.measureText(value).width;
}
function formatAddressCollectionForClipboard(columns: EntryAddressColumn[], values: HeaderAddressValues): string {
return addressDisplaySegments(columns, values).join(", ");
}
function addressDisplayPrefix(key: AddressFieldKey): string {
if (key === "from") return "From: ";
if (key === "reply_to") return "Reply-To: ";
if (key === "to") return "To: ";
if (key === "cc") return "CC: ";
return "BCC: ";
}
function parsePastedAddressGroups(targetKey: AddressFieldKey, text: string): Array<{key: AddressFieldKey;addresses: MailboxAddress[];}> {
const tokens = splitPastedAddressText(text);
const hasPrefixedToken = tokens.some((token) => Boolean(prefixedAddressToken(token)));
const grouped = new Map<AddressFieldKey, MailboxAddress[]>();
for (const token of tokens) {
const prefixed = prefixedAddressToken(token);
const key = prefixed?.key ?? (hasPrefixedToken ? "to" : targetKey);
const addressText = prefixed?.text ?? token;
const address = parseMailboxAddressText(addressText);
if (!address?.email) continue;
grouped.set(key, dedupeAddresses([...(grouped.get(key) ?? []), address]));
}
return [...grouped.entries()].map(([key, addresses]) => ({ key, addresses }));
}
function splitPastedAddressText(text: string): string[] {
return text.
replace(/\r/g, "\n").
split(/\n|;/).
flatMap((part) => part.split(",")).
map((part) => part.trim()).
filter(Boolean);
}
function prefixedAddressToken(token: string): {key: AddressFieldKey;text: string;} | null {
const match = token.match(/^(from|sender|reply[-_\s]?to|to|cc|bcc)\s*:\s*(.+)$/i);
if (!match) return null;
const key = addressKeyFromPrefix(match[1]);
return key ? { key, text: match[2].trim() } : null;
}
function addressKeyFromPrefix(prefix: string): AddressFieldKey | null {
const normalized = prefix.toLowerCase().replace(/[-_\s]/g, "");
if (normalized === "from" || normalized === "sender") return "from";
if (normalized === "replyto") return "reply_to";
if (normalized === "to") return "to";
if (normalized === "cc") return "cc";
if (normalized === "bcc") return "bcc";
return null;
}
function getEntryMerge(entry: Record<string, unknown>, defaults: Record<string, unknown>, mergeKey: NonNullable<EntryAddressColumn["mergeKey"]>): boolean {
const legacyKey = mergeKey.replace("merge_", "combine_");
if (typeof entry[mergeKey] === "boolean") return entry[mergeKey] as boolean;
if (typeof entry[legacyKey] === "boolean") return entry[legacyKey] as boolean;
if (typeof defaults[mergeKey] === "boolean") return defaults[mergeKey] as boolean;
if (typeof defaults[legacyKey] === "boolean") return defaults[legacyKey] as boolean;
return true;
}
function entryAddressEnabled(recipientsSection: Record<string, unknown>, key: EntryAddressColumn["key"]): boolean {
return getBool(recipientsSection, `allow_individual_${key}`, key === "to");
}
function getEntryAddresses(entry: Record<string, unknown>, key: EntryAddressColumn["key"]): MailboxAddress[] {
if (key === "to") {
const explicit = addressesFromValue(entry.to);
return explicit.length ? explicit : fallbackRecipientAddress(entry);
}
if (key === "from") return addressesFromValue(entry.from).slice(0, 1);
if (key === "reply_to") return addressesFromValue(entry.reply_to);
return addressesFromValue(entry[key]);
}
function fallbackRecipientAddress(entry: Record<string, unknown>): MailboxAddress[] {
const direct = addressesFromValue(entry.recipient)[0] ?? addressesFromValue(entry)[0];
return direct?.email ? [direct] : [];
}
function uniqueEntryId(entries: Record<string, unknown>[], preferred: string): string {
const existing = new Set(entries.map((entry) => String(entry.id || "")).filter(Boolean));
if (!existing.has(preferred)) return preferred;
let counter = entries.length + 1;
let candidate = `recipient-${counter}`;
while (existing.has(candidate)) {
counter += 1;
candidate = `recipient-${counter}`;
}
return candidate;
}