diff --git a/.github/ISSUE_TEMPLATE/new-locale.yml b/.github/ISSUE_TEMPLATE/new-locale.yml new file mode 100644 index 0000000..0449abf --- /dev/null +++ b/.github/ISSUE_TEMPLATE/new-locale.yml @@ -0,0 +1,77 @@ +name: 새 언어 추가 요청 +description: 새로운 언어의 커뮤니티 번역을 추가합니다 +title: "[New Locale] " +labels: ["new-locale", "community"] +body: + - type: markdown + attributes: + value: | + 새로운 언어를 추가해 주셔서 감사합니다! 아래 정보를 채워주세요. + + - type: input + id: locale-code + attributes: + label: 언어 코드 + description: "IETF 언어 태그 (예: ja-JP, vi-VN, fr-FR)" + placeholder: "ja-JP" + validations: + required: true + + - type: input + id: locale-name + attributes: + label: 언어 이름 (원어) + description: "해당 언어의 원어 표기 (예: 日本語, Tiếng Việt, Français)" + placeholder: "日本語" + validations: + required: true + + - type: input + id: aliases + attributes: + label: 별칭 (aliases) + description: "/lang 명령어에서 사용할 수 있는 별칭들 (쉼표 구분)" + placeholder: "ja, jp, japanese, 日本語" + validations: + required: true + + - type: input + id: openclaw-version + attributes: + label: 대상 OpenClaw 버전 + description: "번역 대상 OpenClaw 버전" + placeholder: "2026.3.13" + validations: + required: true + + - type: textarea + id: coverage + attributes: + label: 예상 번역률 + description: "전체 키 대비 번역한 키의 비율과 미번역 영역을 알려주세요" + placeholder: | + 번역률: 약 90% + 미번역: settings 섹션 일부 + validations: + required: true + + - type: textarea + id: notes + attributes: + label: 참고사항 + description: "번역 시 참고한 자료나 특이사항이 있으면 알려주세요" + placeholder: "OpenClaw PR #44902의 번역을 기반으로 작업했습니다." + + - type: checkboxes + id: checklist + attributes: + label: 체크리스트 + options: + - label: chunk 파일이 올바른 JS export 형식입니다 + required: true + - label: locale-meta.json에 언어 정보를 추가했습니다 + required: true + - label: aliases가 기존 언어와 중복되지 않습니다 + required: true + - label: CONTRIBUTING.md의 가이드를 읽었습니다 + required: true diff --git a/.github/ISSUE_TEMPLATE/translation-update.yml b/.github/ISSUE_TEMPLATE/translation-update.yml new file mode 100644 index 0000000..8a26a83 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/translation-update.yml @@ -0,0 +1,68 @@ +name: 번역 개선 요청 +description: 기존 번역의 오류 수정이나 품질 개선을 요청합니다 +title: "[Translation] " +labels: ["translation-update"] +body: + - type: markdown + attributes: + value: | + 번역 개선에 기여해 주셔서 감사합니다! + + - type: input + id: locale-code + attributes: + label: 언어 코드 + description: "수정 대상 언어 코드 (예: ko-KR)" + placeholder: "ko-KR" + validations: + required: true + + - type: input + id: openclaw-version + attributes: + label: OpenClaw 버전 + description: "해당 번역의 OpenClaw 버전" + placeholder: "2026.3.13" + validations: + required: true + + - type: dropdown + id: type + attributes: + label: 요청 유형 + options: + - 오역 수정 + - 누락 번역 추가 + - 용어 통일 + - 문체 개선 + - 새 버전 키 추가 + validations: + required: true + + - type: textarea + id: details + attributes: + label: 상세 내용 + description: "수정이 필요한 부분과 제안하는 번역을 알려주세요" + placeholder: | + 키: common.health + 현재 번역: 건강 상태 + 제안 번역: 시스템 상태 + + 이유: OpenClaw 맥락에서 "health"는 시스템 상태를 의미합니다. + validations: + required: true + + - type: textarea + id: context + attributes: + label: 참고 맥락 + description: "해당 번역이 사용되는 UI 화면이나 맥락을 알려주세요 (스크린샷 첨부 가능)" + + - type: checkboxes + id: checklist + attributes: + label: 체크리스트 + options: + - label: OpenClaw UI에서 해당 번역의 맥락을 확인했습니다 + required: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..9d8f279 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,23 @@ +## 변경 내용 + + + +## 번역 정보 + +- **언어**: +- **OpenClaw 버전**: +- **번역률**: + +## 테스트 방법 + +1. OpenClaw에 플러그인 설치: `openclaw plugins install -l ./plugin` +2. `/lang <언어코드>` 명령어 실행 +3. 브라우저 새로고침 후 설정에서 언어 변경 확인 + +## 체크리스트 + +- [ ] chunk 파일이 올바른 JS export 형식 (`var e={...};export{e as xx_XX};`) +- [ ] `locale-meta.json`에 언어 정보가 올바르게 등록됨 +- [ ] aliases가 기존 언어와 중복되지 않음 +- [ ] `CONTRIBUTING.md`의 파일명 규칙을 따름 (`{locale-code}-community.js`) +- [ ] 번역률이 `locale-meta.json`의 `coverage` 필드에 반영됨 diff --git a/.github/workflows/check-coverage.yml b/.github/workflows/check-coverage.yml new file mode 100644 index 0000000..c0e26d1 --- /dev/null +++ b/.github/workflows/check-coverage.yml @@ -0,0 +1,110 @@ +name: 번역률 계산 + +on: + pull_request: + paths: + - "locales/**" + - "locale-meta.json" + - "scripts/check-coverage.ts" + - "scripts/en-keys/**" + - "package.json" + - "package-lock.json" + push: + branches: + - main + paths: + - "locales/**" + - "locale-meta.json" + - "scripts/check-coverage.ts" + - "scripts/en-keys/**" + - "package.json" + - "package-lock.json" + +permissions: + pull-requests: write # fork PR에서 PR 코멘트 작성에 필요 + issues: write # issues.createComment / issues.updateComment 호출에 필요 + contents: read + +jobs: + check-coverage: + name: 번역률 계산 + runs-on: ubuntu-latest + + steps: + - name: 체크아웃 + uses: actions/checkout@v4 + + - name: Node.js 설정 + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: 의존성 설치 + run: npm ci + + - name: 번역률 계산 + id: coverage + run: npm run check-coverage + + - name: PR 코멘트 작성 + # fork PR / Dependabot PR은 read-only 토큰이므로 코멘트 작성 건너뜀 + # 동일 레포 PR에서만 실행 (외부 기여자는 Actions 탭 로그로 결과 확인) + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + + // 스크립트를 다시 실행하여 마크다운 결과 획득 + const { execSync } = require('child_process'); + let output; + try { + output = execSync('npm run check-coverage --silent', { encoding: 'utf-8' }); + } catch (e) { + output = e.stdout || '번역률 계산 중 오류가 발생했습니다.'; + } + + // 번역률 테이블을 마크다운으로 변환 + const lines = output.split('\n'); + const tableLines = lines.filter(l => l.startsWith('|') || l.includes('===')); + let body = '## 📊 번역률 계산 결과\n\n'; + + if (tableLines.length > 0) { + body += tableLines.filter(l => l.startsWith('|')).join('\n') + '\n'; + } else { + body += '번역 파일이 없습니다.\n'; + } + + // 80% 미만 경고 + if (output.includes('⚠️')) { + body += '\n> ⚠️ **주의**: 번역률이 80% 미만인 언어가 있습니다.\n'; + } + + body += '\n---\n🤖 *자동 생성된 코멘트 — [check-coverage.yml](.github/workflows/check-coverage.yml)*'; + + // 기존 코멘트 찾아서 업데이트 또는 새로 생성 + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const botComment = comments.find(c => + c.body.includes('📊 번역률 계산 결과') && c.user.type === 'Bot' + ); + + if (botComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } diff --git a/.github/workflows/validate-locale.yml b/.github/workflows/validate-locale.yml new file mode 100644 index 0000000..9959045 --- /dev/null +++ b/.github/workflows/validate-locale.yml @@ -0,0 +1,214 @@ +name: 로케일 검증 + +on: + pull_request: + paths: + - "locales/**" + - "locale-meta.json" + - ".github/workflows/validate-locale.yml" + +jobs: + validate: + name: 로케일 파일 검증 + runs-on: ubuntu-latest + + steps: + - name: 체크아웃 + uses: actions/checkout@v4 + + - name: Node.js 설정 + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: locale-meta.json 스키마 검증 + run: | + node -e " + const fs = require('fs'); + const meta = JSON.parse(fs.readFileSync('locale-meta.json', 'utf-8')); + + const errors = []; + + // 필수 최상위 필드 + if (typeof meta.schemaVersion !== 'number') { + errors.push('schemaVersion 필드가 없거나 숫자가 아닙니다.'); + } + if (!Array.isArray(meta.officialLocales)) { + errors.push('officialLocales 필드가 없거나 배열이 아닙니다.'); + } + if (typeof meta.locales !== 'object' || meta.locales === null) { + errors.push('locales 필드가 없거나 객체가 아닙니다.'); + } + + // 각 locale 엔트리 검증 + const allAliases = []; + for (const [code, entry] of Object.entries(meta.locales || {})) { + if (!entry.name) { + errors.push(code + ': name 필드가 없습니다.'); + } + if (!Array.isArray(entry.aliases)) { + errors.push(code + ': aliases 필드가 없거나 배열이 아닙니다.'); + } else { + allAliases.push(...entry.aliases.map(a => ({ alias: a, locale: code }))); + } + if (typeof entry.versions !== 'object') { + errors.push(code + ': versions 필드가 없거나 객체가 아닙니다.'); + } else { + for (const [ver, vInfo] of Object.entries(entry.versions)) { + if (!vInfo.file) errors.push(code + '/' + ver + ': file 필드가 없습니다.'); + if (!vInfo.exportName) errors.push(code + '/' + ver + ': exportName 필드가 없습니다.'); + if (!vInfo.coverage) errors.push(code + '/' + ver + ': coverage 필드가 없습니다. /lang 명령어에서 번역률이 undefined로 표시됩니다.'); + } + } + } + + // aliases 중복 검사 + const seen = {}; + for (const { alias, locale } of allAliases) { + const lower = alias.toLowerCase(); + if (seen[lower] && seen[lower] !== locale) { + errors.push('alias 중복: \"' + alias + '\"이(가) ' + seen[lower] + '과(와) ' + locale + '에서 모두 사용됩니다.'); + } + seen[lower] = locale; + } + + // aliases가 officialLocales와 충돌하는지 검사 + // 공식 locale 코드와 겹치는 alias가 있으면 런타임에서 해당 locale에 + // 도달할 수 없으므로 (installLanguage()가 공식 locale을 먼저 확인) CI에서 차단 + const officialSet = new Set((meta.officialLocales || []).map(l => l.toLowerCase())); + for (const { alias, locale } of allAliases) { + if (officialSet.has(alias.toLowerCase())) { + errors.push(locale + ': alias \"' + alias + '\"이(가) 공식 locale과 충돌합니다. 런타임에서 해당 커뮤니티 locale에 도달할 수 없습니다.'); + } + } + // locale 코드 자체가 officialLocales와 충돌하는지도 검사 + for (const code of Object.keys(meta.locales || {})) { + if (officialSet.has(code.toLowerCase())) { + errors.push(code + ': 커뮤니티 locale 코드가 공식 locale과 충돌합니다.'); + } + } + + // alias가 다른 커뮤니티 locale 코드와 충돌하는지 검사 + // resolveAlias()는 코드 정확 일치를 alias 탐색보다 먼저 수행하므로, + // locale B의 alias가 locale A의 코드와 같으면 그 alias는 도달 불가능합니다. + const communityCodeMap = {}; + for (const code of Object.keys(meta.locales || {})) { + communityCodeMap[code.toLowerCase()] = code; + } + for (const { alias, locale } of allAliases) { + const lower = alias.toLowerCase(); + if (communityCodeMap[lower] && communityCodeMap[lower] !== locale) { + const conflictCode = communityCodeMap[lower]; + errors.push(locale + ': alias \"' + alias + '\"이(가) 다른 커뮤니티 locale 코드 \"' + conflictCode + '\"와 충돌합니다. resolveAlias()에서 코드 일치가 alias보다 우선하므로 이 alias는 도달 불가능합니다.'); + } + } + + if (errors.length > 0) { + console.error('❌ locale-meta.json 검증 실패:\\n'); + errors.forEach(e => console.error(' - ' + e)); + process.exit(1); + } + + console.log('✅ locale-meta.json 검증 통과'); + " + + - name: chunk 파일 문법 검증 + run: | + node -e " + const fs = require('fs'); + const path = require('path'); + const meta = JSON.parse(fs.readFileSync('locale-meta.json', 'utf-8')); + const errors = []; + + for (const [code, entry] of Object.entries(meta.locales)) { + for (const [ver, vInfo] of Object.entries(entry.versions)) { + const filePath = vInfo.file; + if (!fs.existsSync(filePath)) { + errors.push(filePath + ': 파일이 존재하지 않습니다.'); + continue; + } + + const content = fs.readFileSync(filePath, 'utf-8'); + + // JS export 형식 확인 + if (!content.match(/var\s+\w+\s*=\s*\{/)) { + errors.push(filePath + ': var 선언이 없습니다.'); + } + if (!content.match(/export\s*\{/)) { + errors.push(filePath + ': export 구문이 없습니다.'); + } + + // exportName 확인 — 파일 내 raw substring 탐색이 아닌 실제 export 구문 검사 + // content.includes(expectedExport) 방식은 번역 값 안에 해당 문자열이 있으면 + // 오탐이 발생하므로, export{...as } 형태의 클로즈에서 as 뒤 식별자를 직접 검사합니다. + const expectedExport = vInfo.exportName; + const escapedExport = expectedExport.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + // (1) export{ as } 구문이 존재하는지 확인 + const exportClausePattern = new RegExp('export\\s*\\{[^}]*\\b(\\w+)\\s+as\\s+' + escapedExport + '\\b[^}]*\\}'); + const exportMatch = content.match(exportClausePattern); + if (!exportMatch) { + errors.push(filePath + ': export 구문에서 \"' + expectedExport + '\"이(가) 내보내지지 않습니다. export{...as ' + expectedExport + '} 형태로 명시해야 합니다.'); + } else { + // (2) export 좌변의 로컬 바인딩이 파일 내에서 실제로 선언되어 있는지 검사합니다. + // export{missing as ko_KR}처럼 undeclared 식별자를 내보내는 경우 + // JS 문법 파서(new Function)는 통과하지만 브라우저에서 모듈 로드 시 실패합니다. + const localBinding = exportMatch[1]; + const escapedLocal = localBinding.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const declPattern = new RegExp('(?:^|[\\s;{,(])(?:var|const|let|function)\\s+' + escapedLocal + '\\b'); + if (!declPattern.test(content)) { + errors.push(filePath + ': export 바인딩 \"' + localBinding + '\"이(가) 파일 내에 선언되지 않았습니다. \"export{' + localBinding + ' as ' + expectedExport + '}\"에서 \"' + localBinding + '\"은 var/const/let/function으로 선언되어 있어야 합니다.'); + } + } + + // JS 문법 검증 — ESM export 구문을 제거한 후 파싱하여 + // 누락된 콤마 등 구조적 오류를 감지합니다. + // (new Function()은 classic script만 지원하므로 export 구문이 있으면 실패) + try { + const scriptContent = content.replace(/export\s*\{[^}]*\}\s*;?\s*$/, ''); + new Function(scriptContent); + } catch (syntaxErr) { + errors.push(filePath + ': JS 문법 오류 — ' + syntaxErr.message); + } + } + } + + // locale-meta.json에 등록되지 않은 orphan locale 파일 검사 + // 플러그인은 locale-meta.json을 통해서만 파일을 탐색하므로, + // locales/ 디렉토리에 존재하지만 메타에 등록되지 않은 파일은 프로덕션에서 + // 도달 불가능합니다. PR에서 미리 차단합니다. + const registeredFiles = new Set(); + for (const [code2, entry2] of Object.entries(meta.locales)) { + for (const [ver2, vInfo2] of Object.entries(entry2.versions)) { + registeredFiles.add(path.resolve(vInfo2.file)); + } + } + + function walkDir(dir) { + if (!fs.existsSync(dir)) return []; + const results = []; + for (const dirent of fs.readdirSync(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, dirent.name); + if (dirent.isDirectory()) { + results.push(...walkDir(fullPath)); + } else if (dirent.isFile() && dirent.name.endsWith('.js')) { + results.push(fullPath); + } + } + return results; + } + + const allLocaleFiles = walkDir('locales'); + for (const filePath of allLocaleFiles) { + if (!registeredFiles.has(path.resolve(filePath))) { + errors.push(filePath + ': locale-meta.json에 등록되지 않은 파일입니다. 플러그인이 이 파일을 로드할 수 없으므로 locale-meta.json에 등록하거나 파일을 제거하세요.'); + } + } + + if (errors.length > 0) { + console.error('❌ chunk 파일 검증 실패:\\n'); + errors.forEach(e => console.error(' - ' + e)); + process.exit(1); + } + + console.log('✅ 모든 chunk 파일 검증 통과'); + " diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5e9eee0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.js.map diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a398d52 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,92 @@ +# 기여 가이드 + +OpenClaw i18n Plus에 기여해주셔서 감사합니다! 이 문서는 새로운 언어를 추가하거나 기존 번역을 개선하는 방법을 안내합니다. + +## 새 언어 추가하기 + +### 1. locale chunk 파일 작성 + +기존 chunk 파일(예: `locales/` 디렉토리 내 파일)을 참고하여 새 언어의 chunk 파일을 작성합니다. + +chunk 파일은 다음 포맷을 따릅니다: + +```javascript +var e={common:{health:"건강 상태",...},nav:{...},...};export{e as ko_KR}; +``` + +주의사항: +- export 이름은 locale 코드에서 `-`를 `_`로 변환한 값입니다 (예: `ko-KR` → `ko_KR`) +- OpenClaw의 기존 영어 키를 기준으로 번역합니다 +- 번역하지 않은 키는 생략해도 됩니다 (영어 fallback이 자동 적용됨) + +### 2. 파일 배치 + +`locales/{openclaw-version}/` 디렉토리에 파일을 추가합니다. + +``` +locales/ +└── 2026.3.13/ + └── {locale-code}-community.js +``` + +파일명 규칙: `{locale-code}-community.js` (예: `ko-KR-community.js`, `ja-JP-community.js`) + +### 3. locale-meta.json 업데이트 + +`locale-meta.json`에 언어 정보를 등록합니다: + +```json +{ + "locales": { + "ja-JP": { + "name": "日本語", + "aliases": ["ja", "jp", "japanese", "日本語"], + "versions": { + "2026.3.13": { + "file": "locales/2026.3.13/ja-JP-community.js", + "exportName": "ja_JP", + "coverage": "95%" + } + } + } + } +} +``` + +필드 설명: +- `name`: 해당 언어의 원어 이름 +- `aliases`: 사용자가 `/lang` 명령어에서 사용할 수 있는 별칭들 +- `versions`: OpenClaw 버전별 chunk 파일 정보 + - `file`: chunk 파일 경로 + - `exportName`: chunk 파일의 export 이름 (`-` → `_` 변환) + - `coverage`: 번역률 (전체 키 대비 번역된 키의 비율) + +### 4. PR 제출 + +- 브랜치명: `locale/{locale-code}` (예: `locale/ja-JP`) +- PR 제목: `Add {언어이름} ({locale-code}) locale` +- PR 본문에 번역률과 참고한 자료를 명시해주세요 + +## 기존 번역 개선하기 + +1. 해당 locale chunk 파일을 수정합니다 +2. 번역률이 변경된 경우 `locale-meta.json`도 업데이트합니다 +3. PR을 제출합니다 + +## 새 OpenClaw 버전 대응 + +OpenClaw에 새 버전이 출시되면: + +1. 새 버전에서 추가/변경된 번역 키를 확인합니다 +2. `locales/{new-version}/` 디렉토리에 업데이트된 chunk 파일을 추가합니다 +3. `locale-meta.json`의 `versions`에 새 버전 엔트리를 추가합니다 + +## 코드 스타일 + +- 커밋 메시지는 한국어 또는 영어로 작성합니다 +- chunk 파일은 1줄 JS 포맷을 유지합니다 (minified) +- JSON 파일은 2칸 들여쓰기를 사용합니다 + +## 질문이 있다면 + +이슈를 생성하거나 디스커션에서 질문해주세요. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ca47831 --- /dev/null +++ b/README.md @@ -0,0 +1,103 @@ +# OpenClaw i18n Plus + +커뮤니티 주도 OpenClaw 언어팩 — 명령어 한 줄로 설치합니다. + +Community-driven language packs for OpenClaw, installable in a single command. + +> OpenClaw 공식 i18n 지원이 보류된 동안([#3460](https://github.com/openclaw/openclaw/issues/3460)), 이 플러그인이 커뮤니티 언어팩을 코어 수정 없이 제공합니다. +> +> While OpenClaw's official i18n support is pending ([#3460](https://github.com/openclaw/openclaw/issues/3460)), this plugin bridges the gap by delivering community-maintained locale chunks without touching OpenClaw's core. + +--- + +## 지원 언어 / Supported Languages + +### 공식 언어팩 (OpenClaw 내장) / Official (built-in) + +`de` `es` `pt-BR` `zh-CN` `zh-TW` + +### 커뮤니티 언어팩 / Community Packs + +| 언어 Language | 코드 Code | 상태 Status | +|--------------|-----------|-------------| +| 한국어 Korean | `ko` / `ko-KR` | ✅ 사용 가능 Available | + +--- + +## 설치 / Installation + +### 1. 플러그인 설치 (최초 1회) / Install the plugin (once) + +```bash +git clone https://github.com/mapd3692/openclaw-i18n-plus.git +cd openclaw-i18n-plus +openclaw plugins install -l ./plugin +``` + +### 2. Gateway 재시작 / Restart the Gateway + +플러그인 설치 후 **반드시 OpenClaw Gateway를 재시작**해야 `/lang` 명령어가 등록됩니다. + +After installing the plugin, **restart the OpenClaw Gateway** before using `/lang`. + +```bash +openclaw gateway restart +``` + +### 3. 언어팩 설치 / Install a language pack + +``` +/lang ko +``` + +OpenClaw 업데이트 후에는 플러그인이 자동으로 감지해 재패치합니다. + +After an OpenClaw update, the plugin automatically detects the version change and re-patches. + +--- + +## 사용법 / Usage + +| 명령어 Command | 설명 Description | +|---------------|-----------------| +| `/lang` | 사용 가능한 언어 목록 보기 / List available community packs | +| `/lang <코드 code>` | 언어팩 설치 / Install a community language pack | + +**예시 / Examples:** + +``` +/lang +/lang ko +/lang korean +``` + +공식 언어코드(예: `de`, `es`)를 입력하면, Control UI 설정에서 직접 변경하도록 안내합니다. + +If you enter an official locale code (e.g. `de`, `es`), the plugin will guide you to the built-in language setting in Control UI instead. + +--- + +## 동작 원리 / How It Works + +OpenClaw Control UI 빌드 결과물만 패치합니다. 코어는 수정하지 않습니다: + +This plugin patches OpenClaw's Control UI build artifacts directly — no core modifications: + +1. `locale-meta.json`에서 언어 정보 확인 / Fetches language metadata from `locale-meta.json` +2. 현재 버전에 맞는 locale chunk 다운로드 / Downloads the matching locale chunk for your OpenClaw version +3. chunk 파일을 `/app/dist/control-ui/assets/`에 저장 / Copies the chunk to assets directory +4. 메인 번들의 locale 매핑 테이블에 엔트리 삽입 / Injects a locale entry into the main bundle + +--- + +## 기여하기 / Contributing + +새로운 언어를 추가하거나 번역을 개선하고 싶다면 [CONTRIBUTING.md](CONTRIBUTING.md)를 참고해주세요. + +Want to add a new language or improve an existing translation? See [CONTRIBUTING.md](CONTRIBUTING.md). + +--- + +## 라이선스 / License + +[MIT](LICENSE) diff --git a/locale-meta.json b/locale-meta.json new file mode 100644 index 0000000..569c107 --- /dev/null +++ b/locale-meta.json @@ -0,0 +1,17 @@ +{ + "schemaVersion": 1, + "officialLocales": ["de", "es", "pt-BR", "zh-CN", "zh-TW"], + "locales": { + "ko-KR": { + "name": "한국어", + "aliases": ["ko", "kr", "kor", "korean", "한국어"], + "versions": { + "2026.3.13": { + "file": "locales/2026.3.13/ko-KR-community.js", + "exportName": "ko_KR", + "coverage": "98%" + } + } + } + } +} diff --git a/locales/2026.3.13/ko-KR-community.js b/locales/2026.3.13/ko-KR-community.js new file mode 100644 index 0000000..43afb2d --- /dev/null +++ b/locales/2026.3.13/ko-KR-community.js @@ -0,0 +1 @@ +var e={common:{submit:`제출`,cancel:`취소`,save:`저장`,delete:`삭제`,edit:`편집`,add:`추가`,remove:`제거`,close:`닫기`,confirm:`확인`,back:`뒤로`,next:`다음`,loading:`로딩 중...`,error:`오류`,success:`성공`,warning:`경고`,info:`정보`,yes:`예`,no:`아니오`,ok:`확인`,retry:`재시도`,search:`검색`,filter:`필터`,sort:`정렬`,refresh:`새로고침`,download:`다운로드`,upload:`업로드`,copy:`복사`,paste:`붙여넣기`,reset:`초기화`,apply:`적용`,enable:`활성화`,disable:`비활성화`,enabled:`활성화됨`,disabled:`비활성화됨`,status:`상태`,name:`이름`,description:`설명`,type:`유형`,value:`값`,actions:`작업`,details:`상세`,created:`생성됨`,updated:`수정됨`,version:`버전`,unknown:`알 수 없음`,none:`없음`,all:`전체`,selected:`선택됨`,required:`필수`,optional:`선택사항`,default:`기본값`,custom:`사용자 지정`,preview:`미리보기`,more:`더 보기`,less:`접기`,expand:`펼치기`,collapse:`접기`,settings:`설정`,configuration:`구성`,preferences:`환경설정`,general:`일반`,advanced:`고급`,basic:`기본`,total:`합계`,count:`개수`,size:`크기`,date:`날짜`,time:`시간`,duration:`기간`,start:`시작`,end:`종료`,from:`시작`,to:`종료`,min:`최소`,max:`최대`,on:`켜기`,off:`끄기`,auto:`자동`,manual:`수동`,active:`활성`,inactive:`비활성`,online:`온라인`,offline:`오프라인`,connected:`연결됨`,disconnected:`연결 해제됨`,pending:`대기 중`,running:`실행 중`,stopped:`중지됨`,completed:`완료됨`,failed:`실패`,paused:`일시정지`,queued:`대기열`,healthy:`정상`,unhealthy:`비정상`,deprecated:`사용 중단`,experimental:`실험적`},nav:{home:`홈`,dashboard:`대시보드`,overview:`개요`,services:`서비스`,containers:`컨테이너`,images:`이미지`,volumes:`볼륨`,networks:`네트워크`,configs:`구성`,secrets:`시크릿`,plugins:`플러그인`,users:`사용자`,logs:`로그`,events:`이벤트`,monitoring:`모니터링`,terminal:`터미널`,registry:`레지스트리`,stacks:`스택`,templates:`템플릿`,settings:`설정`,about:`정보`,help:`도움말`,documentation:`문서`,support:`지원`,feedback:`피드백`,logout:`로그아웃`,login:`로그인`,profile:`프로필`,notifications:`알림`,tasks:`작업`,schedules:`스케줄`,backups:`백업`,updates:`업데이트`,system:`시스템`,cluster:`클러스터`,nodes:`노드`,endpoints:`엔드포인트`,groups:`그룹`,tags:`태그`,environments:`환경`,access:`접근`,security:`보안`,authentication:`인증`,authorization:`권한`,roles:`역할`,teams:`팀`,audit:`감사`,activity:`활동`,wizard:`마법사`,setup:`설정`,integration:`통합`,extensions:`확장`,marketplace:`마켓플레이스`},settings:{language:`언어`,theme:`테마`,darkMode:`다크 모드`,lightMode:`라이트 모드`,autoTheme:`자동 테마`,timezone:`시간대`,dateFormat:`날짜 형식`,timeFormat:`시간 형식`,notifications:`알림 설정`,email:`이메일`,password:`비밀번호`,changePassword:`비밀번호 변경`,twoFactor:`이중 인증`,apiKeys:`API 키`,accessTokens:`접근 토큰`,webhooks:`웹훅`,backup:`백업`,restore:`복원`,export:`내보내기`,import:`가져오기`,reset:`초기화`,factory:`공장 초기화`,update:`업데이트`,checkUpdates:`업데이트 확인`,autoUpdate:`자동 업데이트`,telemetry:`원격 측정`,analytics:`분석`,privacy:`개인정보 보호`,terms:`이용약관`,license:`라이선스`,about:`정보`,version:`버전`,buildInfo:`빌드 정보`,systemInfo:`시스템 정보`,resourceUsage:`리소스 사용량`,storageUsage:`저장소 사용량`,sessionTimeout:`세션 시간 초과`,edgeCompute:`엣지 컴퓨팅`,ssl:`SSL 인증서`,customLogo:`사용자 로고`,appUrl:`애플리케이션 URL`,internalAuth:`내부 인증`,ldap:`LDAP`,oauth:`OAuth`,saveSettings:`설정 저장`,settingsSaved:`설정이 저장되었습니다`,settingsError:`설정 저장 중 오류가 발생했습니다`},containers:{container:`컨테이너`,containers:`컨테이너`,createContainer:`컨테이너 생성`,startContainer:`컨테이너 시작`,stopContainer:`컨테이너 중지`,restartContainer:`컨테이너 재시작`,removeContainer:`컨테이너 제거`,pauseContainer:`컨테이너 일시정지`,resumeContainer:`컨테이너 재개`,killContainer:`컨테이너 강제 종료`,inspectContainer:`컨테이너 검사`,containerLogs:`컨테이너 로그`,containerStats:`컨테이너 통계`,containerConsole:`컨테이너 콘솔`,exec:`실행`,attach:`연결`,portMappings:`포트 매핑`,volumeMounts:`볼륨 마운트`,environmentVars:`환경 변수`,networkSettings:`네트워크 설정`,restartPolicy:`재시작 정책`,resources:`리소스`,cpuLimit:`CPU 제한`,memoryLimit:`메모리 제한`,healthCheck:`헬스 체크`,labels:`레이블`,image:`이미지`,command:`명령어`,entrypoint:`엔트리포인트`,workingDir:`작업 디렉토리`,user:`사용자`,hostname:`호스트명`,domainName:`도메인 이름`,privileged:`특권 모드`,readOnly:`읽기 전용`,autoRemove:`자동 제거`,created:`생성됨`,running:`실행 중`,paused:`일시정지됨`,restarting:`재시작 중`,exited:`종료됨`,dead:`종료(데드)`,uptime:`가동 시간`,ipAddress:`IP 주소`,ports:`포트`,state:`상태`,pid:`PID`,exitCode:`종료 코드`,startedAt:`시작 시각`,finishedAt:`종료 시각`,gpu:`GPU`,gpuOptions:`GPU 옵션`,capabilities:`기능`,sysctls:`시스템 설정`,devices:`장치`,runtime:`런타임`,recreate:`재생성`,duplicate:`복제`,updateContainer:`컨테이너 업데이트`,rollback:`롤백`,scale:`스케일`},images:{image:`이미지`,images:`이미지`,pullImage:`이미지 풀`,buildImage:`이미지 빌드`,removeImage:`이미지 제거`,tagImage:`이미지 태그`,pushImage:`이미지 푸시`,exportImage:`이미지 내보내기`,importImage:`이미지 가져오기`,imageHistory:`이미지 히스토리`,imageInspect:`이미지 검사`,layers:`레이어`,repository:`리포지토리`,tag:`태그`,imageId:`이미지 ID`,imageSize:`이미지 크기`,created:`생성됨`,dockerfile:`Dockerfile`,buildContext:`빌드 컨텍스트`,buildArgs:`빌드 인수`,noCache:`캐시 없이`,forceRemove:`강제 제거`,dangling:`댕글링`,unused:`미사용`,prune:`정리`,pruneImages:`이미지 정리`,totalSize:`전체 크기`,reclaimable:`회수 가능`,registry:`레지스트리`,registryLogin:`레지스트리 로그인`,registryLogout:`레지스트리 로그아웃`},volumes:{volume:`볼륨`,volumes:`볼륨`,createVolume:`볼륨 생성`,removeVolume:`볼륨 제거`,volumeInspect:`볼륨 검사`,driver:`드라이버`,mountpoint:`마운트 포인트`,scope:`범위`,labels:`레이블`,options:`옵션`,usedBy:`사용 중`,unused:`미사용`,pruneVolumes:`볼륨 정리`,browse:`탐색`,volumeSize:`볼륨 크기`},networks:{network:`네트워크`,networks:`네트워크`,createNetwork:`네트워크 생성`,removeNetwork:`네트워크 제거`,networkInspect:`네트워크 검사`,driver:`드라이버`,subnet:`서브넷`,gateway:`게이트웨이`,ipRange:`IP 범위`,internal:`내부`,attachable:`연결 가능`,ingress:`인그레스`,scope:`범위`,connectedContainers:`연결된 컨테이너`,ipamDriver:`IPAM 드라이버`,macvlan:`macvlan`,overlay:`오버레이`,bridge:`브리지`,host:`호스트`,none:`없음`},stacks:{stack:`스택`,stacks:`스택`,createStack:`스택 생성`,removeStack:`스택 제거`,updateStack:`스택 업데이트`,startStack:`스택 시작`,stopStack:`스택 중지`,stackDetails:`스택 상세`,services:`서비스`,composeFile:`Compose 파일`,editor:`편집기`,webEditor:`웹 편집기`,uploadFile:`파일 업로드`,gitRepository:`Git 리포지토리`,customTemplate:`사용자 템플릿`,stackName:`스택 이름`,environment:`환경`,envFile:`환경 파일`,deploy:`배포`,redeploy:`재배포`,migrate:`마이그레이션`,stackStatus:`스택 상태`,active:`활성`,inactive:`비활성`,limited:`제한적`},endpoints:{endpoint:`엔드포인트`,endpoints:`엔드포인트`,addEndpoint:`엔드포인트 추가`,removeEndpoint:`엔드포인트 제거`,editEndpoint:`엔드포인트 편집`,dockerApi:`Docker API`,dockerSocket:`Docker 소켓`,agentEnvironment:`에이전트 환경`,edgeAgent:`엣지 에이전트`,url:`URL`,publicIp:`공개 IP`,tls:`TLS`,tlsSettings:`TLS 설정`,tlsCa:`TLS CA`,tlsCert:`TLS 인증서`,tlsKey:`TLS 키`,skipVerify:`인증서 검증 건너뛰기`,connectionStatus:`연결 상태`,heartbeat:`하트비트`,edgeId:`엣지 ID`,edgeKey:`엣지 키`,edgeCheckin:`엣지 체크인 간격`},auth:{login:`로그인`,logout:`로그아웃`,username:`사용자명`,password:`비밀번호`,rememberMe:`로그인 유지`,forgotPassword:`비밀번호를 잊으셨나요?`,resetPassword:`비밀번호 재설정`,createAccount:`계정 생성`,loginFailed:`로그인 실패`,invalidCredentials:`유효하지 않은 인증 정보`,sessionExpired:`세션이 만료되었습니다`,accessDenied:`접근이 거부되었습니다`,unauthorized:`인증되지 않음`,forbidden:`금지됨`,administrator:`관리자`,user:`사용자`,team:`팀`,role:`역할`,permissions:`권한`},docker:{dockerVersion:`Docker 버전`,apiVersion:`API 버전`,goVersion:`Go 버전`,os:`운영체제`,arch:`아키텍처`,kernelVersion:`커널 버전`,cpus:`CPU`,memory:`메모리`,storage:`저장소`,storageDriver:`스토리지 드라이버`,loggingDriver:`로깅 드라이버`,cgroupDriver:`Cgroup 드라이버`,swarm:`Swarm`,swarmMode:`Swarm 모드`,manager:`매니저`,worker:`워커`,raft:`Raft`,nodeId:`노드 ID`,nodeAddr:`노드 주소`,managerStatus:`매니저 상태`,availability:`가용성`,drain:`드레인`,pause:`일시정지`},kubernetes:{kubernetes:`쿠버네티스`,namespace:`네임스페이스`,namespaces:`네임스페이스`,pod:`파드`,pods:`파드`,deployment:`디플로이먼트`,deployments:`디플로이먼트`,service:`서비스`,services:`서비스`,configMap:`컨피그맵`,configMaps:`컨피그맵`,secret:`시크릿`,secrets:`시크릿`,ingress:`인그레스`,ingresses:`인그레스`,persistentVolumeClaim:`퍼시스턴트 볼륨 클레임`,persistentVolumeClaims:`퍼시스턴트 볼륨 클레임`,node:`노드`,nodes:`노드`,clusterRole:`클러스터 역할`,clusterRoleBinding:`클러스터 역할 바인딩`,serviceAccount:`서비스 계정`,resourceQuota:`리소스 할당량`,limitRange:`리소스 제한 범위`,horizontalPodAutoscaler:`수평 파드 오토스케일러`,replica:`레플리카`,replicas:`레플리카`,selector:`셀렉터`,annotations:`어노테이션`,tolerations:`톨러레이션`,affinity:`어피니티`,nodeSelector:`노드 셀렉터`},monitoring:{cpu:`CPU`,memory:`메모리`,disk:`디스크`,network:`네트워크`,cpuUsage:`CPU 사용량`,memoryUsage:`메모리 사용량`,diskUsage:`디스크 사용량`,networkIO:`네트워크 I/O`,blockIO:`블록 I/O`,processCount:`프로세스 수`,uptime:`가동 시간`,loadAverage:`로드 에버리지`,bandwidth:`대역폭`,latency:`지연 시간`,throughput:`처리량`,requestRate:`요청 비율`,errorRate:`오류 비율`,responseTime:`응답 시간`,healthStatus:`헬스 상태`,metrics:`메트릭`,alerts:`알림`,chart:`차트`,graph:`그래프`,realtime:`실시간`,historical:`히스토리`,period:`기간`,interval:`간격`,lastHour:`지난 1시간`,lastDay:`지난 24시간`,lastWeek:`지난 1주`,lastMonth:`지난 1달`},messages:{confirmDelete:`정말 삭제하시겠습니까?`,confirmRemove:`정말 제거하시겠습니까?`,confirmStop:`정말 중지하시겠습니까?`,confirmRestart:`정말 재시작하시겠습니까?`,operationSuccess:`작업이 성공적으로 완료되었습니다`,operationFailed:`작업에 실패했습니다`,saveSuccess:`저장되었습니다`,saveFailed:`저장에 실패했습니다`,deleteSuccess:`삭제되었습니다`,deleteFailed:`삭제에 실패했습니다`,connectionError:`연결 오류가 발생했습니다`,timeoutError:`시간 초과 오류가 발생했습니다`,notFound:`찾을 수 없습니다`,alreadyExists:`이미 존재합니다`,invalidInput:`유효하지 않은 입력입니다`,requiredField:`필수 항목입니다`,minLength:`최소 {0}자 이상이어야 합니다`,maxLength:`최대 {0}자까지 입력할 수 있습니다`,noData:`데이터가 없습니다`,noResults:`결과가 없습니다`,loadingError:`로딩 중 오류가 발생했습니다`,networkError:`네트워크 오류가 발생했습니다`,permissionDenied:`권한이 없습니다`,unsavedChanges:`저장되지 않은 변경사항이 있습니다. 계속하시겠습니까?`,copied:`클립보드에 복사되었습니다`},wizard:{welcome:`환영합니다`,getStarted:`시작하기`,quickSetup:`빠른 설정`,environmentSetup:`환경 설정`,selectEnvironment:`환경을 선택하세요`,localDocker:`로컬 Docker`,remoteDocker:`원격 Docker`,kubernetes:`쿠버네티스`,edgeAgent:`엣지 에이전트`,step:`단계`,of:`/`,previous:`이전`,next:`다음`,finish:`완료`,skip:`건너뛰기`}};export{e as ko_KR}; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..0f2f171 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,233 @@ +{ + "name": "openclaw-i18n-plus", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "openclaw-i18n-plus", + "devDependencies": { + "@types/node": "^22.0.0", + "ts-node": "^10.9.2", + "typescript": "^5.4.0" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.15", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz", + "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a0141e2 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "openclaw-i18n-plus", + "private": true, + "scripts": { + "check-coverage": "ts-node scripts/check-coverage.ts", + "extract-keys": "ts-node scripts/extract-keys.ts" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "ts-node": "^10.9.2", + "typescript": "^5.4.0" + } +} diff --git a/plugin/index.ts b/plugin/index.ts new file mode 100644 index 0000000..b38714e --- /dev/null +++ b/plugin/index.ts @@ -0,0 +1,615 @@ +/** + * OpenClaw i18n Plus — 커뮤니티 언어팩 플러그인 + * + * /lang — 사용 가능한 언어 목록 출력 + * /lang — 커뮤니티 언어팩 설치 + */ + +import { execSync } from "child_process"; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs"; +import { join } from "path"; +import https from "https"; +import http from "http"; + +// --- 상수 --- +// 로컬 개발/테스트 시 환경변수로 오버라이드 가능 +// 예: I18N_PLUS_BASE_URL=http://localhost:8080 openclaw plugins install -l ./plugin +const GITHUB_RAW_BASE = + process.env.I18N_PLUS_BASE_URL || + "https://raw.githubusercontent.com/mapd3692/openclaw-i18n-plus/main"; +const LOCALE_META_URL = `${GITHUB_RAW_BASE}/locale-meta.json`; + +// Docker(/app/...) 및 네이티브 설치 모두 지원하기 위해 환경변수 또는 런타임 +// stateDir에서 경로를 유도합니다. 하드코딩 경로는 최종 폴백으로만 사용됩니다. +const DEFAULT_CONTROL_UI_ASSETS = "/app/dist/control-ui/assets"; +const DEFAULT_STATE_FILE = "/app/data/.i18n-plus-state.json"; + +// 런타임에서 주입되는 경로 — register() 호출 시 설정됨 +let resolvedControlUiAssets: string = process.env.OPENCLAW_CONTROL_UI_ASSETS || DEFAULT_CONTROL_UI_ASSETS; +let resolvedStateFile: string = process.env.OPENCLAW_STATE_DIR + ? join(process.env.OPENCLAW_STATE_DIR, ".i18n-plus-state.json") + : DEFAULT_STATE_FILE; + +// --- 타입 --- +interface LocaleVersion { + file: string; + exportName: string; + coverage: string; +} + +interface LocaleEntry { + name: string; + aliases: string[]; + versions: Record; +} + +interface LocaleMeta { + schemaVersion: number; + officialLocales: string[]; + locales: Record; +} + +// --- 유틸리티: HTTP(S) GET --- +// http:// 와 https:// 모두 지원하여 로컬 개발 환경(I18N_PLUS_BASE_URL=http://localhost:8080)에서도 동작 +function httpGet(url: string): Promise { + return new Promise((resolve, reject) => { + const request = (targetUrl: string) => { + const parsedUrl = new URL(targetUrl); + const transport = parsedUrl.protocol === "http:" ? http : https; + transport + .get(targetUrl, (res) => { + // 리다이렉트 처리 + if ( + res.statusCode && + res.statusCode >= 300 && + res.statusCode < 400 && + res.headers.location + ) { + request(res.headers.location); + return; + } + if (res.statusCode !== 200) { + reject(new Error(`HTTP ${res.statusCode} for ${targetUrl}`)); + return; + } + let data = ""; + res.on("data", (chunk: string) => (data += chunk)); + res.on("end", () => resolve(data)); + res.on("error", reject); + }) + .on("error", reject); + }; + request(url); + }); +} + +// --- locale-meta.json 다운로드 --- +async function fetchLocaleMeta(): Promise { + const raw = await httpGet(LOCALE_META_URL); + return JSON.parse(raw) as LocaleMeta; +} + +// --- alias → 정식 locale 코드 정규화 --- +function resolveAlias( + meta: LocaleMeta, + input: string +): { code: string; entry: LocaleEntry } | null { + const lower = input.toLowerCase(); + + // 1. 정확한 locale 코드 매칭 (대소문자 무시) + for (const [code, entry] of Object.entries(meta.locales)) { + if (code.toLowerCase() === lower) { + return { code, entry }; + } + } + + // 2. alias 매칭 + for (const [code, entry] of Object.entries(meta.locales)) { + if (entry.aliases.some((a) => a.toLowerCase() === lower)) { + return { code, entry }; + } + } + + return null; +} + +// --- OpenClaw 버전 확인 --- +function getOpenClawVersion(): string { + try { + const output = execSync("openclaw --version", { encoding: "utf-8" }).trim(); + // "openclaw 2026.3.13" → "2026.3.13" + const match = output.match(/(\d+\.\d+\.\d+)/); + return match ? match[1] : output; + } catch { + // fallback: package.json 등에서 추정 + try { + const pkg = JSON.parse( + readFileSync("/app/package.json", "utf-8") + ); + return pkg.version || "unknown"; + } catch { + return "unknown"; + } + } +} + +// --- 시맨틱 버전 비교 (a <= b) --- +function versionLte(a: string, b: string): boolean { + const pa = a.split(".").map(Number); + const pb = b.split(".").map(Number); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const va = pa[i] || 0; + const vb = pb[i] || 0; + if (va < vb) return true; + if (va > vb) return false; + } + return true; // equal +} + +// --- 가장 적합한 chunk 버전 선택 --- +function selectBestVersion( + versions: Record, + currentVersion: string +): { version: string; versionInfo: LocaleVersion; exact: boolean } | null { + const versionKeys = Object.keys(versions).sort((a, b) => + versionLte(a, b) ? 1 : -1 + ); // 내림차순 정렬 + + // 1. 정확 매칭 + if (versions[currentVersion]) { + return { + version: currentVersion, + versionInfo: versions[currentVersion], + exact: true, + }; + } + + // 2. 가장 가까운 이전 버전 (현재 버전 이하 중 최신) + for (const v of versionKeys) { + if (versionLte(v, currentVersion)) { + return { version: v, versionInfo: versions[v], exact: false }; + } + } + + // 3. 대응 버전 없음 → 가장 최신 버전이라도 사용 + if (versionKeys.length > 0) { + const latest = versionKeys[0]; + return { version: latest, versionInfo: versions[latest], exact: false }; + } + + return null; +} + +// --- 메인 번들에 이미 패치되었는지 확인 --- +// expectedExportName을 전달하면 현재 번들에 패치된 exportName이 일치하는지도 검사합니다. +// locale-meta.json이 같은 코드의 exportName을 변경한 경우(예: 번들 재생성 후), +// 기존 패치가 이미 존재하더라도 재패치가 필요하므로 false를 반환합니다. +function isAlreadyPatched(indexJsPath: string, localeCode: string, expectedExportName?: string): boolean { + const content = readFileSync(indexJsPath, "utf-8"); + if (!content.includes(`"${localeCode}":{exportName:`)) return false; + // expectedExportName이 제공된 경우, 현재 번들의 exportName과 일치하는지 추가 검사 + if (expectedExportName !== undefined) { + return content.includes(`"${localeCode}":{exportName:\`${expectedExportName}\``); + } + return true; +} + +// --- 메인 번들 패치: locale 매핑 테이블에 엔트리 삽입 --- +// 반환값: true = 패치 성공, false = 앵커 미발견(패치 실패) +function patchMainBundle( + indexJsPath: string, + localeCode: string, + exportName: string, + chunkFileName: string +): boolean { + const content = readFileSync(indexJsPath, "utf-8"); + + // "zh-CN" 앵커를 기준으로 삽입 + const anchor = `"zh-CN":`; + if (!content.includes(anchor)) { + // 앵커가 없으면 번들 형식이 변경된 것 — 패치하지 않고 실패 반환 + return false; + } + + const newEntry = + `"${localeCode}":{exportName:\`${exportName}\`,` + + `loader:()=>E(()=>import(\`./${chunkFileName}\`),[],import.meta.url)},`; + + const patched = content.replace(anchor, newEntry + anchor); + writeFileSync(indexJsPath, patched, "utf-8"); + return true; +} + +// --- 메인 번들 파일 탐색 --- +function findMainBundle(): string | null { + try { + const result = execSync( + `find "${resolvedControlUiAssets}" -name "index-*.js" -not -name "*.map" | head -1`, + { encoding: "utf-8" } + ).trim(); + return result || null; + } catch { + return null; + } +} + +// --- 상태 파일 타입 --- +interface PluginState { + installedLocales: Record< + string, + { + patchedAt: string; // ISO 타임스탬프 + openClawVersion: string; // 패치 당시 OpenClaw 버전 + localePackVersion?: string; // 설치한 locale-meta.json 버전 키 (exact 여부 판단에 사용) + } + >; +} + +// --- 상태 읽기 --- +function readState(): PluginState { + try { + if (existsSync(resolvedStateFile)) { + return JSON.parse(readFileSync(resolvedStateFile, "utf-8")) as PluginState; + } + } catch { + // 파일이 손상된 경우 초기화 + } + return { installedLocales: {} }; +} + +// --- 상태 쓰기 --- +function writeState(state: PluginState): void { + try { + writeFileSync(resolvedStateFile, JSON.stringify(state, null, 2), "utf-8"); + } catch { + // /app/data 디렉토리가 없는 환경(테스트 등)에서는 무시 + } +} + +// --- 자동 업데이트: 버전 변경 감지 시 설치된 언어팩 재패치 --- +async function autoUpdate(): Promise { + const state = readState(); + const installedCodes = Object.keys(state.installedLocales); + + if (installedCodes.length === 0) return; + + const currentVersion = getOpenClawVersion(); + if (currentVersion === "unknown") return; + + // locale-meta.json을 먼저 가져온 뒤 업데이트 필요 여부를 판단합니다. + // openClawVersion 변경뿐 아니라, 동일 OpenClaw 버전에 더 나은 locale pack(exact 버전)이 + // 새로 추가된 경우(localePackVersion 변경)도 재패치 대상으로 포함합니다. + let meta: LocaleMeta; + try { + meta = await fetchLocaleMeta(); + } catch { + // 네트워크 오류 — 자동 업데이트 생략 + return; + } + + const outdated = installedCodes.filter((code) => { + const installed = state.installedLocales[code]; + // OpenClaw 버전이 변경된 경우 + if (installed.openClawVersion !== currentVersion) return true; + // localePackVersion이 기록되어 있고, 현재 사용 가능한 최선 버전이 다른 경우 + // (예: 이전 설치 당시 exact 버전이 없어 fallback을 썼지만, 이후 exact 버전이 추가됨) + if (installed.localePackVersion !== undefined) { + const entry = meta.locales[code]; + if (entry) { + const best = selectBestVersion(entry.versions, currentVersion); + if (best && best.version !== installed.localePackVersion) return true; + } + } + return false; + }); + + if (outdated.length === 0) return; + + console.log( + `[i18n-plus] OpenClaw ${currentVersion} 감지 — ` + + `${outdated.join(", ")} 언어팩 자동 업데이트 중...` + ); + + try { + + for (const code of outdated) { + const entry = meta.locales[code]; + if (!entry) continue; + + const best = selectBestVersion(entry.versions, currentVersion); + if (!best) continue; + + const { versionInfo } = best; + const chunkUrl = `${GITHUB_RAW_BASE}/${versionInfo.file}`; + + try { + const chunkContent = await httpGet(chunkUrl); + const chunkFileName = `${code}-community.js`; + const chunkDest = `${resolvedControlUiAssets}/${chunkFileName}`; + + if (!existsSync(resolvedControlUiAssets)) continue; + + writeFileSync(chunkDest, chunkContent, "utf-8"); + + const indexJs = findMainBundle(); + if (!indexJs) { + console.warn(`[i18n-plus] ${entry.name} 메인 번들을 찾을 수 없어 자동 업데이트를 건너뜁니다.`); + continue; + } + if (!isAlreadyPatched(indexJs, code, versionInfo.exportName)) { + const ok = patchMainBundle(indexJs, code, versionInfo.exportName, chunkFileName); + if (!ok) { + console.warn(`[i18n-plus] ${entry.name} 패치 앵커 미발견 — 번들 형식 변경 가능성 있음.`); + continue; + } + } + + // 번들 패치가 확인된 후에만 상태 업데이트 + state.installedLocales[code] = { + patchedAt: new Date().toISOString(), + openClawVersion: currentVersion, + localePackVersion: best.version, + }; + + console.log(`[i18n-plus] ${entry.name} 자동 업데이트 완료.`); + } catch { + console.warn(`[i18n-plus] ${entry.name} 자동 업데이트 실패 — 수동으로 /lang ${code} 를 실행해주세요.`); + } + } + + writeState(state); + } catch { + // 네트워크 오류 등 — 자동 업데이트 실패해도 플러그인 로드는 계속 + } +} + +// --- /lang (인자 없음): 사용 가능한 언어 목록 출력 --- +async function listLanguages(meta: LocaleMeta): Promise { + const officialList = meta.officialLocales.join(", "); + + const communityEntries = Object.entries(meta.locales) + .map(([code, entry]) => { + const shortAlias = entry.aliases[0] || code; + return ` ${shortAlias} (${entry.name})`; + }) + .join("\n"); + + return [ + `📦 OpenClaw Community Language Pack (i18n-plus)`, + ``, + `공식 언어팩 (built-in):`, + ` ${officialList}`, + ``, + `커뮤니티 언어팩 (설치 가능):`, + communityEntries, + ``, + `사용법: /lang <언어코드>`, + `예시: /lang ko`, + ].join("\n"); +} + +// --- /lang : 커뮤니티 언어팩 설치 --- +async function installLanguage( + meta: LocaleMeta, + input: string +): Promise { + // 1. 공식 locale인지 확인 + const inputLower = input.toLowerCase(); + if ( + meta.officialLocales.some((loc) => loc.toLowerCase() === inputLower) + ) { + return [ + `ℹ️ ${input}은(는) 공식 언어팩입니다.`, + ` Control UI 설정(Language)에서 직접 변경할 수 있습니다.`, + ].join("\n"); + } + + // 2. alias 정규화 + const resolved = resolveAlias(meta, input); + if (!resolved) { + return [ + `❌ "${input}"에 해당하는 언어팩을 찾을 수 없습니다.`, + ` /lang 명령으로 사용 가능한 언어 목록을 확인하세요.`, + ].join("\n"); + } + + const { code, entry } = resolved; + + // 3. OpenClaw 버전 확인 + const currentVersion = getOpenClawVersion(); + if (currentVersion === "unknown") { + return `❌ OpenClaw 버전을 확인할 수 없습니다. OpenClaw 환경에서 실행해주세요.`; + } + + // 4. 가장 적합한 버전의 chunk 선택 + const best = selectBestVersion(entry.versions, currentVersion); + if (!best) { + return [ + `❌ ${entry.name} (${code}) 언어팩에 사용 가능한 버전이 없습니다.`, + ` 기여를 원하시면: https://github.com/mapd3692/openclaw-i18n-plus`, + ].join("\n"); + } + + const { version, versionInfo, exact } = best; + + // 5. 메인 번들 위치 확인 (네트워크 호출 전에 먼저 수행) + // 이미 설치된 경우 네트워크 없이 상태만 복구할 수 있으므로 다운로드 전에 체크 + const indexJs = findMainBundle(); + if (!indexJs) { + return [ + `❌ 메인 번들 파일(index-*.js)을 찾을 수 없습니다.`, + ` OpenClaw 설치 경로를 확인하거나 환경변수를 설정해주세요.`, + ].join("\n"); + } + + // 6. 이미 패치된 경우 — chunk 파일만 갱신하고 상태를 복구한 뒤 반환 + // 중복 설치 방지 — exportName도 함께 검사하여 exportName이 변경된 경우 재패치 + // (같은 locale 코드더라도 locale-meta.json에서 exportName이 변경되면 번들을 재패치해야 함) + const chunkFileName = `${code}-community.js`; + if (isAlreadyPatched(indexJs, code, versionInfo.exportName)) { + // 번들은 이미 올바른 exportName으로 패치돼 있음. + // 단, 같은 버전·exportName을 유지하면서 chunk 파일만 인플레이스 수정(오타 수정 등)된 + // 경우를 처리하기 위해 chunk는 항상 새로 다운로드합니다. + // patchMainBundle()은 중복 실행하지 않습니다. + const chunkUrl = `${GITHUB_RAW_BASE}/${versionInfo.file}`; + try { + const chunkContent = await httpGet(chunkUrl); + if (existsSync(resolvedControlUiAssets)) { + const chunkDest = join(resolvedControlUiAssets, chunkFileName); + writeFileSync(chunkDest, chunkContent, "utf-8"); + } + } catch { + // 네트워크 오류 시 기존 chunk를 그대로 사용 — 상태만 복구 + } + + // 상태 파일이 없을 수 있음 (경로 마이그레이션 후 / 수동 삭제 등) + // — 여기서도 상태를 저장해 다음 OpenClaw 업그레이드 때 autoUpdate()가 건너뛰지 않도록 보장 + const state = readState(); + state.installedLocales[code] = { + patchedAt: new Date().toISOString(), + openClawVersion: currentVersion, + localePackVersion: version, + }; + writeState(state); + + return exact + ? [ + `✅ ${entry.name}가 업데이트되었습니다. (openclaw ${version} 대응, 번역률 ${versionInfo.coverage})`, + ` 브라우저를 새로고침한 뒤 설정에서 ${entry.name}를 선택하세요.`, + ].join("\n") + : [ + `✅ ${entry.name}가 업데이트되었습니다. (openclaw ${version} 기준)`, + ` ⚠️ 일부 새 항목은 영어로 표시될 수 있습니다.`, + ` 브라우저를 새로고침한 뒤 설정에서 ${entry.name}를 선택하세요.`, + ].join("\n"); + } + + // 7. chunk 파일 다운로드 (이미 설치된 경우엔 건너뜀) + const chunkUrl = `${GITHUB_RAW_BASE}/${versionInfo.file}`; + let chunkContent: string; + try { + chunkContent = await httpGet(chunkUrl); + } catch (err) { + return `❌ 언어팩 파일을 다운로드할 수 없습니다: ${chunkUrl}`; + } + + // 8. assets 디렉토리에 저장 + const chunkDest = join(resolvedControlUiAssets, chunkFileName); + + if (!existsSync(resolvedControlUiAssets)) { + return [ + `❌ Control UI assets 디렉토리를 찾을 수 없습니다: ${resolvedControlUiAssets}`, + ` 네이티브 설치 환경에서는 환경변수를 설정해주세요:`, + ` OPENCLAW_CONTROL_UI_ASSETS=`, + ].join("\n"); + } + + writeFileSync(chunkDest, chunkContent, "utf-8"); + + // 패치 실행 + const patchOk = patchMainBundle(indexJs, code, versionInfo.exportName, chunkFileName); + if (!patchOk) { + return [ + `❌ 메인 번들 패치 실패: "zh-CN" 앵커를 찾을 수 없습니다.`, + ` OpenClaw 버전 업데이트로 번들 형식이 변경되었을 수 있습니다.`, + ` https://github.com/mapd3692/openclaw-i18n-plus/issues 에 신고해주세요.`, + ].join("\n"); + } + + // 8. 상태 저장 (자동 업데이트를 위해 설치 버전 기록) + const state = readState(); + state.installedLocales[code] = { + patchedAt: new Date().toISOString(), + openClawVersion: currentVersion, + localePackVersion: version, + }; + writeState(state); + + // 9. 결과 메시지 + if (exact) { + return [ + `✅ ${entry.name}가 설치되었습니다. (openclaw ${version} 대응, 번역률 ${versionInfo.coverage})`, + ` 브라우저를 새로고침한 뒤 설정에서 ${entry.name}를 선택하세요.`, + ].join("\n"); + } else { + return [ + `✅ ${entry.name}가 설치되었습니다. (openclaw ${version} 기준)`, + ` ⚠️ 일부 새 항목은 영어로 표시될 수 있습니다.`, + ` 브라우저를 새로고침한 뒤 설정에서 ${entry.name}를 선택하세요.`, + ].join("\n"); + } +} + +// --- 플러그인 엔트리포인트 --- +// OpenClaw 플러그인 API: register(api) 함수를 통해 명령어/서비스 등록 +// +// 타입 출처: openclaw@2026.3.13 dist/plugin-sdk/plugins/types.d.ts +// - registerCommand: (command: OpenClawPluginCommandDefinition) => void +// - registerService: (service: OpenClawPluginService) => void +export function register(api: { + registerCommand: (descriptor: { + name: string; + description: string; + acceptsArgs?: boolean; + handler: (ctx: { + args?: string; // 공백 구분 인자 전체 문자열 (예: "ko-KR") + commandBody: string; // 명령어 본문 전체 (예: "/lang ko-KR") + channel: string; // 채널 식별자 (예: "telegram") + isAuthorizedSender: boolean; + senderId?: string; + }) => Promise<{ text?: string }> | { text?: string }; + }) => void; + registerService: (descriptor: { + id: string; + start: (ctx: { stateDir: string; logger: { info: (m: string) => void; warn: (m: string) => void; error: (m: string) => void }; [key: string]: unknown }) => void | Promise; + stop?: (ctx: unknown) => void | Promise; + }) => void; +}): void { + // 자동 업데이트 서비스 등록 — 플러그인 시작 시 백그라운드로 실행 + api.registerService({ + id: "auto-update", + start: (ctx) => { + // stateDir은 상태 파일 경로에만 사용합니다. + // stateDir의 부모 디렉토리(dirname)로 assets 경로를 추론하는 것은 + // stateDir이 ~/.openclaw 같은 사용자 홈 디렉토리 하위인 경우 + // dirname이 홈 디렉토리가 되어 잘못된 경로를 생성할 수 있으므로 금지합니다. + // Control UI assets 경로는 환경변수 OPENCLAW_CONTROL_UI_ASSETS로 오버라이드하세요. + if (ctx.stateDir) { + resolvedStateFile = join(ctx.stateDir, ".i18n-plus-state.json"); + } + autoUpdate().catch(() => {}); + }, + stop: (_ctx) => { /* 정리 불필요 */ }, + }); + + // /lang 명령어 등록 + api.registerCommand({ + name: "lang", + description: "커뮤니티 언어팩 설치 및 관리", + acceptsArgs: true, + handler: async (ctx) => { + // ctx.args: "/lang ko-KR" 입력 시 "ko-KR" (명령어 이름 제거된 나머지) + const input = ctx.args?.trim() ?? ""; + try { + // 1. locale-meta.json 다운로드 + const meta = await fetchLocaleMeta(); + + // 2. 인자 없으면 목록 출력 + if (!input) { + return { text: await listLanguages(meta) }; + } + + // 3. 첫 번째 토큰만 언어 코드로 사용 (예: "ko-KR extra" → "ko-KR") + const langCode = input.split(/\s+/)[0]; + return { text: await installLanguage(meta, langCode) }; + } catch (err) { + return { + text: `❌ 오류가 발생했습니다: ${err instanceof Error ? err.message : String(err)}`, + }; + } + }, + }); +} + +// OpenClaw 로더는 default export를 통해 플러그인을 인식합니다. +export default register; diff --git a/plugin/openclaw.plugin.json b/plugin/openclaw.plugin.json new file mode 100644 index 0000000..4de077e --- /dev/null +++ b/plugin/openclaw.plugin.json @@ -0,0 +1,22 @@ +{ + "id": "openclaw-community/i18n-plus", + "name": "i18n-plus", + "displayName": "i18n Plus — Community Language Pack", + "version": "0.1.0", + "description": "커뮤니티 주도 OpenClaw 언어팩. /lang <언어코드>로 설치하세요.", + "configSchema": {}, + "commands": [ + { + "name": "lang", + "description": "커뮤니티 언어팩 설치 및 관리", + "usage": "/lang [언어코드]", + "examples": [ + "/lang", + "/lang ko", + "/lang korean" + ] + } + ], + "entrypoint": "index.ts", + "minimumOpenClawVersion": "2026.3.0" +} diff --git a/plugin/package.json b/plugin/package.json new file mode 100644 index 0000000..25088a0 --- /dev/null +++ b/plugin/package.json @@ -0,0 +1,29 @@ +{ + "name": "@openclaw-community/i18n-plus", + "version": "0.1.0", + "description": "OpenClaw 커뮤니티 언어팩 플러그인", + "main": "index.ts", + "openclaw": { + "extensions": [ + "index.ts" + ] + }, + "keywords": [ + "openclaw", + "openclaw-plugin", + "i18n", + "localization", + "language-pack", + "korean" + ], + "author": "OpenClaw Community", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/mapd3692/openclaw-i18n-plus.git" + }, + "homepage": "https://github.com/mapd3692/openclaw-i18n-plus#readme", + "bugs": { + "url": "https://github.com/mapd3692/openclaw-i18n-plus/issues" + } +} diff --git a/scripts/check-coverage.ts b/scripts/check-coverage.ts new file mode 100644 index 0000000..8322389 --- /dev/null +++ b/scripts/check-coverage.ts @@ -0,0 +1,280 @@ +#!/usr/bin/env npx ts-node + +/** + * 번역률 자동 계산 스크립트 + * + * 영어 원본 키(OpenClaw 소스의 en 번역 키)와 커뮤니티 chunk 파일의 키를 비교하여 + * 언어별, 버전별 번역률(%)을 계산합니다. + * + * 사용법: + * npx ts-node scripts/check-coverage.ts + * npx ts-node scripts/check-coverage.ts --update-meta # locale-meta.json 자동 업데이트 + * + * 출력: + * 언어별, 버전별 번역률 테이블 + */ + +import * as fs from "fs"; +import * as path from "path"; + +// --- 타입 --- +interface LocaleVersion { + file: string; + exportName: string; + coverage: string; +} + +interface LocaleEntry { + name: string; + aliases: string[]; + versions: Record; +} + +interface LocaleMeta { + schemaVersion: number; + officialLocales: string[]; + locales: Record; +} + +interface CoverageResult { + locale: string; + name: string; + version: string; + totalKeys: number; + translatedKeys: number; + coveragePercent: number; +} + +// --- 유틸리티 --- + +/** + * JS chunk 파일에서 번역 키를 재귀적으로 추출합니다. + * chunk 형식: var e={common:{health:`건강상태`,...},nav:{...},...};export{e as ko_KR}; + */ +function extractKeysFromChunk(filePath: string): string[] { + const content = fs.readFileSync(filePath, "utf-8"); + + // var e={...} 부분에서 객체 리터럴 추출 + const match = content.match(/var\s+\w+\s*=\s*(\{[\s\S]*\})\s*;\s*export/); + if (!match) { + console.warn(` 경고: ${filePath} 파싱 실패 — chunk 형식이 아닙니다.`); + return []; + } + + // 키를 재귀적으로 추출 (중첩 객체 지원) + const keys: string[] = []; + extractNestedKeys(match[1], "", keys); + return keys; +} + +/** + * 중첩 객체 리터럴에서 키를 재귀적으로 추출합니다. + * 간단한 파서 — 백틱/따옴표 문자열 값을 가진 키를 탐색합니다. + */ +function extractNestedKeys(objStr: string, prefix: string, keys: string[]): void { + // 키:값 쌍을 매칭하는 정규식 + // 키는 식별자 또는 따옴표 문자열, 값은 백틱/따옴표 문자열 또는 중첩 객체 + const keyValueRegex = /(\w+|"[^"]+"|'[^']+')\s*:\s*(?:`[^`]*`|"[^"]*"|'[^']*'|\{)/g; + let match: RegExpExecArray | null; + + while ((match = keyValueRegex.exec(objStr)) !== null) { + const rawKey = match[0]; + const key = match[1].replace(/['"]/g, ""); + const fullKey = prefix ? `${prefix}.${key}` : key; + + // 값이 중첩 객체인 경우 + if (rawKey.endsWith("{")) { + // 중괄호 매칭으로 중첩 객체 범위 찾기 + // 문자열 리터럴(백틱/따옴표) 안의 중괄호는 무시합니다. + let depth = 1; + let i = match.index + rawKey.length; + const start = i; + while (i < objStr.length && depth > 0) { + const ch = objStr[i]; + // 백틱/따옴표 문자열 건너뛰기 + if (ch === "`" || ch === '"' || ch === "'") { + const quote = ch; + i++; + while (i < objStr.length && objStr[i] !== quote) { + if (objStr[i] === "\\" && i + 1 < objStr.length) i++; // 이스케이프 처리 + i++; + } + i++; // 닫는 따옴표 + continue; + } + if (ch === "{") depth++; + else if (ch === "}") depth--; + i++; + } + const nestedObj = objStr.slice(start, i - 1); + extractNestedKeys(nestedObj, fullKey, keys); + // 중첩 객체 범위를 건너뛰도록 lastIndex 업데이트 + // 이렇게 하지 않으면 외부 regex가 같은 위치에서 재개해 + // 내부 키를 부모 레벨에서 다시 매칭하는 이중 카운팅 버그 발생 + keyValueRegex.lastIndex = i; + } else { + // 리프 키 + keys.push(fullKey); + } + } +} + +/** + * 기준 영어 키 목록을 가져옵니다. + * scripts/en-keys/ 디렉토리에 버전별 키 목록이 있으면 사용하고, + * 없으면 해당 버전의 첫 번째 커뮤니티 chunk에서 키를 추출합니다. + */ +function getReferenceKeys(version: string, meta: LocaleMeta): string[] { + const rootDir = path.resolve(__dirname, ".."); + + // 1. 영어 원본 키 파일이 있는지 확인 + const enKeysFile = path.join(rootDir, "scripts", "en-keys", `${version}.txt`); + if (fs.existsSync(enKeysFile)) { + return fs + .readFileSync(enKeysFile, "utf-8") + .split("\n") + .filter((line) => line.trim().length > 0); + } + + // 2. en-keys 파일이 없으면 에러로 처리 — 커뮤니티 chunk를 baseline으로 사용하면 + // 첫 번째 locale이 항상 100%가 되어 locale-meta.json의 coverage 값이 오염됩니다. + console.error( + `오류: scripts/en-keys/${version}.txt 파일이 없습니다.\n` + + ` 영어 원본 키 파일을 먼저 생성해야 번역률을 정확히 계산할 수 있습니다.\n` + + ` 생성 방법: npx ts-node scripts/extract-keys.ts --save ${version}` + ); + process.exit(1); +} + +// --- 메인 --- +function main(): void { + const rootDir = path.resolve(__dirname, ".."); + const metaPath = path.join(rootDir, "locale-meta.json"); + const updateMeta = process.argv.includes("--update-meta"); + + if (!fs.existsSync(metaPath)) { + console.error("오류: locale-meta.json을 찾을 수 없습니다."); + process.exit(1); + } + + const meta: LocaleMeta = JSON.parse(fs.readFileSync(metaPath, "utf-8")); + const results: CoverageResult[] = []; + + // 모든 버전 수집 + const allVersions = new Set(); + for (const entry of Object.values(meta.locales)) { + for (const version of Object.keys(entry.versions)) { + allVersions.add(version); + } + } + + for (const version of Array.from(allVersions).sort()) { + const referenceKeys = getReferenceKeys(version, meta); + const totalKeys = referenceKeys.length; + + if (totalKeys === 0) { + console.log(`\n버전 ${version}: 기준 키를 찾을 수 없습니다. 건너뜁니다.`); + continue; + } + + for (const [localeCode, entry] of Object.entries(meta.locales)) { + const versionInfo = entry.versions[version]; + if (!versionInfo) continue; + + const chunkPath = path.join(rootDir, versionInfo.file); + if (!fs.existsSync(chunkPath)) { + console.warn(` 경고: ${chunkPath} 파일이 존재하지 않습니다.`); + continue; + } + + const translatedKeys = extractKeysFromChunk(chunkPath); + // 참조 키 집합과의 교집합만 카운트 — stale/extra 키가 coverage를 부풀리지 않도록 + // 중복 키도 제거하여 coverage가 100%를 초과하지 않도록 보장 + const referenceSet = new Set(referenceKeys); + const matchedCount = new Set(translatedKeys.filter((k) => referenceSet.has(k))).size; + const coveragePercent = + totalKeys > 0 ? Math.round((matchedCount / totalKeys) * 100) : 0; + + results.push({ + locale: localeCode, + name: entry.name, + version, + totalKeys, + translatedKeys: matchedCount, + coveragePercent, + }); + + // locale-meta.json 업데이트 + if (updateMeta) { + meta.locales[localeCode].versions[version].coverage = `${coveragePercent}%`; + } + } + } + + // 결과 테이블 출력 + console.log("\n=== 번역률 계산 결과 ===\n"); + + if (results.length === 0) { + console.log("번역 파일이 없습니다."); + return; + } + + // 테이블 헤더 + console.log( + "| 언어 | 코드 | 버전 | 번역 키 | 전체 키 | 번역률 |" + ); + console.log( + "|------|------|------|---------|---------|--------|" + ); + + for (const r of results) { + const warning = r.coveragePercent < 80 ? " ⚠️" : ""; + console.log( + `| ${r.name} | ${r.locale} | ${r.version} | ${r.translatedKeys} | ${r.totalKeys} | ${r.coveragePercent}%${warning} |` + ); + } + + // GitHub Actions용 출력 (GITHUB_OUTPUT) + if (process.env.GITHUB_OUTPUT) { + const summary = results + .map( + (r) => `${r.locale}(${r.version}): ${r.coveragePercent}%` + ) + .join(", "); + const hasWarning = results.some((r) => r.coveragePercent < 80); + + fs.appendFileSync( + process.env.GITHUB_OUTPUT, + `coverage_summary=${summary}\n` + ); + fs.appendFileSync( + process.env.GITHUB_OUTPUT, + `has_warning=${hasWarning}\n` + ); + + // PR 코멘트용 마크다운 테이블 + let markdown = "## 📊 번역률 계산 결과\n\n"; + markdown += "| 언어 | 코드 | 버전 | 번역 키 | 전체 키 | 번역률 |\n"; + markdown += "|------|------|------|---------|---------|--------|\n"; + for (const r of results) { + const warning = r.coveragePercent < 80 ? " ⚠️" : ""; + markdown += `| ${r.name} | ${r.locale} | ${r.version} | ${r.translatedKeys} | ${r.totalKeys} | ${r.coveragePercent}%${warning} |\n`; + } + if (hasWarning) { + markdown += "\n> ⚠️ 번역률이 80% 미만인 언어가 있습니다.\n"; + } + + fs.appendFileSync( + process.env.GITHUB_OUTPUT, + `coverage_markdown< + * npx ts-node scripts/extract-keys.ts --save + * npx ts-node scripts/extract-keys.ts --diff + * + * 예시: + * # chunk 파일에서 키 목록 출력 + * npx ts-node scripts/extract-keys.ts locales/2026.3.13/ko-KR-community.js + * + * # 키 목록을 en-keys/ 디렉토리에 저장 + * npx ts-node scripts/extract-keys.ts locales/2026.3.13/ko-KR-community.js --save 2026.3.13 + * + * # 두 버전 간 키 차이 비교 + * npx ts-node scripts/extract-keys.ts --diff 2026.3.8 2026.3.13 + */ + +import * as fs from "fs"; +import * as path from "path"; + +// --- 키 추출 함수 (check-coverage.ts와 동일한 로직) --- + +function extractKeysFromChunk(filePath: string): string[] { + const content = fs.readFileSync(filePath, "utf-8"); + + const match = content.match(/var\s+\w+\s*=\s*(\{[\s\S]*\})\s*;\s*export/); + if (!match) { + console.error(`오류: ${filePath} 파싱 실패 — chunk 형식이 아닙니다.`); + process.exit(1); + } + + const keys: string[] = []; + extractNestedKeys(match[1], "", keys); + return keys.sort(); +} + +function extractNestedKeys(objStr: string, prefix: string, keys: string[]): void { + const keyValueRegex = /(\w+|"[^"]+"|'[^']+')\s*:\s*(?:`[^`]*`|"[^"]*"|'[^']*'|\{)/g; + let match: RegExpExecArray | null; + + while ((match = keyValueRegex.exec(objStr)) !== null) { + const rawKey = match[0]; + const key = match[1].replace(/['"]/g, ""); + const fullKey = prefix ? `${prefix}.${key}` : key; + + if (rawKey.endsWith("{")) { + // 중괄호 매칭으로 중첩 객체 범위 찾기 + // 문자열 리터럴(백틱/따옴표) 안의 중괄호는 무시합니다. + let depth = 1; + let i = match.index + rawKey.length; + const start = i; + while (i < objStr.length && depth > 0) { + const ch = objStr[i]; + if (ch === "`" || ch === '"' || ch === "'") { + const quote = ch; + i++; + while (i < objStr.length && objStr[i] !== quote) { + if (objStr[i] === "\\" && i + 1 < objStr.length) i++; + i++; + } + i++; + continue; + } + if (ch === "{") depth++; + else if (ch === "}") depth--; + i++; + } + const nestedObj = objStr.slice(start, i - 1); + extractNestedKeys(nestedObj, fullKey, keys); + // check-coverage.ts와 동일: 중첩 범위를 건너뛰어 이중 카운팅 방지 + keyValueRegex.lastIndex = i; + } else { + keys.push(fullKey); + } + } +} + +// --- 명령어 처리 --- + +function printUsage(): void { + console.log(`사용법: + npx ts-node scripts/extract-keys.ts + npx ts-node scripts/extract-keys.ts --save + npx ts-node scripts/extract-keys.ts --diff +`); +} + +function loadKeysFile(version: string): string[] { + const keysDir = path.join(__dirname, "en-keys"); + const filePath = path.join(keysDir, `${version}.txt`); + + if (!fs.existsSync(filePath)) { + console.error(`오류: ${filePath} 파일이 존재하지 않습니다.`); + console.error(`먼저 --save 옵션으로 키 목록을 저장하세요.`); + process.exit(1); + } + + return fs + .readFileSync(filePath, "utf-8") + .split("\n") + .filter((line) => line.trim().length > 0) + .sort(); +} + +function main(): void { + const args = process.argv.slice(2); + + if (args.length === 0) { + printUsage(); + process.exit(1); + } + + // diff 모드 + if (args[0] === "--diff") { + if (args.length < 3) { + console.error("오류: --diff에는 두 개의 버전이 필요합니다."); + printUsage(); + process.exit(1); + } + + const version1 = args[1]; + const version2 = args[2]; + + const keys1 = new Set(loadKeysFile(version1)); + const keys2 = new Set(loadKeysFile(version2)); + + const added = [...keys2].filter((k) => !keys1.has(k)); + const removed = [...keys1].filter((k) => !keys2.has(k)); + + console.log(`\n=== 키 변경사항: ${version1} → ${version2} ===\n`); + + if (added.length > 0) { + console.log(`추가된 키 (${added.length}개):`); + for (const key of added) { + console.log(` + ${key}`); + } + } + + if (removed.length > 0) { + console.log(`\n삭제된 키 (${removed.length}개):`); + for (const key of removed) { + console.log(` - ${key}`); + } + } + + if (added.length === 0 && removed.length === 0) { + console.log("변경된 키가 없습니다."); + } + + console.log(`\n요약: +${added.length} / -${removed.length}`); + return; + } + + // 키 추출 모드 + const chunkFile = args[0]; + if (!fs.existsSync(chunkFile)) { + console.error(`오류: ${chunkFile} 파일이 존재하지 않습니다.`); + process.exit(1); + } + + const keys = extractKeysFromChunk(chunkFile); + console.log(`\n=== 키 목록 (${keys.length}개) ===\n`); + + for (const key of keys) { + console.log(key); + } + + // --save 모드 + const saveIndex = args.indexOf("--save"); + if (saveIndex !== -1 && args[saveIndex + 1]) { + // en-keys 베이스라인은 영어 원본 번들에서 추출해야 합니다. + // *-community.js 파일(커뮤니티 번역)을 사용하면 번역 누락 키가 + // 베이스라인에서 빠져 coverage가 순환 참조로 부풀려집니다. + const baseName = path.basename(chunkFile); + const forceFlag = args.includes("--force"); + if (baseName.includes("-community") && !forceFlag) { + console.error( + `\n❌ 커뮤니티 번역 파일(${baseName})은 en-keys 베이스라인으로 사용할 수 없습니다.` + ); + console.error( + ` 영어 원본 번들에서 추출한 키를 사용해주세요.` + ); + console.error( + ` 강제로 저장하려면 --force 플래그를 추가하세요.` + ); + process.exit(1); + } + + const version = args[saveIndex + 1]; + const keysDir = path.join(__dirname, "en-keys"); + + if (!fs.existsSync(keysDir)) { + fs.mkdirSync(keysDir, { recursive: true }); + } + + const outputPath = path.join(keysDir, `${version}.txt`); + fs.writeFileSync(outputPath, keys.join("\n") + "\n"); + console.log(`\n✅ ${outputPath}에 키 목록이 저장되었습니다.`); + } +} + +main(); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2a988f5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2019", + "module": "CommonJS", + "moduleResolution": "node", + "strict": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "outDir": "./dist", + "rootDir": "." + }, + "ts-node": { + "compilerOptions": { + "module": "CommonJS" + } + }, + "include": ["scripts/**/*.ts"], + "exclude": ["node_modules", "dist", "plugin"] +}