From 5ade9734463b48ad75858031427f52459c4e52c0 Mon Sep 17 00:00:00 2001 From: Emile Nijssen Date: Tue, 16 Jul 2024 13:15:03 +0200 Subject: [PATCH 1/6] wip --- bin/cmds/app/translate.js | 36 +++++++++++ lib/App.js | 122 +++++++++++++++++++++++++++++++++++++ package-lock.json | 125 ++++++++++++++++++++++++++++++++++++++ package.json | 1 + 4 files changed, 284 insertions(+) create mode 100644 bin/cmds/app/translate.js diff --git a/bin/cmds/app/translate.js b/bin/cmds/app/translate.js new file mode 100644 index 00000000..5ec08299 --- /dev/null +++ b/bin/cmds/app/translate.js @@ -0,0 +1,36 @@ +'use strict'; + +const Log = require('../../../lib/Log'); +const App = require('../../../lib/App'); + +exports.desc = 'Translate a Homey App with OpenAI'; +exports.builder = yargs => { + return yargs + .option('languages', { + default: ['nl', 'da', 'de', 'es', 'fr', 'it', 'no', 'sv', 'pl', 'ru', 'ko'].join(','), + type: 'string', + description: 'Comma-seperated list of languages to translate to.', + }) + .option('api-key', { + default: process.env.OPENAI_API_KEY, + type: 'string', + description: 'OpenAI API key. You can create an API Key on https://platform.openai.com/api-keys.', + }); +}; +exports.handler = async yargs => { + try { + const app = new App(yargs.path); + await app.translateWithOpenAI({ + languages: yargs.languages.split(',').map(lang => lang.trim()), + apiKey: yargs.apiKey, + }); + await app.preprocess(); + await app.validate({ + level: yargs.level, + }); + process.exit(0); + } catch (err) { + Log.error(err); + process.exit(1); + } +}; diff --git a/lib/App.js b/lib/App.js index 466ba490..52dfe471 100644 --- a/lib/App.js +++ b/lib/App.js @@ -31,6 +31,7 @@ const SocketIOClient = require('socket.io-client'); const express = require('express'); const childProcess = require('child_process'); const isWsl = require('is-wsl'); +const OpenAI = require('openai'); const AthomApi = require('../services/AthomApi'); const Settings = require('../services/Settings'); @@ -2916,6 +2917,127 @@ $ sudo systemctl restart docker Log(`\n\tLearn more about Homey App development at: ${colors.underline('https://apps.developer.homey.app')}\n`); } + async translateWithOpenAI({ + languages = [], + apiKey = process.env.OPENAI_API_KEY, + }) { + // Validate languages + if (languages.includes('en')) { + throw new Error('You cannot translate to English, as it is the default language.'); + } + + // Validate API Key + if (!apiKey) { + throw new Error(`Missing OPENAI_API_KEY in the environment variables. Try running 'OPENAI_API_KEY="..." homey app translate'. + +You can create an API Key on https://platform.openai.com/api-keys.`); + } + + // Create OpenAI instance + const openai = new OpenAI({ apiKey }); + + // Find & translate all .json files recursively + const jsonFiles = []; + + if (App.hasHomeyCompose({ appPath: this.path })) { + jsonFiles.push(path.join(this.path, '.homeycompose', 'app.json')); + } else { + jsonFiles.push(path.join(this.path, 'app.json')); + } + + const walkSync = async dir => { + const files = await fs.promises.readdir(dir); + await Promise.all(files.map(async file => { + const filePath = `${dir}/${file}`; + const fileStats = await fs.promises.stat(filePath); + + if (fileStats.isDirectory()) { + if (file === 'locales') return; + if (file === 'node_modules') return; + if (file === '.homeybuild') return; + if (file === '.homeycompose') { + jsonFiles.push(path.normalize(`${filePath}/app.json`)); + } + + await walkSync(filePath); + } + + if (fileStats.isFile() && filePath.endsWith('.json')) { + if (file.startsWith('.')) return; + if (file === 'app.json') return; + if (file === 'env.json') return; + if (file === 'package.json') return; + if (file === 'package-lock.json') return; + + jsonFiles.push(path.normalize(filePath)); + } + })); + }; + + await walkSync(this.path); + + await Promise.all(jsonFiles.slice(0, 1).map(async file => { + try { + const content = await fs.promises.readFile(file, 'utf8'); + + const chatResult = await openai.chat.completions.create({ + messages: [{ + role: 'user', + content: ` + Translate the JSON objects, but only those where an "en" property is present. Other two-letter keys are allowed. If there's any other key, skip the object. + Translate to ${languages.join(', ')}. // + Tone of voice: casual, friendly, helpful. + Don't overwrite a key if one of these already exists. + Don't touch any other properties. + If no translations have been made, return the original JSON file. + Only output the new JSON file. No text. Don't format it as JSON. + + --- + ${content}`, + }], + model: 'gpt-4o', + }); + + await fs.promises.writeFile(file, chatResult.choices[0].message.content); + + Log(`✅ ${file}`); + } catch (err) { + Log(`❌ ${file}`); + Log(err); + } + })); + + // Translate README.txt + const readme = await fs.promises.readFile(path.join(this.path, 'README.txt'), 'utf8'); + await Promise.all(Object.values(languages).map(async language => { + try { + const translatedReadmePath = path.join(this.path, `README.${language}.txt`); + const translationExists = !!await fs.promises.stat(translatedReadmePath).catch(() => null); + if (translationExists) return; + + const chatResult = await openai.chat.completions.create({ + messages: [{ + role: 'user', + content: ` + Translate this text, starting directly after ---, to the language code: ${language}. + Tone of voice: casual, friendly, helpful. + Only output the new README file. No text. Don't format it as code. + + --- + ${readme}`, + }], + model: 'gpt-4o', + }); + // console.log(language, translationExists); + await fs.promises.writeFile(translatedReadmePath, chatResult.choices[0].message.content); + Log(`✅ README.${language}.txt`); + } catch (err) { + Log(`❌ README.${language}.txt`); + Log(err); + } + })); + } + static async addTypes({ appPath, useTs }) { if (useTs) { await NpmCommands.installDev([ diff --git a/package-lock.json b/package-lock.json index b4c767fc..d52234bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "node-fetch": "^2.7.0", "object-path": "^0.11.4", "open": "^8.4.2", + "openai": "^4.52.7", "semver": "^7.6.0", "socket.io": "^4.7.5", "socket.io-client": "^2.4.0", @@ -435,6 +436,26 @@ "undici-types": "~5.26.4" } }, + "node_modules/@types/node-fetch": { + "version": "2.6.11", + "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.11.tgz", + "integrity": "sha512-24xFj9R5+rfQJLRyM56qh+wnVSYhyXC2tkoBndtY0U+vubqNsYXGjufB2nn8Q6gt0LrARwL6UBtMCSVCwl4B1g==", + "dependencies": { + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -471,6 +492,17 @@ "resolved": "https://registry.npmjs.org/after/-/after-0.8.2.tgz", "integrity": "sha512-QbJ0NTQ/I9DI3uSJA4cbexiwQeRAfjPScqIbSjUDd9TOrcg6pTkdgziesOqxBMBzit8vFCTwrP27t13vFOORRA==" }, + "node_modules/agentkeepalive": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.5.0.tgz", + "integrity": "sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==", + "dependencies": { + "humanize-ms": "^1.2.1" + }, + "engines": { + "node": ">= 8.0.0" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -2224,6 +2256,14 @@ "node": ">= 0.6" } }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "engines": { + "node": ">=6" + } + }, "node_modules/express": { "version": "4.19.2", "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", @@ -2423,6 +2463,31 @@ "node": ">= 6" } }, + "node_modules/form-data-encoder": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", + "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==" + }, + "node_modules/formdata-node": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", + "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", + "dependencies": { + "node-domexception": "1.0.0", + "web-streams-polyfill": "4.0.0-beta.3" + }, + "engines": { + "node": ">= 12.20" + } + }, + "node_modules/formdata-node/node_modules/web-streams-polyfill": { + "version": "4.0.0-beta.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", + "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", + "engines": { + "node": ">= 14" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -2781,6 +2846,14 @@ "node": ">= 0.8" } }, + "node_modules/humanize-ms": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", + "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", + "dependencies": { + "ms": "^2.0.0" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -3544,6 +3617,24 @@ "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==" }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -3723,6 +3814,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openai": { + "version": "4.52.7", + "resolved": "https://registry.npmjs.org/openai/-/openai-4.52.7.tgz", + "integrity": "sha512-dgxA6UZHary6NXUHEDj5TWt8ogv0+ibH+b4pT5RrWMjiRZVylNwLcw/2ubDrX5n0oUmHX/ZgudMJeemxzOvz7A==", + "dependencies": { + "@types/node": "^18.11.18", + "@types/node-fetch": "^2.6.4", + "abort-controller": "^3.0.0", + "agentkeepalive": "^4.2.1", + "form-data-encoder": "1.7.2", + "formdata-node": "^4.3.2", + "node-fetch": "^2.6.7", + "web-streams-polyfill": "^3.2.1" + }, + "bin": { + "openai": "bin/cli" + } + }, + "node_modules/openai/node_modules/@types/node": { + "version": "18.19.39", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.39.tgz", + "integrity": "sha512-nPwTRDKUctxw3di5b4TfT3I0sWDiWoPQCZjXhvdkINntwr8lcoVCKsTgnXeRubKIlfnV+eN/HYk6Jb40tbcEAQ==", + "dependencies": { + "undici-types": "~5.26.4" + } + }, "node_modules/optionator": { "version": "0.8.3", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", @@ -5074,6 +5191,14 @@ "defaults": "^1.0.3" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "engines": { + "node": ">= 8" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/package.json b/package.json index aa57a8ca..2324c14a 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "node-fetch": "^2.7.0", "object-path": "^0.11.4", "open": "^8.4.2", + "openai": "^4.52.7", "semver": "^7.6.0", "socket.io": "^4.7.5", "socket.io-client": "^2.4.0", From 3a077cc471b84996dfc898efc0475dc8b86bd349 Mon Sep 17 00:00:00 2001 From: Emile Nijssen Date: Tue, 16 Jul 2024 13:23:12 +0200 Subject: [PATCH 2/6] wip --- lib/App.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/App.js b/lib/App.js index 52dfe471..848966d0 100644 --- a/lib/App.js +++ b/lib/App.js @@ -2976,7 +2976,7 @@ You can create an API Key on https://platform.openai.com/api-keys.`); await walkSync(this.path); - await Promise.all(jsonFiles.slice(0, 1).map(async file => { + await Promise.all(jsonFiles.map(async file => { try { const content = await fs.promises.readFile(file, 'utf8'); @@ -3000,9 +3000,9 @@ You can create an API Key on https://platform.openai.com/api-keys.`); await fs.promises.writeFile(file, chatResult.choices[0].message.content); - Log(`✅ ${file}`); + Log(`✅ Translated ${file}`); } catch (err) { - Log(`❌ ${file}`); + Log(`❌ Error translating ${file}`); Log(err); } })); @@ -3030,9 +3030,9 @@ You can create an API Key on https://platform.openai.com/api-keys.`); }); // console.log(language, translationExists); await fs.promises.writeFile(translatedReadmePath, chatResult.choices[0].message.content); - Log(`✅ README.${language}.txt`); + Log(`✅ Translated README.${language}.txt`); } catch (err) { - Log(`❌ README.${language}.txt`); + Log(`❌ Error translating README.${language}.txt`); Log(err); } })); From 300750a79dffa33e66fb5d57e87f19dce421af9d Mon Sep 17 00:00:00 2001 From: Emile Nijssen Date: Tue, 16 Jul 2024 13:35:11 +0200 Subject: [PATCH 3/6] wip --- bin/cmds/app/translate.js | 12 ++++++++++++ lib/App.js | 12 ++++++++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/bin/cmds/app/translate.js b/bin/cmds/app/translate.js index 5ec08299..6bc22609 100644 --- a/bin/cmds/app/translate.js +++ b/bin/cmds/app/translate.js @@ -1,5 +1,7 @@ 'use strict'; +const colors = require('colors'); + const Log = require('../../../lib/Log'); const App = require('../../../lib/App'); @@ -15,6 +17,11 @@ exports.builder = yargs => { default: process.env.OPENAI_API_KEY, type: 'string', description: 'OpenAI API key. You can create an API Key on https://platform.openai.com/api-keys.', + }) + .option('model', { + default: 'gpt-4o', + type: 'string', + description: 'OpenAI model to use.', }); }; exports.handler = async yargs => { @@ -23,11 +30,16 @@ exports.handler = async yargs => { await app.translateWithOpenAI({ languages: yargs.languages.split(',').map(lang => lang.trim()), apiKey: yargs.apiKey, + model: yargs.model, }); await app.preprocess(); await app.validate({ level: yargs.level, }); + + Log(''); + Log(colors.yellow('The app has been translated using AI, so results may vary. Please check every file manually before committing.')); + process.exit(0); } catch (err) { Log.error(err); diff --git a/lib/App.js b/lib/App.js index 848966d0..9361de43 100644 --- a/lib/App.js +++ b/lib/App.js @@ -2920,6 +2920,7 @@ $ sudo systemctl restart docker async translateWithOpenAI({ languages = [], apiKey = process.env.OPENAI_API_KEY, + model = 'gpt-4o', }) { // Validate languages if (languages.includes('en')) { @@ -2976,11 +2977,14 @@ You can create an API Key on https://platform.openai.com/api-keys.`); await walkSync(this.path); + Log(`Found ${jsonFiles.length} JSON files to translate...`); + await Promise.all(jsonFiles.map(async file => { try { const content = await fs.promises.readFile(file, 'utf8'); const chatResult = await openai.chat.completions.create({ + model, messages: [{ role: 'user', content: ` @@ -2988,17 +2992,17 @@ You can create an API Key on https://platform.openai.com/api-keys.`); Translate to ${languages.join(', ')}. // Tone of voice: casual, friendly, helpful. Don't overwrite a key if one of these already exists. - Don't touch any other properties. + Don't touch any other JSON properties. + The return amount of keys should be the same as the input, excluding translated keys of course. If no translations have been made, return the original JSON file. Only output the new JSON file. No text. Don't format it as JSON. --- ${content}`, }], - model: 'gpt-4o', }); - await fs.promises.writeFile(file, chatResult.choices[0].message.content); + await fs.promises.writeFile(file, JSON.stringify(JSON.parse(chatResult.choices[0].message.content), false, 2)); Log(`✅ Translated ${file}`); } catch (err) { @@ -3016,6 +3020,7 @@ You can create an API Key on https://platform.openai.com/api-keys.`); if (translationExists) return; const chatResult = await openai.chat.completions.create({ + model, messages: [{ role: 'user', content: ` @@ -3026,7 +3031,6 @@ You can create an API Key on https://platform.openai.com/api-keys.`); --- ${readme}`, }], - model: 'gpt-4o', }); // console.log(language, translationExists); await fs.promises.writeFile(translatedReadmePath, chatResult.choices[0].message.content); From def375ac7770766d76a1b25a257a6dd4a6cde48b Mon Sep 17 00:00:00 2001 From: Emile Nijssen Date: Tue, 16 Jul 2024 13:39:58 +0200 Subject: [PATCH 4/6] wip --- lib/App.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/App.js b/lib/App.js index 9361de43..bc0e10ea 100644 --- a/lib/App.js +++ b/lib/App.js @@ -2938,12 +2938,12 @@ You can create an API Key on https://platform.openai.com/api-keys.`); const openai = new OpenAI({ apiKey }); // Find & translate all .json files recursively - const jsonFiles = []; + const jsonFiles = new Set(); if (App.hasHomeyCompose({ appPath: this.path })) { - jsonFiles.push(path.join(this.path, '.homeycompose', 'app.json')); + jsonFiles.add(path.join(this.path, '.homeycompose', 'app.json')); } else { - jsonFiles.push(path.join(this.path, 'app.json')); + jsonFiles.add(path.join(this.path, 'app.json')); } const walkSync = async dir => { @@ -2957,7 +2957,7 @@ You can create an API Key on https://platform.openai.com/api-keys.`); if (file === 'node_modules') return; if (file === '.homeybuild') return; if (file === '.homeycompose') { - jsonFiles.push(path.normalize(`${filePath}/app.json`)); + jsonFiles.add(path.normalize(`${filePath}/app.json`)); } await walkSync(filePath); @@ -2970,16 +2970,16 @@ You can create an API Key on https://platform.openai.com/api-keys.`); if (file === 'package.json') return; if (file === 'package-lock.json') return; - jsonFiles.push(path.normalize(filePath)); + jsonFiles.add(path.normalize(filePath)); } })); }; await walkSync(this.path); - Log(`Found ${jsonFiles.length} JSON files to translate...`); + Log(`Found ${jsonFiles.size} JSON files to translate...`); - await Promise.all(jsonFiles.map(async file => { + await Promise.all([...jsonFiles].map(async file => { try { const content = await fs.promises.readFile(file, 'utf8'); From bec776c46805580ac1f1a6cbbb3301c9fcc1c463 Mon Sep 17 00:00:00 2001 From: Emile Nijssen Date: Tue, 16 Jul 2024 15:34:06 +0200 Subject: [PATCH 5/6] wip --- lib/App.js | 112 +++++++++++++++++++++++++--------------------- package-lock.json | 40 +++++++++++++++++ package.json | 1 + 3 files changed, 102 insertions(+), 51 deletions(-) diff --git a/lib/App.js b/lib/App.js index bc0e10ea..170ad9ac 100644 --- a/lib/App.js +++ b/lib/App.js @@ -32,6 +32,7 @@ const express = require('express'); const childProcess = require('child_process'); const isWsl = require('is-wsl'); const OpenAI = require('openai'); +const PQueue = require('p-queue').default; const AthomApi = require('../services/AthomApi'); const Settings = require('../services/Settings'); @@ -2936,6 +2937,7 @@ You can create an API Key on https://platform.openai.com/api-keys.`); // Create OpenAI instance const openai = new OpenAI({ apiKey }); + const pqueue = new PQueue({ concurrency: 10 }); // Find & translate all .json files recursively const jsonFiles = new Set(); @@ -2980,65 +2982,73 @@ You can create an API Key on https://platform.openai.com/api-keys.`); Log(`Found ${jsonFiles.size} JSON files to translate...`); await Promise.all([...jsonFiles].map(async file => { - try { - const content = await fs.promises.readFile(file, 'utf8'); - - const chatResult = await openai.chat.completions.create({ - model, - messages: [{ - role: 'user', - content: ` - Translate the JSON objects, but only those where an "en" property is present. Other two-letter keys are allowed. If there's any other key, skip the object. - Translate to ${languages.join(', ')}. // - Tone of voice: casual, friendly, helpful. - Don't overwrite a key if one of these already exists. - Don't touch any other JSON properties. - The return amount of keys should be the same as the input, excluding translated keys of course. - If no translations have been made, return the original JSON file. - Only output the new JSON file. No text. Don't format it as JSON. - - --- - ${content}`, - }], - }); + return pqueue.add(async () => { + try { + const content = await fs.promises.readFile(file, 'utf8'); + + const chatResult = await openai.chat.completions.create({ + model, + messages: [{ + role: 'user', + content: ` +This JSON structure may or may note have JSON Objects with "en", "nl", etc. as keys. We call them translation objects. +Your job is to translate these to the following languages: ${languages.join(', ')} +These are your constraints: +— Only translate objects where an "en" property is present. Use "en" as the base translation. +— Don't overwrite a key if one of these already exists. For example "nl". +— Never translate IDs between [[ and ]]. +— Tone of voice: casual, friendly, helpful. +— Always return the original file exactly as submitted. The only changes may be newly added languages. +— Only output the new JSON file. No text. Don't format it as JSON. Don't output === START FILE ===. + +=== START FILE === +${content}`, + }], + }); - await fs.promises.writeFile(file, JSON.stringify(JSON.parse(chatResult.choices[0].message.content), false, 2)); + await fs.promises.writeFile(file, JSON.stringify(JSON.parse(chatResult.choices[0].message.content), false, 2)); - Log(`✅ Translated ${file}`); - } catch (err) { - Log(`❌ Error translating ${file}`); - Log(err); - } + Log(`✅ Translated ${file}`); + } catch (err) { + Log(`❌ Error translating ${file}`); + Log(err); + } + }); })); // Translate README.txt const readme = await fs.promises.readFile(path.join(this.path, 'README.txt'), 'utf8'); await Promise.all(Object.values(languages).map(async language => { - try { + return pqueue.add(async () => { const translatedReadmePath = path.join(this.path, `README.${language}.txt`); - const translationExists = !!await fs.promises.stat(translatedReadmePath).catch(() => null); - if (translationExists) return; - - const chatResult = await openai.chat.completions.create({ - model, - messages: [{ - role: 'user', - content: ` - Translate this text, starting directly after ---, to the language code: ${language}. - Tone of voice: casual, friendly, helpful. - Only output the new README file. No text. Don't format it as code. - - --- - ${readme}`, - }], - }); - // console.log(language, translationExists); - await fs.promises.writeFile(translatedReadmePath, chatResult.choices[0].message.content); - Log(`✅ Translated README.${language}.txt`); - } catch (err) { - Log(`❌ Error translating README.${language}.txt`); - Log(err); - } + + try { + const translationExists = !!await fs.promises.stat(translatedReadmePath).catch(() => null); + if (translationExists) return; + + const chatResult = await openai.chat.completions.create({ + model, + messages: [{ + role: 'user', + content: ` +This text is a description of an integration published on the Homey App Store. +Your job is to translate this text to the following language: ${language} +These are your constraints: +— Tone of voice: casual, friendly, helpful. +— Only output the new text. No other words. Don't format it. Don't output === START FILE ===. + +=== START FILE === +${readme}`, + }], + }); + + await fs.promises.writeFile(translatedReadmePath, chatResult.choices[0].message.content); + Log(`✅ Translated ${translatedReadmePath}`); + } catch (err) { + Log(`❌ Error translating ${translatedReadmePath}`); + Log(err); + } + }); })); } diff --git a/package-lock.json b/package-lock.json index d52234bd..5c3fa90b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "object-path": "^0.11.4", "open": "^8.4.2", "openai": "^4.52.7", + "p-queue": "^6.6.2", "semver": "^7.6.0", "socket.io": "^4.7.5", "socket.io-client": "^2.4.0", @@ -2264,6 +2265,11 @@ "node": ">=6" } }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + }, "node_modules/express": { "version": "4.19.2", "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", @@ -3894,6 +3900,14 @@ "node": ">=6" } }, + "node_modules/p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "engines": { + "node": ">=4" + } + }, "node_modules/p-limit": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz", @@ -3918,6 +3932,32 @@ "node": ">=4" } }, + "node_modules/p-queue": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz", + "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", + "dependencies": { + "eventemitter3": "^4.0.4", + "p-timeout": "^3.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "dependencies": { + "p-finally": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/p-try": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", diff --git a/package.json b/package.json index 2324c14a..f67adce6 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "object-path": "^0.11.4", "open": "^8.4.2", "openai": "^4.52.7", + "p-queue": "^6.6.2", "semver": "^7.6.0", "socket.io": "^4.7.5", "socket.io-client": "^2.4.0", From 6db2cbcfcec423cd3c9f9084bfc51dda6e5286e8 Mon Sep 17 00:00:00 2001 From: Emile Nijssen Date: Tue, 16 Jul 2024 15:45:21 +0200 Subject: [PATCH 6/6] support `homey app translate --file` --- bin/cmds/app/translate.js | 5 +++ lib/App.js | 69 +++++++++++++++++++++++---------------- 2 files changed, 45 insertions(+), 29 deletions(-) diff --git a/bin/cmds/app/translate.js b/bin/cmds/app/translate.js index 6bc22609..d9f9ccaa 100644 --- a/bin/cmds/app/translate.js +++ b/bin/cmds/app/translate.js @@ -22,6 +22,10 @@ exports.builder = yargs => { default: 'gpt-4o', type: 'string', description: 'OpenAI model to use.', + }) + .option('file', { + type: 'string', + description: 'Absolute path to a single file to translate, instead of automatically translating the entire folder.', }); }; exports.handler = async yargs => { @@ -31,6 +35,7 @@ exports.handler = async yargs => { languages: yargs.languages.split(',').map(lang => lang.trim()), apiKey: yargs.apiKey, model: yargs.model, + file: yargs.file, }); await app.preprocess(); await app.validate({ diff --git a/lib/App.js b/lib/App.js index 170ad9ac..c2923f18 100644 --- a/lib/App.js +++ b/lib/App.js @@ -2922,6 +2922,7 @@ $ sudo systemctl restart docker languages = [], apiKey = process.env.OPENAI_API_KEY, model = 'gpt-4o', + file = null, }) { // Validate languages if (languages.includes('en')) { @@ -2942,42 +2943,50 @@ You can create an API Key on https://platform.openai.com/api-keys.`); // Find & translate all .json files recursively const jsonFiles = new Set(); - if (App.hasHomeyCompose({ appPath: this.path })) { - jsonFiles.add(path.join(this.path, '.homeycompose', 'app.json')); + if (file) { + if (!file.endsWith('.json')) { + throw new Error('--file only supports a .json file.'); + } + + jsonFiles.add(file); } else { - jsonFiles.add(path.join(this.path, 'app.json')); - } + if (App.hasHomeyCompose({ appPath: this.path })) { + jsonFiles.add(path.join(this.path, '.homeycompose', 'app.json')); + } else { + jsonFiles.add(path.join(this.path, 'app.json')); + } - const walkSync = async dir => { - const files = await fs.promises.readdir(dir); - await Promise.all(files.map(async file => { - const filePath = `${dir}/${file}`; - const fileStats = await fs.promises.stat(filePath); + const walkSync = async dir => { + const files = await fs.promises.readdir(dir); + await Promise.all(files.map(async file => { + const filePath = `${dir}/${file}`; + const fileStats = await fs.promises.stat(filePath); + + if (fileStats.isDirectory()) { + if (file === 'locales') return; + if (file === 'node_modules') return; + if (file === '.homeybuild') return; + if (file === '.homeycompose') { + jsonFiles.add(path.normalize(`${filePath}/app.json`)); + } - if (fileStats.isDirectory()) { - if (file === 'locales') return; - if (file === 'node_modules') return; - if (file === '.homeybuild') return; - if (file === '.homeycompose') { - jsonFiles.add(path.normalize(`${filePath}/app.json`)); + await walkSync(filePath); } - await walkSync(filePath); - } - - if (fileStats.isFile() && filePath.endsWith('.json')) { - if (file.startsWith('.')) return; - if (file === 'app.json') return; - if (file === 'env.json') return; - if (file === 'package.json') return; - if (file === 'package-lock.json') return; + if (fileStats.isFile() && filePath.endsWith('.json')) { + if (file.startsWith('.')) return; + if (file === 'app.json') return; + if (file === 'env.json') return; + if (file === 'package.json') return; + if (file === 'package-lock.json') return; - jsonFiles.add(path.normalize(filePath)); - } - })); - }; + jsonFiles.add(path.normalize(filePath)); + } + })); + }; - await walkSync(this.path); + await walkSync(this.path); + } Log(`Found ${jsonFiles.size} JSON files to translate...`); @@ -3016,6 +3025,8 @@ ${content}`, }); })); + if (file) return; + // Translate README.txt const readme = await fs.promises.readFile(path.join(this.path, 'README.txt'), 'utf8'); await Promise.all(Object.values(languages).map(async language => {