0.4.0.0.a3
- Added linkable and sharable commands - Added copy-buttons to command fields
This commit is contained in:
parent
66447865f5
commit
87bcc61a1a
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.0.0.a2"
|
||||
VERSION = "0.4.0.0.a3"
|
||||
|
||||
# ---------- Env loading ----------
|
||||
|
||||
|
@ -50,28 +50,6 @@ def _to_primitive(obj: Any, depth: int = 0) -> Any:
|
||||
def _json_dumps_safe(payload: Any) -> bytes:
|
||||
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
|
||||
@ -95,7 +73,6 @@ def _read_version_from_file() -> Optional[str]:
|
||||
return None
|
||||
|
||||
def _get_version_from_botpy() -> Optional[str]:
|
||||
# Secondary fallback: import
|
||||
try:
|
||||
m = importlib.import_module("bot")
|
||||
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 []:
|
||||
if _looks_like_mod_check(chk):
|
||||
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):
|
||||
is_mod = True
|
||||
except Exception:
|
||||
@ -248,6 +225,30 @@ def _command_usage_slash(cmd: app_commands.Command) -> str:
|
||||
except Exception:
|
||||
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]]:
|
||||
rows: List[Dict[str, Any]] = []
|
||||
seen = set()
|
||||
@ -282,7 +283,7 @@ def _gather_prefix_and_hybrid(bot: commands.Bot) -> List[Dict[str, Any]]:
|
||||
row = {
|
||||
"type": ctype,
|
||||
"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(),
|
||||
"brief": (cmd.brief or "").strip(),
|
||||
"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]]:
|
||||
rows: List[Dict[str, Any]] = []
|
||||
|
||||
# Collect paths from all scopes (global + each guild)
|
||||
collected: List[Tuple[str, app_commands.Command, str]] = []
|
||||
try:
|
||||
for scope, top in _iter_all_app_commands(bot):
|
||||
# walk each top-level into leaves
|
||||
for path, leaf in _walk_app_tree(top, prefix=""):
|
||||
collected.append((scope, leaf, path))
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
|
||||
# De-dupe by canonical path (e.g. "power/restart"), regardless of scope
|
||||
seen_paths = set()
|
||||
for scope, leaf, path in collected:
|
||||
try:
|
||||
canon = path.lstrip("/") # e.g. "power/restart"
|
||||
canon = path.lstrip("/") # power/restart
|
||||
if canon in seen_paths:
|
||||
continue
|
||||
seen_paths.add(canon)
|
||||
@ -347,19 +344,14 @@ def _gather_slash(bot: commands.Bot) -> List[Dict[str, Any]]:
|
||||
binding = getattr(leaf, "binding", None)
|
||||
callback = getattr(leaf, "callback", None)
|
||||
|
||||
# UI shows "power restart" (title), but usage keeps "/power restart ..."
|
||||
display = canon.replace("/", " ")
|
||||
options = (
|
||||
getattr(leaf, "options", None)
|
||||
or getattr(leaf, "parameters", None)
|
||||
or getattr(leaf, "_params", None)
|
||||
)
|
||||
options = getattr(leaf, "options", None) or getattr(leaf, "parameters", None) or getattr(leaf, "_params", None)
|
||||
usage_full = _command_usage_slash_like(display, options)
|
||||
|
||||
row = {
|
||||
"type": "slash",
|
||||
"name": "/" + canon, # canonical with leading slash
|
||||
"display_name": "/" + display, # shown without the leading slash in UI
|
||||
"name": "/" + canon,
|
||||
"display_name": "/" + display,
|
||||
"help": (getattr(leaf, "description", "") or "").strip(),
|
||||
"brief": "",
|
||||
"usage": usage_full,
|
||||
@ -376,7 +368,6 @@ def _gather_slash(bot: commands.Bot) -> List[Dict[str, Any]]:
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
continue
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
@ -399,7 +390,7 @@ def _row_key_candidates(row: Dict[str, Any]) -> List[str]:
|
||||
if c:
|
||||
keys.append(f"{c}.{base}")
|
||||
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)
|
||||
return keys
|
||||
|
||||
@ -535,35 +526,6 @@ def _merge_hybrid_slash(rows: List[Dict[str, Any]]) -> None:
|
||||
for i in sorted(to_remove, reverse=True):
|
||||
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
|
||||
@ -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
|
||||
# =============================
|
||||
@ -630,7 +616,7 @@ _HTML = """<!doctype html>
|
||||
.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); }
|
||||
.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; }
|
||||
.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; }
|
||||
@ -638,25 +624,40 @@ _HTML = """<!doctype html>
|
||||
.pill.slash { border-color:#60a5fa; }
|
||||
.pill.prefix { border-color:#f59e0b; }
|
||||
.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; }
|
||||
.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-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; }
|
||||
.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 .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 {
|
||||
position: sticky;
|
||||
top: var(--header-h);
|
||||
height: var(--veil-h, 16px);
|
||||
pointer-events: none;
|
||||
z-index: 2; /* just under toolbar */
|
||||
z-index: 2;
|
||||
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));
|
||||
-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">
|
||||
<div class="line" id="copyright"></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>
|
||||
|
||||
<!-- 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>
|
||||
</main>
|
||||
<script>
|
||||
@ -732,13 +745,10 @@ function computeStickyTop() {
|
||||
const header = document.querySelector('header');
|
||||
const toolbar = document.getElementById('toolbar');
|
||||
const headerH = header ? header.offsetHeight : 0;
|
||||
// Set how much veil space we want between header bottom and toolbar top.
|
||||
const veilH = 16; // px
|
||||
// Toolbar sticks immediately under header
|
||||
const veilH = 16;
|
||||
document.documentElement.style.setProperty('--header-h', headerH + 'px');
|
||||
// Details panel sticks under header + toolbar + small rhythm spacing
|
||||
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');
|
||||
const veil = document.getElementById('veil');
|
||||
if (veil) {
|
||||
@ -778,6 +788,64 @@ function renderMD(src) {
|
||||
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() {
|
||||
if (!location.hash) location.hash = '#user';
|
||||
let data = (window.__DATA__ || null);
|
||||
@ -790,32 +858,67 @@ function renderMD(src) {
|
||||
|
||||
function shownName(r) {
|
||||
const n = (r.display_name || r.name || '');
|
||||
return n.replace(/^\\//, ''); // strip leading '/'
|
||||
return n.replace(/^\\//, '');
|
||||
}
|
||||
function helpSansMod(r) {
|
||||
return (r.help || '').replace(/^\\s*\\[MOD\\]\\s*/i, '');
|
||||
}
|
||||
function moduleSansPrefix(r) {
|
||||
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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
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(/</g,'<').replace(/>/g,'>').replace(/&/g,'&');
|
||||
const ok = await copyText(t);
|
||||
if (!ok) openCopyModal(t);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function openDetails(r) {
|
||||
const anchor = rowAnchor(r);
|
||||
replaceURLFor(anchor); // keep URL synced
|
||||
|
||||
let html = `
|
||||
<div class="name" style="margin-bottom:6px">
|
||||
<span class="pill ${r.type}">${r.type}</span>
|
||||
${r.moderator_only ? '<span class="pill mod">mod</span>' : ''}
|
||||
<span>${shownName(r)}</span>
|
||||
<span style="flex:1"></span>
|
||||
<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 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>` : '' }
|
||||
</div>
|
||||
${r.usage_prefix ? `<div class="usage">${r.usage_prefix}</div>` : ''}
|
||||
${r.usage_slash ? `<div class="usage">${r.usage_slash}</div>` : ''}
|
||||
${usageBlockHTML(r.usage_prefix)}
|
||||
${usageBlockHTML(r.usage_slash)}
|
||||
${helpSansMod(r) ? `<div class="help">${helpSansMod(r)}</div>` : ''}
|
||||
`;
|
||||
if (r.details_md) {
|
||||
@ -830,6 +933,11 @@ function renderMD(src) {
|
||||
}
|
||||
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
|
||||
if (window.matchMedia('(max-width: 900px)').matches) {
|
||||
detailsEl.classList.add('open');
|
||||
@ -850,32 +958,32 @@ function renderMD(src) {
|
||||
|
||||
function card(r) {
|
||||
const c = document.createElement('div');
|
||||
const anchor = rowAnchor(r);
|
||||
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();
|
||||
|
||||
const usageBlock = (() => {
|
||||
if (r.type === 'hybrid') {
|
||||
return `
|
||||
${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>` : '';
|
||||
})();
|
||||
const usageHTML = (r.type === 'hybrid')
|
||||
? `${usageBlockHTML(r.usage_prefix)}${usageBlockHTML(r.usage_slash)}`
|
||||
: `${usageBlockHTML(r.usage)}`;
|
||||
|
||||
c.innerHTML = `
|
||||
<div class="name">
|
||||
<span class="pill ${r.type}">${r.type}</span>
|
||||
${r.moderator_only ? '<span class="pill mod">mod</span>' : ''}
|
||||
<span>${shownName(r)}</span>
|
||||
<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 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>` : '' }
|
||||
</div>
|
||||
${usageBlock}
|
||||
${usageHTML}
|
||||
${helpSansMod(r) ? `<div class="help">${helpSansMod(r)}</div>` : ''}
|
||||
`;
|
||||
|
||||
@ -883,7 +991,14 @@ function renderMD(src) {
|
||||
ev.stopPropagation();
|
||||
openDetails(r);
|
||||
});
|
||||
c.querySelector('[data-share]').addEventListener('click', async (ev) => {
|
||||
ev.stopPropagation();
|
||||
await shareFor(r);
|
||||
});
|
||||
c.addEventListener('click', () => openDetails(r));
|
||||
|
||||
// Wire usage copy buttons within card
|
||||
wireUsageCopy(c);
|
||||
return c;
|
||||
}
|
||||
|
||||
@ -892,7 +1007,7 @@ function renderMD(src) {
|
||||
rows.forEach(r => target.appendChild(card(r)));
|
||||
}
|
||||
|
||||
function applyFilter() {
|
||||
function applyFilter(selectAnchorIfPresent=true) {
|
||||
if (!data) return;
|
||||
const all = data.all || [];
|
||||
const mods = (data.sections && data.sections.moderator) || [];
|
||||
@ -903,14 +1018,24 @@ function renderMD(src) {
|
||||
countsEl.textContent = ct;
|
||||
|
||||
const q = (qEl.value || '').toLowerCase();
|
||||
const hash = (location.hash || '#user').slice(1);
|
||||
const src = hash === 'moderator' ? mods : (hash === 'all' ? all : users);
|
||||
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 || ""]
|
||||
.join(' ').toLowerCase().includes(q)
|
||||
));
|
||||
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() {
|
||||
@ -921,9 +1046,9 @@ function renderMD(src) {
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
data = await res.json();
|
||||
}
|
||||
window.addEventListener('hashchange', applyFilter);
|
||||
qEl.addEventListener('input', applyFilter);
|
||||
applyFilter();
|
||||
window.addEventListener('hashchange', () => applyFilter(false));
|
||||
qEl.addEventListener('input', () => applyFilter(false));
|
||||
applyFilter(true);
|
||||
} catch (e) {
|
||||
document.getElementById('alerts').textContent = 'Failed to load.';
|
||||
}
|
||||
@ -988,33 +1113,17 @@ class _DocsHandler(BaseHTTPRequestHandler):
|
||||
return
|
||||
|
||||
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
|
||||
|
||||
# Static assets: /assets/docs/...
|
||||
# Serve static assets from /assets/docs/*
|
||||
if path.startswith("/assets/docs/"):
|
||||
try:
|
||||
root = _static_root().resolve()
|
||||
rel = path[len("/assets/docs/"):]
|
||||
fs_path = (root / rel).resolve()
|
||||
# Prevent path traversal
|
||||
try:
|
||||
# Python 3.10+: Path.is_relative_to
|
||||
if not fs_path.is_relative_to(root):
|
||||
raise ValueError("outside root")
|
||||
except AttributeError:
|
||||
# Fallback for very old Python (not needed on 3.10+)
|
||||
if str(root) not in str(fs_path):
|
||||
raise ValueError("outside root")
|
||||
if fs_path.is_file():
|
||||
@ -1035,6 +1144,18 @@ class _DocsHandler(BaseHTTPRequestHandler):
|
||||
self.wfile.write(b"internal error")
|
||||
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":
|
||||
payload = _status_payload(self.bot)
|
||||
self._set(200, "application/json; charset=utf-8")
|
||||
|
Loading…
Reference in New Issue
Block a user