#!/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
|