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