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>`:''}
|
||||
@ -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();
|
||||
})();
|
||||
</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