feat: implement locales scripts

This commit is contained in:
divocat
2025-10-21 21:33:51 +03:00
parent 3bccf8d617
commit 1acdbe67a2
12 changed files with 8387 additions and 2032 deletions

View File

@@ -0,0 +1,38 @@
import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const sourceDir = path.resolve(__dirname, 'locales');
const targetRoot = path.resolve(__dirname, '../luci-app-podkop/po');
async function main() {
const files = await fs.readdir(sourceDir);
for (const file of files) {
const filePath = path.join(sourceDir, file);
if (file === 'podkop.pot') {
const potTarget = path.join(targetRoot, 'templates', 'podkop.pot');
await fs.mkdir(path.dirname(potTarget), { recursive: true });
await fs.copyFile(filePath, potTarget);
console.log(`✅ Copied POT: ${filePath}${potTarget}`);
}
const match = file.match(/^podkop\.([a-zA-Z_]+)\.po$/);
if (match) {
const lang = match[1];
const poTarget = path.join(targetRoot, lang, 'podkop.po');
await fs.mkdir(path.dirname(poTarget), { recursive: true });
await fs.copyFile(filePath, poTarget);
console.log(`✅ Copied ${lang.toUpperCase()}: ${filePath}${poTarget}`);
}
}
}
main().catch((err) => {
console.error('❌ Ошибка при распространении переводов:', err);
process.exit(1);
});

View File

@@ -0,0 +1,93 @@
import fg from 'fast-glob';
import fs from 'fs/promises';
import path from 'path';
const outputFile = 'locales/calls.json';
const tsSearchGlob = 'src/**/*.ts';
const jsSearchGlob = '../luci-app-podkop/htdocs/luci-static/resources/view/podkop/**/*.js';
function extractAllUnderscoreCallsFromContent(content) {
const results = [];
let index = 0;
while (index < content.length) {
const start = content.indexOf('_(', index);
if (start === -1) break;
let i = start + 2;
let depth = 1;
while (i < content.length && depth > 0) {
if (content[i] === '(') depth++;
else if (content[i] === ')') depth--;
i++;
}
const raw = content.slice(start, i);
results.push({ raw, index: start });
index = i;
}
return results;
}
function getLineNumber(content, charIndex) {
return content.slice(0, charIndex).split('\n').length;
}
function extractKey(call) {
const match = call.match(/^_\(\s*(['"`])((?:\\\1|.)*?)\1\s*\)$/);
return match ? match[2].trim() : null;
}
function normalizeCall(call) {
return call
.replace(/\s*\n\s*/g, ' ')
.replace(/\s+/g, ' ')
.replace(/\(\s+/g, '(')
.replace(/\s+\)/g, ')')
.replace(/,\s*\)$/, ')')
.trim();
}
async function extractAllUnderscoreCallsWithLocations() {
const files = [
...(await fg(tsSearchGlob, { ignore: ['**/*test.ts'], absolute: true })),
...(await fg(jsSearchGlob, { ignore: ['**/main.js'], absolute: true })),
];
const callMap = new Map();
for (const file of files) {
const content = await fs.readFile(file, 'utf8');
const relativePath = path.relative(process.cwd(), file);
const extracted = extractAllUnderscoreCallsFromContent(content);
for (const { raw, index } of extracted) {
const line = getLineNumber(content, index);
const location = `${relativePath}:${line}`;
const normalized = normalizeCall(raw);
const key = extractKey(normalized);
if (!callMap.has(normalized)) {
callMap.set(normalized, {
call: normalized,
key: key ?? '',
places: [],
});
}
callMap.get(normalized).places.push(location);
}
}
const result = [...callMap.values()];
await fs.mkdir(path.dirname(outputFile), { recursive: true });
await fs.writeFile(outputFile, JSON.stringify(result, null, 2), 'utf8');
console.log(`✅ Найдено ${result.length} уникальных вызовов _(...). Сохранено в ${outputFile}`);
}
extractAllUnderscoreCallsWithLocations().catch(console.error);

View File

@@ -0,0 +1,113 @@
import fs from 'fs/promises';
import { execSync } from 'child_process';
const lang = process.argv[2];
if (!lang) {
console.error('❌ Укажи язык, например: node generate-po.js ru');
process.exit(1);
}
const callsPath = 'locales/calls.json';
const poPath = `locales/podkop.${lang}.po`;
function getGitUser() {
try {
return execSync('git config user.name').toString().trim();
} catch {
return 'Automatically generated';
}
}
function getHeader(lang) {
const now = new Date();
const date = now.toISOString().split('T')[0];
const time = now.toTimeString().split(' ')[0].slice(0, 5);
const tzOffset = (() => {
const offset = -now.getTimezoneOffset();
const sign = offset >= 0 ? '+' : '-';
const hours = String(Math.floor(Math.abs(offset) / 60)).padStart(2, '0');
const minutes = String(Math.abs(offset) % 60).padStart(2, '0');
return `${sign}${hours}${minutes}`;
})();
const translator = getGitUser();
const pluralForms = lang === 'ru'
? 'nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);'
: 'nplurals=2; plural=(n != 1);';
return [
`# ${lang.toUpperCase()} translations for PODKOP package.`,
`# Copyright (C) ${now.getFullYear()} THE PODKOP'S COPYRIGHT HOLDER`,
`# This file is distributed under the same license as the PODKOP package.`,
`# ${translator}, ${now.getFullYear()}.`,
'#',
'msgid ""',
'msgstr ""',
`"Project-Id-Version: PODKOP\\n"`,
`"Report-Msgid-Bugs-To: \\n"`,
`"POT-Creation-Date: ${date} ${time}${tzOffset}\\n"`,
`"PO-Revision-Date: ${date} ${time}${tzOffset}\\n"`,
`"Last-Translator: ${translator}\\n"`,
`"Language-Team: none\\n"`,
`"Language: ${lang}\\n"`,
`"MIME-Version: 1.0\\n"`,
`"Content-Type: text/plain; charset=UTF-8\\n"`,
`"Content-Transfer-Encoding: 8bit\\n"`,
`"Plural-Forms: ${pluralForms}\\n"`,
'',
];
}
function parsePo(content) {
const lines = content.split('\n');
const translations = new Map();
let msgid = null;
let msgstr = null;
for (const line of lines) {
if (line.startsWith('msgid ')) {
msgid = JSON.parse(line.slice(6));
} else if (line.startsWith('msgstr ') && msgid !== null) {
msgstr = JSON.parse(line.slice(7));
translations.set(msgid, msgstr);
msgid = null;
msgstr = null;
}
}
return translations;
}
function escapePoString(str) {
return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
async function generatePo() {
const [callsRaw, oldPoRaw] = await Promise.all([
fs.readFile(callsPath, 'utf8'),
fs.readFile(poPath, 'utf8').catch(() => ''),
]);
const calls = JSON.parse(callsRaw);
const oldTranslations = parsePo(oldPoRaw);
const header = getHeader(lang);
const body = calls
.map(({ key }) => {
const msgid = key;
const msgstr = oldTranslations.get(msgid) || '';
return [
`msgid "${escapePoString(msgid)}"`,
`msgstr "${escapePoString(msgstr)}"`,
''
].join('\n');
})
.join('\n');
const finalPo = header.join('\n') + '\n' + body;
await fs.writeFile(poPath, finalPo, 'utf8');
console.log(`✅ Файл ${poPath} успешно сгенерирован. Переведено ${[...oldTranslations.keys()].length}/${calls.length}`);
}
generatePo().catch((err) => {
console.error('Ошибка генерации PO файла:', err);
});

View File

@@ -0,0 +1,73 @@
import fs from 'fs/promises';
import { execSync } from 'child_process';
const inputFile = 'locales/calls.json';
const outputFile = 'locales/podkop.pot';
const projectId = 'PODKOP';
function getGitUser() {
const name = execSync('git config user.name').toString().trim();
const email = execSync('git config user.email').toString().trim();
return { name, email };
}
function getPotHeader({ name, email }) {
const now = new Date();
const date = now.toISOString().replace('T', ' ').slice(0, 16);
const offset = -now.getTimezoneOffset();
const sign = offset >= 0 ? '+' : '-';
const hours = String(Math.floor(Math.abs(offset) / 60)).padStart(2, '0');
const minutes = String(Math.abs(offset) % 60).padStart(2, '0');
const timezone = `${sign}${hours}${minutes}`;
return [
'# SOME DESCRIPTIVE TITLE.',
`# Copyright (C) ${now.getFullYear()} THE PACKAGE'S COPYRIGHT HOLDER`,
`# This file is distributed under the same license as the ${projectId} package.`,
`# ${name} <${email}>, ${now.getFullYear()}.`,
'#, fuzzy',
'msgid ""',
'msgstr ""',
`"Project-Id-Version: ${projectId}\\n"`,
`"Report-Msgid-Bugs-To: \\n"`,
`"POT-Creation-Date: ${date}${timezone}\\n"`,
`"PO-Revision-Date: ${date}${timezone}\\n"`,
`"Last-Translator: ${name} <${email}>\\n"`,
`"Language-Team: LANGUAGE <LL@li.org>\\n"`,
`"Language: \\n"`,
`"MIME-Version: 1.0\\n"`,
`"Content-Type: text/plain; charset=UTF-8\\n"`,
`"Content-Transfer-Encoding: 8bit\\n"`,
'',
].join('\n');
}
function escapePoString(str) {
return str.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
function generateEntry(item) {
const locations = item.places.map(loc => `#: ${loc}`).join('\n');
const msgid = escapePoString(item.key);
return [
locations,
`msgid "${msgid}"`,
`msgstr ""`,
''
].join('\n');
}
async function generatePot() {
const gitUser = getGitUser();
const raw = await fs.readFile(inputFile, 'utf8');
const entries = JSON.parse(raw);
const header = getPotHeader(gitUser);
const body = entries.map(generateEntry).join('\n');
await fs.writeFile(outputFile, `${header}\n${body}`, 'utf8');
console.log(`✅ POT-файл успешно создан: ${outputFile}`);
}
generatePot().catch(console.error);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -12,7 +12,12 @@
"dev": "tsup src/main.ts --watch",
"test": "vitest",
"ci": "yarn format && yarn lint --max-warnings=0 && yarn test --run && yarn build",
"watch:sftp": "node watch-upload.js"
"watch:sftp": "node watch-upload.js",
"locales:exctract-calls": "node extract-calls.js",
"locales:generate-pot": "node generate-pot.js",
"locales:generate-po:ru": "node generate-po.js ru",
"locales:distribute": "node distribute-locales.js",
"locales:actualize": "yarn locales:exctract-calls && yarn locales:generate-pot && yarn locales:generate-po:ru && yarn locales:distribute"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "8.45.0",
@@ -21,6 +26,7 @@
"dotenv": "17.2.3",
"eslint": "9.36.0",
"eslint-config-prettier": "10.1.8",
"fast-glob": "3.3.3",
"glob": "11.0.3",
"prettier": "3.6.2",
"ssh2-sftp-client": "12.0.1",

View File

@@ -996,6 +996,17 @@ fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
resolved "https://registry.npmmirror.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
fast-glob@3.3.3:
version "3.3.3"
resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.3.tgz#d06d585ce8dba90a16b0505c543c3ccfb3aeb818"
integrity sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==
dependencies:
"@nodelib/fs.stat" "^2.0.2"
"@nodelib/fs.walk" "^1.2.3"
glob-parent "^5.1.2"
merge2 "^1.3.0"
micromatch "^4.0.8"
fast-glob@^3.3.2:
version "3.3.3"
resolved "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz#d06d585ce8dba90a16b0505c543c3ccfb3aeb818"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff