From 6b221b722ff92737249f58df101a1183bea1dd23 Mon Sep 17 00:00:00 2001 From: Pontus Abrahamsson Date: Wed, 25 Dec 2024 22:35:52 +0100 Subject: [PATCH 1/5] Add support for xCode --- bun.lockb | Bin 165096 -> 165432 bytes .../Example/en.lproj/Localizable.strings | 20 ++ .../Example/es.lproj/Localizable.strings | 16 ++ examples/xcode-strings/languine.config.mjs | 16 ++ .../Example/Localizable.xcstrings | 83 +++++++ examples/xcode-xcstrings/languine.config.mjs | 16 ++ packages/cli/package.json | 5 +- packages/cli/src/commands/init.ts | 110 ++++++--- packages/cli/src/translators/index.ts | 6 + packages/cli/src/translators/xcode-strings.ts | 136 +++++++++++ .../cli/src/translators/xcode-xcstrings.ts | 231 ++++++++++++++++++ 11 files changed, 599 insertions(+), 40 deletions(-) create mode 100644 examples/xcode-strings/Example/en.lproj/Localizable.strings create mode 100644 examples/xcode-strings/Example/es.lproj/Localizable.strings create mode 100644 examples/xcode-strings/languine.config.mjs create mode 100644 examples/xcode-xcstrings/Example/Localizable.xcstrings create mode 100644 examples/xcode-xcstrings/languine.config.mjs create mode 100644 packages/cli/src/translators/xcode-strings.ts create mode 100644 packages/cli/src/translators/xcode-xcstrings.ts diff --git a/bun.lockb b/bun.lockb index 558d2d475dbae3243e2e3e6816d886ba237e2b28..81b9ee25f58502a9cdbe62031cc3e05f738a60b1 100755 GIT binary patch delta 21508 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 delta 21133 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 diff --git a/examples/xcode-strings/Example/en.lproj/Localizable.strings b/examples/xcode-strings/Example/en.lproj/Localizable.strings new file mode 100644 index 0000000..aa0c9d7 --- /dev/null +++ b/examples/xcode-strings/Example/en.lproj/Localizable.strings @@ -0,0 +1,20 @@ +"welcome_message" = "Welcome to Weather App!"; +"temperature_format" = "%1$.1f°%2$@"; +"wind_speed" = "Wind: %1$.1f %2$@"; +"forecast_daily" = "Daily forecast for %@"; +"notification_body" = "Storm alert for %1$@ region\nWind speeds up to %2$d mph expected"; + +"battery_empty" = "Battery depleted"; +"battery_low" = "Battery at %d%% - please charge soon"; +"battery_full" = "Battery fully charged"; + +"photo_count_zero" = "No photos"; +"photo_count_one" = "%d photo"; +"photo_count_other" = "%d photos"; + +"time_remaining_now" = "Download complete!"; +"time_remaining_minutes" = "%d minutes remaining"; +"time_remaining_hours" = "About %d hours remaining"; + +"share_message" = "Check out this photo I took with %1$@!\nLocation: %2$@\nTime: %3$@"; +"rich_notification" = "New message from %@:\n%@\nView details"; \ No newline at end of file diff --git a/examples/xcode-strings/Example/es.lproj/Localizable.strings b/examples/xcode-strings/Example/es.lproj/Localizable.strings new file mode 100644 index 0000000..3af230b --- /dev/null +++ b/examples/xcode-strings/Example/es.lproj/Localizable.strings @@ -0,0 +1,16 @@ +"welcome_message" = "¡Bienvenido a la aplicación del tiempo!"; +"temperature_format" = "%1$.1f°%2$@"; +"wind_speed" = "Viento: %1$.1f %2$@"; +"forecast_daily" = "Pronóstico diario para %@"; +"notification_body" = "Alerta de tormenta para la región de %1$@\nSe esperan velocidades de viento de hasta %2$d mph"; +"battery_empty" = "Batería agotada"; +"battery_low" = "Batería al %d%% - por favor, carga pronto"; +"battery_full" = "Batería completamente cargada"; +"photo_count_zero" = "No hay fotos"; +"photo_count_one" = "%d foto"; +"photo_count_other" = "%d fotos"; +"time_remaining_now" = "¡Descarga completa!"; +"time_remaining_minutes" = "%d minutos restantes"; +"time_remaining_hours" = "Aproximadamente %d horas restantes"; +"share_message" = "Mira esta foto que tomé con %1$@!\nUbicación: %2$@\nHora: %3$@"; +"rich_notification" = "Nuevo mensaje de %@:\n%@\nVer detalles"; \ No newline at end of file diff --git a/examples/xcode-strings/languine.config.mjs b/examples/xcode-strings/languine.config.mjs new file mode 100644 index 0000000..802b87a --- /dev/null +++ b/examples/xcode-strings/languine.config.mjs @@ -0,0 +1,16 @@ +export default { + version: "0.6.2", + locale: { + source: "en", + targets: ["es"], + }, + files: { + "xcode-strings": { + include: ["Example/[locale].lproj/Localizable.strings"], + }, + }, + llm: { + provider: "openai", + model: "gpt-4-turbo", + }, +}; diff --git a/examples/xcode-xcstrings/Example/Localizable.xcstrings b/examples/xcode-xcstrings/Example/Localizable.xcstrings new file mode 100644 index 0000000..75a373a --- /dev/null +++ b/examples/xcode-xcstrings/Example/Localizable.xcstrings @@ -0,0 +1,83 @@ +{ + "version": "1.0", + "sourceLanguage": "en", + "strings": { + "app_title": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Aufgabenmanager Pro" + } + } + } + }, + "task_due_date": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Fällig: %@" + } + } + } + }, + "task_count": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Aufgaben" + } + } + } + }, + "reminder_time": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "%lld Aufgabe" + } + } + } + }, + "task_details": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "%lld Aufgaben" + } + } + } + }, + "last_modified": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "%lld Aufgaben" + } + } + } + }, + "task_description": { + "extractionState": "manual", + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "%lld Aufgaben" + } + } + } + } + } +} \ No newline at end of file diff --git a/examples/xcode-xcstrings/languine.config.mjs b/examples/xcode-xcstrings/languine.config.mjs new file mode 100644 index 0000000..7221154 --- /dev/null +++ b/examples/xcode-xcstrings/languine.config.mjs @@ -0,0 +1,16 @@ +export default { + version: "0.6.2", + locale: { + source: "en", + targets: ["de"], + }, + files: { + "xcode-xcstrings": { + include: ["Example/Localizable.xcstrings"], + }, + }, + llm: { + provider: "openai", + model: "gpt-4-turbo", + }, +} \ No newline at end of file diff --git a/packages/cli/package.json b/packages/cli/package.json index 3c8617c..5f8950e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "languine", - "version": "0.6.1", + "version": "0.6.2", "type": "module", "bin": "dist/index.js", "main": "dist/index.js", @@ -24,9 +24,10 @@ "dedent": "^1.5.3", "diff": "^7.0.0", "dotenv": "^16.4.7", - "simple-git": "^3.27.0", "ollama": "^0.5.11", "ollama-ai-provider": "^1.1.0", + "rambda": "^9.4.1", + "simple-git": "^3.27.0", "zod": "^3.24.1" }, "devDependencies": { diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index e60484f..af198bc 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -6,6 +6,46 @@ import { providers } from "../providers.js"; import type { Provider } from "../types.js"; import { configPath } from "../utils.js"; +async function createDirectoryOrFile(filePath: string, isDirectory = false) { + try { + const dirPath = isDirectory ? filePath : path.dirname(filePath); + await fs.mkdir(dirPath, { recursive: true }); + + if (!isDirectory) { + const exists = await fs + .access(filePath) + .then(() => true) + .catch(() => false); + if (!exists) { + await fs.writeFile(filePath, "", "utf-8"); + } + } + } catch (error) { + throw new Error( + `Failed to create ${isDirectory ? "directory" : "file"}: ${filePath}`, + ); + } +} + +function getDefaultPattern(format: string) { + switch (format) { + case "xcode-strings": + return "Example/[locale].lproj/Localizable.strings"; + case "xcode-stringsdict": + return "Example/[locale].lproj/Localizable.stringsdict"; + case "xcode-xcstrings": + return "Example/Localizable.xcstrings"; + case "yaml": + return "locales/[locale].yml"; + case "json": + return "locales/[locale].json"; + case "md": + return "docs/[locale]/*.md"; + default: + return `locales/[locale].${format}`; + } +} + export async function init() { try { execSync("git rev-parse --is-inside-work-tree", { stdio: "ignore" }); @@ -37,22 +77,27 @@ export async function init() { }, })) as string; - const filesDirectory = (await text({ - message: "Where should language files be stored?", - placeholder: "src/locales", - defaultValue: "src/locales", - validate: () => undefined, - })) as string; - const fileFormat = (await select({ message: "What format should language files use?", options: [ { value: "ts", label: "TypeScript (.ts)" }, { value: "json", label: "JSON (.json)" }, { value: "md", label: "Markdown (.md)" }, + { value: "xcode-strings", label: "Xcode Strings (.strings)" }, + { value: "xcode-stringsdict", label: "Xcode Stringsdict (.stringsdict)" }, + { value: "xcode-xcstrings", label: "Xcode XCStrings (.xcstrings)" }, + { value: "yaml", label: "YAML (.yml)" }, ], })) as string; + const defaultPattern = getDefaultPattern(fileFormat); + const filesPattern = (await text({ + message: "Where should language files be stored?", + placeholder: defaultPattern, + defaultValue: defaultPattern, + validate: () => undefined, + })) as string; + const provider = (await select({ message: "Which provider would you like to use?", options: Object.values(providers), @@ -86,54 +131,43 @@ export async function init() { targets: ${JSON.stringify(targetLanguages.split(",").map((l) => l.trim()))}, }, files: { - ${fileFormat}: { - include: ["${filesDirectory}/[locale].${fileFormat}"], + ${fileFormat.includes("-") ? `"${fileFormat}"` : fileFormat}: { + include: ["${filesPattern}"], }, }, llm: { provider: "${provider}", model: "${model}", - temperature: 0, }, }`; try { - await fs.mkdir(path.join(process.cwd(), filesDirectory), { - recursive: true, - }); + const targetLangs = [ + sourceLanguage, + ...targetLanguages.split(",").map((l) => l.trim()), + ]; + const isDirectory = filesPattern.includes("*"); - const sourceFile = path.join( - process.cwd(), - `${filesDirectory}/${sourceLanguage}.${fileFormat}`, - ); - if ( - !(await fs - .access(sourceFile) - .then(() => true) - .catch(() => false)) - ) { - await fs.writeFile(sourceFile, "", "utf-8"); - } - - const targetLangs = targetLanguages.split(","); - for (const targetLang of targetLangs.map((l: string) => l.trim())) { - const targetFile = path.join( + for (const lang of targetLangs) { + const filePath = path.join( process.cwd(), - `${filesDirectory}/${targetLang}.${fileFormat}`, + filesPattern.replace("[locale]", lang), ); - if ( - !(await fs - .access(targetFile) - .then(() => true) - .catch(() => false)) - ) { - await fs.writeFile(targetFile, "", "utf-8"); + + if (isDirectory) { + // For patterns with wildcards, create the directory structure + await createDirectoryOrFile(path.dirname(filePath), true); + } else { + // For direct file patterns, create the file + await createDirectoryOrFile(filePath); } } // Write config file await fs.writeFile(configPath, configContent); - outro("Configuration file and language files created successfully!"); + outro( + "Configuration file and language files/directories created successfully!", + ); } catch (error) { outro("Failed to create config and language files"); process.exit(1); diff --git a/packages/cli/src/translators/index.ts b/packages/cli/src/translators/index.ts index 2f1b2a9..4ed75de 100644 --- a/packages/cli/src/translators/index.ts +++ b/packages/cli/src/translators/index.ts @@ -2,6 +2,8 @@ import type { Translator } from "../types.js"; import { javascript } from "./js.js"; import { json } from "./json.js"; import { markdown } from "./md.js"; +import { xcodeStrings } from "./xcode-strings.js"; +import { xcodeXCStrings } from "./xcode-xcstrings.js"; /** * Get adapter from file extension/format @@ -20,6 +22,10 @@ export async function getTranslator( case "md": case "mdx": return markdown; + case "xcode-strings": + return xcodeStrings; + case "xcode-xcstrings": + return xcodeXCStrings; default: return undefined; } diff --git a/packages/cli/src/translators/xcode-strings.ts b/packages/cli/src/translators/xcode-strings.ts new file mode 100644 index 0000000..1ec932b --- /dev/null +++ b/packages/cli/src/translators/xcode-strings.ts @@ -0,0 +1,136 @@ +import { generateObject } from "ai"; +import dedent from "dedent"; +import { z } from "zod"; +import { baseRequirements, createBasePrompt } from "../prompt.js"; +import type { PromptOptions, Translator } from "../types.js"; + +const STRING_REGEX = /^"(.+)"\s*=\s*"(.+)";$/; + +const UNESCAPE_REPLACEMENTS: Record = { + '\\"': '"', + "\\n": "\n", + "\\\\": "\\", +}; + +const ESCAPE_REPLACEMENTS: Record = { + "\\": "\\\\", + '"': '\\"', + "\n": "\\n", +}; + +function parseXcodeStrings(content: string) { + const result: Record = {}; + + for (const line of content.split("\n")) { + const trimmedLine = line.trim(); + if (!trimmedLine || trimmedLine.startsWith("//")) continue; + + const match = trimmedLine.match(STRING_REGEX); + if (match) { + const [, key, value] = match; + result[key] = value.replace( + /\\[\\n"]/g, + (match) => UNESCAPE_REPLACEMENTS[match], + ); + } + } + + return result; +} + +function stringifyXcodeStrings(strings: Record) { + return Object.entries(strings) + .map( + ([key, value]) => + `"${key}" = "${value.replace(/[\\"\n]/g, (match) => ESCAPE_REPLACEMENTS[match])}";`, + ) + .join("\n"); +} + +export const xcodeStrings: Translator = { + async onUpdate(options) { + const sourceStrings = parseXcodeStrings(options.content); + const previousStrings = parseXcodeStrings(options.previousContent); + + const addedKeys = Object.keys(sourceStrings).filter( + (key) => + !(key in previousStrings) || + previousStrings[key] !== sourceStrings[key], + ); + + if (addedKeys.length === 0) { + return { + summary: "No new keys to translate", + content: options.previousTranslation, + }; + } + + const toTranslate = Object.fromEntries( + addedKeys.map((key) => [key, sourceStrings[key]]), + ); + + const { object } = await generateObject({ + model: options.model, + temperature: options.config.llm?.temperature ?? 0, + prompt: getPrompt(JSON.stringify(toTranslate, null, 2), options), + schema: z.object({ + items: z.array(z.string().describe("Translated string value")), + }), + }); + + const translated = addedKeys.reduce>( + (acc, key, index) => { + acc[key] = object.items[index]; + return acc; + }, + {}, + ); + + return { + summary: `Translated ${addedKeys.length} new keys`, + content: stringifyXcodeStrings({ + ...parseXcodeStrings(options.previousTranslation), + ...translated, + }), + }; + }, + + async onNew(options) { + const sourceStrings = parseXcodeStrings(options.content); + const sourceKeys = Object.keys(sourceStrings); + + const { object } = await generateObject({ + model: options.model, + prompt: getPrompt(JSON.stringify(sourceStrings, null, 2), options), + temperature: options.config.llm?.temperature ?? 0, + schema: z.object({ + items: z.array(z.string().describe("Translated string value")), + }), + }); + + const translatedStrings = sourceKeys.reduce>( + (acc, key, index) => { + acc[key] = object.items[index]; + return acc; + }, + {}, + ); + + return { + content: stringifyXcodeStrings(translatedStrings), + }; + }, +}; + +function getPrompt(base: string, options: PromptOptions) { + const text = dedent` + ${baseRequirements} + - Preserve all key names exactly as they appear + - Only translate the values, not the keys + - Return translations as a JSON array of strings in the same order as input + - Maintain all format specifiers like %@, %d, %1$@, etc. in the exact same order + - Preserve any HTML tags or special formatting in the strings + `; + + return createBasePrompt(`${text}\n${base}`, options); +} diff --git a/packages/cli/src/translators/xcode-xcstrings.ts b/packages/cli/src/translators/xcode-xcstrings.ts new file mode 100644 index 0000000..e505e24 --- /dev/null +++ b/packages/cli/src/translators/xcode-xcstrings.ts @@ -0,0 +1,231 @@ +import { generateObject } from "ai"; +import dedent from "dedent"; +import { merge } from "rambda"; +import { z } from "zod"; +import { baseRequirements, createBasePrompt } from "../prompt.js"; +import type { PromptOptions, Translator } from "../types.js"; + +interface StringUnit { + state: string; + value: string; +} + +interface Localization { + stringUnit?: StringUnit; + variations?: { + plural: Record; + }; +} + +interface TranslationEntity { + extractionState: string; + localizations: Record; +} + +interface XCStringsData { + strings: Record; +} + +function parseXcodeXCStrings(content: string, locale: string) { + const data = JSON.parse(content) as XCStringsData; + const resultData: Record> = {}; + + for (const [translationKey, translationEntity] of Object.entries( + data.strings, + )) { + const langTranslationEntity = translationEntity?.localizations?.[locale]; + if (langTranslationEntity) { + if ( + "stringUnit" in langTranslationEntity && + langTranslationEntity.stringUnit + ) { + resultData[translationKey] = langTranslationEntity.stringUnit.value; + } else if ( + "variations" in langTranslationEntity && + langTranslationEntity.variations + ) { + if ("plural" in langTranslationEntity.variations) { + resultData[translationKey] = {}; + const pluralForms = langTranslationEntity.variations.plural; + for (const form in pluralForms) { + if (pluralForms[form]?.stringUnit?.value) { + (resultData[translationKey] as Record)[form] = + pluralForms[form].stringUnit.value; + } + } + } + } + } + } + + return resultData; +} + +function stringifyXcodeXCStrings( + strings: Record>, + locale: string, + originalContent: string, +) { + const langDataToMerge: XCStringsData = { strings: {} }; + + for (const [key, value] of Object.entries(strings)) { + if (typeof value === "string") { + langDataToMerge.strings[key] = { + extractionState: "manual", + localizations: { + [locale]: { + stringUnit: { + state: "translated", + value, + }, + }, + }, + }; + } else { + const updatedVariations: Record = {}; + + for (const form in value) { + updatedVariations[form] = { + stringUnit: { + state: "translated", + value: value[form], + }, + }; + } + + langDataToMerge.strings[key] = { + extractionState: "manual", + localizations: { + [locale]: { + variations: { + plural: updatedVariations, + }, + }, + }, + }; + } + } + + const result = merge(JSON.parse(originalContent), langDataToMerge); + return JSON.stringify(result, null, 2); +} + +export const xcodeXCStrings: Translator = { + async onUpdate(options) { + const sourceStrings = parseXcodeXCStrings( + options.content, + options.config.locale.source, + ); + const previousStrings = parseXcodeXCStrings( + options.previousContent, + options.config.locale.source, + ); + + const addedKeys = Object.keys(sourceStrings).filter((key) => { + if (!(key in previousStrings)) return true; + return ( + JSON.stringify(previousStrings[key]) !== + JSON.stringify(sourceStrings[key]) + ); + }); + + if (addedKeys.length === 0) { + return { + summary: "No new keys to translate", + content: options.previousTranslation, + }; + } + + const toTranslate = Object.fromEntries( + addedKeys.map((key) => [key, sourceStrings[key]]), + ); + + const { object } = await generateObject({ + model: options.model, + temperature: options.config.llm?.temperature ?? 0, + prompt: getPrompt(JSON.stringify(toTranslate, null, 2), options), + schema: z.object({ + items: z.array( + z + .union([z.string(), z.record(z.string(), z.string())]) + .describe("Translated string value or plural forms"), + ), + }), + }); + + const translated = addedKeys.reduce< + Record> + >((acc, key, index) => { + acc[key] = object.items[index]; + return acc; + }, {}); + + return { + summary: `Translated ${addedKeys.length} new keys`, + content: stringifyXcodeXCStrings( + { + ...parseXcodeXCStrings( + options.previousTranslation, + options.targetLocale, + ), + ...translated, + }, + options.targetLocale, + options.previousTranslation, + ), + }; + }, + async onNew(options: PromptOptions) { + const sourceStrings = parseXcodeXCStrings( + options.content, + options.config.locale.source, + ); + + console.log("katt"); + + const sourceKeys = Object.keys(sourceStrings); + + const { object } = await generateObject({ + model: options.model, + prompt: getPrompt(JSON.stringify(sourceStrings, null, 2), options), + temperature: options.config.llm?.temperature ?? 0, + schema: z.object({ + items: z.array( + z + .union([z.string(), z.record(z.string(), z.string())]) + .describe("Translated string value or plural forms"), + ), + }), + }); + + const translatedStrings = sourceKeys.reduce< + Record> + >((acc, key, index) => { + acc[key] = object.items[index]; + return acc; + }, {}); + + return { + content: stringifyXcodeXCStrings( + translatedStrings, + options.targetLocale, + options.content, + ), + }; + }, +}; + +function getPrompt(base: string, options: PromptOptions) { + const text = dedent` + ${baseRequirements} + - Preserve all key names exactly as they appear + - Only translate the values, not the keys + - Return translations as a JSON array in the same order as input + - For simple strings, return the translated string + - For plural forms, return an object with translated values for each form (zero, one, two, few, many, other) + - Maintain all format specifiers like %@, %d, %lld, %1$@, etc. in the exact same order + - Preserve any HTML tags or special formatting in the strings + `; + + return createBasePrompt(`${text}\n${base}`, options); +} From 5e583767d21a043eda17558f2fb2f55f7f606b6e Mon Sep 17 00:00:00 2001 From: Pontus Abrahamsson Date: Wed, 25 Dec 2024 23:09:04 +0100 Subject: [PATCH 2/5] Add support for yaml and po --- bun.lockb | Bin 165432 -> 165464 bytes examples/po/languine.config.mjs | 16 ++ examples/po/locales/en.po | 7 + examples/po/locales/es.po | 7 + examples/yaml/languine.config.mjs | 16 ++ examples/yaml/locales/en.yml | 48 +++++ examples/yaml/locales/es.yml | 35 ++++ packages/cli/package.json | 1 + packages/cli/src/commands/init.ts | 9 + packages/cli/src/envs.ts | 26 ++- packages/cli/src/translators/index.ts | 11 ++ packages/cli/src/translators/po.ts | 175 ++++++++++++++++++ .../cli/src/translators/xcode-xcstrings.ts | 2 - packages/cli/src/translators/yaml.ts | 158 ++++++++++++++++ 14 files changed, 508 insertions(+), 3 deletions(-) create mode 100644 examples/po/languine.config.mjs create mode 100644 examples/po/locales/en.po create mode 100644 examples/po/locales/es.po create mode 100644 examples/yaml/languine.config.mjs create mode 100644 examples/yaml/locales/en.yml create mode 100644 examples/yaml/locales/es.yml create mode 100644 packages/cli/src/translators/po.ts create mode 100644 packages/cli/src/translators/yaml.ts diff --git a/bun.lockb b/bun.lockb index 81b9ee25f58502a9cdbe62031cc3e05f738a60b1..7dee3d88196a921ebef263d1500aed902a8729e3 100755 GIT binary patch delta 17001 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 delta 16733 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 z { + const commentLines = + comments.length > 0 ? `${comments.join("\n")}\n` : ""; + return `${commentLines}msgid "${key}"\nmsgstr "${value}"`; + }) + .join("\n\n"); +} + +export const po: Translator = { + async onUpdate(options) { + const sourceEntries = parsePoFile(options.content); + const previousEntries = parsePoFile(options.previousContent); + const previousTranslationEntries = parsePoFile(options.previousTranslation); + + const sourceMap = new Map(sourceEntries.map((entry) => [entry.key, entry])); + const previousMap = new Map( + previousEntries.map((entry) => [entry.key, entry]), + ); + const prevTransMap = new Map( + previousTranslationEntries.map((entry) => [entry.key, entry]), + ); + + const addedKeys = sourceEntries + .filter(({ key, value }) => { + const prev = previousMap.get(key); + return !prev || prev.value !== value; + }) + .map((entry) => entry.key); + + if (addedKeys.length === 0) { + return { + summary: "No new keys to translate", + content: options.previousTranslation, + }; + } + + const toTranslate = Object.fromEntries( + addedKeys.map((key) => [key, sourceMap.get(key)!.value]), + ); + + const { object } = await generateObject({ + model: options.model, + temperature: options.config.llm?.temperature ?? 0, + prompt: getPrompt(JSON.stringify(toTranslate, null, 2), options), + schema: z.object({ + items: z.array(z.string().describe("Translated string value")), + }), + }); + + // Update translations while preserving order and comments + const updatedEntries = sourceEntries.map(({ key, comments }) => { + const translationIndex = addedKeys.indexOf(key); + const value = + translationIndex !== -1 + ? object.items[translationIndex] + : (prevTransMap.get(key)?.value ?? ""); + + return { key, value, comments }; + }); + + return { + summary: `Translated ${addedKeys.length} new keys`, + content: stringifyPoFile(updatedEntries), + }; + }, + + async onNew(options) { + const sourceEntries = parsePoFile(options.content); + const sourceStrings = Object.fromEntries( + sourceEntries.map((entry) => [entry.key, entry.value]), + ); + + const { object } = await generateObject({ + model: options.model, + prompt: getPrompt(JSON.stringify(sourceStrings, null, 2), options), + temperature: options.config.llm?.temperature ?? 0, + schema: z.object({ + items: z.array(z.string().describe("Translated string value")), + }), + }); + + const translatedEntries = sourceEntries.map((entry, index) => ({ + ...entry, + value: object.items[index], + })); + + return { + content: stringifyPoFile(translatedEntries), + }; + }, +}; + +function getPrompt(base: string, options: PromptOptions) { + const text = dedent` + ${baseRequirements} + - Preserve all msgid values exactly as they appear + - Only translate the msgstr values, not the msgid values + - Return translations as a JSON array of strings in the same order as input + - Maintain all format specifiers like %s, %d, etc. in the exact same order + - Preserve any HTML tags or special formatting in the strings + `; + + return createBasePrompt(`${text}\n${base}`, options); +} diff --git a/packages/cli/src/translators/xcode-xcstrings.ts b/packages/cli/src/translators/xcode-xcstrings.ts index e505e24..69d2785 100644 --- a/packages/cli/src/translators/xcode-xcstrings.ts +++ b/packages/cli/src/translators/xcode-xcstrings.ts @@ -181,8 +181,6 @@ export const xcodeXCStrings: Translator = { options.config.locale.source, ); - console.log("katt"); - const sourceKeys = Object.keys(sourceStrings); const { object } = await generateObject({ diff --git a/packages/cli/src/translators/yaml.ts b/packages/cli/src/translators/yaml.ts new file mode 100644 index 0000000..4badfcb --- /dev/null +++ b/packages/cli/src/translators/yaml.ts @@ -0,0 +1,158 @@ +import { generateObject } from "ai"; +import dedent from "dedent"; +import YAML from "yaml"; +import { z } from "zod"; +import { baseRequirements, createBasePrompt } from "../prompt.js"; +import type { PromptOptions, Translator } from "../types.js"; + +interface YamlEntry { + key: string; + value: string; +} + +function parseYamlFile(content: string) { + const doc = YAML.parse(content); + const entries: YamlEntry[] = []; + + function traverse(obj: Record, prefix = "") { + if (typeof obj === "object" && obj !== null) { + for (const [key, value] of Object.entries(obj)) { + const fullKey = prefix ? `${prefix}.${key}` : key; + if (typeof value === "object" && value !== null) { + traverse(value as Record, fullKey); + } else { + entries.push({ + key: fullKey, + value: String(value), + }); + } + } + } + } + + traverse(doc); + return entries; +} + +function stringifyYamlFile(entries: YamlEntry[]) { + const doc = new YAML.Document(); + const obj: Record = {}; + + for (const { key, value } of entries) { + const parts = key.split("."); + let current = obj; + + // Build nested structure + for (let i = 0; i < parts.length - 1; i++) { + if (!current[parts[i]]) { + current[parts[i]] = {}; + } + current = current[parts[i]] as Record; + } + + const lastKey = parts[parts.length - 1]; + current[lastKey] = value; + } + + doc.contents = doc.createNode(obj); + return doc.toString(); +} + +export const yaml: Translator = { + async onUpdate(options) { + const sourceEntries = parseYamlFile(options.content); + const previousEntries = parseYamlFile(options.previousContent); + const previousTranslationEntries = parseYamlFile( + options.previousTranslation, + ); + + const sourceMap = new Map(sourceEntries.map((entry) => [entry.key, entry])); + const previousMap = new Map( + previousEntries.map((entry) => [entry.key, entry]), + ); + const prevTransMap = new Map( + previousTranslationEntries.map((entry) => [entry.key, entry]), + ); + + const addedKeys = sourceEntries + .filter(({ key, value }) => { + const prev = previousMap.get(key); + return !prev || prev.value !== value; + }) + .map((entry) => entry.key); + + if (addedKeys.length === 0) { + return { + summary: "No new keys to translate", + content: options.previousTranslation, + }; + } + + const toTranslate = Object.fromEntries( + addedKeys.map((key) => [key, sourceMap.get(key)!.value]), + ); + + const { object } = await generateObject({ + model: options.model, + temperature: options.config.llm?.temperature ?? 0, + prompt: getPrompt(JSON.stringify(toTranslate, null, 2), options), + schema: z.object({ + items: z.array(z.string().describe("Translated string value")), + }), + }); + + // Update translations while preserving order + const updatedEntries = sourceEntries.map(({ key }) => { + const translationIndex = addedKeys.indexOf(key); + const value = + translationIndex !== -1 + ? object.items[translationIndex] + : (prevTransMap.get(key)?.value ?? ""); + + return { key, value }; + }); + + return { + summary: `Translated ${addedKeys.length} new keys`, + content: stringifyYamlFile(updatedEntries), + }; + }, + + async onNew(options) { + const sourceEntries = parseYamlFile(options.content); + const sourceStrings = Object.fromEntries( + sourceEntries.map((entry) => [entry.key, entry.value]), + ); + + const { object } = await generateObject({ + model: options.model, + prompt: getPrompt(JSON.stringify(sourceStrings, null, 2), options), + temperature: options.config.llm?.temperature ?? 0, + schema: z.object({ + items: z.array(z.string().describe("Translated string value")), + }), + }); + + const translatedEntries = sourceEntries.map((entry, index) => ({ + ...entry, + value: object.items[index], + })); + + return { + content: stringifyYamlFile(translatedEntries), + }; + }, +}; + +function getPrompt(base: string, options: PromptOptions) { + const text = dedent` + ${baseRequirements} + - Preserve all YAML keys exactly as they appear + - Only translate the values, not the keys + - Return translations as a JSON array of strings in the same order as input + - Maintain all format specifiers like {name}, {count}, etc. in the exact same order + - Preserve any HTML tags or special formatting in the strings + `; + + return createBasePrompt(`${text}\n${base}`, options); +} From 2deef1118ca530893713b2fdaa3f97d738021d76 Mon Sep 17 00:00:00 2001 From: Pontus Abrahamsson Date: Thu, 26 Dec 2024 10:20:34 +0100 Subject: [PATCH 3/5] Add support for xcode and android --- apps/web/src/components/commands.tsx | 49 ++++ bun.lockb | Bin 165464 -> 168712 bytes examples/android/languine.config.mjs | 16 ++ examples/android/locales/en.xml | 66 ++++++ examples/android/locales/es.xml | 48 ++++ .../Example/en.lproj/Localizable.stringsdict | 89 ++++++++ .../Example/es.lproj/Localizable.stringsdict | 89 ++++++++ .../xcode-stringsdict/languine.config.mjs | 16 ++ packages/cli/README.md | 2 +- packages/cli/package.json | 4 + packages/cli/src/commands/init.ts | 7 +- packages/cli/src/translators/android.ts | 215 ++++++++++++++++++ packages/cli/src/translators/index.ts | 11 +- .../cli/src/translators/xcode-stringsdict.ts | 116 ++++++++++ 14 files changed, 717 insertions(+), 11 deletions(-) create mode 100644 examples/android/languine.config.mjs create mode 100644 examples/android/locales/en.xml create mode 100644 examples/android/locales/es.xml create mode 100644 examples/xcode-stringsdict/Example/en.lproj/Localizable.stringsdict create mode 100644 examples/xcode-stringsdict/Example/es.lproj/Localizable.stringsdict create mode 100644 examples/xcode-stringsdict/languine.config.mjs create mode 100644 packages/cli/src/translators/android.ts create mode 100644 packages/cli/src/translators/xcode-stringsdict.ts diff --git a/apps/web/src/components/commands.tsx b/apps/web/src/components/commands.tsx index ed2355d..2b4232d 100644 --- a/apps/web/src/components/commands.tsx +++ b/apps/web/src/components/commands.tsx @@ -171,6 +171,55 @@ export function Commands() { > │ ○ Markdown (.md) + = 4 ? "opacity-100" : "opacity-0", + )} + > + │ ○ Xcode Strings (.strings) + + = 4 ? "opacity-100" : "opacity-0", + )} + > + │ ○ Xcode Stringsdict (.stringsdict) + + = 4 ? "opacity-100" : "opacity-0", + )} + > + │ ○ Xcode XCStrings (.xcstrings) + + = 4 ? "opacity-100" : "opacity-0", + )} + > + │ ○ YAML (.yml) + + = 4 ? "opacity-100" : "opacity-0", + )} + > + │ ○ Gettext (.po) + + = 4 ? "opacity-100" : "opacity-0", + )} + > + │ ○ Android (.xml) + + Nl0cu0v(0s-;p;Z% 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 qle6WajCu3EPU`k63_s1;Oo^GI4GYUWwn)$l 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 Dqq1y@ diff --git a/examples/android/languine.config.mjs b/examples/android/languine.config.mjs new file mode 100644 index 0000000..61caf7a --- /dev/null +++ b/examples/android/languine.config.mjs @@ -0,0 +1,16 @@ +export default { + version: "0.6.2", + locale: { + source: "en", + targets: ["es"], + }, + files: { + android: { + include: ["locales/[locale].xml"], + }, + }, + llm: { + provider: "openai", + model: "gpt-4-turbo", + }, +}; diff --git a/examples/android/locales/en.xml b/examples/android/locales/en.xml new file mode 100644 index 0000000..7f0109c --- /dev/null +++ b/examples/android/locales/en.xml @@ -0,0 +1,66 @@ + + + + Home + Search + Favorites + Settings + + + My Profile + Edit Profile + Save Changes + Sign Out + + + + %d song in queue + %d songs in queue + + Play + Pause + Next Track + Previous Track + + + Shopping Cart + Total: $%1$.2f + Proceed to Checkout + + Credit Card + PayPal + Bank Transfer + + + + Current Temperature: %1$d° + Humidity: %1$d%% + Wind Speed: %1$d mph + + + %1$s sent you a message + New friend request from %1$s + + %d person liked your post + %d people liked your post + + + + Unable to connect to network + Request timed out + Invalid input provided + + + Event: %1$s + Time: %1$s - %2$s + Location: %1$s + + + Score: %1$d + High Score: %1$d + Lives: %1$d + + %d achievement unlocked + %d achievements unlocked + + \ No newline at end of file diff --git a/examples/android/locales/es.xml b/examples/android/locales/es.xml new file mode 100644 index 0000000..febb32a --- /dev/null +++ b/examples/android/locales/es.xml @@ -0,0 +1,48 @@ + + Inicio + Buscar + Favoritos + Configuración + Mi Perfil + Editar Perfil + Guardar Cambios + Cerrar Sesión + Reproducir + Pausa + Siguiente Canción + Canción Anterior + Carrito de Compras + Total: $%1$.2f + Proceder al Pago + Temperatura Actual: %1$d° + Humedad: %1$d%% + Velocidad del Viento: %1$d mph + %1$s te ha enviado un mensaje + Nueva solicitud de amistad de %1$s + No se puede conectar a la red + Tiempo de solicitud agotado + Entrada inválida proporcionada + Evento: %1$s + Hora: %1$s - %2$s + Ubicación: %1$s + Puntuación: %1$d + Puntuación Más Alta: %1$d + Vidas: %1$d + + Tarjeta de Crédito + PayPal + Transferencia Bancaria + + + %d canción en cola + %d canciones en cola + + + %d persona le gustó tu publicación + %d personas les gustó tu publicación + + + %d logro desbloqueado + %d logros desbloqueados + + \ No newline at end of file diff --git a/examples/xcode-stringsdict/Example/en.lproj/Localizable.stringsdict b/examples/xcode-stringsdict/Example/en.lproj/Localizable.stringsdict new file mode 100644 index 0000000..405e028 --- /dev/null +++ b/examples/xcode-stringsdict/Example/en.lproj/Localizable.stringsdict @@ -0,0 +1,89 @@ + + + + + product_count + + NSStringLocalizedFormatKey + %#@items@ + items + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + lld + zero + No items available + one + One item available + two + Two items available + few + %lld items available + many + %lld items available + other + %lld items available + + + countdown_days + + NSStringLocalizedFormatKey + %#@days@ + days + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + lld + zero + Event starts today! + one + Event starts tomorrow! + two + Event starts in two days + few + Event starts in %lld days + many + Event starts in %lld days + other + Event starts in %lld days + + + hello + Hi there! + user_welcome + Welcome back, %@! + score_format + Player %1$@ has earned %2$lld points with $%3$.2f remaining + current_date + Current date: %@ + formatted_message + Here's a <b>formatted</b> message with: +• HTML formatting +• Special symbols: ©®™ +• A <a href="https://example.com">hyperlink</a> + alert_count + + NSStringLocalizedFormatKey + %#@notifications@ + notifications + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + lld + zero + No new notifications + one + One new notification + other + %lld new notifications + + + remaining_time + Time remaining: %1$@ (%2$lld seconds) + version_info + Version %1$@ (Build %2$@) + + \ No newline at end of file diff --git a/examples/xcode-stringsdict/Example/es.lproj/Localizable.stringsdict b/examples/xcode-stringsdict/Example/es.lproj/Localizable.stringsdict new file mode 100644 index 0000000..cf3808f --- /dev/null +++ b/examples/xcode-stringsdict/Example/es.lproj/Localizable.stringsdict @@ -0,0 +1,89 @@ + + + + + product_count + + NSStringLocalizedFormatKey + %#@items@ + items + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + lld + zero + No hay artículos disponibles + one + Un artículo disponible + two + Dos artículos disponibles + few + %lld artículos disponibles + many + %lld artículos disponibles + other + %lld artículos disponibles + + + countdown_days + + NSStringLocalizedFormatKey + %#@days@ + days + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + lld + zero + ¡El evento comienza hoy! + one + El evento comienza mañana! + two + El evento comienza en dos días + few + El evento comienza en %lld días + many + El evento comienza en %lld días + other + El evento comienza en %lld días + + + hello + ¡Hola! + user_welcome + ¡Bienvenido de nuevo, %@! + score_format + El jugador %1$@ ha ganado %2$lld puntos con $%3$.2f restantes + current_date + Fecha actual: %@ + formatted_message + Aquí tienes un mensaje <b>formateado</b> con: +• Formato HTML +• Símbolos especiales: ©®™ +• Un <a href="https://example.com">hipervínculo</a> + alert_count + + NSStringLocalizedFormatKey + %#@notifications@ + notifications + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + lld + zero + No hay nuevas notificaciones + one + Una nueva notificación + other + %lld nuevas notificaciones + + + remaining_time + Tiempo restante: %1$@ (%2$lld segundos) + version_info + Versión %1$@ (Construcción %2$@) + + \ No newline at end of file diff --git a/examples/xcode-stringsdict/languine.config.mjs b/examples/xcode-stringsdict/languine.config.mjs new file mode 100644 index 0000000..8fcf5ad --- /dev/null +++ b/examples/xcode-stringsdict/languine.config.mjs @@ -0,0 +1,16 @@ +export default { + version: "0.6.2", + locale: { + source: "en", + targets: ["es"], + }, + files: { + "xcode-stringsdict": { + include: ["Example/[locale].lproj/Localizable.stringsdict"], + }, + }, + llm: { + provider: "openai", + model: "gpt-4-turbo", + }, +} \ No newline at end of file diff --git a/packages/cli/README.md b/packages/cli/README.md index de83148..94713a4 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -38,7 +38,7 @@ developer-friendly experience. - Automatically identifies new, modified, or removed translation keys in your codebase using Git diff -- Handles multiple file formats (.json, .ts, .md) with precise parsing and +- Handles multiple file formats (.json, .ts, .md, .yaml, .po, .strings, .stringsdict, .xcstrings) with precise parsing and file-specific updates ### 🌍 AI-Powered Translation diff --git a/packages/cli/package.json b/packages/cli/package.json index bd8f5de..0d5754b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -19,6 +19,8 @@ "dependencies": { "@ai-sdk/openai": "^1.0.11", "@clack/prompts": "^0.9.0", + "@types/plist": "^3.0.5", + "@types/xml2js": "^0.4.14", "ai": "^4.0.22", "chalk": "^5.4.1", "dedent": "^1.5.3", @@ -26,8 +28,10 @@ "dotenv": "^16.4.7", "ollama": "^0.5.11", "ollama-ai-provider": "^1.1.0", + "plist": "^3.1.0", "rambda": "^9.4.1", "simple-git": "^3.27.0", + "xml2js": "^0.6.2", "yaml": "^2.6.1", "zod": "^3.24.1" }, diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index c5b03a9..f1eb4d1 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -45,10 +45,8 @@ function getDefaultPattern(format: string) { return "locales/[locale].po"; case "xml": return "locales/[locale].xml"; - case ".arb": - return "locales/[locale].arb"; default: - return `locales/[locale].${format}`; + throw new Error(`Unsupported format: ${format}`); } } @@ -93,9 +91,8 @@ export async function init() { { value: "xcode-stringsdict", label: "Xcode Stringsdict (.stringsdict)" }, { value: "xcode-xcstrings", label: "Xcode XCStrings (.xcstrings)" }, { value: "yaml", label: "YAML (.yml)" }, - { value: "xml", label: "XML (.xml)" }, - { value: "arb", label: "Arb (.arb)" }, { value: "po", label: "Gettext (.po)" }, + { value: "android", label: "Android (.xml)" }, ], })) as string; diff --git a/packages/cli/src/translators/android.ts b/packages/cli/src/translators/android.ts new file mode 100644 index 0000000..11119f9 --- /dev/null +++ b/packages/cli/src/translators/android.ts @@ -0,0 +1,215 @@ +import { generateObject } from "ai"; +import dedent from "dedent"; +import { Builder, parseStringPromise } from "xml2js"; +import { z } from "zod"; +import { baseRequirements, createBasePrompt } from "../prompt.js"; +import type { PromptOptions, Translator } from "../types.js"; + +interface AndroidResource { + $: { + name: string; + [key: string]: string; + }; + _?: string; + item?: Array<{ $?: { quantity?: string }; _: string } | string>; +} + +interface AndroidResources { + resources: { + string?: AndroidResource[]; + "string-array"?: AndroidResource[]; + plurals?: AndroidResource[]; + bool?: AndroidResource[]; + integer?: AndroidResource[]; + }; +} + +async function parseXmlFile(content: string): Promise { + try { + const parsed = (await parseStringPromise(content, { + explicitArray: true, + mergeAttrs: false, + normalize: true, + preserveChildrenOrder: true, + normalizeTags: true, + includeWhiteChars: true, + trim: true, + })) as AndroidResources; + return parsed; + } catch (error) { + console.error("Failed to parse Android XML:", error); + return { resources: {} }; + } +} + +function stringifyXmlFile(data: AndroidResources): string { + try { + const builder = new Builder({ headless: true }); + const xmlOutput = builder.buildObject(data); + return xmlOutput; + } catch (error) { + console.error("Failed to build Android XML:", error); + return ""; + } +} + +function processAndroidResources( + resources: AndroidResources, +): Record { + const result: Record = {}; + + const processResource = ( + resourceType: keyof AndroidResources["resources"], + ) => { + const items = resources.resources[resourceType]; + if (!items) return; + + for (const item of items) { + if (resourceType === "string") { + result[item.$.name] = item._ || ""; + } else if (resourceType === "string-array") { + result[item.$.name] = (item.item || []) + .map((subItem) => { + if (typeof subItem === "string") return subItem; + if ("_" in subItem) return subItem._ || ""; + return ""; + }) + .filter(Boolean); + } else if (resourceType === "plurals") { + const pluralObj: Record = {}; + if (Array.isArray(item.item)) { + for (const subItem of item.item) { + if (typeof subItem === "object" && subItem.$?.quantity) { + pluralObj[subItem.$.quantity] = subItem._ || ""; + } + } + } + result[item.$.name] = pluralObj; + } else if (resourceType === "bool") { + result[item.$.name] = item._ === "true"; + } else if (resourceType === "integer") { + result[item.$.name] = Number.parseInt(item._ || "0", 10); + } + } + }; + + for (const type of ["string", "string-array", "plurals", "bool", "integer"]) { + processResource(type as keyof AndroidResources["resources"]); + } + + return result; +} + +function createAndroidResources( + data: Record, +): AndroidResources { + const xmlObj: AndroidResources = { resources: {} }; + + for (const [key, value] of Object.entries(data)) { + if (typeof value === "string") { + if (!xmlObj.resources.string) xmlObj.resources.string = []; + xmlObj.resources.string.push({ $: { name: key }, _: value }); + } else if (Array.isArray(value)) { + if (!xmlObj.resources["string-array"]) + xmlObj.resources["string-array"] = []; + xmlObj.resources["string-array"].push({ + $: { name: key }, + item: value.map((item) => ({ _: item })), + }); + } else if (typeof value === "object") { + if (!xmlObj.resources.plurals) xmlObj.resources.plurals = []; + xmlObj.resources.plurals.push({ + $: { name: key }, + item: Object.entries(value || {}).map(([quantity, text]) => ({ + $: { quantity }, + _: text as string, + })), + }); + } else if (typeof value === "boolean") { + if (!xmlObj.resources.bool) xmlObj.resources.bool = []; + xmlObj.resources.bool.push({ $: { name: key }, _: value.toString() }); + } else if (typeof value === "number") { + if (!xmlObj.resources.integer) xmlObj.resources.integer = []; + xmlObj.resources.integer.push({ $: { name: key }, _: value.toString() }); + } + } + + return xmlObj; +} + +export const android: Translator = { + async onUpdate(options) { + const sourceData = await parseXmlFile(options.content); + const previousData = await parseXmlFile(options.previousContent); + const previousTranslationData = await parseXmlFile( + options.previousTranslation, + ); + + const sourceStrings = JSON.stringify(processAndroidResources(sourceData)); + const previousStrings = JSON.stringify( + processAndroidResources(previousData), + ); + + if (sourceStrings === previousStrings) { + return { + summary: "No new strings to translate", + content: options.previousTranslation, + }; + } + + const { object } = await generateObject({ + model: options.model, + temperature: options.config.llm?.temperature ?? 0, + prompt: getPrompt(sourceStrings, options), + mode: "json", + schema: z.object({ + items: z.record(z.string(), z.unknown()).describe("Translated strings"), + }), + }); + + // Merge with previous translation data to preserve untranslated strings + const mergedTranslation = { + ...processAndroidResources(previousTranslationData), + ...object.items, + }; + + return { + summary: "Translated Android resources", + content: stringifyXmlFile(createAndroidResources(mergedTranslation)), + }; + }, + + async onNew(options) { + const sourceData = await parseXmlFile(options.content); + const sourceStrings = JSON.stringify(processAndroidResources(sourceData)); + + const { object } = await generateObject({ + model: options.model, + prompt: getPrompt(sourceStrings, options), + mode: "json", + temperature: options.config.llm?.temperature ?? 0, + schema: z.object({ + items: z.record(z.string(), z.unknown()).describe("Translated strings"), + }), + }); + + return { + content: stringifyXmlFile(createAndroidResources(object.items)), + }; + }, +}; + +function getPrompt(base: string, options: PromptOptions) { + const text = dedent` + ${baseRequirements} + - Preserve Android resource XML structure exactly + - Support translation of strings, string-arrays, plurals, bools, and integers + - Only translate text content, not resource names or attributes + - Return translations as a JSON object matching the original structure + - Maintain all format specifiers (like %1$s, %d) in the exact same order + - Preserve any HTML tags or special formatting in the strings + - Return the translations in the form of a JSON object specified in the schema + `; + + return createBasePrompt(`${text}\n${base}`, options); +} diff --git a/packages/cli/src/translators/index.ts b/packages/cli/src/translators/index.ts index a836970..1a831cf 100644 --- a/packages/cli/src/translators/index.ts +++ b/packages/cli/src/translators/index.ts @@ -1,9 +1,11 @@ import type { Translator } from "../types.js"; +import { android } from "./android.js"; import { javascript } from "./js.js"; import { json } from "./json.js"; import { markdown } from "./md.js"; import { po } from "./po.js"; import { xcodeStrings } from "./xcode-strings.js"; +import { xcodeStringsdict } from "./xcode-stringsdict.js"; import { xcodeXCStrings } from "./xcode-xcstrings.js"; import { yaml } from "./yaml.js"; @@ -28,15 +30,14 @@ export async function getTranslator( return xcodeStrings; case "xcode-xcstrings": return xcodeXCStrings; + case "xcode-stringsdict": + return xcodeStringsdict; case "po": return po; case "yaml": return yaml; - // case "xml": - // return xml; - // case "arb": - // return arb; - + case "android": + return android; default: return undefined; } diff --git a/packages/cli/src/translators/xcode-stringsdict.ts b/packages/cli/src/translators/xcode-stringsdict.ts new file mode 100644 index 0000000..f77c47e --- /dev/null +++ b/packages/cli/src/translators/xcode-stringsdict.ts @@ -0,0 +1,116 @@ +import { generateObject } from "ai"; +import dedent from "dedent"; +import plist, { type PlistValue } from "plist"; +import { z } from "zod"; +import { baseRequirements, createBasePrompt } from "../prompt.js"; +import type { PromptOptions, Translator } from "../types.js"; + +const EMPTY_STRINGSDICT = [ + '', + '', + '', + "", + "", +].join("\n"); + +function parseStringsdictFile(fileContent: string) { + try { + const parsedData = plist.parse(fileContent || EMPTY_STRINGSDICT); + if (typeof parsedData !== "object" || parsedData === null) { + throw new Error("Invalid .stringsdict format"); + } + return parsedData as Record; + } catch (error: unknown) { + throw new Error(`Invalid .stringsdict format: ${(error as Error).message}`); + } +} + +function buildStringsdictContent(translationData: Record) { + return plist.build(translationData); +} + +export const xcodeStringsdict: Translator = { + async onUpdate(options) { + const currentTranslations = parseStringsdictFile(options.content); + const oldTranslations = parseStringsdictFile(options.previousContent); + + const modifiedKeys = Object.keys(currentTranslations).filter((key) => { + if (!(key in oldTranslations)) return true; + return ( + JSON.stringify(oldTranslations[key]) !== + JSON.stringify(currentTranslations[key]) + ); + }); + + if (modifiedKeys.length === 0) { + return { + summary: "No new keys to translate", + content: options.previousTranslation, + }; + } + + const pendingTranslations = Object.fromEntries( + modifiedKeys.map((key) => [key, currentTranslations[key]]), + ); + + const { object } = await generateObject({ + model: options.model, + temperature: options.config.llm?.temperature ?? 0, + prompt: getPrompt(JSON.stringify(pendingTranslations, null, 2), options), + mode: "json", + schema: z.object({ + items: z.record(z.string(), z.unknown()).describe("Translated strings"), + }), + }); + + const existingTranslations = parseStringsdictFile( + options.previousTranslation, + ); + + return { + summary: `Translated ${modifiedKeys.length} new keys`, + content: buildStringsdictContent({ + ...existingTranslations, + ...(object.items as Record), + }), + }; + }, + + async onNew(options) { + const sourceTranslations = parseStringsdictFile(options.content); + + const { object } = await generateObject({ + model: options.model, + prompt: getPrompt(JSON.stringify(sourceTranslations, null, 2), options), + temperature: options.config.llm?.temperature ?? 0, + mode: "json", + schema: z.object({ + items: z.record(z.string(), z.unknown()).describe("Translated strings"), + }), + }); + + return { + content: buildStringsdictContent( + object.items as Record, + ), + }; + }, +}; + +function getPrompt(translationInput: string, options: PromptOptions) { + const promptRequirements = dedent` + ${baseRequirements} + - Preserve all key names exactly as they appear + - Only translate the values, not the keys + - Return translations as a JSON object matching the original structure + - Maintain all format specifiers like %@, %d, %lld, %1$@, etc. in the exact same order + - Preserve any HTML tags or special formatting in the strings + - Preserve all plural rules and NSStringFormatSpecTypeKey/NSStringFormatValueTypeKey values + - Return the translations in the form of a JSON object specified in the schema + `; + + return createBasePrompt( + `${promptRequirements}\n${translationInput}`, + options, + ); +} From e8461155a971496c2404199ded933e00ff55b637 Mon Sep 17 00:00:00 2001 From: Pontus Abrahamsson Date: Thu, 26 Dec 2024 10:53:59 +0100 Subject: [PATCH 4/5] Add support for xcode and android --- examples/android/locales/en.xml | 2 + examples/android/locales/es.xml | 96 ++++++++++--------- examples/i18next/languine.config.mjs | 3 +- examples/i18next/locales/sv.json | 2 +- examples/lingui/languine.config.mjs | 3 +- examples/next-intl/languine.config.mjs | 3 +- examples/po/locales/es.po | 2 +- .../Example/es.lproj/Localizable.strings | 4 +- .../Example/en.lproj/Localizable.stringsdict | 4 +- .../Example/es.lproj/Localizable.stringsdict | 2 + packages/cli/package.json | 2 +- packages/cli/src/commands/translate.ts | 6 +- packages/cli/src/translators/android.ts | 12 ++- .../cli/src/translators/xcode-stringsdict.ts | 1 + 14 files changed, 83 insertions(+), 59 deletions(-) diff --git a/examples/android/locales/en.xml b/examples/android/locales/en.xml index 7f0109c..c932723 100644 --- a/examples/android/locales/en.xml +++ b/examples/android/locales/en.xml @@ -63,4 +63,6 @@ %d achievement unlocked %d achievements unlocked + + Welcome to our application! \ No newline at end of file diff --git a/examples/android/locales/es.xml b/examples/android/locales/es.xml index febb32a..984fb51 100644 --- a/examples/android/locales/es.xml +++ b/examples/android/locales/es.xml @@ -1,48 +1,52 @@ + - Inicio - Buscar - Favoritos - Configuración - Mi Perfil - Editar Perfil - Guardar Cambios - Cerrar Sesión - Reproducir - Pausa - Siguiente Canción - Canción Anterior - Carrito de Compras - Total: $%1$.2f - Proceder al Pago - Temperatura Actual: %1$d° - Humedad: %1$d%% - Velocidad del Viento: %1$d mph - %1$s te ha enviado un mensaje - Nueva solicitud de amistad de %1$s - No se puede conectar a la red - Tiempo de solicitud agotado - Entrada inválida proporcionada - Evento: %1$s - Hora: %1$s - %2$s - Ubicación: %1$s - Puntuación: %1$d - Puntuación Más Alta: %1$d - Vidas: %1$d - - Tarjeta de Crédito - PayPal - Transferencia Bancaria - - - %d canción en cola - %d canciones en cola - - - %d persona le gustó tu publicación - %d personas les gustó tu publicación - - - %d logro desbloqueado - %d logros desbloqueados - + + Inicio + Buscar + Favoritos + Configuración + Mi Perfil + Editar Perfil + Guardar Cambios + Cerrar Sesión + Reproducir + Pausa + Siguiente Canción + Canción Anterior + Carrito de Compras + Total: $%1$.2f + Proceder al Pago + Temperatura Actual: %1$d° + Humedad: %1$d%% + Velocidad del Viento: %1$d mph + %1$s te ha enviado un mensaje + Nueva solicitud de amistad de %1$s + No se puede conectar a la red + Tiempo de solicitud agotado + Entrada inválida proporcionada + Evento: %1$s + Hora: %1$s - %2$s + Ubicación: %1$s + Puntuación: %1$d + Puntuación Más Alta: %1$d + Vidas: %1$d + ¡Bienvenido a nuestra aplicación! + + Tarjeta de Crédito + PayPal + Transferencia Bancaria + + + %d canción en cola + %d canciones en cola + + + %d persona le gustó tu publicación + %d personas les gustó tu publicación + + + %d logro desbloqueado + %d logros desbloqueados + + \ No newline at end of file diff --git a/examples/i18next/languine.config.mjs b/examples/i18next/languine.config.mjs index 75f46a5..4c7a6b3 100644 --- a/examples/i18next/languine.config.mjs +++ b/examples/i18next/languine.config.mjs @@ -9,7 +9,8 @@ export default { include: ["locales/[locale].json"], }, }, - openai: { + llm: { + provider: "openai", model: "gpt-4-turbo", }, }; diff --git a/examples/i18next/locales/sv.json b/examples/i18next/locales/sv.json index 76491c8..c878c82 100644 --- a/examples/i18next/locales/sv.json +++ b/examples/i18next/locales/sv.json @@ -6,4 +6,4 @@ "interpolated": "Ha en trevlig dag, {{name}}!", "pluralKey_one": "Det här är ett fint exempel.", "pluralKey_other": "Det här är fina exempel." -} +} \ No newline at end of file diff --git a/examples/lingui/languine.config.mjs b/examples/lingui/languine.config.mjs index dbc0a3e..0be4931 100644 --- a/examples/lingui/languine.config.mjs +++ b/examples/lingui/languine.config.mjs @@ -15,7 +15,8 @@ export default { include: ["locales/[locale].json"], }, }, - openai: { + llm: { + provider: "openai", model: "gpt-4-turbo", }, hooks: { diff --git a/examples/next-intl/languine.config.mjs b/examples/next-intl/languine.config.mjs index 8965acf..a032176 100644 --- a/examples/next-intl/languine.config.mjs +++ b/examples/next-intl/languine.config.mjs @@ -9,7 +9,8 @@ export default { include: ["messages/[locale].json"], }, }, - openai: { + llm: { + provider: "openai", model: "gpt-4-turbo", }, }; diff --git a/examples/po/locales/es.po b/examples/po/locales/es.po index 199a050..9a8d1e3 100644 --- a/examples/po/locales/es.po +++ b/examples/po/locales/es.po @@ -4,4 +4,4 @@ msgid "welcome_message" msgstr "Bienvenido a nuestra aplicación!" msgid "error_message" -msgstr "Ocurrió un error: {error_code}" \ No newline at end of file +msgstr "Se produjo un error: {error_code}" \ No newline at end of file diff --git a/examples/xcode-strings/Example/es.lproj/Localizable.strings b/examples/xcode-strings/Example/es.lproj/Localizable.strings index 3af230b..0d52d27 100644 --- a/examples/xcode-strings/Example/es.lproj/Localizable.strings +++ b/examples/xcode-strings/Example/es.lproj/Localizable.strings @@ -1,10 +1,10 @@ -"welcome_message" = "¡Bienvenido a la aplicación del tiempo!"; +"welcome_message" = "¡Bienvenido a Weather App!"; "temperature_format" = "%1$.1f°%2$@"; "wind_speed" = "Viento: %1$.1f %2$@"; "forecast_daily" = "Pronóstico diario para %@"; "notification_body" = "Alerta de tormenta para la región de %1$@\nSe esperan velocidades de viento de hasta %2$d mph"; "battery_empty" = "Batería agotada"; -"battery_low" = "Batería al %d%% - por favor, carga pronto"; +"battery_low" = "Batería al %d%% - por favor carga pronto"; "battery_full" = "Batería completamente cargada"; "photo_count_zero" = "No hay fotos"; "photo_count_one" = "%d foto"; diff --git a/examples/xcode-stringsdict/Example/en.lproj/Localizable.stringsdict b/examples/xcode-stringsdict/Example/en.lproj/Localizable.stringsdict index 405e028..466ff0c 100644 --- a/examples/xcode-stringsdict/Example/en.lproj/Localizable.stringsdict +++ b/examples/xcode-stringsdict/Example/en.lproj/Localizable.stringsdict @@ -85,5 +85,7 @@ Time remaining: %1$@ (%2$lld seconds) version_info Version %1$@ (Build %2$@) + test + Test - \ No newline at end of file + diff --git a/examples/xcode-stringsdict/Example/es.lproj/Localizable.stringsdict b/examples/xcode-stringsdict/Example/es.lproj/Localizable.stringsdict index cf3808f..c8f5549 100644 --- a/examples/xcode-stringsdict/Example/es.lproj/Localizable.stringsdict +++ b/examples/xcode-stringsdict/Example/es.lproj/Localizable.stringsdict @@ -85,5 +85,7 @@ Tiempo restante: %1$@ (%2$lld segundos) version_info Versión %1$@ (Construcción %2$@) + test + Prueba \ No newline at end of file diff --git a/packages/cli/package.json b/packages/cli/package.json index 0d5754b..af5c4f4 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "languine", - "version": "0.6.2", + "version": "0.6.3", "type": "module", "bin": "dist/index.js", "main": "dist/index.js", diff --git a/packages/cli/src/commands/translate.ts b/packages/cli/src/commands/translate.ts index e1dc2c3..cee282c 100644 --- a/packages/cli/src/commands/translate.ts +++ b/packages/cli/src/commands/translate.ts @@ -69,7 +69,11 @@ export async function translate(targetLocale?: string, force = false) { let previousContent = ""; if (!force) { - previousContent = await git.show(sourcePath).catch(() => ""); + previousContent = await git + .show([`HEAD:${sourcePath}`]) + .catch(() => ""); + + console.log(previousContent); if (previousContent === sourceContent) return { locale, sourcePath, success: true, noChanges: true }; diff --git a/packages/cli/src/translators/android.ts b/packages/cli/src/translators/android.ts index 11119f9..5a42861 100644 --- a/packages/cli/src/translators/android.ts +++ b/packages/cli/src/translators/android.ts @@ -36,15 +36,21 @@ async function parseXmlFile(content: string): Promise { trim: true, })) as AndroidResources; return parsed; - } catch (error) { - console.error("Failed to parse Android XML:", error); + } catch { return { resources: {} }; } } function stringifyXmlFile(data: AndroidResources): string { try { - const builder = new Builder({ headless: true }); + const builder = new Builder({ + headless: false, + xmldec: { version: "1.0", encoding: "utf-8" }, + renderOpts: { pretty: true, indent: " " }, + // Preserve comments from source XML + rootName: "resources", + cdata: true, + }); const xmlOutput = builder.buildObject(data); return xmlOutput; } catch (error) { diff --git a/packages/cli/src/translators/xcode-stringsdict.ts b/packages/cli/src/translators/xcode-stringsdict.ts index f77c47e..bbb6769 100644 --- a/packages/cli/src/translators/xcode-stringsdict.ts +++ b/packages/cli/src/translators/xcode-stringsdict.ts @@ -16,6 +16,7 @@ const EMPTY_STRINGSDICT = [ function parseStringsdictFile(fileContent: string) { try { const parsedData = plist.parse(fileContent || EMPTY_STRINGSDICT); + if (typeof parsedData !== "object" || parsedData === null) { throw new Error("Invalid .stringsdict format"); } From 2d80bfe2e4eb73ed010f4ca4fa87d6a7f32bf4d9 Mon Sep 17 00:00:00 2001 From: Pontus Abrahamsson Date: Thu, 26 Dec 2024 11:04:23 +0100 Subject: [PATCH 5/5] Update all examples --- .../Example/Localizable.xcstrings | 28 +++++++++---------- examples/xcode-xcstrings/languine.config.mjs | 4 +-- packages/cli/package.json | 1 - .../cli/src/translators/xcode-xcstrings.ts | 22 +++++++++++++-- 4 files changed, 35 insertions(+), 20 deletions(-) diff --git a/examples/xcode-xcstrings/Example/Localizable.xcstrings b/examples/xcode-xcstrings/Example/Localizable.xcstrings index 75a373a..d0a54c9 100644 --- a/examples/xcode-xcstrings/Example/Localizable.xcstrings +++ b/examples/xcode-xcstrings/Example/Localizable.xcstrings @@ -2,79 +2,79 @@ "version": "1.0", "sourceLanguage": "en", "strings": { - "app_title": { + "welcome_message": { "extractionState": "manual", "localizations": { "de": { "stringUnit": { "state": "translated", - "value": "Aufgabenmanager Pro" + "value": "Willkommen bei der App" } } } }, - "task_due_date": { + "user_greeting": { "extractionState": "manual", "localizations": { "de": { "stringUnit": { "state": "translated", - "value": "Fällig: %@" + "value": "Hallo %@" } } } }, - "task_count": { + "items_count": { "extractionState": "manual", "localizations": { "de": { "stringUnit": { "state": "translated", - "value": "Keine Aufgaben" + "value": "Keine Einträge" } } } }, - "reminder_time": { + "notification_time": { "extractionState": "manual", "localizations": { "de": { "stringUnit": { "state": "translated", - "value": "%lld Aufgabe" + "value": "In %lld Minute" } } } }, - "task_details": { + "item_details": { "extractionState": "manual", "localizations": { "de": { "stringUnit": { "state": "translated", - "value": "%lld Aufgaben" + "value": "%lld Details anzeigen" } } } }, - "last_modified": { + "modified_date": { "extractionState": "manual", "localizations": { "de": { "stringUnit": { "state": "translated", - "value": "%lld Aufgaben" + "value": "Zuletzt geändert: %lld" } } } }, - "task_description": { + "item_description": { "extractionState": "manual", "localizations": { "de": { "stringUnit": { "state": "translated", - "value": "%lld Aufgaben" + "value": "Beschreibung: %lld" } } } diff --git a/examples/xcode-xcstrings/languine.config.mjs b/examples/xcode-xcstrings/languine.config.mjs index 7221154..b4e154e 100644 --- a/examples/xcode-xcstrings/languine.config.mjs +++ b/examples/xcode-xcstrings/languine.config.mjs @@ -2,7 +2,7 @@ export default { version: "0.6.2", locale: { source: "en", - targets: ["de"], + targets: ["de", "fr"], }, files: { "xcode-xcstrings": { @@ -13,4 +13,4 @@ export default { provider: "openai", model: "gpt-4-turbo", }, -} \ No newline at end of file +}; diff --git a/packages/cli/package.json b/packages/cli/package.json index af5c4f4..69e798b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -29,7 +29,6 @@ "ollama": "^0.5.11", "ollama-ai-provider": "^1.1.0", "plist": "^3.1.0", - "rambda": "^9.4.1", "simple-git": "^3.27.0", "xml2js": "^0.6.2", "yaml": "^2.6.1", diff --git a/packages/cli/src/translators/xcode-xcstrings.ts b/packages/cli/src/translators/xcode-xcstrings.ts index 69d2785..cddc6c9 100644 --- a/packages/cli/src/translators/xcode-xcstrings.ts +++ b/packages/cli/src/translators/xcode-xcstrings.ts @@ -1,6 +1,5 @@ import { generateObject } from "ai"; import dedent from "dedent"; -import { merge } from "rambda"; import { z } from "zod"; import { baseRequirements, createBasePrompt } from "../prompt.js"; import type { PromptOptions, Translator } from "../types.js"; @@ -23,6 +22,8 @@ interface TranslationEntity { } interface XCStringsData { + version: string; + sourceLanguage: string; strings: Record; } @@ -66,7 +67,12 @@ function stringifyXcodeXCStrings( locale: string, originalContent: string, ) { - const langDataToMerge: XCStringsData = { strings: {} }; + const originalData = JSON.parse(originalContent) as XCStringsData; + const langDataToMerge: XCStringsData = { + version: originalData.version, + sourceLanguage: originalData.sourceLanguage, + strings: {}, + }; for (const [key, value] of Object.entries(strings)) { if (typeof value === "string") { @@ -106,7 +112,15 @@ function stringifyXcodeXCStrings( } } - const result = merge(JSON.parse(originalContent), langDataToMerge); + const result: XCStringsData = { + version: originalData.version, + sourceLanguage: originalData.sourceLanguage, + strings: { + ...originalData.strings, + ...langDataToMerge.strings, + }, + }; + return JSON.stringify(result, null, 2); } @@ -143,6 +157,7 @@ export const xcodeXCStrings: Translator = { const { object } = await generateObject({ model: options.model, temperature: options.config.llm?.temperature ?? 0, + mode: "json", prompt: getPrompt(JSON.stringify(toTranslate, null, 2), options), schema: z.object({ items: z.array( @@ -187,6 +202,7 @@ export const xcodeXCStrings: Translator = { model: options.model, prompt: getPrompt(JSON.stringify(sourceStrings, null, 2), options), temperature: options.config.llm?.temperature ?? 0, + mode: "json", schema: z.object({ items: z.array( z