import { createHash, randomUUID } from "node:crypto"; import type { DatabaseSync } from "node:sqlite"; import { DocumentValidationError } from "./document-errors"; export type DocumentSection = { id: string; title: string; content: string; summary: string; }; export type SectionOperation = | { op: "update"; id: string; content?: string; summary?: string } | { op: "insert"; after: string; content: string; summary: string } | { op: "delete"; id: string } | { op: "move"; id: string; after: string }; export type SectionEdit = { revision: number; operations: SectionOperation[] }; const hash = (text: string) => createHash("sha256").update(text).digest("hex"); // Preserve every byte, including CRLF and separators. H3-H6 belong to their H2 chapter. export function splitSections(content: string): DocumentSection[] { const starts: { offset: number; title: string }[] = [{ offset: 0, title: "导言" }]; let offset = 0; let fence: { marker: string; length: number } | undefined; for (const line of content.match(/[^\n]*\n|[^\n]+$/g) ?? []) { const opening = line.match(/^ {0,3}(`{3,}|~{3,})(.*?)(?:\r?\n)?$/); if (fence) { if (new RegExp(`^ {0,3}${fence.marker}{${fence.length},}[ \\t]*\\r?\\n?$`).test(line)) fence = undefined; } else if (opening && !(opening[1][0] === "`" && opening[2].includes("`"))) { fence = { marker: opening[1][0], length: opening[1].length }; } else { const heading = line.match(/^##(?:[ \t]+(.*?))?[ \t]*\r?\n?$/); if (heading) starts.push({ offset, title: (heading[1] ?? "").replace(/[ \t]+#+[ \t]*$/, "").trim() }); } offset += line.length; } const seen = new Map(); return starts.map((start, index) => { const occurrence = seen.get(start.title) ?? 0; seen.set(start.title, occurrence + 1); return { id: index === 0 ? "preamble" : "s_" + hash(`${start.title}\0${occurrence}`).slice(0, 24), title: start.title, content: content.slice(start.offset, starts[index + 1]?.offset), summary: "" }; }); } export function sectionSchema(db: DatabaseSync) { db.exec(`CREATE TABLE IF NOT EXISTS document_sections ( document_id TEXT PRIMARY KEY REFERENCES documents(id), payload TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS document_section_revisions ( document_id TEXT NOT NULL REFERENCES documents(id), revision INTEGER NOT NULL, payload TEXT NOT NULL, PRIMARY KEY(document_id, revision) );`); } export function loadSections(db: DatabaseSync, documentId: string, content: string): DocumentSection[] { const row = db.prepare("SELECT payload FROM document_sections WHERE document_id=?") .get(documentId) as { payload: string } | undefined; if (!row) return splitSections(content); const sections = JSON.parse(row.payload) as DocumentSection[]; // A legacy writer must not leave an apparently current outline over different Markdown. return sections.map((s) => s.content).join("") === content ? sections : reconcileSections(content, sections); } export function reconcileSections(content: string, previous: DocumentSection[]): DocumentSection[] { const unused = new Set(previous); return splitSections(content).map((section) => { const prior = section.id === "preamble" ? previous.find((p) => p.id === "preamble") : previous.find((p) => p.id !== "preamble" && unused.has(p) && p.content === section.content) ?? previous.find((p) => p.id !== "preamble" && unused.has(p) && p.title === section.title); if (prior) unused.delete(prior); return { ...section, id: prior?.id ?? (section.id === "preamble" ? "preamble" : "s_" + randomUUID()), summary: prior?.content === section.content ? prior.summary : "" }; }); } export function persistSections(db: DatabaseSync, documentId: string, revision: number, sections: DocumentSection[]) { const payload = JSON.stringify(sections); db.prepare(`INSERT INTO document_sections VALUES (?,?) ON CONFLICT(document_id) DO UPDATE SET payload=excluded.payload`).run(documentId, payload); db.prepare("INSERT INTO document_section_revisions VALUES (?,?,?)").run(documentId, revision, payload); } export function outline(sections: DocumentSection[]) { return sections.map(({ content, ...section }, position) => ({ ...section, position, contentHash: hash(content), summaryStatus: section.summary.trim() ? "current" : "missing" })); } export function parseSectionEdit(value: unknown): SectionEdit { if (!value || typeof value !== "object" || Array.isArray(value)) throw new DocumentValidationError("Expected section edit object"); const row = value as Record; if (Object.keys(row).some((key) => !["revision", "operations"].includes(key)) || !Number.isSafeInteger(row.revision) || (row.revision as number) < 1 || !Array.isArray(row.operations) || !row.operations.length || row.operations.length > 100) throw new DocumentValidationError("Provide revision and 1 to 100 operations"); for (const raw of row.operations) { if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new DocumentValidationError("Invalid operation"); const op = raw as Record; const fields: Record = { update: ["op", "id", "content", "summary"], insert: ["op", "after", "content", "summary"], delete: ["op", "id"], move: ["op", "id", "after"] }; if (typeof op.op !== "string" || !Object.hasOwn(fields, op.op) || Object.keys(op).some((key) => !fields[op.op as string].includes(key))) throw new DocumentValidationError("Unknown operation or field"); for (const key of fields[op.op].filter((key) => !["content", "summary", "op"].includes(key))) if (typeof op[key] !== "string" || !(op[key] as string).length) throw new DocumentValidationError("Missing section identifier"); for (const key of ["content", "summary"]) if (op[key] !== undefined && typeof op[key] !== "string") throw new DocumentValidationError(`Invalid ${key}`); if (op.op === "update" && op.content === undefined && op.summary === undefined) throw new DocumentValidationError("Empty update"); if (op.op === "insert" && (typeof op.content !== "string" || typeof op.summary !== "string" || !op.summary.trim())) throw new DocumentValidationError("Insert requires content and summary"); if (typeof op.summary === "string" && (!op.summary.trim() || op.summary.length > 8000)) throw new DocumentValidationError("Summary must contain 1 to 8000 characters"); } return { revision: row.revision as number, operations: row.operations as SectionOperation[] }; } export function applySectionOperations(previous: DocumentSection[], operations: SectionOperation[]) { const sections = previous.map((section) => ({ ...section })); function index(id: string) { const result = sections.findIndex((s) => s.id === id); if (result < 0) throw new DocumentValidationError("Unknown section id"); return result; } for (const op of operations) { if (op.op === "insert") { sections.splice(index(op.after) + 1, 0, { id: "s_" + randomUUID(), title: "", content: op.content, summary: op.summary }); } else { const at = index(op.id); if (op.op === "update") { if (op.content !== undefined && op.content !== sections[at].content) sections[at].summary = ""; if (op.content !== undefined) sections[at].content = op.content; if (op.summary !== undefined) sections[at].summary = op.summary; } else { if (op.id === "preamble") throw new DocumentValidationError("Cannot delete or move the preamble"); if (op.op === "delete") sections.splice(at, 1); else { if (op.id === op.after) throw new DocumentValidationError("Cannot move section after itself"); index(op.after); const [section] = sections.splice(at, 1); sections.splice(index(op.after) + 1, 0, section); } } } } const content = sections.map((s) => s.content).join(""); const parsed = splitSections(content); if (parsed.length !== sections.length || parsed.some((s, i) => s.content !== sections[i].content)) throw new DocumentValidationError("Each chapter must start with one H2 heading; preserve newline boundaries and balanced fences"); return sections.map((s, i) => ({ ...s, title: parsed[i].title })); }