From 4e86eb43fc2ccc03646c3654a78ae5a986d712d0 Mon Sep 17 00:00:00 2001 From: Franz Rolfsvaag Date: Sat, 16 Aug 2025 02:26:49 +0200 Subject: [PATCH] 0.4.1.0.a2 - Docs site changes - Details brief no longer opens automatically on narrower devices - Implemented a counter that displays the number of executions for each command --- assets/docs/cmd.html | 64 +++++++++++++++++++------- bot.py | 2 +- data_manager.py | 33 +++++++++----- modules/docs_site/docs_site.py | 21 ++++++++- modules/usage/__init__.py | 0 modules/usage/usage_stats.py | 82 ++++++++++++++++++++++++++++++++++ 6 files changed, 174 insertions(+), 28 deletions(-) create mode 100644 modules/usage/__init__.py create mode 100644 modules/usage/usage_stats.py diff --git a/assets/docs/cmd.html b/assets/docs/cmd.html index 0cfe6e2..efd3572 100644 --- a/assets/docs/cmd.html +++ b/assets/docs/cmd.html @@ -385,7 +385,7 @@ fdbtn?.addEventListener('click', closeFullDetails);
${r.cog?`cog: ${r.cog}`:''} ${r.module?`module: ${moduleSansPrefix(r)}`:''} - ${r.required_permissions&&r.required_permissions.length?`perms: ${r.required_permissions.join(', ')}`:''} + runs: ${Number(r.exec_count||0).toLocaleString()}
${usageBlockHTML(r.usage_prefix)} ${usageBlockHTML(r.usage_slash)} @@ -454,7 +454,7 @@ fdbtn?.addEventListener('click', closeFullDetails);
${r.cog?`cog: ${r.cog}`:''} ${r.module?`module: ${moduleSansPrefix(r)}`:''} - ${r.required_permissions&&r.required_permissions.length?`perms: ${r.required_permissions.join(', ')}`:''} + runs: ${Number(r.exec_count||0).toLocaleString()}
${usageHTML} ${helpSansMod(r)?`
${helpSansMod(r)}
`:''} @@ -475,28 +475,56 @@ fdbtn?.addEventListener('click', closeFullDetails); function render(target, rows){ target.innerHTML=''; rows.forEach(r=>target.appendChild(card(r))); } function applyFilter(selectAnchorIfPresent=true){ - if(!data) return; - const all=data.all||[], mods=(data.sections&&data.sections.moderator)||[], users=(data.sections&&data.sections.user)||[]; - countsEl.textContent=`User: ${users.length} · Moderator: ${mods.length} · Total: ${all.length}`; + if(!data) return; + const all=data.all||[], mods=(data.sections&&data.sections.moderator)||[], users=(data.sections&&data.sections.user)||[]; + countsEl.textContent=`User: ${users.length} · Moderator: ${mods.length} · Total: ${all.length}`; - const q=(qEl.value||'').toLowerCase(); - const {filter, cmd}=getFilterFromHash(); - const src=filter==='moderator'?mods:(filter==='all'?all:users); - const rows=!q?src:src.filter(r=>([shownName(r), helpSansMod(r), r.brief, r.usage, r.usage_prefix, r.usage_slash, r.cog, r.module, r.details_md||"", r.brief_html||"", r.details_html||""].join(' ').toLowerCase().includes(q))); - render(listEl, rows); + const q=(qEl.value||'').toLowerCase(); + const {filter, cmd}=getFilterFromHash(); + const src=filter==='moderator'?mods:(filter==='all'?all:users); + const rows=!q?src:src.filter(r=>([shownName(r), helpSansMod(r), r.brief, r.usage, r.usage_prefix, r.usage_slash, r.cog, r.module, r.details_md||"", r.brief_html||"", r.details_html||""].join(' ').toLowerCase().includes(q))); + render(listEl, rows); - let sel=rows[0]; - if(selectAnchorIfPresent && cmd){ const hit=rows.find(r=>rowAnchor(r)===cmd); if(hit) sel=hit; - const cardEl=document.getElementById('card-'+cmd); if(cardEl) cardEl.scrollIntoView({behavior:'smooth', block:'start'}); } - if(sel) openDetails(sel); + const isMobile = matchMedia('(max-width: 900px)').matches; + + // Selection logic: + // - If URL has an anchor, prefer that (scroll to it) + // - Otherwise, ONLY auto-open first row on non-mobile + let sel = null; + + if (selectAnchorIfPresent && cmd) { + const hit = rows.find(r => rowAnchor(r) === cmd); + if (hit) sel = hit; + const cardEl = document.getElementById('card-'+cmd); + if (cardEl) cardEl.scrollIntoView({behavior:'smooth', block:'start'}); + } else if (!isMobile && rows.length) { + sel = rows[0]; } + // Do not auto-open on mobile + if (sel && !isMobile) openDetails(sel); +} + async function boot(){ computeStickyTop(); try{ - if(!data){ const res=await fetch('/api/commands',{cache:'no-store'}); if(!res.ok) throw new Error('HTTP '+res.status); data=await res.json(); } + async function loadData(){ + try{ + const res = await fetch('/api/commands', { cache:'no-store' }); + if(!res.ok) throw new Error('HTTP '+res.status); + data = await res.json(); + return true; // fetched fresh + }catch(e){ + // fallback to inline bootstrap if fetch fails + if(window.__DATA__){ data = window.__DATA__; return false; } + throw e; + } + } + addEventListener('hashchange', ()=>applyFilter(false)); qEl.addEventListener('input', ()=>applyFilter(false)); + + await loadData(); // always refresh from backend applyFilter(true); }catch{ document.getElementById('alerts').textContent='Failed to load.'; } @@ -524,6 +552,12 @@ fdbtn?.addEventListener('click', closeFullDetails); const d=Math.floor(s/86400); s%=86400; const h=Math.floor(s/3600); s%=3600; const m=Math.floor(s/60); const sec=s%60; const parts=[]; if(d) parts.push(d+'d'); if(h||d) parts.push(h+'h'); if(m||h||d) parts.push(m+'m'); parts.push(sec+'s'); return parts.join(' '); } + setInterval(async ()=>{ + try{ + const gotFresh = await loadData(); + if(gotFresh) applyFilter(false); + }catch{} + }, 30000); boot(); })(); diff --git a/bot.py b/bot.py index 8117f58..9b752bd 100644 --- a/bot.py +++ b/bot.py @@ -9,7 +9,7 @@ from modules.common.boot_notice import post_boot_notice # Version consists of: # Major.Enhancement.Minor.Patch.Test (Test is alphanumeric; doesn’t trigger auto update) -VERSION = "0.4.1.0.a1" +VERSION = "0.4.1.0.a2" # ---------- Env loading ---------- diff --git a/data_manager.py b/data_manager.py index 453d7e4..3cc9053 100644 --- a/data_manager.py +++ b/data_manager.py @@ -1,3 +1,4 @@ +# data_manager.py import json import threading import shutil @@ -12,7 +13,7 @@ class DataManager: def _load(self): try: - with open(self.json_path, 'r') as f: + with open(self.json_path, 'r', encoding='utf-8') as f: return json.load(f) except FileNotFoundError: default = { @@ -34,17 +35,16 @@ class DataManager: 'nick_claim_pending': [], 'nick_reviews': [], 'rr_msg_channels': [], + '_counters': {}, } self._save(default) return default def _safe_write(self, data: dict): - # ensure parent dir exists os.makedirs(os.path.dirname(self.json_path) or ".", exist_ok=True) tmp = self.json_path + ".tmp" - with open(tmp, 'w') as f: + with open(tmp, 'w', encoding='utf-8') as f: json.dump(data, f, indent=4) - # backup current file (best-effort) if os.path.exists(self.json_path): try: shutil.copy2(self.json_path, self.json_path + ".bak") @@ -53,9 +53,9 @@ class DataManager: os.replace(tmp, self.json_path) def _save(self, data: dict): - # single place to write (atomic replace + rolling .bak) self._safe_write(data) + # ------------- existing list helpers ------------- def get(self, category: str): with self.lock: return list(self._data.get(category, [])) @@ -72,15 +72,11 @@ class DataManager: self._save(self._data) def update(self, category: str, predicate: Callable[[Any], bool], updater: Callable[[dict], dict]) -> bool: - """ - Atomically find one item in `category` matching predicate and update it with `updater`. - Returns True if an item was updated, else False. - """ + """Atomically find one item in `category` and update it with `updater`.""" with self.lock: arr = self._data.get(category, []) for idx, item in enumerate(arr): if predicate(item): - # Copy → mutate → save back new_item = dict(item) new_item = updater(new_item) or new_item arr[idx] = new_item @@ -88,3 +84,20 @@ class DataManager: self._save(self._data) return True return False + + # ------------- NEW: tiny key→counter helpers ------------- + def incr_counter(self, key: str, by: int = 1) -> int: + with self.lock: + c = self._data.setdefault('_counters', {}) + c[key] = int(c.get(key, 0)) + int(by) + self._save(self._data) + return c[key] + + def get_counter(self, key: str) -> int: + with self.lock: + return int(self._data.get('_counters', {}).get(key, 0)) + + def get_all_counters(self, prefix: str = "") -> dict[str, int]: + with self.lock: + c = dict(self._data.get('_counters', {})) + return {k: v for k, v in c.items() if (not prefix or k.startswith(prefix))} diff --git a/modules/docs_site/docs_site.py b/modules/docs_site/docs_site.py index 90d0340..8fe6379 100644 --- a/modules/docs_site/docs_site.py +++ b/modules/docs_site/docs_site.py @@ -276,11 +276,12 @@ def _gather_prefix_and_hybrid(bot: commands.Bot) -> List[Dict[str, Any]]: pass usage_prefix = _command_usage_prefix(cmd, prefix) + qn = cmd.qualified_name # single source of truth for counters row = { "type": ctype, "name": cmd.qualified_name, - "display_name": cmd.qualified_name, # bare name + "display_name": cmd.qualified_name, "help": (cmd.help or "").strip(), "brief": (cmd.brief or "").strip(), "usage": usage_prefix if not is_hybrid else None, @@ -290,6 +291,9 @@ def _gather_prefix_and_hybrid(bot: commands.Bot) -> List[Dict[str, Any]]: "module": getattr(getattr(cmd, "callback", None), "__module__", None), "moderator_only": bool(is_mod), "required_permissions": perms, + # NEW: counter fields + "counter_key": qn, + "exec_count": _cmd_counter(bot, qn), } key = ("px", row["name"]) if key not in seen: @@ -332,7 +336,7 @@ def _gather_slash(bot: commands.Bot) -> List[Dict[str, Any]]: seen_paths = set() for scope, leaf, path in collected: try: - canon = path.lstrip("/") # power/restart + canon = path.lstrip("/") # e.g., 'power/restart' if canon in seen_paths: continue seen_paths.add(canon) @@ -345,6 +349,9 @@ def _gather_slash(bot: commands.Bot) -> List[Dict[str, Any]]: options = getattr(leaf, "options", None) or getattr(leaf, "parameters", None) or getattr(leaf, "_params", None) usage_full = _command_usage_slash_like(display, options) + # Use leaf.qualified_name when available, it matches listener keys + qn = getattr(leaf, "qualified_name", None) or display + row = { "type": "slash", "name": "/" + canon, @@ -360,6 +367,9 @@ def _gather_slash(bot: commands.Bot) -> List[Dict[str, Any]]: "required_permissions": perms, "extras": _safe_extras(leaf), "dm_permission": getattr(leaf, "dm_permission", None), + # NEW: counter fields + "counter_key": qn, + "exec_count": _cmd_counter(bot, qn), } rows.append(row) except Exception: @@ -367,6 +377,9 @@ def _gather_slash(bot: commands.Bot) -> List[Dict[str, Any]]: continue return rows +def _cmd_counter(bot, qualified_name: str) -> int: + dm = getattr(bot, "data_manager", None) + return dm.get_counter(f"cmd::{qualified_name}") if dm else 0 # ============================= # Details loader & master JSON @@ -562,6 +575,10 @@ def _merge_hybrid_slash(rows: List[Dict[str, Any]]) -> None: h["brief_html"] = r["brief_html"] if r.get("details_html") and not h.get("details_html"): h["details_html"] = r["details_html"] + + # NEW: sum exec_count from slash twin into the hybrid row + h["exec_count"] = int(h.get("exec_count", 0) or 0) + int(r.get("exec_count", 0) or 0) + to_remove.append(i) for i in sorted(to_remove, reverse=True): diff --git a/modules/usage/__init__.py b/modules/usage/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/modules/usage/usage_stats.py b/modules/usage/usage_stats.py new file mode 100644 index 0000000..c907cd7 --- /dev/null +++ b/modules/usage/usage_stats.py @@ -0,0 +1,82 @@ +# modules/usage/usage_stats.py +from __future__ import annotations +from discord.ext import commands +import discord + +COUNTER_KEY_PREFIX = "cmd::" + +def _key_from_app(cmd: discord.app_commands.Command) -> str: + # app command qualified_name is "group sub" or "name" + name = getattr(cmd, "qualified_name", None) or getattr(cmd, "name", "unknown") + return f"{COUNTER_KEY_PREFIX}{name}" + +def _key_from_ctx(ctx: commands.Context) -> str: + # prefix/hybrid qualified_name is "group sub" or "name" + c = getattr(ctx, "command", None) + name = getattr(c, "qualified_name", None) or getattr(c, "name", "unknown") + return f"{COUNTER_KEY_PREFIX}{name}" + +class UsageStatsCog(commands.Cog): + """Lightweight command run/attempt counters persisted in DataManager.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + print("[usage] UsageStatsCog init") + + # ---- successful completions ---- + + @commands.Cog.listener() + async def on_app_command_completion(self, interaction: discord.Interaction, command: discord.app_commands.Command): + dm = getattr(self.bot, "data_manager", None) + if not dm: + print("[usage] skip app (no data_manager)") + return + try: + key = _key_from_app(command) + newv = dm.incr_counter(key, 1) + print(f"[usage] app ++ {key} -> {newv}") + except Exception as e: + print("[usage] incr app failed:", repr(e)) + + @commands.Cog.listener() + async def on_command_completion(self, ctx: commands.Context): + dm = getattr(self.bot, "data_manager", None) + if not dm: + print("[usage] skip prefix (no data_manager)") + return + try: + key = _key_from_ctx(ctx) + newv = dm.incr_counter(key, 1) + print(f"[usage] px ++ {key} -> {newv}") + except Exception as e: + print("[usage] incr prefix failed:", repr(e)) + + # ---- attempts that error (optional but useful while testing) ---- + + @commands.Cog.listener() + async def on_app_command_error(self, interaction: discord.Interaction, error: Exception): + # Count attempts (separate key) so you can confirm the listener fires at all. + dm = getattr(self.bot, "data_manager", None) + cmd = getattr(interaction, "command", None) + if dm and isinstance(cmd, discord.app_commands.Command): + try: + key = _key_from_app(cmd).replace("cmd::", "cmd_attempt::") + newv = dm.incr_counter(key, 1) + print(f"[usage] app !! {key} -> {newv} ({type(error).__name__})") + except Exception as e: + print("[usage] app err incr failed:", repr(e)) + + @commands.Cog.listener() + async def on_command_error(self, ctx: commands.Context, error: Exception): + dm = getattr(self.bot, "data_manager", None) + if dm and getattr(ctx, "command", None): + try: + key = _key_from_ctx(ctx).replace("cmd::", "cmd_attempt::") + newv = dm.incr_counter(key, 1) + print(f"[usage] px !! {key} -> {newv} ({type(error).__name__})") + except Exception as e: + print("[usage] px err incr failed:", repr(e)) + +async def setup(bot: commands.Bot): + await bot.add_cog(UsageStatsCog(bot)) + print("[usage] UsageStatsCog loaded")