0.4.0.0.a3

- Added linkable and sharable commands
- Added copy-buttons to command fields
This commit is contained in:
Franz Rolfsvaag 2025-08-13 12:47:34 +02:00
parent 66447865f5
commit 87bcc61a1a
2 changed files with 242 additions and 121 deletions

2
bot.py
View File

@ -9,7 +9,7 @@ from modules.common.boot_notice import post_boot_notice
# Version consists of: # Version consists of:
# Major.Enhancement.Minor.Patch.Test (Test is alphanumeric; doesnt trigger auto update) # Major.Enhancement.Minor.Patch.Test (Test is alphanumeric; doesnt trigger auto update)
VERSION = "0.4.0.0.a2" VERSION = "0.4.0.0.a3"
# ---------- Env loading ---------- # ---------- Env loading ----------

View File

@ -50,28 +50,6 @@ def _to_primitive(obj: Any, depth: int = 0) -> Any:
def _json_dumps_safe(payload: Any) -> bytes: def _json_dumps_safe(payload: Any) -> bytes:
return json.dumps(_to_primitive(payload), ensure_ascii=False, separators=(",", ":")).encode("utf-8") return json.dumps(_to_primitive(payload), ensure_ascii=False, separators=(",", ":")).encode("utf-8")
# =============================
# File serving helpers
# =============================
def _static_root() -> Path:
return _project_root() / "assets" / "docs"
def _guess_mime(p: Path) -> str:
ext = p.suffix.lower()
return {
".svg": "image/svg+xml; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
".css": "text/css; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".md": "text/markdown; charset=utf-8",
".txt": "text/plain; charset=utf-8",
}.get(ext, "application/octet-stream")
# ============================= # =============================
# Version / uptime helpers # Version / uptime helpers
@ -95,7 +73,6 @@ def _read_version_from_file() -> Optional[str]:
return None return None
def _get_version_from_botpy() -> Optional[str]: def _get_version_from_botpy() -> Optional[str]:
# Secondary fallback: import
try: try:
m = importlib.import_module("bot") m = importlib.import_module("bot")
v = getattr(m, "VERSION", None) v = getattr(m, "VERSION", None)
@ -203,7 +180,7 @@ def _is_mod_command_slash(cmd: app_commands.Command) -> Tuple[bool, List[str]]:
for chk in getattr(cmd, "checks", []) or []: for chk in getattr(cmd, "checks", []) or []:
if _looks_like_mod_check(chk): if _looks_like_mod_check(chk):
is_mod = True is_mod = True
for chk in getattr(cmd, "_checks", []) or []: # private best-effort for chk in getattr(cmd, "_checks", []) or []:
if _looks_like_mod_check(chk): if _looks_like_mod_check(chk):
is_mod = True is_mod = True
except Exception: except Exception:
@ -248,6 +225,30 @@ def _command_usage_slash(cmd: app_commands.Command) -> str:
except Exception: except Exception:
return f"/{cmd.name}" return f"/{cmd.name}"
def _iter_all_app_commands(bot: commands.Bot):
"""Yield (scope_tag, top_level_command) including global and per-guild trees."""
out = []
# Global
try:
for cmd in bot.tree.get_commands():
out.append(("", cmd))
except Exception:
pass
# Per-guild
for g in list(getattr(bot, "guilds", []) or []):
try:
cmds = bot.tree.get_commands(guild=g)
except TypeError:
try:
cmds = bot.tree.get_commands(guild=discord.Object(id=g.id))
except Exception:
cmds = []
except Exception:
cmds = []
for cmd in cmds or []:
out.append((str(g.id), cmd))
return out
def _gather_prefix_and_hybrid(bot: commands.Bot) -> List[Dict[str, Any]]: def _gather_prefix_and_hybrid(bot: commands.Bot) -> List[Dict[str, Any]]:
rows: List[Dict[str, Any]] = [] rows: List[Dict[str, Any]] = []
seen = set() seen = set()
@ -282,7 +283,7 @@ def _gather_prefix_and_hybrid(bot: commands.Bot) -> List[Dict[str, Any]]:
row = { row = {
"type": ctype, "type": ctype,
"name": cmd.qualified_name, "name": cmd.qualified_name,
"display_name": cmd.qualified_name, # bare name, no slash/prefix "display_name": cmd.qualified_name, # bare name
"help": (cmd.help or "").strip(), "help": (cmd.help or "").strip(),
"brief": (cmd.brief or "").strip(), "brief": (cmd.brief or "").strip(),
"usage": usage_prefix if not is_hybrid else None, "usage": usage_prefix if not is_hybrid else None,
@ -323,22 +324,18 @@ def _safe_extras(obj: Any) -> Optional[Dict[str, Any]]:
def _gather_slash(bot: commands.Bot) -> List[Dict[str, Any]]: def _gather_slash(bot: commands.Bot) -> List[Dict[str, Any]]:
rows: List[Dict[str, Any]] = [] rows: List[Dict[str, Any]] = []
# Collect paths from all scopes (global + each guild)
collected: List[Tuple[str, app_commands.Command, str]] = [] collected: List[Tuple[str, app_commands.Command, str]] = []
try: try:
for scope, top in _iter_all_app_commands(bot): for scope, top in _iter_all_app_commands(bot):
# walk each top-level into leaves
for path, leaf in _walk_app_tree(top, prefix=""): for path, leaf in _walk_app_tree(top, prefix=""):
collected.append((scope, leaf, path)) collected.append((scope, leaf, path))
except Exception: except Exception:
traceback.print_exc() traceback.print_exc()
# De-dupe by canonical path (e.g. "power/restart"), regardless of scope
seen_paths = set() seen_paths = set()
for scope, leaf, path in collected: for scope, leaf, path in collected:
try: try:
canon = path.lstrip("/") # e.g. "power/restart" canon = path.lstrip("/") # power/restart
if canon in seen_paths: if canon in seen_paths:
continue continue
seen_paths.add(canon) seen_paths.add(canon)
@ -347,19 +344,14 @@ def _gather_slash(bot: commands.Bot) -> List[Dict[str, Any]]:
binding = getattr(leaf, "binding", None) binding = getattr(leaf, "binding", None)
callback = getattr(leaf, "callback", None) callback = getattr(leaf, "callback", None)
# UI shows "power restart" (title), but usage keeps "/power restart ..."
display = canon.replace("/", " ") display = canon.replace("/", " ")
options = ( options = getattr(leaf, "options", None) or getattr(leaf, "parameters", None) or getattr(leaf, "_params", None)
getattr(leaf, "options", None)
or getattr(leaf, "parameters", None)
or getattr(leaf, "_params", None)
)
usage_full = _command_usage_slash_like(display, options) usage_full = _command_usage_slash_like(display, options)
row = { row = {
"type": "slash", "type": "slash",
"name": "/" + canon, # canonical with leading slash "name": "/" + canon,
"display_name": "/" + display, # shown without the leading slash in UI "display_name": "/" + display,
"help": (getattr(leaf, "description", "") or "").strip(), "help": (getattr(leaf, "description", "") or "").strip(),
"brief": "", "brief": "",
"usage": usage_full, "usage": usage_full,
@ -376,7 +368,6 @@ def _gather_slash(bot: commands.Bot) -> List[Dict[str, Any]]:
except Exception: except Exception:
traceback.print_exc() traceback.print_exc()
continue continue
return rows return rows
@ -399,7 +390,7 @@ def _row_key_candidates(row: Dict[str, Any]) -> List[str]:
if c: if c:
keys.append(f"{c}.{base}") keys.append(f"{c}.{base}")
if row.get("type") == "slash": if row.get("type") == "slash":
keys.append(str(row.get("name", "")).lstrip("/")) # e.g., power/restart keys.append(str(row.get("name", "")).lstrip("/"))
keys.append(base) keys.append(base)
return keys return keys
@ -535,35 +526,6 @@ def _merge_hybrid_slash(rows: List[Dict[str, Any]]) -> None:
for i in sorted(to_remove, reverse=True): for i in sorted(to_remove, reverse=True):
rows.pop(i) rows.pop(i)
# =============================
# Global commands helper
# =============================
def _iter_all_app_commands(bot: commands.Bot):
"""Yield (path, app_commands.Command) for global and per-guild trees."""
out = []
# Global
try:
for cmd in bot.tree.get_commands():
out.append(("", cmd)) # empty scope tag
except Exception:
pass
# Per-guild (guild-specific commands live here)
for g in list(getattr(bot, "guilds", []) or []):
try:
cmds = bot.tree.get_commands(guild=g)
except TypeError:
# older d.py variants accept Snowflake-like instead of Guild
try:
cmds = bot.tree.get_commands(guild=discord.Object(id=g.id))
except Exception:
cmds = []
except Exception:
cmds = []
for cmd in cmds or []:
out.append((str(g.id), cmd)) # scope tag = guild id as string
return out
# ============================= # =============================
# Schema builder # Schema builder
@ -608,6 +570,30 @@ def build_command_schema(bot: commands.Bot) -> Dict[str, Any]:
} }
# =============================
# Static asset serving
# =============================
def _static_root() -> Path:
return _project_root() / "assets" / "docs"
def _guess_mime(p: Path) -> str:
ext = p.suffix.lower()
return {
".svg": "image/svg+xml; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
".css": "text/css; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".md": "text/markdown; charset=utf-8",
".txt": "text/plain; charset=utf-8",
}.get(ext, "application/octet-stream")
# ============================= # =============================
# HTTP + UI # HTTP + UI
# ============================= # =============================
@ -630,7 +616,7 @@ _HTML = """<!doctype html>
.toolbar { margin-bottom:16px; position:sticky; top: var(--header-h); z-index:2; } .toolbar { margin-bottom:16px; position:sticky; top: var(--header-h); z-index:2; }
.search { width:100%; padding:10px 12px; border-radius:8px; border:1px solid #1f2937; background:#0b1220; color:var(--fg); } .search { width:100%; padding:10px 12px; border-radius:8px; border:1px solid #1f2937; background:#0b1220; color:var(--fg); }
.list { margin-top:12px; display:flex; flex-direction:column; gap:10px; transition: filter .2s ease; } .list { margin-top:12px; display:flex; flex-direction:column; gap:10px; transition: filter .2s ease; }
.card { border:1px solid #233; border-radius:10px; padding:10px 12px; background:#0c1522; cursor:default; } .card { border:1px solid #233; border-radius:10px; padding:10px 12px; background:#0c1522; cursor:default; scroll-margin-top: calc(var(--sticky-top) + 12px); }
.name { font-weight:600; display:flex; align-items:center; gap:8px; } .name { font-weight:600; display:flex; align-items:center; gap:8px; }
.meta { font-size:12px; color:var(--muted); display:flex; gap:10px; flex-wrap:wrap; margin-top:4px; } .meta { font-size:12px; color:var(--muted); display:flex; gap:10px; flex-wrap:wrap; margin-top:4px; }
.pill { display:inline-block; padding:2px 8px; border-radius:999px; font-size:12px; border:1px solid #2b4; } .pill { display:inline-block; padding:2px 8px; border-radius:999px; font-size:12px; border:1px solid #2b4; }
@ -638,25 +624,40 @@ _HTML = """<!doctype html>
.pill.slash { border-color:#60a5fa; } .pill.slash { border-color:#60a5fa; }
.pill.prefix { border-color:#f59e0b; } .pill.prefix { border-color:#f59e0b; }
.pill.hybrid { border-color:#34d399; } .pill.hybrid { border-color:#34d399; }
.usage { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size:12px; background:#0a1220; border:1px dashed #1f2937; padding:6px 8px; border-radius:6px; margin-top:6px; word-break:break-word; } .usage { position:relative; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size:12px; background:#0a1220; border:1px dashed #1f2937; padding:6px 8px; border-radius:6px; margin-top:6px; word-break:break-word; }
.help { margin-top:6px; color:#cbd5e1; } .help { margin-top:6px; color:#cbd5e1; }
.btn { padding:4px 8px; border:1px solid #334155; border-radius:8px; background:#0b1220; color:#e5e7eb; cursor:pointer; font-size:12px; } .btn { padding:4px 8px; border:1px solid #334155; border-radius:8px; background:#0b1220; color:#e5e7eb; cursor:pointer; font-size:12px; }
.btn:hover { background:#0f172a; } .btn:hover { background:#0f172a; }
.btn-icon { width:28px; height:28px; display:inline-flex; align-items:center; justify-content:center; padding:0; }
.btn-row { display:flex; gap:8px; align-items:center; }
.detailsbox { margin-top:12px; position: sticky; top: var(--sticky-top); min-height: 280px; z-index:1; transition: transform .25s ease; } .detailsbox { margin-top:12px; position: sticky; top: var(--sticky-top); min-height: 280px; z-index:1; transition: transform .25s ease; }
.flag-emoji { height:1em; width:auto; vertical-align:-0.18em; border-radius:2px; display:inline-block; } .flag-emoji { height:1em; width:auto; vertical-align:-0.18em; border-radius:2px; display:inline-block; }
footer { margin-top:16px; color:var(--muted); font-size:12px; text-align:center; } footer { margin-top:16px; color:var(--muted); font-size:12px; text-align:center; }
footer .line { margin:4px 0; } footer .line { margin:4px 0; }
/* Blur veil between header and toolbar (gradually increases up to toolbar) */ /* Copy modal */
#copyModal { position: fixed; inset: 0; display:none; align-items:center; justify-content:center; z-index: 10; }
#copyModal.open { display:flex; }
#copyModal .overlay { position:absolute; inset:0; background:rgba(0,0,0,.4); }
#copyModal .sheet { position:relative; z-index:1; min-width: min(520px, 92vw); background:var(--panel); border:1px solid #1f2937; border-radius:12px; padding:16px; box-shadow: 0 20px 60px rgba(0,0,0,.45); }
#copyModal .close { position:absolute; right:10px; top:10px; }
/* Copy button inside usage */
.usage .copybtn { position:absolute; right:6px; top:6px; }
@media (max-width: 900px) {
.usage .copybtn { right:6px; top:6px; }
.btn-icon { width:auto; padding:4px 10px; } /* larger tap target on mobile */
}
/* Blur veil under the toolbar */
.veil { .veil {
position: sticky; position: sticky;
top: var(--header-h); top: var(--header-h);
height: var(--veil-h, 16px); height: var(--veil-h, 16px);
pointer-events: none; pointer-events: none;
z-index: 2; /* just under toolbar */ z-index: 2;
backdrop-filter: blur(4px); backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
/* Gradual mask: 0 blur visible at top, full blur at bottom near the toolbar */
mask-image: linear-gradient(to bottom, rgba(0,0,0,0), rgba(0,0,0,1)); mask-image: linear-gradient(to bottom, rgba(0,0,0,0), rgba(0,0,0,1));
-webkit-mask-image: linear-gradient(to bottom, rgba(0,0,0,0), rgba(0,0,0,1)); -webkit-mask-image: linear-gradient(to bottom, rgba(0,0,0,0), rgba(0,0,0,1));
} }
@ -722,9 +723,21 @@ _HTML = """<!doctype html>
<footer id="footer"> <footer id="footer">
<div class="line" id="copyright"></div> <div class="line" id="copyright"></div>
<div class="line" id="statusline">Uptime: · Version: v</div> <div class="line" id="statusline">Uptime: · Version: v</div>
<div class="line" id="coffee"><a href="https://throne.com/ookamikuntv/item/39590391-c582-4c5d-8795-fe6f1925eaae">Buy me a </a></div>
</footer> </footer>
<!-- Copy/share modal -->
<div id="copyModal" aria-hidden="true">
<div class="overlay" data-close="1"></div>
<div class="sheet">
<button class="btn btn-icon close" title="Close" data-close="1"></button>
<div id="copyText" style="word-break:break-all; font-family: ui-monospace, monospace;"></div>
<div style="margin-top:10px" class="btn-row">
<button id="copyAction" class="btn">Copy</button>
<span id="copyHint" style="font-size:12px;color:#9ca3af">Tap outside to dismiss.</span>
</div>
</div>
</div>
<div id="backdrop"></div> <div id="backdrop"></div>
</main> </main>
<script> <script>
@ -732,13 +745,10 @@ function computeStickyTop() {
const header = document.querySelector('header'); const header = document.querySelector('header');
const toolbar = document.getElementById('toolbar'); const toolbar = document.getElementById('toolbar');
const headerH = header ? header.offsetHeight : 0; const headerH = header ? header.offsetHeight : 0;
// Set how much veil space we want between header bottom and toolbar top. const veilH = 16;
const veilH = 16; // px
// Toolbar sticks immediately under header
document.documentElement.style.setProperty('--header-h', headerH + 'px'); document.documentElement.style.setProperty('--header-h', headerH + 'px');
// Details panel sticks under header + toolbar + small rhythm spacing
const toolbarH = toolbar ? toolbar.offsetHeight : 0; const toolbarH = toolbar ? toolbar.offsetHeight : 0;
const stickyTop = headerH + toolbarH + 8; // rhythm const stickyTop = headerH + toolbarH + 8;
document.documentElement.style.setProperty('--sticky-top', stickyTop + 'px'); document.documentElement.style.setProperty('--sticky-top', stickyTop + 'px');
const veil = document.getElementById('veil'); const veil = document.getElementById('veil');
if (veil) { if (veil) {
@ -778,6 +788,64 @@ function renderMD(src) {
return s; return s;
} }
// --------- Linking helpers ---------
function rowAnchor(r) {
// Stable anchor: "<cog-or-nocog>-<path with slashes -> dashes>"
const cog = (r.cog || 'nocog').toLowerCase();
let base = (r.name || '').toLowerCase();
base = base.replace(/^\\//, ''); // remove leading "/"
base = base.replace(/\\s+/g, '-'); // spaces to dash
base = base.replace(/\\//g, '-'); // slashes to dash
return `${cog}-${base}`;
}
function getFilterFromHash() {
const h = (location.hash || '#user').slice(1);
// support legacy "cmd=" in hash too
const parts = h.split('&').map(s => s.trim());
const filt = parts.find(p => p === 'user' || p === 'moderator' || p === 'all') || 'user';
let cmd = null;
const cmdPart = parts.find(p => p.startsWith('cmd='));
if (cmdPart) cmd = cmdPart.slice(4);
// also check query
const sp = new URLSearchParams(location.search);
const qcmd = sp.get('cmd');
if (!cmd && qcmd) cmd = qcmd;
return { filter: filt, cmd };
}
function buildLink(anchor) {
const url = new URL(window.location.href);
url.searchParams.set('cmd', anchor);
url.hash = location.hash || '#user';
return url.toString();
}
function replaceURLFor(anchor) {
try {
const url = new URL(window.location.href);
url.searchParams.set('cmd', anchor);
history.replaceState(null, '', url.toString());
} catch {}
}
async function copyText(s) {
try {
await navigator.clipboard.writeText(s);
return true;
} catch {
return false;
}
}
function openCopyModal(text) {
const modal = document.getElementById('copyModal');
const sheet = modal.querySelector('.sheet');
document.getElementById('copyText').textContent = text;
const action = document.getElementById('copyAction');
const closeables = modal.querySelectorAll('[data-close]');
const close = () => { modal.classList.remove('open'); action.onclick = null; closeables.forEach(el => el.onclick = null); };
closeables.forEach(el => el.onclick = close);
action.onclick = async () => { const ok = await copyText(text); if (ok) close(); };
modal.classList.add('open');
}
// --------- App ---------
(function() { (function() {
if (!location.hash) location.hash = '#user'; if (!location.hash) location.hash = '#user';
let data = (window.__DATA__ || null); let data = (window.__DATA__ || null);
@ -790,32 +858,67 @@ function renderMD(src) {
function shownName(r) { function shownName(r) {
const n = (r.display_name || r.name || ''); const n = (r.display_name || r.name || '');
return n.replace(/^\\//, ''); // strip leading '/' return n.replace(/^\\//, '');
} }
function helpSansMod(r) { function helpSansMod(r) {
return (r.help || '').replace(/^\\s*\\[MOD\\]\\s*/i, ''); return (r.help || '').replace(/^\\s*\\[MOD\\]\\s*/i, '');
} }
function moduleSansPrefix(r) { function moduleSansPrefix(r) {
const m = r.module || ''; const m = r.module || '';
return m.replace(/^modules?\\./, ''); // 'modules.' or 'module.' return m.replace(/^modules?\\./, '');
}
async function shareFor(r) {
const anchor = rowAnchor(r);
const url = buildLink(anchor);
const ok = await copyText(url);
if (!ok) openCopyModal(url);
}
function usageBlockHTML(text) {
if (!text) return '';
const esc = String(text).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
return `
<div class="usage">
${esc}
<button class="btn btn-icon copybtn" title="Copy usage" data-copy="${esc}">📋</button>
</div>
`;
}
function wireUsageCopy(container) {
container.querySelectorAll('[data-copy]').forEach(btn => {
btn.addEventListener('click', async (ev) => {
ev.stopPropagation();
const t = btn.getAttribute('data-copy').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&amp;/g,'&');
const ok = await copyText(t);
if (!ok) openCopyModal(t);
});
});
} }
function openDetails(r) { function openDetails(r) {
const anchor = rowAnchor(r);
replaceURLFor(anchor); // keep URL synced
let html = ` let html = `
<div class="name" style="margin-bottom:6px"> <div class="name" style="margin-bottom:6px">
<span class="pill ${r.type}">${r.type}</span> <span class="pill ${r.type}">${r.type}</span>
${r.moderator_only ? '<span class="pill mod">mod</span>' : ''} ${r.moderator_only ? '<span class="pill mod">mod</span>' : ''}
<span>${shownName(r)}</span> <span>${shownName(r)}</span>
<span style="flex:1"></span> <span style="flex:1"></span>
<button class="btn" id="closeDetails" style="display:none">Close</button> <div class="btn-row">
<button class="btn btn-icon" title="Copy link" id="shareDetails">🔗</button>
<button class="btn" id="closeDetails" style="display:none">Close</button>
</div>
</div> </div>
<div class="meta"> <div class="meta">
${r.cog ? `<span>cog: ${r.cog}</span>` : '' } ${r.cog ? `<span>cog: ${r.cog}</span>` : '' }
${r.module ? `<span>module: ${moduleSansPrefix(r)}</span>` : '' } ${r.module ? `<span>module: ${moduleSansPrefix(r)}</span>` : '' }
${r.required_permissions && r.required_permissions.length ? `<span>perms: ${r.required_permissions.join(', ')}</span>` : '' } ${r.required_permissions && r.required_permissions.length ? `<span>perms: ${r.required_permissions.join(', ')}</span>` : '' }
</div> </div>
${r.usage_prefix ? `<div class="usage">${r.usage_prefix}</div>` : ''} ${usageBlockHTML(r.usage_prefix)}
${r.usage_slash ? `<div class="usage">${r.usage_slash}</div>` : ''} ${usageBlockHTML(r.usage_slash)}
${helpSansMod(r) ? `<div class="help">${helpSansMod(r)}</div>` : ''} ${helpSansMod(r) ? `<div class="help">${helpSansMod(r)}</div>` : ''}
`; `;
if (r.details_md) { if (r.details_md) {
@ -830,6 +933,11 @@ function renderMD(src) {
} }
detailsEl.innerHTML = html; detailsEl.innerHTML = html;
// Wire share + copy buttons
const shareBtn = document.getElementById('shareDetails');
if (shareBtn) shareBtn.onclick = (e) => { e.stopPropagation(); shareFor(r); };
wireUsageCopy(detailsEl);
// Mobile sheet open // Mobile sheet open
if (window.matchMedia('(max-width: 900px)').matches) { if (window.matchMedia('(max-width: 900px)').matches) {
detailsEl.classList.add('open'); detailsEl.classList.add('open');
@ -850,32 +958,32 @@ function renderMD(src) {
function card(r) { function card(r) {
const c = document.createElement('div'); const c = document.createElement('div');
const anchor = rowAnchor(r);
c.className = 'card'; c.className = 'card';
c.id = 'card-' + anchor;
c.dataset.anchor = anchor;
c.dataset.search = [shownName(r), helpSansMod(r), r.brief, r.usage, r.usage_prefix, r.usage_slash, r.cog, r.module, r.details_md || ""].join(' ').toLowerCase(); c.dataset.search = [shownName(r), helpSansMod(r), r.brief, r.usage, r.usage_prefix, r.usage_slash, r.cog, r.module, r.details_md || ""].join(' ').toLowerCase();
const usageBlock = (() => { const usageHTML = (r.type === 'hybrid')
if (r.type === 'hybrid') { ? `${usageBlockHTML(r.usage_prefix)}${usageBlockHTML(r.usage_slash)}`
return ` : `${usageBlockHTML(r.usage)}`;
${r.usage_prefix ? `<div class="usage">${r.usage_prefix}</div>` : ''}
${r.usage_slash ? `<div class="usage">${r.usage_slash}</div>` : ''}
`;
}
return r.usage ? `<div class="usage">${r.usage}</div>` : '';
})();
c.innerHTML = ` c.innerHTML = `
<div class="name"> <div class="name">
<span class="pill ${r.type}">${r.type}</span> <span class="pill ${r.type}">${r.type}</span>
${r.moderator_only ? '<span class="pill mod">mod</span>' : ''} ${r.moderator_only ? '<span class="pill mod">mod</span>' : ''}
<span>${shownName(r)}</span> <span>${shownName(r)}</span>
<button class="btn" data-details="1">Details</button> <div class="btn-row">
<button class="btn btn-icon" title="Copy link" data-share="1">🔗</button>
<button class="btn" data-details="1">Details</button>
</div>
</div> </div>
<div class="meta"> <div class="meta">
${r.cog ? `<span>cog: ${r.cog}</span>` : '' } ${r.cog ? `<span>cog: ${r.cog}</span>` : '' }
${r.module ? `<span>module: ${moduleSansPrefix(r)}</span>` : '' } ${r.module ? `<span>module: ${moduleSansPrefix(r)}</span>` : '' }
${r.required_permissions && r.required_permissions.length ? `<span>perms: ${r.required_permissions.join(', ')}</span>` : '' } ${r.required_permissions && r.required_permissions.length ? `<span>perms: ${r.required_permissions.join(', ')}</span>` : '' }
</div> </div>
${usageBlock} ${usageHTML}
${helpSansMod(r) ? `<div class="help">${helpSansMod(r)}</div>` : ''} ${helpSansMod(r) ? `<div class="help">${helpSansMod(r)}</div>` : ''}
`; `;
@ -883,7 +991,14 @@ function renderMD(src) {
ev.stopPropagation(); ev.stopPropagation();
openDetails(r); openDetails(r);
}); });
c.querySelector('[data-share]').addEventListener('click', async (ev) => {
ev.stopPropagation();
await shareFor(r);
});
c.addEventListener('click', () => openDetails(r)); c.addEventListener('click', () => openDetails(r));
// Wire usage copy buttons within card
wireUsageCopy(c);
return c; return c;
} }
@ -892,7 +1007,7 @@ function renderMD(src) {
rows.forEach(r => target.appendChild(card(r))); rows.forEach(r => target.appendChild(card(r)));
} }
function applyFilter() { function applyFilter(selectAnchorIfPresent=true) {
if (!data) return; if (!data) return;
const all = data.all || []; const all = data.all || [];
const mods = (data.sections && data.sections.moderator) || []; const mods = (data.sections && data.sections.moderator) || [];
@ -903,14 +1018,24 @@ function renderMD(src) {
countsEl.textContent = ct; countsEl.textContent = ct;
const q = (qEl.value || '').toLowerCase(); const q = (qEl.value || '').toLowerCase();
const hash = (location.hash || '#user').slice(1); const { filter, cmd } = getFilterFromHash();
const src = hash === 'moderator' ? mods : (hash === 'all' ? all : users); const src = filter === 'moderator' ? mods : (filter === 'all' ? all : users);
const rows = !q ? src : src.filter(r => ( 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 || ""] [shownName(r), helpSansMod(r), r.brief, r.usage, r.usage_prefix, r.usage_slash, r.cog, r.module, r.details_md || ""]
.join(' ').toLowerCase().includes(q) .join(' ').toLowerCase().includes(q)
)); ));
render(listEl, rows); render(listEl, rows);
if (rows.length) openDetails(rows[0]); // auto-select first
// Auto-select first or anchor target
let sel = rows[0];
if (selectAnchorIfPresent && cmd) {
const hit = rows.find(r => rowAnchor(r) === cmd);
if (hit) sel = hit;
// Also scroll the specific card into view smoothly
const cardEl = document.getElementById('card-' + cmd);
if (cardEl) cardEl.scrollIntoView({behavior:'smooth', block:'start'});
}
if (sel) openDetails(sel);
} }
async function boot() { async function boot() {
@ -921,9 +1046,9 @@ function renderMD(src) {
if (!res.ok) throw new Error('HTTP ' + res.status); if (!res.ok) throw new Error('HTTP ' + res.status);
data = await res.json(); data = await res.json();
} }
window.addEventListener('hashchange', applyFilter); window.addEventListener('hashchange', () => applyFilter(false));
qEl.addEventListener('input', applyFilter); qEl.addEventListener('input', () => applyFilter(false));
applyFilter(); applyFilter(true);
} catch (e) { } catch (e) {
document.getElementById('alerts').textContent = 'Failed to load.'; document.getElementById('alerts').textContent = 'Failed to load.';
} }
@ -988,33 +1113,17 @@ class _DocsHandler(BaseHTTPRequestHandler):
return return
path = urlparse(self.path).path path = urlparse(self.path).path
if path == "/":
self._set()
html = _HTML.replace("__TITLE__", self.title)
try:
schema = build_command_schema(self.bot)
inline = json.dumps(_to_primitive(schema), ensure_ascii=False, separators=(",", ":"))
html = html.replace("</head>", f"<script>window.__DATA__={inline};</script></head>")
except Exception:
traceback.print_exc()
self.wfile.write(html.encode("utf-8"))
return
path = urlparse(self.path).path # Serve static assets from /assets/docs/*
# Static assets: /assets/docs/...
if path.startswith("/assets/docs/"): if path.startswith("/assets/docs/"):
try: try:
root = _static_root().resolve() root = _static_root().resolve()
rel = path[len("/assets/docs/"):] rel = path[len("/assets/docs/"):]
fs_path = (root / rel).resolve() fs_path = (root / rel).resolve()
# Prevent path traversal
try: try:
# Python 3.10+: Path.is_relative_to
if not fs_path.is_relative_to(root): if not fs_path.is_relative_to(root):
raise ValueError("outside root") raise ValueError("outside root")
except AttributeError: except AttributeError:
# Fallback for very old Python (not needed on 3.10+)
if str(root) not in str(fs_path): if str(root) not in str(fs_path):
raise ValueError("outside root") raise ValueError("outside root")
if fs_path.is_file(): if fs_path.is_file():
@ -1035,6 +1144,18 @@ class _DocsHandler(BaseHTTPRequestHandler):
self.wfile.write(b"internal error") self.wfile.write(b"internal error")
return return
if path == "/":
self._set()
html = _HTML.replace("__TITLE__", self.title)
try:
schema = build_command_schema(self.bot)
inline = json.dumps(_to_primitive(schema), ensure_ascii=False, separators=(",", ":"))
html = html.replace("</head>", f"<script>window.__DATA__={inline};</script></head>")
except Exception:
traceback.print_exc()
self.wfile.write(html.encode("utf-8"))
return
if path == "/api/status": if path == "/api/status":
payload = _status_payload(self.bot) payload = _status_payload(self.bot)
self._set(200, "application/json; charset=utf-8") self._set(200, "application/json; charset=utf-8")