From e189a64631da8b90d9e90915851f5004e1f1b507 Mon Sep 17 00:00:00 2001 From: Gnill82 Date: Wed, 29 Oct 2025 13:32:42 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B2=88=EC=97=AD=ED=88=B4=202.0=20=EC=97=85?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 1_미번역추출.bat | 13 + 2_번역검증.bat | 13 + 3_PO업데이트.bat | 13 + 4_CSV병합.bat | 13 + 5_파일정리.bat | 13 + README.md | 200 ++++++++- .../ver1.0/# 번역 앱 생성을 위한 기본 가이드.md | 0 .../ver1.0/DS_Context.txt | 0 .../ver1.0/DS_Terminology_DB.txt | 0 .../ver1.0/DS_Terminology_Guide.txt | 0 .../ver1.0/po_extract_untranslated.py | 0 .../ver1.0/po_merge_to_csv.py | 0 .../ver1.0/po_update_from_tsv.py | 0 config.yaml | 228 ++++++++++ ds_l10n.py | 381 ++++++++++++++++ lib/__init__.py | 1 + lib/config_loader.py | 87 ++++ lib/file_manager.py | 161 +++++++ lib/logger.py | 193 ++++++++ lib/po_handler.py | 387 ++++++++++++++++ lib/validator.py | 284 ++++++++++++ requirements.txt | 11 + 사용가이드.md | 419 ++++++++++++++++++ 전체워크플로우.bat | 70 +++ 24 files changed, 2486 insertions(+), 1 deletion(-) create mode 100644 1_미번역추출.bat create mode 100644 2_번역검증.bat create mode 100644 3_PO업데이트.bat create mode 100644 4_CSV병합.bat create mode 100644 5_파일정리.bat rename # 번역 앱 생성을 위한 기본 가이드.md => archive/ver1.0/# 번역 앱 생성을 위한 기본 가이드.md (100%) rename DS_Context.txt => archive/ver1.0/DS_Context.txt (100%) rename DS_Terminology_DB.txt => archive/ver1.0/DS_Terminology_DB.txt (100%) rename DS_Terminology_Guide.txt => archive/ver1.0/DS_Terminology_Guide.txt (100%) rename po_extract_untranslated.py => archive/ver1.0/po_extract_untranslated.py (100%) rename po_merge_to_csv.py => archive/ver1.0/po_merge_to_csv.py (100%) rename po_update_from_tsv.py => archive/ver1.0/po_update_from_tsv.py (100%) create mode 100644 config.yaml create mode 100644 ds_l10n.py create mode 100644 lib/__init__.py create mode 100644 lib/config_loader.py create mode 100644 lib/file_manager.py create mode 100644 lib/logger.py create mode 100644 lib/po_handler.py create mode 100644 lib/validator.py create mode 100644 requirements.txt create mode 100644 사용가이드.md create mode 100644 전체워크플로우.bat diff --git a/1_미번역추출.bat b/1_미번역추출.bat new file mode 100644 index 0000000..3b855b9 --- /dev/null +++ b/1_미번역추출.bat @@ -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 diff --git a/2_번역검증.bat b/2_번역검증.bat new file mode 100644 index 0000000..0667c32 --- /dev/null +++ b/2_번역검증.bat @@ -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 diff --git a/3_PO업데이트.bat b/3_PO업데이트.bat new file mode 100644 index 0000000..954276d --- /dev/null +++ b/3_PO업데이트.bat @@ -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 diff --git a/4_CSV병합.bat b/4_CSV병합.bat new file mode 100644 index 0000000..b953c22 --- /dev/null +++ b/4_CSV병합.bat @@ -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 diff --git a/5_파일정리.bat b/5_파일정리.bat new file mode 100644 index 0000000..243035f --- /dev/null +++ b/5_파일정리.bat @@ -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 diff --git a/README.md b/README.md index bc9bdad..cb4a22b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,201 @@ # DS_L10N -던전스토커즈 다국어 번역관련 툴 \ No newline at end of file +던전스토커즈 다국어 번역 통합 툴 + +## 🚀 빠른 시작 + +### 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 \ No newline at end of file diff --git a/# 번역 앱 생성을 위한 기본 가이드.md b/archive/ver1.0/# 번역 앱 생성을 위한 기본 가이드.md similarity index 100% rename from # 번역 앱 생성을 위한 기본 가이드.md rename to archive/ver1.0/# 번역 앱 생성을 위한 기본 가이드.md diff --git a/DS_Context.txt b/archive/ver1.0/DS_Context.txt similarity index 100% rename from DS_Context.txt rename to archive/ver1.0/DS_Context.txt diff --git a/DS_Terminology_DB.txt b/archive/ver1.0/DS_Terminology_DB.txt similarity index 100% rename from DS_Terminology_DB.txt rename to archive/ver1.0/DS_Terminology_DB.txt diff --git a/DS_Terminology_Guide.txt b/archive/ver1.0/DS_Terminology_Guide.txt similarity index 100% rename from DS_Terminology_Guide.txt rename to archive/ver1.0/DS_Terminology_Guide.txt diff --git a/po_extract_untranslated.py b/archive/ver1.0/po_extract_untranslated.py similarity index 100% rename from po_extract_untranslated.py rename to archive/ver1.0/po_extract_untranslated.py diff --git a/po_merge_to_csv.py b/archive/ver1.0/po_merge_to_csv.py similarity index 100% rename from po_merge_to_csv.py rename to archive/ver1.0/po_merge_to_csv.py diff --git a/po_update_from_tsv.py b/archive/ver1.0/po_update_from_tsv.py similarity index 100% rename from po_update_from_tsv.py rename to archive/ver1.0/po_update_from_tsv.py diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..8c17129 --- /dev/null +++ b/config.yaml @@ -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 + + # 리치 텍스트 태그 일치 확인 (예: ...) + 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: + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + - "" + + # 태그 검증 활성화 + 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 diff --git a/ds_l10n.py b/ds_l10n.py new file mode 100644 index 0000000..bcb4fce --- /dev/null +++ b/ds_l10n.py @@ -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()) diff --git a/lib/__init__.py b/lib/__init__.py new file mode 100644 index 0000000..42ce2bc --- /dev/null +++ b/lib/__init__.py @@ -0,0 +1 @@ +# DS_L10N Library Modules diff --git a/lib/config_loader.py b/lib/config_loader.py new file mode 100644 index 0000000..743bfa2 --- /dev/null +++ b/lib/config_loader.py @@ -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) diff --git a/lib/file_manager.py b/lib/file_manager.py new file mode 100644 index 0000000..a459d84 --- /dev/null +++ b/lib/file_manager.py @@ -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}') diff --git a/lib/logger.py b/lib/logger.py new file mode 100644 index 0000000..7d62877 --- /dev/null +++ b/lib/logger.py @@ -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 diff --git a/lib/po_handler.py b/lib/po_handler.py new file mode 100644 index 0000000..58191c0 --- /dev/null +++ b/lib/po_handler.py @@ -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}건 더 있음') diff --git a/lib/validator.py b/lib/validator.py new file mode 100644 index 0000000..d08621f --- /dev/null +++ b/lib/validator.py @@ -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"]}건)') diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..08a4ee5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +# DS_L10N Requirements +# 던전 스토커즈 현지화 툴 필요 라이브러리 + +# PO 파일 처리 +polib>=1.2.0 + +# YAML 설정 파일 +PyYAML>=6.0.1 + +# 데이터 처리 (기존 스크립트 호환성) +pandas>=2.0.0 diff --git a/사용가이드.md b/사용가이드.md new file mode 100644 index 0000000..0ded6a8 --- /dev/null +++ b/사용가이드.md @@ -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 # 태그 일치 확인 + 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}` 등이 번역문에 없음 +- ❌ **태그 불일치**: `` 개수가 원문과 다름 +- ⚠️ **줄바꿈 불일치**: `\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}` 같은 변수를 포함시키지 않음 +- 원문의 변수를 그대로 번역문에 복사 + +**태그 불일치 오류**: +- `강력한`처럼 여는 태그와 닫는 태그 개수 일치시키기 + +**줄바꿈 불일치 경고**: +- `\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 diff --git a/전체워크플로우.bat b/전체워크플로우.bat new file mode 100644 index 0000000..3a3de26 --- /dev/null +++ b/전체워크플로우.bat @@ -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