Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
195 lines
7.4 KiB
Python
195 lines
7.4 KiB
Python
"""위키 뷰어 빌더 — wiki/ 를 스캔해 단일 HTML(뷰어)로 만든다.
|
|
|
|
- 템플릿: wiki_out/viewer.html (플레이스홀더 __DATA_JSON__, __GENERATED_AT__)
|
|
- 빌드: python -m wiki_out.viewer [--out PATH] (기본 data/wiki-viewer.html)
|
|
- 라이브: query/api.py 의 GET /wiki-viewer 가 요청마다 build_html() 호출
|
|
- 공유용 스냅샷은 이 출력을 Claude 아티팩트로 게시 (index.db 관측소와 같은 패턴)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
from config.settings import settings
|
|
|
|
from .merge import is_human_verified, split_frontmatter
|
|
|
|
TEMPLATE = Path(__file__).parent / "viewer.html"
|
|
|
|
|
|
def _fm_get(fm: str, key: str) -> str | None:
|
|
m = re.search(rf"^{re.escape(key)}: (.*)$", fm, re.M)
|
|
return m.group(1).strip() if m else None
|
|
|
|
|
|
def _unquote(v: str | None) -> str:
|
|
if not v:
|
|
return ""
|
|
if v.startswith('"'):
|
|
try:
|
|
return json.loads(v)
|
|
except ValueError:
|
|
return v.strip('"')
|
|
return v
|
|
|
|
|
|
def _list(v: str | None) -> list:
|
|
if not v or not v.startswith("["):
|
|
return []
|
|
try:
|
|
return json.loads(v)
|
|
except ValueError:
|
|
return []
|
|
|
|
|
|
# 프로그램 문서의 조각 섹션을 읽어 사이드바 내비게이션을 만든다.
|
|
# okf_writer 가 생성하는 모양에 의존한다:
|
|
# ### FORM SELECT_DATA — `ZFIR0030F01` L64-L80 (17줄)
|
|
# #### [sql_select] 미처리 건을 읽는다
|
|
_UNIT_H = re.compile(r"^### (\w+) (.+?) — `([^`]+)` L(\d+)-L(\d+)", re.M)
|
|
_CHUNK_H = re.compile(r"^#### \[(\w+)\] (.+)$", re.M)
|
|
|
|
|
|
def _chunk_nav(body: str) -> list[dict]:
|
|
"""프로그램 문서 본문 → [{unit, unit_type, include, kind, purpose}] (문서 순서 유지).
|
|
|
|
구조 변경(2026-09-16)으로 units/ 문서가 없어졌으므로, 내비게이션은 프로그램 문서 안의
|
|
조각 섹션에서 뽑는다. 예전 x-unit-* frontmatter 기반 코드는 항상 빈 목록을 돌려줬다.
|
|
"""
|
|
out: list[dict] = []
|
|
cur = {"unit": "", "unit_type": "", "include": ""}
|
|
for m in re.finditer(r"^(###|####) (.*)$", body, re.M):
|
|
level, text = m.group(1), m.group(2)
|
|
if level == "###":
|
|
um = _UNIT_H.match(m.group(0))
|
|
if um:
|
|
cur = {"unit": um.group(2), "unit_type": um.group(1), "include": um.group(3)}
|
|
else:
|
|
cur = {"unit": text, "unit_type": "", "include": ""}
|
|
else:
|
|
cm = _CHUNK_H.match(m.group(0))
|
|
if cm:
|
|
out.append({**cur, "kind": cm.group(1), "purpose": cm.group(2)})
|
|
return out
|
|
|
|
|
|
def build_data(wiki_dir: Path | None = None) -> dict:
|
|
wiki_dir = wiki_dir or settings.wiki_dir
|
|
docs: dict[str, dict] = {}
|
|
chunk_nav: dict[str, list[dict]] = {} # program → 조각 목록
|
|
|
|
for path in sorted(wiki_dir.rglob("*.md")):
|
|
rel = path.relative_to(wiki_dir).as_posix()
|
|
if path.name in ("index.md", "log.md", "_review.md"):
|
|
continue
|
|
fm, body = split_frontmatter(path.read_text(encoding="utf-8"))
|
|
if fm is None:
|
|
continue
|
|
gen = ""
|
|
m = re.search(r'generated:.*?by:\s*"([^"]+)".*?at:\s*"([^"]+)"', fm)
|
|
if m:
|
|
gen = f"{m.group(1)} · {m.group(2)}"
|
|
doc = {
|
|
"t": _fm_get(fm, "type") or "?",
|
|
"title": _unquote(_fm_get(fm, "title")) or rel,
|
|
"d": _unquote(_fm_get(fm, "description")),
|
|
"tags": _list(_fm_get(fm, "tags")),
|
|
"st": _fm_get(fm, "status") or "stable",
|
|
"gen": gen,
|
|
"res": _unquote(_fm_get(fm, "resource")),
|
|
"ver": is_human_verified(fm),
|
|
"body": body.strip(),
|
|
}
|
|
docs[rel] = doc
|
|
if doc["t"] == "abap-program":
|
|
name = rel.rsplit("/", 1)[-1][:-3]
|
|
chunk_nav[name] = _chunk_nav(doc["body"])
|
|
doc["chunk_count"] = len(chunk_nav[name])
|
|
|
|
programs = []
|
|
for rel, d in docs.items():
|
|
if d["t"] != "abap-program":
|
|
continue
|
|
name = rel.rsplit("/", 1)[-1][:-3]
|
|
chunks = chunk_nav.get(name, [])
|
|
# unit 단위로 묶어 사이드바에 접히는 목록으로 준다
|
|
by_unit: list[dict] = []
|
|
for c in chunks:
|
|
if not by_unit or by_unit[-1]["name"] != c["unit"]:
|
|
by_unit.append({"name": c["unit"], "type": c["unit_type"],
|
|
"include": c["include"], "chunks": []})
|
|
by_unit[-1]["chunks"].append({"kind": c["kind"], "purpose": c["purpose"]})
|
|
# 개요 표에는 짧은 타이틀(frontmatter title 의 "NAME — " 뒤)만 싣고, 긴 LLM 설명은 desc 로 따로 준다
|
|
short = re.sub(rf"^{re.escape(name)}\s*[—-]\s*", "", d["title"]).strip() or name
|
|
programs.append({
|
|
"name": name, "path": rel, "title": short, "desc": d["d"] or "",
|
|
"status": d["st"], "verified": d["ver"],
|
|
"units": by_unit,
|
|
"chunks": len(chunks),
|
|
"done": len(by_unit), "total": len(by_unit),
|
|
})
|
|
programs.sort(key=lambda p: p["name"])
|
|
|
|
packages = sorted(
|
|
({"name": rel.rsplit("/", 1)[-1][:-3], "path": rel,
|
|
"count": len([1 for ln in d["body"].splitlines() if ln.startswith("| [")])}
|
|
for rel, d in docs.items() if d["t"] == "abap-package"),
|
|
key=lambda p: p["name"])
|
|
concepts = sorted(
|
|
({"name": d["title"], "path": rel} for rel, d in docs.items() if d["t"] == "concept"),
|
|
key=lambda c: c["name"])
|
|
|
|
review_path = wiki_dir / "_review.md"
|
|
review = (sum(1 for ln in review_path.read_text(encoding="utf-8").splitlines()
|
|
if ln.startswith("- [ ]")) if review_path.exists() else 0)
|
|
|
|
tables = sorted(
|
|
({"name": rel.rsplit("/", 1)[-1][:-3], "path": rel}
|
|
for rel, d in docs.items() if d["t"] == "abap-table"),
|
|
key=lambda c: c["name"])
|
|
functions = sorted(
|
|
({"name": rel.rsplit("/", 1)[-1][:-3], "path": rel}
|
|
for rel, d in docs.items() if d["t"] == "abap-function"),
|
|
key=lambda c: c["name"])
|
|
|
|
n_chunks = sum(p["chunks"] for p in programs)
|
|
units_total = sum(p["total"] for p in programs)
|
|
return {
|
|
"stats": {
|
|
"programs": len(programs),
|
|
"chunks": n_chunks,
|
|
"units_total": units_total, "units_done": units_total,
|
|
"tables": len(tables), "functions": len(functions),
|
|
"human_verified": sum(1 for d in docs.values() if d["ver"]),
|
|
"review": review,
|
|
},
|
|
"docs": docs, "programs": programs, "packages": packages, "concepts": concepts,
|
|
"tables": tables, "functions": functions,
|
|
}
|
|
|
|
|
|
def build_html(wiki_dir: Path | None = None) -> str:
|
|
data = build_data(wiki_dir)
|
|
payload = json.dumps(data, ensure_ascii=False, separators=(",", ":")).replace("</", "<\\/")
|
|
return (TEMPLATE.read_text(encoding="utf-8")
|
|
.replace("__DATA_JSON__", payload)
|
|
.replace("__GENERATED_AT__", datetime.now().strftime("%Y-%m-%d %H:%M")))
|
|
|
|
|
|
def main() -> None:
|
|
ap = argparse.ArgumentParser(description="위키 뷰어 HTML 빌드")
|
|
ap.add_argument("--out", default=None)
|
|
args = ap.parse_args()
|
|
out = Path(args.out) if args.out else settings.wiki_dir.parent / "data" / "wiki-viewer.html"
|
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
html = build_html()
|
|
out.write_text(html, encoding="utf-8")
|
|
print(f"{out} ({len(html) / 1024:.0f} KB)")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|