Dash-math_Database / site_manager.py
Mochi0622's picture
initial deploy
e66cfb4
Raw
History Blame Contribute Delete
23.3 kB
"""
site_manager.py
---------------
網站內容管理工具。
python site_manager.py # 互動選單
python site_manager.py list # 列出所有 task
python site_manager.py add [name] # 新增單元 task
python site_manager.py delete [name] # 刪除單元 task
python site_manager.py scan # 掃描 pages/ 補齊 paths.json
python site_manager.py gen [all|unit|chapter|navbar] # 生成頁面
python site_manager.py gen --force # 強制覆蓋
python site_manager.py gen --dry-run # 預覽不寫入
"""
from __future__ import annotations
import json, re, argparse
from pathlib import Path
CONFIG_PATH = Path("paths.json")
PAGES_ROOT = Path("pages")
# ─────────────────────────────────────────────────────────────────────────────
# 範本字串(用變數儲存,避免縮排問題)
# ─────────────────────────────────────────────────────────────────────────────
def _unit_template(json_filename: str, route: str, title: str) -> str:
return (
"import dash\n"
"from layout.page_builder import make_test_page\n"
"from pathlib import Path\n"
"\n"
"HERE = Path(__file__).resolve().parent\n"
f'JSON_PATH = HERE / "{json_filename}"\n'
"\n"
f'dash.register_page(__name__, path="/{route}", title="{title}")\n'
"\n"
"def layout():\n"
" return make_test_page(str(JSON_PATH), top_offset=90)\n"
)
def _chapter_template(path: str, title: str, cards_repr: str) -> str:
return (
"from dash import register_page\n"
"import dash_mantine_components as dmc\n"
"from layout.Chapter_card import create_card\n"
"\n"
f'register_page(__name__, path="{path}", title="{title}")\n'
"\n"
f"card_data = {cards_repr}\n"
"\n"
"layout = dmc.SimpleGrid(\n"
" cols=2,\n"
' spacing="sm",\n'
" children=[create_card(data) for data in card_data],\n"
")\n"
)
def _navbar_template(chapter_links: str) -> str:
return (
"import dash\n"
"from dash import html\n"
"import dash_mantine_components as dmc\n"
"from dash_iconify import DashIconify\n"
"\n"
"# 此檔案由 site_manager.py 自動生成,請勿手動修改\n"
"\n"
"def generate_navbar():\n"
" height = 16\n"
" navbar_content = html.Div(\n"
" [\n"
" dmc.NavLink(\n"
' label="首頁",\n'
' href="/",\n'
' leftSection=DashIconify(icon="bi:house-door-fill", height=height),\n'
' active="partial",\n'
" ),\n"
f"{chapter_links}\n"
" ],\n"
" style={\n"
' "width": {"xs": 200, "sm": 250, "md": 300, "lg": 300},\n'
' "overflowY": "auto",\n'
' "maxHeight": "100vh",\n'
" },\n"
" )\n"
" return dmc.AppShellNavbar(\n"
' id="navbar",\n'
" children=[\n"
' dmc.Divider(label="頁面導覽", variant="solid", labelPosition="left", size="md"),\n'
" navbar_content,\n"
" ],\n"
' p="md",\n'
" )\n"
)
def _chapter_nav_block(nav_label: str, nav_icon: str, section_links: str) -> str:
return (
" dmc.NavLink(\n"
f' label="{nav_label}",\n'
f' leftSection=DashIconify(icon="{nav_icon}", height=height),\n'
" childrenOffset=28,\n"
' active="partial",\n'
" children=[\n"
f"{section_links}\n"
" ],\n"
" ),"
)
def _section_link(label: str, href: str, icon: str) -> str:
return (
" dmc.NavLink(\n"
f' label="{label}",\n'
f' href="{href}",\n'
' active="exact",\n'
" childrenOffset=14,\n"
f' leftSection=DashIconify(icon="{icon}", height=height),\n'
" ),"
)
# ─────────────────────────────────────────────────────────────────────────────
# paths.json 讀寫
# ─────────────────────────────────────────────────────────────────────────────
def _load() -> dict:
if CONFIG_PATH.exists():
return json.loads(CONFIG_PATH.read_text(encoding="utf-8"))
return {"chapters": [], "tasks": [], "up_to_date": True}
def _save(cfg: dict) -> None:
CONFIG_PATH.write_text(json.dumps(cfg, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"✅ 已儲存 {CONFIG_PATH}")
def _task_names(cfg: dict) -> set[str]:
return {t["name"] for t in cfg.get("tasks", [])}
def _infer_paths(name: str) -> tuple[str, str]:
m = re.match(r'[Cc][Hh]?(\d+)[_\-](\d+)', name)
if m:
ch, sec = m.group(1), m.group(2)
folder = PAGES_ROOT / f"Ch{ch}" / f"C{ch}_{sec}"
return str(folder / f"Md_{ch}_{sec}.md"), str(folder / f"exported_result_{ch}_{sec}.json")
return "", ""
# ─────────────────────────────────────────────────────────────────────────────
# 卡片轉 Python 原始碼(badges 用 tuple)
# ─────────────────────────────────────────────────────────────────────────────
def _cards_to_repr(cards: list[dict]) -> str:
lines = ["[\n"]
for card in cards:
lines.append(" {\n")
lines.append(f' "title": {json.dumps(card["title"], ensure_ascii=False)},\n')
badges = card.get("badges", [])
badge_strs = [f'("{b[0]}", "{b[1]}")' for b in badges]
lines.append(f' "badges": [{", ".join(badge_strs)}],\n')
desc = card.get("description", [])
desc_strs = ", ".join(json.dumps(d, ensure_ascii=False) for d in desc)
lines.append(f' "description": [{desc_strs}],\n')
lines.append(f' "link": {json.dumps(card.get("link", ""), ensure_ascii=False)},\n')
lines.append(f' "test": {json.dumps(card.get("test", ""), ensure_ascii=False)},\n')
if "trick" in card:
lines.append(f' "trick": {json.dumps(card["trick"], ensure_ascii=False)},\n')
lines.append(" },\n")
lines.append("]")
return "".join(lines)
# ─────────────────────────────────────────────────────────────────────────────
# 命令:list
# ─────────────────────────────────────────────────────────────────────────────
def cmd_list() -> None:
cfg = _load()
chs = cfg.get("chapters", [])
tasks = cfg.get("tasks", [])
print(f"\n章節總覽({len(chs)} 個):")
for ch in chs:
print(f" • {ch['name']:<10} {ch.get('title','')}{len(ch.get('cards',[]))} 張卡片)")
print(f"\n單元 task({len(tasks)} 個):")
for i, t in enumerate(tasks, 1):
md_ok = "✔" if Path(t.get("md", "")).exists() else "✘"
out_ok = "✔" if Path(t.get("out", "")).exists() else "✘"
print(f" {i:2d}. {t['name']:<12} md:{md_ok} json:{out_ok}{t.get('out','')}")
# ─────────────────────────────────────────────────────────────────────────────
# 命令:add
# ─────────────────────────────────────────────────────────────────────────────
def cmd_add(name: str | None = None) -> None:
cfg = _load()
if not name:
name = input("Task 名稱(例如 Ch14_3):").strip()
if not name:
print("❌ 名稱不能為空。"); return
if name in _task_names(cfg):
print(f"❌ '{name}' 已存在。"); return
default_md, default_out = _infer_paths(name)
print(f"預設 md 路徑:{default_md or '(請自行輸入)'}")
md = input("md 路徑(Enter 使用預設):").strip() or default_md
print(f"預設 out 路徑:{default_out or '(請自行輸入)'}")
out = input("out 路徑(Enter 使用預設):").strip() or default_out
if not md or not out:
print("❌ 路徑不能為空。"); return
md_path = Path(md)
md_path.parent.mkdir(parents=True, exist_ok=True)
Path(out).parent.mkdir(parents=True, exist_ok=True)
if not md_path.exists():
md_path.write_text(f"# {name}\n\n在這裡撰寫教學內容。\n", encoding="utf-8")
print(f" 📄 已建立空白 MD:{md_path}")
cfg["tasks"].append({"name": name, "md": md, "out": out})
cfg["up_to_date"] = False
# 自動加進對應 chapter
m = re.match(r'[Cc][Hh]?(\d+)[_\-](\d+)', name)
if m:
ch_num = m.group(1)
sec_num = m.group(2)
ch_key = f"Ch{ch_num}"
route = f"/{name}"
chapter = next((c for c in cfg.get("chapters", []) if c.get("name") == ch_key), None)
if chapter is None:
ans = input(f"\n找不到 {ch_key} 的章節總覽,要建立嗎?(y/n):").strip().lower()
if ans == "y":
title = input("章節標題:").strip() or ch_key
chapter = {"name": ch_key, "path": f"/{ch_key}", "title": title,
"folder": f"pages/{ch_key}", "cards": []}
cfg.setdefault("chapters", []).append(chapter)
print(f" 📁 已建立 chapter:{ch_key}")
if chapter is not None:
existing = {c.get("link") for c in chapter.get("cards", [])}
if route not in existing:
card_title = input("卡片標題(Enter 略過):").strip()
if card_title:
desc_raw = input("卡片說明(多行用 | 分隔,Enter 略過):").strip()
desc = [d.strip() for d in desc_raw.split("|") if d.strip()]
chapter.setdefault("cards", []).append({
"title": card_title, "badges": [[ch_key, "pink"]],
"description": desc, "link": route, "test": f"{route}/test/"
})
print(f" 🃏 已將卡片加入 {ch_key} 總覽")
_save(cfg)
print(f"✅ 已新增 task:{name}\n 下一步:編輯 {md_path},然後執行 python build.py")
# ─────────────────────────────────────────────────────────────────────────────
# 命令:delete
# ─────────────────────────────────────────────────────────────────────────────
def cmd_delete(name: str | None = None) -> None:
cfg = _load()
if not name:
cmd_list()
name = input("\n要刪除的 task 名稱:").strip()
before = len(cfg["tasks"])
cfg["tasks"] = [t for t in cfg["tasks"] if t["name"] != name]
if len(cfg["tasks"]) == before:
print(f"❌ 找不到 '{name}'。"); return
_save(cfg)
print(f"✅ 已刪除 task:{name}(MD/JSON 檔案需手動刪除)")
# ─────────────────────────────────────────────────────────────────────────────
# 命令:scan
# ─────────────────────────────────────────────────────────────────────────────
def cmd_scan() -> None:
cfg, existing, added = _load(), _task_names(_load()), []
for md_file in sorted(PAGES_ROOT.rglob("Md_*.md")):
parts = md_file.parts
if len(parts) < 3: continue
m = re.match(r'C(\d+)_(\d+)', parts[-2])
if not m: continue
ch, sec = m.group(1), m.group(2)
name = f"Ch{ch}_{sec}"
if name in existing:
print(f" — {name}:已存在"); continue
out = md_file.parent / f"exported_result_{ch}_{sec}.json"
cfg["tasks"].append({"name": name, "md": str(md_file), "out": str(out)})
existing.add(name); added.append(name)
print(f" ✔ 新增:{name}")
if added:
cfg["up_to_date"] = False
_save(cfg)
print(f"\n共新增 {len(added)} 個 task。執行 python build.py 轉換 MD。")
else:
print("沒有新的 MD 需要補充。")
# ─────────────────────────────────────────────────────────────────────────────
# 命令:gen(生成單元頁 + 總覽頁)
# ─────────────────────────────────────────────────────────────────────────────
def cmd_gen(target: str = "all", force: bool = False, dry_run: bool = False) -> None:
cfg = _load()
base = CONFIG_PATH.resolve().parent
generated, skipped = [], []
# 單元頁
if target in ("all", "unit"):
for t in cfg.get("tasks", []):
name = t.get("name", "")
out_path = Path(t.get("out", ""))
if not name or not out_path.name: continue
folder = (base / out_path).resolve().parent
page_file = folder / f"Dash_{name}.py"
if page_file.exists() and not force:
skipped.append(f" — [單元] {page_file}"); continue
content = _unit_template(
json_filename=(base / out_path).resolve().name,
route=name, title=name,
)
if not dry_run:
folder.mkdir(parents=True, exist_ok=True)
page_file.write_text(content, encoding="utf-8")
generated.append(f" ✔ [單元] {page_file}")
# 總覽頁
if target in ("all", "chapter"):
for ch in cfg.get("chapters", []):
name = ch.get("name", "")
if not name: continue
folder = Path(ch.get("folder", f"pages/{name}"))
page_file = folder / f"{name.lower()}.py"
if page_file.exists() and not force:
skipped.append(f" — [總覽] {page_file}"); continue
content = _chapter_template(
path = ch.get("path", f"/{name}"),
title = ch.get("title", name),
cards_repr = _cards_to_repr(ch.get("cards", [])),
)
if not dry_run:
folder.mkdir(parents=True, exist_ok=True)
page_file.write_text(content, encoding="utf-8")
generated.append(f" ✔ [總覽] {page_file}")
label = "[dry-run] 會生成" if dry_run else "已生成"
if generated:
print(f"\n✅ {label} {len(generated)} 個檔案:")
print("\n".join(generated))
if skipped:
print(f"\n⏭ 已略過 {len(skipped)} 個(用 --force 覆蓋):")
for s in skipped: print(s)
if not generated and not skipped:
print("沒有任何檔案需要生成。")
# ─────────────────────────────────────────────────────────────────────────────
# 命令:gen navbar
# ─────────────────────────────────────────────────────────────────────────────
def cmd_gen_navbar(force: bool = False, dry_run: bool = False) -> None:
cfg = _load()
chapters = cfg.get("chapters", [])
nav_file = Path("layout/shell/navbar.py")
if not chapters:
print("paths.json 裡沒有 chapters,無法生成 navbar。"); return
if nav_file.exists() and not force:
print(f"⏭ {nav_file} 已存在,用 --force 覆蓋。"); return
chapter_blocks = []
for ch in chapters:
nav_label = ch.get("nav_label") or ch.get("title") or ch.get("name", "")
nav_icon = ch.get("nav_icon", "mdi:book-outline")
sections = ch.get("sections", [])
if not sections:
ch_name = ch.get("name", "")
m = re.match(r'[Cc][Hh]?(\d+)', ch_name)
ch_num = m.group(1) if m else ""
sections = [{"label": f"{ch_name} 總覽", "href": f"/{ch_name}",
"icon": "tabler:square-rounded-number-0"}]
icons = ["one","two","three","four","five","six","seven","eight","nine"]
for t in cfg.get("tasks", []):
tn = t.get("name", "")
tm = re.match(rf'[Cc][Hh]?{ch_num}[_\-](\d+)', tn) if ch_num else None
if tm:
sec = int(tm.group(1))
icon_name = icons[sec-1] if sec <= len(icons) else str(sec)
sections.append({"label": tn, "href": f"/{tn}",
"icon": f"tabler:square-rounded-number-{icon_name}"})
links = "\n".join(
_section_link(s.get("label",""), s.get("href","/"), s.get("icon","tabler:circle"))
for s in sections
)
chapter_blocks.append(_chapter_nav_block(nav_label, nav_icon, links))
content = _navbar_template("\n".join(chapter_blocks))
if dry_run:
print(f"[dry-run] 會生成:{nav_file}\n")
print(content); return
nav_file.parent.mkdir(parents=True, exist_ok=True)
nav_file.write_text(content, encoding="utf-8")
print(f"✅ 已生成:{nav_file}")
# ─────────────────────────────────────────────────────────────────────────────
# 互動選單
# ─────────────────────────────────────────────────────────────────────────────
def menu() -> None:
while True:
print("\n═══════════ 網站管理工具 ═══════════")
print(" 1. 列出所有 task / chapter")
print(" 2. 新增單元 task")
print(" 3. 刪除單元 task")
print(" 4. 掃描 pages/ 補齊 paths.json")
print(" ─────────── 生成頁面 ───────────")
print(" 5. 生成全部頁面(單元 + 總覽)")
print(" 6. 強制重新生成全部")
print(" 7. 只生成小單元頁")
print(" 8. 強制重新生成小單元頁")
print(" 9. 只生成總覽頁")
print(" 10. 強制重新生成總覽頁")
print(" 11. 生成 navbar.py")
print(" 12. 強制重新生成 navbar.py")
print(" 0. 離開")
c = input("\n請選擇:").strip()
if c == "1": cmd_list()
elif c == "2": cmd_add()
elif c == "3": cmd_delete()
elif c == "4": cmd_scan()
elif c == "5": cmd_gen("all")
elif c == "6": cmd_gen("all", force=True)
elif c == "7": cmd_gen("unit")
elif c == "8": cmd_gen("unit", force=True)
elif c == "9": cmd_gen("chapter")
elif c == "10": cmd_gen("chapter", force=True)
elif c == "11": cmd_gen_navbar()
elif c == "12": cmd_gen_navbar(force=True)
elif c == "0": print("bye!"); break
else: print("請輸入 0-12。")
# ─────────────────────────────────────────────────────────────────────────────
# 命令列介面
# ─────────────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
ap = argparse.ArgumentParser(description="網站內容管理工具")
sub = ap.add_subparsers(dest="cmd")
sub.add_parser("list", help="列出所有 task / chapter")
sub.add_parser("scan", help="掃描 pages/ 補齊 paths.json")
p_add = sub.add_parser("add", help="新增單元 task")
p_add.add_argument("name", nargs="?")
p_del = sub.add_parser("delete", help="刪除單元 task")
p_del.add_argument("name", nargs="?")
p_gen = sub.add_parser("gen", help="生成頁面檔")
p_gen.add_argument("target", nargs="?", default="all",
choices=["all","unit","chapter","navbar"],
help="all=全部, unit=小單元, chapter=總覽, navbar=導覽列")
p_gen.add_argument("--force", action="store_true")
p_gen.add_argument("--dry-run", action="store_true")
args = ap.parse_args()
if args.cmd == "list": cmd_list()
elif args.cmd == "add": cmd_add(args.name)
elif args.cmd == "delete": cmd_delete(args.name)
elif args.cmd == "scan": cmd_scan()
elif args.cmd == "gen":
if args.target == "navbar":
cmd_gen_navbar(args.force, args.dry_run)
else:
cmd_gen(args.target, args.force, args.dry_run)
else:
menu()