Compare commits
6 Commits
33d03481e4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3cce7a74d8 | |||
| df126a641f | |||
| 0482c8299b | |||
| e189a64631 | |||
| fbef7989e4 | |||
| 7874970816 |
10
.claude/settings.local.json
Normal file
10
.claude/settings.local.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Read(//d/Work/WorldStalker/**)",
|
||||
"Bash(python ds_l10n.py:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
}
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -1 +1,2 @@
|
||||
*.log
|
||||
*.tsv
|
||||
457
README.md
457
README.md
@ -1,3 +1,458 @@
|
||||
# DS_L10N
|
||||
|
||||
던전스토커즈 다국어 번역관련 툴
|
||||
던전스토커즈 다국어 번역 통합 툴
|
||||
|
||||
---
|
||||
|
||||
## 📑 목차
|
||||
|
||||
1. [빠른 시작](#-빠른-시작)
|
||||
2. [주요 기능](#-주요-기능)
|
||||
3. [설치 및 설정](#-설치-및-설정)
|
||||
4. [워크플로우](#-워크플로우)
|
||||
5. [명령어 상세](#-명령어-상세)
|
||||
6. [문제 해결](#-문제-해결)
|
||||
7. [프로젝트 구조](#-프로젝트-구조)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 빠른 시작
|
||||
|
||||
### 1. 라이브러리 설치 (최초 1회)
|
||||
|
||||
```bash
|
||||
cd D:\Work\WorldStalker\DS_L10N
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
**설치되는 라이브러리**:
|
||||
- `polib`: PO 파일 안정적 처리
|
||||
- `PyYAML`: 설정 파일 관리
|
||||
- `pandas`: 데이터 처리
|
||||
|
||||
### 2. 명령어 실행
|
||||
|
||||
```bash
|
||||
python ds_l10n.py extract # 미번역 추출 (en만)
|
||||
python ds_l10n.py extract --all-languages # 미번역 추출 (모든 언어)
|
||||
python ds_l10n.py extract --include-fuzzy # fuzzy 항목 포함
|
||||
python ds_l10n.py validate # 번역 검증
|
||||
python ds_l10n.py update # PO 업데이트
|
||||
python ds_l10n.py merge # CSV 병합
|
||||
python ds_l10n.py cleanup # 파일 정리
|
||||
```
|
||||
|
||||
### 3. 도움말
|
||||
|
||||
```bash
|
||||
python ds_l10n.py --help
|
||||
python ds_l10n.py extract --help
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✨ 주요 기능
|
||||
|
||||
- ✅ **자동 경로 인식** (`config.yaml` 기반)
|
||||
- ✅ **번역 검증** (변수, 태그, 줄바꿈 확인)
|
||||
- ✅ **안정적 업데이트** (polib 라이브러리 사용)
|
||||
- ✅ **컬러 로그** (오류 원인 명확히 표시)
|
||||
- ✅ **자동 백업** (업데이트 전 자동 생성)
|
||||
- ✅ **자동 파일 정리** (오래된 파일 보관)
|
||||
- ✅ **원본 언어(ko) 업데이트 지원**
|
||||
- ✅ **줄바꿈 이스케이프 자동 처리**
|
||||
- ✅ **스마트 미번역 감지** (msgstr 빈 값, ko 원본 변경 감지)
|
||||
- ✅ **fuzzy 항목 추출** (리뷰 필요한 번역 감지)
|
||||
- ✅ **유연한 언어 검사** (en만 또는 전체 언어)
|
||||
|
||||
---
|
||||
|
||||
## 🔧 설치 및 설정
|
||||
|
||||
### config.yaml 설정
|
||||
|
||||
`config.yaml` 파일에서 경로와 동작 설정 (상대 경로 사용):
|
||||
|
||||
```yaml
|
||||
paths:
|
||||
unreal_localization: ../WorldStalker/Content/Localization/LocalExport
|
||||
output_dir: ./output
|
||||
logs_dir: ./logs
|
||||
archive_dir: ./archive
|
||||
|
||||
languages:
|
||||
source: ko # 원본 언어
|
||||
targets: # 번역 대상 언어 (17개)
|
||||
- en
|
||||
- ja
|
||||
- zh-Hans
|
||||
- zh-Hant
|
||||
# ... 기타 언어
|
||||
|
||||
extract:
|
||||
check_all_languages: false # 모든 언어 검사 (기본: en만)
|
||||
include_fuzzy: true # fuzzy 항목 포함 (리뷰 필요)
|
||||
separate_files: true # 언어별 개별 파일 생성
|
||||
|
||||
validation:
|
||||
check_variables: true # {Value} 변수 확인
|
||||
check_rich_text_tags: true # <Red></> 태그 확인
|
||||
check_newlines: true # \r\n 줄바꿈 확인
|
||||
check_empty_translations: true # 빈 번역 확인
|
||||
|
||||
cleanup:
|
||||
keep_recent_files: 5 # 최근 N개 파일만 유지
|
||||
auto_archive: true # 오래된 파일 자동 보관
|
||||
|
||||
backup:
|
||||
auto_backup_before_update: true # 업데이트 전 자동 백업
|
||||
keep_backups_days: 7 # 백업 보관 기간
|
||||
```
|
||||
|
||||
### 필요한 디렉토리 (자동 생성)
|
||||
|
||||
스크립트 실행 시 자동으로 생성됩니다:
|
||||
- `output/` - 미번역 항목 TSV 파일
|
||||
- `logs/` - 작업 로그 파일
|
||||
- `archive/` - 오래된 파일 보관
|
||||
|
||||
---
|
||||
|
||||
## 📖 워크플로우
|
||||
|
||||
```
|
||||
1️⃣ 언리얼 에디터
|
||||
└─ 현지화 대시보드 → 텍스트 수집 → 텍스트 익스포트
|
||||
|
||||
2️⃣ 미번역 추출
|
||||
python ds_l10n.py extract # en만 검사 (기본)
|
||||
python ds_l10n.py extract --all-languages # 모든 언어 검사
|
||||
└─ output/untranslated_YYYYMMDD_HHMMSS.tsv 생성
|
||||
|
||||
3️⃣ 외부 번역 수행
|
||||
└─ Google AI Studio 번역 앱 사용
|
||||
└─ 번역업데이트.tsv에 결과 저장
|
||||
|
||||
4️⃣ 번역 검증
|
||||
python ds_l10n.py validate
|
||||
└─ 변수, 태그, 줄바꿈 오류 확인
|
||||
|
||||
5️⃣ PO 파일 업데이트
|
||||
python ds_l10n.py update
|
||||
└─ 자동 백업 + 모든 언어 PO 파일 업데이트 (ko 포함)
|
||||
|
||||
6️⃣ 언리얼 에디터
|
||||
└─ 현지화 대시보드 → PO 파일 임포트 → 텍스트 컴파일
|
||||
|
||||
7️⃣ 사후 관리
|
||||
python ds_l10n.py merge # CSV로 백업
|
||||
python ds_l10n.py cleanup # 파일 정리
|
||||
```
|
||||
|
||||
### 자동화 옵션
|
||||
|
||||
4~7단계 자동 실행:
|
||||
|
||||
```bash
|
||||
python ds_l10n.py validate && python ds_l10n.py update && python ds_l10n.py merge && python ds_l10n.py cleanup
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 명령어 상세
|
||||
|
||||
### 1. extract - 미번역 항목 추출
|
||||
|
||||
```bash
|
||||
# 기본 사용 (en만 검사)
|
||||
python ds_l10n.py extract
|
||||
|
||||
# 모든 언어 검사
|
||||
python ds_l10n.py extract --all-languages
|
||||
|
||||
# fuzzy 항목 포함 (원본 변경으로 리뷰 필요한 항목)
|
||||
python ds_l10n.py extract --include-fuzzy
|
||||
|
||||
# 옵션 조합
|
||||
python ds_l10n.py extract --all-languages --include-fuzzy
|
||||
|
||||
# GUI 모드 (파일 선택)
|
||||
python ds_l10n.py extract --gui
|
||||
```
|
||||
|
||||
**기능**:
|
||||
- **기본 동작**: en PO 파일에서 msgstr이 비어있는 항목 추출
|
||||
- **--all-languages**: 모든 대상 언어(17개) 검사
|
||||
- **--include-fuzzy**: fuzzy 플래그 항목도 추출 (ko 원본 변경 감지)
|
||||
- msgstr이 비어있는 항목을 정확히 감지
|
||||
- TSV 형식으로 저장 (msgctxt, SourceLocation, msgid)
|
||||
|
||||
**출력**:
|
||||
- 기본: `output/untranslated_YYYYMMDD_HHMMSS.tsv`
|
||||
- 전체 언어: `output/untranslated_{lang}_YYYYMMDD_HHMMSS.tsv` (언어별 17개 파일)
|
||||
|
||||
**사용 시나리오**:
|
||||
1. **일반적인 경우**: `python ds_l10n.py extract` (en만 빠르게 검사)
|
||||
2. **ko 원본이 변경된 경우**: `python ds_l10n.py extract --include-fuzzy`
|
||||
3. **전체 언어 점검**: `python ds_l10n.py extract --all-languages`
|
||||
|
||||
---
|
||||
|
||||
### 2. validate - 번역 검증
|
||||
|
||||
```bash
|
||||
python ds_l10n.py validate [TSV파일]
|
||||
```
|
||||
|
||||
**검증 항목**:
|
||||
- ❌ **오류 (ERROR)**: 변수 누락/추가, 태그 불일치
|
||||
- ⚠️ **경고 (WARNING)**: 줄바꿈 불일치 (UI 레이아웃 영향)
|
||||
- ℹ️ **정보 (INFO)**: 길이 초과
|
||||
|
||||
**특징**:
|
||||
- ko는 원본 언어로 검증에서 자동 제외
|
||||
- msgid 컬럼이 없으면 ko 컬럼을 원본으로 사용
|
||||
- 오류 발견 시 상세한 원인과 위치 표시
|
||||
|
||||
---
|
||||
|
||||
### 3. update - PO 파일 업데이트
|
||||
|
||||
```bash
|
||||
python ds_l10n.py update [TSV파일]
|
||||
python ds_l10n.py update --dry-run # 시뮬레이션 모드
|
||||
```
|
||||
|
||||
**기능**:
|
||||
- TSV 파일의 번역을 모든 언어의 PO 파일에 반영
|
||||
- ko(원본 언어)도 함께 업데이트
|
||||
- TSV의 `\r\n` 이스케이프 시퀀스를 자동으로 PO 형식으로 변환
|
||||
- 업데이트 전 자동 백업 생성
|
||||
|
||||
**중요**: 원본 텍스트 수정 시 TSV의 ko 컬럼에 새 원본을 입력하면 자동으로 반영됩니다.
|
||||
|
||||
---
|
||||
|
||||
### 4. merge - CSV 병합
|
||||
|
||||
```bash
|
||||
python ds_l10n.py merge
|
||||
```
|
||||
|
||||
**기능**:
|
||||
- 모든 언어의 PO 파일을 하나의 CSV로 병합
|
||||
- Excel에서 편집 가능한 형식으로 저장
|
||||
|
||||
**출력**: `LocalExport/merged_po_entries_YYYYMMDD_HHMMSS.csv`
|
||||
|
||||
**CSV 구조**:
|
||||
```
|
||||
msgctxt | SourceLocation | ko | en | ja | zh-Hans | ...
|
||||
```
|
||||
|
||||
**참고**: ko(원본 언어)가 en 앞에 위치하여 원본 확인이 용이합니다.
|
||||
|
||||
---
|
||||
|
||||
### 5. cleanup - 파일 정리
|
||||
|
||||
```bash
|
||||
python ds_l10n.py cleanup
|
||||
```
|
||||
|
||||
**정리 항목**:
|
||||
- 📦 오래된 CSV/TSV 파일 → `archive/YYYYMM/` 폴더로 이동
|
||||
- 🗑️ 7일 이상 된 백업 파일 삭제
|
||||
- 🗑️ 30일 이상 된 로그 파일 삭제
|
||||
|
||||
---
|
||||
|
||||
## 🐛 문제 해결
|
||||
|
||||
### Q1. "모듈을 찾을 수 없습니다" 오류
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### Q2. "PO 파일을 찾을 수 없습니다" 오류
|
||||
|
||||
`config.yaml`의 경로가 올바른지 확인:
|
||||
```yaml
|
||||
paths:
|
||||
unreal_localization: ../WorldStalker/Content/Localization/LocalExport
|
||||
```
|
||||
|
||||
### Q3. 업데이트 후에도 언리얼에서 번역이 안 보임
|
||||
|
||||
1. 언리얼 에디터에서 **PO 임포트** 실행
|
||||
2. **텍스트 컴파일** 실행
|
||||
3. 에디터 재시작
|
||||
|
||||
### Q4. 검증에서 오류가 발생함
|
||||
|
||||
**변수 누락 오류**: 원문의 `{Value}` 같은 변수를 번역문에 그대로 복사
|
||||
|
||||
**태그 불일치 오류**: 모든 `<TagName>`에 대응하는 `</>`가 있는지 확인
|
||||
|
||||
**줄바꿈 불일치 경고**: `\r\n` 위치를 원문과 동일하게 유지 (UI 레이아웃)
|
||||
|
||||
### Q5. 원본 언어(ko)를 수정하고 싶음
|
||||
|
||||
`번역업데이트.tsv`의 ko 컬럼에 수정된 원본을 입력하고:
|
||||
|
||||
```bash
|
||||
python ds_l10n.py update # ko 포함 모든 언어 업데이트
|
||||
```
|
||||
|
||||
### Q6. 코드 수정 후 반영이 안 됨
|
||||
|
||||
Python 캐시 문제일 수 있습니다:
|
||||
|
||||
```bash
|
||||
# 방법 1: 캐시 삭제
|
||||
find . -type d -name "__pycache__" -exec rm -rf {} +
|
||||
|
||||
# 방법 2: 캐시 무시 옵션
|
||||
python -B ds_l10n.py merge
|
||||
```
|
||||
|
||||
### Q7. Unicode/이모지 표시 오류
|
||||
|
||||
Windows 콘솔에서 이모지가 깨지는 경우:
|
||||
- 로그 파일(`logs/`)에서는 정상적으로 표시됨
|
||||
- Windows Terminal 사용 권장
|
||||
|
||||
### Q8. 미번역이 있는데 추출되지 않음
|
||||
|
||||
**상황**: PO 파일에 msgstr이 비어있는데 extract 명령으로 감지되지 않음
|
||||
|
||||
**원인**: 기본적으로 en만 검사하기 때문
|
||||
|
||||
**해결**:
|
||||
```bash
|
||||
# 모든 언어 검사
|
||||
python ds_l10n.py extract --all-languages
|
||||
```
|
||||
|
||||
### Q9. ko 원본이 변경되었는데 번역이 그대로임
|
||||
|
||||
**상황**: ko 원본 텍스트가 수정되었지만 번역문이 예전 원본 기준임
|
||||
|
||||
**원인**: fuzzy 플래그가 있는 항목은 기본 extract에서 제외됨
|
||||
|
||||
**해결**:
|
||||
```bash
|
||||
# fuzzy 항목도 추출 (리뷰 필요한 번역)
|
||||
python ds_l10n.py extract --include-fuzzy
|
||||
|
||||
# 또는 전체 언어 + fuzzy
|
||||
python ds_l10n.py extract --all-languages --include-fuzzy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 프로젝트 구조
|
||||
|
||||
```
|
||||
DS_L10N/
|
||||
├── ds_l10n.py # 메인 CLI 툴
|
||||
├── config.yaml # 설정 파일
|
||||
├── 번역업데이트.tsv # 번역 입력 파일
|
||||
├── requirements.txt # 필요 라이브러리
|
||||
├── README.md # 이 문서
|
||||
│
|
||||
├── lib/ # 라이브러리 모듈
|
||||
│ ├── config_loader.py # 설정 로더
|
||||
│ ├── logger.py # 컬러 로깅
|
||||
│ ├── validator.py # 번역 검증
|
||||
│ ├── po_handler.py # PO 파일 처리
|
||||
│ └── file_manager.py # 파일 관리
|
||||
│
|
||||
├── output/ # 출력 파일 (자동 생성)
|
||||
├── logs/ # 로그 파일 (자동 생성)
|
||||
├── archive/ # 보관 파일 (자동 생성)
|
||||
│
|
||||
├── DS_Context.txt # 번역 문맥 가이드
|
||||
├── DS_Terminology_DB.txt # 용어 데이터베이스
|
||||
└── DS_Terminology_Guide.txt # 용어집 가이드
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💡 고급 사용법
|
||||
|
||||
### Dry-run 모드
|
||||
|
||||
```bash
|
||||
python ds_l10n.py update --dry-run
|
||||
```
|
||||
|
||||
실행 결과를 미리 확인 후 실제 업데이트 진행
|
||||
|
||||
### 커스텀 설정 파일
|
||||
|
||||
```bash
|
||||
python ds_l10n.py --config my_config.yaml extract
|
||||
```
|
||||
|
||||
여러 프로젝트 관리 시 유용
|
||||
|
||||
### Jenkins/CI 통합
|
||||
|
||||
```bash
|
||||
cd DS_L10N
|
||||
python ds_l10n.py validate || exit 1
|
||||
python ds_l10n.py update || exit 1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔗 관련 문서
|
||||
|
||||
- **번역 문맥 가이드**: `DS_Context.txt`
|
||||
- **용어 데이터베이스**: `DS_Terminology_DB.txt`
|
||||
- **용어집 가이드**: `DS_Terminology_Guide.txt`
|
||||
|
||||
---
|
||||
|
||||
## 🆕 버전 히스토리
|
||||
|
||||
### v2.0.2 (2025-10-30)
|
||||
- ✨ 미번역 추출 기능 대폭 개선
|
||||
- 기본적으로 en만 검사 (빠른 실행)
|
||||
- `--all-languages` 옵션으로 전체 언어 검사
|
||||
- `--include-fuzzy` 옵션으로 리뷰 필요 항목 추출
|
||||
- msgstr 빈 값 정확히 감지 (ko 원본 변경 케이스)
|
||||
- ✨ config.yaml에 extract 설정 섹션 추가
|
||||
- 📝 README에 새 기능 상세 설명 추가
|
||||
- 📝 문제 해결 섹션에 Q8, Q9 추가
|
||||
|
||||
### v2.0.1 (2025-10-29)
|
||||
- 🐛 줄바꿈 이스케이프 버그 수정 (TSV `\r\n` → PO 정확한 변환)
|
||||
- 🐛 검증 로직 수정 (실제 escape sequence 검사)
|
||||
- ✨ CSV 병합 시 msgid 컬럼 제거
|
||||
- ✨ CSV에서 ko가 en 앞에 위치하도록 변경
|
||||
- 📝 배치 파일 제거 (CLI만 사용)
|
||||
- 📝 README 간소화 및 업데이트
|
||||
|
||||
### v2.0 (2025-01-29)
|
||||
- ✨ 통합 CLI 툴 구현 (`ds_l10n.py`)
|
||||
- ✨ 번역 검증 기능 추가
|
||||
- ✨ 자동 파일 정리 기능
|
||||
- ✨ 컬러 로깅 시스템
|
||||
- ✨ ko(원본 언어) 업데이트 지원
|
||||
- 🐛 polib 기반으로 안정성 향상
|
||||
- 🐛 Unicode/이모지 인코딩 문제 해결
|
||||
|
||||
---
|
||||
|
||||
## 📄 라이선스
|
||||
|
||||
Copyright © 2025 OneUniverse. All rights reserved.
|
||||
|
||||
---
|
||||
|
||||
**버전**: 2.0.2
|
||||
**최종 수정**: 2025-10-30
|
||||
**작성자**: DS_L10N Team
|
||||
|
||||
6647
archive/202510/merged_po_entries_20250812_163854.csv
Normal file
6647
archive/202510/merged_po_entries_20250812_163854.csv
Normal file
File diff suppressed because one or more lines are too long
3372
archive/202510/merged_po_entries_literal_20250812_164459.csv
Normal file
3372
archive/202510/merged_po_entries_literal_20250812_164459.csv
Normal file
File diff suppressed because one or more lines are too long
3373
archive/202510/merged_po_entries_literal_20250812_231429.csv
Normal file
3373
archive/202510/merged_po_entries_literal_20250812_231429.csv
Normal file
File diff suppressed because one or more lines are too long
3373
archive/202510/merged_po_entries_literal_20250813_225345.csv
Normal file
3373
archive/202510/merged_po_entries_literal_20250813_225345.csv
Normal file
File diff suppressed because one or more lines are too long
3373
archive/202510/merged_po_entries_literal_20250816_140829.csv
Normal file
3373
archive/202510/merged_po_entries_literal_20250816_140829.csv
Normal file
File diff suppressed because one or more lines are too long
3376
archive/202510/merged_po_entries_literal_20250819_034018.csv
Normal file
3376
archive/202510/merged_po_entries_literal_20250819_034018.csv
Normal file
File diff suppressed because one or more lines are too long
3379
archive/202510/merged_po_entries_literal_20250821_152550.csv
Normal file
3379
archive/202510/merged_po_entries_literal_20250821_152550.csv
Normal file
File diff suppressed because one or more lines are too long
3378
archive/202510/merged_po_entries_literal_20250821_173444.csv
Normal file
3378
archive/202510/merged_po_entries_literal_20250821_173444.csv
Normal file
File diff suppressed because one or more lines are too long
3355
archive/202510/merged_po_entries_literal_20250905_134803.csv
Normal file
3355
archive/202510/merged_po_entries_literal_20250905_134803.csv
Normal file
File diff suppressed because one or more lines are too long
3367
archive/202510/merged_po_entries_literal_20250905_170607.csv
Normal file
3367
archive/202510/merged_po_entries_literal_20250905_170607.csv
Normal file
File diff suppressed because one or more lines are too long
3367
archive/202510/merged_po_entries_literal_20250905_172141.csv
Normal file
3367
archive/202510/merged_po_entries_literal_20250905_172141.csv
Normal file
File diff suppressed because one or more lines are too long
3370
archive/202510/merged_po_entries_literal_20250910_210147.csv
Normal file
3370
archive/202510/merged_po_entries_literal_20250910_210147.csv
Normal file
File diff suppressed because one or more lines are too long
3372
archive/202510/merged_po_entries_literal_20250910_220249.csv
Normal file
3372
archive/202510/merged_po_entries_literal_20250910_220249.csv
Normal file
File diff suppressed because one or more lines are too long
3372
archive/202510/merged_po_entries_literal_20250911_015944.csv
Normal file
3372
archive/202510/merged_po_entries_literal_20250911_015944.csv
Normal file
File diff suppressed because one or more lines are too long
3372
archive/202510/merged_po_entries_literal_20250911_022706.csv
Normal file
3372
archive/202510/merged_po_entries_literal_20250911_022706.csv
Normal file
File diff suppressed because one or more lines are too long
3365
archive/202510/merged_po_entries_literal_20251001_203736.csv
Normal file
3365
archive/202510/merged_po_entries_literal_20251001_203736.csv
Normal file
File diff suppressed because one or more lines are too long
3515
archive/202510/merged_po_entries_literal_20251021_162043.csv
Normal file
3515
archive/202510/merged_po_entries_literal_20251021_162043.csv
Normal file
File diff suppressed because one or more lines are too long
3515
archive/202510/merged_po_entries_literal_20251021_173630.csv
Normal file
3515
archive/202510/merged_po_entries_literal_20251021_173630.csv
Normal file
File diff suppressed because one or more lines are too long
3519
archive/202510/merged_po_entries_literal_20251022_164625.csv
Normal file
3519
archive/202510/merged_po_entries_literal_20251022_164625.csv
Normal file
File diff suppressed because one or more lines are too long
3484
archive/202510/merged_po_entries_literal_20251022_173409.csv
Normal file
3484
archive/202510/merged_po_entries_literal_20251022_173409.csv
Normal file
File diff suppressed because one or more lines are too long
3484
archive/202510/merged_po_entries_literal_20251022_173650.csv
Normal file
3484
archive/202510/merged_po_entries_literal_20251022_173650.csv
Normal file
File diff suppressed because one or more lines are too long
3484
archive/202510/merged_po_entries_literal_20251022_174917.csv
Normal file
3484
archive/202510/merged_po_entries_literal_20251022_174917.csv
Normal file
File diff suppressed because one or more lines are too long
3484
archive/202510/merged_po_entries_literal_20251022_180556.csv
Normal file
3484
archive/202510/merged_po_entries_literal_20251022_180556.csv
Normal file
File diff suppressed because one or more lines are too long
3492
archive/202510/merged_po_entries_literal_20251024_005756.csv
Normal file
3492
archive/202510/merged_po_entries_literal_20251024_005756.csv
Normal file
File diff suppressed because one or more lines are too long
3520
archive/202510/merged_po_entries_literal_20251024_204358.csv
Normal file
3520
archive/202510/merged_po_entries_literal_20251024_204358.csv
Normal file
File diff suppressed because one or more lines are too long
@ -1,3 +1,4 @@
|
||||
대분류 중분류 원본 용어 (ko) 설명 (Description) en ja zh-Hans zh-Hant es-ES es-419 fr-FR de-DE ru-RU pt-BR pt-PT it-IT pl-PL tr-TR uk-UA vi-VN th
|
||||
Character 스토커 정보 속죄의 방랑자 네이브의 이명 Atoning Wanderer 贖罪の放浪者 赎罪流浪者 贖罪浪人 Errante Expiatoria Errante Expiatoria Vagabonde Expiatoire Büßende Wanderin Искупающая грехи странница Andarilha da Expiação Andarilha da Expiação Viandante Penitente Pokutująca Wędrowczyni Kefaret Gezgini Мандрівниця Спокути Kẻ Lang Thang Chuộc Tội ผู้พเนจรไถ่บาป
|
||||
Character 스토커 정보 마법사 네이브의 직업 Mage 魔法使い 魔法师 魔法師 Maga Maga Mage Magierin Волшебница Mago Mago Maga Czarodziejka Büyücü Чарівниця Pháp Sư นักเวท
|
||||
Character 스토커 정보 검은 순례자 레네의 이명 Dark Pilgrim 黒き巡礼者 暗之朝圣者 黑暗朝聖者 Peregrino Oscuro Peregrino Oscuro Pèlerin Sombre Dunkler Pilger Темный Пилигрим Peregrino Sombrio Peregrino Sombrio Pellegrino Oscuro Mroczny Pielgrzym Kara Hacı Темний Прочанин Lữ Khách Hắc Ám นักแสวงบุญทมิฬ
|
||||
@ -84,7 +85,7 @@ Character 몬스터 이름 구울 시체를 먹는다고 알려진 언데드 몬
|
||||
Character 몬스터 이름 단검 박쥐 단검을 사용하는 박쥐. Dagger Bat ダガーバット 匕首蝙蝠 匕首蝙蝠 Murciélago Daga Murciélago Daga Chauve-souris d'Armes Dolchfledermaus Летучая Мышь с Кинжалами Morcego com Adaga Morcego com Adaga Pipistrello Pugnale Nietoperz Sztyletnik Hançerli Yarasa Кажан з кинджалами Dơi Dao Găm ค้างคาวมีด
|
||||
Character 몬스터 이름 도살자 보스 몬스터. 거대한 육체를 가졌다. The Slaughterer 屠殺人 屠夫 屠夫 El Carnicero El Verdugo Le Boucher Der Schlachter Мясник O Açougueiro O Carniceiro Il Massacratore Rzeźnik Kasap М'ясник Kẻ Đồ Tể ผู้สังหาร
|
||||
Character 몬스터 이름 마녀의 하수인 마녀를 섬기는 몬스터. Witch's Minion 魔女の手下 魔女仆从 魔女僕從 Esbirro de la Bruja Esbirro de la Bruja Serviteur de Sorcière Hexendiener Прислужник Ведьмы Lacaio da Bruxa Lacaio da Bruxa Servitore della Strega Pachołek Wiedźmy Cadı'nın Dalkavuğu Поплічник Відьми Tay Sai Của Phù Thủy ลูกสมุนแม่มด
|
||||
Character 몬스터 이름 미믹 보물상자로 위장한 몬스터. Mimic ミミック 宝箱怪 寶箱怪 Mímico Mímico Mimic Mimic Мимик Mímico Mímico Mimic Mimik Mimik Мімік Mimic มิミック
|
||||
Character 몬스터 이름 미믹 보물상자로 위장한 몬스터. Mimic ミミック 宝箱怪 寶箱怪 Mímico Mímico Mimic Mimic Мимик Mímico Mímico Mimic Mimik Mimik Мімік Mimic มิมิก
|
||||
Character 몬스터 이름 박쥐 일반적인 박쥐 몬스터. Bat コウモリ 蝙蝠 蝙蝠 Murciélago Murciélago Chauve-souris Fledermaus Летучая Мышь Morcego Morcego Pipistrello Nietoperz Yarasa Кажан Dơi ค้างคาว
|
||||
Character 몬스터 이름 스파이더 네스트 거미를 생성하는 둥지. Spider Nest スパイダーネスト 蜘蛛巢穴 蜘蛛巢穴 Nido de Arañas Nido de Arañas Nid d'Araignées Spinnennest Паучье Гнездо Ninho de Aranha Ninho de Aranha Nido di Ragni Gniazdo Pająków Örümcek Yuvası Павуче Гніздо Tổ Nhện รังแมงมุม
|
||||
Character 몬스터 이름 슬라임 젤리 형태의 몬스터. Slime スライム 史莱姆 史萊姆 Limo Limo Slime Schleim Слизь Slime Slime Slime Szlam Balçık Слиз Slime สไลม์
|
||||
@ -300,6 +301,17 @@ Item & Equipment 제작법/설계도 철벽의 갑옷 설계도 '철벽의 갑
|
||||
Item & Equipment 제작법/설계도 괴력의 장갑 설계도 '괴력의 장갑'을 제작할 수 있는 설계도. Blueprint: Gauntlets of Brute Force 怪力の篭手の設計図 蓝图:蛮力护手 藍圖:蠻力護手 Plano: Guanteletes de Fuerza Bruta Plano: Guanteletes de Fuerza Bruta Plan : Gantelets de Force Brute Bauplan: Stulpen der rohen Gewalt Чертёж: Рукавицы Грубой Силы Projeto: Manoplas de Força Bruta Projeto: Manoplas de Força Bruta Progetto: Guanti d'Arme della Forza Bruta Schemat: Rękawice Brutalnej Siły Şablon: Kaba Kuvvet Eldivenleri Креслення: Рукавиці Грубої Сили Bản Vẽ: Găng Tay Quái Lực พิมพ์เขียว: ถุงมือแห่งพลังมหาศาล
|
||||
Item & Equipment 제작법/설계도 속도의 가죽 부츠 설계도 '속도의 가죽 부츠'를 제작할 수 있는 설계도. Blueprint: Leather Boots of Speed 速度のレザーブーツの設計図 蓝图:急速皮靴 藍圖:急速皮靴 Plano: Botas de Cuero de Velocidad Plano: Botas de Cuero de Velocidad Plan : Bottes de Cuir de Vitesse Bauplan: Lederstiefel der Geschwindigkeit Чертёж: Кожаные Сапоги Скорости Projeto: Botas de Couro da Velocidade Projeto: Botas de Couro da Velocidade Progetto: Stivali di Cuoio della Velocità Schemat: Skórzane Buty Szybkości Şablon: Hızın Deri Çizmeleri Креслення: Шкіряні Чоботи Швидкості Bản Vẽ: Giày Da Tốc Độ พิมพ์เขียว: รองเท้าบูตหนังแห่งความเร็ว
|
||||
Item & Equipment 제작법/설계도 생명의 바지 설계도 '생명의 바지'를 제작할 수 있는 설계도. Blueprint: Trousers of Life 生命のズボンの設計図 蓝图:生命马裤 藍圖:生命馬褲 Plano: Pantalones de Vida Plano: Pantalones de Vida Plan : Pantalon de Vie Bauplan: Hose des Lebens Чертёж: Штаны Жизни Projeto: Calças da Vida Projeto: Calças da Vida Progetto: Pantaloni della Vita Schemat: Spodnie Życia Şablon: Yaşam Pantolonu Креслення: Штани Життя Bản Vẽ: Quần Sinh Mệnh พิมพ์เขียว: กางเกงแห่งชีวิต
|
||||
Item & Equipment 고유 장비 이름 서풍의 검 Zephyr Sword 西風の剣 西风之剑 西風之劍 Espada Céfiro Espada Céfiro Épée de Zéphyr Zephirschwert Меч Зефира Espada Zéfiro Espada Zéfiro Spada Zefiro Miecz Zefira Batı Rüzgarı Kılıcı Меч Зефіру Kiếm Zephyr ดาบสายลมตะวันตก
|
||||
Item & Equipment 고유 장비 이름 샛별 파수꾼 Starlette Protector 明星の番人 启明星守护者 啟明星守護者 Guardián del Alba Guardián de la Estrella del Alba Gardien de l'Étoile du Matin Morgenstern-Wächter Хранитель Утренней Звезды Guardião da Estrela da Manhã Guardião da Estrela da Manhã Guardiano della Stella del Mattino Strażnik Gwiazdy Porannej Sabah Yıldızı Muhafızı Хранитель Ранкової Зорі Vệ Binh Sao Mai ผู้พิทักษ์ดาวประกายพรึก
|
||||
Item & Equipment 고유 장비 이름 완력의 지팡이 Staff of Might 腕力の杖 力量法杖 力量法杖 Bastón de Poder Báculo de Poder Bâton de Puissance Stab der Macht Посох Могущества Cajado de Poder Cajado de Poder Bastone della Potenza Laska Mocy Kudret Asası Посох Могутності Trượng Sức Mạnh ไม้เท้าแห่งพละกำลัง
|
||||
Item & Equipment 고유 장비 이름 트롤 사냥꾼 대검 Troll Hunter Greatsword トロルハンターグレートソード 巨魔猎手巨剑 食人妖獵手巨劍 Mandoble Cazador de Trolls Mandoble de Cazador de Trolls Espadon de Chasseur de Trolls Trolljäger-Großschwert Двуручный Меч Охотника на Троллей Espada Grande Caça-Troll Montante Caça-Troll Spadone da Cacciatore di Troll Wielki Miecz Łowcy Trolli Trol Avcısı Çift Elli Kılıcı Дворучний Меч Мисливця на Тролів Đại Kiếm Săn Troll ดาบใหญ่ผู้ล่าโทรลล์
|
||||
Item & Equipment 고유 장비 이름 마나 칼날 Mana Blade マナブレード 法力之刃 法力之刃 Hoja de Maná Hoja de Maná Lame de Mana Manaklinge Клинок Маны Lâmina de Mana Lâmina de Mana Lama di Mana Ostrze Many Mana Kılıcı Клинок Мани Kiếm Mana ใบมีดมานา
|
||||
Item & Equipment 고유 장비 이름 명상의 전투 망치 Meditative War Hammer 瞑想のウォーハンマー 冥想战锤 冥想戰錘 Martillo de Guerra Meditativo Martillo de Guerra Meditativo Marteau de Guerre Méditatif Meditativer Kriegshammer Медитативный Боевой Молот Martelo de Guerra Meditativo Martelo de Guerra Meditativo Martello da Guerra Meditativo Medytacyjny Młot Bojowy Meditasyon Savaş Çekici Медитативний Бойовий Молот Búa Chiến Thiền Định ค้อนศึกแห่งการทำสมาธิ
|
||||
Item & Equipment 고유 장비 이름 심안의 투구 Helm of Insight 心眼の兜 洞察之盔 洞察之盔 Yelmo de Perspicacia Yelmo de Perspicacia Heaume de Perspicacité Helm der Einsicht Шлем Прозрения Elmo da Percepção Elmo da Percepção Elmo dell'Intuito Hełm Wnikliwości İçgörü Miğferi Шолом Прозріння Mũ Trụ Tâm Nhãn หมวกเกราะเนตรทิพย์
|
||||
Item & Equipment 고유 장비 이름 철벽의 갑옷 Bulwark Armor 鉄壁の鎧 壁垒之甲 壁壘之甲 Armadura Baluarte Armadura Baluarte Armure de Rempart Bollwerk-Rüstung Доспех-Оплот Armadura Baluarte Armadura Baluarte Armatura Baluardo Zbroja Bastionu İstihkam Zırhı Доспіх-Оплот Giáp Thiết Bích เกราะกำแพงเหล็ก
|
||||
Item & Equipment 고유 장비 이름 괴력의 장갑 Gauntlets of Brute Force 怪力の篭手 蛮力护手 蠻力護手 Guanteletes de Fuerza Bruta Guanteletes de Fuerza Bruta Gantelets de Force Brute Stulpen der rohen Gewalt Рукавицы Грубой Силы Manoplas de Força Bruta Manoplas de Força Bruta Guanti d'Arme della Forza Bruta Rękawice Brutalnej Siły Kaba Kuvvet Eldivenleri Рукавиці Грубої Сили Găng Tay Quái Lực ถุงมือแห่งพลังมหาศาล
|
||||
Item & Equipment 고유 장비 이름 속도의 가죽 부츠 Leather Boots of Speed 速度のレザーブーツ 急速皮靴 急速皮靴 Botas de Cuero de Velocidad Botas de Cuero de Velocidad Bottes de Cuir de Vitesse Lederstiefel der Geschwindigkeit Кожаные Сапоги Скорости Botas de Couro da Velocidade Botas de Couro da Velocidade Stivali di Cuoio della Velocità Skórzane Buty Szybkości Hızın Deri Çizmeleri Шкіряні Чоботи Швидкості Giày Da Tốc Độ รองเท้าบูตหนังแห่งความเร็ว
|
||||
Item & Equipment 고유 장비 이름 생명의 바지 Trousers of Life 生命のズボン 生命马裤 生命馬褲 Pantalones de Vida Pantalones de Vida Pantalon de Vie Hose des Lebens Штаны Жизни Calças da Vida Calças da Vida Pantaloni della Vita Spodnie Życia Yaşam Pantolonu Штани Життя Quần Sinh Mệnh กางเกงแห่งชีวิต
|
||||
Item & Equipment 고유 장비 이름 스톰브링어 공격 적중 시 낙뢰를 소환하는 전설 한손검. Stormbringer ストームブリンガー 风暴使者 風暴使者 Portadora de Tormentas Portadora de Tormentas Porte-Tempête Sturmbringer Буревестник Portadora da Tempestade Portadora da Tempestade Portatrice di Tempeste Zwiastun Burzy Fırtına Getiren Буревісник Kẻ Mang Bão Tố สตอร์มบริงเกอร์
|
||||
Item & Equipment 고유 장비 이름 칼리번 전설 등급의 한손검. Caliburn カリバーン 卡利班 卡利班 Caliburn Caliburn Caliburn Caliburn Калибурн Caliburn Caliburn Caliburn Caliburn Caliburn Калібурн Caliburn คาลิเบอร์น
|
||||
Item & Equipment 고유 장비 이름 심장추적자 사격 시 추가 화살을 발사하는 전설 활. Heartseeker ハートシーカー 觅心者 覓心者 Buscacorazones Buscacorazones Cherchecœur Herzsucher Искатель Сердец Busca-coração Perseguidor de Corações Cercacuori Poszukiwacz Serc Kalp Arayan Серцеїд Kẻ Truy Tìm Trái Tim ฮาร์ตซีคเกอร์
|
||||
@ -61,10 +61,18 @@ def merge_po_to_csv_literal():
|
||||
# source_location은 경로이므로 줄바꿈이 없을 가능성이 높지만, 안정성을 위해 추가
|
||||
source_location_str = source_location_str.replace('\r', '\\r').replace('\n', '\\n')
|
||||
|
||||
data_key = (msgctxt_str, msgid_str, source_location_str)
|
||||
# msgctxt + SourceLocation을 키로 사용 (msgid는 언어마다 다를 수 있음)
|
||||
data_key = (msgctxt_str, source_location_str)
|
||||
|
||||
if data_key not in merged_data:
|
||||
merged_data[data_key] = {}
|
||||
merged_data[data_key] = {
|
||||
'msgid': msgid_str, # 첫 번째로 발견된 msgid 저장
|
||||
'msgid_ko': None, # ko의 msgid를 별도로 저장
|
||||
}
|
||||
|
||||
# ko 언어의 msgid는 별도로 저장 (원본 언어이므로 우선)
|
||||
if lang_code == 'ko':
|
||||
merged_data[data_key]['msgid_ko'] = msgid_str
|
||||
|
||||
merged_data[data_key][lang_code] = msgstr_str
|
||||
# --- 수정 완료 ---
|
||||
@ -74,13 +82,21 @@ def merge_po_to_csv_literal():
|
||||
continue
|
||||
|
||||
records = []
|
||||
for (msgctxt, msgid, source_location), translations in merged_data.items():
|
||||
for (msgctxt, source_location), data in merged_data.items():
|
||||
# ko의 msgid가 있으면 우선 사용, 없으면 첫 번째로 발견된 msgid 사용
|
||||
msgid = data.get('msgid_ko') if data.get('msgid_ko') else data.get('msgid')
|
||||
|
||||
record = {
|
||||
'msgctxt': msgctxt,
|
||||
'SourceLocation': source_location,
|
||||
'msgid': msgid,
|
||||
}
|
||||
record.update(translations)
|
||||
|
||||
# 언어별 번역 추가 ('msgid', 'msgid_ko' 키는 제외)
|
||||
for key, value in data.items():
|
||||
if key not in ['msgid', 'msgid_ko']:
|
||||
record[key] = value
|
||||
|
||||
records.append(record)
|
||||
|
||||
df = pd.DataFrame(records)
|
||||
241
config.yaml
Normal file
241
config.yaml
Normal file
@ -0,0 +1,241 @@
|
||||
# DS_L10N Configuration File
|
||||
# 던전 스토커즈 현지화 워크플로우 설정
|
||||
|
||||
# ============================================================================
|
||||
# 경로 설정 (Paths)
|
||||
# ============================================================================
|
||||
paths:
|
||||
# 언리얼 엔진 현지화 폴더 (LocalExport 상위 폴더)
|
||||
# DS_L10N 폴더 기준 상대 경로
|
||||
unreal_localization: ../WorldStalker/Content/Localization/LocalExport
|
||||
|
||||
# 번역 작업 출력 폴더
|
||||
output_dir: ./output
|
||||
|
||||
# 로그 파일 저장 폴더
|
||||
logs_dir: ./logs
|
||||
|
||||
# 오래된 파일 보관 폴더
|
||||
archive_dir: ./archive
|
||||
|
||||
# 임시 파일 폴더
|
||||
temp_dir: ./temp
|
||||
|
||||
# ============================================================================
|
||||
# 파일명 설정 (File Names)
|
||||
# ============================================================================
|
||||
files:
|
||||
# 언리얼에서 생성하는 PO 파일명
|
||||
po_filename: LocalExport.po
|
||||
|
||||
# 번역 업데이트용 TSV 입력 파일
|
||||
translation_input: 번역업데이트.tsv
|
||||
|
||||
# 미번역 항목 추출 파일명 패턴
|
||||
untranslated_pattern: "untranslated_{timestamp}.tsv"
|
||||
|
||||
# 병합된 CSV 파일명 패턴
|
||||
merged_pattern: "merged_po_entries_{timestamp}.csv"
|
||||
|
||||
# 로그 파일명 패턴
|
||||
log_pattern: "workflow_{timestamp}.log"
|
||||
|
||||
# ============================================================================
|
||||
# 언어 설정 (Languages)
|
||||
# ============================================================================
|
||||
languages:
|
||||
# 원본 언어
|
||||
source: ko
|
||||
|
||||
# 번역 대상 언어 목록 (우선순위 순서)
|
||||
targets:
|
||||
- en
|
||||
- ja
|
||||
- zh-Hans
|
||||
- zh-Hant
|
||||
- es-ES
|
||||
- es-419
|
||||
- fr-FR
|
||||
- de-DE
|
||||
- ru-RU
|
||||
- pt-BR
|
||||
- pt-PT
|
||||
- it-IT
|
||||
- pl-PL
|
||||
- tr-TR
|
||||
- uk-UA
|
||||
- vi-VN
|
||||
- th
|
||||
|
||||
# ============================================================================
|
||||
# 검증 설정 (Validation)
|
||||
# ============================================================================
|
||||
validation:
|
||||
# 변수 일치 확인 (예: {Value}, {Count})
|
||||
check_variables: true
|
||||
|
||||
# 리치 텍스트 태그 일치 확인 (예: <Red>...</>)
|
||||
check_rich_text_tags: true
|
||||
|
||||
# 줄바꿈 문자 일치 확인 (예: \r\n)
|
||||
check_newlines: true
|
||||
|
||||
# 빈 번역 확인
|
||||
check_empty_translations: true
|
||||
|
||||
# msgctxt 존재 여부 확인 (PO 파일에 실제 존재하는지)
|
||||
check_msgctxt_exists: true
|
||||
|
||||
# 최대 길이 초과 경고 (UI 텍스트 제한)
|
||||
max_length_warning: 200
|
||||
|
||||
# 검증 실패 시 업데이트 중단 여부
|
||||
stop_on_validation_error: false
|
||||
|
||||
# ============================================================================
|
||||
# 자동 정리 설정 (Cleanup)
|
||||
# ============================================================================
|
||||
cleanup:
|
||||
# 최근 N개 파일만 유지 (나머지는 archive로 이동)
|
||||
keep_recent_files: 5
|
||||
|
||||
# 오래된 파일 자동 보관 활성화
|
||||
auto_archive: true
|
||||
|
||||
# 보관 대상 파일 패턴
|
||||
archive_patterns:
|
||||
- "merged_po_entries_*.csv"
|
||||
- "untranslated_*.tsv"
|
||||
- "untranslated_*.csv"
|
||||
|
||||
# N일 이상 된 로그 파일 삭제
|
||||
delete_old_logs_days: 30
|
||||
|
||||
# ============================================================================
|
||||
# 백업 설정 (Backup)
|
||||
# ============================================================================
|
||||
backup:
|
||||
# PO 파일 업데이트 전 자동 백업
|
||||
auto_backup_before_update: true
|
||||
|
||||
# 백업 파일명 패턴
|
||||
backup_pattern: "{original_name}.backup_{timestamp}"
|
||||
|
||||
# 백업 보관 기간 (일)
|
||||
keep_backups_days: 7
|
||||
|
||||
# ============================================================================
|
||||
# 로그 설정 (Logging)
|
||||
# ============================================================================
|
||||
logging:
|
||||
# 로그 레벨: DEBUG, INFO, WARNING, ERROR, CRITICAL
|
||||
console_level: INFO
|
||||
|
||||
# 파일 로그 레벨
|
||||
file_level: DEBUG
|
||||
|
||||
# 컬러 콘솔 출력 활성화
|
||||
colored_output: true
|
||||
|
||||
# 상세 모드 (더 많은 정보 출력)
|
||||
verbose: false
|
||||
|
||||
# 진행률 표시
|
||||
show_progress: true
|
||||
|
||||
# ============================================================================
|
||||
# 미번역 추출 설정 (Extract)
|
||||
# ============================================================================
|
||||
extract:
|
||||
# 모든 대상 언어 검사 (false면 en만 검사)
|
||||
check_all_languages: false
|
||||
|
||||
# fuzzy 플래그 항목도 포함 (원본 변경으로 리뷰 필요한 항목)
|
||||
include_fuzzy: true
|
||||
|
||||
# 언어별 개별 파일로 추출 (false면 통합 파일)
|
||||
separate_files: true
|
||||
|
||||
# ============================================================================
|
||||
# 워크플로우 설정 (Workflow)
|
||||
# ============================================================================
|
||||
workflow:
|
||||
# 오류 발생 시 작업 중단
|
||||
stop_on_error: true
|
||||
|
||||
# ============================================================================
|
||||
# 출력 형식 설정 (Output Format)
|
||||
# ============================================================================
|
||||
output:
|
||||
# CSV/TSV 인코딩
|
||||
encoding: utf-8-sig # UTF-8 with BOM (Excel 호환)
|
||||
|
||||
# CSV 구분자 (쉼표)
|
||||
csv_delimiter: ","
|
||||
|
||||
# TSV 구분자 (탭)
|
||||
tsv_delimiter: "\t"
|
||||
|
||||
# 모든 필드 따옴표로 감싸기
|
||||
quote_all: true
|
||||
|
||||
# 줄바꿈 문자 처리 방식: "literal" (\\r\\n) 또는 "preserve" (\r\n)
|
||||
newline_mode: literal
|
||||
|
||||
# ============================================================================
|
||||
# 리치 텍스트 태그 패턴 (Rich Text Tags)
|
||||
# ============================================================================
|
||||
rich_text:
|
||||
# 인식할 리치 텍스트 태그 패턴
|
||||
tag_patterns:
|
||||
- "<Red>"
|
||||
- "<Blue>"
|
||||
- "<Green>"
|
||||
- "<Yellow>"
|
||||
- "<Orange>"
|
||||
- "<Purple>"
|
||||
- "<White>"
|
||||
- "<Black>"
|
||||
- "<RichTextBlock.Style>"
|
||||
- "</>"
|
||||
|
||||
# 태그 검증 활성화
|
||||
validate_tags: true
|
||||
|
||||
# ============================================================================
|
||||
# 변수 패턴 (Variable Patterns)
|
||||
# ============================================================================
|
||||
variables:
|
||||
# 변수 패턴 (정규식)
|
||||
pattern: "\\{[A-Za-z0-9_]+\\}"
|
||||
|
||||
# 알려진 변수 목록
|
||||
known_variables:
|
||||
- "{Value}"
|
||||
- "{Count}"
|
||||
- "{PlayerName}"
|
||||
- "{Time}"
|
||||
- "{Damage}"
|
||||
- "{Duration}"
|
||||
- "{Amount}"
|
||||
- "{Percent}"
|
||||
- "{Range}"
|
||||
- "{Level}"
|
||||
- "{ItemName}"
|
||||
- "{SkillName}"
|
||||
|
||||
# ============================================================================
|
||||
# 고급 설정 (Advanced)
|
||||
# ============================================================================
|
||||
advanced:
|
||||
# 멀티스레딩 활성화
|
||||
use_multithreading: true
|
||||
|
||||
# 스레드 개수 (0 = CPU 코어 수)
|
||||
thread_count: 0
|
||||
|
||||
# 메모리 사용 최적화 (대용량 파일 처리 시)
|
||||
memory_optimization: false
|
||||
|
||||
# dry-run 모드 (실제 파일 수정 없이 시뮬레이션)
|
||||
dry_run: false
|
||||
483
ds_l10n.py
Normal file
483
ds_l10n.py
Normal file
@ -0,0 +1,483 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
DS_L10N - 던전 스토커즈 현지화 통합 툴
|
||||
Unified localization workflow tool for DungeonStalkers
|
||||
|
||||
Usage:
|
||||
python ds_l10n.py extract [--gui] # 미번역 항목 추출
|
||||
python ds_l10n.py validate [input] # 번역 검증
|
||||
python ds_l10n.py update [input] # PO 파일 업데이트
|
||||
python ds_l10n.py merge # CSV 병합
|
||||
python ds_l10n.py cleanup # 파일 정리
|
||||
"""
|
||||
import sys
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
# 라이브러리 임포트
|
||||
from lib.config_loader import load_config
|
||||
from lib.logger import init_logger, Icons
|
||||
from lib.validator import TranslationValidator
|
||||
from lib.po_handler import POHandler
|
||||
from lib.file_manager import FileManager
|
||||
|
||||
|
||||
def get_timestamp() -> str:
|
||||
"""타임스탬프 생성"""
|
||||
return datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
|
||||
|
||||
def command_extract(args, config, logger):
|
||||
"""미번역 항목 추출"""
|
||||
logger.section('미번역 항목 추출', Icons.SEARCH)
|
||||
|
||||
# extract 설정
|
||||
check_all_languages = config.get('extract.check_all_languages', False)
|
||||
include_fuzzy = config.get('extract.include_fuzzy', False)
|
||||
separate_files = config.get('extract.separate_files', True)
|
||||
|
||||
# CLI 옵션으로 설정 오버라이드
|
||||
if hasattr(args, 'include_fuzzy') and args.include_fuzzy is not None:
|
||||
include_fuzzy = args.include_fuzzy
|
||||
if hasattr(args, 'all_languages') and args.all_languages:
|
||||
check_all_languages = True
|
||||
|
||||
# 출력 디렉토리
|
||||
output_dir = config.get_path('paths.output_dir')
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
timestamp = get_timestamp()
|
||||
po_handler = POHandler(config.data, logger)
|
||||
|
||||
# GUI 모드
|
||||
if args.gui:
|
||||
logger.info('GUI 모드로 실행합니다...')
|
||||
import tkinter as tk
|
||||
from tkinter import filedialog
|
||||
|
||||
root = tk.Tk()
|
||||
root.withdraw()
|
||||
|
||||
logger.info('PO 파일을 선택하세요...')
|
||||
po_path = filedialog.askopenfilename(
|
||||
title='미번역 항목을 추출할 PO 파일 선택',
|
||||
filetypes=[('PO Files', '*.po'), ('All files', '*.*')]
|
||||
)
|
||||
|
||||
if not po_path:
|
||||
logger.warning('파일을 선택하지 않았습니다.')
|
||||
return False
|
||||
|
||||
po_path = Path(po_path)
|
||||
|
||||
# 출력 파일 경로
|
||||
pattern = config.get('files.untranslated_pattern', 'untranslated_{timestamp}.tsv')
|
||||
output_filename = pattern.format(timestamp=timestamp)
|
||||
output_path = output_dir / output_filename
|
||||
|
||||
# 추출 실행
|
||||
count = po_handler.extract_untranslated(po_path, output_path, include_fuzzy)
|
||||
|
||||
if count > 0:
|
||||
logger.success(f'\n✅ 미번역 항목 {count}건 추출 완료')
|
||||
logger.info(f'📄 출력 파일: {output_path}')
|
||||
else:
|
||||
logger.success(f'\n✅ 모든 텍스트가 번역되어 있습니다!')
|
||||
|
||||
return True
|
||||
|
||||
# CLI 모드
|
||||
unreal_loc = config.get_path('paths.unreal_localization')
|
||||
|
||||
if not unreal_loc.exists():
|
||||
logger.error(f'언리얼 현지화 폴더를 찾을 수 없습니다: {unreal_loc}')
|
||||
return False
|
||||
|
||||
logger.info(f'언리얼 현지화 폴더: {unreal_loc}')
|
||||
logger.info(f'fuzzy 항목 포함: {"예" if include_fuzzy else "아니오"}')
|
||||
|
||||
# 검사할 언어 결정
|
||||
if check_all_languages:
|
||||
# 모든 대상 언어 검사
|
||||
target_langs = config.get('languages.targets', [])
|
||||
if not target_langs:
|
||||
logger.error('config.yaml에 대상 언어(languages.targets)가 정의되지 않았습니다.')
|
||||
return False
|
||||
|
||||
logger.info(f'대상 언어: {", ".join(target_langs)}')
|
||||
else:
|
||||
# en만 검사 (기본 동작)
|
||||
target_langs = ['en']
|
||||
logger.info(f'검사 언어: en')
|
||||
|
||||
# 언어별로 미번역 추출
|
||||
total_extracted = 0
|
||||
extracted_files = []
|
||||
|
||||
for lang in target_langs:
|
||||
po_path = unreal_loc / lang / config.get('files.po_filename', 'LocalExport.po')
|
||||
|
||||
if not po_path.exists():
|
||||
logger.warning(f' ⚠️ {lang}: PO 파일을 찾을 수 없음 - {po_path}')
|
||||
continue
|
||||
|
||||
# 출력 파일 경로
|
||||
if check_all_languages and separate_files:
|
||||
# 모든 언어 검사 시: 언어별 개별 파일
|
||||
output_filename = f'untranslated_{lang}_{timestamp}.tsv'
|
||||
else:
|
||||
# en만 검사 시: 단일 파일 (언어 코드 생략)
|
||||
pattern = config.get('files.untranslated_pattern', 'untranslated_{timestamp}.tsv')
|
||||
output_filename = pattern.format(timestamp=timestamp)
|
||||
|
||||
output_path = output_dir / output_filename
|
||||
|
||||
# 모든 언어 검사 시에만 언어별로 구분하여 표시
|
||||
if check_all_languages:
|
||||
logger.separator()
|
||||
logger.info(f'🔍 {lang} 처리 중...')
|
||||
|
||||
# 추출 실행
|
||||
count = po_handler.extract_untranslated(po_path, output_path, include_fuzzy)
|
||||
|
||||
if count > 0:
|
||||
total_extracted += count
|
||||
extracted_files.append((lang, output_path, count))
|
||||
if check_all_languages:
|
||||
logger.success(f' ✅ {lang}: {count}건 추출')
|
||||
else:
|
||||
if check_all_languages:
|
||||
logger.success(f' ✅ {lang}: 모든 번역 완료')
|
||||
|
||||
# 최종 결과
|
||||
logger.separator()
|
||||
|
||||
if total_extracted > 0:
|
||||
if check_all_languages:
|
||||
# 모든 언어 검사: 언어별 상세 표시
|
||||
logger.success(f'\n✅ 총 {total_extracted}건의 미번역 항목 추출 완료')
|
||||
logger.info('\n📄 생성된 파일:')
|
||||
for lang, file_path, count in extracted_files:
|
||||
logger.info(f' - {file_path.name} ({lang}: {count}건)')
|
||||
else:
|
||||
# en만 검사: 간단한 메시지
|
||||
logger.success(f'\n✅ 미번역 항목 {total_extracted}건 추출 완료')
|
||||
logger.info(f'📄 출력 파일: {extracted_files[0][1]}')
|
||||
else:
|
||||
if check_all_languages:
|
||||
logger.success(f'\n✅ 모든 언어의 번역이 완료되어 있습니다!')
|
||||
else:
|
||||
logger.success(f'\n✅ 모든 텍스트가 번역되어 있습니다!')
|
||||
logger.info('미번역 항목이 없습니다.')
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def command_validate(args, config, logger):
|
||||
"""번역 검증"""
|
||||
logger.section('번역 검증', Icons.SEARCH)
|
||||
|
||||
# 입력 파일 결정
|
||||
if args.input:
|
||||
input_path = Path(args.input)
|
||||
else:
|
||||
# config에서 기본 입력 파일 가져오기
|
||||
base_dir = config.base_dir
|
||||
input_filename = config.get('files.translation_input', '번역업데이트.tsv')
|
||||
input_path = base_dir / input_filename
|
||||
|
||||
if not input_path.exists():
|
||||
logger.error(f'입력 파일을 찾을 수 없습니다: {input_path}')
|
||||
return False
|
||||
|
||||
logger.info(f'입력 파일: {input_path}')
|
||||
|
||||
# TSV 파일 읽기
|
||||
import csv
|
||||
|
||||
entries = []
|
||||
try:
|
||||
with open(input_path, 'r', encoding='utf-8-sig') as f:
|
||||
reader = csv.DictReader(f, delimiter='\t')
|
||||
|
||||
# 제외할 컬럼
|
||||
exclude_columns = {'msgctxt', 'SourceLocation', 'msgid'}
|
||||
lang_codes = [col for col in reader.fieldnames if col not in exclude_columns]
|
||||
|
||||
# 원본 언어 (ko가 source language)
|
||||
source_lang = config.get('languages.source', 'ko')
|
||||
|
||||
for row in reader:
|
||||
msgctxt = row.get('msgctxt', '')
|
||||
|
||||
# msgid 컬럼이 있으면 사용, 없으면 source language(ko) 사용
|
||||
msgid = row.get('msgid', '') or row.get(source_lang, '')
|
||||
|
||||
for lang in lang_codes:
|
||||
# source language는 원본이므로 검증 스킵
|
||||
if lang == source_lang:
|
||||
continue
|
||||
|
||||
msgstr = row.get(lang, '')
|
||||
if msgstr: # 번역문이 있는 경우만 검증
|
||||
entries.append({
|
||||
'msgctxt': msgctxt,
|
||||
'msgid': msgid,
|
||||
'msgstr': msgstr,
|
||||
'lang': lang
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'파일 읽기 실패: {e}')
|
||||
return False
|
||||
|
||||
if not entries:
|
||||
logger.warning('검증할 번역 항목이 없습니다.')
|
||||
return False
|
||||
|
||||
logger.info(f'총 {len(entries)}개 번역 항목 검증 중...')
|
||||
|
||||
# 검증 실행
|
||||
validator = TranslationValidator(config.data)
|
||||
issues, stats = validator.validate_batch(entries)
|
||||
|
||||
# 결과 출력
|
||||
validator.print_validation_report(issues, stats, logger)
|
||||
|
||||
# 검증 실패 시 오류 코드 반환
|
||||
if stats['errors'] > 0:
|
||||
if config.get('validation.stop_on_validation_error', False):
|
||||
logger.error('\n검증 실패로 인해 작업을 중단합니다.')
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def command_update(args, config, logger):
|
||||
"""PO 파일 업데이트"""
|
||||
logger.section('PO 파일 업데이트', Icons.GEAR)
|
||||
|
||||
# 입력 파일 결정
|
||||
if args.input:
|
||||
input_path = Path(args.input)
|
||||
else:
|
||||
base_dir = config.base_dir
|
||||
input_filename = config.get('files.translation_input', '번역업데이트.tsv')
|
||||
input_path = base_dir / input_filename
|
||||
|
||||
if not input_path.exists():
|
||||
logger.error(f'입력 파일을 찾을 수 없습니다: {input_path}')
|
||||
return False
|
||||
|
||||
logger.info(f'입력 파일: {input_path}')
|
||||
|
||||
# 언리얼 현지화 폴더
|
||||
unreal_loc = config.get_path('paths.unreal_localization')
|
||||
if not unreal_loc.exists():
|
||||
logger.error(f'언리얼 현지화 폴더를 찾을 수 없습니다: {unreal_loc}')
|
||||
return False
|
||||
|
||||
logger.info(f'언리얼 현지화 폴더: {unreal_loc}')
|
||||
|
||||
# 설정
|
||||
backup = config.get('backup.auto_backup_before_update', True)
|
||||
dry_run = args.dry_run if hasattr(args, 'dry_run') else False
|
||||
|
||||
if dry_run:
|
||||
logger.warning('⚠️ DRY-RUN 모드: 실제 파일 수정 없이 시뮬레이션만 수행합니다.')
|
||||
|
||||
# 업데이트 실행
|
||||
po_handler = POHandler(config.data, logger)
|
||||
results = po_handler.update_from_tsv(input_path, unreal_loc, backup, dry_run)
|
||||
|
||||
if not results:
|
||||
logger.error('업데이트 실패')
|
||||
return False
|
||||
|
||||
# 전체 통계
|
||||
total_updated = sum(r.updated for r in results.values())
|
||||
total_failed = sum(r.failed for r in results.values())
|
||||
total_skipped = sum(r.skipped for r in results.values())
|
||||
|
||||
logger.separator()
|
||||
logger.section('📊 전체 업데이트 결과')
|
||||
logger.stats(
|
||||
**{
|
||||
'업데이트됨': total_updated,
|
||||
'스킵됨': total_skipped,
|
||||
'실패': total_failed,
|
||||
'처리 언어': len(results)
|
||||
}
|
||||
)
|
||||
|
||||
if total_failed > 0:
|
||||
logger.warning(f'\n⚠️ {total_failed}건의 업데이트 실패가 발생했습니다.')
|
||||
return False
|
||||
|
||||
logger.success(f'\n✅ PO 파일 업데이트 완료!')
|
||||
return True
|
||||
|
||||
|
||||
def command_merge(args, config, logger):
|
||||
"""CSV 병합"""
|
||||
logger.section('PO 파일 → CSV 병합', Icons.FILE)
|
||||
|
||||
# 언리얼 현지화 폴더
|
||||
unreal_loc = config.get_path('paths.unreal_localization')
|
||||
if not unreal_loc.exists():
|
||||
logger.error(f'언리얼 현지화 폴더를 찾을 수 없습니다: {unreal_loc}')
|
||||
return False
|
||||
|
||||
logger.info(f'언리얼 현지화 폴더: {unreal_loc}')
|
||||
|
||||
# 출력 파일 경로
|
||||
output_dir = unreal_loc # LocalExport 폴더에 저장
|
||||
timestamp = get_timestamp()
|
||||
pattern = config.get('files.merged_pattern', 'merged_po_entries_{timestamp}.csv')
|
||||
output_filename = pattern.format(timestamp=timestamp)
|
||||
output_path = output_dir / output_filename
|
||||
|
||||
# 병합 실행
|
||||
po_handler = POHandler(config.data, logger)
|
||||
count = po_handler.merge_to_csv(unreal_loc, output_path)
|
||||
|
||||
if count > 0:
|
||||
logger.success(f'\n✅ CSV 병합 완료: {count}개 항목')
|
||||
logger.info(f'📄 출력 파일: {output_path}')
|
||||
return True
|
||||
else:
|
||||
logger.error('병합 실패')
|
||||
return False
|
||||
|
||||
|
||||
def command_cleanup(args, config, logger):
|
||||
"""파일 정리"""
|
||||
logger.section('파일 정리', Icons.CLEAN)
|
||||
|
||||
file_manager = FileManager(config.data, logger)
|
||||
|
||||
# 출력 폴더 정리
|
||||
output_dir = config.get_path('paths.output_dir')
|
||||
if output_dir.exists():
|
||||
archived, _ = file_manager.cleanup_old_files(output_dir)
|
||||
|
||||
# 언리얼 현지화 폴더의 CSV 파일 정리
|
||||
unreal_loc = config.get_path('paths.unreal_localization')
|
||||
if unreal_loc.exists():
|
||||
archived2, _ = file_manager.cleanup_old_files(unreal_loc)
|
||||
|
||||
# 백업 파일 정리
|
||||
if unreal_loc.exists():
|
||||
deleted_backups = file_manager.delete_old_backups(unreal_loc)
|
||||
|
||||
logger.success('\n✅ 파일 정리 완료')
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
"""메인 함수"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description='DS_L10N - 던전 스토커즈 현지화 통합 툴',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter
|
||||
)
|
||||
|
||||
parser.add_argument('--config', type=str, help='설정 파일 경로 (기본: config.yaml)')
|
||||
|
||||
subparsers = parser.add_subparsers(dest='command', help='명령어')
|
||||
|
||||
# extract 명령어
|
||||
parser_extract = subparsers.add_parser('extract', help='미번역 항목 추출')
|
||||
parser_extract.add_argument('--gui', action='store_true', help='GUI 모드로 실행')
|
||||
parser_extract.add_argument('--include-fuzzy', action='store_true', dest='include_fuzzy',
|
||||
help='fuzzy 플래그 항목 포함 (원본 변경으로 리뷰 필요한 항목)')
|
||||
parser_extract.add_argument('--all-languages', action='store_true', dest='all_languages',
|
||||
help='모든 대상 언어 검사 (기본: en만 검사)')
|
||||
|
||||
# validate 명령어
|
||||
parser_validate = subparsers.add_parser('validate', help='번역 검증')
|
||||
parser_validate.add_argument('input', nargs='?', help='입력 TSV 파일 (기본: 번역업데이트.tsv)')
|
||||
|
||||
# update 명령어
|
||||
parser_update = subparsers.add_parser('update', help='PO 파일 업데이트')
|
||||
parser_update.add_argument('input', nargs='?', help='입력 TSV 파일 (기본: 번역업데이트.tsv)')
|
||||
parser_update.add_argument('--dry-run', action='store_true', help='시뮬레이션 모드 (실제 수정 안함)')
|
||||
|
||||
# merge 명령어
|
||||
parser_merge = subparsers.add_parser('merge', help='PO 파일을 CSV로 병합')
|
||||
|
||||
# cleanup 명령어
|
||||
parser_cleanup = subparsers.add_parser('cleanup', help='오래된 파일 정리')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# 명령어가 없으면 도움말 출력
|
||||
if not args.command:
|
||||
parser.print_help()
|
||||
return 0
|
||||
|
||||
try:
|
||||
# 설정 로드
|
||||
config_path = Path(args.config) if args.config else None
|
||||
config = load_config(config_path)
|
||||
|
||||
# 로거 초기화
|
||||
logs_dir = config.get_path('paths.logs_dir')
|
||||
logs_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
timestamp = get_timestamp()
|
||||
log_pattern = config.get('files.log_pattern', 'workflow_{timestamp}.log')
|
||||
log_filename = log_pattern.format(timestamp=timestamp)
|
||||
log_file = logs_dir / log_filename
|
||||
|
||||
logger = init_logger(config.data, log_file)
|
||||
|
||||
logger.section(f'DS_L10N - {args.command.upper()}', Icons.ROCKET)
|
||||
logger.info(f'시작 시간: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}')
|
||||
logger.info(f'설정 파일: {config.config_path}')
|
||||
logger.info(f'로그 파일: {log_file}')
|
||||
logger.separator()
|
||||
|
||||
# 필요한 디렉토리 생성
|
||||
file_manager = FileManager(config.data, logger)
|
||||
file_manager.ensure_directories()
|
||||
|
||||
# 명령어 실행
|
||||
commands = {
|
||||
'extract': command_extract,
|
||||
'validate': command_validate,
|
||||
'update': command_update,
|
||||
'merge': command_merge,
|
||||
'cleanup': command_cleanup,
|
||||
}
|
||||
|
||||
command_func = commands.get(args.command)
|
||||
if command_func:
|
||||
success = command_func(args, config, logger)
|
||||
|
||||
logger.separator()
|
||||
logger.info(f'종료 시간: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}')
|
||||
|
||||
if success:
|
||||
logger.success(f'\n✅ {args.command} 작업 완료!')
|
||||
return 0
|
||||
else:
|
||||
logger.error(f'\n❌ {args.command} 작업 실패')
|
||||
return 1
|
||||
else:
|
||||
logger.error(f'알 수 없는 명령어: {args.command}')
|
||||
return 1
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print('\n\n사용자에 의해 중단되었습니다.')
|
||||
return 130
|
||||
|
||||
except Exception as e:
|
||||
print(f'\n오류 발생: {e}')
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
1
lib/__init__.py
Normal file
1
lib/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# DS_L10N Library Modules
|
||||
BIN
lib/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
lib/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
lib/__pycache__/config_loader.cpython-313.pyc
Normal file
BIN
lib/__pycache__/config_loader.cpython-313.pyc
Normal file
Binary file not shown.
BIN
lib/__pycache__/file_manager.cpython-313.pyc
Normal file
BIN
lib/__pycache__/file_manager.cpython-313.pyc
Normal file
Binary file not shown.
BIN
lib/__pycache__/logger.cpython-313.pyc
Normal file
BIN
lib/__pycache__/logger.cpython-313.pyc
Normal file
Binary file not shown.
BIN
lib/__pycache__/po_handler.cpython-313.pyc
Normal file
BIN
lib/__pycache__/po_handler.cpython-313.pyc
Normal file
Binary file not shown.
BIN
lib/__pycache__/validator.cpython-313.pyc
Normal file
BIN
lib/__pycache__/validator.cpython-313.pyc
Normal file
Binary file not shown.
87
lib/config_loader.py
Normal file
87
lib/config_loader.py
Normal file
@ -0,0 +1,87 @@
|
||||
"""
|
||||
Configuration Loader for DS_L10N
|
||||
YAML 설정 파일 로더
|
||||
"""
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
|
||||
|
||||
class Config:
|
||||
"""설정 관리 클래스"""
|
||||
|
||||
def __init__(self, config_path: Path):
|
||||
self.config_path = config_path
|
||||
self.data: Dict[str, Any] = {}
|
||||
self.base_dir = config_path.parent
|
||||
self.load()
|
||||
|
||||
def load(self):
|
||||
"""YAML 설정 파일 로드"""
|
||||
if not self.config_path.exists():
|
||||
raise FileNotFoundError(f"설정 파일을 찾을 수 없습니다: {self.config_path}")
|
||||
|
||||
with open(self.config_path, 'r', encoding='utf-8') as f:
|
||||
self.data = yaml.safe_load(f)
|
||||
|
||||
def get(self, key: str, default=None):
|
||||
"""설정 값 가져오기 (점 표기법 지원)"""
|
||||
keys = key.split('.')
|
||||
value = self.data
|
||||
|
||||
for k in keys:
|
||||
if isinstance(value, dict):
|
||||
value = value.get(k)
|
||||
else:
|
||||
return default
|
||||
|
||||
if value is None:
|
||||
return default
|
||||
|
||||
return value
|
||||
|
||||
def get_path(self, key: str, default=None) -> Path:
|
||||
"""경로 설정 가져오기 (상대 경로를 절대 경로로 변환)"""
|
||||
value = self.get(key, default)
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
path = Path(value)
|
||||
|
||||
# 상대 경로면 base_dir 기준으로 절대 경로 변환
|
||||
if not path.is_absolute():
|
||||
path = (self.base_dir / path).resolve()
|
||||
|
||||
return path
|
||||
|
||||
def get_all_paths(self) -> Dict[str, Path]:
|
||||
"""모든 경로 설정 가져오기"""
|
||||
paths_config = self.get('paths', {})
|
||||
return {key: self.get_path(f'paths.{key}') for key in paths_config.keys()}
|
||||
|
||||
def get_languages(self) -> Dict[str, Any]:
|
||||
"""언어 설정 가져오기"""
|
||||
return {
|
||||
'source': self.get('languages.source', 'ko'),
|
||||
'targets': self.get('languages.targets', [])
|
||||
}
|
||||
|
||||
def get_validation_config(self) -> Dict[str, bool]:
|
||||
"""검증 설정 가져오기"""
|
||||
return self.get('validation', {})
|
||||
|
||||
def __getitem__(self, key: str):
|
||||
"""딕셔너리 스타일 접근"""
|
||||
return self.get(key)
|
||||
|
||||
def __repr__(self):
|
||||
return f"Config(config_path={self.config_path})"
|
||||
|
||||
|
||||
def load_config(config_path: Path = None) -> Config:
|
||||
"""설정 파일 로드"""
|
||||
if config_path is None:
|
||||
# 현재 스크립트 위치 기준으로 config.yaml 찾기
|
||||
config_path = Path(__file__).parent.parent / 'config.yaml'
|
||||
|
||||
return Config(config_path)
|
||||
161
lib/file_manager.py
Normal file
161
lib/file_manager.py
Normal file
@ -0,0 +1,161 @@
|
||||
"""
|
||||
File Manager for DS_L10N
|
||||
파일 자동 관리 및 정리
|
||||
"""
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Tuple
|
||||
|
||||
|
||||
class FileManager:
|
||||
"""파일 관리자"""
|
||||
|
||||
def __init__(self, config: dict, logger):
|
||||
self.config = config
|
||||
self.logger = logger
|
||||
self.cleanup_config = config.get('cleanup', {})
|
||||
|
||||
def cleanup_old_files(self, target_dir: Path) -> Tuple[int, int]:
|
||||
"""
|
||||
오래된 파일 정리
|
||||
|
||||
Returns:
|
||||
(archived_count, deleted_count)
|
||||
"""
|
||||
if not self.cleanup_config.get('auto_archive', True):
|
||||
self.logger.info('자동 정리가 비활성화되어 있습니다.')
|
||||
return 0, 0
|
||||
|
||||
keep_recent = self.cleanup_config.get('keep_recent_files', 5)
|
||||
archive_patterns = self.cleanup_config.get('archive_patterns', [])
|
||||
archive_dir = Path(self.config.get('paths', {}).get('archive_dir', './archive'))
|
||||
|
||||
archived_count = 0
|
||||
|
||||
self.logger.info(f'파일 정리 시작: {target_dir}')
|
||||
self.logger.info(f'최근 {keep_recent}개 파일 유지, 나머지는 보관')
|
||||
|
||||
for pattern in archive_patterns:
|
||||
files = sorted(target_dir.glob(pattern), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
|
||||
if len(files) <= keep_recent:
|
||||
continue
|
||||
|
||||
# 오래된 파일들
|
||||
old_files = files[keep_recent:]
|
||||
|
||||
for file_path in old_files:
|
||||
archived = self._archive_file(file_path, archive_dir)
|
||||
if archived:
|
||||
archived_count += 1
|
||||
|
||||
# 오래된 로그 파일 삭제
|
||||
logs_dir = Path(self.config.get('paths', {}).get('logs_dir', './logs'))
|
||||
deleted_count = self._delete_old_logs(logs_dir)
|
||||
|
||||
if archived_count > 0:
|
||||
self.logger.success(f'✅ {archived_count}개 파일 보관 완료')
|
||||
|
||||
if deleted_count > 0:
|
||||
self.logger.success(f'✅ {deleted_count}개 오래된 로그 파일 삭제')
|
||||
|
||||
return archived_count, deleted_count
|
||||
|
||||
def _archive_file(self, file_path: Path, archive_dir: Path) -> bool:
|
||||
"""파일을 archive 폴더로 이동"""
|
||||
try:
|
||||
archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 날짜별 하위 폴더
|
||||
date_folder = archive_dir / datetime.now().strftime('%Y%m')
|
||||
date_folder.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 대상 경로
|
||||
dest_path = date_folder / file_path.name
|
||||
|
||||
# 이미 존재하면 타임스탬프 추가
|
||||
if dest_path.exists():
|
||||
timestamp = datetime.now().strftime('%H%M%S')
|
||||
dest_path = date_folder / f"{file_path.stem}_{timestamp}{file_path.suffix}"
|
||||
|
||||
shutil.move(str(file_path), str(dest_path))
|
||||
self.logger.info(f' 📦 보관: {file_path.name} → {dest_path.relative_to(archive_dir)}')
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f'파일 보관 실패: {file_path.name} - {e}')
|
||||
return False
|
||||
|
||||
def _delete_old_logs(self, logs_dir: Path) -> int:
|
||||
"""오래된 로그 파일 삭제"""
|
||||
if not logs_dir.exists():
|
||||
return 0
|
||||
|
||||
delete_days = self.cleanup_config.get('delete_old_logs_days', 30)
|
||||
cutoff_date = datetime.now() - timedelta(days=delete_days)
|
||||
|
||||
deleted_count = 0
|
||||
|
||||
for log_file in logs_dir.glob('*.log'):
|
||||
try:
|
||||
mtime = datetime.fromtimestamp(log_file.stat().st_mtime)
|
||||
|
||||
if mtime < cutoff_date:
|
||||
log_file.unlink()
|
||||
self.logger.debug(f' 🗑️ 삭제: {log_file.name}')
|
||||
deleted_count += 1
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f'로그 파일 삭제 실패: {log_file.name} - {e}')
|
||||
|
||||
return deleted_count
|
||||
|
||||
def delete_old_backups(self, localization_root: Path) -> int:
|
||||
"""오래된 백업 파일 삭제"""
|
||||
keep_days = self.config.get('backup', {}).get('keep_backups_days', 7)
|
||||
cutoff_date = datetime.now() - timedelta(days=keep_days)
|
||||
|
||||
deleted_count = 0
|
||||
|
||||
self.logger.info(f'백업 파일 정리 중 ({keep_days}일 이상 된 파일 삭제)...')
|
||||
|
||||
for lang_folder in localization_root.iterdir():
|
||||
if not lang_folder.is_dir():
|
||||
continue
|
||||
|
||||
for backup_file in lang_folder.glob('*.backup_*.po'):
|
||||
try:
|
||||
mtime = datetime.fromtimestamp(backup_file.stat().st_mtime)
|
||||
|
||||
if mtime < cutoff_date:
|
||||
backup_file.unlink()
|
||||
self.logger.debug(f' 🗑️ 백업 삭제: {backup_file.name}')
|
||||
deleted_count += 1
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f'백업 파일 삭제 실패: {backup_file.name} - {e}')
|
||||
|
||||
if deleted_count > 0:
|
||||
self.logger.success(f'✅ {deleted_count}개 백업 파일 삭제 완료')
|
||||
|
||||
return deleted_count
|
||||
|
||||
def ensure_directories(self):
|
||||
"""필요한 디렉토리 생성"""
|
||||
paths = [
|
||||
Path(self.config.get('paths', {}).get('output_dir', './output')),
|
||||
Path(self.config.get('paths', {}).get('logs_dir', './logs')),
|
||||
Path(self.config.get('paths', {}).get('archive_dir', './archive')),
|
||||
Path(self.config.get('paths', {}).get('temp_dir', './temp')),
|
||||
]
|
||||
|
||||
for path in paths:
|
||||
if not path.is_absolute():
|
||||
# 상대 경로를 절대 경로로 변환
|
||||
base_dir = Path(__file__).parent.parent
|
||||
path = (base_dir / path).resolve()
|
||||
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
self.logger.debug(f'디렉토리 확인: {path}')
|
||||
209
lib/logger.py
Normal file
209
lib/logger.py
Normal file
@ -0,0 +1,209 @@
|
||||
"""
|
||||
Colored Console Logger for DS_L10N
|
||||
컬러 콘솔 로깅 시스템
|
||||
"""
|
||||
import logging
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# Windows에서 ANSI 컬러 지원 활성화
|
||||
if sys.platform == 'win32':
|
||||
import os
|
||||
os.system('') # ANSI escape sequences 활성화
|
||||
|
||||
# UTF-8 콘솔 출력 설정
|
||||
try:
|
||||
import ctypes
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
kernel32.SetConsoleCP(65001) # UTF-8 input
|
||||
kernel32.SetConsoleOutputCP(65001) # UTF-8 output
|
||||
except:
|
||||
pass
|
||||
|
||||
# ANSI 컬러 코드
|
||||
class Colors:
|
||||
RESET = '\033[0m'
|
||||
BOLD = '\033[1m'
|
||||
|
||||
# 기본 색상
|
||||
BLACK = '\033[30m'
|
||||
RED = '\033[31m'
|
||||
GREEN = '\033[32m'
|
||||
YELLOW = '\033[33m'
|
||||
BLUE = '\033[34m'
|
||||
MAGENTA = '\033[35m'
|
||||
CYAN = '\033[36m'
|
||||
WHITE = '\033[37m'
|
||||
|
||||
# 밝은 색상
|
||||
BRIGHT_RED = '\033[91m'
|
||||
BRIGHT_GREEN = '\033[92m'
|
||||
BRIGHT_YELLOW = '\033[93m'
|
||||
BRIGHT_BLUE = '\033[94m'
|
||||
BRIGHT_MAGENTA = '\033[95m'
|
||||
BRIGHT_CYAN = '\033[96m'
|
||||
BRIGHT_WHITE = '\033[97m'
|
||||
|
||||
# 이모지 아이콘
|
||||
class Icons:
|
||||
SUCCESS = '✅'
|
||||
ERROR = '❌'
|
||||
WARNING = '⚠️'
|
||||
INFO = 'ℹ️'
|
||||
ROCKET = '🚀'
|
||||
GEAR = '⚙️'
|
||||
FILE = '📄'
|
||||
FOLDER = '📁'
|
||||
CHART = '📊'
|
||||
SEARCH = '🔍'
|
||||
CLEAN = '🧹'
|
||||
BACKUP = '💾'
|
||||
CLOCK = '⏱️'
|
||||
|
||||
|
||||
class ColoredFormatter(logging.Formatter):
|
||||
"""컬러 로그 포매터"""
|
||||
|
||||
LEVEL_COLORS = {
|
||||
'DEBUG': Colors.CYAN,
|
||||
'INFO': Colors.BLUE,
|
||||
'WARNING': Colors.YELLOW,
|
||||
'ERROR': Colors.RED,
|
||||
'CRITICAL': Colors.BRIGHT_RED + Colors.BOLD,
|
||||
'SUCCESS': Colors.GREEN,
|
||||
}
|
||||
|
||||
LEVEL_ICONS = {
|
||||
'DEBUG': Icons.GEAR,
|
||||
'INFO': Icons.INFO,
|
||||
'WARNING': Icons.WARNING,
|
||||
'ERROR': Icons.ERROR,
|
||||
'CRITICAL': Icons.ERROR,
|
||||
'SUCCESS': Icons.SUCCESS,
|
||||
}
|
||||
|
||||
def __init__(self, colored=True):
|
||||
super().__init__()
|
||||
self.colored = colored
|
||||
|
||||
def format(self, record):
|
||||
if self.colored:
|
||||
level_color = self.LEVEL_COLORS.get(record.levelname, '')
|
||||
level_icon = self.LEVEL_ICONS.get(record.levelname, '')
|
||||
reset = Colors.RESET
|
||||
|
||||
# 타임스탬프
|
||||
timestamp = datetime.fromtimestamp(record.created).strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
# 레벨명 포맷
|
||||
level_name = f"{level_color}{record.levelname:<8}{reset}"
|
||||
|
||||
# 메시지
|
||||
message = record.getMessage()
|
||||
|
||||
return f"[{timestamp}] {level_icon} {level_name} {message}"
|
||||
else:
|
||||
timestamp = datetime.fromtimestamp(record.created).strftime('%Y-%m-%d %H:%M:%S')
|
||||
return f"[{timestamp}] [{record.levelname:<8}] {record.getMessage()}"
|
||||
|
||||
|
||||
class DSLogger:
|
||||
"""DS_L10N 전용 로거"""
|
||||
|
||||
def __init__(self, name: str = 'DS_L10N',
|
||||
console_level: str = 'INFO',
|
||||
file_level: str = 'DEBUG',
|
||||
log_file: Optional[Path] = None,
|
||||
colored: bool = True):
|
||||
|
||||
self.logger = logging.getLogger(name)
|
||||
self.logger.setLevel(logging.DEBUG)
|
||||
self.logger.handlers.clear() # 기존 핸들러 제거
|
||||
|
||||
# 콘솔 핸들러 (UTF-8 강제)
|
||||
if sys.platform == 'win32':
|
||||
# Windows에서 UTF-8 출력 스트림 사용
|
||||
import io
|
||||
console_stream = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', line_buffering=True)
|
||||
else:
|
||||
console_stream = sys.stdout
|
||||
|
||||
console_handler = logging.StreamHandler(console_stream)
|
||||
console_handler.setLevel(getattr(logging, console_level.upper()))
|
||||
console_handler.setFormatter(ColoredFormatter(colored=colored))
|
||||
self.logger.addHandler(console_handler)
|
||||
|
||||
# 파일 핸들러
|
||||
if log_file:
|
||||
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
file_handler = logging.FileHandler(log_file, encoding='utf-8')
|
||||
file_handler.setLevel(getattr(logging, file_level.upper()))
|
||||
file_handler.setFormatter(ColoredFormatter(colored=False))
|
||||
self.logger.addHandler(file_handler)
|
||||
|
||||
# SUCCESS 레벨 추가 (INFO와 WARNING 사이)
|
||||
logging.addLevelName(25, 'SUCCESS')
|
||||
|
||||
def debug(self, message: str):
|
||||
self.logger.debug(message)
|
||||
|
||||
def info(self, message: str):
|
||||
self.logger.info(message)
|
||||
|
||||
def success(self, message: str):
|
||||
self.logger.log(25, message)
|
||||
|
||||
def warning(self, message: str):
|
||||
self.logger.warning(message)
|
||||
|
||||
def error(self, message: str):
|
||||
self.logger.error(message)
|
||||
|
||||
def critical(self, message: str):
|
||||
self.logger.critical(message)
|
||||
|
||||
def separator(self, char: str = '=', length: int = 80):
|
||||
"""구분선 출력"""
|
||||
self.logger.info(char * length)
|
||||
|
||||
def section(self, title: str, icon: str = Icons.ROCKET):
|
||||
"""섹션 제목 출력"""
|
||||
self.separator()
|
||||
self.logger.info(f"{icon} {title}")
|
||||
self.separator()
|
||||
|
||||
def stats(self, **kwargs):
|
||||
"""통계 정보 출력"""
|
||||
self.logger.info(f"{Icons.CHART} 통계:")
|
||||
for key, value in kwargs.items():
|
||||
self.logger.info(f" - {key}: {value}")
|
||||
|
||||
|
||||
# 전역 로거 인스턴스
|
||||
_global_logger: Optional[DSLogger] = None
|
||||
|
||||
|
||||
def get_logger() -> DSLogger:
|
||||
"""전역 로거 가져오기"""
|
||||
global _global_logger
|
||||
if _global_logger is None:
|
||||
_global_logger = DSLogger()
|
||||
return _global_logger
|
||||
|
||||
|
||||
def init_logger(config: dict, log_file: Optional[Path] = None) -> DSLogger:
|
||||
"""로거 초기화"""
|
||||
global _global_logger
|
||||
|
||||
logging_config = config.get('logging', {})
|
||||
|
||||
_global_logger = DSLogger(
|
||||
console_level=logging_config.get('console_level', 'INFO'),
|
||||
file_level=logging_config.get('file_level', 'DEBUG'),
|
||||
log_file=log_file,
|
||||
colored=logging_config.get('colored_output', True)
|
||||
)
|
||||
|
||||
return _global_logger
|
||||
403
lib/po_handler.py
Normal file
403
lib/po_handler.py
Normal file
@ -0,0 +1,403 @@
|
||||
"""
|
||||
PO File Handler for DS_L10N
|
||||
polib 기반의 안정적인 PO 파일 처리
|
||||
"""
|
||||
import polib
|
||||
import csv
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Tuple, Optional
|
||||
from datetime import datetime
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class POUpdateResult:
|
||||
"""PO 업데이트 결과"""
|
||||
total: int
|
||||
updated: int
|
||||
failed: int
|
||||
skipped: int
|
||||
errors: List[Tuple[str, str]] # (msgctxt, error_message)
|
||||
|
||||
|
||||
class POHandler:
|
||||
"""PO 파일 핸들러"""
|
||||
|
||||
def __init__(self, config: dict, logger):
|
||||
self.config = config
|
||||
self.logger = logger
|
||||
self.po_filename = config.get('files', {}).get('po_filename', 'LocalExport.po')
|
||||
|
||||
def load_po_file(self, po_path: Path) -> Optional[polib.POFile]:
|
||||
"""PO 파일 로드"""
|
||||
try:
|
||||
if not po_path.exists():
|
||||
self.logger.error(f'PO 파일을 찾을 수 없습니다: {po_path}')
|
||||
return None
|
||||
|
||||
po = polib.pofile(str(po_path), encoding='utf-8')
|
||||
return po
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f'PO 파일 로드 실패: {po_path} - {e}')
|
||||
return None
|
||||
|
||||
def extract_untranslated(self, po_path: Path, output_path: Path, include_fuzzy: bool = False) -> int:
|
||||
"""
|
||||
미번역 항목 추출
|
||||
|
||||
Args:
|
||||
po_path: PO 파일 경로
|
||||
output_path: 출력 TSV 파일 경로
|
||||
include_fuzzy: fuzzy 플래그 항목 포함 여부 (원본 변경으로 리뷰 필요한 항목)
|
||||
|
||||
Returns:
|
||||
추출된 항목 개수
|
||||
"""
|
||||
self.logger.info(f'PO 파일 로드 중: {po_path.name}')
|
||||
po = self.load_po_file(po_path)
|
||||
|
||||
if po is None:
|
||||
return 0
|
||||
|
||||
# 전체 항목 및 미번역 항목 필터링
|
||||
total_entries = len([entry for entry in po if entry.msgid]) # msgid가 있는 항목만 카운트
|
||||
|
||||
# 미번역 항목: msgstr이 비어있는 항목
|
||||
untranslated = [entry for entry in po if not entry.msgstr.strip()]
|
||||
|
||||
# fuzzy 항목: fuzzy 플래그가 있는 항목 (원본 변경으로 리뷰 필요)
|
||||
fuzzy_entries = []
|
||||
if include_fuzzy:
|
||||
fuzzy_entries = [entry for entry in po if 'fuzzy' in entry.flags and entry.msgstr.strip()]
|
||||
|
||||
# 통합 리스트
|
||||
all_entries = untranslated + fuzzy_entries
|
||||
|
||||
if not all_entries:
|
||||
translated_count = total_entries
|
||||
self.logger.info(f'전체 {total_entries}개 항목 중 {translated_count}개 번역 완료 (100%)')
|
||||
return 0
|
||||
|
||||
# 통계 출력
|
||||
if include_fuzzy and fuzzy_entries:
|
||||
self.logger.info(f'전체 {total_entries}개 항목 중:')
|
||||
self.logger.info(f' - 미번역 항목: {len(untranslated)}건')
|
||||
self.logger.info(f' - 리뷰 필요 (fuzzy): {len(fuzzy_entries)}건')
|
||||
self.logger.info(f' - 총 추출 항목: {len(all_entries)}건')
|
||||
else:
|
||||
self.logger.info(f'전체 {total_entries}개 항목 중 미번역 {len(all_entries)}건 발견')
|
||||
|
||||
# TSV 파일로 저장
|
||||
self._save_to_tsv(all_entries, output_path)
|
||||
|
||||
self.logger.success(f'미번역 항목 추출 완료: {output_path}')
|
||||
return len(all_entries)
|
||||
|
||||
def merge_to_csv(self, localization_root: Path, output_path: Path) -> int:
|
||||
"""
|
||||
여러 언어의 PO 파일을 하나의 CSV로 병합
|
||||
|
||||
Returns:
|
||||
병합된 항목 개수
|
||||
"""
|
||||
self.logger.info(f'언어 폴더 탐색 중: {localization_root}')
|
||||
|
||||
# 언어 폴더 찾기
|
||||
lang_folders = []
|
||||
for item in localization_root.iterdir():
|
||||
if item.is_dir():
|
||||
po_file = item / self.po_filename
|
||||
if po_file.exists():
|
||||
lang_folders.append(item.name)
|
||||
|
||||
if not lang_folders:
|
||||
self.logger.error(f'{self.po_filename} 파일을 포함하는 언어 폴더를 찾을 수 없습니다.')
|
||||
return 0
|
||||
|
||||
self.logger.info(f'탐지된 언어: {", ".join(lang_folders)}')
|
||||
|
||||
# 각 언어별 PO 파일 파싱
|
||||
merged_data = {}
|
||||
|
||||
for lang_code in lang_folders:
|
||||
po_file_path = localization_root / lang_code / self.po_filename
|
||||
self.logger.info(f' - {lang_code} 처리 중...')
|
||||
|
||||
po = self.load_po_file(po_file_path)
|
||||
if po is None:
|
||||
continue
|
||||
|
||||
for entry in po:
|
||||
# msgctxt 추출 (언리얼 해시 키)
|
||||
msgctxt = entry.msgctxt if entry.msgctxt else 'NoContext'
|
||||
|
||||
# SourceLocation 추출
|
||||
source_location = entry.occurrences[0][0] if entry.occurrences else 'NoSourceLocation'
|
||||
|
||||
# 줄바꿈 문자를 문자열로 치환
|
||||
msgctxt_escaped = self._escape_newlines(msgctxt)
|
||||
msgid_escaped = self._escape_newlines(entry.msgid)
|
||||
msgstr_escaped = self._escape_newlines(entry.msgstr)
|
||||
source_location_escaped = self._escape_newlines(source_location)
|
||||
|
||||
# 키 생성 (msgctxt + SourceLocation)
|
||||
key = (msgctxt_escaped, source_location_escaped)
|
||||
|
||||
if key not in merged_data:
|
||||
merged_data[key] = {}
|
||||
|
||||
# 언어별 번역문 저장
|
||||
merged_data[key][lang_code] = msgstr_escaped
|
||||
|
||||
# CSV 레코드 생성
|
||||
records = []
|
||||
for (msgctxt, source_location), data in merged_data.items():
|
||||
record = {
|
||||
'msgctxt': msgctxt,
|
||||
'SourceLocation': source_location,
|
||||
}
|
||||
|
||||
# 언어별 번역 추가
|
||||
for key, value in data.items():
|
||||
record[key] = value
|
||||
|
||||
records.append(record)
|
||||
|
||||
if not records:
|
||||
self.logger.error('병합할 데이터가 없습니다.')
|
||||
return 0
|
||||
|
||||
# 언어 컬럼 정렬
|
||||
all_langs = set()
|
||||
for record in records:
|
||||
all_langs.update(record.keys())
|
||||
all_langs -= {'msgctxt', 'SourceLocation'}
|
||||
|
||||
# 선호 순서: ko를 맨 앞에, en을 두 번째로
|
||||
source_lang = self.config.get('languages', {}).get('source', 'ko')
|
||||
preferred_order = [source_lang] + self.config.get('languages', {}).get('targets', [])
|
||||
|
||||
ordered_langs = [lang for lang in preferred_order if lang in all_langs]
|
||||
other_langs = sorted([lang for lang in all_langs if lang not in preferred_order])
|
||||
final_langs = ordered_langs + other_langs
|
||||
|
||||
# CSV 저장
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(output_path, 'w', newline='', encoding='utf-8-sig') as f:
|
||||
fieldnames = ['msgctxt', 'SourceLocation'] + final_langs
|
||||
writer = csv.DictWriter(f, fieldnames=fieldnames, quoting=csv.QUOTE_ALL)
|
||||
writer.writeheader()
|
||||
writer.writerows(records)
|
||||
|
||||
self.logger.success(f'CSV 병합 완료: {output_path}')
|
||||
self.logger.info(f'총 {len(records)}개 항목, {len(final_langs)}개 언어')
|
||||
|
||||
return len(records)
|
||||
|
||||
def update_from_tsv(self, tsv_path: Path, localization_root: Path,
|
||||
backup: bool = True, dry_run: bool = False) -> Dict[str, POUpdateResult]:
|
||||
"""
|
||||
TSV 파일로 PO 파일 업데이트 (polib 사용)
|
||||
|
||||
Args:
|
||||
tsv_path: 번역 TSV 파일 경로
|
||||
localization_root: 언어 폴더들의 루트
|
||||
backup: 백업 생성 여부
|
||||
dry_run: 실제 파일 수정 없이 시뮬레이션
|
||||
|
||||
Returns:
|
||||
언어별 업데이트 결과
|
||||
"""
|
||||
self.logger.info(f'TSV 파일 로드 중: {tsv_path}')
|
||||
|
||||
# TSV 파일 읽기
|
||||
translations_by_lang = self._load_tsv(tsv_path)
|
||||
|
||||
if not translations_by_lang:
|
||||
self.logger.error('TSV 파일에서 번역 데이터를 읽을 수 없습니다.')
|
||||
return {}
|
||||
|
||||
self.logger.info(f'업데이트 대상 언어: {", ".join(translations_by_lang.keys())}')
|
||||
|
||||
results = {}
|
||||
|
||||
# 언어별로 PO 파일 업데이트
|
||||
for lang_code, translations in translations_by_lang.items():
|
||||
self.logger.info(f'\n언어 처리 중: {lang_code}')
|
||||
|
||||
lang_folder = localization_root / lang_code
|
||||
if not lang_folder.is_dir():
|
||||
self.logger.warning(f' 언어 폴더를 찾을 수 없습니다: {lang_folder}')
|
||||
continue
|
||||
|
||||
po_path = lang_folder / self.po_filename
|
||||
if not po_path.exists():
|
||||
self.logger.warning(f' PO 파일을 찾을 수 없습니다: {po_path}')
|
||||
continue
|
||||
|
||||
# PO 파일 업데이트
|
||||
result = self._update_po_file(po_path, translations, backup, dry_run)
|
||||
results[lang_code] = result
|
||||
|
||||
# 결과 출력
|
||||
self._print_update_result(lang_code, result)
|
||||
|
||||
return results
|
||||
|
||||
def _update_po_file(self, po_path: Path, translations: Dict[str, str],
|
||||
backup: bool, dry_run: bool) -> POUpdateResult:
|
||||
"""단일 PO 파일 업데이트"""
|
||||
result = POUpdateResult(
|
||||
total=len(translations),
|
||||
updated=0,
|
||||
failed=0,
|
||||
skipped=0,
|
||||
errors=[]
|
||||
)
|
||||
|
||||
# 백업 생성
|
||||
if backup and not dry_run:
|
||||
backup_path = self._create_backup(po_path)
|
||||
if backup_path:
|
||||
self.logger.info(f' 백업 생성: {backup_path.name}')
|
||||
|
||||
# PO 파일 로드
|
||||
po = self.load_po_file(po_path)
|
||||
if po is None:
|
||||
result.failed = result.total
|
||||
result.errors.append(('ALL', 'PO 파일 로드 실패'))
|
||||
return result
|
||||
|
||||
# msgctxt로 인덱싱
|
||||
po_index = {}
|
||||
for entry in po:
|
||||
if entry.msgctxt:
|
||||
po_index[entry.msgctxt] = entry
|
||||
|
||||
# 번역문 업데이트
|
||||
for msgctxt, new_msgstr in translations.items():
|
||||
if msgctxt not in po_index:
|
||||
result.failed += 1
|
||||
result.errors.append((msgctxt, 'PO 파일에서 msgctxt를 찾을 수 없음'))
|
||||
continue
|
||||
|
||||
entry = po_index[msgctxt]
|
||||
current_msgstr = entry.msgstr
|
||||
|
||||
# 변경사항 없으면 스킵
|
||||
if current_msgstr == new_msgstr:
|
||||
result.skipped += 1
|
||||
continue
|
||||
|
||||
# msgstr 업데이트
|
||||
if not dry_run:
|
||||
entry.msgstr = new_msgstr
|
||||
|
||||
result.updated += 1
|
||||
|
||||
# 파일 저장
|
||||
if not dry_run and result.updated > 0:
|
||||
try:
|
||||
po.save(str(po_path))
|
||||
except Exception as e:
|
||||
self.logger.error(f' PO 파일 저장 실패: {e}')
|
||||
result.errors.append(('SAVE', str(e)))
|
||||
|
||||
return result
|
||||
|
||||
def _load_tsv(self, tsv_path: Path) -> Dict[str, Dict[str, str]]:
|
||||
"""TSV 파일 로드"""
|
||||
translations_by_lang = {}
|
||||
|
||||
try:
|
||||
with open(tsv_path, 'r', encoding='utf-8-sig') as f:
|
||||
reader = csv.DictReader(f, delimiter='\t')
|
||||
|
||||
# 컬럼 확인
|
||||
if not reader.fieldnames or len(reader.fieldnames) <= 1:
|
||||
self.logger.error('TSV 파일이 탭(tab)으로 구분되지 않았습니다.')
|
||||
return {}
|
||||
|
||||
# 제외할 컬럼
|
||||
exclude_columns = {'msgctxt', 'SourceLocation', 'msgid'}
|
||||
lang_codes = [col for col in reader.fieldnames if col not in exclude_columns]
|
||||
|
||||
# 언어별 딕셔너리 초기화
|
||||
for lang in lang_codes:
|
||||
translations_by_lang[lang] = {}
|
||||
|
||||
# 행 읽기
|
||||
for row in reader:
|
||||
msgctxt = row.get('msgctxt')
|
||||
if not msgctxt:
|
||||
continue
|
||||
|
||||
for lang in lang_codes:
|
||||
msgstr = row.get(lang, '')
|
||||
if msgstr: # 빈 문자열이 아니면 저장
|
||||
# TSV의 이스케이프된 줄바꿈을 실제 escape sequence로 변환
|
||||
msgstr = self._unescape_newlines(msgstr)
|
||||
translations_by_lang[lang][msgctxt] = msgstr
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f'TSV 파일 읽기 실패: {e}')
|
||||
return {}
|
||||
|
||||
return translations_by_lang
|
||||
|
||||
def _save_to_tsv(self, entries: List[polib.POEntry], output_path: Path):
|
||||
"""POEntry 리스트를 TSV로 저장"""
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(output_path, 'w', newline='', encoding='utf-8-sig') as f:
|
||||
writer = csv.writer(f, delimiter='\t', quoting=csv.QUOTE_ALL)
|
||||
|
||||
# 헤더
|
||||
writer.writerow(['msgctxt', 'SourceLocation', 'msgid'])
|
||||
|
||||
# 데이터
|
||||
for entry in entries:
|
||||
msgctxt = self._escape_newlines(entry.msgctxt or '')
|
||||
source_location = entry.occurrences[0][0] if entry.occurrences else ''
|
||||
msgid = self._escape_newlines(entry.msgid)
|
||||
|
||||
writer.writerow([msgctxt, source_location, msgid])
|
||||
|
||||
def _escape_newlines(self, text: str) -> str:
|
||||
"""줄바꿈 문자를 문자열로 치환"""
|
||||
return text.replace('\r', '\\r').replace('\n', '\\n')
|
||||
|
||||
def _unescape_newlines(self, text: str) -> str:
|
||||
"""문자열 줄바꿈을 실제 문자로 변환"""
|
||||
return text.replace('\\r', '\r').replace('\\n', '\n')
|
||||
|
||||
def _create_backup(self, po_path: Path) -> Optional[Path]:
|
||||
"""백업 파일 생성"""
|
||||
try:
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
backup_path = po_path.with_suffix(f'.backup_{timestamp}.po')
|
||||
backup_path.write_bytes(po_path.read_bytes())
|
||||
return backup_path
|
||||
except Exception as e:
|
||||
self.logger.warning(f'백업 생성 실패: {e}')
|
||||
return None
|
||||
|
||||
def _print_update_result(self, lang_code: str, result: POUpdateResult):
|
||||
"""업데이트 결과 출력"""
|
||||
if result.updated > 0:
|
||||
self.logger.success(f' ✅ {lang_code}: {result.updated}건 업데이트')
|
||||
|
||||
if result.skipped > 0:
|
||||
self.logger.info(f' ⏭️ {lang_code}: {result.skipped}건 스킵 (변경사항 없음)')
|
||||
|
||||
if result.failed > 0:
|
||||
self.logger.error(f' ❌ {lang_code}: {result.failed}건 실패')
|
||||
|
||||
# 실패 이유 출력 (최대 5개)
|
||||
for msgctxt, error in result.errors[:5]:
|
||||
self.logger.error(f' - {msgctxt}: {error}')
|
||||
|
||||
if len(result.errors) > 5:
|
||||
self.logger.error(f' ... 외 {len(result.errors) - 5}건 더 있음')
|
||||
285
lib/validator.py
Normal file
285
lib/validator.py
Normal file
@ -0,0 +1,285 @@
|
||||
"""
|
||||
Translation Validator for DS_L10N
|
||||
번역 검증 시스템
|
||||
"""
|
||||
import re
|
||||
from typing import List, Dict, Tuple, Optional
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ValidationLevel(Enum):
|
||||
"""검증 레벨"""
|
||||
ERROR = 'ERROR' # 치명적 오류
|
||||
WARNING = 'WARNING' # 경고
|
||||
INFO = 'INFO' # 정보
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationIssue:
|
||||
"""검증 이슈"""
|
||||
level: ValidationLevel
|
||||
msgctxt: str
|
||||
lang: str
|
||||
category: str
|
||||
message: str
|
||||
original: str
|
||||
translation: str
|
||||
|
||||
def __str__(self):
|
||||
icon = {
|
||||
ValidationLevel.ERROR: '❌',
|
||||
ValidationLevel.WARNING: '⚠️',
|
||||
ValidationLevel.INFO: 'ℹ️'
|
||||
}[self.level]
|
||||
|
||||
return (f"{icon} [{self.level.value}] {self.lang}: {self.msgctxt}\n"
|
||||
f" 분류: {self.category}\n"
|
||||
f" 문제: {self.message}\n"
|
||||
f" 원문: {self.original}\n"
|
||||
f" 번역: {self.translation}")
|
||||
|
||||
|
||||
class TranslationValidator:
|
||||
"""번역 검증기"""
|
||||
|
||||
def __init__(self, config: dict):
|
||||
self.config = config
|
||||
self.validation_config = config.get('validation', {})
|
||||
|
||||
# 변수 패턴 컴파일
|
||||
self.variable_pattern = re.compile(r'\{[A-Za-z0-9_]+\}')
|
||||
|
||||
# 리치 텍스트 태그 패턴
|
||||
self.tag_patterns = config.get('rich_text', {}).get('tag_patterns', [])
|
||||
|
||||
def validate_entry(self, msgctxt: str, original: str, translation: str, lang: str) -> List[ValidationIssue]:
|
||||
"""단일 항목 검증"""
|
||||
issues = []
|
||||
|
||||
# 빈 번역 확인
|
||||
if self.validation_config.get('check_empty_translations', True):
|
||||
if not translation or not translation.strip():
|
||||
issues.append(ValidationIssue(
|
||||
level=ValidationLevel.WARNING,
|
||||
msgctxt=msgctxt,
|
||||
lang=lang,
|
||||
category='빈 번역',
|
||||
message='번역문이 비어있습니다',
|
||||
original=original,
|
||||
translation=translation
|
||||
))
|
||||
return issues # 빈 번역이면 다른 검증 건너뛰기
|
||||
|
||||
# 변수 일치 확인
|
||||
if self.validation_config.get('check_variables', True):
|
||||
var_issues = self._check_variables(msgctxt, original, translation, lang)
|
||||
issues.extend(var_issues)
|
||||
|
||||
# 리치 텍스트 태그 확인
|
||||
if self.validation_config.get('check_rich_text_tags', True):
|
||||
tag_issues = self._check_rich_text_tags(msgctxt, original, translation, lang)
|
||||
issues.extend(tag_issues)
|
||||
|
||||
# 줄바꿈 확인
|
||||
if self.validation_config.get('check_newlines', True):
|
||||
newline_issues = self._check_newlines(msgctxt, original, translation, lang)
|
||||
issues.extend(newline_issues)
|
||||
|
||||
# 최대 길이 확인
|
||||
max_length = self.validation_config.get('max_length_warning', 200)
|
||||
if len(translation) > max_length:
|
||||
issues.append(ValidationIssue(
|
||||
level=ValidationLevel.INFO,
|
||||
msgctxt=msgctxt,
|
||||
lang=lang,
|
||||
category='길이 초과',
|
||||
message=f'번역문이 {max_length}자를 초과합니다 (현재: {len(translation)}자)',
|
||||
original=original,
|
||||
translation=translation
|
||||
))
|
||||
|
||||
return issues
|
||||
|
||||
def _check_variables(self, msgctxt: str, original: str, translation: str, lang: str) -> List[ValidationIssue]:
|
||||
"""변수 일치 확인"""
|
||||
issues = []
|
||||
|
||||
orig_vars = set(self.variable_pattern.findall(original))
|
||||
trans_vars = set(self.variable_pattern.findall(translation))
|
||||
|
||||
# 누락된 변수
|
||||
missing_vars = orig_vars - trans_vars
|
||||
if missing_vars:
|
||||
issues.append(ValidationIssue(
|
||||
level=ValidationLevel.ERROR,
|
||||
msgctxt=msgctxt,
|
||||
lang=lang,
|
||||
category='변수 누락',
|
||||
message=f'누락된 변수: {", ".join(sorted(missing_vars))}',
|
||||
original=original,
|
||||
translation=translation
|
||||
))
|
||||
|
||||
# 추가된 변수
|
||||
extra_vars = trans_vars - orig_vars
|
||||
if extra_vars:
|
||||
issues.append(ValidationIssue(
|
||||
level=ValidationLevel.WARNING,
|
||||
msgctxt=msgctxt,
|
||||
lang=lang,
|
||||
category='추가 변수',
|
||||
message=f'원문에 없는 변수: {", ".join(sorted(extra_vars))}',
|
||||
original=original,
|
||||
translation=translation
|
||||
))
|
||||
|
||||
return issues
|
||||
|
||||
def _check_rich_text_tags(self, msgctxt: str, original: str, translation: str, lang: str) -> List[ValidationIssue]:
|
||||
"""리치 텍스트 태그 일치 확인"""
|
||||
issues = []
|
||||
|
||||
# 여는 태그와 닫는 태그 개수 확인
|
||||
for tag in self.tag_patterns:
|
||||
if tag == '</>':
|
||||
continue # 닫는 태그는 별도 처리
|
||||
|
||||
orig_count = original.count(tag)
|
||||
trans_count = translation.count(tag)
|
||||
|
||||
if orig_count != trans_count:
|
||||
issues.append(ValidationIssue(
|
||||
level=ValidationLevel.ERROR,
|
||||
msgctxt=msgctxt,
|
||||
lang=lang,
|
||||
category='태그 불일치',
|
||||
message=f'태그 {tag} 개수 불일치 (원문: {orig_count}, 번역: {trans_count})',
|
||||
original=original,
|
||||
translation=translation
|
||||
))
|
||||
|
||||
# 닫는 태그 </> 개수 확인
|
||||
orig_close = original.count('</>')
|
||||
trans_close = translation.count('</>')
|
||||
|
||||
if orig_close != trans_close:
|
||||
issues.append(ValidationIssue(
|
||||
level=ValidationLevel.ERROR,
|
||||
msgctxt=msgctxt,
|
||||
lang=lang,
|
||||
category='태그 불일치',
|
||||
message=f'닫는 태그 </> 개수 불일치 (원문: {orig_close}, 번역: {trans_close})',
|
||||
original=original,
|
||||
translation=translation
|
||||
))
|
||||
|
||||
return issues
|
||||
|
||||
def _check_newlines(self, msgctxt: str, original: str, translation: str, lang: str) -> List[ValidationIssue]:
|
||||
"""줄바꿈 문자 확인"""
|
||||
issues = []
|
||||
|
||||
# 실제 escape sequence (\r\n, \n, \r) 개수 확인
|
||||
# 주의: '\r\n'은 실제 CR+LF 문자 (2바이트), '\\r\\n'은 리터럴 문자열 (4바이트)
|
||||
orig_crlf = original.count('\r\n')
|
||||
trans_crlf = translation.count('\r\n')
|
||||
|
||||
orig_lf = original.count('\n') - orig_crlf # \r\n에 포함된 \n 제외
|
||||
trans_lf = translation.count('\n') - trans_crlf
|
||||
|
||||
orig_cr = original.count('\r') - orig_crlf # \r\n에 포함된 \r 제외
|
||||
trans_cr = translation.count('\r') - trans_crlf
|
||||
|
||||
if orig_crlf != trans_crlf or orig_lf != trans_lf or orig_cr != trans_cr:
|
||||
issues.append(ValidationIssue(
|
||||
level=ValidationLevel.WARNING,
|
||||
msgctxt=msgctxt,
|
||||
lang=lang,
|
||||
category='줄바꿈 불일치',
|
||||
message=f'줄바꿈 문자 개수 불일치 (원문: \\r\\n={orig_crlf}, \\n={orig_lf}, \\r={orig_cr} / '
|
||||
f'번역: \\r\\n={trans_crlf}, \\n={trans_lf}, \\r={trans_cr})',
|
||||
original=original,
|
||||
translation=translation
|
||||
))
|
||||
|
||||
return issues
|
||||
|
||||
def validate_batch(self, entries: List[Dict]) -> Tuple[List[ValidationIssue], Dict[str, int]]:
|
||||
"""
|
||||
여러 항목 일괄 검증
|
||||
|
||||
Args:
|
||||
entries: [{'msgctxt': ..., 'msgid': ..., 'lang': ..., 'msgstr': ...}, ...]
|
||||
|
||||
Returns:
|
||||
(issues, stats)
|
||||
"""
|
||||
all_issues = []
|
||||
|
||||
for entry in entries:
|
||||
msgctxt = entry.get('msgctxt', '')
|
||||
original = entry.get('msgid', '')
|
||||
translation = entry.get('msgstr', '')
|
||||
lang = entry.get('lang', '')
|
||||
|
||||
issues = self.validate_entry(msgctxt, original, translation, lang)
|
||||
all_issues.extend(issues)
|
||||
|
||||
# 통계 생성
|
||||
stats = {
|
||||
'total': len(entries),
|
||||
'errors': sum(1 for issue in all_issues if issue.level == ValidationLevel.ERROR),
|
||||
'warnings': sum(1 for issue in all_issues if issue.level == ValidationLevel.WARNING),
|
||||
'info': sum(1 for issue in all_issues if issue.level == ValidationLevel.INFO),
|
||||
'passed': len(entries) - len(set(issue.msgctxt for issue in all_issues if issue.level == ValidationLevel.ERROR))
|
||||
}
|
||||
|
||||
return all_issues, stats
|
||||
|
||||
def print_validation_report(self, issues: List[ValidationIssue], stats: Dict[str, int], logger):
|
||||
"""검증 리포트 출력"""
|
||||
logger.section('🔍 번역 검증 결과')
|
||||
|
||||
if not issues:
|
||||
logger.success('✅ 모든 검증 통과!')
|
||||
logger.stats(**stats)
|
||||
return
|
||||
|
||||
# 레벨별로 그룹화
|
||||
errors = [i for i in issues if i.level == ValidationLevel.ERROR]
|
||||
warnings = [i for i in issues if i.level == ValidationLevel.WARNING]
|
||||
infos = [i for i in issues if i.level == ValidationLevel.INFO]
|
||||
|
||||
# 오류 출력
|
||||
if errors:
|
||||
logger.error(f'\n❌ 오류 ({len(errors)}건):')
|
||||
for issue in errors[:10]: # 최대 10개만 출력
|
||||
logger.error(f'\n{issue}')
|
||||
if len(errors) > 10:
|
||||
logger.error(f'\n... 외 {len(errors) - 10}건 더 있음')
|
||||
|
||||
# 경고 출력
|
||||
if warnings:
|
||||
logger.warning(f'\n⚠️ 경고 ({len(warnings)}건):')
|
||||
for issue in warnings[:10]: # 최대 10개만 출력
|
||||
logger.warning(f'\n{issue}')
|
||||
if len(warnings) > 10:
|
||||
logger.warning(f'\n... 외 {len(warnings) - 10}건 더 있음')
|
||||
|
||||
# 정보 출력
|
||||
if infos:
|
||||
logger.info(f'\nℹ️ 정보 ({len(infos)}건):')
|
||||
for issue in infos[:5]: # 최대 5개만 출력
|
||||
logger.info(f'\n{issue}')
|
||||
if len(infos) > 5:
|
||||
logger.info(f'\n... 외 {len(infos) - 5}건 더 있음')
|
||||
|
||||
# 통계 출력
|
||||
logger.separator()
|
||||
logger.stats(**stats)
|
||||
|
||||
if errors:
|
||||
logger.error(f'\n검증 실패: {stats["errors"]}개의 오류가 발견되었습니다.')
|
||||
else:
|
||||
logger.success(f'\n검증 완료: {stats["passed"]}건 통과 (경고 {stats["warnings"]}건)')
|
||||
11
requirements.txt
Normal file
11
requirements.txt
Normal file
@ -0,0 +1,11 @@
|
||||
# DS_L10N Requirements
|
||||
# 던전 스토커즈 현지화 툴 필요 라이브러리
|
||||
|
||||
# PO 파일 처리
|
||||
polib>=1.2.0
|
||||
|
||||
# YAML 설정 파일
|
||||
PyYAML>=6.0.1
|
||||
|
||||
# 데이터 처리 (기존 스크립트 호환성)
|
||||
pandas>=2.0.0
|
||||
3517
번역업데이트.tsv
3517
번역업데이트.tsv
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user