scripts/gen_admin_panel_doc_tables.py
Rama main · réplica de lectura del repositorio original.
"""Generate the admin-panel API doc's route and permission tables from source.
The reason this script exists: those tables drifted ~40% out of date without
anyone noticing, because nothing checked them. They are generated from
web/src/nav.ts, web/src/permissions.tsx and cmd/telesrv-admin/server.go
instead, and the doc carries the generated marker so a hand edit is visible in
review as a diff against the next regeneration.
Usage:
python3 scripts/gen_admin_panel_doc_tables.py # print
python3 scripts/gen_admin_panel_doc_tables.py --check # exit 1 if stale
"""
from __future__ import annotations
import argparse
import pathlib
import re
import sys
ROOT = pathlib.Path(__file__).resolve().parent.parent
WEB = ROOT / "cmd/telesrv-admin/web/src"
WEB_PAGES = WEB / "pages"
SERVER_GO = ROOT / "cmd/telesrv-admin/server.go"
# ---------------------------------------------------------------- nav sections
# The section header is matched on its own and the tabs array is then read out by
# bracket matching. It used to be captured by one non-greedy (?P<tabs>.*?)
# terminated with `\]\s*\}`, which stops at the first `]` that happens to be
# followed by a `}` -- i.e. the closing bracket of an entry's own
# `aliases: [...]` -- so a section whose tabs carry aliases lost its tabs and
# dropped out of the table with no error at all. /messages/private is that
# entry, and the docs silently lost the Messages section because of it.
SECTION_RE = re.compile(
r'\{\s*(?://[^\n]*\n\s*)*id:\s*"(?P<id>[a-z0-9-]+)",\s*labelKey:\s*"(?P<label>[^"]+)",'
r'\s*icon:\s*\w+,\s*tabs:\s*\['
)
TAB_RE = re.compile(
r'\{\s*path:\s*"(?P<path>[^"]+)",\s*labelKey:\s*"(?P<label>[a-zA-Z.]+)",'
r'\s*permission:\s*(?P<perm>\w+)(?P<rest>[^}]*)\}'
)
ALIAS_RE = re.compile(r'aliases:\s*\[(?P<paths>[^\]]*)\]')
def parse_aliases(rest: str) -> list[str]:
"""Return the extra paths a tab entry serves, if it lists any.
A tab that carries aliases is served under a second URL as well: the
Messages tab answers to a bare /messages, and the Premium tab still
answers to the old /star-issue. The table prints those paths, so
the URL a bookmark points at is written down next to the one the tab
lives at.
"""
match = ALIAS_RE.search(rest)
if not match:
return []
return re.findall(r'"([^"]+)"', match.group("paths"))
def read_bracket_array(src: str, open_bracket: int) -> str:
"""Return the text between the `[` at open_bracket and its matching `]`."""
depth = 0
for i in range(open_bracket, len(src)):
if src[i] == "[":
depth += 1
elif src[i] == "]":
depth -= 1
if depth == 0:
return src[open_bracket + 1 : i]
raise ValueError(f"unbalanced [ at offset {open_bracket} of nav.ts")
def parse_nav() -> list[tuple[str, str, list[tuple[str, str, str, list[str]]]]]:
"""Return [(section id, label key, [(path, label key, permission const, aliases)])]."""
src = (WEB / "nav.ts").read_text(encoding="utf-8")
body = src[src.index("export const navSections"):]
body = body[: body.index("\n];")]
out = []
pos = 0
while (m := SECTION_RE.search(body, pos)) is not None:
tabs_src = read_bracket_array(body, m.end() - 1)
pos = m.end() + len(tabs_src) + 1
tabs = []
for t in TAB_RE.finditer(tabs_src):
tabs.append(
(
t.group("path"),
t.group("label"),
t.group("perm"),
parse_aliases(t.group("rest")),
)
)
# A section that parses to zero tabs is a broken parse, not an empty
# section -- nav.ts has no such thing, and quietly dropping one is
# exactly how these tables rotted in the first place.
if not tabs:
raise ValueError(f"nav section {m.group('id')!r} parsed to zero tabs")
out.append((m.group("id"), m.group("label"), tabs))
# Every section object in nav.ts starts with an `id:`; if the regex matched
# fewer sections than there are ids, it silently skipped one (a comment or a
# hyphen in the id, say), which is exactly how these tables rot. Fail loudly
# instead of emitting docs that are missing a section.
declared = len(re.findall(r'\bid:\s*"', body))
if declared != len(out):
raise ValueError(
f"parsed {len(out)} nav sections but nav.ts declares {declared} 'id:' entries; "
"SECTION_RE missed a section"
)
return out
# ----------------------------------------------------------------- permissions
PERM_CONST_RE = re.compile(r'export const (\w+) = "([a-zA-Z.*]+)";')
LABEL_RE = re.compile(r'"([a-zA-Z.*]+)":\s*\{\s*title:\s*"((?:[^"\\]|\\.)*)",\s*hint:\s*"((?:[^"\\]|\\.)*)"')
# The group array is one object per line-per-field, so match each object as a
# unit rather than relying on the fields being adjacent.
GROUP_TITLE_RE = re.compile(
r'title:\s*"([^"]+)",\s*\n\s*hint:\s*"([^"]+)",\s*\n\s*permissions:\s*\[([^\]]*)\]',
)
GROUP_ITEM_RE = re.compile(r'"([a-zA-Z.*]+)"')
def parse_i18n(lang: str = "en") -> dict[str, str]:
"""English UI strings, so the tables show what an operator reads rather than
an i18n key. Falls back to the key itself when a string is missing, which
is itself a useful failure signal in review."""
src = (WEB / "i18n.tsx").read_text(encoding="utf-8")
body = src[src.index(f" {lang}: {{") + len(lang) + 6:]
nxt = [m.start() for m in re.finditer(r"^ (?:en|zh|ru): \{$", body, re.M)]
body = body[: nxt[0]] if nxt else body
out = {}
for m in re.finditer(r'^\s*"([a-zA-Z0-9_.]+)":\s*"((?:[^"\\]|\\.)*)"', body, re.M):
out[m.group(1)] = m.group(2)
return out
def parse_permissions() -> tuple[dict[str, str], dict[str, str], list[tuple[str, str, list[str]]], list[str]]:
"""Return (const name -> name, name -> (title, hint), groups, assignable order)."""
src = (WEB / "permissions.tsx").read_text(encoding="utf-8")
const_to_name = dict(PERM_CONST_RE.findall(src))
labels: dict[str, str] = {}
label_block = src[src.index("const permissionLabels"): src.index("export function permissionTitle")]
for m in LABEL_RE.finditer(label_block):
labels[m.group(1)] = f"{m.group(2)} — {m.group(3)}"
groups: list[tuple[str, str, list[str]]] = []
group_src = src[src.index("export const permissionGroups"):]
group_src = group_src[: group_src.index("\n];")]
for m in GROUP_TITLE_RE.finditer(group_src):
groups.append((m.group(1), m.group(2), GROUP_ITEM_RE.findall(m.group(3))))
return const_to_name, labels, groups, sorted(labels)
SECURITY_GO = ROOT / "cmd/telesrv-admin/security.go"
def parse_assignable() -> list[str]:
"""Permission names in the order assignablePermissions() lists them.
This is the list the operator-accounts screen offers, so it -- not
permissionLabels -- is the set of rights that can actually be granted, and
it is therefore the set the documentation is about.
"""
src = SECURITY_GO.read_text(encoding="utf-8")
block = src[src.index("func assignablePermissions() []string {"):]
block = block[: block.index("\n}\n")]
const_to_name = dict(PERM_CONST_RE.findall((WEB / "permissions.tsx").read_text(encoding="utf-8")))
names = []
for line in block.splitlines():
m = re.match(r"\s*(permission\w+|permissionAll),", line)
if m:
names.append(const_to_name.get(m.group(1), m.group(1)))
return names
# ------------------------------------------------------------- server routes
ROUTE_RE = re.compile(
r'mux\.Handle(?:Func)?\(\s*"(?P<method>GET|POST|PUT|DELETE)\s+(?P<path>[^"]+)"\s*,\s*'
r'(?P<gate>s\.\w+)\((?P<handler>[\w.]+)'
)
def parse_routes() -> list[tuple[str, str, str, str]]:
"""Return [(method, path, gate name, handler)] for the panel's own routes."""
src = SERVER_GO.read_text(encoding="utf-8")
body = src[src.index("func (s *server) routes() http.Handler {"):]
body = body[: body.index("\n}\n")]
out = []
for m in ROUTE_RE.finditer(body):
gate = m.group("gate")
if gate == "mux.HandleFunc" or gate.endswith("HandleFunc"):
continue
gate = gate.rsplit(".", 1)[-1]
out.append((m.group("method"), m.group("path"), gate, m.group("handler")))
return out
# ------------------------------------------------------- section tab placement
def pages_rendering_section_tabs() -> list[str]:
"""Pages that render a <SectionTabs> of their own.
The shell (components/Layout.tsx) draws the active section's tab bar above
every page from nav.ts, so a page that also renders <SectionTabs> shows the
bar twice. That compiles and renders either way, so it is checked here for
the same reason the tables are: drift that renders is exactly the kind that
survives review.
"""
bad: list[str] = []
for page in sorted(WEB_PAGES.rglob("*.tsx")):
for lineno, line in enumerate(page.read_text(encoding="utf-8").splitlines(), 1):
if "<SectionTabs" in line:
bad.append(f"{page.relative_to(ROOT).as_posix()}:{lineno}")
return bad
# ------------------------------------------------------------------- rendering
GATE_TO_PERMISSION = {
"scopedRoute": None, # resolved per-route below
"serverManage": "server.manage",
"premiumManage": "premium.manage",
"requireAdminsManage": "admins.manage",
}
def gate_permission(gate: str) -> str:
if gate in GATE_TO_PERMISSION and GATE_TO_PERMISSION[gate]:
return GATE_TO_PERMISSION[gate]
if gate == "scopedRoute":
return "per-route"
return gate
def render_tables() -> dict[str, str]:
const_to_name, labels, _groups, all_names = parse_permissions()
i18n = parse_i18n()
assignable = parse_assignable()
sections = parse_nav()
routes = parse_routes()
def label(key: str) -> str:
# A missing string is a bug worth seeing in the diff, not hiding.
return i18n.get(key, f"`{key}` (missing)")
# -- sections / tabs -----------------------------------------------------
# One row per tab. The section repeats rather than being left blank: a
# generated table with merged cells renders unpredictably once it is
# copy-pasted into anything else.
sections_md = [
"| Sidebar section | Tab | Path | Required right |",
"| --- | --- | --- | --- |",
]
tab_count = 0
for _sid, section_key, tabs in sections:
for path, tab_key, perm, aliases in tabs:
name = const_to_name.get(perm, perm)
suffix = ""
if aliases:
extra = ", ".join(f"`{alias}`" for alias in aliases)
suffix = f" (also served at {extra})"
sections_md.append(
f"| {label(section_key)} | {label(tab_key)} | `{path}` | `{name}`{suffix} |"
)
tab_count += 1
# -- permissions ---------------------------------------------------------
# Grouped the way the operator-accounts screen groups them, so the doc and
# the picker an operator actually clicks through read the same way.
perms_md = ["| Permission | Grants |", "| --- | --- |"]
seen: set[str] = set()
_c2n, _lbl, groups, _all = parse_permissions()
for title, _hint, members in groups:
rows = []
for name in members:
if name not in assignable or name in seen:
continue
seen.add(name)
rows.append(f"| `{name}` | {labels.get(name, '—')} |")
if rows:
perms_md.append(f"| **{title}** | |")
perms_md.extend(rows)
leftovers = [n for n in assignable if n not in seen]
if leftovers:
perms_md.append("| **Ungrouped** | |")
perms_md.extend(f"| `{n}` | {labels.get(n, '—')} |" for n in leftovers)
perms_md.append("| `*` | Every right, including ones added later (wildcard). |")
return {
"sections": "\n".join(sections_md),
"permissions": "\n".join(perms_md),
"assignable_count": str(len(assignable)),
"permission_count": str(len(all_names)),
"section_count": str(len(sections)),
"tab_count": str(tab_count),
"route_count": str(len(routes)),
"unlabelled": ", ".join(n for n in assignable if n not in labels) or "none",
}
BEGIN = "<!-- BEGIN GENERATED:{block} -->"
END = "<!-- END GENERATED:{block} -->"
NOTE = (
"<!-- GENERATED by scripts/gen_admin_panel_doc_tables.py -- run it and paste"
" the output, or use --check in CI. Edit nav.ts / permissions.tsx instead. -->"
)
def block(block_name: str, body: str) -> str:
return f"{BEGIN.format(block=block_name)}\n{body}\n{END.format(block=block_name)}"
def splice(doc_path: pathlib.Path, block_name: str, body: str) -> bool:
"""Replace one generated block in a doc file. Returns True if it changed."""
doc = doc_path.read_text(encoding="utf-8")
start = doc.index(BEGIN.format(block=block_name))
end = doc.index(END.format(block=block_name))
updated = doc[:start] + block(block_name, body) + doc[end + len(END.format(block=block_name)) :]
if updated == doc:
return False
doc_path.write_text(updated, encoding="utf-8")
return True
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument(
"--check",
action="store_true",
help="exit 1 if any generated block in the docs is stale",
)
parser.add_argument(
"--write",
action="store_true",
help="rewrite the generated blocks in the docs in place",
)
args = parser.parse_args()
tables = render_tables()
blocks = {
"nav": tables["sections"],
"permissions": tables["permissions"],
}
docs = [
ROOT / "docs/admin-panel-api.en.md",
ROOT / "docs/admin-panel-api.ru.md",
]
if args.check:
stale = []
for doc_path in docs:
if not doc_path.exists():
continue
doc = doc_path.read_text(encoding="utf-8")
for name, body in blocks.items():
begin = BEGIN.format(block=name)
end = END.format(block=name)
if begin not in doc or end not in doc:
continue
# Compare the body *between* the markers: including the BEGIN
# line on one side and the END line on the other would report
# every file as stale.
current = doc[doc.index(begin) + len(begin) : doc.index(end)]
if current.strip() != body.strip():
stale.append(f"{doc_path.name}: {name}")
if stale:
print("stale generated tables:\n " + "\n ".join(stale), file=sys.stderr)
print("\nrun: python3 scripts/gen_admin_panel_doc_tables.py --write", file=sys.stderr)
return 1
duplicated = pages_rendering_section_tabs()
if duplicated:
print(
"<SectionTabs> rendered inside a page:\n " + "\n ".join(duplicated),
file=sys.stderr,
)
print(
"\nThe shell (components/Layout.tsx) already renders the active section's"
" tab bar from nav.ts, so these pages show it twice. Remove <SectionTabs>"
" from each listed page.",
file=sys.stderr,
)
return 1
print("generated admin-panel doc tables are current")
print("no page renders its own section tab bar")
return 0
if args.write:
changed = []
for doc_path in docs:
if not doc_path.exists():
continue
doc = doc_path.read_text(encoding="utf-8")
if NOTE not in doc:
continue
for name, body in blocks.items():
if BEGIN.format(block=name) in doc and splice(doc_path, name, body):
changed.append(f"{doc_path.name}: {name}")
print("rewrote:\n " + "\n ".join(changed) if changed else "nothing to rewrite")
return 0
for key in ("sections", "permissions"):
print(f"### {key}\n")
print(tables[key])
print()
print(
f"// {tables['section_count']} sections, {tables['tab_count']} tabs, "
f"{tables['route_count']} routes, {tables['assignable_count']} assignable rights, "
f"{tables['permission_count']} labelled; unlabelled: {tables['unlabelled']}",
file=sys.stderr,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())