diff --git a/web/src/v2/thread-export.test.ts b/web/src/v2/thread-export.test.ts index 0291cd39..9ecab880 100644 --- a/web/src/v2/thread-export.test.ts +++ b/web/src/v2/thread-export.test.ts @@ -113,6 +113,19 @@ it("merges duplicate public assistant endings and labels pending and withdrawn i expect(filename).not.toMatch(/[\\/:*?"<>|\u0000-\u001f]/u); }); +it("truncates export filenames without splitting Unicode or ending in separators", () => { + const prefix = "a".repeat(99); + expect(threadExportFilename(`${prefix}๐Ÿ˜€suffix`, "markdown")).toBe(`conversation-${prefix}๐Ÿ˜€.md`); + expect(threadExportFilename(`${prefix} tail`, "text")).toBe(`conversation-${prefix}.txt`); +}); + +it("keeps ZWJ emoji families whole when truncating export filenames", () => { + const family = "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ"; + const prefix = "a".repeat(99); + expect(threadExportFilename(`${prefix}${family}tail`, "markdown")).toBe(`conversation-${prefix}${family}.md`); + expect(threadExportFilename(`${prefix}a${family}`, "text")).toBe(`conversation-${prefix}a.txt`); +}); + it("rejects missing full bodies instead of returning partial history", async () => { const get = vi.fn(async (path: string) => { if (path.endsWith("second")) throw new Error("body unavailable"); diff --git a/web/src/v2/thread-export.ts b/web/src/v2/thread-export.ts index 36bed15a..c02cda78 100644 --- a/web/src/v2/thread-export.ts +++ b/web/src/v2/thread-export.ts @@ -81,7 +81,26 @@ export function formatThreadExport(title: string, messages: ExportMessage[], for return `${format === "markdown" ? "# " : ""}${heading}\n\n${sections.join("\n\n---\n\n")}\n`; } +// Truncate by user-perceived character, not by code point: ZWJ emoji families, +// skin-tone modifiers and combining marks each count as one grapheme, so the +// bound can never split them into invalid or unrecognizable fragments. Runtimes +// without Intl.Segmenter keep the code-point bound as a floor. +function truncateExportTitleGraphemes(value: string, maxGraphemes: number): string { + if (typeof Intl === "undefined" || !("Segmenter" in Intl)) { + return [...value].slice(0, maxGraphemes).join(""); + } + let name = ""; + let count = 0; + for (const segment of new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(value)) { + if (count >= maxGraphemes) break; + name += segment.segment; + count += 1; + } + return name; +} + export function threadExportFilename(title: string, format: ThreadExportFormat): string { - const name = title.replace(/[\\/:*?"<>|\u0000-\u001f]/gu, "_").trim().replace(/[. ]+$/u, "").slice(0, 100); + const clean = title.replace(/[\\/:*?"<>|\u0000-\u001f]/gu, "_").trim().replace(/[. ]+$/u, ""); + const name = truncateExportTitleGraphemes(clean, 100).replace(/[. ]+$/u, ""); return `conversation-${name || "export"}.${format === "markdown" ? "md" : "txt"}`; }