Compare commits

...

6 Commits

51 changed files with 91094 additions and 3524 deletions

View File

@ -0,0 +1,10 @@
{
"permissions": {
"allow": [
"Read(//d/Work/WorldStalker/**)",
"Bash(python ds_l10n.py:*)"
],
"deny": [],
"ask": []
}
}

1
.gitignore vendored
View File

@ -1 +1,2 @@
*.log *.log
*.tsv

457
README.md
View File

@ -1,3 +1,458 @@
# DS_L10N # 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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -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 스토커 정보 속죄의 방랑자 네이브의 이명 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 스토커 정보 마법사 네이브의 직업 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 นักแสวงบุญทมิฬ 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 몬스터 이름 단검 박쥐 단검을 사용하는 박쥐. 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 몬스터 이름 도살자 보스 몬스터. 거대한 육체를 가졌다. 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 몬스터 이름 마녀의 하수인 마녀를 섬기는 몬스터. 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 몬스터 이름 박쥐 일반적인 박쥐 몬스터. 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 몬스터 이름 스파이더 네스트 거미를 생성하는 둥지. 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 สไลม์ 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: 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: 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 제작법/설계도 생명의 바지 설계도 '생명의 바지'를 제작할 수 있는 설계도. 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 고유 장비 이름 스톰브링어 공격 적중 시 낙뢰를 소환하는 전설 한손검. 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 고유 장비 이름 칼리번 전설 등급의 한손검. 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 ฮาร์ตซีคเกอร์ 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 ฮาร์ตซีคเกอร์

View File

@ -61,10 +61,18 @@ def merge_po_to_csv_literal():
# source_location은 경로이므로 줄바꿈이 없을 가능성이 높지만, 안정성을 위해 추가 # source_location은 경로이므로 줄바꿈이 없을 가능성이 높지만, 안정성을 위해 추가
source_location_str = source_location_str.replace('\r', '\\r').replace('\n', '\\n') 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: 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 merged_data[data_key][lang_code] = msgstr_str
# --- 수정 완료 --- # --- 수정 완료 ---
@ -74,13 +82,21 @@ def merge_po_to_csv_literal():
continue continue
records = [] 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 = { record = {
'msgctxt': msgctxt, 'msgctxt': msgctxt,
'SourceLocation': source_location, 'SourceLocation': source_location,
'msgid': msgid, '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) records.append(record)
df = pd.DataFrame(records) df = pd.DataFrame(records)

241
config.yaml Normal file
View 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
View 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
View File

@ -0,0 +1 @@
# DS_L10N Library Modules

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

87
lib/config_loader.py Normal file
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,11 @@
# DS_L10N Requirements
# 던전 스토커즈 현지화 툴 필요 라이브러리
# PO 파일 처리
polib>=1.2.0
# YAML 설정 파일
PyYAML>=6.0.1
# 데이터 처리 (기존 스크립트 호환성)
pandas>=2.0.0

File diff suppressed because one or more lines are too long