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 <cursoragent@cursor.com>
This commit is contained in:
Dominik Bartosik 2026-06-17 14:32:59 +02:00
parent 7380f6f6f2
commit 5141186e96
5 changed files with 76 additions and 41 deletions

View file

@ -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",

View file

@ -4,9 +4,11 @@
* Polls ``GET /api/exports/changed-since/?updated_after=<watermark>``,
* paginates through the response set, and writes each row into the
* user-configured ``exportTargetFolder`` as
* ``<slugify(title)>.md``. The bottom of every written file carries a
* ``<filenameFromTitle(title)>.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 ``<!-- unabyss-export-id: <uuid> -->`` 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 ``-<first-6-of-uuid>``).
*
@ -153,8 +155,8 @@ async function applyUpsert(
targetFolder: string,
row: ExportRow,
): Promise<InboundFileOutcome> {
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 ``<slug>-<suffix>.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;
}
/**

View file

@ -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);
});
});

View file

@ -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();

View file

@ -1,4 +1,5 @@
{
"1.1.6": "1.6.6",
"1.1.5": "1.6.6",
"1.1.4": "1.6.6"
}