364 lines
10 KiB
JavaScript
364 lines
10 KiB
JavaScript
/*
|
|
* Copyright (C) 2026 Fluxer Contributors
|
|
*
|
|
* This file is part of Fluxer.
|
|
*
|
|
* Fluxer is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License as published by
|
|
* the Free Software Foundation, either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* Fluxer is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License
|
|
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
import {readdirSync, readFileSync, writeFileSync} from 'node:fs';
|
|
import {join} from 'node:path';
|
|
|
|
const OPENROUTER_API_KEY = process.env.OPENROUTER_API_KEY;
|
|
if (!OPENROUTER_API_KEY) {
|
|
console.error('Error: OPENROUTER_API_KEY environment variable is required');
|
|
process.exit(1);
|
|
}
|
|
|
|
const LOCALES_DIR = new URL('../src/locales', import.meta.url).pathname;
|
|
const SOURCE_LOCALE = 'en-US';
|
|
const BATCH_SIZE = 20;
|
|
const CONCURRENT_LOCALES = 10;
|
|
const CONCURRENT_BATCHES_PER_LOCALE = 3;
|
|
|
|
const LOCALE_NAMES = {
|
|
ar: 'Arabic',
|
|
bg: 'Bulgarian',
|
|
cs: 'Czech',
|
|
da: 'Danish',
|
|
de: 'German',
|
|
el: 'Greek',
|
|
'en-GB': 'British English',
|
|
'es-ES': 'Spanish (Spain)',
|
|
'es-419': 'Spanish (Latin America)',
|
|
fi: 'Finnish',
|
|
fr: 'French',
|
|
he: 'Hebrew',
|
|
hi: 'Hindi',
|
|
hr: 'Croatian',
|
|
hu: 'Hungarian',
|
|
id: 'Indonesian',
|
|
it: 'Italian',
|
|
ja: 'Japanese',
|
|
ko: 'Korean',
|
|
lt: 'Lithuanian',
|
|
nl: 'Dutch',
|
|
no: 'Norwegian',
|
|
pl: 'Polish',
|
|
'pt-BR': 'Portuguese (Brazil)',
|
|
ro: 'Romanian',
|
|
ru: 'Russian',
|
|
'sv-SE': 'Swedish',
|
|
th: 'Thai',
|
|
tr: 'Turkish',
|
|
uk: 'Ukrainian',
|
|
vi: 'Vietnamese',
|
|
'zh-CN': 'Chinese (Simplified)',
|
|
'zh-TW': 'Chinese (Traditional)',
|
|
};
|
|
|
|
function parsePo(content) {
|
|
const entries = [];
|
|
const lines = content.split('\n');
|
|
let currentEntry = null;
|
|
let currentField = null;
|
|
let isHeader = true;
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
|
|
if (line.startsWith('#. ')) {
|
|
if (!currentEntry) {
|
|
currentEntry = {comments: [], references: [], msgid: '', msgstr: '', lineNumber: i};
|
|
}
|
|
currentEntry.comments.push(line);
|
|
} else if (line.startsWith('#: ')) {
|
|
if (!currentEntry) {
|
|
currentEntry = {comments: [], references: [], msgid: '', msgstr: '', lineNumber: i};
|
|
}
|
|
currentEntry.references.push(line);
|
|
} else if (line.startsWith('msgid "')) {
|
|
if (!currentEntry) {
|
|
currentEntry = {comments: [], references: [], msgid: '', msgstr: '', lineNumber: i};
|
|
}
|
|
currentEntry.msgid = line.slice(7, -1);
|
|
currentField = 'msgid';
|
|
} else if (line.startsWith('msgstr "')) {
|
|
if (currentEntry) {
|
|
currentEntry.msgstr = line.slice(8, -1);
|
|
currentField = 'msgstr';
|
|
}
|
|
} else if (line.startsWith('"') && line.endsWith('"')) {
|
|
if (currentEntry && currentField) {
|
|
currentEntry[currentField] += line.slice(1, -1);
|
|
}
|
|
} else if (line === '' && currentEntry) {
|
|
if (isHeader && currentEntry.msgid === '') {
|
|
isHeader = false;
|
|
} else if (currentEntry.msgid !== '') {
|
|
entries.push(currentEntry);
|
|
}
|
|
currentEntry = null;
|
|
currentField = null;
|
|
}
|
|
}
|
|
|
|
if (currentEntry && currentEntry.msgid !== '') {
|
|
entries.push(currentEntry);
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
function rebuildPo(content, translations) {
|
|
const translationMap = new Map(translations.map((t) => [t.msgid, t.msgstr]));
|
|
const normalized = content.replace(/\r\n/g, '\n');
|
|
const blocks = normalized.trimEnd().split(/\n{2,}/g);
|
|
const nextBlocks = blocks.map((block) => rebuildPoBlock(block, translationMap));
|
|
return `${nextBlocks.join('\n\n')}\n`;
|
|
}
|
|
|
|
function rebuildPoBlock(block, translationMap) {
|
|
const lines = block.split('\n');
|
|
const msgidRange = getFieldRange(lines, 'msgid');
|
|
const msgstrRange = getFieldRange(lines, 'msgstr');
|
|
|
|
if (!msgidRange || !msgstrRange) {
|
|
return block;
|
|
}
|
|
|
|
const hasReferences = lines.some((line) => line.startsWith('#: '));
|
|
const msgid = readFieldRawValue(lines, msgidRange);
|
|
if (!hasReferences && msgid === '') {
|
|
return block;
|
|
}
|
|
|
|
const currentMsgstr = readFieldRawValue(lines, msgstrRange);
|
|
if (currentMsgstr !== '') {
|
|
return block;
|
|
}
|
|
|
|
if (!translationMap.has(msgid)) {
|
|
return block;
|
|
}
|
|
|
|
const newMsgstr = translationMap.get(msgid);
|
|
const newMsgstrLine = `msgstr "${escapePo(newMsgstr)}"`;
|
|
return [...lines.slice(0, msgstrRange.startIndex), newMsgstrLine, ...lines.slice(msgstrRange.endIndex)].join('\n');
|
|
}
|
|
|
|
function getFieldRange(lines, field) {
|
|
const startIndex = lines.findIndex((line) => line.startsWith(`${field} `));
|
|
if (startIndex === -1) {
|
|
return null;
|
|
}
|
|
let endIndex = startIndex + 1;
|
|
while (endIndex < lines.length && lines[endIndex].startsWith('"') && lines[endIndex].endsWith('"')) {
|
|
endIndex++;
|
|
}
|
|
return {startIndex, endIndex};
|
|
}
|
|
|
|
function readFieldRawValue(lines, range) {
|
|
const firstLine = lines[range.startIndex];
|
|
const match = firstLine.match(/^[a-z]+\s+"(.*)"$/);
|
|
let value = match ? match[1] : '';
|
|
for (let i = range.startIndex + 1; i < range.endIndex; i++) {
|
|
value += lines[i].slice(1, -1);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function escapePo(str) {
|
|
return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\t/g, '\\t');
|
|
}
|
|
|
|
function unescapePo(str) {
|
|
return str.replace(/\\n/g, '\n').replace(/\\t/g, '\t').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
|
}
|
|
|
|
async function translateBatch(strings, targetLocale) {
|
|
const localeName = LOCALE_NAMES[targetLocale] || targetLocale;
|
|
|
|
const prompt = `You are a professional translator. Translate the following UI strings from English to ${localeName}.
|
|
|
|
CRITICAL RULES:
|
|
1. Preserve ALL placeholders exactly as they appear: {0}, {1}, {name}, {count}, etc.
|
|
2. Preserve ICU plural syntax exactly: {0, plural, one {...} other {...}}
|
|
3. Keep technical terms, brand names, and special characters intact
|
|
4. Match the tone and formality of a modern chat/messaging application
|
|
5. Return ONLY a JSON array of translated strings in the same order as input
|
|
6. Do NOT add any explanations or notes
|
|
|
|
Input strings (JSON array):
|
|
${JSON.stringify(strings, null, 2)}
|
|
|
|
Output (JSON array of translated strings only):`;
|
|
|
|
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
|
method: 'POST',
|
|
headers: {
|
|
Authorization: `Bearer ${OPENROUTER_API_KEY}`,
|
|
'Content-Type': 'application/json',
|
|
'HTTP-Referer': 'https://fluxer.dev',
|
|
'X-Title': 'Fluxer i18n Translation',
|
|
},
|
|
body: JSON.stringify({
|
|
model: 'openai/gpt-4o-mini',
|
|
messages: [{role: 'user', content: prompt}],
|
|
temperature: 0.3,
|
|
max_tokens: 4096,
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = await response.text();
|
|
throw new Error(`OpenRouter API error: ${response.status} - ${error}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
const content = data.choices[0]?.message?.content;
|
|
|
|
if (!content) {
|
|
throw new Error('Empty response from API');
|
|
}
|
|
|
|
const jsonMatch = content.match(/\[[\s\S]*\]/);
|
|
if (!jsonMatch) {
|
|
throw new Error(`Failed to parse JSON from response: ${content}`);
|
|
}
|
|
|
|
const translations = JSON.parse(jsonMatch[0]);
|
|
|
|
if (translations.length !== strings.length) {
|
|
throw new Error(`Translation count mismatch: expected ${strings.length}, got ${translations.length}`);
|
|
}
|
|
|
|
return translations;
|
|
}
|
|
|
|
async function pMap(items, mapper, concurrency) {
|
|
const results = [];
|
|
const executing = new Set();
|
|
|
|
for (const [index, item] of items.entries()) {
|
|
const promise = Promise.resolve().then(() => mapper(item, index));
|
|
results.push(promise);
|
|
executing.add(promise);
|
|
|
|
const clean = () => executing.delete(promise);
|
|
promise.then(clean, clean);
|
|
|
|
if (executing.size >= concurrency) {
|
|
await Promise.race(executing);
|
|
}
|
|
}
|
|
|
|
return Promise.all(results);
|
|
}
|
|
|
|
async function processLocale(locale) {
|
|
const poPath = join(LOCALES_DIR, locale, 'messages.po');
|
|
console.log(`[${locale}] Starting...`);
|
|
|
|
let content;
|
|
try {
|
|
content = readFileSync(poPath, 'utf-8');
|
|
} catch (error) {
|
|
console.error(`[${locale}] Error reading file: ${error.message}`);
|
|
return {locale, translated: 0, errors: 1};
|
|
}
|
|
|
|
const entries = parsePo(content);
|
|
const untranslated = entries.filter((e) => e.msgstr === '');
|
|
|
|
if (untranslated.length === 0) {
|
|
console.log(`[${locale}] No untranslated strings`);
|
|
return {locale, translated: 0, errors: 0};
|
|
}
|
|
|
|
console.log(`[${locale}] Found ${untranslated.length} untranslated strings`);
|
|
|
|
const batches = [];
|
|
for (let i = 0; i < untranslated.length; i += BATCH_SIZE) {
|
|
batches.push({
|
|
index: Math.floor(i / BATCH_SIZE),
|
|
total: Math.ceil(untranslated.length / BATCH_SIZE),
|
|
entries: untranslated.slice(i, i + BATCH_SIZE),
|
|
});
|
|
}
|
|
|
|
let errorCount = 0;
|
|
const allTranslations = [];
|
|
|
|
const batchResults = await pMap(
|
|
batches,
|
|
async (batch) => {
|
|
const batchStrings = batch.entries.map((e) => unescapePo(e.msgid));
|
|
|
|
try {
|
|
const translatedStrings = await translateBatch(batchStrings, locale);
|
|
console.log(`[${locale}] Batch ${batch.index + 1}/${batch.total} complete`);
|
|
|
|
return batch.entries.map((entry, j) => ({
|
|
msgid: entry.msgid,
|
|
msgstr: translatedStrings[j],
|
|
}));
|
|
} catch (error) {
|
|
console.error(`[${locale}] Batch ${batch.index + 1}/${batch.total} error: ${error.message}`);
|
|
errorCount++;
|
|
return [];
|
|
}
|
|
},
|
|
CONCURRENT_BATCHES_PER_LOCALE,
|
|
);
|
|
|
|
for (const translations of batchResults) {
|
|
allTranslations.push(...translations);
|
|
}
|
|
|
|
if (allTranslations.length > 0) {
|
|
const updatedContent = rebuildPo(content, allTranslations);
|
|
writeFileSync(poPath, updatedContent, 'utf-8');
|
|
console.log(`[${locale}] Updated ${allTranslations.length} translations`);
|
|
}
|
|
|
|
return {locale, translated: allTranslations.length, errors: errorCount};
|
|
}
|
|
|
|
async function main() {
|
|
console.log('Starting i18n translation...');
|
|
console.log(`Locales directory: ${LOCALES_DIR}`);
|
|
console.log(`Concurrency: ${CONCURRENT_LOCALES} locales, ${CONCURRENT_BATCHES_PER_LOCALE} batches per locale`);
|
|
|
|
const locales = readdirSync(LOCALES_DIR).filter((d) => d !== SOURCE_LOCALE && LOCALE_NAMES[d]);
|
|
|
|
console.log(`Found ${locales.length} locales to process\n`);
|
|
|
|
const startTime = Date.now();
|
|
const results = await pMap(locales, processLocale, CONCURRENT_LOCALES);
|
|
|
|
const totalTranslated = results.reduce((sum, r) => sum + (r?.translated || 0), 0);
|
|
const totalErrors = results.reduce((sum, r) => sum + (r?.errors || 0), 0);
|
|
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
|
|
console.log(`\nTranslation complete in ${elapsed}s`);
|
|
console.log(`Total: ${totalTranslated} strings translated, ${totalErrors} errors`);
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error('Fatal error:', error);
|
|
process.exit(1);
|
|
});
|