번역툴 2.0 업데이트
This commit is contained in:
1
lib/__init__.py
Normal file
1
lib/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
# DS_L10N Library Modules
|
||||
87
lib/config_loader.py
Normal file
87
lib/config_loader.py
Normal file
@ -0,0 +1,87 @@
|
||||
"""
|
||||
Configuration Loader for DS_L10N
|
||||
YAML 설정 파일 로더
|
||||
"""
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
|
||||
|
||||
class Config:
|
||||
"""설정 관리 클래스"""
|
||||
|
||||
def __init__(self, config_path: Path):
|
||||
self.config_path = config_path
|
||||
self.data: Dict[str, Any] = {}
|
||||
self.base_dir = config_path.parent
|
||||
self.load()
|
||||
|
||||
def load(self):
|
||||
"""YAML 설정 파일 로드"""
|
||||
if not self.config_path.exists():
|
||||
raise FileNotFoundError(f"설정 파일을 찾을 수 없습니다: {self.config_path}")
|
||||
|
||||
with open(self.config_path, 'r', encoding='utf-8') as f:
|
||||
self.data = yaml.safe_load(f)
|
||||
|
||||
def get(self, key: str, default=None):
|
||||
"""설정 값 가져오기 (점 표기법 지원)"""
|
||||
keys = key.split('.')
|
||||
value = self.data
|
||||
|
||||
for k in keys:
|
||||
if isinstance(value, dict):
|
||||
value = value.get(k)
|
||||
else:
|
||||
return default
|
||||
|
||||
if value is None:
|
||||
return default
|
||||
|
||||
return value
|
||||
|
||||
def get_path(self, key: str, default=None) -> Path:
|
||||
"""경로 설정 가져오기 (상대 경로를 절대 경로로 변환)"""
|
||||
value = self.get(key, default)
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
path = Path(value)
|
||||
|
||||
# 상대 경로면 base_dir 기준으로 절대 경로 변환
|
||||
if not path.is_absolute():
|
||||
path = (self.base_dir / path).resolve()
|
||||
|
||||
return path
|
||||
|
||||
def get_all_paths(self) -> Dict[str, Path]:
|
||||
"""모든 경로 설정 가져오기"""
|
||||
paths_config = self.get('paths', {})
|
||||
return {key: self.get_path(f'paths.{key}') for key in paths_config.keys()}
|
||||
|
||||
def get_languages(self) -> Dict[str, Any]:
|
||||
"""언어 설정 가져오기"""
|
||||
return {
|
||||
'source': self.get('languages.source', 'ko'),
|
||||
'targets': self.get('languages.targets', [])
|
||||
}
|
||||
|
||||
def get_validation_config(self) -> Dict[str, bool]:
|
||||
"""검증 설정 가져오기"""
|
||||
return self.get('validation', {})
|
||||
|
||||
def __getitem__(self, key: str):
|
||||
"""딕셔너리 스타일 접근"""
|
||||
return self.get(key)
|
||||
|
||||
def __repr__(self):
|
||||
return f"Config(config_path={self.config_path})"
|
||||
|
||||
|
||||
def load_config(config_path: Path = None) -> Config:
|
||||
"""설정 파일 로드"""
|
||||
if config_path is None:
|
||||
# 현재 스크립트 위치 기준으로 config.yaml 찾기
|
||||
config_path = Path(__file__).parent.parent / 'config.yaml'
|
||||
|
||||
return Config(config_path)
|
||||
161
lib/file_manager.py
Normal file
161
lib/file_manager.py
Normal file
@ -0,0 +1,161 @@
|
||||
"""
|
||||
File Manager for DS_L10N
|
||||
파일 자동 관리 및 정리
|
||||
"""
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Tuple
|
||||
|
||||
|
||||
class FileManager:
|
||||
"""파일 관리자"""
|
||||
|
||||
def __init__(self, config: dict, logger):
|
||||
self.config = config
|
||||
self.logger = logger
|
||||
self.cleanup_config = config.get('cleanup', {})
|
||||
|
||||
def cleanup_old_files(self, target_dir: Path) -> Tuple[int, int]:
|
||||
"""
|
||||
오래된 파일 정리
|
||||
|
||||
Returns:
|
||||
(archived_count, deleted_count)
|
||||
"""
|
||||
if not self.cleanup_config.get('auto_archive', True):
|
||||
self.logger.info('자동 정리가 비활성화되어 있습니다.')
|
||||
return 0, 0
|
||||
|
||||
keep_recent = self.cleanup_config.get('keep_recent_files', 5)
|
||||
archive_patterns = self.cleanup_config.get('archive_patterns', [])
|
||||
archive_dir = Path(self.config.get('paths', {}).get('archive_dir', './archive'))
|
||||
|
||||
archived_count = 0
|
||||
|
||||
self.logger.info(f'파일 정리 시작: {target_dir}')
|
||||
self.logger.info(f'최근 {keep_recent}개 파일 유지, 나머지는 보관')
|
||||
|
||||
for pattern in archive_patterns:
|
||||
files = sorted(target_dir.glob(pattern), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
|
||||
if len(files) <= keep_recent:
|
||||
continue
|
||||
|
||||
# 오래된 파일들
|
||||
old_files = files[keep_recent:]
|
||||
|
||||
for file_path in old_files:
|
||||
archived = self._archive_file(file_path, archive_dir)
|
||||
if archived:
|
||||
archived_count += 1
|
||||
|
||||
# 오래된 로그 파일 삭제
|
||||
logs_dir = Path(self.config.get('paths', {}).get('logs_dir', './logs'))
|
||||
deleted_count = self._delete_old_logs(logs_dir)
|
||||
|
||||
if archived_count > 0:
|
||||
self.logger.success(f'✅ {archived_count}개 파일 보관 완료')
|
||||
|
||||
if deleted_count > 0:
|
||||
self.logger.success(f'✅ {deleted_count}개 오래된 로그 파일 삭제')
|
||||
|
||||
return archived_count, deleted_count
|
||||
|
||||
def _archive_file(self, file_path: Path, archive_dir: Path) -> bool:
|
||||
"""파일을 archive 폴더로 이동"""
|
||||
try:
|
||||
archive_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 날짜별 하위 폴더
|
||||
date_folder = archive_dir / datetime.now().strftime('%Y%m')
|
||||
date_folder.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 대상 경로
|
||||
dest_path = date_folder / file_path.name
|
||||
|
||||
# 이미 존재하면 타임스탬프 추가
|
||||
if dest_path.exists():
|
||||
timestamp = datetime.now().strftime('%H%M%S')
|
||||
dest_path = date_folder / f"{file_path.stem}_{timestamp}{file_path.suffix}"
|
||||
|
||||
shutil.move(str(file_path), str(dest_path))
|
||||
self.logger.info(f' 📦 보관: {file_path.name} → {dest_path.relative_to(archive_dir)}')
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f'파일 보관 실패: {file_path.name} - {e}')
|
||||
return False
|
||||
|
||||
def _delete_old_logs(self, logs_dir: Path) -> int:
|
||||
"""오래된 로그 파일 삭제"""
|
||||
if not logs_dir.exists():
|
||||
return 0
|
||||
|
||||
delete_days = self.cleanup_config.get('delete_old_logs_days', 30)
|
||||
cutoff_date = datetime.now() - timedelta(days=delete_days)
|
||||
|
||||
deleted_count = 0
|
||||
|
||||
for log_file in logs_dir.glob('*.log'):
|
||||
try:
|
||||
mtime = datetime.fromtimestamp(log_file.stat().st_mtime)
|
||||
|
||||
if mtime < cutoff_date:
|
||||
log_file.unlink()
|
||||
self.logger.debug(f' 🗑️ 삭제: {log_file.name}')
|
||||
deleted_count += 1
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f'로그 파일 삭제 실패: {log_file.name} - {e}')
|
||||
|
||||
return deleted_count
|
||||
|
||||
def delete_old_backups(self, localization_root: Path) -> int:
|
||||
"""오래된 백업 파일 삭제"""
|
||||
keep_days = self.config.get('backup', {}).get('keep_backups_days', 7)
|
||||
cutoff_date = datetime.now() - timedelta(days=keep_days)
|
||||
|
||||
deleted_count = 0
|
||||
|
||||
self.logger.info(f'백업 파일 정리 중 ({keep_days}일 이상 된 파일 삭제)...')
|
||||
|
||||
for lang_folder in localization_root.iterdir():
|
||||
if not lang_folder.is_dir():
|
||||
continue
|
||||
|
||||
for backup_file in lang_folder.glob('*.backup_*.po'):
|
||||
try:
|
||||
mtime = datetime.fromtimestamp(backup_file.stat().st_mtime)
|
||||
|
||||
if mtime < cutoff_date:
|
||||
backup_file.unlink()
|
||||
self.logger.debug(f' 🗑️ 백업 삭제: {backup_file.name}')
|
||||
deleted_count += 1
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f'백업 파일 삭제 실패: {backup_file.name} - {e}')
|
||||
|
||||
if deleted_count > 0:
|
||||
self.logger.success(f'✅ {deleted_count}개 백업 파일 삭제 완료')
|
||||
|
||||
return deleted_count
|
||||
|
||||
def ensure_directories(self):
|
||||
"""필요한 디렉토리 생성"""
|
||||
paths = [
|
||||
Path(self.config.get('paths', {}).get('output_dir', './output')),
|
||||
Path(self.config.get('paths', {}).get('logs_dir', './logs')),
|
||||
Path(self.config.get('paths', {}).get('archive_dir', './archive')),
|
||||
Path(self.config.get('paths', {}).get('temp_dir', './temp')),
|
||||
]
|
||||
|
||||
for path in paths:
|
||||
if not path.is_absolute():
|
||||
# 상대 경로를 절대 경로로 변환
|
||||
base_dir = Path(__file__).parent.parent
|
||||
path = (base_dir / path).resolve()
|
||||
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
self.logger.debug(f'디렉토리 확인: {path}')
|
||||
193
lib/logger.py
Normal file
193
lib/logger.py
Normal file
@ -0,0 +1,193 @@
|
||||
"""
|
||||
Colored Console Logger for DS_L10N
|
||||
컬러 콘솔 로깅 시스템
|
||||
"""
|
||||
import logging
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
# Windows에서 ANSI 컬러 지원 활성화
|
||||
if sys.platform == 'win32':
|
||||
import os
|
||||
os.system('') # ANSI escape sequences 활성화
|
||||
|
||||
# ANSI 컬러 코드
|
||||
class Colors:
|
||||
RESET = '\033[0m'
|
||||
BOLD = '\033[1m'
|
||||
|
||||
# 기본 색상
|
||||
BLACK = '\033[30m'
|
||||
RED = '\033[31m'
|
||||
GREEN = '\033[32m'
|
||||
YELLOW = '\033[33m'
|
||||
BLUE = '\033[34m'
|
||||
MAGENTA = '\033[35m'
|
||||
CYAN = '\033[36m'
|
||||
WHITE = '\033[37m'
|
||||
|
||||
# 밝은 색상
|
||||
BRIGHT_RED = '\033[91m'
|
||||
BRIGHT_GREEN = '\033[92m'
|
||||
BRIGHT_YELLOW = '\033[93m'
|
||||
BRIGHT_BLUE = '\033[94m'
|
||||
BRIGHT_MAGENTA = '\033[95m'
|
||||
BRIGHT_CYAN = '\033[96m'
|
||||
BRIGHT_WHITE = '\033[97m'
|
||||
|
||||
# 이모지 아이콘
|
||||
class Icons:
|
||||
SUCCESS = '✅'
|
||||
ERROR = '❌'
|
||||
WARNING = '⚠️'
|
||||
INFO = 'ℹ️'
|
||||
ROCKET = '🚀'
|
||||
GEAR = '⚙️'
|
||||
FILE = '📄'
|
||||
FOLDER = '📁'
|
||||
CHART = '📊'
|
||||
SEARCH = '🔍'
|
||||
CLEAN = '🧹'
|
||||
BACKUP = '💾'
|
||||
CLOCK = '⏱️'
|
||||
|
||||
|
||||
class ColoredFormatter(logging.Formatter):
|
||||
"""컬러 로그 포매터"""
|
||||
|
||||
LEVEL_COLORS = {
|
||||
'DEBUG': Colors.CYAN,
|
||||
'INFO': Colors.BLUE,
|
||||
'WARNING': Colors.YELLOW,
|
||||
'ERROR': Colors.RED,
|
||||
'CRITICAL': Colors.BRIGHT_RED + Colors.BOLD,
|
||||
'SUCCESS': Colors.GREEN,
|
||||
}
|
||||
|
||||
LEVEL_ICONS = {
|
||||
'DEBUG': Icons.GEAR,
|
||||
'INFO': Icons.INFO,
|
||||
'WARNING': Icons.WARNING,
|
||||
'ERROR': Icons.ERROR,
|
||||
'CRITICAL': Icons.ERROR,
|
||||
'SUCCESS': Icons.SUCCESS,
|
||||
}
|
||||
|
||||
def __init__(self, colored=True):
|
||||
super().__init__()
|
||||
self.colored = colored
|
||||
|
||||
def format(self, record):
|
||||
if self.colored:
|
||||
level_color = self.LEVEL_COLORS.get(record.levelname, '')
|
||||
level_icon = self.LEVEL_ICONS.get(record.levelname, '')
|
||||
reset = Colors.RESET
|
||||
|
||||
# 타임스탬프
|
||||
timestamp = datetime.fromtimestamp(record.created).strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
# 레벨명 포맷
|
||||
level_name = f"{level_color}{record.levelname:<8}{reset}"
|
||||
|
||||
# 메시지
|
||||
message = record.getMessage()
|
||||
|
||||
return f"[{timestamp}] {level_icon} {level_name} {message}"
|
||||
else:
|
||||
timestamp = datetime.fromtimestamp(record.created).strftime('%Y-%m-%d %H:%M:%S')
|
||||
return f"[{timestamp}] [{record.levelname:<8}] {record.getMessage()}"
|
||||
|
||||
|
||||
class DSLogger:
|
||||
"""DS_L10N 전용 로거"""
|
||||
|
||||
def __init__(self, name: str = 'DS_L10N',
|
||||
console_level: str = 'INFO',
|
||||
file_level: str = 'DEBUG',
|
||||
log_file: Optional[Path] = None,
|
||||
colored: bool = True):
|
||||
|
||||
self.logger = logging.getLogger(name)
|
||||
self.logger.setLevel(logging.DEBUG)
|
||||
self.logger.handlers.clear() # 기존 핸들러 제거
|
||||
|
||||
# 콘솔 핸들러
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setLevel(getattr(logging, console_level.upper()))
|
||||
console_handler.setFormatter(ColoredFormatter(colored=colored))
|
||||
self.logger.addHandler(console_handler)
|
||||
|
||||
# 파일 핸들러
|
||||
if log_file:
|
||||
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
file_handler = logging.FileHandler(log_file, encoding='utf-8')
|
||||
file_handler.setLevel(getattr(logging, file_level.upper()))
|
||||
file_handler.setFormatter(ColoredFormatter(colored=False))
|
||||
self.logger.addHandler(file_handler)
|
||||
|
||||
# SUCCESS 레벨 추가 (INFO와 WARNING 사이)
|
||||
logging.addLevelName(25, 'SUCCESS')
|
||||
|
||||
def debug(self, message: str):
|
||||
self.logger.debug(message)
|
||||
|
||||
def info(self, message: str):
|
||||
self.logger.info(message)
|
||||
|
||||
def success(self, message: str):
|
||||
self.logger.log(25, message)
|
||||
|
||||
def warning(self, message: str):
|
||||
self.logger.warning(message)
|
||||
|
||||
def error(self, message: str):
|
||||
self.logger.error(message)
|
||||
|
||||
def critical(self, message: str):
|
||||
self.logger.critical(message)
|
||||
|
||||
def separator(self, char: str = '=', length: int = 80):
|
||||
"""구분선 출력"""
|
||||
self.logger.info(char * length)
|
||||
|
||||
def section(self, title: str, icon: str = Icons.ROCKET):
|
||||
"""섹션 제목 출력"""
|
||||
self.separator()
|
||||
self.logger.info(f"{icon} {title}")
|
||||
self.separator()
|
||||
|
||||
def stats(self, **kwargs):
|
||||
"""통계 정보 출력"""
|
||||
self.logger.info(f"{Icons.CHART} 통계:")
|
||||
for key, value in kwargs.items():
|
||||
self.logger.info(f" - {key}: {value}")
|
||||
|
||||
|
||||
# 전역 로거 인스턴스
|
||||
_global_logger: Optional[DSLogger] = None
|
||||
|
||||
|
||||
def get_logger() -> DSLogger:
|
||||
"""전역 로거 가져오기"""
|
||||
global _global_logger
|
||||
if _global_logger is None:
|
||||
_global_logger = DSLogger()
|
||||
return _global_logger
|
||||
|
||||
|
||||
def init_logger(config: dict, log_file: Optional[Path] = None) -> DSLogger:
|
||||
"""로거 초기화"""
|
||||
global _global_logger
|
||||
|
||||
logging_config = config.get('logging', {})
|
||||
|
||||
_global_logger = DSLogger(
|
||||
console_level=logging_config.get('console_level', 'INFO'),
|
||||
file_level=logging_config.get('file_level', 'DEBUG'),
|
||||
log_file=log_file,
|
||||
colored=logging_config.get('colored_output', True)
|
||||
)
|
||||
|
||||
return _global_logger
|
||||
387
lib/po_handler.py
Normal file
387
lib/po_handler.py
Normal file
@ -0,0 +1,387 @@
|
||||
"""
|
||||
PO File Handler for DS_L10N
|
||||
polib 기반의 안정적인 PO 파일 처리
|
||||
"""
|
||||
import polib
|
||||
import csv
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Tuple, Optional
|
||||
from datetime import datetime
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class POUpdateResult:
|
||||
"""PO 업데이트 결과"""
|
||||
total: int
|
||||
updated: int
|
||||
failed: int
|
||||
skipped: int
|
||||
errors: List[Tuple[str, str]] # (msgctxt, error_message)
|
||||
|
||||
|
||||
class POHandler:
|
||||
"""PO 파일 핸들러"""
|
||||
|
||||
def __init__(self, config: dict, logger):
|
||||
self.config = config
|
||||
self.logger = logger
|
||||
self.po_filename = config.get('files', {}).get('po_filename', 'LocalExport.po')
|
||||
|
||||
def load_po_file(self, po_path: Path) -> Optional[polib.POFile]:
|
||||
"""PO 파일 로드"""
|
||||
try:
|
||||
if not po_path.exists():
|
||||
self.logger.error(f'PO 파일을 찾을 수 없습니다: {po_path}')
|
||||
return None
|
||||
|
||||
po = polib.pofile(str(po_path), encoding='utf-8')
|
||||
return po
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f'PO 파일 로드 실패: {po_path} - {e}')
|
||||
return None
|
||||
|
||||
def extract_untranslated(self, po_path: Path, output_path: Path) -> int:
|
||||
"""
|
||||
미번역 항목 추출
|
||||
|
||||
Returns:
|
||||
추출된 항목 개수
|
||||
"""
|
||||
self.logger.info(f'PO 파일 로드 중: {po_path.name}')
|
||||
po = self.load_po_file(po_path)
|
||||
|
||||
if po is None:
|
||||
return 0
|
||||
|
||||
# 미번역 항목 필터링
|
||||
untranslated = [entry for entry in po if not entry.msgstr.strip()]
|
||||
|
||||
if not untranslated:
|
||||
self.logger.info('미번역 항목이 없습니다.')
|
||||
return 0
|
||||
|
||||
self.logger.info(f'미번역 항목 {len(untranslated)}건 발견')
|
||||
|
||||
# TSV 파일로 저장
|
||||
self._save_to_tsv(untranslated, output_path)
|
||||
|
||||
self.logger.success(f'미번역 항목 추출 완료: {output_path}')
|
||||
return len(untranslated)
|
||||
|
||||
def merge_to_csv(self, localization_root: Path, output_path: Path) -> int:
|
||||
"""
|
||||
여러 언어의 PO 파일을 하나의 CSV로 병합
|
||||
|
||||
Returns:
|
||||
병합된 항목 개수
|
||||
"""
|
||||
self.logger.info(f'언어 폴더 탐색 중: {localization_root}')
|
||||
|
||||
# 언어 폴더 찾기
|
||||
lang_folders = []
|
||||
for item in localization_root.iterdir():
|
||||
if item.is_dir():
|
||||
po_file = item / self.po_filename
|
||||
if po_file.exists():
|
||||
lang_folders.append(item.name)
|
||||
|
||||
if not lang_folders:
|
||||
self.logger.error(f'{self.po_filename} 파일을 포함하는 언어 폴더를 찾을 수 없습니다.')
|
||||
return 0
|
||||
|
||||
self.logger.info(f'탐지된 언어: {", ".join(lang_folders)}')
|
||||
|
||||
# 각 언어별 PO 파일 파싱
|
||||
merged_data = {}
|
||||
|
||||
for lang_code in lang_folders:
|
||||
po_file_path = localization_root / lang_code / self.po_filename
|
||||
self.logger.info(f' - {lang_code} 처리 중...')
|
||||
|
||||
po = self.load_po_file(po_file_path)
|
||||
if po is None:
|
||||
continue
|
||||
|
||||
for entry in po:
|
||||
# msgctxt 추출 (언리얼 해시 키)
|
||||
msgctxt = entry.msgctxt if entry.msgctxt else 'NoContext'
|
||||
|
||||
# SourceLocation 추출
|
||||
source_location = entry.occurrences[0][0] if entry.occurrences else 'NoSourceLocation'
|
||||
|
||||
# 줄바꿈 문자를 문자열로 치환
|
||||
msgctxt_escaped = self._escape_newlines(msgctxt)
|
||||
msgid_escaped = self._escape_newlines(entry.msgid)
|
||||
msgstr_escaped = self._escape_newlines(entry.msgstr)
|
||||
source_location_escaped = self._escape_newlines(source_location)
|
||||
|
||||
# 키 생성 (msgctxt + SourceLocation)
|
||||
key = (msgctxt_escaped, source_location_escaped)
|
||||
|
||||
if key not in merged_data:
|
||||
merged_data[key] = {
|
||||
'msgid': msgid_escaped,
|
||||
'msgid_ko': None, # ko의 msgid를 별도로 저장
|
||||
}
|
||||
|
||||
# ko 언어의 msgid는 별도로 저장
|
||||
if lang_code == 'ko':
|
||||
merged_data[key]['msgid_ko'] = msgid_escaped
|
||||
|
||||
# 언어별 번역문 저장
|
||||
merged_data[key][lang_code] = msgstr_escaped
|
||||
|
||||
# CSV 레코드 생성
|
||||
records = []
|
||||
for (msgctxt, source_location), data in merged_data.items():
|
||||
# ko의 msgid가 있으면 우선 사용
|
||||
msgid = data.get('msgid_ko') if data.get('msgid_ko') else data.get('msgid')
|
||||
|
||||
record = {
|
||||
'msgctxt': msgctxt,
|
||||
'SourceLocation': source_location,
|
||||
'msgid': msgid,
|
||||
}
|
||||
|
||||
# 언어별 번역 추가
|
||||
for key, value in data.items():
|
||||
if key not in ['msgid', 'msgid_ko']:
|
||||
record[key] = value
|
||||
|
||||
records.append(record)
|
||||
|
||||
if not records:
|
||||
self.logger.error('병합할 데이터가 없습니다.')
|
||||
return 0
|
||||
|
||||
# 언어 컬럼 정렬
|
||||
all_langs = set()
|
||||
for record in records:
|
||||
all_langs.update(record.keys())
|
||||
all_langs -= {'msgctxt', 'SourceLocation', 'msgid'}
|
||||
|
||||
# 선호 순서
|
||||
preferred_order = self.config.get('languages', {}).get('targets', [])
|
||||
ordered_langs = [lang for lang in preferred_order if lang in all_langs]
|
||||
other_langs = sorted([lang for lang in all_langs if lang not in preferred_order])
|
||||
final_langs = ordered_langs + other_langs
|
||||
|
||||
# CSV 저장
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(output_path, 'w', newline='', encoding='utf-8-sig') as f:
|
||||
fieldnames = ['msgctxt', 'SourceLocation', 'msgid'] + final_langs
|
||||
writer = csv.DictWriter(f, fieldnames=fieldnames, quoting=csv.QUOTE_ALL)
|
||||
writer.writeheader()
|
||||
writer.writerows(records)
|
||||
|
||||
self.logger.success(f'CSV 병합 완료: {output_path}')
|
||||
self.logger.info(f'총 {len(records)}개 항목, {len(final_langs)}개 언어')
|
||||
|
||||
return len(records)
|
||||
|
||||
def update_from_tsv(self, tsv_path: Path, localization_root: Path,
|
||||
backup: bool = True, dry_run: bool = False) -> Dict[str, POUpdateResult]:
|
||||
"""
|
||||
TSV 파일로 PO 파일 업데이트 (polib 사용)
|
||||
|
||||
Args:
|
||||
tsv_path: 번역 TSV 파일 경로
|
||||
localization_root: 언어 폴더들의 루트
|
||||
backup: 백업 생성 여부
|
||||
dry_run: 실제 파일 수정 없이 시뮬레이션
|
||||
|
||||
Returns:
|
||||
언어별 업데이트 결과
|
||||
"""
|
||||
self.logger.info(f'TSV 파일 로드 중: {tsv_path}')
|
||||
|
||||
# TSV 파일 읽기
|
||||
translations_by_lang = self._load_tsv(tsv_path)
|
||||
|
||||
if not translations_by_lang:
|
||||
self.logger.error('TSV 파일에서 번역 데이터를 읽을 수 없습니다.')
|
||||
return {}
|
||||
|
||||
self.logger.info(f'업데이트 대상 언어: {", ".join(translations_by_lang.keys())}')
|
||||
|
||||
results = {}
|
||||
|
||||
# 언어별로 PO 파일 업데이트
|
||||
for lang_code, translations in translations_by_lang.items():
|
||||
self.logger.info(f'\n언어 처리 중: {lang_code}')
|
||||
|
||||
lang_folder = localization_root / lang_code
|
||||
if not lang_folder.is_dir():
|
||||
self.logger.warning(f' 언어 폴더를 찾을 수 없습니다: {lang_folder}')
|
||||
continue
|
||||
|
||||
po_path = lang_folder / self.po_filename
|
||||
if not po_path.exists():
|
||||
self.logger.warning(f' PO 파일을 찾을 수 없습니다: {po_path}')
|
||||
continue
|
||||
|
||||
# PO 파일 업데이트
|
||||
result = self._update_po_file(po_path, translations, backup, dry_run)
|
||||
results[lang_code] = result
|
||||
|
||||
# 결과 출력
|
||||
self._print_update_result(lang_code, result)
|
||||
|
||||
return results
|
||||
|
||||
def _update_po_file(self, po_path: Path, translations: Dict[str, str],
|
||||
backup: bool, dry_run: bool) -> POUpdateResult:
|
||||
"""단일 PO 파일 업데이트"""
|
||||
result = POUpdateResult(
|
||||
total=len(translations),
|
||||
updated=0,
|
||||
failed=0,
|
||||
skipped=0,
|
||||
errors=[]
|
||||
)
|
||||
|
||||
# 백업 생성
|
||||
if backup and not dry_run:
|
||||
backup_path = self._create_backup(po_path)
|
||||
if backup_path:
|
||||
self.logger.info(f' 백업 생성: {backup_path.name}')
|
||||
|
||||
# PO 파일 로드
|
||||
po = self.load_po_file(po_path)
|
||||
if po is None:
|
||||
result.failed = result.total
|
||||
result.errors.append(('ALL', 'PO 파일 로드 실패'))
|
||||
return result
|
||||
|
||||
# msgctxt로 인덱싱
|
||||
po_index = {}
|
||||
for entry in po:
|
||||
if entry.msgctxt:
|
||||
po_index[entry.msgctxt] = entry
|
||||
|
||||
# 번역문 업데이트
|
||||
for msgctxt, new_msgstr in translations.items():
|
||||
if msgctxt not in po_index:
|
||||
result.failed += 1
|
||||
result.errors.append((msgctxt, 'PO 파일에서 msgctxt를 찾을 수 없음'))
|
||||
continue
|
||||
|
||||
entry = po_index[msgctxt]
|
||||
current_msgstr = entry.msgstr
|
||||
|
||||
# 변경사항 없으면 스킵
|
||||
if current_msgstr == new_msgstr:
|
||||
result.skipped += 1
|
||||
continue
|
||||
|
||||
# msgstr 업데이트
|
||||
if not dry_run:
|
||||
entry.msgstr = new_msgstr
|
||||
|
||||
result.updated += 1
|
||||
|
||||
# 파일 저장
|
||||
if not dry_run and result.updated > 0:
|
||||
try:
|
||||
po.save(str(po_path))
|
||||
except Exception as e:
|
||||
self.logger.error(f' PO 파일 저장 실패: {e}')
|
||||
result.errors.append(('SAVE', str(e)))
|
||||
|
||||
return result
|
||||
|
||||
def _load_tsv(self, tsv_path: Path) -> Dict[str, Dict[str, str]]:
|
||||
"""TSV 파일 로드"""
|
||||
translations_by_lang = {}
|
||||
|
||||
try:
|
||||
with open(tsv_path, 'r', encoding='utf-8-sig') as f:
|
||||
reader = csv.DictReader(f, delimiter='\t')
|
||||
|
||||
# 컬럼 확인
|
||||
if not reader.fieldnames or len(reader.fieldnames) <= 1:
|
||||
self.logger.error('TSV 파일이 탭(tab)으로 구분되지 않았습니다.')
|
||||
return {}
|
||||
|
||||
# 제외할 컬럼
|
||||
exclude_columns = {'msgctxt', 'SourceLocation', 'msgid'}
|
||||
lang_codes = [col for col in reader.fieldnames if col not in exclude_columns]
|
||||
|
||||
# 언어별 딕셔너리 초기화
|
||||
for lang in lang_codes:
|
||||
translations_by_lang[lang] = {}
|
||||
|
||||
# 행 읽기
|
||||
for row in reader:
|
||||
msgctxt = row.get('msgctxt')
|
||||
if not msgctxt:
|
||||
continue
|
||||
|
||||
for lang in lang_codes:
|
||||
msgstr = row.get(lang, '')
|
||||
if msgstr: # 빈 문자열이 아니면 저장
|
||||
translations_by_lang[lang][msgctxt] = msgstr
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f'TSV 파일 읽기 실패: {e}')
|
||||
return {}
|
||||
|
||||
return translations_by_lang
|
||||
|
||||
def _save_to_tsv(self, entries: List[polib.POEntry], output_path: Path):
|
||||
"""POEntry 리스트를 TSV로 저장"""
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(output_path, 'w', newline='', encoding='utf-8-sig') as f:
|
||||
writer = csv.writer(f, delimiter='\t', quoting=csv.QUOTE_ALL)
|
||||
|
||||
# 헤더
|
||||
writer.writerow(['msgctxt', 'SourceLocation', 'msgid'])
|
||||
|
||||
# 데이터
|
||||
for entry in entries:
|
||||
msgctxt = self._escape_newlines(entry.msgctxt or '')
|
||||
source_location = entry.occurrences[0][0] if entry.occurrences else ''
|
||||
msgid = self._escape_newlines(entry.msgid)
|
||||
|
||||
writer.writerow([msgctxt, source_location, msgid])
|
||||
|
||||
def _escape_newlines(self, text: str) -> str:
|
||||
"""줄바꿈 문자를 문자열로 치환"""
|
||||
return text.replace('\r', '\\r').replace('\n', '\\n')
|
||||
|
||||
def _unescape_newlines(self, text: str) -> str:
|
||||
"""문자열 줄바꿈을 실제 문자로 변환"""
|
||||
return text.replace('\\r', '\r').replace('\\n', '\n')
|
||||
|
||||
def _create_backup(self, po_path: Path) -> Optional[Path]:
|
||||
"""백업 파일 생성"""
|
||||
try:
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
backup_path = po_path.with_suffix(f'.backup_{timestamp}.po')
|
||||
backup_path.write_bytes(po_path.read_bytes())
|
||||
return backup_path
|
||||
except Exception as e:
|
||||
self.logger.warning(f'백업 생성 실패: {e}')
|
||||
return None
|
||||
|
||||
def _print_update_result(self, lang_code: str, result: POUpdateResult):
|
||||
"""업데이트 결과 출력"""
|
||||
if result.updated > 0:
|
||||
self.logger.success(f' ✅ {lang_code}: {result.updated}건 업데이트')
|
||||
|
||||
if result.skipped > 0:
|
||||
self.logger.info(f' ⏭️ {lang_code}: {result.skipped}건 스킵 (변경사항 없음)')
|
||||
|
||||
if result.failed > 0:
|
||||
self.logger.error(f' ❌ {lang_code}: {result.failed}건 실패')
|
||||
|
||||
# 실패 이유 출력 (최대 5개)
|
||||
for msgctxt, error in result.errors[:5]:
|
||||
self.logger.error(f' - {msgctxt}: {error}')
|
||||
|
||||
if len(result.errors) > 5:
|
||||
self.logger.error(f' ... 외 {len(result.errors) - 5}건 더 있음')
|
||||
284
lib/validator.py
Normal file
284
lib/validator.py
Normal file
@ -0,0 +1,284 @@
|
||||
"""
|
||||
Translation Validator for DS_L10N
|
||||
번역 검증 시스템
|
||||
"""
|
||||
import re
|
||||
from typing import List, Dict, Tuple, Optional
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ValidationLevel(Enum):
|
||||
"""검증 레벨"""
|
||||
ERROR = 'ERROR' # 치명적 오류
|
||||
WARNING = 'WARNING' # 경고
|
||||
INFO = 'INFO' # 정보
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationIssue:
|
||||
"""검증 이슈"""
|
||||
level: ValidationLevel
|
||||
msgctxt: str
|
||||
lang: str
|
||||
category: str
|
||||
message: str
|
||||
original: str
|
||||
translation: str
|
||||
|
||||
def __str__(self):
|
||||
icon = {
|
||||
ValidationLevel.ERROR: '❌',
|
||||
ValidationLevel.WARNING: '⚠️',
|
||||
ValidationLevel.INFO: 'ℹ️'
|
||||
}[self.level]
|
||||
|
||||
return (f"{icon} [{self.level.value}] {self.lang}: {self.msgctxt}\n"
|
||||
f" 분류: {self.category}\n"
|
||||
f" 문제: {self.message}\n"
|
||||
f" 원문: {self.original}\n"
|
||||
f" 번역: {self.translation}")
|
||||
|
||||
|
||||
class TranslationValidator:
|
||||
"""번역 검증기"""
|
||||
|
||||
def __init__(self, config: dict):
|
||||
self.config = config
|
||||
self.validation_config = config.get('validation', {})
|
||||
|
||||
# 변수 패턴 컴파일
|
||||
self.variable_pattern = re.compile(r'\{[A-Za-z0-9_]+\}')
|
||||
|
||||
# 리치 텍스트 태그 패턴
|
||||
self.tag_patterns = config.get('rich_text', {}).get('tag_patterns', [])
|
||||
|
||||
def validate_entry(self, msgctxt: str, original: str, translation: str, lang: str) -> List[ValidationIssue]:
|
||||
"""단일 항목 검증"""
|
||||
issues = []
|
||||
|
||||
# 빈 번역 확인
|
||||
if self.validation_config.get('check_empty_translations', True):
|
||||
if not translation or not translation.strip():
|
||||
issues.append(ValidationIssue(
|
||||
level=ValidationLevel.WARNING,
|
||||
msgctxt=msgctxt,
|
||||
lang=lang,
|
||||
category='빈 번역',
|
||||
message='번역문이 비어있습니다',
|
||||
original=original,
|
||||
translation=translation
|
||||
))
|
||||
return issues # 빈 번역이면 다른 검증 건너뛰기
|
||||
|
||||
# 변수 일치 확인
|
||||
if self.validation_config.get('check_variables', True):
|
||||
var_issues = self._check_variables(msgctxt, original, translation, lang)
|
||||
issues.extend(var_issues)
|
||||
|
||||
# 리치 텍스트 태그 확인
|
||||
if self.validation_config.get('check_rich_text_tags', True):
|
||||
tag_issues = self._check_rich_text_tags(msgctxt, original, translation, lang)
|
||||
issues.extend(tag_issues)
|
||||
|
||||
# 줄바꿈 확인
|
||||
if self.validation_config.get('check_newlines', True):
|
||||
newline_issues = self._check_newlines(msgctxt, original, translation, lang)
|
||||
issues.extend(newline_issues)
|
||||
|
||||
# 최대 길이 확인
|
||||
max_length = self.validation_config.get('max_length_warning', 200)
|
||||
if len(translation) > max_length:
|
||||
issues.append(ValidationIssue(
|
||||
level=ValidationLevel.INFO,
|
||||
msgctxt=msgctxt,
|
||||
lang=lang,
|
||||
category='길이 초과',
|
||||
message=f'번역문이 {max_length}자를 초과합니다 (현재: {len(translation)}자)',
|
||||
original=original,
|
||||
translation=translation
|
||||
))
|
||||
|
||||
return issues
|
||||
|
||||
def _check_variables(self, msgctxt: str, original: str, translation: str, lang: str) -> List[ValidationIssue]:
|
||||
"""변수 일치 확인"""
|
||||
issues = []
|
||||
|
||||
orig_vars = set(self.variable_pattern.findall(original))
|
||||
trans_vars = set(self.variable_pattern.findall(translation))
|
||||
|
||||
# 누락된 변수
|
||||
missing_vars = orig_vars - trans_vars
|
||||
if missing_vars:
|
||||
issues.append(ValidationIssue(
|
||||
level=ValidationLevel.ERROR,
|
||||
msgctxt=msgctxt,
|
||||
lang=lang,
|
||||
category='변수 누락',
|
||||
message=f'누락된 변수: {", ".join(sorted(missing_vars))}',
|
||||
original=original,
|
||||
translation=translation
|
||||
))
|
||||
|
||||
# 추가된 변수
|
||||
extra_vars = trans_vars - orig_vars
|
||||
if extra_vars:
|
||||
issues.append(ValidationIssue(
|
||||
level=ValidationLevel.WARNING,
|
||||
msgctxt=msgctxt,
|
||||
lang=lang,
|
||||
category='추가 변수',
|
||||
message=f'원문에 없는 변수: {", ".join(sorted(extra_vars))}',
|
||||
original=original,
|
||||
translation=translation
|
||||
))
|
||||
|
||||
return issues
|
||||
|
||||
def _check_rich_text_tags(self, msgctxt: str, original: str, translation: str, lang: str) -> List[ValidationIssue]:
|
||||
"""리치 텍스트 태그 일치 확인"""
|
||||
issues = []
|
||||
|
||||
# 여는 태그와 닫는 태그 개수 확인
|
||||
for tag in self.tag_patterns:
|
||||
if tag == '</>':
|
||||
continue # 닫는 태그는 별도 처리
|
||||
|
||||
orig_count = original.count(tag)
|
||||
trans_count = translation.count(tag)
|
||||
|
||||
if orig_count != trans_count:
|
||||
issues.append(ValidationIssue(
|
||||
level=ValidationLevel.ERROR,
|
||||
msgctxt=msgctxt,
|
||||
lang=lang,
|
||||
category='태그 불일치',
|
||||
message=f'태그 {tag} 개수 불일치 (원문: {orig_count}, 번역: {trans_count})',
|
||||
original=original,
|
||||
translation=translation
|
||||
))
|
||||
|
||||
# 닫는 태그 </> 개수 확인
|
||||
orig_close = original.count('</>')
|
||||
trans_close = translation.count('</>')
|
||||
|
||||
if orig_close != trans_close:
|
||||
issues.append(ValidationIssue(
|
||||
level=ValidationLevel.ERROR,
|
||||
msgctxt=msgctxt,
|
||||
lang=lang,
|
||||
category='태그 불일치',
|
||||
message=f'닫는 태그 </> 개수 불일치 (원문: {orig_close}, 번역: {trans_close})',
|
||||
original=original,
|
||||
translation=translation
|
||||
))
|
||||
|
||||
return issues
|
||||
|
||||
def _check_newlines(self, msgctxt: str, original: str, translation: str, lang: str) -> List[ValidationIssue]:
|
||||
"""줄바꿈 문자 확인"""
|
||||
issues = []
|
||||
|
||||
# \r\n, \n, \r 개수 확인
|
||||
orig_crlf = original.count('\\r\\n')
|
||||
trans_crlf = translation.count('\\r\\n')
|
||||
|
||||
orig_lf = original.count('\\n') - orig_crlf # \\r\\n에 포함된 \\n 제외
|
||||
trans_lf = translation.count('\\n') - trans_crlf
|
||||
|
||||
orig_cr = original.count('\\r') - orig_crlf # \\r\\n에 포함된 \\r 제외
|
||||
trans_cr = translation.count('\\r') - trans_crlf
|
||||
|
||||
if orig_crlf != trans_crlf or orig_lf != trans_lf or orig_cr != trans_cr:
|
||||
issues.append(ValidationIssue(
|
||||
level=ValidationLevel.WARNING,
|
||||
msgctxt=msgctxt,
|
||||
lang=lang,
|
||||
category='줄바꿈 불일치',
|
||||
message=f'줄바꿈 문자 개수 불일치 (원문: \\r\\n={orig_crlf}, \\n={orig_lf}, \\r={orig_cr} / '
|
||||
f'번역: \\r\\n={trans_crlf}, \\n={trans_lf}, \\r={trans_cr})',
|
||||
original=original,
|
||||
translation=translation
|
||||
))
|
||||
|
||||
return issues
|
||||
|
||||
def validate_batch(self, entries: List[Dict]) -> Tuple[List[ValidationIssue], Dict[str, int]]:
|
||||
"""
|
||||
여러 항목 일괄 검증
|
||||
|
||||
Args:
|
||||
entries: [{'msgctxt': ..., 'msgid': ..., 'lang': ..., 'msgstr': ...}, ...]
|
||||
|
||||
Returns:
|
||||
(issues, stats)
|
||||
"""
|
||||
all_issues = []
|
||||
|
||||
for entry in entries:
|
||||
msgctxt = entry.get('msgctxt', '')
|
||||
original = entry.get('msgid', '')
|
||||
translation = entry.get('msgstr', '')
|
||||
lang = entry.get('lang', '')
|
||||
|
||||
issues = self.validate_entry(msgctxt, original, translation, lang)
|
||||
all_issues.extend(issues)
|
||||
|
||||
# 통계 생성
|
||||
stats = {
|
||||
'total': len(entries),
|
||||
'errors': sum(1 for issue in all_issues if issue.level == ValidationLevel.ERROR),
|
||||
'warnings': sum(1 for issue in all_issues if issue.level == ValidationLevel.WARNING),
|
||||
'info': sum(1 for issue in all_issues if issue.level == ValidationLevel.INFO),
|
||||
'passed': len(entries) - len(set(issue.msgctxt for issue in all_issues if issue.level == ValidationLevel.ERROR))
|
||||
}
|
||||
|
||||
return all_issues, stats
|
||||
|
||||
def print_validation_report(self, issues: List[ValidationIssue], stats: Dict[str, int], logger):
|
||||
"""검증 리포트 출력"""
|
||||
logger.section('🔍 번역 검증 결과')
|
||||
|
||||
if not issues:
|
||||
logger.success('✅ 모든 검증 통과!')
|
||||
logger.stats(**stats)
|
||||
return
|
||||
|
||||
# 레벨별로 그룹화
|
||||
errors = [i for i in issues if i.level == ValidationLevel.ERROR]
|
||||
warnings = [i for i in issues if i.level == ValidationLevel.WARNING]
|
||||
infos = [i for i in issues if i.level == ValidationLevel.INFO]
|
||||
|
||||
# 오류 출력
|
||||
if errors:
|
||||
logger.error(f'\n❌ 오류 ({len(errors)}건):')
|
||||
for issue in errors[:10]: # 최대 10개만 출력
|
||||
logger.error(f'\n{issue}')
|
||||
if len(errors) > 10:
|
||||
logger.error(f'\n... 외 {len(errors) - 10}건 더 있음')
|
||||
|
||||
# 경고 출력
|
||||
if warnings:
|
||||
logger.warning(f'\n⚠️ 경고 ({len(warnings)}건):')
|
||||
for issue in warnings[:10]: # 최대 10개만 출력
|
||||
logger.warning(f'\n{issue}')
|
||||
if len(warnings) > 10:
|
||||
logger.warning(f'\n... 외 {len(warnings) - 10}건 더 있음')
|
||||
|
||||
# 정보 출력
|
||||
if infos:
|
||||
logger.info(f'\nℹ️ 정보 ({len(infos)}건):')
|
||||
for issue in infos[:5]: # 최대 5개만 출력
|
||||
logger.info(f'\n{issue}')
|
||||
if len(infos) > 5:
|
||||
logger.info(f'\n... 외 {len(infos) - 5}건 더 있음')
|
||||
|
||||
# 통계 출력
|
||||
logger.separator()
|
||||
logger.stats(**stats)
|
||||
|
||||
if errors:
|
||||
logger.error(f'\n검증 실패: {stats["errors"]}개의 오류가 발견되었습니다.')
|
||||
else:
|
||||
logger.success(f'\n검증 완료: {stats["passed"]}건 통과 (경고 {stats["warnings"]}건)')
|
||||
Reference in New Issue
Block a user