'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/.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(/ /g, ' '), type, `[${attr}]`); } if (tag === 'script' || tag === 'style') { const close = src.indexOf(`')); 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 을 만드는 템플릿(`약 ${a} ~ ${b}` 등)을 태그 경계로 나눠 화면에 보일 글자 조각만 돌려준다 (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, '&').replace(//g, '>'); const escAttr = (v) => escText(v).replace(/"/g, '"'); 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) — 번역 파일에