mirror of
https://github.com/anthonyfitzpatrick/manuscript-compiler.git
synced 2026-07-22 08:31:19 +00:00
Fix real-vault structure mapping and folder compile action
This commit is contained in:
parent
913b3f2624
commit
db8d2e2d3d
41 changed files with 729 additions and 115 deletions
|
|
@ -6,6 +6,8 @@ Every production route resolves a `TFolder` through `BookRootResolver` and cross
|
|||
|
||||
`src/main.ts` is the plugin composition root. It loads and repairs settings, constructs services, registers stable command IDs and the settings tab, runs conservative startup cleanup, and cancels the active global operation on unload.
|
||||
|
||||
It also registers the documented workspace `file-menu` event through `registerEvent()`. `folder-context-menu.ts` adds the action only for `TFolder`; its callback delegates to `openCompilerForFolder()` and contains no scanning or preparation logic.
|
||||
|
||||
- `CompileCommandService` resolves roots and coordinates guided, legacy, sample, validation, and diagnostics commands.
|
||||
- `CompilePreparationService` is the only root-to-`PreparedCompileSession` boundary.
|
||||
- `CompileWorkspaceController` owns four-step state, validation, invalidation, duplicate-click protection, and cancellable workspace operations.
|
||||
|
|
@ -21,6 +23,7 @@ Every production route resolves a `TFolder` through `BookRootResolver` and cross
|
|||
| Command or caller | Root resolution | Content plan | Preparation purpose | Consumer |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `compile-manuscript` / `SimpleCompileModal` | explicit workspace folder through the preparation service | edited workspace plan, authoritative | `preview` / `guided` | Export-step preview, then `exportPreparedSession()` |
|
||||
| File Explorer folder action | exact right-clicked `TFolder`; no ancestry inference | workspace scan, then edited authoritative plan | `preview` / `guided` | same four-step workspace and `exportPreparedSession()` |
|
||||
| `compileRequest()` compatibility caller | explicit request root | supplied plan, or safely inferred when absent | `preview` / `guided` | `exportPreparedSession()` |
|
||||
| `compile-current-book` | `BookRootResolver.configuredOrCurrent()` | safely inferred and classified | `compile` / `current-book` | final-model preview, then `exportPreparedSession()` |
|
||||
| `compile-selected-folder` | `BookRootResolver.selected()` | safely inferred and classified | `compile` / `selected-folder` | final-model preview, then `exportPreparedSession()` |
|
||||
|
|
@ -72,9 +75,12 @@ SimpleCompileModal
|
|||
|
||||
The workspace plan wins over inference and legacy profile structure. Automatic routes always classify project folders, dashboards, revision notes, and empty cleaned notes before parsing. Legacy profiles can still supply formatting, output choices, matter preferences, and scene-break settings, but cannot bypass the content plan.
|
||||
|
||||
`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 item’s 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.
|
||||
|
||||
## Execution invariants
|
||||
|
||||
- The selected root names the book and is never a Part or Chapter.
|
||||
- An explicitly selected root is exact: no ancestor or nested child may replace it.
|
||||
- Transparent containers never emit headings.
|
||||
- The parser receives only a scan rewritten by an authoritative content plan.
|
||||
- Manual workspace inclusion, roles, and sibling order remain authoritative.
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@
|
|||
|
||||
### Fixed
|
||||
|
||||
- Fixed a real-vault hierarchy failure where a nested folder repeating the book name became a second Part, leaving valid Chapter Scenes orphaned and producing a zero Chapter count.
|
||||
- Transparent containers now reparent descendants to their nearest included structural ancestor without losing hierarchy, order, source paths, or explicit choices.
|
||||
- Mixed front/back matter, copyright containers, and common back-matter note names no longer become manuscript Parts, Chapters, or Scenes.
|
||||
- Added a folder-only File Explorer action, **Compile manuscript from this folder**, which opens the existing workspace with the exact clicked folder as the authoritative root.
|
||||
- Preview, validation, Markdown, and DOCX export now consume one prepared semantic `Book`; export never rebuilds the manuscript after preview.
|
||||
- Source or compile-choice changes after preview block stale export and keep **Refresh Preview** available.
|
||||
- Every compile command uses the same authoritative content plan, transparent-container rules, project-folder exclusions, dashboard/revision classification, and body cleaning.
|
||||
|
|
@ -15,13 +19,18 @@
|
|||
- Callouts are described accurately as conversion to plain text, preserving body text while removing the marker and title.
|
||||
- Removed the unused Subtitle Word style and the unused generic Markdown-to-DOCX production compatibility route.
|
||||
- Separated orchestration, workspace state, history, result actions, operation state, and step rendering without changing command IDs.
|
||||
- Contents role and inclusion edits now preserve scroll position and keyboard focus by updating existing rows instead of rebuilding the tree.
|
||||
- Excluded folders collapse automatically without clearing descendant inclusion choices or manual order.
|
||||
- Native DOCX scene breaks preserve the selected `#`, `*`, `***`, `* * *`, blank-line, or custom value instead of normalising it to another separator.
|
||||
|
||||
### Improved
|
||||
|
||||
- Added privacy-safe orphan hierarchy diagnostics and a realistic nested-container/mixed-matter Warden regression.
|
||||
- Final-model preview now shows the exact exported outline, statistics, warnings, exclusions, filename, and destination.
|
||||
- Output verification, rollback guidance, cancellation boundaries, partial-result handling, and history sequencing have stronger automated coverage.
|
||||
- Vellum, Standard Manuscript, and supported Custom formatting resolve deterministically and are inspected semantically in generated Word XML.
|
||||
- Architecture, release documentation, performance coverage, and the manual release checklist now describe the actual native-DOCX product.
|
||||
- New Vellum and Standard requests default to A4 and metric indentation; existing Letter and legacy inch-based choices migrate without visual formatting drift.
|
||||
|
||||
### Compatibility
|
||||
|
||||
|
|
|
|||
|
|
@ -9,16 +9,24 @@ Record the date, tester, Obsidian version, operating system, Word/LibreOffice ve
|
|||
- [ ] Confirm first-run behaviour is understandable and does not require Pandoc or an external executable.
|
||||
- [ ] Open **Manuscript Compiler: Compile Manuscript** from the command palette.
|
||||
- [ ] Open the compiler from **Open Manuscript Compiler** in plugin settings.
|
||||
- [ ] Right-click a folder in File Explorer and confirm **Compile manuscript from this folder** appears; confirm it is absent for note menus.
|
||||
- [ ] Confirm the folder action opens the existing workspace, shows the exact clicked path, and begins scanning without another folder picker.
|
||||
|
||||
## Real manuscript
|
||||
|
||||
- [ ] Select the actual book folder, not its `Manuscript` child.
|
||||
- [ ] Confirm the selected root is not displayed as a Part or Chapter.
|
||||
- [ ] Confirm `Manuscript` is suggested as a Transparent container and creates no heading.
|
||||
- [ ] Confirm nested `Manuscript/Book 1 - …` containers remain transparent while Part, Chapter, and Scene hierarchy is preserved.
|
||||
- [ ] Confirm the final report has non-zero Chapters and no orphan Scenes for a normally structured book.
|
||||
- [ ] Confirm mixed `Front and back matter`/`Font and back matter` and `Copyright notices` folders do not become Parts or Chapters.
|
||||
- [ ] Confirm About the Author, Acknowledgments/Acknowledgements, Also by…, and Back Cover Blurb are back matter after the manuscript.
|
||||
- [ ] Confirm Archive, Development, and Exports are visibly excluded with reasons.
|
||||
- [ ] Confirm dashboard and revision notes are visibly excluded.
|
||||
- [ ] Inspect and reorder individual front- and back-matter notes.
|
||||
- [ ] Change one folder or note role manually and confirm the outline updates.
|
||||
- [ ] Scroll halfway down a large Contents tree, change several roles and inclusion boxes, and confirm scroll position and keyboard focus remain stable.
|
||||
- [ ] Exclude a folder and confirm it collapses; re-enable it and confirm it remains collapsed until explicitly expanded.
|
||||
- [ ] Reorder two Scenes and confirm the order survives moving between steps.
|
||||
- [ ] Exclude and re-enable a parent folder; confirm prior child choices are preserved.
|
||||
- [ ] Confirm no Part 0 or Chapter 0 is shown.
|
||||
|
|
@ -34,6 +42,7 @@ Record the date, tester, Obsidian version, operating system, Word/LibreOffice ve
|
|||
## Vellum DOCX
|
||||
|
||||
- [ ] Select Vellum and create a DOCX.
|
||||
- [ ] Confirm Vellum defaults to A4, a 0.75 cm first-line indent, and `#` scene breaks.
|
||||
- [ ] Inspect title-page enabled and disabled behaviour.
|
||||
- [ ] Confirm every Part begins on a new page without an unintended blank page.
|
||||
- [ ] Confirm Chapters begin on new pages with separate Chapter Number and Chapter Title styles.
|
||||
|
|
@ -42,11 +51,12 @@ Record the date, tester, Obsidian version, operating system, Word/LibreOffice ve
|
|||
- [ ] Search for YAML, Series/Book/Part/Chapter/Scene metadata, Dashboard, Synopsis, Revision Notes, Archive, Development, and Exports.
|
||||
- [ ] Import the DOCX into Vellum on macOS.
|
||||
- [ ] Confirm Vellum recognises Parts and Chapters and does not duplicate their titles.
|
||||
- [ ] Confirm Vellum places front matter before Parts and back matter after every manuscript Part.
|
||||
|
||||
## Standard Manuscript
|
||||
|
||||
- [ ] Select Standard Manuscript and confirm Times New Roman 12 pt.
|
||||
- [ ] Confirm double spacing and a 0.5-inch first-line indent on Body Text.
|
||||
- [ ] Confirm A4, double spacing, a 1.27 cm first-line indent, 2.54 cm margins, and `* * *` scene breaks.
|
||||
- [ ] Confirm First Paragraph remains unindented.
|
||||
- [ ] Confirm Chapters start on new pages.
|
||||
|
||||
|
|
@ -57,7 +67,7 @@ Record the date, tester, Obsidian version, operating system, Word/LibreOffice ve
|
|||
- [ ] Change font, font size, line spacing, and first-line indent and inspect each result.
|
||||
- [ ] Test title page on and off.
|
||||
- [ ] Enable the TOC, update fields in Word/LibreOffice, and confirm it populates.
|
||||
- [ ] Test centred `* * *`, blank scene spacing, and a compatible-profile custom/Unicode separator.
|
||||
- [ ] Test `#`, `*`, `***`, `* * *`, Blank line, and a custom/Unicode separator.
|
||||
- [ ] Confirm bold, italics, combined emphasis, links, smart quotes, dashes, accented characters, and non-Latin Unicode survive.
|
||||
- [ ] Confirm callout markers/titles become plain text while their body remains readable.
|
||||
|
||||
|
|
|
|||
20
README.md
20
README.md
|
|
@ -7,14 +7,16 @@ Manuscript Compiler 0.9.2 turns fiction written in an Obsidian vault into a clea
|
|||
## Quick Start
|
||||
|
||||
1. Install and enable Manuscript Compiler.
|
||||
2. Run **Manuscript Compiler: Compile Manuscript**.
|
||||
3. Select the actual book folder.
|
||||
2. In the File Explorer, right-click the actual book folder and choose **Compile manuscript from this folder** (or run **Manuscript Compiler: Compile Manuscript**).
|
||||
3. Confirm the selected folder shown as the exact book root.
|
||||
4. Review and correct **Contents**.
|
||||
5. Choose **Vellum** or **Standard Manuscript** formatting, or select supported Custom values.
|
||||
6. Review the exact final manuscript and press **Create DOCX**.
|
||||
7. Save the verified DOCX to the vault, then optionally save a copy to the computer, open it, or reveal it where supported.
|
||||
|
||||
The selected folder is the book root and never becomes a Part or Chapter. The Contents step lets the author confirm front matter, transparent containers, Parts, Chapters, Scenes, back matter, inclusion, and order before any output is created.
|
||||
The selected folder is the book root and never becomes a Part or Chapter. The Contents step lets the author confirm front matter, transparent containers, Parts, Chapters, Scenes, back matter, inclusion, and order before any output is created. Role and inclusion edits update the affected rows without returning the tree to the top; scroll position, keyboard focus, expansion state, child choices, and manual order are retained. Excluding a folder collapses it automatically while preserving its descendants' individual choices for later re-enabling.
|
||||
|
||||
The File Explorer action passes the exact right-clicked `TFolder` to the existing four-step workspace and starts its scan. Explicit selection never walks to an ancestor or substitutes a nested `Book`, `Manuscript`, or `Draft` folder. Nested organisational containers can be transparent while their Part → Chapter → Scene relationships remain intact.
|
||||
|
||||
The four-step compile workspace is authoritative for that export. Existing profiles and settings are preserved for compatibility but cannot override explicit workspace choices.
|
||||
|
||||
|
|
@ -32,6 +34,8 @@ Project folders such as Archive, Development, Exports, Research, Notes, Revision
|
|||
|
||||
Dashboard/index notes, revision notes, underscore-prefixed project notes, and notes classified by YAML as dashboard, character, location, plotline, research, planning, or revision material are also excluded by default. Empty notes are shown as exclusions after manuscript cleaning.
|
||||
|
||||
Mixed matter folders such as `Front and back matter`, `Front & Back Matter`, and the observed `Font and back matter` spelling are treated as transparent matter containers. `Copyright notices` is a transparent front-matter container. Common names including Copyright variants, About the Author, Acknowledgments/Acknowledgements, Also by…, Back Cover Blurb, and author/newsletter notes are classified as matter rather than manuscript Chapters or Scenes. Every inferred role remains editable.
|
||||
|
||||
## Scene Templates and Metadata Cleaning
|
||||
|
||||
For template-based scene notes, the compiler recognises these manuscript-body headings by default:
|
||||
|
|
@ -61,21 +65,23 @@ Generated Word paragraph styles include:
|
|||
|
||||
Parts always begin on a new page. Chapters begin on a new page by default; in Custom formatting this can be disabled without changing the Chapter Number or Chapter Title styles. Page breaks are paragraph properties on the first displayed heading, not synthetic blank paragraphs. Number and title can be shown separately, numerically, in words, as title only, or through a retained legacy-profile template. Missing numbers remain missing—zero is never invented.
|
||||
|
||||
The deterministic **Vellum** preset uses Garamond 12 pt, 1.15 line spacing, a 0.3-inch body indent, Letter pages, clean separate word-number/title headings, Chapter page breaks, and no automatic TOC. **Standard Manuscript** uses Times New Roman 12 pt, double spacing, a 0.5-inch body indent, one-inch margins, Letter pages, and Chapter page breaks. Selecting an individual formatting value switches the workspace to **Custom**. Custom supports the exposed font, size, spacing, indent, Letter/A4, Chapter-break, title-page, scene-break, heading-display, and TOC choices; margins remain fixed at one inch.
|
||||
The deterministic **Vellum** preset uses Garamond 12 pt, 1.15 line spacing, a 0.75 cm body indent, A4 pages, a centred `#` scene break, clean separate word-number/title headings, Chapter page breaks, and no automatic TOC. **Standard Manuscript** uses Times New Roman 12 pt, double spacing, a 1.27 cm body indent, 2.54 cm margins, A4 pages, a centred `* * *` scene break, and Chapter page breaks. Selecting an individual formatting value switches the workspace to **Custom**. Custom supports the exposed font, size, spacing, metric indent, A4/Letter, Chapter-break, title-page, scene-break, heading-display, and TOC choices; margins remain fixed at 2.54 cm.
|
||||
|
||||
The title page is off by default. When enabled, it contains only the book title and author and ends with a page break. The optional table of contents is a genuine Word TOC field that must be updated in Word; it is off by default for Vellum. The default scene break is centred `* * *`; blank scene-break mode emits an empty Scene Break paragraph only between included scenes. Headers, footers, and page numbers are not generated because Vellum handles final book layout.
|
||||
The title page is off by default. When enabled, it contains only the book title and author and ends with a page break. The optional table of contents is a genuine Word TOC field that must be updated in Word; it is off by default for Vellum. Scene-break choices are `#`, `*`, `***`, `* * *`, Blank line, and Custom. Blank-line mode emits an empty Scene Break paragraph only between included scenes. Headers, footers, and page numbers are not generated because Vellum handles final book layout.
|
||||
|
||||
Bold, italics, combined bold/italics, readable Markdown link text, inline-code text, punctuation, accented characters, smart quotes, dashes, and Unicode are preserved. Complex nested Markdown, tables, embedded media, and advanced layout are intentionally unsupported. **Convert callouts to plain text** removes the Obsidian callout marker and callout title while preserving its quoted body as readable text; ordinary blockquotes are not classified as callouts.
|
||||
|
||||
## Front and Back Matter
|
||||
|
||||
Front and back matter folders are transparent. Individual notes can be included, excluded, and ordered in the Contents step. Common front-matter items are ordered with Title Page before Copyright, Dedication, Epigraph, Contents, Preface, and Prologue. Common back-matter items include Acknowledgements, About the Author, Also by the Author, Newsletter, and Copyright Notes.
|
||||
Front- and back-matter container folders do not emit headings. Individual notes can be included, excluded, and ordered in the Contents step. Common front-matter items are ordered with Title Page before Copyright, Dedication, Epigraph, Contents, Preface, and Prologue. Common back-matter items include Acknowledgements, About the Author, Also by the Author, Newsletter, and Copyright Notes.
|
||||
|
||||
Inference is intentionally name- and ancestry-based, not a general natural-language classifier. Unusual matter names or unconventional structures should be corrected in Contents before preparing the final preview.
|
||||
|
||||
## Settings and Compatibility
|
||||
|
||||
The registered plugin settings page contains saved defaults and a prominent **Open Manuscript Compiler** button. Legacy profiles and migration data remain preserved for compatibility. The normal compile workflow does not require or expose the legacy profile system.
|
||||
|
||||
Settings from earlier releases are repaired idempotently. Obsolete Pandoc-related values may remain in stored profiles so user data is not silently deleted, but no Pandoc runtime exists or executes.
|
||||
Settings from earlier releases are repaired idempotently. Existing Letter selections are retained, and legacy inch-based indentation values are converted once to the canonical centimetre value without changing the resulting Word indentation. New settings use A4 and metric measurements. Obsolete Pandoc-related values may remain in stored profiles so user data is not silently deleted, but no Pandoc runtime exists or executes.
|
||||
|
||||
## Privacy and Platform Support
|
||||
|
||||
|
|
|
|||
|
|
@ -2,11 +2,12 @@
|
|||
|
||||
## Current status
|
||||
|
||||
Version 0.9.2 is an automated release candidate. On 2026-07-13, TypeScript checking, 80 core/release tests, 18 safe-writer tests, semantic Warden DOCX inspection, the large-manuscript benchmark, production build, release packaging, archive-content validation, and `git diff --check` completed successfully. An earlier `npm audit` run reported zero vulnerabilities; the final retry could not resolve `registry.npmjs.org`, so the registry-dependent check should be repeated before release. Live Obsidian, Word/LibreOffice, Vellum, desktop, and mobile checks remain manual and are not represented as complete.
|
||||
Version 0.9.2 remains a release candidate. Live Obsidian/Vellum testing exposed a nested-container hierarchy and matter-classification defect; Step 8 corrects it with exact File Explorer root selection, structural-ancestor reconstruction, matter aliases, and a realistic regression fixture. The updated DOCX has not yet been manually re-imported into Vellum, so application-level confirmation remains required.
|
||||
|
||||
## Supported workflow
|
||||
|
||||
- Select a book root in the four-step Compile Manuscript workspace.
|
||||
- Or right-click the exact File Explorer folder and choose **Compile manuscript from this folder**.
|
||||
- Review roles, inclusion, exclusions, transparent containers, and manual order.
|
||||
- Select Vellum, Standard Manuscript, or supported Custom DOCX formatting.
|
||||
- Review the exact prepared semantic manuscript.
|
||||
|
|
@ -16,10 +17,10 @@ Version 0.9.2 is an automated release candidate. On 2026-07-13, TypeScript check
|
|||
## Automated release gates completed
|
||||
|
||||
- `npm run typecheck` — passed
|
||||
- `npm test` — 80 tests passed
|
||||
- `npm test` — 97 tests passed, including the real-vault nested-container and matter-role regression
|
||||
- `npm run test:safe-writer` — 18 tests passed
|
||||
- `npm run test:docx` — passed; Warden regression DOCX generated and semantically inspected
|
||||
- `npm run benchmark:large` — 500 Chapters, 2,000 Scenes, and 2,000,000 words measured locally; timing is informational
|
||||
- `npm run test:docx` — passed; original and real-vault Warden semantic structures inspected
|
||||
- `npm run benchmark:large` — 500 Chapters, 2,000 Scenes, and 2,000,000 words in 373 ms locally; timing is informational
|
||||
- `npm run build` — passed
|
||||
- `npm run package` — passed
|
||||
- `npm run package:validate` — passed
|
||||
|
|
@ -40,6 +41,7 @@ Local filesystem vaults receive the strongest same-folder staged replacement pat
|
|||
- Complex nested Markdown, tables, embedded media, and advanced page layout are outside the fiction-manuscript renderer.
|
||||
- Ordinary blockquotes are preserved by cleaning but rendered as readable paragraphs rather than a dedicated Word quotation style.
|
||||
- Vellum recognition and visual pagination require application-level confirmation.
|
||||
- Matter inference covers documented aliases and ancestry; unusual names still require author review in Contents.
|
||||
- Mobile and non-filesystem adapter recovery cannot promise filesystem-atomic replacement.
|
||||
|
||||
## Manual gates still required
|
||||
|
|
|
|||
|
|
@ -12,8 +12,10 @@ export class BookRootResolver {
|
|||
return folder;
|
||||
}
|
||||
|
||||
/** Explicit selections are authoritative and are never replaced by an ancestor or descendant. */
|
||||
selected(folder: TFolder): TFolder { return this.require(folder.path, "selected manuscript folder"); }
|
||||
|
||||
/** Legacy commands may infer a root from saved configuration or current-file ancestry. */
|
||||
configuredOrCurrent(configuredPath: string, activeFile: TFile | null): TFolder | null {
|
||||
if (configuredPath.trim()) {
|
||||
const configured = this.vault.getAbstractFileByPath(configuredPath.trim());
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { CompileWorkspaceController } from "./workspace/compile-workspace-contro
|
|||
import type { CompileWorkspaceStep } from "./workspace/workspace-types";
|
||||
import { renderManuscriptStep } from "./workspace/manuscript-step";
|
||||
import { renderContentsStep } from "./workspace/contents-step";
|
||||
import { ContentsTreeViewState } from "./workspace/contents-tree-view-state";
|
||||
import { renderFormattingStep } from "./workspace/formatting-step";
|
||||
import { renderExportStep } from "./workspace/export-step";
|
||||
|
||||
|
|
@ -21,15 +22,24 @@ const labels = ["Manuscript", "Contents", "Formatting", "Export"];
|
|||
|
||||
export class SimpleCompileModal extends Modal {
|
||||
private readonly controller: CompileWorkspaceController;
|
||||
constructor(app: App, private readonly plugin: ManuscriptCompilerPlugin) {
|
||||
private readonly contentsViewState = new ContentsTreeViewState();
|
||||
private readonly fileExplorerRoot?: TFolder;
|
||||
constructor(app: App, private readonly plugin: ManuscriptCompilerPlugin, selectedFolder?: TFolder) {
|
||||
super(app);
|
||||
this.fileExplorerRoot = selectedFolder;
|
||||
const settings = plugin.settings; const profile = plugin.getActiveProfile();
|
||||
const formatting: DocxFormatting = docxFormattingForPreset(settings.defaultDocxStyle, settings.includeTitlePageByDefault);
|
||||
const request: SimpleCompileRequest = { manuscriptRoot: settings.defaultManuscriptFolder || profile.manuscriptRoot, structurePreset: settings.defaultStructurePreset, includeFrontMatter: true, includeBackMatter: true, exportFolder: settings.defaultExportFolder || profile.exportFolder, outputFilename: this.filename(profile.outputFilename || "Manuscript.docx"), outputFormat: "docx", docxPreset: settings.defaultDocxStyle, downloadAfterExport: true, formatting, tableOfContents: settings.includeTableOfContentsByDefault, partDisplay: "word-title", chapterDisplay: "word-title", custom: { variables: { ...profile.variables }, bodySectionAliases: [...(profile.bodySectionAliases ?? ["Scene", "Manuscript", "Text", "Draft", "Body"])] } };
|
||||
formatting.pageSize = settings.defaultDocxPageSize;
|
||||
formatting.firstLineIndentCm = settings.defaultDocxFirstLineIndentCm;
|
||||
const request: SimpleCompileRequest = { manuscriptRoot: selectedFolder?.path ?? (settings.defaultManuscriptFolder || profile.manuscriptRoot), structurePreset: settings.defaultStructurePreset, includeFrontMatter: true, includeBackMatter: true, exportFolder: settings.defaultExportFolder || profile.exportFolder, outputFilename: this.filename(profile.outputFilename || "Manuscript.docx"), outputFormat: "docx", docxPreset: settings.defaultDocxStyle, downloadAfterExport: true, formatting, tableOfContents: settings.includeTableOfContentsByDefault, partDisplay: "word-title", chapterDisplay: "word-title", custom: { variables: { ...profile.variables }, sceneSeparator: profile.sceneSeparator, bodySectionAliases: [...(profile.bodySectionAliases ?? ["Scene", "Manuscript", "Text", "Draft", "Body"])] } };
|
||||
this.controller = new CompileWorkspaceController(request, formatting, { prepare: (next, plan, signal) => this.plugin.prepareCompileRequest(next, plan, signal), sessionIsCurrent: (session) => this.plugin.preparedSessionIsCurrent(session), export: (session) => this.plugin.exportPreparedSession(session) });
|
||||
}
|
||||
|
||||
onOpen(): void { this.modalEl.addClass("manuscript-compile-workspace"); this.render(); }
|
||||
onOpen(): void {
|
||||
this.modalEl.addClass("manuscript-compile-workspace");
|
||||
this.render();
|
||||
if (this.fileExplorerRoot) void this.selectFolder(this.fileExplorerRoot).catch(() => new Notice("The selected folder could not be scanned.", 7000));
|
||||
}
|
||||
onClose(): void { this.controller.close(); this.contentEl.empty(); }
|
||||
|
||||
private render(): void {
|
||||
|
|
@ -37,8 +47,8 @@ export class SimpleCompileModal extends Modal {
|
|||
const nav = this.contentEl.createDiv({ cls: "manuscript-compile-steps", attr: { role: "tablist", "aria-label": "Compile steps" } });
|
||||
labels.forEach((label, index) => { const button = nav.createEl("button", { text: `${index + 1} ${label}`, cls: index === current ? "is-active" : index < current ? "is-complete" : "" }); button.setAttribute("role", "tab"); button.setAttribute("aria-selected", String(index === current)); button.disabled = index > current + 1 || index > 0 && !state.contentPlan.length; button.addEventListener("click", () => this.enterStep(steps[index])); });
|
||||
const body = this.contentEl.createDiv({ cls: "manuscript-compile-body" });
|
||||
if (state.step === "manuscript") renderManuscriptStep(body, this.controller, this.folder(), { chooseFolder: () => new FolderPicker(this.app, (folder) => { void this.selectFolder(folder); }).open(), useCurrentFolder: () => { const folder = this.app.workspace.getActiveFile()?.parent; if (folder) void this.selectFolder(folder); else new Notice("Open a note inside the manuscript folder first."); }, changed: () => this.updateCreateButton() });
|
||||
else if (state.step === "contents") renderContentsStep(body, this.controller, () => this.render());
|
||||
if (state.step === "manuscript") renderManuscriptStep(body, this.controller, this.folder(), { selectedFromFileExplorer: this.fileExplorerRoot?.path === state.request.manuscriptRoot, chooseFolder: () => new FolderPicker(this.app, (folder) => { void this.selectFolder(folder); }).open(), useCurrentFolder: () => { const folder = this.app.workspace.getActiveFile()?.parent; if (folder) void this.selectFolder(folder); else new Notice("Open a note inside the manuscript folder first."); }, changed: () => this.updateCreateButton() });
|
||||
else if (state.step === "contents") renderContentsStep(body, this.controller, this.contentsViewState);
|
||||
else if (state.step === "formatting") renderFormattingStep(body, this.controller);
|
||||
else renderExportStep(body, this.controller, { refresh: () => { void this.prepare(true); }, filename: (value) => this.filename(value), changed: () => this.markPreviewInvalidated() });
|
||||
this.renderFooter();
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { applyContentPlan, classifyContentPlan, createContentPlan, isPlanItemInc
|
|||
import { MarkdownExporter, type ExportProgressStage, type ExportRequest } from "./exporter";
|
||||
import type { Book, CompileResult, CompileWarning, ManuscriptStatistics } from "./model";
|
||||
import type { CompileProfile, StructurePreset } from "./settings";
|
||||
import { inchesToCentimetres } from "./measurements";
|
||||
import { applyContentPlanAuthority, applyWorkspacePlanAuthority, inferStructurePreset, resolveSimpleCompileRequest, type SimpleCompileRequest } from "./simple-workflow";
|
||||
import type { ScannedBook } from "./types";
|
||||
import { VaultScanner } from "./vault-scanner";
|
||||
|
|
@ -164,8 +165,8 @@ function simpleRequestFromProfile(request: CompilePreparationRequest, plan: Cont
|
|||
font: profile.docxFont ?? "Times New Roman",
|
||||
fontSize: profile.docxFontSize ?? 12,
|
||||
lineSpacing: profile.docxLineSpacing ?? 2,
|
||||
firstLineIndent: profile.docxFirstLineIndent ?? 0.5,
|
||||
pageSize: profile.docxPageSize ?? "letter",
|
||||
firstLineIndentCm: profile.docxFirstLineIndentCm ?? inchesToCentimetres(profile.docxFirstLineIndent ?? 0.5),
|
||||
pageSize: profile.docxPageSize ?? "a4",
|
||||
chapterPageBreak: profile.docxChapterPageBreak ?? true,
|
||||
titlePage: profile.docxTitlePage ?? true
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,41 +4,50 @@ import type { ScannedBook, ScannedChapter, ScannedPart } from "./types";
|
|||
import { cleanManuscriptContent, hasProjectMetadataLeakage } from "./filters";
|
||||
|
||||
export type ContentRole = "front-matter" | "transparent" | "part" | "chapter" | "scene" | "back-matter" | "ignore";
|
||||
export interface ContentPlanItem { path: string; parentPath: string; name: string; kind: "folder" | "note"; role: ContentRole; included: boolean; order: number; exclusionReason?: string; warning?: string; userOverride?: boolean; }
|
||||
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" });
|
||||
const frontPattern = /^(?:ebook |print )?front matter$/i;
|
||||
const backPattern = /^(?:ebook |print )?back matter$/i;
|
||||
const frontPattern = /^(?:ebook |e book |print )?front matter$/i;
|
||||
const backPattern = /^(?:(?:ebook|e book|print) )?(?:back matter|end matter|backmatter)$/i;
|
||||
const mixedMatterFolders = new Set(["font and back matter", "front and back matter", "front & back matter"]);
|
||||
const copyrightContainerPattern = /^copyright notices?$/i;
|
||||
const excludedFolders = new Set(["archive", "archives", "development", "export", "exports", "research", "notes", "revision notes", "planning", "characters", "locations", "plotlines", "dashboards", "templates", "attachments", "images", "deleted", "trash", "previous drafts", "old drafts"]);
|
||||
const transparentFolders = new Set(["manuscript", "draft", "drafts", "book", "content", "chapters"]);
|
||||
const excludedYamlKinds = new Set(["dashboard", "character", "location", "plotline", "research", "planning", "revision"]);
|
||||
const matterOrder: Record<string, number> = { "title page": 10, copyright: 20, dedication: 30, epigraph: 40, contents: 50, preface: 60, prologue: 70, acknowledgements: 100, "about the author": 110, "also by the author": 120, newsletter: 130, "copyright notes": 140 };
|
||||
const matterOrder: Record<string, number> = { "title page": 10, copyright: 20, "copyright notice": 20, "copyright notices": 20, dedication: 30, epigraph: 40, contents: 50, "table of contents": 50, foreword: 60, preface: 65, prologue: 70, "a note from elin": 90, acknowledgment: 100, acknowledgments: 100, acknowledgement: 100, acknowledgements: 100, "about the author": 110, "also by": 120, "also by the author": 120, newsletter: 130, "back cover blurb": 140, "reader note": 150, "author note": 160, "connect with the author": 170 };
|
||||
|
||||
export function normalizedProjectName(value: string): string { return value.replace(/\.[^.]+$/, "").replace(/^\s*\d+[\s._—–-]*/, "").replace(/[_.—–-]+/g, " ").replace(/\s+/g, " ").trim().toLowerCase(); }
|
||||
|
||||
export function createContentPlan(root: TFolder, preset: StructurePreset): ContentPlanItem[] {
|
||||
const items: ContentPlanItem[] = [];
|
||||
const normalizedRootName = normalizedProjectName(root.name);
|
||||
const visit = (folder: TFolder, semanticDepth: number, inheritedMatter?: "front-matter" | "back-matter"): void => {
|
||||
const children = folder.children.filter((item) => !item.name.startsWith(".") && (item instanceof TFolder || item instanceof TFile && item.extension.toLowerCase() === "md")).sort((a, b) => inheritedMatter ? (matterOrder[normalizedProjectName(a.name)] ?? 1000) - (matterOrder[normalizedProjectName(b.name)] ?? 1000) || collator.compare(a.name, b.name) : collator.compare(a.name, b.name));
|
||||
children.forEach((child, order) => {
|
||||
const kind = child instanceof TFolder ? "folder" : "note";
|
||||
const suggestion = inheritedMatter ? { role: inheritedMatter } : inferredRole(child, semanticDepth, preset); const excluded = suggestion.role === "ignore";
|
||||
items.push({ path: child.path, parentPath: folder.path, name: kind === "note" ? (child as TFile).basename : child.name, kind, role: suggestion.role, included: !excluded, order, exclusionReason: suggestion.reason });
|
||||
if (child instanceof TFolder) visit(child, semanticDepth + (suggestion.role === "transparent" || suggestion.role === "front-matter" || suggestion.role === "back-matter" || suggestion.role === "ignore" ? 0 : 1), suggestion.role === "front-matter" || suggestion.role === "back-matter" ? suggestion.role : inheritedMatter);
|
||||
const suggestion = inheritedMatter ? { role: inheritedMatter } : inferredRole(child, semanticDepth, preset, normalizedRootName); const excluded = suggestion.role === "ignore";
|
||||
items.push({ path: child.path, parentPath: folder.path, name: kind === "note" ? (child as TFile).basename : child.name, kind, role: suggestion.role, detectedRole: suggestion.role, included: !excluded, order, exclusionReason: suggestion.reason });
|
||||
if (child instanceof TFolder) {
|
||||
const childMatter = matterContext(child, suggestion.role, inheritedMatter);
|
||||
visit(child, semanticDepth + (suggestion.role === "transparent" || suggestion.role === "front-matter" || suggestion.role === "back-matter" || suggestion.role === "ignore" ? 0 : 1), childMatter);
|
||||
}
|
||||
});
|
||||
};
|
||||
visit(root, 0);
|
||||
return items;
|
||||
}
|
||||
|
||||
function inferredRole(item: TAbstractFile, semanticDepth: number, preset: StructurePreset): { role: ContentRole; reason?: string } {
|
||||
function inferredRole(item: TAbstractFile, semanticDepth: number, preset: StructurePreset, normalizedRootName: string): { role: ContentRole; reason?: string } {
|
||||
const normalized = normalizedProjectName(item.name); const isFolder = item instanceof TFolder;
|
||||
if (isFolder && frontPattern.test(normalized)) return { role: "front-matter" };
|
||||
if (isFolder && backPattern.test(normalized)) return { role: "back-matter" };
|
||||
if (isFolder && (mixedMatterFolders.has(normalized) || copyrightContainerPattern.test(normalized))) return { role: "transparent" };
|
||||
if (isFolder && excludedFolders.has(normalized)) return { role: "ignore", reason: `Project folder “${item.name}” is excluded by default.` };
|
||||
if (isFolder && transparentFolders.has(normalized)) return { role: "transparent" };
|
||||
if (isFolder && (transparentFolders.has(normalized) || normalized === normalizedRootName || isNestedBookContainer(item, normalized))) return { role: "transparent" };
|
||||
if (!isFolder && /dashboard/i.test(item.name)) return { role: "ignore", reason: "Dashboard/index note excluded by default." };
|
||||
if (!isFolder && (/^_/.test(item.name) || normalized === "revision notes")) return { role: "ignore", reason: normalized === "revision notes" ? "Revision note excluded by default." : "Underscore-prefixed project note excluded by default." };
|
||||
if (!isFolder && isFrontMatterNote(normalized)) return { role: "front-matter" };
|
||||
if (!isFolder && isBackMatterNote(normalized)) return { role: "back-matter" };
|
||||
if (!isFolder) return { role: preset === "chapter-notes" || preset === "short-story" || preset === "anthology" ? "chapter" : "scene" };
|
||||
if (/^part\b/i.test(normalized)) return { role: "part" };
|
||||
if (/^(?:chapter\b|\d+\s+chapter\b)/i.test(normalized)) return { role: "chapter" };
|
||||
|
|
@ -46,17 +55,49 @@ function inferredRole(item: TAbstractFile, semanticDepth: number, preset: Struct
|
|||
return { role: "chapter" };
|
||||
}
|
||||
|
||||
function matterContext(folder: TFolder, role: ContentRole, inherited?: "front-matter" | "back-matter"): "front-matter" | "back-matter" | undefined {
|
||||
if (role === "front-matter" || role === "back-matter") return role;
|
||||
const normalized = normalizedProjectName(folder.name);
|
||||
if (copyrightContainerPattern.test(normalized)) return "front-matter";
|
||||
if (mixedMatterFolders.has(normalized)) return undefined;
|
||||
return inherited;
|
||||
}
|
||||
|
||||
function isNestedBookContainer(folder: TFolder, normalized: string): boolean {
|
||||
if (!/^book(?:\s+\d+)?(?:\s|$)/i.test(normalized)) return false;
|
||||
return folder.children.some((child) => child instanceof TFolder && /^(?:part|chapter)\b/i.test(normalizedProjectName(child.name)));
|
||||
}
|
||||
|
||||
function isFrontMatterNote(normalized: string): boolean {
|
||||
return /^(?:title page|copyright|dedication|epigraph|foreword|preface|prologue|contents|table of contents)(?:\s|$)/i.test(normalized);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
const yaml = frontmatter(raw); const kind = [yaml.type, yaml["note type"], yaml.category].find((value) => typeof value === "string"); const normalizedKind = typeof kind === "string" ? normalizedProjectName(kind) : "";
|
||||
if (excludedYamlKinds.has(normalizedKind)) { item.role = "ignore"; item.included = false; item.exclusionReason = `YAML classifies this as ${kind}.`; return; }
|
||||
if (excludedYamlKinds.has(normalizedKind)) { item.role = "ignore"; item.detectedRole = "ignore"; item.included = false; item.exclusionReason = `YAML classifies this as ${kind}.`; return; }
|
||||
const cleaned = cleanManuscriptContent(raw, ["Scene", "Manuscript", "Text", "Draft", "Body"]);
|
||||
if (!cleaned.trim()) { item.role = "ignore"; item.included = false; item.exclusionReason = "No manuscript body remains after cleaning."; }
|
||||
if (!cleaned.trim()) { item.role = "ignore"; item.detectedRole = "ignore"; item.included = false; item.exclusionReason = "No manuscript body remains after cleaning."; }
|
||||
else if (hasProjectMetadataLeakage(cleaned)) item.warning = "Recognised project metadata remains outside the removable metadata regions.";
|
||||
}));
|
||||
return plan;
|
||||
}
|
||||
|
||||
/** Applies a folder matter role only to descendants that the author has not explicitly classified. */
|
||||
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";
|
||||
if (!matterRole && !wasMatter) return;
|
||||
plan.filter((item) => item.kind === "note" && item.path.startsWith(`${folderPath}/`) && !item.userOverride).forEach((item) => {
|
||||
item.role = matterRole ?? item.detectedRole ?? item.role;
|
||||
item.exclusionReason = item.role === "ignore" ? item.exclusionReason : undefined;
|
||||
});
|
||||
}
|
||||
export function isPlanItemIncluded(item: ContentPlanItem, plan: ContentPlanItem[], rootPath: string): boolean { const byPath = new Map(plan.map((candidate) => [candidate.path, candidate])); let current: ContentPlanItem | undefined = item; while (current) { if (!current.included || current.role === "ignore") return false; if (current.parentPath === rootPath) break; current = byPath.get(current.parentPath); } return true; }
|
||||
|
||||
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 {}; } }
|
||||
|
|
@ -72,30 +113,55 @@ export function applyContentPlan(scan: ScannedBook, plan: ContentPlanItem[], pro
|
|||
while (path.startsWith(scan.root.path) && path !== scan.root.path) { const item = byPath.get(path); if (item && (!item.included || item.role === "ignore")) return false; const slash = path.lastIndexOf("/"); if (slash < 0) break; path = path.slice(0, slash); }
|
||||
return true;
|
||||
};
|
||||
const rank = (path: string): number => byPath.get(path)?.order ?? Number.MAX_SAFE_INTEGER;
|
||||
const globalRanks = new Map((profile.contentOrder ?? []).map((path, index) => [path, index]));
|
||||
const rank = (path: string): number => globalRanks.get(path) ?? byPath.get(path)?.order ?? Number.MAX_SAFE_INTEGER;
|
||||
const sortFiles = (files: TFile[]): TFile[] => [...files].filter(enabled).sort((a, b) => rank(a.path) - rank(b.path) || collator.compare(a.name, b.name));
|
||||
const roleFor = (path: string): ContentRole | undefined => byPath.get(path)?.role;
|
||||
const itemEnabled = (item: ContentPlanItem): boolean => isPlanItemIncluded(item, plan, scan.root.path);
|
||||
const ancestorWithRole = (path: string, role: ContentRole): string | undefined => {
|
||||
let current = path;
|
||||
while (current !== scan.root.path) { if (roleFor(current) === role) return current; const slash = current.lastIndexOf("/"); if (slash < 0) return; current = current.slice(0, slash); }
|
||||
const nearestAncestorWithRole = (path: string, role: ContentRole): string | undefined => {
|
||||
let current = byPath.get(path)?.parentPath ?? parentPath(path);
|
||||
while (current && current !== scan.root.path) {
|
||||
const item = byPath.get(current);
|
||||
if (item && item.role === role && itemEnabled(item)) return current;
|
||||
current = item?.parentPath ?? parentPath(current);
|
||||
}
|
||||
return;
|
||||
};
|
||||
const files = scan.allMarkdown.filter(enabled);
|
||||
const frontMatter = sortFiles(files.filter((file) => roleFor(file.path) === "front-matter" || ancestorWithRole(file.path, "front-matter")));
|
||||
const backMatter = sortFiles(files.filter((file) => roleFor(file.path) === "back-matter" || ancestorWithRole(file.path, "back-matter")));
|
||||
const frontMatter = sortFiles(files.filter((file) => roleFor(file.path) === "front-matter" || nearestAncestorWithRole(file.path, "front-matter")));
|
||||
const backMatter = sortFiles(files.filter((file) => roleFor(file.path) === "back-matter" || nearestAncestorWithRole(file.path, "back-matter")));
|
||||
const body = files.filter((file) => !frontMatter.includes(file) && !backMatter.includes(file));
|
||||
const chapterFolders = plan.filter((item) => item.kind === "folder" && item.role === "chapter" && itemEnabled(item)).map((item) => folders.get(item.path)).filter((folder): folder is TFolder => folder !== undefined);
|
||||
const chapter = (folder: TFolder): ScannedChapter => ({ folder, scenes: sortFiles(body.filter((file) => file.path.startsWith(`${folder.path}/`) && !chapterFolders.some((nested) => nested.path !== folder.path && nested.path.startsWith(`${folder.path}/`) && file.path.startsWith(`${nested.path}/`)))) });
|
||||
const chapterFolders = plan.filter((item) => item.kind === "folder" && item.role === "chapter" && itemEnabled(item)).sort((a, b) => rank(a.path) - rank(b.path)).map((item) => folders.get(item.path)).filter((folder): folder is TFolder => folder !== undefined);
|
||||
const chapter = (folder: TFolder): ScannedChapter => ({ folder, scenes: sortFiles(body.filter((file) => nearestAncestorWithRole(file.path, "chapter") === folder.path)) });
|
||||
let parts: ScannedPart[];
|
||||
let looseScenes: TFile[];
|
||||
if (profile.useParts) {
|
||||
const partFolders = plan.filter((item) => item.kind === "folder" && item.role === "part" && itemEnabled(item)).sort((a, b) => a.order - b.order).map((item) => folders.get(item.path)).filter((folder): folder is TFolder => folder !== undefined);
|
||||
parts = partFolders.map((folder) => { const chapters = chapterFolders.filter((candidate) => candidate.path.startsWith(`${folder.path}/`) && !partFolders.some((nested) => nested.path !== folder.path && candidate.path.startsWith(`${nested.path}/`))).sort((a, b) => rank(a.path) - rank(b.path)).map(chapter); const chapterPaths = chapters.map((item) => item.folder.path); const loose = sortFiles(body.filter((file) => file.path.startsWith(`${folder.path}/`) && !chapterPaths.some((path) => file.path.startsWith(`${path}/`)))); return { folder, chapters, looseScenes: loose }; });
|
||||
const partPaths = partFolders.map((folder) => folder.path); looseScenes = sortFiles(body.filter((file) => !partPaths.some((path) => file.path.startsWith(`${path}/`))));
|
||||
const partFolders = plan.filter((item) => item.kind === "folder" && item.role === "part" && itemEnabled(item)).sort((a, b) => rank(a.path) - rank(b.path)).map((item) => folders.get(item.path)).filter((folder): folder is TFolder => folder !== undefined);
|
||||
parts = partFolders.map((folder) => {
|
||||
const chapters = chapterFolders.filter((candidate) => nearestAncestorWithRole(candidate.path, "part") === folder.path).map(chapter);
|
||||
const loose = sortFiles(body.filter((file) => nearestAncestorWithRole(file.path, "part") === folder.path && nearestAncestorWithRole(file.path, "chapter") === undefined));
|
||||
return { folder, chapters, looseScenes: loose };
|
||||
});
|
||||
looseScenes = sortFiles(body.filter((file) => nearestAncestorWithRole(file.path, "part") === undefined && nearestAncestorWithRole(file.path, "chapter") === undefined));
|
||||
} else {
|
||||
parts = chapterFolders.sort((a, b) => rank(a.path) - rank(b.path)).map((folder) => ({ folder, chapters: [], looseScenes: chapter(folder).scenes }));
|
||||
const chapterPaths = chapterFolders.map((folder) => folder.path); looseScenes = sortFiles(body.filter((file) => !chapterPaths.some((path) => file.path.startsWith(`${path}/`))));
|
||||
}
|
||||
return { ...scan, frontMatter, backMatter, parts, looseScenes, allMarkdown: [...frontMatter, ...body, ...backMatter], warnings: scan.warnings.filter((warning) => !/orphan scene|front matter folder|back matter folder/i.test(warning)) };
|
||||
const potentialOrphans = profile.chapterSource === "folders" ? [...looseScenes, ...parts.flatMap((part) => part.looseScenes)] : [];
|
||||
const hierarchyDiagnostics = potentialOrphans.map((file) => hierarchyDiagnostic(file, scan.root.path, byPath, itemEnabled, nearestAncestorWithRole));
|
||||
return { ...scan, frontMatter, backMatter, parts, looseScenes, allMarkdown: [...frontMatter, ...body, ...backMatter], warnings: scan.warnings.filter((warning) => !/orphan scene|front matter folder|back matter folder/i.test(warning)), hierarchyDiagnostics };
|
||||
}
|
||||
|
||||
function hierarchyDiagnostic(file: TFile, rootPath: string, byPath: ReadonlyMap<string, ContentPlanItem>, included: (item: ContentPlanItem) => boolean, nearest: (path: string, role: ContentRole) => string | undefined) {
|
||||
const item = byPath.get(file.path); const parent = item ? byPath.get(item.parentPath) : undefined;
|
||||
const nearestChapter = nearest(file.path, "chapter"); const nearestPart = nearest(file.path, "part");
|
||||
let current = item?.parentPath; let transparentReparenting = false;
|
||||
while (current && current !== rootPath) { const ancestor = byPath.get(current); if (ancestor?.role === "transparent") transparentReparenting = true; current = ancestor?.parentPath ?? parentPath(current); }
|
||||
const relative = (value: string | undefined): string => !value ? "none" : value === rootPath ? "." : value.startsWith(`${rootPath}/`) ? value.slice(rootPath.length + 1) : value;
|
||||
return { scenePath: relative(file.path), inferredRole: item?.detectedRole ?? item?.role ?? "unknown", parentPath: relative(item?.parentPath), parentRole: parent?.role ?? "book-root", nearestStructuralAncestor: relative(nearestChapter ?? nearestPart), transparentReparenting, parentExcluded: parent ? !included(parent) : false };
|
||||
}
|
||||
|
||||
function parentPath(path: string): string {
|
||||
const slash = path.lastIndexOf("/");
|
||||
return slash < 0 ? "" : path.slice(0, slash);
|
||||
}
|
||||
|
|
|
|||
21
src/docx.ts
21
src/docx.ts
|
|
@ -3,6 +3,7 @@ import type { Book, Chapter, ManuscriptDocument, Part } from "./model";
|
|||
import { numberWord } from "./ordering";
|
||||
import type { CompileProfile, StructuralDisplay } from "./settings";
|
||||
import { TemplateEngine } from "./template-engine";
|
||||
import { centimetresToTwips, clampCentimetres } from "./measurements";
|
||||
|
||||
const XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`;
|
||||
const DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
|
|
@ -15,7 +16,7 @@ export interface DocxOptions {
|
|||
font?: string;
|
||||
fontSize?: number;
|
||||
lineSpacing?: number;
|
||||
firstLineIndent?: number;
|
||||
firstLineIndentCm?: number;
|
||||
pageSize?: "letter" | "a4";
|
||||
chapterPageBreak?: boolean;
|
||||
sceneSeparator?: string;
|
||||
|
|
@ -27,7 +28,7 @@ export interface ResolvedDocxOptions extends DocxOptions {
|
|||
font: string;
|
||||
fontSize: number;
|
||||
lineSpacing: number;
|
||||
firstLineIndent: number;
|
||||
firstLineIndentCm: number;
|
||||
pageSize: "letter" | "a4";
|
||||
chapterPageBreak: boolean;
|
||||
titlePage: boolean;
|
||||
|
|
@ -55,8 +56,8 @@ export function resolveDocxOptions(options: DocxOptions): ResolvedDocxOptions {
|
|||
font: cleanFont(options.font),
|
||||
fontSize: clamp(options.fontSize, 8, 24, 12),
|
||||
lineSpacing: clamp(options.lineSpacing, 0.8, 3, 2),
|
||||
firstLineIndent: clamp(options.firstLineIndent, 0, 1.5, 0.5),
|
||||
pageSize: options.pageSize === "a4" ? "a4" : "letter",
|
||||
firstLineIndentCm: clampCentimetres(options.firstLineIndentCm, 0, 3.81, 1.27),
|
||||
pageSize: options.pageSize === "letter" ? "letter" : "a4",
|
||||
chapterPageBreak: options.chapterPageBreak !== false,
|
||||
titlePage: options.titlePage === true,
|
||||
tableOfContents: options.tableOfContents === true
|
||||
|
|
@ -119,7 +120,7 @@ function addScenes(blocks: string[], documents: ManuscriptDocument[], profile: C
|
|||
const scenes = documents.filter(included);
|
||||
scenes.forEach((scene, index) => {
|
||||
if (index > 0) {
|
||||
blocks.push(plainParagraph(normalizeSceneBreak(separator), "SceneBreak"));
|
||||
blocks.push(plainParagraph(separator.trim(), "SceneBreak"));
|
||||
state.firstParagraph = true;
|
||||
state.atPageStart = false;
|
||||
}
|
||||
|
|
@ -217,7 +218,8 @@ function packageDocx(document: string, options: ResolvedDocxOptions): Uint8Array
|
|||
|
||||
function documentXml(blocks: string[], options: ResolvedDocxOptions): string {
|
||||
const size = options.pageSize === "a4" ? { width: 11906, height: 16838 } : { width: 12240, height: 15840 };
|
||||
return `${XML}<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>${blocks.join("")}<w:sectPr><w:pgSz w:w="${size.width}" w:h="${size.height}"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="720" w:footer="720"/><w:cols w:space="720"/></w:sectPr></w:body></w:document>`;
|
||||
const margin = centimetresToTwips(2.54);
|
||||
return `${XML}<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>${blocks.join("")}<w:sectPr><w:pgSz w:w="${size.width}" w:h="${size.height}"/><w:pgMar w:top="${margin}" w:right="${margin}" w:bottom="${margin}" w:left="${margin}" w:header="720" w:footer="720"/><w:cols w:space="720"/></w:sectPr></w:body></w:document>`;
|
||||
}
|
||||
|
||||
function tocField(): string {
|
||||
|
|
@ -256,16 +258,11 @@ function run(value: string, properties = ""): string {
|
|||
return `<w:r>${properties ? `<w:rPr>${properties}</w:rPr>` : ""}<w:t xml:space="preserve">${escapeXml(value)}</w:t></w:r>`;
|
||||
}
|
||||
|
||||
function normalizeSceneBreak(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
return /^#$/m.test(trimmed) || /^\*{3}$/.test(trimmed) ? "* * *" : trimmed;
|
||||
}
|
||||
|
||||
function stylesXml(options: ResolvedDocxOptions): string {
|
||||
const font = escapeXml(options.font);
|
||||
const size = Math.round(options.fontSize * 2);
|
||||
const line = Math.round(options.lineSpacing * 240);
|
||||
const indent = Math.round(options.firstLineIndent * 1440);
|
||||
const indent = centimetresToTwips(options.firstLineIndentCm);
|
||||
const style = (id: string, name: string, basedOn: string, paragraphProperties: string, runProperties = ""): string => `<w:style w:type="paragraph" w:styleId="${id}"><w:name w:val="${name}"/>${basedOn === id ? "" : `<w:basedOn w:val="${basedOn}"/>`}<w:next w:val="${id === "BodyText" ? "BodyText" : "FirstParagraph"}"/><w:qFormat/><w:pPr>${paragraphProperties}</w:pPr>${runProperties ? `<w:rPr>${runProperties}</w:rPr>` : ""}</w:style>`;
|
||||
const centered = `<w:jc w:val="center"/><w:ind w:firstLine="0"/>`;
|
||||
return `${XML}<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:docDefaults><w:rPrDefault><w:rPr><w:rFonts w:ascii="${font}" w:hAnsi="${font}" w:eastAsia="${font}"/><w:sz w:val="${size}"/><w:lang w:val="en-US"/></w:rPr></w:rPrDefault><w:pPrDefault><w:pPr><w:spacing w:after="0" w:line="${line}" w:lineRule="auto"/></w:pPr></w:pPrDefault></w:docDefaults>${style("Normal", "Normal", "Normal", `<w:spacing w:after="0" w:line="${line}" w:lineRule="auto"/>`)}${style("Title", "Title", "Normal", `${centered}<w:spacing w:before="3600" w:after="480"/>`, `<w:b/><w:sz w:val="40"/>`)}${style("Author", "Author", "Normal", `${centered}<w:spacing w:before="480"/>`, `<w:sz w:val="28"/>`)}${style("PartNumber", "Part Number", "Normal", `${centered}<w:spacing w:before="1440" w:after="240"/>`, `<w:b/><w:sz w:val="32"/>`)}${style("PartTitle", "Part Title", "Normal", `${centered}<w:spacing w:after="720"/>`, `<w:b/><w:sz w:val="32"/>`)}${style("ChapterNumber", "Chapter Number", "Normal", `${centered}<w:spacing w:before="720" w:after="180"/>`, `<w:b/><w:sz w:val="28"/>`)}${style("ChapterTitle", "Chapter Title", "Normal", `${centered}<w:spacing w:after="720"/>`, `<w:b/><w:sz w:val="28"/>`)}${style("BodyText", "Body Text", "Normal", `<w:spacing w:after="0" w:line="${line}" w:lineRule="auto"/><w:ind w:firstLine="${indent}"/>`)}${style("FirstParagraph", "First Paragraph", "BodyText", `<w:spacing w:after="0" w:line="${line}" w:lineRule="auto"/><w:ind w:firstLine="0"/>`)}${style("SceneBreak", "Scene Break", "Normal", `${centered}<w:spacing w:before="240" w:after="240"/>`)}${style("FrontMatterHeading", "Front Matter Heading", "Normal", `${centered}<w:spacing w:before="720" w:after="480"/>`, `<w:b/><w:sz w:val="28"/>`)}${style("BackMatterHeading", "Back Matter Heading", "Normal", `${centered}<w:spacing w:before="720" w:after="480"/>`, `<w:b/><w:sz w:val="28"/>`)}</w:styles>`;
|
||||
|
|
|
|||
|
|
@ -71,7 +71,16 @@ export class ExportCoordinator {
|
|||
if (compileInputSignature(session.request, session.contentPlan) !== session.inputSignature) throw new Error("The compile choices changed after the preview was prepared. Refresh the preview before creating the DOCX.");
|
||||
if (await calculateSourceFingerprint(this.app.vault, session.sourcePaths) !== session.sourceFingerprint) throw new Error("The manuscript changed after the preview was prepared. Refresh the preview before creating the DOCX.");
|
||||
}
|
||||
private async persistDefaults(session: PreparedCompileSession): Promise<void> { const settings = this.settings(); settings.defaultManuscriptFolder = session.request.manuscriptRoot; settings.defaultExportFolder = session.request.exportFolder; settings.defaultStructurePreset = session.request.structurePreset; if (session.request.docxPreset !== "custom") settings.defaultDocxStyle = session.request.docxPreset; settings.defaultExportFormat = session.request.outputFormat; await this.saveSettings(); }
|
||||
private async persistDefaults(session: PreparedCompileSession): Promise<void> {
|
||||
const settings = this.settings(); settings.defaultManuscriptFolder = session.request.manuscriptRoot; settings.defaultExportFolder = session.request.exportFolder; settings.defaultStructurePreset = session.request.structurePreset; if (session.request.docxPreset !== "custom") settings.defaultDocxStyle = session.request.docxPreset; settings.defaultExportFormat = session.request.outputFormat;
|
||||
if (session.request.formatting) {
|
||||
settings.defaultDocxPageSize = session.request.formatting.pageSize;
|
||||
settings.defaultDocxFirstLineIndentCm = session.request.formatting.firstLineIndentCm;
|
||||
const profile = settings.profiles.find((item) => item.id === session.profile.id);
|
||||
if (profile) { profile.docxPageSize = session.request.formatting.pageSize; profile.docxFirstLineIndentCm = session.request.formatting.firstLineIndentCm; profile.sceneSeparator = session.profile.sceneSeparator; }
|
||||
}
|
||||
await this.saveSettings();
|
||||
}
|
||||
private async exportIssues(paths: string[]): Promise<CompileWarning[]> { const issues: CompileWarning[] = []; for (const path of paths) if (await this.outputExists(path)) issues.push({ severity: "warning", code: "output-exists", message: `Output already exists and will require confirmation: ${path}`, path }); return issues; }
|
||||
private async outputExists(path: string): Promise<boolean> { if (this.app.vault.getAbstractFileByPath(path)) return true; return this.app.vault.adapter instanceof FileSystemAdapter && await pathExists(this.app.vault.adapter.getFullPath(path)); }
|
||||
private confirmOverwrite(path: string): Promise<boolean> { return modalPromise((finish) => new ConfirmOverwriteModal(this.app, path, finish)); }
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export class DocxExporter implements Exporter {
|
|||
async export(request: ExportRequest): Promise<ExportResult> {
|
||||
if (request.signal?.aborted) throw new Error("Compilation cancelled.");
|
||||
request.onProgress?.("Creating DOCX");
|
||||
const bytes = createManuscriptDocx(request.book, request.profile, { title: String(request.variables.BookTitle ?? request.book.title), author: String(request.variables.Author ?? ""), tableOfContents: request.profile.generateTableOfContents, font: request.profile.docxFont, fontSize: request.profile.docxFontSize, lineSpacing: request.profile.docxLineSpacing, firstLineIndent: request.profile.docxFirstLineIndent, pageSize: request.profile.docxPageSize, chapterPageBreak: request.profile.docxChapterPageBreak, titlePage: request.profile.docxTitlePage, sceneSeparator: request.profile.sceneSeparator, partDisplay: request.profile.partDisplay, chapterDisplay: request.profile.chapterDisplay });
|
||||
const bytes = createManuscriptDocx(request.book, request.profile, { title: String(request.variables.BookTitle ?? request.book.title), author: String(request.variables.Author ?? ""), tableOfContents: request.profile.generateTableOfContents, font: request.profile.docxFont, fontSize: request.profile.docxFontSize, lineSpacing: request.profile.docxLineSpacing, firstLineIndentCm: request.profile.docxFirstLineIndentCm, pageSize: request.profile.docxPageSize, chapterPageBreak: request.profile.docxChapterPageBreak, titlePage: request.profile.docxTitlePage, sceneSeparator: request.profile.sceneSeparator, partDisplay: request.profile.partDisplay, chapterDisplay: request.profile.chapterDisplay });
|
||||
request.onProgress?.("Checking DOCX"); const validation = validateDocxBytes(bytes); if (!validation.valid) throw new Error(`The generated DOCX could not be validated and was not saved. ${validation.errors.join(" ")}`);
|
||||
if (request.signal?.aborted) throw new Error("Compilation cancelled."); const slash = request.outputPath.lastIndexOf("/"); if (slash >= 0) await this.markdownExporter.ensureFolder(request.outputPath.slice(0, slash));
|
||||
const cleanup = await this.binaryWriter.cleanupStaleArtifacts(slash < 0 ? "" : request.outputPath.slice(0, slash)); if (cleanup.removed.length) console.info(`Manuscript Compiler removed ${cleanup.removed.length} stale temporary file(s) before saving.`); if (cleanup.preservedBackups.length) console.warn(`Manuscript Compiler preserved ${cleanup.preservedBackups.length} recovery backup file(s) for manual review.`);
|
||||
|
|
|
|||
15
src/folder-context-menu.ts
Normal file
15
src/folder-context-menu.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { TFolder, type Menu, type TAbstractFile } from "obsidian";
|
||||
|
||||
export const COMPILE_FOLDER_MENU_TITLE = "Compile manuscript from this folder";
|
||||
|
||||
/** Adds the documented File Explorer action without doing any preparation in the menu callback. */
|
||||
export function addCompileFolderMenuItem(menu: Menu, file: TAbstractFile, openCompiler: (folder: TFolder) => void): boolean {
|
||||
if (!(file instanceof TFolder)) return false;
|
||||
menu.addItem((item) => {
|
||||
item
|
||||
.setTitle(COMPILE_FOLDER_MENU_TITLE)
|
||||
.setIcon("book-open")
|
||||
.onClick(() => openCompiler(file));
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
|
@ -14,6 +14,7 @@ import { SafeBinaryWriter } from "./safe-binary-writer";
|
|||
import { SimpleCompileModal } from "./compile-modal";
|
||||
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. */
|
||||
export default class ManuscriptCompilerPlugin extends Plugin {
|
||||
|
|
@ -30,6 +31,7 @@ export default class ManuscriptCompilerPlugin extends Plugin {
|
|||
await this.cleanupStaleOutputFiles();
|
||||
this.addSettingTab(new ManuscriptCompilerSettingTab(this.app, this));
|
||||
this.registerCommands();
|
||||
this.registerFolderContextMenu();
|
||||
this.app.workspace.onLayoutReady(() => {
|
||||
if (!this.settings.onboardingCompleted) new FirstRunWizardModal(this.app, this).open();
|
||||
});
|
||||
|
|
@ -51,6 +53,7 @@ export default class ManuscriptCompilerPlugin extends Plugin {
|
|||
async saveSettings(): Promise<void> { await this.saveData(this.settings); }
|
||||
getActiveProfile(): CompileProfile { return activeProfile(this.settings); }
|
||||
openCompiler(): void { new SimpleCompileModal(this.app, this).open(); }
|
||||
async openCompilerForFolder(folder: TFolder): Promise<void> { new SimpleCompileModal(this.app, this, folder).open(); }
|
||||
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.")); }
|
||||
async clearHistory(): Promise<void> { await this.history.clearHistory(); }
|
||||
async compileRequest(request: SimpleCompileRequest): Promise<void> { await this.commands.compileRequest(request); }
|
||||
|
|
@ -75,6 +78,12 @@ export default class ManuscriptCompilerPlugin extends Plugin {
|
|||
this.addCommand({ id: COMMAND_IDS.generateDiagnostics, name: "Generate Diagnostics Report", callback: () => { void this.commands.generateDiagnostics(); } });
|
||||
}
|
||||
|
||||
private registerFolderContextMenu(): void {
|
||||
this.registerEvent(this.app.workspace.on("file-menu", (menu, file) => {
|
||||
addCompileFolderMenuItem(menu, file, (folder) => { void this.openCompilerForFolder(folder); });
|
||||
}));
|
||||
}
|
||||
|
||||
private async cleanupStaleOutputFiles(): Promise<void> {
|
||||
try {
|
||||
const result = await new SafeBinaryWriter(this.app.vault).cleanupStaleArtifacts(this.settings.defaultExportFolder);
|
||||
|
|
|
|||
14
src/measurements.ts
Normal file
14
src/measurements.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
const CENTIMETRES_PER_INCH = 2.54;
|
||||
const TWIPS_PER_INCH = 1440;
|
||||
|
||||
export function centimetresToInches(value: number): number { return round(value / CENTIMETRES_PER_INCH); }
|
||||
export function inchesToCentimetres(value: number): number { return round(value * CENTIMETRES_PER_INCH); }
|
||||
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); }
|
||||
|
||||
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)));
|
||||
}
|
||||
|
||||
function round(value: number): number { return Math.round(value * 10_000) / 10_000; }
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { TFile, TFolder } from "obsidian";
|
||||
import type { HierarchyDiagnostic } from "./types";
|
||||
|
||||
export type MatterKind = "front" | "back";
|
||||
|
||||
|
|
@ -63,6 +64,7 @@ export interface Book {
|
|||
excludedFiles: Array<{ file: TFile; reason: string }>;
|
||||
warnings: string[];
|
||||
issues: CompileWarning[];
|
||||
hierarchyDiagnostics?: HierarchyDiagnostic[];
|
||||
}
|
||||
|
||||
export type WarningSeverity = "information" | "warning" | "error";
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export class ManuscriptParser {
|
|||
|
||||
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);
|
||||
const unreadable = new Map<string, string>();
|
||||
const cache = new Map<string, ManuscriptDocument>();
|
||||
this.filterDurationMs = 0;
|
||||
|
|
@ -47,7 +48,7 @@ export class ManuscriptParser {
|
|||
root: scan.root, title: scan.root.name,
|
||||
frontMatter: { kind: "front", title: "Front Matter", documents: frontDocuments },
|
||||
parts, orphanScenes, backMatter: { kind: "back", title: "Back Matter", documents: backDocuments },
|
||||
includedFiles, excludedFiles: [...uniqueExclusions.values()], warnings, issues: []
|
||||
includedFiles, excludedFiles: [...uniqueExclusions.values()], warnings, issues: [], hierarchyDiagnostics: scan.hierarchyDiagnostics
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import type { CompileProfile, ManuscriptCompilerSettings } from "./settings";
|
||||
import { DEFAULT_OPTIONS } from "./settings";
|
||||
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): 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 }; }
|
||||
export function createDefaultProfiles(): CompileProfile[] { return [profile("Default"), profile("Vellum", VELLUM_OPTIONS)]; }
|
||||
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" }; }
|
||||
export function createDefaultProfiles(): CompileProfile[] { return [profile("Default"), profile("Vellum", VELLUM_OPTIONS, 0.75)]; }
|
||||
export function duplicateProfile(source: CompileProfile, name = `${source.name} Copy`): CompileProfile { return { ...source, id: profileId(), name, metadataFilters: source.metadataFilters.map((rule) => ({ ...rule })), variables: { ...source.variables } }; }
|
||||
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; }
|
||||
|
|
@ -27,6 +28,9 @@ export function repairSettings(settings: ManuscriptCompilerSettings): Manuscript
|
|||
repaired.defaultDocxStyle ??= repaired.defaultCompilePreset === "vellum" || /vellum/i.test(activeForMigration?.name ?? "") ? "vellum" : "standard";
|
||||
if (!(repaired.defaultDocxStyle === "vellum" || repaired.defaultDocxStyle === "standard")) repaired.defaultDocxStyle = "standard";
|
||||
repaired.defaultExportFormat ??= "docx"; repaired.warnBeforeOverwrite ??= true; repaired.openAfterCompile ??= false; repaired.includeTitlePageByDefault ??= false; repaired.includeTableOfContentsByDefault ??= activeForMigration?.generateTableOfContents ?? false; repaired.showAdvancedOptions ??= false;
|
||||
repaired.defaultDocxPageSize = activeForMigration?.docxPageSize === "letter" || activeForMigration?.docxPageSize === "a4" ? activeForMigration.docxPageSize : repaired.defaultDocxPageSize === "letter" ? "letter" : "a4";
|
||||
const migratedDefaultIndent = activeForMigration?.docxFirstLineIndentCm ?? (typeof activeForMigration?.docxFirstLineIndent === "number" ? inchesToCentimetres(activeForMigration.docxFirstLineIndent) : repaired.defaultDocxStyle === "vellum" ? 0.75 : 1.27);
|
||||
repaired.defaultDocxFirstLineIndentCm = clampCentimetres(migratedDefaultIndent, 0, 3.81, repaired.defaultDocxStyle === "vellum" ? 0.75 : 1.27);
|
||||
if (!Array.isArray(repaired.exportHistory)) { repaired.exportHistory = []; warnings.push("Invalid export history was reset."); }
|
||||
if (!Array.isArray(repaired.compileLogs)) { repaired.compileLogs = []; warnings.push("Invalid compile logs were reset."); }
|
||||
if (!Number.isFinite(repaired.readingWordsPerMinute) || repaired.readingWordsPerMinute <= 0) { repaired.readingWordsPerMinute = 250; warnings.push("Reading speed was repaired to 250 words per minute."); }
|
||||
|
|
@ -42,6 +46,10 @@ export function repairSettings(settings: ManuscriptCompilerSettings): Manuscript
|
|||
if (merged.chapterSource !== "folders" && merged.chapterSource !== "notes") { merged.chapterSource = "folders"; warnings.push(`Profile “${merged.name}” chapter source was repaired to folders.`); }
|
||||
for (const key of ["manuscriptRoot", "exportFolder", "outputFilename", "partHeadingTemplate", "chapterHeadingTemplate", "sceneSeparator", "referenceDocx", "pandocMetadataFile", "additionalPandocArguments"] as const) if (typeof merged[key] !== "string") { (merged[key] as string) = defaults[key]; warnings.push(`Profile “${merged.name}” setting ${key} was repaired.`); }
|
||||
for (const key of ["blankLinesBetweenSections", "blankLinesBetweenChapters"] as const) if (!Number.isInteger(merged[key]) || merged[key] < 0) { merged[key] = defaults[key]; warnings.push(`Profile “${merged.name}” setting ${key} was repaired.`); }
|
||||
const profileIndentDefault = /vellum/i.test(item.name ?? "") ? 0.75 : defaults.docxFirstLineIndentCm ?? 1.27;
|
||||
const metricIndent = typeof item.docxFirstLineIndentCm === "number" ? item.docxFirstLineIndentCm : typeof item.docxFirstLineIndent === "number" ? inchesToCentimetres(item.docxFirstLineIndent) : profileIndentDefault;
|
||||
merged.docxFirstLineIndentCm = clampCentimetres(metricIndent, 0, 3.81, profileIndentDefault);
|
||||
merged.docxPageSize = item.docxPageSize === "letter" || item.docxPageSize === "a4" ? item.docxPageSize : "a4";
|
||||
return merged;
|
||||
});
|
||||
if (!repaired.profiles.some((item) => item.id === repaired.activeProfileId)) { repaired.activeProfileId = repaired.profiles[0].id; warnings.push("Active profile selection was repaired."); }
|
||||
|
|
|
|||
|
|
@ -20,7 +20,11 @@ export interface CompileProfile extends CompileOptions {
|
|||
exportTarget: ExportTarget; referenceDocx: string; pandocMetadataFile: string; additionalPandocArguments: string;
|
||||
generateTableOfContents: boolean; keepIntermediateMarkdown: boolean;
|
||||
/** Session-level Compile Manuscript choices. These are not profile settings. */
|
||||
contentOrder?: string[]; docxFont?: string; docxFontSize?: number; docxLineSpacing?: number; docxFirstLineIndent?: number;
|
||||
contentOrder?: string[]; docxFont?: string; docxFontSize?: number; docxLineSpacing?: number;
|
||||
/** Canonical first-line indentation unit for current settings. */
|
||||
docxFirstLineIndentCm?: number;
|
||||
/** Legacy pre-metric value in inches. Retained only for migration compatibility. */
|
||||
docxFirstLineIndent?: number;
|
||||
docxPageSize?: "letter" | "a4"; docxChapterPageBreak?: boolean; docxTitlePage?: boolean; downloadAfterExport?: boolean;
|
||||
skipLegacyPreview?: boolean;
|
||||
partDisplay?: StructuralDisplay; chapterDisplay?: StructuralDisplay; explicitlyIncludedPaths?: string[];
|
||||
|
|
@ -37,6 +41,7 @@ export interface ManuscriptCompilerSettings extends CompileOptions {
|
|||
configurationWarnings: string[];
|
||||
onboardingCompleted: boolean;
|
||||
defaultStructurePreset: StructurePreset; defaultDocxStyle: DocxStylePreset; warnBeforeOverwrite: boolean;
|
||||
defaultDocxPageSize: "letter" | "a4"; defaultDocxFirstLineIndentCm: number;
|
||||
openAfterCompile: boolean; includeTitlePageByDefault: boolean; includeTableOfContentsByDefault: boolean; showAdvancedOptions: boolean;
|
||||
/* Stage 1/2 migration fields. */
|
||||
defaultManuscriptFolder: string; defaultExportFolder: string; defaultCompilePreset: "default" | "vellum";
|
||||
|
|
@ -57,5 +62,6 @@ export const DEFAULT_SETTINGS: ManuscriptCompilerSettings = {
|
|||
keepTemporaryMarkdown: false, enableCompileLogs: true, maximumExportHistoryEntries: 50, exportHistory: [], compileLogs: [], configurationWarnings: [], onboardingCompleted: false,
|
||||
defaultManuscriptFolder: "", defaultExportFolder: "Manuscript Exports", defaultCompilePreset: "default",
|
||||
defaultStructurePreset: "novel-parts", defaultDocxStyle: "vellum", warnBeforeOverwrite: true, openAfterCompile: false,
|
||||
defaultDocxPageSize: "a4", defaultDocxFirstLineIndentCm: 0.75,
|
||||
includeTitlePageByDefault: false, includeTableOfContentsByDefault: false, showAdvancedOptions: false
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import type { CompileProfile, DocxStylePreset, ExportTarget, StructuralDisplay, StructurePreset } from "./settings";
|
||||
import type { ContentPlanItem } from "./content-plan";
|
||||
|
||||
export interface DocxFormatting { font: string; fontSize: number; lineSpacing: number; firstLineIndent: number; pageSize: "letter" | "a4"; chapterPageBreak: boolean; titlePage: boolean; }
|
||||
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>> = {
|
||||
vellum: { font: "Garamond", fontSize: 12, lineSpacing: 1.15, firstLineIndent: 0.3, pageSize: "letter", chapterPageBreak: true, titlePage: false },
|
||||
standard: { font: "Times New Roman", fontSize: 12, lineSpacing: 2, firstLineIndent: 0.5, pageSize: "letter", chapterPageBreak: true, titlePage: false }
|
||||
vellum: { font: "Garamond", fontSize: 12, lineSpacing: 1.15, firstLineIndentCm: 0.75, pageSize: "a4", chapterPageBreak: true, titlePage: false },
|
||||
standard: { font: "Times New Roman", fontSize: 12, lineSpacing: 2, firstLineIndentCm: 1.27, pageSize: "a4", chapterPageBreak: true, titlePage: false }
|
||||
};
|
||||
|
||||
export function docxFormattingForPreset(preset: DocxStylePreset, titlePage = false, current?: DocxFormatting): DocxFormatting {
|
||||
|
|
@ -36,23 +36,25 @@ const STRUCTURES: Record<Exclude<StructurePreset, "custom">, Partial<CompileProf
|
|||
};
|
||||
|
||||
const DOCX: Record<DocxStylePreset, Partial<CompileProfile>> = {
|
||||
vellum: { exportTarget: "docx", orderingMethod: "metadata", metadataOrdering: true, stripYamlFrontmatter: true, removeObsidianComments: true, removeHtmlComments: true, removeDataviewBlocks: true, removeCallouts: true, stripInternalLinks: true, generateTableOfContents: false, keepIntermediateMarkdown: false, blankLinesBetweenSections: 1, blankLinesBetweenChapters: 1 },
|
||||
standard: { exportTarget: "docx", orderingMethod: "filename", metadataOrdering: false, stripYamlFrontmatter: true, removeObsidianComments: true, removeHtmlComments: true, removeDataviewBlocks: true, removeCallouts: true, stripInternalLinks: true, generateTableOfContents: false, keepIntermediateMarkdown: false, blankLinesBetweenSections: 1, blankLinesBetweenChapters: 1 },
|
||||
vellum: { exportTarget: "docx", orderingMethod: "metadata", metadataOrdering: true, stripYamlFrontmatter: true, removeObsidianComments: true, removeHtmlComments: true, removeDataviewBlocks: true, removeCallouts: true, stripInternalLinks: true, generateTableOfContents: false, keepIntermediateMarkdown: false, blankLinesBetweenSections: 1, blankLinesBetweenChapters: 1, sceneSeparator: "#" },
|
||||
standard: { exportTarget: "docx", orderingMethod: "filename", metadataOrdering: false, stripYamlFrontmatter: true, removeObsidianComments: true, removeHtmlComments: true, removeDataviewBlocks: true, removeCallouts: true, stripInternalLinks: true, generateTableOfContents: false, keepIntermediateMarkdown: false, blankLinesBetweenSections: 1, blankLinesBetweenChapters: 1, sceneSeparator: "* * *" },
|
||||
custom: { exportTarget: "docx", stripYamlFrontmatter: true, removeObsidianComments: true, removeHtmlComments: true, removeDataviewBlocks: true, removeCallouts: true, stripInternalLinks: true, generateTableOfContents: false, keepIntermediateMarkdown: false }
|
||||
};
|
||||
|
||||
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);
|
||||
return { ...base, ...DOCX[request.docxPreset], ...structure, ...(request.structurePreset === "custom" ? request.custom : {}),
|
||||
id: base.id, name: `${STRUCTURE_PRESET_NAMES[request.structurePreset]} · ${request.docxPreset === "vellum" ? "Vellum" : request.docxPreset === "standard" ? "Standard DOCX" : "Custom DOCX"}`,
|
||||
manuscriptRoot: request.manuscriptRoot.trim(), exportFolder: request.exportFolder.trim(), outputFilename: request.outputFilename.trim(),
|
||||
exportTarget: request.outputFormat, includeFrontMatter: request.includeFrontMatter, includeBackMatter: request.includeBackMatter, sceneSeparator: request.custom?.sceneSeparator ?? structure.sceneSeparator ?? base.sceneSeparator,
|
||||
exportTarget: request.outputFormat, includeFrontMatter: request.includeFrontMatter, includeBackMatter: request.includeBackMatter,
|
||||
sceneSeparator: request.docxPreset === "custom" ? request.custom?.sceneSeparator ?? structure.sceneSeparator ?? base.sceneSeparator : DOCX[request.docxPreset].sceneSeparator ?? base.sceneSeparator,
|
||||
generateTableOfContents: request.outputFormat !== "markdown" && request.tableOfContents === true,
|
||||
variables: { ...base.variables, ...(request.custom?.variables ?? {}) }, metadataFilters: base.metadataFilters.map((rule) => ({ ...rule })), referenceDocx: "", pandocMetadataFile: "", additionalPandocArguments: "",
|
||||
contentOrder: request.contentPlan?.filter((item) => item.included && item.role !== "ignore").map((item) => item.path),
|
||||
docxFont: request.formatting?.font, docxFontSize: request.formatting?.fontSize, docxLineSpacing: request.formatting?.lineSpacing,
|
||||
docxFirstLineIndent: request.formatting?.firstLineIndent, docxPageSize: request.formatting?.pageSize,
|
||||
docxChapterPageBreak: request.formatting?.chapterPageBreak, docxTitlePage: request.formatting?.titlePage, downloadAfterExport: request.downloadAfterExport, skipLegacyPreview: request.contentPlan !== undefined,
|
||||
docxFont: formatting.font, docxFontSize: formatting.fontSize, docxLineSpacing: formatting.lineSpacing,
|
||||
docxFirstLineIndentCm: formatting.firstLineIndentCm, docxPageSize: formatting.pageSize,
|
||||
docxChapterPageBreak: formatting.chapterPageBreak, docxTitlePage: formatting.titlePage, downloadAfterExport: request.downloadAfterExport, skipLegacyPreview: request.contentPlan !== undefined,
|
||||
partDisplay: request.partDisplay ?? "word-title", chapterDisplay: request.chapterDisplay ?? "word-title", explicitlyIncludedPaths: request.contentPlan?.filter((item) => item.userOverride && item.included).map((item) => item.path), bodySectionAliases: request.custom?.bodySectionAliases ?? base.bodySectionAliases
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { TFile, TFolder } from "obsidian";
|
||||
export interface ScannedBook { root: TFolder; frontMatter: TFile[]; parts: ScannedPart[]; looseScenes: TFile[]; backMatter: TFile[]; allMarkdown: TFile[]; warnings: string[]; }
|
||||
export interface HierarchyDiagnostic { scenePath: string; inferredRole: string; parentPath: string; parentRole: string; nearestStructuralAncestor: string; transparentReparenting: boolean; parentExcluded: boolean; }
|
||||
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[]; }
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { PreparedCompileSession } from "../compile-preparation";
|
||||
import type { ContentPlanItem, ContentRole } from "../content-plan";
|
||||
import { applyMatterRoleInheritance, type ContentPlanItem, type ContentRole } from "../content-plan";
|
||||
import { OperationStateController } from "../operation-state";
|
||||
import { docxFormattingForPreset, type DocxFormatting, type SimpleCompileRequest } from "../simple-workflow";
|
||||
import { validateSimpleCompileRequest } from "../simple-workflow";
|
||||
|
|
@ -19,7 +19,7 @@ export class CompileWorkspaceController {
|
|||
private preparationPromise?: Promise<PreparedCompileSession | undefined>;
|
||||
private exportPromise?: Promise<boolean>;
|
||||
private detachedExport = false;
|
||||
private readonly childSnapshots = new Map<string, Map<string, { included: boolean; role: ContentRole }>>();
|
||||
private readonly childSnapshots = new Map<string, Map<string, { included: boolean; role: ContentRole; userOverride?: boolean }>>();
|
||||
|
||||
constructor(request: SimpleCompileRequest, formatting: DocxFormatting, private readonly services: CompileWorkspaceServices) {
|
||||
this.state = { step: "manuscript", request, contentPlan: [], formatting, scannedRoot: "", preparationStatus: "idle", exportStatus: "idle" };
|
||||
|
|
@ -32,13 +32,20 @@ export class CompileWorkspaceController {
|
|||
setRoot(root: string): void { this.update(() => { this.state.request.manuscriptRoot = root.trim(); this.state.contentPlan = []; this.state.scannedRoot = ""; }); }
|
||||
setPreset(preset: StructurePreset): void { this.update(() => { this.state.request.structurePreset = preset; this.state.contentPlan = []; this.state.scannedRoot = ""; }); }
|
||||
setDetectedPlan(root: string, plan: ContentPlanItem[]): void { this.update(() => { this.state.request.manuscriptRoot = root; this.state.contentPlan = plan; this.state.scannedRoot = root; }); }
|
||||
setRole(path: string, role: ContentRole): void { this.update(() => setItemRole(this.state.contentPlan, this.state.request.manuscriptRoot, path, role)); }
|
||||
setRole(path: string, role: ContentRole): void {
|
||||
this.update(() => {
|
||||
const item = this.state.contentPlan.find((candidate) => candidate.path === path);
|
||||
const previousRole = item?.role;
|
||||
setItemRole(this.state.contentPlan, this.state.request.manuscriptRoot, path, role);
|
||||
if (item?.kind === "folder") applyMatterRoleInheritance(this.state.contentPlan, path, role, previousRole);
|
||||
});
|
||||
}
|
||||
setIncluded(path: string, included: boolean): void {
|
||||
this.update(() => {
|
||||
const item = this.state.contentPlan.find((candidate) => candidate.path === path);
|
||||
if (item?.kind === "folder" && !included) 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 }])));
|
||||
if (item?.kind === "folder" && !included && item.included) this.snapshotChildren(path);
|
||||
setItemIncluded(this.state.contentPlan, this.state.request.manuscriptRoot, path, included);
|
||||
if (item?.kind === "folder" && included) 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; } });
|
||||
if (item?.kind === "folder") this.restoreChildren(path);
|
||||
});
|
||||
}
|
||||
moveItem(path: string, direction: -1 | 1): void { this.update(() => { this.state.contentPlan = moveSibling(this.state.contentPlan, this.state.request.manuscriptRoot, path, direction); }); }
|
||||
|
|
@ -55,7 +62,6 @@ export class CompileWorkspaceController {
|
|||
this.state.request.docxPreset = value;
|
||||
if (value !== "custom") {
|
||||
Object.assign(this.state.formatting, docxFormattingForPreset(value, this.state.formatting.titlePage));
|
||||
if (this.state.request.custom) this.state.request.custom.sceneSeparator = "#";
|
||||
this.state.request.partDisplay = "word-title";
|
||||
this.state.request.chapterDisplay = "word-title";
|
||||
this.state.request.tableOfContents = false;
|
||||
|
|
@ -137,6 +143,8 @@ export class CompileWorkspaceController {
|
|||
cancelActiveOperation(): boolean { return this.operations.cancel(); }
|
||||
detachExport(): void { this.detachedExport = true; }
|
||||
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; } }); }
|
||||
private update(change: () => void): void { change(); this.invalidatePreparedSession(); }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,11 @@ import type { ContentPlanItem, ContentRole } from "../content-plan";
|
|||
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]));
|
||||
return effectivelyIncluded(item, byPath, root);
|
||||
}
|
||||
|
||||
function effectivelyIncluded(item: ContentPlanItem, byPath: ReadonlyMap<string, ContentPlanItem>, root: string): boolean {
|
||||
if (!item.included || item.role === "ignore") return false;
|
||||
let parent = item.parentPath;
|
||||
while (parent !== root) {
|
||||
const ancestor = byPath.get(parent);
|
||||
|
|
@ -35,9 +40,9 @@ export function setItemRole(plan: ContentPlanItem[], root: string, path: string,
|
|||
if (!item) return;
|
||||
item.role = role;
|
||||
item.userOverride = true;
|
||||
item.included = role !== "ignore";
|
||||
item.exclusionReason = role === "ignore" ? "Excluded by user." : undefined;
|
||||
setItemIncluded(plan, root, path, role !== "ignore");
|
||||
item.role = role;
|
||||
if (item.included) enableAncestors(plan, root, item.parentPath);
|
||||
}
|
||||
|
||||
export function moveSibling(plan: ContentPlanItem[], root: string, path: string, direction: -1 | 1): ContentPlanItem[] {
|
||||
|
|
@ -68,11 +73,13 @@ export function orderedPlan(plan: ContentPlanItem[], root: string): ContentPlanI
|
|||
}
|
||||
|
||||
export function includedNoteCount(plan: ContentPlanItem[], root: string): number {
|
||||
return plan.filter((item) => item.kind === "note" && isEffectivelyIncluded(item, plan, root)).length;
|
||||
const byPath = new Map(plan.map((item) => [item.path, item]));
|
||||
return plan.filter((item) => item.kind === "note" && effectivelyIncluded(item, byPath, root)).length;
|
||||
}
|
||||
|
||||
export function visibleRows(plan: ContentPlanItem[], root: string): Array<{ item: ContentPlanItem; depth: number; included: boolean }> {
|
||||
return orderedPlan(plan, root).map((item) => ({ item, depth: Math.max(0, item.path.slice(root.length + 1).split("/").length - 1), included: isEffectivelyIncluded(item, plan, root) }));
|
||||
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) }));
|
||||
}
|
||||
|
||||
function enableAncestors(plan: ContentPlanItem[], root: string, initial: string): void {
|
||||
|
|
|
|||
|
|
@ -1,22 +1,150 @@
|
|||
import { Setting } from "obsidian";
|
||||
import type { ContentRole } from "../content-plan";
|
||||
import type { ContentPlanItem, ContentRole } from "../content-plan";
|
||||
import type { CompileWorkspaceController } from "./compile-workspace-controller";
|
||||
import { includedNoteCount, visibleRows } from "./content-tree";
|
||||
import { ContentsTreeViewState, type ContentsControl } from "./contents-tree-view-state";
|
||||
|
||||
const roleLabels: Record<ContentRole, string> = { "front-matter": "Front matter", transparent: "Transparent container", part: "Part", chapter: "Chapter", scene: "Scene", "back-matter": "Back matter", ignore: "Exclude" };
|
||||
export function renderContentsStep(container: HTMLElement, controller: CompileWorkspaceController, changed: () => void): void {
|
||||
interface RowRecord {
|
||||
element: HTMLElement;
|
||||
include: HTMLInputElement;
|
||||
description: HTMLElement;
|
||||
select: HTMLSelectElement;
|
||||
toggle?: HTMLButtonElement;
|
||||
up: HTMLButtonElement;
|
||||
down: HTMLButtonElement;
|
||||
}
|
||||
|
||||
interface RowSnapshot {
|
||||
role: ContentRole;
|
||||
included: boolean;
|
||||
effective: boolean;
|
||||
reason?: string;
|
||||
order: number;
|
||||
first: boolean;
|
||||
last: boolean;
|
||||
}
|
||||
|
||||
export function renderContentsStep(container: HTMLElement, controller: CompileWorkspaceController, viewState: ContentsTreeViewState): void {
|
||||
const { contentPlan: plan, request } = controller.state;
|
||||
viewState.prepare(request.manuscriptRoot, plan);
|
||||
container.createEl("h2", { text: "Choose and arrange contents" }); container.createEl("p", { text: "Include the notes that belong in the manuscript, identify what each item represents, and use the arrows to set the order." });
|
||||
new Setting(container).setName(`${includedNoteCount(plan, request.manuscriptRoot)} of ${plan.filter((item) => item.kind === "note").length} notes included`).addButton((button) => button.setButtonText("Include all").onClick(() => { controller.includeAll(); changed(); })).addButton((button) => button.setButtonText("Exclude all").onClick(() => { controller.excludeAllNotes(); changed(); }));
|
||||
const summary = new Setting(container).setName("");
|
||||
const tree = container.createDiv({ cls: "manuscript-content-plan", attr: { role: "tree", "aria-label": "Manuscript contents" } });
|
||||
const records = new Map<string, RowRecord>();
|
||||
const byPath = (): Map<string, ContentPlanItem> => new Map(controller.state.contentPlan.map((item) => [item.path, item]));
|
||||
const snapshots = (): Map<string, RowSnapshot> => snapshotRows(controller.state.contentPlan, request.manuscriptRoot);
|
||||
const rememberFocus = (path: string, control: ContentsControl, element: HTMLElement): void => {
|
||||
element.addEventListener("focus", () => viewState.setFocus(path, control));
|
||||
};
|
||||
|
||||
const updateSummary = (): void => {
|
||||
summary.setName(`${includedNoteCount(controller.state.contentPlan, request.manuscriptRoot)} of ${controller.state.contentPlan.filter((item) => item.kind === "note").length} notes included`);
|
||||
};
|
||||
const updateRow = (path: string, states = snapshotRows(controller.state.contentPlan, request.manuscriptRoot), items = byPath()): void => {
|
||||
const item = items.get(path); const record = records.get(path);
|
||||
if (!item || !record) return;
|
||||
const state = states.get(path);
|
||||
if (!state) return;
|
||||
record.element.toggleClass("is-excluded", !state.effective);
|
||||
record.include.checked = state.effective;
|
||||
record.include.setAttribute("aria-label", `${state.effective ? "Exclude" : "Include"} ${item.name}`);
|
||||
record.description.setText(item.exclusionReason && !state.effective ? item.exclusionReason : item.kind === "folder" ? "Folder" : "Note");
|
||||
record.select.value = item.role;
|
||||
record.up.disabled = state.first;
|
||||
record.down.disabled = state.last;
|
||||
if (record.toggle) {
|
||||
const expanded = viewState.isExpanded(path);
|
||||
record.toggle.setText(expanded ? "▾" : "▸");
|
||||
record.toggle.setAttribute("aria-expanded", String(expanded));
|
||||
record.toggle.setAttribute("aria-label", `${expanded ? "Collapse" : "Expand"} ${item.name}`);
|
||||
record.element.setAttribute("aria-expanded", String(expanded));
|
||||
}
|
||||
};
|
||||
const syncOrderAndVisibility = (): void => {
|
||||
const items = byPath();
|
||||
visibleRows(controller.state.contentPlan, request.manuscriptRoot).forEach(({ item }) => {
|
||||
const record = records.get(item.path);
|
||||
if (!record) return;
|
||||
record.element.hidden = !viewState.isVisible(item, items);
|
||||
tree.appendChild(record.element);
|
||||
});
|
||||
};
|
||||
const refreshChangedRows = (before: Map<string, RowSnapshot>): void => {
|
||||
const after = snapshots(); const items = byPath();
|
||||
for (const path of changedRowPaths(before, after)) updateRow(path, after, items);
|
||||
updateSummary();
|
||||
};
|
||||
const preserveInteraction = (element: HTMLElement, change: () => void): void => {
|
||||
const scrollTop = container.scrollTop;
|
||||
change();
|
||||
container.scrollTop = scrollTop;
|
||||
viewState.setScrollTop(scrollTop);
|
||||
if (document.activeElement !== element) element.focus({ preventScroll: true });
|
||||
};
|
||||
|
||||
summary.addButton((button) => button.setButtonText("Include all").onClick(() => {
|
||||
const before = snapshots(); controller.includeAll(); refreshChangedRows(before);
|
||||
})).addButton((button) => button.setButtonText("Exclude all").onClick(() => {
|
||||
const before = snapshots(); controller.excludeAllNotes(); refreshChangedRows(before);
|
||||
}));
|
||||
visibleRows(plan, request.manuscriptRoot).forEach(({ item, depth, included }) => {
|
||||
const row = tree.createDiv({ cls: `manuscript-content-row ${included ? "" : "is-excluded"}` }); row.setAttribute("role", "treeitem"); row.style.setProperty("--manuscript-depth", String(depth));
|
||||
const include = row.createEl("input", { type: "checkbox" }); include.checked = included; include.setAttribute("aria-label", `${included ? "Exclude" : "Include"} ${item.name}`); include.addEventListener("change", () => { controller.setIncluded(item.path, include.checked); changed(); });
|
||||
const label = row.createDiv({ cls: "manuscript-content-name" }); label.createSpan({ text: item.kind === "folder" ? "▸" : "•", cls: "manuscript-content-icon" }); label.createSpan({ text: item.name }); label.createEl("small", { text: item.exclusionReason && !included ? item.exclusionReason : item.kind === "folder" ? "Folder" : "Note" });
|
||||
const select = row.createEl("select"); select.setAttribute("aria-label", `Role for ${item.name}`); Object.entries(roleLabels).forEach(([value, text]) => select.createEl("option", { value, text })); select.value = item.role; select.addEventListener("change", () => { controller.setRole(item.path, select.value as ContentRole); changed(); });
|
||||
row.dataset.path = item.path;
|
||||
const include = row.createEl("input", { type: "checkbox" }); include.checked = included; include.setAttribute("aria-label", `${included ? "Exclude" : "Include"} ${item.name}`); rememberFocus(item.path, "include", include);
|
||||
const label = row.createDiv({ cls: "manuscript-content-name" });
|
||||
let toggle: HTMLButtonElement | undefined;
|
||||
if (item.kind === "folder") {
|
||||
toggle = label.createEl("button", { text: "▾", cls: "manuscript-content-icon clickable-icon" }); rememberFocus(item.path, "toggle", toggle);
|
||||
} else label.createSpan({ text: "•", cls: "manuscript-content-icon" });
|
||||
label.createSpan({ text: item.name }); const description = label.createEl("small", { text: item.exclusionReason && !included ? item.exclusionReason : item.kind === "folder" ? "Folder" : "Note" });
|
||||
const select = row.createEl("select"); select.setAttribute("aria-label", `Role for ${item.name}`); Object.entries(roleLabels).forEach(([value, text]) => select.createEl("option", { value, text })); select.value = item.role; rememberFocus(item.path, "role", select);
|
||||
const siblings = plan.filter((candidate) => candidate.parentPath === item.parentPath).sort((a, b) => a.order - b.order); const index = siblings.findIndex((candidate) => candidate.path === item.path); const buttons = row.createDiv({ cls: "manuscript-order-buttons" });
|
||||
const up = buttons.createEl("button", { text: "↑", cls: "clickable-icon", attr: { "aria-label": `Move ${item.name} up` } }); up.disabled = index === 0; up.addEventListener("click", () => { controller.moveItem(item.path, -1); changed(); });
|
||||
const down = buttons.createEl("button", { text: "↓", cls: "clickable-icon", attr: { "aria-label": `Move ${item.name} down` } }); down.disabled = index === siblings.length - 1; down.addEventListener("click", () => { controller.moveItem(item.path, 1); changed(); });
|
||||
const up = buttons.createEl("button", { text: "↑", cls: "clickable-icon", attr: { "aria-label": `Move ${item.name} up` } }); up.disabled = index === 0; rememberFocus(item.path, "move-up", up);
|
||||
const down = buttons.createEl("button", { text: "↓", cls: "clickable-icon", attr: { "aria-label": `Move ${item.name} down` } }); down.disabled = index === siblings.length - 1; rememberFocus(item.path, "move-down", down);
|
||||
records.set(item.path, { element: row, include, description, select, toggle, up, down });
|
||||
include.addEventListener("change", () => preserveInteraction(include, () => { const before = snapshots(); controller.setIncluded(item.path, include.checked); if (item.kind === "folder" && !include.checked) { viewState.collapse(item.path); syncOrderAndVisibility(); } refreshChangedRows(before); updateRow(item.path); }));
|
||||
select.addEventListener("change", () => preserveInteraction(select, () => { const before = snapshots(); controller.setRole(item.path, select.value as ContentRole); if (item.kind === "folder" && select.value === "ignore") { viewState.collapse(item.path); syncOrderAndVisibility(); } refreshChangedRows(before); updateRow(item.path); }));
|
||||
up.addEventListener("click", () => preserveInteraction(up, () => { const before = snapshots(); controller.moveItem(item.path, -1); syncOrderAndVisibility(); refreshChangedRows(before); }));
|
||||
down.addEventListener("click", () => preserveInteraction(down, () => { const before = snapshots(); controller.moveItem(item.path, 1); syncOrderAndVisibility(); refreshChangedRows(before); }));
|
||||
toggle?.addEventListener("click", () => preserveInteraction(toggle!, () => { viewState.toggle(item.path); syncOrderAndVisibility(); updateRow(item.path); }));
|
||||
});
|
||||
updateSummary(); syncOrderAndVisibility(); const initialStates = snapshots(); const initialItems = byPath(); records.forEach((_record, path) => updateRow(path, initialStates, initialItems));
|
||||
container.scrollTop = viewState.scrollTop;
|
||||
container.addEventListener("scroll", () => viewState.setScrollTop(container.scrollTop), { passive: true });
|
||||
if (viewState.focus) {
|
||||
const record = records.get(viewState.focus.path); const control = record && controlFor(record, viewState.focus.control);
|
||||
if (control && !control.closest<HTMLElement>(".manuscript-content-row")?.hidden) control.focus({ preventScroll: true });
|
||||
}
|
||||
if (!plan.length) container.createEl("p", { cls: "manuscript-empty-state", text: "No Markdown notes were found. Go back and choose another folder." });
|
||||
}
|
||||
|
||||
export function snapshotRows(plan: ContentPlanItem[], root: string): Map<string, RowSnapshot> {
|
||||
const effective = new Map(visibleRows(plan, root).map((row) => [row.item.path, row.included]));
|
||||
const siblings = new Map<string, ContentPlanItem[]>();
|
||||
plan.forEach((item) => siblings.set(item.parentPath, [...(siblings.get(item.parentPath) ?? []), item]));
|
||||
const positions = new Map<string, { first: boolean; last: boolean }>();
|
||||
siblings.forEach((items) => items.sort((a, b) => a.order - b.order).forEach((item, index) => positions.set(item.path, { first: index === 0, last: index === items.length - 1 })));
|
||||
return new Map(plan.map((item) => {
|
||||
const position = positions.get(item.path) ?? { first: true, last: true };
|
||||
return [item.path, { role: item.role, included: item.included, effective: effective.get(item.path) === true, reason: item.exclusionReason, order: item.order, ...position }];
|
||||
}));
|
||||
}
|
||||
|
||||
export function changedRowPaths(before: ReadonlyMap<string, RowSnapshot>, after: ReadonlyMap<string, RowSnapshot>): string[] {
|
||||
const changed: string[] = [];
|
||||
for (const [path, state] of after) if (!sameSnapshot(before.get(path), state)) changed.push(path);
|
||||
return changed;
|
||||
}
|
||||
|
||||
function sameSnapshot(left: RowSnapshot | undefined, right: RowSnapshot): boolean {
|
||||
return left !== undefined && left.role === right.role && left.included === right.included && left.effective === right.effective && left.reason === right.reason && left.order === right.order && left.first === right.first && left.last === right.last;
|
||||
}
|
||||
|
||||
function controlFor(record: RowRecord, control: ContentsControl): HTMLElement | undefined {
|
||||
if (control === "include") return record.include;
|
||||
if (control === "role") return record.select;
|
||||
if (control === "move-up") return record.up;
|
||||
if (control === "move-down") return record.down;
|
||||
return record.toggle;
|
||||
}
|
||||
|
|
|
|||
45
src/workspace/contents-tree-view-state.ts
Normal file
45
src/workspace/contents-tree-view-state.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import type { ContentPlanItem } from "../content-plan";
|
||||
|
||||
export type ContentsControl = "include" | "role" | "move-up" | "move-down" | "toggle";
|
||||
|
||||
export interface ContentsFocus {
|
||||
path: string;
|
||||
control: ContentsControl;
|
||||
}
|
||||
|
||||
/** UI-only state that must survive a Contents-step DOM rebuild. */
|
||||
export class ContentsTreeViewState {
|
||||
scrollTop = 0;
|
||||
focus?: ContentsFocus;
|
||||
private root = "";
|
||||
private readonly collapsed = new Set<string>();
|
||||
|
||||
prepare(root: string, plan: ContentPlanItem[]): void {
|
||||
if (root !== this.root) {
|
||||
this.root = root;
|
||||
this.scrollTop = 0;
|
||||
this.focus = undefined;
|
||||
this.collapsed.clear();
|
||||
}
|
||||
const folders = new Set(plan.filter((item) => item.kind === "folder").map((item) => item.path));
|
||||
for (const path of this.collapsed) if (!folders.has(path)) this.collapsed.delete(path);
|
||||
if (this.focus && !plan.some((item) => item.path === this.focus?.path)) this.focus = undefined;
|
||||
}
|
||||
|
||||
setScrollTop(value: number): void { this.scrollTop = Math.max(0, value); }
|
||||
setFocus(path: string, control: ContentsControl): void { this.focus = { path, control }; }
|
||||
isExpanded(path: string): boolean { return !this.collapsed.has(path); }
|
||||
collapse(path: string): void { this.collapsed.add(path); }
|
||||
toggle(path: string): void { if (this.collapsed.has(path)) this.collapsed.delete(path); else this.collapsed.add(path); }
|
||||
|
||||
isVisible(item: ContentPlanItem, byPath: ReadonlyMap<string, ContentPlanItem>): boolean {
|
||||
let parent = item.parentPath;
|
||||
while (parent !== this.root) {
|
||||
if (this.collapsed.has(parent)) return false;
|
||||
const ancestor = byPath.get(parent);
|
||||
if (!ancestor) break;
|
||||
parent = ancestor.parentPath;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,21 +1,36 @@
|
|||
import { Setting } from "obsidian";
|
||||
import { Setting, type DropdownComponent, type ToggleComponent } from "obsidian";
|
||||
import type { DocxStylePreset, StructuralDisplay } from "../settings";
|
||||
import type { CompileWorkspaceController } from "./compile-workspace-controller";
|
||||
|
||||
const sceneBreakValues = new Set(["#", "*", "***", "* * *", ""]);
|
||||
|
||||
export function renderFormattingStep(container: HTMLElement, controller: CompileWorkspaceController): void {
|
||||
const { formatting, request } = controller.state;
|
||||
let fontControl: DropdownComponent | undefined; let sizeControl: DropdownComponent | undefined; let spacingControl: DropdownComponent | undefined; let indentControl: DropdownComponent | undefined; let pageControl: DropdownComponent | undefined; let chapterBreakControl: ToggleComponent | undefined; let sceneControl: DropdownComponent | undefined; let customSetting: Setting | undefined; let customInput: HTMLInputElement | undefined;
|
||||
const effectiveSeparator = (): string => request.docxPreset === "vellum" ? "#" : request.docxPreset === "standard" ? "* * *" : request.custom?.sceneSeparator ?? "#";
|
||||
const syncPresetControls = (): void => {
|
||||
fontControl?.setValue(formatting.font); sizeControl?.setValue(String(formatting.fontSize)); spacingControl?.setValue(String(formatting.lineSpacing)); indentControl?.setValue(String(formatting.firstLineIndentCm)); pageControl?.setValue(formatting.pageSize); chapterBreakControl?.setValue(formatting.chapterPageBreak);
|
||||
const separator = effectiveSeparator(); sceneControl?.setValue(sceneBreakValues.has(separator) ? separator : "custom"); customSetting?.settingEl.toggleClass("is-hidden", sceneBreakValues.has(separator)); if (customInput && !sceneBreakValues.has(separator)) customInput.value = separator;
|
||||
};
|
||||
container.createEl("h2", { text: "Format your DOCX" }); container.createEl("p", { text: "These choices affect the generated Word document only. Your Markdown notes are never changed." });
|
||||
new Setting(container).setName("Document style").addDropdown((dropdown) => dropdown.addOption("vellum", "Vellum-ready manuscript").addOption("standard", "Standard manuscript").addOption("custom", "Custom").setValue(request.docxPreset).onChange((value) => controller.setDocxPreset(value as DocxStylePreset)));
|
||||
new Setting(container).setName("Document style").addDropdown((dropdown) => dropdown.addOption("vellum", "Vellum-ready manuscript").addOption("standard", "Standard manuscript").addOption("custom", "Custom").setValue(request.docxPreset).onChange((value) => { controller.setDocxPreset(value as DocxStylePreset); syncPresetControls(); }));
|
||||
new Setting(container).setName("Book title").addText((text) => text.setValue(request.custom?.variables?.BookTitle ?? "").onChange((value) => controller.setVariable("BookTitle", value)));
|
||||
new Setting(container).setName("Author").addText((text) => text.setValue(request.custom?.variables?.Author ?? "").onChange((value) => controller.setVariable("Author", value)));
|
||||
new Setting(container).setName("Font").addDropdown((dropdown) => dropdown.addOption("Times New Roman", "Times New Roman").addOption("Garamond", "Garamond").addOption("Georgia", "Georgia").addOption("Arial", "Arial").setValue(formatting.font).onChange((font) => controller.setFormatting({ font })));
|
||||
new Setting(container).setName("Font size").addDropdown((dropdown) => dropdown.addOption("11", "11 pt").addOption("12", "12 pt").addOption("13", "13 pt").setValue(String(formatting.fontSize)).onChange((value) => controller.setFormatting({ fontSize: Number(value) })));
|
||||
new Setting(container).setName("Line spacing").addDropdown((dropdown) => dropdown.addOption("1", "Single").addOption("1.15", "1.15").addOption("1.5", "1.5 lines").addOption("2", "Double").setValue(String(formatting.lineSpacing)).onChange((value) => controller.setFormatting({ lineSpacing: Number(value) })));
|
||||
new Setting(container).setName("First-line indent").addDropdown((dropdown) => dropdown.addOption("0", "None").addOption("0.25", "0.25 in").addOption("0.5", "0.5 in").setValue(String(formatting.firstLineIndent)).onChange((value) => controller.setFormatting({ firstLineIndent: Number(value) })));
|
||||
new Setting(container).setName("Page size").addDropdown((dropdown) => dropdown.addOption("letter", "US Letter").addOption("a4", "A4").setValue(formatting.pageSize).onChange((value) => controller.setFormatting({ pageSize: value === "a4" ? "a4" : "letter" })));
|
||||
new Setting(container).setName("Start chapters on a new page").addToggle((toggle) => toggle.setValue(formatting.chapterPageBreak).onChange((chapterPageBreak) => controller.setFormatting({ chapterPageBreak })));
|
||||
new Setting(container).setName("Add a title page").setDesc("Uses the book title and author entered on the Export step.").addToggle((toggle) => toggle.setValue(formatting.titlePage).onChange((titlePage) => controller.setFormatting({ titlePage })));
|
||||
new Setting(container).setName("Scene breaks").addDropdown((dropdown) => dropdown.addOption("#", "Centered * * *").addOption("***", "Three asterisks").addOption("", "Blank space only").setValue(request.custom?.sceneSeparator ?? "#").onChange((value) => controller.setSceneSeparator(value)));
|
||||
new Setting(container).setName("Font").addDropdown((dropdown) => { fontControl = dropdown; dropdown.addOption("Times New Roman", "Times New Roman").addOption("Garamond", "Garamond").addOption("Georgia", "Georgia").addOption("Arial", "Arial").setValue(formatting.font).onChange((font) => controller.setFormatting({ font })); });
|
||||
new Setting(container).setName("Font size").addDropdown((dropdown) => { sizeControl = dropdown; dropdown.addOption("11", "11 pt").addOption("12", "12 pt").addOption("13", "13 pt").setValue(String(formatting.fontSize)).onChange((value) => controller.setFormatting({ fontSize: Number(value) })); });
|
||||
new Setting(container).setName("Line spacing").addDropdown((dropdown) => { spacingControl = dropdown; dropdown.addOption("1", "Single").addOption("1.15", "1.15").addOption("1.5", "1.5 lines").addOption("2", "Double").setValue(String(formatting.lineSpacing)).onChange((value) => controller.setFormatting({ lineSpacing: Number(value) })); });
|
||||
new Setting(container).setName("First-line indent (cm)").addDropdown((dropdown) => { indentControl = dropdown; dropdown.addOption("0", "None").addOption("0.75", "0.75 cm").addOption("1.27", "1.27 cm").setValue(String(formatting.firstLineIndentCm)).onChange((value) => controller.setFormatting({ firstLineIndentCm: Number(value) })); });
|
||||
new Setting(container).setName("Page size").addDropdown((dropdown) => { pageControl = dropdown; dropdown.addOption("a4", "A4").addOption("letter", "Letter").setValue(formatting.pageSize).onChange((value) => controller.setFormatting({ pageSize: value === "letter" ? "letter" : "a4" })); });
|
||||
new Setting(container).setName("Start chapters on a new page").addToggle((toggle) => { chapterBreakControl = toggle; toggle.setValue(formatting.chapterPageBreak).onChange((chapterPageBreak) => controller.setFormatting({ chapterPageBreak })); });
|
||||
new Setting(container).setName("Add a title page").setDesc("Uses the book title and author entered above.").addToggle((toggle) => toggle.setValue(formatting.titlePage).onChange((titlePage) => controller.setFormatting({ titlePage })));
|
||||
const selectedSeparator = effectiveSeparator();
|
||||
const customSeparator = sceneBreakValues.has(selectedSeparator) ? "" : selectedSeparator;
|
||||
new Setting(container).setName("Scene break").addDropdown((dropdown) => { sceneControl = dropdown; dropdown.addOption("#", "#").addOption("*", "*").addOption("***", "***").addOption("* * *", "* * *").addOption("", "Blank line").addOption("custom", "Custom").setValue(sceneBreakValues.has(selectedSeparator) ? selectedSeparator : "custom").onChange((value) => {
|
||||
customSetting?.settingEl.toggleClass("is-hidden", value !== "custom");
|
||||
if (value !== "custom") controller.setSceneSeparator(value);
|
||||
}); });
|
||||
customSetting = new Setting(container).setName("Custom scene break").addText((text) => { customInput = text.inputEl; text.setPlaceholder("Enter separator text").setValue(customSeparator).onChange((value) => controller.setSceneSeparator(value)); });
|
||||
customSetting.settingEl.toggleClass("is-hidden", sceneBreakValues.has(selectedSeparator));
|
||||
displayChoice(container, "Part headings", request.partDisplay ?? "word-title", (value) => controller.setDisplay("part", value)); displayChoice(container, "Chapter headings", request.chapterDisplay ?? "word-title", (value) => controller.setDisplay("chapter", value));
|
||||
new Setting(container).setName("Include table of contents").setDesc("Adds a real Word TOC field; update the field in Word to populate it.").addToggle((toggle) => toggle.setValue(request.tableOfContents === true).onChange((value) => controller.setTableOfContents(value)));
|
||||
const advanced = container.createEl("details"); advanced.createEl("summary", { text: "Advanced options" }); new Setting(advanced).setName("Manuscript body headings").setDesc("If a note contains one of these headings, only that section is exported.").addText((text) => text.setValue(request.custom?.bodySectionAliases?.join(", ") ?? "Scene, Manuscript, Text, Draft, Body").onChange((value) => controller.setBodyAliases(value.split(",").map((item) => item.trim()).filter(Boolean))));
|
||||
|
|
|
|||
|
|
@ -3,14 +3,15 @@ import { STRUCTURE_PRESET_NAMES } from "../simple-workflow";
|
|||
import type { StructurePreset } from "../settings";
|
||||
import type { CompileWorkspaceController } from "./compile-workspace-controller";
|
||||
|
||||
export interface ManuscriptStepActions { chooseFolder(): void; useCurrentFolder(): void; changed(): void; }
|
||||
export interface ManuscriptStepActions { selectedFromFileExplorer?: boolean; chooseFolder(): void; useCurrentFolder(): void; changed(): void; }
|
||||
export function renderManuscriptStep(container: HTMLElement, controller: CompileWorkspaceController, folder: TFolder | null, actions: ManuscriptStepActions): void {
|
||||
const state = controller.state;
|
||||
container.createEl("h2", { text: "Select your manuscript" });
|
||||
container.createEl("p", { text: "Choose the vault folder that contains the notes for this book." });
|
||||
if (actions.selectedFromFileExplorer) container.createEl("p", { cls: "manuscript-root-source", text: "Selected from the File Explorer. This folder is the book root." });
|
||||
const setting = new Setting(container).setName("Manuscript folder").setDesc("The plugin will scan this folder without changing your notes.");
|
||||
setting.addText((text) => { text.setPlaceholder("Books/My Novel").setValue(state.request.manuscriptRoot).onChange((value) => { controller.setRoot(value); actions.changed(); }); text.inputEl.setAttribute("aria-label", "Manuscript folder path"); });
|
||||
setting.addButton((button) => button.setButtonText("Choose").setCta().onClick(actions.chooseFolder));
|
||||
setting.addButton((button) => button.setButtonText(actions.selectedFromFileExplorer ? "Change folder" : "Choose").setCta().onClick(actions.chooseFolder));
|
||||
setting.addButton((button) => button.setButtonText("Use current folder").onClick(actions.useCurrentFolder));
|
||||
new Setting(container).setName("Book structure").setDesc("Choose the closest match. You can correct every item on the next step.").addDropdown((dropdown) => { Object.entries(STRUCTURE_PRESET_NAMES).forEach(([value, label]) => dropdown.addOption(value, label)); dropdown.setValue(state.request.structurePreset).onChange((value) => { controller.setPreset(value as StructurePreset); actions.changed(); }); });
|
||||
if (folder && state.scannedRoot === folder.path && state.contentPlan.length) { const notes = state.contentPlan.filter((item) => item.kind === "note").length; const folders = state.contentPlan.filter((item) => item.kind === "folder").length; const card = container.createDiv({ cls: "manuscript-found-card" }); card.createEl("h3", { text: "Manuscript found" }); card.createEl("strong", { text: folder.name }); card.createEl("p", { text: `${notes} Markdown note${notes === 1 ? "" : "s"} in ${folders} folder${folders === 1 ? "" : "s"}.` }); card.createEl("p", { text: "Next, check what each folder and note represents." }); }
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@
|
|||
.manuscript-content-name > span:nth-child(2) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.manuscript-content-name small { grid-column: 2; color: var(--text-muted); }
|
||||
.manuscript-content-icon { color: var(--text-muted); }
|
||||
.manuscript-content-name button.manuscript-content-icon { width: 18px; height: 18px; padding: 0; border: 0; box-shadow: none; background: transparent; }
|
||||
.manuscript-order-buttons { display: flex; gap: var(--size-4-1); }
|
||||
.manuscript-order-buttons button { width: 32px; height: 32px; }
|
||||
.manuscript-export-summary { display: grid; grid-template-columns: max-content 1fr; gap: var(--size-4-2) var(--size-4-5); padding: var(--size-4-4); border-radius: var(--radius-m); background: var(--background-secondary); }
|
||||
|
|
|
|||
|
|
@ -8,12 +8,13 @@ import { createDefaultProfiles } from "../src/profiles";
|
|||
import { loadFixtureTree } from "./fixture-loader";
|
||||
import { validateDocxBytes } from "../src/docx-validator";
|
||||
import { SafeBinaryWriter, type SafeBinaryBackend } from "../src/safe-binary-writer";
|
||||
import { centimetresToTwips } from "../src/measurements";
|
||||
|
||||
const loaded = await loadFixtureTree("samples/Book 1 - Warden of Silence");
|
||||
const profile = createDefaultProfiles()[1]; profile.useParts = true; profile.chapterSource = "folders"; profile.sceneSeparator = "#"; profile.partDisplay = "word-title"; profile.chapterDisplay = "word-title"; profile.bodySectionAliases = ["Scene", "Manuscript", "Text", "Draft", "Body"];
|
||||
const session = await new CompilePreparationService(loaded.vault as never, profile, 250).prepareAuthoritative({ manuscriptRoot: loaded.root.path, profile, structurePreset: "novel-parts", purpose: "compile", route: "legacy-profile" });
|
||||
const { book } = session;
|
||||
const baseOptions: DocxOptions = { title: "Warden of Silence", author: "Anthony Fitzpatrick", titlePage: true, font: "Times New Roman", fontSize: 12, lineSpacing: 2, firstLineIndent: 0.5, partDisplay: "word-title", chapterDisplay: "word-title", chapterPageBreak: true };
|
||||
const baseOptions: DocxOptions = { title: "Warden of Silence", author: "Anthony Fitzpatrick", titlePage: true, font: "Times New Roman", fontSize: 12, lineSpacing: 2, firstLineIndentCm: 1.27, partDisplay: "word-title", chapterDisplay: "word-title", chapterPageBreak: true };
|
||||
const bytes = createManuscriptDocx(book, profile, baseOptions);
|
||||
assert.equal(validateDocxBytes(bytes).valid, true);
|
||||
const backend: SafeBinaryBackend = { kind: "filesystem", exists: async (value) => { try { await stat(value); return true; } catch { return false; } }, read: async (value) => new Uint8Array(await readFile(value)), write: async (value, data) => { await writeFile(value, data); }, rename: async (from, to) => { await rename(from, to); }, remove: async (value) => { await rm(value, { force: true }); }, list: async (folder) => (await readdir(folder)).map((name) => ({ path: path.join(folder, name), mtime: 0 })) };
|
||||
|
|
@ -29,7 +30,7 @@ assert.doesNotMatch(styles, /w:styleId="Subtitle"/);
|
|||
assert.match(document, /w:pStyle w:val="Title"[\s\S]*?Warden of Silence/); assert.match(document, /w:pStyle w:val="Author"[\s\S]*?Anthony Fitzpatrick/);
|
||||
assert.match(document, /w:pStyle w:val="PartNumber"[\s\S]*?Part One/); assert.match(document, /w:pStyle w:val="PartTitle"[\s\S]*?The Silence Breaks/);
|
||||
assert.match(document, /w:pStyle w:val="ChapterNumber"[\s\S]*?Chapter One/); assert.match(document, /w:pStyle w:val="ChapterTitle"[\s\S]*?The Silence of Östersund/);
|
||||
assert.match(document, /w:pStyle w:val="SceneBreak"[\s\S]*?\* \* \*/); assert.match(document, /w:pStyle w:val="FirstParagraph"[\s\S]*?Östersund was silent/); assert.match(document, /w:pStyle w:val="BodyText"[\s\S]*?The barn answered/);
|
||||
assert.match(document, /w:pStyle w:val="SceneBreak"[\s\S]*?#/); assert.match(document, /w:pStyle w:val="FirstParagraph"[\s\S]*?Östersund was silent/); assert.match(document, /w:pStyle w:val="BodyText"[\s\S]*?The barn answered/);
|
||||
assert.match(document, /winter stars—too silent/); assert.match(document, /“Listen,”/); assert.match(document, /Östersund/);
|
||||
for (const forbidden of ["Part: Archive", "Part: Development", "Part: Exports", "Part 0: Manuscript", "Chapter 0: Book 1 - Warden of Silence", "Warden of Silence Dashboard", "The Watchers of Silence Series Dashboard", "Revision Notes", "Internal synopsis", "Editing Status", "Character Notes", "Previous Export"]) assert.doesNotMatch(document, new RegExp(forbidden.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i"), forbidden);
|
||||
assert.ok(document.indexOf("Copyright ©") < document.indexOf("Part One")); assert.ok(document.indexOf("Part Two") < document.indexOf("With gratitude"));
|
||||
|
|
@ -77,6 +78,7 @@ const numberOnlyPartXml = documentFor({}, profile, { ...book, frontMatter: { ...
|
|||
assert.equal(paragraphs(numberOnlyPartXml, "PartNumber").length, 1);
|
||||
assert.equal(paragraphs(numberOnlyPartXml, "PartTitle").length, 0);
|
||||
|
||||
for (const separator of ["#", "*", "***", "* * *"] as const) { const sceneBreak = paragraphs(documentFor({ sceneSeparator: separator }), "SceneBreak"); assert.equal(sceneBreak.length, 1); assert.equal(paragraphText(sceneBreak[0]), separator); }
|
||||
assert.equal(paragraphs(documentFor({ sceneSeparator: "§ & <雪>" }), "SceneBreak").length, 1);
|
||||
assert.match(documentFor({ sceneSeparator: "§ & <雪>" }), /§ & <雪>/);
|
||||
const blankBreak = paragraphs(documentFor({ sceneSeparator: "" }), "SceneBreak");
|
||||
|
|
@ -109,13 +111,18 @@ assert.doesNotMatch(documentFor({ tableOfContents: false }), /w:instrText[^>]*>
|
|||
|
||||
assert.match(documentFor({ pageSize: "letter" }), /w:pgSz w:w="12240" w:h="15840"/);
|
||||
assert.match(documentFor({ pageSize: "a4" }), /w:pgSz w:w="11906" w:h="16838"/);
|
||||
const customStyles = stylesFor({ font: "Georgia", fontSize: 13, lineSpacing: 1.5, firstLineIndent: 0.25 });
|
||||
const customStyles = stylesFor({ font: "Georgia", fontSize: 13, lineSpacing: 1.5, firstLineIndentCm: 0.635 });
|
||||
assert.match(customStyles, /w:rFonts w:ascii="Georgia" w:hAnsi="Georgia"/);
|
||||
assert.match(customStyles, /w:sz w:val="26"/);
|
||||
assert.match(customStyles, /w:spacing w:after="0" w:line="360" w:lineRule="auto"/);
|
||||
assert.match(customStyles, /w:styleId="BodyText"[\s\S]*?w:ind w:firstLine="360"/);
|
||||
assert.match(customStyles, /w:styleId="FirstParagraph"[\s\S]*?w:ind w:firstLine="0"/);
|
||||
assert.deepEqual(resolveDocxOptions({ title: "x", author: "y", font: "", fontSize: 100, lineSpacing: -2, firstLineIndent: 9, pageSize: "a4" }), { title: "x", author: "y", font: "Times New Roman", fontSize: 24, lineSpacing: 0.8, firstLineIndent: 1.5, pageSize: "a4", chapterPageBreak: true, titlePage: false, tableOfContents: false });
|
||||
assert.match(stylesFor({ firstLineIndentCm: 0.75 }), /w:styleId="BodyText"[\s\S]*?w:ind w:firstLine="425"/);
|
||||
assert.match(stylesFor({ firstLineIndentCm: 1.27 }), /w:styleId="BodyText"[\s\S]*?w:ind w:firstLine="720"/);
|
||||
assert.equal(centimetresToTwips(0.75), 425); assert.equal(centimetresToTwips(1.27), 720); assert.equal(centimetresToTwips(2.54), 1440);
|
||||
assert.match(documentFor({ pageSize: "a4" }), /w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440"/);
|
||||
assert.deepEqual(resolveDocxOptions({ title: "x", author: "y", font: "", fontSize: 100, lineSpacing: -2, firstLineIndentCm: 9, pageSize: "a4" }), { title: "x", author: "y", font: "Times New Roman", fontSize: 24, lineSpacing: 0.8, firstLineIndentCm: 3.81, pageSize: "a4", chapterPageBreak: true, titlePage: false, tableOfContents: false });
|
||||
assert.equal(resolveDocxOptions({ title: "x", author: "y" }).pageSize, "a4");
|
||||
|
||||
const richScene = { ...book.parts[0].chapters[0].scenes[0], content: "**bold** *italic* ***both*** [readable link](https://example.invalid) `code` & <angle> “smart”—Östersund 雪" };
|
||||
const richBook = { ...book, parts: [{ ...book.parts[0], chapters: [{ ...book.parts[0].chapters[0], scenes: [richScene] }] }] };
|
||||
|
|
@ -126,4 +133,16 @@ assert.match(richXml, /<w:b\/><w:i\/>[\s\S]*?both/);
|
|||
assert.match(richXml, /readable link/);
|
||||
assert.doesNotMatch(richXml, /example\.invalid/);
|
||||
assert.match(richXml, /& <angle> “smart”—Östersund 雪/);
|
||||
|
||||
const realVault = await loadFixtureTree("tests/fixtures/real-vault/Book 1 - Warden of Silence");
|
||||
const realProfile = createDefaultProfiles()[1]; realProfile.sceneSeparator = "#"; realProfile.partDisplay = "word-title"; realProfile.chapterDisplay = "word-title";
|
||||
const realSession = await new CompilePreparationService(realVault.vault as never, realProfile, 250).prepareAuthoritative({ manuscriptRoot: realVault.root.path, profile: realProfile, structurePreset: "novel-parts", purpose: "compile", route: "selected-folder" });
|
||||
assert.equal(realSession.book.parts.length, 1); assert.equal(realSession.statistics.chapterCount, 1); assert.equal(realSession.statistics.sceneCount, 3); assert.equal(realSession.book.orphanScenes.length, 0); assert.equal(realSession.book.parts[0].orphanScenes.length, 0);
|
||||
const realBytes = createManuscriptDocx(realSession.book, realSession.profile, { title: "Warden of Silence", author: "Anthony Fitzpatrick", titlePage: true, sceneSeparator: "#", partDisplay: "word-title", chapterDisplay: "word-title" });
|
||||
assert.equal(validateDocxBytes(realBytes).valid, true);
|
||||
const realDocument = strFromU8(unzipSync(realBytes)["word/document.xml"]);
|
||||
assert.match(realDocument, /w:pStyle w:val="PartNumber"[\s\S]*?Part One/); assert.match(realDocument, /w:pStyle w:val="ChapterNumber"[\s\S]*?Chapter One/);
|
||||
assert.ok(realDocument.indexOf("Copyright ebook edition") < realDocument.indexOf("Part One")); assert.ok(realDocument.indexOf("The answer arrived without warning") < realDocument.indexOf("About the Author"));
|
||||
for (const matter of ["About the Author", "Acknowledgments", "Also by Anthony Fitzpatrick", "Back Cover Blurb"]) { const paragraph = [...realDocument.matchAll(/<w:p[\s\S]*?<\/w:p>/g)].find((match) => match[0].includes(matter))?.[0] ?? ""; assert.doesNotMatch(paragraph, /w:pStyle w:val="(?:PartNumber|ChapterNumber)"/, matter); }
|
||||
assert.doesNotMatch(realDocument, />Manuscript<|>Front and back matter<|>Copyright notices<|Revision Notes|Internal summary/);
|
||||
process.stdout.write(`Built-in Warden regression DOCX passed (${bytes.length.toLocaleString()} bytes); wrote .test-build/Warden-of-Silence-regression.docx.\n`);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
# A note from Elin
|
||||
|
||||
A short note for readers.
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# About the Author
|
||||
|
||||
Anthony writes speculative fiction.
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# Acknowledgments
|
||||
|
||||
With gratitude to the readers.
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# Also by Anthony Fitzpatrick
|
||||
|
||||
Other books by the author.
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# Back Cover Blurb
|
||||
|
||||
A restrained first-contact novel.
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# Copyright
|
||||
|
||||
Copyright hardcover edition.
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# Copyright
|
||||
|
||||
Copyright paperback edition.
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# Copyright
|
||||
|
||||
Copyright ebook edition.
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
---
|
||||
Part: Part 1 - The Silence Breaks
|
||||
Chapter: Chapter 1 - The Silence of Östersund
|
||||
Scene: 1
|
||||
---
|
||||
|
||||
# Scene
|
||||
|
||||
Östersund was silent beneath the winter sky.
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
# Synopsis
|
||||
|
||||
Internal summary.
|
||||
|
||||
# Scene
|
||||
|
||||
Henrik listened to the frozen radio.
|
||||
|
||||
# Revision Notes
|
||||
|
||||
Internal revision note.
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# Scene
|
||||
|
||||
The answer arrived without warning.
|
||||
187
tests/run.ts
187
tests/run.ts
|
|
@ -24,7 +24,7 @@ import { CompilationCancelledError } from "../src/cancellation";
|
|||
import { DOCX_FORMATTING_PRESETS, docxFormattingForPreset, inferStructurePreset, resolveSimpleCompileRequest, validateSimpleCompileRequest } from "../src/simple-workflow";
|
||||
import type { SimpleCompileRequest } from "../src/simple-workflow";
|
||||
import { canProceedWithExport } from "../src/export-safety";
|
||||
import { applyContentPlan, classifyContentPlan, createContentPlan } from "../src/content-plan";
|
||||
import { applyContentPlan, classifyContentPlan, createContentPlan, type ContentPlanItem } from "../src/content-plan";
|
||||
import { calculateSourceFingerprint, compileInputSignature, CompilePreparationService, createPreparedExportRequest, preparedSessionMatchesInputs, sessionMatchesBook } from "../src/compile-preparation";
|
||||
import { createManuscriptDocx } from "../src/docx";
|
||||
import { strFromU8, unzipSync } from "fflate";
|
||||
|
|
@ -33,16 +33,20 @@ import { BookRootResolver } from "../src/book-root-resolver";
|
|||
import { CompileWorkspaceController } from "../src/workspace/compile-workspace-controller";
|
||||
import { buildExportPreviewViewModel } from "../src/workspace/export-preview";
|
||||
import { isEffectivelyIncluded, moveSibling, setItemIncluded } from "../src/workspace/content-tree";
|
||||
import { changedRowPaths, snapshotRows } from "../src/workspace/contents-step";
|
||||
import { ContentsTreeViewState } from "../src/workspace/contents-tree-view-state";
|
||||
import { OperationStateController } from "../src/operation-state";
|
||||
import { CompileHistoryService } from "../src/compile-history";
|
||||
import { ResultActionService } from "../src/result-actions";
|
||||
import { createTestDocx } from "./docx-test-fixture";
|
||||
import { centimetresToInches, centimetresToTwips, clampCentimetres, inchesToCentimetres, twipsToCentimetres } from "../src/measurements";
|
||||
import { addCompileFolderMenuItem, COMPILE_FOLDER_MENU_TITLE } from "../src/folder-context-menu";
|
||||
|
||||
const tests: Array<[string, () => void | Promise<void>]> = [];
|
||||
const test = (name: string, action: () => void | Promise<void>): void => { tests.push([name, action]); };
|
||||
async function sourceFiles(folder: string): Promise<string[]> { const entries = await readdir(folder, { withFileTypes: true }); const nested = await Promise.all(entries.map((entry) => entry.isDirectory() ? sourceFiles(path.join(folder, entry.name)) : Promise.resolve(entry.name.endsWith(".ts") ? [path.join(folder, entry.name)] : []))); return nested.flat(); }
|
||||
const file = (name: string, content: string, metadata: Record<string, unknown> = {}): ManuscriptDocument => ({ file: { name: `${name}.md`, basename: name, path: `Book/${name}.md` } as never, title: name, number: extractNumber(name), metadata: { values: metadata }, rawContent: content, content, excluded: false });
|
||||
const preparedRequest = (root: string, plan: ReturnType<typeof createContentPlan>): SimpleCompileRequest => ({ manuscriptRoot: root, structurePreset: "novel-parts", includeFrontMatter: true, includeBackMatter: true, exportFolder: "Manuscript Exports", outputFilename: "Warden of Silence.docx", outputFormat: "docx", docxPreset: "vellum", contentPlan: plan, formatting: { font: "Times New Roman", fontSize: 12, lineSpacing: 2, firstLineIndent: 0.5, pageSize: "letter", chapterPageBreak: true, titlePage: true }, tableOfContents: false, partDisplay: "word-title", chapterDisplay: "word-title", custom: { variables: { BookTitle: "Warden of Silence", Author: "Anthony Fitzpatrick" }, bodySectionAliases: ["Scene", "Manuscript", "Text", "Draft", "Body"] } });
|
||||
const preparedRequest = (root: string, plan: ReturnType<typeof createContentPlan>): SimpleCompileRequest => ({ manuscriptRoot: root, structurePreset: "novel-parts", includeFrontMatter: true, includeBackMatter: true, exportFolder: "Manuscript Exports", outputFilename: "Warden of Silence.docx", outputFormat: "docx", docxPreset: "vellum", contentPlan: plan, formatting: { font: "Times New Roman", fontSize: 12, lineSpacing: 2, firstLineIndentCm: 1.27, pageSize: "a4", chapterPageBreak: true, titlePage: true }, tableOfContents: false, partDisplay: "word-title", chapterDisplay: "word-title", custom: { variables: { BookTitle: "Warden of Silence", Author: "Anthony Fitzpatrick" }, bodySectionAliases: ["Scene", "Manuscript", "Text", "Draft", "Body"] } });
|
||||
|
||||
test("template engine resolves known and unknown variables", () => assert.equal(new TemplateEngine().render("{BookTitle} — {Unknown}", { BookTitle: "North" }), "North —"));
|
||||
test("internal links retain visible text", () => assert.equal(stripInternalLinks("[[People/Elin|Elin Andersson]] met [[Noah]]."), "Elin Andersson met Noah."));
|
||||
|
|
@ -67,22 +71,33 @@ test("output paths reject traversal, absolute paths, and nonportable characters"
|
|||
test("blocking output safety errors disable export", () => { assert.equal(canProceedWithExport([{ severity: "error", code: "output-inside-root", message: "unsafe" }], "markdown", true), false); assert.equal(canProceedWithExport([{ severity: "warning", code: "output-exists", message: "confirm" }], "markdown", true), true); assert.equal(canProceedWithExport([], "docx", false), false); });
|
||||
test("native DOCX defaults require no desktop or Pandoc state", () => { assert.equal(DEFAULT_SETTINGS.defaultExportFormat, "docx"); assert.equal(DEFAULT_SETTINGS.defaultDocxStyle, "vellum"); assert.equal(DEFAULT_SETTINGS.automaticallyDetectPandoc, false); });
|
||||
for (const preset of ["novel-parts", "novel", "chapter-notes", "short-story", "anthology", "custom"] as const) test(`simple workflow resolves ${preset}`, () => { const base = createDefaultProfiles()[0]; const profile = resolveSimpleCompileRequest({ manuscriptRoot: "Books/Test", structurePreset: preset, includeFrontMatter: true, includeBackMatter: false, exportFolder: "Exports", outputFilename: "Test.docx", outputFormat: "docx", docxPreset: "vellum", custom: { useParts: false, chapterSource: "folders" } }, base); assert.equal(profile.manuscriptRoot, "Books/Test"); assert.equal(profile.exportFolder, "Exports"); assert.equal(profile.exportTarget, "docx"); assert.equal(profile.referenceDocx, ""); });
|
||||
test("Vellum preset applies safe cleaning and readable links", () => { const profile = resolveSimpleCompileRequest({ manuscriptRoot: "Book", structurePreset: "novel-parts", includeFrontMatter: true, includeBackMatter: true, exportFolder: "Exports", outputFilename: "Book.docx", outputFormat: "docx", docxPreset: "vellum" }, createDefaultProfiles()[0]); assert.equal(profile.removeDataviewBlocks, true); assert.equal(profile.removeCallouts, true); assert.equal(profile.stripInternalLinks, true); assert.equal(profile.generateTableOfContents, false); });
|
||||
test("Vellum preset applies safe cleaning and readable links", () => { const profile = resolveSimpleCompileRequest({ manuscriptRoot: "Book", structurePreset: "novel-parts", includeFrontMatter: true, includeBackMatter: true, exportFolder: "Exports", outputFilename: "Book.docx", outputFormat: "docx", docxPreset: "vellum" }, createDefaultProfiles()[0]); assert.equal(profile.removeDataviewBlocks, true); assert.equal(profile.removeCallouts, true); assert.equal(profile.stripInternalLinks, true); assert.equal(profile.generateTableOfContents, false); assert.equal(profile.sceneSeparator, "#"); assert.equal(profile.docxPageSize, "a4"); assert.equal(profile.docxFirstLineIndentCm, 0.75); });
|
||||
test("TOC selection survives preset resolution as a real compile-request choice", () => { const profile = resolveSimpleCompileRequest({ manuscriptRoot: "Book", structurePreset: "novel-parts", includeFrontMatter: true, includeBackMatter: true, exportFolder: "Exports", outputFilename: "Book.docx", outputFormat: "docx", docxPreset: "vellum", tableOfContents: true }, createDefaultProfiles()[0]); assert.equal(profile.generateTableOfContents, true); });
|
||||
test("Standard DOCX preset uses conventional filename ordering", () => { const profile = resolveSimpleCompileRequest({ manuscriptRoot: "Book", structurePreset: "novel", includeFrontMatter: true, includeBackMatter: true, exportFolder: "Exports", outputFilename: "Book.docx", outputFormat: "docx", docxPreset: "standard" }, createDefaultProfiles()[0]); assert.equal(profile.orderingMethod, "filename"); assert.equal(profile.stripYamlFrontmatter, true); });
|
||||
test("Standard DOCX preset uses conventional filename ordering", () => { const profile = resolveSimpleCompileRequest({ manuscriptRoot: "Book", structurePreset: "novel", includeFrontMatter: true, includeBackMatter: true, exportFolder: "Exports", outputFilename: "Book.docx", outputFormat: "docx", docxPreset: "standard" }, createDefaultProfiles()[0]); assert.equal(profile.orderingMethod, "filename"); assert.equal(profile.stripYamlFrontmatter, true); assert.equal(profile.sceneSeparator, "* * *"); assert.equal(profile.docxPageSize, "a4"); });
|
||||
test("Vellum, Standard, and Custom DOCX formatting resolve deterministically", () => {
|
||||
assert.deepEqual(docxFormattingForPreset("vellum"), DOCX_FORMATTING_PRESETS.vellum);
|
||||
assert.deepEqual(docxFormattingForPreset("standard"), DOCX_FORMATTING_PRESETS.standard);
|
||||
assert.deepEqual(docxFormattingForPreset("vellum"), docxFormattingForPreset("vellum"));
|
||||
const custom = { font: "Georgia", fontSize: 13, lineSpacing: 1.5, firstLineIndent: 0.25, pageSize: "a4" as const, chapterPageBreak: false, titlePage: true };
|
||||
const custom = { font: "Georgia", fontSize: 13, lineSpacing: 1.5, firstLineIndentCm: 0.75, pageSize: "a4" as const, chapterPageBreak: false, titlePage: true };
|
||||
assert.deepEqual(docxFormattingForPreset("custom", true, custom), custom);
|
||||
assert.equal(DOCX_FORMATTING_PRESETS.vellum.chapterPageBreak, true);
|
||||
assert.equal(DOCX_FORMATTING_PRESETS.standard.chapterPageBreak, true);
|
||||
assert.equal(DOCX_FORMATTING_PRESETS.standard.fontSize, 12);
|
||||
assert.equal(DOCX_FORMATTING_PRESETS.standard.lineSpacing, 2);
|
||||
assert.equal(DOCX_FORMATTING_PRESETS.vellum.pageSize, "a4"); assert.equal(DOCX_FORMATTING_PRESETS.standard.pageSize, "a4");
|
||||
});
|
||||
|
||||
test("metric formatting conversions are stable and constrained", () => {
|
||||
assert.equal(centimetresToTwips(0.75), 425); assert.equal(centimetresToTwips(1.27), 720); assert.equal(centimetresToTwips(2.54), 1440);
|
||||
assert.equal(inchesToCentimetres(0.5), 1.27); assert.equal(centimetresToInches(2.54), 1); assert.equal(twipsToCentimetres(720), 1.27);
|
||||
assert.equal(clampCentimetres(-1, 0, 3.81, 1.27), 0); assert.equal(clampCentimetres(99, 0, 3.81, 1.27), 3.81); assert.equal(clampCentimetres(Number.NaN, 0, 3.81, 1.27), 1.27);
|
||||
});
|
||||
test("scene-break preset selection is distinct and Custom retains its choice", () => {
|
||||
const root = "Book"; const plan: ContentPlanItem[] = [{ path: `${root}/Scene.md`, parentPath: root, name: "Scene", kind: "note", role: "scene", included: true, order: 0 }]; const request = preparedRequest(root, plan); request.custom!.sceneSeparator = "§";
|
||||
const controller = new CompileWorkspaceController(request, request.formatting!, { prepare: async () => { throw new Error(); }, sessionIsCurrent: async () => true, export: async () => undefined }); controller.setDocxPreset("vellum"); assert.equal(request.custom?.sceneSeparator, "§"); controller.setDocxPreset("standard"); assert.equal(request.custom?.sceneSeparator, "§"); controller.setDocxPreset("custom"); assert.equal(request.custom?.sceneSeparator, "§");
|
||||
});
|
||||
test("simple workflow validates missing folders and invalid filenames", () => { const request = { manuscriptRoot: "", structurePreset: "novel" as const, includeFrontMatter: true, includeBackMatter: true, exportFolder: "Exports", outputFilename: "bad/name.docx", outputFormat: "docx" as const, docxPreset: "standard" as const }; const errors = validateSimpleCompileRequest(request); assert.ok(errors.some((item) => /folder/i.test(item))); assert.ok(errors.some((item) => /filename/i.test(item))); });
|
||||
test("custom workflow preserves advanced profile options", () => { const base = createDefaultProfiles()[0]; base.metadataFilters = [{ id: "1", field: "POV", operator: "equals", value: "Elin" }]; const profile = resolveSimpleCompileRequest({ manuscriptRoot: "Book", structurePreset: "custom", includeFrontMatter: true, includeBackMatter: true, exportFolder: "Exports", outputFilename: "Book.docx", outputFormat: "docx", docxPreset: "standard", custom: { sceneSeparator: "***" } }, base); assert.equal(profile.sceneSeparator, "***"); assert.equal(profile.metadataFilters.length, 1); });
|
||||
test("custom workflow preserves advanced profile options", () => { const base = createDefaultProfiles()[0]; base.metadataFilters = [{ id: "1", field: "POV", operator: "equals", value: "Elin" }]; const profile = resolveSimpleCompileRequest({ manuscriptRoot: "Book", structurePreset: "custom", includeFrontMatter: true, includeBackMatter: true, exportFolder: "Exports", outputFilename: "Book.docx", outputFormat: "docx", docxPreset: "custom", custom: { sceneSeparator: "***" } }, base); assert.equal(profile.sceneSeparator, "***"); assert.equal(profile.metadataFilters.length, 1); });
|
||||
test("structure inference preserves existing layout", () => { const profile = createDefaultProfiles()[0]; profile.useParts = false; profile.chapterSource = "notes"; assert.equal(inferStructurePreset(profile), "chapter-notes"); });
|
||||
test("Markdown export uses documented vault APIs on non-filesystem adapters", async () => { const entries = new Map<string, unknown>(); const vault = { adapter: {}, getAbstractFileByPath: (value: string) => entries.get(value) ?? null, createFolder: async (value: string) => { entries.set(value, { path: value }); }, create: async (value: string, content: string) => { const created = Object.assign(new TFile(), { path: value, extension: "md", content }); entries.set(value, created); return created; }, modify: async (item: { content: string }, content: string) => { item.content = content; } } as never; const file = await new MarkdownExporter(vault).write("Exports/Test.md", "Body\n"); assert.equal((file as unknown as { content: string }).content, "Body\n"); });
|
||||
test("content cleaners cover YAML, comments, Dataview, callouts, links, embeds, blocks, and line endings", () => { assert.equal(stripYamlFrontmatter("---\r\ntitle: Test\r\n---\r\nBody"), "Body"); assert.equal(removeHtmlComments("A<!-- x -->B"), "AB"); assert.equal(removeObsidianComments("A%% x %%B"), "AB"); assert.equal(removeDataviewBlocks("A\n```dataviewjs\nx\n```\nB"), "A\nB"); assert.equal(removeCallouts("> [!note] Title\n> Body\n\n> Ordinary"), "Body\n\n> Ordinary"); assert.equal(stripInternalLinks("[[Note]] [[Folder/Note|Alias]] [[Note#^block]] ![[image.png]] ![[Embedded Note]]"), "Note Alias Note image.png Embedded Note"); });
|
||||
|
|
@ -110,15 +125,29 @@ for (const fixture of [
|
|||
test("0.7 settings migrate idempotently into the simplified model", () => { const profile = createDefaultProfiles()[0]; profile.manuscriptRoot = "Books/Existing"; profile.exportFolder = "Exports/Existing"; profile.outputFilename = "Existing.docx"; const legacy = { ...DEFAULT_SETTINGS, profiles: [profile], activeProfileId: profile.id, defaultProfileId: profile.id } as Record<string, unknown>; delete legacy.defaultStructurePreset; delete legacy.defaultDocxStyle; delete legacy.warnBeforeOverwrite; delete legacy.openAfterCompile; delete legacy.showAdvancedOptions; const once = repairSettings(legacy as never); const snapshot = JSON.stringify(once); const twice = repairSettings(once); assert.equal(JSON.stringify(twice), snapshot); assert.equal(once.profiles[0].manuscriptRoot, "Books/Existing"); assert.equal(once.profiles[0].exportFolder, "Exports/Existing"); assert.equal(once.profiles[0].outputFilename, "Existing.docx"); assert.equal(once.defaultStructurePreset, "novel-parts"); });
|
||||
test("saved Chapter page-break compatibility values survive repair", () => { const profile = { ...createDefaultProfiles()[0], docxChapterPageBreak: false }; const settings = repairSettings({ ...DEFAULT_SETTINGS, profiles: [profile], activeProfileId: profile.id, defaultProfileId: profile.id } as never); assert.equal(settings.profiles[0].docxChapterPageBreak, false); });
|
||||
test("0.9.1 settings migrate to 0.9.2 idempotently without losing author choices", () => {
|
||||
const profile = { ...createDefaultProfiles()[0], name: "My 0.9.1 Profile", manuscriptRoot: "Books/My Novel", exportFolder: "Exports/Books", outputFilename: "{BookTitle}.docx", variables: { BookTitle: "My Novel", Series: "My Series", Author: "A. Writer" }, metadataFilters: [{ id: "filter-1", field: "Status", operator: "not-equals" as const, value: "Excluded" }], stripInternalLinks: true, removeCallouts: false, bodySectionAliases: ["Scene", "Body"], docxFont: "Georgia", docxFontSize: 13, docxLineSpacing: 1.5, docxFirstLineIndent: 0.25, docxPageSize: "a4" as const, docxChapterPageBreak: false, docxTitlePage: true, generateTableOfContents: true };
|
||||
const profile = { ...createDefaultProfiles()[0], name: "My 0.9.1 Profile", manuscriptRoot: "Books/My Novel", exportFolder: "Exports/Books", outputFilename: "{BookTitle}.docx", variables: { BookTitle: "My Novel", Series: "My Series", Author: "A. Writer" }, metadataFilters: [{ id: "filter-1", field: "Status", operator: "not-equals" as const, value: "Excluded" }], stripInternalLinks: true, removeCallouts: false, bodySectionAliases: ["Scene", "Body"], docxFont: "Georgia", docxFontSize: 13, docxLineSpacing: 1.5, docxFirstLineIndentCm: undefined, docxFirstLineIndent: 0.25, docxPageSize: "a4" as const, docxChapterPageBreak: false, docxTitlePage: true, generateTableOfContents: true };
|
||||
const history = [{ id: "history-1", timestamp: "2026-07-01T10:00:00.000Z", profile: profile.name, manuscript: profile.manuscriptRoot, outputFiles: ["Exports/Books/My Novel.docx"], wordCount: 1234, success: true }];
|
||||
const persisted = { ...DEFAULT_SETTINGS, profiles: [profile], activeProfileId: profile.id, defaultProfileId: profile.id, defaultManuscriptFolder: profile.manuscriptRoot, defaultExportFolder: profile.exportFolder, defaultStructurePreset: "novel-parts" as const, defaultDocxStyle: "standard" as const, includeTitlePageByDefault: true, includeTableOfContentsByDefault: true, exportHistory: history, compileLogs: [], showAdvancedOptions: true };
|
||||
const once = repairSettings(structuredClone(persisted)); const snapshot = JSON.stringify(once); const twice = repairSettings(once);
|
||||
assert.equal(JSON.stringify(twice), snapshot); const migrated = twice.profiles[0];
|
||||
assert.deepEqual({ root: migrated.manuscriptRoot, folder: migrated.exportFolder, filename: migrated.outputFilename, variables: migrated.variables, filters: migrated.metadataFilters, font: migrated.docxFont, size: migrated.docxFontSize, spacing: migrated.docxLineSpacing, indent: migrated.docxFirstLineIndent, page: migrated.docxPageSize, chapterBreak: migrated.docxChapterPageBreak, titlePage: migrated.docxTitlePage, toc: migrated.generateTableOfContents }, { root: profile.manuscriptRoot, folder: profile.exportFolder, filename: profile.outputFilename, variables: profile.variables, filters: profile.metadataFilters, font: "Georgia", size: 13, spacing: 1.5, indent: 0.25, page: "a4", chapterBreak: false, titlePage: true, toc: true });
|
||||
assert.deepEqual({ root: migrated.manuscriptRoot, folder: migrated.exportFolder, filename: migrated.outputFilename, variables: migrated.variables, filters: migrated.metadataFilters, font: migrated.docxFont, size: migrated.docxFontSize, spacing: migrated.docxLineSpacing, indentCm: migrated.docxFirstLineIndentCm, page: migrated.docxPageSize, chapterBreak: migrated.docxChapterPageBreak, titlePage: migrated.docxTitlePage, toc: migrated.generateTableOfContents }, { root: profile.manuscriptRoot, folder: profile.exportFolder, filename: profile.outputFilename, variables: profile.variables, filters: profile.metadataFilters, font: "Georgia", size: 13, spacing: 1.5, indentCm: 0.635, page: "a4", chapterBreak: false, titlePage: true, toc: true });
|
||||
assert.equal(twice.activeProfileId, profile.id); assert.equal(twice.defaultProfileId, profile.id); assert.equal(twice.exportHistory[0].id, "history-1"); assert.equal(twice.showAdvancedOptions, true);
|
||||
});
|
||||
test("first-run defaults are DOCX, Vellum, previewed, and overwrite-safe", () => { assert.equal(DEFAULT_SETTINGS.defaultExportFormat, "docx"); assert.equal(DEFAULT_SETTINGS.defaultDocxStyle, "vellum"); assert.equal(DEFAULT_SETTINGS.showPreview, true); assert.equal(DEFAULT_SETTINGS.warnBeforeOverwrite, true); assert.equal(DEFAULT_SETTINGS.showAdvancedOptions, false); });
|
||||
test("first-run defaults are DOCX, Vellum, A4, metric, previewed, and overwrite-safe", () => { assert.equal(DEFAULT_SETTINGS.defaultExportFormat, "docx"); assert.equal(DEFAULT_SETTINGS.defaultDocxStyle, "vellum"); assert.equal(DEFAULT_SETTINGS.defaultDocxPageSize, "a4"); assert.equal(DEFAULT_SETTINGS.defaultDocxFirstLineIndentCm, 0.75); assert.equal(DEFAULT_SETTINGS.showPreview, true); assert.equal(DEFAULT_SETTINGS.warnBeforeOverwrite, true); assert.equal(DEFAULT_SETTINGS.showAdvancedOptions, false); const profiles = createDefaultProfiles(); assert.deepEqual(profiles.map((profile) => profile.docxPageSize), ["a4", "a4"]); assert.deepEqual(profiles.map((profile) => profile.docxFirstLineIndentCm), [1.27, 0.75]); assert.deepEqual(profiles.map((profile) => profile.sceneSeparator), ["#", "#"]); });
|
||||
|
||||
test("legacy inch indentation and explicit Letter page size migrate once without drift", () => {
|
||||
const profile = createDefaultProfiles()[0]; delete profile.docxFirstLineIndentCm; profile.docxFirstLineIndent = 0.5; profile.docxPageSize = "letter";
|
||||
const once = repairSettings({ ...DEFAULT_SETTINGS, profiles: [profile], activeProfileId: profile.id, defaultProfileId: profile.id } as never); const snapshot = JSON.stringify(once); const twice = repairSettings(once);
|
||||
assert.equal(once.profiles[0].docxFirstLineIndentCm, 1.27); assert.equal(once.profiles[0].docxPageSize, "letter"); assert.equal(once.defaultDocxFirstLineIndentCm, 1.27); assert.equal(once.defaultDocxPageSize, "letter"); assert.equal(JSON.stringify(twice), snapshot);
|
||||
});
|
||||
test("older Vellum profiles without formatting fields receive metric A4 defaults", () => {
|
||||
const profile = createDefaultProfiles()[1]; delete profile.docxFirstLineIndentCm; delete profile.docxFirstLineIndent; delete profile.docxPageSize;
|
||||
const repaired = repairSettings({ ...DEFAULT_SETTINGS, defaultDocxStyle: "vellum", profiles: [profile], activeProfileId: profile.id, defaultProfileId: profile.id } as never); assert.equal(repaired.profiles[0].docxFirstLineIndentCm, 0.75); assert.equal(repaired.profiles[0].docxPageSize, "a4"); assert.equal(repaired.defaultDocxFirstLineIndentCm, 0.75); assert.equal(repaired.defaultDocxPageSize, "a4");
|
||||
});
|
||||
test("invalid metric indentation settings are repaired to supported bounds", () => {
|
||||
const negative = createDefaultProfiles()[0]; negative.docxFirstLineIndentCm = -2; const repairedNegative = repairSettings({ ...DEFAULT_SETTINGS, profiles: [negative], activeProfileId: negative.id, defaultProfileId: negative.id } as never); assert.equal(repairedNegative.profiles[0].docxFirstLineIndentCm, 0);
|
||||
const excessive = createDefaultProfiles()[0]; excessive.docxFirstLineIndentCm = 20; const repairedExcessive = repairSettings({ ...DEFAULT_SETTINGS, profiles: [excessive], activeProfileId: excessive.id, defaultProfileId: excessive.id } as never); assert.equal(repairedExcessive.profiles[0].docxFirstLineIndentCm, 3.81);
|
||||
});
|
||||
test("simple workflow output path uses the requested folder and filename", () => { const exporter = new MarkdownExporter({} as never); assert.equal(exporter.getOutputPath("Manuscript Exports", "Warden of Silence.docx", {}, ".docx"), "Manuscript Exports/Warden of Silence.docx"); });
|
||||
test("existing-output warnings do not block overwrite handling", () => { assert.equal(canProceedWithExport([{ severity: "warning", code: "output-exists", message: "Already exists" }], "docx", true), true); });
|
||||
test("native DOCX generation has no platform or executable prerequisite", () => { const bytes = createTestDocx("Text", "Book"); assert.equal(String.fromCharCode(bytes[0], bytes[1]), "PK"); });
|
||||
|
|
@ -157,7 +186,86 @@ test("guided preparation keeps explicit inclusion, role, and manual order author
|
|||
|
||||
test("book-root resolution and command identifiers are central and stable", async () => {
|
||||
const root = Object.assign(new TFolder(), { name: "Book", path: "Books/Book", children: [] }); const vault = { getAbstractFileByPath: (value: string) => value === root.path ? root : null } as never;
|
||||
assert.equal(new BookRootResolver(vault).require(root.path), root); assert.deepEqual(Object.values(COMMAND_IDS), ["compile-manuscript", "compile-current-book", "compile-selected-folder", "validate-manuscript", "generate-diagnostics-report"]); const main = await readFile(path.join("src", "main.ts"), "utf8"); for (const title of ["Compile Manuscript", "Compile Current Book", "Compile Selected Folder", "Validate Manuscript", "Generate Diagnostics Report"]) assert.match(main, new RegExp(`name: "${title}"`));
|
||||
assert.equal(new BookRootResolver(vault).require(root.path), root); assert.equal(new BookRootResolver(vault).selected(root), root); assert.deepEqual(Object.values(COMMAND_IDS), ["compile-manuscript", "compile-current-book", "compile-selected-folder", "validate-manuscript", "generate-diagnostics-report"]); const main = await readFile(path.join("src", "main.ts"), "utf8"); for (const title of ["Compile Manuscript", "Compile Current Book", "Compile Selected Folder", "Validate Manuscript", "Generate Diagnostics Report"]) assert.match(main, new RegExp(`name: "${title}"`)); assert.match(main, /workspace\.on\("file-menu"/); assert.match(main, /openCompilerForFolder\(folder/);
|
||||
});
|
||||
|
||||
test("File Explorer context action is folder-only and preserves the exact clicked root", () => {
|
||||
const folder = Object.assign(new TFolder(), { name: "Book 1 - Warden of Silence", path: "Library/Book 1 - Warden of Silence", children: [] });
|
||||
const note = Object.assign(new TFile(), { name: "Scene.md", path: `${folder.path}/Scene.md` });
|
||||
let title = ""; let icon = ""; let click: (() => void) | undefined; let opened: TFolder | undefined;
|
||||
const menu = { addItem: (build: (item: unknown) => void) => { const item = { setTitle: (value: string) => { title = value; return item; }, setIcon: (value: string) => { icon = value; return item; }, onClick: (value: () => void) => { click = value; return item; } }; build(item); } } as never;
|
||||
assert.equal(addCompileFolderMenuItem(menu, note as never, (selected) => { opened = selected; }), false);
|
||||
assert.equal(title, "");
|
||||
assert.equal(addCompileFolderMenuItem(menu, folder as never, (selected) => { opened = selected; }), true);
|
||||
assert.equal(title, COMPILE_FOLDER_MENU_TITLE); assert.equal(icon, "book-open"); click?.(); assert.equal(opened, folder);
|
||||
});
|
||||
|
||||
test("mixed matter aliases and copyright containers never infer Chapter roles", () => {
|
||||
const copyright = Object.assign(new TFile(), { name: "Copyright - eBook version - First Edition.md", basename: "Copyright - eBook version - First Edition", extension: "md", path: "Book/Font and back matter/Copyright notices/Copyright - eBook version - First Edition.md" });
|
||||
const notices = Object.assign(new TFolder(), { name: "Copyright notices", path: "Book/Font and back matter/Copyright notices", children: [copyright] });
|
||||
const about = Object.assign(new TFile(), { name: "About the Author.md", basename: "About the Author", extension: "md", path: "Book/Font and back matter/About the Author.md" });
|
||||
const matter = Object.assign(new TFolder(), { name: "Font and back matter", path: "Book/Font and back matter", children: [about, notices] });
|
||||
const root = Object.assign(new TFolder(), { name: "Book", path: "Book", children: [matter] });
|
||||
const plan = createContentPlan(root, "novel-parts"); const role = (name: string) => plan.find((item) => item.name === name)?.role;
|
||||
assert.equal(role("Font and back matter"), "transparent"); assert.equal(role("Copyright notices"), "transparent"); assert.equal(role("Copyright - eBook version - First Edition"), "front-matter"); assert.equal(role("About the Author"), "back-matter"); assert.ok(!plan.some((item) => item.role === "chapter"));
|
||||
});
|
||||
|
||||
test("real-vault Warden hierarchy keeps transparent containers, chapters, scenes, and matter roles", async () => {
|
||||
const loaded = await loadFixtureTree("tests/fixtures/real-vault/Book 1 - Warden of Silence");
|
||||
const plan = await classifyContentPlan(loaded.vault as never, createContentPlan(loaded.root, "novel-parts"));
|
||||
const role = (name: string) => plan.find((item) => item.name === name)?.role;
|
||||
assert.equal(loaded.root.name, "Book 1 - Warden of Silence");
|
||||
assert.equal(role("Manuscript"), "transparent");
|
||||
assert.equal(plan.find((item) => item.kind === "folder" && item.name === "Book 1 - Warden of Silence")?.role, "transparent");
|
||||
assert.equal(role("Front and back matter"), "transparent");
|
||||
assert.equal(role("Copyright notices"), "transparent");
|
||||
for (const name of ["Copyright - eBook version - First Edition", "Copyright - Hardcover version - First Edition", "Copyright - Paperback version - First Edition"]) assert.equal(role(name), "front-matter", name);
|
||||
for (const name of ["A note from Elin", "About the Author", "Acknowledgments", "Also by Anthony Fitzpatrick", "Back Cover Blurb"]) assert.equal(role(name), "back-matter", name);
|
||||
const profile = createDefaultProfiles()[1];
|
||||
const session = await new CompilePreparationService(loaded.vault as never, profile, 250).prepare(preparedRequest(loaded.root.path, plan), plan);
|
||||
assert.equal(session.book.root, loaded.root); assert.equal(session.book.parts.length, 1); assert.equal(session.statistics.chapterCount, 1); assert.equal(session.statistics.sceneCount, 3);
|
||||
assert.equal(session.book.orphanScenes.filter((scene) => !scene.excluded).length, 0); assert.equal(session.book.parts[0].orphanScenes.filter((scene) => !scene.excluded).length, 0);
|
||||
assert.equal(session.book.parts[0].chapters[0].scenes.length, 3);
|
||||
assert.equal(new Set(session.scannedBook.allMarkdown.map((file) => file.path)).size, session.scannedBook.allMarkdown.length);
|
||||
assert.ok(session.book.frontMatter.documents.every((item) => /Copyright/.test(item.title)));
|
||||
assert.deepEqual(session.book.backMatter.documents.map((item) => item.title), ["A note from Elin", "About the Author", "Acknowledgments", "Also by Anthony Fitzpatrick", "Back Cover Blurb"]);
|
||||
const document = strFromU8(unzipSync(createManuscriptDocx(session.book, session.profile, { title: "Warden of Silence", author: "Anthony Fitzpatrick" }))["word/document.xml"]);
|
||||
assert.ok(document.indexOf("The Silence Breaks") < document.indexOf("About the Author"));
|
||||
for (const matter of ["About the Author", "Acknowledgments", "Also by Anthony Fitzpatrick", "Back Cover Blurb"]) {
|
||||
const paragraph = [...document.matchAll(/<w:p[\s\S]*?<\/w:p>/g)].find((match) => match[0].includes(matter))?.[0] ?? "";
|
||||
assert.doesNotMatch(paragraph, /w:pStyle w:val="(?:PartNumber|ChapterNumber)"/, matter);
|
||||
}
|
||||
assert.doesNotMatch(document, />Manuscript<|>Front and back matter<|>Copyright notices</);
|
||||
});
|
||||
|
||||
test("matter-role inheritance updates only non-overridden descendant notes", async () => {
|
||||
const loaded = await loadFixtureTree("tests/fixtures/real-vault/Book 1 - Warden of Silence");
|
||||
const plan = await classifyContentPlan(loaded.vault as never, createContentPlan(loaded.root, "novel-parts"));
|
||||
const request = preparedRequest(loaded.root.path, plan); const controller = new CompileWorkspaceController(request, request.formatting!, { prepare: async () => { throw new Error(); }, sessionIsCurrent: async () => true, export: async () => undefined }); controller.setDetectedPlan(loaded.root.path, plan);
|
||||
const container = plan.find((item) => item.name === "Front and back matter")!; const explicit = plan.find((item) => item.name === "About the Author")!; explicit.role = "scene"; explicit.userOverride = true;
|
||||
const order = plan.map((item) => [item.path, item.order]); controller.setRole(container.path, "back-matter");
|
||||
assert.equal(explicit.role, "scene");
|
||||
assert.ok(plan.filter((item) => item.kind === "note" && item.path.startsWith(`${container.path}/`) && !item.userOverride).every((item) => item.role === "back-matter"));
|
||||
assert.deepEqual(plan.map((item) => [item.path, item.order]), order);
|
||||
});
|
||||
|
||||
test("manual Scene order survives multiple transparent ancestors", async () => {
|
||||
const loaded = await loadFixtureTree("tests/fixtures/real-vault/Book 1 - Warden of Silence");
|
||||
const plan = await classifyContentPlan(loaded.vault as never, createContentPlan(loaded.root, "novel-parts"));
|
||||
const scenes = plan.filter((item) => item.kind === "note" && /^Scene 00/.test(item.name));
|
||||
const desired = [scenes[2], scenes[0], scenes[1]]; desired.forEach((item, order) => { item.order = order; item.userOverride = true; });
|
||||
const session = await new CompilePreparationService(loaded.vault as never, createDefaultProfiles()[1], 250).prepare(preparedRequest(loaded.root.path, plan), plan);
|
||||
assert.deepEqual(session.book.parts[0].chapters[0].scenes.map((scene) => scene.title), desired.map((item) => item.name));
|
||||
});
|
||||
|
||||
test("orphan hierarchy diagnostics are relative, structural, and prose-free", async () => {
|
||||
const loaded = await loadFixtureTree("tests/fixtures/real-vault/Book 1 - Warden of Silence");
|
||||
const plan = await classifyContentPlan(loaded.vault as never, createContentPlan(loaded.root, "novel-parts"));
|
||||
const chapter = plan.find((item) => item.name === "Chapter 1 - The Silence of Östersund")!; chapter.role = "transparent"; chapter.userOverride = true;
|
||||
const profile = createDefaultProfiles()[1]; profile.useParts = true; profile.chapterSource = "folders"; profile.contentOrder = plan.map((item) => item.path);
|
||||
const scan = applyContentPlan(new VaultScanner().scan(loaded.root), plan, profile);
|
||||
assert.equal(scan.hierarchyDiagnostics?.length, 3); const diagnostic = scan.hierarchyDiagnostics![0];
|
||||
assert.match(diagnostic.scenePath, /^Manuscript\/Book 1 - Warden of Silence\/Part 1/); assert.equal(diagnostic.inferredRole, "scene"); assert.equal(diagnostic.parentRole, "transparent"); assert.match(diagnostic.nearestStructuralAncestor, /^Manuscript\/.*\/Part 1/); assert.equal(diagnostic.transparentReparenting, true); assert.equal(diagnostic.parentExcluded, false); assert.doesNotMatch(JSON.stringify(diagnostic), /Östersund was silent beneath/);
|
||||
});
|
||||
|
||||
test("production preparation has one scanner-to-semantic boundary", async () => {
|
||||
|
|
@ -187,6 +295,65 @@ test("operation cancellation retains its lock until settled and finalisation dis
|
|||
|
||||
test("content-tree helpers preserve child choices and authoritative sibling order", () => { const root = "Book"; const plan = [{ path: "Book/Manuscript", parentPath: root, name: "Manuscript", kind: "folder", role: "transparent", included: true, order: 0 }, { path: "Book/Manuscript/A.md", parentPath: "Book/Manuscript", name: "A", kind: "note", role: "scene", included: false, order: 0 }, { path: "Book/Manuscript/B.md", parentPath: "Book/Manuscript", name: "B", kind: "note", role: "scene", included: true, order: 1 }] as never; const controller = new CompileWorkspaceController(preparedRequest(root, plan), preparedRequest(root, plan).formatting!, { prepare: async () => { throw new Error(); }, sessionIsCurrent: async () => true, export: async () => undefined }); controller.setDetectedPlan(root, plan); controller.setIncluded("Book/Manuscript", false); controller.setIncluded("Book/Manuscript", true); assert.equal(plan[1].included, false); assert.equal(plan[2].included, true); assert.equal(isEffectivelyIncluded(plan[2], plan, root), true); const moved = moveSibling(plan, root, plan[2].path, -1); assert.equal(moved.find((item) => item.path === plan[2].path)?.order, 0); });
|
||||
|
||||
test("long Contents edits preserve scroll, focus, selection, and update only changed rows", () => {
|
||||
const root = "Book"; const folder = "Book/Manuscript";
|
||||
const plan: ContentPlanItem[] = [{ path: folder, parentPath: root, name: "Manuscript", kind: "folder", role: "transparent", included: true, order: 0 }];
|
||||
for (let index = 0; index < 1_000; index += 1) plan.push({ path: `${folder}/Scene ${index + 1}.md`, parentPath: folder, name: `Scene ${index + 1}`, kind: "note", role: "scene", included: true, order: index });
|
||||
const request = preparedRequest(root, plan); const controller = new CompileWorkspaceController(request, request.formatting!, { prepare: async () => { throw new Error(); }, sessionIsCurrent: async () => true, export: async () => undefined }); controller.setDetectedPlan(root, plan);
|
||||
const view = new ContentsTreeViewState(); view.prepare(root, plan); view.setScrollTop(8_420); const halfway = plan[501].path; view.setFocus(halfway, "role");
|
||||
const before = snapshotRows(controller.state.contentPlan, root); controller.setRole(halfway, "chapter"); const after = snapshotRows(controller.state.contentPlan, root);
|
||||
assert.deepEqual(changedRowPaths(before, after), [halfway]); assert.equal(view.scrollTop, 8_420); assert.deepEqual(view.focus, { path: halfway, control: "role" }); assert.equal(controller.state.contentPlan.find((item) => item.path === halfway)?.role, "chapter");
|
||||
for (let offset = 1; offset <= 20; offset += 1) { const path = plan[501 + offset].path; const prior = snapshotRows(controller.state.contentPlan, root); view.setFocus(path, "role"); controller.setRole(path, "chapter"); assert.deepEqual(changedRowPaths(prior, snapshotRows(controller.state.contentPlan, root)), [path]); }
|
||||
assert.equal(view.scrollTop, 8_420); assert.equal(view.focus?.path, plan[521].path);
|
||||
});
|
||||
|
||||
test("Contents include toggles remain local and stable after manual ordering", () => {
|
||||
const root = "Book"; const folder = "Book/Manuscript"; const plan: ContentPlanItem[] = [
|
||||
{ path: folder, parentPath: root, name: "Manuscript", kind: "folder", role: "transparent", included: true, order: 0 },
|
||||
...["A", "B", "C", "D"].map((name, order): ContentPlanItem => ({ path: `${folder}/${name}.md`, parentPath: folder, name, kind: "note", role: "scene", included: true, order }))
|
||||
];
|
||||
const request = preparedRequest(root, plan); const controller = new CompileWorkspaceController(request, request.formatting!, { prepare: async () => { throw new Error(); }, sessionIsCurrent: async () => true, export: async () => undefined }); controller.setDetectedPlan(root, plan); const view = new ContentsTreeViewState(); view.prepare(root, plan); view.setScrollTop(320);
|
||||
const target = `${folder}/C.md`; controller.moveItem(target, -1); assert.deepEqual(controller.state.contentPlan.filter((item) => item.parentPath === folder).map((item) => item.name), ["A", "C", "B", "D"]);
|
||||
for (let index = 0; index < 4; index += 1) { const before = snapshotRows(controller.state.contentPlan, root); view.setFocus(target, "include"); controller.setIncluded(target, index % 2 === 1); assert.deepEqual(changedRowPaths(before, snapshotRows(controller.state.contentPlan, root)), [target]); }
|
||||
const orderBeforeRoleEdit = controller.state.contentPlan.filter((item) => item.parentPath === folder).map((item) => item.path); controller.setRole(target, "chapter"); assert.deepEqual(controller.state.contentPlan.filter((item) => item.parentPath === folder).map((item) => item.path), orderBeforeRoleEdit); assert.equal(view.scrollTop, 320); assert.deepEqual(view.focus, { path: target, control: "include" });
|
||||
});
|
||||
|
||||
test("Contents folder expansion survives edits in expanded and collapsed branches", () => {
|
||||
const root = "Book"; const expanded = `${root}/Expanded`; const collapsed = `${root}/Collapsed`;
|
||||
const plan: ContentPlanItem[] = [
|
||||
{ path: expanded, parentPath: root, name: "Expanded", kind: "folder", role: "chapter", included: true, order: 0 },
|
||||
{ path: `${expanded}/Scene.md`, parentPath: expanded, name: "Scene", kind: "note", role: "scene", included: true, order: 0 },
|
||||
{ path: collapsed, parentPath: root, name: "Collapsed", kind: "folder", role: "chapter", included: true, order: 1 },
|
||||
{ path: `${collapsed}/Scene.md`, parentPath: collapsed, name: "Scene", kind: "note", role: "scene", included: true, order: 0 }
|
||||
];
|
||||
const request = preparedRequest(root, plan); const controller = new CompileWorkspaceController(request, request.formatting!, { prepare: async () => { throw new Error(); }, sessionIsCurrent: async () => true, export: async () => undefined }); controller.setDetectedPlan(root, plan); const view = new ContentsTreeViewState(); view.prepare(root, plan); view.toggle(collapsed); view.setScrollTop(180); view.setFocus(collapsed, "role"); const indexed = new Map(plan.map((item) => [item.path, item]));
|
||||
assert.equal(view.isExpanded(expanded), true); assert.equal(view.isExpanded(collapsed), false); assert.equal(view.isVisible(plan[1], indexed), true); assert.equal(view.isVisible(plan[3], indexed), false);
|
||||
controller.setRole(expanded, "transparent"); controller.setRole(collapsed, "transparent"); controller.setIncluded(collapsed, false); controller.setIncluded(collapsed, true);
|
||||
assert.equal(view.isExpanded(expanded), true); assert.equal(view.isExpanded(collapsed), false); assert.equal(view.isVisible(plan[1], indexed), true); assert.equal(view.isVisible(plan[3], indexed), false); assert.equal(view.scrollTop, 180); assert.deepEqual(view.focus, { path: collapsed, control: "role" });
|
||||
});
|
||||
|
||||
test("excluding a folder collapses descendants without clearing child choices or order", () => {
|
||||
const root = "Book"; const folder = `${root}/Chapter`; const plan: ContentPlanItem[] = [
|
||||
{ path: folder, parentPath: root, name: "Chapter", kind: "folder", role: "chapter", included: true, order: 0 },
|
||||
{ path: `${folder}/A.md`, parentPath: folder, name: "A", kind: "note", role: "scene", included: false, order: 1 },
|
||||
{ path: `${folder}/B.md`, parentPath: folder, name: "B", kind: "note", role: "scene", included: true, order: 0 }
|
||||
];
|
||||
const request = preparedRequest(root, plan); const controller = new CompileWorkspaceController(request, request.formatting!, { prepare: async () => { throw new Error(); }, sessionIsCurrent: async () => true, export: async () => undefined }); controller.setDetectedPlan(root, plan); const view = new ContentsTreeViewState(); view.prepare(root, plan); const indexed = new Map(plan.map((item) => [item.path, item])); const childState = plan.slice(1).map((item) => [item.path, item.included, item.order]);
|
||||
controller.setIncluded(folder, false); view.collapse(folder); assert.equal(view.isExpanded(folder), false); assert.equal(view.isVisible(plan[1], indexed), false); assert.deepEqual(plan.slice(1).map((item) => [item.path, item.included, item.order]), childState);
|
||||
controller.setIncluded(folder, true); assert.equal(view.isExpanded(folder), false); assert.deepEqual(plan.slice(1).map((item) => [item.path, item.included, item.order]), childState);
|
||||
controller.setRole(folder, "ignore"); view.collapse(folder); controller.setRole(folder, "chapter"); assert.equal(view.isExpanded(folder), false); assert.deepEqual(plan.slice(1).map((item) => [item.path, item.included, item.order]), childState);
|
||||
});
|
||||
|
||||
test("Contents property handlers use persistent row state instead of rebuilding the modal", async () => {
|
||||
const modal = await readFile(path.join("src", "compile-modal.ts"), "utf8"); const contents = await readFile(path.join("src", "workspace", "contents-step.ts"), "utf8");
|
||||
assert.match(modal, /renderContentsStep\(body, this\.controller, this\.contentsViewState\)/); assert.doesNotMatch(modal, /renderContentsStep\(body, this\.controller, \(\) => this\.render\(\)\)/);
|
||||
assert.match(contents, /refreshChangedRows\(before\)/); assert.match(contents, /element\.focus\(\{ preventScroll: true \}\)/); assert.doesNotMatch(contents, /controller\.set(?:Role|Included)[^\n]+changed\(\)/);
|
||||
});
|
||||
test("Formatting UI exposes metric indentation and every supported scene break distinctly", async () => {
|
||||
const source = await readFile(path.join("src", "workspace", "formatting-step.ts"), "utf8"); assert.match(source, /First-line indent \(cm\)/); assert.doesNotMatch(source, /0\.25 in|0\.5 in|inch/i);
|
||||
for (const option of ["#", "*", "***", "* * *", "", "custom"]) assert.match(source, new RegExp(`addOption\\(\\"${option.replace(/\*/g, "\\*")}\\"`));
|
||||
});
|
||||
|
||||
test("export preview view model references the exact prepared Book", async () => { const loaded = await loadFixtureTree("samples/Book 1 - Warden of Silence"); const plan = await classifyContentPlan(loaded.vault as never, createContentPlan(loaded.root, "novel-parts")); const session = await new CompilePreparationService(loaded.vault as never, createDefaultProfiles()[1], 250).prepare(preparedRequest(loaded.root.path, plan), plan); const view = buildExportPreviewViewModel(session); assert.equal(view.book, session.book); assert.equal(createPreparedExportRequest(session, session.outputPaths[0], false).book, view.book); assert.deepEqual(view.parts.map((part) => part.chapters.map((chapter) => chapter.sceneCount)), [[2], [1]]); });
|
||||
|
||||
test("history service records success only after requested and distinguishes failure/cancellation", async () => { const settings = { ...DEFAULT_SETTINGS, exportHistory: [], compileLogs: [], maximumExportHistoryEntries: 10, enableCompileLogs: true }; let saves = 0; const history = new CompileHistoryService(() => settings, async () => { saves += 1; }, "0.9.1"); const record = { timestamp: new Date("2026-01-01"), started: Date.now(), profile: "Test", manuscript: "Book", format: "docx" as const, outputFiles: ["Exports/Book.docx"] }; assert.equal(history.getHistory().length, 0); await history.recordFailure({ ...record, message: "failed" }); await history.recordCancellation(record); await history.recordSuccess(record); assert.deepEqual(history.getHistory().map((item) => [item.success, item.cancelled]), [[true, undefined], [false, true], [false, undefined]]); assert.equal(saves, 3); });
|
||||
|
|
|
|||
Loading…
Reference in a new issue