404 lines
14 KiB
Python
404 lines
14 KiB
Python
"""
|
|
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, include_fuzzy: bool = False) -> int:
|
|
"""
|
|
미번역 항목 추출
|
|
|
|
Args:
|
|
po_path: PO 파일 경로
|
|
output_path: 출력 TSV 파일 경로
|
|
include_fuzzy: fuzzy 플래그 항목 포함 여부 (원본 변경으로 리뷰 필요한 항목)
|
|
|
|
Returns:
|
|
추출된 항목 개수
|
|
"""
|
|
self.logger.info(f'PO 파일 로드 중: {po_path.name}')
|
|
po = self.load_po_file(po_path)
|
|
|
|
if po is None:
|
|
return 0
|
|
|
|
# 전체 항목 및 미번역 항목 필터링
|
|
total_entries = len([entry for entry in po if entry.msgid]) # msgid가 있는 항목만 카운트
|
|
|
|
# 미번역 항목: msgstr이 비어있는 항목
|
|
untranslated = [entry for entry in po if not entry.msgstr.strip()]
|
|
|
|
# fuzzy 항목: fuzzy 플래그가 있는 항목 (원본 변경으로 리뷰 필요)
|
|
fuzzy_entries = []
|
|
if include_fuzzy:
|
|
fuzzy_entries = [entry for entry in po if 'fuzzy' in entry.flags and entry.msgstr.strip()]
|
|
|
|
# 통합 리스트
|
|
all_entries = untranslated + fuzzy_entries
|
|
|
|
if not all_entries:
|
|
translated_count = total_entries
|
|
self.logger.info(f'전체 {total_entries}개 항목 중 {translated_count}개 번역 완료 (100%)')
|
|
return 0
|
|
|
|
# 통계 출력
|
|
if include_fuzzy and fuzzy_entries:
|
|
self.logger.info(f'전체 {total_entries}개 항목 중:')
|
|
self.logger.info(f' - 미번역 항목: {len(untranslated)}건')
|
|
self.logger.info(f' - 리뷰 필요 (fuzzy): {len(fuzzy_entries)}건')
|
|
self.logger.info(f' - 총 추출 항목: {len(all_entries)}건')
|
|
else:
|
|
self.logger.info(f'전체 {total_entries}개 항목 중 미번역 {len(all_entries)}건 발견')
|
|
|
|
# TSV 파일로 저장
|
|
self._save_to_tsv(all_entries, output_path)
|
|
|
|
self.logger.success(f'미번역 항목 추출 완료: {output_path}')
|
|
return len(all_entries)
|
|
|
|
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] = {}
|
|
|
|
# 언어별 번역문 저장
|
|
merged_data[key][lang_code] = msgstr_escaped
|
|
|
|
# CSV 레코드 생성
|
|
records = []
|
|
for (msgctxt, source_location), data in merged_data.items():
|
|
record = {
|
|
'msgctxt': msgctxt,
|
|
'SourceLocation': source_location,
|
|
}
|
|
|
|
# 언어별 번역 추가
|
|
for key, value in data.items():
|
|
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'}
|
|
|
|
# 선호 순서: ko를 맨 앞에, en을 두 번째로
|
|
source_lang = self.config.get('languages', {}).get('source', 'ko')
|
|
preferred_order = [source_lang] + 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'] + 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: # 빈 문자열이 아니면 저장
|
|
# TSV의 이스케이프된 줄바꿈을 실제 escape sequence로 변환
|
|
msgstr = self._unescape_newlines(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}건 더 있음')
|