forked from baron/baron-sso
i18n refresh and frontend fixes
This commit is contained in:
86
tools/i18n-scanner/gen-flutter-i18n.js
Executable file
86
tools/i18n-scanner/gen-flutter-i18n.js
Executable file
@@ -0,0 +1,86 @@
|
||||
#!/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 KO_PATH = path.join(LOCALES_DIR, "ko.toml");
|
||||
const EN_PATH = path.join(LOCALES_DIR, "en.toml");
|
||||
const OUT_PATH = path.join(ROOT, "userfront", "lib", "i18n_data.dart");
|
||||
|
||||
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();
|
||||
const valueRaw = line.slice(eqIndex + 1).trim();
|
||||
if (!key) continue;
|
||||
|
||||
let value = "";
|
||||
if (valueRaw.startsWith('"')) {
|
||||
try {
|
||||
value = JSON.parse(valueRaw);
|
||||
} catch {
|
||||
value = valueRaw.slice(1, -1);
|
||||
}
|
||||
} else if (valueRaw.startsWith("'") && valueRaw.endsWith("'")) {
|
||||
value = valueRaw.slice(1, -1);
|
||||
} else {
|
||||
value = valueRaw;
|
||||
}
|
||||
|
||||
const fullKey = [...section, key].join(".");
|
||||
map.set(fullKey, value);
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
function dartStringLiteral(value) {
|
||||
return JSON.stringify(value).replace(/\$/g, "\\$");
|
||||
}
|
||||
|
||||
function renderDartMap(name, map) {
|
||||
const keys = Array.from(map.keys()).sort();
|
||||
const lines = [];
|
||||
lines.push(`const Map<String, String> ${name} = {`);
|
||||
for (const key of keys) {
|
||||
const value = map.get(key) ?? "";
|
||||
lines.push(` ${dartStringLiteral(key)}: ${dartStringLiteral(value)},`);
|
||||
}
|
||||
lines.push("};");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
const koMap = parseToml(KO_PATH);
|
||||
const enMap = parseToml(EN_PATH);
|
||||
|
||||
const output = [
|
||||
"// locales/*.toml에서 생성됨",
|
||||
renderDartMap("koStrings", koMap),
|
||||
"",
|
||||
renderDartMap("enStrings", enMap),
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
fs.writeFileSync(OUT_PATH, output, "utf8");
|
||||
224
tools/i18n-scanner/index.js
Normal file
224
tools/i18n-scanner/index.js
Normal file
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT_DIR = process.cwd();
|
||||
const LOCALES_DIR = path.join(ROOT_DIR, 'locales');
|
||||
const TEMPLATE_PATH = path.join(LOCALES_DIR, 'template.toml');
|
||||
const LANG_FILES = ['ko.toml', 'en.toml'];
|
||||
const FAIL_UNUSED = process.argv.includes('--fail-unused');
|
||||
|
||||
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']);
|
||||
|
||||
const CODE_PATTERNS = [
|
||||
/\b(?:i18n\.)?t\s*\(\s*['"]([^'"]+)['"]/g,
|
||||
/\btr\s*\(\s*['"]([^'"]+)['"]/g,
|
||||
/['"]([^'"]+)['"]\s*\.tr\s*\(/g,
|
||||
];
|
||||
|
||||
function readFileRequired(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return { ok: false, error: `파일이 없습니다: ${filePath}` };
|
||||
}
|
||||
return { ok: true, value: fs.readFileSync(filePath, 'utf8') };
|
||||
}
|
||||
|
||||
function parseTomlKeys(filePath) {
|
||||
const result = readFileRequired(filePath);
|
||||
if (!result.ok) {
|
||||
return { ok: false, error: result.error, keys: new Set() };
|
||||
}
|
||||
|
||||
const keys = new Set();
|
||||
const lines = result.value.split(/\r?\n/);
|
||||
let currentSection = [];
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('[[') && line.endsWith(']]')) {
|
||||
const sectionName = line.slice(2, -2).trim();
|
||||
currentSection = sectionName ? sectionName.split('.').map((p) => p.trim()).filter(Boolean) : [];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith('[') && line.endsWith(']')) {
|
||||
const sectionName = line.slice(1, -1).trim();
|
||||
currentSection = sectionName ? sectionName.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;
|
||||
}
|
||||
|
||||
const fullKey = [...currentSection, key].join('.');
|
||||
keys.add(fullKey);
|
||||
}
|
||||
|
||||
return { ok: true, keys };
|
||||
}
|
||||
|
||||
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 collectCodeKeys() {
|
||||
const files = [];
|
||||
walkDir(ROOT_DIR, files);
|
||||
|
||||
const keys = new Set();
|
||||
for (const filePath of files) {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
for (const pattern of CODE_PATTERNS) {
|
||||
let match;
|
||||
while ((match = pattern.exec(content)) !== null) {
|
||||
if (match[1]) {
|
||||
keys.add(match[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
function difference(aSet, bSet) {
|
||||
const result = [];
|
||||
for (const item of aSet) {
|
||||
if (!bSet.has(item)) {
|
||||
result.push(item);
|
||||
}
|
||||
}
|
||||
return result.sort();
|
||||
}
|
||||
|
||||
function printList(title, items) {
|
||||
if (items.length === 0) {
|
||||
return;
|
||||
}
|
||||
console.log(`\n${title}`);
|
||||
for (const item of items) {
|
||||
console.log(`- ${item}`);
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
|
||||
const templateResult = parseTomlKeys(TEMPLATE_PATH);
|
||||
if (!templateResult.ok) {
|
||||
errors.push(templateResult.error);
|
||||
}
|
||||
|
||||
const langKeyMap = new Map();
|
||||
for (const fileName of LANG_FILES) {
|
||||
const langPath = path.join(LOCALES_DIR, fileName);
|
||||
const langResult = parseTomlKeys(langPath);
|
||||
if (!langResult.ok) {
|
||||
errors.push(langResult.error);
|
||||
continue;
|
||||
}
|
||||
langKeyMap.set(fileName, langResult.keys);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error('i18n 검증 실패: 필수 리소스 파일을 찾지 못했습니다.');
|
||||
for (const error of errors) {
|
||||
console.error(`- ${error}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const templateKeys = templateResult.keys;
|
||||
const codeKeys = collectCodeKeys();
|
||||
|
||||
for (const [fileName, langKeys] of langKeyMap.entries()) {
|
||||
const missingInLang = difference(templateKeys, langKeys);
|
||||
if (missingInLang.length > 0) {
|
||||
errors.push(`[Sync Error] ${fileName} 누락 키 ${missingInLang.length}개`);
|
||||
printList(`${fileName}에 없는 키`, missingInLang);
|
||||
}
|
||||
}
|
||||
|
||||
const missingInTemplate = difference(codeKeys, templateKeys);
|
||||
if (missingInTemplate.length > 0) {
|
||||
errors.push(`[Missing Key] template.toml 누락 키 ${missingInTemplate.length}개`);
|
||||
printList('template.toml에 없는 코드 사용 키', missingInTemplate);
|
||||
}
|
||||
|
||||
const unusedInTemplate = difference(templateKeys, codeKeys);
|
||||
if (unusedInTemplate.length > 0) {
|
||||
warnings.push(`[Unused Key] template.toml 미사용 키 ${unusedInTemplate.length}개`);
|
||||
printList('코드에서 사용되지 않는 키', unusedInTemplate);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.error('\n요약');
|
||||
for (const error of errors) {
|
||||
console.error(`- ${error}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (warnings.length > 0) {
|
||||
console.warn('\n요약');
|
||||
for (const warning of warnings) {
|
||||
console.warn(`- ${warning}`);
|
||||
}
|
||||
if (FAIL_UNUSED) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n✅ i18n 검증 완료');
|
||||
}
|
||||
|
||||
main();
|
||||
443
tools/i18n-scanner/translate-locales.js
Normal file
443
tools/i18n-scanner/translate-locales.js
Normal file
@@ -0,0 +1,443 @@
|
||||
#!/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)));
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user