From 5141186e96659a9eea1bd6e1666eba51a64f0658 Mon Sep 17 00:00:00 2001 From: Dominik Bartosik Date: Wed, 17 Jun 2026 14:32:59 +0200 Subject: [PATCH] feat: name synced exports from title instead of slug (1.1.6) Preserve export title casing and spacing in vault filenames, rewrite only illegal path characters, and truncate at 70 characters on a word boundary. Co-authored-by: Cursor --- manifest.json | 2 +- src/syncInbound.ts | 52 +++++++++++++++++++++++++------------ tests/slugify.test.ts | 54 ++++++++++++++++++++++++--------------- tests/syncInbound.test.ts | 8 +++--- versions.json | 1 + 5 files changed, 76 insertions(+), 41 deletions(-) diff --git a/manifest.json b/manifest.json index 533040f..409cdaa 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "unabyss", "name": "Unabyss", - "version": "1.1.5", + "version": "1.1.6", "minAppVersion": "1.6.6", "description": "Sync notes and exports between your vault and Unabyss.", "author": "Unabyss", diff --git a/src/syncInbound.ts b/src/syncInbound.ts index 55e385d..d65449b 100644 --- a/src/syncInbound.ts +++ b/src/syncInbound.ts @@ -4,9 +4,11 @@ * Polls ``GET /api/exports/changed-since/?updated_after=``, * paginates through the response set, and writes each row into the * user-configured ``exportTargetFolder`` as - * ``.md``. The bottom of every written file carries a + * ``.md`` - the export title with illegal + * filename characters rewritten and the name capped at 70 characters + * on a whole-word boundary. The bottom of every written file carries a * stable trailer line ```` so the - * next sync can recognise a same-slug existing file as the same export + * next sync can recognise a same-name existing file as the same export * (idempotent overwrite) versus an unrelated user-authored note * (suffix-on-write with ``-``). * @@ -153,8 +155,8 @@ async function applyUpsert( targetFolder: string, row: ExportRow, ): Promise { - const baseSlug = slugifyTitle(row.title) || "untitled"; - const primaryPath = joinPath(targetFolder, `${baseSlug}.md`); + const baseName = filenameFromTitle(row.title) || "untitled"; + const primaryPath = joinPath(targetFolder, `${baseName}.md`); const ownPath = await locateOwnedExport(deps.app.vault, targetFolder, row.id); const targetPath = ownPath ?? (await resolveCollisionPath(deps.app.vault, primaryPath, row.id)); const body = appendTrailer(row.markdown, row.id); @@ -290,22 +292,40 @@ export function trailerLineFor(exportId: string): string { return `${EXPORT_TRAILER_PREFIX} ${exportId} ${EXPORT_TRAILER_SUFFIX}`; } +const FILENAME_MAX_LENGTH = 70; + /** - * Slugify an export title for use as a filename. + * Derive a human-readable filename body from an export title. * - * Rules (kept simple to avoid pulling in a runtime dependency): + * Rules: * - * - Lowercase ASCII alphanumerics and dashes. - * - Non-ASCII letters and punctuation collapse to dashes. - * - Repeated dashes collapse to one; leading / trailing dashes drop. - * - Truncate to 80 characters so the final ``-.md`` path - * fits comfortably inside Obsidian's ``normalizePath`` limit. + * - Preserve the title's casing and spacing as far as the filesystem + * allows; only characters Obsidian / the host OS forbid in note + * names are rewritten. + * - Characters illegal in vault filenames + * (``\ / : * ? " < > | # ^ [ ]``) collapse to a single space. + * - Whitespace runs collapse to one space; leading / trailing spaces + * and dots are dropped (a leading dot would otherwise hide the + * file; a trailing dot is rejected on Windows). + * - Truncate to ``FILENAME_MAX_LENGTH`` characters. When the title is + * longer, the cut falls back to the last whole word so a word is + * never split mid-way (unless the first word alone already exceeds + * the limit, in which case it is hard-cut). */ -export function slugifyTitle(title: string): string { - const lowered = title.toLowerCase(); - const stripped = lowered.replace(/[^a-z0-9]+/gu, "-"); - const collapsed = stripped.replace(/-+/gu, "-").replace(/^-|-$/gu, ""); - return collapsed.slice(0, 80); +export function filenameFromTitle(title: string): string { + const cleaned = title.replace(/[\\/:*?"<>|#^[\]]/gu, " "); + const normalized = cleaned.replace(/\s+/gu, " ").trim(); + const truncated = truncateAtWord(normalized, FILENAME_MAX_LENGTH); + return truncated.replace(/^[.\s]+|[.\s]+$/gu, ""); +} + +function truncateAtWord(text: string, maxLength: number): string { + if (text.length <= maxLength) { + return text; + } + const hardCut = text.slice(0, maxLength); + const lastSpace = hardCut.lastIndexOf(" "); + return lastSpace > 0 ? hardCut.slice(0, lastSpace) : hardCut; } /** diff --git a/tests/slugify.test.ts b/tests/slugify.test.ts index cecbec7..84e3f62 100644 --- a/tests/slugify.test.ts +++ b/tests/slugify.test.ts @@ -1,41 +1,55 @@ /** - * Slug + collision-suffix policy for inbound exports. + * Filename + collision-suffix policy for inbound exports. * - * The slug rules are conservative (lowercase ASCII alphanumerics + - * dashes only) so the resulting filename round-trips through - * Obsidian's path normaliser regardless of host filesystem. The - * collision suffix MUST be deterministic so a second pass against an - * unchanged server state writes back to the same on-disk path. + * The filename preserves the export title's casing and spacing, + * rewriting only characters Obsidian / the host OS forbid in note + * names, and caps the name at 70 characters on a whole-word boundary. + * The collision suffix MUST be deterministic so a second pass against + * an unchanged server state writes back to the same on-disk path. */ import { buildCollisionPath, collisionSuffixFor, - slugifyTitle, + filenameFromTitle, trailerLineFor, } from "../src/syncInbound"; -describe("slugifyTitle", () => { - it("lowercases and dashifies a typical title", () => { - expect(slugifyTitle("Hello World")).toEqual("hello-world"); +describe("filenameFromTitle", () => { + it("preserves the casing and spacing of a typical title", () => { + expect(filenameFromTitle("Hello World")).toEqual("Hello World"); }); - it("collapses sequences of non-alphanumeric chars into a single dash", () => { - expect(slugifyTitle("Foo --- Bar / Baz!")).toEqual("foo-bar-baz"); + it("rewrites illegal filename characters as spaces and collapses runs", () => { + expect(filenameFromTitle("Foo --- Bar / Baz!")).toEqual("Foo --- Bar Baz!"); }); - it("strips diacritics by collapsing them into dashes (lossy)", () => { - const slug = slugifyTitle("\u00e9diteur \u00e0 La Mode"); - expect(slug).toEqual("diteur-la-mode"); + it("keeps non-ASCII letters intact", () => { + expect(filenameFromTitle("\u00e9diteur \u00e0 La Mode")).toEqual( + "\u00e9diteur \u00e0 La Mode", + ); }); - it("returns an empty string for all-punctuation titles", () => { - expect(slugifyTitle("!!!---???")).toEqual(""); + it("returns an empty string for a title made only of illegal chars", () => { + expect(filenameFromTitle("///???")).toEqual(""); }); - it("truncates very long titles to 80 chars", () => { - const slug = slugifyTitle("a".repeat(120)); - expect(slug.length).toEqual(80); + it("strips a leading dot so the file is not hidden", () => { + expect(filenameFromTitle(".hidden")).toEqual("hidden"); + }); + + it("truncates long titles at the last whole word within 70 chars", () => { + const title = "The quick brown fox jumps over the lazy dog while the slow tortoise watches keenly"; + const name = filenameFromTitle(title); + expect(name.length).toBeLessThanOrEqual(70); + expect(name).toEqual( + "The quick brown fox jumps over the lazy dog while the slow tortoise", + ); + }); + + it("hard-cuts a single word longer than 70 chars", () => { + const name = filenameFromTitle("a".repeat(120)); + expect(name.length).toEqual(70); }); }); diff --git a/tests/syncInbound.test.ts b/tests/syncInbound.test.ts index e4df758..0bbe8eb 100644 --- a/tests/syncInbound.test.ts +++ b/tests/syncInbound.test.ts @@ -142,15 +142,15 @@ describe("runInboundSync watermark advancement", () => { expect(report.written).toEqual(3); expect(cache.getExportsWatermark()).toEqual("2026-01-03T00:00:00Z"); expect(vault.createCalls.map((row) => row.path)).toEqual([ - "Exports/note-a.md", - "Exports/note-b.md", - "Exports/note-c.md", + "Exports/Note A.md", + "Exports/Note B.md", + "Exports/Note C.md", ]); }); it("does NOT advance the watermark when a row inside the page errors", async () => { const vault = new FakeVault(["Exports"]); - vault.failOnCreate = /note-b\.md$/u; + vault.failOnCreate = /Note B\.md$/u; const app = setupApp(vault); const { cache } = setupCache(); diff --git a/versions.json b/versions.json index d3786ff..08ef4a9 100644 --- a/versions.json +++ b/versions.json @@ -1,4 +1,5 @@ { + "1.1.6": "1.6.6", "1.1.5": "1.6.6", "1.1.4": "1.6.6" }