1
0
forked from baron/baron-sso
Files
baron-sso/tools/i18n-scanner/translate-locales.js

534 lines
16 KiB
JavaScript

#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const ROOT = process.cwd();
const LOCALE_SPECS = [
{
name: 'root',
label: 'root locales',
dir: path.join(ROOT, 'locales'),
template: 'template.toml',
langs: ['ko.toml', 'en.toml'],
ownsKey: (key) => !key.startsWith('ui.common.') && !key.startsWith('msg.common.'),
},
{
name: 'common',
label: 'common locales',
dir: path.join(ROOT, 'common', 'locales'),
template: 'template.toml',
langs: ['ko.toml', 'en.toml'],
ownsKey: (key) => key.startsWith('ui.common.') || key.startsWith('msg.common.'),
},
];
function shouldIgnoreCodeKey(key) {
return (
key.includes('.msg.') ||
key.includes('.ui.') ||
key.includes('.err.') ||
key.includes('.test.') ||
key.includes('.non.') ||
key.startsWith('ui.admin.users.list.table.') ||
key.startsWith('msg.admin.users.detail.') ||
key.startsWith('msg.dev.clients.') ||
key.startsWith('ui.admin.users.create.') ||
key.startsWith('ui.admin.users.detail.') ||
key.startsWith('ui.dev.clients.') ||
key.startsWith('ui.dev.session.')
);
}
const SKIP_DIRS = new Set([
'.git',
'node_modules',
'dist',
'build',
'.dart_tool',
'.idea',
'.vscode',
'coverage',
'.next',
'.cache',
'tmp',
'logs',
]);
const CODE_EXTENSIONS = new Set(['.ts', '.tsx', '.dart']);
function walkDir(dirPath, files) {
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
if (SKIP_DIRS.has(entry.name)) continue;
walkDir(path.join(dirPath, entry.name), files);
continue;
}
if (!entry.isFile()) continue;
const ext = path.extname(entry.name).toLowerCase();
if (!CODE_EXTENSIONS.has(ext)) continue;
files.push(path.join(dirPath, entry.name));
}
}
function parseToml(filePath) {
if (!fs.existsSync(filePath)) return new Map();
const content = fs.readFileSync(filePath, 'utf8');
const lines = content.split(/\r?\n/);
let section = [];
const map = new Map();
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
if (line.startsWith('[[') && line.endsWith(']]')) {
const name = line.slice(2, -2).trim();
section = name ? name.split('.').map((p) => {
p = p.trim();
if ((p.startsWith('"') && p.endsWith('"')) || (p.startsWith("'") && p.endsWith("'"))) {
p = p.slice(1, -1).trim();
}
return p;
}).filter(Boolean) : [];
continue;
}
if (line.startsWith('[') && line.endsWith(']')) {
const name = line.slice(1, -1).trim();
section = name ? name.split('.').map((p) => {
p = p.trim();
if ((p.startsWith('"') && p.endsWith('"')) || (p.startsWith("'") && p.endsWith("'"))) {
p = p.slice(1, -1).trim();
}
return p;
}).filter(Boolean) : [];
continue;
}
const eqIndex = line.indexOf('=');
if (eqIndex === -1) continue;
let key = line.slice(0, eqIndex).trim();
if (!key) continue;
if ((key.startsWith('"') && key.endsWith('"')) || (key.startsWith("'") && key.endsWith("'"))) {
key = key.slice(1, -1).trim();
}
let valueRaw = line.slice(eqIndex + 1).trim();
let value = '';
if (
(valueRaw.startsWith('"') && valueRaw.endsWith('"')) ||
(valueRaw.startsWith("'") && valueRaw.endsWith("'"))
) {
value = valueRaw.slice(1, -1);
} else {
value = valueRaw;
}
const fullKey = [...section, key].join('.');
map.set(fullKey, value);
}
return map;
}
function buildTree(keys, valuesMap) {
const root = {};
for (const key of keys) {
const parts = key.split('.');
let node = root;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (node[part] === undefined) {
node[part] = {};
} else if (typeof node[part] === 'string') {
node[part] = { "": node[part] };
}
node = node[part];
}
const leaf = parts[parts.length - 1];
const value = valuesMap ? (valuesMap.get(key) ?? '') : '';
if (node[leaf] !== undefined && typeof node[leaf] === 'object') {
node[leaf][""] = value;
} else {
node[leaf] = value;
}
}
return root;
}
function renderToml(tree) {
const lines = [];
function walk(node, path) {
if (path.length) {
lines.push(`[${path.join('.')}]`);
}
const keys = Object.keys(node).sort();
const leafKeys = [];
const childKeys = [];
for (const key of keys) {
if (typeof node[key] === 'string') {
leafKeys.push(key);
} else if (typeof node[key] === 'object') {
if (node[key][""] !== undefined) {
leafKeys.push(key);
} else {
childKeys.push(key);
}
}
}
for (const key of leafKeys) {
const val = node[key];
if (typeof val === 'string') {
lines.push(`${key} = ${JSON.stringify(val)}`);
} else {
lines.push(`${key} = ${JSON.stringify(val[""])}`);
const subKeys = Object.keys(val).filter((k) => k !== "").sort();
for (const subKey of subKeys) {
lines.push(`${key}.${subKey} = ${JSON.stringify(val[subKey])}`);
}
}
}
for (const key of childKeys) {
lines.push('');
walk(node[key], [...path, key]);
}
}
walk(tree, []);
return lines.join('\n').trimEnd() + '\n';
}
function extractFallbacks() {
const files = [];
walkDir(ROOT, files);
const map = new Map();
const tsRegex = /\b(?:i18n\.)?t\s*\(\s*['"]([^'"]+)['"]\s*,\s*(['"])([\s\S]*?)\2/g;
const dartRegex = /\btr\s*\(\s*['"]([^'"]+)['"][\s\S]*?fallback\s*:\s*(?:(\"\"\"[\s\S]*?\"\"\")|('(?:\\.|[^'])*')|(\"(?:\\.|[^\"])*\"))/g;
for (const filePath of files) {
const content = fs.readFileSync(filePath, 'utf8');
let match;
while ((match = tsRegex.exec(content)) !== null) {
const key = match[1];
const raw = match[3] ?? '';
if (!map.has(key)) map.set(key, raw);
}
while ((match = dartRegex.exec(content)) !== null) {
const key = match[1];
let raw = match[2] || match[3] || match[4] || '';
if (raw.startsWith('"""') && raw.endsWith('"""')) {
raw = raw.slice(3, -3);
} else if (
(raw.startsWith('"') && raw.endsWith('"')) ||
(raw.startsWith("'") && raw.endsWith("'"))
) {
raw = raw.slice(1, -1);
}
if (!map.has(key)) map.set(key, raw);
}
}
return map;
}
function isLongText(value) {
if (!value) return false;
if (value.includes('\n')) return true;
if (value.length > 220) return true;
if (/제\\d+조/.test(value)) return true;
return false;
}
function isMostlyAscii(value) {
if (!value) return false;
const ascii = value.replace(/[^\x00-\x7F]/g, '');
return ascii.length / value.length > 0.85;
}
const phraseMap = [
['로딩 중...', 'Loading...'],
['저장 중...', 'Saving...'],
['가족사 임직원', 'Affiliate employees'],
['일반 User', 'General user'],
['로그인 링크 전송', 'Send sign-in link'],
['링크 로그인 완료', 'Link sign-in complete'],
['링크 로그인', 'Link sign-in'],
['로그인 완료', 'Sign-in complete'],
['로그인 화면으로 이동', 'Go to sign-in'],
['회원가입 하기', 'Sign up'],
['회원가입', 'Sign up'],
['가입 완료', 'Sign-up complete'],
['미등록 회원', 'Unregistered member'],
['본인인증', 'Identity verification'],
['로그인', 'Sign in'],
['로그아웃', 'Sign out'],
['대시보드', 'Dashboard'],
['프로필', 'Profile'],
['내 정보', 'My profile'],
['QR 코드', 'QR code'],
['QR 인증', 'QR verification'],
['QR 스캔', 'Scan QR'],
['카메라 켜기', 'Turn on camera'],
['카메라', 'Camera'],
['스캔', 'Scan'],
['링크', 'Link'],
['승인', 'Approve'],
['거부', 'Reject'],
['완료', 'Complete'],
['실패', 'Failed'],
['성공', 'Success'],
['인증번호', 'Verification code'],
['인증', 'Verification'],
['비밀번호', 'Password'],
['재설정', 'Reset'],
['재설정', 'Reset'],
['재발송', 'Resend'],
['발송', 'Send'],
['요청', 'Request'],
['확인', 'Confirm'],
['취소', 'Cancel'],
['저장', 'Save'],
['삭제', 'Delete'],
['생성', 'Create'],
['수정', 'Edit'],
['등록', 'Register'],
['조회', 'Fetch'],
['목록', 'List'],
['상세', 'Details'],
['설정', 'Settings'],
['권한', 'Permission'],
['범위', 'Scope'],
['보안', 'Security'],
['사용자', 'User'],
['테넌트', 'Tenant'],
['그룹', 'Group'],
['이름', 'Name'],
['설명', 'Description'],
['상태', 'Status'],
['활성', 'Active'],
['비활성', 'Inactive'],
['복사', 'Copy'],
['정보', 'Information'],
['소속', 'Affiliation'],
['부서', 'Department'],
['부서명', 'Department name'],
['회사코드', 'Company code'],
['회사', 'Company'],
['조직', 'Organization'],
['약관동의', 'Agreements'],
['약관', 'Terms'],
['개인정보', 'Privacy'],
['동의', 'Consent'],
['접속이력', 'Access history'],
['접속일자', 'Access date'],
['접속환경', 'Access environment'],
['인증수단', 'Authentication method'],
['현황', 'Overview'],
['세션', 'Session'],
['없는', 'None'],
['없음', 'None'],
['없습니다', 'Not available'],
['없습니다.', 'Not available.'],
['알 수 없음', 'Unknown'],
['준비중', 'Pending'],
['남은 시간', 'Time remaining'],
['유효시간', 'Valid for'],
['숫자', 'Digits'],
['영문', 'Letters'],
['필수', 'Required'],
['선택', 'Optional'],
['이메일', 'Email'],
['휴대폰', 'Mobile'],
['전화번호', 'Phone number'],
['내용', 'Details'],
['추가', 'Add'],
['편집', 'Edit'],
['관리', 'Manage'],
['바론', 'Baron'],
['한라', 'Halla'],
['한맥', 'Hanmac'],
['장헌', 'Jangheon'],
['삼안', 'Saman'],
['준비 중', 'Pending'],
['새로고침', 'Refresh'],
['검색', 'Search'],
['추가', 'Add'],
['삭제', 'Delete'],
['수정', 'Edit'],
['편집', 'Edit'],
['생성', 'Create'],
['등록', 'Register'],
['관리', 'Manage'],
['목록', 'List'],
['상세', 'Details'],
['설정', 'Settings'],
['권한', 'Permission'],
['범위', 'Scope'],
['보안', 'Security'],
['비밀번호', 'Password'],
['이메일', 'Email'],
['전화번호', 'Phone number'],
['휴대폰', 'Mobile'],
['사용자', 'User'],
['테넌트', 'Tenant'],
['그룹', 'Group'],
['이름', 'Name'],
['설명', 'Description'],
['상태', 'Status'],
['활성', 'Active'],
['비활성', 'Inactive'],
['승인', 'Approve'],
['해지', 'Revoke'],
['복사', 'Copy'],
['요청', 'Request'],
['확인', 'Confirm'],
['취소', 'Cancel'],
['저장', 'Save'],
];
const keyTokenMap = [
['qr', 'QR'],
['idp', 'IDP'],
['oidc', 'OIDC'],
['api', 'API'],
['app', 'App'],
['m2m', 'M2M'],
['ui', 'UI'],
['otp', 'OTP'],
];
function translateNoun(text) {
let result = text;
for (const [from, to] of phraseMap) {
result = result.split(from).join(to);
}
result = result.replace(/\s+/g, ' ').trim();
return result;
}
function translateKorean(text) {
if (!text) return text;
const trimmed = text.trim();
const patterns = [
[/^(.+?)에 실패했습니다\.$/, (m, p1) => `Failed to ${translateNoun(p1)}.`],
[/^(.+?)에 실패했습니다$/, (m, p1) => `Failed to ${translateNoun(p1)}.`],
[/^(.+?)가 필요합니다\.$/, (m, p1) => `${translateNoun(p1)} is required.`],
[/^(.+?)가 필요합니다$/, (m, p1) => `${translateNoun(p1)} is required.`],
[/^(.+?)을 입력해 주세요\.$/, (m, p1) => `Please enter ${translateNoun(p1)}.`],
[/^(.+?)을 입력해 주세요$/, (m, p1) => `Please enter ${translateNoun(p1)}.`],
[/^(.+?)를 입력해 주세요\.$/, (m, p1) => `Please enter ${translateNoun(p1)}.`],
[/^(.+?)를 입력해 주세요$/, (m, p1) => `Please enter ${translateNoun(p1)}.`],
[/^(.+?)를 입력해주세요\.$/, (m, p1) => `Please enter ${translateNoun(p1)}.`],
[/^(.+?)를 입력해주세요$/, (m, p1) => `Please enter ${translateNoun(p1)}.`],
[/^(.+?)을 확인해 주세요\.$/, (m, p1) => `Please check ${translateNoun(p1)}.`],
[/^(.+?)을 확인해 주세요$/, (m, p1) => `Please check ${translateNoun(p1)}.`],
[/^(.+?)를 확인해 주세요\.$/, (m, p1) => `Please check ${translateNoun(p1)}.`],
[/^(.+?)를 확인해 주세요$/, (m, p1) => `Please check ${translateNoun(p1)}.`],
[/^(.+?)가 없습니다\.$/, (m, p1) => `No ${translateNoun(p1)}.`],
[/^(.+?)가 없습니다$/, (m, p1) => `No ${translateNoun(p1)}.`],
[/^(.+?)이 없습니다\.$/, (m, p1) => `No ${translateNoun(p1)}.`],
[/^(.+?)이 없습니다$/, (m, p1) => `No ${translateNoun(p1)}.`],
[/^(.+?)가 복사되었습니다\.$/, (m, p1) => `${translateNoun(p1)} copied.`],
[/^(.+?)가 저장되었습니다\.$/, (m, p1) => `${translateNoun(p1)} saved.`],
[/^(.+?)가 생성되었습니다\.$/, (m, p1) => `${translateNoun(p1)} created.`],
[/^(.+?)가 수정되었습니다\.$/, (m, p1) => `${translateNoun(p1)} updated.`],
[/^(.+?)가 삭제되었습니다\.$/, (m, p1) => `${translateNoun(p1)} deleted.`],
[/^(.+?)를 삭제할까요\?$/, (m, p1) => `Delete ${translateNoun(p1)}?`],
];
for (const [regex, fn] of patterns) {
const match = trimmed.match(regex);
if (match) return fn(...match);
}
let result = trimmed;
const sortedPhraseMap = [...phraseMap].sort((a, b) => b[0].length - a[0].length);
for (const [from, to] of sortedPhraseMap) {
result = result.split(from).join(to);
}
return result;
}
function containsHangul(text) {
return /[가-힣]/.test(text);
}
function keyToEnglish(key) {
if (!key) return 'Unknown';
const segment = key.split('.').pop() || key;
let result = segment.replace(/_/g, ' ').replace(/\s+/g, ' ').trim();
result = result.replace(/\b([a-z])/g, (m) => m.toUpperCase());
for (const [from, to] of keyTokenMap) {
const regex = new RegExp(`\\b${from}\\b`, 'gi');
result = result.replace(regex, to);
}
return result || 'Unknown';
}
function main() {
const fallbacks = extractFallbacks();
for (const spec of LOCALE_SPECS) {
const templatePath = path.join(spec.dir, spec.template);
const koPath = path.join(spec.dir, 'ko.toml');
const enPath = path.join(spec.dir, 'en.toml');
const templateMap = parseToml(templatePath);
const koMap = parseToml(koPath);
const enMap = parseToml(enPath);
const ownedFallbackKeys = Array.from(fallbacks.keys()).filter(
(key) => spec.ownsKey(key) && !shouldIgnoreCodeKey(key)
);
const allKeys = new Set([
...templateMap.keys(),
...koMap.keys(),
...enMap.keys(),
...ownedFallbackKeys,
]);
for (const key of allKeys) {
const fallback = fallbacks.get(key);
const currentKo = koMap.get(key) ?? '';
const currentEn = enMap.get(key) ?? '';
let nextKo = currentKo;
if (!nextKo && fallback) {
nextKo = fallback;
}
if (!nextKo) {
nextKo = key;
}
let nextEn = currentEn;
if (!nextEn) {
const source = fallback || nextKo || key;
if (isLongText(source)) {
nextEn = source;
} else if (isMostlyAscii(source)) {
nextEn = source;
} else {
nextEn = translateKorean(source);
}
}
if (!nextEn) {
nextEn = key;
}
if (!isLongText(nextEn) && containsHangul(nextEn)) {
nextEn = keyToEnglish(key);
}
koMap.set(key, nextKo);
enMap.set(key, nextEn);
}
const keys = Array.from(allKeys).sort();
fs.writeFileSync(koPath, renderToml(buildTree(keys, koMap)));
fs.writeFileSync(enPath, renderToml(buildTree(keys, enMap)));
fs.writeFileSync(templatePath, renderToml(buildTree(keys, null)));
}
}
main();