278 lines
10 KiB
TypeScript
278 lines
10 KiB
TypeScript
import { asArray, asRecord, isRecord } from "./campaignView";
|
|
import { getBool, getText } from "./draftEditor";
|
|
|
|
export type AttachmentRule = Record<string, unknown>;
|
|
|
|
export type AttachmentZipPasswordScope = "local" | "global";
|
|
|
|
export type AttachmentZipArchive = {
|
|
id: string;
|
|
name: string;
|
|
standard: boolean;
|
|
password_enabled: boolean;
|
|
password_field: string;
|
|
password_scope: AttachmentZipPasswordScope;
|
|
method: "aes" | "zip_standard";
|
|
// Read-only compatibility values retained when normalizing older campaigns.
|
|
password_mode?: "none" | "direct" | "field" | "template";
|
|
password?: string;
|
|
password_template?: string;
|
|
};
|
|
|
|
export type AttachmentZipCollection = {
|
|
enabled: boolean;
|
|
archives: AttachmentZipArchive[];
|
|
};
|
|
|
|
export function createAttachmentZipArchive(name = "attachments.zip", standard = false): AttachmentZipArchive {
|
|
return {
|
|
id: `zip-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
name,
|
|
standard,
|
|
password_enabled: false,
|
|
password_field: "",
|
|
password_scope: "local",
|
|
method: "aes"
|
|
};
|
|
}
|
|
|
|
export function normalizeAttachmentZipCollection(value: unknown): AttachmentZipCollection {
|
|
const zip = asRecord(value);
|
|
const rawArchives = asArray(zip.archives).filter(isRecord);
|
|
if (rawArchives.length > 0 || Object.prototype.hasOwnProperty.call(zip, "archives")) {
|
|
return {
|
|
enabled: getBool(zip, "enabled"),
|
|
archives: rawArchives.map((archive, index) => {
|
|
const legacyMode = getText(archive, "password_mode") as AttachmentZipArchive["password_mode"];
|
|
return {
|
|
id: getText(archive, "id", `zip-${index + 1}`),
|
|
name: getText(archive, "name", getText(archive, "filename_template", "attachments.zip")),
|
|
standard: getBool(archive, "standard"),
|
|
password_enabled: getBool(archive, "password_enabled", ["direct", "field", "template"].includes(legacyMode || "")),
|
|
password_field: getText(archive, "password_field"),
|
|
password_scope: getText(archive, "password_scope") === "global" ? "global" : "local",
|
|
method: getText(archive, "method", "aes") === "zip_standard" ? "zip_standard" : "aes",
|
|
...(legacyMode ? { password_mode: legacyMode } : {}),
|
|
...(getText(archive, "password") ? { password: getText(archive, "password") } : {}),
|
|
...(getText(archive, "password_template") ? { password_template: getText(archive, "password_template") } : {})
|
|
};
|
|
})
|
|
};
|
|
}
|
|
|
|
// Upgrade the first, single-archive implementation in memory.
|
|
const legacyEnabled = getBool(zip, "enabled");
|
|
const legacyName = getText(zip, "filename_template");
|
|
const legacyMode = getText(zip, "password_mode", getText(zip, "password_template") ? "template" : "none") as AttachmentZipArchive["password_mode"];
|
|
const meaningful = legacyEnabled || Boolean(legacyName || getText(zip, "password") || getText(zip, "password_field") || getText(zip, "password_template"));
|
|
if (!meaningful) return { enabled: false, archives: [] };
|
|
return {
|
|
enabled: legacyEnabled,
|
|
archives: [{
|
|
id: "default",
|
|
name: legacyName || "attachments.zip",
|
|
standard: true,
|
|
password_enabled: ["direct", "field", "template"].includes(legacyMode || ""),
|
|
password_field: getText(zip, "password_field"),
|
|
password_scope: "local",
|
|
method: getText(zip, "method", "aes") === "zip_standard" ? "zip_standard" : "aes",
|
|
...(legacyMode ? { password_mode: legacyMode } : {}),
|
|
...(getText(zip, "password") ? { password: getText(zip, "password") } : {}),
|
|
...(getText(zip, "password_template") ? { password_template: getText(zip, "password_template") } : {})
|
|
}]
|
|
};
|
|
}
|
|
|
|
export function attachmentRuleZipSelection(rule: AttachmentRule): string {
|
|
const zip = asRecord(rule.zip);
|
|
const archiveId = getText(zip, "archive_id");
|
|
if (archiveId) return archiveId;
|
|
const legacyMode = getText(zip, "mode");
|
|
if (legacyMode === "exclude") return "exclude";
|
|
return "inherit";
|
|
}
|
|
|
|
export type ManagedAttachmentSource = {
|
|
ownerType: "user" | "group";
|
|
ownerId: string;
|
|
};
|
|
|
|
type ManagedAttachmentSourceSpace = {
|
|
owner_type: "user" | "group";
|
|
owner_id: string;
|
|
};
|
|
|
|
const MANAGED_ATTACHMENT_SOURCE_PREFIX = "managed:";
|
|
|
|
export function encodeManagedAttachmentSource(space: ManagedAttachmentSourceSpace): string {
|
|
return `${MANAGED_ATTACHMENT_SOURCE_PREFIX}${space.owner_type}:${space.owner_id}`;
|
|
}
|
|
|
|
export function parseManagedAttachmentSource(value: unknown): ManagedAttachmentSource | null {
|
|
if (typeof value !== "string" || !value.startsWith(MANAGED_ATTACHMENT_SOURCE_PREFIX)) return null;
|
|
const [, ownerType, ...ownerIdParts] = value.split(":");
|
|
const ownerId = ownerIdParts.join(":").trim();
|
|
if ((ownerType !== "user" && ownerType !== "group") || !ownerId) return null;
|
|
return { ownerType, ownerId };
|
|
}
|
|
|
|
export type AttachmentBasePath = {
|
|
id: string;
|
|
name: string;
|
|
path: string;
|
|
source?: string;
|
|
allow_individual?: boolean;
|
|
unsent_warning?: boolean;
|
|
};
|
|
|
|
export type AttachmentSummary = {
|
|
direct: number;
|
|
rules: number;
|
|
};
|
|
|
|
export type MockAttachmentPathOption = Partial<AttachmentBasePath> & {
|
|
label: string;
|
|
};
|
|
|
|
export const mockAttachmentPathOptions: MockAttachmentPathOption[] = [
|
|
{ label: "Campaign attachments", path: "attachments" },
|
|
{ label: "Campaign root", path: "." },
|
|
{ label: "Shared group files", path: "group/shared" },
|
|
{ label: "Tenant templates", path: "tenant/templates" },
|
|
{ label: "Personal upload area", path: "user/uploads" }
|
|
];
|
|
|
|
|
|
|
|
export function createAttachmentBasePath(name = "New attachment source", path = "."): AttachmentBasePath {
|
|
return {
|
|
id: `base-path-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
name,
|
|
path,
|
|
allow_individual: false,
|
|
unsent_warning: false
|
|
};
|
|
}
|
|
|
|
export function createAttachmentRule(baseDir = "", label = "", basePathId = ""): AttachmentRule {
|
|
return {
|
|
id: `attachment-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
label,
|
|
base_path_id: basePathId || undefined,
|
|
base_dir: baseDir,
|
|
file_filter: "",
|
|
required: true,
|
|
include_subdirs: false,
|
|
zip: { archive_id: "inherit" }
|
|
};
|
|
}
|
|
|
|
|
|
export function normalizeAttachmentBasePaths(value: unknown, attachments: Record<string, unknown>, fallbackAllowIndividual = false): AttachmentBasePath[] {
|
|
if (Array.isArray(value) && value.length > 0) {
|
|
return value.filter(isRecord).map((basePath, index) => ({
|
|
id: getText(basePath, "id", `base-path-${index + 1}`),
|
|
name: getText(basePath, "name", `Base path ${index + 1}`),
|
|
source: getText(basePath, "source"),
|
|
path: getText(basePath, "path", index === 0 ? getText(attachments, "base_path", ".") : "."),
|
|
allow_individual: getBool(basePath, "allow_individual"),
|
|
unsent_warning: getBool(basePath, "unsent_warning")
|
|
}));
|
|
}
|
|
|
|
return [{
|
|
id: "base-path-campaign",
|
|
name: "Campaign files",
|
|
path: getText(attachments, "base_path", "."),
|
|
allow_individual: getBool(attachments, "allow_individual", fallbackAllowIndividual),
|
|
unsent_warning: false
|
|
}];
|
|
}
|
|
|
|
export function ensureAttachmentBasePaths(paths: AttachmentBasePath[]): AttachmentBasePath[] {
|
|
return paths.length > 0 ? paths : [createAttachmentBasePath("Campaign files", ".")];
|
|
}
|
|
|
|
export function getIndividualAttachmentBasePaths(paths: AttachmentBasePath[]): AttachmentBasePath[] {
|
|
return paths.filter((basePath) => basePath.allow_individual);
|
|
}
|
|
|
|
export function attachmentRuleUsesBasePath(rule: AttachmentRule, basePath: AttachmentBasePath): boolean {
|
|
const basePathId = getText(rule, "base_path_id");
|
|
if (basePathId) return basePathId === basePath.id;
|
|
return getText(rule, "base_dir") === basePath.path;
|
|
}
|
|
|
|
export function countIndividualAttachmentRulesForBasePath(entriesValue: unknown, basePath: AttachmentBasePath): number {
|
|
const entries = asRecord(entriesValue);
|
|
return asArray(entries.inline)
|
|
.map(asRecord)
|
|
.flatMap((entry) => normalizeAttachmentRules(entry.attachments))
|
|
.filter((rule) => attachmentRuleUsesBasePath(rule, basePath))
|
|
.length;
|
|
}
|
|
|
|
export function removeIndividualAttachmentRulesForBasePath(entriesValue: unknown, basePath: AttachmentBasePath): Record<string, unknown> {
|
|
const entries = asRecord(entriesValue);
|
|
const inline = asArray(entries.inline).map(asRecord).map((entry) => ({
|
|
...entry,
|
|
attachments: normalizeAttachmentRules(entry.attachments).filter((rule) => !attachmentRuleUsesBasePath(rule, basePath))
|
|
}));
|
|
return { ...entries, inline };
|
|
}
|
|
|
|
|
|
export function nextAttachmentLabel(rules: AttachmentRule[]): string {
|
|
let highest = 0;
|
|
for (const rule of rules) {
|
|
const match = /^Attachment\s+(\d+)$/i.exec(getText(rule, "label").trim());
|
|
if (match) highest = Math.max(highest, Number(match[1]));
|
|
}
|
|
highest = Math.max(highest, rules.length);
|
|
return `Attachment ${highest + 1}`;
|
|
}
|
|
|
|
export function normalizeAttachmentRules(value: unknown): AttachmentRule[] {
|
|
if (!Array.isArray(value)) return [];
|
|
return value.filter(isRecord).map((rule) => ({
|
|
id: getText(rule, "id", `attachment-${Math.random().toString(36).slice(2)}`),
|
|
label: getText(rule, "label"),
|
|
base_path_id: getText(rule, "base_path_id"),
|
|
base_dir: getText(rule, "base_dir", ""),
|
|
file_filter: getText(rule, "file_filter") || getText(rule, "file") || getText(rule, "filename") || getText(rule, "path"),
|
|
include_subdirs: getBool(rule, "include_subdirs"),
|
|
required: getBool(rule, "required", true),
|
|
missing_behavior: getText(rule, "missing_behavior", "ask"),
|
|
ambiguous_behavior: getText(rule, "ambiguous_behavior", "ask"),
|
|
...(isRecord(rule.zip) ? { zip: rule.zip } : {})
|
|
}));
|
|
}
|
|
|
|
export function summarizeAttachmentRules(rules: AttachmentRule[]): AttachmentSummary {
|
|
return rules.reduce<AttachmentSummary>((summary, rule) => {
|
|
if (isDirectAttachmentRule(rule)) {
|
|
summary.direct += 1;
|
|
} else {
|
|
summary.rules += 1;
|
|
}
|
|
return summary;
|
|
}, { direct: 0, rules: 0 });
|
|
}
|
|
|
|
export function countIndividualAttachmentRules(entriesValue: unknown): number {
|
|
const entries = asRecord(entriesValue);
|
|
return asArray(entries.inline)
|
|
.map(asRecord)
|
|
.flatMap((entry) => asArray(entry.attachments))
|
|
.length;
|
|
}
|
|
|
|
export function isDirectAttachmentRule(rule: AttachmentRule): boolean {
|
|
const explicitType = getText(rule, "type");
|
|
if (explicitType === "direct") return true;
|
|
if (explicitType === "pattern") return false;
|
|
const fileFilter = getText(rule, "file_filter") || getText(rule, "file") || getText(rule, "filename") || getText(rule, "path");
|
|
if (!fileFilter) return false;
|
|
return !/[{}*?\[\]]/.test(fileFilter);
|
|
}
|