Add script to detect obsolete or undefined locale strings

This commit is contained in:
Miguel Jacq 2025-11-21 12:35:17 +11:00
parent e7ef615053
commit 4fb5be96b1
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
6 changed files with 258 additions and 49 deletions

View file

@ -5,7 +5,6 @@
"db_version_id_does_not_belong_to_the_given_date": "version_id does not belong to the given date",
"db_key_incorrect": "The key is probably incorrect",
"db_database_error": "Database error",
"database_path": "Database path",
"database_maintenance": "Database maintenance",
"database_compact": "Compact the database",
"database_compact_explanation": "Compacting runs VACUUM on the database. This can help reduce its size.",
@ -33,9 +32,7 @@
"system": "System",
"light": "Light",
"dark": "Dark",
"behaviour": "Behaviour",
"never": "Never",
"browse": "Browse",
"close_tab": "Close tab",
"previous": "Previous",
"previous_day": "Previous day",
@ -45,7 +42,6 @@
"show": "Show",
"history": "History",
"view_history": "View History",
"export": "Export",
"export_accessible_flag": "&Export",
"export_entries": "Export entries",
"export_complete": "Export complete",
@ -123,20 +119,11 @@
"tags": "Tags",
"tag": "Tag",
"manage_tags": "Manage tags",
"main_window_manage_tags_accessible_flag": "Manage &Tags",
"add_tag_placeholder": "Add a tag and press Enter",
"tag_browser_title": "Tag Browser",
"tag_browser_instructions": "Click a tag to expand and see all pages with that tag. Click a date to open it. Select a tag to edit its name, change its color, or delete it globally.",
"tag_name": "Tag name",
"tag_color_hex": "Hex colour",
"color_hex": "Colour",
"date": "Date",
"pick_color": "Pick colour",
"invalid_color_title": "Invalid colour",
"invalid_color_message": "Please enter a valid hex colour like #RRGGBB.",
"add": "Add",
"remove": "Remove",
"ok": "OK",
"add_a_tag": "Add a tag",
"edit_tag_name": "Edit tag name",
"new_tag_name": "New tag name:",
@ -164,6 +151,7 @@
"bug_report_send_failed": "Could not send bug report.",
"bug_report_sent_ok": "Bug report sent. Thank you!",
"send": "Send",
"reminder": "Reminder",
"set_reminder": "Set reminder prompt",
"set_reminder_prompt": "Enter a time",
"reminder_no_text_fallback": "You scheduled a reminder to alert you now!",
@ -201,7 +189,6 @@
"invalid_activity_title": "Invalid activity",
"invalid_project_message": "The project is invalid",
"invalid_project_title": "Invalid project",
"label_key": "Label",
"manage_activities": "Manage activities",
"manage_projects": "Manage projects",
"manage_projects_activities": "Manage project activities",
@ -217,8 +204,14 @@
"rename_activity": "Rename activity",
"rename_project": "Rename project",
"run_report": "Run report",
"add_project_label": "Add a project",
"add_activity_title": "Add activity",
"add_activity_label": "Add an activity",
"rename_activity_label": "Rename activity",
"add_project_title": "Add project",
"add_project_label": "Add a project",
"rename_activity_title": "Rename this activity",
"rename_project_label": "Rename project",
"rename_project_title": "Rename this project",
"select_activity_message": "Select an activity",
"select_activity_title": "Select activity",
"select_project_message": "Select a project",
@ -235,7 +228,6 @@
"time_log_total_hours": "Total time spent",
"time_log_with_total": "Time log ({hours:.2f}h)",
"time_log_total_hours": "Total for day: {hours:.2f}h",
"title_key": "title",
"update_time_entry": "Update time entry",
"time_report_total": "Total: {hours:.2f} hours",
"no_report_title": "No report",

View file

@ -5,7 +5,6 @@
"db_version_id_does_not_belong_to_the_given_date": "version_id ne correspond pas à la date indiquée",
"db_key_incorrect": "La clé est peut-être incorrecte",
"db_database_error": "Erreur de base de données",
"database_path": "Chemin de la base de données",
"database_maintenance": "Maintenance de la base de données",
"database_compact": "Compacter la base de données",
"database_compact_explanation": "La compaction exécute VACUUM sur la base de données. Cela peut aider à réduire sa taille.",
@ -33,9 +32,7 @@
"system": "Système",
"light": "Clair",
"dark": "Sombre",
"behaviour": "Comportement",
"never": "Jamais",
"browse": "Parcourir",
"previous": "Précédent",
"previous_day": "Jour précédent",
"next": "Suivant",
@ -44,7 +41,6 @@
"show": "Afficher",
"history": "Historique",
"view_history": "Afficher l'historique",
"export": "Exporter",
"export_accessible_flag": "E&xporter",
"export_entries": "Exporter les entrées",
"export_complete": "Exportation terminée",
@ -117,16 +113,8 @@
"add_tag_placeholder": "Ajouter une étiquette et appuyez sur Entrée",
"tag_browser_title": "Navigateur de étiquettes",
"tag_browser_instructions": "Cliquez sur une étiquette pour l'étendre et voir toutes les pages avec cette étiquette. Cliquez sur une date pour l'ouvrir. Sélectionnez une étiquette pour modifier son nom, changer sa couleur ou la supprimer globalement.",
"tag_name": "Nom de l'étiquette",
"tag_color_hex": "Couleur hexadécimale",
"color_hex": "Couleur",
"date": "Date",
"pick_color": "Choisir la couleur",
"invalid_color_title": "Couleur invalide",
"invalid_color_message": "Veuillez entrer une couleur hexadécimale valide comme #RRGGBB.",
"add": "Ajouter",
"remove": "Supprimer",
"ok": "OK",
"add_a_tag": "Ajouter une étiquette",
"edit_tag_name": "Modifier le nom de l'étiquette",
"new_tag_name": "Nouveau nom de l'étiquette :",

View file

@ -3,9 +3,8 @@
"db_issues_reported": "problema/i segnalato/i",
"db_reopen_failed_after_rekey": "Riapertura fallita dopo il cambio chiave",
"db_version_id_does_not_belong_to_the_given_date": "version_id non appartiene alla data indicata",
"db_key_incorrect": "La chiave è probabilmente errata",
"db_database_error": "Errore del database",
"database_path": "Percorso del database",
"db_key_incorrect": "La chiave è probabilmente errata",
"database_maintenance": "Manutenzione del database",
"database_compact": "Compatta il database",
"database_compact_explanation": "La compattazione esegue VACUUM sul database. Può aiutare a ridurne le dimensioni.",
@ -33,9 +32,7 @@
"system": "Sistema",
"light": "Chiaro",
"dark": "Scuro",
"behaviour": "Comportamento",
"never": "Mai",
"browse": "Sfoglia",
"previous": "Precedente",
"previous_day": "Giorno precedente",
"next": "Successivo",
@ -44,7 +41,6 @@
"show": "Mostra",
"history": "Cronologia",
"view_history": "Visualizza cronologia",
"export": "Esporta",
"export_accessible_flag": "&Esporta",
"export_entries": "Esporta voci",
"export_complete": "Esportazione completata",
@ -116,16 +112,8 @@
"add_tag_placeholder": "Aggiungi un tag e premi Invio",
"tag_browser_title": "Browser dei tag",
"tag_browser_instructions": "Fai clic su un tag per espandere e vedere tutte le pagine con quel tag. Fai clic su una data per aprirla. Seleziona un tag per modificarne il nome, cambiarne il colore o eliminarlo globalmente.",
"tag_name": "Nome del tag",
"tag_color_hex": "Colore esadecimale",
"color_hex": "Colore",
"date": "Data",
"pick_color": "Scegli colore",
"invalid_color_title": "Colore non valido",
"invalid_color_message": "Inserisci un colore esadecimale valido come #RRGGBB.",
"add": "Aggiungi",
"remove": "Rimuovi",
"ok": "OK",
"add_a_tag": "Aggiungi un tag",
"edit_tag_name": "Modifica nome tag",
"new_tag_name": "Nuovo nome tag:",

View file

@ -230,7 +230,7 @@ class SettingsDialog(QDialog):
self.idle_spin.setSingleStep(1)
self.idle_spin.setAccelerated(True)
self.idle_spin.setSuffix(" min")
self.idle_spin.setSpecialValueText(strings._("Never"))
self.idle_spin.setSpecialValueText(strings._("never"))
self.idle_spin.setValue(getattr(cfg, "idle_minutes", 15))
priv.addWidget(self.idle_spin, 0, Qt.AlignLeft)

View file

@ -69,18 +69,18 @@ class ToolBar(QToolBar):
self.actH3.setCheckable(True)
self.actH3.setShortcut("Ctrl+3")
self.actH3.triggered.connect(lambda: self.headingRequested.emit(14))
self.actNormal = QAction("N", self)
self.actNormal = QAction("P", self)
self.actNormal.setToolTip(strings._("toolbar_normal_paragraph_text"))
self.actNormal.setCheckable(True)
self.actNormal.setShortcut("Ctrl+N")
self.actNormal.setShortcut("Ctrl+.")
self.actNormal.triggered.connect(lambda: self.headingRequested.emit(0))
self.actFontSmaller = QAction("N-", self)
self.actFontSmaller = QAction("P-", self)
self.actFontSmaller.setToolTip(strings._("toolbar_font_smaller"))
self.actFontSmaller.setShortcut("Ctrl+Shift+-")
self.actFontSmaller.triggered.connect(self.fontSizeSmallerRequested)
self.actFontLarger = QAction("N+", self)
self.actFontLarger = QAction("P+", self)
self.actFontLarger.setToolTip(strings._("toolbar_font_larger"))
self.actFontLarger.setShortcut("Ctrl+Shift+=")
self.actFontLarger.triggered.connect(self.fontSizeLargerRequested)
@ -167,9 +167,9 @@ class ToolBar(QToolBar):
self._style_letter_button(self.actH1, "H1")
self._style_letter_button(self.actH2, "H2")
self._style_letter_button(self.actH3, "H3")
self._style_letter_button(self.actNormal, "N")
self._style_letter_button(self.actFontSmaller, "N-")
self._style_letter_button(self.actFontLarger, "N+")
self._style_letter_button(self.actNormal, "P")
self._style_letter_button(self.actFontSmaller, "P-")
self._style_letter_button(self.actFontLarger, "P+")
# Lists
self._style_letter_button(self.actBullets, "")

241
find_unused_strings.py Executable file
View file

@ -0,0 +1,241 @@
#!/usr/bin/env python3
import ast
import json
from pathlib import Path
from typing import Dict, Set
BASE_DIR = Path(__file__).resolve().parent / "bouquin"
LOCALES_DIR = BASE_DIR / "locales"
DEFAULT_LOCALE = "en"
def load_json_keys(locale: str = DEFAULT_LOCALE) -> Set[str]:
"""Load all keys from the given locale JSON file."""
path = LOCALES_DIR / f"{locale}.json"
with path.open(encoding="utf-8") as f:
data = json.load(f)
return set(data.keys())
class KeyParamFinder(ast.NodeVisitor):
"""
First pass:
For each function/method, figure out which parameters are later passed
into _(), translated(), or strings._().
Example: in your _prompt_name, it discovers that title_key and label_key
are translation-key parameters.
"""
def __init__(self) -> None:
# func_name -> {"param_positions": {param: arg_index}, "key_param_positions": set[arg_index]}
self.func_info: Dict[str, dict] = {}
self.current_func_name_stack: list[str] = []
self.current_param_positions_stack: list[Dict[str, int]] = []
self.current_class_stack: list[str] = []
# Track when we're inside a class so we can treat "self" specially
def visit_ClassDef(self, node: ast.ClassDef) -> None:
self.current_class_stack.append(node.name)
self.generic_visit(node)
self.current_class_stack.pop()
def _enter_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
funcname = node.name
params = [arg.arg for arg in node.args.args]
# If we're inside a class and there is at least one param,
# assume the first one is "self"/"cls" and is implicit at call sites.
is_method = bool(self.current_class_stack) and len(params) > 0
param_positions: Dict[str, int] = {}
for i, name in enumerate(params):
if is_method and i == 0:
# skip self/cls; it doesn't correspond to an explicit arg in calls like self.method(...)
continue
call_index = i - 1 if is_method else i
param_positions[name] = call_index
self.current_func_name_stack.append(funcname)
self.current_param_positions_stack.append(param_positions)
self.func_info.setdefault(funcname, {
"param_positions": param_positions,
"key_param_positions": set(),
})
# If the function name is reused, last definition wins
self.func_info[funcname]["param_positions"] = param_positions
def _exit_function(self) -> None:
self.current_func_name_stack.pop()
self.current_param_positions_stack.pop()
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
self._enter_function(node)
self.generic_visit(node)
self._exit_function()
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
self._enter_function(node)
self.generic_visit(node)
self._exit_function()
def visit_Call(self, node: ast.Call) -> None:
# Only care about calls *inside* functions
if not self.current_func_name_stack:
return self.generic_visit(node)
func = node.func
func_name: str | None = None
if isinstance(func, ast.Name):
func_name = func.id
elif isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
# e.g. strings._(...)
func_name = f"{func.value.id}.{func.attr}"
# Is this a translation call?
if func_name in {"_", "translated", "strings._"}:
cur_name = self.current_func_name_stack[-1]
param_positions = self.current_param_positions_stack[-1]
# Positional first arg
if node.args:
first = node.args[0]
if isinstance(first, ast.Name):
pname = first.id
if pname in param_positions:
idx = param_positions[pname]
self.func_info[cur_name]["key_param_positions"].add(idx)
# Keyword args, e.g. strings._(key=title_key)
for kw in node.keywords or []:
if isinstance(kw.value, ast.Name):
pname = kw.value.id
if pname in param_positions:
idx = param_positions[pname]
self.func_info[cur_name]["key_param_positions"].add(idx)
self.generic_visit(node)
class UsedKeyCollector(ast.NodeVisitor):
"""
Second pass:
- Collect string literals passed directly to _()/translated()/strings._()
- Collect string literals passed into parameters that we know are
"translation-key parameters" of wrapper functions/methods.
"""
def __init__(self, func_info: Dict[str, dict]) -> None:
self.func_info = func_info
self.used_keys: Set[str] = set()
def visit_Call(self, node: ast.Call) -> None:
func = node.func
def full_name(f: ast.expr) -> str | None:
if isinstance(f, ast.Name):
return f.id
if isinstance(f, ast.Attribute) and isinstance(f.value, ast.Name):
return f"{f.value.id}.{f.attr}"
return None
func_full = full_name(func)
# 1) Direct translation calls like _("key") or strings._("key")
if func_full in {"_", "translated", "strings._"}:
if node.args:
first = node.args[0]
if isinstance(first, ast.Constant) and isinstance(first.value, str):
self.used_keys.add(first.value)
for kw in node.keywords or []:
if isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str):
self.used_keys.add(kw.value.value)
# 2) Wrapper calls: functions whose params we know are translation-key params
called_base_name: str | None = None
if isinstance(func, ast.Name):
called_base_name = func.id
elif isinstance(func, ast.Attribute):
called_base_name = func.attr # e.g. self._prompt_name -> "_prompt_name"
if called_base_name in self.func_info:
info = self.func_info[called_base_name]
param_positions: Dict[str, int] = info["param_positions"]
key_positions: Set[int] = info["key_param_positions"]
# positional args
for idx, arg in enumerate(node.args):
if idx in key_positions and isinstance(arg, ast.Constant) and isinstance(arg.value, str):
self.used_keys.add(arg.value)
# keyword args
for kw in node.keywords or []:
if kw.arg is None:
continue # **kwargs, ignore
param_name = kw.arg
if param_name in param_positions:
idx = param_positions[param_name]
if idx in key_positions:
val = kw.value
if isinstance(val, ast.Constant) and isinstance(val.value, str):
self.used_keys.add(val.value)
self.generic_visit(node)
def collect_used_keys() -> Set[str]:
"""Parse all .py files and collect all translation keys used."""
trees: list[ast.AST] = []
# Read and parse all Python files in this folder
for path in BASE_DIR.glob("*.py"):
# Optionally skip this script itself
if path.name == Path(__file__).name:
continue
src = path.read_text(encoding="utf-8")
tree = ast.parse(src, filename=str(path))
trees.append(tree)
# First pass: find which parameters are translation-key params
finder = KeyParamFinder()
for tree in trees:
finder.visit(tree)
# Second pass: collect string literals passed to those parameters
collector = UsedKeyCollector(finder.func_info)
for tree in trees:
collector.visit(tree)
return collector.used_keys
def main() -> None:
json_keys = load_json_keys()
used_keys = collect_used_keys()
unused_keys = sorted(json_keys - used_keys)
missing_in_json = sorted(used_keys - json_keys)
print("=== Unused keys in JSON (present in locales but never used in code) ===")
if unused_keys:
for k in unused_keys:
print(" ", k)
else:
print(" (none)")
print("\n=== Keys used in code but missing from JSON ===")
if missing_in_json:
for k in missing_in_json:
print(" ", k)
else:
print(" (none)")
if __name__ == "__main__":
main()