445 lines
13 KiB
JavaScript
445 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
|
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const ROOT = process.cwd();
|
|
const LOCALES_DIR = path.join(ROOT, 'locales');
|
|
const TEMPLATE_PATH = path.join(LOCALES_DIR, 'template.toml');
|
|
const KO_PATH = path.join(LOCALES_DIR, 'ko.toml');
|
|
const EN_PATH = path.join(LOCALES_DIR, 'en.toml');
|
|
|
|
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.trim()).filter(Boolean) : [];
|
|
continue;
|
|
}
|
|
if (line.startsWith('[') && line.endsWith(']')) {
|
|
const name = line.slice(1, -1).trim();
|
|
section = name ? name.split('.').map((p) => p.trim()).filter(Boolean) : [];
|
|
continue;
|
|
}
|
|
const eqIndex = line.indexOf('=');
|
|
if (eqIndex === -1) continue;
|
|
const key = line.slice(0, eqIndex).trim();
|
|
if (!key) continue;
|
|
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]) node[part] = {};
|
|
node = node[part];
|
|
}
|
|
const leaf = parts[parts.length - 1];
|
|
const value = valuesMap ? (valuesMap.get(key) ?? '') : '';
|
|
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 = keys.filter((k) => typeof node[k] === 'string');
|
|
const childKeys = keys.filter((k) => typeof node[k] === 'object');
|
|
for (const key of leafKeys) {
|
|
const value = node[key];
|
|
lines.push(`${key} = ${JSON.stringify(value)}`);
|
|
}
|
|
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 templateMap = parseToml(TEMPLATE_PATH);
|
|
const koMap = parseToml(KO_PATH);
|
|
const enMap = parseToml(EN_PATH);
|
|
const fallbacks = extractFallbacks();
|
|
|
|
const allKeys = new Set([
|
|
...templateMap.keys(),
|
|
...koMap.keys(),
|
|
...enMap.keys(),
|
|
]);
|
|
|
|
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(KO_PATH, renderToml(buildTree(keys, koMap)));
|
|
fs.writeFileSync(EN_PATH, renderToml(buildTree(keys, enMap)));
|
|
fs.writeFileSync(TEMPLATE_PATH, renderToml(buildTree(keys, null)));
|
|
}
|
|
|
|
main();
|