feat: integrate @bindery/merge for book merging and tool location

- Added bindery-merge as a workspace in package.json.
- Updated vscode-ext to include @bindery/merge as a dependency.
- Refactored merge.ts to re-export merging functionalities from @bindery/merge, removing legacy code.
- Simplified tool-locate.ts by re-exporting tool location logic from @bindery/merge, enhancing code reuse and maintainability.

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Erik van der Boom 2026-05-03 23:50:11 +02:00
parent fa9ca7748f
commit e4773fdf28
28 changed files with 2634 additions and 1219 deletions

View file

@ -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

View file

@ -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/

1
.gitignore vendored
View file

@ -5,6 +5,7 @@ node_modules/
vscode-ext/out/
mcp-ts/out/
bindery-core/out/
bindery-merge/out/
obsidian-plugin/out/
mcp-rust/target/

View file

@ -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)

View file

@ -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 `<!-- Bindery: Review start/stop -->` 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**:

View file

@ -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"
}
}

View file

@ -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';

848
bindery-merge/src/merge.ts Normal file
View file

@ -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<string, string>; pattern: RegExp } {
const merged = new Map<string, string>();
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<string, number> = {
'I': 1, 'II': 2, 'III': 3, 'IV': 4, 'V': 5
};
return map[roman.toUpperCase()];
}
function intToRoman(n: number): string {
const map: Record<number, string> = { 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<number, string>();
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}
<w:p><w:r><w:br w:type="page"/></w:r></w:p>
\`\`\`
`;
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<string> {
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<string, string[]>();
export async function getPandocOutputFormats(pandocPath: string): Promise<string[]> {
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<void> {
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<void> {
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<MergeResult> {
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}`);
}
}
}

View file

@ -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<ToolName, ResolvedTool | null>();
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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -0,0 +1,7 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"composite": false
},
"include": ["**/*.ts"]
}

View file

@ -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"]
}

View file

@ -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,
},
},
},
});

View file

@ -12,7 +12,8 @@
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@bindery/core": "*"
"@bindery/core": "*",
"@bindery/merge": "*"
},
"devDependencies": {
"@types/node": "^25.6.0",

View file

@ -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<AiSetupResult> {
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<string, unknown>;
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<string, unknown>): 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);
}

View file

@ -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<void> {
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<void> {
// 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<void> {
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<void> {
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<void> {
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<string, string> = {};
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<void> {
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<void> {
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<void> {
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<string | null> {
return new Promise((resolve) => {
const result = window.prompt(prompt, defaultValue);
resolve(result);
});
}
private insertStartReviewMarker(editor: Editor): void {

View file

@ -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<MergeResult> {
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}`);
}
}

View file

@ -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<string, string>;
}
/**
* 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<string, string>): 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<string>();
for (const pattern of usPatterns) {
let match;
while ((match = pattern.exec(content)) !== null) {
found.add(match[0]);
}
}
return Array.from(found).sort();
}

View file

@ -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);
});
});

View file

@ -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);
});
});

View file

@ -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);
});
});
});

25
package-lock.json generated
View file

@ -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",

View file

@ -3,6 +3,7 @@
"private": true,
"workspaces": [
"bindery-core",
"bindery-merge",
"vscode-ext",
"mcp-ts",
"obsidian-plugin"

View file

@ -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",

View file

@ -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<string, string>; pattern: RegExp } {
const merged = new Map<string, string>();
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<string, number> = {
'I': 1, 'II': 2, 'III': 3, 'IV': 4, 'V': 5
};
return map[roman.toUpperCase()];
}
function intToRoman(n: number): string {
const map: Record<number, string> = { 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<number, string>();
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}
<w:p><w:r><w:br w:type="page"/></w:r></w:p>
\`\`\`
`;
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<string> {
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<string, string[]>();
/**
* 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<string[]> {
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<void> {
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 <basename>.pdf into outputDir we then rename it to finalPdfPath.
*/
async function runLibreOfficeToPdf(
docxPath: string,
outputDir: string,
finalPdfPath: string,
libreOfficePath: string
): Promise<void> {
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 <basename-without-ext>.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<MergeResult> {
// 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}`);
}
}
}

View file

@ -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<ToolName, ResolvedTool | null>();
/** 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 <path>" 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';