UNPKG

i18n-ai-translate

Version:

Use LLMs to translate your i18n JSON to any language.

509 lines 24.6 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const fs_1 = __importDefault(require("fs")); const os_1 = __importDefault(require("os")); const path_1 = __importDefault(require("path")); const utils = __importStar(require("../utils")); const translate_1 = require("../translate"); const translate_directory_1 = require("../translate_directory"); const translate_file_1 = require("../translate_file"); const engine_1 = __importDefault(require("../enums/engine")); const prompt_mode_1 = __importDefault(require("../enums/prompt_mode")); const rate_limiter_1 = __importDefault(require("../rate_limiter")); const fr = (v) => `${v}_fr`; const es = (v) => `${v}_es`; const mkCaseDir = () => fs_1.default.mkdtempSync(path_1.default.join(os_1.default.tmpdir(), "i18n-case-")); describe.each(Object.values(prompt_mode_1.default))("translate (promptMode=%s)", (promptMode) => { it("translates a flat JSON object", async () => { const result = await (0, translate_1.translate)({ engine: engine_1.default.ChatGPT, inputJSON: { hello: "Hello" }, inputLanguage: "en", model: "gpt-4o", outputLanguage: "fr", promptMode, rateLimitMs: 0, }); expect(result).toEqual({ hello: fr("Hello") }); }); it("translates a nested JSON object", async () => { const result = await (0, translate_1.translate)({ engine: engine_1.default.ChatGPT, inputJSON: { greeting: { text: "Hello" } }, inputLanguage: "en", model: "gpt-4o", outputLanguage: "fr", promptMode, rateLimitMs: 0, }); expect(result).toEqual({ greeting: { text: fr("Hello") } }); }); it("de-duplicates identical strings and includes them all in output", async () => { const input = { a: "Hello", b: "Hello", c: { d: "Hello" } }; const result = await (0, translate_1.translate)({ engine: engine_1.default.ChatGPT, inputJSON: input, inputLanguage: "en", model: "gpt-4o", outputLanguage: "fr", promptMode, rateLimitMs: 0, }); expect(result).toEqual({ a: fr("Hello"), b: fr("Hello"), c: { d: fr("Hello") } }); }); }); describe.each(Object.values(prompt_mode_1.default))("translateDiff (promptMode=%s)", (promptMode) => { it("only touches added / changed keys", async () => { const before = { greeting: "Hello", unchanged: "Stay" }; const after = { added: "New", greeting: "Hi" }; const out = await (0, translate_1.translateDiff)({ engine: engine_1.default.ChatGPT, inputJSONAfter: after, inputJSONBefore: before, inputLanguage: "en", model: "gpt-4o", promptMode, rateLimitMs: 0, toUpdateJSONs: { fr: { greeting: "Bonjour", unchanged: "Rester" }, }, }); const frOut = out.fr; expect(frOut).toEqual({ added: fr("New"), greeting: fr("Hi") }); }); it("only touches added / changed keys with nested objects", async () => { const before = { greeting: { text: "Hello" }, unchanged: "Stay" }; const after = { added: "New", greeting: { text: "Hi" } }; const out = await (0, translate_1.translateDiff)({ engine: engine_1.default.ChatGPT, inputJSONAfter: after, inputJSONBefore: before, inputLanguage: "en", model: "gpt-4o", promptMode, rateLimitMs: 0, toUpdateJSONs: { fr: { greeting: { text: "Bonjour" }, unchanged: "Rester" }, }, }); const frOut = out.fr; expect(frOut).toEqual({ added: fr("New"), greeting: { text: fr("Hi") }, }); }); it("prunes removed keys", async () => { const before = { greeting: "Hello", unused: "Unused" }; const after = { greeting: "Hi" }; const out = await (0, translate_1.translateDiff)({ engine: engine_1.default.ChatGPT, inputJSONAfter: after, inputJSONBefore: before, inputLanguage: "en", model: "gpt-4o", promptMode, rateLimitMs: 0, toUpdateJSONs: { fr: { greeting: "Bonjour", unused: "Obsolete" }, }, }); const frOut = out.fr; expect(frOut).toEqual({ greeting: fr("Hi") }); // 'unused' pruned }); it("handles empty input gracefully", async () => { const out = await (0, translate_1.translateDiff)({ engine: engine_1.default.ChatGPT, inputJSONAfter: {}, inputJSONBefore: {}, inputLanguage: "en", model: "gpt-4o", promptMode, rateLimitMs: 0, toUpdateJSONs: { fr: {} }, }); expect(out).toEqual({ fr: {} }); }); it("handles multiple languages", async () => { const before = { greeting: "Hello" }; const after = { added: "New", greeting: "Hi" }; const out = await (0, translate_1.translateDiff)({ engine: engine_1.default.ChatGPT, inputJSONAfter: after, inputJSONBefore: before, inputLanguage: "en", model: "gpt-4o", promptMode, rateLimitMs: 0, toUpdateJSONs: { es: { greeting: "Hola" }, fr: { greeting: "Bonjour" }, }, }); expect(out.fr).toEqual({ added: fr("New"), greeting: fr("Hi") }); expect(out.es).toEqual({ added: es("New"), greeting: es("Hi") }); }); }); describe.each(Object.values(prompt_mode_1.default))("translateFile (promptMode=%s)", (promptMode) => { it("creates a sibling file with translated JSON", async () => { const dir = mkCaseDir(); const inputPath = path_1.default.join(dir, "en.json"); const outputPath = path_1.default.join(dir, "fr.json"); fs_1.default.writeFileSync(inputPath, JSON.stringify({ cat: "Cat" })); await (0, translate_file_1.translateFile)({ engine: engine_1.default.ChatGPT, forceLanguageName: "fr", inputFilePath: inputPath, inputLanguage: "en", model: "gpt-4o", outputFilePath: outputPath, promptMode, rateLimitMs: 0, }); const translated = JSON.parse(fs_1.default.readFileSync(outputPath, "utf-8")); expect(translated).toEqual({ cat: fr("Cat") }); }); it("handles empty input file gracefully", async () => { const dir = mkCaseDir(); const inputPath = path_1.default.join(dir, "en.json"); const outputPath = path_1.default.join(dir, "fr.json"); fs_1.default.writeFileSync(inputPath, JSON.stringify({})); await (0, translate_file_1.translateFile)({ engine: engine_1.default.ChatGPT, forceLanguageName: "fr", inputFilePath: inputPath, inputLanguage: "en", model: "gpt-4o", outputFilePath: outputPath, promptMode, rateLimitMs: 0, }); const translated = JSON.parse(fs_1.default.readFileSync(outputPath, "utf-8")); expect(translated).toEqual({}); }); }); describe.each(Object.values(prompt_mode_1.default))("translateFileDiff (promptMode=%s)", (promptMode) => { it("updates only the changed keys in-place", async () => { const dir = mkCaseDir(); const beforePath = path_1.default.join(dir, "before_en.json"); const afterPath = path_1.default.join(dir, "after_en.json"); const frPath = path_1.default.join(dir, "fr.json"); fs_1.default.writeFileSync(beforePath, JSON.stringify({ key: "Old" })); fs_1.default.writeFileSync(afterPath, JSON.stringify({ added: "Yes", key: "New" })); fs_1.default.writeFileSync(frPath, JSON.stringify({ key: "Ancien" })); await (0, translate_file_1.translateFileDiff)({ engine: engine_1.default.ChatGPT, inputAfterFileOrPath: afterPath, inputBeforeFileOrPath: beforePath, inputLanguageCode: "en", model: "gpt-4o", promptMode, rateLimitMs: 0, }); const out = JSON.parse(fs_1.default.readFileSync(frPath, "utf-8")); expect(out).toEqual({ added: fr("Yes"), key: fr("New") }); }); it("prunes removed keys", async () => { const dir = mkCaseDir(); const beforePath = path_1.default.join(dir, "before_en.json"); const afterPath = path_1.default.join(dir, "after_en.json"); const frPath = path_1.default.join(dir, "fr.json"); fs_1.default.writeFileSync(beforePath, JSON.stringify({ key: "Old", unused: "Unused" })); fs_1.default.writeFileSync(afterPath, JSON.stringify({ key: "New" })); fs_1.default.writeFileSync(frPath, JSON.stringify({ key: "Ancien", unused: "Obsolete" })); await (0, translate_file_1.translateFileDiff)({ engine: engine_1.default.ChatGPT, inputAfterFileOrPath: afterPath, inputBeforeFileOrPath: beforePath, inputLanguageCode: "en", model: "gpt-4o", promptMode, rateLimitMs: 0, }); const out = JSON.parse(fs_1.default.readFileSync(frPath, "utf-8")); expect(out).toEqual({ key: fr("New") }); // 'unused' pruned }); it("handles multiple languages", async () => { const dir = mkCaseDir(); const beforePath = path_1.default.join(dir, "before_en.json"); const afterPath = path_1.default.join(dir, "after_en.json"); const frPath = path_1.default.join(dir, "fr.json"); const esPath = path_1.default.join(dir, "es.json"); fs_1.default.writeFileSync(beforePath, JSON.stringify({ key: "Old" })); fs_1.default.writeFileSync(afterPath, JSON.stringify({ added: "Yes", key: "New" })); fs_1.default.writeFileSync(frPath, JSON.stringify({ key: "Ancien" })); fs_1.default.writeFileSync(esPath, JSON.stringify({ key: "Viejo" })); await (0, translate_file_1.translateFileDiff)({ engine: engine_1.default.ChatGPT, inputAfterFileOrPath: afterPath, inputBeforeFileOrPath: beforePath, inputLanguageCode: "en", model: "gpt-4o", promptMode, rateLimitMs: 0, }); const frOut = JSON.parse(fs_1.default.readFileSync(frPath, "utf-8")); const esOut = JSON.parse(fs_1.default.readFileSync(esPath, "utf-8")); expect(frOut).toEqual({ added: fr("Yes"), key: fr("New") }); expect(esOut).toEqual({ added: es("Yes"), key: es("New") }); }); }); describe.each(Object.values(prompt_mode_1.default))("translateDirectory (promptMode=%s)", (promptMode) => { it("replicates the directory hierarchy for the target language", async () => { const dir = mkCaseDir(); const enDir = path_1.default.join(dir, "en"); fs_1.default.mkdirSync(enDir, { recursive: true }); const enFile = path_1.default.join(enDir, "app.json"); fs_1.default.writeFileSync(enFile, JSON.stringify({ welcome: "Welcome" })); await (0, translate_directory_1.translateDirectory)({ baseDirectory: dir, engine: engine_1.default.ChatGPT, inputLanguage: "en", model: "gpt-4o", outputLanguage: "fr", promptMode, rateLimitMs: 0, }); const frFile = path_1.default.join(dir, "fr", "app.json"); const frJSON = JSON.parse(fs_1.default.readFileSync(frFile, "utf-8")); expect(frJSON).toEqual({ welcome: fr("Welcome") }); }); it("handles nested directories", async () => { const dir = mkCaseDir(); const enDir = path_1.default.join(dir, "en", "nested"); fs_1.default.mkdirSync(enDir, { recursive: true }); const enFile = path_1.default.join(enDir, "app.json"); fs_1.default.writeFileSync(enFile, JSON.stringify({ greeting: "Hello" })); await (0, translate_directory_1.translateDirectory)({ baseDirectory: dir, engine: engine_1.default.ChatGPT, inputLanguage: "en", model: "gpt-4o", outputLanguage: "fr", promptMode, rateLimitMs: 0, }); const frFile = path_1.default.join(dir, "fr", "nested", "app.json"); const frJSON = JSON.parse(fs_1.default.readFileSync(frFile, "utf-8")); expect(frJSON).toEqual({ greeting: fr("Hello") }); }); it("handles multiple files with various amounts of nesting", async () => { const dir = mkCaseDir(); const enDir = path_1.default.join(dir, "en"); fs_1.default.mkdirSync(enDir, { recursive: true }); // ── layout ──────────────────────────────────────────── // base/en/app.json { welcome: "Welcome" } // base/en/nested/app.json { greeting: "Hello" } const enFile1 = path_1.default.join(enDir, "app.json"); const enFile2 = path_1.default.join(enDir, "nested", "app.json"); fs_1.default.mkdirSync(path_1.default.dirname(enFile2), { recursive: true }); fs_1.default.writeFileSync(enFile1, JSON.stringify({ welcome: "Welcome" })); fs_1.default.writeFileSync(enFile2, JSON.stringify({ greeting: "Hello" })); await (0, translate_directory_1.translateDirectory)({ baseDirectory: dir, engine: engine_1.default.ChatGPT, inputLanguage: "en", model: "gpt-4o", outputLanguage: "fr", promptMode, rateLimitMs: 0, }); const frFile1 = path_1.default.join(dir, "fr", "app.json"); const frFile2 = path_1.default.join(dir, "fr", "nested", "app.json"); const frJSON1 = JSON.parse(fs_1.default.readFileSync(frFile1, "utf-8")); const frJSON2 = JSON.parse(fs_1.default.readFileSync(frFile2, "utf-8")); expect(frJSON1).toEqual({ welcome: fr("Welcome") }); expect(frJSON2).toEqual({ greeting: fr("Hello") }); }); }); describe.each(Object.values(prompt_mode_1.default))("translateDirectoryDiff (promptMode=%s)", (promptMode) => { it("writes translations for changed keys and prunes removed keys", async () => { const dir = mkCaseDir(); // ── layout ──────────────────────────────────────────── // base/en_before/app.json { hello: "Hello", unused: "Unused" } // base/en_after /app.json { hello: "Hi", bye: "Bye" } // base/fr /app.json { hello: "Bonjour", unused: "Obso" } const enBefore = path_1.default.join(dir, "en_before"); const enAfter = path_1.default.join(dir, "en_after"); const frDir = path_1.default.join(dir, "fr"); fs_1.default.mkdirSync(enBefore, { recursive: true }); fs_1.default.mkdirSync(enAfter, { recursive: true }); fs_1.default.mkdirSync(frDir, { recursive: true }); const beforeFile = path_1.default.join(enBefore, "app.json"); const afterFile = path_1.default.join(enAfter, "app.json"); const frFile = path_1.default.join(frDir, "app.json"); fs_1.default.writeFileSync(beforeFile, JSON.stringify({ hello: "Hello", unused: "Unused" })); fs_1.default.writeFileSync(afterFile, JSON.stringify({ bye: "Bye", hello: "Hi" })); fs_1.default.writeFileSync(frFile, JSON.stringify({ hello: "Bonjour", unused: "Obso" })); await (0, translate_directory_1.translateDirectoryDiff)({ baseDirectory: dir, engine: engine_1.default.ChatGPT, inputFolderNameAfter: "en_after", inputFolderNameBefore: "en_before", inputLanguageCode: "en", model: "gpt-4o", promptMode, rateLimitMs: 0, verbose: true, }); const updated = JSON.parse(fs_1.default.readFileSync(frFile, "utf-8")); expect(updated).toEqual({ bye: fr("Bye"), hello: fr("Hi") }); // 'unused' pruned }); it("handles nested directories and multiple files", async () => { const dir = mkCaseDir(); // ── layout ──────────────────────────────────────────── // base/en_before/app.json { welcome: "Welcome" } // base/en_after /app.json { greeting: "Hello" } // base/en_after /nested/app.json { farewell: "Goodbye" } // base/fr /app.json { welcome: "Bienvenue" } const enBefore = path_1.default.join(dir, "en_before"); const enAfter = path_1.default.join(dir, "en_after"); const frDir = path_1.default.join(dir, "fr"); fs_1.default.mkdirSync(enBefore, { recursive: true }); fs_1.default.mkdirSync(enAfter, { recursive: true }); fs_1.default.mkdirSync(frDir, { recursive: true }); fs_1.default.mkdirSync(path_1.default.join(enAfter, "nested"), { recursive: true }); const beforeFile = path_1.default.join(enBefore, "app.json"); const afterFile = path_1.default.join(enAfter, "app.json"); const afterNestedFile = path_1.default.join(enAfter, "nested", "app.json"); const frFile = path_1.default.join(frDir, "app.json"); const frNestedFile = path_1.default.join(frDir, "nested", "app.json"); fs_1.default.writeFileSync(beforeFile, JSON.stringify({ welcome: "Welcome" })); fs_1.default.writeFileSync(afterFile, JSON.stringify({ greeting: "Hello" })); fs_1.default.writeFileSync(afterNestedFile, JSON.stringify({ farewell: "Goodbye" })); fs_1.default.writeFileSync(frFile, JSON.stringify({ welcome: "Bienvenue" })); await (0, translate_directory_1.translateDirectoryDiff)({ baseDirectory: dir, engine: engine_1.default.ChatGPT, inputFolderNameAfter: "en_after", inputFolderNameBefore: "en_before", inputLanguageCode: "en", model: "gpt-4o", promptMode, rateLimitMs: 0, verbose: true, }); const updated = JSON.parse(fs_1.default.readFileSync(frFile, "utf-8")); const updatedNested = JSON.parse(fs_1.default.readFileSync(frNestedFile, "utf-8")); expect(updated).toEqual({ greeting: fr("Hello") }); expect(updatedNested).toEqual({ farewell: fr("Goodbye") }); }); it("handles multiple languages in the directory", async () => { const dir = mkCaseDir(); // ── layout ──────────────────────────────────────────── // base/en_before/app.json { hello: "Hello" } // base/en_after /app.json { hello: "Hi" } // base/fr /app.json { hello: "Bonjour" } // base/es /app.json { hello: "Hola" } const enBefore = path_1.default.join(dir, "en_before"); const enAfter = path_1.default.join(dir, "en_after"); const frDir = path_1.default.join(dir, "fr"); const esDir = path_1.default.join(dir, "es"); fs_1.default.mkdirSync(enBefore, { recursive: true }); fs_1.default.mkdirSync(enAfter, { recursive: true }); fs_1.default.mkdirSync(frDir, { recursive: true }); fs_1.default.mkdirSync(esDir, { recursive: true }); const beforeFile = path_1.default.join(enBefore, "app.json"); const afterFile = path_1.default.join(enAfter, "app.json"); const frFile = path_1.default.join(frDir, "app.json"); const esFile = path_1.default.join(esDir, "app.json"); fs_1.default.writeFileSync(beforeFile, JSON.stringify({ hello: "Hello" })); fs_1.default.writeFileSync(afterFile, JSON.stringify({ hello: "Hi" })); fs_1.default.writeFileSync(frFile, JSON.stringify({ hello: "Bonjour" })); fs_1.default.writeFileSync(esFile, JSON.stringify({ hello: "Hola" })); await (0, translate_directory_1.translateDirectoryDiff)({ baseDirectory: dir, engine: engine_1.default.ChatGPT, inputFolderNameAfter: "en_after", inputFolderNameBefore: "en_before", inputLanguageCode: "en", model: "gpt-4o", promptMode, rateLimitMs: 0, verbose: true, }); const updatedFr = JSON.parse(fs_1.default.readFileSync(frFile, "utf-8")); const updatedEs = JSON.parse(fs_1.default.readFileSync(esFile, "utf-8")); expect(updatedFr).toEqual({ hello: fr("Hi") }); expect(updatedEs).toEqual({ hello: es("Hi") }); }); }); describe("RateLimiter", () => { const mockedDelay = utils.delay; const delayBetweenCallsMs = 500; afterEach(() => { jest.restoreAllMocks(); jest.clearAllMocks(); }); it("returns immediately when no API call has been made", async () => { const rl = new rate_limiter_1.default(delayBetweenCallsMs, true); // Date.now() is irrelevant here, but stub anyway for determinism. jest.spyOn(Date, "now").mockReturnValue(1_000); await rl.wait(); expect(mockedDelay).not.toHaveBeenCalled(); }); it("returns immediately when enough time has already passed since the last call", async () => { const now = 10_000; jest.spyOn(Date, "now").mockReturnValue(now); const rl = new rate_limiter_1.default(delayBetweenCallsMs, true); rl.lastAPICall = now - delayBetweenCallsMs - 50; // already past the window await rl.wait(); expect(mockedDelay).not.toHaveBeenCalled(); }); it("waits the correct time when called too soon and verboseLogging = true", async () => { const now = 20_000; const timeRemaining = 125; // ms still to wait // Stub Date.now() for this test jest.spyOn(Date, "now").mockReturnValue(now); const rl = new rate_limiter_1.default(delayBetweenCallsMs, true); rl.lastAPICall = now - (delayBetweenCallsMs - timeRemaining); await rl.wait(); expect(mockedDelay).toHaveBeenCalledTimes(1); expect(mockedDelay).toHaveBeenCalledWith(timeRemaining); }); it("does not log when verboseLogging = false", async () => { const now = 30_000; const timeRemaining = 200; jest.spyOn(Date, "now").mockReturnValue(now); const rl = new rate_limiter_1.default(delayBetweenCallsMs, false); rl.lastAPICall = now - (delayBetweenCallsMs - timeRemaining); await rl.wait(); expect(mockedDelay).toHaveBeenCalledWith(timeRemaining); }); }); //# sourceMappingURL=translate.spec.js.map