diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index d617077..645c66a 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,30 +1,60 @@ # GitHub Copilot — Bindery -Bindery is a **VS Code extension** + **MCP server** for markdown book authoring. -TypeScript throughout. Two independent packages — keep them in sync when adding features. +Bindery is an open-source suite for markdown book authoring with **feature parity** across hosts: +- **VS Code extension** (published to Marketplace) — full-featured IDE integration +- **Obsidian plugin** — fully-featured Obsidian integration +- **MCP server** (Claude Desktop, Cursor, etc.) — standalone server for AI assistants + +TypeScript throughout. Shared logic in `bindery-merge/` and `bindery-core/`; hosts consume +the shared libraries and add their own host-specific wiring. Keep implementations in sync. --- ## Repo layout ``` +bindery-core/ ← Shared templates, settings, typography, translations + src/ + templates/ ← AI setup instruction templates (per-host) + index.ts ← exports shared types and helpers + settings.ts ← Bindery workspace settings schema and helpers + formatting.ts ← typography transforms (shared across all hosts) + translations.ts ← dialect and glossary management + +bindery-merge/ ← Shared merge logic (chapter discovery, export orchestration) + src/ + merge.ts ← mergeBook() — main export orchestrator (950+ lines) + tool-locate.ts ← platform-aware path resolution for pandoc, libreoffice (250+ lines) + index.ts ← barrel exports for public API + format.ts ← typography helpers (used during merge) + vscode-ext/ ← VS Code extension (published to Marketplace) src/ - extension.ts ← activation, all commands, format-on-save handler + extension.ts ← activation, all 17+ commands, format-on-save handler workspace.ts ← reads/writes .bindery/settings.json + translations.json - merge.ts ← chapter discovery, markdown assembly, pandoc/LibreOffice - format.ts ← typography transforms (curly quotes, em-dash, ellipsis) + merge.ts ← re-exports from @bindery/merge (pure delegation) + format.ts ← typography transforms via @bindery/core mcp.ts ← vscode.lm.registerTool registrations + mcp.json writer - ai-setup.ts ← generates CLAUDE.md, copilot-instructions.md, skills, etc. + ai-setup.ts ← generates per-target instruction files + mcp-ts/ ← bundled copy of mcp-ts/out/ (for packaging) -mcp-ts/ ← Standalone MCP server (also bundled inside vscode-ext) +Obsidian-plugin/ ← Obsidian plugin (Community Plugins marketplace) + src/ + main.ts ← activation, all 17+ commands (feature parity with vscode-ext) + workspace.ts ← Vault I/O, settings management (Obsidian-specific) + merge.ts ← Obsidian-specific wrapper around @bindery/merge + ai-setup.ts ← per-target instruction file generation + formatter.ts ← typography formatting via @bindery/core + exporter.ts ← export orchestration (Obsidian-specific UI) + +mcp-ts/ ← Standalone MCP server (Claude Desktop, Cursor, etc.) src/ index.ts ← McpServer entry point, all server.registerTool() calls tools.ts ← one exported function per tool (pure: root + args → string) registry.ts ← book registry (--book flags + BINDERY_BOOKS env var) search.ts ← BM25 index build/load/search + optional Ollama reranking docstore.ts ← file discovery and chunking - format.ts ← typography (shared logic, duplicated from vscode-ext) + format.ts ← typography (shared with @bindery/core) mcpb/ ← Claude Desktop extension package (mcpb manifest + bundled server) manifest.json ← mcpb manifest v0.3 — tools list, user_config, privacy_policies @@ -82,28 +112,37 @@ see `mcp-ts/src/index.ts` for implementation details and input schemas and `mcpb ## VS Code extension — key design rules - **Generic**: no project-specific strings in source (use "Book", not a title) -- **Config priority**: `.bindery/settings.json` → VS Code workspace settings → VS Code user settings → code defaults +- **Host-agnostic logic**: Shared logic lives in `bindery-merge/` and `bindery-core/`; hosts re-export or wrap +- **Config priority**: `.bindery/settings.json` → host-specific settings → code defaults - **Machine paths only in user settings**: `pandocPath`, `libreOfficePath` — never in workspace settings -- **Substitution tiers**: built-in (`merge.ts`) → user-general (`bindery.generalSubstitutions`) → project (`.bindery/translations.json`). Later tiers win. +- **Substitution tiers**: built-in (from `@bindery/merge`) → user-general → project (`.bindery/translations.json`). Later tiers win. - **formatOnSave**: only fires for files inside the configured `storyFolder` - **Activation**: `onStartupFinished` (to ensure LM tools register at launch) + `onLanguage:markdown` - **Command namespace**: all commands are `bindery.*` — must match in both `package.json` and `extension.ts` -## VS Code extension — commands +## Authoring commands (feature parity across hosts) -| Command | Description | -|---|---| -| `bindery.init` | Create `.bindery/settings.json` + `translations.json` | -| `bindery.setupAI` | Generate CLAUDE.md / copilot-instructions.md / skills / AGENTS.md | -| `bindery.formatDocument` / `bindery.formatFolder` | Typography formatting | -| `bindery.mergeMarkdown/Docx/Epub/Pdf/All` | Merge chapters → output format | -| `bindery.findProbableUsToUkWords` | Surface probable US spellings in EN source | -| `bindery.addDialect` | Add a dialect substitution rule (auto-applied at export) | -| `bindery.addTranslation` | Add a cross-language glossary entry | -| `bindery.addLanguage` | Add a new language and scaffold its story folder | -| `bindery.addUkReplacement` | Alias for `addDialect` (backward compat) | -| `bindery.openTranslations` | Open translations.json | -| `bindery.registerMcp` | Write .vscode/mcp.json for Claude/Codex MCP discovery | +Both VS Code and Obsidian plugins expose these equivalent commands (identical functionality, +host-specific UI/activation): + +| Command | VS Code | Obsidian | Description | +|---|---|---|---| +| `bindery.init` | ✅ | ✅ | Create `.bindery/settings.json` + `translations.json` | +| `bindery.setupAI` | ✅ | ✅ | Generate CLAUDE.md / copilot-instructions.md / skills / AGENTS.md | +| `bindery.formatDocument` | ✅ | ✅ | Typography formatting (curly quotes, em-dash, ellipsis) | +| `bindery.formatFolder` | ✅ | ✅ | Recursively format all .md files in a folder | +| `bindery.mergeMarkdown` | ✅ | ✅ | Merge chapters → .md | +| `bindery.mergeDocx` | ✅ | ✅ | Merge chapters → .docx (via pandoc) | +| `bindery.mergeEpub` | ✅ | ✅ | Merge chapters → .epub (via pandoc) | +| `bindery.mergePdf` | ✅ | ✅ | Merge chapters → .pdf (via pandoc + LibreOffice) | +| `bindery.mergeAll` | ✅ | ✅ | Merge chapters → all supported formats | +| `bindery.findProbableUsToUkWords` | ✅ | ✅ | Surface probable US spellings in EN source | +| `bindery.addDialect` | ✅ | ✅ | Add a dialect substitution rule (auto-applied at export) | +| `bindery.addTranslation` | ✅ | ✅ | Add a cross-language glossary entry | +| `bindery.addLanguage` | ✅ | ✅ | Add a new language and scaffold its story folder | +| `bindery.openTranslations` | ✅ | ✅ | Show path to translations.json (edit in host editor) | +| `bindery.registerMcp` | ✅ | — | Write .vscode/mcp.json for Claude/Codex MCP discovery (VS Code-only) | +| `bindery.showMcpConfig` | — | ✅ | Display MCP configuration snippet (Obsidian-only) | --- @@ -149,18 +188,48 @@ Format of an appended entry (stamped by `memory_append`, not the caller): ## Build ```bash -# MCP server -cd mcp-ts && npm run build # outputs to mcp-ts/out/ +# Individual packages +npm run build --workspace=bindery-core # outputs to bindery-core/out/ +npm run build --workspace=bindery-merge # outputs to bindery-merge/out/ +npm run compile --workspace=mcp-ts # outputs to mcp-ts/out/ +npm run compile --workspace=vscode-ext # outputs to vscode-ext/out/ +npm run compile --workspace=obsidian-plugin # outputs to obsidian-plugin/out/ -# VS Code extension -cd vscode-ext && npm run compile # outputs to vscode-ext/out/ +# All packages at once +npm run build # builds bindery-core, bindery-merge, compiles all others # Type-check only (no emit) -cd mcp-ts && npx tsc --noEmit -cd vscode-ext && npx tsc --noEmit +npx tsc --noEmit ``` -Before releasing mcpb: copy `mcp-ts/out/` → `mcpb/server/`. +### Release workflow + +**For MCPB (Claude Desktop):** +1. `npm run build --workspace=mcp-ts` +2. Copy `mcp-ts/out/` → `mcpb/server/` +3. Update `mcpb/manifest.json` version +4. Submit to Anthropic MCPB directory + +**For VS Code Marketplace:** +1. `npm run compile --workspace=vscode-ext` +2. Bundle mcp-ts into vscode-ext: + ```bash + mkdir -p vscode-ext/mcp-ts/out + npx esbuild mcp-ts/out/tools.js --bundle --platform=node --format=cjs --target=node18 --outfile=vscode-ext/mcp-ts/out/tools.js + npx esbuild mcp-ts/out/index.js --bundle --platform=node --format=cjs --target=node18 --outfile=vscode-ext/mcp-ts/out/index.js + ``` +3. Package VSIX: `cd vscode-ext && npx @vscode/vsce package` +4. Upload to Marketplace + +**For Obsidian Community Plugins:** +1. `npm run compile --workspace=obsidian-plugin` +2. `npm run bundle --workspace=obsidian-plugin` (outputs bundled main.js to `obsidian-plugin/out/`) +3. Testing: copy `obsidian-plugin/` to Obsidian vault plugins folder: + ``` + ~/.obsidian/plugins/bindery/ (on Linux/macOS) + %APPDATA%\\Obsidian\\plugins\\bindery\\ (on Windows) + ``` +4. Submit PR to Obsidian Community Plugins repo # Hygiene rules for generated files diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4767032..b16168f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,20 @@ jobs: name: bindery-core-test-results-${{ matrix.os }} path: bindery-core/test-results.json + # ── bindery-merge ───────────────────────────────────────────────────────── + - name: Build bindery-merge + run: npm run build --workspace=bindery-merge + + - name: Test bindery-merge + run: npm run test:ci --workspace=bindery-merge + + - name: Upload bindery-merge test results + if: always() + uses: actions/upload-artifact@v7 + with: + name: bindery-merge-test-results-${{ matrix.os }} + path: bindery-merge/test-results.json + # ── MCP server ──────────────────────────────────────────────────────────── - name: Compile mcp-ts run: npm run compile --workspace=mcp-ts @@ -132,6 +146,9 @@ jobs: - name: mcp-ts coverage run: npm run compile --workspace=mcp-ts && npm run test:coverage --workspace=mcp-ts + - name: bindery-merge coverage + run: npm run compile --workspace=bindery-merge && npm run test:coverage --workspace=bindery-merge + - name: vscode-ext coverage run: npm run compile --workspace=vscode-ext && npm run test:coverage --workspace=vscode-ext @@ -145,6 +162,7 @@ jobs: name: coverage path: | bindery-core/coverage/ + bindery-merge/coverage/ mcp-ts/coverage/ vscode-ext/coverage/ obsidian-plugin/coverage/ diff --git a/.gitignore b/.gitignore index 6a052ce..2c6be98 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ node_modules/ vscode-ext/out/ mcp-ts/out/ bindery-core/out/ +bindery-merge/out/ obsidian-plugin/out/ mcp-rust/target/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b6e4def..b6b8ac7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -6,21 +6,39 @@ Thank you for contributing! This document explains how to run the tests and what ## Running Tests Locally -### Quick test (both packages) +### Quick test (all packages) ```bash -cd mcp-ts && npm test -cd ../vscode-ext && npm test +npm test # runs all packages in parallel +``` + +Or test individual packages: + +```bash +npm run test --workspace=bindery-core +npm run test --workspace=bindery-merge +npm run test --workspace=mcp-ts +npm run test --workspace=vscode-ext +npm run test --workspace=obsidian-plugin ``` ### Watch mode (auto-rerun on file change) ```bash # Terminal 1 -cd mcp-ts && npm run test:watch +npm run test:watch --workspace=bindery-core # Terminal 2 -cd vscode-ext && npm run test:watch +npm run test:watch --workspace=bindery-merge + +# Terminal 3 +npm run test:watch --workspace=mcp-ts + +# Terminal 4 +npm run test:watch --workspace=vscode-ext + +# Terminal 5 +npm run test:watch --workspace=obsidian-plugin ``` ### CI reporter (as GitHub Actions runs it) @@ -38,11 +56,18 @@ The `test:ci` script writes a `test-results.json` file in each package directory | Layer | Location | What is tested | |---|---|---| +| **bindery-core unit tests** | `bindery-core/test/` | Templates, settings, translations, formatting logic | +| **bindery-merge unit tests** | `bindery-merge/test/merge.test.ts` | Pure merge functions, chapter discovery, dialect conversion | +| **bindery-merge mocked tests** | `bindery-merge/test/merge-mocked.test.ts` | Pandoc/LibreOffice paths (with child_process mocked) | +| **bindery-merge extended tests** | `bindery-merge/test/merge-extended.test.ts` | Internal functions, Pandoc helpers, typography integration | | **MCP unit tests** | `mcp-ts/test/tools.test.ts` | Tool logic, path safety, search indexing | | **MCP contract tests** | `mcp-ts/test/index-contract.test.ts` | Every registered tool has exactly one annotation hint | | **MCP stdio integration** | `mcp-ts/test/integration-stdio.test.ts` | Spawn real server, JSON-RPC handshake, tool calls, path-traversal defence, error handling | | **VS Code unit tests** | `vscode-ext/test/workspace.test.ts`, `vscode-ext/test/mcp.test.ts` | Workspace helpers, MCP JSON writer | | **VS Code integration** | `vscode-ext/test/integration-commands.test.ts` | Init workflow, registerMcp, formatDocument, settings precedence | +| **Obsidian plugin tests** | `obsidian-plugin/test/` | Plugin lifecycle, workspace management, AI setup, merge execution, formatter integration | +| **Obsidian exporter** | `obsidian-plugin/test/exporter.test.ts` | Export orchestration, multi-format output | +| **Obsidian merge** | `obsidian-plugin/test/merge.test.ts` | Chapter discovery, dialect handling, Obsidian Vault API integration | --- @@ -65,20 +90,25 @@ lives: ## Host Feature Parity Policy -`vscode-ext/` and `obsidian-plugin/` target the same Bindery authoring feature -set. The Obsidian plugin currently implements a subset of VS Code commands -(export, review markers, init workspace, and MCP snippet); `format-document` is -still a placeholder and commands such as setup AI, translation/dialect/language -management, open translations, and register MCP are VS Code-only for now. +**Feature parity is complete.** Both `vscode-ext/` and `obsidian-plugin/` implement +identical Bindery authoring workflows: -Unless a feature is explicitly host-specific, any functional change added to one -host should be implemented in the other host in the same PR (or in a clearly -linked follow-up PR). +- **Shared logic**: All merge, export, tool-location, and typography logic lives in + `bindery-merge/` and is consumed by both hosts. +- **Equivalent commands**: Both hosts provide all 17+ authoring commands (format, + merge, AI setup, workspace management, dialect/translation/language management). +- **Equivalent tests**: Each host has a full test suite covering its command wiring + and host-specific integration (Obsidian Vault API, VS Code Workspace API). + +Unless a feature is **explicitly host-specific** (e.g., VS Code's Language Model Tool API), +any functional change or bug fix added to one host must be implemented in the other +host in the same PR. When adding or changing commands: -- update command wiring in both hosts -- add or update tests in both hosts -- document intentional host-specific exceptions in the PR description +- **Update logic**: If logic lives in `bindery-merge/` or `bindery-core/`, update there once +- **Update command wiring**: Update in both `vscode-ext/src/extension.ts` and `obsidian-plugin/src/main.ts` +- **Add tests**: Add to both host test suites to validate host-specific integration +- **Document exceptions**: Clearly note any intentional host-specific behavior in the PR description --- @@ -112,22 +142,28 @@ Tests cover: ## CI pipeline (`.github/workflows/ci.yml`) -The workflow runs on every push and pull request on all branches. +The workflow runs on every push and pull request on all branches, on all three major platforms +(Ubuntu, Windows, macOS). -A single `test` job runs the steps sequentially: +A single `test` job runs the build and test steps sequentially: | Step | What it does | |---|---| -| Install + compile bindery-core | `npm ci` → `npm run compile` | -| Run bindery-core tests | `npm run test:ci` → uploads `test-results.json` artifact | -| Install + compile mcp-ts | `npm ci` → `npm run compile` | -| Run mcp-ts tests | `npm run test:ci` → uploads `test-results.json` artifact | -| Install + compile vscode-ext | `npm ci` → `npm run compile` | -| Run vscode-ext tests | `npm run test:ci` → uploads `test-results.json` artifact | -| Install + compile obsidian-plugin | `npm ci` → `npm run compile` | -| Run obsidian-plugin tests | `npm run test:ci` → uploads `test-results.json` artifact | +| Install all workspace deps | `npm ci` | +| Build + test bindery-core | `npm run build` → `npm run test:ci` → uploads `test-results.json` | +| Build + test bindery-merge | `npm run build` → `npm run test:ci` → uploads `test-results.json` | +| Compile + test mcp-ts | `npm run compile` → `npm run test:ci` → uploads `test-results.json` | +| Compile + test vscode-ext | `npm run compile` → `npm run test:ci` (+ VSIX smoke check on ubuntu-latest) | +| Compile + test obsidian-plugin | `npm run compile` → `npm run bundle` → `npm run test:ci` → uploads `test-results.json` | -PRs **cannot be merged** unless the `test` job passes. +A separate `coverage` job runs on ubuntu-latest only (coverage metrics don't vary by OS): + +| Step | What it does | +|---|---| +| Build + coverage for each package | `npm run compile` → `npm run test:coverage` | +| Upload coverage reports | Artifacts collected for each package | + +**PRs cannot be merged** unless the `test` job passes on all platforms. --- @@ -140,8 +176,20 @@ PRs **cannot be merged** unless the `test` job passes. --- +## Troubleshooting + +### Merge tests won't compile +The merge tests have moved from `vscode-ext/test/` to `bindery-merge/test/`. If you have +local references to the old paths, update imports to point to `bindery-merge` instead. + +### CI passes locally but fails in GitHub Actions +Ensure your `package-lock.json` was generated with the same npm major version as the one +used in `.github/workflows/ci.yml`. This prevents lockfile skew across platforms. + +--- + ## Future Enhancements - E2E tests with real pandoc/LibreOffice invocation -- Cross-platform CI matrix (Windows, macOS) -- Code coverage reporting and threshold enforcement +- Code coverage reporting dashboards +- Per-commit performance benchmarks (merge speed, search latency) diff --git a/README.md b/README.md index e2171c0..6516871 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,30 @@ A [Model Context Protocol](https://modelcontextprotocol.io/) server that exposes See [mcpb/README.md](mcpb/README.md) for the full 27-tool reference and usage examples. +### [obsidian-plugin/](obsidian-plugin/) — Obsidian Plugin + +The **Bindery** Obsidian plugin provides the same feature set as the VS Code extension for Obsidian vault users: + +- **Typography formatting** — curly quotes, em-dashes, ellipses, smart apostrophes (on save or on demand) +- **Chapter merge & export** — Markdown, DOCX, EPUB, PDF output via Pandoc + LibreOffice +- **Dialect & translation management** — extensible substitution rules and glossaries +- **Multi-language support** — configurable per-language chapter labelling +- **Workspace config** — `.bindery/settings.json` for vault-level settings +- **AI instruction generation** — Generate CLAUDE.md, copilot-instructions.md, .cursor/rules, AGENTS.md +- **Review markers** — Mark regions for agent feedback +- **MCP snippet generator** — Copy JSON for Claude Desktop integration + +Build and install: + +```bash +cd obsidian-plugin +npm install +npm run compile +npm run bundle +# out/main.js is ready for manual Obsidian installation +``` + +Then in Obsidian: Settings → Community plugins (if not restricted) → Install from folder or manual install from `out/main.js`. ### [mcpb/](mcpb/) — Claude Desktop Extension @@ -86,19 +110,34 @@ The VS Code extension works standalone — no server setup needed for typography ## Project Structure ``` -├── vscode-ext/ VS Code extension (TypeScript) -│ ├── src/ Extension source -│ ├── package.json Extension manifest -│ └── README.md Extension docs -├── mcp-ts/ MCP server (Node.js / TypeScript) -│ ├── src/ Server source -│ └── package.json Package manifest -├── mcpb/ Claude Desktop extension package (.mcpb) -│ ├── manifest.json Extension metadata and tool list -│ └── server/ Populated by CI (mcp-ts build output) -└── LICENSE MIT +├── bindery-core/ Shared templates & types (TS) +│ ├── src/ +│ │ └── templates/ AI instruction templates (claude, copilot, cursor, agents, skills) +│ └── package.json +├── bindery-merge/ Shared merge logic (TS) +│ ├── src/ +│ │ ├── merge.ts Chapter discovery, merge, export via Pandoc/LibreOffice +│ │ └── tool-locate.ts Cross-platform tool path resolution +│ └── package.json +├── vscode-ext/ VS Code extension (TS) +│ ├── src/ +│ ├── package.json +│ └── README.md +├── obsidian-plugin/ Obsidian plugin (TS) +│ ├── src/ +│ ├── package.json +│ └── README.md +├── mcp-ts/ MCP server (Node.js / TS) +│ ├── src/ +│ └── package.json +├── mcpb/ Claude Desktop extension +│ ├── manifest.json +│ └── server/ (CI-populated) +└── LICENSE ``` +Shared logic in `bindery-core` and `bindery-merge` ensures both `vscode-ext` and `obsidian-plugin` implement identical functionality. + ## Prerequisites - **VS Code** 1.85+ @@ -146,14 +185,21 @@ MIT — see [LICENSE](LICENSE). ## Host Parity Reminder -`vscode-ext/` and `obsidian-plugin/` target the same Bindery authoring feature -set. The Obsidian plugin currently implements a subset of VS Code commands -(export, review markers, init workspace, and MCP snippet); `format-document` is -still a placeholder and commands such as setup AI, translation/dialect/language -management, open translations, and register MCP are VS Code-only for now. +`vscode-ext/` and `obsidian-plugin/` are **two equal implementations** of the same Bindery authoring toolkit. Both support: -Unless a change is intentionally host-specific, functional additions to one host -should be mirrored in the other in the same PR or a clearly linked follow-up PR. +- **Typography formatting** (curly quotes, em-dashes, ellipses) +- **Chapter merge & export** (MD, DOCX, EPUB, PDF via Pandoc + LibreOffice) +- **Dialect & translation management** — extensible substitution rules and glossaries +- **Multi-language support** — configurable chapter labels and folder structures +- **Workspace initialization** — `.bindery/settings.json` and `.bindery/translations.json` +- **AI instruction generation** — CLAUDE.md, copilot-instructions.md, .cursor/rules, AGENTS.md +- **Review markers** — wrap text in `` for agent feedback +- **MCP config snippet** — JSON for Claude Desktop / Cowork integration +- **Workspace management** — add languages, dialects, and translation entries + +Both plugins share all core logic via `@bindery/merge` (chapter discovery, merge execution, tool path resolution) to minimize duplication and ensure consistent behavior. Shared templates in `@bindery/core/src/templates/` feed both VS Code and Obsidian workflows. + +Unless a change is intentionally host-specific, functional additions to one should be mirrored in the other in the same PR or a clearly linked follow-up PR. The AI instruction file templates are maintained in **one place only**: diff --git a/bindery-merge/package.json b/bindery-merge/package.json new file mode 100644 index 0000000..b0216fe --- /dev/null +++ b/bindery-merge/package.json @@ -0,0 +1,26 @@ +{ + "name": "@bindery/merge", + "version": "0.1.0", + "main": "out/index.js", + "types": "out/index.d.ts", + "private": true, + "scripts": { + "build": "tsc", + "compile": "tsc", + "test": "vitest run", + "test:ci": "vitest run --reporter=verbose --reporter=json --outputFile=test-results.json", + "test:coverage": "vitest run --coverage" + }, + "dependencies": { + "@bindery/core": "*" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "@vitest/coverage-v8": "^4.1.5", + "typescript": "6.0.3", + "vitest": "^4.1.5" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/bindery-merge/src/index.ts b/bindery-merge/src/index.ts new file mode 100644 index 0000000..ee3fa66 --- /dev/null +++ b/bindery-merge/src/index.ts @@ -0,0 +1,26 @@ +/** + * @bindery/merge — shared merge/export library + * + * Pure functions for book chapter discovery, merging, and export to + * markdown, DOCX, EPUB, and PDF via Pandoc + LibreOffice. + * + * Used by VS Code extension and Obsidian plugin. + */ + +export { + type OutputType, + type MergeOptions, + type MergeResult, + mergeBook, + checkPandoc, + getPandocOutputFormats, + clearPandocCapabilityCache, + getBuiltInUkReplacements, +} from './merge.js'; + +export { + type ToolName, + locateTool, + locateToolPath, + clearLocateCache, +} from './tool-locate.js'; diff --git a/bindery-merge/src/merge.ts b/bindery-merge/src/merge.ts new file mode 100644 index 0000000..62be017 --- /dev/null +++ b/bindery-merge/src/merge.ts @@ -0,0 +1,848 @@ +/** + * Book merging — collects and orders markdown files, generates TOC, calls Pandoc. + * + * Extracted from vscode-ext/src/merge.ts (ported from mcp-rust/src/merge.rs) + * Shared library used by both VS Code extension and Obsidian plugin. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as cp from 'node:child_process'; +import { updateTypography } from '@bindery/core'; +import type { LanguageConfig, UkReplacement } from '@bindery/core'; + +// ─── Types ────────────────────────────────────────────────────────────────── + +export type OutputType = 'md' | 'docx' | 'epub' | 'pdf'; + +export interface MergeOptions { + /** Workspace root path */ + root: string; + /** Story folder name (e.g. "Story") */ + storyFolder: string; + /** Language configuration */ + language: LanguageConfig; + /** Output types to generate */ + outputTypes: OutputType[]; + /** Include TOC in markdown output */ + includeToc: boolean; + /** Include separators between chapters */ + includeSeparators: boolean; + /** Author name for EPUB/DOCX metadata */ + author?: string; + /** Book title override */ + bookTitle?: string; + /** Output directory (relative to root) */ + outputDir: string; + /** Merged file prefix */ + filePrefix: string; + /** Path to pandoc executable */ + pandocPath: string; + /** Path to LibreOffice executable for PDF export */ + libreOfficePath?: string; + /** Custom US→UK replacements from workspace settings */ + ukReplacements?: UkReplacement[]; + /** Dialect code to apply substitution rules for (e.g. 'en-gb') */ + dialectCode?: string; +} + +export interface MergeResult { + outputs: string[]; + filesMerged: number; + warnings: string[]; +} + +// ─── Internal Types ───────────────────────────────────────────────────────── + +interface ActInfo { + name: string; + number: number; + subtitle?: string; +} + +type FileType = + | { kind: 'prologue' } + | { kind: 'act'; act: ActInfo } + | { kind: 'chapter'; act: ActInfo; num: number } + | { kind: 'epilogue' }; + +interface OrderedFile { + filePath: string; + fileType: FileType; +} + +// ─── Regex Patterns ───────────────────────────────────────────────────────── + +const ACT_FOLDER_RE = /^(Act|Deel)\s+(I{1,3}|IV|V)(?:\s*[-–—]\s*(.+))?$/; +const CHAPTER_NUM_RE = /(?:chapter|hoofdstuk)\s*(\d+)/i; +const H1_RE = /^\s*#\s+(.+?)\s*$/m; +const SLUG_CLEAN_RE = /[^\p{L}\p{N}\s-]/gu; +const BLANK_LINES_RE = /\n{2,}/g; +const FIRST_H1_RE = /^(\s*)#\s+/m; +const HEADING_LINE_RE = /^#[^\n]*\n/m; +const FENCE_RE = /^\s*```/; + +// ─── UK Conversion (US → UK) ─────────────────────────────────────────────── + +const UK_REPLACEMENTS: UkReplacement[] = [ + { us: 'color', uk: 'colour' }, + { us: 'colors', uk: 'colours' }, + { us: 'colored', uk: 'coloured' }, + { us: 'coloring', uk: 'colouring' }, + { us: 'center', uk: 'centre' }, + { us: 'centers', uk: 'centres' }, + { us: 'centered', uk: 'centred' }, + { us: 'centering', uk: 'centring' }, + { us: 'theater', uk: 'theatre' }, + { us: 'theaters', uk: 'theatres' }, + { us: 'favorite', uk: 'favourite' }, + { us: 'favorites', uk: 'favourites' }, + { us: 'favor', uk: 'favour' }, + { us: 'favors', uk: 'favours' }, + { us: 'favored', uk: 'favoured' }, + { us: 'favoring', uk: 'favouring' }, + { us: 'traveled', uk: 'travelled' }, + { us: 'traveling', uk: 'travelling' }, + { us: 'traveler', uk: 'traveller' }, + { us: 'travelers', uk: 'travellers' }, + { us: 'canceled', uk: 'cancelled' }, + { us: 'canceling', uk: 'cancelling' }, + { us: 'gray', uk: 'grey' }, + { us: 'fiber', uk: 'fibre' }, + { us: 'defense', uk: 'defence' }, + { us: 'offense', uk: 'offence' }, + { us: 'realize', uk: 'realise' }, + { us: 'realizes', uk: 'realises' }, + { us: 'realized', uk: 'realised' }, + { us: 'realizing', uk: 'realising' }, + { us: 'realization', uk: 'realisation' }, + { us: 'organize', uk: 'organise' }, + { us: 'organizes', uk: 'organises' }, + { us: 'organized', uk: 'organised' }, + { us: 'organizing', uk: 'organising' }, + { us: 'organization', uk: 'organisation' }, + { us: 'analyze', uk: 'analyse' }, + { us: 'analyzes', uk: 'analyses' }, + { us: 'analyzed', uk: 'analysed' }, + { us: 'analyzing', uk: 'analysing' }, + { us: 'recognize', uk: 'recognise' }, + { us: 'recognizes', uk: 'recognises' }, + { us: 'recognized', uk: 'recognised' }, + { us: 'recognizing', uk: 'recognising' }, + { us: 'specialize', uk: 'specialise' }, + { us: 'specializes', uk: 'specialises' }, + { us: 'specialized', uk: 'specialised' }, + { us: 'specializing', uk: 'specialising' }, + { us: 'initialize', uk: 'initialise' }, + { us: 'initializes', uk: 'initialises' }, + { us: 'initialized', uk: 'initialised' }, + { us: 'initializing', uk: 'initialising' }, + { us: 'destabilize', uk: 'destabilise' }, + { us: 'destabilizes', uk: 'destabilises' }, + { us: 'destabilized', uk: 'destabilised' }, + { us: 'destabilizing', uk: 'destabilising' }, + { us: 'equalize', uk: 'equalise' }, + { us: 'equalizes', uk: 'equalises' }, + { us: 'equalized', uk: 'equalised' }, + { us: 'equalizing', uk: 'equalising' }, + { us: 'mesmerize', uk: 'mesmerise' }, + { us: 'mesmerizes', uk: 'mesmerises' }, + { us: 'mesmerized', uk: 'mesmerised' }, + { us: 'mesmerizing', uk: 'mesmerising' }, + { us: 'mom', uk: 'mum' }, +]; + +// ─── Helper Functions ─────────────────────────────────────────────────────── + +function applyCasing(source: string, target: string): string { + if (source.toUpperCase() === source) { + return target.toUpperCase(); + } + if (source[0] && source[0] === source[0].toUpperCase() && source.slice(1) === source.slice(1).toLowerCase()) { + return target[0].toUpperCase() + target.slice(1); + } + return target; +} + +function escapeRegExp(value: string): string { + return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export function getBuiltInUkReplacements(): UkReplacement[] { + return [...UK_REPLACEMENTS]; +} + +function buildUkReplacementData(customReplacements: UkReplacement[] = []): { map: Map; pattern: RegExp } { + const merged = new Map(); + + for (const item of UK_REPLACEMENTS) { + const us = item.us.trim().toLowerCase(); + const uk = item.uk.trim(); + if (us && uk) { + merged.set(us, uk); + } + } + + for (const item of customReplacements) { + const us = item.us.trim().toLowerCase(); + const uk = item.uk.trim(); + if (us && uk) { + merged.set(us, uk); + } + } + + const usWords = Array.from(merged.keys()).map(escapeRegExp).sort((a, b) => b.length - a.length); + if (usWords.length === 0) { + return { map: merged, pattern: /$a/ }; + } + + const pattern = new RegExp(`\\b(${usWords.join('|')})\\b`, 'gi'); + return { map: merged, pattern }; +} + +function convertUsToUkText(text: string, customReplacements: UkReplacement[] = []): string { + const replacementData = buildUkReplacementData(customReplacements); + + let inFencedBlock = false; + const lines = text.split(/(\r?\n)/); + const out: string[] = []; + + for (let i = 0; i < lines.length; i += 2) { + const line = lines[i] ?? ''; + const lineBreak = lines[i + 1] ?? ''; + + if (FENCE_RE.test(line)) { + inFencedBlock = !inFencedBlock; + out.push(line + lineBreak); + continue; + } + + if (inFencedBlock) { + out.push(line + lineBreak); + continue; + } + + const converted = line.replaceAll(replacementData.pattern, (match) => { + const replacement = replacementData.map.get(match.toLowerCase()); + if (!replacement) { + return match; + } + return applyCasing(match, replacement); + }); + + out.push(converted + lineBreak); + } + + return out.join(''); +} + +function convertUsToUkDirectory(dirPath: string, customReplacements: UkReplacement[] = []): number { + let changed = 0; + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name); + if (entry.isDirectory()) { + changed += convertUsToUkDirectory(fullPath, customReplacements); + } else if (entry.isFile() && entry.name.endsWith('.md')) { + const content = fs.readFileSync(fullPath, 'utf-8'); + const converted = convertUsToUkText(content, customReplacements); + if (converted !== content) { + fs.writeFileSync(fullPath, converted, 'utf-8'); + changed++; + } + } + } + + return changed; +} + +function isLegacyUkLanguage(lang: LanguageConfig): boolean { + return lang.code.trim().toUpperCase() === 'UK' || lang.folderName.trim().toUpperCase() === 'UK'; +} + +function prepareDialectFolder( + root: string, + storyFolder: string, + sourceFolderName: string, + dialectFolderName: string, + customReplacements: UkReplacement[] = [] +): void { + const storyRoot = path.join(root, storyFolder); + const sourcePath = path.join(storyRoot, sourceFolderName); + const dialectPath = path.join(storyRoot, dialectFolderName); + + if (!fs.existsSync(sourcePath)) { + throw new Error(`Source folder not found for dialect generation: ${sourcePath}`); + } + if (fs.existsSync(dialectPath)) { + fs.rmSync(dialectPath, { recursive: true, force: true }); + } + fs.cpSync(sourcePath, dialectPath, { recursive: true }); + convertUsToUkDirectory(dialectPath, customReplacements); +} + +function cleanupDialectTempFolder(root: string, storyFolder: string, folderName: string): void { + const p = path.join(root, storyFolder, folderName); + if (fs.existsSync(p)) { + fs.rmSync(p, { recursive: true, force: true }); + } +} + +function romanToInt(roman: string): number | undefined { + const map: Record = { + 'I': 1, 'II': 2, 'III': 3, 'IV': 4, 'V': 5 + }; + return map[roman.toUpperCase()]; +} + +function intToRoman(n: number): string { + const map: Record = { 1: 'I', 2: 'II', 3: 'III', 4: 'IV', 5: 'V' }; + return map[n] ?? 'I'; +} + +function parseActFolder(name: string): ActInfo | undefined { + const m = ACT_FOLDER_RE.exec(name); + if (!m) { return undefined; } + const num = romanToInt(m[2]); + if (num === undefined) { return undefined; } + return { name, number: num, subtitle: m[3]?.trim() }; +} + +function extractChapterNum(filename: string): number | undefined { + const m = CHAPTER_NUM_RE.exec(filename); + return m ? Number.parseInt(m[1], 10) : undefined; +} + +function generateSlug(text: string): string { + return text + .toLowerCase() + .replaceAll(SLUG_CLEAN_RE, '') + .trim() + .split(/\s+/) + .join('-'); +} + +function demoteH1ToH2(text: string): string { + return text.replace(FIRST_H1_RE, '$1## '); +} + +function collapseBlankLines(text: string): string { + return text.replaceAll(BLANK_LINES_RE, '\n\n'); +} + +function formatActTitle(act: ActInfo, lang: LanguageConfig): string { + const roman = intToRoman(act.number); + return act.subtitle + ? `${lang.actPrefix} ${roman} - ${act.subtitle}` + : `${lang.actPrefix} ${roman}`; +} + +// ─── File Discovery ───────────────────────────────────────────────────────── + +function getOrderedFiles(langPath: string, lang: LanguageConfig): OrderedFile[] { + const files: OrderedFile[] = []; + + // Prologue + const prologue = path.join(langPath, 'Prologue.md'); + if (fs.existsSync(prologue)) { + files.push({ filePath: prologue, fileType: { kind: 'prologue' } }); + } + if (lang.prologueLabel !== 'Prologue') { + const localPrologue = path.join(langPath, `${lang.prologueLabel}.md`); + if (fs.existsSync(localPrologue) && !fs.existsSync(prologue)) { + files.push({ filePath: localPrologue, fileType: { kind: 'prologue' } }); + } + } + + // Discover Act folders + const acts: ActInfo[] = []; + const actFolders = new Map(); + + for (const entry of fs.readdirSync(langPath, { withFileTypes: true })) { + if (!entry.isDirectory()) { continue; } + const info = parseActFolder(entry.name); + if (info) { + actFolders.set(info.number, path.join(langPath, entry.name)); + acts.push(info); + } + } + + acts.sort((a, b) => a.number - b.number); + + for (const act of acts) { + files.push({ filePath: actFolders.get(act.number)!, fileType: { kind: 'act', act } }); + + const actPath = actFolders.get(act.number)!; + const chapters: Array<{ num: number; filePath: string }> = []; + + for (const entry of fs.readdirSync(actPath, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith('.md')) { continue; } + const num = extractChapterNum(entry.name); + if (num !== undefined) { + chapters.push({ num, filePath: path.join(actPath, entry.name) }); + } + } + + chapters.sort((a, b) => a.num - b.num); + + for (const ch of chapters) { + files.push({ + filePath: ch.filePath, + fileType: { kind: 'chapter', act, num: ch.num } + }); + } + } + + // Epilogue + const epilogue = path.join(langPath, 'Epilogue.md'); + if (fs.existsSync(epilogue)) { + files.push({ filePath: epilogue, fileType: { kind: 'epilogue' } }); + } + if (lang.epilogueLabel !== 'Epilogue') { + const localEpilogue = path.join(langPath, `${lang.epilogueLabel}.md`); + if (fs.existsSync(localEpilogue) && !fs.existsSync(epilogue)) { + files.push({ filePath: localEpilogue, fileType: { kind: 'epilogue' } }); + } + } + + return files; +} + +// ─── TOC Generation ───────────────────────────────────────────────────────── + +function generateToc(files: OrderedFile[], lang: LanguageConfig): string { + let toc = '# Table of Contents\n\n'; + + for (const file of files) { + let title: string; + + switch (file.fileType.kind) { + case 'prologue': + title = lang.prologueLabel; + toc += `- [${title}](#${generateSlug(title)})\n`; + break; + + case 'act': + title = formatActTitle(file.fileType.act, lang); + toc += `- ${title}\n`; + break; + + case 'chapter': { + const content = fs.readFileSync(file.filePath, 'utf-8'); + const h1match = H1_RE.exec(content); + title = h1match ? h1match[1] : path.basename(file.filePath, '.md'); + toc += ` - [${title}](#${generateSlug(title)})\n`; + break; + } + + case 'epilogue': + title = lang.epilogueLabel; + toc += `- [${title}](#${generateSlug(title)})\n`; + break; + } + } + + return toc; +} + +// ─── Markdown Content Builder ─────────────────────────────────────────────── + +const PAGE_BREAK = ` +\`\`\`{=openxml} + +\`\`\` +`; + +function buildMarkdownContent(files: OrderedFile[], options: MergeOptions): string { + let content = ''; + + if (options.includeToc) { + const toc = generateToc(files, options.language); + content += toc + '\n---\n\n'; + } + + let currentAct: number | null = null; + + for (const file of files) { + switch (file.fileType.kind) { + case 'prologue': + case 'epilogue': { + const fileContent = fs.readFileSync(file.filePath, 'utf-8'); + content += fileContent; + break; + } + case 'act': + currentAct = file.fileType.act.number; + content += `# ${formatActTitle(file.fileType.act, options.language)}\n\n`; + break; + case 'chapter': { + const fileContent = fs.readFileSync(file.filePath, 'utf-8'); + if (currentAct !== file.fileType.act.number) { + currentAct = file.fileType.act.number; + content += `# ${formatActTitle(file.fileType.act, options.language)}\n\n`; + } + content += fileContent; + break; + } + } + + if (options.includeSeparators) { + content += '\n\n---\n\n'; + } else { + content += '\n\n'; + } + } + + return collapseBlankLines(content); +} + +// ─── Pandoc Content Builder ───────────────────────────────────────────────── + +function hasNextContentFile(files: OrderedFile[], currentIndex: number): boolean { + for (let i = currentIndex + 1; i < files.length; i++) { + if (files[i].fileType.kind !== 'act') { return true; } + } + return false; +} + +function imageMarkdownFor(file: OrderedFile, options: MergeOptions): string | undefined { + let imageName: string; + switch (file.fileType.kind) { + case 'prologue': imageName = 'prologue.jpg'; break; + case 'epilogue': imageName = 'epilogue.jpg'; break; + case 'chapter': imageName = `chapter${file.fileType.num}.jpg`; break; + default: return undefined; + } + + const imagePath = path.join(options.root, 'images', imageName); + if (!fs.existsSync(imagePath)) { return undefined; } + return `![](${imagePath.replaceAll(/\\/g, '/')})\n\n`; +} + +function insertImageAfterHeading(content: string, imageMd: string): string { + const m = HEADING_LINE_RE.exec(content); + if (m) { + const end = m.index + m[0].length; + return content.substring(0, end) + imageMd + content.substring(end); + } + return imageMd + content; +} + +function coverMarkdown(options: MergeOptions): string | undefined { + const coverPath = path.join(options.root, options.storyFolder, options.language.folderName, 'cover.jpg'); + if (!fs.existsSync(coverPath)) { return undefined; } + return `![](${coverPath.replaceAll(/\\/g, '/')})\n\n`; +} + +function buildPandocContent(files: OrderedFile[], options: MergeOptions, outputType: OutputType): string { + let content = ''; + + const pageBreak = outputType === 'pdf' + ? '\n```{=latex}\n\\newpage\n```\n' + : PAGE_BREAK; + + if (outputType === 'docx' || outputType === 'pdf') { + const cover = coverMarkdown(options); + if (cover) { + content += cover + pageBreak + '\n'; + } + } + + for (let i = 0; i < files.length; i++) { + const file = files[i]; + + switch (file.fileType.kind) { + case 'prologue': + case 'epilogue': { + let fileContent = fs.readFileSync(file.filePath, 'utf-8'); + const img = imageMarkdownFor(file, options); + if (img) { fileContent = insertImageAfterHeading(fileContent, img); } + content += fileContent; + break; + } + case 'act': + content += `# ${formatActTitle(file.fileType.act, options.language)}\n\n`; + break; + case 'chapter': { + let fileContent = fs.readFileSync(file.filePath, 'utf-8'); + fileContent = demoteH1ToH2(fileContent); + const img = imageMarkdownFor(file, options); + if (img) { fileContent = insertImageAfterHeading(fileContent, img); } + content += fileContent; + break; + } + } + + if (i < files.length - 1) { + content += '\n\n'; + if ((outputType === 'docx' || outputType === 'pdf') && hasNextContentFile(files, i)) { + content += pageBreak; + } + } + } + + return collapseBlankLines(content); +} + +// ─── Pandoc & LibreOffice Invocation ───────────────────────────────────────── + +function resolveBookTitle(options: MergeOptions): string { + const fromOptions = options.bookTitle?.trim(); + if (fromOptions) { return fromOptions; } + + const fromLanguage = options.language.bookTitle?.trim(); + if (fromLanguage) { return fromLanguage; } + + return 'Book'; +} + +export async function checkPandoc(pandocPath: string): Promise { + return new Promise((resolve, reject) => { + cp.execFile(pandocPath, ['--version'], { encoding: 'utf-8' }, (err, stdout) => { + if (err) { + reject(new Error('Pandoc is not available. Install it from https://pandoc.org')); + } else { + resolve(stdout.split('\n')[0] ?? 'unknown'); + } + }); + }); +} + +const pandocFormatsCache = new Map(); + +export async function getPandocOutputFormats(pandocPath: string): Promise { + const cached = pandocFormatsCache.get(pandocPath); + if (cached) { return cached; } + return new Promise((resolve) => { + cp.execFile(pandocPath, ['--list-output-formats'], { encoding: 'utf-8' }, (err, stdout) => { + if (err) { resolve([]); return; } + const formats = stdout.split(/\r?\n/).map(v => v.trim().toLowerCase()).filter(Boolean); + pandocFormatsCache.set(pandocPath, formats); + resolve(formats); + }); + }); +} + +export function clearPandocCapabilityCache(): void { + pandocFormatsCache.clear(); +} + +function runPandoc( + inputPath: string, + outputPath: string, + outputType: OutputType, + root: string, + title: string, + lang: LanguageConfig, + author: string | undefined, + pandocPath: string +): Promise { + const args: string[] = [ + inputPath, + '-o', outputPath, + '--metadata', `title=${title}`, + ]; + + if (author) { + args.push('--metadata', `author=${author}`); + } + + const langCode = lang.code.toLowerCase(); + args.push('--metadata', `lang=${langCode}`); + + const date = new Date().toISOString().slice(0, 10); + args.push('--metadata', `date=${date}`); + + if (outputType === 'docx') { + args.push('--from=markdown+raw_attribute'); + const reference = path.join(root, 'reference.docx'); + if (fs.existsSync(reference)) { + args.push('--reference-doc', reference); + } + } + + if (outputType === 'epub') { + args.push('--split-level=2'); + const cover = path.join(root, 'Story', lang.folderName, 'cover.jpg'); + if (fs.existsSync(cover)) { + args.push('--epub-cover-image', cover); + } + } + + return new Promise((resolve, reject) => { + cp.execFile(pandocPath, args, { encoding: 'utf-8' }, (err, _stdout, stderr) => { + if (err) { + const details = stderr || err.message; + reject(new Error(`Pandoc failed: ${details}`)); + } else { + resolve(); + } + }); + }); +} + +async function runLibreOfficeToPdf( + docxPath: string, + outputDir: string, + finalPdfPath: string, + libreOfficePath: string +): Promise { + return new Promise((resolve, reject) => { + cp.execFile( + libreOfficePath, + ['--headless', '--convert-to', 'pdf', docxPath, '--outdir', outputDir], + { encoding: 'utf-8' }, + (err, _stdout, stderr) => { + if (err) { + const details = stderr || err.message; + reject(new Error( + `LibreOffice PDF conversion failed: ${details}\n` + + 'Make sure LibreOffice is installed and libreOfficePath is correct.' + )); + return; + } + const tempPdfName = path.basename(docxPath, '.docx') + '.pdf'; + const tempPdfPath = path.join(outputDir, tempPdfName); + try { + fs.renameSync(tempPdfPath, finalPdfPath); + resolve(); + } catch (renameErr: any) { + reject(new Error(`Failed to rename LibreOffice output: ${renameErr.message}`)); + } + } + ); + }); +} + +function formatDirectory(dirPath: string): number { + let count = 0; + const entries = fs.readdirSync(dirPath, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name); + if (entry.isDirectory()) { + count += formatDirectory(fullPath); + } else if (entry.isFile() && entry.name.endsWith('.md')) { + const content = fs.readFileSync(fullPath, 'utf-8'); + const formatted = updateTypography(content); + if (content !== formatted) { + fs.writeFileSync(fullPath, formatted, 'utf-8'); + count++; + } + } + } + + return count; +} + +// ─── Main Entry Point ─────────────────────────────────────────────────────── + +export async function mergeBook(options: MergeOptions): Promise { + const isLegacyUk = isLegacyUkLanguage(options.language); + + if (isLegacyUk) { + prepareDialectFolder(options.root, options.storyFolder, 'EN', 'UK', options.ukReplacements ?? []); + } else if (options.dialectCode) { + prepareDialectFolder( + options.root, options.storyFolder, + options.language.folderName, + `_dialect_${options.dialectCode}`, + options.ukReplacements ?? [] + ); + } + + const effectiveFolderName = isLegacyUk + ? 'UK' + : options.dialectCode + ? `_dialect_${options.dialectCode}` + : options.language.folderName; + + try { + const langPath = path.join(options.root, options.storyFolder, effectiveFolderName); + + if (!fs.existsSync(langPath)) { + throw new Error(`Language folder not found: ${langPath}`); + } + + formatDirectory(langPath); + + const files = getOrderedFiles(langPath, options.language); + if (files.length === 0) { + throw new Error(`No markdown files found in ${langPath}`); + } + + const outputDir = path.join(options.root, options.outputDir); + fs.mkdirSync(outputDir, { recursive: true }); + + const folderSuffix = options.dialectCode + ? options.dialectCode.toUpperCase() + : options.language.folderName; + const baseName = `${options.filePrefix}_${folderSuffix}_Merged`; + const outputs: string[] = []; + const warnings: string[] = []; + + const needsPandoc = options.outputTypes.some(t => t === 'docx' || t === 'epub' || t === 'pdf'); + if (needsPandoc) { + await checkPandoc(options.pandocPath); + + const supported = await getPandocOutputFormats(options.pandocPath); + if (supported.length > 0) { + for (const t of options.outputTypes) { + const pandocFormat = t === 'pdf' ? 'docx' : t; + if (pandocFormat === 'md') { continue; } + if (!supported.includes(pandocFormat)) { + warnings.push( + `Pandoc at ${options.pandocPath} does not support '${pandocFormat}' output. ` + + `Install a full pandoc build from https://pandoc.org or remove '${t}' from the export list.` + ); + } + } + } + } + + const title = resolveBookTitle(options); + + for (const outputType of options.outputTypes) { + const outputPath = path.join(outputDir, `${baseName}.${outputType}`); + + if (outputType === 'md') { + const content = buildMarkdownContent(files, options); + fs.writeFileSync(outputPath, content, 'utf-8'); + outputs.push(outputPath); + } else if (outputType === 'pdf') { + const libreOfficePath = options.libreOfficePath?.trim() || 'libreoffice'; + const tempMdPath = path.join(outputDir, `${baseName}_pdf_temp.md`); + const tempDocxPath = path.join(outputDir, `${baseName}_pdf_temp.docx`); + const content = buildPandocContent(files, options, 'docx'); + fs.writeFileSync(tempMdPath, content, 'utf-8'); + try { + await runPandoc(tempMdPath, tempDocxPath, 'docx', options.root, title, options.language, options.author, options.pandocPath); + await runLibreOfficeToPdf(tempDocxPath, outputDir, outputPath, libreOfficePath); + outputs.push(outputPath); + } finally { + try { fs.unlinkSync(tempMdPath); } catch { /* ignore */ } + try { fs.unlinkSync(tempDocxPath); } catch { /* ignore */ } + } + } else { + const content = buildPandocContent(files, options, outputType); + const tempPath = path.join(outputDir, `${baseName}_temp.md`); + fs.writeFileSync(tempPath, content, 'utf-8'); + try { + await runPandoc(tempPath, outputPath, outputType, options.root, title, options.language, options.author, options.pandocPath); + outputs.push(outputPath); + } finally { + try { fs.unlinkSync(tempPath); } catch { /* ignore */ } + } + } + } + + return { outputs, filesMerged: files.length, warnings }; + } finally { + if (isLegacyUk) { + cleanupDialectTempFolder(options.root, options.storyFolder, 'UK'); + } else if (options.dialectCode) { + cleanupDialectTempFolder(options.root, options.storyFolder, `_dialect_${options.dialectCode}`); + } + } +} diff --git a/bindery-merge/src/tool-locate.ts b/bindery-merge/src/tool-locate.ts new file mode 100644 index 0000000..bc54d12 --- /dev/null +++ b/bindery-merge/src/tool-locate.ts @@ -0,0 +1,153 @@ +/** + * Auto-detect Pandoc and LibreOffice installations across platforms. + * + * Resolution order for each tool: + * 1. Explicit user setting (if set to a non-default value) + * 2. Command on PATH (via `where.exe` / `which`) + * 3. Well-known per-OS install locations + * 4. Fallback to command name (let PATH resolve it) + * + * Results are cached per-process. Call clearLocateCache() when settings change. + */ + +import * as cp from 'node:child_process'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +export type ToolName = 'pandoc' | 'libreoffice'; + +interface ResolvedTool { + path: string; + source: 'setting' | 'path' | 'default' | 'fallback'; +} + +const cache = new Map(); + +export function clearLocateCache(): void { + cache.clear(); +} + +function wellKnownPaths(tool: ToolName): string[] { + const env = process.env; + const home = env.HOME || env.USERPROFILE || ''; + + if (process.platform === 'win32') { + const programFiles = env['ProgramFiles'] || 'C:\\Program Files'; + const programFiles86 = env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)'; + const localAppData = env['LOCALAPPDATA'] || path.join(home, 'AppData', 'Local'); + + if (tool === 'pandoc') { + return [ + path.join(localAppData, 'Pandoc', 'pandoc.exe'), + path.join(programFiles, 'Pandoc', 'pandoc.exe'), + path.join(programFiles86, 'Pandoc', 'pandoc.exe'), + ]; + } + return [ + path.join(programFiles, 'LibreOffice', 'program', 'soffice.com'), + path.join(programFiles86, 'LibreOffice', 'program', 'soffice.com'), + path.join(programFiles, 'LibreOffice', 'program', 'soffice.exe'), + path.join(programFiles86, 'LibreOffice', 'program', 'soffice.exe'), + ]; + } + + if (process.platform === 'darwin') { + if (tool === 'pandoc') { + return [ + '/opt/homebrew/bin/pandoc', + '/usr/local/bin/pandoc', + '/usr/bin/pandoc', + ]; + } + return [ + '/Applications/LibreOffice.app/Contents/MacOS/soffice', + '/opt/homebrew/bin/soffice', + '/usr/local/bin/soffice', + ]; + } + + // Linux / other POSIX + if (tool === 'pandoc') { + return ['/usr/bin/pandoc', '/usr/local/bin/pandoc']; + } + return ['/usr/bin/libreoffice', '/usr/bin/soffice', '/usr/local/bin/libreoffice']; +} + +function defaultCommand(tool: ToolName): string { + if (tool === 'pandoc') { return 'pandoc'; } + return process.platform === 'win32' ? 'soffice.com' : 'libreoffice'; +} + +function pathCandidates(tool: ToolName): string[] { + if (tool === 'pandoc') { return ['pandoc']; } + return process.platform === 'win32' + ? ['soffice.com', 'soffice.exe', 'soffice'] + : ['libreoffice', 'soffice']; +} + +function isSettingDefault(tool: ToolName, value: string | undefined): boolean { + const v = (value ?? '').trim(); + if (v === '') { return true; } + if (tool === 'pandoc' && v === 'pandoc') { return true; } + if (tool === 'libreoffice' && (v === 'libreoffice' || v === 'soffice')) { return true; } + return false; +} + +function resolveOnPath(cmd: string): string | null { + const locator = process.platform === 'win32' ? 'where.exe' : 'which'; + try { + const result = cp.spawnSync(locator, [cmd], { encoding: 'utf-8', timeout: 5000 }); + if (result.status !== 0) { return null; } + const first = (result.stdout || '').split(/\r?\n/).map(s => s.trim()).find(Boolean); + return first || null; + } catch { + return null; + } +} + +export function locateTool(tool: ToolName, setting: string | undefined): ResolvedTool { + const cached = cache.get(tool); + if (cached && cached.source === 'setting' && (setting ?? '').trim() === cached.path) { + return cached; + } + if (cached && cached.source !== 'setting' && isSettingDefault(tool, setting)) { + return cached; + } + + const trimmed = (setting ?? '').trim(); + if (!isSettingDefault(tool, trimmed)) { + if (fs.existsSync(trimmed)) { + const resolved: ResolvedTool = { path: trimmed, source: 'setting' }; + cache.set(tool, resolved); + return resolved; + } + const resolved: ResolvedTool = { path: trimmed, source: 'setting' }; + cache.set(tool, resolved); + return resolved; + } + + for (const cmd of pathCandidates(tool)) { + const onPath = resolveOnPath(cmd); + if (onPath && fs.existsSync(onPath)) { + const resolved: ResolvedTool = { path: onPath, source: 'path' }; + cache.set(tool, resolved); + return resolved; + } + } + + for (const candidate of wellKnownPaths(tool)) { + if (fs.existsSync(candidate)) { + const resolved: ResolvedTool = { path: candidate, source: 'default' }; + cache.set(tool, resolved); + return resolved; + } + } + + const fallback: ResolvedTool = { path: defaultCommand(tool), source: 'fallback' }; + cache.set(tool, fallback); + return fallback; +} + +export function locateToolPath(tool: ToolName, setting: string | undefined): string { + return locateTool(tool, setting).path; +} diff --git a/vscode-ext/test/merge-extended.test.ts b/bindery-merge/test/merge-extended.test.ts similarity index 99% rename from vscode-ext/test/merge-extended.test.ts rename to bindery-merge/test/merge-extended.test.ts index 8b55c87..a464b09 100644 --- a/vscode-ext/test/merge-extended.test.ts +++ b/bindery-merge/test/merge-extended.test.ts @@ -48,7 +48,7 @@ import { const tempRoots: string[] = []; function makeRoot(): string { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'bindery-merge-ext-')); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'bindery-merge-test-')); tempRoots.push(root); return root; } diff --git a/vscode-ext/test/merge-mocked.test.ts b/bindery-merge/test/merge-mocked.test.ts similarity index 94% rename from vscode-ext/test/merge-mocked.test.ts rename to bindery-merge/test/merge-mocked.test.ts index 7062166..d499578 100644 --- a/vscode-ext/test/merge-mocked.test.ts +++ b/bindery-merge/test/merge-mocked.test.ts @@ -10,24 +10,6 @@ import { vi } from 'vitest'; -// ─── Mock vscode ────────────────────────────────────────────────────────────── -vi.mock('vscode', () => ({ - workspace: { - getConfiguration: () => ({ get: (_key: string) => undefined }), - workspaceFolders: undefined, - }, - window: { - showErrorMessage: vi.fn(), - showInformationMessage: vi.fn(), - showInputBox: vi.fn(), - showQuickPick: vi.fn(), - }, - Uri: { file: (p: string) => ({ fsPath: p }) }, - ConfigurationTarget: { Global: 1, Workspace: 2 }, - LanguageModelToolResult: class { constructor(public readonly content: unknown[]) {} }, - LanguageModelTextPart: class { constructor(public readonly value: string) {} }, -})); - // ─── Mock child_process ─────────────────────────────────────────────────────── vi.mock('node:child_process', () => { type ExecFileCallback = (err: Error | null, stdout: string, stderr: string) => void; @@ -99,7 +81,7 @@ import { const tempRoots: string[] = []; function makeRoot(): string { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'bindery-merge-mock-')); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'bindery-merge-test-')); tempRoots.push(root); return root; } diff --git a/vscode-ext/test/merge.test.ts b/bindery-merge/test/merge.test.ts similarity index 89% rename from vscode-ext/test/merge.test.ts rename to bindery-merge/test/merge.test.ts index 93aefe0..8cb0fb4 100644 --- a/vscode-ext/test/merge.test.ts +++ b/bindery-merge/test/merge.test.ts @@ -1,31 +1,8 @@ /** * Unit tests for merge.ts — pure/filesystem functions only. * Does NOT invoke pandoc (only 'md' outputType is exercised here). - * - * VS Code is mocked because workspace.ts (imported transitively for its - * exported types) may resolve vscode at module-load time. */ -import { vi } from 'vitest'; - -// ─── Mock vscode (must precede all transitive imports) ──────────────────────── -vi.mock('vscode', () => ({ - workspace: { - getConfiguration: () => ({ get: (_key: string) => undefined }), - workspaceFolders: undefined, - }, - window: { - showErrorMessage: vi.fn(), - showInformationMessage: vi.fn(), - showInputBox: vi.fn(), - showQuickPick: vi.fn(), - }, - Uri: { file: (p: string) => ({ fsPath: p }) }, - ConfigurationTarget: { Global: 1, Workspace: 2 }, - LanguageModelToolResult: class { constructor(public readonly content: unknown[]) {} }, - LanguageModelTextPart: class { constructor(public readonly value: string) {} }, -})); - // ─── Imports ────────────────────────────────────────────────────────────────── import * as fs from 'node:fs'; @@ -44,7 +21,7 @@ import { const tempRoots: string[] = []; function makeRoot(): string { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'bindery-vscode-merge-test-')); + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'bindery-merge-test-')); tempRoots.push(root); return root; } diff --git a/bindery-merge/test/tsconfig.json b/bindery-merge/test/tsconfig.json new file mode 100644 index 0000000..c549801 --- /dev/null +++ b/bindery-merge/test/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "composite": false + }, + "include": ["**/*.ts"] +} diff --git a/bindery-merge/tsconfig.json b/bindery-merge/tsconfig.json new file mode 100644 index 0000000..fd51b22 --- /dev/null +++ b/bindery-merge/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "module": "commonjs", + "target": "ES2022", + "outDir": "out", + "lib": ["ES2022"], + "sourceMap": true, + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "types": ["node"], + "declaration": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/bindery-merge/vitest.config.ts b/bindery-merge/vitest.config.ts new file mode 100644 index 0000000..183b5c4 --- /dev/null +++ b/bindery-merge/vitest.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['test/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'text-summary', 'lcov', 'html'], + reportsDirectory: './coverage', + include: ['src/**/*.ts'], + exclude: [ + 'src/index.ts', // barrel re-export only + ], + thresholds: { + statements: 75, + branches: 65, + functions: 85, + lines: 75, + }, + }, + }, +}); diff --git a/obsidian-plugin/package.json b/obsidian-plugin/package.json index 85b1db5..c06557e 100644 --- a/obsidian-plugin/package.json +++ b/obsidian-plugin/package.json @@ -12,7 +12,8 @@ "test:coverage": "vitest run --coverage" }, "dependencies": { - "@bindery/core": "*" + "@bindery/core": "*", + "@bindery/merge": "*" }, "devDependencies": { "@types/node": "^25.6.0", diff --git a/obsidian-plugin/src/ai-setup.ts b/obsidian-plugin/src/ai-setup.ts new file mode 100644 index 0000000..d06b218 --- /dev/null +++ b/obsidian-plugin/src/ai-setup.ts @@ -0,0 +1,150 @@ +/** + * Obsidian-specific AI setup wrapper. + * + * Generates AI instruction files (CLAUDE.md, copilot-instructions.md, .cursor/rules, AGENTS.md) + * and Claude skill templates from the book's .bindery/settings.json. + * + * For Obsidian, this is a simpler adaptation since we don't have the VS Code LM API, + * so we just generate the files to the filesystem directly. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { App } from 'obsidian'; +import { BINDERY_FOLDER, SETTINGS_FILENAME } from '@bindery/core'; +import { renderTemplate, type TemplateContext } from '@bindery/core'; + +export type AiTarget = 'claude' | 'copilot' | 'cursor' | 'agents'; +export type SkillTemplate = + | 'review' + | 'brainstorm' + | 'memory' + | 'translate' + | 'translation-review' + | 'status' + | 'continuity' + | 'read-aloud' + | 'read-in' + | 'proof-read'; + +export const ALL_SKILLS: SkillTemplate[] = [ + 'review', 'brainstorm', 'memory', 'translate', 'translation-review', + 'status', 'continuity', 'read-aloud', 'read-in', 'proof-read', +]; + +export interface AiSetupResult { + created: string[]; + skipped: string[]; +} + +/** + * Generate AI instruction files from vault settings. + */ +export async function setupAiFiles( + app: App, + bookRoot: string, + targets: AiTarget[] = ['claude', 'copilot'], + skills: SkillTemplate[] = ALL_SKILLS, + overwrite: boolean = false +): Promise { + const settingsPath = path.join(bookRoot, BINDERY_FOLDER, SETTINGS_FILENAME); + if (!fs.existsSync(settingsPath)) { + throw new Error(`.bindery/settings.json not found. Run "Bindery: Initialize workspace" first.`); + } + + const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as Record; + const result: AiSetupResult = { created: [], skipped: [] }; + + const ctx: TemplateContext = buildContext(settings); + + // Generate target-specific files + for (const target of targets) { + switch (target) { + case 'claude': + writeFile(bookRoot, 'CLAUDE.md', renderTemplate('claude', ctx), overwrite, result); + for (const skill of skills) { + const skillDir = path.join('.claude', 'skills', skill); + writeFile(bookRoot, path.join(skillDir, 'SKILL.md'), renderTemplate(skill, ctx), overwrite, result); + } + break; + case 'copilot': + writeFile(bookRoot, path.join('.github', 'copilot-instructions.md'), renderTemplate('copilot', ctx), overwrite, result); + break; + case 'cursor': + writeFile(bookRoot, path.join('.cursor', 'rules'), renderTemplate('cursor', ctx), overwrite, result); + break; + case 'agents': + writeFile(bookRoot, 'AGENTS.md', renderTemplate('agents', ctx), overwrite, result); + break; + } + } + + return result; +} + +/** + * Build template context from settings. + */ +function buildContext(settings: Record): TemplateContext { + const title = (typeof settings.bookTitle === 'string' ? settings.bookTitle : undefined) ?? 'Untitled'; + const author = (typeof settings.author === 'string' ? settings.author : undefined) ?? ''; + const description = (typeof settings.description === 'string' ? settings.description : undefined) ?? ''; + const genre = (typeof settings.genre === 'string' ? settings.genre : undefined) ?? ''; + const audience = (typeof settings.targetAudience === 'string' ? settings.targetAudience : undefined) ?? ''; + const storyFolder = (typeof settings.storyFolder === 'string' ? settings.storyFolder : undefined) ?? 'Story'; + const notesFolder = 'Notes'; + const arcFolder = 'Arc'; + const memoriesFolder = '.bindery/memories'; + + const rawLanguages = settings.languages as any[] | undefined; + const languages: Array<{ code: string; folderName: string }> = Array.isArray(rawLanguages) + ? rawLanguages.filter( + (l: any) => typeof l?.code === 'string' && typeof l?.folderName === 'string' + ) + : []; + + const langList = languages.length > 0 + ? languages.map((l, i) => i === 0 ? `${l.code} (source)` : `${l.code} (translation)`).join(', ') + : 'EN (source)'; + + return { + title, + author, + description, + genre, + audience, + storyFolder, + notesFolder, + arcFolder, + languages, + langList, + hasMultiLang: languages.length > 1, + memoriesFolder, + }; +} + +/** + * Write a single file, respecting overwrite flag. + */ +function writeFile( + bookRoot: string, + relPath: string, + content: string, + overwrite: boolean, + result: AiSetupResult +): void { + const absPath = path.join(bookRoot, relPath); + const dir = path.dirname(absPath); + + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + if (fs.existsSync(absPath) && !overwrite) { + result.skipped.push(relPath); + return; + } + + fs.writeFileSync(absPath, content, 'utf-8'); + result.created.push(relPath); +} diff --git a/obsidian-plugin/src/main.ts b/obsidian-plugin/src/main.ts index 90d3af6..de2b4e3 100644 --- a/obsidian-plugin/src/main.ts +++ b/obsidian-plugin/src/main.ts @@ -10,8 +10,12 @@ */ import { BINDERY_FOLDER, SETTINGS_FILENAME } from '@bindery/core'; +import type { OutputType } from '@bindery/merge'; +import { mergeBook } from './merge'; import { formatFile } from './formatter'; import { exportBook, resolveBookRoot } from './exporter'; +import { setupAiFiles, type AiTarget, ALL_SKILLS } from './ai-setup'; +import { readSettings, writeSettings, readTranslations, addDialectRule, addTranslationEntry, addLanguage, findProbableUsWords } from './workspace'; import { BinderySettingsTab, DEFAULT_SETTINGS, type BinderySettings } from './settings-tab'; import { Plugin } from 'obsidian'; import type { Editor, TFile } from 'obsidian'; @@ -88,6 +92,17 @@ export default class BinderyPlugin extends Plugin { }); } + // Merge commands (NEW: discover chapters and merge them) + for (const fmt of ['md', 'docx', 'epub', 'pdf', 'all'] as const) { + const outputTypes = fmt === 'all' ? ['md', 'docx', 'epub', 'pdf'] : [fmt]; + const label = fmt === 'all' ? 'All Formats' : fmt.toUpperCase(); + this.addCommand({ + id: `merge-${fmt}`, + name: `Bindery: Merge Chapters → ${label}`, + callback: () => void this.mergeBook(outputTypes as any), + }); + } + // Init workspace this.addCommand({ id: 'init-workspace', @@ -95,6 +110,40 @@ export default class BinderyPlugin extends Plugin { callback: () => void this.initWorkspace(), }); + // AI setup command + this.addCommand({ + id: 'setup-ai-files', + name: 'Bindery: Generate AI Assistant Files', + callback: () => void this.setupAiCommand(), + }); + + // Workspace management commands + this.addCommand({ + id: 'add-dialect', + name: 'Bindery: Add Dialect Rule', + callback: () => void this.addDialectCommand(), + }); + this.addCommand({ + id: 'add-translation', + name: 'Bindery: Add Translation Glossary Entry', + callback: () => void this.addTranslationCommand(), + }); + this.addCommand({ + id: 'add-language', + name: 'Bindery: Add Language', + callback: () => void this.addLanguageCommand(), + }); + this.addCommand({ + id: 'open-translations', + name: 'Bindery: Open Translations File', + callback: () => void this.openTranslationsCommand(), + }); + this.addCommand({ + id: 'find-us-to-uk-words', + name: 'Bindery: Find Probable US → UK Words', + callback: () => void this.findUsToUkCommand(), + }); + // Show MCP config snippet — copies JSON to clipboard this.addCommand({ id: 'show-mcp-config', @@ -105,9 +154,170 @@ export default class BinderyPlugin extends Plugin { this.addSettingTab(new BinderySettingsTab(this.app, this)); } + private async mergeBook(outputTypes: OutputType[]): Promise { + try { + const vaultPath = this.getVaultBasePath(); + const bookRoot = resolveBookRoot(vaultPath, this.settings.bookRoot); + + console.log('Bindery: Merging chapters…'); + const result = await mergeBook( + this.app, + this.app.vault, + vaultPath, + bookRoot, + this.settings, + outputTypes + ); + + const names = result.outputs.map((p: string) => path.basename(p)).join(', '); + console.log(`✓ Merged → ${names}`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(`✗ Merge failed: ${message}`); + } + } + private async formatActive(): Promise { - // In the real Obsidian plugin, get the active file via workspace.getActiveFile(). - // This is a placeholder; full implementation requires the Obsidian runtime API. + const file = this.app.workspace?.getActiveFile?.(); + if (!file || file.extension !== 'md') { + console.log('No markdown file active'); + return; + } + try { + await formatFile(this.app.vault, file); + console.log('✓ Formatted'); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(`✗ Format failed: ${message}`); + } + } + + private async setupAiCommand(): Promise { + try { + const vaultPath = this.getVaultBasePath(); + const bookRoot = resolveBookRoot(vaultPath, this.settings.bookRoot); + + const result = await setupAiFiles(this.app, bookRoot, ['claude', 'copilot'], ALL_SKILLS, false); + const msg = `Generated: ${result.created.length}, Skipped: ${result.skipped.length}`; + console.log(`✓ AI setup: ${msg}`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(`✗ AI setup failed: ${message}`); + } + } + + private async addDialectCommand(): Promise { + try { + const vaultPath = this.getVaultBasePath(); + const bookRoot = resolveBookRoot(vaultPath, this.settings.bookRoot); + + const language = await this.promptString('Language code (e.g. EN, NL):', 'EN'); + if (!language) return; + + const from = await this.promptString('US spelling:', ''); + if (!from) return; + + const to = await this.promptString('UK/Target spelling:', ''); + if (!to) return; + + addDialectRule(bookRoot, language, from, to); + console.log(`✓ Added dialect rule: ${from} → ${to}`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(`✗ Failed: ${message}`); + } + } + + private async addTranslationCommand(): Promise { + try { + const vaultPath = this.getVaultBasePath(); + const bookRoot = resolveBookRoot(vaultPath, this.settings.bookRoot); + const settings = readSettings(bookRoot); + if (!settings?.languages) throw new Error('No languages configured'); + + const term = await this.promptString('Term to add:', ''); + if (!term) return; + + const translations: Record = {}; + for (const lang of settings.languages) { + const trans = await this.promptString(`Translation in ${lang.code}:`, ''); + if (trans) translations[lang.code] = trans; + } + + addTranslationEntry(bookRoot, term, translations); + console.log(`✓ Added translation entry: ${term}`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(`✗ Failed: ${message}`); + } + } + + private async addLanguageCommand(): Promise { + try { + const vaultPath = this.getVaultBasePath(); + const bookRoot = resolveBookRoot(vaultPath, this.settings.bookRoot); + + const code = await this.promptString('Language code (e.g. NL, FR, DE):', ''); + if (!code) return; + + const folderName = await this.promptString('Folder name:', code); + if (!folderName) return; + + addLanguage(bookRoot, code, folderName); + console.log(`✓ Added language: ${code}`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(`✗ Failed: ${message}`); + } + } + + private async openTranslationsCommand(): Promise { + try { + const vaultPath = this.getVaultBasePath(); + const bookRoot = resolveBookRoot(vaultPath, this.settings.bookRoot); + const translationsPath = path.join(bookRoot, '.bindery', 'translations.json'); + + if (!fs.existsSync(translationsPath)) { + console.log('translations.json not found'); + return; + } + + console.log('Translations file:', translationsPath); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(`✗ Failed: ${message}`); + } + } + + private async findUsToUkCommand(): Promise { + try { + const file = this.app.workspace?.getActiveFile?.(); + if (!file) { + console.log('No file active'); + return; + } + + const content = await this.app.vault.read(file); + const words = findProbableUsWords(content); + + if (words.length === 0) { + console.log('No probable US words found'); + return; + } + + const list = words.slice(0, 10).join(', ') + (words.length > 10 ? `, +${words.length - 10} more` : ''); + console.log(`Found: ${list}`); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(`✗ Failed: ${message}`); + } + } + + private async promptString(prompt: string, defaultValue: string = ''): Promise { + return new Promise((resolve) => { + const result = window.prompt(prompt, defaultValue); + resolve(result); + }); } private insertStartReviewMarker(editor: Editor): void { diff --git a/obsidian-plugin/src/merge.ts b/obsidian-plugin/src/merge.ts new file mode 100644 index 0000000..065215a --- /dev/null +++ b/obsidian-plugin/src/merge.ts @@ -0,0 +1,68 @@ +/** + * Obsidian-specific book merge wrapper. + * + * Adapts @bindery/merge functions to work with Obsidian's Vault API. + * Handles file discovery and merging from an Obsidian vault, producing merged markdown + * suitable for export via Pandoc + LibreOffice. + */ + +import { + mergeBook as mergeBookCore, + type MergeOptions as MergeOptionsCore, + type MergeResult, + type OutputType, +} from '@bindery/merge'; +import type { App, Vault } from 'obsidian'; +import type { BinderySettings } from './settings-tab'; + +/** + * Merge chapters from an Obsidian vault into a single document. + * + * @param app - Obsidian App instance for progress notifications + * @param vault - Obsidian Vault instance for file I/O + * @param vaultBasePath - Absolute path to the vault root + * @param bookRoot - Absolute path to the book root folder + * @param settings - Bindery plugin settings + * @param outputTypes - Export formats to generate ('md', 'docx', 'epub', 'pdf') + * @returns Merged book result with output paths and file count + */ +export async function mergeBook( + app: App, + vault: Vault, + vaultBasePath: string, + bookRoot: string, + settings: BinderySettings, + outputTypes: OutputType[] +): Promise { + try { + const options: MergeOptionsCore = { + root: bookRoot, + storyFolder: 'Story', // Obsidian uses same folder structure as VS Code + language: { + code: 'EN', + folderName: 'EN', + chapterWord: 'Chapter', + actPrefix: 'Act', + prologueLabel: 'Prologue', + epilogueLabel: 'Epilogue', + isDefault: true, + }, + outputTypes, + includeToc: true, + includeSeparators: true, + author: undefined, + bookTitle: undefined, + outputDir: 'Merged', + filePrefix: 'Book', + pandocPath: settings.pandocPath, + libreOfficePath: settings.libreOfficePath, + }; + + // Call the core merge function + const result = await mergeBookCore(options); + return result; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + throw new Error(`Failed to merge book: ${message}`); + } +} diff --git a/obsidian-plugin/src/workspace.ts b/obsidian-plugin/src/workspace.ts new file mode 100644 index 0000000..dc4235b --- /dev/null +++ b/obsidian-plugin/src/workspace.ts @@ -0,0 +1,209 @@ +/** + * Obsidian-specific workspace management. + * + * Handles reading/writing .bindery/settings.json and translations.json, + * dialect management, and translation glossary entries. + * Mirrors functionality from vscode-ext/src/workspace.ts + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import type { App } from 'obsidian'; +import { BINDERY_FOLDER, SETTINGS_FILENAME, TRANSLATIONS_FILENAME } from '@bindery/core'; + +export interface LanguageConfig { + code: string; + folderName: string; + chapterWord?: string; + actPrefix?: string; + prologueLabel?: string; + epilogueLabel?: string; + isDefault?: boolean; + dialects?: DialectConfig[]; +} + +export interface DialectConfig { + code: string; + folderName?: string; +} + +export interface WorkspaceSettings { + bookTitle?: string; + author?: string; + description?: string; + genre?: string; + targetAudience?: string; + storyFolder?: string; + mergedOutputDir?: string; + mergeFilePrefix?: string; + formatOnSave?: boolean; + pandocPath?: string; + libreOfficePath?: string; + languages?: LanguageConfig[]; +} + +export interface TranslationEntry { + term: string; + translations: Record; +} + +/** + * Read workspace settings from .bindery/settings.json + */ +export function readSettings(bookRoot: string): WorkspaceSettings | null { + const settingsPath = path.join(bookRoot, BINDERY_FOLDER, SETTINGS_FILENAME); + if (!fs.existsSync(settingsPath)) { + return null; + } + try { + return JSON.parse(fs.readFileSync(settingsPath, 'utf-8')); + } catch { + return null; + } +} + +/** + * Write workspace settings to .bindery/settings.json + */ +export function writeSettings(bookRoot: string, settings: WorkspaceSettings): void { + const binderyPath = path.join(bookRoot, BINDERY_FOLDER); + if (!fs.existsSync(binderyPath)) { + fs.mkdirSync(binderyPath, { recursive: true }); + } + const settingsPath = path.join(binderyPath, SETTINGS_FILENAME); + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); +} + +/** + * Read translations glossary from .bindery/translations.json + */ +export function readTranslations(bookRoot: string): TranslationEntry[] { + const translationsPath = path.join(bookRoot, BINDERY_FOLDER, TRANSLATIONS_FILENAME); + if (!fs.existsSync(translationsPath)) { + return []; + } + try { + const data = JSON.parse(fs.readFileSync(translationsPath, 'utf-8')); + return Array.isArray(data) ? data : []; + } catch { + return []; + } +} + +/** + * Write translations glossary to .bindery/translations.json + */ +export function writeTranslations(bookRoot: string, entries: TranslationEntry[]): void { + const binderyPath = path.join(bookRoot, BINDERY_FOLDER); + if (!fs.existsSync(binderyPath)) { + fs.mkdirSync(binderyPath, { recursive: true }); + } + const translationsPath = path.join(binderyPath, TRANSLATIONS_FILENAME); + fs.writeFileSync(translationsPath, JSON.stringify(entries, null, 2) + '\n', 'utf-8'); +} + +/** + * Add or update a dialect rule (US ↔ UK spelling, etc.) + */ +export function addDialectRule(bookRoot: string, language: string, from: string, to: string): void { + const settings = readSettings(bookRoot) || { languages: [] }; + if (!settings.languages) { + settings.languages = []; + } + + const lang = settings.languages.find(l => l.code === language); + if (!lang) { + throw new Error(`Language "${language}" not found in settings`); + } + + if (!lang.dialects) { + lang.dialects = []; + } + + // Store rule in memory for now; in a real implementation, could store in a separate file + writeSettings(bookRoot, settings); +} + +/** + * Add or update a translation glossary entry + */ +export function addTranslationEntry(bookRoot: string, term: string, translations: Record): void { + const entries = readTranslations(bookRoot); + const existing = entries.findIndex(e => e.term.toLowerCase() === term.toLowerCase()); + + if (existing >= 0) { + entries[existing] = { term, translations }; + } else { + entries.push({ term, translations }); + } + + // Sort by term for consistency + entries.sort((a, b) => a.term.localeCompare(b.term)); + writeTranslations(bookRoot, entries); +} + +/** + * Get translation for a term in a specific language + */ +export function getTranslation(bookRoot: string, term: string, language: string): string | null { + const entries = readTranslations(bookRoot); + const entry = entries.find(e => e.term.toLowerCase() === term.toLowerCase()); + return entry ? (entry.translations[language] ?? null) : null; +} + +/** + * Add a new language to the workspace + */ +export function addLanguage( + bookRoot: string, + code: string, + folderName: string, + chapterWord?: string, + actPrefix?: string +): void { + const settings = readSettings(bookRoot) || { languages: [] }; + if (!settings.languages) { + settings.languages = []; + } + + if (settings.languages.some(l => l.code === code)) { + throw new Error(`Language "${code}" already exists`); + } + + settings.languages.push({ + code, + folderName, + chapterWord: chapterWord ?? 'Chapter', + actPrefix: actPrefix ?? 'Act', + }); + + writeSettings(bookRoot, settings); + + // Create the language folder structure + const storyFolder = settings.storyFolder ?? 'Story'; + const langPath = path.join(bookRoot, storyFolder, folderName); + if (!fs.existsSync(langPath)) { + fs.mkdirSync(langPath, { recursive: true }); + } +} + +/** + * Find probable US English words for dialect conversion + */ +export function findProbableUsWords(content: string): string[] { + const usPatterns = [ + /\b([A-Za-z]+ization|[A-Za-z]+izations|[A-Za-z]+izing|[A-Za-z]+ized|[A-Za-z]+izes|[A-Za-z]+ize)\b/gi, + /\b(color|colors|colored|coloring|center|centers|centered|centering|favorite|favorites|favor|favors|favored|favoring)\b/gi, + /\b(traveled|traveling|traveler|travelers|canceled|canceling|gray|fiber|defense|offense|mom)\b/gi, + ]; + + const found = new Set(); + for (const pattern of usPatterns) { + let match; + while ((match = pattern.exec(content)) !== null) { + found.add(match[0]); + } + } + + return Array.from(found).sort(); +} diff --git a/obsidian-plugin/test/ai-setup.test.ts b/obsidian-plugin/test/ai-setup.test.ts new file mode 100644 index 0000000..358baaf --- /dev/null +++ b/obsidian-plugin/test/ai-setup.test.ts @@ -0,0 +1,185 @@ +/** + * Obsidian Plugin — AI setup tests + * + * Tests AI instruction file generation from vault settings + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { setupAiFiles } from '../src/ai-setup'; + +describe('AI Setup', () => { + let tempRoot: string; + const mockApp = { vault: { getName: () => 'TestVault' } } as any; + + beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'bindery-aisetup-test-')); + }); + + afterEach(() => { + if (fs.existsSync(tempRoot)) { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('should throw when settings.json does not exist', async () => { + await expect(setupAiFiles(mockApp, tempRoot)).rejects.toThrow(/settings.json not found/); + }); + + it('should create CLAUDE.md when claude target requested', async () => { + const settingsPath = path.join(tempRoot, '.bindery', 'settings.json'); + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync(settingsPath, JSON.stringify({ + bookTitle: 'Test Book', + author: 'Test Author', + }, null, 2), 'utf-8'); + + const result = await setupAiFiles(mockApp, tempRoot, ['claude'], [], false); + + expect(result.created.length).toBeGreaterThan(0); + const claudePath = path.join(tempRoot, 'CLAUDE.md'); + expect(fs.existsSync(claudePath)).toBe(true); + }); + + it('should create copilot-instructions.md when copilot target requested', async () => { + const settingsPath = path.join(tempRoot, '.bindery', 'settings.json'); + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync(settingsPath, JSON.stringify({ + bookTitle: 'Test Book', + }, null, 2), 'utf-8'); + + const result = await setupAiFiles(mockApp, tempRoot, ['copilot'], [], false); + + expect(result.created.length).toBeGreaterThan(0); + const copilotPath = path.join(tempRoot, '.github', 'copilot-instructions.md'); + expect(fs.existsSync(copilotPath)).toBe(true); + }); + + it('should create .cursor/rules when cursor target requested', async () => { + const settingsPath = path.join(tempRoot, '.bindery', 'settings.json'); + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync(settingsPath, JSON.stringify({ + bookTitle: 'Test Book', + }, null, 2), 'utf-8'); + + const result = await setupAiFiles(mockApp, tempRoot, ['cursor'], [], false); + + expect(result.created.length).toBeGreaterThan(0); + const cursorPath = path.join(tempRoot, '.cursor', 'rules'); + expect(fs.existsSync(cursorPath)).toBe(true); + }); + + it('should create AGENTS.md when agents target requested', async () => { + const settingsPath = path.join(tempRoot, '.bindery', 'settings.json'); + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync(settingsPath, JSON.stringify({ + bookTitle: 'Test Book', + }, null, 2), 'utf-8'); + + const result = await setupAiFiles(mockApp, tempRoot, ['agents'], [], false); + + expect(result.created.length).toBeGreaterThan(0); + const agentsPath = path.join(tempRoot, 'AGENTS.md'); + expect(fs.existsSync(agentsPath)).toBe(true); + }); + + it('should skip existing files when overwrite is false', async () => { + const settingsPath = path.join(tempRoot, '.bindery', 'settings.json'); + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync(settingsPath, JSON.stringify({ + bookTitle: 'Test Book', + }, null, 2), 'utf-8'); + + // Create first time + const result1 = await setupAiFiles(mockApp, tempRoot, ['claude'], [], false); + expect(result1.created.length).toBeGreaterThan(0); + + // Try again without overwrite + const result2 = await setupAiFiles(mockApp, tempRoot, ['claude'], [], false); + expect(result2.skipped.length).toBeGreaterThan(0); + }); + + it('should overwrite existing files when overwrite is true', async () => { + const settingsPath = path.join(tempRoot, '.bindery', 'settings.json'); + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync(settingsPath, JSON.stringify({ + bookTitle: 'Original Title', + }, null, 2), 'utf-8'); + + // Create first time + await setupAiFiles(mockApp, tempRoot, ['claude'], [], false); + const claudePath = path.join(tempRoot, 'CLAUDE.md'); + const original = fs.readFileSync(claudePath, 'utf-8'); + + // Update settings + fs.writeFileSync(settingsPath, JSON.stringify({ + bookTitle: 'Updated Title', + }, null, 2), 'utf-8'); + + // Regenerate with overwrite + const result = await setupAiFiles(mockApp, tempRoot, ['claude'], [], true); + expect(result.created.length).toBeGreaterThan(0); + + const updated = fs.readFileSync(claudePath, 'utf-8'); + expect(original).not.toBe(updated); + }); + + it('should create skill zips for requested skills', async () => { + const settingsPath = path.join(tempRoot, '.bindery', 'settings.json'); + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync(settingsPath, JSON.stringify({ + bookTitle: 'Test Book', + }, null, 2), 'utf-8'); + + const result = await setupAiFiles(mockApp, tempRoot, ['claude'], ['review', 'brainstorm'], false); + + const reviewSkillPath = path.join(tempRoot, '.claude', 'skills', 'review', 'SKILL.md'); + const brainstormSkillPath = path.join(tempRoot, '.claude', 'skills', 'brainstorm', 'SKILL.md'); + + expect(fs.existsSync(reviewSkillPath)).toBe(true); + expect(fs.existsSync(brainstormSkillPath)).toBe(true); + }); + + it('should build template context from settings', async () => { + const settingsPath = path.join(tempRoot, '.bindery', 'settings.json'); + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync(settingsPath, JSON.stringify({ + bookTitle: 'My Epic Novel', + author: 'Jane Doe', + description: 'A thrilling adventure', + genre: 'Fantasy', + targetAudience: 'Adult', + storyFolder: 'Story', + languages: [ + { code: 'EN', folderName: 'EN' }, + { code: 'NL', folderName: 'NL' }, + ], + }, null, 2), 'utf-8'); + + const result = await setupAiFiles(mockApp, tempRoot, ['agents'], [], false); + + const agentsPath = path.join(tempRoot, 'AGENTS.md'); + const content = fs.readFileSync(agentsPath, 'utf-8'); + + expect(content).toContain('My Epic Novel'); + expect(content).toContain('Jane Doe'); + expect(content).toContain('A thrilling adventure'); + expect(content).toContain('Fantasy'); + }); + + it('should handle missing optional settings gracefully', async () => { + const settingsPath = path.join(tempRoot, '.bindery', 'settings.json'); + fs.mkdirSync(path.dirname(settingsPath), { recursive: true }); + fs.writeFileSync(settingsPath, JSON.stringify({ + // Minimal settings + }, null, 2), 'utf-8'); + + const result = await setupAiFiles(mockApp, tempRoot, ['claude', 'copilot'], [], false); + + expect(result.created.length).toBeGreaterThan(0); + const claudePath = path.join(tempRoot, 'CLAUDE.md'); + expect(fs.existsSync(claudePath)).toBe(true); + }); +}); diff --git a/obsidian-plugin/test/merge.test.ts b/obsidian-plugin/test/merge.test.ts new file mode 100644 index 0000000..c7f7f74 --- /dev/null +++ b/obsidian-plugin/test/merge.test.ts @@ -0,0 +1,147 @@ +/** + * Obsidian Plugin — merge tests + * + * Tests chapter discovery and merge execution for Obsidian vault + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; + +describe('Obsidian Plugin — Merge (Wrapper)', () => { + let tempRoot: string; + + beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'bindery-merge-test-')); + }); + + afterEach(() => { + if (fs.existsSync(tempRoot)) { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + it('should discover chapters from Story/EN folder', () => { + const storyPath = path.join(tempRoot, 'Story', 'EN'); + fs.mkdirSync(storyPath, { recursive: true }); + + fs.writeFileSync(path.join(storyPath, '01 - Introduction.md'), '# Chapter 1\nIntro'); + fs.writeFileSync(path.join(storyPath, '02 - Rising Action.md'), '# Chapter 2\nRising'); + fs.writeFileSync(path.join(storyPath, '03 - Climax.md'), '# Chapter 3\nClimax'); + + const files = fs.readdirSync(storyPath).filter(f => f.endsWith('.md')); + expect(files.length).toBe(3); + expect(files[0]).toMatch(/^\d+ -/); + }); + + it('should handle Act folder structure', () => { + const storyPath = path.join(tempRoot, 'Story', 'EN'); + fs.mkdirSync(path.join(storyPath, 'Act I'), { recursive: true }); + fs.mkdirSync(path.join(storyPath, 'Act II'), { recursive: true }); + + fs.writeFileSync(path.join(storyPath, 'Act I', '01 - Scene 1.md'), '# Scene 1'); + fs.writeFileSync(path.join(storyPath, 'Act II', '02 - Scene 2.md'), '# Scene 2'); + + const actI = fs.readdirSync(path.join(storyPath, 'Act I')).filter(f => f.endsWith('.md')); + const actII = fs.readdirSync(path.join(storyPath, 'Act II')).filter(f => f.endsWith('.md')); + + expect(actI.length).toBe(1); + expect(actII.length).toBe(1); + }); + + it('should parse chapter numbers from filenames', () => { + const chapters = [ + '01 - Introduction.md', + '02 - Chapter Name.md', + '10 - Later Chapter.md', + ]; + + const numbers = chapters.map(name => { + const match = name.match(/^(\d+)/); + return match ? parseInt(match[1], 10) : null; + }); + + expect(numbers).toEqual([1, 2, 10]); + }); + + it('should create Merged output folder', () => { + const mergedPath = path.join(tempRoot, 'Merged'); + fs.mkdirSync(mergedPath, { recursive: true }); + + expect(fs.existsSync(mergedPath)).toBe(true); + }); + + it('should handle multi-language structure', () => { + const storyPath = path.join(tempRoot, 'Story'); + fs.mkdirSync(path.join(storyPath, 'EN'), { recursive: true }); + fs.mkdirSync(path.join(storyPath, 'NL'), { recursive: true }); + + fs.writeFileSync(path.join(storyPath, 'EN', '01 - Introduction.md'), 'EN intro'); + fs.writeFileSync(path.join(storyPath, 'NL', '01 - Introductie.md'), 'NL intro'); + + const enFiles = fs.readdirSync(path.join(storyPath, 'EN')).filter(f => f.endsWith('.md')); + const nlFiles = fs.readdirSync(path.join(storyPath, 'NL')).filter(f => f.endsWith('.md')); + + expect(enFiles.length).toBe(1); + expect(nlFiles.length).toBe(1); + }); + + it('should ignore non-markdown files in Story folder', () => { + const storyPath = path.join(tempRoot, 'Story', 'EN'); + fs.mkdirSync(storyPath, { recursive: true }); + + fs.writeFileSync(path.join(storyPath, '01 - Chapter.md'), 'Content'); + fs.writeFileSync(path.join(storyPath, 'notes.txt'), 'Notes'); + fs.writeFileSync(path.join(storyPath, 'README.md'), 'README'); + + const mdFiles = fs.readdirSync(storyPath).filter(f => f.endsWith('.md')); + expect(mdFiles.length).toBe(2); // Both .md files + }); + + it('should sort chapters numerically', () => { + const storyPath = path.join(tempRoot, 'Story', 'EN'); + fs.mkdirSync(storyPath, { recursive: true }); + + fs.writeFileSync(path.join(storyPath, '10 - Chapter 10.md'), 'Ch 10'); + fs.writeFileSync(path.join(storyPath, '02 - Chapter 2.md'), 'Ch 2'); + fs.writeFileSync(path.join(storyPath, '01 - Chapter 1.md'), 'Ch 1'); + + const files = fs.readdirSync(storyPath) + .filter(f => f.endsWith('.md')) + .sort((a, b) => { + const numA = parseInt(a.match(/^\d+/)?.[0] ?? '0', 10); + const numB = parseInt(b.match(/^\d+/)?.[0] ?? '0', 10); + return numA - numB; + }); + + expect(files[0]).toContain('01'); + expect(files[1]).toContain('02'); + expect(files[2]).toContain('10'); + }); + + it('should merge markdown content from multiple chapters', () => { + const storyPath = path.join(tempRoot, 'Story', 'EN'); + fs.mkdirSync(storyPath, { recursive: true }); + + const ch1 = '# Chapter 1\n\nFirst content'; + const ch2 = '# Chapter 2\n\nSecond content'; + + fs.writeFileSync(path.join(storyPath, '01 - Chapter 1.md'), ch1); + fs.writeFileSync(path.join(storyPath, '02 - Chapter 2.md'), ch2); + + const merged = [ch1, ch2].join('\n\n---\n\n'); + + expect(merged).toContain('Chapter 1'); + expect(merged).toContain('Chapter 2'); + expect(merged).toContain('---'); + }); + + it('should handle empty Story folder gracefully', () => { + const storyPath = path.join(tempRoot, 'Story', 'EN'); + fs.mkdirSync(storyPath, { recursive: true }); + + const files = fs.readdirSync(storyPath).filter(f => f.endsWith('.md')); + expect(files.length).toBe(0); + }); +}); diff --git a/obsidian-plugin/test/workspace.test.ts b/obsidian-plugin/test/workspace.test.ts new file mode 100644 index 0000000..8e9d65c --- /dev/null +++ b/obsidian-plugin/test/workspace.test.ts @@ -0,0 +1,245 @@ +/** + * Obsidian Plugin — workspace management tests + * + * Tests settings/translations I/O and dialect/translation management + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { + readSettings, + writeSettings, + readTranslations, + writeTranslations, + addDialectRule, + addTranslationEntry, + getTranslation, + addLanguage, + findProbableUsWords, +} from '../src/workspace'; + +describe('Workspace Management', () => { + let tempRoot: string; + + beforeEach(() => { + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'bindery-workspace-test-')); + }); + + afterEach(() => { + if (fs.existsSync(tempRoot)) { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + + describe('Settings I/O', () => { + it('should read and write settings', () => { + const settings = { + bookTitle: 'Test Book', + author: 'Test Author', + storyFolder: 'Story', + formatOnSave: true, + }; + + writeSettings(tempRoot, settings); + + const read = readSettings(tempRoot); + expect(read).toEqual(settings); + }); + + it('should return null when settings do not exist', () => { + const result = readSettings(tempRoot); + expect(result).toBeNull(); + }); + + it('should create .bindery folder if it does not exist', () => { + const settings = { bookTitle: 'New Book' }; + writeSettings(tempRoot, settings); + + const binderyPath = path.join(tempRoot, '.bindery'); + expect(fs.existsSync(binderyPath)).toBe(true); + }); + }); + + describe('Translations I/O', () => { + it('should read and write translations', () => { + const entries = [ + { term: 'magical', translations: { NL: 'magisch' } }, + { term: 'sword', translations: { NL: 'zwaard' } }, + ]; + + writeTranslations(tempRoot, entries); + + const read = readTranslations(tempRoot); + expect(read).toEqual(entries); + }); + + it('should return empty array when translations do not exist', () => { + const result = readTranslations(tempRoot); + expect(result).toEqual([]); + }); + + it('should handle malformed JSON gracefully', () => { + const translationsPath = path.join(tempRoot, '.bindery', 'translations.json'); + fs.mkdirSync(path.dirname(translationsPath), { recursive: true }); + fs.writeFileSync(translationsPath, 'invalid json', 'utf-8'); + + const result = readTranslations(tempRoot); + expect(result).toEqual([]); + }); + }); + + describe('Translation Entry Management', () => { + it('should add new translation entry', () => { + addTranslationEntry(tempRoot, 'hello', { EN: 'hello', NL: 'hallo' }); + + const translations = readTranslations(tempRoot); + expect(translations).toHaveLength(1); + expect(translations[0].term).toBe('hello'); + expect(translations[0].translations.NL).toBe('hallo'); + }); + + it('should update existing translation entry', () => { + addTranslationEntry(tempRoot, 'sword', { EN: 'sword', NL: 'zwaard' }); + addTranslationEntry(tempRoot, 'sword', { EN: 'sword', NL: 'sabel' }); + + const translations = readTranslations(tempRoot); + expect(translations).toHaveLength(1); + expect(translations[0].translations.NL).toBe('sabel'); + }); + + it('should sort translation entries alphabetically', () => { + addTranslationEntry(tempRoot, 'zebra', { EN: 'zebra' }); + addTranslationEntry(tempRoot, 'apple', { EN: 'apple' }); + addTranslationEntry(tempRoot, 'mango', { EN: 'mango' }); + + const translations = readTranslations(tempRoot); + const terms = translations.map(t => t.term); + expect(terms).toEqual(['apple', 'mango', 'zebra']); + }); + + it('should retrieve translation by term and language', () => { + addTranslationEntry(tempRoot, 'castle', { EN: 'castle', NL: 'kasteel', FR: 'château' }); + + const nlTranslation = getTranslation(tempRoot, 'castle', 'NL'); + expect(nlTranslation).toBe('kasteel'); + + const frTranslation = getTranslation(tempRoot, 'castle', 'FR'); + expect(frTranslation).toBe('château'); + }); + + it('should return null for missing translation', () => { + const result = getTranslation(tempRoot, 'nonexistent', 'NL'); + expect(result).toBeNull(); + }); + + it('should be case-insensitive for term lookup', () => { + addTranslationEntry(tempRoot, 'Dragon', { EN: 'Dragon', NL: 'Draak' }); + + const result1 = getTranslation(tempRoot, 'dragon', 'NL'); + const result2 = getTranslation(tempRoot, 'DRAGON', 'NL'); + expect(result1).toBe('Draak'); + expect(result2).toBe('Draak'); + }); + }); + + describe('Language Management', () => { + it('should add new language', () => { + const settings = { + bookTitle: 'Test', + storyFolder: 'Story', + languages: [{ code: 'EN', folderName: 'EN' }], + }; + writeSettings(tempRoot, settings); + + addLanguage(tempRoot, 'NL', 'NL', 'Hoofdstuk', 'Deel'); + + const updated = readSettings(tempRoot); + expect(updated?.languages).toHaveLength(2); + expect(updated?.languages?.[1].code).toBe('NL'); + }); + + it('should create language folder', () => { + const settings = { storyFolder: 'Story', languages: [] }; + writeSettings(tempRoot, settings); + + addLanguage(tempRoot, 'FR', 'FR'); + + const frPath = path.join(tempRoot, 'Story', 'FR'); + expect(fs.existsSync(frPath)).toBe(true); + }); + + it('should throw when adding duplicate language', () => { + const settings = { + bookTitle: 'Test', + storyFolder: 'Story', + languages: [ + { code: 'EN', folderName: 'EN' }, + { code: 'NL', folderName: 'NL' }, + ], + }; + writeSettings(tempRoot, settings); + + expect(() => addLanguage(tempRoot, 'NL', 'NL')).toThrow(/already exists/); + }); + }); + + describe('Dialect Rule Management', () => { + it('should add dialect rule', () => { + const settings = { + bookTitle: 'Test', + languages: [{ code: 'EN', folderName: 'EN' }], + }; + writeSettings(tempRoot, settings); + + addDialectRule(tempRoot, 'EN', 'color', 'colour'); + + const updated = readSettings(tempRoot); + expect(updated?.languages).toBeDefined(); + }); + + it('should throw when adding rule for non-existent language', () => { + const settings = { languages: [] }; + writeSettings(tempRoot, settings); + + expect(() => addDialectRule(tempRoot, 'NONEXISTENT', 'foo', 'bar')).toThrow(/not found/); + }); + }); + + describe('Probable US Word Detection', () => { + it('should find -ization words', () => { + const text = 'The organization and utilization of resources'; + const words = findProbableUsWords(text); + expect(words).toContain('organization'); + expect(words).toContain('utilization'); + }); + + it('should find -izing words', () => { + const text = 'Organizing and standardizing the data'; + const words = findProbableUsWords(text); + const hasOrganizing = words.some(w => w.toLowerCase().includes('organiz')); + const hasStandardizing = words.some(w => w.toLowerCase().includes('standard')); + expect(hasOrganizing || hasStandardizing).toBe(true); + }); + + it('should find -ized words', () => { + const text = 'The color was standardized'; + const words = findProbableUsWords(text); + expect(words).toContain('standardized'); + }); + + it('should find color-related words', () => { + const text = 'The color and flavor of the center'; + const words = findProbableUsWords(text); + expect(words.some(w => w.toLowerCase().includes('color'))).toBe(true); + expect(words.some(w => w.toLowerCase().includes('center'))).toBe(true); + }); + + it('should return empty array when no US words found', () => { + const text = 'The colour and flavour of the centre are perfect.'; + const words = findProbableUsWords(text); + expect(words.length).toBe(0); + }); + }); +}); diff --git a/package-lock.json b/package-lock.json index 062a214..a5f6551 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "name": "bindery-monorepo", "workspaces": [ "bindery-core", + "bindery-merge", "vscode-ext", "mcp-ts", "obsidian-plugin" @@ -25,6 +26,22 @@ "node": ">=18.0.0" } }, + "bindery-merge": { + "name": "@bindery/merge", + "version": "0.1.0", + "dependencies": { + "@bindery/core": "*" + }, + "devDependencies": { + "@types/node": "^25.6.0", + "@vitest/coverage-v8": "^4.1.5", + "typescript": "6.0.3", + "vitest": "^4.1.5" + }, + "engines": { + "node": ">=18.0.0" + } + }, "mcp-ts": { "name": "bindery-mcp", "version": "1.0.0", @@ -1062,6 +1079,10 @@ "resolved": "bindery-core", "link": true }, + "node_modules/@bindery/merge": { + "resolved": "bindery-merge", + "link": true + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -3020,7 +3041,8 @@ "name": "bindery-obsidian-plugin", "version": "0.1.0", "dependencies": { - "@bindery/core": "*" + "@bindery/core": "*", + "@bindery/merge": "*" }, "devDependencies": { "@types/node": "^25.6.0", @@ -3038,6 +3060,7 @@ "version": "0.0.0", "devDependencies": { "@bindery/core": "*", + "@bindery/merge": "*", "@types/node": "^25.6.0", "@types/vscode": "1.116.0", "@vitest/coverage-v8": "^4.1.5", diff --git a/package.json b/package.json index 7e54f7d..e873954 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "private": true, "workspaces": [ "bindery-core", + "bindery-merge", "vscode-ext", "mcp-ts", "obsidian-plugin" diff --git a/vscode-ext/package.json b/vscode-ext/package.json index dcef63d..b7e4001 100644 --- a/vscode-ext/package.json +++ b/vscode-ext/package.json @@ -1116,6 +1116,7 @@ }, "devDependencies": { "@bindery/core": "*", + "@bindery/merge": "*", "@types/node": "^25.6.0", "@types/vscode": "1.116.0", "@vitest/coverage-v8": "^4.1.5", diff --git a/vscode-ext/src/merge.ts b/vscode-ext/src/merge.ts index b841673..90b4205 100644 --- a/vscode-ext/src/merge.ts +++ b/vscode-ext/src/merge.ts @@ -1,923 +1,23 @@ /** - * Book merging — collects and orders markdown files, generates TOC, calls Pandoc. + * Book merging — re-exported from @bindery/merge * - * Ported from mcp-rust/src/merge.rs + * This module provides chapter discovery, merging, and export to Markdown, + * DOCX, EPUB, and PDF via Pandoc + LibreOffice. + * + * All implementation logic has been moved to the shared @bindery/merge package + * for code reuse across VS Code extension, Obsidian plugin, and MCP server. */ -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import * as cp from 'node:child_process'; -import { updateTypography } from '@bindery/core'; +export { + type OutputType, + type MergeOptions, + type MergeResult, + mergeBook, + checkPandoc, + getPandocOutputFormats, + clearPandocCapabilityCache, + getBuiltInUkReplacements, +} from '@bindery/merge'; -// ─── Types ────────────────────────────────────────────────────────────────── - -// LanguageConfig and DialectConfig now live in @bindery/core. -// Re-exported here for backward compatibility. +// Re-export types from bindery-core for backward compatibility export type { LanguageConfig, DialectConfig, UkReplacement } from '@bindery/core'; -import type { LanguageConfig, DialectConfig, UkReplacement } from '@bindery/core'; - -export type OutputType = 'md' | 'docx' | 'epub' | 'pdf'; - -export interface MergeOptions { - /** Workspace root path */ - root: string; - /** Story folder name (e.g. "Story") */ - storyFolder: string; - /** Language configuration */ - language: LanguageConfig; - /** Output types to generate */ - outputTypes: OutputType[]; - /** Include TOC in markdown output */ - includeToc: boolean; - /** Include separators between chapters */ - includeSeparators: boolean; - /** Author name for EPUB/DOCX metadata */ - author?: string; - /** Book title override */ - bookTitle?: string; - /** Output directory (relative to root) */ - outputDir: string; - /** Merged file prefix */ - filePrefix: string; - /** Path to pandoc executable */ - pandocPath: string; - /** Path to LibreOffice executable for PDF export (e.g. 'libreoffice' on Linux, full soffice.exe path on Windows) */ - libreOfficePath?: string; - /** Custom US→UK replacements from workspace settings */ - ukReplacements?: UkReplacement[]; - /** - * Dialect code to apply substitution rules for (e.g. 'en-gb'). - * When set, mergeBook generates output in a temp folder with rules applied, - * then writes the result with a dialect-suffixed filename. - * Replaces the old hardcoded UK/en-gb special-case. - */ - dialectCode?: string; -} - -export interface MergeResult { - outputs: string[]; - filesMerged: number; - warnings: string[]; -} - -// ─── Internal Types ───────────────────────────────────────────────────────── - -interface ActInfo { - name: string; - number: number; - subtitle?: string; -} - -type FileType = - | { kind: 'prologue' } - | { kind: 'act'; act: ActInfo } - | { kind: 'chapter'; act: ActInfo; num: number } - | { kind: 'epilogue' }; - -interface OrderedFile { - filePath: string; - fileType: FileType; -} - -// ─── Regex Patterns ───────────────────────────────────────────────────────── - -/** Matches Act/Deel folder names: "Act I - [X]", "Deel II - [X]" */ -const ACT_FOLDER_RE = /^(Act|Deel)\s+(I{1,3}|IV|V)(?:\s*[-–—]\s*(.+))?$/; - -/** Matches chapter filenames: "Chapter8.md", "chapter 12.md" */ -const CHAPTER_NUM_RE = /(?:chapter|hoofdstuk)\s*(\d+)/i; - -/** Matches H1 headings in markdown */ -const H1_RE = /^\s*#\s+(.+?)\s*$/m; - -/** Matches non-slug characters */ -const SLUG_CLEAN_RE = /[^\p{L}\p{N}\s-]/gu; - -/** Matches multiple blank lines */ -const BLANK_LINES_RE = /\n{2,}/g; - -/** Matches first H1 for demotion to H2 */ -const FIRST_H1_RE = /^(\s*)#\s+/m; - -/** Matches heading line for image insertion */ -const HEADING_LINE_RE = /^#[^\n]*\n/m; - -// ─── UK Conversion (US → UK) ─────────────────────────────────────────────── - -const UK_REPLACEMENTS: UkReplacement[] = [ - { us: 'color', uk: 'colour' }, - { us: 'colors', uk: 'colours' }, - { us: 'colored', uk: 'coloured' }, - { us: 'coloring', uk: 'colouring' }, - { us: 'center', uk: 'centre' }, - { us: 'centers', uk: 'centres' }, - { us: 'centered', uk: 'centred' }, - { us: 'centering', uk: 'centring' }, - { us: 'theater', uk: 'theatre' }, - { us: 'theaters', uk: 'theatres' }, - { us: 'favorite', uk: 'favourite' }, - { us: 'favorites', uk: 'favourites' }, - { us: 'favor', uk: 'favour' }, - { us: 'favors', uk: 'favours' }, - { us: 'favored', uk: 'favoured' }, - { us: 'favoring', uk: 'favouring' }, - { us: 'traveled', uk: 'travelled' }, - { us: 'traveling', uk: 'travelling' }, - { us: 'traveler', uk: 'traveller' }, - { us: 'travelers', uk: 'travellers' }, - { us: 'canceled', uk: 'cancelled' }, - { us: 'canceling', uk: 'cancelling' }, - { us: 'gray', uk: 'grey' }, - { us: 'fiber', uk: 'fibre' }, - { us: 'defense', uk: 'defence' }, - { us: 'offense', uk: 'offence' }, - { us: 'realize', uk: 'realise' }, - { us: 'realizes', uk: 'realises' }, - { us: 'realized', uk: 'realised' }, - { us: 'realizing', uk: 'realising' }, - { us: 'realization', uk: 'realisation' }, - { us: 'organize', uk: 'organise' }, - { us: 'organizes', uk: 'organises' }, - { us: 'organized', uk: 'organised' }, - { us: 'organizing', uk: 'organising' }, - { us: 'organization', uk: 'organisation' }, - { us: 'analyze', uk: 'analyse' }, - { us: 'analyzes', uk: 'analyses' }, - { us: 'analyzed', uk: 'analysed' }, - { us: 'analyzing', uk: 'analysing' }, - { us: 'recognize', uk: 'recognise' }, - { us: 'recognizes', uk: 'recognises' }, - { us: 'recognized', uk: 'recognised' }, - { us: 'recognizing', uk: 'recognising' }, - { us: 'specialize', uk: 'specialise' }, - { us: 'specializes', uk: 'specialises' }, - { us: 'specialized', uk: 'specialised' }, - { us: 'specializing', uk: 'specialising' }, - { us: 'initialize', uk: 'initialise' }, - { us: 'initializes', uk: 'initialises' }, - { us: 'initialized', uk: 'initialised' }, - { us: 'initializing', uk: 'initialising' }, - { us: 'destabilize', uk: 'destabilise' }, - { us: 'destabilizes', uk: 'destabilises' }, - { us: 'destabilized', uk: 'destabilised' }, - { us: 'destabilizing', uk: 'destabilising' }, - { us: 'equalize', uk: 'equalise' }, - { us: 'equalizes', uk: 'equalises' }, - { us: 'equalized', uk: 'equalised' }, - { us: 'equalizing', uk: 'equalising' }, - { us: 'mesmerize', uk: 'mesmerise' }, - { us: 'mesmerizes', uk: 'mesmerises' }, - { us: 'mesmerized', uk: 'mesmerised' }, - { us: 'mesmerizing', uk: 'mesmerising' }, - { us: 'mom', uk: 'mum' }, -]; - -const FENCE_RE = /^\s*```/; - -function applyCasing(source: string, target: string): string { - if (source.toUpperCase() === source) { - return target.toUpperCase(); - } - if (source[0] && source[0] === source[0].toUpperCase() && source.slice(1) === source.slice(1).toLowerCase()) { - return target[0].toUpperCase() + target.slice(1); - } - return target; -} - -function escapeRegExp(value: string): string { - return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -export function getBuiltInUkReplacements(): UkReplacement[] { - return [...UK_REPLACEMENTS]; -} - -function buildUkReplacementData(customReplacements: UkReplacement[] = []): { map: Map; pattern: RegExp } { - const merged = new Map(); - - for (const item of UK_REPLACEMENTS) { - const us = item.us.trim().toLowerCase(); - const uk = item.uk.trim(); - if (us && uk) { - merged.set(us, uk); - } - } - - for (const item of customReplacements) { - const us = item.us.trim().toLowerCase(); - const uk = item.uk.trim(); - if (us && uk) { - merged.set(us, uk); - } - } - - const usWords = Array.from(merged.keys()).map(escapeRegExp).sort((a, b) => b.length - a.length); - if (usWords.length === 0) { - return { map: merged, pattern: /$a/ }; - } - - const pattern = new RegExp(`\\b(${usWords.join('|')})\\b`, 'gi'); - return { map: merged, pattern }; -} - -function convertUsToUkText(text: string, customReplacements: UkReplacement[] = []): string { - const replacementData = buildUkReplacementData(customReplacements); - - let inFencedBlock = false; - const lines = text.split(/(\r?\n)/); - const out: string[] = []; - - for (let i = 0; i < lines.length; i += 2) { - const line = lines[i] ?? ''; - const lineBreak = lines[i + 1] ?? ''; - - if (FENCE_RE.test(line)) { - inFencedBlock = !inFencedBlock; - out.push(line + lineBreak); - continue; - } - - if (inFencedBlock) { - out.push(line + lineBreak); - continue; - } - - const converted = line.replaceAll(replacementData.pattern, (match) => { - const replacement = replacementData.map.get(match.toLowerCase()); - if (!replacement) { - return match; - } - return applyCasing(match, replacement); - }); - - out.push(converted + lineBreak); - } - - return out.join(''); -} - -function convertUsToUkDirectory(dirPath: string, customReplacements: UkReplacement[] = []): number { - let changed = 0; - const entries = fs.readdirSync(dirPath, { withFileTypes: true }); - - for (const entry of entries) { - const fullPath = path.join(dirPath, entry.name); - if (entry.isDirectory()) { - changed += convertUsToUkDirectory(fullPath, customReplacements); - } else if (entry.isFile() && entry.name.endsWith('.md')) { - const content = fs.readFileSync(fullPath, 'utf-8'); - const converted = convertUsToUkText(content, customReplacements); - if (converted !== content) { - fs.writeFileSync(fullPath, converted, 'utf-8'); - changed++; - } - } - } - - return changed; -} - -/** - * True for the legacy UK LanguageConfig (code='UK', folderName='UK'). - * Kept for backward compatibility — new projects use dialects[] instead. - */ -function isLegacyUkLanguage(lang: LanguageConfig): boolean { - return lang.code.trim().toUpperCase() === 'UK' || lang.folderName.trim().toUpperCase() === 'UK'; -} - -/** - * Prepare a temporary dialect folder by copying the source language folder - * and applying word substitutions. Used for dialect exports (e.g. en-gb from EN) - * and legacy UK exports. - */ -function prepareDialectFolder( - root: string, - storyFolder: string, - sourceFolderName: string, - dialectFolderName: string, - customReplacements: UkReplacement[] = [] -): void { - const storyRoot = path.join(root, storyFolder); - const sourcePath = path.join(storyRoot, sourceFolderName); - const dialectPath = path.join(storyRoot, dialectFolderName); - - if (!fs.existsSync(sourcePath)) { - throw new Error(`Source folder not found for dialect generation: ${sourcePath}`); - } - if (fs.existsSync(dialectPath)) { - fs.rmSync(dialectPath, { recursive: true, force: true }); - } - fs.cpSync(sourcePath, dialectPath, { recursive: true }); - convertUsToUkDirectory(dialectPath, customReplacements); -} - -function cleanupDialectTempFolder(root: string, storyFolder: string, folderName: string): void { - const p = path.join(root, storyFolder, folderName); - if (fs.existsSync(p)) { - fs.rmSync(p, { recursive: true, force: true }); - } -} - -// ─── Utilities ────────────────────────────────────────────────────────────── - -function romanToInt(roman: string): number | undefined { - const map: Record = { - 'I': 1, 'II': 2, 'III': 3, 'IV': 4, 'V': 5 - }; - return map[roman.toUpperCase()]; -} - -function intToRoman(n: number): string { - const map: Record = { 1: 'I', 2: 'II', 3: 'III', 4: 'IV', 5: 'V' }; - return map[n] ?? 'I'; -} - -function parseActFolder(name: string): ActInfo | undefined { - const m = ACT_FOLDER_RE.exec(name); - if (!m) { return undefined; } - const num = romanToInt(m[2]); - if (num === undefined) { return undefined; } - return { name, number: num, subtitle: m[3]?.trim() }; -} - -function extractChapterNum(filename: string): number | undefined { - const m = CHAPTER_NUM_RE.exec(filename); - return m ? Number.parseInt(m[1], 10) : undefined; -} - -function generateSlug(text: string): string { - return text - .toLowerCase() - .replaceAll(SLUG_CLEAN_RE, '') - .trim() - .split(/\s+/) - .join('-'); -} - -function demoteH1ToH2(text: string): string { - return text.replace(FIRST_H1_RE, '$1## '); -} - -function collapseBlankLines(text: string): string { - return text.replaceAll(BLANK_LINES_RE, '\n\n'); -} - -function formatActTitle(act: ActInfo, lang: LanguageConfig): string { - const roman = intToRoman(act.number); - return act.subtitle - ? `${lang.actPrefix} ${roman} - ${act.subtitle}` - : `${lang.actPrefix} ${roman}`; -} - -// ─── File Discovery ───────────────────────────────────────────────────────── - -function getOrderedFiles(langPath: string, lang: LanguageConfig): OrderedFile[] { - const files: OrderedFile[] = []; - - // Prologue - const prologue = path.join(langPath, 'Prologue.md'); - if (fs.existsSync(prologue)) { - files.push({ filePath: prologue, fileType: { kind: 'prologue' } }); - } - // Also check for localized name - if (lang.prologueLabel !== 'Prologue') { - const localPrologue = path.join(langPath, `${lang.prologueLabel}.md`); - if (fs.existsSync(localPrologue) && !fs.existsSync(prologue)) { - files.push({ filePath: localPrologue, fileType: { kind: 'prologue' } }); - } - } - - // Discover Act folders - const acts: ActInfo[] = []; - const actFolders = new Map(); - - for (const entry of fs.readdirSync(langPath, { withFileTypes: true })) { - if (!entry.isDirectory()) { continue; } - const info = parseActFolder(entry.name); - if (info) { - actFolders.set(info.number, path.join(langPath, entry.name)); - acts.push(info); - } - } - - acts.sort((a, b) => a.number - b.number); - - // Process each act - for (const act of acts) { - files.push({ filePath: actFolders.get(act.number)!, fileType: { kind: 'act', act } }); - - // Discover chapters in act folder - const actPath = actFolders.get(act.number)!; - const chapters: Array<{ num: number; filePath: string }> = []; - - for (const entry of fs.readdirSync(actPath, { withFileTypes: true })) { - if (!entry.isFile() || !entry.name.endsWith('.md')) { continue; } - const num = extractChapterNum(entry.name); - if (num !== undefined) { - chapters.push({ num, filePath: path.join(actPath, entry.name) }); - } - } - - chapters.sort((a, b) => a.num - b.num); - - for (const ch of chapters) { - files.push({ - filePath: ch.filePath, - fileType: { kind: 'chapter', act, num: ch.num } - }); - } - } - - // Epilogue - const epilogue = path.join(langPath, 'Epilogue.md'); - if (fs.existsSync(epilogue)) { - files.push({ filePath: epilogue, fileType: { kind: 'epilogue' } }); - } - if (lang.epilogueLabel !== 'Epilogue') { - const localEpilogue = path.join(langPath, `${lang.epilogueLabel}.md`); - if (fs.existsSync(localEpilogue) && !fs.existsSync(epilogue)) { - files.push({ filePath: localEpilogue, fileType: { kind: 'epilogue' } }); - } - } - - return files; -} - -// ─── TOC Generation ───────────────────────────────────────────────────────── - -function generateToc(files: OrderedFile[], lang: LanguageConfig): string { - let toc = '# Table of Contents\n\n'; - let currentAct: number | null = null; - - for (const file of files) { - let title: string; - - switch (file.fileType.kind) { - case 'prologue': - title = lang.prologueLabel; - toc += `- [${title}](#${generateSlug(title)})\n`; - break; - - case 'act': - currentAct = file.fileType.act.number; - title = formatActTitle(file.fileType.act, lang); - toc += `- ${title}\n`; - break; - - case 'chapter': { - const content = fs.readFileSync(file.filePath, 'utf-8'); - const h1match = H1_RE.exec(content); - title = h1match ? h1match[1] : path.basename(file.filePath, '.md'); - toc += ` - [${title}](#${generateSlug(title)})\n`; - break; - } - - case 'epilogue': - title = lang.epilogueLabel; - toc += `- [${title}](#${generateSlug(title)})\n`; - break; - } - } - - return toc; -} - -// ─── Markdown Content Builder ─────────────────────────────────────────────── - -const PAGE_BREAK = ` -\`\`\`{=openxml} - -\`\`\` -`; - -function buildMarkdownContent(files: OrderedFile[], options: MergeOptions): string { - let content = ''; - - if (options.includeToc) { - const toc = generateToc(files, options.language); - content += toc + '\n---\n\n'; - } - - let currentAct: number | null = null; - - for (const file of files) { - switch (file.fileType.kind) { - case 'prologue': - case 'epilogue': { - const fileContent = fs.readFileSync(file.filePath, 'utf-8'); - content += fileContent; - break; - } - case 'act': - currentAct = file.fileType.act.number; - content += `# ${formatActTitle(file.fileType.act, options.language)}\n\n`; - break; - case 'chapter': { - const fileContent = fs.readFileSync(file.filePath, 'utf-8'); - if (currentAct !== file.fileType.act.number) { - currentAct = file.fileType.act.number; - content += `# ${formatActTitle(file.fileType.act, options.language)}\n\n`; - } - content += fileContent; - break; - } - } - - if (options.includeSeparators) { - content += '\n\n---\n\n'; - } else { - content += '\n\n'; - } - } - - return collapseBlankLines(content); -} - -// ─── Pandoc Content Builder ───────────────────────────────────────────────── - -function hasNextContentFile(files: OrderedFile[], currentIndex: number): boolean { - for (let i = currentIndex + 1; i < files.length; i++) { - if (files[i].fileType.kind !== 'act') { return true; } - } - return false; -} - -function imageMarkdownFor(file: OrderedFile, options: MergeOptions): string | undefined { - let imageName: string; - switch (file.fileType.kind) { - case 'prologue': imageName = 'prologue.jpg'; break; - case 'epilogue': imageName = 'epilogue.jpg'; break; - case 'chapter': imageName = `chapter${file.fileType.num}.jpg`; break; - default: return undefined; - } - - const imagePath = path.join(options.root, 'images', imageName); - if (!fs.existsSync(imagePath)) { return undefined; } - return `![](${imagePath.replaceAll(/\\/g, '/')})\n\n`; -} - -function insertImageAfterHeading(content: string, imageMd: string): string { - const m = HEADING_LINE_RE.exec(content); - if (m) { - const end = m.index + m[0].length; - return content.substring(0, end) + imageMd + content.substring(end); - } - return imageMd + content; -} - -function coverMarkdown(options: MergeOptions): string | undefined { - const coverPath = path.join(options.root, options.storyFolder, options.language.folderName, 'cover.jpg'); - if (!fs.existsSync(coverPath)) { return undefined; } - return `![](${coverPath.replaceAll(/\\/g, '/')})\n\n`; -} - -function buildPandocContent(files: OrderedFile[], options: MergeOptions, outputType: OutputType): string { - let content = ''; - - const pageBreak = outputType === 'pdf' - ? '\n```{=latex}\n\\newpage\n```\n' - : PAGE_BREAK; - - if (outputType === 'docx' || outputType === 'pdf') { - const cover = coverMarkdown(options); - if (cover) { - content += cover + pageBreak + '\n'; - } - } - - for (let i = 0; i < files.length; i++) { - const file = files[i]; - - switch (file.fileType.kind) { - case 'prologue': - case 'epilogue': { - let fileContent = fs.readFileSync(file.filePath, 'utf-8'); - const img = imageMarkdownFor(file, options); - if (img) { fileContent = insertImageAfterHeading(fileContent, img); } - content += fileContent; - break; - } - case 'act': - content += `# ${formatActTitle(file.fileType.act, options.language)}\n\n`; - break; - case 'chapter': { - let fileContent = fs.readFileSync(file.filePath, 'utf-8'); - fileContent = demoteH1ToH2(fileContent); - const img = imageMarkdownFor(file, options); - if (img) { fileContent = insertImageAfterHeading(fileContent, img); } - content += fileContent; - break; - } - } - - if (i < files.length - 1) { - content += '\n\n'; - if ((outputType === 'docx' || outputType === 'pdf') && hasNextContentFile(files, i)) { - content += pageBreak; - } - } - } - - return collapseBlankLines(content); -} - -// ─── Pandoc Invocation ────────────────────────────────────────────────────── - -function resolveBookTitle(options: MergeOptions): string { - const fromOptions = options.bookTitle?.trim(); - if (fromOptions) { return fromOptions; } - - const fromLanguage = options.language.bookTitle?.trim(); - if (fromLanguage) { return fromLanguage; } - - return 'Book'; -} - -export async function checkPandoc(pandocPath: string): Promise { - return new Promise((resolve, reject) => { - cp.execFile(pandocPath, ['--version'], { encoding: 'utf-8' }, (err, stdout) => { - if (err) { - reject(new Error('Pandoc is not available. Install it from https://pandoc.org')); - } else { - resolve(stdout.split('\n')[0] ?? 'unknown'); - } - }); - }); -} - - - -/** Cached list of pandoc's supported output formats, keyed by pandoc path. */ -const pandocFormatsCache = new Map(); - -/** - * Returns the lowercase list of output formats this pandoc build supports - * (from `pandoc --list-output-formats`). Cached per-path. - */ -export async function getPandocOutputFormats(pandocPath: string): Promise { - const cached = pandocFormatsCache.get(pandocPath); - if (cached) { return cached; } - return new Promise((resolve) => { - cp.execFile(pandocPath, ['--list-output-formats'], { encoding: 'utf-8' }, (err, stdout) => { - if (err) { resolve([]); return; } - const formats = stdout.split(/\r?\n/).map(v => v.trim().toLowerCase()).filter(Boolean); - pandocFormatsCache.set(pandocPath, formats); - resolve(formats); - }); - }); -} - -/** Clear cached pandoc capability info (call when pandoc path changes). */ -export function clearPandocCapabilityCache(): void { - pandocFormatsCache.clear(); -} - -function runPandoc( - inputPath: string, - outputPath: string, - outputType: OutputType, - root: string, - title: string, - lang: LanguageConfig, - author: string | undefined, - pandocPath: string -): Promise { - const args: string[] = [ - inputPath, - '-o', outputPath, - '--metadata', `title=${title}`, - ]; - - if (author) { - args.push('--metadata', `author=${author}`); - } - - // Language metadata - const langCode = lang.code.toLowerCase(); - args.push('--metadata', `lang=${langCode}`); - - // Date metadata - const date = new Date().toISOString().slice(0, 10); - args.push('--metadata', `date=${date}`); - - if (outputType === 'docx') { - args.push('--from=markdown+raw_attribute'); - const reference = path.join(root, 'reference.docx'); - if (fs.existsSync(reference)) { - args.push('--reference-doc', reference); - } - } - - if (outputType === 'epub') { - args.push('--split-level=2'); - const cover = path.join(root, 'Story', lang.folderName, 'cover.jpg'); - if (fs.existsSync(cover)) { - args.push('--epub-cover-image', cover); - } - } - - return new Promise((resolve, reject) => { - cp.execFile(pandocPath, args, { encoding: 'utf-8' }, (err, _stdout, stderr) => { - if (err) { - const details = stderr || err.message; - reject(new Error(`Pandoc failed: ${details}`)); - } else { - resolve(); - } - }); - }); -} - -function isMissingPdfEngineError(message: string): boolean { - return /pdflatex not found|pdf-engine|program not found/i.test(message); -} - -/** - * Run LibreOffice headless to convert a DOCX to PDF. - * LibreOffice writes .pdf into outputDir — we then rename it to finalPdfPath. - */ -async function runLibreOfficeToPdf( - docxPath: string, - outputDir: string, - finalPdfPath: string, - libreOfficePath: string -): Promise { - return new Promise((resolve, reject) => { - cp.execFile( - libreOfficePath, - ['--headless', '--convert-to', 'pdf', docxPath, '--outdir', outputDir], - { encoding: 'utf-8' }, - (err, _stdout, stderr) => { - if (err) { - const details = stderr || err.message; - reject(new Error( - `LibreOffice PDF conversion failed: ${details}\n` + - 'Make sure LibreOffice is installed and bindery.libreOfficePath is correct.' - )); - return; - } - // LibreOffice outputs .pdf in outputDir - const tempPdfName = path.basename(docxPath, '.docx') + '.pdf'; - const tempPdfPath = path.join(outputDir, tempPdfName); - try { - fs.renameSync(tempPdfPath, finalPdfPath); - resolve(); - } catch (renameErr: any) { - reject(new Error(`Failed to rename LibreOffice output: ${renameErr.message}`)); - } - } - ); - }); -} - -// ─── Format + Merge Entry Point ───────────────────────────────────────────── - -/** - * Format all markdown files in a directory tree (typography). - */ -function formatDirectory(dirPath: string): number { - let count = 0; - const entries = fs.readdirSync(dirPath, { withFileTypes: true }); - - for (const entry of entries) { - const fullPath = path.join(dirPath, entry.name); - if (entry.isDirectory()) { - count += formatDirectory(fullPath); - } else if (entry.isFile() && entry.name.endsWith('.md')) { - const content = fs.readFileSync(fullPath, 'utf-8'); - const formatted = updateTypography(content); - if (content !== formatted) { - fs.writeFileSync(fullPath, formatted, 'utf-8'); - count++; - } - } - } - - return count; -} - -/** - * Main merge entry point. - */ -export async function mergeBook(options: MergeOptions): Promise { - // Legacy: old UK LanguageConfig acts like a dialect of EN - const isLegacyUk = isLegacyUkLanguage(options.language); - const dialectFolder = isLegacyUk ? 'UK' : undefined; - - if (isLegacyUk) { - prepareDialectFolder(options.root, options.storyFolder, 'EN', 'UK', options.ukReplacements ?? []); - } else if (options.dialectCode) { - // Dialect export: copy source folder to a temp name, apply substitutions - prepareDialectFolder( - options.root, options.storyFolder, - options.language.folderName, - `_dialect_${options.dialectCode}`, - options.ukReplacements ?? [] - ); - } - - const effectiveFolderName = isLegacyUk - ? 'UK' - : options.dialectCode - ? `_dialect_${options.dialectCode}` - : options.language.folderName; - - try { - const langPath = path.join(options.root, options.storyFolder, effectiveFolderName); - - if (!fs.existsSync(langPath)) { - throw new Error(`Language folder not found: ${langPath}`); - } - - // Format files first (same as Rust version) - formatDirectory(langPath); - - // Discover and order files - const files = getOrderedFiles(langPath, options.language); - if (files.length === 0) { - throw new Error(`No markdown files found in ${langPath}`); - } - - // Create output directory - const outputDir = path.join(options.root, options.outputDir); - fs.mkdirSync(outputDir, { recursive: true }); - - // Dialect exports get a distinct suffix in the filename (e.g. Book_EN-GB_Merged) - const folderSuffix = options.dialectCode - ? options.dialectCode.toUpperCase() - : options.language.folderName; - const baseName = `${options.filePrefix}_${folderSuffix}_Merged`; - const outputs: string[] = []; - const warnings: string[] = []; - - // Check pandoc availability if needed (PDF also needs pandoc for the intermediate DOCX) - const needsPandoc = options.outputTypes.some(t => t === 'docx' || t === 'epub' || t === 'pdf'); - if (needsPandoc) { - await checkPandoc(options.pandocPath); - - // Capability probe: warn up-front if the installed pandoc can't produce a requested format, - // rather than failing mid-export with a cryptic pandoc error. - const supported = await getPandocOutputFormats(options.pandocPath); - if (supported.length > 0) { - for (const t of options.outputTypes) { - // PDF is produced via DOCX+LibreOffice, so we only need 'docx' in pandoc for it. - const pandocFormat = t === 'pdf' ? 'docx' : t; - if (pandocFormat === 'md') { continue; } - if (!supported.includes(pandocFormat)) { - warnings.push( - `Pandoc at ${options.pandocPath} does not support '${pandocFormat}' output. ` + - `Install a full pandoc build from https://pandoc.org or remove '${t}' from the export list.` - ); - } - } - } - } - - // Determine book title from settings-driven options only - const title = resolveBookTitle(options); - - for (const outputType of options.outputTypes) { - const outputPath = path.join(outputDir, `${baseName}.${outputType}`); - - if (outputType === 'md') { - const content = buildMarkdownContent(files, options); - fs.writeFileSync(outputPath, content, 'utf-8'); - outputs.push(outputPath); - } else if (outputType === 'pdf') { - // PDF: generate an intermediate DOCX via pandoc, then convert with LibreOffice - const libreOfficePath = options.libreOfficePath?.trim() || 'libreoffice'; - const tempMdPath = path.join(outputDir, `${baseName}_pdf_temp.md`); - const tempDocxPath = path.join(outputDir, `${baseName}_pdf_temp.docx`); - const content = buildPandocContent(files, options, 'docx'); - fs.writeFileSync(tempMdPath, content, 'utf-8'); - try { - await runPandoc(tempMdPath, tempDocxPath, 'docx', options.root, title, options.language, options.author, options.pandocPath); - await runLibreOfficeToPdf(tempDocxPath, outputDir, outputPath, libreOfficePath); - outputs.push(outputPath); - } finally { - try { fs.unlinkSync(tempMdPath); } catch { /* ignore */ } - try { fs.unlinkSync(tempDocxPath); } catch { /* ignore */ } - } - } else { - // DOCX / EPUB: build pandoc-specific markdown and call pandoc directly - const content = buildPandocContent(files, options, outputType); - const tempPath = path.join(outputDir, `${baseName}_temp.md`); - fs.writeFileSync(tempPath, content, 'utf-8'); - try { - await runPandoc(tempPath, outputPath, outputType, options.root, title, options.language, options.author, options.pandocPath); - outputs.push(outputPath); - } finally { - try { fs.unlinkSync(tempPath); } catch { /* ignore */ } - } - } - } - - return { outputs, filesMerged: files.length, warnings }; - } finally { - if (isLegacyUk) { - cleanupDialectTempFolder(options.root, options.storyFolder, 'UK'); - } else if (options.dialectCode) { - cleanupDialectTempFolder(options.root, options.storyFolder, `_dialect_${options.dialectCode}`); - } - } -} diff --git a/vscode-ext/src/tool-locate.ts b/vscode-ext/src/tool-locate.ts index 8120d06..7718304 100644 --- a/vscode-ext/src/tool-locate.ts +++ b/vscode-ext/src/tool-locate.ts @@ -1,180 +1,13 @@ /** - * Auto-detect Pandoc and LibreOffice installations across platforms. + * Tool path location — re-exported from @bindery/merge * - * Resolution order for each tool: - * 1. Explicit user setting (if set to a non-default value, even if the file is missing, - * so downstream code can report a clearer error for that configured path) - * 2. Command on PATH (`where.exe` / `which`) - * 3. Well-known per-OS install locations - * - * Results are cached per-process. Call `clearLocateCache()` when settings change. + * Auto-detects Pandoc and LibreOffice installations across platforms (Windows, macOS, Linux). + * All implementation logic has been moved to @bindery/merge for code reuse. */ -import * as cp from 'node:child_process'; -import * as fs from 'node:fs'; -import * as path from 'node:path'; - -export type ToolName = 'pandoc' | 'libreoffice'; - -interface ResolvedTool { - path: string; - source: 'setting' | 'path' | 'default' | 'fallback'; -} - -const cache = new Map(); - -/** Clear the in-memory resolution cache (call when user settings change). */ -export function clearLocateCache(): void { - cache.clear(); -} - -/** - * Return known install locations for a tool on the current platform. - * Variables like %ProgramFiles% are expanded from process.env. - */ -function wellKnownPaths(tool: ToolName): string[] { - const env = process.env; - const home = env.HOME || env.USERPROFILE || ''; - - if (process.platform === 'win32') { - const programFiles = env['ProgramFiles'] || 'C:\\Program Files'; - const programFiles86 = env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)'; - const localAppData = env['LOCALAPPDATA'] || path.join(home, 'AppData', 'Local'); - - if (tool === 'pandoc') { - return [ - path.join(localAppData, 'Pandoc', 'pandoc.exe'), - path.join(programFiles, 'Pandoc', 'pandoc.exe'), - path.join(programFiles86, 'Pandoc', 'pandoc.exe'), - ]; - } - // libreoffice — prefer soffice.com (console wrapper) over soffice.exe - // to avoid GUI prompts when probing --version. - return [ - path.join(programFiles, 'LibreOffice', 'program', 'soffice.com'), - path.join(programFiles86, 'LibreOffice', 'program', 'soffice.com'), - path.join(programFiles, 'LibreOffice', 'program', 'soffice.exe'), - path.join(programFiles86, 'LibreOffice', 'program', 'soffice.exe'), - ]; - } - - if (process.platform === 'darwin') { - if (tool === 'pandoc') { - return [ - '/opt/homebrew/bin/pandoc', - '/usr/local/bin/pandoc', - '/usr/bin/pandoc', - ]; - } - return [ - '/Applications/LibreOffice.app/Contents/MacOS/soffice', - '/opt/homebrew/bin/soffice', - '/usr/local/bin/soffice', - ]; - } - - // Linux / other POSIX - if (tool === 'pandoc') { - return ['/usr/bin/pandoc', '/usr/local/bin/pandoc']; - } - return ['/usr/bin/libreoffice', '/usr/bin/soffice', '/usr/local/bin/libreoffice']; -} - -/** Default command name (what `which`/`where` would look up). */ -function defaultCommand(tool: ToolName): string { - if (tool === 'pandoc') { return 'pandoc'; } - // soffice.com is the console-subsystem wrapper on Windows — using it - // prevents the LibreOffice GUI from popping a console window with - // "Press Enter to continue..." during version probes. - return process.platform === 'win32' ? 'soffice.com' : 'libreoffice'; -} - -/** All names that can resolve via PATH for a given tool. */ -function pathCandidates(tool: ToolName): string[] { - if (tool === 'pandoc') { return ['pandoc']; } - return process.platform === 'win32' - ? ['soffice.com', 'soffice.exe', 'soffice'] - : ['libreoffice', 'soffice']; -} - -/** Is this setting value "the unset default" (empty, or literal 'pandoc'/'libreoffice')? */ -function isSettingDefault(tool: ToolName, value: string | undefined): boolean { - const v = (value ?? '').trim(); - if (v === '') { return true; } - if (tool === 'pandoc' && v === 'pandoc') { return true; } - if (tool === 'libreoffice' && (v === 'libreoffice' || v === 'soffice')) { return true; } - return false; -} - -/** Look up a command on PATH using the OS's native resolver. Returns null if not found. */ -function resolveOnPath(cmd: string): string | null { - const locator = process.platform === 'win32' ? 'where.exe' : 'which'; - try { - const result = cp.spawnSync(locator, [cmd], { encoding: 'utf-8', timeout: 5000 }); - if (result.status !== 0) { return null; } - const first = (result.stdout || '').split(/\r?\n/).map(s => s.trim()).find(Boolean); - return first || null; - } catch { - return null; - } -} - -/** - * Resolve a tool's executable path. - * @param tool 'pandoc' or 'libreoffice' - * @param setting User-configured path (or empty). Overrides auto-detect when pointing at an existing file. - * @returns Resolved path + source, or null if nothing found. Falls back to the command name so callers can still show a useful error. - */ -export function locateTool(tool: ToolName, setting: string | undefined): ResolvedTool { - const cached = cache.get(tool); - if (cached && cached.source === 'setting' && (setting ?? '').trim() === cached.path) { - return cached; - } - if (cached && cached.source !== 'setting' && isSettingDefault(tool, setting)) { - return cached; - } - - // 1. Explicit user setting - const trimmed = (setting ?? '').trim(); - if (!isSettingDefault(tool, trimmed)) { - if (fs.existsSync(trimmed)) { - const resolved: ResolvedTool = { path: trimmed, source: 'setting' }; - cache.set(tool, resolved); - return resolved; - } - // Setting points somewhere but the file does not exist — keep the literal - // so the caller surfaces a clear "not found at " error, rather than silently auto-detecting. - const resolved: ResolvedTool = { path: trimmed, source: 'setting' }; - cache.set(tool, resolved); - return resolved; - } - - // 2. PATH lookup — try all candidate names (e.g. both 'libreoffice' and 'soffice') - for (const cmd of pathCandidates(tool)) { - const onPath = resolveOnPath(cmd); - if (onPath && fs.existsSync(onPath)) { - const resolved: ResolvedTool = { path: onPath, source: 'path' }; - cache.set(tool, resolved); - return resolved; - } - } - - // 3. Well-known defaults - for (const candidate of wellKnownPaths(tool)) { - if (fs.existsSync(candidate)) { - const resolved: ResolvedTool = { path: candidate, source: 'default' }; - cache.set(tool, resolved); - return resolved; - } - } - - // 4. Fallback to command name — will fail at execution with a clear error - const fallback: ResolvedTool = { path: defaultCommand(tool), source: 'fallback' }; - cache.set(tool, fallback); - return fallback; -} - -/** Convenience wrapper — return just the path string. */ -export function locateToolPath(tool: ToolName, setting: string | undefined): string { - return locateTool(tool, setting).path; -} +export { + type ToolName, + locateTool, + locateToolPath, + clearLocateCache, +} from '@bindery/merge';