Spaces:
Sleeping
Sleeping
| """ | |
| layout/page/loader.py | |
| --------------------- | |
| JSON 資料載入與結構解析。 | |
| 無任何 Dash / DMC 依賴,可獨立單元測試。 | |
| 公開 API: | |
| load_json(source) → {"blocks", "sections", "toc"} | |
| norm_id_or_none(raw) → str | None | |
| build_toc_titles(blocks, toc) → [{"id", "label", "level"}, ...] | |
| """ | |
| from __future__ import annotations | |
| import os | |
| import json | |
| from typing import Any | |
| # --------------------------------------------------------------------------- | |
| # 檔案快取(process 層級,同一 JSON 只讀一次) | |
| # --------------------------------------------------------------------------- | |
| _cache: dict[str, dict] = {} | |
| def _load_file_cached(path: str) -> dict: | |
| """讀取並快取 JSON 檔案,檔案未更動時直接回傳快取。""" | |
| try: | |
| mtime = os.path.getmtime(path) | |
| except OSError: | |
| return {} | |
| cached = _cache.get(path) | |
| if cached and cached.get("_mtime") == mtime: | |
| return cached["_data"] | |
| try: | |
| with open(path, "r", encoding="utf-8") as f: | |
| data = json.load(f) | |
| except Exception: | |
| data = {} | |
| _cache[path] = {"_mtime": mtime, "_data": data} | |
| return data | |
| # --------------------------------------------------------------------------- | |
| # 錯誤佔位文件 | |
| # --------------------------------------------------------------------------- | |
| def _err_doc(msg: str) -> dict[str, Any]: | |
| return {"blocks": [{"type": "paragraph", "content": f"⚠ {msg}"}], "toc": []} | |
| # --------------------------------------------------------------------------- | |
| # 主要載入函式 | |
| # --------------------------------------------------------------------------- | |
| def load_json(source: Any) -> dict[str, Any]: | |
| """ | |
| 接受多種來源,統一回傳 {"blocks": [...], "sections": [...], "toc": [...]}。 | |
| source 可以是: | |
| - list / dict:直接使用 | |
| - str / Path:當作檔案路徑讀取 | |
| """ | |
| if isinstance(source, (list, dict)): | |
| obj = source | |
| else: | |
| p = str(source or "").strip() | |
| if not p: | |
| return _err_doc("未提供 JSON 路徑") | |
| if not os.path.exists(p): | |
| return _err_doc(f"找不到 JSON:{p}") | |
| try: | |
| obj = _load_file_cached(p) | |
| if not obj: | |
| return _err_doc(f"讀取 JSON 失敗或為空:{p}") | |
| except Exception as e: | |
| return _err_doc(f"讀取 JSON 失敗:{e}") | |
| if isinstance(obj, list): | |
| return {"blocks": obj, "sections": [], "toc": []} | |
| if isinstance(obj, dict): | |
| blocks = obj.get("blocks") or obj.get("doc") or obj.get("content") or [] | |
| sections = obj.get("sections") or [] | |
| toc = obj.get("toc") or obj.get("TOC") or [] | |
| if sections and isinstance(sections, list): | |
| return {"blocks": blocks, "sections": sections, "toc": toc} | |
| return {"blocks": blocks, "sections": [], "toc": toc} | |
| return _err_doc(f"不支援的 JSON 型別:{type(obj).__name__}") | |
| # --------------------------------------------------------------------------- | |
| # ID 正規化 | |
| # --------------------------------------------------------------------------- | |
| def norm_id_or_none(raw: str | None) -> str | None: | |
| """ | |
| 把 URL fragment 或帶前綴的 id 字串還原成純 id。 | |
| 範例: | |
| '頁面#h-CH4-P2_id2' → 'CH4-P2_id2' | |
| '#h-foo' → 'foo' | |
| 'h-bar' → 'bar' | |
| '' / None → None | |
| """ | |
| if not raw: | |
| return None | |
| s = str(raw).strip() | |
| if not s: | |
| return None | |
| if "#" in s: | |
| s = s.split("#", 1)[1].strip() | |
| if s.startswith("h-"): | |
| s = s[2:] | |
| return s or None | |
| # --------------------------------------------------------------------------- | |
| # TOC 標題清單建立 | |
| # --------------------------------------------------------------------------- | |
| def build_toc_titles( | |
| blocks: list[dict[str, Any]], | |
| raw_toc: list[Any], | |
| ) -> list[dict[str, Any]]: | |
| """ | |
| 回傳給 make_toc() 使用的 titles 清單: | |
| [{"id": str, "label": str, "level": int}, ...] | |
| 優先使用 JSON 內嵌的 toc;若無,則從 blocks 的 heading 自動產生。 | |
| """ | |
| titles: list[dict[str, Any]] = [] | |
| # 優先:JSON 內嵌 toc | |
| if isinstance(raw_toc, list) and raw_toc: | |
| for it in raw_toc: | |
| if not isinstance(it, dict): | |
| continue | |
| label = ( | |
| it.get("title") or it.get("text") or it.get("label") or "" | |
| ).strip() | |
| nid = norm_id_or_none( | |
| it.get("id") or it.get("href") or it.get("slug") | |
| ) | |
| level = int(it.get("level") or 1) | |
| if not label or not nid: | |
| continue | |
| titles.append({"id": nid, "label": label, "level": max(1, min(level, 6))}) | |
| return titles | |
| # 退回:從 blocks heading 產生 | |
| for b in blocks or []: | |
| if (b.get("type") or "").lower() != "heading": | |
| continue | |
| nid = norm_id_or_none(b.get("id")) | |
| if not nid: | |
| continue | |
| label = (b.get("content") or "").strip() | |
| level = int(b.get("level") or 3) | |
| titles.append({ | |
| "id": nid, | |
| "label": label or "(空白標題)", | |
| "level": max(1, min(level, 6)), | |
| }) | |
| return titles | |