Boris Yumankulov abec9bbef8
Move repo from git to gitea
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-06-01 15:21:32 +05:00

247 lines
9.3 KiB
Python
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 = (
"<!-- Auto-generated coverage table -->\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"<!--\s*(?:Сгенерировано автоматически!|Auto-generated coverage table)\s*-->"
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)