번역툴 2.0 업데이트
This commit is contained in:
13
1_미번역추출.bat
Normal file
13
1_미번역추출.bat
Normal 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
13
2_번역검증.bat
Normal 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
13
3_PO업데이트.bat
Normal 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
13
4_CSV병합.bat
Normal 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
13
5_파일정리.bat
Normal 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
200
README.md
@ -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
228
config.yaml
Normal 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
381
ds_l10n.py
Normal 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
1
lib/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# DS_L10N Library Modules
|
||||
87
lib/config_loader.py
Normal file
87
lib/config_loader.py
Normal file
@ -0,0 +1,87 @@
|
||||
"""
|
||||
Configuration Loader for DS_L10N
|
||||
YAML 설정 파일 로더
|
||||
"""
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
|
||||
|
||||
class Config:
|
||||
"""설정 관리 클래스"""
|
||||
|
||||
def __init__(self, config_path: Path):
|
||||
self.config_path = config_path
|
||||
self.data: Dict[str, Any] = {}
|
||||
self.base_dir = config_path.parent
|
||||
self.load()
|
||||
|
||||
def load(self):
|
||||
"""YAML 설정 파일 로드"""
|
||||
if not self.config_path.exists():
|
||||
raise FileNotFoundError(f"설정 파일을 찾을 수 없습니다: {self.config_path}")
|
||||
|
||||
with open(self.config_path, 'r', encoding='utf-8') as f:
|
||||
self.data = yaml.safe_load(f)
|
||||
|
||||
def get(self, key: str, default=None):
|
||||
"""설정 값 가져오기 (점 표기법 지원)"""
|
||||
keys = key.split('.')
|
||||
value = self.data
|
||||
|
||||
for k in keys:
|
||||
if isinstance(value, dict):
|
||||
value = value.get(k)
|
||||
else:
|
||||
return default
|
||||
|
||||
if value is None:
|
||||
return default
|
||||
|
||||
return value
|
||||
|
||||
def get_path(self, key: str, default=None) -> Path:
|
||||
"""경로 설정 가져오기 (상대 경로를 절대 경로로 변환)"""
|
||||
value = self.get(key, default)
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
path = Path(value)
|
||||
|
||||
# 상대 경로면 base_dir 기준으로 절대 경로 변환
|
||||
if not path.is_absolute():
|
||||
path = (self.base_dir / path).resolve()
|
||||
|
||||
return path
|
||||
|
||||
def get_all_paths(self) -> Dict[str, Path]:
|
||||
"""모든 경로 설정 가져오기"""
|
||||
paths_config = self.get('paths', {})
|
||||
return {key: self.get_path(f'paths.{key}') for key in paths_config.keys()}
|
||||
|
||||
def get_languages(self) -> Dict[str, Any]:
|
||||
"""언어 설정 가져오기"""
|
||||
return {
|
||||
'source': self.get('languages.source', 'ko'),
|
||||
'targets': self.get('languages.targets', [])
|
||||
}
|
||||
|
||||
def get_validation_config(self) -> Dict[str, bool]:
|
||||
"""검증 설정 가져오기"""
|
||||
return self.get('validation', {})
|
||||
|
||||
def __getitem__(self, key: str):
|
||||
"""딕셔너리 스타일 접근"""
|
||||
return self.get(key)
|
||||
|
||||
def __repr__(self):
|
||||
return f"Config(config_path={self.config_path})"
|
||||
|
||||
|
||||
def load_config(config_path: Path = None) -> Config:
|
||||
"""설정 파일 로드"""
|
||||
if config_path is None:
|
||||
# 현재 스크립트 위치 기준으로 config.yaml 찾기
|
||||
config_path = Path(__file__).parent.parent / 'config.yaml'
|
||||
|
||||
return Config(config_path)
|
||||
161
lib/file_manager.py
Normal file
161
lib/file_manager.py
Normal file
@ -0,0 +1,161 @@
|
||||
"""
|
||||
File Manager for DS_L10N
|
||||
파일 자동 관리 및 정리
|
||||
"""
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Tuple
|
||||
|
||||
|
||||
class FileManager:
|
||||
"""파일 관리자"""
|
||||
|
||||
def __init__(self, config: dict, logger):
|
||||
self.config = config
|
||||
self.logger = logger
|
||||
self.cleanup_config = config.get('cleanup', {})
|
||||
|
||||
def cleanup_old_files(self, target_dir: Path) -> Tuple[int, int]:
|
||||
"""
|
||||
오래된 파일 정리
|
||||
|
||||
Returns:
|
||||
(archived_count, deleted_count)
|
||||
"""
|
||||
if not self.cleanup_config.get('auto_archive', True):
|
||||
self.logger.info('자동 정리가 비활성화되어 있습니다.')
|
||||
return 0, 0
|
||||
|
||||
keep_recent = self.cleanup_config.get('keep_recent_files', 5)
|
||||
archive_patterns = self.cleanup_config.get('archive_patterns', [])
|
||||
archive_dir = Path(self.config.get('paths', {}).get('archive_dir', './archive'))
|
||||
|
||||
archived_count = 0
|
||||
|
||||
self.logger.info(f'파일 정리 시작: {target_dir}')
|
||||
self.logger.info(f'최근 {keep_recent}개 파일 유지, 나머지는 보관')
|
||||
|
||||
for pattern in archive_patterns:
|
||||
files = sorted(target_dir.glob(pattern), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
|
||||
if len(files) <= keep_recent:
|
||||
continue
|
||||
|
||||
# 오래된 파일들
|
||||
old_files = files[keep_recent:]
|
||||
|
||||
for file_path in old_files:
|
||||
archived = self._archive_file(file_path, archive_dir)
|
||||
if archived:
|
||||
archived_count += 1
|
||||
|
||||
# 오래된 로그 파일 삭제
|
||||
logs_dir = Path(self.config.get('paths', {}).get('logs_dir', './logs'))
|
||||
deleted_count = self._delete_old_logs(logs_dir)
|
||||
|
||||
if archived_count > 0:
|
||||
self.logger.success(f'✅ {archived_count}개 파일 보관 완료')
|
||||
|
||||
if deleted_count > 0:
|
||||
self.logger.success(f'✅ {deleted_count}개 오래된 로그 파일 삭제')
|
||||
|
||||
return archived_count, deleted_count
|
||||
|
||||
def _archive_file(self, file_path: Path, archive_dir: Path) -> bool:
|
||||
"""파일을 archive 폴더로 이동"""
|
||||
try:
|
||||
archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 날짜별 하위 폴더
|
||||
date_folder = archive_dir / datetime.now().strftime('%Y%m')
|
||||
date_folder.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 대상 경로
|
||||
dest_path = date_folder / file_path.name
|
||||
|
||||
# 이미 존재하면 타임스탬프 추가
|
||||
if dest_path.exists():
|
||||
timestamp = datetime.now().strftime('%H%M%S')
|
||||
dest_path = date_folder / f"{file_path.stem}_{timestamp}{file_path.suffix}"
|
||||
|
||||
shutil.move(str(file_path), str(dest_path))
|
||||
self.logger.info(f' 📦 보관: {file_path.name} → {dest_path.relative_to(archive_dir)}')
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f'파일 보관 실패: {file_path.name} - {e}')
|
||||
return False
|
||||
|
||||
def _delete_old_logs(self, logs_dir: Path) -> int:
|
||||
"""오래된 로그 파일 삭제"""
|
||||
if not logs_dir.exists():
|
||||
return 0
|
||||
|
||||
delete_days = self.cleanup_config.get('delete_old_logs_days', 30)
|
||||
cutoff_date = datetime.now() - timedelta(days=delete_days)
|
||||
|
||||
deleted_count = 0
|
||||
|
||||
for log_file in logs_dir.glob('*.log'):
|
||||
try:
|
||||
mtime = datetime.fromtimestamp(log_file.stat().st_mtime)
|
||||
|
||||
if mtime < cutoff_date:
|
||||
log_file.unlink()
|
||||
self.logger.debug(f' 🗑️ 삭제: {log_file.name}')
|
||||
deleted_count += 1
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f'로그 파일 삭제 실패: {log_file.name} - {e}')
|
||||
|
||||
return deleted_count
|
||||
|
||||
def delete_old_backups(self, localization_root: Path) -> int:
|
||||
"""오래된 백업 파일 삭제"""
|
||||
keep_days = self.config.get('backup', {}).get('keep_backups_days', 7)
|
||||
cutoff_date = datetime.now() - timedelta(days=keep_days)
|
||||
|
||||
deleted_count = 0
|
||||
|
||||
self.logger.info(f'백업 파일 정리 중 ({keep_days}일 이상 된 파일 삭제)...')
|
||||
|
||||
for lang_folder in localization_root.iterdir():
|
||||
if not lang_folder.is_dir():
|
||||
continue
|
||||
|
||||
for backup_file in lang_folder.glob('*.backup_*.po'):
|
||||
try:
|
||||
mtime = datetime.fromtimestamp(backup_file.stat().st_mtime)
|
||||
|
||||
if mtime < cutoff_date:
|
||||
backup_file.unlink()
|
||||
self.logger.debug(f' 🗑️ 백업 삭제: {backup_file.name}')
|
||||
deleted_count += 1
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f'백업 파일 삭제 실패: {backup_file.name} - {e}')
|
||||
|
||||
if deleted_count > 0:
|
||||
self.logger.success(f'✅ {deleted_count}개 백업 파일 삭제 완료')
|
||||
|
||||
return deleted_count
|
||||
|
||||
def ensure_directories(self):
|
||||
"""필요한 디렉토리 생성"""
|
||||
paths = [
|
||||
Path(self.config.get('paths', {}).get('output_dir', './output')),
|
||||
Path(self.config.get('paths', {}).get('logs_dir', './logs')),
|
||||
Path(self.config.get('paths', {}).get('archive_dir', './archive')),
|
||||
Path(self.config.get('paths', {}).get('temp_dir', './temp')),
|
||||
]
|
||||
|
||||
for path in paths:
|
||||
if not path.is_absolute():
|
||||
# 상대 경로를 절대 경로로 변환
|
||||
base_dir = Path(__file__).parent.parent
|
||||
path = (base_dir / path).resolve()
|
||||
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
self.logger.debug(f'디렉토리 확인: {path}')
|
||||
193
lib/logger.py
Normal file
193
lib/logger.py
Normal 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
387
lib/po_handler.py
Normal 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
284
lib/validator.py
Normal 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
11
requirements.txt
Normal file
@ -0,0 +1,11 @@
|
||||
# DS_L10N Requirements
|
||||
# 던전 스토커즈 현지화 툴 필요 라이브러리
|
||||
|
||||
# PO 파일 처리
|
||||
polib>=1.2.0
|
||||
|
||||
# YAML 설정 파일
|
||||
PyYAML>=6.0.1
|
||||
|
||||
# 데이터 처리 (기존 스크립트 호환성)
|
||||
pandas>=2.0.0
|
||||
419
사용가이드.md
Normal file
419
사용가이드.md
Normal 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
70
전체워크플로우.bat
Normal 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
|
||||
Reference in New Issue
Block a user