Document compiler architecture and invariants

This commit is contained in:
Anthony Fitzpatrick 2026-07-13 23:36:18 +02:00
parent db8d2e2d3d
commit 8ea273215c
58 changed files with 722 additions and 2 deletions

View file

@ -1,5 +1,16 @@
# Compile Route Architecture
This document is the maintainer handbook for the runtime architecture. The README explains the product to authors; this file explains where a developer can safely change it and which boundaries must remain intact.
## Mental model
The plugin deliberately has two models:
- `ScannedBook` is a permissive physical discovery model. It describes what exists below a folder and is never safe to export directly.
- `Book` is the semantic publishing model. It contains only prepared front matter, Parts, Chapters, Scenes, and back matter and is the sole input to renderers.
`ContentPlanItem[]` is the bridge between them. Automatic inference proposes roles, while the Contents workspace records authoritative author corrections. This intermediate plan exists because vault organisation is not publishing structure: project folders, transparent containers, dashboards, and template notes can coexist beside manuscript prose.
Every production route resolves a `TFolder` through `BookRootResolver` and crosses one root-to-model boundary: `CompilePreparationService.prepareAuthoritative()` in `src/compile-preparation.ts`. `VaultScanner` performs mechanical discovery only. No command, validator, or exporter may convert its raw `ScannedBook` independently.
## Composition and runtime boundaries
@ -77,6 +88,14 @@ The workspace plan wins over inference and legacy profile structure. Automatic r
`createContentPlan()` records each detected role, recognises dedicated and mixed matter containers, and treats nested folders repeating the selected root name as transparent when they are not explicit Parts or Chapters. `applyContentPlan()` reconstructs the scan using each items nearest included Part/Chapter ancestor. Transparent folders therefore flatten only their own heading; they do not flatten or detach the structural descendants below them. Manual order is read from the authoritative global content order.
## 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.
## Content-cleaning boundary
`ManuscriptParser` reads each included file and delegates mandatory manuscript-body cleaning to `filters.ts`. Body-heading extraction runs before optional syntax conversion. Structured metadata removal is deliberately limited to recognised property regions, tables, and note boundaries; broad “lines beginning with Book/Chapter” deletion would destroy valid fiction prose. Any new cleaner must be deterministic, prose-preserving, and covered by an exact leakage regression.
## Execution invariants
- The selected root names the book and is never a Part or Chapter.
@ -113,3 +132,43 @@ The workspace plan wins over inference and legacy profile structure. Automatic r
| Scene titles | compatibility-only `includeSceneTitles` | functional for legacy profiles but not exposed in the normal workflow |
The `Subtitle` style was removed because the semantic model has no supported subtitle field. Legacy custom heading templates, `includeSceneTitles`, and the stored `removeCallouts` field remain data-compatible; the active wording for the latter is **Convert callouts to plain text**. Pandoc/reference-document fields remain migration-only and are not active formatting controls. Margins are fixed at one inch, and separate front/back page-behaviour toggles are not exposed.
## Safe-saving transaction
`createManuscriptDocx()` returns complete bytes and never accesses the vault. `DocxExporter` validates those bytes and passes them to `SafeBinaryWriter`, the only normal DOCX destination-writing path.
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.
## Settings and migration
`settings.ts` is the persisted schema. `profiles.ts` performs historical migration followed by repair. Migration retains obsolete fields when deleting them would lose user data, but retained Pandoc fields are inert. Both stages must remain idempotent: applying them twice must produce the same serialised settings.
When adding a persisted field:
1. Add a safe new-user default.
2. Repair missing or malformed values without overwriting valid explicit values.
3. Add a realistic old-settings migration test and run it twice.
4. Decide whether the field belongs in the normal workspace, Advanced compatibility UI, or storage only.
## Testing map
- `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/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.
Automated XML tests establish semantic structure, not visual pagination in Word/Vellum. Keep application-level checks in `MANUAL_TESTING.md` truthful and unchecked until performed.
## Safe extension points
- Add folder/note aliases in `content-plan.ts`, with fixture tests proving both inferred roles and final Book structure.
- Add a deterministic warning in `warnings.ts`; keep blocking policy in `export-safety.ts`.
- Add a supported DOCX option by carrying it from `SimpleCompileRequest` through resolved profile fields into `DocxOptions`, then assert its XML effect. Do not expose the control first.
- Add a result capability behind `ResultActionService` and `platform-compat.ts`; UI should only render reported capabilities.
- Add a cleaner in `filters.ts` only when it can distinguish authoring syntax from ordinary prose.
Areas requiring cross-pipeline review are content-plan authority, Book shape, prepared-session identity/fingerprints, migration, DOCX styles, and SafeBinaryWriter commit order. These should not be changed as local conveniences.

View file

@ -1,3 +1,10 @@
/**
* Manuscript Compiler central book-root policy.
*
* Explicit roots are returned exactly and never replaced by ancestors or
* children. Legacy commands may infer from configuration or active-note ancestry.
* The root names the Book but is never emitted as a structural node.
*/
import { TFile, TFolder, type Vault } from "obsidian";
const BOOK_STRUCTURE_PATTERN = /^(?:part\b|(?:ebook |print )?(?:front|back) matter$|manuscript$|drafts?$|chapters$)/i;

View file

@ -1,2 +1,8 @@
/**
* Manuscript Compiler cancellation vocabulary.
*
* Gives cancellable services one recognisable error and a shared checkpoint.
* Callers distinguish cancellation from failure so no failure history is written.
*/
export class CompilationCancelledError extends Error { constructor() { super("Compilation cancelled."); this.name = "CompilationCancelledError"; } }
export function throwIfCancelled(signal?: AbortSignal): void { if (signal?.aborted) throw new CompilationCancelledError(); }

View file

@ -1,3 +1,9 @@
/**
* Manuscript Compiler stable Obsidian command identifiers.
*
* main.ts registers these IDs and tests protect them because users may bind
* hotkeys externally. Change display names separately; never casually rename IDs.
*/
export const COMMAND_IDS = {
compileManuscript: "compile-manuscript",
compileCurrentBook: "compile-current-book",

View file

@ -1,3 +1,10 @@
/**
* Manuscript Compiler command orchestration service.
*
* Resolves command roots, requests authoritative preparation, presents legacy
* previews/validation, and delegates export. main.ts routes compile commands here.
* It calls BookRootResolver, CompilePreparationService, validation, and export.
*/
import { Notice, TFolder, type App } from "obsidian";
import { BookRootResolver } from "./book-root-resolver";
import { CompilationCancelledError } from "./cancellation";
@ -13,19 +20,24 @@ import { MarkdownExporter } from "./exporter";
import { CompilationProgressModal, CompilePreviewModal, DiagnosticsReportModal, ValidationReportModal, showError } from "./ui";
import { ManuscriptValidationService } from "./validation";
/** Application-scoped command owner; global operation state prevents overlap. */
export class CompileCommandService {
private readonly roots: BookRootResolver;
constructor(private readonly app: App, private readonly settings: () => ManuscriptCompilerSettings, private readonly activeProfile: () => CompileProfile, private readonly operations: OperationStateController, private readonly exporter: ExportCoordinator, private readonly pluginVersion: string) { this.roots = new BookRootResolver(app.vault); }
/** Prepares an edited workspace plan without replacing explicit author choices. */
async prepareGuided(request: SimpleCompileRequest, contentPlan?: ContentPlanItem[], signal?: AbortSignal): Promise<PreparedCompileSession> {
const root = this.roots.require(request.manuscriptRoot);
const normalizedRequest = { ...request, manuscriptRoot: root.path };
const base = { ...this.activeProfile(), generateTableOfContents: request.outputFormat !== "markdown" && this.settings().includeTableOfContentsByDefault };
return this.prepare({ manuscriptRoot: root.path, profile: base, structurePreset: normalizedRequest.structurePreset, contentPlan, simpleRequest: normalizedRequest, purpose: "preview", route: "guided" }, signal);
}
/** Rechecks source metadata so a preview cannot silently export stale files. */
async preparedSessionIsCurrent(session: PreparedCompileSession): Promise<boolean> { return await calculateSourceFingerprint(this.app.vault, session.sourcePaths) === session.sourceFingerprint; }
/** Delegates an already prepared session; this method never scans or rebuilds its Book. */
async exportPreparedSession(session: PreparedCompileSession): Promise<ExportExecutionResult> { return this.exporter.exportPreparedSession(session); }
/** Runs the compatibility request flow through the same preparation and preview used by the workspace. */
async compileRequest(request: SimpleCompileRequest): Promise<void> {
const session = await this.prepareGuided(request, request.contentPlan);
const preview = this.exporter.previewFromSession(session);
@ -34,6 +46,7 @@ export class CompileCommandService {
const result = await this.exportPreparedSession(session); if (result.status === "failed") throw new Error(result.error);
}
/** Compiles an explicit folder while preserving its identity as the authoritative book root. */
async compileFolder(folder: TFolder, compileProfile?: CompileProfile, contentPlan: ContentPlanItem[] = [], route: CompileRoute = "legacy-profile"): Promise<void> {
const profile = compileProfile ?? this.activeProfile(); const controller = new AbortController(); const progress = new CompilationProgressModal(this.app, () => controller.abort()); progress.open();
try {
@ -46,9 +59,13 @@ export class CompileCommandService {
} catch (error) { progress.finish(); if (controller.signal.aborted || error instanceof CompilationCancelledError) { new Notice("Compilation cancelled. No output was changed."); return; } showError(error); }
}
/** Resolves the current configured/active-file root, then enters the unified folder route. */
async compileCurrentBook(): Promise<void> { try { const folder = this.currentRoot(); if (!folder) throw new Error("Set a manuscript root in the active compile profile, or open a note inside a recognisable book folder."); await this.compileFolder(folder, undefined, [], "current-book"); } catch (error) { showError(error); } }
/** Compiles the bundled sample through production preparation; absence is reported without creating it. */
async compileSampleManuscript(): Promise<void> { try { await this.compileFolder(this.roots.require("samples/Complete Sample Book", "sample manuscript folder"), undefined, [], "sample"); } catch { new Notice("Sample manuscript was not found in this vault. Copy the repository samples folder into the vault to try it.", 8000); } }
/** Validates the exact semantic model export would consume and never writes output. */
async validateManuscript(): Promise<void> { try { const folder = this.currentRoot(); if (!folder) throw new Error("Set a manuscript root in the active compile profile, or open a note inside a recognisable book folder."); const session = await this.prepareAutomatic(folder, this.activeProfile(), "validation", "validation"); const result = await new ManuscriptValidationService(this.app.vault, this.settings()).validate(session); new ValidationReportModal(this.app, folder.path, result).open(); } catch (error) { showError(error); } }
/** Builds a privacy-limited configuration report; manuscript prose is never included. */
async generateDiagnostics(): Promise<void> { try { const report = new DiagnosticsReportGenerator().generate({ pluginVersion: this.pluginVersion, obsidianVersion: getObsidianVersion(), operatingSystem: navigator.userAgent, profile: this.activeProfile(), settings: this.settings() }); new DiagnosticsReportModal(this.app, report, () => this.saveDiagnostics(report)).open(); } catch (error) { showError(error); } }
private currentRoot(): TFolder | null { return this.roots.configuredOrCurrent(this.activeProfile().manuscriptRoot || this.settings().defaultManuscriptFolder, this.app.workspace.getActiveFile()); }

View file

@ -1,7 +1,15 @@
/**
* Manuscript Compiler export history and diagnostic-log persistence.
*
* Centralises bounded history repair and outcome recording. ExportCoordinator
* calls it only after a truthful terminal state. Exporters never mutate settings
* directly, and records contain no manuscript prose.
*/
import type { CompileResult } from "./model";
import { profileId } from "./profiles";
import type { CompileLogEntry, ExportHistoryEntry, ExportTarget, ManuscriptCompilerSettings } from "./settings";
/** Ephemeral terminal-operation data converted to persisted history/log entries. */
export interface HistoryRecord {
timestamp: Date;
started: number;
@ -14,14 +22,21 @@ export interface HistoryRecord {
timings?: Partial<Pick<CompileLogEntry, "scanDurationMs" | "parseDurationMs" | "filterDurationMs" | "generationDurationMs" | "exportDurationMs">>;
}
/** Settings-backed application service; every mutation persists through its callback. */
export class CompileHistoryService {
constructor(private readonly settings: () => ManuscriptCompilerSettings, private readonly save: () => Promise<void>, private readonly compilerVersion: string) {}
/** Persists success only after the coordinator has verified every final output. */
async recordSuccess(record: HistoryRecord): Promise<void> { await this.record(record, true, false); }
/** Records a terminal failure without presenting partial outputs as successful. */
async recordFailure(record: HistoryRecord): Promise<void> { await this.record(record, false, false); }
/** Records user cancellation distinctly from a compiler or storage failure. */
async recordCancellation(record: HistoryRecord): Promise<void> { await this.record({ ...record, message: record.message ?? "Cancelled" }, false, true); }
/** 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); }
/** 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"); }
private async record(record: HistoryRecord, success: boolean, cancelled: boolean): Promise<void> {

View file

@ -1,3 +1,10 @@
/**
* Manuscript Compiler four-step workspace shell.
*
* Owns modal lifecycle, step composition, folder selection, and DOM event wiring.
* CompileWorkspaceController owns state/operations; step modules own controls.
* Parser, exporter, history, filesystem, and Electron logic do not belong here.
*/
import { App, FuzzySuggestModal, Modal, Notice, TFolder } from "obsidian";
import type ManuscriptCompilerPlugin from "./main";
import { classifyContentPlan, createContentPlan } from "./content-plan";
@ -20,6 +27,7 @@ class FolderPicker extends FuzzySuggestModal<TFolder> {
const steps: CompileWorkspaceStep[] = ["manuscript", "contents", "formatting", "export"];
const labels = ["Manuscript", "Contents", "Formatting", "Export"];
/** Modal-scoped workspace view; closing delegates cancellation to its controller. */
export class SimpleCompileModal extends Modal {
private readonly controller: CompileWorkspaceController;
private readonly contentsViewState = new ContentsTreeViewState();

View file

@ -1,3 +1,15 @@
/**
* Manuscript Compiler authoritative preparation boundary.
*
* Vault scanner content plan parser/cleaner semantic Book
* statistics/warnings/fingerprints PreparedCompileSession
*
* Every preview, validation, Markdown export, and DOCX export crosses this
* boundary. Called by CompileCommandService; calls VaultScanner, content-plan
* rewriting, ManuscriptCompiler, and output-path calculation. Scanner-to-parser
* or scanner-to-export shortcuts are forbidden because they bypass author roles
* and safe exclusions.
*/
import { TFile, TFolder, type Vault } from "obsidian";
import { ManuscriptCompiler } from "./compiler";
import { applyContentPlan, classifyContentPlan, createContentPlan, isPlanItemIncluded, type ContentPlanItem } from "./content-plan";
@ -9,9 +21,14 @@ import { applyContentPlanAuthority, applyWorkspacePlanAuthority, inferStructureP
import type { ScannedBook } from "./types";
import { VaultScanner } from "./vault-scanner";
/** Prose-free explanation of an item absent from the final Book. */
export interface PreparedExclusion { path: string; name: string; reason: string; }
export type CompilePurpose = "preview" | "compile" | "validation";
export type CompileRoute = "guided" | "current-book" | "selected-folder" | "legacy-profile" | "sample" | "validation";
/**
* Route-neutral input accepted by the authoritative service. Guided callers
* provide an edited plan; compatibility routes omit it and receive safe inference.
*/
export interface CompilePreparationRequest {
manuscriptRoot: string;
profile: CompileProfile;
@ -21,6 +38,12 @@ export interface CompilePreparationRequest {
purpose: CompilePurpose;
route: CompileRoute;
}
/**
* Immutable-in-practice snapshot owned by its requesting command/workspace.
* Consumers retain references rather than mutating or reconstructing fields.
* `book` is the exact object used by preview and export; fingerprints bind that
* object to both source files and author-controlled inputs.
*/
export interface PreparedCompileSession {
request: SimpleCompileRequest;
contentPlan: ContentPlanItem[];
@ -40,13 +63,23 @@ export interface PreparedCompileSession {
route: CompileRoute;
}
/**
* Sole root-to-semantic-Book service. Instances are vault-bound and stateless
* between calls. Preparation performs reads and computation but no output writes.
* AbortSignal cancellation propagates through parsing before a session is returned.
*/
export class CompilePreparationService {
constructor(private readonly vault: Vault, private readonly baseProfile: CompileProfile, private readonly wordsPerMinute: number) {}
/** Prepares an edited four-step workspace request without altering its plan. */
async prepare(request: SimpleCompileRequest, contentPlan: ContentPlanItem[], signal?: AbortSignal): Promise<PreparedCompileSession> {
return this.prepareAuthoritative({ manuscriptRoot: request.manuscriptRoot, profile: this.baseProfile, structurePreset: request.structurePreset, contentPlan, simpleRequest: request, purpose: "preview", route: "guided" }, signal);
}
/**
* Runs the complete production preparation pipeline for every route. Throws for
* an invalid root/read/parse failure and never returns a partially prepared session.
*/
async prepareAuthoritative(request: CompilePreparationRequest, signal?: AbortSignal): Promise<PreparedCompileSession> {
const folder = this.vault.getAbstractFileByPath(request.manuscriptRoot);
if (!(folder instanceof TFolder)) throw new Error("The manuscript folder does not exist.");
@ -95,6 +128,10 @@ 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.
*/
export async function calculateSourceFingerprint(vault: Vault, sourcePaths: string[]): Promise<string> {
const entries: string[] = [];
for (const path of [...sourcePaths].sort()) {
@ -110,10 +147,13 @@ export async function calculateSourceFingerprint(vault: Vault, sourcePaths: stri
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 };
}
/** Fingerprints all author inputs that can change model or output preparation. */
export function compileInputSignature(request: SimpleCompileRequest, plan: ContentPlanItem[]): string {
return hash(JSON.stringify({
root: request.manuscriptRoot, preset: request.structurePreset, front: request.includeFrontMatter,

View file

@ -1,3 +1,10 @@
/**
* Manuscript Compiler semantic compilation facade.
*
* Joins parsing with deterministic statistics, warning analysis, and Markdown
* generation. CompilePreparationService owns this facade; exporters consume its
* finished Book/result and never ask it to parse again.
*/
import { Vault } from "obsidian";
import { MarkdownGenerator } from "./markdown-generator";
import type { Book, CompileResult } from "./model";
@ -8,12 +15,19 @@ import type { ScannedBook } from "./types";
import { WarningEngine } from "./warnings";
import { throwIfCancelled } from "./cancellation";
/** Vault-bound facade used only during authoritative preparation. */
export class ManuscriptCompiler {
private readonly parser: ManuscriptParser; private readonly generator = new MarkdownGenerator();
private readonly statistics = new StatisticsEngine(); private readonly warnings = new WarningEngine();
constructor(vault: Vault) { this.parser = new ManuscriptParser(vault); }
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; }
/**
* 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 {
throwIfCancelled(signal);
const statistics = this.statistics.calculate(book, profile, wordsPerMinute);

View file

@ -1,9 +1,27 @@
/**
* Manuscript Compiler author-controlled structural plan.
*
* Bridges mechanical vault discovery and semantic parsing. It infers safe
* defaults, records explicit author overrides, and rewrites ScannedBook so only
* authoritative roles, inclusion, and order reach the parser. Called by
* CompilePreparationService and the Contents workspace; calls content cleaning
* for note classification.
*
* Invariants: the selected root is absent, transparent containers emit no
* heading, explicit choices beat inference, and flattening retains the nearest
* Part/Chapter relationship.
*/
import { parseYaml, TAbstractFile, TFile, TFolder, type Vault } from "obsidian";
import type { CompileProfile, StructurePreset } from "./settings";
import type { ScannedBook, ScannedChapter, ScannedPart } from "./types";
import { cleanManuscriptContent, hasProjectMetadataLeakage } from "./filters";
export type ContentRole = "front-matter" | "transparent" | "part" | "chapter" | "scene" | "back-matter" | "ignore";
/**
* Mutable workspace record for one discovered item. `detectedRole` remembers
* inference while `role` is authoritative; `userOverride` protects explicit
* choices from later parent-role propagation. Preparation copies supplied plans.
*/
export interface ContentPlanItem { path: string; parentPath: string; name: string; kind: "folder" | "note"; role: ContentRole; detectedRole?: ContentRole; included: boolean; order: number; exclusionReason?: string; warning?: string; userOverride?: boolean; }
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
@ -18,6 +36,7 @@ const matterOrder: Record<string, number> = { "title page": 10, copyright: 20, "
export function normalizedProjectName(value: string): string { return value.replace(/\.[^.]+$/, "").replace(/^\s*\d+[\s._—-]*/, "").replace(/[_.—–-]+/g, " ").replace(/\s+/g, " ").trim().toLowerCase(); }
/** Creates deterministic path/ancestry roles without reading note bodies. */
export function createContentPlan(root: TFolder, preset: StructurePreset): ContentPlanItem[] {
const items: ContentPlanItem[] = [];
const normalizedRootName = normalizedProjectName(root.name);
@ -76,6 +95,7 @@ function isBackMatterNote(normalized: string): boolean {
return /^(?:a note from elin|about the author|acknowledg(?:e)?ments?|also by|back cover blurb|newsletter|reader note|author note|connect with the author)(?:\s|$)/i.test(normalized);
}
/** Reads note metadata/body and enriches the supplied mutable plan in place. */
export async function classifyContentPlan(vault: Vault, plan: ContentPlanItem[]): Promise<ContentPlanItem[]> {
await Promise.all(plan.filter((item) => item.kind === "note" && item.role !== "ignore").map(async (item) => {
const file = vault.getAbstractFileByPath(item.path); if (!(file instanceof TFile)) return; const raw = await vault.cachedRead(file);
@ -89,6 +109,7 @@ export async function classifyContentPlan(vault: Vault, plan: ContentPlanItem[])
}
/** Applies a folder matter role only to descendants that the author has not explicitly classified. */
/** Propagates matter roles only to descendants without explicit overrides. */
export function applyMatterRoleInheritance(plan: ContentPlanItem[], folderPath: string, role: ContentRole, previousRole?: ContentRole): void {
const matterRole = role === "front-matter" || role === "back-matter" ? role : undefined;
const wasMatter = previousRole === "front-matter" || previousRole === "back-matter";
@ -102,6 +123,11 @@ export function isPlanItemIncluded(item: ContentPlanItem, plan: ContentPlanItem[
function frontmatter(markdown: string): Record<string, unknown> { const match = markdown.replace(/^\uFEFF/, "").match(/^---[\t ]*\r?\n([\s\S]*?)\r?\n(?:---|\.\.\.)[\t ]*(?:\r?\n|$)/); if (!match) return {}; try { const value = parseYaml(match[1]); if (!value || typeof value !== "object" || Array.isArray(value)) return {}; return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([key, item]) => [normalizedProjectName(key), item])); } catch { return {}; } }
/**
* Reconstructs scanner output using nearest included structural ancestors. This
* is the transparent-container flattening boundary and must preserve source paths,
* manual order, explicit roles, and uniqueness of descendants.
*/
export function applyContentPlan(scan: ScannedBook, plan: ContentPlanItem[], profile: CompileProfile): ScannedBook {
if (!plan.length) return scan;
const byPath = new Map(plan.map((item) => [item.path, item]));

View file

@ -1,6 +1,14 @@
/**
* Manuscript Compiler privacy-safe support diagnostics.
*
* Produces configuration/platform summaries without reading manuscript notes.
* Called by CompileCommandService and diagnostics UI. Absolute paths, metadata
* filter values, and manuscript prose must never enter the report.
*/
import type { CompileProfile, ManuscriptCompilerSettings } from "./settings";
export interface DiagnosticsContext { pluginVersion: string; obsidianVersion: string; operatingSystem: string; profile: CompileProfile; settings: ManuscriptCompilerSettings; generatedAt?: Date; }
/** Stateless redacted report builder; generated output is safe to share for support. */
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;

View file

@ -1,9 +1,18 @@
/**
* Manuscript Compiler focused DOCX package validation.
*
* Checks the minimum ZIP and WordprocessingML structure before any save can be
* trusted. Called by DocxExporter tests and SafeBinaryWriter before and after
* replacement. This is intentionally not a full OOXML conformance validator.
*/
import { unzipSync } from "fflate";
/** Reusable structural verdict suitable for technical logs and tests. */
export interface DocxValidationResult { valid: boolean; errors: string[]; }
const REQUIRED = ["[Content_Types].xml", "_rels/.rels", "word/document.xml", "word/styles.xml"] as const;
/** Performs read-only minimum package validation and never throws for bad bytes. */
export function validateDocxBytes(bytes: Uint8Array): DocxValidationResult {
const errors: string[] = [];
if (bytes.length < 4 || bytes[0] !== 0x50 || bytes[1] !== 0x4b) return { valid: false, errors: ["The generated DOCX is not a readable ZIP package."] };

View file

@ -1,3 +1,11 @@
/**
* Manuscript Compiler native semantic DOCX generation.
*
* Converts a prepared Book directly into an offline WordprocessingML package.
* Called by DocxExporter; calls XML/ZIP helpers and measurement conversion. It
* never scans the vault, reparses generic Markdown structure, or writes files.
* Structural styles come from Book nodes and missing numbers remain missing.
*/
import { strToU8, zipSync } from "fflate";
import type { Book, Chapter, ManuscriptDocument, Part } from "./model";
import { numberWord } from "./ordering";
@ -8,6 +16,7 @@ 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 {
title: string;
author: string;
@ -24,6 +33,7 @@ export interface DocxOptions {
chapterDisplay?: StructuralDisplay;
}
/** Repaired, complete values safe for XML/style generation; owned by one build. */
export interface ResolvedDocxOptions extends DocxOptions {
font: string;
fontSize: number;

View file

@ -1,3 +1,11 @@
/**
* Manuscript Compiler sole export execution coordinator.
*
* Accepts PreparedCompileSession, checks fingerprints and overwrite consent,
* reports progress, invokes exporters, records terminal outcomes, and exposes
* supported result actions. Called by command/workspace services. It never
* scans, parses, creates a content plan, or replaces session.book.
*/
import { FileSystemAdapter, Notice, type App } from "obsidian";
import { CompilationCancelledError, throwIfCancelled } from "./cancellation";
import { calculateSourceFingerprint, compileInputSignature, createPreparedExportRequest, type PreparedCompileSession } from "./compile-preparation";
@ -13,12 +21,22 @@ import type { ManuscriptCompilerSettings } from "./settings";
import { CompilationProgressModal, CompileReportModal, ConfirmOverwriteModal } from "./ui";
import { WarningEngine } from "./warnings";
/** Truthful aggregate outcome; partial multi-format results remain explicit. */
export interface ExportExecutionResult { status: "success" | "failed" | "cancelled"; outputFiles: string[]; report?: CompileResult; error?: string; }
export interface ExportExecutionOptions { showResult?: boolean; }
/**
* Application-scoped export owner composed in main.ts. Global OperationState
* prevents overlapping exports. Cancellation is honoured until SafeBinaryWriter
* announces its commit boundary; history/result UI follow verified completion.
*/
export class ExportCoordinator {
constructor(private readonly app: App, private readonly settings: () => ManuscriptCompilerSettings, private readonly saveSettings: () => Promise<void>, private readonly operations: OperationStateController, private readonly history: CompileHistoryService, private readonly actions: ResultActionService) {}
/**
* Exports the supplied session without rebuilding it. Throws no raw stack to UI;
* failures become author-facing results and corresponding history records.
*/
async exportPreparedSession(session: PreparedCompileSession, options: ExportExecutionOptions = {}): Promise<ExportExecutionResult> {
const operation = this.operations.begin("exporting");
if (!operation) throw new Error("A manuscript preparation or compilation is already running.");

View file

@ -1,3 +1,10 @@
/**
* Manuscript Compiler final export eligibility policy.
*
* Converts prepared warnings and DOCX readiness into one blocking decision.
* Preview and export coordination share this pure policy so UI code cannot
* accidentally bypass output-safety errors.
*/
import type { CompileWarning } from "./model";
import type { ExportTarget } from "./settings";

View file

@ -1,3 +1,11 @@
/**
* Manuscript Compiler format-specific exporters.
*
* MarkdownExporter writes prepared Markdown through vault APIs. DocxExporter
* generates semantic DOCX bytes and delegates destination mutation to
* SafeBinaryWriter. Called only by ExportCoordinator. Exporters never scan,
* build Books, update history, or open result UI.
*/
import { FileSystemAdapter, normalizePath, TFile, Vault } from "obsidian";
import type { Book } from "./model";
import { createManuscriptDocx } from "./docx";
@ -9,10 +17,17 @@ import { SafeBinaryWriter, type SafeSaveStage } from "./safe-binary-writer";
import { validateVaultPath } from "./output-path";
export type ExportProgressStage = "Creating DOCX" | "Checking DOCX" | SafeSaveStage;
/**
* Immutable-in-practice exporter input built from PreparedCompileSession. `book`
* must be retained by identity; exporters may read it but must not mutate it.
*/
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; }
/** Format adapter contract; implementations perform output only, never orchestration. */
export interface Exporter { readonly format: string; export(request: ExportRequest): Promise<ExportResult>; }
/** Vault-API Markdown writer used for final output and diagnostic reports. */
export class MarkdownExporter implements Exporter {
readonly format = "markdown"; private readonly templates = new TemplateEngine();
constructor(private readonly vault: Vault) {}
@ -26,6 +41,7 @@ export class MarkdownExporter implements Exporter {
async ensureFolder(path: string): Promise<void> { const parts = normalizePath(path).split("/"); for (let index = 1; index <= parts.length; index += 1) { const current = parts.slice(0, index).join("/"); if (!this.vault.getAbstractFileByPath(current)) await this.vault.createFolder(current); } }
}
/** Semantic DOCX generator whose only save path is SafeBinaryWriter. */
export class DocxExporter implements Exporter {
readonly format = "docx";
constructor(private readonly vault: Vault, private readonly markdownExporter: MarkdownExporter, private readonly binaryWriter = new SafeBinaryWriter(vault)) {}

View file

@ -1,3 +1,10 @@
/**
* Manuscript Compiler isolated desktop filesystem capability bridge.
*
* Keeps optional Node access out of UI/domain modules. Platform services call
* 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"); }
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; } }

View file

@ -1,5 +1,14 @@
/**
* Manuscript Compiler content cleaning pipeline.
*
* Converts authoring-oriented Markdown into publishable manuscript Markdown.
* Parser and content-plan classification call this pure module. Metadata is
* removed only in structured boundary regions or known property forms so prose
* beginning with words such as Book or Chapter is preserved.
*/
import type { CleaningSettings } from "./settings";
export type ContentFilter = (markdown: string) => string;
/** Stateless configured cleaner; `clean` is deterministic and cancellable upstream. */
export class ContentCleaningPipeline {
clean(markdown: string, settings: CleaningSettings): string {
const filters: ContentFilter[] = [];
@ -16,11 +25,13 @@ const DEFAULT_BODY_ALIASES = ["Scene", "Manuscript", "Text", "Draft", "Body"];
const AUTHORING_SECTIONS = new Set(["revision notes", "editing notes", "author notes", "scene notes", "development notes", "comments", "synopsis"]);
const METADATA_FIELDS = new Set(["series", "book", "book number", "part", "part number", "chapter", "chapter number", "scene", "scene number", "point of view", "pov", "characters", "locations", "plotlines", "editing status", "editing stage", "status", "stage", "importance", "date", "start time", "end time"]);
/** Performs mandatory body extraction and structured metadata/author-note removal. */
export function cleanManuscriptContent(markdown: string, bodyAliases: string[] = DEFAULT_BODY_ALIASES): string {
const withoutYaml = stripYamlFrontmatter(markdown); const section = extractBodySection(withoutYaml, bodyAliases); const withoutSections = section.found ? section.content : removeAuthoringSections(section.content);
return removeProjectMetadataRegions(withoutSections).replace(/^\s+|\s+$/g, "");
}
/** Selects one configured body section through its next peer/ancestor heading. */
export function extractBodySection(markdown: string, aliases: string[] = DEFAULT_BODY_ALIASES): { content: string; found: boolean } {
const lines = markdown.replace(/\r\n?/g, "\n").split("\n"); const allowed = new Set(aliases.map(normalizeLabel)); let start = -1; let level = 0;
for (let index = 0; index < lines.length; index += 1) { const heading = /^(#{1,6})\s+(.+?)\s*$/.exec(lines[index]); if (heading && allowed.has(normalizeLabel(heading[2]))) { start = index + 1; level = heading[1].length; break; } }
@ -29,12 +40,14 @@ export function extractBodySection(markdown: string, aliases: string[] = DEFAULT
return { content: lines.slice(start, end).join("\n"), found: true };
}
/** Omits template sections such as Synopsis and Revision Notes from whole notes. */
export function removeAuthoringSections(markdown: string): string {
const lines = markdown.replace(/\r\n?/g, "\n").split("\n"); const output: string[] = []; let skippingLevel = 0;
for (const line of lines) { const heading = /^(#{1,6})\s+(.+?)\s*$/.exec(line); if (heading) { const level = heading[1].length; if (skippingLevel && level <= skippingLevel) skippingLevel = 0; if (AUTHORING_SECTIONS.has(normalizeLabel(heading[2]))) { skippingLevel = level; continue; } } if (!skippingLevel) output.push(line); }
return output.join("\n");
}
/** Removes recognised property lines/tables only at structured note boundaries. */
export function removeProjectMetadataRegions(markdown: string): string {
const lines = markdown.replace(/\r\n?/g, "\n").split("\n"); let first = 0; let last = lines.length - 1;
while (first <= last && !lines[first].trim()) first += 1; while (last >= first && !lines[last].trim()) last -= 1;
@ -42,6 +55,7 @@ export function removeProjectMetadataRegions(markdown: string): string {
while (last >= first) { const consumed = metadataBlockLength(lines, last, -1); if (!consumed) break; last -= consumed; while (last >= first && !lines[last].trim()) last -= 1; }
return lines.slice(first, last + 1).join("\n");
}
/** Flags suspicious structured fields that survived mandatory cleaning. */
export function hasProjectMetadataLeakage(markdown: string): boolean { return markdown.split(/\r?\n/).some(isMetadataLine); }
function metadataBlockLength(lines: string[], index: number, direction: 1 | -1): number {
@ -53,10 +67,12 @@ function metadataBlockLength(lines: string[], index: number, direction: 1 | -1):
function isMetadataLine(line: string): boolean { const match = line.match(/^\s*(?:[-*]\s+)?(?:\*\*|__)?([^:*_]+?)(?:\*\*|__)?\s*(?:::|:)\s*(?:.*)$/); return !!match && METADATA_FIELDS.has(normalizeLabel(match[1])); }
function normalizeLabel(value: string): string { return value.replace(/[*_`:[\]]/g, "").replace(/[—–-]+/g, " ").replace(/\s+/g, " ").trim().toLowerCase(); }
/** Removes one leading YAML document without interpreting its values. */
export function stripYamlFrontmatter(markdown: string): string { return markdown.replace(/^\uFEFF/, "").replace(/^---[\t ]*\r?\n[\s\S]*?\r?\n(?:---|\.\.\.)[\t ]*(?:\r?\n|$)/, ""); }
export function removeObsidianComments(markdown: string): string { return markdown.replace(/%%[\s\S]*?%%/g, ""); }
export function removeHtmlComments(markdown: string): string { return markdown.replace(/<!--[\s\S]*?-->/g, ""); }
export function removeDataviewBlocks(markdown: string): string { return markdown.replace(/```(?:dataview|dataviewjs)\b[^\n]*\n[\s\S]*?```[\t ]*(?:\r?\n)?/gi, ""); }
/** Removes Obsidian callout markers/titles while preserving readable body text. */
export function convertCalloutsToPlainText(markdown: string): string {
const output: string[] = []; let inCallout = false;
for (const line of markdown.split(/\r?\n/)) {
@ -68,4 +84,5 @@ export function convertCalloutsToPlainText(markdown: string): string {
}
/** Retained for source and profile compatibility with pre-0.9.1 integrations. */
export const removeCallouts = convertCalloutsToPlainText;
/** Converts wikilinks/embeds to their readable alias or target label. */
export function stripInternalLinks(markdown: string): string { return markdown.replace(/!?\[\[([^\]|#]+)(?:#[^\]|]+)?(?:\|([^\]]+))?\]\]/g, (_match, target: string, alias?: string) => alias ?? target.split("/").pop() ?? target); }

View file

@ -1,3 +1,9 @@
/**
* Manuscript Compiler File Explorer folder action.
*
* Adds one documented menu item only for TFolder and delegates the exact clicked
* object to main.ts. It contains no inference, preparation, or legacy compile path.
*/
import { TFolder, type Menu, type TAbstractFile } from "obsidian";
export const COMPILE_FOLDER_MENU_TITLE = "Compile manuscript from this folder";

View file

@ -1,3 +1,15 @@
/**
* Manuscript Compiler plugin composition root.
*
* Loads and repairs settings, constructs the application services, registers
* commands and File Explorer integration, and owns plugin shutdown. Compile
* logic deliberately lives in CompileCommandService and the workspace
* controller so entry points cannot acquire different preparation paths.
*
* Called by Obsidian. Calls profile repair, UI registration, command/export/
* history/action services, onboarding, and conservative output cleanup.
* Invariant: application services are composed here once and then delegated to.
*/
import { Notice, Plugin, TFolder } from "obsidian";
import { activeProfile, repairSettings } from "./profiles";
import { DEFAULT_SETTINGS, type CompileProfile, type ManuscriptCompilerSettings } from "./settings";
@ -16,7 +28,7 @@ import { FirstRunWizardModal } from "./wizards";
import { FolderSuggestModal, ManuscriptCompilerSettingTab, showError } from "./ui";
import { addCompileFolderMenuItem } from "./folder-context-menu";
/** Plugin composition root: lifecycle, settings persistence, service construction, and command registration. */
/** Obsidian-owned plugin instance and composition root for one enabled lifecycle. */
export default class ManuscriptCompilerPlugin extends Plugin {
settings: ManuscriptCompilerSettings = { ...DEFAULT_SETTINGS };
private readonly operations = new OperationStateController();
@ -25,6 +37,7 @@ export default class ManuscriptCompilerPlugin extends Plugin {
private exporter!: ExportCoordinator;
private commands!: CompileCommandService;
/** Loads durable state, composes services once, and registers all Obsidian entry points. */
async onload(): Promise<void> {
await this.loadSettings();
this.composeServices();
@ -37,8 +50,10 @@ export default class ManuscriptCompilerPlugin extends Plugin {
});
}
/** Cancels work that has not crossed its non-cancellable file-finalisation boundary. */
onunload(): void { this.operations.cancel(); }
/** Repairs persisted data idempotently before any service is allowed to read it. */
async loadSettings(): Promise<void> {
const raw = await this.loadData() as Partial<ManuscriptCompilerSettings> | null;
const loaded = Object.assign({}, DEFAULT_SETTINGS, raw);
@ -50,17 +65,29 @@ export default class ManuscriptCompilerPlugin extends Plugin {
if (this.settings.configurationWarnings.length > previousWarnings) new Notice("Manuscript Compiler repaired invalid settings. Run Validate Manuscript for details.", 8000);
}
/** Persists the complete repaired settings object through Obsidian's plugin storage. */
async saveSettings(): Promise<void> { await this.saveData(this.settings); }
/** Returns the repaired active profile, including compatibility fallback rules. */
getActiveProfile(): CompileProfile { return activeProfile(this.settings); }
/** Opens a new guided workspace without choosing a root on the user's behalf. */
openCompiler(): void { new SimpleCompileModal(this.app, this).open(); }
/** Opens the same workspace with the exact File Explorer folder selected as root. */
async openCompilerForFolder(folder: TFolder): Promise<void> { new SimpleCompileModal(this.app, this, folder).open(); }
/** Opens a verified result through platform capabilities or presents an author-facing failure. */
async openExport(path: string): Promise<void> { if (!await this.actions.openExport(path)) showError(new Error("Obsidian could not open this export automatically. Open it from your file manager.")); }
/** Clears both history and associated compile logs through their persistence service. */
async clearHistory(): Promise<void> { await this.history.clearHistory(); }
/** Compatibility facade retained for callers; all work is delegated to CompileCommandService. */
async compileRequest(request: SimpleCompileRequest): Promise<void> { await this.commands.compileRequest(request); }
/** Supplies the workspace with an authoritative prepared semantic session. */
async prepareCompileRequest(request: SimpleCompileRequest, contentPlan?: ContentPlanItem[], signal?: AbortSignal): Promise<PreparedCompileSession> { return this.commands.prepareGuided(request, contentPlan, signal); }
/** Rechecks a session's source fingerprint without mutating or rebuilding it. */
async preparedSessionIsCurrent(session: PreparedCompileSession): Promise<boolean> { return this.commands.preparedSessionIsCurrent(session); }
/** Exports the exact prepared session and converts coordinator failure into a UI-safe exception. */
async exportPreparedSession(session: PreparedCompileSession): Promise<void> { const result = await this.commands.exportPreparedSession(session); if (result.status === "failed") throw new Error(result.error); }
/** Routes onboarding/sample compilation through the production command service. */
async compileSampleManuscript(): Promise<void> { await this.commands.compileSampleManuscript(); }
/** Retains the historical plugin facade while enforcing the unified explicit-root route. */
async compileFolder(folder: TFolder, profile?: CompileProfile, contentPlan: ContentPlanItem[] = [], route: CompileRoute = "legacy-profile"): Promise<void> { await this.commands.compileFolder(folder, profile, contentPlan, route); }
private composeServices(): void {

View file

@ -1,8 +1,15 @@
/**
* Manuscript Compiler semantic Markdown rendering.
*
* Produces deterministic Markdown from the same final Book used for DOCX. It
* never infers structure from source Markdown and must preserve semantic order.
*/
import type { Book, Chapter, ManuscriptDocument, ManuscriptStatistics, Part } from "./model";
import type { CompileProfile } from "./settings";
import { TemplateEngine, type TemplateVariables } from "./template-engine";
import { numberWord } from "./ordering";
/** Stateless final-Book renderer; output is deterministic for a supplied date. */
export class MarkdownGenerator {
private readonly templates = new TemplateEngine();
generate(book: Book, profile: CompileProfile, statistics: ManuscriptStatistics, compileDate = new Date()): string {

View file

@ -1,11 +1,21 @@
/**
* Manuscript Compiler metric/OOXML measurement conversion.
*
* UI and persistence use centimetres; WordprocessingML uses twips. Centralised,
* rounded conversion prevents formula duplication and migration drift.
*/
const CENTIMETRES_PER_INCH = 2.54;
const TWIPS_PER_INCH = 1440;
/** Converts canonical UI centimetres to compatibility inches with stable rounding. */
export function centimetresToInches(value: number): number { return round(value / CENTIMETRES_PER_INCH); }
/** Migrates legacy inch values into canonical centimetres without visual drift. */
export function inchesToCentimetres(value: number): number { return round(value * CENTIMETRES_PER_INCH); }
/** Converts UI/persisted indentation to the integer unit required by OOXML. */
export function centimetresToTwips(value: number): number { return Math.round(centimetresToInches(value) * TWIPS_PER_INCH); }
export function twipsToCentimetres(value: number): number { return inchesToCentimetres(value / TWIPS_PER_INCH); }
/** Repairs non-finite/out-of-range measurements before persistence or generation. */
export function clampCentimetres(value: number | undefined, minimum: number, maximum: number, fallback: number): number {
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
return round(Math.min(maximum, Math.max(minimum, value)));

View file

@ -1,3 +1,9 @@
/**
* Manuscript Compiler optional frontmatter inclusion rules.
*
* Evaluates persisted equality filters against parser-normalised metadata.
* Called by ManuscriptParser. Explicit workspace inclusion remains authoritative.
*/
import type { DocumentMetadata } from "./model";
import type { MetadataFilterRule, MetadataOperator } from "./settings";
export interface FilterOperator { id: MetadataOperator; matches(actual: unknown, expected: string): boolean; }
@ -6,6 +12,7 @@ const OPERATORS: Record<MetadataOperator, FilterOperator> = {
equals: { id: "equals", matches: (actual, expected) => normalize(actual) === normalize(expected) },
"not-equals": { id: "not-equals", matches: (actual, expected) => normalize(actual) !== normalize(expected) }
};
/** Stateless evaluator; matching never mutates document metadata or filter rules. */
export class MetadataFilterEngine {
matches(metadata: DocumentMetadata, rules: MetadataFilterRule[]): { included: boolean; failedRule?: MetadataFilterRule } {
for (const rule of rules) { const actual = metadata.values[normalizeKey(rule.field)]; const operator = OPERATORS[rule.operator]; if (!operator || !operator.matches(actual, rule.value)) return { included: false, failedRule: rule }; }

View file

@ -1,3 +1,13 @@
/**
* Manuscript Compiler semantic domain model.
*
* Defines the publishable vocabulary shared by parser, preview, validation,
* Markdown, DOCX, warnings, and statistics. These types describe manuscript
* structure rather than vault organisation.
*
* Invariant: once a Book enters PreparedCompileSession its object graph is
* treated as immutable and the same instance reaches preview and export.
*/
import type { TFile, TFolder } from "obsidian";
import type { HierarchyDiagnostic } from "./types";
@ -12,6 +22,7 @@ export interface DocumentMetadata {
values: Record<string, unknown>;
}
/** Parsed source note containing cleaned content and exclusion diagnostics. */
export interface ManuscriptDocument {
file: TFile;
title: string;
@ -32,6 +43,7 @@ export interface MatterSection {
export interface Scene extends ManuscriptDocument {}
/** Semantic Chapter. An absent number remains absent rather than becoming zero. */
export interface Chapter {
title: string;
name: string;
@ -42,6 +54,7 @@ export interface Chapter {
orphan: boolean;
}
/** Semantic Part; `synthetic` represents a partless book wrapper, not a heading. */
export interface Part {
title: string;
name: string;
@ -53,6 +66,10 @@ export interface Part {
synthetic?: boolean;
}
/**
* Final publishable model owned by PreparedCompileSession. `root` names and bounds
* the source but is never itself a Part or Chapter. Treat the graph as immutable.
*/
export interface Book {
root: TFolder;
title: string;
@ -68,6 +85,7 @@ export interface Book {
}
export type WarningSeverity = "information" | "warning" | "error";
/** Prose-free issue shared unchanged by preview, validation, and export. */
export interface CompileWarning { severity: WarningSeverity; code: string; message: string; path?: string; suggestion?: string; }
export interface NamedStatistic { name: string; words: number; }
export interface ManuscriptStatistics {

View file

@ -1,5 +1,12 @@
/**
* Manuscript Compiler single-operation state and cancellation ownership.
*
* Prevents duplicate preparation/export and gives each task one AbortController.
* Finalisation disables cancellation so replacement or rollback can finish.
*/
export type OperationStatus = "idle" | "preparing" | "ready" | "exporting" | "finalising" | "cancelled" | "failed" | "complete";
/** Handle retained by one caller until `settle`; methods are idempotent by design. */
export interface ActiveOperation {
readonly signal: AbortSignal;
readonly status: OperationStatus;
@ -10,11 +17,12 @@ export interface ActiveOperation {
settle(): void;
}
/** A single-operation lock with an explicit non-cancellable finalisation boundary. */
/** Owns at most one active operation and releases its lock only when settled. */
export class OperationStateController {
private current?: OperationHandle;
status: OperationStatus = "idle";
/** Acquires the global operation slot, returning undefined rather than queueing duplicate work. */
begin(status: "preparing" | "exporting"): ActiveOperation | undefined {
if (this.current) return undefined;
const handle = new OperationHandle(status, (next) => { this.status = next; }, () => { if (this.current === handle) this.current = undefined; });
@ -23,7 +31,9 @@ export class OperationStateController {
return handle;
}
/** Requests cancellation only while the active handle still permits interruption. */
cancel(): boolean { return this.current?.cancel() ?? false; }
/** Indicates ownership of the operation slot, including non-cancellable finalisation. */
get busy(): boolean { return this.current !== undefined; }
}

View file

@ -1,5 +1,13 @@
/**
* Manuscript Compiler structural title, number, and ordering rules.
*
* Parser and generators share these helpers. Explicit manual content order is
* applied after automatic comparators. Missing numbers stay undefined; zero is
* never used as a fallback.
*/
import type { Chapter, ManuscriptDocument, Part } from "./model";
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
/** Extracts explicit numeric/English-word structure numbers without inventing 0. */
export function extractNumber(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value !== "string") return undefined;
@ -7,6 +15,7 @@ export function extractNumber(value: unknown): number | undefined {
const words = value.toLowerCase().match(/\b(?:zero|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty|thirty|forty|fifty|sixty|seventy|eighty|ninety)\b/g); if (!words?.length) return undefined;
const values: Record<string, number> = { zero: 0, one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10, eleven: 11, twelve: 12, thirteen: 13, fourteen: 14, fifteen: 15, sixteen: 16, seventeen: 17, eighteen: 18, nineteen: 19, twenty: 20, thirty: 30, forty: 40, fifty: 50, sixty: 60, seventy: 70, eighty: 80, ninety: 90 }; return words.slice(0, 2).reduce((total, word) => total + values[word], 0);
}
/** Separates a clean display name from internal Part/Chapter/Scene prefixes. */
export function titleName(title: string): string {
let value = title.trim(); const typed = value.match(/^(?:part|chapter|scene)\b/i);
if (typed) value = value.slice(typed[0].length).trim();
@ -16,12 +25,14 @@ export function titleName(title: string): string {
return value.replace(/^[\s:._—-]+|[\s:._—-]+$/g, "").trim();
}
/** Formats supported positive structure numbers for author-facing headings. */
export function numberWord(value: number): string { const ones = ["Zero", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine", "Ten", "Eleven", "Twelve", "Thirteen", "Fourteen", "Fifteen", "Sixteen", "Seventeen", "Eighteen", "Nineteen"]; const tens = ["", "", "Twenty", "Thirty", "Forty", "Fifty", "Sixty", "Seventy", "Eighty", "Ninety"]; if (!Number.isInteger(value) || value < 0) return String(value); if (value < 20) return ones[value]; if (value < 100) return `${tens[Math.floor(value / 10)]}${value % 10 ? `-${ones[value % 10]}` : ""}`; return String(value); }
function compareValues(aOrder: number | undefined, aNumber: number | undefined, aName: string, bOrder: number | undefined, bNumber: number | undefined, bName: string, metadataOrdering: boolean): number {
if (metadataOrdering && aOrder !== bOrder) { if (aOrder === undefined) return 1; if (bOrder === undefined) return -1; return aOrder - bOrder; }
if (aNumber !== bNumber) { if (aNumber === undefined) return 1; if (bNumber === undefined) return -1; return aNumber - bNumber; }
return collator.compare(aName, bName);
}
/** Sorts a mutable document list by metadata (when enabled), number, then title. */
export function sortDocuments(documents: ManuscriptDocument[], metadataOrdering: boolean): void {
documents.sort((a, b) => compareValues(a.metadata.order ?? extractNumber(a.metadata.scene), a.number, a.file.name, b.metadata.order ?? extractNumber(b.metadata.scene), b.number, b.file.name, metadataOrdering));
}

View file

@ -1,3 +1,10 @@
/**
* Manuscript Compiler vault-relative path validation.
*
* Shared by output code before adapter access. Rejects absolute, traversal, and
* non-portable paths. WarningEngine separately checks whether a valid output
* path would still fall inside the manuscript source root.
*/
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; }

View file

@ -1,3 +1,13 @@
/**
* Manuscript Compiler semantic parser.
*
* Reads a content-plan-rewritten scan, parses metadata, cleans notes, and builds
* the Book/Part/Chapter/Scene model. Called by ManuscriptCompiler; calls content
* cleaning, metadata filters, and ordering.
*
* Invariants: metadata never becomes prose, zero numbering is never invented,
* and cancellation stops queued reads rather than returning a partial Book.
*/
import { parseYaml, TFile, Vault } from "obsidian";
import { ContentCleaningPipeline } from "./filters";
import { MetadataFilterEngine, normalizeKey } from "./metadata-filter";
@ -7,12 +17,20 @@ import type { CompileOptions, CompileProfile } from "./settings";
import type { ScannedBook, ScannedChapter, ScannedPart } from "./types";
import { throwIfCancelled } from "./cancellation";
/** Vault-bound parser; each parse call owns caches, warnings, and cancellation. */
export class ManuscriptParser {
private readonly cleaner = new ContentCleaningPipeline();
private readonly metadataFilter = new MetadataFilterEngine();
filterDurationMs = 0;
constructor(private readonly vault: Vault) {}
/**
* Builds one semantic Book from an already authoritative scan.
*
* File reads may run concurrently, but the returned hierarchy and ordering are
* deterministic. Cancellation rejects the whole parse; callers must never use
* partially populated caches as a manuscript.
*/
async parse(scan: ScannedBook, settings: CompileOptions, signal?: AbortSignal): Promise<Book> {
const warnings = [...scan.warnings];
if (scan.hierarchyDiagnostics?.length) console.warn("Manuscript Compiler hierarchy diagnostics", scan.hierarchyDiagnostics);

View file

@ -1,3 +1,10 @@
/**
* Manuscript Compiler platform capability isolation.
*
* Provides optional desktop open/reveal/file-picker bridges without leaking
* Electron or filesystem details into modals. ResultActionService is the main
* caller. Every operation fails closed when unavailable.
*/
import { FileSystemAdapter, Vault } from "obsidian";
/**

View file

@ -1,3 +1,10 @@
/**
* Manuscript Compiler settings migration, repair, and profile utilities.
*
* Called during plugin load and advanced profile editing. Migration preserves
* old data while repair supplies safe current defaults and warnings. Both must
* be idempotent and must not overwrite explicit user choices.
*/
import type { CompileProfile, ManuscriptCompilerSettings } from "./settings";
import { DEFAULT_OPTIONS } from "./settings";
import { clampCentimetres, inchesToCentimetres } from "./measurements";
@ -5,8 +12,11 @@ import { clampCentimetres, inchesToCentimetres } from "./measurements";
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)}`; }
function profile(name: string, options = DEFAULT_OPTIONS, firstLineIndentCm = 1.27): CompileProfile { return { ...options, metadataFilters: options.metadataFilters.map((rule) => ({ ...rule })), id: profileId(), name, manuscriptRoot: "", exportFolder: "Manuscript Exports", outputFilename: "{BookTitle}.docx", variables: { BookTitle: "", Series: "", Author: "" }, exportTarget: "docx", referenceDocx: "", pandocMetadataFile: "", additionalPandocArguments: "", generateTableOfContents: false, keepIntermediateMarkdown: false, docxFirstLineIndentCm: firstLineIndentCm, docxPageSize: "a4" }; }
/** Creates fresh profiles; callers may mutate them without sharing nested arrays. */
export function createDefaultProfiles(): CompileProfile[] { return [profile("Default"), profile("Vellum", VELLUM_OPTIONS, 0.75)]; }
/** Copies mutable nested profile values and assigns a new stable identity. */
export function duplicateProfile(source: CompileProfile, name = `${source.name} Copy`): CompileProfile { return { ...source, id: profileId(), name, metadataFilters: source.metadataFilters.map((rule) => ({ ...rule })), variables: { ...source.variables } }; }
/** Applies historical schema upgrades once while retaining compatibility data. */
export function migrateSettings(settings: ManuscriptCompilerSettings): ManuscriptCompilerSettings {
if (settings.profiles.length > 0) { settings.profiles = settings.profiles.map((item) => ({ ...item, exportTarget: item.exportTarget ?? settings.defaultExportFormat ?? "markdown", referenceDocx: item.referenceDocx ?? settings.defaultReferenceDocx ?? "", pandocMetadataFile: item.pandocMetadataFile ?? "", additionalPandocArguments: item.additionalPandocArguments ?? "", generateTableOfContents: item.generateTableOfContents ?? false, keepIntermediateMarkdown: item.keepIntermediateMarkdown ?? settings.keepTemporaryMarkdown ?? false })); return settings; }
const profiles = createDefaultProfiles(); const active = profiles[settings.defaultCompilePreset === "vellum" ? 1 : 0];
@ -21,6 +31,7 @@ export function migrateSettings(settings: ManuscriptCompilerSettings): Manuscrip
});
return { ...settings, profiles, activeProfileId: active.id, defaultProfileId: active.id };
}
/** 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 activeForMigration = repaired.profiles.find((item) => item.id === repaired.activeProfileId) ?? repaired.profiles[0];
@ -57,9 +68,11 @@ export function repairSettings(settings: ManuscriptCompilerSettings): Manuscript
repaired.configurationWarnings = [...(Array.isArray(repaired.configurationWarnings) ? repaired.configurationWarnings : []), ...warnings].slice(-100);
return repaired;
}
/** Resolves active/default profile safely and creates defaults when necessary. */
export function activeProfile(settings: ManuscriptCompilerSettings): CompileProfile {
return settings.profiles.find((item) => item.id === settings.activeProfileId) ?? settings.profiles.find((item) => item.id === settings.defaultProfileId) ?? settings.profiles[0];
}
/** Validates imported profile data without mutating settings or accepting code. */
export function validateProfile(value: unknown): { profile?: CompileProfile; errors: string[] } {
const errors: string[] = []; if (!value || typeof value !== "object" || Array.isArray(value)) return { errors: ["Profile must be a JSON object."] };
const item = value as Partial<CompileProfile>;

View file

@ -1,21 +1,34 @@
/**
* Manuscript Compiler post-export platform actions.
*
* Determines and performs open, reveal, and save-copy actions after verified
* success. Called by ExportCoordinator/result UI; calls platform-compat only.
* Failed or rolled-back exports must never expose these capabilities.
*/
import { FileSystemAdapter, TFile, type App } from "obsidian";
import { openExternalVaultFile, revealExternalVaultFile } from "./platform-compat";
/** Platform actions that may be offered only for a successfully verified output. */
export interface ResultActionCapabilities { open: boolean; reveal: boolean; saveCopy: boolean; }
/** App-scoped capability service; methods return false instead of fabricating support. */
export class ResultActionService {
constructor(private readonly app: App) {}
/** Computes UI actions from output type, platform support, and truthful success state. */
capabilities(path: string, succeeded: boolean): ResultActionCapabilities {
if (!succeeded || !path) return { open: false, reveal: false, saveCopy: false };
const markdown = /\.md$/i.test(path);
const local = this.app.vault.adapter instanceof FileSystemAdapter;
return { open: markdown || local, reveal: local, saveCopy: /\.docx$/i.test(path) };
}
/** Opens Markdown inside Obsidian and delegates binary files to the isolated platform bridge. */
async openExport(path: string): Promise<boolean> {
const file = this.app.vault.getAbstractFileByPath(path);
if (file instanceof TFile && file.extension === "md") { await this.app.workspace.getLeaf(true).openFile(file); return true; }
return openExternalVaultFile(this.app.vault, path);
}
/** Reveals local filesystem output where the adapter exposes that capability. */
revealExport(path: string): boolean { return revealExternalVaultFile(this.app.vault, path); }
/** Offers a browser-mediated copy without changing or revalidating the vault output. */
async saveCopyToComputer(path: string): Promise<boolean> {
try {
const file = this.app.vault.getAbstractFileByPath(path);

View file

@ -1,3 +1,14 @@
/**
* Manuscript Compiler staged, validated binary output.
*
* Validates complete DOCX bytes, writes a same-folder temporary file, verifies
* readback, then commits or rolls back through an adapter-aware backend. Called
* by DocxExporter; calls docx-validator and platform adapters.
*
* Invariants: preserve the old file until final verification, stop accepting
* cancellation at commit, return success only after validation, and clean only
* artifacts matching this plugin's exact naming convention.
*/
import { FileSystemAdapter, normalizePath, type DataAdapter, type Vault } from "obsidian";
import { CompilationCancelledError } from "./cancellation";
import { assertValidDocx, validateDocxBytes, type DocxValidationResult } from "./docx-validator";
@ -5,10 +16,16 @@ import { nodeFs } from "./filesystem";
import { validateVaultPath } from "./output-path";
export type SafeSaveStage = "Saving to vault" | "Verifying temporary file" | "Finalising file" | "Verifying saved DOCX" | "Restoring previous file" | "Cleaning up";
/** Per-operation callbacks and cancellation supplied by export orchestration. */
export interface SafeBinaryWriteOptions { signal?: AbortSignal; onProgress?: (stage: SafeSaveStage) => void; onCommit?: () => void; token?: string; }
/** Returned only after the destination has been read back and validated. */
export interface SafeBinaryWriteResult { path: string; strategy: "same-folder-filesystem" | "verified-adapter-recovery"; replacedExisting: boolean; finalValidation: DocxValidationResult; checksum: string; }
export interface StaleCleanupResult { removed: string[]; preservedBackups: string[]; }
export interface BinaryEntry { path: string; mtime: number; }
/**
* Minimal binary storage contract. Tests inject deterministic failure backends;
* production wraps either a local filesystem or generic Obsidian adapter.
*/
export interface SafeBinaryBackend {
readonly kind: "filesystem" | "adapter";
exists(path: string): Promise<boolean>;
@ -19,16 +36,26 @@ export interface SafeBinaryBackend {
list(folder: string): Promise<BinaryEntry[]>;
}
/** Failure carrying restoration outcome and, only when critical, recovery paths. */
export class SafeBinaryWriteError extends Error {
readonly severity: "error" | "critical";
constructor(message: string, readonly restoration: "not-needed" | "restored" | "failed", readonly backupPath?: string, readonly destinationPath?: string) { super(message); this.name = "SafeBinaryWriteError"; this.severity = restoration === "failed" ? "critical" : "error"; }
}
/**
* Transaction-like writer for validated DOCX bytes. One instance may serve many
* sequential exports; callers provide per-write cancellation/progress. It owns
* temporary and backup artifacts but never records history or displays UI.
*/
export class SafeBinaryWriter {
static readonly STALE_TEMP_AGE_MS = 24 * 60 * 60 * 1000;
private readonly backend: SafeBinaryBackend;
constructor(vaultOrBackend: Vault | SafeBinaryBackend) { this.backend = isBackend(vaultOrBackend) ? vaultOrBackend : backendForVault(vaultOrBackend); }
/**
* Validates before touching the destination, stages and verifies bytes, then
* completes replacement or rollback. Cancellation after `onCommit` is ignored.
*/
async writeValidated(destinationPath: string, bytes: Uint8Array, options: SafeBinaryWriteOptions = {}): Promise<SafeBinaryWriteResult> {
const destination = validateVaultPath(destinationPath); assertValidDocx(bytes, "Generated DOCX"); checkCancelled(options.signal);
const { temp, backup } = artifactPaths(destination, options.token ?? token()); const generatedChecksum = checksum(bytes); let commitStarted = false; let preserveBackup = false;
@ -45,6 +72,10 @@ export class SafeBinaryWriter {
}
}
/**
* Non-recursively removes only recognised old temporary files. Backups and
* recent files are preserved because they may represent recovery or active work.
*/
async cleanupStaleArtifacts(folderPath: string, now = Date.now()): Promise<StaleCleanupResult> {
const folder = validateVaultPath(folderPath); const removed: string[] = []; const preservedBackups: string[] = [];
for (const entry of await this.backend.list(folder)) {

View file

@ -1,3 +1,10 @@
/**
* Manuscript Compiler persisted configuration schema and defaults.
*
* Shared by migration, workspace resolution, compiler services, and settings
* UI. Historical fields remain for data compatibility even when inactive. New
* defaults must remain native-DOCX, offline, A4/metric, and author-safe.
*/
export type OrderingMethod = "filename" | "metadata";
export type WarningLevel = "information" | "warning" | "error";
export type MetadataOperator = "equals" | "not-equals";
@ -6,14 +13,21 @@ export type ChapterSource = "folders" | "notes";
export type StructurePreset = "novel-parts" | "novel" | "chapter-notes" | "short-story" | "anthology" | "custom";
export type DocxStylePreset = "vellum" | "standard" | "custom";
/** Persisted legacy-compatible metadata predicate with stable UI identity. */
export interface MetadataFilterRule { id: string; field: string; operator: MetadataOperator; value: string; }
/** Configurable syntax cleaning; mandatory metadata/body safety is not optional. */
export interface CleaningSettings { stripYamlFrontmatter: boolean; removeObsidianComments: boolean; removeHtmlComments: boolean; removeDataviewBlocks: boolean; removeCallouts: boolean; stripInternalLinks: boolean; bodySectionAliases?: string[]; }
/** Parser/generator options after request/profile resolution. */
export interface CompileOptions extends CleaningSettings {
includeFrontMatter: boolean; includeBackMatter: boolean; includeSceneTitles: boolean; metadataOrdering: boolean;
partHeadingTemplate: string; chapterHeadingTemplate: string; sceneSeparator: string;
orderingMethod: OrderingMethod; metadataFilters: MetadataFilterRule[]; blankLinesBetweenSections: number; blankLinesBetweenChapters: number;
useParts: boolean; chapterSource: ChapterSource;
}
/**
* Persisted reusable configuration. Profiles are cloned before compilation.
* Pandoc-named fields are inert compatibility data and must remain non-executable.
*/
export interface CompileProfile extends CompileOptions {
id: string; name: string; manuscriptRoot: string; exportFolder: string; outputFilename: string;
variables: { BookTitle: string; Series: string; Author: string };
@ -30,8 +44,10 @@ export interface CompileProfile extends CompileOptions {
partDisplay?: StructuralDisplay; chapterDisplay?: StructuralDisplay; explicitlyIncludedPaths?: string[];
}
export type StructuralDisplay = "word" | "numeric" | "word-title" | "numeric-title" | "title" | "custom";
/** Repaired, bounded persisted summary; never contains manuscript prose. */
export interface ExportHistoryEntry { id: string; timestamp: string; profile: string; manuscript: string; outputFiles: string[]; wordCount: number; success: boolean; cancelled?: boolean; message?: string; }
export interface CompileLogEntry extends ExportHistoryEntry { exportFormats: ExportTarget; compilerVersion: string; pandocVersion?: string; durationMs: number; scanDurationMs: number; parseDurationMs: number; filterDurationMs: number; generationDurationMs: number; exportDurationMs: number; warnings: string[]; diagnostics?: string; }
/** Plugin-owned persisted state loaded and saved only through the plugin lifecycle. */
export interface ManuscriptCompilerSettings extends CompileOptions {
profiles: CompileProfile[]; activeProfileId: string; defaultProfileId: string;
showPreview: boolean; expandPreviewTree: boolean; showStatistics: boolean; readingWordsPerMinute: number; minimumWarningLevel: WarningLevel;

View file

@ -1,6 +1,14 @@
/**
* Manuscript Compiler four-step request and preset resolution.
*
* Translates concise author choices into a complete compatibility profile while
* preserving the workspace plan as authoritative. Called by workspace state and
* CompilePreparationService; calls no vault or UI APIs.
*/
import type { CompileProfile, DocxStylePreset, ExportTarget, StructuralDisplay, StructurePreset } from "./settings";
import type { ContentPlanItem } from "./content-plan";
/** Controller-owned mutable formatting; centimetres are the canonical UI unit. */
export interface DocxFormatting { font: string; fontSize: number; lineSpacing: number; firstLineIndentCm: number; pageSize: "letter" | "a4"; chapterPageBreak: boolean; titlePage: boolean; }
export const DOCX_FORMATTING_PRESETS: Record<Exclude<DocxStylePreset, "custom">, Readonly<DocxFormatting>> = {
@ -8,11 +16,16 @@ export const DOCX_FORMATTING_PRESETS: Record<Exclude<DocxStylePreset, "custom">,
standard: { font: "Times New Roman", fontSize: 12, lineSpacing: 2, firstLineIndentCm: 1.27, pageSize: "a4", chapterPageBreak: true, titlePage: false }
};
/** Returns a fresh deterministic preset; Custom copies the caller's values. */
export function docxFormattingForPreset(preset: DocxStylePreset, titlePage = false, current?: DocxFormatting): DocxFormatting {
if (preset === "custom") return { ...(current ?? DOCX_FORMATTING_PRESETS.standard), titlePage };
return { ...DOCX_FORMATTING_PRESETS[preset], titlePage };
}
/**
* Complete author request. Mutable in the workspace, then copied into a prepared
* session so later UI edits cannot alter reviewed output implicitly.
*/
export interface SimpleCompileRequest {
manuscriptRoot: string; structurePreset: StructurePreset; includeFrontMatter: boolean; includeBackMatter: boolean;
exportFolder: string; outputFilename: string; outputFormat: ExportTarget; docxPreset: DocxStylePreset;
@ -41,6 +54,7 @@ const DOCX: Record<DocxStylePreset, Partial<CompileProfile>> = {
custom: { exportTarget: "docx", stripYamlFrontmatter: true, removeObsidianComments: true, removeHtmlComments: true, removeDataviewBlocks: true, removeCallouts: true, stripInternalLinks: true, generateTableOfContents: false, keepIntermediateMarkdown: false }
};
/** Resolves one request into a new profile snapshot without mutating the base. */
export function resolveSimpleCompileRequest(request: SimpleCompileRequest, base: CompileProfile): CompileProfile {
const structure = request.structurePreset === "custom" ? request.custom ?? {} : STRUCTURES[request.structurePreset];
const formatting = request.formatting ?? docxFormattingForPreset(request.docxPreset, base.docxTitlePage === true);
@ -59,6 +73,7 @@ export function resolveSimpleCompileRequest(request: SimpleCompileRequest, base:
};
}
/** Ensures an edited workspace plan wins after compatibility-profile resolution. */
export function applyWorkspacePlanAuthority(profile: CompileProfile, request: SimpleCompileRequest): CompileProfile {
const plan = request.contentPlan;
if (!plan) return profile;
@ -91,6 +106,7 @@ export function applyContentPlanAuthority(profile: CompileProfile, manuscriptRoo
return profile;
}
/** Returns author-facing input errors without touching the vault. */
export function validateSimpleCompileRequest(request: SimpleCompileRequest): string[] {
const errors: string[] = [];
if (!request.manuscriptRoot.trim()) errors.push("Choose a manuscript folder.");
@ -100,6 +116,7 @@ export function validateSimpleCompileRequest(request: SimpleCompileRequest): str
return errors;
}
/** Maps legacy structural settings to the closest safe automatic-plan preset. */
export function inferStructurePreset(profile: CompileProfile): StructurePreset {
if (profile.useParts && profile.chapterSource === "notes") return "anthology";
if (profile.useParts) return "novel-parts";

View file

@ -1,6 +1,14 @@
/**
* Manuscript Compiler semantic manuscript statistics.
*
* Counts only included, non-empty documents from the final Book. Preparation,
* preview, validation, and reports share this result so raw discovery counts
* cannot disagree with exported structure.
*/
import type { Book, ManuscriptStatistics, NamedStatistic } from "./model";
import type { CompileProfile } from "./settings";
export function documentWordCount(content: string): number { const text = content.replace(/```[\s\S]*?```/g, " ").replace(/`[^`]*`/g, " ").replace(/[\p{P}\p{S}]+/gu, " ").trim(); return text ? text.split(/\s+/u).length : 0; }
/** Stateless calculator over the final semantic Book. */
export class StatisticsEngine {
calculate(book: Book, profile: CompileProfile, wordsPerMinute: number): ManuscriptStatistics {
const scenes = [...book.orphanScenes, ...book.parts.flatMap((part) => [...part.orphanScenes, ...part.chapters.flatMap((chapter) => chapter.scenes)])].filter((scene) => !scene.excluded && !!scene.content.trim());

View file

@ -1,4 +1,11 @@
/**
* Manuscript Compiler constrained variable expansion.
*
* Used by output naming and retained custom heading templates. Unknown variables
* resolve safely; this is interpolation, not a script runtime.
*/
export type TemplateVariables = Record<string, string | number | undefined>;
/** Stateless safe interpolator with no evaluation or side effects. */
export class TemplateEngine {
render(template: string, variables: TemplateVariables): string {
const exact = new Map(Object.entries(variables));

View file

@ -1,5 +1,14 @@
/**
* Manuscript Compiler scanner transfer types.
*
* Physical discovery records passed from VaultScanner to content-plan rewriting
* and then ManuscriptParser. They are separate from the semantic model.
* Production exporters must never accept these types.
*/
import type { TFile, TFolder } from "obsidian";
/** Relative-path-only orphan context safe for diagnostic logs. */
export interface HierarchyDiagnostic { scenePath: string; inferredRole: string; parentPath: string; parentRole: string; nearestStructuralAncestor: string; transparentReparenting: boolean; parentExcluded: boolean; }
/** Mutable physical scan rewritten by ContentPlan before parsing. */
export interface ScannedBook { root: TFolder; frontMatter: TFile[]; parts: ScannedPart[]; looseScenes: TFile[]; backMatter: TFile[]; allMarkdown: TFile[]; warnings: string[]; hierarchyDiagnostics?: HierarchyDiagnostic[]; }
export interface ScannedPart { folder: TFolder; chapters: ScannedChapter[]; looseScenes: TFile[]; }
export interface ScannedChapter { folder: TFolder; scenes: TFile[]; }

View file

@ -1,3 +1,11 @@
/**
* Manuscript Compiler shared legacy/secondary Obsidian UI.
*
* Contains folder selection, legacy preview, overwrite, progress, validation,
* result, diagnostics, and settings views still reached outside the four-step
* workspace. It calls plugin/service callbacks and must not implement scanning,
* parsing, safe-write transactions, or platform filesystem bridges.
*/
import { App, FuzzySuggestModal, Modal, Notice, PluginSettingTab, Setting, TextAreaComponent, TFolder } from "obsidian";
import type ManuscriptCompilerPlugin from "./main";
import type { Chapter, CompilePreview, CompileResult, CompileWarning, ManuscriptDocument, Part } from "./model";
@ -8,6 +16,7 @@ import { STRUCTURE_PRESET_NAMES } from "./simple-workflow";
import type { ValidationResult } from "./validation";
import { ProfileWizardModal } from "./wizards";
/** Reusable vault-folder picker for compatibility commands/settings. */
export class FolderSuggestModal extends FuzzySuggestModal<TFolder> {
constructor(app: App, private readonly onChoose: (folder: TFolder) => void) { super(app); }
getItems(): TFolder[] { return this.app.vault.getAllLoadedFiles().filter((file): file is TFolder => file instanceof TFolder && file.path !== "/"); }
@ -15,6 +24,7 @@ export class FolderSuggestModal extends FuzzySuggestModal<TFolder> {
onChooseItem(folder: TFolder): void { this.onChoose(folder); }
}
/** Legacy-route semantic preview; receives finished preview data and never scans. */
export class CompilePreviewModal extends Modal {
constructor(app: App, private readonly preview: CompilePreview, private readonly expanded: boolean, private readonly showStatistics: boolean, private readonly resolve: (compile: boolean) => void) { super(app); }
onOpen(): void {
@ -44,11 +54,13 @@ export class CompilePreviewModal extends Modal {
private renderIssues(parent: HTMLElement, issues: CompileWarning[]): void { const details = parent.createEl("details"); details.createEl("summary", { text: `Issues (${issues.length})` }); const list = details.createEl("ul"); issues.forEach((issue) => list.createEl("li", { text: issue.message })); }
}
/** Resolves one overwrite decision; it performs no destination mutation. */
export class ConfirmOverwriteModal extends Modal {
constructor(app: App, private readonly path: string, private readonly resolve: (confirmed: boolean) => void) { super(app); }
onOpen(): void { this.titleEl.setText("Overwrite manuscript?"); this.contentEl.createEl("p", { text: `The file “${this.path}” already exists. Replace it?` }); new Setting(this.contentEl).addButton((button) => button.setButtonText("Cancel").onClick(() => { this.resolve(false); this.close(); })).addButton((button) => button.setButtonText("Overwrite").setWarning().onClick(() => { this.resolve(true); this.close(); })); }
}
/** Progress/cancel view whose cancel callback is owned by orchestration. */
export class CompilationProgressModal extends Modal {
private statusEl?: HTMLElement; private cancelButton?: HTMLButtonElement; private cancelled = false; private completed = false;
constructor(app: App, private readonly cancel: () => void) { super(app); }
@ -59,16 +71,19 @@ export class CompilationProgressModal extends Modal {
onClose(): void { if (!this.completed && !this.cancelled) { this.cancelled = true; this.cancel(); } this.contentEl.empty(); }
}
/** Read-only presentation of ManuscriptValidationService output. */
export class ValidationReportModal extends Modal {
constructor(app: App, private readonly root: string, private readonly report: ValidationResult) { super(app); }
onOpen(): void { this.titleEl.setText("Manuscript validation report"); const counts = { information: 0, warning: 0, error: 0 }; this.report.issues.forEach((issue) => { counts[issue.severity] += 1; }); const summary = this.contentEl.createEl("dl", { cls: "manuscript-compiler-report" }); [["Manuscript", this.root], ["Front matter", String(this.report.book.frontMatter.documents.filter((item) => !item.excluded && item.content.trim()).length)], ["Parts", String(this.report.statistics.partCount)], ["Chapters", String(this.report.statistics.chapterCount)], ["Scenes", String(this.report.statistics.sceneCount)], ["Back matter", String(this.report.book.backMatter.documents.filter((item) => !item.excluded && item.content.trim()).length)], ["Excluded items", String(this.report.exclusions.length)], ["Errors", String(counts.error)], ["Warnings", String(counts.warning)], ["Information", String(counts.information)]].forEach(([label, value]) => row(summary, label, value)); if (this.report.exclusions.length) { const excluded = this.contentEl.createEl("details"); excluded.createEl("summary", { text: `Excluded content (${this.report.exclusions.length})` }); const list = excluded.createEl("ul"); this.report.exclusions.forEach((item) => list.createEl("li", { text: `${item.name}: ${item.reason}` })); } const issues = this.contentEl.createEl("ul"); this.report.issues.forEach((issue) => issues.createEl("li", { text: `${issue.message}${issue.suggestion ? ` Suggested fix: ${issue.suggestion}` : ""}` })); new Setting(this.contentEl).addButton((button) => button.setButtonText("Close").setCta().onClick(() => this.close())); }
}
/** Post-success result view delegating platform actions to supplied callbacks. */
export class CompileReportModal extends Modal {
constructor(app: App, private readonly output: string, private readonly report: CompileResult, private readonly showStatistics: boolean, private readonly openFile?: () => void, private readonly revealFile?: () => void, private readonly saveCopy?: () => void) { super(app); }
onOpen(): void { const filename = this.output.split(",")[0].split("/").pop() ?? this.output; this.titleEl.setText("DOCX created successfully"); this.contentEl.createEl("h3", { text: filename }); const list = this.contentEl.createEl("dl", { cls: "manuscript-compiler-report" }); [["Saved to vault", this.output], ["Words", this.report.wordCount.toLocaleString()], ["Chapters", String(this.report.chapters)], ["Scenes", String(this.report.scenes)]].forEach(([label, value]) => row(list, label, value)); const warnings = this.report.issues.filter((issue) => issue.severity !== "information"); if (warnings.length) { this.contentEl.createEl("h3", { text: "Warnings" }); warnings.forEach((issue) => this.contentEl.createEl("p", { cls: `manuscript-issue-${issue.severity}`, text: issue.message })); } if (this.showStatistics) this.contentEl.createEl("p", { text: `Average chapter: ${this.report.statistics.averageChapterLength.toLocaleString()} words · Average scene: ${this.report.statistics.averageSceneLength.toLocaleString()} words` }); const actions = new Setting(this.contentEl); if (this.saveCopy) actions.addButton((button) => button.setButtonText("Save a copy to computer").onClick(this.saveCopy!)); if (this.openFile) actions.addButton((button) => button.setButtonText("Open DOCX").onClick(this.openFile!)); if (this.revealFile) actions.addButton((button) => button.setButtonText("Reveal in file manager").onClick(this.revealFile!)); actions.addButton((button) => button.setButtonText("Close").setCta().onClick(() => this.close())); }
}
/** Displays and optionally saves an already-redacted diagnostics report. */
export class DiagnosticsReportModal extends Modal {
constructor(app: App, private readonly report: string, private readonly save: () => Promise<string>) { super(app); }
onOpen(): void { this.titleEl.setText("Diagnostics report"); this.contentEl.createEl("p", { text: "This report contains configuration and environment information, but no manuscript contents." }); const area = new TextAreaComponent(this.contentEl); area.setValue(this.report); area.inputEl.readOnly = true; area.inputEl.addClass("manuscript-profile-json"); new Setting(this.contentEl).addButton((button) => button.setButtonText("Copy").onClick(async () => { try { await navigator.clipboard.writeText(this.report); new Notice("Diagnostics report copied."); } catch { area.inputEl.focus(); area.inputEl.select(); } })).addButton((button) => button.setButtonText("Save to vault").setCta().onClick(async () => { new Notice(`Diagnostics saved to ${await this.save()}`, 6000); this.close(); })).addButton((button) => button.setButtonText("Close").onClick(() => this.close())); }
@ -82,6 +97,7 @@ class ProfileJsonModal extends Modal {
class ExportHistoryModal extends Modal { constructor(app: App, private readonly plugin: ManuscriptCompilerPlugin) { super(app); } onOpen(): void { this.titleEl.setText("Export history"); this.plugin.settings.exportHistory.forEach((entry) => { const details = this.contentEl.createEl("details"); details.createEl("summary", { text: `${entry.cancelled ? "—" : entry.success ? "✓" : "✗"} ${new Date(entry.timestamp).toLocaleString()}${entry.profile}` }); details.createEl("p", { text: `${entry.manuscript} · ${entry.wordCount.toLocaleString()} words` }); entry.outputFiles.forEach((path) => new Setting(details).setName(path).addButton((button) => button.setButtonText("Open").onClick(() => { void this.plugin.openExport(path); }))); }); new Setting(this.contentEl).addButton((button) => button.setButtonText("Clear history and logs").setWarning().onClick(async () => { await this.plugin.clearHistory(); this.close(); })).addButton((button) => button.setButtonText("Close").onClick(() => this.close())); } }
class CompileLogsModal extends Modal { constructor(app: App, private readonly plugin: ManuscriptCompilerPlugin) { super(app); } onOpen(): void { this.titleEl.setText("Compile logs"); this.plugin.settings.compileLogs.forEach((log) => { const details = this.contentEl.createEl("details"); details.createEl("summary", { text: `${log.cancelled ? "Cancelled" : log.success ? "Success" : "Failure"}${new Date(log.timestamp).toLocaleString()}${log.profile}` }); const pre = details.createEl("pre"); pre.setText([`Compiler: ${log.compilerVersion}`, `Manuscript: ${log.manuscript}`, `Formats: ${log.exportFormats}`, `Outputs: ${log.outputFiles.join(", ") || "None"}`, `Duration: ${log.durationMs} ms`, `Warnings: ${log.warnings.join(" | ") || "None"}`, log.diagnostics ? `Diagnostics:\n${log.diagnostics}` : ""].filter(Boolean).join("\n")); }); new Setting(this.contentEl).addButton((button) => button.setButtonText("Close").onClick(() => this.close())); } }
/** Defaults/advanced compatibility settings; not the primary compile workspace. */
export class ManuscriptCompilerSettingTab extends PluginSettingTab {
constructor(app: App, private readonly plugin: ManuscriptCompilerPlugin) { super(app, plugin); }
display(): void {

View file

@ -1,12 +1,22 @@
/**
* Manuscript Compiler read-only prepared-manuscript validation.
*
* Reports the exact Book, counts, exclusions, and issues that export would use.
* Called by CompileCommandService. It consumes PreparedCompileSession and never
* scans, rebuilds the Book, or writes output.
*/
import { TFolder, Vault } from "obsidian";
import type { PreparedCompileSession, PreparedExclusion } from "./compile-preparation";
import type { Book, CompileWarning, ManuscriptStatistics } from "./model";
import { validateProfile } from "./profiles";
import type { ManuscriptCompilerSettings } from "./settings";
/** Immutable report view over the prepared Book; ownership remains with the session. */
export interface ValidationResult { book: Book; statistics: ManuscriptStatistics & { partCount: number }; exclusions: PreparedExclusion[]; issues: CompileWarning[]; docxEngine: "built-in"; durationMs: number; }
/** Vault/settings-bound read-only validator over PreparedCompileSession. */
export class ManuscriptValidationService {
constructor(private readonly vault: Vault, private readonly settings: ManuscriptCompilerSettings) {}
/** Adds configuration/output-safety issues to session warnings without writing or rebuilding. */
async validate(session: PreparedCompileSession): Promise<ValidationResult> {
const started = performance.now(); const { book, profile } = session; const issues = [...session.warnings];
const profileValidation = validateProfile(profile); profileValidation.errors.forEach((message) => issues.push({ severity: "error", code: "invalid-profile", message }));

View file

@ -1,3 +1,13 @@
/**
* Manuscript Compiler mechanical vault discovery.
*
* Walks one exact TFolder boundary and records Markdown files. It intentionally
* does not infer author intent; content-plan.ts owns classification. Called only
* by CompilePreparationService.
*
* Invariant: production code must apply an authoritative ContentPlan before
* scanner output reaches ManuscriptParser or any exporter.
*/
import { TAbstractFile, TFile, TFolder } from "obsidian";
import type { ScannedBook, ScannedChapter, ScannedPart } from "./types";
@ -5,7 +15,13 @@ const FRONT_NAMES = new Set(["front matter", "ebook front matter", "print front
const BACK_NAMES = new Set(["back matter", "ebook back matter", "print back matter"]);
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" });
/** Stateless synchronous scanner; it never reads file bodies or mutates vault data. */
export class VaultScanner {
/**
* Discovers Markdown below exactly `root` and returns a provisional mechanical
* shape. The result is intentionally permissive and is unsafe for export until
* content-plan classification and authoritative reconstruction have run.
*/
scan(root: TFolder): ScannedBook {
const warnings: string[] = [];
const children = this.visibleChildren(root);

View file

@ -1,8 +1,15 @@
/**
* Manuscript Compiler semantic and output-safety warning analysis.
*
* Examines the final Book and resolved profile, never permissive scanner output.
* Warnings are prose-free and author-facing; export-safety applies blocking policy.
*/
import type { Book, CompileWarning, WarningSeverity } from "./model";
import type { CompileProfile } from "./settings";
import { extractNumber } from "./ordering";
import { normalizeKey } from "./metadata-filter";
/** Stateless semantic/output analysis run after model construction. */
export class WarningEngine {
analyze(book: Book, profile: CompileProfile, outputPath: string): CompileWarning[] {
const issues: CompileWarning[] = book.warnings.map((message) => ({ severity: this.legacySeverity(message), code: "structure", message }));

View file

@ -1,3 +1,10 @@
/**
* Manuscript Compiler retained onboarding and profile setup UI.
*
* Creates compatible saved defaults and profiles for first-run/advanced users.
* It does not provide an alternative compile path; compilation still flows
* through CompileCommandService and CompilePreparationService.
*/
import { App, FuzzySuggestModal, Modal, Notice, Setting, TFolder } from "obsidian";
import type ManuscriptCompilerPlugin from "./main";
import { createDefaultProfiles, profileId } from "./profiles";
@ -20,6 +27,7 @@ export function profileFromWizard(choices: WizardChoices): CompileProfile {
return { ...base, id: profileId(), name: choices.name.trim() || (choices.vellum ? "Vellum" : "Standard"), manuscriptRoot: choices.manuscriptRoot, exportFolder: choices.exportFolder, chapterSource: choices.chapterSource, useParts: choices.useParts, sceneSeparator: choices.sceneSeparators ? "#" : "", includeFrontMatter: choices.includeFrontMatter, includeBackMatter: choices.includeBackMatter, referenceDocx: choices.referenceDocx, exportTarget: choices.referenceDocx ? "markdown-docx" : base.exportTarget };
}
/** Advanced profile creator; its result is persisted but never compiled directly. */
export class ProfileWizardModal extends Modal {
protected choices = initialChoices();
constructor(app: App, protected readonly plugin: ManuscriptCompilerPlugin, private readonly finished?: (profile: CompileProfile) => Promise<void>) { super(app); }
@ -33,6 +41,7 @@ export class ProfileWizardModal extends Modal {
protected toggle(name: string, description: string, key: "useParts" | "sceneSeparators" | "includeFrontMatter" | "includeBackMatter" | "vellum"): void { new Setting(this.contentEl).setName(name).setDesc(description).addToggle((toggle) => toggle.setValue(this.choices[key]).onChange((value) => { this.choices[key] = value; })); }
}
/** One-time onboarding view that stores defaults and may launch the sample route. */
export class FirstRunWizardModal extends Modal {
private choices = initialChoices(); private compileSample = false;
constructor(app: App, private readonly plugin: ManuscriptCompilerPlugin) { super(app); }

View file

@ -1,3 +1,10 @@
/**
* Manuscript Compiler DOM-independent workspace state controller.
*
* Owns author mutations, session invalidation, duplicate-operation prevention,
* cancellation, and export dispatch. The modal renders state but does not own
* compiler logic. Model/output changes invalidate the prepared session once.
*/
import type { PreparedCompileSession } from "../compile-preparation";
import { applyMatterRoleInheritance, type ContentPlanItem, type ContentRole } from "../content-plan";
import { OperationStateController } from "../operation-state";
@ -7,12 +14,18 @@ import type { StructuralDisplay, StructurePreset } from "../settings";
import { includedNoteCount, moveSibling, setItemIncluded, setItemRole } from "./content-tree";
import type { CompileWorkspaceState, CompileWorkspaceStep, WorkspaceError } from "./workspace-types";
/** Injected service boundary; implementations retain prepared Book identity. */
export interface CompileWorkspaceServices {
prepare(request: SimpleCompileRequest, plan: ContentPlanItem[], signal: AbortSignal): Promise<PreparedCompileSession>;
sessionIsCurrent(session: PreparedCompileSession): Promise<boolean>;
export(session: PreparedCompileSession): Promise<void>;
}
/**
* Modal-scoped state owner. One controller serves one workspace lifetime and one
* active operation. Closing cancels cancellable work unless export was detached
* for safe finalisation.
*/
export class CompileWorkspaceController {
readonly state: CompileWorkspaceState;
private readonly operations = new OperationStateController();
@ -25,13 +38,18 @@ export class CompileWorkspaceController {
this.state = { step: "manuscript", request, contentPlan: [], formatting, scannedRoot: "", preparationStatus: "idle", exportStatus: "idle" };
}
/** Changes visible step and cancels preparation when leaving Export. */
setStep(step: CompileWorkspaceStep): void {
if (step !== "export" && this.state.preparationStatus === "preparing") this.cancelActiveOperation();
this.state.step = step;
}
/** Replaces the authoritative root and discards scan-dependent choices. */
setRoot(root: string): void { this.update(() => { this.state.request.manuscriptRoot = root.trim(); this.state.contentPlan = []; this.state.scannedRoot = ""; }); }
/** Changes inference policy; a new scan is required before proceeding. */
setPreset(preset: StructurePreset): void { this.update(() => { this.state.request.structurePreset = preset; this.state.contentPlan = []; this.state.scannedRoot = ""; }); }
/** Takes ownership of a freshly detected mutable plan for the exact root. */
setDetectedPlan(root: string, plan: ContentPlanItem[]): void { this.update(() => { this.state.request.manuscriptRoot = root; this.state.contentPlan = plan; this.state.scannedRoot = root; }); }
/** Records an explicit role and propagates matter only to untouched children. */
setRole(path: string, role: ContentRole): void {
this.update(() => {
const item = this.state.contentPlan.find((candidate) => candidate.path === path);
@ -40,6 +58,7 @@ export class CompileWorkspaceController {
if (item?.kind === "folder") applyMatterRoleInheritance(this.state.contentPlan, path, role, previousRole);
});
}
/** Toggles effective inclusion while preserving a folder's child snapshot. */
setIncluded(path: string, included: boolean): void {
this.update(() => {
const item = this.state.contentPlan.find((candidate) => candidate.path === path);
@ -48,15 +67,20 @@ export class CompileWorkspaceController {
if (item?.kind === "folder") this.restoreChildren(path);
});
}
/** Moves one sibling; the resulting order is authoritative for compilation. */
moveItem(path: string, direction: -1 | 1): void { this.update(() => { this.state.contentPlan = moveSibling(this.state.contentPlan, this.state.request.manuscriptRoot, path, direction); }); }
/** Explicitly includes all items, converting inferred exclusions to safe roles. */
includeAll(): void { this.update(() => this.state.contentPlan.forEach((item) => { item.included = true; item.userOverride = true; if (item.role === "ignore") item.role = item.kind === "folder" ? "transparent" : "scene"; })); }
/** Explicitly excludes notes without altering folder structure or order. */
excludeAllNotes(): void { this.update(() => this.state.contentPlan.filter((item) => item.kind === "note").forEach((item) => { item.included = false; item.userOverride = true; })); }
/** Applies supported custom formatting and invalidates the prepared output. */
setFormatting(change: Partial<DocxFormatting>): void {
this.update(() => {
Object.assign(this.state.formatting, change);
this.state.request.docxPreset = "custom";
});
}
/** Applies deterministic preset values; Custom retains explicit values. */
setDocxPreset(value: SimpleCompileRequest["docxPreset"]): void {
this.update(() => {
this.state.request.docxPreset = value;
@ -68,21 +92,34 @@ export class CompileWorkspaceController {
}
});
}
/** Selects literal scene-break text; an empty string means styled blank spacing. */
setSceneSeparator(value: string): void { this.update(() => { if (this.state.request.custom) this.state.request.custom.sceneSeparator = value; this.state.request.docxPreset = "custom"; }); }
/** Changes semantic heading display without altering Part/Chapter identity. */
setDisplay(kind: "part" | "chapter", value: StructuralDisplay): void { this.update(() => { if (kind === "part") this.state.request.partDisplay = value; else this.state.request.chapterDisplay = value; this.state.request.docxPreset = "custom"; }); }
/** Enables or disables the genuine Word TOC field. */
setTableOfContents(value: boolean): void { this.update(() => { this.state.request.tableOfContents = value; this.state.request.docxPreset = "custom"; }); }
/** Replaces body-heading aliases used during note cleaning. */
setBodyAliases(values: string[]): void { this.update(() => { if (this.state.request.custom) this.state.request.custom.bodySectionAliases = values; }); }
/** Controls final matter-section inclusion without rewriting individual roles. */
setMatter(kind: "front" | "back", included: boolean): void { this.update(() => { if (kind === "front") this.state.request.includeFrontMatter = included; else this.state.request.includeBackMatter = included; }); }
/** Updates title-page variables that affect prepared DOCX output. */
setVariable(kind: "BookTitle" | "Author", value: string): void { this.update(() => { if (this.state.request.custom?.variables) this.state.request.custom.variables[kind] = value; }); }
/** Updates validated vault destination inputs and invalidates preview metadata. */
setOutput(folder: string, filename: string): void { this.update(() => { this.state.request.exportFolder = folder.trim(); this.state.request.outputFilename = filename; }); }
/** Changes only a post-success action preference; it does not rebuild the Book. */
setDownloadAfterExport(value: boolean): void { this.state.request.downloadAfterExport = value; }
/** Returns author-facing blockers for the current step without side effects. */
canAdvance(): string[] {
if (this.state.step === "manuscript" && !this.state.request.manuscriptRoot) return ["Choose a manuscript folder."];
if (this.state.step === "contents" && includedNoteCount(this.state.contentPlan, this.state.request.manuscriptRoot) === 0) return ["Include at least one manuscript note."];
return [];
}
/**
* Deduplicates preparation clicks and caches a successful session. `force`
* rebuilds stale preview state; failures leave the workspace usable.
*/
prepare(force = false): Promise<PreparedCompileSession | undefined> {
if (this.preparationPromise) return this.preparationPromise;
if (!force && this.state.preparedSession) return Promise.resolve(this.state.preparedSession);
@ -112,6 +149,10 @@ export class CompileWorkspaceController {
return this.preparationPromise;
}
/**
* Verifies source freshness then delegates the exact prepared session. Duplicate
* clicks share one promise. False means the modal should remain open.
*/
export(): Promise<boolean> {
if (this.exportPromise) return this.exportPromise;
const session = this.state.preparedSession;
@ -134,14 +175,18 @@ export class CompileWorkspaceController {
return this.exportPromise;
}
/** Cancels cancellable work and removes all derived preview state. */
invalidatePreparedSession(message = ""): void {
this.cancelActiveOperation();
this.state.preparedSession = undefined;
this.state.preparationStatus = "idle";
this.state.error = message ? workspaceError(message) : undefined;
}
/** Requests cancellation if the active operation has not begun finalisation. */
cancelActiveOperation(): boolean { return this.operations.cancel(); }
/** Transfers finalising export ownership beyond modal close. */
detachExport(): void { this.detachedExport = true; }
/** Releases modal ownership and cancels work that remains safely cancellable. */
close(): void { if (!this.detachedExport) this.cancelActiveOperation(); }
private snapshotChildren(path: string): void { this.childSnapshots.set(path, new Map(this.state.contentPlan.filter((candidate) => candidate.path.startsWith(`${path}/`)).map((child) => [child.path, { included: child.included, role: child.role, userOverride: child.userOverride }]))); }
private restoreChildren(path: string): void { this.childSnapshots.get(path)?.forEach((snapshot, childPath) => { const child = this.state.contentPlan.find((candidate) => candidate.path === childPath); if (child) { child.included = snapshot.included; child.role = snapshot.role; child.userOverride = snapshot.userOverride; } }); }

View file

@ -1,5 +1,12 @@
/**
* Manuscript Compiler pure Contents-tree mutations and projections.
*
* Keeps inclusion propagation, overrides, sibling order, and visible depth
* testable without a DOM. Parent disable/enable preserves saved child choices.
*/
import type { ContentPlanItem, ContentRole } from "../content-plan";
/** Resolves local inclusion through every ancestor up to the authoritative root. */
export function isEffectivelyIncluded(item: ContentPlanItem, plan: ContentPlanItem[], root: string): boolean {
if (!item.included || item.role === "ignore") return false;
const byPath = new Map(plan.map((candidate) => [candidate.path, candidate]));
@ -19,6 +26,7 @@ function effectivelyIncluded(item: ContentPlanItem, byPath: ReadonlyMap<string,
return true;
}
/** Applies explicit inclusion and enables ancestors needed to make it effective. */
export function setItemIncluded(plan: ContentPlanItem[], root: string, path: string, included: boolean): void {
const item = plan.find((candidate) => candidate.path === path);
if (!item) return;
@ -35,6 +43,7 @@ export function setItemIncluded(plan: ContentPlanItem[], root: string, path: str
if (included) enableAncestors(plan, root, item.parentPath);
}
/** Records an explicit role without silently rewriting descendant role choices. */
export function setItemRole(plan: ContentPlanItem[], root: string, path: string, role: ContentRole): void {
const item = plan.find((candidate) => candidate.path === path);
if (!item) return;
@ -45,6 +54,7 @@ export function setItemRole(plan: ContentPlanItem[], root: string, path: string,
if (item.included) enableAncestors(plan, root, item.parentPath);
}
/** Swaps sibling order only; hierarchy and descendant ordering remain unchanged. */
export function moveSibling(plan: ContentPlanItem[], root: string, path: string, direction: -1 | 1): ContentPlanItem[] {
const item = plan.find((candidate) => candidate.path === path);
if (!item) return plan;
@ -58,6 +68,7 @@ export function moveSibling(plan: ContentPlanItem[], root: string, path: string,
return orderedPlan(plan, root);
}
/** Flattens the tree in authoritative sibling order without changing parent paths. */
export function orderedPlan(plan: ContentPlanItem[], root: string): ContentPlanItem[] {
const children = new Map<string, ContentPlanItem[]>();
plan.forEach((item) => children.set(item.parentPath, [...(children.get(item.parentPath) ?? []), item]));
@ -77,6 +88,7 @@ export function includedNoteCount(plan: ContentPlanItem[], root: string): number
return plan.filter((item) => item.kind === "note" && effectivelyIncluded(item, byPath, root)).length;
}
/** Produces tree rows and effective inclusion; collapse remains separate view state. */
export function visibleRows(plan: ContentPlanItem[], root: string): Array<{ item: ContentPlanItem; depth: number; included: boolean }> {
const byPath = new Map(plan.map((item) => [item.path, item]));
return orderedPlan(plan, root).map((item) => ({ item, depth: Math.max(0, item.path.slice(root.length + 1).split("/").length - 1), included: effectivelyIncluded(item, byPath, root) }));

View file

@ -1,3 +1,9 @@
/**
* Manuscript Compiler Contents step renderer.
*
* Renders the editable tree and delegates mutations to the controller. Rows are
* updated in place to preserve focus, scroll, expansion, and large-book speed.
*/
import { Setting } from "obsidian";
import type { ContentPlanItem, ContentRole } from "../content-plan";
import type { CompileWorkspaceController } from "./compile-workspace-controller";

View file

@ -1,3 +1,9 @@
/**
* Manuscript Compiler persistent Contents presentation state.
*
* Stores scroll, focus identity, and collapsed folders independently of compile
* data. One modal owns one instance; changing roots intentionally resets it.
*/
import type { ContentPlanItem } from "../content-plan";
export type ContentsControl = "include" | "role" | "move-up" | "move-down" | "toggle";
@ -8,6 +14,7 @@ export interface ContentsFocus {
}
/** UI-only state that must survive a Contents-step DOM rebuild. */
/** Mutable view-only state retained for one modal lifetime. */
export class ContentsTreeViewState {
scrollTop = 0;
focus?: ContentsFocus;

View file

@ -1,3 +1,9 @@
/**
* Manuscript Compiler final semantic preview projection.
*
* Converts PreparedCompileSession into display-only data. It reads session.book,
* never ContentPlan structure, so preview describes the exact exported instance.
*/
import type { PreparedCompileSession, PreparedExclusion } from "../compile-preparation";
import type { CompileWarning, ManuscriptStatistics } from "../model";
import { numberWord } from "../ordering";
@ -6,6 +12,7 @@ import type { StructuralDisplay } from "../settings";
export interface PreviewMatterItem { title: string; }
export interface PreviewChapterItem { title: string; sceneCount: number; }
export interface PreviewPartItem { title: string; chapters: PreviewChapterItem[]; }
/** Immutable display projection derived only from PreparedCompileSession. */
export interface ExportPreviewViewModel {
book: PreparedCompileSession["book"];
title: string;

View file

@ -1,3 +1,9 @@
/**
* Manuscript Compiler Export step renderer.
*
* Displays preparation status, semantic outline, warnings, exclusions, and output.
* It emits actions to the controller and never builds a Book or accesses files.
*/
import { Setting } from "obsidian";
import type { CompileWorkspaceController } from "./compile-workspace-controller";
import { buildExportPreviewViewModel } from "./export-preview";

View file

@ -1,3 +1,9 @@
/**
* Manuscript Compiler Formatting step renderer.
*
* Exposes only native-DOCX options implemented by the generator. Author input is
* delegated to the controller; metric-to-OOXML conversion stays elsewhere.
*/
import { Setting, type DropdownComponent, type ToggleComponent } from "obsidian";
import type { DocxStylePreset, StructuralDisplay } from "../settings";
import type { CompileWorkspaceController } from "./compile-workspace-controller";

View file

@ -1,3 +1,9 @@
/**
* Manuscript Compiler Manuscript root step renderer.
*
* Shows the exact root, structure preset, and scan summary. It emits choices to
* the modal/controller and performs no inference or scanning itself.
*/
import { Setting, type TFolder } from "obsidian";
import { STRUCTURE_PRESET_NAMES } from "../simple-workflow";
import type { StructurePreset } from "../settings";

View file

@ -1,9 +1,16 @@
/**
* Manuscript Compiler four-step workspace state contracts.
*
* State is controller-owned and mutable. Views read it synchronously and request
* mutations rather than retaining divergent copies.
*/
import type { PreparedCompileSession } from "../compile-preparation";
import type { ContentPlanItem } from "../content-plan";
import type { OperationStatus } from "../operation-state";
import type { DocxFormatting, SimpleCompileRequest } from "../simple-workflow";
export type CompileWorkspaceStep = "manuscript" | "contents" | "formatting" | "export";
/** Separates concise author guidance from log-only technical detail. */
export interface WorkspaceError {
message: string;
suggestion?: string;
@ -11,6 +18,7 @@ export interface WorkspaceError {
severity: "information" | "warning" | "error" | "critical";
recoverable: boolean;
}
/** Single mutable state tree owned exclusively by CompileWorkspaceController. */
export interface CompileWorkspaceState {
step: CompileWorkspaceStep;
request: SimpleCompileRequest;

View file

@ -1,3 +1,8 @@
/**
* Native DOCX semantic integration suite.
* Inspects WordprocessingML to protect styles, pagination, scene/paragraph state,
* Unicode, matter order, Warden exclusions, validation, and staged output.
*/
import assert from "node:assert/strict";
import { readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
import path from "node:path";

View file

@ -1,3 +1,4 @@
/** Minimal semantic Book factory shared by DOCX validator/writer tests. */
import type { Book, ManuscriptDocument } from "../src/model";
import { createManuscriptDocx } from "../src/docx";
import { createDefaultProfiles } from "../src/profiles";

View file

@ -1,3 +1,8 @@
/**
* Filesystem-to-Obsidian fixture adapter for integration tests. It constructs
* TFolder/TFile-shaped trees and a readable fake vault without replacing
* production preparation or parsing code.
*/
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import type { ScannedBook, ScannedChapter, ScannedPart } from "../src/types";

View file

@ -1,3 +1,7 @@
/**
* Informational large-manuscript benchmark and deterministic correctness check.
* Timing is reported, not treated as a universal hardware promise.
*/
import { MarkdownGenerator } from "../src/markdown-generator";
import { ManuscriptParser } from "../src/parser";
import { createDefaultProfiles } from "../src/profiles";

View file

@ -1,3 +1,4 @@
/** Runtime-only Obsidian API stubs for Node-based bundled tests. */
export function parseYaml(source: string): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const line of source.split(/\r?\n/)) { const match = line.match(/^([^:#]+):\s*(.*)$/); if (!match) throw new Error(`Invalid YAML line: ${line}`); const value = match[2].trim(); result[match[1].trim()] = /^\d+(?:\.\d+)?$/.test(value) ? Number(value) : value; }

View file

@ -1,3 +1,9 @@
/**
* Core and release-regression suite.
* Protects parsing, cleaning, inference, migration, prepared-session identity,
* route unification, workspace state, real-vault structure, privacy, and package
* invariants from drifting independently.
*/
import assert from "node:assert/strict";
import { cleanManuscriptContent, ContentCleaningPipeline, removeCallouts, removeDataviewBlocks, removeHtmlComments, removeObsidianComments, removeProjectMetadataRegions, stripInternalLinks, stripYamlFrontmatter } from "../src/filters";
import { MarkdownGenerator } from "../src/markdown-generator";

View file

@ -1,3 +1,8 @@
/**
* SafeBinaryWriter failure-injection suite.
* Fake backends prove destination preservation, rollback, cancellation boundaries,
* cleanup, and critical recovery guidance without real disk failures.
*/
import assert from "node:assert/strict";
import { createTestDocx } from "./docx-test-fixture";
import { validateDocxBytes } from "../src/docx-validator";