mirror of
https://github.com/unabyss/obsidian-plugin.git
synced 2026-07-22 07:47:17 +00:00
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:
parent
7380f6f6f2
commit
5141186e96
5 changed files with 76 additions and 41 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
{
|
||||
"1.1.6": "1.6.6",
|
||||
"1.1.5": "1.6.6",
|
||||
"1.1.4": "1.6.6"
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue