Files
govoplan-campaign/webui/src/features/campaigns/utils/attachments.ts
2026-07-02 14:59:52 +02:00

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);
}