Skip to content

Commit 7245042

Browse files
committed
Fix handling of keys containing dots (v1.0.3)
- Implement proper escaping for object keys that contain dots - Use null byte as path separator to avoid conflicts with dots in keys - Fix issue with files like society-flow that have keys like 'auth.login' - Update all tests to match new path separator format - Preserve exact JSON structure without interpreting dots as nested paths This fixes the 'Cannot create property on string' error when processing JSON files with ambiguous key names.
1 parent af79a48 commit 7245042

File tree

6 files changed

+114
-29
lines changed

6 files changed

+114
-29
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "translator-gemini",
3-
"version": "1.0.2",
3+
"version": "1.0.3",
44
"description": "Fast and efficient JSON i18n translator powered by Google Gemini API with incremental caching support",
55
"main": "dist/index.js",
66
"bin": {

src/helpers.ts

Lines changed: 46 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -25,17 +25,27 @@ export function getDefaultCacheFilePath(): string {
2525
return path.join(getCacheDirectory(), 'translation-cache.json');
2626
}
2727

28+
// Use a unique separator that won't appear in normal keys
29+
const PATH_SEPARATOR = '\x00';
30+
const DOT_ESCAPE = '\x01';
31+
2832
export function flattenObjectWithPaths(obj: JsonValue, currentPath: string = '', result: Map<string, string> = new Map()): Map<string, string> {
2933
if (typeof obj === 'string') {
3034
if (/[a-zA-Z]/.test(obj) && !/^{{.*}}$/.test(obj)) {
3135
result.set(currentPath, obj);
3236
}
3337
} else if (Array.isArray(obj)) {
34-
obj.forEach((item, index) => flattenObjectWithPaths(item, `${currentPath}[${index}]`, result));
38+
obj.forEach((item, index) => {
39+
const newPath = currentPath ? `${currentPath}${PATH_SEPARATOR}[${index}]` : `[${index}]`;
40+
flattenObjectWithPaths(item, newPath, result);
41+
});
3542
} else if (typeof obj === 'object' && obj !== null) {
3643
for (const key in obj) {
3744
if(Object.prototype.hasOwnProperty.call(obj, key)) {
38-
flattenObjectWithPaths(obj[key], currentPath ? `${currentPath}.${key}` : key, result);
45+
// Escape dots in the key to preserve them
46+
const escapedKey = key.replace(/\./g, DOT_ESCAPE);
47+
const newPath = currentPath ? `${currentPath}${PATH_SEPARATOR}${escapedKey}` : escapedKey;
48+
flattenObjectWithPaths(obj[key], newPath, result);
3949
}
4050
}
4151
}
@@ -44,19 +54,44 @@ export function flattenObjectWithPaths(obj: JsonValue, currentPath: string = '',
4454

4555
export function unflattenObject(flatMap: Map<string, string>): JsonObject {
4656
const result: JsonObject = {};
57+
4758
for (const [path, value] of flatMap.entries()) {
48-
const keys = path.match(/[^.[\]]+/g) || [];
49-
keys.reduce((acc: any, key: string, index: number) => {
50-
if (index === keys.length - 1) {
51-
acc[key] = value;
59+
const parts = path.split(PATH_SEPARATOR);
60+
61+
let current: any = result;
62+
for (let i = 0; i < parts.length; i++) {
63+
const part = parts[i];
64+
const isLast = i === parts.length - 1;
65+
66+
if (part.startsWith('[') && part.endsWith(']')) {
67+
// Array index
68+
const index = parseInt(part.slice(1, -1));
69+
if (isLast) {
70+
current[index] = value;
71+
} else {
72+
if (!current[index]) {
73+
// Look ahead to determine if next is array or object
74+
const nextPart = parts[i + 1];
75+
current[index] = nextPart.startsWith('[') ? [] : {};
76+
}
77+
current = current[index];
78+
}
5279
} else {
53-
const nextKeyIsNumeric = /^\d+$/.test(keys[index + 1]);
54-
if (!acc[key]) {
55-
acc[key] = nextKeyIsNumeric ? [] : {};
80+
// Regular key - unescape dots
81+
const key = part.replace(new RegExp(DOT_ESCAPE, 'g'), '.');
82+
if (isLast) {
83+
current[key] = value;
84+
} else {
85+
if (!current[key]) {
86+
// Look ahead to determine if next is array or object
87+
const nextPart = parts[i + 1];
88+
current[key] = nextPart.startsWith('[') ? [] : {};
89+
}
90+
current = current[key];
5691
}
5792
}
58-
return acc[key];
59-
}, result);
93+
}
6094
}
95+
6196
return result;
6297
}

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ async function main() {
176176

177177
const program = new Command();
178178
program
179-
.version('1.0.2')
179+
.version('1.0.3')
180180
.description('A CLI tool to translate and synchronize JSON i18n files.')
181181
.argument('<inputFile>', 'Path to the source English JSON file.')
182182
.requiredOption('-l, --lang <langCode>', 'The target language code.')

tests/unit/cli.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ describe('CLI Argument Parsing', () => {
77
beforeEach(() => {
88
program = new Command();
99
program
10-
.version('1.0.2')
10+
.version('1.0.3')
1111
.description('A CLI tool to translate and synchronize JSON i18n files.')
1212
.argument('<inputFile>', 'Path to the source English JSON file.')
1313
.requiredOption('-l, --lang <langCode>', 'The target language code.')
@@ -93,7 +93,7 @@ describe('CLI Argument Parsing', () => {
9393
const args = ['node', 'translator-gemini', '--version'];
9494

9595
// exitOverride causes it to throw with the version string
96-
expect(() => program.parse(args)).toThrow('1.0.2');
96+
expect(() => program.parse(args)).toThrow('1.0.3');
9797

9898
mockLog.mockRestore();
9999
});

tests/unit/dots-escape.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { flattenObjectWithPaths, unflattenObject } from '../../src/helpers';
2+
3+
describe('Dot escaping in keys', () => {
4+
it('should preserve dots in original keys', () => {
5+
const obj = {
6+
"auth.login": "Login",
7+
"auth.logout": "Logout",
8+
"menu": {
9+
"home": "Home",
10+
"auth.login": "Sign In"
11+
}
12+
};
13+
14+
const flattened = flattenObjectWithPaths(obj);
15+
const unflattened = unflattenObject(flattened);
16+
17+
expect(unflattened).toEqual(obj);
18+
});
19+
20+
it('should handle the society-flow menu structure', () => {
21+
const obj = {
22+
"menu": {
23+
"legal": "Legal",
24+
"legal.terms-of-service": "Terms of Service",
25+
"legal.privacy-policy": "Privacy Policy"
26+
}
27+
};
28+
29+
const flattened = flattenObjectWithPaths(obj);
30+
const unflattened = unflattenObject(flattened);
31+
32+
expect(unflattened).toEqual(obj);
33+
expect((unflattened.menu as any).legal).toBe("Legal");
34+
expect((unflattened.menu as any)["legal.terms-of-service"]).toBe("Terms of Service");
35+
});
36+
37+
it('should handle arrays correctly', () => {
38+
const obj = {
39+
"items": ["one", "two", "three"],
40+
"nested": {
41+
"list": ["a", "b"]
42+
}
43+
};
44+
45+
const flattened = flattenObjectWithPaths(obj);
46+
const unflattened = unflattenObject(flattened);
47+
48+
expect(unflattened).toEqual(obj);
49+
});
50+
});

tests/unit/helpers.test.ts

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,8 @@ describe('Helper Functions', () => {
109109
};
110110
const result = flattenObjectWithPaths(obj);
111111

112-
expect(result.get('user.name')).toBe('John');
113-
expect(result.get('user.address.city')).toBe('New York');
112+
expect(result.get('user\x00name')).toBe('John');
113+
expect(result.get('user\x00address\x00city')).toBe('New York');
114114
expect(result.size).toBe(2);
115115
});
116116

@@ -120,9 +120,9 @@ describe('Helper Functions', () => {
120120
};
121121
const result = flattenObjectWithPaths(obj);
122122

123-
expect(result.get('items[0]')).toBe('apple');
124-
expect(result.get('items[1]')).toBe('banana');
125-
expect(result.get('items[2]')).toBe('cherry');
123+
expect(result.get('items\x00[0]')).toBe('apple');
124+
expect(result.get('items\x00[1]')).toBe('banana');
125+
expect(result.get('items\x00[2]')).toBe('cherry');
126126
expect(result.size).toBe(3);
127127
});
128128

@@ -180,8 +180,8 @@ describe('Helper Functions', () => {
180180

181181
it('should unflatten nested paths', () => {
182182
const flatMap = new Map([
183-
['user.name', 'John'],
184-
['user.address.city', 'New York']
183+
['user\x00name', 'John'],
184+
['user\x00address\x00city', 'New York']
185185
]);
186186
const result = unflattenObject(flatMap);
187187

@@ -197,9 +197,9 @@ describe('Helper Functions', () => {
197197

198198
it('should unflatten array paths', () => {
199199
const flatMap = new Map([
200-
['items[0]', 'apple'],
201-
['items[1]', 'banana'],
202-
['items[2]', 'cherry']
200+
['items\x00[0]', 'apple'],
201+
['items\x00[1]', 'banana'],
202+
['items\x00[2]', 'cherry']
203203
]);
204204
const result = unflattenObject(flatMap);
205205

@@ -210,10 +210,10 @@ describe('Helper Functions', () => {
210210

211211
it('should handle mixed nested structures', () => {
212212
const flatMap = new Map([
213-
['user.name', 'John'],
214-
['user.tags[0]', 'admin'],
215-
['user.tags[1]', 'developer'],
216-
['settings.theme', 'dark']
213+
['user\x00name', 'John'],
214+
['user\x00tags\x00[0]', 'admin'],
215+
['user\x00tags\x00[1]', 'developer'],
216+
['settings\x00theme', 'dark']
217217
]);
218218
const result = unflattenObject(flatMap);
219219

0 commit comments

Comments
 (0)