mirror of
https://github.com/evdboom/Bindery.git
synced 2026-07-22 06:49:36 +00:00
Merge pull request #10 from evdboom/translation-update
feat: Enhance translation and dialect management in Bindery
This commit is contained in:
commit
16eef1dd26
12 changed files with 824 additions and 198 deletions
12
.github/copilot-instructions.md
vendored
12
.github/copilot-instructions.md
vendored
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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." }
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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 (2–3 uppercase letters)',
|
||||
placeHolder: 'FR NL DE ES IT PT …',
|
||||
validateInput: v => /^[A-Za-z]{2,3}$/.test(v.trim()) ? undefined : 'Enter 2–3 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)),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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. EN→NL 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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue