refactor(ids): co-locate ID generation and validation in ids.ts to prevent contract drift
This commit is contained in:
parent
23cef06580
commit
90b7f2cf76
4 changed files with 63 additions and 29 deletions
|
|
@ -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);
|
||||
|
|
|
|||
48
src/main/ids.ts
Normal file
48
src/main/ids.ts
Normal file
|
|
@ -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: `<slug>-<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`;
|
||||
}
|
||||
|
|
@ -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<BookResult | ErrResult> {
|
|||
}
|
||||
}
|
||||
|
||||
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 <name>/ 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)) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue