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
This commit is contained in:
		
							parent
							
								
									fdd336fe91
								
							
						
					
					
						commit
						4e86eb43fc
					
				@ -385,7 +385,7 @@ fdbtn?.addEventListener('click', closeFullDetails);
 | 
			
		||||
      <div class="meta">
 | 
			
		||||
        ${r.cog?`<span>cog: ${r.cog}</span>`:''}
 | 
			
		||||
        ${r.module?`<span>module: ${moduleSansPrefix(r)}</span>`:''}
 | 
			
		||||
        ${r.required_permissions&&r.required_permissions.length?`<span>perms: ${r.required_permissions.join(', ')}</span>`:''}
 | 
			
		||||
        <span>runs: ${Number(r.exec_count||0).toLocaleString()}</span>
 | 
			
		||||
      </div>
 | 
			
		||||
      ${usageBlockHTML(r.usage_prefix)}
 | 
			
		||||
      ${usageBlockHTML(r.usage_slash)}
 | 
			
		||||
@ -454,7 +454,7 @@ fdbtn?.addEventListener('click', closeFullDetails);
 | 
			
		||||
      <div class="meta">
 | 
			
		||||
        ${r.cog?`<span>cog: ${r.cog}</span>`:''}
 | 
			
		||||
        ${r.module?`<span>module: ${moduleSansPrefix(r)}</span>`:''}
 | 
			
		||||
        ${r.required_permissions&&r.required_permissions.length?`<span>perms: ${r.required_permissions.join(', ')}</span>`:''}
 | 
			
		||||
        <span>runs: ${Number(r.exec_count||0).toLocaleString()}</span>
 | 
			
		||||
      </div>
 | 
			
		||||
      ${usageHTML}
 | 
			
		||||
      ${helpSansMod(r)?`<div class="help">${helpSansMod(r)}</div>`:''}
 | 
			
		||||
@ -485,18 +485,46 @@ fdbtn?.addEventListener('click', closeFullDetails);
 | 
			
		||||
  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();
 | 
			
		||||
})();
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										2
									
								
								bot.py
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								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 ----------
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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))}
 | 
			
		||||
 | 
			
		||||
@ -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):
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										0
									
								
								modules/usage/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								modules/usage/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										82
									
								
								modules/usage/usage_stats.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								modules/usage/usage_stats.py
									
									
									
									
									
										Normal file
									
								
							@ -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")
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user