i18n-tools-mcp 1.0.3 → 1.0.4

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 CHANGED
@@ -57,6 +57,14 @@ messages/
57
57
 
58
58
  In namespace mode, prefix keys with the namespace: `common:Users.name`. Read operations (`query`, `list`, `search`, `missing`) work across all namespaces when no prefix is given. Write operations (`set`, `add`) require a namespace prefix.
59
59
 
60
+ ## Keys containing dots
61
+
62
+ Keys may be nested objects, literal keys containing dots (Laravel-style `{"auth.login.title": "..."}`), or a mix of both. A dot-notation key is resolved against what is actually in the file, with the longest literal key winning at each level, so `auth.login.title` finds both `{"auth": {"login": {"title": ...}}}` and `{"auth.login.title": ...}`.
63
+
64
+ - **Existing keys** are updated in place, in whichever form they already have.
65
+ - **New keys** descend into existing nested objects as far as possible. The remainder is written as one literal dotted key when that object already contains dotted keys, and as nested objects otherwise.
66
+ - **Prefixes** of flat keys act as groups: `query`, `delete`, `rename` and `move` on `datatable` cover `datatable.columns` and `datatable.rows`.
67
+
60
68
  ## Tools
61
69
 
62
70
  ### `query`
@@ -62,58 +62,170 @@ function writeJson(filePath, data) {
62
62
  export function setIndent(spaces) {
63
63
  jsonIndent = spaces;
64
64
  }
65
- function getNestedValue(obj, key) {
66
- const parts = key.split('.');
67
- let current = obj;
68
- for (const part of parts) {
69
- if (current == null || typeof current !== 'object') {
70
- return undefined;
65
+ function isObject(value) {
66
+ return value != null && typeof value === 'object' && !Array.isArray(value);
67
+ }
68
+ function hasOwn(obj, key) {
69
+ return Object.prototype.hasOwnProperty.call(obj, key);
70
+ }
71
+ /**
72
+ * Resolve a dot-notation key to the real chain of object keys. A single object key may itself
73
+ * contain dots (Laravel-style flat files such as `{"auth.login.title": "..."}`), so at every
74
+ * level the longest literal key wins, backtracking when a branch does not lead to the full key.
75
+ */
76
+ function resolvePath(obj, key) {
77
+ return resolveParts(obj, key.split('.'));
78
+ }
79
+ function resolveParts(obj, parts) {
80
+ for (let i = parts.length; i > 0; i--) {
81
+ const segment = parts.slice(0, i).join('.');
82
+ if (!hasOwn(obj, segment)) {
83
+ continue;
84
+ }
85
+ if (i === parts.length) {
86
+ return [segment];
87
+ }
88
+ const child = obj[segment];
89
+ if (isObject(child)) {
90
+ const rest = resolveParts(child, parts.slice(i));
91
+ if (rest) {
92
+ return [segment, ...rest];
93
+ }
71
94
  }
72
- current = current[part];
73
95
  }
74
- return current;
96
+ return undefined;
75
97
  }
76
- function deleteNestedValue(obj, key) {
77
- const parts = key.split('.');
98
+ function parentOf(obj, path) {
78
99
  let current = obj;
79
- for (let i = 0; i < parts.length - 1; i++) {
80
- const part = parts[i];
81
- if (current[part] == null || typeof current[part] !== 'object') {
82
- return false;
100
+ for (const segment of path.slice(0, -1)) {
101
+ current = current[segment];
102
+ }
103
+ return current;
104
+ }
105
+ function collectEntries(obj, prefix = '', path = []) {
106
+ const entries = [];
107
+ for (const [k, v] of Object.entries(obj)) {
108
+ const key = prefix ? `${prefix}.${k}` : k;
109
+ if (isObject(v)) {
110
+ entries.push(...collectEntries(v, key, [...path, k]));
111
+ }
112
+ else {
113
+ entries.push({ key, path: [...path, k], value: v });
83
114
  }
84
- current = current[part];
85
115
  }
86
- const lastPart = parts[parts.length - 1];
87
- if (!(lastPart in current)) {
88
- return false;
116
+ return entries;
117
+ }
118
+ function collectKeys(obj) {
119
+ return collectEntries(obj).map((entry) => entry.key);
120
+ }
121
+ /**
122
+ * Leaf entries whose full key equals `key` or lives under it. Unlike resolvePath this also
123
+ * finds groups that only exist as a shared prefix of flat keys (`datatable` for
124
+ * `"datatable.columns"` and `"datatable.rows"`).
125
+ */
126
+ function matchingEntries(obj, key) {
127
+ return collectEntries(obj).filter((entry) => entry.key === key || entry.key.startsWith(`${key}.`));
128
+ }
129
+ function getValue(obj, key) {
130
+ const path = resolvePath(obj, key);
131
+ if (path) {
132
+ return parentOf(obj, path)[path[path.length - 1]];
89
133
  }
90
- delete current[lastPart];
91
- return true;
134
+ const entries = matchingEntries(obj, key);
135
+ if (entries.length === 0) {
136
+ return undefined;
137
+ }
138
+ return Object.fromEntries(entries.map((entry) => [entry.key.slice(key.length + 1), entry.value]));
139
+ }
140
+ function deleteValue(obj, key) {
141
+ let deleted = false;
142
+ const path = resolvePath(obj, key);
143
+ if (path) {
144
+ delete parentOf(obj, path)[path[path.length - 1]];
145
+ deleted = true;
146
+ }
147
+ for (const entry of matchingEntries(obj, key)) {
148
+ delete parentOf(obj, entry.path)[entry.path[entry.path.length - 1]];
149
+ deleted = true;
150
+ }
151
+ return deleted;
92
152
  }
93
- function setNestedValue(obj, key, value) {
94
- const parts = key.split('.');
153
+ /**
154
+ * Set a value, overwriting an existing key wherever it lives. A new key descends into existing
155
+ * nested objects as far as possible; the remainder is written as one literal dotted key when
156
+ * that object already uses dotted keys (flat style), and as nested objects otherwise.
157
+ */
158
+ function setValue(obj, key, value) {
159
+ const path = resolvePath(obj, key);
160
+ if (path) {
161
+ parentOf(obj, path)[path[path.length - 1]] = value;
162
+ return;
163
+ }
95
164
  let current = obj;
96
- for (let i = 0; i < parts.length - 1; i++) {
97
- const part = parts[i];
98
- if (current[part] == null || typeof current[part] !== 'object') {
165
+ let parts = key.split('.');
166
+ descend: while (parts.length > 1) {
167
+ for (let i = parts.length - 1; i > 0; i--) {
168
+ const segment = parts.slice(0, i).join('.');
169
+ const child = current[segment];
170
+ if (hasOwn(current, segment) && isObject(child)) {
171
+ current = child;
172
+ parts = parts.slice(i);
173
+ continue descend;
174
+ }
175
+ }
176
+ break;
177
+ }
178
+ if (Object.keys(current).some((k) => k.includes('.'))) {
179
+ current[parts.join('.')] = value;
180
+ return;
181
+ }
182
+ for (const part of parts.slice(0, -1)) {
183
+ if (!isObject(current[part])) {
99
184
  current[part] = {};
100
185
  }
101
186
  current = current[part];
102
187
  }
103
188
  current[parts[parts.length - 1]] = value;
104
189
  }
105
- function collectKeys(obj, prefix = '') {
106
- const keys = [];
107
- for (const [k, v] of Object.entries(obj)) {
108
- const fk = prefix ? `${prefix}.${k}` : k;
109
- if (v != null && typeof v === 'object' && !Array.isArray(v)) {
110
- keys.push(...collectKeys(v, fk));
190
+ function valueAt(obj, path) {
191
+ let current = obj;
192
+ for (const segment of path) {
193
+ if (!isObject(current) || !hasOwn(current, segment)) {
194
+ return undefined;
111
195
  }
112
- else {
113
- keys.push(fk);
196
+ current = current[segment];
197
+ }
198
+ return current;
199
+ }
200
+ /**
201
+ * Move every leaf under `oldKey` to `newKey`. New keys are written before the old ones are
202
+ * removed, so setValue still sees the file's key style (a flat file emptied first would look
203
+ * nested). Afterwards the old node is dropped if it is an object that is now empty.
204
+ */
205
+ function renameValue(obj, oldKey, newKey) {
206
+ const entries = matchingEntries(obj, oldKey);
207
+ if (entries.length === 0) {
208
+ return false;
209
+ }
210
+ const oldPath = resolvePath(obj, oldKey);
211
+ const written = new Set();
212
+ for (const entry of entries) {
213
+ const target = newKey + entry.key.slice(oldKey.length);
214
+ setValue(obj, target, entry.value);
215
+ written.add(JSON.stringify(resolvePath(obj, target)));
216
+ }
217
+ for (const entry of entries) {
218
+ if (!written.has(JSON.stringify(entry.path)) && valueAt(obj, entry.path) !== undefined && !isObject(valueAt(obj, entry.path))) {
219
+ delete parentOf(obj, entry.path)[entry.path[entry.path.length - 1]];
220
+ }
221
+ }
222
+ if (oldPath) {
223
+ const node = valueAt(obj, oldPath);
224
+ if (isObject(node) && Object.keys(node).length === 0) {
225
+ delete parentOf(obj, oldPath)[oldPath[oldPath.length - 1]];
114
226
  }
115
227
  }
116
- return keys;
228
+ return true;
117
229
  }
118
230
  function filterFiles(files, namespace) {
119
231
  if (!namespace) {
@@ -135,7 +247,7 @@ export function query(key, dir) {
135
247
  const results = [];
136
248
  for (const file of filesToSearch) {
137
249
  const data = readJson(file.path);
138
- const value = getNestedValue(data, dotKey);
250
+ const value = getValue(data, dotKey);
139
251
  if (value !== undefined) {
140
252
  const label = !namespace && file.namespace
141
253
  ? `${file.locale} (${file.namespace})`
@@ -166,7 +278,7 @@ export function set(locale, key, value, dir) {
166
278
  throw new Error(`Locale "${target}" not found in ${resolvedDir}`);
167
279
  }
168
280
  const data = readJson(localeFile.path);
169
- setNestedValue(data, dotKey, sanitize(value));
281
+ setValue(data, dotKey, sanitize(value));
170
282
  writeJson(localeFile.path, data);
171
283
  return `Set ${locale}.${key} = "${value}"`;
172
284
  }
@@ -178,7 +290,7 @@ export function remove(key, dir) {
178
290
  const deleted = [];
179
291
  for (const file of filesToSearch) {
180
292
  const data = readJson(file.path);
181
- if (deleteNestedValue(data, dotKey)) {
293
+ if (deleteValue(data, dotKey)) {
182
294
  writeJson(file.path, data);
183
295
  const label = !namespace && file.namespace
184
296
  ? `${file.locale} (${file.namespace})`
@@ -208,7 +320,7 @@ export function add(key, translations, dir) {
208
320
  continue;
209
321
  }
210
322
  const data = readJson(localeFile.path);
211
- setNestedValue(data, dotKey, sanitize(value));
323
+ setValue(data, dotKey, sanitize(value));
212
324
  writeJson(localeFile.path, data);
213
325
  updated.push(locale);
214
326
  }
@@ -231,21 +343,9 @@ export function rename(oldKey, newKey, dir) {
231
343
  const renamed = [];
232
344
  for (const file of filesToSearch) {
233
345
  const data = readJson(file.path);
234
- const value = getNestedValue(data, oldDotKey);
235
- if (value === undefined) {
346
+ if (!renameValue(data, oldDotKey, newDotKey)) {
236
347
  continue;
237
348
  }
238
- deleteNestedValue(data, oldDotKey);
239
- if (typeof value === 'object' && value !== null) {
240
- const subKeys = collectKeys(value);
241
- for (const subKey of subKeys) {
242
- const subValue = getNestedValue(value, subKey);
243
- setNestedValue(data, `${newDotKey}.${subKey}`, subValue);
244
- }
245
- }
246
- else {
247
- setNestedValue(data, newDotKey, value);
248
- }
249
349
  writeJson(file.path, data);
250
350
  const label = !oldNs && file.namespace
251
351
  ? `${file.locale} (${file.namespace})`
@@ -317,9 +417,7 @@ export function search(value, page = 1, pageSize = 100, dir) {
317
417
  const lowerValue = value.toLowerCase();
318
418
  for (const file of localeFiles) {
319
419
  const data = readJson(file.path);
320
- const keys = collectKeys(data);
321
- for (const key of keys) {
322
- const v = getNestedValue(data, key);
420
+ for (const { key, value: v } of collectEntries(data)) {
323
421
  if (typeof v === 'string' && v.toLowerCase().includes(lowerValue)) {
324
422
  const fk = fullKey(file, key);
325
423
  results.push(`${file.locale}.${fk} = "${v}"`);
@@ -367,7 +465,7 @@ export function batch(ops, dir) {
367
465
  const target = namespace ? `${op.locale}/${namespace}` : op.locale;
368
466
  throw new Error(`Locale "${target}" not found in ${resolvedDir}`);
369
467
  }
370
- setNestedValue(load(file), dotKey, sanitize(op.value));
468
+ setValue(load(file), dotKey, sanitize(op.value));
371
469
  dirty.add(file.path);
372
470
  summary.push(`set ${op.locale}.${op.key}`);
373
471
  break;
@@ -385,7 +483,7 @@ export function batch(ops, dir) {
385
483
  skipped.push(locale);
386
484
  continue;
387
485
  }
388
- setNestedValue(load(file), dotKey, sanitize(value));
486
+ setValue(load(file), dotKey, sanitize(value));
389
487
  dirty.add(file.path);
390
488
  updated.push(locale);
391
489
  }
@@ -402,7 +500,7 @@ export function batch(ops, dir) {
402
500
  const deleted = [];
403
501
  for (const file of filesToSearch) {
404
502
  const data = load(file);
405
- if (deleteNestedValue(data, dotKey)) {
503
+ if (deleteValue(data, dotKey)) {
406
504
  dirty.add(file.path);
407
505
  deleted.push(file.locale);
408
506
  }
@@ -419,21 +517,8 @@ export function batch(ops, dir) {
419
517
  const filesToSearch = filterFiles(localeFiles, oldNs);
420
518
  const renamed = [];
421
519
  for (const file of filesToSearch) {
422
- const data = load(file);
423
- const value = getNestedValue(data, oldDotKey);
424
- if (value === undefined)
520
+ if (!renameValue(load(file), oldDotKey, newDotKey))
425
521
  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
522
  dirty.add(file.path);
438
523
  renamed.push(file.locale);
439
524
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "i18n-tools-mcp",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "description": "MCP server for managing JSON translation files",
5
5
  "type": "module",
6
6
  "bin": {