번역툴 2.0 업데이트

This commit is contained in:
Gnill82
2025-10-29 13:32:42 +09:00
parent fbef7989e4
commit e189a64631
24 changed files with 2486 additions and 1 deletions

13
1_미번역추출.bat Normal file
View File

@ -0,0 +1,13 @@
@echo off
chcp 65001 > nul
cd /d "%~dp0"
echo ========================================
echo DS_L10N - 미번역 항목 추출
echo ========================================
echo.
python ds_l10n.py extract
echo.
pause

13
2_번역검증.bat Normal file
View File

@ -0,0 +1,13 @@
@echo off
chcp 65001 > nul
cd /d "%~dp0"
echo ========================================
echo DS_L10N - 번역 검증
echo ========================================
echo.
python ds_l10n.py validate
echo.
pause

13
3_PO업데이트.bat Normal file
View File

@ -0,0 +1,13 @@
@echo off
chcp 65001 > nul
cd /d "%~dp0"
echo ========================================
echo DS_L10N - PO 파일 업데이트
echo ========================================
echo.
python ds_l10n.py update
echo.
pause

13
4_CSV병합.bat Normal file
View File

@ -0,0 +1,13 @@
@echo off
chcp 65001 > nul
cd /d "%~dp0"
echo ========================================
echo DS_L10N - CSV 병합
echo ========================================
echo.
python ds_l10n.py merge
echo.
pause

13
5_파일정리.bat Normal file
View File

@ -0,0 +1,13 @@
@echo off
chcp 65001 > nul
cd /d "%~dp0"
echo ========================================
echo DS_L10N - 파일 정리
echo ========================================
echo.
python ds_l10n.py cleanup
echo.
pause

200
README.md
View File

@ -1,3 +1,201 @@
# DS_L10N
던전스토커즈 다국어 번역관련
던전스토커즈 다국어 번역 통합
## 🚀 빠른 시작
### 1. 라이브러리 설치
```bash
pip install -r requirements.txt
```
### 2. 배치 파일로 실행 (권장)
더블클릭으로 실행:
- `1_미번역추출.bat` - 미번역 항목 추출
- `2_번역검증.bat` - 번역 검증 (신규)
- `3_PO업데이트.bat` - PO 파일 업데이트
- `4_CSV병합.bat` - CSV 병합
- `5_파일정리.bat` - 파일 정리 (신규)
- `전체워크플로우.bat` - 2~5 자동 실행
### 3. 명령어로 실행
```bash
python ds_l10n.py extract # 미번역 추출
python ds_l10n.py validate # 번역 검증
python ds_l10n.py update # PO 업데이트
python ds_l10n.py merge # CSV 병합
python ds_l10n.py cleanup # 파일 정리
```
## 📚 상세 가이드
자세한 사용법은 [`사용가이드.md`](사용가이드.md) 참조
## ✨ 주요 개선사항
### 기존 문제점
- ❌ 매번 파일/폴더 선택이 번거로움
- ❌ 번역 오류 사전 검증 불가
- ❌ po_update_from_tsv.py 가끔 오류 발생
- ❌ 결과물 파일이 너무 많이 쌓임
### 개선된 기능
-**자동 경로 인식** (config.yaml)
-**번역 검증 기능** (변수, 태그, 줄바꿈 확인)
-**polib 기반 안정적 업데이트**
-**컬러 로그** (오류 원인 명확히 표시)
-**자동 백업** (업데이트 전)
-**자동 파일 정리** (오래된 파일 보관)
## 📁 프로젝트 구조
```
DS_L10N/
├── ds_l10n.py # 메인 CLI 툴
├── config.yaml # 설정 파일
├── 번역업데이트.tsv # 번역 입력 파일
├── requirements.txt # 필요 라이브러리
├── 사용가이드.md # 상세 매뉴얼
├── 1_미번역추출.bat # 배치 스크립트
├── 2_번역검증.bat
├── 3_PO업데이트.bat
├── 4_CSV병합.bat
├── 5_파일정리.bat
├── 전체워크플로우.bat
├── lib/ # 라이브러리 모듈
│ ├── config_loader.py # 설정 로더
│ ├── logger.py # 컬러 로깅
│ ├── validator.py # 번역 검증
│ ├── po_handler.py # PO 파일 처리
│ └── file_manager.py # 파일 관리
├── output/ # 출력 파일 (자동 생성)
├── logs/ # 로그 파일 (자동 생성)
├── archive/ # 보관 파일 (자동 생성)
├── po_extract_untranslated.py # 기존 스크립트 (호환성 유지)
├── po_update_from_tsv.py # 기존 스크립트 (호환성 유지)
└── po_merge_to_csv.py # 기존 스크립트 (호환성 유지)
```
## 🔧 설정
`config.yaml` 파일에서 경로와 동작 설정:
```yaml
paths:
unreal_localization: ../WorldStalker/Content/Localization/LocalExport
output_dir: ./output
logs_dir: ./logs
validation:
check_variables: true
check_rich_text_tags: true
check_newlines: true
cleanup:
keep_recent_files: 5
auto_archive: true
```
## 🆚 기존 스크립트와 비교
| 기능 | 기존 | 신규 |
|------|------|------|
| 미번역 추출 | `po_extract_untranslated.py` | `ds_l10n.py extract` |
| 번역 검증 | ❌ 없음 | `ds_l10n.py validate` ✨ |
| PO 업데이트 | `po_update_from_tsv.py` | `ds_l10n.py update` |
| CSV 병합 | `po_merge_to_csv.py` | `ds_l10n.py merge` |
| 파일 정리 | ❌ 없음 | `ds_l10n.py cleanup` ✨ |
| 경로 관리 | GUI 선택 | config.yaml |
| 로그 | 기본 텍스트 | 컬러 + 아이콘 |
| 백업 | 수동 | 자동 |
| 안정성 | 정규식 파싱 | polib 라이브러리 |
## 📖 워크플로우
### 1단계: 언리얼 에디터
- 현지화 대시보드 → 텍스트 수집
- 텍스트 익스포트
### 2단계: 미번역 추출
```bash
python ds_l10n.py extract
```
### 3단계: 외부 번역
- Google AI Studio 번역 앱 사용
- 결과를 `번역업데이트.tsv`에 저장
### 4단계: 번역 검증
```bash
python ds_l10n.py validate
```
### 5단계: PO 업데이트
```bash
python ds_l10n.py update
```
### 6단계: 언리얼 에디터
- PO 파일 임포트
- 텍스트 컴파일
### 7단계: 사후 관리
```bash
python ds_l10n.py merge # CSV로 백업
python ds_l10n.py cleanup # 파일 정리
```
또는 **4~7단계 자동 실행**:
```bash
전체워크플로우.bat
```
## 💡 팁
### GUI 모드 사용
```bash
python ds_l10n.py extract --gui
```
### 시뮬레이션 모드
```bash
python ds_l10n.py update --dry-run
```
### 특정 파일 검증
```bash
python ds_l10n.py validate output/untranslated_20251029_123456.tsv
```
## 🐛 문제 해결
### "모듈을 찾을 수 없습니다"
```bash
pip install -r requirements.txt
```
### "PO 파일을 찾을 수 없습니다"
- `config.yaml`의 경로 확인
- 언리얼에서 텍스트 익스포트 완료 확인
### 업데이트 실패
- `logs/` 폴더의 최신 로그 파일 확인
- 실패 원인과 msgctxt 확인
## 📄 라이선스
Copyright © 2025 OneUniverse. All rights reserved.
---
**버전**: 2.0
**최종 수정**: 2025-01-29
**작성자**: Claude Code + DS_L10N Team

228
config.yaml Normal file
View File

@ -0,0 +1,228 @@
# 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
# ============================================================================
# 워크플로우 설정 (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

381
ds_l10n.py Normal file
View File

@ -0,0 +1,381 @@
#!/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)
# 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)
else:
# CLI 모드: config에서 경로 가져오기
unreal_loc = config.get_path('paths.unreal_localization')
source_lang = config.get('languages.source', 'ko')
po_path = unreal_loc / source_lang / config.get('files.po_filename', 'LocalExport.po')
if not po_path.exists():
logger.error(f'PO 파일을 찾을 수 없습니다: {po_path}')
return False
# 출력 파일 경로
output_dir = config.get_path('paths.output_dir')
output_dir.mkdir(parents=True, exist_ok=True)
timestamp = get_timestamp()
pattern = config.get('files.untranslated_pattern', 'untranslated_{timestamp}.tsv')
output_filename = pattern.format(timestamp=timestamp)
output_path = output_dir / output_filename
# 추출 실행
po_handler = POHandler(config.data, logger)
count = po_handler.extract_untranslated(po_path, output_path)
if count > 0:
logger.success(f'\n✅ 미번역 항목 {count}건 추출 완료')
logger.info(f'📄 출력 파일: {output_path}')
return True
else:
logger.info('추출할 미번역 항목이 없습니다.')
return False
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]
for row in reader:
msgctxt = row.get('msgctxt', '')
msgid = row.get('msgid', '')
for lang in lang_codes:
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 모드로 실행')
# 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

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}')

193
lib/logger.py Normal file
View File

@ -0,0 +1,193 @@
"""
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 활성화
# 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() # 기존 핸들러 제거
# 콘솔 핸들러
console_handler = logging.StreamHandler(sys.stdout)
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

387
lib/po_handler.py Normal file
View File

@ -0,0 +1,387 @@
"""
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) -> int:
"""
미번역 항목 추출
Returns:
추출된 항목 개수
"""
self.logger.info(f'PO 파일 로드 중: {po_path.name}')
po = self.load_po_file(po_path)
if po is None:
return 0
# 미번역 항목 필터링
untranslated = [entry for entry in po if not entry.msgstr.strip()]
if not untranslated:
self.logger.info('미번역 항목이 없습니다.')
return 0
self.logger.info(f'미번역 항목 {len(untranslated)}건 발견')
# TSV 파일로 저장
self._save_to_tsv(untranslated, output_path)
self.logger.success(f'미번역 항목 추출 완료: {output_path}')
return len(untranslated)
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] = {
'msgid': msgid_escaped,
'msgid_ko': None, # ko의 msgid를 별도로 저장
}
# ko 언어의 msgid는 별도로 저장
if lang_code == 'ko':
merged_data[key]['msgid_ko'] = msgid_escaped
# 언어별 번역문 저장
merged_data[key][lang_code] = msgstr_escaped
# CSV 레코드 생성
records = []
for (msgctxt, source_location), data in merged_data.items():
# ko의 msgid가 있으면 우선 사용
msgid = data.get('msgid_ko') if data.get('msgid_ko') else data.get('msgid')
record = {
'msgctxt': msgctxt,
'SourceLocation': source_location,
'msgid': msgid,
}
# 언어별 번역 추가
for key, value in data.items():
if key not in ['msgid', 'msgid_ko']:
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', 'msgid'}
# 선호 순서
preferred_order = 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', 'msgid'] + 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: # 빈 문자열이 아니면 저장
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}건 더 있음')

284
lib/validator.py Normal file
View File

@ -0,0 +1,284 @@
"""
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 = []
# \r\n, \n, \r 개수 확인
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

419
사용가이드.md Normal file
View File

@ -0,0 +1,419 @@
# DS_L10N 사용 가이드
던전 스토커즈 현지화 통합 툴
## 목차
1. [설치](#설치)
2. [설정](#설정)
3. [기본 사용법](#기본-사용법)
4. [개선된 워크플로우](#개선된-워크플로우)
5. [명령어 상세](#명령어-상세)
6. [문제 해결](#문제-해결)
---
## 설치
### 1. 필요 라이브러리 설치
```bash
cd D:\Work\WorldStalker\DS_L10N
pip install -r requirements.txt
```
설치되는 라이브러리:
- `polib`: PO 파일 안정적 처리
- `PyYAML`: 설정 파일 관리
- `pandas`: 데이터 처리 (기존 스크립트 호환)
### 2. 설정 파일 확인
`config.yaml` 파일에서 경로와 설정 확인:
```yaml
paths:
unreal_localization: ../WorldStalker/Content/Localization/LocalExport
output_dir: ./output
logs_dir: ./logs
...
```
모든 경로는 상대 경로로 설정되어 있으며, `DS_L10N` 폴더 기준입니다.
---
## 설정
### config.yaml 주요 설정
```yaml
# 검증 설정
validation:
check_variables: true # {Value} 같은 변수 일치 확인
check_rich_text_tags: true # <Red></> 태그 일치 확인
check_newlines: true # \r\n 줄바꿈 일치 확인
stop_on_validation_error: false # 검증 실패 시 중단 여부
# 자동 정리
cleanup:
keep_recent_files: 5 # 최근 N개 파일만 유지
auto_archive: true # 오래된 파일 자동 보관
```
필요에 따라 `config.yaml`을 수정하여 사용하세요.
---
## 기본 사용법
### 명령어 구조
```bash
python ds_l10n.py <명령어> [옵션]
```
### 주요 명령어
| 명령어 | 설명 | 기존 스크립트 대체 |
|--------|------|-------------------|
| `extract` | 미번역 항목 추출 | `po_extract_untranslated.py` |
| `validate` | 번역 검증 (신규) | - |
| `update` | PO 파일 업데이트 | `po_update_from_tsv.py` |
| `merge` | CSV 병합 | `po_merge_to_csv.py` |
| `cleanup` | 파일 정리 (신규) | - |
### 도움말 보기
```bash
python ds_l10n.py --help
python ds_l10n.py extract --help
```
---
## 개선된 워크플로우
### Before (기존 방식)
1. 언리얼 에디터: 텍스트 수집 → 익스포트
2. `po_extract_untranslated.py` 실행 → 파일 선택 (GUI)
3. 외부 번역 수행 (Google AI Studio 앱)
4. `번역업데이트.tsv`에 결과 저장
5. `po_update_from_tsv.py` 실행 → 파일/폴더 선택 (GUI)
6. 언리얼 에디터: PO 임포트 → 컴파일
7. `po_merge_to_csv.py` 실행 → 폴더 선택 (GUI)
**문제점**: 매번 파일/폴더 선택, 검증 부재, 오류 발생 시 원인 파악 어려움
### After (개선된 방식)
1. 언리얼 에디터: 텍스트 수집 → 익스포트
2. **`python ds_l10n.py extract`** - 미번역 추출
3. 외부 번역 수행
4. **`python ds_l10n.py validate`** - 번역 검증 ✨ 신규
5. **`python ds_l10n.py update`** - PO 업데이트
6. 언리얼 에디터: PO 임포트 → 컴파일
7. **`python ds_l10n.py merge`** - CSV 병합
8. **`python ds_l10n.py cleanup`** - 파일 정리 ✨ 신규
**개선점**:
- ✅ 경로 자동 인식 (config.yaml)
- ✅ 번역 검증 기능 추가
- ✅ 컬러 로그로 가독성 향상
- ✅ 자동 백업 생성
- ✅ 실패 원인 명확히 출력
- ✅ 오래된 파일 자동 정리
---
## 명령어 상세
### 1. extract - 미번역 항목 추출
```bash
# CLI 모드 (자동 경로)
python ds_l10n.py extract
# GUI 모드 (파일 선택)
python ds_l10n.py extract --gui
```
**출력**:
- `output/untranslated_YYYYMMDD_HHMMSS.tsv`
- msgctxt, SourceLocation, msgid 컬럼
**사용 시기**: 언리얼에서 텍스트 익스포트 후
---
### 2. validate - 번역 검증 ✨ 신규
```bash
# 기본 입력 파일 (번역업데이트.tsv)
python ds_l10n.py validate
# 특정 파일 지정
python ds_l10n.py validate output/untranslated_20251029_123456.tsv
```
**검증 항목**:
-**변수 누락**: `{Value}`, `{Count}` 등이 번역문에 없음
-**태그 불일치**: `<Red></>` 개수가 원문과 다름
- ⚠️ **줄바꿈 불일치**: `\r\n` 개수가 원문과 다름
- **길이 초과**: 번역문이 200자 초과 (설정 가능)
**출력 예시**:
```
🔍 번역 검증 결과
================================================================================
❌ 오류 (3건):
- en: DT_Skill.Fireball.Desc
분류: 변수 누락
문제: 누락된 변수: {Damage}
원문: {Damage} 피해를 입힙니다
번역: Deals damage
⚠️ 경고 (2건):
- ja: UI.Combat.AttackPower
분류: 태그 불일치
...
================================================================================
📊 통계:
- total: 241
- errors: 3
- warnings: 2
- passed: 238
✅ 검증 완료: 238건 통과 (경고 2건)
```
**사용 시기**: 외부 번역 완료 후, PO 업데이트 전
---
### 3. update - PO 파일 업데이트
```bash
# 기본 입력 파일 (번역업데이트.tsv)
python ds_l10n.py update
# 특정 파일 지정
python ds_l10n.py update translations/my_translations.tsv
# 시뮬레이션 모드 (실제 수정 안함)
python ds_l10n.py update --dry-run
```
**개선 사항**:
-**polib 사용**: 정규식 대신 안정적인 라이브러리
-**자동 백업**: 업데이트 전 `.backup_YYYYMMDD_HHMMSS.po` 생성
-**상세 로그**: 업데이트/스킵/실패 건수와 이유 출력
-**msgctxt 매칭**: 언리얼 해시 키로 정확히 매칭
**출력 예시**:
```
언어 처리 중: en
백업 생성: LocalExport.backup_20251029_123456.po
✅ en: 234건 업데이트
⏭️ en: 5건 스킵 (변경사항 없음)
언어 처리 중: ja
백업 생성: LocalExport.backup_20251029_123456.po
✅ ja: 230건 업데이트
❌ ja: 2건 실패
- ,ABC123...: PO 파일에서 msgctxt를 찾을 수 없음
📊 전체 업데이트 결과:
- 업데이트됨: 464
- 스킵됨: 5
- 실패: 2
- 처리 언어: 2
```
**사용 시기**: 번역 검증 완료 후
---
### 4. merge - CSV 병합
```bash
python ds_l10n.py merge
```
**출력**:
- `LocalExport/merged_po_entries_YYYYMMDD_HHMMSS.csv`
- 모든 언어의 번역을 하나의 CSV로 통합
**사용 시기**: PO 임포트/컴파일 후, 엑셀에서 관리할 때
---
### 5. cleanup - 파일 정리 ✨ 신규
```bash
python ds_l10n.py cleanup
```
**정리 항목**:
- 📦 오래된 CSV 파일 → `archive/YYYYMM/` 폴더로 이동
- 📦 오래된 미번역 TSV 파일 보관
- 🗑️ 7일 이상 된 백업 파일 삭제
- 🗑️ 30일 이상 된 로그 파일 삭제
**설정** (`config.yaml`):
```yaml
cleanup:
keep_recent_files: 5 # 최근 5개만 유지
keep_backups_days: 7 # 백업 보관 기간
delete_old_logs_days: 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}` 같은 변수를 포함시키지 않음
- 원문의 변수를 그대로 번역문에 복사
**태그 불일치 오류**:
- `<Red>강력한</>`처럼 여는 태그와 닫는 태그 개수 일치시키기
**줄바꿈 불일치 경고**:
- `\r\n` 위치를 원문과 동일하게 유지 (UI 레이아웃)
### Q5. 기존 GUI 스크립트를 계속 사용하고 싶음
기존 스크립트 파일은 그대로 유지됩니다:
- `po_extract_untranslated.py`
- `po_update_from_tsv.py`
- `po_merge_to_csv.py`
또는 `--gui` 옵션 사용:
```bash
python ds_l10n.py extract --gui
```
### Q6. 로그 파일이 너무 많이 쌓임
```bash
python ds_l10n.py cleanup
```
또는 `config.yaml`에서 자동 삭제 기간 조정:
```yaml
cleanup:
delete_old_logs_days: 7 # 7일로 단축
```
---
## 고급 사용법
### Dry-run 모드로 안전하게 테스트
```bash
# 실제 파일 수정 없이 시뮬레이션
python ds_l10n.py update --dry-run
```
### 커스텀 설정 파일 사용
```bash
python ds_l10n.py --config my_config.yaml extract
```
### 배치 스크립트로 자동화
`update_translations.bat`:
```batch
@echo off
cd /d D:\Work\WorldStalker\DS_L10N
echo [1/4] 번역 검증 중...
python ds_l10n.py validate
if errorlevel 1 (
echo 검증 실패! 번역을 확인하세요.
pause
exit /b 1
)
echo [2/4] PO 파일 업데이트 중...
python ds_l10n.py update
if errorlevel 1 (
echo 업데이트 실패!
pause
exit /b 1
)
echo [3/4] CSV 병합 중...
python ds_l10n.py merge
echo [4/4] 파일 정리 중...
python ds_l10n.py cleanup
echo.
echo ========================================
echo 모든 작업 완료!
echo ========================================
pause
```
---
## 로그 파일
모든 작업의 상세 로그는 `logs/` 폴더에 저장됩니다:
- `logs/workflow_YYYYMMDD_HHMMSS.log`
문제 발생 시 로그 파일을 확인하세요.
---
## 지원
문제가 발생하면:
1. 로그 파일 확인 (`logs/` 폴더)
2. `config.yaml` 설정 확인
3. 이 가이드의 "문제 해결" 섹션 참조
---
**버전**: 2.0
**최종 수정**: 2025-01-29
**작성자**: Claude Code + DS_L10N Team

70
전체워크플로우.bat Normal file
View File

@ -0,0 +1,70 @@
@echo off
chcp 65001 > nul
cd /d "%~dp0"
echo ========================================
echo DS_L10N - 전체 워크플로우
echo ========================================
echo.
echo 이 스크립트는 다음 작업을 순차적으로 수행합니다:
echo 1. 번역 검증
echo 2. PO 파일 업데이트
echo 3. CSV 병합
echo 4. 파일 정리
echo.
echo ※ 언리얼 에디터에서 텍스트 익스포트를 먼저 완료해야 합니다.
echo ※ 번역업데이트.tsv 파일이 준비되어 있어야 합니다.
echo.
pause
echo.
echo ========================================
echo [1/4] 번역 검증 중...
echo ========================================
python ds_l10n.py validate
if errorlevel 1 (
echo.
echo ❌ 번역 검증 실패!
echo 번역업데이트.tsv 파일을 확인하고 오류를 수정하세요.
echo.
pause
exit /b 1
)
echo.
echo ========================================
echo [2/4] PO 파일 업데이트 중...
echo ========================================
python ds_l10n.py update
if errorlevel 1 (
echo.
echo ❌ PO 파일 업데이트 실패!
echo 로그 파일을 확인하세요.
echo.
pause
exit /b 1
)
echo.
echo ========================================
echo [3/4] CSV 병합 중...
echo ========================================
python ds_l10n.py merge
echo.
echo ========================================
echo [4/4] 파일 정리 중...
echo ========================================
python ds_l10n.py cleanup
echo.
echo ========================================
echo ✅ 전체 워크플로우 완료!
echo ========================================
echo.
echo 다음 단계:
echo 1. 언리얼 에디터로 돌아가기
echo 2. 현지화 대시보드에서 PO 파일 임포트
echo 3. 텍스트 컴파일
echo.
pause