Harden 0.9.2 release engineering

This commit is contained in:
Anthony Fitzpatrick 2026-07-14 20:26:13 +02:00
parent cbd08643ca
commit 111ba537a0
26 changed files with 187 additions and 88 deletions

1
.gitignore vendored
View file

@ -1,5 +1,6 @@
node_modules/
main.js
data.json
*.map
Icon?
.DS_Store

View file

@ -24,7 +24,7 @@ It also registers the documented workspace `file-menu` event through `registerEv
- `CompileWorkspaceController` owns four-step state, validation, invalidation, duplicate-click protection, and cancellable workspace operations.
- Step renderers in `src/workspace/` own DOM controls and event wiring only.
- `ExportCoordinator` verifies fingerprints, handles overwrite and progress UI, invokes exporters, and reports the outcome.
- `CompileHistoryService` is the only export-history and compile-log persistence boundary.
- `CompileHistoryService` is the only export-history and compile-log persistence boundary; `history-storage.ts` repairs malformed persisted entries before UI use.
- `ResultActionService` isolates open, reveal, and platform save-copy capabilities.
- `SafeBinaryWriter` owns staged binary replacement, verification, rollback, and cleanup.
- `OperationStateController` models idle, preparation, export, non-cancellable finalisation, cancellation, failure, and completion.
@ -90,7 +90,7 @@ The workspace plan wins over inference and legacy profile structure. Automatic r
## Why preview owns the final Book
The parser and cleaner can exclude empty or malformed notes after the Contents plan has been edited. A plan-derived preview could therefore promise content that the exporter would later omit. Preparation resolves this by constructing one `PreparedCompileSession`: its `book` reference drives the outline, statistics, warnings, Markdown, and DOCX. Export verifies source and input fingerprints but does not rebuild the model. Treat prepared sessions as immutable snapshots even though TypeScript does not deep-freeze them.
The parser and cleaner can exclude empty or malformed notes after the Contents plan has been edited. A plan-derived preview could therefore promise content that the exporter would later omit. Preparation resolves this by constructing one `PreparedCompileSession`: its `book` reference drives the outline, statistics, warnings, Markdown, and DOCX. Export verifies content-based source fingerprints and input fingerprints but does not rebuild the model. Equal-size source edits are therefore detected even when an adapter reports an unchanged or coarse timestamp. Treat prepared sessions as immutable snapshots even though TypeScript does not deep-freeze them.
## Content-cleaning boundary
@ -119,7 +119,7 @@ The parser and cleaner can exclude empty or malformed notes after the Contents p
| Active control | Request/profile field | WordprocessingML effect |
| --- | --- | --- |
| Vellum / Standard / Custom | `docxPreset` plus `DocxFormatting` | deterministic supported defaults or explicit values |
| Font, size, line spacing, indent | `docxFont`, `docxFontSize`, `docxLineSpacing`, `docxFirstLineIndent` | style defaults, `BodyText`, and `FirstParagraph` properties |
| Font, size, line spacing, indent | `docxFont`, `docxFontSize`, `docxLineSpacing`, `docxFirstLineIndentCm` | style defaults, `BodyText`, and `FirstParagraph` properties |
| Letter / A4 | `docxPageSize` | section page dimensions |
| Chapter page breaks | `docxChapterPageBreak` | `pageBreakBefore` on the first displayed Chapter heading only |
| Part headings | semantic Part plus `partDisplay` | Parts always start on a new page; number/title paragraphs are kept together |
@ -139,7 +139,7 @@ The `Subtitle` style was removed because the semantic model has no supported sub
On a local filesystem the writer stages in the destination directory, verifies readback, renames an existing destination to a backup, renames the temporary file into place, verifies the final file, then removes the backup. A failed replacement restores the backup. Generic adapters use the same validation stages but preserve original bytes and a recovery backup because their write APIs cannot promise rename atomicity. Cancellation is accepted before commit; after commit begins the writer must finish replacement or rollback.
History success and Open/Reveal/Save Copy actions occur only after final verification. Do not move those side effects into exporters or UI components.
History success and Open/Reveal/Save Copy actions occur only after final verification. Persisted log warnings are structural code summaries rather than note text, paths, or parser excerpts; shareable diagnostics omit legacy warning details. Do not move those side effects into exporters or UI components.
## Settings and migration
@ -157,7 +157,7 @@ When adding a persisted field:
- `tests/run.ts` is the broad unit/release suite: cleaning, parser, content plan, migrations, route identity, workspace state, privacy, and Warden semantic regressions.
- `tests/docx-integration.ts` opens generated Word XML and asserts semantic styles, page behaviour, matter ordering, Unicode, and forbidden content.
- `tests/safe-binary-writer.ts` injects failures at every save phase and proves rollback/cleanup.
- `tests/large-manuscript-benchmark.ts` checks deterministic large-book correctness and reports informative timing.
- `tests/large-manuscript-benchmark.ts` checks deterministic large-book correctness and reports parse/clean/Book, statistics, Markdown, and DOCX/ZIP timing.
- `tests/golden/` protects stable Markdown output for representative structures.
- `tests/fixtures/real-vault/` reproduces nested transparent containers and mixed matter that previously produced zero Chapters and orphan Scenes.

View file

@ -4,6 +4,12 @@
### Fixed
- Rejected `.` and `..` output segments before adapter path normalisation so traversal cannot be collapsed into an apparently safe vault path.
- Changed stale-preview verification to hash source contents, detecting equal-size edits even when file timestamps do not change.
- Removed tracked local plugin state from release sources and ignored future `data.json` files.
- Repaired malformed profile, history, and log entries before migration or UI rendering.
- Replaced the deprecated Electron version probe with Obsidian's documented API version and explicit desktop guards.
- Removed stale hard-coded plugin version metadata from generated DOCX packages.
- Fixed a real-vault hierarchy failure where a nested folder repeating the book name became a second Part, leaving valid Chapter Scenes orphaned and producing a zero Chapter count.
- Transparent containers now reparent descendants to their nearest included structural ancestor without losing hierarchy, order, source paths, or explicit choices.
- Mixed front/back matter, copyright containers, and common back-matter note names no longer become manuscript Parts, Chapters, or Scenes.
@ -25,6 +31,9 @@
### Improved
- Preparation now calculates statistics and renders Markdown once, records real scan/parse/cleaning/generation timings, and no longer retains a duplicate raw-prose copy for every parsed note.
- Compile logs persist structural warning-code summaries and shareable diagnostics omit legacy warning details that may contain private metadata.
- Release packaging now uses the audited `fflate` ZIP implementation already required by DOCX generation instead of maintaining a second ZIP writer and parser.
- Added privacy-safe orphan hierarchy diagnostics and a realistic nested-container/mixed-matter Warden regression.
- Final-model preview now shows the exact exported outline, statistics, warnings, exclusions, filename, and destination.
- Output verification, rollback guidance, cancellation boundaries, partial-result handling, and history sequencing have stronger automated coverage.

View file

@ -5,6 +5,8 @@ Record the date, tester, Obsidian version, operating system, Word/LibreOffice ve
## Clean install
- [ ] Install only `main.js`, `manifest.json`, and `styles.css` in `.obsidian/plugins/manuscript-compiler/`.
- [ ] Upgrade an existing vault from the previous plugin version and confirm profiles, settings, and history remain usable.
- [ ] Start once with deliberately malformed persisted settings and confirm recovery is actionable and does not prevent the plugin loading.
- [ ] Enable Manuscript Compiler without enabling another community plugin.
- [ ] Confirm first-run behaviour is understandable and does not require Pandoc or an external executable.
- [ ] Open **Manuscript Compiler: Compile Manuscript** from the command palette.
@ -14,6 +16,9 @@ Record the date, tester, Obsidian version, operating system, Word/LibreOffice ve
## Real manuscript
- [ ] Compile a novel with Parts and confirm Part, Chapter, and Scene structure.
- [ ] Compile a novel without Parts and confirm Chapters are not wrapped in a synthetic visible Part.
- [ ] Compile a large real-world manuscript and confirm preparation, preview, export, and cancellation remain responsive.
- [ ] Select the actual book folder, not its `Manuscript` child.
- [ ] Confirm the selected root is not displayed as a Part or Chapter.
- [ ] Confirm `Manuscript` is suggested as a Transparent container and creates no heading.
@ -82,6 +87,12 @@ Record the date, tester, Obsidian version, operating system, Word/LibreOffice ve
- [ ] Use **Open DOCX** where supported.
- [ ] Use **Reveal in file manager** where supported.
- [ ] Confirm result actions appear only after successful final verification.
- [ ] Confirm Open, Reveal, and Save Copy are absent or fail closed on unsupported platforms.
- [ ] Repeat save and recovery checks with a non-filesystem vault adapter.
## Privacy
- [ ] Review diagnostics and compile logs after successful, failed, and warning-producing compiles; confirm they contain no manuscript prose, note excerpts, private metadata values, or absolute local paths.
## Platforms and applications

View file

@ -85,7 +85,7 @@ Settings from earlier releases are repaired idempotently. Existing Letter select
## Privacy and Platform Support
The plugin has no telemetry, network requests, cloud dependency, or online account. It uses Obsidian APIs and bundled `fflate` ZIP generation. DOCX creation works on supported Obsidian platforms; opening, revealing, browser-style saving, or platform share/save actions depend on platform capabilities.
The plugin has no telemetry, network requests, cloud dependency, or online account. It uses Obsidian APIs and bundled `fflate` ZIP generation. Compile logs retain structural warning-code counts rather than note text or parser excerpts, and shareable diagnostics omit legacy warning detail. DOCX creation works on supported Obsidian platforms; opening, revealing, browser-style saving, or platform share/save actions depend on platform capabilities.
## Verified and Recoverable Saving
@ -110,7 +110,7 @@ npm run package:validate
npm audit
```
The regression suite includes a realistic `Book 1 - Warden of Silence` vault tree with transparent manuscript containers, excluded project folders, dashboard notes, YAML properties, scene templates, synopsis/revision sections, front/back matter, Unicode prose, and multiple Parts and Chapters. The DOCX test inspects `document.xml` and `styles.xml` semantically and writes `.test-build/Warden-of-Silence-regression.docx` for manual inspection. The normal large-manuscript test uses a generous runaway guard and deterministic count/output assertions; `npm run benchmark:large` reports local timing without applying a desktop-specific pass/fail target.
The regression suite includes a realistic `Book 1 - Warden of Silence` vault tree with transparent manuscript containers, excluded project folders, dashboard notes, YAML properties, scene templates, synopsis/revision sections, front/back matter, Unicode prose, and multiple Parts and Chapters. The DOCX test inspects `document.xml` and `styles.xml` semantically and writes `.test-build/Warden-of-Silence-regression.docx` for manual inspection. The normal large-manuscript test uses a generous runaway guard and deterministic count/output assertions; `npm run benchmark:large` reports separate parse/clean/Book, statistics, Markdown, and combined DOCX/ZIP timings without applying a desktop-specific pass/fail target.
See [MANUAL_TESTING.md](MANUAL_TESTING.md) for the live Obsidian, Word/LibreOffice, and Vellum checklist. Automated tests do not substitute for those application-level checks.

View file

@ -17,14 +17,14 @@ Version 0.9.2 remains a release candidate. Live Obsidian/Vellum testing exposed
## Automated release gates completed
- `npm run typecheck` — passed
- `npm test`97 tests passed, including the real-vault nested-container and matter-role regression
- `npm test`103 tests passed, including traversal-before-normalisation, equal-size stale-source detection, diagnostics/log privacy, malformed persistence repair, repository hygiene, and the real-vault nested-container/matter-role regression
- `npm run test:safe-writer` — 18 tests passed
- `npm run test:docx` — passed; original and real-vault Warden semantic structures inspected
- `npm run benchmark:large` — 500 Chapters, 2,000 Scenes, and 2,000,000 words in 373 ms locally; timing is informational
- `npm run benchmark:large` — 500 Chapters, 2,000 Scenes, and 2,000,000 words in 484 ms locally: parse/clean/Book 209 ms, statistics 114 ms, Markdown 37 ms, DOCX/ZIP 125 ms; timing is informational
- `npm run build` — passed
- `npm run package` — passed
- `npm run package:validate` — passed
- `npm audit`an earlier run reported 0 vulnerabilities; the final retry failed with `getaddrinfo ENOTFOUND registry.npmjs.org`
- `npm audit` — 0 vulnerabilities
- `git diff --check` — passed
The release archive must be `release/manuscript-compiler-0.9.2.zip` and contain exactly `main.js`, `manifest.json`, and `styles.css`.

View file

@ -1,13 +1,12 @@
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import { unzipSync, zipSync } from "fflate";
const packageJson = JSON.parse(await readFile("package.json", "utf8")); const manifest = JSON.parse(await readFile("manifest.json", "utf8")); const versions = JSON.parse(await readFile("versions.json", "utf8"));
if (packageJson.version !== manifest.version) throw new Error(`Version mismatch: package ${packageJson.version}, manifest ${manifest.version}`);
if (versions[manifest.version] !== manifest.minAppVersion) throw new Error(`versions.json does not map ${manifest.version} to ${manifest.minAppVersion}`);
const required = ["main.js", "manifest.json", "styles.css"]; const archivePath = path.join("release", `manuscript-compiler-${manifest.version}.zip`);
if (process.argv.includes("--validate")) { const archive = await readFile(archivePath); const names = zipNames(archive); if (JSON.stringify(names.sort()) !== JSON.stringify(required.sort())) throw new Error(`Unexpected archive entries: ${names.join(", ")}`); process.stdout.write(`Validated ${archivePath}: ${names.join(", ")}\n`); process.exit(0); }
if (process.argv.includes("--validate")) { const archive = await readFile(archivePath); const names = Object.keys(unzipSync(archive)); if (JSON.stringify(names.sort()) !== JSON.stringify(required.sort())) throw new Error(`Unexpected archive entries: ${names.join(", ")}`); process.stdout.write(`Validated ${archivePath}: ${names.join(", ")}\n`); process.exit(0); }
const entries = await Promise.all(required.map(async (name) => ({ name, data: await readFile(name) }))); await rm("release", { recursive: true, force: true }); await mkdir("release", { recursive: true }); await writeFile(archivePath, createZip(entries)); process.stdout.write(`Created ${archivePath} with only ${required.join(", ")}\n`);
function createZip(entries) { const local = []; const central = []; let offset = 0; for (const entry of entries) { const name = Buffer.from(entry.name); const crc = crc32(entry.data); const header = Buffer.alloc(30); header.writeUInt32LE(0x04034b50, 0); header.writeUInt16LE(20, 4); header.writeUInt16LE(0, 6); header.writeUInt16LE(0, 8); header.writeUInt32LE(crc, 14); header.writeUInt32LE(entry.data.length, 18); header.writeUInt32LE(entry.data.length, 22); header.writeUInt16LE(name.length, 26); const record = Buffer.concat([header, name, entry.data]); local.push(record); const directory = Buffer.alloc(46); directory.writeUInt32LE(0x02014b50, 0); directory.writeUInt16LE(20, 4); directory.writeUInt16LE(20, 6); directory.writeUInt32LE(crc, 16); directory.writeUInt32LE(entry.data.length, 20); directory.writeUInt32LE(entry.data.length, 24); directory.writeUInt16LE(name.length, 28); directory.writeUInt32LE(offset, 42); central.push(Buffer.concat([directory, name])); offset += record.length; } const centralData = Buffer.concat(central); const end = Buffer.alloc(22); end.writeUInt32LE(0x06054b50, 0); end.writeUInt16LE(entries.length, 8); end.writeUInt16LE(entries.length, 10); end.writeUInt32LE(centralData.length, 12); end.writeUInt32LE(offset, 16); return Buffer.concat([...local, centralData, end]); }
function zipNames(buffer) { const names = []; for (let offset = 0; offset <= buffer.length - 46; offset += 1) if (buffer.readUInt32LE(offset) === 0x02014b50) { const length = buffer.readUInt16LE(offset + 28); const extra = buffer.readUInt16LE(offset + 30); const comment = buffer.readUInt16LE(offset + 32); names.push(buffer.subarray(offset + 46, offset + 46 + length).toString("utf8")); offset += 45 + length + extra + comment; } return names; }
function crc32(buffer) { let crc = 0xffffffff; for (const byte of buffer) { crc ^= byte; for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); } return (crc ^ 0xffffffff) >>> 0; }
function createZip(entries) { return zipSync(Object.fromEntries(entries.map((entry) => [entry.name, entry.data])), { level: 9 }); }

View file

@ -7,6 +7,7 @@
*/
import type { CompileResult } from "./model";
import { profileId } from "./profiles";
import { repairCompileLogs, repairExportHistory } from "./history-storage";
import type { CompileLogEntry, ExportHistoryEntry, ExportTarget, ManuscriptCompilerSettings } from "./settings";
/** Ephemeral terminal-operation data converted to persisted history/log entries. */
@ -35,28 +36,37 @@ export class CompileHistoryService {
/** Clears both bounded stores as one persisted settings mutation. */
async clearHistory(): Promise<void> { const settings = this.settings(); settings.exportHistory = []; settings.compileLogs = []; await this.save(); }
/** Returns repaired copies suitable for UI rendering; malformed old entries are discarded. */
getHistory(): ExportHistoryEntry[] { return this.repairHistory(this.settings().exportHistory); }
getHistory(): ExportHistoryEntry[] { return repairExportHistory(this.settings().exportHistory); }
/** Returns structural diagnostics only; compile logs must never contain manuscript prose. */
getLogs(): CompileLogEntry[] { return this.settings().compileLogs.filter((item) => item && typeof item === "object" && typeof item.timestamp === "string"); }
getLogs(): CompileLogEntry[] { return repairCompileLogs(this.settings().compileLogs); }
private async record(record: HistoryRecord, success: boolean, cancelled: boolean): Promise<void> {
const settings = this.settings();
const base: ExportHistoryEntry = {
id: profileId(), timestamp: record.timestamp.toISOString(), profile: record.profile, manuscript: record.manuscript,
outputFiles: [...record.outputFiles], wordCount: record.result?.wordCount ?? 0, success,
cancelled: cancelled || undefined, message: success ? undefined : cancelled ? "Cancelled" : record.message?.split(/\r?\n/)[0]
cancelled: cancelled || undefined, message: success ? undefined : cancelled ? "Cancelled" : redactDiagnostic(record.message)
};
settings.exportHistory = [base, ...this.repairHistory(settings.exportHistory)].slice(0, Math.max(1, settings.maximumExportHistoryEntries));
settings.exportHistory = [base, ...repairExportHistory(settings.exportHistory)].slice(0, Math.max(1, settings.maximumExportHistoryEntries));
if (settings.enableCompileLogs) {
const timings = { scanDurationMs: 0, parseDurationMs: 0, filterDurationMs: 0, generationDurationMs: 0, exportDurationMs: 0, ...record.timings };
const log: CompileLogEntry = { ...base, exportFormats: record.format, compilerVersion: this.compilerVersion, pandocVersion: "Built-in DOCX", durationMs: Date.now() - record.started, ...timings, warnings: record.result?.warnings ?? [], diagnostics: record.message };
const log: CompileLogEntry = { ...base, exportFormats: record.format, compilerVersion: this.compilerVersion, pandocVersion: "Built-in DOCX", durationMs: Date.now() - record.started, ...timings, warnings: warningSummary(record.result), diagnostics: redactDiagnostic(record.message) };
settings.compileLogs = [log, ...this.getLogs()].slice(0, Math.max(1, settings.maximumExportHistoryEntries));
}
await this.save();
}
private repairHistory(entries: unknown): ExportHistoryEntry[] {
if (!Array.isArray(entries)) return [];
return entries.filter((item): item is ExportHistoryEntry => !!item && typeof item === "object" && typeof (item as ExportHistoryEntry).timestamp === "string" && typeof (item as ExportHistoryEntry).profile === "string").map((item) => ({ ...item, outputFiles: Array.isArray(item.outputFiles) ? item.outputFiles.filter((path): path is string => typeof path === "string") : [], wordCount: Number.isFinite(item.wordCount) ? item.wordCount : 0, success: item.success === true }));
}
}
function warningSummary(result?: CompileResult): string[] {
const counts = new Map<string, number>();
for (const issue of result?.issues ?? []) counts.set(issue.code, (counts.get(issue.code) ?? 0) + 1);
return [...counts.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([code, count]) => `${count} × ${code}`);
}
function redactDiagnostic(message?: string): string | undefined {
if (!message) return;
return message.split(/\r?\n/)[0]
.replace(/[A-Za-z]:\\[^\s"”]+/g, "<path redacted>")
.replace(/\/(?:Users|home|private|tmp)\/[^\s"”]+/g, "<path redacted>")
.slice(0, 500);
}

View file

@ -81,6 +81,8 @@ export class CompilePreparationService {
* an invalid root/read/parse failure and never returns a partially prepared session.
*/
async prepareAuthoritative(request: CompilePreparationRequest, signal?: AbortSignal): Promise<PreparedCompileSession> {
const preparationStarted = performance.now();
const scanStarted = performance.now();
const folder = this.vault.getAbstractFileByPath(request.manuscriptRoot);
if (!(folder instanceof TFolder)) throw new Error("The manuscript folder does not exist.");
const suppliedPlan = request.contentPlan;
@ -94,20 +96,29 @@ export class CompilePreparationService {
? applyWorkspacePlanAuthority(resolved, requestSnapshot)
: applyContentPlanAuthority(resolved, folder.path, plan);
const scannedBook = applyContentPlan(new VaultScanner().scan(folder), plan, profile);
const scanMs = performance.now() - scanStarted;
const compiler = new ManuscriptCompiler(this.vault);
const book = await compiler.buildModel(scannedBook, profile, signal);
const preparedAt = new Date();
const preliminary = compiler.compile(book, profile, "", this.wordsPerMinute, preparedAt, signal);
const statistics = compiler.calculateStatistics(book, profile, this.wordsPerMinute);
const variables = {
...profile.variables,
BookTitle: profile.variables.BookTitle || book.title,
Date: preparedAt.toISOString().slice(0, 10),
Year: preparedAt.getFullYear(),
WordCount: preliminary.statistics.totalWordCount,
ChapterCount: preliminary.statistics.chapterCount
WordCount: statistics.totalWordCount,
ChapterCount: statistics.chapterCount
};
const paths = outputPaths(new MarkdownExporter(this.vault), profile, variables);
const result = compiler.compile(book, profile, paths[0] ?? "", this.wordsPerMinute, preparedAt, signal);
const result = compiler.compile(book, profile, paths[0] ?? "", this.wordsPerMinute, preparedAt, signal, statistics);
result.timings = {
totalMs: performance.now() - preparationStarted,
scanMs,
parseMs: Math.max(0, compiler.timings.parseDurationMs - compiler.timings.filterDurationMs),
filterMs: compiler.timings.filterDurationMs,
generationMs: compiler.timings.generationDurationMs,
exportMs: 0
};
const planWarnings: CompileWarning[] = plan
.filter((item) => isPlanItemIncluded(item, plan, folder.path) && item.warning)
.map((item) => ({ severity: "warning", code: "content-plan-warning", message: `${item.name}: ${item.warning}`, path: item.path }));
@ -128,27 +139,21 @@ export class CompilePreparationService {
}
}
/**
* Uses paths plus mtime/size for a lightweight stale-preview guard, falling back
* to content hashing only when an adapter supplies no file statistics.
*/
/** Hashes source contents so equal-size edits or coarse adapter timestamps cannot pass as current. */
export async function calculateSourceFingerprint(vault: Vault, sourcePaths: string[]): Promise<string> {
const entries: string[] = [];
for (const path of [...sourcePaths].sort()) {
const file = vault.getAbstractFileByPath(path);
if (!(file instanceof TFile)) {
entries.push(`${path}:missing`);
continue;
const paths = [...sourcePaths].sort();
const entries = new Array<string>(paths.length); let next = 0;
const worker = async (): Promise<void> => {
while (next < paths.length) {
const index = next; next += 1; const path = paths[index];
const file = vault.getAbstractFileByPath(path);
entries[index] = file instanceof TFile ? `${path}:content:${hash(await vault.cachedRead(file))}` : `${path}:missing`;
}
const stat = file.stat;
if (stat && Number.isFinite(stat.mtime)) entries.push(`${path}:${stat.mtime}:${stat.size}`);
else entries.push(`${path}:content:${hash(await vault.cachedRead(file))}`);
}
};
await Promise.all(Array.from({ length: Math.min(16, paths.length) }, () => worker()));
return hash(entries.join("\n"));
}
/** Identity assertion used by regression tests; semantic equality is insufficient. */
export function sessionMatchesBook(session: PreparedCompileSession, book: Book): boolean { return session.book === book; }
/** Creates exporter input while retaining `session.book` by reference. */
export function createPreparedExportRequest(session: PreparedCompileSession, outputPath: string, keepTemporaryMarkdown: boolean, signal?: AbortSignal, onCommit?: () => void, onProgress?: (stage: ExportProgressStage) => void): ExportRequest {
return { book: session.book, profile: session.profile, markdown: session.result.markdown, outputPath, variables: session.variables, keepTemporaryMarkdown, signal, onCommit, onProgress };

View file

@ -7,7 +7,7 @@
*/
import { Vault } from "obsidian";
import { MarkdownGenerator } from "./markdown-generator";
import type { Book, CompileResult } from "./model";
import type { Book, CompileResult, ManuscriptStatistics } from "./model";
import { ManuscriptParser } from "./parser";
import type { CompileProfile } from "./settings";
import { StatisticsEngine } from "./statistics";
@ -23,16 +23,18 @@ export class ManuscriptCompiler {
readonly timings = { parseDurationMs: 0, filterDurationMs: 0, generationDurationMs: 0 };
/** Parses the authoritative scan once; the resulting Book is retained by the prepared session. */
async buildModel(scan: ScannedBook, profile: CompileProfile, signal?: AbortSignal): Promise<Book> { const started = performance.now(); const book = await this.parser.parse(scan, profile, signal); this.timings.parseDurationMs = performance.now() - started; this.timings.filterDurationMs = this.parser.filterDurationMs; return book; }
/** Calculates template variables once so preparation does not render Markdown twice. */
calculateStatistics(book: Book, profile: CompileProfile, wordsPerMinute: number): ManuscriptStatistics { return this.statistics.calculate(book, profile, wordsPerMinute); }
/**
* Derives deterministic statistics, warnings, and optional Markdown from an
* existing Book. It must not rescan or replace that object because preview and
* native DOCX export depend on object identity.
*/
compile(book: Book, profile: CompileProfile, outputPath: string, wordsPerMinute: number, compileDate = new Date(), signal?: AbortSignal): CompileResult {
compile(book: Book, profile: CompileProfile, outputPath: string, wordsPerMinute: number, compileDate = new Date(), signal?: AbortSignal, preparedStatistics?: ManuscriptStatistics): CompileResult {
throwIfCancelled(signal);
const statistics = this.statistics.calculate(book, profile, wordsPerMinute);
const statistics = preparedStatistics ?? this.calculateStatistics(book, profile, wordsPerMinute);
const generationStarted = performance.now(); const markdown = this.generator.generate(book, profile, statistics, compileDate); this.timings.generationDurationMs = performance.now() - generationStarted; throwIfCancelled(signal);
const issues = this.warnings.analyze(book, profile, outputPath); book.issues = issues;
const issues = this.warnings.analyze(book, profile, outputPath);
return { markdown, parts: profile.useParts ? book.parts.length : 0, chapters: statistics.chapterCount, scenes: statistics.sceneCount,
frontMatter: book.frontMatter.documents.filter((document) => !document.excluded && profile.includeFrontMatter).length,
backMatter: book.backMatter.documents.filter((document) => !document.excluded && profile.includeBackMatter).length,

View file

@ -12,12 +12,11 @@ export interface DiagnosticsContext { pluginVersion: string; obsidianVersion: st
export class DiagnosticsReportGenerator {
generate(context: DiagnosticsContext): string {
const { profile, settings } = context; const lastLog = settings.compileLogs[0]; const historySuccesses = settings.exportHistory.filter((entry) => entry.success).length;
const warningCounts = new Map<string, number>(); settings.compileLogs.flatMap((log) => log.warnings).forEach((warning) => warningCounts.set(warning, (warningCounts.get(warning) ?? 0) + 1));
const warningCount = settings.compileLogs.reduce((total, log) => total + (Array.isArray(log.warnings) ? log.warnings.length : 0), 0);
const lines = ["# Manuscript Compiler Diagnostics", "", `Generated: ${(context.generatedAt ?? new Date()).toISOString()}`, "", "## Environment", "", `- Plugin version: ${context.pluginVersion}`, `- Obsidian version: ${context.obsidianVersion}`, `- Operating system: ${redactEnvironment(context.operatingSystem)}`, "- DOCX engine: Built in", "", "## Active compile profile", "", `- Name: ${profile.name}`, `- Manuscript root: ${profile.manuscriptRoot ? "Configured (path redacted)" : "Not configured"}`, `- Export folder: ${profile.exportFolder ? "Configured (path redacted)" : "Vault root"}`, `- Export target: ${profile.exportTarget}`, `- Chapter source: ${profile.chapterSource}`, `- Uses Parts: ${profile.useParts}`, `- Front matter: ${profile.includeFrontMatter}`, `- Back matter: ${profile.includeBackMatter}`, `- Ordering: ${profile.orderingMethod}`, `- Metadata filters: ${profile.metadataFilters.length} (values redacted)`, "", "## Settings summary", "", `- Profiles: ${settings.profiles.length}`, `- Preview enabled: ${settings.showPreview}`, `- Compile logs enabled: ${settings.enableCompileLogs}`, `- History limit: ${settings.maximumExportHistoryEntries}`, `- Configuration repairs: ${settings.configurationWarnings.length}`, "", "## Latest compile timings", ""];
if (lastLog) lines.push(`- Total: ${lastLog.durationMs} ms`, `- Scan: ${lastLog.scanDurationMs ?? 0} ms`, `- Parse: ${lastLog.parseDurationMs ?? 0} ms`, `- Filter: ${lastLog.filterDurationMs ?? 0} ms`, `- Generation: ${lastLog.generationDurationMs ?? 0} ms`, `- Export: ${lastLog.exportDurationMs ?? 0} ms`); else lines.push("No compile logs recorded.");
lines.push("", "## Warning summary", ""); if (!warningCounts.size) lines.push("No logged warnings."); else [...warningCounts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 25).forEach(([warning, count]) => lines.push(`- ${count}× ${redactSensitiveText(warning, profile)}`));
lines.push("", "## Warning summary", ""); if (!warningCount) lines.push("No logged warnings."); else lines.push(`- Logged warning summaries: ${warningCount} (details omitted for privacy)`);
lines.push("", "## Export history summary", "", `- Entries: ${settings.exportHistory.length}`, `- Successful: ${historySuccesses}`, `- Failed or partial: ${settings.exportHistory.length - historySuccesses}`, "", "> This report intentionally excludes manuscript contents and note text.", ""); return lines.join("\n");
}
}
function redactEnvironment(value: string): string { return value.replace(/\([^)]*\)/g, "(details redacted)").replace(/(?:[A-Za-z]:\\|\/Users\/|\/home\/)[^\s;]+/g, "<path redacted>"); }
function redactSensitiveText(value: string, profile: CompileProfile): string { let redacted = value; for (const sensitive of [profile.referenceDocx, profile.pandocMetadataFile, profile.manuscriptRoot, profile.exportFolder]) if (sensitive) redacted = redacted.split(sensitive).join("<path redacted>"); return redacted.replace(/(?:[A-Za-z]:\\[^\s]+|\/(?:Users|home|private|tmp)\/[^\s]+)/g, "<path redacted>"); }

View file

@ -14,7 +14,6 @@ import { TemplateEngine } from "./template-engine";
import { centimetresToTwips, clampCentimetres } from "./measurements";
const XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`;
const DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
/** Supported generator inputs. Callers may pass unresolved compatibility values. */
export interface DocxOptions {
@ -221,7 +220,7 @@ function packageDocx(document: string, options: ResolvedDocxOptions): Uint8Array
"word/styles.xml": text(stylesXml(options)),
"word/settings.xml": text(`${XML}<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:compat/></w:settings>`),
"docProps/core.xml": text(`${XML}<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"><dc:title>${escapeXml(options.title)}</dc:title><dc:creator>${escapeXml(options.author)}</dc:creator><cp:lastModifiedBy>Manuscript Compiler</cp:lastModifiedBy><dcterms:created xsi:type="dcterms:W3CDTF">${created}</dcterms:created><dcterms:modified xsi:type="dcterms:W3CDTF">${created}</dcterms:modified></cp:coreProperties>`),
"docProps/app.xml": text(`${XML}<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"><Application>Manuscript Compiler</Application><AppVersion>0.9.2</AppVersion></Properties>`)
"docProps/app.xml": text(`${XML}<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties"><Application>Manuscript Compiler</Application></Properties>`)
};
return zipSync(files, { level: 6 });
}
@ -295,5 +294,3 @@ function escapeXml(value: string): string {
function text(value: string): Uint8Array {
return strToU8(value);
}
export { DOCX_MIME };

View file

@ -57,9 +57,11 @@ export class ExportCoordinator {
if (docxPath) { progress.update("Creating DOCX"); outputFiles.push((await new DocxExporter(this.app.vault, markdownExporter).export(request(docxPath))).path); }
throwIfCancelled(operation.signal);
if (markdownPath) { progress.update("Writing Markdown export…"); outputFiles.push((await markdownExporter.export(request(markdownPath))).path); }
const report: CompileResult = { ...session.result, issues: visibleIssues, warnings: visibleIssues.map((issue) => issue.message), timings: { totalMs: Date.now() - started, scanMs: 0, parseMs: 0, filterMs: 0, generationMs: 0, exportMs: performance.now() - exportStarted } };
const exportMs = performance.now() - exportStarted;
const preparedTimings = session.result.timings ?? { totalMs: 0, scanMs: 0, parseMs: 0, filterMs: 0, generationMs: 0, exportMs: 0 };
const report: CompileResult = { ...session.result, issues: visibleIssues, warnings: visibleIssues.map((issue) => issue.message), timings: { ...preparedTimings, totalMs: preparedTimings.totalMs + (Date.now() - started), exportMs } };
progress.finish();
await this.history.recordSuccess({ timestamp, started, profile: session.profile.name, manuscript: session.request.manuscriptRoot, format: session.profile.exportTarget, outputFiles, result: report, timings: { exportDurationMs: report.timings?.exportMs ?? 0 } });
await this.history.recordSuccess({ timestamp, started, profile: session.profile.name, manuscript: session.request.manuscriptRoot, format: session.profile.exportTarget, outputFiles, result: report, timings: { scanDurationMs: report.timings?.scanMs, parseDurationMs: report.timings?.parseMs, filterDurationMs: report.timings?.filterMs, generationDurationMs: report.timings?.generationMs, exportDurationMs: report.timings?.exportMs } });
operation.complete();
if (session.profile.downloadAfterExport && docxPath) await this.actions.saveCopyToComputer(docxPath);
if (options.showResult !== false) this.showResult(outputFiles, docxPath, report);
@ -74,8 +76,8 @@ export class ExportCoordinator {
new Notice("Compilation cancelled. The previous output was not changed.");
return { status: "cancelled", outputFiles };
}
operation.fail(); const message = friendlyExportError(error);
await this.history.recordFailure({ timestamp, started, profile: session.profile.name, manuscript: session.request.manuscriptRoot, format: session.profile.exportTarget, outputFiles, result: session.result, message: error instanceof Error ? error.message : String(error) });
operation.fail(); const baseMessage = friendlyExportError(error); const message = outputFiles.length ? `${baseMessage} Already created: ${outputFiles.join(", ")}.` : baseMessage;
await this.history.recordFailure({ timestamp, started, profile: session.profile.name, manuscript: session.request.manuscriptRoot, format: session.profile.exportTarget, outputFiles, result: session.result, message });
return { status: "failed", outputFiles, error: message };
}
}

View file

@ -23,7 +23,7 @@ export type ExportProgressStage = "Creating DOCX" | "Checking DOCX" | SafeSaveSt
*/
export interface ExportRequest { book: Book; profile: CompileProfile; markdown: string; outputPath: string; variables: TemplateVariables; keepTemporaryMarkdown?: boolean; signal?: AbortSignal; onProgress?: (stage: ExportProgressStage) => void; onCommit?: () => void; }
/** Verified per-format outcome consumed by ExportCoordinator. */
export interface ExportResult { format: string; path: string; file?: TFile; stdout?: string; stderr?: string; }
export interface ExportResult { format: string; path: string; file?: TFile; }
/** Format adapter contract; implementations perform output only, never orchestration. */
export interface Exporter { readonly format: string; export(request: ExportRequest): Promise<ExportResult>; }

View file

@ -5,7 +5,7 @@
* it only after capability checks; mobile and generic adapters retain vault-API
* fallbacks. No shell or child process is used.
*/
interface NodeRequire { (id: "fs/promises"): typeof import("fs/promises"); (id: "os"): typeof import("os"); (id: "path"): typeof import("path"); }
interface NodeRequire { (id: "fs/promises"): typeof import("fs/promises"); (id: "path"): typeof import("path"); }
function nodeRequire(): NodeRequire { return (globalThis as typeof globalThis & { require: NodeRequire }).require; }
export async function pathExists(path: string): Promise<boolean> { if (!path) return false; try { await nodeRequire()("fs/promises").access(path); return true; } catch { return false; } }
export function nodeFs() { return { fs: nodeRequire()("fs/promises"), os: nodeRequire()("os"), path: nodeRequire()("path") }; }
export function nodeFs() { return { fs: nodeRequire()("fs/promises"), path: nodeRequire()("path") }; }

18
src/history-storage.ts Normal file
View file

@ -0,0 +1,18 @@
/** Repairs untrusted persisted history without retaining malformed entries. */
import type { CompileLogEntry, ExportHistoryEntry } from "./settings";
export function repairExportHistory(entries: unknown): ExportHistoryEntry[] {
if (!Array.isArray(entries)) return [];
return entries
.filter((item): item is ExportHistoryEntry => !!item && typeof item === "object" && typeof (item as ExportHistoryEntry).timestamp === "string" && typeof (item as ExportHistoryEntry).profile === "string")
.map((item) => ({ ...item, outputFiles: stringArray(item.outputFiles), wordCount: Number.isFinite(item.wordCount) ? item.wordCount : 0, success: item.success === true }));
}
export function repairCompileLogs(entries: unknown): CompileLogEntry[] {
if (!Array.isArray(entries)) return [];
return entries
.filter((item): item is CompileLogEntry => !!item && typeof item === "object" && typeof (item as CompileLogEntry).timestamp === "string" && typeof (item as CompileLogEntry).profile === "string")
.map((item) => ({ ...item, outputFiles: stringArray(item.outputFiles), warnings: stringArray(item.warnings), diagnostics: typeof item.diagnostics === "string" ? item.diagnostics : undefined }));
}
function stringArray(value: unknown): string[] { return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; }

View file

@ -28,7 +28,6 @@ export interface ManuscriptDocument {
title: string;
number?: number;
metadata: DocumentMetadata;
rawContent: string;
content: string;
excluded: boolean;
exclusionReason?: string;
@ -80,7 +79,6 @@ export interface Book {
includedFiles: TFile[];
excludedFiles: Array<{ file: TFile; reason: string }>;
warnings: string[];
issues: CompileWarning[];
hierarchyDiagnostics?: HierarchyDiagnostic[];
}

View file

@ -7,4 +7,13 @@
*/
import { normalizePath } from "obsidian";
export function validateVaultPath(value: string): string { const raw = value.trim(); if (!raw) return ""; if (/^(?:\/|\\|[A-Za-z]:)/.test(raw)) throw new Error("Export paths must be vault-relative, not absolute."); const normalized = normalizePath(raw.replace(/\/+$/g, "")); if (normalized.split("/").some((segment) => segment === ".." || segment === ".")) throw new Error("Export paths may not contain traversal segments."); if (/[\\:*?"<>|]/.test(normalized)) throw new Error("Export path contains characters that are not portable across supported operating systems."); return normalized; }
export function validateVaultPath(value: string): string {
const raw = value.trim();
if (!raw) return "";
if (/^(?:\/|\\|[A-Za-z]:)/.test(raw)) throw new Error("Export paths must be vault-relative, not absolute.");
// Validate before normalization because adapters may collapse traversal segments.
if (raw.split("/").some((segment) => segment === ".." || segment === ".")) throw new Error("Export paths may not contain traversal segments.");
const normalized = normalizePath(raw.replace(/\/+$/g, ""));
if (/[\\:*?"<>|]/.test(normalized)) throw new Error("Export path contains characters that are not portable across supported operating systems.");
return normalized;
}

View file

@ -66,7 +66,7 @@ export class ManuscriptParser {
root: scan.root, title: scan.root.name,
frontMatter: { kind: "front", title: "Front Matter", documents: frontDocuments },
parts, orphanScenes, backMatter: { kind: "back", title: "Back Matter", documents: backDocuments },
includedFiles, excludedFiles: [...uniqueExclusions.values()], warnings, issues: [], hierarchyDiagnostics: scan.hierarchyDiagnostics
includedFiles, excludedFiles: [...uniqueExclusions.values()], warnings, hierarchyDiagnostics: scan.hierarchyDiagnostics
};
}
@ -84,7 +84,7 @@ export class ManuscriptParser {
const filterStarted = performance.now(); const filter = this.metadataFilter.matches(metadata, settings.metadataFilters); this.filterDurationMs += performance.now() - filterStarted;
const excluded = statusExcluded || !explicitlyIncluded && !filter.included;
const exclusionReason = statusExcluded ? "Editing Status is Excluded" : filter.failedRule ? `${filter.failedRule.field} ${filter.failedRule.operator === "equals" ? "==" : "!="} ${filter.failedRule.value} did not match` : undefined;
return { file, title: file.basename, number: settings.metadataOrdering ? extractNumber(metadata.scene) ?? extractNumber(file.basename) : extractNumber(file.basename), metadata, rawContent, content: this.cleaner.clean(rawContent, settings).trim(), excluded, exclusionReason, metadataError: parsed.error };
return { file, title: file.basename, number: settings.metadataOrdering ? extractNumber(metadata.scene) ?? extractNumber(file.basename) : extractNumber(file.basename), metadata, content: this.cleaner.clean(rawContent, settings).trim(), excluded, exclusionReason, metadataError: parsed.error };
}
private readMetadata(markdown: string): { metadata: DocumentMetadata; error?: string } {

View file

@ -5,7 +5,7 @@
* Electron or filesystem details into modals. ResultActionService is the main
* caller. Every operation fails closed when unavailable.
*/
import { FileSystemAdapter, Vault } from "obsidian";
import { apiVersion, FileSystemAdapter, Platform, Vault } from "obsidian";
/**
* Obsidian documents vault/workspace APIs but does not expose a cross-platform
@ -13,15 +13,11 @@ import { FileSystemAdapter, Vault } from "obsidian";
* These desktop-only Electron bridges are isolated here and always fail closed.
*/
export async function openExternalVaultFile(vault: Vault, vaultPath: string): Promise<boolean> {
if (!(vault.adapter instanceof FileSystemAdapter)) return false;
if (!Platform.isDesktopApp || !(vault.adapter instanceof FileSystemAdapter)) return false;
try { const electron = (globalThis as typeof globalThis & { require?: (id: string) => { shell: { openPath(path: string): Promise<string> } } }).require?.("electron"); if (!electron) return false; return (await electron.shell.openPath(vault.adapter.getFullPath(vaultPath))) === ""; } catch { return false; }
}
export function revealExternalVaultFile(vault: Vault, vaultPath: string): boolean {
if (!(vault.adapter instanceof FileSystemAdapter)) return false;
if (!Platform.isDesktopApp || !(vault.adapter instanceof FileSystemAdapter)) return false;
try { const electron = (globalThis as typeof globalThis & { require?: (id: string) => { shell: { showItemInFolder(path: string): void } } }).require?.("electron"); if (!electron) return false; electron.shell.showItemInFolder(vault.adapter.getFullPath(vaultPath)); return true; } catch { return false; }
}
export function chooseLocalFile(accept: string, callback: (path: string) => void): void {
const input = document.createElement("input"); input.type = "file"; input.accept = accept;
input.setAttribute("aria-label", "Choose local file"); input.addEventListener("change", () => { const selected = input.files?.[0] as (File & { path?: string }) | undefined; if (selected?.path) callback(selected.path); }); input.click();
}
export function getObsidianVersion(): string { try { const electron = (globalThis as typeof globalThis & { require?: (id: string) => { remote?: { app?: { getVersion(): string } } } }).require?.("electron"); return electron?.remote?.app?.getVersion() ?? "Unavailable through documented API"; } catch { return "Unavailable through documented API"; } }
export function getObsidianVersion(): string { return apiVersion; }

View file

@ -8,6 +8,7 @@
import type { CompileProfile, ManuscriptCompilerSettings } from "./settings";
import { DEFAULT_OPTIONS } from "./settings";
import { clampCentimetres, inchesToCentimetres } from "./measurements";
import { repairCompileLogs, repairExportHistory } from "./history-storage";
const VELLUM_OPTIONS = { ...DEFAULT_OPTIONS, orderingMethod: "metadata" as const, metadataOrdering: true, partHeadingTemplate: "Part {number}: {name}", chapterHeadingTemplate: "Chapter {number}: {name}", removeHtmlComments: true, removeDataviewBlocks: true, removeCallouts: true, stripInternalLinks: true };
export function profileId(): string { return `profile-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; }
@ -33,7 +34,14 @@ export function migrateSettings(settings: ManuscriptCompilerSettings): Manuscrip
}
/** Repairs malformed/current fields after migration and records configuration warnings. */
export function repairSettings(settings: ManuscriptCompilerSettings): ManuscriptCompilerSettings {
const warnings: string[] = []; if (!Array.isArray(settings.profiles)) { settings.profiles = []; warnings.push("Invalid profile storage was recovered with default profiles."); } const repaired = migrateSettings(settings);
const warnings: string[] = [];
if (!Array.isArray(settings.profiles)) { settings.profiles = []; warnings.push("Invalid profile storage was recovered with default profiles."); }
else settings.profiles = settings.profiles.map((candidate, index) => {
if (candidate && typeof candidate === "object" && !Array.isArray(candidate)) return candidate;
warnings.push(`Invalid profile entry ${index + 1} was recovered.`);
return createDefaultProfiles()[0];
});
const repaired = migrateSettings(settings);
const activeForMigration = repaired.profiles.find((item) => item.id === repaired.activeProfileId) ?? repaired.profiles[0];
repaired.defaultStructurePreset ??= activeForMigration ? (activeForMigration.useParts ? (activeForMigration.chapterSource === "notes" ? "anthology" : "novel-parts") : activeForMigration.chapterSource === "notes" ? "chapter-notes" : "novel") : "novel-parts";
repaired.defaultDocxStyle ??= repaired.defaultCompilePreset === "vellum" || /vellum/i.test(activeForMigration?.name ?? "") ? "vellum" : "standard";
@ -44,10 +52,12 @@ export function repairSettings(settings: ManuscriptCompilerSettings): Manuscript
repaired.defaultDocxFirstLineIndentCm = clampCentimetres(migratedDefaultIndent, 0, 3.81, repaired.defaultDocxStyle === "vellum" ? 0.75 : 1.27);
if (!Array.isArray(repaired.exportHistory)) { repaired.exportHistory = []; warnings.push("Invalid export history was reset."); }
if (!Array.isArray(repaired.compileLogs)) { repaired.compileLogs = []; warnings.push("Invalid compile logs were reset."); }
repaired.exportHistory = repairExportHistory(repaired.exportHistory);
repaired.compileLogs = repairCompileLogs(repaired.compileLogs);
if (!Number.isFinite(repaired.readingWordsPerMinute) || repaired.readingWordsPerMinute <= 0) { repaired.readingWordsPerMinute = 250; warnings.push("Reading speed was repaired to 250 words per minute."); }
if (!Number.isInteger(repaired.maximumExportHistoryEntries) || repaired.maximumExportHistoryEntries <= 0) { repaired.maximumExportHistoryEntries = 50; warnings.push("Maximum export history was repaired to 50 entries."); }
repaired.profiles = repaired.profiles.map((candidate, index) => {
const item = candidate && typeof candidate === "object" ? candidate : createDefaultProfiles()[0]; if (item !== candidate) warnings.push(`Invalid profile entry ${index + 1} was recovered.`);
const item = candidate;
const defaults = createDefaultProfiles()[0]; const merged = { ...defaults, ...item, variables: { ...defaults.variables, ...(item.variables ?? {}) }, metadataFilters: Array.isArray(item.metadataFilters) ? item.metadataFilters : [] };
const validation = validateProfile(merged); if (validation.errors.length) warnings.push(`Profile “${item.name || index + 1}” has configuration issues: ${validation.errors.join(" ")}`);
if (!merged.id) { merged.id = profileId(); warnings.push(`Profile ${index + 1} was assigned a new identifier.`); }

View file

@ -110,7 +110,13 @@ export class ManuscriptCompilerSettingTab extends PluginSettingTab {
const advanced = container.createEl("details"); advanced.createEl("summary", { text: "Advanced settings and compatibility" });
new Setting(advanced).setName("Active legacy profile").addDropdown((dropdown) => { settings.profiles.forEach((item) => dropdown.addOption(item.id, item.name)); dropdown.setValue(profile.id).onChange(async (id) => { settings.activeProfileId = id; await this.plugin.saveSettings(); this.display(); }); });
new Setting(advanced).setName("Profile actions").addButton((button) => button.setButtonText("New").onClick(() => new ProfileWizardModal(this.app, this.plugin, async (created) => { settings.profiles.push(created); settings.activeProfileId = created.id; await this.plugin.saveSettings(); this.display(); }).open())).addButton((button) => button.setButtonText("Duplicate").onClick(async () => { const copy = duplicateProfile(profile); settings.profiles.push(copy); settings.activeProfileId = copy.id; await this.plugin.saveSettings(); this.display(); })).addButton((button) => button.setButtonText("Delete").setWarning().setDisabled(settings.profiles.length < 2).onClick(async () => { settings.profiles = settings.profiles.filter((item) => item.id !== profile.id); settings.activeProfileId = settings.profiles[0].id; await this.plugin.saveSettings(); this.display(); }));
new Setting(advanced).setName("Profile JSON").addButton((button) => button.setButtonText("Export").onClick(() => new ProfileJsonModal(this.app, "export", profile, () => undefined).open())).addButton((button) => button.setButtonText("Import").onClick(() => new ProfileJsonModal(this.app, "import", profile, async (json) => { if (!json) return; const validation = validateProfile(JSON.parse(json)); if (!validation.profile) { new Notice(validation.errors.join(" "), 8000); return; } settings.profiles.push(validation.profile); settings.activeProfileId = validation.profile.id; await this.plugin.saveSettings(); this.display(); }).open()));
new Setting(advanced).setName("Profile JSON").addButton((button) => button.setButtonText("Export").onClick(() => new ProfileJsonModal(this.app, "export", profile, () => undefined).open())).addButton((button) => button.setButtonText("Import").onClick(() => new ProfileJsonModal(this.app, "import", profile, async (json) => {
if (!json) return;
let parsed: unknown;
try { parsed = JSON.parse(json); } catch { new Notice("Profile JSON is invalid. Correct the JSON syntax and try again.", 8000); return; }
const validation = validateProfile(parsed); if (!validation.profile) { new Notice(validation.errors.join(" "), 8000); return; }
settings.profiles.push(validation.profile); settings.activeProfileId = validation.profile.id; await this.plugin.saveSettings(); this.display();
}).open()));
new Setting(advanced).setName("Export records").addButton((button) => button.setButtonText(`History (${settings.exportHistory.length})`).onClick(() => new ExportHistoryModal(this.app, this.plugin).open())).addButton((button) => button.setButtonText(`Logs (${settings.compileLogs.length})`).onClick(() => new CompileLogsModal(this.app, this.plugin).open()));
}
private text(parent: HTMLElement, name: string, value: string, change: (value: string) => void): void { new Setting(parent).setName(name).addText((text) => text.setValue(value).onChange(async (next) => { change(next.trim()); await this.plugin.saveSettings(); })); }

View file

@ -9,7 +9,6 @@ export function createTestDocx(content: string, title = "Test Book"): Uint8Array
title: "Scene 1",
number: 1,
metadata: { values: {} },
rawContent: content,
content,
excluded: false
} as ManuscriptDocument;
@ -18,7 +17,7 @@ export function createTestDocx(content: string, title = "Test Book"): Uint8Array
frontMatter: { kind: "front", title: "Front Matter", documents: [] },
parts: [{ title: "Book", name: title, path: "Book", synthetic: true, chapters: [{ title: "Chapter 1", name: "", number: 1, path: "Book/Chapter 1", scenes: [document], orphan: false }], orphanScenes: [] }],
orphanScenes: [], backMatter: { kind: "back", title: "Back Matter", documents: [] },
includedFiles: [document.file], excludedFiles: [], warnings: [], issues: []
includedFiles: [document.file], excludedFiles: [], warnings: []
} as Book;
const profile = createDefaultProfiles()[0];
profile.useParts = false;

View file

@ -6,6 +6,8 @@ import { MarkdownGenerator } from "../src/markdown-generator";
import { ManuscriptParser } from "../src/parser";
import { createDefaultProfiles } from "../src/profiles";
import { StatisticsEngine } from "../src/statistics";
import { createManuscriptDocx } from "../src/docx";
import { assertValidDocx } from "../src/docx-validator";
const profile = createDefaultProfiles()[0];
const content = `${"word ".repeat(999)}word`;
@ -25,9 +27,18 @@ const parts = Array.from({ length: 10 }, (_, partIndex) => ({
}))
}));
const scan = { root: { path: "Book", name: "Book" }, frontMatter: [], backMatter: [], looseScenes: [], parts, allMarkdown, warnings: [] } as never;
const started = performance.now();
const parseStarted = performance.now();
const book = await new ManuscriptParser({ cachedRead: async () => content } as never).parse(scan, profile);
const parseMs = performance.now() - parseStarted;
const statisticsStarted = performance.now();
const statistics = new StatisticsEngine().calculate(book, profile, 250);
const statisticsMs = performance.now() - statisticsStarted;
const markdownStarted = performance.now();
const markdown = new MarkdownGenerator().generate(book, profile, statistics, new Date("2026-01-01T00:00:00Z"));
const duration = performance.now() - started;
process.stdout.write(`Large-manuscript benchmark: ${statistics.chapterCount} chapters, ${statistics.sceneCount} scenes, ${statistics.totalWordCount.toLocaleString()} words, ${markdown.length.toLocaleString()} characters in ${Math.round(duration)} ms.\n`);
const markdownMs = performance.now() - markdownStarted;
const docxStarted = performance.now();
const docx = createManuscriptDocx(book, profile, { title: "Benchmark", author: "", titlePage: false });
const docxMs = performance.now() - docxStarted;
assertValidDocx(docx, "Large benchmark DOCX");
const duration = parseMs + statisticsMs + markdownMs + docxMs;
process.stdout.write(`Large-manuscript benchmark: ${statistics.chapterCount} chapters, ${statistics.sceneCount} scenes, ${statistics.totalWordCount.toLocaleString()} words, ${markdown.length.toLocaleString()} Markdown characters, ${docx.length.toLocaleString()} DOCX bytes in ${Math.round(duration)} ms (parse/clean/Book ${Math.round(parseMs)} ms; statistics ${Math.round(statisticsMs)} ms; Markdown ${Math.round(markdownMs)} ms; DOCX/ZIP ${Math.round(docxMs)} ms).\n`);

View file

@ -13,5 +13,6 @@ export class FuzzySuggestModal<T> {}
export class Modal {}
export class Notice {}
export class Setting {}
export const apiVersion = "1.13.1";
export const Platform = { isDesktopApp: true };
export const normalizePath = (value: string): string => value;

View file

@ -5,6 +5,7 @@
* invariants from drifting independently.
*/
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { cleanManuscriptContent, ContentCleaningPipeline, removeCallouts, removeDataviewBlocks, removeHtmlComments, removeObsidianComments, removeProjectMetadataRegions, stripInternalLinks, stripYamlFrontmatter } from "../src/filters";
import { MarkdownGenerator } from "../src/markdown-generator";
import { MetadataFilterEngine } from "../src/metadata-filter";
@ -23,6 +24,7 @@ import { profileFromWizard } from "../src/wizards";
import { loadFixtureScan, loadFixtureTree } from "./fixture-loader";
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { TFile, TFolder } from "obsidian";
import { VaultScanner } from "../src/vault-scanner";
import { MarkdownExporter, validateVaultPath } from "../src/exporter";
@ -31,7 +33,7 @@ import { DOCX_FORMATTING_PRESETS, docxFormattingForPreset, inferStructurePreset,
import type { SimpleCompileRequest } from "../src/simple-workflow";
import { canProceedWithExport } from "../src/export-safety";
import { applyContentPlan, classifyContentPlan, createContentPlan, type ContentPlanItem } from "../src/content-plan";
import { calculateSourceFingerprint, compileInputSignature, CompilePreparationService, createPreparedExportRequest, preparedSessionMatchesInputs, sessionMatchesBook } from "../src/compile-preparation";
import { calculateSourceFingerprint, compileInputSignature, CompilePreparationService, createPreparedExportRequest, preparedSessionMatchesInputs } from "../src/compile-preparation";
import { createManuscriptDocx } from "../src/docx";
import { strFromU8, unzipSync } from "fflate";
import { COMMAND_IDS } from "../src/commands";
@ -50,8 +52,9 @@ import { addCompileFolderMenuItem, COMPILE_FOLDER_MENU_TITLE } from "../src/fold
const tests: Array<[string, () => void | Promise<void>]> = [];
const test = (name: string, action: () => void | Promise<void>): void => { tests.push([name, action]); };
const execFileAsync = promisify(execFile);
async function sourceFiles(folder: string): Promise<string[]> { const entries = await readdir(folder, { withFileTypes: true }); const nested = await Promise.all(entries.map((entry) => entry.isDirectory() ? sourceFiles(path.join(folder, entry.name)) : Promise.resolve(entry.name.endsWith(".ts") ? [path.join(folder, entry.name)] : []))); return nested.flat(); }
const file = (name: string, content: string, metadata: Record<string, unknown> = {}): ManuscriptDocument => ({ file: { name: `${name}.md`, basename: name, path: `Book/${name}.md` } as never, title: name, number: extractNumber(name), metadata: { values: metadata }, rawContent: content, content, excluded: false });
const file = (name: string, content: string, metadata: Record<string, unknown> = {}): ManuscriptDocument => ({ file: { name: `${name}.md`, basename: name, path: `Book/${name}.md` } as never, title: name, number: extractNumber(name), metadata: { values: metadata }, content, excluded: false });
const preparedRequest = (root: string, plan: ReturnType<typeof createContentPlan>): SimpleCompileRequest => ({ manuscriptRoot: root, structurePreset: "novel-parts", includeFrontMatter: true, includeBackMatter: true, exportFolder: "Manuscript Exports", outputFilename: "Warden of Silence.docx", outputFormat: "docx", docxPreset: "vellum", contentPlan: plan, formatting: { font: "Times New Roman", fontSize: 12, lineSpacing: 2, firstLineIndentCm: 1.27, pageSize: "a4", chapterPageBreak: true, titlePage: true }, tableOfContents: false, partDisplay: "word-title", chapterDisplay: "word-title", custom: { variables: { BookTitle: "Warden of Silence", Author: "Anthony Fitzpatrick" }, bodySectionAliases: ["Scene", "Manuscript", "Text", "Draft", "Body"] } });
test("template engine resolves known and unknown variables", () => assert.equal(new TemplateEngine().render("{BookTitle} — {Unknown}", { BookTitle: "North" }), "North —"));
@ -73,7 +76,7 @@ test("parser supports chapter notes in manuscripts without Parts", async () => {
test("bounded concurrency processes every item", async () => { const seen: number[] = []; await mapConcurrent([1, 2, 3, 4], 2, async (value) => { seen.push(value); }); assert.deepEqual(seen.sort(), [1, 2, 3, 4]); });
test("bounded parsing cancels without processing the remaining queue", async () => { const controller = new AbortController(); const seen: number[] = []; await assert.rejects(mapConcurrent([1, 2, 3, 4], 1, async (value) => { seen.push(value); controller.abort(); }, controller.signal), CompilationCancelledError); assert.deepEqual(seen, [1]); });
test("statistics and Markdown generation are deterministic", () => { const scene = file("Scene 1", "one two three"); const profile = createDefaultProfiles()[0]; const book = { root: { path: "Book", name: "Book" }, title: "Book", frontMatter: { kind: "front", title: "Front Matter", documents: [] }, parts: [{ title: "Part 1", name: "Part 1", number: 1, path: "Book/Part 1", chapters: [{ title: "Chapter 1", name: "Chapter 1", number: 1, path: "Book/Part 1/Chapter 1", scenes: [scene], orphan: false }], orphanScenes: [] }], orphanScenes: [], backMatter: { kind: "back", title: "Back Matter", documents: [] }, includedFiles: [scene.file], excludedFiles: [], warnings: [], issues: [] } as Book; const stats = new StatisticsEngine().calculate(book, profile, 250); assert.equal(stats.totalWordCount, 3); const generator = new MarkdownGenerator(); const date = new Date("2026-01-02T00:00:00Z"); assert.equal(generator.generate(book, profile, stats, date), generator.generate(book, profile, stats, date)); assert.match(generator.generate(book, profile, stats, date), /^# Part 1/); const issues = new WarningEngine().analyze(book, profile, "Exports/Book.md"); assert.ok(issues.some((issue) => issue.code === "missing-front-matter")); });
test("output paths reject traversal, absolute paths, and nonportable characters", () => { assert.equal(validateVaultPath("Exports/Book"), "Exports/Book"); assert.throws(() => validateVaultPath("../Book"), /traversal/); assert.throws(() => validateVaultPath("/tmp/Book"), /vault-relative/); assert.throws(() => validateVaultPath("C:\\Book"), /vault-relative/); assert.throws(() => validateVaultPath("Exports/Bad?Name"), /portable/); });
test("output paths reject traversal before adapter normalization, absolute paths, and nonportable characters", () => { assert.equal(validateVaultPath("Exports/Book"), "Exports/Book"); for (const unsafe of ["../Book", "Exports/../Book", "Exports/./Book", "Exports/../../Book"]) assert.throws(() => validateVaultPath(unsafe), /traversal/); assert.throws(() => validateVaultPath("/tmp/Book"), /vault-relative/); assert.throws(() => validateVaultPath("C:\\Book"), /vault-relative/); assert.throws(() => validateVaultPath("Exports/Bad?Name"), /portable/); });
test("blocking output safety errors disable export", () => { assert.equal(canProceedWithExport([{ severity: "error", code: "output-inside-root", message: "unsafe" }], "markdown", true), false); assert.equal(canProceedWithExport([{ severity: "warning", code: "output-exists", message: "confirm" }], "markdown", true), true); assert.equal(canProceedWithExport([], "docx", false), false); });
test("native DOCX defaults require no desktop or Pandoc state", () => { assert.equal(DEFAULT_SETTINGS.defaultExportFormat, "docx"); assert.equal(DEFAULT_SETTINGS.defaultDocxStyle, "vellum"); assert.equal(DEFAULT_SETTINGS.automaticallyDetectPandoc, false); });
for (const preset of ["novel-parts", "novel", "chapter-notes", "short-story", "anthology", "custom"] as const) test(`simple workflow resolves ${preset}`, () => { const base = createDefaultProfiles()[0]; const profile = resolveSimpleCompileRequest({ manuscriptRoot: "Books/Test", structurePreset: preset, includeFrontMatter: true, includeBackMatter: false, exportFolder: "Exports", outputFilename: "Test.docx", outputFormat: "docx", docxPreset: "vellum", custom: { useParts: false, chapterSource: "folders" } }, base); assert.equal(profile.manuscriptRoot, "Books/Test"); assert.equal(profile.exportFolder, "Exports"); assert.equal(profile.exportTarget, "docx"); assert.equal(profile.referenceDocx, ""); });
@ -115,8 +118,11 @@ test("Stage 5 profiles migrate to release-candidate structure", () => { const ol
test("profile wizard creates a usable Vellum note-chapter profile", () => { const profile = profileFromWizard({ name: "Submission", manuscriptRoot: "Books/Submission", exportFolder: "Exports", chapterSource: "notes", useParts: false, sceneSeparators: false, includeFrontMatter: true, includeBackMatter: false, referenceDocx: "", vellum: true }); assert.equal(profile.chapterSource, "notes"); assert.equal(profile.useParts, false); assert.equal(profile.sceneSeparator, ""); assert.match(profile.chapterHeadingTemplate, /Chapter/); });
test("diagnostics excludes manuscript content and reports timings", () => { const profile = createDefaultProfiles()[0]; const settings = { ...DEFAULT_SETTINGS, profiles: [profile], activeProfileId: profile.id, defaultProfileId: profile.id, compileLogs: [{ id: "1", timestamp: "2026-01-01", profile: profile.name, manuscript: "Book", outputFiles: [], wordCount: 10, success: true, exportFormats: "markdown", compilerVersion: "0.6.0", durationMs: 10, scanDurationMs: 1, parseDurationMs: 2, filterDurationMs: 1, generationDurationMs: 3, exportDurationMs: 1, warnings: [] }] } as never; const report = new DiagnosticsReportGenerator().generate({ pluginVersion: "0.8.0", obsidianVersion: "1.13.1", operatingSystem: "Test OS", profile, settings, generatedAt: new Date("2026-01-01T00:00:00Z") }); assert.match(report, /Parse: 2 ms/); assert.match(report, /Built in/); assert.match(report, /intentionally excludes manuscript contents/); });
test("diagnostics redact absolute paths, legacy reference paths, and filter values", () => { const profile = createDefaultProfiles()[0]; profile.manuscriptRoot = "/Users/alice/SecretBook"; profile.referenceDocx = "/Users/alice/Templates/private.docx"; profile.metadataFilters = [{ id: "1", field: "POV", operator: "equals", value: "SecretName" }]; const settings = { ...DEFAULT_SETTINGS, profiles: [profile], activeProfileId: profile.id, defaultProfileId: profile.id } as never; const report = new DiagnosticsReportGenerator().generate({ pluginVersion: "0.8.0", obsidianVersion: "1.13.1", operatingSystem: "Mac /Users/alice", profile, settings }); assert.doesNotMatch(report, /alice|private\.docx|SecretName/); });
test("diagnostics omit legacy warning details that may contain private metadata", () => { const profile = createDefaultProfiles()[0]; const settings = { ...DEFAULT_SETTINGS, profiles: [profile], activeProfileId: profile.id, defaultProfileId: profile.id, compileLogs: [{ timestamp: "2026-01-01", warnings: ["Invalid YAML near SecretCharacter: Elin"] }] } as never; const report = new DiagnosticsReportGenerator().generate({ pluginVersion: "0.9.2", obsidianVersion: "1.13.1", operatingSystem: "Test", profile, settings }); assert.match(report, /details omitted for privacy/); assert.doesNotMatch(report, /SecretCharacter|Elin/); });
for (const version of ["0.1", "0.2", "0.3", "0.4", "0.5", "0.6"]) test(`settings migration is repeatable from ${version}`, () => { const legacyProfile = createDefaultProfiles()[0] as unknown as Record<string, unknown>; legacyProfile.manuscriptRoot = "Books/Legacy"; legacyProfile.exportFolder = "Exports/Legacy"; if (version < "0.4") { delete legacyProfile.exportTarget; delete legacyProfile.referenceDocx; } if (version < "0.6") { delete legacyProfile.useParts; delete legacyProfile.chapterSource; } const hasProfiles = version >= "0.3"; const originalId = legacyProfile.id as string; const legacy = { ...DEFAULT_SETTINGS, profiles: hasProfiles ? [legacyProfile] : [], activeProfileId: hasProfiles ? originalId : "", defaultProfileId: hasProfiles ? originalId : "", defaultManuscriptFolder: "Books/Legacy", defaultExportFolder: "Exports/Legacy", includeBackMatter: false } as never; const once = repairSettings(legacy); const snapshot = JSON.stringify(once); const twice = repairSettings(once); assert.equal(JSON.stringify(twice), snapshot); assert.ok(twice.profiles.length >= 1); const migrated = twice.profiles.find((profile) => profile.manuscriptRoot === "Books/Legacy"); assert.ok(migrated); if (hasProfiles) assert.equal(migrated.id, originalId); else assert.equal(migrated.includeBackMatter, false); });
test("settings repair recovers malformed profile entries before migration", () => { const repaired = repairSettings({ ...DEFAULT_SETTINGS, profiles: [null] } as never); assert.equal(repaired.profiles.length, 1); assert.ok(repaired.profiles[0].id); assert.ok(repaired.configurationWarnings.some((warning) => warning.includes("Invalid profile entry"))); });
test("settings repair removes malformed history and normalizes log arrays", () => { const profile = createDefaultProfiles()[0]; const repaired = repairSettings({ ...DEFAULT_SETTINGS, profiles: [profile], activeProfileId: profile.id, defaultProfileId: profile.id, exportHistory: [null], compileLogs: [{ timestamp: "2026-01-01", profile: "Test", outputFiles: null, warnings: null }] } as never); assert.deepEqual(repaired.exportHistory, []); assert.deepEqual(repaired.compileLogs[0].outputFiles, []); assert.deepEqual(repaired.compileLogs[0].warnings, []); });
test("large-manuscript pipeline handles 500 chapters, 2,000 scenes, and 2 million words", async () => { const profile = createDefaultProfiles()[0]; const content = `${"word ".repeat(999)}word`; let sceneIndex = 0; const allMarkdown: Array<{ name: string; basename: string; path: string }> = []; const parts = Array.from({ length: 10 }, (_, partIndex) => ({ folder: { name: `Part ${partIndex + 1}`, path: `Book/Part ${partIndex + 1}` }, looseScenes: [], chapters: Array.from({ length: 50 }, (_, chapterIndex) => ({ folder: { name: `Chapter ${partIndex * 50 + chapterIndex + 1}`, path: `Book/Part ${partIndex + 1}/Chapter ${chapterIndex + 1}` }, scenes: Array.from({ length: 4 }, () => { const name = `Scene ${++sceneIndex}`; const scene = { name: `${name}.md`, basename: name, path: `Book/Part ${partIndex + 1}/Chapter ${chapterIndex + 1}/${name}.md` }; allMarkdown.push(scene); return scene; }) })) })); const scan = { root: { path: "Book", name: "Book" }, frontMatter: [], backMatter: [], looseScenes: [], parts, allMarkdown, warnings: [] } as never; const started = performance.now(); const book = await new ManuscriptParser({ cachedRead: async () => content } as never).parse(scan, profile); const stats = new StatisticsEngine().calculate(book, profile, 250); const generator = new MarkdownGenerator(); const date = new Date("2026-01-01T00:00:00Z"); const markdown = generator.generate(book, profile, stats, date); assert.equal(stats.chapterCount, 500); assert.equal(stats.sceneCount, 2000); assert.equal(stats.totalWordCount, 2_000_000); assert.equal(generator.generate(book, profile, stats, date), markdown); assert.ok(markdown.length > 9_000_000); const duration = performance.now() - started; assert.ok(duration < 15_000, `Possible runaway regression: expected under 15 seconds, received ${Math.round(duration)} ms`); process.stdout.write(` synthetic full parse-to-Markdown correctness run: ${Math.round(duration)} ms (informational)\n`); });
for (const fixture of [
@ -159,13 +165,14 @@ test("existing-output warnings do not block overwrite handling", () => { assert.
test("native DOCX generation has no platform or executable prerequisite", () => { const bytes = createTestDocx("Text", "Book"); assert.equal(String.fromCharCode(bytes[0], bytes[1]), "PK"); });
test("Warden regression exports only publishable content through a transparent Manuscript container", async () => { const loaded = await loadFixtureTree("samples/Book 1 - Warden of Silence"); const plan = await classifyContentPlan(loaded.vault as never, createContentPlan(loaded.root, "novel-parts")); const role = (suffix: string) => plan.find((item) => item.path.endsWith(suffix)); assert.equal(role("/Manuscript")?.role, "transparent"); for (const folder of ["Archive", "Development", "Exports"]) { assert.equal(role(`/${folder}`)?.role, "ignore"); assert.equal(role(`/${folder}`)?.included, false); } assert.equal(role("/Warden of Silence Dashboard.md")?.role, "ignore"); const profile = createDefaultProfiles()[1]; profile.useParts = true; profile.chapterSource = "folders"; profile.contentOrder = plan.filter((item) => item.included).map((item) => item.path); profile.bodySectionAliases = ["Scene", "Manuscript", "Text", "Draft", "Body"]; const scan = applyContentPlan(new VaultScanner().scan(loaded.root), plan, profile); const book = await new ManuscriptParser(loaded.vault as never).parse(scan, profile); const markdown = new MarkdownGenerator().generate(book, profile, new StatisticsEngine().calculate(book, profile, 250), new Date("2026-01-01")); const forbidden = ["Archive", "Development", "Previous Export", "Dashboard", "Revision Notes", "Internal synopsis", "Editing Status", "Characters:", "Part 0", "Chapter 0", "# Manuscript"]; forbidden.forEach((value) => assert.doesNotMatch(markdown, new RegExp(value, "i"), value)); assert.equal((markdown.match(/The Silence Breaks/g) ?? []).length, 1); assert.equal((markdown.match(/The Silence of Östersund/g) ?? []).length, 1); assert.equal((markdown.match(/Östersund was silent/g) ?? []).length, 1); assert.equal((markdown.match(/The barn door opened/g) ?? []).length, 1); assert.ok(markdown.indexOf("Copyright ©") < markdown.indexOf("The Silence Breaks")); assert.ok(markdown.indexOf("The Weight of Knowing") < markdown.indexOf("With gratitude")); assert.equal((markdown.match(/^#$/gm) ?? []).length, 1); });
test("prepared preview is built from the final Warden Book model used by DOCX", async () => { const loaded = await loadFixtureTree("samples/Book 1 - Warden of Silence"); const plan = await classifyContentPlan(loaded.vault as never, createContentPlan(loaded.root, "novel-parts")); const request = preparedRequest(loaded.root.path, plan); const session = await new CompilePreparationService(loaded.vault as never, createDefaultProfiles()[1], 250).prepare(request, plan); assert.equal(session.book.title, "Book 1 - Warden of Silence"); assert.equal(session.book.parts.length, 2); assert.deepEqual(session.book.parts.map((part) => part.chapters.map((chapter) => [chapter.name, chapter.scenes.filter((scene) => !scene.excluded && scene.content.trim()).length])), [[ ["The Silence of Östersund", 2] ], [ ["The Cold Welcome", 1] ]]); assert.equal(session.statistics.sceneCount, 3); assert.equal(session.book.frontMatter.documents.filter((item) => !item.excluded && item.content.trim()).length, 2); assert.equal(session.book.backMatter.documents.filter((item) => !item.excluded && item.content.trim()).length, 1); assert.ok(sessionMatchesBook(session, session.book)); assert.equal(createPreparedExportRequest(session, session.outputPaths[0], false).book, session.book); const bytes = createManuscriptDocx(session.book, session.profile, { title: "Warden of Silence", author: "Anthony Fitzpatrick", titlePage: true, partDisplay: "word-title", chapterDisplay: "word-title" }); const document = strFromU8(unzipSync(bytes)["word/document.xml"]); const forbidden = ["Archive", "Development", "Exports", "Dashboard", "Revision Notes", "Synopsis", "Internal synopsis", "Editing Status", "Characters:", "The Watchers of Silence Series Dashboard"]; for (const value of forbidden) { assert.doesNotMatch(session.result.markdown, new RegExp(value, "i")); assert.doesNotMatch(document, new RegExp(value, "i")); } });
test("prepared preview is built from the final Warden Book model used by DOCX", async () => { const loaded = await loadFixtureTree("samples/Book 1 - Warden of Silence"); const plan = await classifyContentPlan(loaded.vault as never, createContentPlan(loaded.root, "novel-parts")); const request = preparedRequest(loaded.root.path, plan); const session = await new CompilePreparationService(loaded.vault as never, createDefaultProfiles()[1], 250).prepare(request, plan); assert.equal(session.book.title, "Book 1 - Warden of Silence"); assert.equal(session.book.parts.length, 2); assert.deepEqual(session.book.parts.map((part) => part.chapters.map((chapter) => [chapter.name, chapter.scenes.filter((scene) => !scene.excluded && scene.content.trim()).length])), [[ ["The Silence of Östersund", 2] ], [ ["The Cold Welcome", 1] ]]); assert.equal(session.statistics.sceneCount, 3); assert.equal(session.book.frontMatter.documents.filter((item) => !item.excluded && item.content.trim()).length, 2); assert.equal(session.book.backMatter.documents.filter((item) => !item.excluded && item.content.trim()).length, 1); assert.equal(createPreparedExportRequest(session, session.outputPaths[0], false).book, session.book); assert.ok((session.result.timings?.totalMs ?? -1) >= 0); assert.ok((session.result.timings?.generationMs ?? -1) >= 0); const bytes = createManuscriptDocx(session.book, session.profile, { title: "Warden of Silence", author: "Anthony Fitzpatrick", titlePage: true, partDisplay: "word-title", chapterDisplay: "word-title" }); const document = strFromU8(unzipSync(bytes)["word/document.xml"]); const forbidden = ["Archive", "Development", "Exports", "Dashboard", "Revision Notes", "Synopsis", "Internal synopsis", "Editing Status", "Characters:", "The Watchers of Silence Series Dashboard"]; for (const value of forbidden) { assert.doesNotMatch(session.result.markdown, new RegExp(value, "i")); assert.doesNotMatch(document, new RegExp(value, "i")); } });
test("empty cleaned notes are excluded from both prepared preview counts and DOCX", async () => { const loaded = await loadFixtureTree("samples/Book 1 - Warden of Silence"); const emptyPath = `${loaded.root.path}/Manuscript/Part 1 - The Silence Breaks/Chapter 1 - The Silence of Östersund/Scene 2 - The Barn.md`; loaded.setContent(emptyPath, "# Synopsis\n\nOnly an internal synopsis.\n\n# Revision Notes\n\nNothing publishable."); const plan = await classifyContentPlan(loaded.vault as never, createContentPlan(loaded.root, "novel-parts")); const emptyItem = plan.find((item) => item.path === emptyPath)!; emptyItem.included = true; emptyItem.role = "scene"; emptyItem.userOverride = true; const request = preparedRequest(loaded.root.path, plan); const session = await new CompilePreparationService(loaded.vault as never, createDefaultProfiles()[1], 250).prepare(request, plan); assert.equal(session.statistics.sceneCount, 2); assert.ok(session.exclusions.some((item) => item.path === emptyPath && /No manuscript body remains/i.test(item.reason))); const document = strFromU8(unzipSync(createManuscriptDocx(session.book, session.profile, { title: "Warden", author: "Author" }))["word/document.xml"]); assert.doesNotMatch(session.result.markdown, /Only an internal synopsis|Nothing publishable/); assert.doesNotMatch(document, /Only an internal synopsis|Nothing publishable/); });
test("prepared-session input signatures invalidate on inclusion, role, order, and root changes", async () => { const loaded = await loadFixtureTree("samples/Book 1 - Warden of Silence"); const plan = await classifyContentPlan(loaded.vault as never, createContentPlan(loaded.root, "novel-parts")); const request = preparedRequest(loaded.root.path, plan); const session = await new CompilePreparationService(loaded.vault as never, createDefaultProfiles()[1], 250).prepare(request, plan); assert.ok(preparedSessionMatchesInputs(session, request, plan)); const target = plan.find((item) => item.kind === "note" && item.included)!; const included = target.included; target.included = !included; assert.ok(!preparedSessionMatchesInputs(session, request, plan)); target.included = included; const role = target.role; target.role = role === "scene" ? "chapter" : "scene"; assert.ok(!preparedSessionMatchesInputs(session, request, plan)); target.role = role; const order = target.order; target.order += 1; assert.ok(!preparedSessionMatchesInputs(session, request, plan)); target.order = order; const root = request.manuscriptRoot; request.manuscriptRoot = `${root} changed`; assert.ok(!preparedSessionMatchesInputs(session, request, plan)); request.manuscriptRoot = root; assert.equal(compileInputSignature(request, plan), session.inputSignature); });
test("source changes block a prepared session and refresh preparation rebuilds it", async () => { const loaded = await loadFixtureTree("samples/Book 1 - Warden of Silence"); const plan = await classifyContentPlan(loaded.vault as never, createContentPlan(loaded.root, "novel-parts")); const request = preparedRequest(loaded.root.path, plan); const service = new CompilePreparationService(loaded.vault as never, createDefaultProfiles()[1], 250); const first = await service.prepare(request, plan); const scenePath = `${loaded.root.path}/Manuscript/Part 2 - The Weight of Knowing/Chapter 11 - The Cold Welcome/Scene 1.md`; loaded.setContent(scenePath, "# Scene\n\nFresh preview prose after a source edit."); assert.notEqual(await calculateSourceFingerprint(loaded.vault as never, first.sourcePaths), first.sourceFingerprint); const refreshed = await service.prepare(request, plan); assert.notEqual(refreshed.sourceFingerprint, first.sourceFingerprint); assert.match(refreshed.result.markdown, /Fresh preview prose after a source edit/); assert.doesNotMatch(first.result.markdown, /Fresh preview prose after a source edit/); });
test("source fingerprints detect equal-size edits with unchanged file statistics", async () => { const source = Object.assign(new TFile(), { path: "Book/Scene.md", stat: { mtime: 1, size: 4 } }); let content = "aaaa"; const vault = { getAbstractFileByPath: () => source, cachedRead: async () => content } as never; const before = await calculateSourceFingerprint(vault, [source.path]); content = "bbbb"; const after = await calculateSourceFingerprint(vault, [source.path]); assert.notEqual(after, before); });
test("all automatic compile routes produce the same safe Warden semantic manuscript", async () => {
const loaded = await loadFixtureTree("samples/Book 1 - Warden of Silence"); const profile = createDefaultProfiles()[1];
@ -363,6 +370,7 @@ test("Formatting UI exposes metric indentation and every supported scene break d
test("export preview view model references the exact prepared Book", async () => { const loaded = await loadFixtureTree("samples/Book 1 - Warden of Silence"); const plan = await classifyContentPlan(loaded.vault as never, createContentPlan(loaded.root, "novel-parts")); const session = await new CompilePreparationService(loaded.vault as never, createDefaultProfiles()[1], 250).prepare(preparedRequest(loaded.root.path, plan), plan); const view = buildExportPreviewViewModel(session); assert.equal(view.book, session.book); assert.equal(createPreparedExportRequest(session, session.outputPaths[0], false).book, view.book); assert.deepEqual(view.parts.map((part) => part.chapters.map((chapter) => chapter.sceneCount)), [[2], [1]]); });
test("history service records success only after requested and distinguishes failure/cancellation", async () => { const settings = { ...DEFAULT_SETTINGS, exportHistory: [], compileLogs: [], maximumExportHistoryEntries: 10, enableCompileLogs: true }; let saves = 0; const history = new CompileHistoryService(() => settings, async () => { saves += 1; }, "0.9.1"); const record = { timestamp: new Date("2026-01-01"), started: Date.now(), profile: "Test", manuscript: "Book", format: "docx" as const, outputFiles: ["Exports/Book.docx"] }; assert.equal(history.getHistory().length, 0); await history.recordFailure({ ...record, message: "failed" }); await history.recordCancellation(record); await history.recordSuccess(record); assert.deepEqual(history.getHistory().map((item) => [item.success, item.cancelled]), [[true, undefined], [false, true], [false, undefined]]); assert.equal(saves, 3); });
test("compile logs persist warning codes and redact absolute failure paths", async () => { const settings = { ...DEFAULT_SETTINGS, exportHistory: [], compileLogs: [], maximumExportHistoryEntries: 10, enableCompileLogs: true }; const history = new CompileHistoryService(() => settings, async () => undefined, "0.9.2"); const result = { wordCount: 10, issues: [{ severity: "warning", code: "invalid-metadata", message: "SecretCharacter: Elin" }, { severity: "warning", code: "invalid-metadata", message: "SecretCharacter: Henrik" }] } as never; await history.recordFailure({ timestamp: new Date("2026-01-01"), started: Date.now(), profile: "Test", manuscript: "Book", format: "docx", outputFiles: [], result, message: "Failed at /Users/alice/SecretBook/output.docx" }); assert.deepEqual(settings.compileLogs[0].warnings, ["2 × invalid-metadata"]); assert.doesNotMatch(JSON.stringify(settings.compileLogs[0]), /Elin|Henrik|alice|SecretBook/); });
test("failed results expose no platform result actions", () => { const actions = new ResultActionService({ vault: { adapter: {} } } as never); assert.deepEqual(actions.capabilities("Exports/Book.docx", false), { open: false, reveal: false, saveCopy: false }); });
@ -377,10 +385,18 @@ test("0.9.2 production source has one preparation/export route and no external e
assert.doesNotMatch(source, /from\s+["'](?:node:)?child_process["']|\b(?:execFile|execSync|spawnSync)\s*\(/);
assert.doesNotMatch(source, /\bfetch\s*\(|XMLHttpRequest|new\s+WebSocket|sendBeacon|telemetry|plugins\.getPlugin|app\.plugins/);
assert.doesNotMatch(source, /createDocx\s*\(/);
assert.deepEqual(entries.filter(([, content]) => /pandoc/i.test(content)).map(([file]) => file).sort(), ["src/compile-history.ts", "src/diagnostics.ts", "src/profiles.ts", "src/settings.ts", "src/simple-workflow.ts"]);
assert.deepEqual(entries.filter(([, content]) => /pandoc/i.test(content)).map(([file]) => file).sort(), ["src/compile-history.ts", "src/profiles.ts", "src/settings.ts", "src/simple-workflow.ts"]);
});
test("0.9.2 version metadata and release package allowlist are consistent", async () => { const manifest = JSON.parse(await readFile("manifest.json", "utf8")); const packageJson = JSON.parse(await readFile("package.json", "utf8")); const lock = JSON.parse(await readFile("package-lock.json", "utf8")); const versions = JSON.parse(await readFile("versions.json", "utf8")); const packaging = await readFile(path.join("scripts", "package.mjs"), "utf8"); assert.equal(manifest.version, "0.9.2"); assert.equal(packageJson.version, manifest.version); assert.equal(lock.version, manifest.version); assert.equal(lock.packages[""].version, manifest.version); assert.equal(versions[manifest.version], manifest.minAppVersion); assert.match(packaging, /const required = \["main\.js", "manifest\.json", "styles\.css"\]/); assert.doesNotMatch(packaging, /data\.json|node_modules|\.test-build/); });
test("repository hygiene ignores runtime state and rejects tracked plugin data", async () => {
for (const candidate of ["data.json", ".obsidian/plugins/manuscript-compiler/data.json", "nested/manuscript-compiler/data.json"]) {
await execFileAsync("git", ["check-ignore", "--no-index", "--quiet", candidate]);
}
const { stdout } = await execFileAsync("git", ["ls-files", "-z"], { encoding: "utf8" });
const prohibited = stdout.split("\0").filter((file) => /(?:^|\/)(?:\.obsidian\/plugins\/)?manuscript-compiler\/data\.json$/i.test(file) || /^data\.json$/i.test(file));
assert.deepEqual(prohibited, []);
});
let failures = 0;
for (const [name, action] of tests) { try { await action(); process.stdout.write(`${name}\n`); } catch (error) { failures += 1; process.stderr.write(`${name}\n${error instanceof Error ? error.stack : String(error)}\n`); } }