initial commit after split
This commit is contained in:
273
webui/src/features/campaigns/utils/attachments.ts
Normal file
273
webui/src/features/campaigns/utils/attachments.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import type { FileSpace } from "../../../api/files";
|
||||
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;
|
||||
};
|
||||
|
||||
const MANAGED_ATTACHMENT_SOURCE_PREFIX = "managed:";
|
||||
|
||||
export function encodeManagedAttachmentSource(space: Pick<FileSpace, "owner_type" | "owner_id">): 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);
|
||||
}
|
||||
Reference in New Issue
Block a user