feat: add filename transform settings
This commit is contained in:
@@ -905,6 +905,24 @@ export function EmailViewer({
|
|||||||
const dragOutActive = useMemo(() => isDragOutSupported(), []);
|
const dragOutActive = useMemo(() => isDragOutSupported(), []);
|
||||||
const emailDownloadTemplate = useSettingsStore((state) => state.emailDownloadTemplate) || DEFAULT_EMAIL_TEMPLATE;
|
const emailDownloadTemplate = useSettingsStore((state) => state.emailDownloadTemplate) || DEFAULT_EMAIL_TEMPLATE;
|
||||||
const attachmentDownloadTemplate = useSettingsStore((state) => state.attachmentDownloadTemplate) || DEFAULT_ATTACHMENT_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 timeFormat = useSettingsStore((state) => state.timeFormat);
|
||||||
const isFocusedMailLayout = mailLayout === 'focus';
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
|
|
||||||
@@ -2569,9 +2587,9 @@ export function EmailViewer({
|
|||||||
(attachment: EffectiveAttachment) => {
|
(attachment: EffectiveAttachment) => {
|
||||||
const fallback = attachment.name || 'download';
|
const fallback = attachment.name || 'download';
|
||||||
if (!email) return fallback;
|
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) => {
|
const handleEffectiveAttachmentOpen = useCallback(async (attachment: EffectiveAttachment) => {
|
||||||
@@ -3072,7 +3090,7 @@ export function EmailViewer({
|
|||||||
const handleExportEmail = async () => {
|
const handleExportEmail = async () => {
|
||||||
if (!email?.blobId || !client) return;
|
if (!email?.blobId || !client) return;
|
||||||
try {
|
try {
|
||||||
await client.downloadBlob(email.blobId, emailExportFilename(email, emailDownloadTemplate), 'message/rfc822');
|
await client.downloadBlob(email.blobId, emailExportFilename(email, emailFilenameOptions), 'message/rfc822');
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(tNotifications('export_email_error'));
|
toast.error(tNotifications('export_email_error'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useMemo, useRef } from "react";
|
import { useMemo, useRef } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
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 { RotateCcw } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
emailExportFilename,
|
emailExportFilename,
|
||||||
attachmentDownloadFilename,
|
attachmentDownloadFilename,
|
||||||
buildSampleEmail,
|
buildSampleEmail,
|
||||||
|
type EmailFilenameOptions,
|
||||||
} from "@/lib/download-filename";
|
} from "@/lib/download-filename";
|
||||||
|
|
||||||
function insertTokenAtCursor(
|
function insertTokenAtCursor(
|
||||||
@@ -46,6 +47,7 @@ interface TemplateEditorProps {
|
|||||||
preview: string;
|
preview: string;
|
||||||
onChange: (next: string) => void;
|
onChange: (next: string) => void;
|
||||||
resetLabel: string;
|
resetLabel: string;
|
||||||
|
previewLabel: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,6 +60,7 @@ function TemplateEditor({
|
|||||||
preview,
|
preview,
|
||||||
onChange,
|
onChange,
|
||||||
resetLabel,
|
resetLabel,
|
||||||
|
previewLabel,
|
||||||
placeholder,
|
placeholder,
|
||||||
}: TemplateEditorProps) {
|
}: TemplateEditorProps) {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
@@ -114,7 +117,7 @@ function TemplateEditor({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<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>
|
<span className="font-mono text-foreground/90 break-all">{preview}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -124,23 +127,45 @@ function TemplateEditor({
|
|||||||
|
|
||||||
export function DownloadsSettings() {
|
export function DownloadsSettings() {
|
||||||
const t = useTranslations("settings.downloads");
|
const t = useTranslations("settings.downloads");
|
||||||
const { emailDownloadTemplate, attachmentDownloadTemplate, updateSetting } = useSettingsStore();
|
const {
|
||||||
|
emailDownloadTemplate,
|
||||||
|
attachmentDownloadTemplate,
|
||||||
|
filenameSpaceReplacement,
|
||||||
|
filenameLowercase,
|
||||||
|
filenameStripDiacritics,
|
||||||
|
filenameCollapseSeparators,
|
||||||
|
updateSetting,
|
||||||
|
} = useSettingsStore();
|
||||||
|
|
||||||
const sampleEmail = useMemo(() => buildSampleEmail(), []);
|
const sampleEmail = useMemo(() => buildSampleEmail(), []);
|
||||||
const sampleAttachment = useMemo(() => ({ name: "Invoice-2026-05.pdf", type: "application/pdf" }), []);
|
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(
|
const emlPreview = useMemo(
|
||||||
() => emailExportFilename(sampleEmail, emailDownloadTemplate || DEFAULT_EMAIL_TEMPLATE),
|
() => emailExportFilename(sampleEmail, emailOptions),
|
||||||
[sampleEmail, emailDownloadTemplate],
|
[sampleEmail, emailOptions],
|
||||||
);
|
);
|
||||||
const attachmentPreview = useMemo(
|
const attachmentPreview = useMemo(
|
||||||
() =>
|
() => attachmentDownloadFilename(sampleEmail, sampleAttachment, attachmentOptions),
|
||||||
attachmentDownloadFilename(
|
[sampleEmail, sampleAttachment, attachmentOptions],
|
||||||
sampleEmail,
|
|
||||||
sampleAttachment,
|
|
||||||
attachmentDownloadTemplate || DEFAULT_ATTACHMENT_TEMPLATE,
|
|
||||||
),
|
|
||||||
[sampleEmail, sampleAttachment, attachmentDownloadTemplate],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -154,6 +179,7 @@ export function DownloadsSettings() {
|
|||||||
preview={emlPreview}
|
preview={emlPreview}
|
||||||
onChange={(next) => updateSetting("emailDownloadTemplate", next)}
|
onChange={(next) => updateSetting("emailDownloadTemplate", next)}
|
||||||
resetLabel={t("reset")}
|
resetLabel={t("reset")}
|
||||||
|
previewLabel={t("preview")}
|
||||||
placeholder={DEFAULT_EMAIL_TEMPLATE}
|
placeholder={DEFAULT_EMAIL_TEMPLATE}
|
||||||
/>
|
/>
|
||||||
<TemplateEditor
|
<TemplateEditor
|
||||||
@@ -165,8 +191,38 @@ export function DownloadsSettings() {
|
|||||||
preview={attachmentPreview}
|
preview={attachmentPreview}
|
||||||
onChange={(next) => updateSetting("attachmentDownloadTemplate", next)}
|
onChange={(next) => updateSetting("attachmentDownloadTemplate", next)}
|
||||||
resetLabel={t("reset")}
|
resetLabel={t("reset")}
|
||||||
|
previewLabel={t("preview")}
|
||||||
placeholder={DEFAULT_ATTACHMENT_TEMPLATE}
|
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>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-14
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef, DragEvent } from "react";
|
import { useCallback, useEffect, useMemo, useRef, DragEvent } from "react";
|
||||||
import { Email } from "@/lib/jmap/types";
|
import { Email } from "@/lib/jmap/types";
|
||||||
import { IJMAPClient } from "@/lib/jmap/client-interface";
|
import { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||||
import { useEmailStore } from "@/stores/email-store";
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
@@ -8,7 +8,7 @@ import { useAuthStore } from "@/stores/auth-store";
|
|||||||
import { useDragDropContext } from "@/contexts/drag-drop-context";
|
import { useDragDropContext } from "@/contexts/drag-drop-context";
|
||||||
import { useUIStore } from "@/stores/ui-store";
|
import { useUIStore } from "@/stores/ui-store";
|
||||||
import { isDragOutSupported } from "@/hooks/use-attachment-drag";
|
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";
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
|
||||||
interface UseEmailDragOptions {
|
interface UseEmailDragOptions {
|
||||||
@@ -71,7 +71,7 @@ function selectionKey(ids: string[]): string {
|
|||||||
return [...ids].sort().join(",");
|
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);
|
const eligible = emails.filter((em) => !!em.blobId);
|
||||||
if (eligible.length === 0) return null;
|
if (eligible.length === 0) return null;
|
||||||
const { default: JSZip } = await import("jszip");
|
const { default: JSZip } = await import("jszip");
|
||||||
@@ -79,7 +79,7 @@ async function buildEmailZip(client: IJMAPClient, emails: Email[], template: str
|
|||||||
const used = new Set<string>();
|
const used = new Set<string>();
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
eligible.map(async (em) => {
|
eligible.map(async (em) => {
|
||||||
const base = emailExportFilename(em, template).replace(/\.eml$/, "");
|
const base = emailExportFilename(em, options).replace(/\.eml$/, "");
|
||||||
let name = `${base}.eml`;
|
let name = `${base}.eml`;
|
||||||
while (used.has(name)) name = `${base} [${em.id.slice(0, 6)}].eml`;
|
while (used.has(name)) name = `${base} [${em.id.slice(0, 6)}].eml`;
|
||||||
used.add(name);
|
used.add(name);
|
||||||
@@ -95,7 +95,7 @@ async function buildEmailZip(client: IJMAPClient, emails: Email[], template: str
|
|||||||
return URL.createObjectURL(zipBlob);
|
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));
|
const key = selectionKey(emails.map((e) => e.id));
|
||||||
if (currentBundle && currentBundle.key === key) return;
|
if (currentBundle && currentBundle.key === key) return;
|
||||||
if (currentBundle?.url) {
|
if (currentBundle?.url) {
|
||||||
@@ -108,7 +108,7 @@ function prefetchEmailBundle(client: IJMAPClient, emails: Email[], template: str
|
|||||||
url: null,
|
url: null,
|
||||||
promise: null,
|
promise: null,
|
||||||
};
|
};
|
||||||
entry.promise = buildEmailZip(client, emails, template)
|
entry.promise = buildEmailZip(client, emails, options)
|
||||||
.then((url) => {
|
.then((url) => {
|
||||||
if (url && currentBundle === entry) entry.url = url;
|
if (url && currentBundle === entry) entry.url = url;
|
||||||
return url;
|
return url;
|
||||||
@@ -130,7 +130,15 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
|
|||||||
const { startDrag, endDrag, isDragging, draggedEmails } = useDragDropContext();
|
const { startDrag, endDrag, isDragging, draggedEmails } = useDragDropContext();
|
||||||
const isMobile = useUIStore((state) => state.isMobile);
|
const isMobile = useUIStore((state) => state.isMobile);
|
||||||
const client = useAuthStore((state) => state.client);
|
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 dragOutEnabled = !isMobile && isDragOutSupported() && !!client;
|
||||||
const singleBlobUrlRef = useRef<string | null>(null);
|
const singleBlobUrlRef = useRef<string | null>(null);
|
||||||
@@ -152,7 +160,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
|
|||||||
const prefetchSingle = useCallback(() => {
|
const prefetchSingle = useCallback(() => {
|
||||||
if (!dragOutEnabled || !client || !email.blobId) return;
|
if (!dragOutEnabled || !client || !email.blobId) return;
|
||||||
if (singleBlobUrlRef.current || inFlightRef.current) return;
|
if (singleBlobUrlRef.current || inFlightRef.current) return;
|
||||||
const name = emailExportFilename(email, emailTemplate);
|
const name = emailExportFilename(email, filenameOptions);
|
||||||
inFlightRef.current = client
|
inFlightRef.current = client
|
||||||
.fetchBlobAsObjectUrl(email.blobId, name, "message/rfc822")
|
.fetchBlobAsObjectUrl(email.blobId, name, "message/rfc822")
|
||||||
.then((url) => {
|
.then((url) => {
|
||||||
@@ -163,7 +171,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
|
|||||||
.finally(() => {
|
.finally(() => {
|
||||||
inFlightRef.current = null;
|
inFlightRef.current = null;
|
||||||
});
|
});
|
||||||
}, [dragOutEnabled, client, email, emailTemplate]);
|
}, [dragOutEnabled, client, email, filenameOptions]);
|
||||||
|
|
||||||
const handlePointerEnter = useCallback(() => {
|
const handlePointerEnter = useCallback(() => {
|
||||||
if (!dragOutEnabled || !client) return;
|
if (!dragOutEnabled || !client) return;
|
||||||
@@ -173,12 +181,12 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
|
|||||||
const selected = emails.filter((em) => selectedEmailIds.has(em.id));
|
const selected = emails.filter((em) => selectedEmailIds.has(em.id));
|
||||||
// Only worth bundling when at least one selected email has a blobId.
|
// Only worth bundling when at least one selected email has a blobId.
|
||||||
if (selected.some((em) => em.blobId)) {
|
if (selected.some((em) => em.blobId)) {
|
||||||
prefetchEmailBundle(client, selected, emailTemplate);
|
prefetchEmailBundle(client, selected, filenameOptions);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
prefetchSingle();
|
prefetchSingle();
|
||||||
}
|
}
|
||||||
}, [dragOutEnabled, client, selectedEmailIds, email.id, emails, prefetchSingle, emailTemplate]);
|
}, [dragOutEnabled, client, selectedEmailIds, email.id, emails, prefetchSingle, filenameOptions]);
|
||||||
|
|
||||||
const handleDragStart = useCallback((e: DragEvent<HTMLDivElement>) => {
|
const handleDragStart = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||||
// Determine which emails to drag:
|
// Determine which emails to drag:
|
||||||
@@ -205,7 +213,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
|
|||||||
if (emailsToDrag.length === 1 && emailsToDrag[0].blobId) {
|
if (emailsToDrag.length === 1 && emailsToDrag[0].blobId) {
|
||||||
const url = singleBlobUrlRef.current;
|
const url = singleBlobUrlRef.current;
|
||||||
if (url) {
|
if (url) {
|
||||||
const name = emailExportFilename(emailsToDrag[0], emailTemplate);
|
const name = emailExportFilename(emailsToDrag[0], filenameOptions);
|
||||||
// `DownloadURL` format: <mime>:<filename>:<url>. Chromium expects
|
// `DownloadURL` format: <mime>:<filename>:<url>. Chromium expects
|
||||||
// the filename raw - URL-encoding it ends up literally on disk
|
// the filename raw - URL-encoding it ends up literally on disk
|
||||||
// (e.g. `%20` instead of a space). The sanitiser already removed
|
// (e.g. `%20` instead of a space). The sanitiser already removed
|
||||||
@@ -229,7 +237,7 @@ export function useEmailDrag({ email, sourceMailboxId, threadEmails }: UseEmailD
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Kick off the bundle build for the next attempt.
|
// 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);
|
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(() => {
|
const handleDragEnd = useCallback(() => {
|
||||||
endDrag();
|
endDrag();
|
||||||
|
|||||||
+60
-23
@@ -7,6 +7,26 @@ import type { Email } from "@/lib/jmap/types";
|
|||||||
// non-ASCII scripts.
|
// non-ASCII scripts.
|
||||||
const SAFE_CHARS = /[^\p{L}\p{N} _\-().,!@#&+=[\]{}']/gu;
|
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_EMAIL_TEMPLATE = "{date} ({from}-{to}) {subject}";
|
||||||
export const DEFAULT_ATTACHMENT_TEMPLATE = "{filename}";
|
export const DEFAULT_ATTACHMENT_TEMPLATE = "{filename}";
|
||||||
|
|
||||||
@@ -43,6 +63,24 @@ function sanitizePart(input: string, maxLen = 80): string {
|
|||||||
return cleaned.slice(0, maxLen);
|
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 {
|
function pad2(n: number): string {
|
||||||
return String(n).padStart(2, "0");
|
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
|
function renderRaw(template: string, vars: Record<string, string>): string {
|
||||||
// value is sanitized to safe ASCII filename characters; the rest of the
|
return template.replace(/\{(\w+)\}/g, (_, key: string) => {
|
||||||
// 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) => {
|
|
||||||
const value = vars[key];
|
const value = vars[key];
|
||||||
if (value === undefined) return "";
|
if (value === undefined) return "";
|
||||||
return sanitizePart(value);
|
return sanitizePart(value);
|
||||||
});
|
});
|
||||||
const cleaned = sanitizePart(rendered, 200);
|
|
||||||
return cleaned || fallback;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function emailExportFilename(
|
export function emailExportFilename(
|
||||||
email: Email,
|
email: Email,
|
||||||
template: string = DEFAULT_EMAIL_TEMPLATE,
|
options: EmailFilenameOptions | string = {},
|
||||||
): 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`;
|
return `${stem}.eml`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function attachmentDownloadFilename(
|
export function attachmentDownloadFilename(
|
||||||
email: Email | null | undefined,
|
email: Email | null | undefined,
|
||||||
attachment: AttachmentLike,
|
attachment: AttachmentLike,
|
||||||
template: string = DEFAULT_ATTACHMENT_TEMPLATE,
|
options: EmailFilenameOptions | string = {},
|
||||||
): string {
|
): string {
|
||||||
// No email context (rare - e.g. compose attachments) means we can only
|
const opts = typeof options === "string" ? { template: options } : options;
|
||||||
// honour the {filename}/{name}/{ext} subset, so fall back to the raw name
|
const template = opts.template ?? DEFAULT_ATTACHMENT_TEMPLATE;
|
||||||
// when the template needs email data and we don't have it.
|
|
||||||
if (!email) {
|
if (!email) {
|
||||||
const filename = (attachment.name || "attachment").trim();
|
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 vars = attachmentVars(email, attachment);
|
||||||
const rendered = template.replace(/\{(\w+)\}/g, (_, key: string) => {
|
const rendered = template.replace(/\{(\w+)\}/g, (_, key: string) => {
|
||||||
@@ -166,12 +199,16 @@ export function attachmentDownloadFilename(
|
|||||||
// sanitiser (it strips trailing dots otherwise).
|
// sanitiser (it strips trailing dots otherwise).
|
||||||
return key === "filename" ? value.replace(SAFE_CHARS, "_") : sanitizePart(value);
|
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 templateMentionsExt = /\{(ext|filename)\}/.test(template);
|
||||||
const cleaned = sanitizePart(rendered, 200) || "attachment";
|
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;
|
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.
|
// 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" }],
|
from: [{ name: "Alice Sender", email: "alice@example.com" }],
|
||||||
to: [{ name: "Bob Recipient", email: "bob@example.com" }],
|
to: [{ name: "Bob Recipient", email: "bob@example.com" }],
|
||||||
cc: [],
|
cc: [],
|
||||||
subject: "Quarterly report draft",
|
subject: "Benachrichtigung von Ihrem Gerät",
|
||||||
preview: "",
|
preview: "",
|
||||||
hasAttachment: true,
|
hasAttachment: true,
|
||||||
blobId: "sample-blob",
|
blobId: "sample-blob",
|
||||||
|
|||||||
@@ -1503,6 +1503,7 @@
|
|||||||
"title": "Downloads",
|
"title": "Downloads",
|
||||||
"description": "Customize how downloaded emails and attachments are named.",
|
"description": "Customize how downloaded emails and attachments are named.",
|
||||||
"reset": "Restore default",
|
"reset": "Restore default",
|
||||||
|
"preview": "Preview:",
|
||||||
"email_template": {
|
"email_template": {
|
||||||
"label": "Email (.eml) filename",
|
"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."
|
"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": {
|
"attachment_template": {
|
||||||
"label": "Attachment filename",
|
"label": "Attachment filename",
|
||||||
"description": "Template used when downloading or dragging out an attachment. If you omit {filename} and {ext}, the original extension is preserved."
|
"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": {
|
"filters": {
|
||||||
|
|||||||
@@ -242,6 +242,10 @@ interface SettingsState {
|
|||||||
// Downloads
|
// Downloads
|
||||||
emailDownloadTemplate: string;
|
emailDownloadTemplate: string;
|
||||||
attachmentDownloadTemplate: string;
|
attachmentDownloadTemplate: string;
|
||||||
|
filenameSpaceReplacement: 'keep' | 'underscore' | 'dash';
|
||||||
|
filenameLowercase: boolean;
|
||||||
|
filenameStripDiacritics: boolean;
|
||||||
|
filenameCollapseSeparators: boolean;
|
||||||
|
|
||||||
// Advanced
|
// Advanced
|
||||||
debugMode: boolean;
|
debugMode: boolean;
|
||||||
@@ -431,6 +435,10 @@ const DEFAULT_SETTINGS = {
|
|||||||
// Downloads
|
// Downloads
|
||||||
emailDownloadTemplate: '{date} ({from}-{to}) {subject}',
|
emailDownloadTemplate: '{date} ({from}-{to}) {subject}',
|
||||||
attachmentDownloadTemplate: '{filename}',
|
attachmentDownloadTemplate: '{filename}',
|
||||||
|
filenameSpaceReplacement: 'keep' as 'keep' | 'underscore' | 'dash',
|
||||||
|
filenameLowercase: false,
|
||||||
|
filenameStripDiacritics: false,
|
||||||
|
filenameCollapseSeparators: true,
|
||||||
|
|
||||||
// Advanced
|
// Advanced
|
||||||
debugMode: false,
|
debugMode: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user