diff --git a/bouquin/locales/en.json b/bouquin/locales/en.json index 9654582..b482290 100644 --- a/bouquin/locales/en.json +++ b/bouquin/locales/en.json @@ -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", diff --git a/bouquin/locales/fr.json b/bouquin/locales/fr.json index 0949130..d670436 100644 --- a/bouquin/locales/fr.json +++ b/bouquin/locales/fr.json @@ -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 :", diff --git a/bouquin/locales/it.json b/bouquin/locales/it.json index b5006bb..deedb5f 100644 --- a/bouquin/locales/it.json +++ b/bouquin/locales/it.json @@ -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:", diff --git a/bouquin/settings_dialog.py b/bouquin/settings_dialog.py index 226db08..e749c4c 100644 --- a/bouquin/settings_dialog.py +++ b/bouquin/settings_dialog.py @@ -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) diff --git a/bouquin/toolbar.py b/bouquin/toolbar.py index d6c52be..59c2f50 100644 --- a/bouquin/toolbar.py +++ b/bouquin/toolbar.py @@ -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, "•") diff --git a/find_unused_strings.py b/find_unused_strings.py new file mode 100755 index 0000000..1c9b235 --- /dev/null +++ b/find_unused_strings.py @@ -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() +