Skip to content

Commit

Permalink
Merge pull request #407 from athombv/feature/translate-with-ai
Browse files Browse the repository at this point in the history
Feature/translate with ai
  • Loading branch information
jeroenwienk authored Jul 22, 2024
2 parents 5269913 + 6db2cbc commit 558c091
Show file tree
Hide file tree
Showing 4 changed files with 367 additions and 0 deletions.
53 changes: 53 additions & 0 deletions bin/cmds/app/translate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
'use strict';

const colors = require('colors');

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.',
})
.option('model', {
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 => {
try {
const app = new App(yargs.path);
await app.translateWithOpenAI({
languages: yargs.languages.split(',').map(lang => lang.trim()),
apiKey: yargs.apiKey,
model: yargs.model,
file: yargs.file,
});
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);
process.exit(1);
}
};
147 changes: 147 additions & 0 deletions lib/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ 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 PQueue = require('p-queue').default;

const AthomApi = require('../services/AthomApi');
const Settings = require('../services/Settings');
Expand Down Expand Up @@ -2916,6 +2918,151 @@ $ 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,
model = 'gpt-4o',
file = null,
}) {
// 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 });
const pqueue = new PQueue({ concurrency: 10 });

// Find & translate all .json files recursively
const jsonFiles = new Set();

if (file) {
if (!file.endsWith('.json')) {
throw new Error('--file only supports a .json file.');
}

jsonFiles.add(file);
} else {
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);

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);
}

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));
}
}));
};

await walkSync(this.path);
}

Log(`Found ${jsonFiles.size} JSON files to translate...`);

await Promise.all([...jsonFiles].map(async file => {
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));

Log(`✅ Translated ${file}`);
} catch (err) {
Log(`❌ Error translating ${file}`);
Log(err);
}
});
}));

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 => {
return pqueue.add(async () => {
const translatedReadmePath = path.join(this.path, `README.${language}.txt`);

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);
}
});
}));
}

static async addTypes({ appPath, useTs }) {
if (useTs) {
await NpmCommands.installDev([
Expand Down
Loading

0 comments on commit 558c091

Please sign in to comment.