Skip to content

Commit 9ef4da1

Browse files
authored
fix: transform object with numeric keys (#466)
* fix: transform object with numeric keys Flat loader remembers which sections were object and arrays on pull. It reconstructs original object on push. * chore: change naming for better clarity
1 parent 2d83bde commit 9ef4da1

5 files changed

Lines changed: 199 additions & 2 deletions

File tree

.changeset/few-trains-pay.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"lingo.dev": patch
3+
---
4+
5+
transform object with numeric keys

packages/cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"dev": "tsup --watch",
3838
"build": "tsc --noEmit && tsup",
3939
"test": "vitest run",
40+
"test:watch": "vitest",
4041
"clean": "rm -rf build"
4142
},
4243
"keywords": [],
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { describe, expect, it } from "vitest";
2+
import { flatten, unflatten } from "flat";
3+
import {
4+
buildDenormalizedKeysMap,
5+
denormalizeObjectKeys,
6+
mapDeormalizedKeys,
7+
normalizeObjectKeys,
8+
OBJECT_NUMERIC_KEY_PREFIX,
9+
} from "./flat";
10+
11+
describe("flat loader helper functions", () => {
12+
const inputObj = {
13+
messages: {
14+
"1": "a",
15+
"2": "b",
16+
},
17+
};
18+
const inputArray = {
19+
messages: ["a", "b", "c"],
20+
};
21+
22+
describe("denormalizeObjectKeys", () => {
23+
it("should denormalize object keys", () => {
24+
const output = denormalizeObjectKeys(inputObj);
25+
expect(output).toEqual({
26+
messages: {
27+
[`${OBJECT_NUMERIC_KEY_PREFIX}1`]: "a",
28+
[`${OBJECT_NUMERIC_KEY_PREFIX}2`]: "b",
29+
},
30+
});
31+
});
32+
33+
it("should preserve array", () => {
34+
const output = denormalizeObjectKeys(inputArray);
35+
expect(output).toEqual({
36+
messages: ["a", "b", "c"],
37+
});
38+
});
39+
});
40+
41+
describe("buildDenormalizedKeysMap", () => {
42+
it("should build normalized keys map", () => {
43+
const denormalized: Record<string, string> = flatten(denormalizeObjectKeys(inputObj), { delimiter: "/" });
44+
const output = buildDenormalizedKeysMap(denormalized);
45+
expect(output).toEqual({
46+
"messages/1": `messages/${OBJECT_NUMERIC_KEY_PREFIX}1`,
47+
"messages/2": `messages/${OBJECT_NUMERIC_KEY_PREFIX}2`,
48+
});
49+
});
50+
51+
it("should build keys map array", () => {
52+
const denormalized: Record<string, string> = flatten(denormalizeObjectKeys(inputArray), { delimiter: "/" });
53+
const output = buildDenormalizedKeysMap(denormalized);
54+
expect(output).toEqual({
55+
"messages/0": "messages/0",
56+
"messages/1": "messages/1",
57+
"messages/2": "messages/2",
58+
});
59+
});
60+
});
61+
62+
describe("normalizeObjectKeys", () => {
63+
it("should normalize denormalized object keys", () => {
64+
const output = normalizeObjectKeys(denormalizeObjectKeys(inputObj));
65+
expect(output).toEqual(inputObj);
66+
});
67+
68+
it("should process array keys", () => {
69+
const output = normalizeObjectKeys(denormalizeObjectKeys(inputArray));
70+
expect(output).toEqual(inputArray);
71+
});
72+
});
73+
74+
describe("mapDeormalizedKeys", () => {
75+
it("should map normalized keys", () => {
76+
const denormalized: Record<string, string> = flatten(denormalizeObjectKeys(inputObj), { delimiter: "/" });
77+
const keyMap = buildDenormalizedKeysMap(denormalized);
78+
const flattened: Record<string, string> = flatten(inputObj, { delimiter: "/" });
79+
const mapped = mapDeormalizedKeys(flattened, keyMap);
80+
expect(mapped).toEqual(denormalized);
81+
});
82+
83+
it("should map array", () => {
84+
const denormalized: Record<string, string> = flatten(denormalizeObjectKeys(inputArray), { delimiter: "/" });
85+
const keyMap = buildDenormalizedKeysMap(denormalized);
86+
const flattened: Record<string, string> = flatten(inputArray, { delimiter: "/" });
87+
const mapped = mapDeormalizedKeys(flattened, keyMap);
88+
expect(mapped).toEqual(denormalized);
89+
});
90+
});
91+
});
Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,88 @@
11
import { flatten, unflatten } from "flat";
22
import { ILoader } from "./_types";
33
import { createLoader } from "./_utils";
4+
import _ from "lodash";
5+
6+
export const OBJECT_NUMERIC_KEY_PREFIX = "__lingodotdev__obj__";
47

58
export default function createFlatLoader(): ILoader<Record<string, any>, Record<string, string>> {
9+
let denormalizedKeysMap: Record<string, string>;
10+
611
return createLoader({
712
pull: async (locale, input) => {
8-
return flatten(input || {}, {
13+
const denormalized = denormalizeObjectKeys(input || {});
14+
const flattened: Record<string, string> = flatten(denormalized, {
915
delimiter: "/",
1016
transformKey(key) {
1117
return encodeURIComponent(String(key));
1218
},
1319
});
20+
denormalizedKeysMap = buildDenormalizedKeysMap(flattened);
21+
const normalized = normalizeObjectKeys(flattened);
22+
return normalized;
1423
},
1524
push: async (locale, data) => {
16-
return unflatten(data || {}, {
25+
const denormalized = mapDeormalizedKeys(data, denormalizedKeysMap);
26+
const unflattened: Record<string, any> = unflatten(denormalized || {}, {
1727
delimiter: "/",
1828
transformKey(key) {
1929
return decodeURIComponent(String(key));
2030
},
2131
});
32+
const normalized = normalizeObjectKeys(unflattened);
33+
return normalized;
2234
},
2335
});
2436
}
37+
38+
export function buildDenormalizedKeysMap(obj: Record<string, string>) {
39+
return Object.keys(obj).reduce(
40+
(acc, key) => {
41+
const normalizedKey = `${key}`.replace(OBJECT_NUMERIC_KEY_PREFIX, "");
42+
acc[normalizedKey] = key;
43+
return acc;
44+
},
45+
{} as Record<string, string>,
46+
);
47+
}
48+
49+
export function mapDeormalizedKeys(obj: Record<string, any>, denormalizedKeysMap: Record<string, string>) {
50+
return Object.keys(obj).reduce(
51+
(acc, key) => {
52+
const denormalizedKey = denormalizedKeysMap[key];
53+
acc[denormalizedKey] = obj[key];
54+
return acc;
55+
},
56+
{} as Record<string, string>,
57+
);
58+
}
59+
60+
export function denormalizeObjectKeys(obj: Record<string, any>): Record<string, any> {
61+
if (_.isObject(obj) && !_.isArray(obj)) {
62+
return _.transform(
63+
obj,
64+
(result, value, key) => {
65+
const newKey = !isNaN(Number(key)) ? `${OBJECT_NUMERIC_KEY_PREFIX}${key}` : key;
66+
result[newKey] = _.isObject(value) ? denormalizeObjectKeys(value) : value;
67+
},
68+
{} as Record<string, any>,
69+
);
70+
} else {
71+
return obj;
72+
}
73+
}
74+
75+
export function normalizeObjectKeys(obj: Record<string, any>): Record<string, any> {
76+
if (_.isObject(obj) && !_.isArray(obj)) {
77+
return _.transform(
78+
obj,
79+
(result, value, key) => {
80+
const newKey = `${key}`.replace(OBJECT_NUMERIC_KEY_PREFIX, "");
81+
result[newKey] = _.isObject(value) ? normalizeObjectKeys(value) : value;
82+
},
83+
{} as Record<string, any>,
84+
);
85+
} else {
86+
return obj;
87+
}
88+
}

packages/cli/src/cli/loaders/index.spec.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,42 @@ describe("bucket loaders", () => {
332332

333333
expect(fs.writeFile).toHaveBeenCalledWith("i18n/es.json", expectedOutput, { encoding: "utf-8", flag: "w" });
334334
});
335+
336+
it("should save json data with numeric keys", async () => {
337+
setupFileMocks();
338+
339+
const input = { messages: { "1": "foo", "2": "bar", "3": "bar" } };
340+
const payload = { "messages/1": "foo", "messages/2": "bar", "messages/3": "bar" };
341+
const expectedOutput = JSON.stringify(input, null, 2);
342+
343+
mockFileOperations(JSON.stringify(input));
344+
345+
const jsonLoader = createBucketLoader("json", "i18n/[locale].json");
346+
jsonLoader.setDefaultLocale("en");
347+
await jsonLoader.pull("en");
348+
349+
await jsonLoader.push("es", payload);
350+
351+
expect(fs.writeFile).toHaveBeenCalledWith("i18n/es.json", expectedOutput, { encoding: "utf-8", flag: "w" });
352+
});
353+
354+
it("should save json data with array", async () => {
355+
setupFileMocks();
356+
357+
const input = { messages: ["foo", "bar"] };
358+
const payload = { "messages/0": "foo", "messages/1": "bar" };
359+
const expectedOutput = `{\n "messages\": [\"foo\", \"bar\"]\n}`;
360+
361+
mockFileOperations(JSON.stringify(input));
362+
363+
const jsonLoader = createBucketLoader("json", "i18n/[locale].json");
364+
jsonLoader.setDefaultLocale("en");
365+
await jsonLoader.pull("en");
366+
367+
await jsonLoader.push("es", payload);
368+
369+
expect(fs.writeFile).toHaveBeenCalledWith("i18n/es.json", expectedOutput, { encoding: "utf-8", flag: "w" });
370+
});
335371
});
336372

337373
describe("markdown bucket loader", () => {

0 commit comments

Comments
 (0)