add translation process badge

This commit is contained in:
MaysWind
2026-03-30 00:40:14 +08:00
parent ce0c9ec65e
commit 97fb73ad43
3 changed files with 416 additions and 21 deletions
+322
View File
@@ -0,0 +1,322 @@
const fs = require('fs');
const path = require('path');
const FRONTEND_LOCALES_DIR = path.join(__dirname, '..', '..', 'src', 'locales');
const BACKEND_LOCALES_DIR = path.join(__dirname, '..', '..', 'pkg', 'locales');
const OUTPUT_DIR = process.argv[2] || path.join(__dirname, '..', '..', 'i18n-badge');
const DEFAULT_LANGUAGE_TAG = 'en';
const BACKEND_SKIP_STRUCTS = new Set([
'GlobalTextItems',
'DefaultTypes',
'DataConverterTextItems',
]);
function discoverFrontendLanguages() {
const indexPath = path.join(FRONTEND_LOCALES_DIR, 'index.ts');
const content = fs.readFileSync(indexPath, 'utf-8');
const importMap = {};
const importRegex = /import\s+(\w+)\s+from\s+['"]\.\/([\w_]+\.json)['"]/g;
let match;
while ((match = importRegex.exec(content)) !== null) {
importMap[match[1]] = match[2];
}
const result = {};
const langRegex = /['"]([^'"]+)['"]\s*:\s*\{[^}]*content\s*:\s*(\w+)/g;
while ((match = langRegex.exec(content)) !== null) {
const tag = match[1];
const varName = match[2];
if (importMap[varName]) {
result[tag] = importMap[varName];
}
}
return result;
}
function discoverBackendLanguages() {
const allLocalesPath = path.join(BACKEND_LOCALES_DIR, 'all_locales.go');
const content = fs.readFileSync(allLocalesPath, 'utf-8');
const result = {};
const entryRegex = /"([^"]+)"\s*:\s*\{[^}]*Content\s*:\s*(\w+)/g;
let match;
while ((match = entryRegex.exec(content)) !== null) {
const tag = match[1];
const fileName = tag.toLowerCase().replace(/-/g, '_') + '.go';
const filePath = path.join(BACKEND_LOCALES_DIR, fileName);
if (fs.existsSync(filePath)) {
result[tag] = fileName;
}
}
return result;
}
function flattenJSON(obj, prefix) {
const result = {};
for (const key of Object.keys(obj)) {
const fullKey = prefix ? prefix + '.' + key : key;
if (typeof obj[key] === 'object' && obj[key] !== null) {
Object.assign(result, flattenJSON(obj[key], fullKey));
} else {
result[fullKey] = obj[key];
}
}
return result;
}
function shouldSkipFrontendKey(key) {
if (key.startsWith('global.')) {
return true;
} else if (key.startsWith('default.')) {
return true;
} else if (key.startsWith('format.')) {
if (key.startsWith('format.misc.')) {
if (key === 'format.misc.multiTextJoinSeparator') {
return true;
} else if (key === 'format.misc.eachMonthDayInMonthDays') {
return true;
} else {
return false;
}
} else {
return true;
}
} else if (key.startsWith('datetime.')) {
return true;
} else if (key.startsWith('timezone.')) {
return true;
} else if (key.startsWith('currency.')) {
return true;
} else if (key.startsWith('mapprovider.')) {
return true;
} else if (key.startsWith('parameter.id')) {
return true;
} else if (key.startsWith('encoding.')) {
return true;
} else if (key.startsWith('document.anchor.')) {
return true;
} else {
return false;
}
}
function extractGoStringFields(content) {
const fields = [];
const structBlockRegex = /(\w+):\s*&\w+\{([^}]*)\}/gs;
let blockMatch;
while ((blockMatch = structBlockRegex.exec(content)) !== null) {
const structName = blockMatch[1];
const blockBody = blockMatch[2];
const fieldRegex = /(\w+):\s+"((?:[^"\\]|\\.)*)"/g;
let fieldMatch;
while ((fieldMatch = fieldRegex.exec(blockBody)) !== null) {
fields.push({
struct: structName,
name: fieldMatch[1],
value: fieldMatch[2],
});
}
}
return fields;
}
function getProgressColor(progress) {
if (progress >= 95) {
return 'brightgreen';
} else if (progress >= 90) {
return 'green';
} else if (progress >= 70) {
return 'yellowgreen';
} else if (progress >= 50) {
return 'yellow';
} else if (progress >= 20) {
return 'orange';
} else {
return 'red';
}
}
function main() {
const frontendLangs = discoverFrontendLanguages();
const backendLangs = discoverBackendLanguages();
const allTags = new Set([...Object.keys(frontendLangs), ...Object.keys(backendLangs)]);
console.log('Discovered ' + allTags.size + ' languages: ' + [...allTags].sort().join(', '));
const defaultFrontendJSON = JSON.parse(fs.readFileSync(path.join(FRONTEND_LOCALES_DIR, `${DEFAULT_LANGUAGE_TAG}.json`), 'utf-8'));
const defaultFrontendItemsMap = flattenJSON(defaultFrontendJSON, '');
const defaultFrontendKeys = Object.keys(defaultFrontendItemsMap);
const frontendTranslatableKeys = defaultFrontendKeys.filter(function (k) {
return !shouldSkipFrontendKey(k);
});
const frontendSkippedCount = defaultFrontendKeys.length - frontendTranslatableKeys.length;
const frontendTotal = frontendTranslatableKeys.length;
const defaultBackendContent = fs.readFileSync(path.join(BACKEND_LOCALES_DIR, `${DEFAULT_LANGUAGE_TAG}.go`), 'utf-8');
const defaultBackendItems = extractGoStringFields(defaultBackendContent);
const defaultBackendTranslatableItems = defaultBackendItems.filter(function (f) {
return !BACKEND_SKIP_STRUCTS.has(f.struct);
});
const backendSkippedCount = defaultBackendItems.length - defaultBackendTranslatableItems.length;
const backendTotal = defaultBackendTranslatableItems.length;
console.log('Frontend: ' + frontendTotal + ' translatable keys (' + frontendSkippedCount + ' excluded)');
console.log('Backend: ' + backendTotal + ' translatable fields (' + backendSkippedCount + ' excluded)');
const results = {};
const untranslatedKeys = {};
for (const tag of allTags) {
results[tag] = {
languageTag: tag,
frontendTranslated: 0,
frontendTotal: frontendTotal,
backendTranslated: 0,
backendTotal: backendTotal
};
untranslatedKeys[tag] = [];
}
for (const tag of Object.keys(frontendLangs)) {
if (tag === DEFAULT_LANGUAGE_TAG) {
results[tag].frontendTranslated = frontendTotal;
continue;
}
const file = frontendLangs[tag];
const filePath = path.join(FRONTEND_LOCALES_DIR, file);
if (!fs.existsSync(filePath)) {
continue;
}
const json = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
const kv = flattenJSON(json, '');
let translated = 0;
for (const key of frontendTranslatableKeys) {
if (kv[key] !== undefined && kv[key] !== '' && kv[key] !== defaultFrontendItemsMap[key]) {
translated++;
} else {
untranslatedKeys[tag].push({ source: 'frontend', key: key, defaultValue: defaultFrontendItemsMap[key], value: kv[key] });
}
}
results[tag].frontendTranslated = translated;
}
for (const tag of Object.keys(backendLangs)) {
if (tag === DEFAULT_LANGUAGE_TAG) {
results[tag].backendTranslated = backendTotal;
continue;
}
const file = backendLangs[tag];
const filePath = path.join(BACKEND_LOCALES_DIR, file);
if (!fs.existsSync(filePath)) {
continue;
}
const content = fs.readFileSync(filePath, 'utf-8');
const fields = extractGoStringFields(content).filter(function (f) {
return !BACKEND_SKIP_STRUCTS.has(f.struct);
});
let translated = 0;
for (let i = 0; i < defaultBackendTranslatableItems.length; i++) {
if (i < fields.length && fields[i].value !== defaultBackendTranslatableItems[i].value) {
translated++;
} else {
untranslatedKeys[tag].push({ source: 'backend', key: defaultBackendTranslatableItems[i].struct + '.' + defaultBackendTranslatableItems[i].name, defaultValue: defaultBackendTranslatableItems[i].value, value: (i < fields.length) ? fields[i].value : null });
}
}
results[tag].backendTranslated = translated;
}
for (const tag of Object.keys(results)) {
const r = results[tag];
const totalTranslated = r.frontendTranslated + r.backendTranslated;
const totalItems = r.frontendTotal + r.backendTotal;
r.totalProgress = Math.round((totalTranslated / totalItems) * 10000) / 100;
}
const sortedResults = {};
var sortedTags = Object.keys(results).sort();
for (const tag of sortedTags) {
sortedResults[tag] = results[tag];
}
if (!fs.existsSync(OUTPUT_DIR)) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
}
var badgesDir = path.join(OUTPUT_DIR, 'badges');
if (!fs.existsSync(badgesDir)) {
fs.mkdirSync(badgesDir, { recursive: true });
}
fs.writeFileSync(
path.join(OUTPUT_DIR, 'i18n-progress.json'),
JSON.stringify(sortedResults, null, 4) + '\n'
);
for (const tag of sortedTags) {
const data = sortedResults[tag];
const badge = {
schemaVersion: 1,
label: 'translation',
message: data.totalProgress + '%',
color: getProgressColor(data.totalProgress)
};
fs.writeFileSync(
path.join(badgesDir, tag + '.json'),
JSON.stringify(badge, null, 4) + '\n'
);
}
var untranslatedDir = path.join(OUTPUT_DIR, 'untranslated');
if (!fs.existsSync(untranslatedDir)) {
fs.mkdirSync(untranslatedDir, { recursive: true });
}
for (const tag of sortedTags) {
const items = untranslatedKeys[tag] || [];
fs.writeFileSync(
path.join(untranslatedDir, tag + '.json'),
JSON.stringify(items, null, 4) + '\n'
);
}
for (const tag of sortedTags) {
const data = sortedResults[tag];
const missingCount = (untranslatedKeys[tag] || []).length;
console.log(tag + ': ' + data.totalProgress + '% (frontend: ' + data.frontendTranslated + '/' + data.frontendTotal + ', backend: ' + data.backendTranslated + '/' + data.backendTotal + ', untranslated: ' + missingCount + ')');
}
console.log('\nResults written to ' + OUTPUT_DIR);
}
main();
@@ -0,0 +1,73 @@
name: Update i18n Translation Progress Badges
on:
push:
branches:
- main
paths:
- 'src/locales/**'
- 'pkg/locales/**'
workflow_dispatch:
jobs:
update-i18n-progress:
if: vars.UPDATE_I18N_BADGE_REPO == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '24'
- name: Update translation progress data
run: |
node .github/scripts/update-i18n-progress.js ${{ runner.temp }}/i18n-badge
- name: Checkout badge repository
uses: actions/checkout@v5
with:
repository: mayswind/ezbookkeeping-i18n-badge
token: ${{ secrets.I18N_BADGE_REPO_TOKEN }}
path: ezbookkeeping-i18n-badge
- name: Update badge data
run: |
cp ${{ runner.temp }}/i18n-badge/i18n-progress.json ezbookkeeping-i18n-badge/
mkdir -p ezbookkeeping-i18n-badge/badges
cp ${{ runner.temp }}/i18n-badge/badges/*.json ezbookkeeping-i18n-badge/badges/
mkdir -p ezbookkeeping-i18n-badge/untranslated
cp ${{ runner.temp }}/i18n-badge/untranslated/*.json ezbookkeeping-i18n-badge/untranslated/
- name: Commit and push
run: |
cd ezbookkeeping-i18n-badge
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add -A
if git diff --cached --quiet; then
echo "No changes to commit"
else
git commit -m "Update i18n progress data (${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }})"
git push
fi
- name: Purge GitHub camo image cache
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
CAMO_URLS=$(curl -s -H "Accept: application/vnd.github.html+json" -H "Authorization: token $GITHUB_TOKEN" "https://api.github.com/repos/${{ github.repository }}/readme" | grep -oP 'https://camo\.githubusercontent\.com/[^"]+' | sort -u)
if [ -z "$CAMO_URLS" ]; then
echo "No camo URLs found, skipping cache purge"
exit 0
fi
for url in $CAMO_URLS; do
echo "Purging: $url"
curl -s -X PURGE "$url" > /dev/null
done
echo "Purged $(echo "$CAMO_URLS" | wc -l) camo URLs"
+21 -21
View File
@@ -129,27 +129,27 @@ Help make ezBookkeeping accessible to users around the world. We welcome help to
Currently available translations: Currently available translations:
| Tag | Language | Contributors | | Tag | Language | Progress | Contributors |
| --- | --- | --- | | --- | --- | --- | --- |
| de | Deutsch | [@chrgm](https://github.com/chrgm), [@1270o1](https://github.com/1270o1) | | de | Deutsch | ![Translation Progress](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fmayswind%2FezBookkeeping-i18n-badge%2Fmain%2Fbadges%2Fde.json) | [@chrgm](https://github.com/chrgm), [@1270o1](https://github.com/1270o1) |
| en | English | / | | en | English | ![Translation Progress](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fmayswind%2FezBookkeeping-i18n-badge%2Fmain%2Fbadges%2Fen.json) | / |
| es | Español | [@Miguelonlonlon](https://github.com/Miguelonlonlon), [@abrugues](https://github.com/abrugues), [@AndresTeller](https://github.com/AndresTeller), [@diegofercri](https://github.com/diegofercri) | | es | Español | ![Translation Progress](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fmayswind%2FezBookkeeping-i18n-badge%2Fmain%2Fbadges%2Fes.json) | [@Miguelonlonlon](https://github.com/Miguelonlonlon), [@abrugues](https://github.com/abrugues), [@AndresTeller](https://github.com/AndresTeller), [@diegofercri](https://github.com/diegofercri) |
| fr | Français | [@brieucdlf](https://github.com/brieucdlf) | | fr | Français | ![Translation Progress](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fmayswind%2FezBookkeeping-i18n-badge%2Fmain%2Fbadges%2Ffr.json) | [@brieucdlf](https://github.com/brieucdlf) |
| it | Italiano | [@waron97](https://github.com/waron97) | | it | Italiano | ![Translation Progress](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fmayswind%2FezBookkeeping-i18n-badge%2Fmain%2Fbadges%2Fit.json) | [@waron97](https://github.com/waron97) |
| ja | 日本語 | [@tkymmm](https://github.com/tkymmm) | | ja | 日本語 | ![Translation Progress](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fmayswind%2FezBookkeeping-i18n-badge%2Fmain%2Fbadges%2Fja.json) | [@tkymmm](https://github.com/tkymmm) |
| kn | ಕನ್ನಡ | [@Darshanbm05](https://github.com/Darshanbm05) | | kn | ಕನ್ನಡ | ![Translation Progress](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fmayswind%2FezBookkeeping-i18n-badge%2Fmain%2Fbadges%2Fkn.json) | [@Darshanbm05](https://github.com/Darshanbm05) |
| ko | 한국어 | [@overworks](https://github.com/overworks) | | ko | 한국어 | ![Translation Progress](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fmayswind%2FezBookkeeping-i18n-badge%2Fmain%2Fbadges%2Fko.json) | [@overworks](https://github.com/overworks) |
| nl | Nederlands | [@automagics](https://github.com/automagics) | | nl | Nederlands | ![Translation Progress](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fmayswind%2FezBookkeeping-i18n-badge%2Fmain%2Fbadges%2Fnl.json) | [@automagics](https://github.com/automagics) |
| pt-BR | Português (Brasil) | [@thecodergus](https://github.com/thecodergus), [@balaios](https://github.com/balaios) | | pt-BR | Português (Brasil) | ![Translation Progress](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fmayswind%2FezBookkeeping-i18n-badge%2Fmain%2Fbadges%2Fpt-BR.json) | [@thecodergus](https://github.com/thecodergus), [@balaios](https://github.com/balaios) |
| ru | Русский | [@artegoser](https://github.com/artegoser), [@dshemin](https://github.com/dshemin) | | ru | Русский | ![Translation Progress](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fmayswind%2FezBookkeeping-i18n-badge%2Fmain%2Fbadges%2Fru.json) | [@artegoser](https://github.com/artegoser), [@dshemin](https://github.com/dshemin) |
| sl | Slovenščina | [@thehijacker](https://github.com/thehijacker) | | sl | Slovenščina | ![Translation Progress](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fmayswind%2FezBookkeeping-i18n-badge%2Fmain%2Fbadges%2Fsl.json) | [@thehijacker](https://github.com/thehijacker) |
| ta | தமிழ் | [@hhharsha36](https://github.com/hhharsha36) | | ta | தமிழ் | ![Translation Progress](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fmayswind%2FezBookkeeping-i18n-badge%2Fmain%2Fbadges%2Fta.json) | [@hhharsha36](https://github.com/hhharsha36) |
| th | ไทย | [@natthavat28](https://github.com/natthavat28) | | th | ไทย | ![Translation Progress](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fmayswind%2FezBookkeeping-i18n-badge%2Fmain%2Fbadges%2Fth.json) | [@natthavat28](https://github.com/natthavat28) |
| tr | Türkçe | [@aydnykn](https://github.com/aydnykn) | | tr | Türkçe | ![Translation Progress](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fmayswind%2FezBookkeeping-i18n-badge%2Fmain%2Fbadges%2Ftr.json) | [@aydnykn](https://github.com/aydnykn) |
| uk | Українська | [@nktlitvinenko](https://github.com/nktlitvinenko) | | uk | Українська | ![Translation Progress](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fmayswind%2FezBookkeeping-i18n-badge%2Fmain%2Fbadges%2Fuk.json) | [@nktlitvinenko](https://github.com/nktlitvinenko) |
| vi | Tiếng Việt | [@f97](https://github.com/f97) | | vi | Tiếng Việt | ![Translation Progress](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fmayswind%2FezBookkeeping-i18n-badge%2Fmain%2Fbadges%2Fvi.json) | [@f97](https://github.com/f97) |
| zh-Hans | 中文 (简体) | / | | zh-Hans | 中文 (简体) | ![Translation Progress](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fmayswind%2FezBookkeeping-i18n-badge%2Fmain%2Fbadges%2Fzh-Hans.json) | / |
| zh-Hant | 中文 (繁體) | / | | zh-Hant | 中文 (繁體) | ![Translation Progress](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fmayswind%2FezBookkeeping-i18n-badge%2Fmain%2Fbadges%2Fzh-Hant.json) | / |
## Documentation ## Documentation
1. [English](https://ezbookkeeping.mayswind.net) 1. [English](https://ezbookkeeping.mayswind.net)