Files
DFWebOS-Apps/tools/translate-hash-comments.js

259 lines
7.1 KiB
JavaScript
Raw 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 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);
});