""" 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