From c3004f87826e4e77aae8037bdaad545f06ee851d Mon Sep 17 00:00:00 2001 From: Qoder-AI <215799558+Qoder-AI@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:51:50 +0800 Subject: [PATCH 1/2] fix(web): keep export filenames valid when truncated --- web/src/v2/thread-export.test.ts | 6 ++++++ web/src/v2/thread-export.ts | 3 ++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/web/src/v2/thread-export.test.ts b/web/src/v2/thread-export.test.ts index 0291cd39..5155838e 100644 --- a/web/src/v2/thread-export.test.ts +++ b/web/src/v2/thread-export.test.ts @@ -113,6 +113,12 @@ 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("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..792919a8 100644 --- a/web/src/v2/thread-export.ts +++ b/web/src/v2/thread-export.ts @@ -82,6 +82,7 @@ export function formatThreadExport(title: string, messages: ExportMessage[], for } 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 = [...clean].slice(0, 100).join("").replace(/[. ]+$/u, ""); return `conversation-${name || "export"}.${format === "markdown" ? "md" : "txt"}`; } From d55a9cc4cc25c4bdb8a61e2b97c8d12765c908be Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 01:30:12 +0800 Subject: [PATCH 2/2] fix(web): truncate export filenames on grapheme boundaries The code-point bound could still split a ZWJ emoji family or a combining sequence into a broken fragment. Segment the title with Intl.Segmenter when available (ES2023 baseline, code-point fallback otherwise) so the 100-unit budget counts user-perceived characters and never lands inside one. Extend the truncation test with a ZWJ family at the boundary. --- web/src/v2/thread-export.test.ts | 7 +++++++ web/src/v2/thread-export.ts | 20 +++++++++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/web/src/v2/thread-export.test.ts b/web/src/v2/thread-export.test.ts index 5155838e..9ecab880 100644 --- a/web/src/v2/thread-export.test.ts +++ b/web/src/v2/thread-export.test.ts @@ -119,6 +119,13 @@ it("truncates export filenames without splitting Unicode or ending in separators 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 792919a8..c02cda78 100644 --- a/web/src/v2/thread-export.ts +++ b/web/src/v2/thread-export.ts @@ -81,8 +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 clean = title.replace(/[\\/:*?"<>|\u0000-\u001f]/gu, "_").trim().replace(/[. ]+$/u, ""); - const name = [...clean].slice(0, 100).join("").replace(/[. ]+$/u, ""); + const name = truncateExportTitleGraphemes(clean, 100).replace(/[. ]+$/u, ""); return `conversation-${name || "export"}.${format === "markdown" ? "md" : "txt"}`; }