Files
AriWeb-Suite/modules/webmaker/lib/i18n.js

390 lines
35 KiB
JavaScript

'use strict';
// ============================================================================
// 다국어(로케일) 모듈 (v1.1.2)
//
// 방식: "원문(한국어) → 번역" 사전.
// - 카탈로그: 사용자 화면 파일(public/index.html, login.html, app.js, wizard.js, import.js, modules-ui.js,
// site-admin.html/js)에서 한국어 문자열을 모두 추출해 하나의 목록으로 만든다. 각 항목에는 번역에 참고할
// 속성(type: 제목/버튼이름/입력항목명/입력예시/도움말/선택항목/메시지/설명내용)과 출처(where)를 붙인다.
// - 내보내기: 관리자가 카탈로그를 한 JSON 파일로 내려받아 translation 칸만 채워 올린다.
// - 올리기: JSON 파일 여러 개 또는 ZIP(JSON 묶음). lang 코드별로 data/locales/<lang>.json 에 저장.
// - 유효성: 번역 파일의 sourceVersion 이 현재 서비스 버전보다 낮으면 "구버전" 으로 보고 노출을 중지한다
// (화면 문구가 바뀐 뒤 옛 번역이 섞여 표시되는 것을 막기 위해). 관리자가 켜고 끌 수도 있다.
// - 런타임(public/i18n.js): 사전을 받아 화면의 텍스트·placeholder·title·data-tip 을 치환한다.
// 코드에 번역 키를 심지 않고 원문 자체를 키로 쓰므로, 화면 문구를 바꾸면 그 문구의 번역만 사라진다.
// ============================================================================
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const zlib = require('zlib');
const ROOT = path.resolve(__dirname, '..');
const DATA_ROOT = process.env.DATA_ROOT || path.join(ROOT, 'data');
const DIR = path.join(DATA_ROOT, 'locales');
fs.mkdirSync(DIR, { recursive: true });
const SOURCE = 'ko';
const VERSION = require(path.join(ROOT, 'package.json')).version;
// 추출 대상 파일과 종류
// 추출 대상 파일: [경로, 종류, 묶음(group)]. 묶음은 번역 파일에서 "어느 화면 문구인지" 를 알려 준다.
const FILES = [
['public/index.html', 'html', '빌더'], ['public/app.js', 'js', '빌더'], ['public/wizard.js', 'js', '빌더'], ['public/import.js', 'js', '빌더'], ['public/modules-ui.js', 'js', '빌더'], ['public/optimize.js', 'js', '빌더'], ['public/templates-ui.js', 'js', '빌더'], ['public/domains-ui.js', 'js', '빌더'], ['public/manage-ui.js', 'js', '빌더'], ['public/flow-ui.js', 'js', '빌더'], ['public/site-info.js', 'js', '빌더'], ['public/credits-ui.js', 'js', '빌더'], // v1.4.14 관리 창 · v1.6.40 작업 흐름 지도·캐시 충전
['public/login.html', 'html', '로그인'],
['public/site-admin.html', 'html', '사이트 관리자'], ['public/site-admin.js', 'js', '사이트 관리자'],
['public/webmail.html', 'html', '웹메일'], ['public/webmail.js', 'js', '웹메일'],
['public/admin.html', 'html', '관리자 콘솔'], ['public/admin.js', 'js', '관리자 콘솔'], // v1.3.10 관리자 페이지 다국어
['public/setup.html', 'html', '초기 설정'], ['public/setup.js', 'js', '초기 설정'], // v1.5.7 초기 설정 페이지(시스템 설정 탭과 공용)
];
/**
* v1.3.10: 서버가 화면으로 보내는 문구(모듈 이름·설명, 모델 등급·비용 안내, 업종·지역 목록, 상태·오류 메시지 등).
* catalog : 화면 표시용 목록 파일 — 표시 문맥(label: 등)의 문자열과 목록 배열 안의 문자열을 모두 뽑는다
* messages: 일반 서버 코드 — 사용자에게 보이는 문맥(error:, httpError(, new Error(, text:, userWarn( 등)의 문자열만
* AI 프롬프트 파일(lib/prompt.js, lib/planner.js)은 넣지 않는다(화면에 나오지 않음).
*/
const SERVER_FILES = [
['lib/config.js', 'catalog'], ['lib/setupapi.js', 'messages'], ['lib/connectors/gitea.js', 'messages'], ['lib/connectors/arimail.js', 'messages'], // v1.5.7 설정 항목표·연동 모듈 문구
['lib/mailusage.js', 'messages'], // v1.7.3 메일 사용량 연동 문구
['lib/setupstate.js', 'messages'], ['lib/setupauth.js', 'messages'], // v1.5.11 설치 점검표·설정 페이지 인증 문구
['lib/modules.js', 'catalog'], ['lib/brief.js', 'catalog'], ['lib/settings.js', 'catalog'], ['lib/seo.js', 'catalog'], ['lib/mailsys.js', 'catalog'], ['lib/logview.js', 'catalog'], ['lib/feedback.js', 'messages'],
['server.js', 'messages'], ['lib/deploy.js', 'messages'], ['lib/remote.js', 'messages'], ['lib/credits.js', 'messages'], ['lib/prepaid.js', 'messages'], ['lib/vault.js', 'messages'], ['lib/crawler.js', 'messages'], ['lib/auth.js', 'messages'], ['lib/uploads.js', 'messages'], ['lib/forms.js', 'messages'], ['lib/i18n.js', 'messages'], ['lib/mailer.js', 'messages'], ['lib/templates.js', 'catalog'], ['lib/multilang.js', 'catalog'], ['lib/google.js', 'messages'], ['lib/passpolicy.js', 'messages'], ['lib/netguard.js', 'messages'],
];
// 사용자에게 보이는 문맥 (바로 앞 코드가 이 모양이면 화면 문구로 본다)
const SHOWN_CTX = /(?:\b(?:label|desc|description|title|summary|fit|text|error|message|detail|reason|why|note|help|name|stateLabel|stateDetail|caption|hint|placeholder|state|next|mode|kindLabel|warning|guide)\s*:\s*|httpError\(\s*|new Error\(\s*|userWarn\([^,()]+,\s*|alert\(\s*|send\(res,\s*\d+,\s*\{\s*error:\s*|\?\s*|:\s*)$/;
const ARRAY_CTX = /[\[,]\s*$/; // 목록 배열 안 (catalog 파일만)
const PROMPT_CTX = /(?:prompt|Prompt|PROMPT|SYSTEM|focus|instruction)\s*[=:+]\s*(?:\(?\s*)?$|return\s*$/; // 프롬프트로 보이는 문자열은 제외
const HANGUL = /[가-힣]/;
const httpError = (m, s = 400) => Object.assign(new Error(m), { status: s });
const idOf = (text) => crypto.createHash('sha1').update(text).digest('hex').slice(0, 10);
// ---------- 추출 ----------
/** 태그 이름으로 문구 속성을 추정 */
function typeForTag(tag, cls) {
if (/^h[1-6]$|^summary$|^legend$/.test(tag)) return '제목';
if (tag === 'button' || (tag === 'a' && /\bbtn\b/.test(cls || ''))) return '버튼이름';
if (tag === 'label') return '입력항목명';
if (tag === 'option') return '선택항목';
if (tag === 'th') return '표머리';
if (/field-label/.test(cls || '')) return '제목';
return '설명내용';
}
/** HTML 파일에서 텍스트 노드와 번역 대상 속성(placeholder, title, data-tip, alt)을 뽑는다 (의존성 없는 간단한 토크나이저) */
function extractHtml(src, where) {
const out = []; const stack = []; let i = 0; const push = (text, type, extra) => { const t = text.replace(/\s+/g, ' ').trim(); if (t && HANGUL.test(t)) out.push({ text: t, type, where: `${where}${extra ? ' ' + extra : ''}` }); };
while (i < src.length) {
const lt = src.indexOf('<', i); if (lt < 0) { push(src.slice(i), typeForTag(stack[stack.length - 1] || '', '')); break; }
if (lt > i) { const top = stack[stack.length - 1] || {}; push(src.slice(i, lt), typeForTag(top.tag || '', top.cls)); }
const gt = src.indexOf('>', lt); if (gt < 0) break;
const tagSrc = src.slice(lt + 1, gt);
if (tagSrc.startsWith('!--')) { const end = src.indexOf('-->', lt); i = end < 0 ? src.length : end + 3; continue; }
if (tagSrc.startsWith('/')) { stack.pop(); i = gt + 1; continue; }
const m = tagSrc.match(/^([a-zA-Z][\w-]*)/); const tag = m ? m[1].toLowerCase() : '';
const cls = (tagSrc.match(/\sclass="([^"]*)"/) || [])[1] || '';
for (const [attr, type] of [['placeholder', '입력예시'], ['title', '도움말'], ['data-tip', '도움말'], ['alt', '설명내용'], ['aria-label', '도움말']]) { const am = tagSrc.match(new RegExp(`\\s${attr}="([^"]*)"`)); if (am) push(am[1].replace(/&#10;/g, ' '), type, `[${attr}]`); }
if (tag === 'script' || tag === 'style') { const close = src.indexOf(`</${tag}`, gt); if (tag === 'script') out.push(...extractJs(src.slice(gt + 1, close < 0 ? src.length : close), where + ' <script>')); i = close < 0 ? src.length : close; continue; }
if (!tagSrc.endsWith('/') && !['br', 'hr', 'img', 'input', 'meta', 'link'].includes(tag)) stack.push({ tag, cls });
i = gt + 1;
}
return out;
}
/**
* 템플릿 리터럴(`…${식}…`) 찾기 (v1.3.2).
* 식 부분(${…})은 괄호 깊이와 그 안의 문자열·중첩 템플릿을 건너뛰며 끝을 찾는다. 중첩 템플릿도 따로 돌려준다.
* @returns {Array<{index, parts: string[]}>} parts: 식을 뺀 글자 조각들 (조각 수 = 식 수 + 1)
*/
function scanTemplates(src) {
const out = [];
const skipString = (i, q) => { i++; while (i < src.length && src[i] !== q) { if (src[i] === '\\') i++; if (src[i] === '\n') break; i++; } return i + 1; };
// 정규식 리터럴(/"/g 같은)의 따옴표를 문자열로 오해하지 않도록: 앞 글자가 연산자·여는 괄호면 정규식으로 보고 건너뛴다
const isRegexStart = (i) => { let j = i - 1; while (j >= 0 && /\s/.test(src[j])) j--; return j < 0 || /[(,=:[!&|?{};+\-*%<>~^]/.test(src[j]) || /\b(return|typeof|case|in|of)$/.test(src.slice(Math.max(0, j - 6), j + 1)); };
const skipRegex = (i) => { i++; let cls = false; while (i < src.length && src[i] !== '\n') { const c = src[i]; if (c === '\\') { i += 2; continue; } if (c === '[') cls = true; else if (c === ']') cls = false; else if (c === '/' && !cls) { i++; while (/[a-z]/i.test(src[i] || '')) i++; return i; } i++; } return i; };
function template(i) { // src[i] === '`'
const start = i; const parts = ['']; i++;
while (i < src.length && src[i] !== '`') {
if (src[i] === '\\') { parts[parts.length - 1] += src[i] + (src[i + 1] || ''); i += 2; continue; }
if (src[i] === '$' && src[i + 1] === '{') {
i += 2; let depth = 1;
while (i < src.length && depth > 0) { const c = src[i]; if (c === '{') depth++; else if (c === '}') depth--; else if (c === "'" || c === '"') { i = skipString(i, c); continue; } else if (c === '`') { i = template(i); continue; } else if (c === '/' && src[i + 1] !== '/' && src[i + 1] !== '*' && isRegexStart(i)) { i = skipRegex(i); continue; } if (depth > 0) i++; }
i++; parts.push(''); continue;
}
parts[parts.length - 1] += src[i]; i++;
}
out.push({ index: start, end: i + 1, parts }); return i + 1;
}
let i = 0;
while (i < src.length) {
const c = src[i];
if (c === '/' && src[i + 1] === '/') { const nl = src.indexOf('\n', i); i = nl < 0 ? src.length : nl; continue; }
if (c === '/' && src[i + 1] === '*') { const e = src.indexOf('*/', i + 2); i = e < 0 ? src.length : e + 2; continue; }
if (c === "'" || c === '"') { i = skipString(i, c); continue; }
if (c === '`') { i = template(i); continue; }
if (c === '/' && isRegexStart(i)) { i = skipRegex(i); continue; }
i++;
}
return out;
}
/** JS 문자열 조각의 이스케이프 풀기 + 공백 정리 */
const cleanJs = (t) => t.replace(/\\n/g, ' ').replace(/\\'/g, "'").replace(/\\"/g, '"').replace(/\\`/g, '`').replace(/\s+/g, ' ');
/** 앞 코드로 문구 속성(type) 추정 — 따옴표 문자열과 템플릿에 함께 쓴다 */
function typeFromContext(before) {
let type = '설명내용';
if (/btn\(\s*$|mkBtn\(\s*$|textContent = \s*$|\.textContent\s*=\s*$|'button',\s*(['"][^'"]*['"],\s*)?$/.test(before)) type = '버튼이름';
if (/alert\(\s*$|confirm\(\s*$|prompt\(\s*$|text:\s*$|\.textContent = \s*$/.test(before) && /alert|confirm|prompt|text:/.test(before.slice(-12))) type = '메시지';
if (/h3\(\s*$|el\('h[1-6]',\s*(['"][^'"]*['"],\s*)?$|'field-label',\s*$|el\('summary',[^,]*,\s*$/.test(before)) type = '제목';
if (/placeholder\s*=\s*$/.test(before)) type = '입력예시';
if (/title\s*=\s*$|\.title = \s*$|tip = \s*$|dataset\.tip = \s*$/.test(before)) type = '도움말';
if (/new Option\(\s*$/.test(before)) type = '선택항목';
if (/el\('th',[^,]*,\s*$|el\('td', 'muted small',\s*$/.test(before)) type = '표머리';
return type;
}
/**
* 템플릿 리터럴 → 번역 패턴 (v1.3.2).
* `현재 사이트: ${current.title} · 화면: ${tabLabel()} (함께 전달됩니다)` → "현재 사이트: {1} · 화면: {2} (함께 전달됩니다)"
* 화면(public/i18n.js)은 이 패턴으로 실제 문장을 맞춰 보고 {1}·{2} 자리에 원래 값을 넣은 번역문으로 바꾼다.
* HTML 조각(<…>)을 만드는 템플릿, 한글이 식 안에만 있는 템플릿은 제외한다.
*/
/**
* HTML 을 만드는 템플릿(`<td>약 ${a} ~ ${b}</td>` 등)을 태그 경계로 나눠 화면에 보일 글자 조각만 돌려준다 (v1.3.10).
* 1.3.9 까지는 태그가 든 템플릿을 통째로 건너뛰어 표 머리·비용 안내 같은 문구가 번역 파일에 없었다.
* 조각 안의 ${식} 은 조각마다 {1}·{2} 로 다시 번호를 매기고, 태그 속성(title="…" 등)의 문구도 따로 뽑는다.
* @param {string[]} statics 식을 뺀 글자 조각 (식 자리는 사이사이)
* @returns {Array<{text: string, pattern: boolean}>}
*/
function splitHtmlTemplate(statics) {
const MARK = '\u0000'; const joined = statics.map((x, i) => (i ? `${MARK}${i}${MARK}` : '') + x).join('');
const out = []; const norm = (t) => { let n = 0; const map = {}; const txt = t.replace(new RegExp(`${MARK}(\\d+)${MARK}`, 'g'), (a, k) => `{${map[k] || (map[k] = ++n)}}`).replace(/&[a-z]+;|&#\d+;/g, ' ').replace(/\s+/g, ' ').trim(); return { text: txt, pattern: n > 0 }; };
// 태그 속성 안의 문구
for (const m of joined.matchAll(/\s(?:title|placeholder|data-tip|aria-label|alt)="([^"]*)"/g)) { const r = norm(m[1]); if (HANGUL.test(r.text) && !/^\{\d+\}$/.test(r.text)) out.push(r); }
for (const seg of joined.replace(/<[^>]*>/g, '\u0001').split('\u0001')) { const r = norm(seg); if (r.text && HANGUL.test(r.text) && r.text.length <= 400 && !/^\{\d+\}$/.test(r.text)) out.push(r); }
return out;
}
function extractTemplates(src, where) {
const out = [];
for (const t of scanTemplates(src)) {
const statics = t.parts.map(cleanJs);
if (!statics.some((x) => HANGUL.test(x))) continue;
if (statics.some((x) => /<[a-zA-Z\/!][^>]*>/.test(x))) { const type = typeFromContext(src.slice(Math.max(0, t.index - 60), t.index)); for (const r of splitHtmlTemplate(statics)) out.push({ text: r.text, type, where, pattern: r.pattern }); continue; }
let text = statics.map((x, i) => (i ? `{${i}}` : '') + x).join('').replace(/\s+/g, ' ').trim();
if (!text || text.length > 400) continue;
if (/^\{\d+\}$/.test(text)) continue;
out.push({ text, type: typeFromContext(src.slice(Math.max(0, t.index - 60), t.index)), where, pattern: t.parts.length > 1 });
}
return out;
}
/** JS 코드에서 한글이 든 문자열 리터럴('…', "…")과 템플릿 리터럴(v1.3.2, 변수 자리는 {1}…)을 뽑는다. */
function extractJs(src, where) {
const out = []; const re = /(['"])((?:\\.|(?!\1)[^\\\n])*)\1/g; let m;
while ((m = re.exec(src))) {
let text = m[2]; if (!HANGUL.test(text) || text.includes('${')) continue;
text = cleanJs(text).trim(); if (!text || text.length > 400) continue;
out.push({ text, type: typeFromContext(src.slice(Math.max(0, m.index - 60), m.index)), where });
}
out.push(...extractTemplates(src, where));
return out;
}
/**
* 서버 코드에서 화면 문구만 뽑는다 (v1.3.10). 따옴표 문자열과 템플릿 문자열({1} 패턴) 모두 문맥으로 거른다.
* 300자를 넘는 문자열은 화면 문구가 아니라 프롬프트·HTML 조각으로 보고 뺀다.
*/
function extractServer(src, where, mode) {
const out = []; const take = (text, idx, pattern) => {
const before = src.slice(Math.max(0, idx - 80), idx);
if (mode !== 'catalog' && PROMPT_CTX.test(before)) return;
const ok = mode === 'catalog' || SHOWN_CTX.test(before); // 목록 파일은 화면용 문구 모음이라 모두 (프롬프트 템플릿·무시 구역은 위에서 제외)
if (!ok) return; text = text.replace(/\s+/g, ' ').trim(); if (!text || text.length > 300 || !HANGUL.test(text)) return;
if (/^[^가-힣]*(?:[가-힣]+[^가-힣]*){0,1}$/.test(text) && text.length < 2) return;
out.push({ text, type: '서버 메시지', where, ...(pattern ? { pattern: true } : {}) });
};
// 300자가 넘는 템플릿 문자열(=AI 프롬프트·HTML 조각) 안의 따옴표 문자열은 화면 문구가 아니다
// 소스에 // i18n-ignore-start … // i18n-ignore-end 로 감싼 구역(AI 지시문·파일 내용 등)은 번역 대상이 아니다
const ign = []; { const r = /i18n-ignore-start[\s\S]*?i18n-ignore-end/g; let k; while ((k = r.exec(src))) ign.push([k.index, k.index + k[0].length]); }
const tpls = scanTemplates(src); const big = tpls.filter((t) => t.end - t.index > 300).map((t) => [t.index, t.end]);
const inBig = (i) => big.some(([a, b]) => i > a && i < b) || ign.some(([a, b]) => i > a && i < b);
const re = /(['"])((?:\\.|(?!\1)[^\\\n])*)\1/g; let m;
while ((m = re.exec(src))) { if (!HANGUL.test(m[2]) || m[2].includes('${') || inBig(m.index)) continue; take(cleanJs(m[2]), m.index, false); }
for (const t of tpls) { if (inBig(t.index)) continue; const statics = t.parts.map(cleanJs); if (!statics.some((x) => HANGUL.test(x))) continue; if (statics.some((x) => /<[a-zA-Z\/!][^>]*>/.test(x))) { for (const r of splitHtmlTemplate(statics)) take(r.text, t.index, r.pattern); continue; } const text = statics.map((x, i) => (i ? `{${i}}` : '') + x).join(''); if (/^\{\d+\}$/.test(text.trim())) continue; take(text, t.index, t.parts.length > 1); }
return out;
}
let catalogCache = null;
/** 전체 카탈로그 (중복 제거, 첫 출현 기준 속성). 서버 기동 중에는 파일이 바뀌지 않으므로 캐시 */
function buildCatalog(force) {
if (catalogCache && !force) return catalogCache;
const map = new Map();
const add = (it, group) => { const prev = map.get(it.text); if (!prev) map.set(it.text, { id: idOf(it.text), group, text: it.text, type: it.type, where: it.where, ...(it.pattern || /\{\d+\}/.test(it.text) ? { pattern: true } : {}) }); else if (prev.type === '설명내용' && it.type !== '설명내용') prev.type = it.type; };
for (const [rel, kind, group] of FILES) {
let src; try { src = fs.readFileSync(path.join(ROOT, rel), 'utf8'); } catch (_) { continue; }
for (const it of (kind === 'html' ? extractHtml(src, rel) : extractJs(src, rel))) add(it, group);
}
// v1.3.10: 서버가 보내는 화면 문구 (화면 파일에 이미 있는 문구는 그 묶음을 유지)
for (const [rel, mode] of SERVER_FILES) {
let src; try { src = fs.readFileSync(path.join(ROOT, rel), 'utf8'); } catch (_) { continue; }
for (const it of extractServer(src, rel, mode)) add(it, mode === 'catalog' ? '서버 목록(모듈·등급·업종 등)' : '서버 메시지');
}
// v1.3.16: 생성 사이트 시스템 문구(locales/site/ko.json) 중 자리 표시가 없는 것 — 사이트 관리자 화면에 모듈 오류 등으로 그대로 보이므로 함께 번역
try { for (const [id, m] of Object.entries(require('./sitemsg').source())) if (!/\{\w+\}/.test(m.text) && HANGUL.test(m.text)) add({ text: m.text, type: '사이트 시스템 문구', where: `locales/site/ko.json ${id}` }, '사이트 시스템 문구'); } catch (_) {}
const entries = [...map.values()];
const hash = crypto.createHash('sha1').update(entries.map((e) => e.text).join('\n')).digest('hex').slice(0, 12);
catalogCache = { version: VERSION, date: new Date().toISOString().slice(0, 10), hash, count: entries.length, entries };
return catalogCache;
}
/** 번역자가 내려받는 파일: 구조를 유지하고 translation 만 채우면 된다 */
function exportFile(lang, existing) {
const c = buildCatalog(); const dict = existing ? existing.dict : {};
const holes = (t) => (t.match(/\{\d+\}/g) || []);
const entries = c.entries.map((e) => { const tr = dict[e.text] || ''; return { id: e.id, status: tr ? '번역됨' : '번역 필요', group: e.group, type: e.type, where: e.where, ...(e.pattern ? { pattern: true, placeholders: holes(e.text) } : {}), text: e.text, translation: tr }; });
const need = entries.filter((e) => e.status === '번역 필요').length;
return {
format: 'ai-webmaker-locale', formatVersion: 2,
// v1.3.10: 번역할 곳을 분명히 — 모든 항목에 status(번역 필요/번역됨)·group(화면)을 달고, 맨 위에 요약을 둔다
guide: {
'무엇을 번역하나요': '모든 항목의 "translation" 칸. "text" 는 원문(한국어)이라 고치지 않습니다. status 가 "번역 필요" 인 항목이 아직 번역되지 않은 것입니다.',
'status': '번역 필요 = translation 이 비어 있음 / 번역됨 = 이미 번역이 있음(올리면 그대로 쓰임)',
'group': '문구가 보이는 화면: 빌더·로그인·사이트 관리자·웹메일·관리자 콘솔·서버 목록(모듈·등급·업종 등)·서버 메시지',
'pattern / placeholders': '{1}·{2} 는 화면에서 값(이름·숫자 등)이 들어가는 자리입니다. 번역문에도 같은 {숫자}를 모두 넣으세요(순서는 바꿔도 됨).',
'type': '제목·버튼이름·입력항목명·입력예시·도움말·선택항목·표머리·메시지·설명내용·서버 메시지 — 번역 길이·말투 참고용',
'길이': '번역문이 길어도 화면이 자동으로 줄바꿈합니다. 단어를 줄여 쓸 필요는 없습니다.',
'올리기': '상단 lang(언어 코드, 예: en, ja, zh-CN)과 name(언어 표시 이름)을 채운 뒤 관리자 콘솔 › 언어 에 올립니다.',
},
stats: { total: entries.length, '번역 필요': need, '번역됨': entries.length - need },
source: SOURCE, lang: lang || '', name: existing ? existing.name : '', sourceVersion: c.version, sourceDate: c.date, sourceHash: c.hash, count: c.count,
entries,
};
}
// ---------- 저장소 ----------
function fileOf(lang) { return path.join(DIR, `${lang}.json`); }
function loadLocale(lang) { try { return JSON.parse(fs.readFileSync(fileOf(lang), 'utf8')); } catch (_) { return null; } }
/** 버전 비교: a < b 이면 true (semver 숫자 비교) */
function semverLt(a, b) { const pa = String(a || '0').split('.').map((x) => parseInt(x, 10) || 0); const pb = String(b || '0').split('.').map((x) => parseInt(x, 10) || 0); for (let i = 0; i < 3; i++) { if ((pa[i] || 0) < (pb[i] || 0)) return true; if ((pa[i] || 0) > (pb[i] || 0)) return false; } return false; }
function isOutdated(loc) { return semverLt(loc.sourceVersion, VERSION); }
const LANG_RE = /^[a-z]{2,3}(-[A-Za-z]{2,4})?$/;
function publicInfo(loc) { const c = buildCatalog(); const translated = Object.values(loc.dict || {}).filter(Boolean).length; return { lang: loc.lang, name: loc.name || loc.lang, enabled: loc.enabled !== false, sourceVersion: loc.sourceVersion, sourceDate: loc.sourceDate, uploadedAt: loc.uploadedAt, translated, total: c.count, coverage: c.count ? Math.round(translated / c.count * 100) : 0, outdated: isOutdated(loc), active: loc.enabled !== false && !isOutdated(loc), serviceVersion: VERSION, ...stateOf(loc) }; }
/**
* 언어의 실제 표시 상태 (v1.3.5). 켜기 스위치와 버전 일치 여부를 함께 보고 사용자 화면에 정말 보이는지를 알려 준다.
* 1.3.4 까지 관리자 화면은 상태와 스위치가 섞여, 버전이 올라가 숨겨진 언어도 "끄기" 버튼과 함께 보여 헷갈렸다.
* state: shown(표시 중) | outdated(버전 불일치 — 숨김) | off(꺼짐) | off-outdated(꺼짐 · 버전 불일치)
*/
function stateOf(loc) {
const on = loc.enabled !== false; const old = isOutdated(loc);
if (on && !old) return { state: 'shown', stateLabel: '표시 중', stateDetail: `사용자 화면 언어 선택에 보입니다 (번역 기준 v${loc.sourceVersion} = 서비스 v${VERSION})` };
if (on && old) return { state: 'outdated', stateLabel: '버전 불일치 — 숨김', stateDetail: `켜져 있지만 번역 기준 v${loc.sourceVersion} 가 서비스 v${VERSION} 보다 낮아 사용자 화면에 보이지 않습니다. "현재 번역 내려받기" 로 최신 원본에 기존 번역을 채운 파일을 받아 빈 항목을 번역해 다시 올리세요.` };
if (!on && old) return { state: 'off-outdated', stateLabel: '꺼짐 · 버전 불일치', stateDetail: `관리자가 껐고, 번역 기준 v${loc.sourceVersion} 도 서비스 v${VERSION} 보다 낮습니다.` };
return { state: 'off', stateLabel: '꺼짐', stateDetail: '관리자가 꺼 두어 사용자 화면에 보이지 않습니다. "켜기" 를 누르면 바로 보입니다.' };
}
function listLanguages() { return fs.readdirSync(DIR).filter((f) => f.endsWith('.json')).map((f) => loadLocale(f.slice(0, -5))).filter(Boolean).map(publicInfo).sort((a, b) => a.lang.localeCompare(b.lang)); }
/** 사용자 화면용: 켜져 있고 구버전이 아닌 언어만 */
/** 사용자 화면용: 켜져 있고 구버전이 아닌 언어만. stamp 는 브라우저가 사전을 캐시해 두고 바뀌었는지 비교하는 값(v1.3.10) */
function activeLanguages() { return [{ lang: SOURCE, name: '한국어', active: true }, ...listLanguages().filter((l) => l.active).map((l) => ({ lang: l.lang, name: l.name, active: true, stamp: `${l.sourceVersion}|${l.uploadedAt}`, dir: RTL.has(l.lang.split('-')[0]) ? 'rtl' : 'ltr' }))]; }
const RTL = new Set(['ar', 'he', 'fa', 'ur']); // 오른쪽→왼쪽 언어
function dictionary(lang) {
if (lang === SOURCE) return {}; const loc = loadLocale(lang); if (!loc || loc.enabled === false || isOutdated(loc)) return null;
// v1.3.17 (S14): 이미 올라와 있던 번역에 태그·스크립트가 섞여 있으면 브라우저로 보내지 않는다(화면 일부가 innerHTML 로 그리므로)
const out = {}; for (const [k, v] of Object.entries(loc.dict || {})) if (!(MARKUP.test(v) && !MARKUP.test(k))) out[k] = v; return out;
}
function setEnabled(lang, on) { const loc = loadLocale(lang); if (!loc) throw httpError('언어가 없습니다', 404); loc.enabled = !!on; fs.writeFileSync(fileOf(lang), JSON.stringify(loc)); return publicInfo(loc); }
function remove(lang) { try { fs.unlinkSync(fileOf(lang)); } catch (_) {} return true; }
/** 올린 JSON 한 건을 검증·저장 */
function importJson(obj, filename) {
if (!obj || typeof obj !== 'object') throw httpError(`${filename}: JSON 객체가 아닙니다`);
if (obj.format !== 'ai-webmaker-locale' || !Array.isArray(obj.entries)) throw httpError(`${filename}: AI Webmaker 로케일 파일 형식이 아닙니다 (format/entries 없음)`);
const lang = String(obj.lang || (filename.match(/([a-z]{2,3}(?:-[A-Za-z]{2,4})?)\.json$/i) || [])[1] || '').trim();
if (!LANG_RE.test(lang) || lang === SOURCE) throw httpError(`${filename}: lang(언어 코드)이 없거나 올바르지 않습니다 (예: en, ja, zh-CN)`);
const dict = {}; let n = 0; const badPatterns = [];
// v1.3.2: {1}·{2} 자리표시가 있는 항목은 번역문에도 같은 자리표시가 모두 있어야 한다 (빠지면 값이 사라지므로 건너뜀)
const holes = (t) => (t.match(/\{\d+\}/g) || []).sort().join(',');
for (const e of obj.entries) { if (!e || typeof e.text !== 'string') continue; const tr = typeof e.translation === 'string' ? e.translation.trim() : ''; if (!tr || tr === e.text) continue; if (holes(e.text) !== holes(tr)) { badPatterns.push(e.text); continue; } if (MARKUP.test(tr) && !MARKUP.test(e.text)) { badPatterns.push(e.text); continue; } dict[e.text] = tr; n += 1; }
if (!n) throw httpError(`${filename}: 번역된 항목이 없습니다 (translation 이 모두 비어 있음)`);
const prev = loadLocale(lang);
const loc = { lang, name: String(obj.name || (prev && prev.name) || lang).slice(0, 40), sourceVersion: String(obj.sourceVersion || '0'), sourceDate: String(obj.sourceDate || ''), sourceHash: obj.sourceHash || '', enabled: prev ? prev.enabled !== false : true, uploadedAt: new Date().toISOString(), dict };
fs.writeFileSync(fileOf(lang), JSON.stringify(loc));
return { file: filename, lang, name: loc.name, translated: n, outdated: isOutdated(loc), sourceVersion: loc.sourceVersion, badPatterns: badPatterns.slice(0, 20), badPatternCount: badPatterns.length };
}
/** 최소 ZIP 해제 (저장/deflate 만). 중앙 디렉터리를 읽어 파일 목록을 얻는다. */
function unzip(buf) {
const out = []; let eocd = buf.length - 22; while (eocd >= 0 && buf.readUInt32LE(eocd) !== 0x06054b50) eocd--; if (eocd < 0) throw httpError('ZIP 형식이 아닙니다');
const count = buf.readUInt16LE(eocd + 10); let off = buf.readUInt32LE(eocd + 16);
for (let k = 0; k < count; k++) {
if (buf.readUInt32LE(off) !== 0x02014b50) break;
const method = buf.readUInt16LE(off + 10); const csize = buf.readUInt32LE(off + 20); const nlen = buf.readUInt16LE(off + 28); const xlen = buf.readUInt16LE(off + 30); const clen = buf.readUInt16LE(off + 32); const lho = buf.readUInt32LE(off + 42);
const name = buf.slice(off + 46, off + 46 + nlen).toString('utf8');
const lnlen = buf.readUInt16LE(lho + 26); const lxlen = buf.readUInt16LE(lho + 28); const start = lho + 30 + lnlen + lxlen; const data = buf.slice(start, start + csize);
const flags = buf.readUInt16LE(off + 8);
// v1.3.0: 저장(0)·Deflate(8) 외의 방식(Deflate64=9, bzip2=12, AES 암호=99)이나 암호 걸린 ZIP 은 풀 수 없으므로
// 쓰레기 바이트를 JSON 으로 읽어 "문법 오류" 가 나는 대신 이유를 알려 준다
const METHOD = { 9: 'Deflate64', 12: 'bzip2', 14: 'LZMA', 93: 'Zstandard', 95: 'XZ', 99: 'AES 암호화' };
if (name.endsWith('/')) { /* 폴더 */ }
else if (flags & 1) out.push({ name, error: '암호가 걸린 ZIP 은 풀 수 없습니다. 암호 없이 다시 압축하세요' });
else if (method !== 0 && method !== 8) out.push({ name, error: `지원하지 않는 압축 방식(${METHOD[method] || method})입니다. 일반 ZIP(Deflate)으로 다시 압축하세요` });
else if (csize < 50 * 1024 * 1024) { try { out.push({ name, data: method === 8 ? zlib.inflateRawSync(data) : data }); } catch (e) { out.push({ name, error: `압축을 풀지 못했습니다 (${e.message})` }); } }
off += 46 + nlen + xlen + clen;
}
return out;
}
/**
* 업로드된 바이트를 JSON 으로 읽는다 (v1.3.0).
* 번역 도구·메모장·엑셀에서 저장하면 UTF-8 BOM 이 붙거나 UTF-16 으로 저장되는 경우가 많아 그대로 JSON.parse 하면 실패한다.
* BOM 을 보고 인코딩을 고르고, 실패하면 몇 번째 줄·칸에서 틀렸는지 알려 준다.
*/
function parseJsonBuffer(buf, filename) {
let text;
if (buf[0] === 0xff && buf[1] === 0xfe) text = buf.slice(2).toString('utf16le');
else if (buf[0] === 0xfe && buf[1] === 0xff) { const b = Buffer.from(buf.slice(2)); b.swap16(); text = b.toString('utf16le'); }
else if (buf.length > 1 && buf[0] !== 0 && buf[1] === 0) text = buf.toString('utf16le'); // BOM 없는 UTF-16LE
else text = buf.toString('utf8');
text = text.replace(/^\uFEFF/, '');
try { return JSON.parse(text); }
catch (e) {
const m = String(e.message).match(/position (\d+)/); let where = '';
if (m) { const pos = +m[1]; const before = text.slice(0, pos); const line = before.split('\n').length; const col = pos - before.lastIndexOf('\n'); where = ` (${line}번째 줄 ${col}번째 칸 근처: "${text.slice(Math.max(0, pos - 20), pos + 20).replace(/\s+/g, ' ')}")`; }
throw httpError(`${filename}: JSON 문법 오류${where}. 따옴표·쉼표·괄호를 확인하세요 (마지막 항목 뒤 쉼표, 번역문 안의 큰따옴표는 \\" 로 적어야 함)`);
}
}
/** 업로드 처리: JSON 파일들 또는 ZIP. 파일별 결과를 돌려준다(한 파일이 틀려도 나머지는 저장) */
function importFiles(files) {
const results = [];
for (const f of files) {
const name = f.name || 'file'; const buf = f.data;
try {
if (/\.zip$/i.test(name) || (buf[0] === 0x50 && buf[1] === 0x4b)) { const entries = unzip(buf); if (!entries.some((z) => /\.json$/i.test(z.name) && !/(^|\/)\./.test(z.name))) results.push({ ok: false, file: name, error: `${name}: ZIP 안에 .json 파일이 없습니다` }); for (const z of entries) { if (!/\.json$/i.test(z.name) || /(^|\/)\./.test(z.name) || /(^|\/)__MACOSX\//.test(z.name)) continue; if (z.error) { results.push({ ok: false, file: `${name}/${z.name}`, error: `${name}/${z.name}: ${z.error}` }); continue; } try { results.push({ ok: true, ...importJson(parseJsonBuffer(z.data, `${name}/${z.name}`), `${name}/${z.name}`) }); } catch (e) { results.push({ ok: false, file: `${name}/${z.name}`, error: e.message }); } } }
else results.push({ ok: true, ...importJson(parseJsonBuffer(buf, name), name) });
} catch (e) { results.push({ ok: false, file: name, error: e.message.startsWith(name) ? e.message : `${name}: ${e.message}` }); }
}
return results;
}
/**
* 정적 HTML 을 서버에서 미리 번역 (v1.3.10, 속도 개선).
* 사용자가 고른 언어(쿠키 wm_lang)로 페이지의 텍스트 노드와 번역 대상 속성을 바꿔 보낸다. 브라우저 런타임(public/i18n.js)은
* 동적으로 그려지는 부분만 번역하면 되므로 첫 화면이 한국어로 깜빡이지 않고, 큰 화면에서 치환 작업이 줄어든다.
* 결과는 (언어, 파일, 사전 시각) 별로 메모리에 캐시한다.
*/
const pageCache = new Map();
/** v1.3.17 (S14): HTML 태그·스크립트처럼 보이는 번역 (원문에 태그가 없는데 번역에 있으면 받지 않는다) */
const MARKUP = /<\s*[a-zA-Z!\/]|javascript:|on\w+\s*=/i;
/** 번역문을 HTML 글자로 넣을 때 이스케이프 (번역 파일은 신뢰 경계 밖에서 오므로 마크업으로 해석되지 않게) */
const escText = (v) => String(v).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const escAttr = (v) => escText(v).replace(/"/g, '&quot;');
function translateHtml(html, lang, cacheKey) {
if (!lang || lang === SOURCE) return html;
const loc = loadLocale(lang); if (!loc || loc.enabled === false || isOutdated(loc)) return html;
const key = `${lang}|${cacheKey}|${loc.uploadedAt}`; if (pageCache.has(key)) return pageCache.get(key);
const dict = loc.dict || {};
// v1.3.17 (S14): 번역문은 글자로만 넣는다 (텍스트는 escText, 속성은 escAttr) — 번역 파일에 <script> 등이 있어도 실행되지 않는다
const tr = (t, esc = escText) => { const k = t.trim(); if (!k || !HANGUL.test(k)) return t; const v = dict[k]; return v ? t.replace(k, () => esc(v)) : t; };
let out = ''; let i = 0; let skip = null;
while (i < html.length) {
const lt = html.indexOf('<', i); if (lt < 0) { out += skip ? html.slice(i) : tr(html.slice(i)); break; }
out += skip ? html.slice(i, lt) : tr(html.slice(i, lt));
const gt = html.indexOf('>', lt); if (gt < 0) { out += html.slice(lt); break; }
let tag = html.slice(lt, gt + 1); const m = tag.match(/^<\/?([a-zA-Z][\w-]*)/); const name = m ? m[1].toLowerCase() : '';
if (tag.startsWith('<!--')) { const e = html.indexOf('-->', lt); out += html.slice(lt, e < 0 ? html.length : e + 3); i = e < 0 ? html.length : e + 3; continue; }
if (tag.startsWith('</')) { if (skip === name) skip = null; out += tag; i = gt + 1; continue; }
if (!skip) tag = tag.replace(/\s(placeholder|title|data-tip|aria-label|alt)="([^"]*)"/g, (all, a, v) => { const v1 = v.replace(/&#10;/g, ' '); const t1 = tr(v1, escAttr); return ` ${a}="${t1 === v1 ? v : t1}"`; });
if (name === 'html') tag = tag.replace(/\slang="[^"]*"/, ` lang="${lang}"`).replace(/>$/, RTL.has(lang.split('-')[0]) && !/\sdir=/.test(tag) ? ' dir="rtl">' : '>');
if (['script', 'style', 'textarea'].includes(name) && !tag.endsWith('/>')) skip = name;
out += tag; i = gt + 1;
}
pageCache.set(key, out); if (pageCache.size > 200) pageCache.delete(pageCache.keys().next().value);
return out;
}
module.exports = { translateHtml, RTL, SOURCE, VERSION, buildCatalog, exportFile, listLanguages, activeLanguages, dictionary, setEnabled, remove, importFiles, importJson, unzip, semverLt, loadLocale };