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:
|
# Version consists of:
|
||||||
# Major.Enhancement.Minor.Patch.Test (Test is alphanumeric; doesn’t trigger auto update)
|
# 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 ----------
|
# ---------- Env loading ----------
|
||||||
|
|
||||||
|
@ -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,'&').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) {
|
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")
|
||||||
|
Loading…
Reference in New Issue
Block a user