mirror of
https://github.com/evdboom/Bindery.git
synced 2026-07-22 06:49:36 +00:00
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:
parent
fa9ca7748f
commit
e4773fdf28
28 changed files with 2634 additions and 1219 deletions
131
.github/copilot-instructions.md
vendored
131
.github/copilot-instructions.md
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
18
.github/workflows/ci.yml
vendored
18
.github/workflows/ci.yml
vendored
|
|
@ -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
1
.gitignore
vendored
|
|
@ -5,6 +5,7 @@ node_modules/
|
|||
vscode-ext/out/
|
||||
mcp-ts/out/
|
||||
bindery-core/out/
|
||||
bindery-merge/out/
|
||||
obsidian-plugin/out/
|
||||
mcp-rust/target/
|
||||
|
||||
|
|
|
|||
106
CONTRIBUTING.md
106
CONTRIBUTING.md
|
|
@ -6,21 +6,39 @@ Thank you for contributing! This document explains how to run the tests and what
|
|||
|
||||
## Running Tests Locally
|
||||
|
||||
### Quick test (both packages)
|
||||
### Quick test (all packages)
|
||||
|
||||
```bash
|
||||
cd mcp-ts && npm test
|
||||
cd ../vscode-ext && npm test
|
||||
npm test # runs all packages in parallel
|
||||
```
|
||||
|
||||
Or test individual packages:
|
||||
|
||||
```bash
|
||||
npm run test --workspace=bindery-core
|
||||
npm run test --workspace=bindery-merge
|
||||
npm run test --workspace=mcp-ts
|
||||
npm run test --workspace=vscode-ext
|
||||
npm run test --workspace=obsidian-plugin
|
||||
```
|
||||
|
||||
### Watch mode (auto-rerun on file change)
|
||||
|
||||
```bash
|
||||
# Terminal 1
|
||||
cd mcp-ts && npm run test:watch
|
||||
npm run test:watch --workspace=bindery-core
|
||||
|
||||
# Terminal 2
|
||||
cd vscode-ext && npm run test:watch
|
||||
npm run test:watch --workspace=bindery-merge
|
||||
|
||||
# Terminal 3
|
||||
npm run test:watch --workspace=mcp-ts
|
||||
|
||||
# Terminal 4
|
||||
npm run test:watch --workspace=vscode-ext
|
||||
|
||||
# Terminal 5
|
||||
npm run test:watch --workspace=obsidian-plugin
|
||||
```
|
||||
|
||||
### CI reporter (as GitHub Actions runs it)
|
||||
|
|
@ -38,11 +56,18 @@ The `test:ci` script writes a `test-results.json` file in each package directory
|
|||
|
||||
| Layer | Location | What is tested |
|
||||
|---|---|---|
|
||||
| **bindery-core unit tests** | `bindery-core/test/` | Templates, settings, translations, formatting logic |
|
||||
| **bindery-merge unit tests** | `bindery-merge/test/merge.test.ts` | Pure merge functions, chapter discovery, dialect conversion |
|
||||
| **bindery-merge mocked tests** | `bindery-merge/test/merge-mocked.test.ts` | Pandoc/LibreOffice paths (with child_process mocked) |
|
||||
| **bindery-merge extended tests** | `bindery-merge/test/merge-extended.test.ts` | Internal functions, Pandoc helpers, typography integration |
|
||||
| **MCP unit tests** | `mcp-ts/test/tools.test.ts` | Tool logic, path safety, search indexing |
|
||||
| **MCP contract tests** | `mcp-ts/test/index-contract.test.ts` | Every registered tool has exactly one annotation hint |
|
||||
| **MCP stdio integration** | `mcp-ts/test/integration-stdio.test.ts` | Spawn real server, JSON-RPC handshake, tool calls, path-traversal defence, error handling |
|
||||
| **VS Code unit tests** | `vscode-ext/test/workspace.test.ts`, `vscode-ext/test/mcp.test.ts` | Workspace helpers, MCP JSON writer |
|
||||
| **VS Code integration** | `vscode-ext/test/integration-commands.test.ts` | Init workflow, registerMcp, formatDocument, settings precedence |
|
||||
| **Obsidian plugin tests** | `obsidian-plugin/test/` | Plugin lifecycle, workspace management, AI setup, merge execution, formatter integration |
|
||||
| **Obsidian exporter** | `obsidian-plugin/test/exporter.test.ts` | Export orchestration, multi-format output |
|
||||
| **Obsidian merge** | `obsidian-plugin/test/merge.test.ts` | Chapter discovery, dialect handling, Obsidian Vault API integration |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -65,20 +90,25 @@ lives:
|
|||
|
||||
## Host Feature Parity Policy
|
||||
|
||||
`vscode-ext/` and `obsidian-plugin/` target the same Bindery authoring feature
|
||||
set. The Obsidian plugin currently implements a subset of VS Code commands
|
||||
(export, review markers, init workspace, and MCP snippet); `format-document` is
|
||||
still a placeholder and commands such as setup AI, translation/dialect/language
|
||||
management, open translations, and register MCP are VS Code-only for now.
|
||||
**Feature parity is complete.** Both `vscode-ext/` and `obsidian-plugin/` implement
|
||||
identical Bindery authoring workflows:
|
||||
|
||||
Unless a feature is explicitly host-specific, any functional change added to one
|
||||
host should be implemented in the other host in the same PR (or in a clearly
|
||||
linked follow-up PR).
|
||||
- **Shared logic**: All merge, export, tool-location, and typography logic lives in
|
||||
`bindery-merge/` and is consumed by both hosts.
|
||||
- **Equivalent commands**: Both hosts provide all 17+ authoring commands (format,
|
||||
merge, AI setup, workspace management, dialect/translation/language management).
|
||||
- **Equivalent tests**: Each host has a full test suite covering its command wiring
|
||||
and host-specific integration (Obsidian Vault API, VS Code Workspace API).
|
||||
|
||||
Unless a feature is **explicitly host-specific** (e.g., VS Code's Language Model Tool API),
|
||||
any functional change or bug fix added to one host must be implemented in the other
|
||||
host in the same PR.
|
||||
|
||||
When adding or changing commands:
|
||||
- update command wiring in both hosts
|
||||
- add or update tests in both hosts
|
||||
- document intentional host-specific exceptions in the PR description
|
||||
- **Update logic**: If logic lives in `bindery-merge/` or `bindery-core/`, update there once
|
||||
- **Update command wiring**: Update in both `vscode-ext/src/extension.ts` and `obsidian-plugin/src/main.ts`
|
||||
- **Add tests**: Add to both host test suites to validate host-specific integration
|
||||
- **Document exceptions**: Clearly note any intentional host-specific behavior in the PR description
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -112,22 +142,28 @@ Tests cover:
|
|||
|
||||
## CI pipeline (`.github/workflows/ci.yml`)
|
||||
|
||||
The workflow runs on every push and pull request on all branches.
|
||||
The workflow runs on every push and pull request on all branches, on all three major platforms
|
||||
(Ubuntu, Windows, macOS).
|
||||
|
||||
A single `test` job runs the steps sequentially:
|
||||
A single `test` job runs the build and test steps sequentially:
|
||||
|
||||
| Step | What it does |
|
||||
|---|---|
|
||||
| Install + compile bindery-core | `npm ci` → `npm run compile` |
|
||||
| Run bindery-core tests | `npm run test:ci` → uploads `test-results.json` artifact |
|
||||
| Install + compile mcp-ts | `npm ci` → `npm run compile` |
|
||||
| Run mcp-ts tests | `npm run test:ci` → uploads `test-results.json` artifact |
|
||||
| Install + compile vscode-ext | `npm ci` → `npm run compile` |
|
||||
| Run vscode-ext tests | `npm run test:ci` → uploads `test-results.json` artifact |
|
||||
| Install + compile obsidian-plugin | `npm ci` → `npm run compile` |
|
||||
| Run obsidian-plugin tests | `npm run test:ci` → uploads `test-results.json` artifact |
|
||||
| Install all workspace deps | `npm ci` |
|
||||
| Build + test bindery-core | `npm run build` → `npm run test:ci` → uploads `test-results.json` |
|
||||
| Build + test bindery-merge | `npm run build` → `npm run test:ci` → uploads `test-results.json` |
|
||||
| Compile + test mcp-ts | `npm run compile` → `npm run test:ci` → uploads `test-results.json` |
|
||||
| Compile + test vscode-ext | `npm run compile` → `npm run test:ci` (+ VSIX smoke check on ubuntu-latest) |
|
||||
| Compile + test obsidian-plugin | `npm run compile` → `npm run bundle` → `npm run test:ci` → uploads `test-results.json` |
|
||||
|
||||
PRs **cannot be merged** unless the `test` job passes.
|
||||
A separate `coverage` job runs on ubuntu-latest only (coverage metrics don't vary by OS):
|
||||
|
||||
| Step | What it does |
|
||||
|---|---|
|
||||
| Build + coverage for each package | `npm run compile` → `npm run test:coverage` |
|
||||
| Upload coverage reports | Artifacts collected for each package |
|
||||
|
||||
**PRs cannot be merged** unless the `test` job passes on all platforms.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -140,8 +176,20 @@ PRs **cannot be merged** unless the `test` job passes.
|
|||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Merge tests won't compile
|
||||
The merge tests have moved from `vscode-ext/test/` to `bindery-merge/test/`. If you have
|
||||
local references to the old paths, update imports to point to `bindery-merge` instead.
|
||||
|
||||
### CI passes locally but fails in GitHub Actions
|
||||
Ensure your `package-lock.json` was generated with the same npm major version as the one
|
||||
used in `.github/workflows/ci.yml`. This prevents lockfile skew across platforms.
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- E2E tests with real pandoc/LibreOffice invocation
|
||||
- Cross-platform CI matrix (Windows, macOS)
|
||||
- Code coverage reporting and threshold enforcement
|
||||
- Code coverage reporting dashboards
|
||||
- Per-commit performance benchmarks (merge speed, search latency)
|
||||
|
|
|
|||
82
README.md
82
README.md
|
|
@ -51,6 +51,30 @@ A [Model Context Protocol](https://modelcontextprotocol.io/) server that exposes
|
|||
|
||||
See [mcpb/README.md](mcpb/README.md) for the full 27-tool reference and usage examples.
|
||||
|
||||
### [obsidian-plugin/](obsidian-plugin/) — Obsidian Plugin
|
||||
|
||||
The **Bindery** Obsidian plugin provides the same feature set as the VS Code extension for Obsidian vault users:
|
||||
|
||||
- **Typography formatting** — curly quotes, em-dashes, ellipses, smart apostrophes (on save or on demand)
|
||||
- **Chapter merge & export** — Markdown, DOCX, EPUB, PDF output via Pandoc + LibreOffice
|
||||
- **Dialect & translation management** — extensible substitution rules and glossaries
|
||||
- **Multi-language support** — configurable per-language chapter labelling
|
||||
- **Workspace config** — `.bindery/settings.json` for vault-level settings
|
||||
- **AI instruction generation** — Generate CLAUDE.md, copilot-instructions.md, .cursor/rules, AGENTS.md
|
||||
- **Review markers** — Mark regions for agent feedback
|
||||
- **MCP snippet generator** — Copy JSON for Claude Desktop integration
|
||||
|
||||
Build and install:
|
||||
|
||||
```bash
|
||||
cd obsidian-plugin
|
||||
npm install
|
||||
npm run compile
|
||||
npm run bundle
|
||||
# out/main.js is ready for manual Obsidian installation
|
||||
```
|
||||
|
||||
Then in Obsidian: Settings → Community plugins (if not restricted) → Install from folder or manual install from `out/main.js`.
|
||||
|
||||
### [mcpb/](mcpb/) — Claude Desktop Extension
|
||||
|
||||
|
|
@ -86,19 +110,34 @@ The VS Code extension works standalone — no server setup needed for typography
|
|||
## Project Structure
|
||||
|
||||
```
|
||||
├── vscode-ext/ VS Code extension (TypeScript)
|
||||
│ ├── src/ Extension source
|
||||
│ ├── package.json Extension manifest
|
||||
│ └── README.md Extension docs
|
||||
├── mcp-ts/ MCP server (Node.js / TypeScript)
|
||||
│ ├── src/ Server source
|
||||
│ └── package.json Package manifest
|
||||
├── mcpb/ Claude Desktop extension package (.mcpb)
|
||||
│ ├── manifest.json Extension metadata and tool list
|
||||
│ └── server/ Populated by CI (mcp-ts build output)
|
||||
└── LICENSE MIT
|
||||
├── bindery-core/ Shared templates & types (TS)
|
||||
│ ├── src/
|
||||
│ │ └── templates/ AI instruction templates (claude, copilot, cursor, agents, skills)
|
||||
│ └── package.json
|
||||
├── bindery-merge/ Shared merge logic (TS)
|
||||
│ ├── src/
|
||||
│ │ ├── merge.ts Chapter discovery, merge, export via Pandoc/LibreOffice
|
||||
│ │ └── tool-locate.ts Cross-platform tool path resolution
|
||||
│ └── package.json
|
||||
├── vscode-ext/ VS Code extension (TS)
|
||||
│ ├── src/
|
||||
│ ├── package.json
|
||||
│ └── README.md
|
||||
├── obsidian-plugin/ Obsidian plugin (TS)
|
||||
│ ├── src/
|
||||
│ ├── package.json
|
||||
│ └── README.md
|
||||
├── mcp-ts/ MCP server (Node.js / TS)
|
||||
│ ├── src/
|
||||
│ └── package.json
|
||||
├── mcpb/ Claude Desktop extension
|
||||
│ ├── manifest.json
|
||||
│ └── server/ (CI-populated)
|
||||
└── LICENSE
|
||||
```
|
||||
|
||||
Shared logic in `bindery-core` and `bindery-merge` ensures both `vscode-ext` and `obsidian-plugin` implement identical functionality.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **VS Code** 1.85+
|
||||
|
|
@ -146,14 +185,21 @@ MIT — see [LICENSE](LICENSE).
|
|||
|
||||
## Host Parity Reminder
|
||||
|
||||
`vscode-ext/` and `obsidian-plugin/` target the same Bindery authoring feature
|
||||
set. The Obsidian plugin currently implements a subset of VS Code commands
|
||||
(export, review markers, init workspace, and MCP snippet); `format-document` is
|
||||
still a placeholder and commands such as setup AI, translation/dialect/language
|
||||
management, open translations, and register MCP are VS Code-only for now.
|
||||
`vscode-ext/` and `obsidian-plugin/` are **two equal implementations** of the same Bindery authoring toolkit. Both support:
|
||||
|
||||
Unless a change is intentionally host-specific, functional additions to one host
|
||||
should be mirrored in the other in the same PR or a clearly linked follow-up PR.
|
||||
- **Typography formatting** (curly quotes, em-dashes, ellipses)
|
||||
- **Chapter merge & export** (MD, DOCX, EPUB, PDF via Pandoc + LibreOffice)
|
||||
- **Dialect & translation management** — extensible substitution rules and glossaries
|
||||
- **Multi-language support** — configurable chapter labels and folder structures
|
||||
- **Workspace initialization** — `.bindery/settings.json` and `.bindery/translations.json`
|
||||
- **AI instruction generation** — CLAUDE.md, copilot-instructions.md, .cursor/rules, AGENTS.md
|
||||
- **Review markers** — wrap text in `<!-- 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**:
|
||||
|
||||
|
|
|
|||
26
bindery-merge/package.json
Normal file
26
bindery-merge/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
26
bindery-merge/src/index.ts
Normal file
26
bindery-merge/src/index.ts
Normal 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
848
bindery-merge/src/merge.ts
Normal 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 `})\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 `})\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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
153
bindery-merge/src/tool-locate.ts
Normal file
153
bindery-merge/src/tool-locate.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
7
bindery-merge/test/tsconfig.json
Normal file
7
bindery-merge/test/tsconfig.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": false
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
}
|
||||
19
bindery-merge/tsconfig.json
Normal file
19
bindery-merge/tsconfig.json
Normal 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"]
|
||||
}
|
||||
22
bindery-merge/vitest.config.ts
Normal file
22
bindery-merge/vitest.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -12,7 +12,8 @@
|
|||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bindery/core": "*"
|
||||
"@bindery/core": "*",
|
||||
"@bindery/merge": "*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.6.0",
|
||||
|
|
|
|||
150
obsidian-plugin/src/ai-setup.ts
Normal file
150
obsidian-plugin/src/ai-setup.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
68
obsidian-plugin/src/merge.ts
Normal file
68
obsidian-plugin/src/merge.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
209
obsidian-plugin/src/workspace.ts
Normal file
209
obsidian-plugin/src/workspace.ts
Normal 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();
|
||||
}
|
||||
185
obsidian-plugin/test/ai-setup.test.ts
Normal file
185
obsidian-plugin/test/ai-setup.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
147
obsidian-plugin/test/merge.test.ts
Normal file
147
obsidian-plugin/test/merge.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
245
obsidian-plugin/test/workspace.test.ts
Normal file
245
obsidian-plugin/test/workspace.test.ts
Normal 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
25
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
"private": true,
|
||||
"workspaces": [
|
||||
"bindery-core",
|
||||
"bindery-merge",
|
||||
"vscode-ext",
|
||||
"mcp-ts",
|
||||
"obsidian-plugin"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 `})\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 `})\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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Reference in a new issue