diff --git a/script/todo/todo.py b/script/todo/todo.py index 9cb641f..af3c5f2 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -129,6 +129,7 @@ class TODO: [2] {t("Install")} [3] {t("Question")} [4] {t("Fork - Open TODO in a new tab")} +[5] {t("Navigation telemetry (TUI)")} [0] {t("Quit")} """ while True: @@ -160,6 +161,8 @@ class TODO: # ) cmd = "make todo" self.execute.exec_command_live(cmd, source_erplibre=True) + elif status == "5": + self._todo_telemetry_tui() # elif status == "3" or status == "install": # print("install") else: @@ -565,8 +568,24 @@ class TODO: header = "" if crumbs: header = "📍 " + " â€ș ".join(crumbs) + "\n" + # TĂ©lĂ©mĂ©trie de navigation (best-effort, ne casse jamais le menu). + try: + from script.todo import todo_telemetry + + todo_telemetry.record(" â€ș ".join(crumbs)) + except Exception: + pass return header + t("Command:") + def _todo_telemetry_tui(self): + """Ouvre le TUI arborescent de tĂ©lĂ©mĂ©trie de navigation.""" + try: + from script.todo.todo_telemetry import run_tui + + run_tui() + except ImportError: + print(t("Install textual for the telemetry TUI (pip).")) + def fill_help_info(self, choices): # Une entrĂ©e {"section": "..."} affiche un titre de section SANS # consommer de numĂ©ro : la numĂ©rotation reste continue sur les vraies diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 3c9013e..b967865 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -1282,6 +1282,30 @@ TRANSLATIONS = { "fr": "suivi d'installation", "en": "install monitoring", }, + "Navigation telemetry (TUI)": { + "fr": "TĂ©lĂ©mĂ©trie de navigation (TUI)", + "en": "Navigation telemetry (TUI)", + }, + "TODO navigation telemetry": { + "fr": "TODO — tĂ©lĂ©mĂ©trie de navigation", + "en": "TODO navigation telemetry", + }, + "navigations": { + "fr": "navigations", + "en": "navigations", + }, + "menus": { + "fr": "menus", + "en": "menus", + }, + "Telemetry reset.": { + "fr": "TĂ©lĂ©mĂ©trie rĂ©initialisĂ©e.", + "en": "Telemetry reset.", + }, + "Install textual for the telemetry TUI (pip).": { + "fr": "Installez textual pour le TUI de tĂ©lĂ©mĂ©trie (pip).", + "en": "Install textual for the telemetry TUI (pip).", + }, "Resize a VM disk": { "fr": "Redimensionner le disque d'une VM", "en": "Resize a VM disk", diff --git a/script/todo/todo_telemetry.py b/script/todo/todo_telemetry.py new file mode 100644 index 0000000..bbf05c5 --- /dev/null +++ b/script/todo/todo_telemetry.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""TĂ©lĂ©mĂ©trie de navigation du CLI TODO. + +Enregistre les fils d'Ariane visitĂ©s (« A â€ș B â€ș C » -> compteur) et affiche un +DIAGRAMME arborescent des fonctionnalitĂ©s dans un TUI Textual, triĂ© par usage. + +- record(path) : appelĂ© par Todo._menu_header Ă  chaque affichage de menu ; on + dĂ©dupe les rĂ©-affichages consĂ©cutifs pour ne compter que les TRANSITIONS. +- run_tui() : ouvre l'arbre de navigation (compteurs par menu). +""" +from __future__ import annotations + +import json +import os +import time +from pathlib import Path + +try: + from script.todo.todo_i18n import t +except Exception: # pragma: no cover - repli si i18n indisponible + + def t(key: str) -> str: + return key + + +# Dernier chemin enregistrĂ© : Ă©vite de recompter chaque rĂ©-affichage du mĂȘme +# menu (une commande qui revient au menu ne compte pas comme une navigation). +_LAST = [None] + + +def _path() -> Path: + base = Path(os.path.expanduser("~/.erplibre")) + base.mkdir(parents=True, exist_ok=True) + return base / "todo_telemetry.json" + + +def load() -> dict: + try: + return json.loads(_path().read_text()) + except (OSError, ValueError): + return {"paths": {}} + + +def _save(data: dict) -> None: + try: + _path().write_text(json.dumps(data, ensure_ascii=False, indent=2)) + except OSError: + pass + + +def record(path: str) -> None: + """IncrĂ©mente le compteur du menu `path`. Best-effort : ne lĂšve jamais + (la tĂ©lĂ©mĂ©trie ne doit jamais casser la navigation).""" + if not path or path == _LAST[0]: + return + _LAST[0] = path + try: + data = load() + paths = data.setdefault("paths", {}) + paths[path] = paths.get(path, 0) + 1 + data["updated"] = int(time.time()) + _save(data) + except Exception: + pass + + +def reset() -> None: + _save({"paths": {}}) + + +def _nested(paths: dict) -> dict: + """{« A â€ș B â€ș C »: count} -> arbre {nom: {'count', 'children'}}. Chaque + menu enregistre son propre chemin, donc chaque nƓud a son compteur.""" + root: dict = {} + for path, count in paths.items(): + cur = root + parts = path.split(" â€ș ") + for i, name in enumerate(parts): + node = cur.setdefault(name, {"count": 0, "children": {}}) + if i == len(parts) - 1: + node["count"] += count + cur = node["children"] + return root + + +def run_tui(run_app: bool = True): + """Ouvre le TUI arborescent. `run_app=False` renvoie l'app (tests).""" + from textual.app import App, ComposeResult + from textual.widgets import Footer, Header, Static, Tree + + class Telemetry(App): + CSS = """ + #summary { height: 1; color: $text-muted; } + Tree { border: solid $accent; } + """ + BINDINGS = [ + ("q", "quit", "Quitter"), + ("r", "reset", "RĂ©initialiser"), + ("e", "expand_all", "Tout dĂ©plier"), + ] + + def compose(self) -> ComposeResult: + yield Header(show_clock=True) + yield Static("", id="summary") + yield Tree("📍 TODO", id="nav") + yield Footer() + + def on_mount(self): + self.title = t("TODO navigation telemetry") + self._populate() + + def _populate(self): + data = load() + paths = data.get("paths", {}) + total = sum(paths.values()) + self.query_one("#summary", Static).update( + f" {total} {t('navigations')} · {len(paths)} {t('menus')}" + ) + tree = self.query_one("#nav", Tree) + tree.clear() + nested = _nested(paths) + todo = nested.get("TODO", {"count": 0, "children": nested}) + tree.root.set_label(f"📍 TODO ({todo['count']})") + self._add(tree.root, todo["children"]) + tree.root.expand() + + def _add(self, tnode, children): + # Tri par visites dĂ©croissantes : les plus utilisĂ©s en tĂȘte. + for name, node in sorted( + children.items(), key=lambda kv: -kv[1]["count"] + ): + child = tnode.add(f"{name} ({node['count']})", expand=True) + self._add(child, node["children"]) + + def action_reset(self): + reset() + self._populate() + self.notify(t("Telemetry reset.")) + + def action_expand_all(self): + self.query_one("#nav", Tree).root.expand_all() + + app = Telemetry() + if run_app: + app.run() + return app