const fs = require('fs'); const path = require('path'); const workspaceRoot = path.resolve(__dirname, '..'); const skipDirs = new Set(['.git', 'node_modules', '.venv']); const skipExtensions = new Set(['.md', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.svg', '.pdf', '.zip', '.tar', '.gz', '.7z']); const nonHashCommentExtensions = new Set(['.html', '.htm', '.css', '.scss', '.sass', '.less', '.js', '.jsx', '.ts', '.tsx']); const translationCache = new Map(); function sleep(delayMs) { return new Promise((resolve) => setTimeout(resolve, delayMs)); } const protectedPattern = /https?:\/\/\S+|www\.\S+|\$\{?[A-Z0-9_]+\}?|\/[A-Za-z0-9._\-/]+|\b[a-z0-9._-]+\/[a-z0-9._-]+\b|\b[a-z0-9._-]+\.[a-z]{2,}\b|\*[A-Za-z]+|`[^`]+`|"[^"]+"|'[^']+'/g; function shouldSkipFile(filePath) { const ext = path.extname(filePath).toLowerCase(); return skipExtensions.has(ext); } function supportsHashComments(filePath) { const ext = path.extname(filePath).toLowerCase(); return !nonHashCommentExtensions.has(ext); } function listFiles(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(...listFiles(path.join(dirPath, entry.name))); continue; } const filePath = path.join(dirPath, entry.name); if (!shouldSkipFile(filePath)) { files.push(filePath); } } return files; } function isProbablyTextFile(filePath) { const buffer = fs.readFileSync(filePath); const length = Math.min(buffer.length, 1024); for (let index = 0; index < length; index += 1) { if (buffer[index] === 0) { return false; } } return true; } function hasCyrillic(text) { return /[А-Яа-яЁё]/.test(text); } function isCodeLikeComment(text) { const trimmed = text.trim(); if (!trimmed) { return true; } if (/^#[A-Za-z_-][A-Za-z0-9_-]*\s*[>{.{:\[]/.test(trimmed)) { return true; } if (/^[!\/]/.test(trimmed)) { return true; } if (/^[\[\]{}()<>:=,$"'`*_./\\-]+$/.test(trimmed)) { return true; } if (/^[A-Za-z0-9_.-]+\s*:\s*[^\s].*$/.test(trimmed) && !/\s{2,}|\b(is|are|if|when|for|with|without|must|can|will|should|here|example)\b/i.test(trimmed)) { return true; } if (/^[A-Za-z0-9_.-]+\s*=\s*[^\s].*$/.test(trimmed) && !/\b(is|are|if|when|for|with|without|must|can|will|should|here|example)\b/i.test(trimmed)) { return true; } if (/^-\s*[A-Z0-9_]+\s*=/.test(trimmed)) { return true; } if (/^[A-Za-z0-9_.-]+\/$/.test(trimmed)) { return true; } return false; } 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 postProcess(text) { return text .replace(/\bUI\b/g, 'интерфейс') .replace(/\bAPI\b/g, 'API') .replace(/\bHTTP\b/g, 'HTTP') .replace(/\bHTTPS\b/g, 'HTTPS') .replace(/\bSSH\b/g, 'SSH') .replace(/\bDNS\b/g, 'DNS') .replace(/\bTor\b/g, 'Tor') .replace(/\bDocker Compose\b/g, 'Docker Compose') .replace(/\bDFWebOS\b/g, 'DFWebOS'); } async function translateText(text) { if (!text.trim() || 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)}`; let response; for (let attempt = 0; attempt < 4; attempt += 1) { response = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0' } }); if (response.ok) { break; } if (response.status < 500 || attempt === 3) { throw new Error(`Translation failed with status ${response.status}`); } await sleep(500 * (attempt + 1)); } if (!response.ok) { throw new Error(`Translation failed with status ${response.status}`); } const payload = await response.json(); const translated = payload[0].map((item) => item[0]).join(''); const restored = restoreTerms(translated, tokens); const processed = postProcess(restored); translationCache.set(text, processed); return processed; } function splitInlineComment(text) { const marker = text.indexOf(' # '); if (marker === -1) { return null; } return { head: text.slice(0, marker + 1), tail: text.slice(marker + 3), }; } async function translateCommentLine(line) { const match = line.match(/^(\s*#+)(\s*)(.*)$/); if (!match) { return { changed: false, line }; } const prefix = match[1]; const spacing = match[2]; const body = match[3]; if (prefix.startsWith('#!')) { return { changed: false, line }; } if (!body.trim()) { return { changed: false, line }; } const inlineComment = splitInlineComment(body); if (inlineComment && isCodeLikeComment(inlineComment.head.trim())) { const translatedTail = await translateText(inlineComment.tail); const updated = `${prefix}${spacing}${inlineComment.head} # ${translatedTail}`; return { changed: updated !== line, line: updated }; } if (isCodeLikeComment(body)) { return { changed: false, line }; } const translated = await translateText(body); const updated = `${prefix}${spacing}${translated}`; return { changed: updated !== line, line: updated }; } async function processFile(filePath) { if (!supportsHashComments(filePath)) { return false; } if (!isProbablyTextFile(filePath)) { return false; } const source = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n'); if (!/^\s*#/m.test(source)) { return false; } const lines = source.split('\n'); let changed = false; for (let index = 0; index < lines.length; index += 1) { const result = await translateCommentLine(lines[index]); if (result.changed) { lines[index] = result.line; changed = true; } } if (changed) { fs.writeFileSync(filePath, lines.join('\n')); } return changed; } async function main() { const args = process.argv.slice(2); const filePaths = args.length ? args.map((filePath) => path.resolve(workspaceRoot, filePath)) : listFiles(workspaceRoot); let updated = 0; const failedFiles = []; for (const filePath of filePaths) { try { const changed = await processFile(filePath); if (changed) { updated += 1; console.log(`translated ${path.relative(workspaceRoot, filePath)}`); } } catch (error) { failedFiles.push(path.relative(workspaceRoot, filePath)); console.warn(`failed ${path.relative(workspaceRoot, filePath)}: ${error.message}`); } } console.log(`updated files: ${updated}`); if (failedFiles.length > 0) { console.log(`failed files: ${failedFiles.length}`); process.exitCode = 1; } } main().catch((error) => { console.error(error); process.exit(1); });