#!/usr/bin/env python """ DS_L10N - 던전 스토커즈 현지화 통합 툴 Unified localization workflow tool for DungeonStalkers Usage: python ds_l10n.py extract [--gui] # 미번역 항목 추출 python ds_l10n.py validate [input] # 번역 검증 python ds_l10n.py update [input] # PO 파일 업데이트 python ds_l10n.py merge # CSV 병합 python ds_l10n.py cleanup # 파일 정리 """ import sys import argparse from pathlib import Path from datetime import datetime # 라이브러리 임포트 from lib.config_loader import load_config from lib.logger import init_logger, Icons from lib.validator import TranslationValidator from lib.po_handler import POHandler from lib.file_manager import FileManager def get_timestamp() -> str: """타임스탬프 생성""" return datetime.now().strftime('%Y%m%d_%H%M%S') def command_extract(args, config, logger): """미번역 항목 추출""" logger.section('미번역 항목 추출', Icons.SEARCH) # extract 설정 check_all_languages = config.get('extract.check_all_languages', False) include_fuzzy = config.get('extract.include_fuzzy', False) separate_files = config.get('extract.separate_files', True) # CLI 옵션으로 설정 오버라이드 if hasattr(args, 'include_fuzzy') and args.include_fuzzy is not None: include_fuzzy = args.include_fuzzy if hasattr(args, 'all_languages') and args.all_languages: check_all_languages = True # 출력 디렉토리 output_dir = config.get_path('paths.output_dir') output_dir.mkdir(parents=True, exist_ok=True) timestamp = get_timestamp() po_handler = POHandler(config.data, logger) # GUI 모드 if args.gui: logger.info('GUI 모드로 실행합니다...') import tkinter as tk from tkinter import filedialog root = tk.Tk() root.withdraw() logger.info('PO 파일을 선택하세요...') po_path = filedialog.askopenfilename( title='미번역 항목을 추출할 PO 파일 선택', filetypes=[('PO Files', '*.po'), ('All files', '*.*')] ) if not po_path: logger.warning('파일을 선택하지 않았습니다.') return False po_path = Path(po_path) # 출력 파일 경로 pattern = config.get('files.untranslated_pattern', 'untranslated_{timestamp}.tsv') output_filename = pattern.format(timestamp=timestamp) output_path = output_dir / output_filename # 추출 실행 count = po_handler.extract_untranslated(po_path, output_path, include_fuzzy) if count > 0: logger.success(f'\n✅ 미번역 항목 {count}건 추출 완료') logger.info(f'📄 출력 파일: {output_path}') else: logger.success(f'\n✅ 모든 텍스트가 번역되어 있습니다!') return True # CLI 모드 unreal_loc = config.get_path('paths.unreal_localization') if not unreal_loc.exists(): logger.error(f'언리얼 현지화 폴더를 찾을 수 없습니다: {unreal_loc}') return False logger.info(f'언리얼 현지화 폴더: {unreal_loc}') logger.info(f'fuzzy 항목 포함: {"예" if include_fuzzy else "아니오"}') # 검사할 언어 결정 if check_all_languages: # 모든 대상 언어 검사 target_langs = config.get('languages.targets', []) if not target_langs: logger.error('config.yaml에 대상 언어(languages.targets)가 정의되지 않았습니다.') return False logger.info(f'대상 언어: {", ".join(target_langs)}') else: # en만 검사 (기본 동작) target_langs = ['en'] logger.info(f'검사 언어: en') # 언어별로 미번역 추출 total_extracted = 0 extracted_files = [] for lang in target_langs: po_path = unreal_loc / lang / config.get('files.po_filename', 'LocalExport.po') if not po_path.exists(): logger.warning(f' ⚠️ {lang}: PO 파일을 찾을 수 없음 - {po_path}') continue # 출력 파일 경로 if check_all_languages and separate_files: # 모든 언어 검사 시: 언어별 개별 파일 output_filename = f'untranslated_{lang}_{timestamp}.tsv' else: # en만 검사 시: 단일 파일 (언어 코드 생략) pattern = config.get('files.untranslated_pattern', 'untranslated_{timestamp}.tsv') output_filename = pattern.format(timestamp=timestamp) output_path = output_dir / output_filename # 모든 언어 검사 시에만 언어별로 구분하여 표시 if check_all_languages: logger.separator() logger.info(f'🔍 {lang} 처리 중...') # 추출 실행 count = po_handler.extract_untranslated(po_path, output_path, include_fuzzy) if count > 0: total_extracted += count extracted_files.append((lang, output_path, count)) if check_all_languages: logger.success(f' ✅ {lang}: {count}건 추출') else: if check_all_languages: logger.success(f' ✅ {lang}: 모든 번역 완료') # 최종 결과 logger.separator() if total_extracted > 0: if check_all_languages: # 모든 언어 검사: 언어별 상세 표시 logger.success(f'\n✅ 총 {total_extracted}건의 미번역 항목 추출 완료') logger.info('\n📄 생성된 파일:') for lang, file_path, count in extracted_files: logger.info(f' - {file_path.name} ({lang}: {count}건)') else: # en만 검사: 간단한 메시지 logger.success(f'\n✅ 미번역 항목 {total_extracted}건 추출 완료') logger.info(f'📄 출력 파일: {extracted_files[0][1]}') else: if check_all_languages: logger.success(f'\n✅ 모든 언어의 번역이 완료되어 있습니다!') else: logger.success(f'\n✅ 모든 텍스트가 번역되어 있습니다!') logger.info('미번역 항목이 없습니다.') return True def command_validate(args, config, logger): """번역 검증""" logger.section('번역 검증', Icons.SEARCH) # 입력 파일 결정 if args.input: input_path = Path(args.input) else: # config에서 기본 입력 파일 가져오기 base_dir = config.base_dir input_filename = config.get('files.translation_input', '번역업데이트.tsv') input_path = base_dir / input_filename if not input_path.exists(): logger.error(f'입력 파일을 찾을 수 없습니다: {input_path}') return False logger.info(f'입력 파일: {input_path}') # TSV 파일 읽기 import csv entries = [] try: with open(input_path, 'r', encoding='utf-8-sig') as f: reader = csv.DictReader(f, delimiter='\t') # 제외할 컬럼 exclude_columns = {'msgctxt', 'SourceLocation', 'msgid'} lang_codes = [col for col in reader.fieldnames if col not in exclude_columns] # 원본 언어 (ko가 source language) source_lang = config.get('languages.source', 'ko') for row in reader: msgctxt = row.get('msgctxt', '') # msgid 컬럼이 있으면 사용, 없으면 source language(ko) 사용 msgid = row.get('msgid', '') or row.get(source_lang, '') for lang in lang_codes: # source language는 원본이므로 검증 스킵 if lang == source_lang: continue msgstr = row.get(lang, '') if msgstr: # 번역문이 있는 경우만 검증 entries.append({ 'msgctxt': msgctxt, 'msgid': msgid, 'msgstr': msgstr, 'lang': lang }) except Exception as e: logger.error(f'파일 읽기 실패: {e}') return False if not entries: logger.warning('검증할 번역 항목이 없습니다.') return False logger.info(f'총 {len(entries)}개 번역 항목 검증 중...') # 검증 실행 validator = TranslationValidator(config.data) issues, stats = validator.validate_batch(entries) # 결과 출력 validator.print_validation_report(issues, stats, logger) # 검증 실패 시 오류 코드 반환 if stats['errors'] > 0: if config.get('validation.stop_on_validation_error', False): logger.error('\n검증 실패로 인해 작업을 중단합니다.') return False return True def command_update(args, config, logger): """PO 파일 업데이트""" logger.section('PO 파일 업데이트', Icons.GEAR) # 입력 파일 결정 if args.input: input_path = Path(args.input) else: base_dir = config.base_dir input_filename = config.get('files.translation_input', '번역업데이트.tsv') input_path = base_dir / input_filename if not input_path.exists(): logger.error(f'입력 파일을 찾을 수 없습니다: {input_path}') return False logger.info(f'입력 파일: {input_path}') # 언리얼 현지화 폴더 unreal_loc = config.get_path('paths.unreal_localization') if not unreal_loc.exists(): logger.error(f'언리얼 현지화 폴더를 찾을 수 없습니다: {unreal_loc}') return False logger.info(f'언리얼 현지화 폴더: {unreal_loc}') # 설정 backup = config.get('backup.auto_backup_before_update', True) dry_run = args.dry_run if hasattr(args, 'dry_run') else False if dry_run: logger.warning('⚠️ DRY-RUN 모드: 실제 파일 수정 없이 시뮬레이션만 수행합니다.') # 업데이트 실행 po_handler = POHandler(config.data, logger) results = po_handler.update_from_tsv(input_path, unreal_loc, backup, dry_run) if not results: logger.error('업데이트 실패') return False # 전체 통계 total_updated = sum(r.updated for r in results.values()) total_failed = sum(r.failed for r in results.values()) total_skipped = sum(r.skipped for r in results.values()) logger.separator() logger.section('📊 전체 업데이트 결과') logger.stats( **{ '업데이트됨': total_updated, '스킵됨': total_skipped, '실패': total_failed, '처리 언어': len(results) } ) if total_failed > 0: logger.warning(f'\n⚠️ {total_failed}건의 업데이트 실패가 발생했습니다.') return False logger.success(f'\n✅ PO 파일 업데이트 완료!') return True def command_merge(args, config, logger): """CSV 병합""" logger.section('PO 파일 → CSV 병합', Icons.FILE) # 언리얼 현지화 폴더 unreal_loc = config.get_path('paths.unreal_localization') if not unreal_loc.exists(): logger.error(f'언리얼 현지화 폴더를 찾을 수 없습니다: {unreal_loc}') return False logger.info(f'언리얼 현지화 폴더: {unreal_loc}') # 출력 파일 경로 output_dir = unreal_loc # LocalExport 폴더에 저장 timestamp = get_timestamp() pattern = config.get('files.merged_pattern', 'merged_po_entries_{timestamp}.csv') output_filename = pattern.format(timestamp=timestamp) output_path = output_dir / output_filename # 병합 실행 po_handler = POHandler(config.data, logger) count = po_handler.merge_to_csv(unreal_loc, output_path) if count > 0: logger.success(f'\n✅ CSV 병합 완료: {count}개 항목') logger.info(f'📄 출력 파일: {output_path}') return True else: logger.error('병합 실패') return False def command_cleanup(args, config, logger): """파일 정리""" logger.section('파일 정리', Icons.CLEAN) file_manager = FileManager(config.data, logger) # 출력 폴더 정리 output_dir = config.get_path('paths.output_dir') if output_dir.exists(): archived, _ = file_manager.cleanup_old_files(output_dir) # 언리얼 현지화 폴더의 CSV 파일 정리 unreal_loc = config.get_path('paths.unreal_localization') if unreal_loc.exists(): archived2, _ = file_manager.cleanup_old_files(unreal_loc) # 백업 파일 정리 if unreal_loc.exists(): deleted_backups = file_manager.delete_old_backups(unreal_loc) logger.success('\n✅ 파일 정리 완료') return True def main(): """메인 함수""" parser = argparse.ArgumentParser( description='DS_L10N - 던전 스토커즈 현지화 통합 툴', formatter_class=argparse.RawDescriptionHelpFormatter ) parser.add_argument('--config', type=str, help='설정 파일 경로 (기본: config.yaml)') subparsers = parser.add_subparsers(dest='command', help='명령어') # extract 명령어 parser_extract = subparsers.add_parser('extract', help='미번역 항목 추출') parser_extract.add_argument('--gui', action='store_true', help='GUI 모드로 실행') parser_extract.add_argument('--include-fuzzy', action='store_true', dest='include_fuzzy', help='fuzzy 플래그 항목 포함 (원본 변경으로 리뷰 필요한 항목)') parser_extract.add_argument('--all-languages', action='store_true', dest='all_languages', help='모든 대상 언어 검사 (기본: en만 검사)') # validate 명령어 parser_validate = subparsers.add_parser('validate', help='번역 검증') parser_validate.add_argument('input', nargs='?', help='입력 TSV 파일 (기본: 번역업데이트.tsv)') # update 명령어 parser_update = subparsers.add_parser('update', help='PO 파일 업데이트') parser_update.add_argument('input', nargs='?', help='입력 TSV 파일 (기본: 번역업데이트.tsv)') parser_update.add_argument('--dry-run', action='store_true', help='시뮬레이션 모드 (실제 수정 안함)') # merge 명령어 parser_merge = subparsers.add_parser('merge', help='PO 파일을 CSV로 병합') # cleanup 명령어 parser_cleanup = subparsers.add_parser('cleanup', help='오래된 파일 정리') args = parser.parse_args() # 명령어가 없으면 도움말 출력 if not args.command: parser.print_help() return 0 try: # 설정 로드 config_path = Path(args.config) if args.config else None config = load_config(config_path) # 로거 초기화 logs_dir = config.get_path('paths.logs_dir') logs_dir.mkdir(parents=True, exist_ok=True) timestamp = get_timestamp() log_pattern = config.get('files.log_pattern', 'workflow_{timestamp}.log') log_filename = log_pattern.format(timestamp=timestamp) log_file = logs_dir / log_filename logger = init_logger(config.data, log_file) logger.section(f'DS_L10N - {args.command.upper()}', Icons.ROCKET) logger.info(f'시작 시간: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}') logger.info(f'설정 파일: {config.config_path}') logger.info(f'로그 파일: {log_file}') logger.separator() # 필요한 디렉토리 생성 file_manager = FileManager(config.data, logger) file_manager.ensure_directories() # 명령어 실행 commands = { 'extract': command_extract, 'validate': command_validate, 'update': command_update, 'merge': command_merge, 'cleanup': command_cleanup, } command_func = commands.get(args.command) if command_func: success = command_func(args, config, logger) logger.separator() logger.info(f'종료 시간: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}') if success: logger.success(f'\n✅ {args.command} 작업 완료!') return 0 else: logger.error(f'\n❌ {args.command} 작업 실패') return 1 else: logger.error(f'알 수 없는 명령어: {args.command}') return 1 except KeyboardInterrupt: print('\n\n사용자에 의해 중단되었습니다.') return 130 except Exception as e: print(f'\n오류 발생: {e}') import traceback traceback.print_exc() return 1 if __name__ == '__main__': sys.exit(main())