i18n-tools-mcp 1.0.1 → 1.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -0
- package/dist/index.js +42 -1
- package/dist/translations.d.ts +22 -0
- package/dist/translations.js +113 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -130,6 +130,26 @@ Search translations by value (case-insensitive).
|
|
|
130
130
|
|
|
131
131
|
Find translation keys that exist in some locales but are missing in others. Takes no parameters.
|
|
132
132
|
|
|
133
|
+
### `batch`
|
|
134
|
+
|
|
135
|
+
Apply multiple mutations in a single call. Each affected locale file is read once, mutated in memory, and written exactly once — regardless of how many ops touch it. Per-file atomic: if any op fails, no files are written.
|
|
136
|
+
|
|
137
|
+
Prefer this over repeated `set`/`add`/`delete`/`rename`/`move` calls for bulk changes. It minimizes file-watcher churn (Next.js dev, Vite HMR) and avoids partial writes.
|
|
138
|
+
|
|
139
|
+
| Parameter | Type | Required | Description |
|
|
140
|
+
|-----------|-----------|----------|----------------------------------------------|
|
|
141
|
+
| `ops` | `BatchOp[]` | yes | Ordered list of operations (see below) |
|
|
142
|
+
|
|
143
|
+
Each op is one of:
|
|
144
|
+
|
|
145
|
+
```jsonc
|
|
146
|
+
{ "op": "set", "locale": "en", "key": "Users.name", "value": "Name" }
|
|
147
|
+
{ "op": "add", "key": "Users.email", "translations": { "nl": "E-mail", "en": "Email" } }
|
|
148
|
+
{ "op": "delete", "key": "Users.legacy" }
|
|
149
|
+
{ "op": "rename", "oldKey": "Users.name", "newKey": "Users.fullName" }
|
|
150
|
+
{ "op": "move", "oldKey": "Users.name", "newKey": "Profile.name" }
|
|
151
|
+
```
|
|
152
|
+
|
|
133
153
|
## Development
|
|
134
154
|
|
|
135
155
|
```bash
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
3
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
4
|
import { z } from 'zod';
|
|
5
|
-
import { query, set, add, remove, rename, missing, list, search, move, setIndent } from './translations.js';
|
|
5
|
+
import { query, set, add, remove, rename, missing, list, search, move, batch, setIndent } from './translations.js';
|
|
6
6
|
const baseDir = process.argv[2];
|
|
7
7
|
if (!baseDir) {
|
|
8
8
|
console.error('Usage: i18n-tools-mcp <translations-directory> [base-locale] [indent]');
|
|
@@ -148,6 +148,47 @@ server.registerTool('move', {
|
|
|
148
148
|
return { content: [{ type: 'text', text: `Error: ${error.message}` }], isError: true };
|
|
149
149
|
}
|
|
150
150
|
});
|
|
151
|
+
const batchOpSchema = z.discriminatedUnion('op', [
|
|
152
|
+
z.object({
|
|
153
|
+
op: z.literal('set'),
|
|
154
|
+
locale: z.string(),
|
|
155
|
+
key: z.string(),
|
|
156
|
+
value: z.string(),
|
|
157
|
+
}),
|
|
158
|
+
z.object({
|
|
159
|
+
op: z.literal('add'),
|
|
160
|
+
key: z.string(),
|
|
161
|
+
translations: z.record(z.string(), z.string()),
|
|
162
|
+
}),
|
|
163
|
+
z.object({
|
|
164
|
+
op: z.literal('delete'),
|
|
165
|
+
key: z.string(),
|
|
166
|
+
}),
|
|
167
|
+
z.object({
|
|
168
|
+
op: z.literal('rename'),
|
|
169
|
+
oldKey: z.string(),
|
|
170
|
+
newKey: z.string(),
|
|
171
|
+
}),
|
|
172
|
+
z.object({
|
|
173
|
+
op: z.literal('move'),
|
|
174
|
+
oldKey: z.string(),
|
|
175
|
+
newKey: z.string(),
|
|
176
|
+
}),
|
|
177
|
+
]);
|
|
178
|
+
server.registerTool('batch', {
|
|
179
|
+
description: `Apply multiple translation mutations in a single call. Each affected locale file is written exactly once, regardless of how many ops touch it. Prefer this over repeated set/add/delete/rename/move calls when making bulk changes — it minimizes file-watcher churn (Next.js dev, etc.) and is atomic per-file: if any op fails, no files are written. Always include the base locale "${baseLocale}" in add ops.`,
|
|
180
|
+
inputSchema: {
|
|
181
|
+
ops: z.array(batchOpSchema).describe('Ordered list of operations. Each op is one of: {op:"set", locale, key, value}, {op:"add", key, translations}, {op:"delete", key}, {op:"rename", oldKey, newKey}, {op:"move", oldKey, newKey}.'),
|
|
182
|
+
},
|
|
183
|
+
}, async ({ ops }) => {
|
|
184
|
+
try {
|
|
185
|
+
const result = batch(ops, baseDir);
|
|
186
|
+
return { content: [{ type: 'text', text: result }] };
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
return { content: [{ type: 'text', text: `Error: ${error.message}` }], isError: true };
|
|
190
|
+
}
|
|
191
|
+
});
|
|
151
192
|
async function main() {
|
|
152
193
|
const transport = new StdioServerTransport();
|
|
153
194
|
await server.connect(transport);
|
package/dist/translations.d.ts
CHANGED
|
@@ -8,3 +8,25 @@ export declare function missing(dir?: string): string;
|
|
|
8
8
|
export declare function list(prefix?: string, page?: number, pageSize?: number, dir?: string): string;
|
|
9
9
|
export declare function search(value: string, page?: number, pageSize?: number, dir?: string): string;
|
|
10
10
|
export declare function move(oldKey: string, newKey: string, dir?: string): string;
|
|
11
|
+
export type BatchOp = {
|
|
12
|
+
op: 'set';
|
|
13
|
+
locale: string;
|
|
14
|
+
key: string;
|
|
15
|
+
value: string;
|
|
16
|
+
} | {
|
|
17
|
+
op: 'add';
|
|
18
|
+
key: string;
|
|
19
|
+
translations: Record<string, string>;
|
|
20
|
+
} | {
|
|
21
|
+
op: 'delete';
|
|
22
|
+
key: string;
|
|
23
|
+
} | {
|
|
24
|
+
op: 'rename';
|
|
25
|
+
oldKey: string;
|
|
26
|
+
newKey: string;
|
|
27
|
+
} | {
|
|
28
|
+
op: 'move';
|
|
29
|
+
oldKey: string;
|
|
30
|
+
newKey: string;
|
|
31
|
+
};
|
|
32
|
+
export declare function batch(ops: BatchOp[], dir?: string): string;
|
package/dist/translations.js
CHANGED
|
@@ -341,3 +341,116 @@ export function search(value, page = 1, pageSize = 100, dir) {
|
|
|
341
341
|
export function move(oldKey, newKey, dir) {
|
|
342
342
|
return rename(oldKey, newKey, dir).replace('Renamed', 'Moved');
|
|
343
343
|
}
|
|
344
|
+
export function batch(ops, dir) {
|
|
345
|
+
const resolvedDir = dir || DEFAULT_DIR;
|
|
346
|
+
const localeFiles = discoverLocaleFiles(resolvedDir);
|
|
347
|
+
const cache = new Map();
|
|
348
|
+
const dirty = new Set();
|
|
349
|
+
const load = (file) => {
|
|
350
|
+
if (!cache.has(file.path)) {
|
|
351
|
+
cache.set(file.path, readJson(file.path));
|
|
352
|
+
}
|
|
353
|
+
return cache.get(file.path);
|
|
354
|
+
};
|
|
355
|
+
const summary = [];
|
|
356
|
+
for (let i = 0; i < ops.length; i++) {
|
|
357
|
+
const op = ops[i];
|
|
358
|
+
try {
|
|
359
|
+
switch (op.op) {
|
|
360
|
+
case 'set': {
|
|
361
|
+
const { namespace, dotKey } = parseKey(op.key);
|
|
362
|
+
if (isNamespaced(localeFiles) && !namespace) {
|
|
363
|
+
throw new Error(`Namespace required in namespace mode. Use "namespace:${op.key}" format.`);
|
|
364
|
+
}
|
|
365
|
+
const file = findLocaleFile(localeFiles, op.locale, namespace);
|
|
366
|
+
if (!file) {
|
|
367
|
+
const target = namespace ? `${op.locale}/${namespace}` : op.locale;
|
|
368
|
+
throw new Error(`Locale "${target}" not found in ${resolvedDir}`);
|
|
369
|
+
}
|
|
370
|
+
setNestedValue(load(file), dotKey, sanitize(op.value));
|
|
371
|
+
dirty.add(file.path);
|
|
372
|
+
summary.push(`set ${op.locale}.${op.key}`);
|
|
373
|
+
break;
|
|
374
|
+
}
|
|
375
|
+
case 'add': {
|
|
376
|
+
const { namespace, dotKey } = parseKey(op.key);
|
|
377
|
+
if (isNamespaced(localeFiles) && !namespace) {
|
|
378
|
+
throw new Error(`Namespace required in namespace mode. Use "namespace:${op.key}" format.`);
|
|
379
|
+
}
|
|
380
|
+
const updated = [];
|
|
381
|
+
const skipped = [];
|
|
382
|
+
for (const [locale, value] of Object.entries(op.translations)) {
|
|
383
|
+
const file = findLocaleFile(localeFiles, locale, namespace);
|
|
384
|
+
if (!file) {
|
|
385
|
+
skipped.push(locale);
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
setNestedValue(load(file), dotKey, sanitize(value));
|
|
389
|
+
dirty.add(file.path);
|
|
390
|
+
updated.push(locale);
|
|
391
|
+
}
|
|
392
|
+
if (updated.length === 0) {
|
|
393
|
+
throw new Error(`No locales updated for "${op.key}"`);
|
|
394
|
+
}
|
|
395
|
+
const skipNote = skipped.length > 0 ? ` (skipped: ${skipped.join(', ')})` : '';
|
|
396
|
+
summary.push(`add ${op.key} → ${updated.join(', ')}${skipNote}`);
|
|
397
|
+
break;
|
|
398
|
+
}
|
|
399
|
+
case 'delete': {
|
|
400
|
+
const { namespace, dotKey } = parseKey(op.key);
|
|
401
|
+
const filesToSearch = filterFiles(localeFiles, namespace);
|
|
402
|
+
const deleted = [];
|
|
403
|
+
for (const file of filesToSearch) {
|
|
404
|
+
const data = load(file);
|
|
405
|
+
if (deleteNestedValue(data, dotKey)) {
|
|
406
|
+
dirty.add(file.path);
|
|
407
|
+
deleted.push(file.locale);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
summary.push(deleted.length > 0
|
|
411
|
+
? `delete ${op.key} → ${deleted.join(', ')}`
|
|
412
|
+
: `delete ${op.key} → not found`);
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
case 'rename':
|
|
416
|
+
case 'move': {
|
|
417
|
+
const { namespace: oldNs, dotKey: oldDotKey } = parseKey(op.oldKey);
|
|
418
|
+
const { dotKey: newDotKey } = parseKey(op.newKey);
|
|
419
|
+
const filesToSearch = filterFiles(localeFiles, oldNs);
|
|
420
|
+
const renamed = [];
|
|
421
|
+
for (const file of filesToSearch) {
|
|
422
|
+
const data = load(file);
|
|
423
|
+
const value = getNestedValue(data, oldDotKey);
|
|
424
|
+
if (value === undefined)
|
|
425
|
+
continue;
|
|
426
|
+
deleteNestedValue(data, oldDotKey);
|
|
427
|
+
if (typeof value === 'object' && value !== null) {
|
|
428
|
+
const subKeys = collectKeys(value);
|
|
429
|
+
for (const subKey of subKeys) {
|
|
430
|
+
const subValue = getNestedValue(value, subKey);
|
|
431
|
+
setNestedValue(data, `${newDotKey}.${subKey}`, subValue);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
else {
|
|
435
|
+
setNestedValue(data, newDotKey, value);
|
|
436
|
+
}
|
|
437
|
+
dirty.add(file.path);
|
|
438
|
+
renamed.push(file.locale);
|
|
439
|
+
}
|
|
440
|
+
summary.push(renamed.length > 0
|
|
441
|
+
? `${op.op} ${op.oldKey} → ${op.newKey} (${renamed.join(', ')})`
|
|
442
|
+
: `${op.op} ${op.oldKey} → not found`);
|
|
443
|
+
break;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
catch (error) {
|
|
448
|
+
throw new Error(`Op #${i + 1} (${op.op}) failed: ${error.message}. No files written.`);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
for (const path of dirty) {
|
|
452
|
+
writeJson(path, cache.get(path));
|
|
453
|
+
}
|
|
454
|
+
const header = `Batch: ${ops.length} op(s), ${dirty.size} file(s) written`;
|
|
455
|
+
return [header, ...summary].join('\n');
|
|
456
|
+
}
|