145 lines
8.1 KiB
TypeScript
145 lines
8.1 KiB
TypeScript
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<string, number>();
|
|
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<string, unknown>;
|
|
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<string, unknown>;
|
|
const fields: Record<string, string[]> = { 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 }));
|
|
}
|