ความคืบหน้างาน ไฟล์ต้นฉบับ

docs/agent/tools/gen_ba_drawio.py

#!/usr/bin/env python3
"""Generate docs/agent/ba-drawio.md from the BA's draw.io source file.

Source: the newest "TNSU_EDU *.drawio" in the repository root (or a path given
on the command line). Each draw.io page is rendered as Markdown:

- Pages whose shapes are connected by arrows become nested lists.
- Unconnected numbered items such as "4.3 ..." are attached to the module
  whose text starts with "4.".
- Unconnected text boxes are attached to the connected shape directly above
  them in the same column (used for the curriculum page).
- Pages built from grouped step boxes (flows) become numbered step lists, with
  side notes attached to the step on the same row.

Re-run after the BA updates the file:

    python3 docs/agent/tools/gen_ba_drawio.py

Uses the standard library only.
"""

import base64
import html
import re
import sys
import urllib.parse
import xml.etree.ElementTree as ET
import zlib
from collections import defaultdict
from pathlib import Path

ROOT = Path(__file__).resolve().parents[3]
OUT = ROOT / "docs" / "agent" / "ba-drawio.md"

COLOUR_NAMES = {
    "#fff2cc": "yellow",
    "#dae8fc": "blue",
    "#d5e8d4": "green",
    "#f8cecc": "pink",
    "#e1d5e7": "purple",
    "#ffe6cc": "orange",
}


def find_source():
    if len(sys.argv) > 1:
        return Path(sys.argv[1])
    files = sorted(ROOT.glob("TNSU_EDU*.drawio"), key=lambda p: p.stat().st_mtime)
    if not files:
        sys.exit("no TNSU_EDU*.drawio file in the repository root")
    return files[-1]


def clean(value):
    v = re.sub(r"<br\s*/?>|</div>|</p>|</li>", "\n", value or "", flags=re.I)
    v = html.unescape(re.sub(r"<[^>]+>", "", v)).replace("\xa0", " ")
    lines = []
    for line in v.split("\n"):
        # "Title- detail" written without a line break: split before the dash
        lines += re.split(r"(?<=\S)(?=-\s)", line)
    lines = [re.sub(r"\s+", " ", line).strip() for line in lines]
    return [line for line in lines if line]


def number_key(lines):
    """Sort key from a leading "8." or "4.3" number; unnumbered items keep diagram order."""
    m = re.match(r"(\d+)(?:\.(\d+))?", lines[0]) if lines else None
    return (int(m.group(1)), int(m.group(2) or 0)) if m else None


def style(cell, key):
    m = re.search(r"(?:^|;)" + key + r"=([^;]*)", cell.get("style") or "")
    return m.group(1) if m else ""


def page_root(diagram):
    model = diagram.find("mxGraphModel")
    if model is not None:
        return model.find("root")
    data = zlib.decompress(base64.b64decode((diagram.text or "").strip()), -15).decode()
    return ET.fromstring(urllib.parse.unquote(data)).find("root")


class Page:
    def __init__(self, diagram):
        self.name = diagram.get("name")
        root = page_root(diagram)
        self.cells = {c.get("id"): c for c in root.iter("mxCell")}
        self.edges = [c for c in self.cells.values() if c.get("edge") == "1"]
        self.verts = {cid: c for cid, c in self.cells.items() if c.get("vertex") == "1"}

    def geo(self, cid):
        """Absolute x, y, w, h (child geometry is relative to its parent group)."""
        c = self.cells[cid]
        g = c.find("mxGeometry")
        x, y, w, h = (float(g.get(k, 0)) for k in ("x", "y", "width", "height")) if g is not None else (0, 0, 0, 0)
        parent = c.get("parent")
        if parent in self.verts:
            px, py, _, _ = self.geo(parent)
            x, y = x + px, y + py
        return x, y, w, h

    def text(self, cid):
        return clean(self.cells[cid].get("value"))

    def kind(self, cid):
        return (self.cells[cid].get("style") or "").split(";")[0]


def colour(cell):
    fill = style(cell, "fillColor").lower()
    return COLOUR_NAMES.get(fill, fill if fill and fill != "none" else "")


def bullet(lines):
    """First line as the item; following lines as its detail."""
    head, *rest = lines
    if rest:
        return head + " (" + "; ".join(r.lstrip("- ").strip() for r in rest) + ")"
    return head


def render_tree(page):
    children = defaultdict(list)
    has_parent = set()
    for e in page.edges:
        s, t = e.get("source"), e.get("target")
        if s in page.verts and t in page.verts and s != t:
            children[s].append(t)
            has_parent.add(t)
    connected = set(children) | has_parent

    # Numbered loose items ("4.3 ...") belong to the module whose text starts with "4."
    by_number = {}
    for cid in connected:
        lines = page.text(cid)
        m = re.match(r"(\d+)\.\s", lines[0]) if lines else None
        if m:
            by_number[m.group(1)] = cid
    extra = defaultdict(list)
    loose = []
    for cid in page.verts:
        if cid in connected or not page.text(cid) or page.kind(cid) in ("group", "swimlane"):
            continue
        lines = page.text(cid)
        m = re.match(r"(\d+)\.\d+", lines[0])
        if m and m.group(1) in by_number:
            extra[by_number[m.group(1)]].append(cid)
        else:
            loose.append(cid)

    # Loose text boxes go under the connected shape directly above them in the same column.
    for cid in loose:
        x, y, _, _ = page.geo(cid)
        best = None
        for other in connected:
            ox, oy, _, oh = page.geo(other)
            if abs(ox - x) <= 40 and oy + oh <= y + 5:
                if best is None or oy > page.geo(best)[1]:
                    best = other
        extra[best].append(cid)

    out = []

    def show(cid, depth, seen):
        c = page.cells[cid]
        tag = f" `{colour(c)}`" if colour(c) else ""
        out.append("  " * depth + f"- {bullet(page.text(cid)) or '(no text)'}{tag}")
        if cid in seen:
            return
        seen.add(cid)
        kids = children[cid]
        if kids and all(number_key(page.text(k)) for k in kids):
            kids = sorted(kids, key=lambda k: number_key(page.text(k)))
        else:
            kids = sorted(kids, key=lambda k: (page.geo(k)[0], page.geo(k)[1]))
        for k in kids:
            show(k, depth + 1, seen)
        for k in sorted(extra.get(cid, []), key=lambda k: number_key(page.text(k)) or (999, page.geo(k)[1])):
            lines = page.text(k)
            tag = f" `{colour(page.cells[k])}`" if colour(page.cells[k]) else ""
            if number_key(lines):  # numbered sub-item: title plus its details
                out.append("  " * (depth + 1) + f"- {bullet(lines)}{tag}")
                continue
            # A loose box may hold several items, each starting with "-".
            items, cur = [], []
            for line in lines:
                if line.startswith("-") and cur:
                    items.append(cur)
                    cur = []
                cur.append(line)
            items.append(cur)
            for it in items:
                out.append("  " * (depth + 1) + f"- {' '.join(l.lstrip('- ').strip() for l in it)}{tag}")

    seen = set()
    roots = [cid for cid in children if cid not in has_parent]
    for r in sorted(roots, key=lambda r: (page.geo(r)[1], page.geo(r)[0])):
        show(r, 0, seen)
    for cid in extra.get(None, []):
        out.append(f"- {bullet(page.text(cid))}")
    return out


def render_flow(page):
    groups = [cid for cid in page.verts if page.kind(cid) == "group"]
    groups.sort(key=lambda g: page.geo(g)[1])
    lanes = [cid for cid in page.verts if page.kind(cid) == "swimlane"]
    notes = [cid for cid in page.verts if page.kind(cid) == "text" and page.text(cid)]
    out = []
    if lanes:
        out.append(f"Swimlane: **{' / '.join(page.text(lanes[0]))}**")
        out.append("")
    for i, g in enumerate(groups, 1):
        members = [cid for cid, c in page.verts.items() if c.get("parent") == g]
        members.sort(key=lambda m: page.geo(m)[1])
        step = " ".join(page.text(members[0])) if members else ""
        module = " ".join(page.text(members[1])) if len(members) > 1 else ""
        gx, gy, gw, gh = page.geo(g)
        out.append(f"{i}. **{step}**" + (f" (sub-module: {module})" if module else ""))
        for n in notes:
            nx, ny, _, _ = page.geo(n)
            if gy - 20 <= ny <= gy + gh + 30 and abs(nx - gx) < 300:
                lines = page.text(n)
                parts = [p for line in lines for p in re.split(r"(?<=\S)-\s", line)]
                for p in parts:
                    out.append(f"   - {p.lstrip('- ').strip()}")
    return out


def main():
    src = find_source()
    pages = [Page(d) for d in ET.parse(src).getroot().findall("diagram")]
    lines = [
        "# BA draw.io source (generated)",
        "",
        f"> Generated by `docs/agent/tools/gen_ba_drawio.py` from `{src.name}`. "
        "Do not edit by hand; re-run the script after the BA updates the file. "
        "For what this means for the project, read [03-modules.md](03-modules.md).",
        "",
        "Colours are the fill colours used in the diagram (their meaning has not been stated by the BA).",
        "",
    ]
    for page in pages:
        lines += [f"## {page.name}", ""]
        is_flow = any(page.kind(c) == "group" for c in page.verts)
        lines += render_flow(page) if is_flow else render_tree(page)
        lines.append("")
    OUT.write_text("\n".join(lines), encoding="utf-8")
    print(f"Wrote {OUT.relative_to(ROOT)} from {src.name} ({len(pages)} pages)")


if __name__ == "__main__":
    main()