add translation process badge
This commit is contained in:
@@ -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"
|
||||||
@@ -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 |  | [@chrgm](https://github.com/chrgm), [@1270o1](https://github.com/1270o1) |
|
||||||
| en | English | / |
|
| en | English |  | / |
|
||||||
| 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 |  | [@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 |  | [@brieucdlf](https://github.com/brieucdlf) |
|
||||||
| it | Italiano | [@waron97](https://github.com/waron97) |
|
| it | Italiano |  | [@waron97](https://github.com/waron97) |
|
||||||
| ja | 日本語 | [@tkymmm](https://github.com/tkymmm) |
|
| ja | 日本語 |  | [@tkymmm](https://github.com/tkymmm) |
|
||||||
| kn | ಕನ್ನಡ | [@Darshanbm05](https://github.com/Darshanbm05) |
|
| kn | ಕನ್ನಡ |  | [@Darshanbm05](https://github.com/Darshanbm05) |
|
||||||
| ko | 한국어 | [@overworks](https://github.com/overworks) |
|
| ko | 한국어 |  | [@overworks](https://github.com/overworks) |
|
||||||
| nl | Nederlands | [@automagics](https://github.com/automagics) |
|
| nl | Nederlands |  | [@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) |  | [@thecodergus](https://github.com/thecodergus), [@balaios](https://github.com/balaios) |
|
||||||
| ru | Русский | [@artegoser](https://github.com/artegoser), [@dshemin](https://github.com/dshemin) |
|
| ru | Русский |  | [@artegoser](https://github.com/artegoser), [@dshemin](https://github.com/dshemin) |
|
||||||
| sl | Slovenščina | [@thehijacker](https://github.com/thehijacker) |
|
| sl | Slovenščina |  | [@thehijacker](https://github.com/thehijacker) |
|
||||||
| ta | தமிழ் | [@hhharsha36](https://github.com/hhharsha36) |
|
| ta | தமிழ் |  | [@hhharsha36](https://github.com/hhharsha36) |
|
||||||
| th | ไทย | [@natthavat28](https://github.com/natthavat28) |
|
| th | ไทย |  | [@natthavat28](https://github.com/natthavat28) |
|
||||||
| tr | Türkçe | [@aydnykn](https://github.com/aydnykn) |
|
| tr | Türkçe |  | [@aydnykn](https://github.com/aydnykn) |
|
||||||
| uk | Українська | [@nktlitvinenko](https://github.com/nktlitvinenko) |
|
| uk | Українська |  | [@nktlitvinenko](https://github.com/nktlitvinenko) |
|
||||||
| vi | Tiếng Việt | [@f97](https://github.com/f97) |
|
| vi | Tiếng Việt |  | [@f97](https://github.com/f97) |
|
||||||
| zh-Hans | 中文 (简体) | / |
|
| zh-Hans | 中文 (简体) |  | / |
|
||||||
| zh-Hant | 中文 (繁體) | / |
|
| zh-Hant | 中文 (繁體) |  | / |
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
1. [English](https://ezbookkeeping.mayswind.net)
|
1. [English](https://ezbookkeeping.mayswind.net)
|
||||||
|
|||||||
Reference in New Issue
Block a user