● 파일명 변경을 완료했습니다:
1. po_extract_untranslated.py - PO 파일에서 번역되지 않은 문자열을 CSV로 추출 2. po_merge_to_csv.py - 여러 언어의 PO 파일을 하나의 CSV로 병합 3. po_update_from_tsv.py - TSV/CSV 파일의 번역을 PO 파일에 반영 새로운 파일명은 Python 컨벤션을 따르며, 각 스크립트의 기능을 명확하게 나타냅니다.
This commit is contained in:
227
po_update_from_tsv.py
Normal file
227
po_update_from_tsv.py
Normal file
@ -0,0 +1,227 @@
|
||||
import csv
|
||||
import os
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from tkinter import filedialog, Tk
|
||||
|
||||
# --- 설정 ---
|
||||
TARGET_PO_FILE = 'LocalExport.po'
|
||||
LOG_FILE = 'update_po.log'
|
||||
MSGCTXT_COLUMN = 'msgctxt'
|
||||
EXCLUDE_COLUMNS = ['SourceLocation', 'msgid']
|
||||
|
||||
# --- 로깅 설정 ---
|
||||
stream_handler = logging.StreamHandler()
|
||||
stream_handler.setFormatter(logging.Formatter('%(asctime)s [%(levelname)s] - %(message)s'))
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
handlers=[stream_handler]
|
||||
)
|
||||
|
||||
def get_paths_from_user():
|
||||
"""
|
||||
Tkinter를 사용하여 사용자로부터 입력 파일 경로와 루트 폴더 경로를 받습니다.
|
||||
"""
|
||||
root = Tk()
|
||||
root.withdraw()
|
||||
|
||||
logging.info("번역 데이터가 포함된 원본 파일을 선택해주세요 (.csv 또는 .txt)")
|
||||
input_file_path = filedialog.askopenfilename(
|
||||
title="번역 원본 파일 선택",
|
||||
filetypes=(("Text files", "*.txt"), ("CSV files", "*.csv"), ("All files", "*.*"))
|
||||
)
|
||||
if not input_file_path:
|
||||
logging.warning("파일을 선택하지 않았습니다. 스크립트를 종료합니다.")
|
||||
return None, None
|
||||
|
||||
logging.info(f"선택된 파일: {input_file_path}")
|
||||
logging.info("업데이트할 PO 파일이 들어있는 언어 폴더들의 상위 폴더(루트 폴더)를 선택해주세요.")
|
||||
|
||||
root_folder_path = filedialog.askdirectory(
|
||||
title="언어 폴더들의 루트 폴더 선택 (예: LocalExport 폴더)"
|
||||
)
|
||||
if not root_folder_path:
|
||||
logging.warning("폴더를 선택하지 않았습니다. 스크립트를 종료합니다.")
|
||||
return None, None
|
||||
|
||||
logging.info(f"선택된 루트 폴더: {root_folder_path}")
|
||||
|
||||
return input_file_path, root_folder_path
|
||||
|
||||
|
||||
def parse_po_file(file_path):
|
||||
"""
|
||||
PO 파일을 파싱하여 msgctxt를 키로 하는 딕셔너리를 반환합니다.
|
||||
"""
|
||||
normalized_path = os.path.normpath(file_path)
|
||||
if not os.path.exists(normalized_path):
|
||||
logging.warning(f"PO 파일을 찾을 수 없습니다: {normalized_path}")
|
||||
return None
|
||||
|
||||
translations = {}
|
||||
try:
|
||||
with open(normalized_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
pattern = re.compile(
|
||||
r'(?:#.KEY:\s*"(.*)"\s*\n)?'
|
||||
r'msgctxt\s+"((?:[^"\\]|\\.)*)"\s*\n'
|
||||
r'msgid\s+"((?:[^"\\]|\\.)*)"\s*\n'
|
||||
r'msgstr\s+"((?:[^"\\]|\\.)*)"',
|
||||
re.DOTALL
|
||||
)
|
||||
|
||||
for match in pattern.finditer(content):
|
||||
msgctxt_key = match.group(1) or match.group(2)
|
||||
msgctxt_value = match.group(2).replace('\\"', '"')
|
||||
msgid_value = match.group(3).replace('\\"', '"')
|
||||
msgstr_value = match.group(4).replace('\\"', '"')
|
||||
|
||||
translations[msgctxt_value] = {
|
||||
"msgid": msgid_value,
|
||||
"msgstr": msgstr_value,
|
||||
"original_block": match.group(0)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"PO 파일 파싱 중 오류 발생: {normalized_path} - {e}")
|
||||
return None
|
||||
|
||||
return translations
|
||||
|
||||
def update_po_file(file_path, translations_to_update):
|
||||
"""
|
||||
PO 파일의 내용을 업데이트합니다.
|
||||
"""
|
||||
po_data = parse_po_file(file_path)
|
||||
if po_data is None:
|
||||
return 0
|
||||
|
||||
update_count = 0
|
||||
normalized_path = os.path.normpath(file_path)
|
||||
|
||||
try:
|
||||
with open(normalized_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
for msgctxt, new_msgstr in translations_to_update.items():
|
||||
if msgctxt in po_data:
|
||||
entry = po_data[msgctxt]
|
||||
original_block = entry['original_block']
|
||||
|
||||
old_msgstr_part = f'msgstr "{entry["msgstr"]}"'
|
||||
escaped_new_msgstr = new_msgstr.replace('"', '\\"')
|
||||
new_msgstr_part = f'msgstr "{escaped_new_msgstr}"'
|
||||
|
||||
if old_msgstr_part in original_block:
|
||||
updated_block = original_block.replace(old_msgstr_part, new_msgstr_part)
|
||||
content = content.replace(original_block, updated_block)
|
||||
logging.info(f" > 업데이트: msgctxt='{msgctxt}'")
|
||||
update_count += 1
|
||||
else:
|
||||
logging.warning(f" > 패턴 불일치 (업데이트 실패): msgctxt='{msgctxt}'의 msgstr을 원본에서 찾지 못했습니다.")
|
||||
else:
|
||||
logging.warning(f" > msgctxt 찾기 실패: '{msgctxt}'를 PO 파일에서 찾을 수 없습니다.")
|
||||
|
||||
if update_count > 0:
|
||||
with open(normalized_path, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"PO 파일 업데이트 중 오류 발생: {normalized_path} - {e}")
|
||||
return 0
|
||||
|
||||
return update_count
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
메인 실행 함수
|
||||
"""
|
||||
input_file, root_folder = get_paths_from_user()
|
||||
if not input_file or not root_folder:
|
||||
input("\n엔터 키를 눌러 프로그램을 종료하세요...")
|
||||
return
|
||||
|
||||
log_file_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), LOG_FILE)
|
||||
file_handler = logging.FileHandler(log_file_path, 'w', encoding='utf-8')
|
||||
file_handler.setFormatter(logging.Formatter('%(asctime)s [%(levelname)s] - %(message)s'))
|
||||
logging.getLogger().addHandler(file_handler)
|
||||
|
||||
logging.info("="*50)
|
||||
logging.info("PO 파일 업데이트 스크립트를 시작합니다.")
|
||||
logging.info(f"시작 시간: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
logging.info(f"선택된 입력 파일: '{os.path.normpath(input_file)}'")
|
||||
logging.info(f"선택된 루트 폴더: '{os.path.normpath(root_folder)}'")
|
||||
logging.info("="*50)
|
||||
|
||||
translations_by_lang = {}
|
||||
try:
|
||||
with open(input_file, 'r', encoding='utf-8-sig') as f:
|
||||
# **수정된 부분**: Sniffer 대신, 탭(\t)을 구분자로 명시적으로 지정
|
||||
reader = csv.DictReader(f, delimiter='\t')
|
||||
|
||||
# 안전장치: 필드가 1개 이하면 구분자 문제를 경고
|
||||
if len(reader.fieldnames) <= 1:
|
||||
logging.warning("파일에서 컬럼을 2개 미만으로 감지했습니다. 입력 파일이 탭(tab)으로 구분된 것이 맞는지 확인해주세요.")
|
||||
|
||||
lang_codes = [h for h in reader.fieldnames if h not in EXCLUDE_COLUMNS and h != MSGCTXT_COLUMN]
|
||||
|
||||
for lang in lang_codes:
|
||||
translations_by_lang[lang] = {}
|
||||
|
||||
for row in reader:
|
||||
msgctxt = row.get(MSGCTXT_COLUMN)
|
||||
if not msgctxt:
|
||||
continue
|
||||
|
||||
for lang in lang_codes:
|
||||
if row.get(lang):
|
||||
translations_by_lang[lang][msgctxt] = row[lang]
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"입력 파일 처리 중 오류 발생: {e}")
|
||||
input("\n엔터 키를 눌러 프로그램을 종료하세요...")
|
||||
return
|
||||
|
||||
logging.info(f"성공적으로 '{os.path.basename(input_file)}' 파일을 읽었습니다.")
|
||||
logging.info(f"제외 컬럼({EXCLUDE_COLUMNS})을 제외한 업데이트 대상 언어: {list(translations_by_lang.keys())}")
|
||||
|
||||
total_updated_files = 0
|
||||
total_updated_entries = 0
|
||||
|
||||
for lang_code in list(translations_by_lang.keys()):
|
||||
lang_folder_path = os.path.join(root_folder, lang_code)
|
||||
|
||||
if not os.path.isdir(lang_folder_path):
|
||||
logging.warning(f"언어 폴더를 찾을 수 없습니다: '{os.path.normpath(lang_folder_path)}'. 건너뜁니다.")
|
||||
continue
|
||||
|
||||
po_file_path = os.path.join(lang_folder_path, TARGET_PO_FILE)
|
||||
|
||||
if os.path.exists(po_file_path):
|
||||
logging.info(f"\n--- '{os.path.normpath(po_file_path)}' 파일 처리 시작 ---")
|
||||
|
||||
if lang_code in translations_by_lang and translations_by_lang[lang_code]:
|
||||
update_count = update_po_file(po_file_path, translations_by_lang[lang_code])
|
||||
|
||||
if update_count > 0:
|
||||
logging.info(f"'{os.path.normpath(po_file_path)}' 파일에 총 {update_count}개의 항목을 업데이트했습니다.")
|
||||
total_updated_files += 1
|
||||
total_updated_entries += update_count
|
||||
else:
|
||||
logging.info(f"'{os.path.normpath(po_file_path)}' 파일에 업데이트할 내용이 없거나, 업데이트에 실패했습니다.")
|
||||
else:
|
||||
logging.warning(f"대상 PO 파일을 찾을 수 없습니다: '{os.path.normpath(po_file_path)}'.")
|
||||
|
||||
logging.info("\n" + "="*50)
|
||||
logging.info("모든 작업이 완료되었습니다.")
|
||||
logging.info(f"총 {total_updated_files}개의 PO 파일을 업데이트했습니다.")
|
||||
logging.info(f"총 {total_updated_entries}개의 번역 항목을 업데이트했습니다.")
|
||||
logging.info(f"종료 시간: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
logging.info(f"자세한 내용은 '{os.path.normpath(log_file_path)}' 파일을 확인해주세요.")
|
||||
logging.info("="*50)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user