diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 790b758..83fdf14 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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 | diff --git a/mcp-ts/src/index.ts b/mcp-ts/src/index.ts index b12de22..0a98719 100644 --- a/mcp-ts/src/index.ts +++ b/mcp-ts/src/index.ts @@ -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', { diff --git a/mcp-ts/src/templates.ts b/mcp-ts/src/templates.ts index 655323d..5f57d4c 100644 --- a/mcp-ts/src/templates.ts +++ b/mcp-ts/src/templates.ts @@ -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 diff --git a/mcp-ts/src/tools.ts b/mcp-ts/src/tools.ts index a65ea20..b856695 100644 --- a/mcp-ts/src/tools.ts +++ b/mcp-ts/src/tools.ts @@ -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; 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 = {}; + try { existing = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as Record; } + 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 ───────────────────────────────────────────────────────────── diff --git a/mcpb/README.md b/mcpb/README.md index d3f4a36..b6e9b77 100644 --- a/mcpb/README.md +++ b/mcpb/README.md @@ -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) | diff --git a/mcpb/manifest.json b/mcpb/manifest.json index 4ec5677..e28b3bc 100644 --- a/mcpb/manifest.json +++ b/mcpb/manifest.json @@ -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." } diff --git a/vscode-ext/CLAUDE.md b/vscode-ext/CLAUDE.md index 88184de..bcb88ef 100644 --- a/vscode-ext/CLAUDE.md +++ b/vscode-ext/CLAUDE.md @@ -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. diff --git a/vscode-ext/package.json b/vscode-ext/package.json index 8add173..c76cbcd 100644 --- a/vscode-ext/package.json +++ b/vscode-ext/package.json @@ -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" }, diff --git a/vscode-ext/src/extension.ts b/vscode-ext/src/extension.ts index a7bcb9d..70d7125 100644 --- a/vscode-ext/src/extension.ts +++ b/vscode-ext/src/extension.ts @@ -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 = { - 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(); 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 { 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 = {}; + try { rawSettings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as Record; } 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)), ); diff --git a/vscode-ext/src/mcp.ts b/vscode-ext/src/mcp.ts index 68769ff..335d80c 100644 --- a/vscode-ext/src/mcp.ts +++ b/vscode-ext/src/mcp.ts @@ -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('bindery_add_dialect', { + invoke: async (opts, _token) => ok(t.toolAddDialect(requireRoot(), opts.input)), + }), + + vscode.lm.registerTool('bindery_get_dialect', { + invoke: async (opts, _token) => ok(t.toolGetDialect(requireRoot(), opts.input)), + }), + + vscode.lm.registerTool('bindery_add_language', { + invoke: async (opts, _token) => ok(t.toolAddLanguage(requireRoot(), opts.input)), + }), + vscode.lm.registerTool('bindery_init_workspace', { invoke: async (opts, _token) => ok(t.toolInitWorkspace(requireRoot(), opts.input)), }), diff --git a/vscode-ext/src/merge.ts b/vscode-ext/src/merge.ts index c4ccb3c..ea76ba5 100644 --- a/vscode-ext/src/merge.ts +++ b/vscode-ext/src/merge.ts @@ -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 { - 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 { 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 { 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}`); + } } } diff --git a/vscode-ext/src/workspace.ts b/vscode-ext/src/workspace.ts index da1d820..280808c 100644 --- a/vscode-ext/src/workspace.ts +++ b/vscode-ext/src/workspace.ts @@ -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 {