#!/usr/bin/env python3 """ wikimap.py — md <-> Gitea wiki JSON. The whole translation, and only the translation. Pure functions: no network, no filesystem, no argparse. Give it a payload and it hands back a page; give it a page and it hands back a request body. That purity is the point — it can be reasoned about and tested without a Gitea anywhere, and it is the single file to open when the two representations disagree. Direction of knowledge: this module imports the domain (page.py) and is imported by the transport's callers. The domain never imports this. What crosses the boundary, and what does not: domain Gitea note ---------------------------------------------------------------------- title title verbatim, both ways; `/` is the only hierarchy either side has path — local only; derived from the title order — local only; the wiki cannot sort body content_base64 base64, utf-8, verbatim — sub_url lands in the manifest as sub_url — last_commit.sha lands as sha — html_url lands as url sub_url is the identity, and it is NOT derivable ------------------------------------------------ Gitea stores a wiki page as one flat file whose name it escapes from the title, and the escaping is not a mapping worth reimplementing: "Abstract Issue" -> Abstract-Issue.md space -> dash "zz-probe/child" -> zz-probe%2Fchild.-.md / -> %2F, and a LITERAL dash forces a `.-` marker so the two cases stay distinct Every rule there is Gitea's to change. So `sub_url` is read back from whatever the API returned and stored; it is never constructed here, and a caller that needs to address a page fetches the listing rather than guessing. Building one by hand is how you get a second page instead of an edit. The wiki is flat, and only titles are structured ------------------------------------------------ There are no directories. A real subdirectory committed into the wiki's git repository is invisible to the API and to the web UI — a ghost file. All nesting lives in the title, which is why `page.py` treats `/` as its only separator. """ import base64 # A page's whole shape on the wire, for reference and for tests. Gitea also # returns `commit_count`, `sidebar` and `footer` on a single-page GET; none of # them describe the page itself, so none of them cross. WIRE_KEYS = ("title", "sub_url", "html_url", "content_base64", "last_commit") def decode(payload): """content_base64 -> text. Missing content is "" and not None: a page that exists with an empty body is a real state, and the caller writing a file should not have to tell the two apart.""" b = payload.get("content_base64") or "" return base64.b64decode(b).decode("utf-8", "replace") if b else "" def encode(text): return base64.b64encode(text.encode("utf-8")).decode("ascii") def from_payload(payload): """Gitea JSON -> the manifest fields the wiki layer owns, plus the title the domain owns. The caller merges this into the existing entry so that domain keys it does not mention (`order`) survive.""" commit = payload.get("last_commit") or {} author = commit.get("author") or {} return { "title": payload.get("title") or "", "sub_url": payload.get("sub_url") or "", "url": payload.get("html_url") or "", "sha": commit.get("sha") or "", "remote-updated": author.get("date") or "", } def new_payload(title, text, message): """POST /repos/{owner}/{repo}/wiki/new. `title` carries the hierarchy; Gitea derives the filename from it and returns the sub_url it settled on. `message` is the wiki commit message — the operator's words, not a generated one, because this is the only record of why a page changed.""" return {"title": title, "content_base64": encode(text), "message": message} def edit_payload(title, text, message): """PATCH /repos/{owner}/{repo}/wiki/page/{sub_url}. The same shape as a create. Sending the unchanged title is a no-op; sending a different one is a RENAME, which moves the file and leaves nothing at the old sub_url — so callers pass the title from the manifest unless the operator asked for a rename.""" return {"title": title, "content_base64": encode(text), "message": message} def page_endpoint(base, sub_url): """The address of one page. `sub_url` goes in verbatim — Gitea hands it back already escaped (`%2F` and all), and re-encoding it here would produce a path that resolves to nothing.""" return "%s/wiki/page/%s" % (base, sub_url) def revisions_endpoint(base, sub_url): return "%s/wiki/revisions/%s" % (base, sub_url) def matches_prefix(title, prefix): """Is this page at, or under, a title prefix? The wiki being flat, "children" is exactly this test and nothing more: there is no tree to walk, only a naming convention to trust. An empty prefix matches everything.""" if not prefix: return True return title == prefix or title.startswith(prefix + "/")