#!/usr/bin/env bash set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" python3 - "$ROOT_DIR" <<'PY' import json import pathlib import re import sys root = pathlib.Path(sys.argv[1]) l10n_swift = root / "Apptag" / "L10n.swift" localization_dir = root / "Apptag" / "Localization" english_path = localization_dir / "en.json" EXPECTED_LANGUAGE_COUNT = 29 ALLOWED_ENGLISH_COPY_KEYS = { "pro.card.badge", "settings.proCompare.pro", } REQUIRED_PLACEHOLDERS = { "pro.notes.remaining": {"%limit%", "%remaining%"}, "pro.theme.status.previewCountdown": {"%time%"}, } REQUIRED_NON_PRO_KEYS = [ "settings.proStatus.freeUser", "settings.proStatus.proUser", "settings.proStatus.lockedListTitle", "settings.proStatus.unlockedListTitle", "settings.proStatus.lockedFeature", "settings.proStatus.unlockedFeature", "settings.proStatus.freeNotes", "settings.proStatus.unlimitedNotes", "settings.proStatus.limitedFeature", "settings.proStatus.unsupportedFeature", "settings.proStatus.unavailableFeature", "settings.proStatus.previewFiveMinutes", "settings.proFeature.appNotes", "settings.proFeature.dataBackup", "settings.proBenefit.general", "settings.proBenefit.hotkeys", "settings.proBenefit.tags", "settings.proBenefit.data", "settings.proBenefit.about", "settings.proGuide.general", "settings.proGuide.hotkeys", "settings.proGuide.tags", "settings.proGuide.data", "settings.proCompare.title", "settings.proCompare.free", "settings.proCompare.pro", "tag.customColor", ] REQUIRED_VISIBLE_SETTINGS_KEYS = [ "settings.bubbleDisplayScope", "settings.bubbleAllApps", "settings.bubbleUncommonOnly", "quickSearch.hotkeys", "quickSearch.mainHotkey", "quickSearch.mainHotkeyDesc", "quickSearch.internalHotkey", "quickSearch.internalHotkeyDesc", "quickSearch.internalHotkeyStatus", "quickSearch.globalHotkey", "quickSearch.globalHotkeyDesc", "quickSearch.status.Active", "quickSearch.status.registrationFailed", "quickSearch.status.unavailable", "quickSearch.mainHotkeyUnavailableMessage", "quickSearch.globalHotkeyUnavailableMessage", "quickSearch.spaceDisplay", ] OLD_NOTE_LIMIT_PATTERNS = [ re.compile(r"5[- ]?(note|notes|app notes)", re.I), re.compile(r"5\s*个应用备注"), re.compile(r"5\s*條備註"), re.compile(r"5\s*条备注"), ] def fail(message: str) -> None: print(f"FAIL: {message}", file=sys.stderr) sys.exit(1) def load_json(path: pathlib.Path) -> dict[str, str]: try: data = json.loads(path.read_text(encoding="utf-8")) except Exception as error: fail(f"{path} is not valid JSON: {error}") if not isinstance(data, dict): fail(f"{path} must contain a JSON object") return data def extract_language_codes() -> list[str]: text = l10n_swift.read_text(encoding="utf-8") match = re.search( r"static\s+let\s+supported\s*:\s*\[\(code:\s*String,\s*name:\s*String\)\]\s*=\s*\[(.*?)\n\s*\]", text, re.S, ) if not match: fail("could not find L10n.supported language list") codes = re.findall(r'\("([^"]+)",\s*"[^"]+"\)', match.group(1)) if len(codes) != EXPECTED_LANGUAGE_COUNT: fail(f"L10n.supported has {len(codes)} languages, expected {EXPECTED_LANGUAGE_COUNT}") if len(set(codes)) != len(codes): fail("L10n.supported contains duplicate language codes") return codes language_codes = extract_language_codes() english = load_json(english_path) pro_keys = [key for key in english if key.startswith("pro.")] if not pro_keys: fail("en.json has no pro.* keys") if english.get("settings.proStatus.lockedFeature") != "✅ Supported": fail("en.json Pro comparison support status must be '✅ Supported'") if english.get("settings.proStatus.unlimitedNotes") != "✅ Unlimited notes": fail("en.json Pro comparison unlimited notes status must include the leading checkmark") if english.get("settings.proStatus.unlockedFeature") != "✅ Unlocked": fail("en.json Pro comparison unlocked status must be '✅ Unlocked'") zh_hans = load_json(localization_dir / "zh-Hans.json") if zh_hans.get("settings.proStatus.lockedFeature") != "✅支持": fail("zh-Hans.json Pro comparison support status must be '✅支持'") if zh_hans.get("settings.proStatus.unlimitedNotes") != "✅无限备注": fail("zh-Hans.json Pro comparison unlimited notes status must be '✅无限备注'") if zh_hans.get("settings.proStatus.unlockedFeature") != "✅已解锁": fail("zh-Hans.json Pro comparison unlocked status must be '✅已解锁'") ko = load_json(localization_dir / "ko.json") if ko.get("quickSearch.hotkeys") != "키보드 단축키": fail("ko.json quickSearch.hotkeys must not fall back to English") if ko.get("quickSearch.mainHotkey") != "앱 목록": fail("ko.json quickSearch.mainHotkey must not fall back to English") if ko.get("quickSearch.status.Active") != "활성": fail("ko.json quickSearch.status.Active must not fall back to English") if ko.get("settings.bubbleDisplayScope") != "말풍선 팁 표시 대상:": fail("ko.json settings.bubbleDisplayScope must not fall back to English") ar = load_json(localization_dir / "ar.json") if ar.get("quickSearch.hotkeys") != "اختصارات لوحة المفاتيح": fail("ar.json quickSearch.hotkeys must not fall back to English") if ar.get("quickSearch.mainHotkey") != "قائمة التطبيقات": fail("ar.json quickSearch.mainHotkey must not fall back to English") if ar.get("quickSearch.status.Active") != "مفعّل": fail("ar.json quickSearch.status.Active must not fall back to English") if ar.get("settings.bubbleDisplayScope") != "إظهار تلميحات الفقاعات لـ:": fail("ar.json settings.bubbleDisplayScope must not fall back to English") for code in language_codes: path = localization_dir / f"{code}.json" if not path.is_file(): fail(f"missing localization file: {path}") data = load_json(path) required_keys = pro_keys + REQUIRED_NON_PRO_KEYS + REQUIRED_VISIBLE_SETTINGS_KEYS missing = [ key for key in required_keys if not isinstance(data.get(key), str) or not data.get(key, "").strip() ] if missing: fail(f"{code}.json is missing Pro/settings translations: {', '.join(missing)}") badge = data["pro.card.badge"] if data.get("pro.card.title.free") == badge: fail(f"{code}.json uses the badge text as the upgrade title for pro.card.title.free") dirty_keys = [key for key in required_keys if "__" in data[key] or "###FILE" in data[key]] if dirty_keys: fail(f"{code}.json still contains placeholder artifacts: {', '.join(dirty_keys)}") old_limit_keys = [ key for key, value in data.items() if isinstance(value, str) and any(pattern.search(value) for pattern in OLD_NOTE_LIMIT_PATTERNS) ] if old_limit_keys: fail(f"{code}.json still contains old 5-note copy: {', '.join(old_limit_keys)}") placeholder_errors = [] for key, placeholders in REQUIRED_PLACEHOLDERS.items(): value = data.get(key, "") missing_placeholders = sorted(placeholder for placeholder in placeholders if placeholder not in value) if missing_placeholders: placeholder_errors.append(f"{key} missing {', '.join(missing_placeholders)}") if placeholder_errors: fail(f"{code}.json has broken Pro placeholders: {'; '.join(placeholder_errors)}") if code != "en": copied_english = [ key for key in required_keys if key not in ALLOWED_ENGLISH_COPY_KEYS and data.get(key) == english.get(key) ] if copied_english: fail(f"{code}.json copies English Pro copy: {', '.join(copied_english)}") print( "PASS Pro localization QA: " f"{len(language_codes)} languages, {len(pro_keys) + len(REQUIRED_NON_PRO_KEYS) + len(REQUIRED_VISIBLE_SETTINGS_KEYS)} Pro/settings keys, " "no placeholder artifacts or English fallback copies" ) PY