Files
ABAP-Indexing/wiki_out/viewer.py
T

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()