Spaces:
Sleeping
Sleeping
| """ | |
| 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() | |