484 lines
17 KiB
Python
484 lines
17 KiB
Python
#!/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())
|