Files
DFWebOS-Apps/tools/translate-manifests.js

260 lines
7.9 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const fs = require('fs');
const path = require('path');
const workspaceRoot = path.resolve(__dirname, '..');
const targetFields = ['releaseNotes', 'tagline', 'description'];
const skipDirs = new Set(['.git', 'node_modules', '.venv']);
const translationCache = new Map();
const protectedPattern = /https?:\/\/\S+|www\.\S+|\b(?:OpenAI|TypeScript|Typescript|JavaScript|Docker|GitHub|OAuth2|OAuth|MCP|API|SDK|UI|CLI|AI|AWS|JSON|CSV|FFMPEG|VCF|PDF|BTC|S3|PostgreSQL|Redis|npmjs\.com|DFWebOS|dfwebOS|Copilot|Docker Compose|MySQL|SQLite|PostgreSQL|WebDAV|WebRTC|Nostr|Bitcoin|Lightning|Tor|TLS|SSH|REST|GraphQL|WebSocket|Prometheus|Grafana|InfluxDB|MongoDB|MariaDB|Node\.js|Nextcloud|OpenStreetMap|YouTube|Plex|Sonarr|Radarr|Lidarr|Readarr|Prowlarr|SABnzbd|qBittorrent|Transmission|Home Assistant|WordPress)\b/g;
function listManifestFiles(dirPath) {
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
const files = [];
for (const entry of entries) {
if (entry.isDirectory()) {
if (skipDirs.has(entry.name)) {
continue;
}
files.push(...listManifestFiles(path.join(dirPath, entry.name)));
continue;
}
if (entry.name === 'dfwebos-app.yml') {
files.push(path.join(dirPath, entry.name));
}
}
return files;
}
function hasCyrillic(text) {
return /[А-Яа-яЁё]/.test(text);
}
function parseTopLevelField(source, key) {
const lines = source.split('\n');
const startIndex = lines.findIndex((line) => line.startsWith(`${key}:`));
if (startIndex === -1) {
return null;
}
let endIndex = lines.length;
for (let index = startIndex + 1; index < lines.length; index += 1) {
if (/^[^\s#][^:]*:/.test(lines[index])) {
endIndex = index;
break;
}
}
const firstLine = lines[startIndex];
const inlineValue = firstLine.slice(`${key}:`.length).trim();
let style = 'plain';
let value = '';
if (inlineValue === '>' || inlineValue === '>-' || inlineValue === '|' || inlineValue === '|-') {
style = inlineValue;
value = lines
.slice(startIndex + 1, endIndex)
.map((line) => (line.startsWith(' ') ? line.slice(2) : line))
.join('\n');
} else if (inlineValue.startsWith('"') && inlineValue.endsWith('"')) {
style = 'double-quoted';
value = JSON.parse(inlineValue);
} else if (inlineValue.startsWith("'") && inlineValue.endsWith("'")) {
style = 'single-quoted';
value = inlineValue.slice(1, -1).replace(/''/g, "'");
} else {
style = 'plain';
value = inlineValue;
}
return { startIndex, endIndex, style, value };
}
function protectTerms(text) {
const tokens = [];
const protectedText = text.replace(protectedPattern, (match) => {
const placeholder = `ZXQQ${tokens.length}QQXZ`;
tokens.push(match);
return placeholder;
});
return { protectedText, tokens };
}
function restoreTerms(text, tokens) {
let restored = text;
tokens.forEach((token, index) => {
restored = restored.replace(new RegExp(`ZXQQ${index}QQXZ`, 'g'), token);
});
return restored;
}
function postProcessTranslation(text) {
return text
.replace(/\bUI\b/g, 'интерфейс')
.replace(/\bAI\b/g, 'ИИ')
.replace(/AI-/g, 'ИИ-')
.replace(/\bself-hosted\b/gi, 'самостоятельно размещаемый')
.replace(/\bopen source\b/gi, 'с открытым исходным кодом')
.replace(/\bbug fixes\b/gi, 'исправления ошибок')
.replace(/\brelease notes\b/gi, 'примечания к выпуску')
.replace(/\bkey features\b/gi, 'ключевые возможности')
.replace(/\benterprise-ready\b/gi, 'готовность к корпоративному использованию')
.replace(/\bAI provider\b/gi, 'провайдер ИИ')
.replace(/\bhot reloading\b/gi, 'горячая перезагрузка');
}
async function translateText(text) {
if (!text.trim()) {
return text;
}
if (hasCyrillic(text)) {
return text;
}
if (translationCache.has(text)) {
return translationCache.get(text);
}
const { protectedText, tokens } = protectTerms(text);
const url = `https://translate.googleapis.com/translate_a/single?client=gtx&sl=en&tl=ru&dt=t&q=${encodeURIComponent(protectedText)}`;
const response = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0',
},
});
if (!response.ok) {
throw new Error(`Translation request failed with ${response.status}`);
}
const payload = await response.json();
const translated = payload[0].map((item) => item[0]).join('');
const restored = restoreTerms(translated, tokens);
const processed = postProcessTranslation(restored);
translationCache.set(text, processed);
return processed;
}
function splitIntoChunks(value) {
const parts = value.split(/(\n{2,})/);
const chunks = [];
for (const part of parts) {
if (!part) {
continue;
}
if (/^\n+$/.test(part)) {
chunks.push({ type: 'separator', text: part });
continue;
}
if (part.length <= 1500) {
chunks.push({ type: 'text', text: part });
continue;
}
const lines = part.split('\n');
let current = [];
let currentLength = 0;
for (const line of lines) {
const nextLength = currentLength + line.length + (current.length ? 1 : 0);
if (current.length && nextLength > 1500) {
chunks.push({ type: 'text', text: current.join('\n') });
current = [line];
currentLength = line.length;
} else {
current.push(line);
currentLength = nextLength;
}
}
if (current.length) {
chunks.push({ type: 'text', text: current.join('\n') });
}
}
return chunks;
}
async function translateMultiline(value) {
const chunks = splitIntoChunks(value);
const translatedChunks = [];
for (const chunk of chunks) {
if (chunk.type === 'separator' || !chunk.text.trim()) {
translatedChunks.push(chunk.text);
continue;
}
translatedChunks.push(await translateText(chunk.text));
}
return translatedChunks.join('');
}
function quoteSingle(text) {
return `'${text.replace(/'/g, "''")}'`;
}
function formatField(key, style, value) {
if (style === '>' || style === '>-' || style === '|' || style === '|-') {
return [`${key}: ${style}`, ...value.split('\n').map((line) => ` ${line}`)].join('\n');
}
if (style === 'double-quoted') {
return `${key}: ${JSON.stringify(value)}`;
}
if (style === 'single-quoted') {
return `${key}: ${quoteSingle(value)}`;
}
if (/^[\p{L}\p{N} .,!?:;()\-\/]+$/u.test(value)) {
return `${key}: ${value}`;
}
return `${key}: ${JSON.stringify(value)}`;
}
async function processManifest(filePath) {
const source = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
const lines = source.split('\n');
const replacements = [];
for (const field of targetFields) {
const parsed = parseTopLevelField(source, field);
if (!parsed || hasCyrillic(parsed.value)) {
continue;
}
const translated = await translateMultiline(parsed.value);
replacements.push({ ...parsed, field, translated });
}
if (!replacements.length) {
return false;
}
replacements.sort((left, right) => right.startIndex - left.startIndex);
for (const replacement of replacements) {
const rendered = formatField(replacement.field, replacement.style, replacement.translated).split('\n');
lines.splice(replacement.startIndex, replacement.endIndex - replacement.startIndex, ...rendered);
}
fs.writeFileSync(filePath, lines.join('\n'));
return true;
}
async function main() {
const files = listManifestFiles(workspaceRoot);
let changedCount = 0;
for (const filePath of files) {
const changed = await processManifest(filePath);
if (changed) {
changedCount += 1;
console.log(`translated ${path.relative(workspaceRoot, filePath)}`);
}
}
console.log(`updated manifests: ${changedCount}`);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});