Merge pull request #10 from evdboom/translation-update

feat: Enhance translation and dialect management in Bindery
This commit is contained in:
Erik van der Boom 2026-04-03 12:34:19 +02:00 committed by GitHub
commit 16eef1dd26
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 824 additions and 198 deletions

View file

@ -87,8 +87,11 @@ All tools take a `book` argument (string name from `--book` config). The VS Code
| `format` | destructive | Apply typography formatting to file/folder |
| `get_review_text` | destructive | Git diff with optional autoStage |
| `git_snapshot` | destructive | Git commit of story/notes/arc changes |
| `get_translation` | readOnly | List all rules for a language, or look up a specific word (forgiving) |
| `add_translation` | destructive | Add/update .bindery/translations.json rule |
| `get_translation` | readOnly | List glossary entries for a language, or look up a specific term (forgiving) |
| `add_translation` | destructive | Add/update a cross-language glossary entry (agent reference, not auto-applied at export) |
| `get_dialect` | readOnly | List dialect substitution rules, or look up a specific word |
| `add_dialect` | destructive | Add/update a dialect substitution rule (auto-applied at export, e.g. US→UK) |
| `add_language` | destructive | Add language to settings.json and scaffold story folder with stubs |
| `memory_list` | readOnly | List Notes/Memories/ files with line counts |
| `memory_append` | destructive | Append dated session entry to a memory file |
| `memory_compact` | destructive | Overwrite memory file with summary (backs up original) |
@ -114,7 +117,10 @@ All tools take a `book` argument (string name from `--book` config). The VS Code
| `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.addUkReplacement` | Add a substitution rule |
| `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 |

View file

@ -29,6 +29,9 @@ import {
toolGitSnapshot,
toolGetTranslation,
toolAddTranslation,
toolAddDialect,
toolGetDialect,
toolAddLanguage,
toolInitWorkspace,
toolSetupAiFiles,
toolMemoryList,
@ -258,34 +261,90 @@ server.registerTool('git_snapshot', {
server.registerTool('get_translation', {
title: 'Get Translation',
description:
'Look up translation/substitution rules in .bindery/translations.json. ' +
'Without a word, lists all rules for the language. ' +
'With a word, does a forgiving case-insensitive lookup including plural and inflected forms.',
'Look up glossary entries in .bindery/translations.json. ' +
'Without a word, lists all entries for the language. ' +
'With a word, does a forgiving case-insensitive lookup including plural and inflected forms. ' +
'For dialect substitution rules, use get_dialect instead.',
inputSchema: {
book: bookSchema,
language: z.string().describe('Language key, label, or code (e.g. "nl", "en-gb", "British English")'),
word: z.string().optional().describe('Word or term to look up (optional — omit to list all rules)'),
language: z.string().describe('Language code or label (e.g. "nl", "fr", "Dutch")'),
word: z.string().optional().describe('Word or term to look up (optional — omit to list all)'),
type: z.enum(['glossary', 'substitution']).optional().describe('Entry type filter — defaults to "glossary"'),
},
annotations: { readOnlyHint: true },
}, async ({ book, language, word }) => {
try { return ok(toolGetTranslation(resolveBook(book).root, { language, word })); } catch (e) { return err(e); }
}, async ({ book, language, word, type }) => {
try { return ok(toolGetTranslation(resolveBook(book).root, { language, word, type })); } catch (e) { return err(e); }
});
server.registerTool('add_translation', {
title: 'Add Translation',
description:
'Add or update a substitution rule in .bindery/translations.json. ' +
'Creates the entry if it does not exist. ' +
'Rules are used during chapter export to convert dialect-specific words (e.g. US→UK spelling).',
'Add or update a glossary entry in .bindery/translations.json for agent reference. ' +
'Glossaries are cross-language term pairs (source → target language) used by agents for consistency, ' +
'not auto-applied during export. For dialect substitution rules (e.g. US→UK spelling), use add_dialect.',
inputSchema: {
book: bookSchema,
langKey: z.string().describe('Language key in translations.json, e.g. "en-gb" or "nl"'),
from: z.string().describe('Source word or phrase (e.g. "airplane")'),
to: z.string().describe('Target word or phrase (e.g. "aeroplane")'),
book: bookSchema,
targetLangCode: z.string().describe('Target language code (e.g. "nl", "fr")'),
from: z.string().describe('Source term (e.g. "FluxCore")'),
to: z.string().describe('Target term (e.g. "FluxKern")'),
},
annotations: { destructiveHint: true },
}, async ({ book, langKey, from, to }) => {
try { return ok(toolAddTranslation(resolveBook(book).root, { langKey, from, to })); } catch (e) { return err(e); }
}, async ({ book, targetLangCode, from, to }) => {
try { return ok(toolAddTranslation(resolveBook(book).root, { targetLangCode, from, to })); } catch (e) { return err(e); }
});
server.registerTool('add_dialect', {
title: 'Add Dialect Rule',
description:
'Add or update a dialect substitution rule in .bindery/translations.json. ' +
'Substitution rules are auto-applied during export (e.g. US→UK spelling: color→colour). ' +
'For cross-language glossary entries, use add_translation.',
inputSchema: {
book: bookSchema,
dialectCode: z.string().describe('Dialect code used as key, e.g. "en-gb"'),
from: z.string().describe('Source word (e.g. "color")'),
to: z.string().describe('Target word (e.g. "colour")'),
},
annotations: { destructiveHint: true },
}, async ({ book, dialectCode, from, to }) => {
try { return ok(toolAddDialect(resolveBook(book).root, { dialectCode, from, to })); } catch (e) { return err(e); }
});
server.registerTool('get_dialect', {
title: 'Get Dialect Rules',
description:
'Look up dialect substitution rules in .bindery/translations.json. ' +
'Without a word, lists all rules for the dialect. ' +
'With a word, does a forgiving case-insensitive lookup. ' +
'For cross-language glossary entries, use get_translation.',
inputSchema: {
book: bookSchema,
dialectCode: z.string().describe('Dialect code, e.g. "en-gb"'),
word: z.string().optional().describe('Word to look up (optional — omit to list all rules)'),
},
annotations: { readOnlyHint: true },
}, async ({ book, dialectCode, word }) => {
try { return ok(toolGetDialect(resolveBook(book).root, { dialectCode, word })); } catch (e) { return err(e); }
});
server.registerTool('add_language', {
title: 'Add Language',
description:
'Add a new language to .bindery/settings.json and optionally scaffold ' +
'its story folder with stub files mirroring the default language structure.',
inputSchema: {
book: bookSchema,
code: z.string().describe('Language code, e.g. "NL", "FR", "DE"'),
folderName: z.string().optional().describe('Story subfolder name (defaults to code)'),
chapterWord: z.string().optional().describe('Word for "Chapter" in this language'),
actPrefix: z.string().optional().describe('Word for "Act" prefix in this language'),
prologueLabel: z.string().optional().describe('Word for "Prologue" in this language'),
epilogueLabel: z.string().optional().describe('Word for "Epilogue" in this language'),
createStubs: z.boolean().optional().describe('Mirror source language folder with stub files (default true)'),
},
annotations: { destructiveHint: true },
}, async ({ book, code, folderName, chapterWord, actPrefix, prologueLabel, epilogueLabel, createStubs }) => {
try { return ok(toolAddLanguage(resolveBook(book).root, { code, folderName, chapterWord, actPrefix, prologueLabel, epilogueLabel, createStubs })); } catch (e) { return err(e); }
});
server.registerTool('init_workspace', {

View file

@ -138,8 +138,11 @@ function claudeMd(ctx: TemplateContext): string {
'| `format` | Apply typography formatting to a file or folder |',
'| `get_review_text` | Structured git diff with optional auto-staging |',
'| `git_snapshot` | Git commit of story, notes, and arc changes |',
'| `get_translation` | List all rules for a language, or look up a specific word (forgiving) |',
'| `add_translation` | Add or update a rule in `.bindery/translations.json` |',
'| `get_translation` | List glossary entries for a language, or look up a specific term (forgiving) |',
'| `add_translation` | Add or update a cross-language glossary entry (agent reference, not auto-applied) |',
'| `get_dialect` | List dialect substitution rules, or look up a specific word |',
'| `add_dialect` | Add or update a dialect substitution rule (auto-applied at export, e.g. US→UK) |',
'| `add_language` | Add a language to settings.json and scaffold its story folder with stubs |',
'| `memory_list` | List `Notes/Memories/` files with line counts |',
'| `memory_append` | Append a dated session entry to a memory file |',
'| `memory_compact` | Overwrite a memory file with a summary (backs up original) |',
@ -433,15 +436,17 @@ User says \`/translate\`, "translate chapter X", or "help me with the translatio
## Tools
Use these Bindery MCP tools:
- \`get_chapter(chapterNumber, language)\` — read a chapter in any language (source or existing translation)
- \`get_translation(language)\` — list all rules for a language key (e.g. \`"nl"\` or \`"en-gb"\`)
- \`get_translation(language)\` — list glossary entries for a target language (e.g. \`"nl"\`)
- \`get_translation(language, word)\` — look up a specific term; forgiving: case-insensitive, handles plurals and inflected forms
- \`get_dialect(dialectCode)\` — list dialect substitution rules (e.g. \`"en-gb"\`)
- \`search(query, language)\` — verify how a term was rendered in other translated chapters
- \`add_translation(langKey, from, to)\` — save a new term pair to \`.bindery/translations.json\` when the user confirms a translation choice
- \`add_translation(targetLangCode, from, to)\` — save a new glossary term pair when the user confirms a translation choice
- \`add_dialect(dialectCode, from, to)\` — save a spelling substitution rule (e.g. US→UK) applied automatically at export
## Steps
### 1. Load the translation table
Call \`get_translation(language)\` to load all known term mappings for the target language before translating anything.
Call \`get_translation(language)\` to load all known glossary term mappings for the target language before translating anything.
### 2. Load the chapter
Use \`get_chapter(chapterNumber, sourceLanguage)\` to read the source chapter.
@ -456,7 +461,7 @@ For spot-check mode, also call \`get_chapter(chapterNumber, targetLanguage)\` to
|---|---|---|---|---|
### 4. Save confirmed terms
When the user confirms a new or corrected term translation, call \`add_translation\` to persist it so future exports apply it automatically.
When the user confirms a new or corrected term translation, call \`add_translation\` to persist it as a glossary entry. For spelling variant rules (dialect substitutions applied at export), use \`add_dialect\` instead.
## Rules
- Always load the translation table first never invent translations for world-specific terms

View file

@ -547,20 +547,24 @@ export function toolGitSnapshot(root: string, args: GitSnapshotArgs): string {
export interface GetTranslationArgs {
language: string;
word?: string;
/** Filter by entry type. Default: 'glossary' (cross-language reference). */
type?: 'glossary' | 'substitution';
}
export function toolGetTranslation(root: string, args: GetTranslationArgs): string {
const filePath = path.join(root, '.bindery', 'translations.json');
if (!fs.existsSync(filePath)) {
return 'No translations.json found. Run "Bindery: Initialise Workspace" or "Bindery: Add Translation" first.';
return 'No translations.json found. Run "init_workspace" or "add_translation" first.';
}
let translations: TranslationsFile;
try { translations = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as TranslationsFile; }
catch { return 'Error: failed to parse .bindery/translations.json'; }
// Resolve the requested language key — case-insensitive, accept code or label
const entryType = args.type ?? 'glossary';
const langLower = args.language.toLowerCase();
// Resolve key — case-insensitive, accept code or label
const matchedKey = Object.keys(translations).find(
k => k.toLowerCase() === langLower ||
translations[k].label?.toLowerCase() === langLower ||
@ -569,28 +573,27 @@ export function toolGetTranslation(root: string, args: GetTranslationArgs): stri
if (!matchedKey) {
const available = Object.entries(translations)
.filter(([, e]) => e.type === entryType || args.type === undefined)
.map(([k, e]) => `${k}${e.label ? ` (${e.label})` : ''}`)
.join(', ');
return `No translation entry found for "${args.language}". Available: ${available || 'none'}`;
}
const entry = translations[matchedKey];
const rules = entry.rules ?? [];
if (entry.type !== entryType) {
return `Entry "${matchedKey}" is type "${entry.type}", not "${entryType}". Use get_dialect for substitution rules.`;
}
const rules = entry.rules ?? [];
if (!args.word) {
// Dump all rules for this language
if (rules.length === 0) { return `No rules defined for "${matchedKey}" yet.`; }
const header = `${matchedKey}${entry.label ? `${entry.label}` : ''} (${entry.type}, ${rules.length} rules):`;
return [header, ...rules.map(r => ` ${r.from}${r.to}`)].join('\n');
}
// Word lookup — forgiving: case-insensitive + stem variants
const stems = wordStems(args.word.toLowerCase());
const matches = rules.filter(r => stems.some(s => r.from.toLowerCase() === s));
if (matches.length === 0) {
return `"${args.word}" not found in ${matchedKey} translations.`;
}
if (matches.length === 0) { return `"${args.word}" not found in ${matchedKey} translations.`; }
return matches.map(r => `${r.from}${r.to} [${matchedKey}]`).join('\n');
}
@ -611,9 +614,10 @@ function wordStems(word: string): string[] {
// ─── add_translation ──────────────────────────────────────────────────────────
export interface AddTranslationArgs {
langKey: string;
from: string;
to: string;
/** Target language code (e.g. 'nl', 'fr'). Used as key in translations.json. */
targetLangCode: string;
from: string;
to: string;
}
interface TranslationRule { from: string; to: string }
@ -621,7 +625,7 @@ interface TranslationEntry { label?: string; type: string; sourceLanguage?: stri
type TranslationsFile = Record<string, TranslationEntry>;
export function toolAddTranslation(root: string, args: AddTranslationArgs): string {
const { langKey, from, to } = args;
const { targetLangCode, from, to } = args;
if (!from.trim() || !to.trim()) { return 'Error: both "from" and "to" must be non-empty.'; }
const filePath = path.join(root, '.bindery', 'translations.json');
@ -631,30 +635,190 @@ export function toolAddTranslation(root: string, args: AddTranslationArgs): stri
catch { return 'Error: failed to parse .bindery/translations.json'; }
}
if (!translations[langKey]) {
translations[langKey] = { type: 'substitution', sourceLanguage: 'en', rules: [], ignoredWords: [] };
}
const entry = translations[langKey];
if (entry.type !== 'substitution') {
return `Error: entry '${langKey}' has type '${entry.type}', expected 'substitution'.`;
}
// Default source: EN (from settings) or 'en'
let sourceLanguage = 'en';
const settings = readSettings(root) as { languages?: Array<{ code: string; isDefault?: boolean }> } | null;
const defaultLang = (settings?.languages ?? []).find(l => l.isDefault) ?? settings?.languages?.[0];
if (defaultLang) { sourceLanguage = defaultLang.code.toLowerCase(); }
const rules = entry.rules ?? [];
const fromLower = from.toLowerCase();
const idx = rules.findIndex(r => r.from.toLowerCase() === fromLower);
const isUpdate = idx >= 0;
if (isUpdate) {
rules[idx] = { from: fromLower, to };
} else {
rules.push({ from: fromLower, to });
rules.sort((a, b) => a.from.localeCompare(b.from));
const key = targetLangCode.toLowerCase();
if (!translations[key]) {
translations[key] = { type: 'glossary', sourceLanguage, rules: [], ignoredWords: [] };
}
const entry = translations[key];
const rules = entry.rules ?? [];
const idx = rules.findIndex(r => r.from.toLowerCase() === from.toLowerCase());
const isUpdate = idx >= 0;
if (isUpdate) { rules[idx] = { from, to }; }
else { rules.push({ from, to }); rules.sort((a, b) => a.from.localeCompare(b.from)); }
entry.rules = rules;
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(translations, null, 2) + '\n', 'utf-8');
return `${isUpdate ? 'Updated' : 'Added'}: ${fromLower}${to} (${langKey})`;
return `${isUpdate ? 'Updated' : 'Added'} glossary: ${from}${to} (${key})`;
}
// ─── add_dialect ──────────────────────────────────────────────────────────────
export interface AddDialectArgs {
/** Dialect code used as key in translations.json, e.g. 'en-gb'. */
dialectCode: string;
from: string;
to: string;
}
export function toolAddDialect(root: string, args: AddDialectArgs): string {
const { dialectCode, from, to } = args;
if (!from.trim() || !to.trim()) { return 'Error: both "from" and "to" must be non-empty.'; }
const filePath = path.join(root, '.bindery', 'translations.json');
let translations: TranslationsFile = {};
if (fs.existsSync(filePath)) {
try { translations = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as TranslationsFile; }
catch { return 'Error: failed to parse .bindery/translations.json'; }
}
const key = dialectCode.toLowerCase();
if (!translations[key]) {
translations[key] = { type: 'substitution', sourceLanguage: 'en', rules: [], ignoredWords: [] };
}
const entry = translations[key];
if (entry.type !== 'substitution') {
return `Error: entry '${key}' has type '${entry.type}', expected 'substitution'. Use add_translation for glossary entries.`;
}
const rules = entry.rules ?? [];
const fromLower = from.toLowerCase();
const idx = rules.findIndex(r => r.from.toLowerCase() === fromLower);
const isUpdate = idx >= 0;
if (isUpdate) { rules[idx] = { from: fromLower, to }; }
else { rules.push({ from: fromLower, to }); rules.sort((a, b) => a.from.localeCompare(b.from)); }
entry.rules = rules;
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(translations, null, 2) + '\n', 'utf-8');
return `${isUpdate ? 'Updated' : 'Added'} dialect rule: ${fromLower}${to} (${key})`;
}
// ─── get_dialect ──────────────────────────────────────────────────────────────
export interface GetDialectArgs {
dialectCode: string;
word?: string;
}
export function toolGetDialect(root: string, args: GetDialectArgs): string {
const filePath = path.join(root, '.bindery', 'translations.json');
if (!fs.existsSync(filePath)) {
return 'No translations.json found. Run "init_workspace" or "add_dialect" first.';
}
let translations: TranslationsFile;
try { translations = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as TranslationsFile; }
catch { return 'Error: failed to parse .bindery/translations.json'; }
const key = Object.keys(translations).find(k => k.toLowerCase() === args.dialectCode.toLowerCase());
if (!key) {
const available = Object.entries(translations)
.filter(([, e]) => e.type === 'substitution')
.map(([k]) => k).join(', ');
return `No dialect entry "${args.dialectCode}". Available: ${available || 'none'}`;
}
const entry = translations[key];
if (entry.type !== 'substitution') {
return `Entry "${key}" is type "${entry.type}", not "substitution". Use get_translation for glossary entries.`;
}
const rules = entry.rules ?? [];
if (!args.word) {
if (rules.length === 0) { return `No dialect rules defined for "${key}" yet.`; }
const header = `${key}${entry.label ? `${entry.label}` : ''} (${rules.length} substitution rules):`;
return [header, ...rules.map(r => ` ${r.from}${r.to}`)].join('\n');
}
const stems = wordStems(args.word.toLowerCase());
const matches = rules.filter(r => stems.some(s => r.from.toLowerCase() === s));
if (matches.length === 0) { return `"${args.word}" not found in dialect "${key}".`; }
return matches.map(r => `${r.from}${r.to} [${key}]`).join('\n');
}
// ─── add_language ─────────────────────────────────────────────────────────────
export interface AddLanguageArgs {
code: string;
folderName?: string;
chapterWord?: string;
actPrefix?: string;
prologueLabel?: string;
epilogueLabel?: string;
/** Mirror source language's folder structure with empty stubs. Default true. */
createStubs?: boolean;
}
interface LanguageEntry { code: string; folderName: string; chapterWord: string; actPrefix: string; prologueLabel: string; epilogueLabel: string; isDefault?: boolean }
export function toolAddLanguage(root: string, args: AddLanguageArgs): string {
const settingsPath = path.join(root, '.bindery', 'settings.json');
let existing: Record<string, unknown> = {};
try { existing = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as Record<string, unknown>; }
catch { return 'Error: .bindery/settings.json not found. Run init_workspace first.'; }
const upper = args.code.trim().toUpperCase();
const newLang: LanguageEntry = {
code: upper,
folderName: args.folderName?.trim() ?? upper,
chapterWord: args.chapterWord?.trim() ?? 'Chapter',
actPrefix: args.actPrefix?.trim() ?? 'Act',
prologueLabel: args.prologueLabel?.trim() ?? 'Prologue',
epilogueLabel: args.epilogueLabel?.trim() ?? 'Epilogue',
};
const languages: LanguageEntry[] = ((existing['languages'] as LanguageEntry[] | undefined) ?? []);
const dupIdx = languages.findIndex(l => l.code.toUpperCase() === upper);
if (dupIdx >= 0) { languages[dupIdx] = newLang; } else { languages.push(newLang); }
existing['languages'] = languages;
fs.writeFileSync(settingsPath, JSON.stringify(existing, null, 2) + '\n', 'utf-8');
// Create stub files mirroring source language (default: true)
const createStubs = args.createStubs !== false;
const storyFolderName = (existing['storyFolder'] as string | undefined) ?? 'Story';
const sourceLang = languages.find((l: LanguageEntry) => l.isDefault) ?? languages[0];
let stubCount = 0;
if (createStubs && sourceLang && sourceLang.code !== upper) {
const sourceDir = path.join(root, storyFolderName, sourceLang.folderName);
const targetDir = path.join(root, storyFolderName, newLang.folderName);
fs.mkdirSync(targetDir, { recursive: true });
if (fs.existsSync(sourceDir)) {
const createStubsIn = (srcDir: string, dstDir: string) => {
fs.mkdirSync(dstDir, { recursive: true });
for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
const srcPath = path.join(srcDir, entry.name);
const dstPath = path.join(dstDir, entry.name);
if (entry.isDirectory()) {
createStubsIn(srcPath, dstPath);
} else if (entry.isFile() && entry.name.endsWith('.md')) {
if (!fs.existsSync(dstPath)) {
const src = fs.readFileSync(srcPath, 'utf-8');
const h1 = /^#\s+(.+)/m.exec(src);
const title = h1 ? h1[1].trim() : path.basename(entry.name, '.md');
fs.writeFileSync(dstPath, `# [Untranslated] ${title}\n`, 'utf-8');
stubCount++;
}
}
}
};
createStubsIn(sourceDir, targetDir);
}
}
return `Added language ${upper} to settings.json. Story/${newLang.folderName}/ created with ${stubCount} stub file(s).`;
}
// ─── diff helpers ─────────────────────────────────────────────────────────────

View file

@ -49,13 +49,13 @@ all acts and chapters with titles.
Claude calls `retrieve_context` with the query and returns the most relevant
passages with file paths and line numbers, letting you jump straight to the scene.
### Add a translation rule
### Add a dialect substitution rule
> "Add a substitution: 'color' should become 'colour' in en-gb"
Claude calls `add_translation` with `langKey: "en-gb"`, `from: "color"`,
Claude calls `add_dialect` with `dialectCode: "en-gb"`, `from: "color"`,
`to: "colour"`. The rule is saved to `.bindery/translations.json` and applied
during future exports.
automatically during future exports.
### Review and snapshot your changes
@ -64,22 +64,22 @@ during future exports.
Claude calls `get_review_text` to show the diff, reviews it, then calls
`git_snapshot` to commit the changes with a descriptive message.
### Look up a translation rule
### Look up a glossary term
> "How is 'flux' translated in the Dutch version?"
Claude calls `get_translation` with `language: "nl"` and `word: "flux"`. The
lookup is forgiving — it matches case-insensitively and checks plural and
inflected forms automatically. If no rule exists yet, Claude can call
`add_translation` to create one.
inflected forms automatically. If no glossary entry exists yet, Claude can call
`add_translation` with `targetLangCode: "nl"` to create one.
### List all known substitution rules for a language
### List all dialect substitution rules
> "Show me all the British English substitution rules"
Claude calls `get_translation` with `language: "en-gb"` (omitting `word`) to
Claude calls `get_dialect` with `dialectCode: "en-gb"` (omitting `word`) to
dump every `from → to` rule in the `en-gb` entry of `.bindery/translations.json`.
Useful before a translation or export session to see what's already configured.
Useful before an export session to see what spelling substitutions are configured.
### Save session decisions to memory
@ -105,7 +105,7 @@ with the compacted text. The original is automatically backed up to
Claude calls `get_chapter` twice — once for EN, once for NL — then calls
`get_translation` with the target language to load the known term table.
Discrepancies are presented in a side-by-side table. Any confirmed corrections
are saved back with `add_translation`.
are saved back with `add_translation` (glossary) or `add_dialect` (dialect substitutions).
### Check continuity across chapters
@ -134,8 +134,11 @@ issue type, location, and the reference that contradicts it.
| `format` | Apply typography formatting to a file or folder |
| `get_review_text` | Structured git diff with optional auto-staging |
| `git_snapshot` | Git commit of story, notes, and arc changes |
| `get_translation` | List all rules for a language, or look up a specific word (forgiving) |
| `add_translation` | Add or update a rule in project translations |
| `get_translation` | List glossary entries for a language, or look up a specific term (forgiving) |
| `add_translation` | Add or update a cross-language glossary entry (agent reference) |
| `get_dialect` | List dialect substitution rules, or look up a specific word |
| `add_dialect` | Add or update a dialect substitution rule (auto-applied at export) |
| `add_language` | Add a language to settings.json and scaffold the story folder with stubs |
| `memory_list` | List memory files with line counts |
| `memory_append` | Append a dated session entry to a memory file |
| `memory_compact` | Overwrite a memory file with a summary (backs up original) |

View file

@ -58,8 +58,11 @@
{ "name": "format", "description": "Apply typography formatting (curly quotes, em-dashes, ellipses) to a file or folder." },
{ "name": "get_review_text", "description": "Structured git diff of uncommitted changes. Filter by language (EN, NL, ALL). Set autoStage to stage reviewed files." },
{ "name": "git_snapshot", "description": "Save a snapshot (git commit) of all changes in story, notes, and arc folders. Use after writing sessions or reviews." },
{ "name": "add_translation", "description": "Add or update a substitution rule in .bindery/translations.json for translation or dialect conversion (e.g. US→UK spelling)." },
{ "name": "get_translation", "description": "Look up translation/substitution rules from .bindery/translations.json. List all rules for a language or do a forgiving word lookup (case-insensitive, handles plurals and inflected forms)." },
{ "name": "add_translation", "description": "Add or update a cross-language glossary entry in .bindery/translations.json for agent reference (source → target term). For dialect substitution rules use add_dialect." },
{ "name": "get_translation", "description": "Look up cross-language glossary entries from .bindery/translations.json. List all entries for a language or do a forgiving word lookup. For dialect substitution rules use get_dialect." },
{ "name": "add_dialect", "description": "Add or update a dialect substitution rule in .bindery/translations.json (e.g. US→UK spelling: color→colour). Applied automatically during export." },
{ "name": "get_dialect", "description": "Look up dialect substitution rules from .bindery/translations.json. List all rules for a dialect or look up a specific word." },
{ "name": "add_language", "description": "Add a new language to .bindery/settings.json and scaffold its story folder with stub files mirroring the default language structure." },
{ "name": "memory_list", "description": "List all session memory files in .bindery/memories/ with their line counts." },
{ "name": "memory_append", "description": "Append a dated session entry to a memory file in .bindery/memories/. Supply a short title and content; the tool stamps the date." },
{ "name": "memory_compact", "description": "Overwrite a memory file with a compacted summary. The original is backed up to .bindery/memories/archive/ first." }

View file

@ -37,7 +37,10 @@ src/
| `formatDocument` / `formatFolder` | Apply typography formatting |
| `mergeMarkdown/Docx/Epub/Pdf/All` | Merge chapters and export |
| `findProbableUsToUkWords` | Scan EN source and surface US spellings |
| `addUkReplacement` | Add a substitution rule (project or general) |
| `addDialect` | Add a dialect substitution rule (auto-applied at export, e.g. US→UK) |
| `addTranslation` | Add a cross-language glossary entry (agent reference, not auto-applied) |
| `addLanguage` | Add a new language to settings.json and scaffold its story folder |
| `addUkReplacement` | Alias for `addDialect` (backward compat) |
## When suggesting changes
- Prefer editing the smallest surface area possible.

View file

@ -169,9 +169,24 @@
"title": "Find Probable US→UK Words",
"category": "Bindery"
},
{
"command": "bindery.addDialect",
"title": "Add Dialect Rule",
"category": "Bindery"
},
{
"command": "bindery.addTranslation",
"title": "Add Translation (Glossary)",
"category": "Bindery"
},
{
"command": "bindery.addLanguage",
"title": "Add Language",
"category": "Bindery"
},
{
"command": "bindery.addUkReplacement",
"title": "Add Translation Rule",
"title": "Add Dialect Rule (UK alias)",
"category": "Bindery"
},
{
@ -391,7 +406,52 @@
}
}
},
{ "name": "bindery_memory_list",
{ "name": "bindery_add_dialect",
"tags": ["bindery"],
"displayName": "Bindery: Add Dialect Rule",
"modelDescription": "Add or update a dialect substitution rule in .bindery/translations.json (e.g. EN→en-gb spelling). Source is auto-detected from the active file's language folder.",
"inputSchema": {
"type": "object",
"properties": {
"dialectCode": { "type": "string", "description": "Dialect key in translations.json, e.g. 'en-gb'" },
"from": { "type": "string", "description": "Source word (e.g. 'airplane')" },
"to": { "type": "string", "description": "Dialect form (e.g. 'aeroplane')" }
},
"required": ["dialectCode", "from", "to"]
}
},
{ "name": "bindery_get_dialect",
"tags": ["bindery"],
"displayName": "Bindery: Get Dialect Rules",
"modelDescription": "List or look up dialect substitution rules from .bindery/translations.json. Without a word, lists all rules for the dialect. With a word, does a forgiving case-insensitive lookup including inflected forms.",
"inputSchema": {
"type": "object",
"properties": {
"dialectCode": { "type": "string", "description": "Dialect key, e.g. 'en-gb'" },
"word": { "type": "string", "description": "Word to look up (optional)" }
},
"required": ["dialectCode"]
}
},
{ "name": "bindery_add_language",
"tags": ["bindery"],
"displayName": "Bindery: Add Language",
"modelDescription": "Add a new main language to settings.json and create its folder structure under Story/ with stub .md files mirroring the default language. Use for manually translated editions (e.g. French, Spanish).",
"inputSchema": {
"type": "object",
"properties": {
"code": { "type": "string", "description": "Language code (e.g. FR, DE, NL)" },
"folderName": { "type": "string", "description": "Folder name under Story/ (default: same as code)" },
"chapterWord": { "type": "string", "description": "Word for 'Chapter' in target language" },
"actPrefix": { "type": "string", "description": "Word for 'Act' in target language" },
"prologueLabel": { "type": "string", "description": "Word for 'Prologue'" },
"epilogueLabel": { "type": "string", "description": "Word for 'Epilogue'" },
"createStubs": { "type": "boolean", "description": "Create stub .md files mirroring source (default true)" }
},
"required": ["code"]
}
},
{ "name": "bindery_memory_list",
"tags": ["bindery"],
"displayName": "Bindery: Memory List",
"modelDescription": "List all session memory files in .bindery/memories/ with their line counts.",
@ -453,7 +513,8 @@
{ "command": "bindery.formatDocument", "when": "resourceLangId == markdown", "group": "1_format@1" },
{ "command": "bindery.formatFolder", "when": "explorerResourceIsFolder", "group": "1_format@2" },
{ "command": "bindery.findProbableUsToUkWords", "when": "resourceLangId == markdown", "group": "2_dialect@1" },
{ "command": "bindery.addUkReplacement", "when": "resourceLangId == markdown", "group": "2_dialect@2" },
{ "command": "bindery.addDialect", "when": "resourceLangId == markdown", "group": "2_dialect@2" },
{ "command": "bindery.addTranslation", "when": "resourceLangId == markdown", "group": "2_dialect@3" },
{ "command": "bindery.mergeMarkdown", "group": "3_export@1" },
{ "command": "bindery.mergeDocx", "group": "3_export@2" },
{ "command": "bindery.mergeEpub", "group": "3_export@3" },

View file

@ -18,14 +18,15 @@ import { execSync } from 'child_process';
import { updateTypography } from './format';
import {
mergeBook, checkPandoc, getBuiltInUkReplacements,
type LanguageConfig, type OutputType, type MergeOptions, type UkReplacement,
type LanguageConfig, type DialectConfig, type OutputType, type MergeOptions, type UkReplacement,
} from './merge';
import {
readWorkspaceSettings, readTranslations,
readWorkspaceSettings, readTranslations, writeTranslations,
getBinderyFolder, getSettingsPath, getTranslationsPath,
getBookTitleForLang, getSubstitutionRules, getIgnoredWords,
upsertSubstitutionRule, addIgnoredWords,
type WorkspaceSettings, type TranslationsFile,
upsertSubstitutionRule, upsertGlossaryRule, addIgnoredWords,
getDefaultLanguage, getDialectsForLanguage, getGlossaryRules,
type WorkspaceSettings, type TranslationsFile, type TranslationEntry,
} from './workspace';
import {
setupAiFiles, ALL_SKILLS, AI_SETUP_VERSION, readAiSetupVersion,
@ -36,12 +37,15 @@ import { registerLmTools, registerMcpCommand } from './mcp';
// ─── Known language presets ───────────────────────────────────────────────────
const KNOWN_LANGUAGES: Record<string, LanguageConfig> = {
EN: { code: 'EN', folderName: 'EN', chapterWord: 'Chapter', actPrefix: 'Act', prologueLabel: 'Prologue', epilogueLabel: 'Epilogue' },
EN: { code: 'EN', folderName: 'EN', chapterWord: 'Chapter', actPrefix: 'Act', prologueLabel: 'Prologue', epilogueLabel: 'Epilogue', isDefault: true, dialects: [{ code: 'en-gb', label: 'British English' }] },
NL: { code: 'NL', folderName: 'NL', chapterWord: 'Hoofdstuk', actPrefix: 'Deel', prologueLabel: 'Proloog', epilogueLabel: 'Epiloog' },
UK: { code: 'UK', folderName: 'UK', chapterWord: 'Chapter', actPrefix: 'Act', prologueLabel: 'Prologue', epilogueLabel: 'Epilogue' },
FR: { code: 'FR', folderName: 'FR', chapterWord: 'Chapitre', actPrefix: 'Acte', prologueLabel: 'Prologue', epilogueLabel: 'Épilogue' },
DE: { code: 'DE', folderName: 'DE', chapterWord: 'Kapitel', actPrefix: 'Teil', prologueLabel: 'Prolog', epilogueLabel: 'Epilog' },
ES: { code: 'ES', folderName: 'ES', chapterWord: 'Capítulo', actPrefix: 'Acto', prologueLabel: 'Prólogo', epilogueLabel: 'Epílogo' },
IT: { code: 'IT', folderName: 'IT', chapterWord: 'Capitolo', actPrefix: 'Atto', prologueLabel: 'Prologo', epilogueLabel: 'Epilogo' },
PT: { code: 'PT', folderName: 'PT', chapterWord: 'Capítulo', actPrefix: 'Ato', prologueLabel: 'Prólogo', epilogueLabel: 'Epílogo' },
// UK retained for backward compatibility only — new projects use EN.dialects instead
UK: { code: 'UK', folderName: 'UK', chapterWord: 'Chapter', actPrefix: 'Act', prologueLabel: 'Prologue', epilogueLabel: 'Epilogue' },
};
const DEFAULT_LANGUAGE: LanguageConfig = KNOWN_LANGUAGES.EN;
@ -106,8 +110,10 @@ function isUkLanguage(lang: LanguageConfig): boolean {
return c === 'UK' || c === 'EN-GB';
}
/** True if the language has a story folder that exists on disk. */
function languageCanExport(root: string, storyFolder: string, lang: LanguageConfig): boolean {
if (isUkLanguage(lang)) {
// Legacy UK LanguageConfig reads from EN folder
return fs.existsSync(path.join(root, storyFolder, 'EN'));
}
return fs.existsSync(path.join(root, storyFolder, lang.folderName));
@ -117,9 +123,7 @@ function languageCanExport(root: string, storyFolder: string, lang: LanguageConf
//
// Tier 1 (built-in) — UK_REPLACEMENTS array inside merge.ts, always applied first.
// Tier 2 (general) — bindery.generalSubstitutions in VS Code *user* settings.
// Words you want across every project (e.g. recognize→recognise).
// Tier 3 (project) — .bindery/translations.json → en-gb entry.
// Terms specific to this book/world.
// Tier 3 (project) — .bindery/translations.json → dialect entry (e.g. 'en-gb').
//
// Later tiers win on conflict.
@ -131,12 +135,12 @@ function getGeneralSubstitutions(): UkReplacement[] {
}
/**
* Build the combined substitution list passed to merge.ts.
* merge.ts applies tier 1 (built-ins) internally; this function merges tiers 2 + 3.
* Build the combined substitution list for a dialect export.
* merge.ts applies tier 1 (built-ins) internally; this merges tiers 2 + 3.
*/
function buildCombinedSubstitutions(translations: TranslationsFile | null): UkReplacement[] {
function buildCombinedSubstitutions(translations: TranslationsFile | null, dialectCode: string): UkReplacement[] {
const general = getGeneralSubstitutions();
const project = getSubstitutionRules(translations, 'en-gb');
const project = getSubstitutionRules(translations, dialectCode);
const map = new Map<string, string>();
for (const r of general) { map.set(r.us, r.uk); }
for (const r of project) { map.set(r.us, r.uk); } // project overrides general
@ -144,8 +148,8 @@ function buildCombinedSubstitutions(translations: TranslationsFile | null): UkRe
}
/**
* Combined ignored-words from translations.json (primary) and legacy
* bindery.ukIgnoredWords VS Code setting (fallback / migration path).
* Combined ignored-words from translations.json and legacy ukIgnoredWords setting.
* dialectCode defaults to 'en-gb' here because findProbableUsToUkWords is UK-specific.
*/
function getAllIgnoredWords(translations: TranslationsFile | null): Set<string> {
const result = getIgnoredWords(translations, 'en-gb');
@ -407,79 +411,61 @@ async function openTranslationsCommand() {
vscode.window.showTextDocument(await vscode.workspace.openTextDocument(translationsPath));
}
// ─── Command: Add substitution rule ──────────────────────────────────────────
// ─── Command: Add dialect rule ────────────────────────────────────────────────
async function addTranslationCommand() {
const root = getWorkspaceRoot();
// Pre-fill "from" with editor selection (if any)
const editor = vscode.window.activeTextEditor;
async function addDialectCommand() {
const root = getWorkspaceRoot();
const editor = vscode.window.activeTextEditor;
const selected = editor && !editor.selection.isEmpty
? editor.document.getText(editor.selection).trim()
: '';
// Determine which substitution entry to target from translations.json
let langKey = 'en-gb';
let fromLabel = 'source';
let toLabel = 'target';
if (!root) { vscode.window.showErrorMessage('No workspace folder open.'); return; }
if (root) {
const translations = readTranslations(root);
const substitutionEntries = Object.entries(translations ?? {})
.filter(([, entry]) => entry.type === 'substitution');
const wsSettings = readWorkspaceSettings(root);
if (substitutionEntries.length === 1) {
langKey = substitutionEntries[0][0];
const e = substitutionEntries[0][1];
fromLabel = e.sourceLanguage ?? 'source';
toLabel = e.label ?? langKey;
} else if (substitutionEntries.length > 1) {
// Try to auto-detect from active file path
let autoKey: string | undefined;
if (editor) {
const wsSettings = readWorkspaceSettings(root);
const sf = wsSettings?.storyFolder ?? 'Story';
const filePath = editor.document.uri.fsPath.replace(/\\/g, '/');
const storyBase = path.join(root, sf).replace(/\\/g, '/');
if (filePath.startsWith(storyBase)) {
const rel = filePath.slice(storyBase.length + 1);
const folderName = rel.split('/')[0];
// Match folder to a substitution entry's sourceLanguage
for (const [key, entry] of substitutionEntries) {
if (entry.sourceLanguage?.toUpperCase() === folderName?.toUpperCase()) {
autoKey = key;
break;
}
}
}
}
if (autoKey) {
langKey = autoKey;
const e = substitutionEntries.find(([k]) => k === autoKey)![1];
fromLabel = e.sourceLanguage ?? 'source';
toLabel = e.label ?? langKey;
} else {
const picked = await vscode.window.showQuickPick(
substitutionEntries.map(([key, entry]) => ({
label: entry.label ?? key,
description: `key: ${key}`,
key,
entry,
})),
{ placeHolder: 'Which substitution language?' }
);
if (!picked) { return; }
langKey = picked.key;
fromLabel = picked.entry.sourceLanguage ?? 'source';
toLabel = picked.entry.label ?? langKey;
}
// Auto-detect source language from active file path
let sourceLang: LanguageConfig | undefined;
if (editor) {
const sf = wsSettings?.storyFolder ?? 'Story';
const file = editor.document.uri.fsPath.replace(/\\/g, '/');
const base = path.join(root, sf).replace(/\\/g, '/');
if (file.startsWith(base + '/')) {
const folderName = file.slice(base.length + 1).split('/')[0];
sourceLang = wsSettings?.languages?.find(
l => l.folderName.toUpperCase() === folderName?.toUpperCase()
);
}
}
sourceLang ??= getDefaultLanguage(wsSettings);
if (!sourceLang) { vscode.window.showErrorMessage('No language configured. Run Bindery: Initialise Workspace first.'); return; }
const dialects = getDialectsForLanguage(wsSettings, sourceLang.code);
if (dialects.length === 0) {
vscode.window.showErrorMessage(
`Language ${sourceLang.code} has no dialects configured in settings.json. ` +
`Add dialects[] to this language entry.`
);
return;
}
// Pick dialect (auto-select if only one)
let dialect: DialectConfig;
if (dialects.length === 1) {
dialect = dialects[0];
} else {
const picked = await vscode.window.showQuickPick(
dialects.map(d => ({ label: d.label ?? d.code, description: `key: ${d.code}`, dialect: d })),
{ placeHolder: `Dialect for ${sourceLang.code}` }
);
if (!picked) { return; }
dialect = picked.dialect;
}
const fromWord = await vscode.window.showInputBox({
title: 'Add Substitution Rule — source word',
prompt: `${fromLabel} word`,
title: `Add Dialect Rule (${sourceLang.code}${dialect.label ?? dialect.code})`,
prompt: `${sourceLang.code} word`,
value: selected,
placeHolder: 'e.g. airplane',
});
@ -487,8 +473,8 @@ async function addTranslationCommand() {
const suggested = suggestUkSpelling(fromWord) ?? '';
const toWord = await vscode.window.showInputBox({
title: 'Add Substitution Rule — target word',
prompt: `${toLabel} word`,
title: `Add Dialect Rule — ${dialect.label ?? dialect.code} form`,
prompt: `${dialect.label ?? dialect.code} word`,
value: suggested,
placeHolder: 'e.g. aeroplane',
});
@ -496,23 +482,198 @@ async function addTranslationCommand() {
const scope = await vscode.window.showQuickPick(
[
{ label: 'This project only', description: 'Saved to .bindery/translations.json', value: 'project' as const },
{ label: 'All projects', description: 'Saved to your VS Code user settings', value: 'general' as const },
{ label: 'This project only', description: 'Saved to .bindery/translations.json', value: 'project' as const },
{ label: 'All projects', description: 'Saved to your VS Code user settings', value: 'general' as const },
],
{ placeHolder: 'Where should this rule be saved?' }
);
if (!scope) { return; }
if (scope.value === 'project') {
if (!root) { vscode.window.showErrorMessage('No workspace folder open.'); return; }
upsertSubstitutionRule(root, langKey, { from: fromWord.toLowerCase(), to: toWord });
vscode.window.showInformationMessage(`Saved to .bindery/translations.json: ${fromWord.toLowerCase()}${toWord}`);
upsertSubstitutionRule(root, dialect.code, { from: fromWord.toLowerCase(), to: toWord });
vscode.window.showInformationMessage(`Dialect rule saved: ${fromWord.toLowerCase()}${toWord} [${dialect.code}]`);
} else {
await upsertGeneralSubstitution({ from: fromWord.toLowerCase(), to: toWord });
vscode.window.showInformationMessage(`Saved to general user settings: ${fromWord.toLowerCase()}${toWord}`);
vscode.window.showInformationMessage(`Dialect rule saved to user settings: ${fromWord.toLowerCase()}${toWord}`);
}
}
// ─── Command: Add translation (glossary) ─────────────────────────────────────
async function addTranslationCommand() {
const root = getWorkspaceRoot();
if (!root) { vscode.window.showErrorMessage('No workspace folder open.'); return; }
const editor = vscode.window.activeTextEditor;
const selected = editor && !editor.selection.isEmpty
? editor.document.getText(editor.selection).trim()
: '';
const wsSettings = readWorkspaceSettings(root);
const sourceLang = getDefaultLanguage(wsSettings);
const targetLangs = (wsSettings?.languages ?? []).filter(
l => !l.isDefault && (l.code !== sourceLang?.code)
);
if (!sourceLang) { vscode.window.showErrorMessage('No default language configured. Run Bindery: Initialise Workspace first.'); return; }
if (targetLangs.length === 0) {
vscode.window.showErrorMessage('No target languages configured. Use Bindery: Add Language to add one.');
return;
}
// Pick target language
let targetLang: LanguageConfig;
if (targetLangs.length === 1) {
targetLang = targetLangs[0];
} else {
const picked = await vscode.window.showQuickPick(
targetLangs.map(l => ({ label: l.code, description: l.folderName, lang: l })),
{ placeHolder: `Translate from ${sourceLang.code} to…` }
);
if (!picked) { return; }
targetLang = picked.lang;
}
const fromWord = await vscode.window.showInputBox({
title: `Add Glossary Entry (${sourceLang.code}${targetLang.code})`,
prompt: `${sourceLang.code} word or term`,
value: selected,
placeHolder: 'e.g. the Flux',
});
if (!fromWord) { return; }
const toWord = await vscode.window.showInputBox({
title: `Add Glossary Entry — ${targetLang.code} form`,
prompt: `${targetLang.code} equivalent`,
placeHolder: 'e.g. de Flux',
});
if (!toWord) { return; }
const langKey = targetLang.code.toLowerCase();
const langLabel = targetLang.folderName;
upsertGlossaryRule(root, langKey, langLabel, sourceLang.code, { from: fromWord, to: toWord });
vscode.window.showInformationMessage(`Glossary entry saved: ${fromWord}${toWord} [${langKey}]`);
}
// ─── Command: Add language ────────────────────────────────────────────────────
async function addLanguageCommand() {
const root = getWorkspaceRoot();
if (!root) { vscode.window.showErrorMessage('No workspace folder open.'); return; }
const wsSettings = readWorkspaceSettings(root);
const sourceLang = getDefaultLanguage(wsSettings) ?? { folderName: 'EN', code: 'EN', chapterWord: 'Chapter', actPrefix: 'Act', prologueLabel: 'Prologue', epilogueLabel: 'Epilogue' };
const storyFolder = wsSettings?.storyFolder ?? 'Story';
const code = await vscode.window.showInputBox({
title: 'Bindery: Add Language (1/6) — Language code',
prompt: 'Short code (23 uppercase letters)',
placeHolder: 'FR NL DE ES IT PT …',
validateInput: v => /^[A-Za-z]{2,3}$/.test(v.trim()) ? undefined : 'Enter 23 letters',
});
if (!code?.trim()) { return; }
const upper = code.trim().toUpperCase();
const preset = KNOWN_LANGUAGES[upper];
const folderName = await vscode.window.showInputBox({
title: 'Bindery: Add Language (2/6) — Folder name',
prompt: 'Subfolder under Story/ for this language',
value: preset?.folderName ?? upper,
});
if (!folderName?.trim()) { return; }
const chapterWord = await vscode.window.showInputBox({
title: 'Bindery: Add Language (3/6) — Chapter word',
prompt: 'Word used for "Chapter" in this language',
value: preset?.chapterWord ?? 'Chapter',
});
if (!chapterWord?.trim()) { return; }
const actPrefix = await vscode.window.showInputBox({
title: 'Bindery: Add Language (4/6) — Act prefix',
prompt: 'Word used for "Act" in this language',
value: preset?.actPrefix ?? 'Act',
});
if (!actPrefix?.trim()) { return; }
const prologueLabel = await vscode.window.showInputBox({
title: 'Bindery: Add Language (5/6) — Prologue label',
value: preset?.prologueLabel ?? 'Prologue',
});
if (!prologueLabel?.trim()) { return; }
const epilogueLabel = await vscode.window.showInputBox({
title: 'Bindery: Add Language (6/6) — Epilogue label',
value: preset?.epilogueLabel ?? 'Epilogue',
});
if (!epilogueLabel?.trim()) { return; }
const newLang: LanguageConfig = {
code: upper,
folderName: folderName.trim(),
chapterWord: chapterWord.trim(),
actPrefix: actPrefix.trim(),
prologueLabel: prologueLabel.trim(),
epilogueLabel: epilogueLabel.trim(),
};
// Update settings.json
const existing = readWorkspaceSettings(root);
const languages = [...(existing?.languages ?? [sourceLang])];
const dupIdx = languages.findIndex(l => l.code.toUpperCase() === upper);
if (dupIdx >= 0) {
const overwrite = await vscode.window.showQuickPick(
[{ label: 'Update existing', value: true as const }, { label: 'Cancel', value: false as const }],
{ placeHolder: `Language ${upper} already exists in settings.json` }
);
if (!overwrite?.value) { return; }
languages[dupIdx] = newLang;
} else {
languages.push(newLang);
}
const settingsPath = getSettingsPath(root);
let rawSettings: Record<string, unknown> = {};
try { rawSettings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as Record<string, unknown>; } catch { /* new */ }
rawSettings['languages'] = languages;
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
fs.writeFileSync(settingsPath, JSON.stringify(rawSettings, null, 2) + '\n', 'utf-8');
// Mirror folder structure from source language with stub files
const sourceDir = path.join(root, storyFolder, sourceLang.folderName);
const targetDir = path.join(root, storyFolder, newLang.folderName);
fs.mkdirSync(targetDir, { recursive: true });
let stubCount = 0;
if (fs.existsSync(sourceDir)) {
const createStubs = (srcDir: string, dstDir: string) => {
fs.mkdirSync(dstDir, { recursive: true });
for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
const srcPath = path.join(srcDir, entry.name);
const dstPath = path.join(dstDir, entry.name);
if (entry.isDirectory()) {
createStubs(srcPath, dstPath);
} else if (entry.isFile() && entry.name.endsWith('.md')) {
if (!fs.existsSync(dstPath)) {
// Read source H1 for stub header
const src = fs.readFileSync(srcPath, 'utf-8');
const h1 = /^#\s+(.+)/m.exec(src);
const title = h1 ? h1[1].trim() : path.basename(entry.name, '.md');
fs.writeFileSync(dstPath, `# [Untranslated] ${title}\n`, 'utf-8');
stubCount++;
}
}
}
};
createStubs(sourceDir, targetDir);
}
vscode.window.showInformationMessage(
`Added language ${upper} to settings.json. Created ${stubCount} stub file(s) in Story/${newLang.folderName}/.`
);
}
// ─── Command: Find probable US→UK words ──────────────────────────────────────
async function findProbableUsToUkWordsCommand() {
@ -614,12 +775,10 @@ function buildMergeOptions(
outputTypes: OutputType[],
wsSettings: WorkspaceSettings | null,
translations: TranslationsFile | null,
dialectCode?: string,
): MergeOptions {
const cfg = getEffectiveConfig(wsSettings);
// Language-specific title from workspace file, falling back to the global title
const bookTitle = getBookTitleForLang(wsSettings, lang.code) ?? cfg.bookTitle;
return {
root,
storyFolder: cfg.storyFolder,
@ -633,7 +792,8 @@ function buildMergeOptions(
filePrefix: cfg.mergeFilePrefix,
pandocPath: cfg.pandocPath,
libreOfficePath: cfg.libreOfficePath,
ukReplacements: buildCombinedSubstitutions(translations),
ukReplacements: dialectCode ? buildCombinedSubstitutions(translations, dialectCode) : undefined,
dialectCode,
};
}
@ -903,6 +1063,7 @@ async function doMerge(outputTypes: OutputType[]) {
for (let i = 0; i < selectedLangs.length; i++) {
const lang = selectedLangs[i];
progress.report({ message: `${lang.code} (${i + 1}/${selectedLangs.length})…` });
// Base language export
try {
const options = buildMergeOptions(root, lang, outputTypes, wsSettings, translations);
const r = await mergeBook(options);
@ -911,6 +1072,18 @@ async function doMerge(outputTypes: OutputType[]) {
} catch (err: any) {
vscode.window.showErrorMessage(`Merge failed for ${lang.code}: ${err.message}`);
}
// Dialect exports — always run alongside parent language
for (const dialect of lang.dialects ?? []) {
progress.report({ message: `${lang.code}${dialect.code}` });
try {
const dOptions = buildMergeOptions(root, lang, outputTypes, wsSettings, translations, dialect.code);
const dr = await mergeBook(dOptions);
allOutputs.push(...dr.outputs);
allWarnings.push(...dr.warnings.map(w => `${dialect.code}: ${w}`));
} catch (err: any) {
vscode.window.showErrorMessage(`Merge failed for dialect ${dialect.code}: ${err.message}`);
}
}
}
return { outputs: allOutputs, warnings: allWarnings };
}
@ -979,7 +1152,10 @@ export function activate(context: vscode.ExtensionContext) {
vscode.commands.registerCommand('bindery.mergePdf', () => mergeCommand(['pdf'])),
vscode.commands.registerCommand('bindery.mergeAll', () => mergeCommand(['md', 'docx', 'epub', 'pdf'])),
vscode.commands.registerCommand('bindery.findProbableUsToUkWords', findProbableUsToUkWordsCommand),
vscode.commands.registerCommand('bindery.addUkReplacement', addTranslationCommand),
vscode.commands.registerCommand('bindery.addDialect', addDialectCommand),
vscode.commands.registerCommand('bindery.addTranslation', addTranslationCommand),
vscode.commands.registerCommand('bindery.addLanguage', addLanguageCommand),
vscode.commands.registerCommand('bindery.addUkReplacement', addDialectCommand), // backward compat alias
vscode.commands.registerCommand('bindery.openTranslations', openTranslationsCommand),
vscode.commands.registerCommand('bindery.registerMcp', () => registerMcpCommand(context)),
);

View file

@ -23,8 +23,11 @@ interface RetrieveInput { query: string; language?: string; topK?: number }
interface FormatInput { filePath?: string; dryRun?: boolean; noRecurse?: boolean }
interface GetReviewTextInput { language?: string; contextLines?: number; autoStage?: boolean }
interface GitSnapshotInput { message?: string }
interface AddTranslationInput { langKey: string; from: string; to: string }
interface GetTranslationInput { language: string; word?: string }
interface AddTranslationInput { from: string; to: string; targetLangCode: string }
interface GetTranslationInput { language: string; word?: string; type?: 'glossary' | 'substitution' }
interface AddDialectInput { dialectCode: string; from: string; to: string }
interface GetDialectInput { dialectCode: string; word?: string }
interface AddLanguageInput { code: string; folderName?: string; chapterWord?: string; actPrefix?: string; prologueLabel?: string; epilogueLabel?: string; createStubs?: boolean }
interface InitWorkspaceInput { bookTitle?: string; author?: string; storyFolder?: string; genre?: string; description?: string; targetAudience?: string }
interface SetupAiFilesInput { targets?: string[]; skills?: string[]; overwrite?: boolean }
interface MemoryAppendInput { file: string; title: string; content: string }
@ -45,6 +48,9 @@ interface McpTools {
toolGitSnapshot: (root: string, args: GitSnapshotInput) => string;
toolAddTranslation: (root: string, args: AddTranslationInput) => string;
toolGetTranslation: (root: string, args: GetTranslationInput) => string;
toolAddDialect: (root: string, args: AddDialectInput) => string;
toolGetDialect: (root: string, args: GetDialectInput) => string;
toolAddLanguage: (root: string, args: AddLanguageInput) => string;
toolInitWorkspace: (root: string, args: InitWorkspaceInput) => string;
toolSetupAiFiles: (root: string, args: SetupAiFilesInput) => string;
toolMemoryList: (root: string) => string;
@ -140,6 +146,18 @@ export function registerLmTools(context: vscode.ExtensionContext): void {
invoke: async (opts, _token) => ok(t.toolGetTranslation(requireRoot(), opts.input)),
}),
vscode.lm.registerTool<AddDialectInput>('bindery_add_dialect', {
invoke: async (opts, _token) => ok(t.toolAddDialect(requireRoot(), opts.input)),
}),
vscode.lm.registerTool<GetDialectInput>('bindery_get_dialect', {
invoke: async (opts, _token) => ok(t.toolGetDialect(requireRoot(), opts.input)),
}),
vscode.lm.registerTool<AddLanguageInput>('bindery_add_language', {
invoke: async (opts, _token) => ok(t.toolAddLanguage(requireRoot(), opts.input)),
}),
vscode.lm.registerTool<InitWorkspaceInput>('bindery_init_workspace', {
invoke: async (opts, _token) => ok(t.toolInitWorkspace(requireRoot(), opts.input)),
}),

View file

@ -18,6 +18,18 @@ export interface LanguageConfig {
actPrefix: string;
prologueLabel: string;
epilogueLabel: string;
/** True for the primary language the book is written in. */
isDefault?: boolean;
/** Dialect exports derived from this language (e.g. en-gb from EN). No story folder of their own. */
dialects?: DialectConfig[];
}
/** A dialect derived from a parent language — same story folder, word substitutions applied at export. */
export interface DialectConfig {
/** Dialect code, used as the key in translations.json (e.g. 'en-gb'). */
code: string;
/** Human-readable label, e.g. 'British English'. */
label?: string;
}
export type OutputType = 'md' | 'docx' | 'epub' | 'pdf';
@ -49,6 +61,13 @@ export interface MergeOptions {
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 {
@ -282,39 +301,44 @@ function convertUsToUkDirectory(dirPath: string, customReplacements: UkReplaceme
return changed;
}
function isUkLanguage(lang: LanguageConfig): boolean {
/**
* 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';
}
function prepareUkFromEn(root: string, storyFolder: string, lang: LanguageConfig, customReplacements: UkReplacement[] = []): void {
if (!isUkLanguage(lang)) {
return;
/**
* 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}`);
}
const storyRoot = path.join(root, storyFolder);
const enPath = path.join(storyRoot, 'EN');
const ukPath = path.join(storyRoot, lang.folderName);
if (!fs.existsSync(enPath)) {
throw new Error(`EN source folder not found for UK generation: ${enPath}`);
if (fs.existsSync(dialectPath)) {
fs.rmSync(dialectPath, { recursive: true, force: true });
}
if (fs.existsSync(ukPath)) {
fs.rmSync(ukPath, { recursive: true, force: true });
}
fs.cpSync(enPath, ukPath, { recursive: true });
convertUsToUkDirectory(ukPath, customReplacements);
fs.cpSync(sourcePath, dialectPath, { recursive: true });
convertUsToUkDirectory(dialectPath, customReplacements);
}
function cleanupUkTempFolder(root: string, storyFolder: string, lang: LanguageConfig): void {
if (!isUkLanguage(lang)) {
return;
}
const ukPath = path.join(root, storyFolder, lang.folderName);
if (fs.existsSync(ukPath)) {
fs.rmSync(ukPath, { recursive: true, force: true });
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 });
}
}
@ -794,10 +818,30 @@ function formatDirectory(dirPath: string): number {
* Main merge entry point.
*/
export async function mergeBook(options: MergeOptions): Promise<MergeResult> {
prepareUkFromEn(options.root, options.storyFolder, options.language, options.ukReplacements ?? []);
// 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, options.language.folderName);
const langPath = path.join(options.root, options.storyFolder, effectiveFolderName);
if (!fs.existsSync(langPath)) {
throw new Error(`Language folder not found: ${langPath}`);
@ -816,7 +860,11 @@ export async function mergeBook(options: MergeOptions): Promise<MergeResult> {
const outputDir = path.join(options.root, options.outputDir);
fs.mkdirSync(outputDir, { recursive: true });
const baseName = `${options.filePrefix}_${options.language.folderName}_Merged`;
// 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[] = [];
@ -867,6 +915,10 @@ export async function mergeBook(options: MergeOptions): Promise<MergeResult> {
return { outputs, filesMerged: files.length, warnings };
} finally {
cleanupUkTempFolder(options.root, options.storyFolder, options.language);
if (isLegacyUk) {
cleanupDialectTempFolder(options.root, options.storyFolder, 'UK');
} else if (options.dialectCode) {
cleanupDialectTempFolder(options.root, options.storyFolder, `_dialect_${options.dialectCode}`);
}
}
}

View file

@ -10,7 +10,7 @@
import * as fs from 'fs';
import * as path from 'path';
import type { LanguageConfig, UkReplacement } from './merge';
import type { LanguageConfig, DialectConfig, UkReplacement } from './merge';
export const BINDERY_FOLDER = '.bindery';
export const SETTINGS_FILENAME = 'settings.json';
@ -34,13 +34,16 @@ export interface WorkspaceSettings {
genre?: string;
/** Target audience, e.g. "12+" or "adults" or "8-10". Used to calibrate AI review feedback. */
targetAudience?: string;
storyFolder?: string;
mergedOutputDir?: string;
storyFolder?: string;
mergedOutputDir?: string;
mergeFilePrefix?: string;
formatOnSave?: boolean;
languages?: LanguageConfig[];
}
// Re-export DialectConfig so callers don't need to import from merge.ts directly
export type { DialectConfig };
/** Type of a translation entry — determines how the extension uses its rules. */
export type TranslationType = 'substitution' | 'glossary';
@ -131,6 +134,30 @@ export function getBookTitleForLang(
?? undefined;
}
/**
* Return the language marked isDefault, or the first language in the list.
*/
export function getDefaultLanguage(
settings: WorkspaceSettings | null
): LanguageConfig | undefined {
const langs = settings?.languages;
if (!langs || langs.length === 0) { return undefined; }
return langs.find(l => l.isDefault) ?? langs[0];
}
/**
* Return dialects[] for the language matching langCode, or [].
*/
export function getDialectsForLanguage(
settings: WorkspaceSettings | null,
langCode: string
): DialectConfig[] {
const lang = settings?.languages?.find(
l => l.code.toUpperCase() === langCode.toUpperCase()
);
return lang?.dialects ?? [];
}
/**
* Get substitution rules from translations.json for the given language key.
* Returns UkReplacement[] compatible with merge.ts (field names us/uk).
@ -234,6 +261,55 @@ export function addIgnoredWords(
return added;
}
/**
* Add or update a glossary rule in .bindery/translations.json.
* Glossary entries are for cross-language reference (e.g. ENNL world terms).
* They are not auto-applied at export; agents use them for consistency checking.
* Creates the file and entry if they do not yet exist.
*/
export function upsertGlossaryRule(
root: string,
langKey: string,
langLabel: string,
sourceLang: string,
rule: TranslationRule
): void {
const translations = readTranslations(root) ?? {};
if (!translations[langKey]) {
translations[langKey] = {
label: langLabel,
type: 'glossary',
sourceLanguage: sourceLang,
rules: [],
};
}
const entry = translations[langKey];
// If entry exists but was previously substitution, keep it — don't downgrade
const rules = entry.rules ?? [];
const idx = rules.findIndex(r => r.from.toLowerCase() === rule.from.toLowerCase());
if (idx >= 0) {
rules[idx] = rule;
} else {
rules.push(rule);
rules.sort((a, b) => a.from.localeCompare(b.from));
}
entry.rules = rules;
writeTranslations(root, translations);
}
/**
* Get glossary rules for a language key (type === 'glossary' entries).
*/
export function getGlossaryRules(
translations: TranslationsFile | null,
langKey: string
): TranslationRule[] {
if (!translations) { return []; }
const entry = resolveEntry(translations, langKey);
if (!entry) { return []; }
return (entry.rules ?? []).filter(r => r.from?.trim() && r.to?.trim());
}
// ─── Internal helpers ─────────────────────────────────────────────────────────
function normaliseKey(key: string): string {