From 90b7f2cf76ef4450462c1a919d1c673027c3a5c9 Mon Sep 17 00:00:00 2001 From: avi Date: Mon, 24 Aug 2026 21:47:37 -0500 Subject: [PATCH] refactor(ids): co-locate ID generation and validation in ids.ts to prevent contract drift --- src/main/chapters.ts | 9 ++++----- src/main/ids.ts | 48 ++++++++++++++++++++++++++++++++++++++++++++ src/main/index.ts | 11 ++-------- src/main/project.ts | 24 +++++++++------------- 4 files changed, 63 insertions(+), 29 deletions(-) create mode 100644 src/main/ids.ts diff --git a/src/main/chapters.ts b/src/main/chapters.ts index 1d7ba95..82164c3 100644 --- a/src/main/chapters.ts +++ b/src/main/chapters.ts @@ -9,6 +9,7 @@ import { makeChapterId, saveMeta, } from "./project.js"; +import { isSafeChapterId } from "./ids.js"; import { getChapterDoc, setChapterDoc, @@ -164,16 +165,14 @@ export interface ChapterSnapshot { index: number; } -// Chapter ids are always slugified lowercase ids; enforcing that shape here -// keeps renderer-supplied snapshots from escaping the book directory. -const SAFE_ID = /^[a-z0-9][a-z0-9-]*$/; - // Re-create a previously deleted chapter at its original position, restoring // its title, color, and content. The chapter is stored as Markdown so it reads // correctly regardless of the format it originally used. Idempotent: restoring // an id that still exists is a no-op instead of a duplicate. export function restoreChapter(bookPath: string, snap: ChapterSnapshot): ChapterEntry { - if (!snap || typeof snap.id !== "string" || !SAFE_ID.test(snap.id)) { + // Enforcing the shared id grammar (see ids.ts) keeps renderer-supplied + // snapshots from escaping the book directory. + if (!isSafeChapterId(snap?.id)) { throw new Error("Invalid chapter id"); } const meta = loadBook(bookPath); diff --git a/src/main/ids.ts b/src/main/ids.ts new file mode 100644 index 0000000..44c7097 --- /dev/null +++ b/src/main/ids.ts @@ -0,0 +1,48 @@ +// The chapter-id grammar and book-folder naming, in one place. +// +// Three concerns used to live scattered across project.ts (slug + id +// generation), chapters.ts (the SAFE_ID validator) and main/index.ts (folder +// sanitization). Splitting the grammar from its validator meant a future edit +// to slugify could silently produce ids that isSafeChapterId rejects (e.g. +// allowing underscores), breaking undo/restore for every new chapter. +// Co-locating them makes the contract impossible to miss. + +/** Human-readable book folder name under ~/Documents. Keeps spaces and case; + * strips characters hostile to filesystems, collapses whitespace, caps at + * 100 chars. Callers fall back to "Untitled" when the result is empty. */ +export function sanitizeBookFolderName(name: string): string { + return name + .replace(/[\\/:*?"<>|\x00-\x1f\x7f]/g, "") + .replace(/\s+/g, " ") + .trim() + .slice(0, 100); +} + +/** Lowercase ASCII slug of a title; never empty ("chapter" fallback). */ +export function slugify(s: string): string { + return s + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") || "chapter"; +} + +/** Fresh chapter id: `-<4 random chars>`. Guaranteed to satisfy + * isSafeChapterId — keep it that way if you ever touch either half. */ +export function makeChapterId(title: string): string { + const rand = Math.random().toString(36).slice(2, 6); + return `${slugify(title)}-${rand}`; +} + +/** The id contract every persisted chapter must satisfy: starts with an + * alphanumeric, continues with alphanumerics or dashes. This is also what + * keeps renderer-supplied snapshots from escaping the book directory. */ +const CHAPTER_ID_RE = /^[a-z0-9][a-z0-9-]*$/; + +export function isSafeChapterId(id: unknown): id is string { + return typeof id === "string" && CHAPTER_ID_RE.test(id); +} + +/** Markdown filename for a chapter id (legacy/native Markdown storage). */ +export function chapterFileName(id: string): string { + return `${id}.md`; +} diff --git a/src/main/index.ts b/src/main/index.ts index eab3d02..0741425 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -37,6 +37,7 @@ import { getRecoveryRoot, setRecoveryRoot, } from "./backup.js"; +import { sanitizeBookFolderName } from "./ids.js"; import { buildDocx, buildOdt } from "./office-export.js"; import { getSetting, setSetting } from "./settings.js"; import { migrateChapterToNative, migrateBookToNative } from "./migration.js"; @@ -187,14 +188,6 @@ async function openBookAt(p: string): Promise { } } -function sanitizeName(name: string): string { - return name - .replace(/[\\/:*?"<>|\x00-\x1f\x7f]/g, "") - .replace(/\s+/g, " ") - .trim() - .slice(0, 100); -} - // Create a new book as a dedicated folder / inside the user's Documents // directory, then open it. The folder name is the sanitized book title; if a // folder with that name already exists we append a numeric suffix. @@ -203,7 +196,7 @@ ipcMain.handle("folio:newBookNamed", async (_e, name: string) => { if (!title) return { error: "Please provide a book name." }; try { const base = app.getPath("documents"); - const folder = sanitizeName(title) || "Untitled"; + const folder = sanitizeBookFolderName(title) || "Untitled"; let target = path.join(base, folder); let n = 2; while (hasBook(target) || fs.existsSync(target)) { diff --git a/src/main/project.ts b/src/main/project.ts index 2407a7b..bcfc2c3 100644 --- a/src/main/project.ts +++ b/src/main/project.ts @@ -53,21 +53,15 @@ export const META_FILE = "folio.json"; export const CHAPTERS_DIR = "chapters"; export const CURRENT_VERSION = 1; -export function slugify(s: string): string { - return s - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") || "chapter"; -} - -export function makeChapterId(title: string): string { - const rand = Math.random().toString(36).slice(2, 6); - return `${slugify(title) || "chapter"}-${rand}`; -} - -export function chapterFileName(id: string): string { - return `${id}.md`; -} +// The id grammar moved to ids.ts (with its validator, so the contract cannot +// drift). Re-exported here for existing importers and test bundles. +export { + slugify, + makeChapterId, + chapterFileName, + isSafeChapterId, + sanitizeBookFolderName, +} from "./ids.js"; export function defaultMeta(title: string): BookMeta { return {