feat: add filename transform settings
This commit is contained in:
@@ -905,6 +905,24 @@ export function EmailViewer({
|
||||
const dragOutActive = useMemo(() => isDragOutSupported(), []);
|
||||
const emailDownloadTemplate = useSettingsStore((state) => state.emailDownloadTemplate) || DEFAULT_EMAIL_TEMPLATE;
|
||||
const attachmentDownloadTemplate = useSettingsStore((state) => state.attachmentDownloadTemplate) || DEFAULT_ATTACHMENT_TEMPLATE;
|
||||
const filenameSpaceReplacement = useSettingsStore((state) => state.filenameSpaceReplacement);
|
||||
const filenameLowercase = useSettingsStore((state) => state.filenameLowercase);
|
||||
const filenameStripDiacritics = useSettingsStore((state) => state.filenameStripDiacritics);
|
||||
const filenameCollapseSeparators = useSettingsStore((state) => state.filenameCollapseSeparators);
|
||||
const emailFilenameOptions = useMemo(() => ({
|
||||
template: emailDownloadTemplate,
|
||||
spaceReplacement: filenameSpaceReplacement,
|
||||
lowercase: filenameLowercase,
|
||||
stripDiacritics: filenameStripDiacritics,
|
||||
collapseSeparators: filenameCollapseSeparators,
|
||||
}), [emailDownloadTemplate, filenameSpaceReplacement, filenameLowercase, filenameStripDiacritics, filenameCollapseSeparators]);
|
||||
const attachmentFilenameOptions = useMemo(() => ({
|
||||
template: attachmentDownloadTemplate,
|
||||
spaceReplacement: filenameSpaceReplacement,
|
||||
lowercase: filenameLowercase,
|
||||
stripDiacritics: filenameStripDiacritics,
|
||||
collapseSeparators: filenameCollapseSeparators,
|
||||
}), [attachmentDownloadTemplate, filenameSpaceReplacement, filenameLowercase, filenameStripDiacritics, filenameCollapseSeparators]);
|
||||
const timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
|
||||
@@ -2569,9 +2587,9 @@ export function EmailViewer({
|
||||
(attachment: EffectiveAttachment) => {
|
||||
const fallback = attachment.name || 'download';
|
||||
if (!email) return fallback;
|
||||
return attachmentDownloadFilename(email, { name: attachment.name, type: attachment.type }, attachmentDownloadTemplate) || fallback;
|
||||
return attachmentDownloadFilename(email, { name: attachment.name, type: attachment.type }, attachmentFilenameOptions) || fallback;
|
||||
},
|
||||
[email, attachmentDownloadTemplate],
|
||||
[email, attachmentFilenameOptions],
|
||||
);
|
||||
|
||||
const handleEffectiveAttachmentOpen = useCallback(async (attachment: EffectiveAttachment) => {
|
||||
@@ -3072,7 +3090,7 @@ export function EmailViewer({
|
||||
const handleExportEmail = async () => {
|
||||
if (!email?.blobId || !client) return;
|
||||
try {
|
||||
await client.downloadBlob(email.blobId, emailExportFilename(email, emailDownloadTemplate), 'message/rfc822');
|
||||
await client.downloadBlob(email.blobId, emailExportFilename(email, emailFilenameOptions), 'message/rfc822');
|
||||
} catch {
|
||||
toast.error(tNotifications('export_email_error'));
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useMemo, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { SettingsSection } from "./settings-section";
|
||||
import { SettingsSection, SettingItem, Select, ToggleSwitch } from "./settings-section";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
emailExportFilename,
|
||||
attachmentDownloadFilename,
|
||||
buildSampleEmail,
|
||||
type EmailFilenameOptions,
|
||||
} from "@/lib/download-filename";
|
||||
|
||||
function insertTokenAtCursor(
|
||||
@@ -46,6 +47,7 @@ interface TemplateEditorProps {
|
||||
preview: string;
|
||||
onChange: (next: string) => void;
|
||||
resetLabel: string;
|
||||
previewLabel: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
@@ -58,6 +60,7 @@ function TemplateEditor({
|
||||
preview,
|
||||
onChange,
|
||||
resetLabel,
|
||||
previewLabel,
|
||||
placeholder,
|
||||
}: TemplateEditorProps) {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -114,7 +117,7 @@ function TemplateEditor({
|
||||
))}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<span className="opacity-70">Preview: </span>
|
||||
<span className="opacity-70">{previewLabel} </span>
|
||||
<span className="font-mono text-foreground/90 break-all">{preview}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -124,23 +127,45 @@ function TemplateEditor({
|
||||
|
||||
export function DownloadsSettings() {
|
||||
const t = useTranslations("settings.downloads");
|
||||
const { emailDownloadTemplate, attachmentDownloadTemplate, updateSetting } = useSettingsStore();
|
||||
const {
|
||||
emailDownloadTemplate,
|
||||
attachmentDownloadTemplate,
|
||||
filenameSpaceReplacement,
|
||||
filenameLowercase,
|
||||
filenameStripDiacritics,
|
||||
filenameCollapseSeparators,
|
||||
updateSetting,
|
||||
} = useSettingsStore();
|
||||
|
||||
const sampleEmail = useMemo(() => buildSampleEmail(), []);
|
||||
const sampleAttachment = useMemo(() => ({ name: "Invoice-2026-05.pdf", type: "application/pdf" }), []);
|
||||
|
||||
const transform = useMemo(
|
||||
() => ({
|
||||
spaceReplacement: filenameSpaceReplacement,
|
||||
lowercase: filenameLowercase,
|
||||
stripDiacritics: filenameStripDiacritics,
|
||||
collapseSeparators: filenameCollapseSeparators,
|
||||
}),
|
||||
[filenameSpaceReplacement, filenameLowercase, filenameStripDiacritics, filenameCollapseSeparators],
|
||||
);
|
||||
|
||||
const emailOptions: EmailFilenameOptions = useMemo(
|
||||
() => ({ ...transform, template: emailDownloadTemplate || DEFAULT_EMAIL_TEMPLATE }),
|
||||
[transform, emailDownloadTemplate],
|
||||
);
|
||||
const attachmentOptions: EmailFilenameOptions = useMemo(
|
||||
() => ({ ...transform, template: attachmentDownloadTemplate || DEFAULT_ATTACHMENT_TEMPLATE }),
|
||||
[transform, attachmentDownloadTemplate],
|
||||
);
|
||||
|
||||
const emlPreview = useMemo(
|
||||
() => emailExportFilename(sampleEmail, emailDownloadTemplate || DEFAULT_EMAIL_TEMPLATE),
|
||||
[sampleEmail, emailDownloadTemplate],
|
||||
() => emailExportFilename(sampleEmail, emailOptions),
|
||||
[sampleEmail, emailOptions],
|
||||
);
|
||||
const attachmentPreview = useMemo(
|
||||
() =>
|
||||
attachmentDownloadFilename(
|
||||
sampleEmail,
|
||||
sampleAttachment,
|
||||
attachmentDownloadTemplate || DEFAULT_ATTACHMENT_TEMPLATE,
|
||||
),
|
||||
[sampleEmail, sampleAttachment, attachmentDownloadTemplate],
|
||||
() => attachmentDownloadFilename(sampleEmail, sampleAttachment, attachmentOptions),
|
||||
[sampleEmail, sampleAttachment, attachmentOptions],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -154,6 +179,7 @@ export function DownloadsSettings() {
|
||||
preview={emlPreview}
|
||||
onChange={(next) => updateSetting("emailDownloadTemplate", next)}
|
||||
resetLabel={t("reset")}
|
||||
previewLabel={t("preview")}
|
||||
placeholder={DEFAULT_EMAIL_TEMPLATE}
|
||||
/>
|
||||
<TemplateEditor
|
||||
@@ -165,8 +191,38 @@ export function DownloadsSettings() {
|
||||
preview={attachmentPreview}
|
||||
onChange={(next) => updateSetting("attachmentDownloadTemplate", next)}
|
||||
resetLabel={t("reset")}
|
||||
previewLabel={t("preview")}
|
||||
placeholder={DEFAULT_ATTACHMENT_TEMPLATE}
|
||||
/>
|
||||
<SettingItem label={t("spaces.label")} description={t("spaces.description")}>
|
||||
<Select
|
||||
value={filenameSpaceReplacement}
|
||||
onChange={(value) => updateSetting("filenameSpaceReplacement", value as "keep" | "underscore" | "dash")}
|
||||
options={[
|
||||
{ value: "keep", label: t("spaces.keep") },
|
||||
{ value: "underscore", label: t("spaces.underscore") },
|
||||
{ value: "dash", label: t("spaces.dash") },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label={t("lowercase.label")} description={t("lowercase.description")}>
|
||||
<ToggleSwitch
|
||||
checked={filenameLowercase}
|
||||
onChange={(checked) => updateSetting("filenameLowercase", checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label={t("strip_diacritics.label")} description={t("strip_diacritics.description")}>
|
||||
<ToggleSwitch
|
||||
checked={filenameStripDiacritics}
|
||||
onChange={(checked) => updateSetting("filenameStripDiacritics", checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem label={t("collapse_separators.label")} description={t("collapse_separators.description")}>
|
||||
<ToggleSwitch
|
||||
checked={filenameCollapseSeparators}
|
||||
onChange={(checked) => updateSetting("filenameCollapseSeparators", checked)}
|
||||
/>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
+22
-14
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, DragEvent } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, DragEvent } from "react";
|
||||
import { Email } from "@/lib/jmap/types";
|
||||
import { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
@@ -8,7 +8,7 @@ import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useDragDropContext } from "@/contexts/drag-drop-context";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { isDragOutSupported } from "@/hooks/use-attachment-drag";
|
||||
import { emailExportFilename, DEFAULT_EMAIL_TEMPLATE } from "@/lib/download-filename";
|
||||
import { emailExportFilename, DEFAULT_EMAIL_TEMPLATE, type EmailFilenameOptions } from "@/lib/download-filename";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
|
||||
interface UseEmailDragOptions {
|
||||
@@ -71,7 +71,7 @@ function selectionKey(ids: string[]): string {
|
||||
return [...ids].sort().join(",");
|
||||
}
|
||||
|
||||
async function buildEmailZip(client: IJMAPClient, emails: Email[], template: string): Promise<string | null> {
|
||||
async function buildEmailZip(client: IJMAPClient, emails: Email[], options: EmailFilenameOptions): Promise<string | null> {
|
||||
const eligible = emails.filter((em) => !!em.blobId);
|
||||
if (eligible.length === 0) return null;
|
||||
const { default: JSZip } = await import("jszip");
|
||||
@@ -79,7 +79,7 @@ async function buildEmailZip(client: IJMAPClient, emails: Email[], template: str
|
||||
const used = new Set<string>();
|
||||
await Promise.all(
|
||||
eligible.map(async (em) => {
|
||||
const base = emailExportFilename(em, template).replace(/\.eml$/, "");
|
||||
const base = emailExportFilename(em, options).replace(/\.eml$/, "");
|
||||
let name = `${base}.eml`;
|
||||
while (used.has(name)) name = `${base} [${em.id.slice(0, 6)}].eml`;
|
||||
used.add(name);
|
||||
@@ -95,7 +95,7 @@ async function buildEmailZip(client: IJMAPClient, emails: Email[], template: str
|
||||
return URL.createObjectURL(zipBlob);
|
||||
}
|
||||
|
||||
function prefetchEmailBundle(client: IJMAPClient, emails: Email[], template: string): void {
|
||||
function prefetchEmailBundle(client: IJMAPClient, emails: Email[], options: EmailFilenameOptions): void {
|
||||
const key = selectionKey(emails.map((e) => e.id));
|
||||
if (currentBundle && currentBundle.key === key) return;
|
||||
if (currentBundle?.url) {
|
||||
@@ -108,7 +108,7 @@ function prefetchEmailBundle(client: IJMAPClient, emails: Email[], template: str
|
||||
url: null,
|
||||
promise: null,
|
||||
};
|
||||
entry.promise = buildEmailZip(client, emails, template)
|
||||
entry.promise = buildEmailZip(client, emails, options)
|
||||
.then((url) => {
|
||||
if (url && currentBundle === entry) entry.url = url;
|
||||
return url;
|
||||
@@ -130,7 +130,15 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
|
||||
const { startDrag, endDrag, isDragging, draggedEmails } = useDragDropContext();
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const client = useAuthStore((state) => state.client);
|
||||
const emailTemplate = useSettingsStore((s) => s.emailDownloadTemplate) || DEFAULT_EMAIL_TEMPLATE;
|
||||
const template = useSettingsStore((s) => s.emailDownloadTemplate) || DEFAULT_EMAIL_TEMPLATE;
|
||||
const spaceReplacement = useSettingsStore((s) => s.filenameSpaceReplacement);
|
||||
const lowercase = useSettingsStore((s) => s.filenameLowercase);
|
||||
const stripDiacritics = useSettingsStore((s) => s.filenameStripDiacritics);
|
||||
const collapseSeparators = useSettingsStore((s) => s.filenameCollapseSeparators);
|
||||
const filenameOptions: EmailFilenameOptions = useMemo(
|
||||
() => ({ template, spaceReplacement, lowercase, stripDiacritics, collapseSeparators }),
|
||||
[template, spaceReplacement, lowercase, stripDiacritics, collapseSeparators],
|
||||
);
|
||||
|
||||
const dragOutEnabled = !isMobile && isDragOutSupported() && !!client;
|
||||
const singleBlobUrlRef = useRef<string | null>(null);
|
||||
@@ -152,7 +160,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
|
||||
const prefetchSingle = useCallback(() => {
|
||||
if (!dragOutEnabled || !client || !email.blobId) return;
|
||||
if (singleBlobUrlRef.current || inFlightRef.current) return;
|
||||
const name = emailExportFilename(email, emailTemplate);
|
||||
const name = emailExportFilename(email, filenameOptions);
|
||||
inFlightRef.current = client
|
||||
.fetchBlobAsObjectUrl(email.blobId, name, "message/rfc822")
|
||||
.then((url) => {
|
||||
@@ -163,7 +171,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
|
||||
.finally(() => {
|
||||
inFlightRef.current = null;
|
||||
});
|
||||
}, [dragOutEnabled, client, email, emailTemplate]);
|
||||
}, [dragOutEnabled, client, email, filenameOptions]);
|
||||
|
||||
const handlePointerEnter = useCallback(() => {
|
||||
if (!dragOutEnabled || !client) return;
|
||||
@@ -173,12 +181,12 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
|
||||
const selected = emails.filter((em) => selectedEmailIds.has(em.id));
|
||||
// Only worth bundling when at least one selected email has a blobId.
|
||||
if (selected.some((em) => em.blobId)) {
|
||||
prefetchEmailBundle(client, selected, emailTemplate);
|
||||
prefetchEmailBundle(client, selected, filenameOptions);
|
||||
}
|
||||
} else {
|
||||
prefetchSingle();
|
||||
}
|
||||
}, [dragOutEnabled, client, selectedEmailIds, email.id, emails, prefetchSingle, emailTemplate]);
|
||||
}, [dragOutEnabled, client, selectedEmailIds, email.id, emails, prefetchSingle, filenameOptions]);
|
||||
|
||||
const handleDragStart = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||
// Determine which emails to drag:
|
||||
@@ -205,7 +213,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
|
||||
if (emailsToDrag.length === 1 && emailsToDrag[0].blobId) {
|
||||
const url = singleBlobUrlRef.current;
|
||||
if (url) {
|
||||
const name = emailExportFilename(emailsToDrag[0], emailTemplate);
|
||||
const name = emailExportFilename(emailsToDrag[0], filenameOptions);
|
||||
// `DownloadURL` format: <mime>:<filename>:<url>. Chromium expects
|
||||
// the filename raw - URL-encoding it ends up literally on disk
|
||||
// (e.g. `%20` instead of a space). The sanitiser already removed
|
||||
@@ -229,7 +237,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
|
||||
);
|
||||
} else {
|
||||
// Kick off the bundle build for the next attempt.
|
||||
prefetchEmailBundle(client, emailsToDrag, emailTemplate);
|
||||
prefetchEmailBundle(client, emailsToDrag, filenameOptions);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -244,7 +252,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
|
||||
});
|
||||
|
||||
startDrag(emailsToDrag, sourceMailboxId);
|
||||
}, [email, selectedEmailIds, emails, sourceMailboxId, startDrag, threadEmails, dragOutEnabled, client, prefetchSingle, emailTemplate]);
|
||||
}, [email, selectedEmailIds, emails, sourceMailboxId, startDrag, threadEmails, dragOutEnabled, client, prefetchSingle, filenameOptions]);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
endDrag();
|
||||
|
||||
+60
-23
@@ -7,6 +7,26 @@ import type { Email } from "@/lib/jmap/types";
|
||||
// non-ASCII scripts.
|
||||
const SAFE_CHARS = /[^\p{L}\p{N} _\-().,!@#&+=[\]{}']/gu;
|
||||
|
||||
export type SpaceReplacement = "keep" | "underscore" | "dash";
|
||||
|
||||
export interface FilenameTransformOptions {
|
||||
spaceReplacement?: SpaceReplacement;
|
||||
lowercase?: boolean;
|
||||
stripDiacritics?: boolean;
|
||||
collapseSeparators?: boolean;
|
||||
}
|
||||
|
||||
export interface EmailFilenameOptions extends FilenameTransformOptions {
|
||||
template?: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_TRANSFORM: Required<FilenameTransformOptions> = {
|
||||
spaceReplacement: "keep",
|
||||
lowercase: false,
|
||||
stripDiacritics: false,
|
||||
collapseSeparators: true,
|
||||
};
|
||||
|
||||
export const DEFAULT_EMAIL_TEMPLATE = "{date} ({from}-{to}) {subject}";
|
||||
export const DEFAULT_ATTACHMENT_TEMPLATE = "{filename}";
|
||||
|
||||
@@ -43,6 +63,24 @@ function sanitizePart(input: string, maxLen = 80): string {
|
||||
return cleaned.slice(0, maxLen);
|
||||
}
|
||||
|
||||
function applyTransforms(input: string, opts: FilenameTransformOptions): string {
|
||||
let s = input;
|
||||
if (opts.stripDiacritics) {
|
||||
// NFD splits "ä" into "a" + U+0308 (combining diaeresis); stripping all
|
||||
// combining marks then leaves plain ASCII letters. `ß` has no
|
||||
// decomposition so it survives as-is.
|
||||
s = s.normalize("NFD").replace(/\p{M}+/gu, "");
|
||||
}
|
||||
const repl = opts.spaceReplacement ?? "keep";
|
||||
if (repl === "underscore") s = s.replace(/ +/g, "_");
|
||||
else if (repl === "dash") s = s.replace(/ +/g, "-");
|
||||
if (opts.collapseSeparators ?? true) {
|
||||
s = s.replace(/_+/g, "_").replace(/-+/g, "-").replace(/ +/g, " ");
|
||||
}
|
||||
if (opts.lowercase) s = s.toLocaleLowerCase();
|
||||
return s.replace(/^[._\- ]+|[._\- ]+$/g, "");
|
||||
}
|
||||
|
||||
function pad2(n: number): string {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
@@ -120,43 +158,38 @@ export function attachmentVars(email: Email, attachment: AttachmentLike): Record
|
||||
};
|
||||
}
|
||||
|
||||
// Render a template by substituting `{key}` occurrences. Each substituted
|
||||
// value is sanitized to safe ASCII filename characters; the rest of the
|
||||
// template (literal text) is sanitized as a whole at the end so the template
|
||||
// itself can't introduce path separators or control chars.
|
||||
export function renderTemplate(
|
||||
template: string,
|
||||
vars: Record<string, string>,
|
||||
fallback: string,
|
||||
): string {
|
||||
const rendered = template.replace(/\{(\w+)\}/g, (_, key: string) => {
|
||||
function renderRaw(template: string, vars: Record<string, string>): string {
|
||||
return template.replace(/\{(\w+)\}/g, (_, key: string) => {
|
||||
const value = vars[key];
|
||||
if (value === undefined) return "";
|
||||
return sanitizePart(value);
|
||||
});
|
||||
const cleaned = sanitizePart(rendered, 200);
|
||||
return cleaned || fallback;
|
||||
}
|
||||
|
||||
export function emailExportFilename(
|
||||
email: Email,
|
||||
template: string = DEFAULT_EMAIL_TEMPLATE,
|
||||
options: EmailFilenameOptions | string = {},
|
||||
): string {
|
||||
const stem = renderTemplate(template, emailVars(email), "email");
|
||||
const opts = typeof options === "string" ? { template: options } : options;
|
||||
const template = opts.template ?? DEFAULT_EMAIL_TEMPLATE;
|
||||
const rendered = renderRaw(template, emailVars(email));
|
||||
const cleaned = sanitizePart(rendered, 200);
|
||||
const transformed = applyTransforms(cleaned, opts);
|
||||
const stem = transformed.slice(0, 200) || "email";
|
||||
return `${stem}.eml`;
|
||||
}
|
||||
|
||||
export function attachmentDownloadFilename(
|
||||
email: Email | null | undefined,
|
||||
attachment: AttachmentLike,
|
||||
template: string = DEFAULT_ATTACHMENT_TEMPLATE,
|
||||
options: EmailFilenameOptions | string = {},
|
||||
): string {
|
||||
// No email context (rare - e.g. compose attachments) means we can only
|
||||
// honour the {filename}/{name}/{ext} subset, so fall back to the raw name
|
||||
// when the template needs email data and we don't have it.
|
||||
const opts = typeof options === "string" ? { template: options } : options;
|
||||
const template = opts.template ?? DEFAULT_ATTACHMENT_TEMPLATE;
|
||||
if (!email) {
|
||||
const filename = (attachment.name || "attachment").trim();
|
||||
return sanitizePart(filename, 200) || "attachment";
|
||||
const cleaned = sanitizePart(filename, 200) || "attachment";
|
||||
return applyTransforms(cleaned, opts) || cleaned;
|
||||
}
|
||||
const vars = attachmentVars(email, attachment);
|
||||
const rendered = template.replace(/\{(\w+)\}/g, (_, key: string) => {
|
||||
@@ -166,12 +199,16 @@ export function attachmentDownloadFilename(
|
||||
// sanitiser (it strips trailing dots otherwise).
|
||||
return key === "filename" ? value.replace(SAFE_CHARS, "_") : sanitizePart(value);
|
||||
});
|
||||
// Preserve the original extension when the template doesn't reference it.
|
||||
const templateMentionsExt = /\{(ext|filename)\}/.test(template);
|
||||
const cleaned = sanitizePart(rendered, 200) || "attachment";
|
||||
if (templateMentionsExt) return cleaned;
|
||||
if (templateMentionsExt) {
|
||||
return applyTransforms(cleaned, opts) || cleaned;
|
||||
}
|
||||
const transformedStem = applyTransforms(cleaned, opts) || cleaned;
|
||||
const ext = vars.ext;
|
||||
return ext ? `${cleaned}.${ext}` : cleaned;
|
||||
if (!ext) return transformedStem;
|
||||
const transformedExt = opts.lowercase ? ext.toLocaleLowerCase() : ext;
|
||||
return `${transformedStem}.${transformedExt}`;
|
||||
}
|
||||
|
||||
// Build a synthetic email for previewing templates in the settings UI.
|
||||
@@ -189,7 +226,7 @@ export function buildSampleEmail(): Email {
|
||||
from: [{ name: "Alice Sender", email: "alice@example.com" }],
|
||||
to: [{ name: "Bob Recipient", email: "bob@example.com" }],
|
||||
cc: [],
|
||||
subject: "Quarterly report draft",
|
||||
subject: "Benachrichtigung von Ihrem Gerät",
|
||||
preview: "",
|
||||
hasAttachment: true,
|
||||
blobId: "sample-blob",
|
||||
|
||||
@@ -1503,6 +1503,7 @@
|
||||
"title": "Downloads",
|
||||
"description": "Customize how downloaded emails and attachments are named.",
|
||||
"reset": "Restore default",
|
||||
"preview": "Preview:",
|
||||
"email_template": {
|
||||
"label": "Email (.eml) filename",
|
||||
"description": "Template used when you export an email or drag one out to the file system. The .eml extension is added automatically."
|
||||
@@ -1510,6 +1511,25 @@
|
||||
"attachment_template": {
|
||||
"label": "Attachment filename",
|
||||
"description": "Template used when downloading or dragging out an attachment. If you omit {filename} and {ext}, the original extension is preserved."
|
||||
},
|
||||
"spaces": {
|
||||
"label": "Spaces",
|
||||
"description": "Replace spaces in the resulting filename with another character.",
|
||||
"keep": "Keep spaces",
|
||||
"underscore": "Replace with _",
|
||||
"dash": "Replace with -"
|
||||
},
|
||||
"lowercase": {
|
||||
"label": "Lowercase",
|
||||
"description": "Force the entire filename to lowercase."
|
||||
},
|
||||
"strip_diacritics": {
|
||||
"label": "Strip diacritics",
|
||||
"description": "Convert accented letters to their ASCII equivalents (ä → a, é → e). Useful for tools that mangle Unicode filenames."
|
||||
},
|
||||
"collapse_separators": {
|
||||
"label": "Collapse repeated separators",
|
||||
"description": "Collapse runs of spaces, underscores, or dashes to a single character."
|
||||
}
|
||||
},
|
||||
"filters": {
|
||||
|
||||
@@ -242,6 +242,10 @@ interface SettingsState {
|
||||
// Downloads
|
||||
emailDownloadTemplate: string;
|
||||
attachmentDownloadTemplate: string;
|
||||
filenameSpaceReplacement: 'keep' | 'underscore' | 'dash';
|
||||
filenameLowercase: boolean;
|
||||
filenameStripDiacritics: boolean;
|
||||
filenameCollapseSeparators: boolean;
|
||||
|
||||
// Advanced
|
||||
debugMode: boolean;
|
||||
@@ -431,6 +435,10 @@ const DEFAULT_SETTINGS = {
|
||||
// Downloads
|
||||
emailDownloadTemplate: '{date} ({from}-{to}) {subject}',
|
||||
attachmentDownloadTemplate: '{filename}',
|
||||
filenameSpaceReplacement: 'keep' as 'keep' | 'underscore' | 'dash',
|
||||
filenameLowercase: false,
|
||||
filenameStripDiacritics: false,
|
||||
filenameCollapseSeparators: true,
|
||||
|
||||
// Advanced
|
||||
debugMode: false,
|
||||
|
||||
Reference in New Issue
Block a user