#!/usr/bin/env python3 import argparse import sys import io import contextlib import re from pathlib import Path from collections import defaultdict from concurrent.futures import ThreadPoolExecutor from babel.messages.frontend import CommandLineInterface from pyaspeller import YandexSpeller # ---------- Пути ---------- GUIDE_DIR = Path(__file__).parent.parent / "documentation" / "localization_guide" README_EN = GUIDE_DIR / "README.md" README_RU = GUIDE_DIR / "README.ru.md" LOCALES_PATH = Path(__file__).parent.parent / "portprotonqt" / "locales" THEMES_PATH = Path(__file__).parent.parent / "portprotonqt" / "themes" README_FILES = [README_EN, README_RU] POT_FILE = LOCALES_PATH / "messages.pot" # ---------- Версия проекта ---------- def _get_version() -> str: return "0.1.1" # ---------- Обновление README ---------- def _update_coverage(lines: list[str]) -> None: # Парсим статистику из вывода pybabel --statistics locales_stats = [line for line in lines if line.endswith(".po")] # Извлекаем (count, pct, locale) и сортируем rows = sorted( (m := re.search( r"""(\d+\ of\ \d+).* # message counts \((\d+\%)\).* # message percentage locales\/(.*)\/LC_MESSAGES # locale name""", stat, re.VERBOSE )) and m.groups() for stat in locales_stats ) for md_file in README_FILES: if not md_file.exists(): continue text = md_file.read_text(encoding="utf-8") is_ru = (md_file == README_RU) # Выбираем заголовок раздела status_header = ( "Current translation status:" if not is_ru else "Текущий статус перевода:" ) # Формируем шапку и строки таблицы if is_ru: table_header = ( "\n\n" "| Локаль | Прогресс | Переведено |\n" "| :----- | -------: | ---------: |\n" ) fmt = lambda count, pct, loc: f"| [{loc}](./{loc}/LC_MESSAGES/messages.po) | {pct} | {count.replace(' of ', ' из ')} |" else: table_header = ( "\n\n" "| Locale | Progress | Translated |\n" "| :----- | -------: | ---------: |\n" ) fmt = lambda count, pct, loc: f"| [{loc}](./{loc}/LC_MESSAGES/messages.po) | {pct} | {count} |" # Собираем строки и добавляем '---' в конце coverage_table = ( table_header + "\n".join(fmt(c, p, l) for c, p, l in rows) + "\n\n---" ) # Удаляем старую автоматически сгенерированную таблицу old_block = ( r"" r".*?(?=\n(?:##|\Z))" ) cleaned = re.sub(old_block, "", text, flags=re.DOTALL) # Вставляем новую таблицу сразу после строки с заголовком insert_pattern = rf"(^.*{re.escape(status_header)}.*$)" new_text = re.sub( insert_pattern, lambda m: m.group(1) + "\n\n" + coverage_table, cleaned, count=1, flags=re.MULTILINE ) # Записываем файл, если были изменения if new_text != text: md_file.write_text(new_text, encoding="utf-8") # ---------- PyBabel команды ---------- def compile_locales() -> None: CommandLineInterface().run([ "pybabel", "compile", "--use-fuzzy", "--directory", f"{LOCALES_PATH.resolve()}", "--statistics" ]) def extract_strings() -> None: input_dir = (Path(__file__).parent.parent / "portprotonqt").resolve() CommandLineInterface().run([ "pybabel", "extract", "--project=PortProtonQT", f"--version={_get_version()}", "--strip-comment-tag", "--no-location", f"--input-dir={input_dir}", "--copyright-holder=boria138", f"--ignore-dirs={THEMES_PATH}", f"--output-file={POT_FILE.resolve()}" ]) def update_locales() -> None: CommandLineInterface().run([ "pybabel", "update", f"--input-file={POT_FILE.resolve()}", f"--output-dir={LOCALES_PATH.resolve()}", "--ignore-obsolete", "--update-header-comment", ]) def create_new(locales: list[str]) -> None: if not POT_FILE.exists(): extract_strings() for locale in locales: CommandLineInterface().run([ "pybabel", "init", f"--input-file={POT_FILE.resolve()}", f"--output-dir={LOCALES_PATH.resolve()}", f"--locale={locale}" ]) # ---------- Игнорируемые префиксы для spellcheck ---------- IGNORED_PREFIXES = () def load_ignored_prefixes(ignore_file=".spellignore"): path = Path(__file__).parent / ignore_file try: return tuple(path.read_text(encoding='utf-8').splitlines()) except FileNotFoundError: return () IGNORED_PREFIXES = load_ignored_prefixes() + ("PortProton", "flatpak") # ---------- Проверка орфографии с параллелизмом ---------- speller = YandexSpeller() MSGID_RE = re.compile(r'^msgid\s+"(.*)"') MSGSTR_RE = re.compile(r'^msgstr\s+"(.*)"') def extract_po_strings(filepath: Path) -> list[str]: # Collect all strings, then filter by ignore list texts, current_key, buffer = [], None, "" def flush(): nonlocal buffer if buffer.strip(): texts.append(buffer) buffer = "" for line in filepath.read_text(encoding='utf-8').splitlines(): stripped = line.strip() if stripped.startswith("msgid ") and filepath.suffix == '.pot': flush(); current_key = 'msgid'; buffer = MSGID_RE.match(stripped).group(1) or '' elif stripped.startswith("msgstr "): flush(); current_key = 'msgstr'; buffer = MSGSTR_RE.match(stripped).group(1) or '' elif stripped.startswith('"') and stripped.endswith('"') and current_key: buffer += stripped[1:-1] else: flush(); current_key = None flush() # Final filter: remove ignored and multi-line return [ t for t in texts if t.strip() and all(pref not in t for pref in IGNORED_PREFIXES) and "\n" not in t ] def _check_text(text: str) -> tuple[str, list[dict]]: result = speller.spell(text) errors = [r for r in result if r.get('word') and r.get('s')] return text, errors def check_file(filepath: Path, issues_summary: dict) -> bool: print(f"Checking file: {filepath}") texts = extract_po_strings(filepath) has_errors = False printed_err = False with ThreadPoolExecutor(max_workers=8) as pool: for text, errors in pool.map(_check_text, texts): print(f' In string: "{text}"') if errors: if not printed_err: print(f"❌ Errors in file: {filepath}") printed_err = True has_errors = True for err in errors: print(f" - typo: {err['word']}, suggestions: {', '.join(err['s'])}") issues_summary[filepath].extend([(text, err) for err in errors]) return has_errors # ---------- Основной обработчик ---------- def main(args) -> int: if args.update_all: extract_strings(); update_locales() if args.create_new: create_new(args.create_new) if args.spellcheck: files = list(LOCALES_PATH.glob("**/*.po")) + [POT_FILE] seen = set(); has_err = False issues_summary = defaultdict(list) for f in files: if not f.exists() or f in seen: continue seen.add(f) if check_file(f, issues_summary): has_err = True else: print(f"✅ {f} — no errors found.") if has_err: print("\n📋 Summary of Spelling Errors:") for file, errs in issues_summary.items(): print(f"\n✗ {file}") print("-----") for idx, (text, err) in enumerate(errs, 1): print(f"{idx}. In '{text}': typo '{err['word']}', suggestions: {', '.join(err['s'])}") print("-----") return 1 if has_err else 0 extract_strings(); compile_locales() return 0 if __name__ == "__main__": parser = argparse.ArgumentParser(prog="l10n", description="Localization utility for PortProtonQT.") parser.add_argument("--create-new", nargs='+', type=str, default=False, help="Create .po for new locales") parser.add_argument("--update-all", action='store_true', help="Extract/update locales and update README coverage") parser.add_argument("--spellcheck", action='store_true', help="Run spellcheck on POT and PO files") args = parser.parse_args() if args.spellcheck: sys.exit(main(args)) f = io.StringIO() with contextlib.redirect_stdout(f), contextlib.redirect_stderr(f): main(args) output = f.getvalue().splitlines() _update_coverage(output) sys.exit(0)