diff --git a/.github/scripts/update-i18n-progress.js b/.github/scripts/update-i18n-progress.js new file mode 100644 index 00000000..bc15bf62 --- /dev/null +++ b/.github/scripts/update-i18n-progress.js @@ -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(); diff --git a/.github/workflows/update-i18n-progress.yml b/.github/workflows/update-i18n-progress.yml new file mode 100644 index 00000000..7ecbdfa6 --- /dev/null +++ b/.github/workflows/update-i18n-progress.yml @@ -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" diff --git a/README.md b/README.md index 1965d300..d366b2cc 100644 --- a/README.md +++ b/README.md @@ -129,27 +129,27 @@ Help make ezBookkeeping accessible to users around the world. We welcome help to Currently available translations: -| Tag | Language | Contributors | -| --- | --- | --- | -| de | Deutsch | [@chrgm](https://github.com/chrgm), [@1270o1](https://github.com/1270o1) | -| 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) | -| fr | Français | [@brieucdlf](https://github.com/brieucdlf) | -| it | Italiano | [@waron97](https://github.com/waron97) | -| ja | 日本語 | [@tkymmm](https://github.com/tkymmm) | -| kn | ಕನ್ನಡ | [@Darshanbm05](https://github.com/Darshanbm05) | -| ko | 한국어 | [@overworks](https://github.com/overworks) | -| nl | Nederlands | [@automagics](https://github.com/automagics) | -| 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) | -| sl | Slovenščina | [@thehijacker](https://github.com/thehijacker) | -| ta | தமிழ் | [@hhharsha36](https://github.com/hhharsha36) | -| th | ไทย | [@natthavat28](https://github.com/natthavat28) | -| tr | Türkçe | [@aydnykn](https://github.com/aydnykn) | -| uk | Українська | [@nktlitvinenko](https://github.com/nktlitvinenko) | -| vi | Tiếng Việt | [@f97](https://github.com/f97) | -| zh-Hans | 中文 (简体) | / | -| zh-Hant | 中文 (繁體) | / | +| Tag | Language | Progress | Contributors | +| --- | --- | --- | --- | +| 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 | ![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 | ![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 | ![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 | ![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 | 日本語 | ![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 | ಕನ್ನಡ | ![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 | 한국어 | ![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 | ![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) | ![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 | Русский | ![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 | ![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 | தமிழ் | ![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 | ไทย | ![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 | ![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 | Українська | ![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 | ![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 | 中文 (简体) | ![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 | 中文 (繁體) | ![Translation Progress](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fmayswind%2FezBookkeeping-i18n-badge%2Fmain%2Fbadges%2Fzh-Hant.json) | / | ## Documentation 1. [English](https://ezbookkeeping.mayswind.net)