feat: discussion artifacts as wiki pages, in two new layers

A discussion leaves behind a directory of markdown somewhere outside this
repo, and the only durable home for it is the Gitea wiki. Getting it
there by hand means re-deriving the same three things every time: what
each file should be called, where it goes, and whether the page already
exists. Two skills wrap that, along the split the repo already uses.

`skills/page` is domain, offline, stdlib-only, and knows nothing about
Gitea. It imports a directory into a space under `tmp/wiki/`, titles
every file, records the result in `.pages.json`, and writes the index.
`skills/wiki` is the bridge — `wikimap.py` translates, and the transport
is `_gitea.py`, the same one the issue side uses. There is no second
transport, and `tea` has no wiki subcommand to offer one.

The Gitea wiki is flat, and that fact shapes everything

There are no directories. A title of `a/b` is stored as one file named
`a%2Fb.md`, and Gitea escapes it by rules of its own: space becomes `-`,
`/` becomes `%2F`, and a literal `-` forces a trailing `.-` marker so the
two stay distinct. `Chain decisions — DC` under two levels of prefix
comes back as `Simple-Chains%2FParked%2FChain-decisions-%E2%80%94-DC`.

So `sub_url` is the identity, it is read back from whatever the API
returned, and it is never constructed. One built by hand that is almost
right does not fail — it creates a second page and abandons the first.

And a real subdirectory committed into a wiki's git repository is a ghost:
the file exists, the API and the web UI do not see it. `folder/page.md`
in this repo's own wiki is one. Nothing here clones a wiki repo.

A title is a decision, not a derivation

Titles come from the first heading, because there is no mechanical route
from `03-q-01-do-we-know-the-chain-participant-by-name.md` to
`Q-01. Do We Know the Chain Participant by Name`. But they are derived
exactly once. A re-import replaces bodies and keeps titles, so editing a
heading cannot rename a published page — which would not rename it, it
would publish a second one.

`--retitle` opts in. It finds the prior entry by `source` rather than by
path, because the path is derived from the title and a retitle moves it;
looked up by path the page would read as new and the next push would
duplicate it. The old file goes, `sub_url` comes along, and `pushed` is
cleared — a rename can leave the body byte-identical, and push decides by
body hash alone, so a stale hash would skip the rename forever.

Ordering is a `NN-` file-name prefix and never reaches the title. `00-`
means "this is the directory's own page", and that page is named for the
directory, not for its own heading: a child's title has to extend its
parent's exactly, and `ideas/00-intro.md` opens with "Ideas for chain
business requirements".

Path collisions are reported and never resolved. Picking a winner is how
a discussion loses a document.

The index is navigation, not decoration

Nothing draws a tree from flat titles. `page_index.py` writes one as an
ordinary page, nested by title depth rather than by manifest path order —
those disagree, since on disk `Top/System.md` sorts before
`Top/Ideas/Scale.md` while in the hierarchy System is a child and Scale a
grandchild. A parent with no page of its own still gets a node, so its
children are not hidden.

Links use `sub_url` when there is one and Gitea's `[[Title|label]]`
syntax when there is not, so the order is push, rebuild, push.

The same stances as the issue store, for the same reasons

Pull overwrites, push is additive and never deletes, change detection is
one hash and there is no drift model. A page with no `sub_url` has never
been published, and that is a durable state.

Issues gain a `wiki:` field holding page titles — titles, not URLs, so
the reference stays in the domain. It already round-trips as a foreign
key; this documents it.

Verified against a live Gitea 1.26.1: create, update with a message,
unchanged-skip, prefix-filtered pull, byte-identical round trip, and the
per-page revision history carrying the operator's own words. The probe
pages were deleted afterwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
naudachu
2026-08-10 16:29:55 +05:00
parent 257c547e22
commit 1815d91cdf
16 changed files with 2319 additions and 26 deletions
+523
View File
@@ -0,0 +1,523 @@
#!/usr/bin/env python3
r"""
page.py — what a PAGE TREE is. The domain layer for wiki artifacts.
Not a command; the module the other page scripts build on. It knows how a
directory of markdown becomes a named, ordered tree of pages, and it knows
NOTHING about any wiki: no Gitea, no `tea`, no logins, no HTTP, no `sub_url`.
The layering rule is mechanically checkable — every import in this directory is
stdlib, and `subprocess` is not among them:
grep -rh '^import \|^from ' skills/page/scripts/ | sort -u
Delete skills/wiki/ entirely and this layer keeps working: a discussion's
artifacts organized into a tree on this machine are a finished thing, not a
draft waiting for an upload.
tmp/wiki/claude-skills/tea/ <- a SPACE
.pages.json <- the manifest
Simple-Chains/
Ideas.md title: Simple Chains/Ideas
Ideas/
Chain-core.md title: Simple Chains/Ideas/Chain core
A space is a directory holding a page tree and one manifest. The space's name
("claude-skills/tea") is an opaque relative path to this module — it happens to
be an owner/repo pair, and this layer never learns that.
Why a manifest at all
---------------------
Because the wiki's own page identity is not derivable from a file path, and
guessing at it is how you get duplicate pages. The manifest is the record of
what each local file IS, written once at import or pull and never re-derived.
Domain keys in a manifest entry are `title` and `order`. Everything else —
`sub_url`, `sha`, `synced`, `pushed` — is written by the wiki layer, carried
through load/save verbatim, and never read here. That passthrough is what lets
one manifest describe both a local-only tree and a published one without the
domain learning a second vocabulary.
Titles
------
The title is the identity that matters, and `/` inside it is the ONLY
hierarchy there is — the wiki this feeds has no directories. A local path is
derived from the title, never the reverse:
title "Simple Chains/Ideas/Chain core"
path "Simple-Chains/Ideas/Chain-core.md"
That direction is deliberate. Deriving a title back from a path would have to
undo `-`-for-space, and `02-chain-core` proves it cannot: the dashes there are
real. So a title is chosen ONCE, at import or at pull, and then it is a fact in
the manifest. Renaming is an explicit act, not a side effect of editing a
heading.
Ordering
--------
A leading `NN-` on a file name is sort order and nothing else — it never
reaches the title. `order 0` is special: it is the directory's own page, so
`ideas/00-intro.md` becomes the page "…/Ideas" rather than a child of it.
"""
import hashlib
import json
import os
import re
# --------------------------------------------------------------------------
# where the cache lives
# --------------------------------------------------------------------------
# `<repo root>/tmp/wiki`, absolute, resolved once at import — the same anchoring
# rule the issue store uses, and for the same reason: a script's own location is
# a fact about the installation, cwd is a fact about the last `cd`. Walking up
# from __file__ hands every script in both layers one answer no matter where it
# is invoked from.
#
# The twenty lines below are duplicated from the issue domain rather than
# imported from it. Two domains that do not know about each other is worth more
# than the duplication is worth saving: skills/page must keep working with
# skills/issue deleted, exactly as skills/issue keeps working with skills/sync
# deleted.
STORE_PARTS = ("tmp", "wiki")
# `.git` is a directory in a normal clone and a FILE in a worktree — hence
# exists(), not isdir(). AGENTS.md is the fallback for a plugin copied out of
# git; the agents-sync hook only ever puts one at a repository root.
REPO_MARKERS = (".git", "AGENTS.md")
_HERE = os.path.dirname(os.path.abspath(__file__))
MANIFEST = ".pages.json"
# Written here; read here. Everything else in an entry belongs to the wiki
# layer and is passed through untouched.
DOMAIN_KEYS = ("title", "order", "source")
def repo_root(start):
"""Nearest ancestor of `start` (inclusive) carrying a repo marker, or None."""
d = os.path.abspath(start)
while True:
if any(os.path.exists(os.path.join(d, m)) for m in REPO_MARKERS):
return d
parent = os.path.dirname(d)
if parent == d:
return None
d = parent
def store_root(start=None):
"""Absolute path of the wiki cache root.
`start` overrides the anchor so the resolution can be exercised against a
scratch tree. Outside a repository, cwd gets a turn, then the historical
cwd-relative location stands — made absolute so an error can name the
directory it really looked in."""
for anchor in ([start] if start is not None else [_HERE, os.getcwd()]):
root = repo_root(anchor)
if root:
return os.path.join(root, *STORE_PARTS)
return os.path.abspath(os.path.join(*STORE_PARTS))
WIKI_ROOT = store_root()
def space_root(space, root=None):
"""Directory of one space. `space` is an opaque relative path — it may
contain `/` (it usually does) and is used as typed."""
return os.path.join(root or WIKI_ROOT, *space.split("/"))
# --------------------------------------------------------------------------
# names, titles, order
# --------------------------------------------------------------------------
# Characters a title may not carry into a path. `/` is absent on purpose: it is
# the hierarchy separator and is split on before this ever applies.
_UNSAFE = re.compile(r'[\\:*?"<>|\x00-\x1f]+')
# Inline code in a heading is markup, not a name: `Inventory — \`P-NN\`` is a
# page called "Inventory — P-NN", and a page list does not render markdown.
_MARKUP = re.compile(r"[`*_]+")
# Dropped from a PATH but kept in a title. An apostrophe in "Don't send to this
# one" belongs in the name and does not belong in something a shell has to
# quote.
_PATH_NOISE = re.compile(r"['‘’\"“”,]+")
_DASHES = re.compile(r"-{2,}")
_ORDER = re.compile(r"^(\d+)[-_. ]+(.*)$")
_HEADING = re.compile(r"^\s{0,3}#{1,6}\s+(.+?)\s*#*\s*$")
def order_of(name):
"""The `NN-` sort key on a file or directory name, or None.
`00-intro.md` -> 0, `02-chain-core.md` -> 2, `handoff.md` -> None. Zero is
a real answer and not None; callers distinguish them."""
m = _ORDER.match(strip_ext(name))
return int(m.group(1)) if m else None
def strip_ext(name):
stem, ext = os.path.splitext(name)
return stem if ext.lower() in (".md", ".markdown") else name
def strip_order(name):
"""`02-chain-core` -> `chain-core`; a name that is only digits is left
alone, because stripping it would leave nothing to call the page."""
m = _ORDER.match(strip_ext(name))
return m.group(2) if m and m.group(2) else strip_ext(name)
def title_from_name(name):
"""Fallback title: the file or directory name made readable.
`02-chain-core.md` -> `Chain core`. Only the first letter is raised —
title-casing would wreck `Q-01`, `sqlc`, `APNs`, and every other name that
already knows how it is spelled."""
t = strip_order(name).replace("_", " ").replace("-", " ").strip()
t = re.sub(r"\s+", " ", t)
return t[:1].upper() + t[1:] if t else t
def title_from_body(text):
"""The document's first markdown heading, or None.
Preferred over the file name because it is what a human wrote for a human:
`03-q-01-do-we-know-the-chain-participant-by-name.md` opens with
`## Q-01. Do We Know the Chain Participant by Name`, and there is no
mechanical route from the first string to the second. Only the first
heading is consulted, and only before any prose — a heading further down is
a section, not a name."""
for line in text.splitlines():
if not line.strip():
continue
m = _HEADING.match(line)
return m.group(1).strip() if m else None
return None
def sanitize_title(title):
"""Make a string safe to be one title SEGMENT.
`/` becomes `-`: a slash inside a heading would silently invent a level of
hierarchy that the author did not ask for, and inventing structure is worse
than losing a slash."""
t = _MARKUP.sub("", _UNSAFE.sub("", title.replace("/", "-")))
return re.sub(r"\s+", " ", t).strip(" .-") or "untitled"
def join_title(*parts):
"""Join title segments with the hierarchy separator, dropping empties."""
return "/".join(p for p in parts if p)
def path_segment(segment):
"""One title segment as one path component."""
s = _PATH_NOISE.sub("", _MARKUP.sub("", _UNSAFE.sub("", segment)))
s = re.sub(r"\s+", "-", s.replace("/", "-").strip())
return _DASHES.sub("-", s).strip("-.") or "untitled"
def path_for_title(title):
"""Relative path, inside a space, for a title. Always ends in `.md`."""
parts = [path_segment(p) for p in title.split("/") if p.strip()]
if not parts:
parts = ["untitled"]
return os.path.join(*parts) + ".md"
# --------------------------------------------------------------------------
# the manifest
# --------------------------------------------------------------------------
def blank_manifest(space):
return {"space": space, "pages": {}}
def manifest_path(space, root=None):
return os.path.join(space_root(space, root), MANIFEST)
def load_manifest(space, root=None):
"""The space's manifest, or a blank one.
A missing manifest and an empty one are the same thing to every caller here
— but they are NOT the same thing to a caller deciding whether to print
"no such space". That distinction is `os.path.isdir(space_root(...))`, and
the commands make it themselves rather than reading it out of a dict."""
p = manifest_path(space, root)
if not os.path.isfile(p):
return blank_manifest(space)
with open(p, encoding="utf-8") as f:
m = json.load(f)
m.setdefault("space", space)
m.setdefault("pages", {})
return m
def save_manifest(manifest, root=None):
"""Write the manifest, keys sorted, one page per line-block.
Sorted and indented because this file lands in a diff every time anything
syncs, and a diff nobody can read is a diff nobody checks."""
p = manifest_path(manifest["space"], root)
os.makedirs(os.path.dirname(p), exist_ok=True)
ordered = {"space": manifest["space"], "pages": {}}
for path, e in sorted(manifest.get("pages", {}).items()):
ordered["pages"][path] = {k: e[k] for k in DOMAIN_KEYS if k in e}
ordered["pages"][path].update(
{k: v for k, v in sorted(e.items()) if k not in DOMAIN_KEYS})
with open(p, "w", encoding="utf-8") as f:
json.dump(ordered, f, ensure_ascii=False, indent=2, sort_keys=False)
f.write("\n")
return p
def entry(title, order=None, source=None, **extra):
"""A manifest entry. Domain keys first, passthrough after — the same
render order the issue layer uses, for the same reason: it makes a diff of
the file readable."""
e = {"title": title}
if order is not None:
e["order"] = order
if source is not None:
e["source"] = source
e.update({k: v for k, v in extra.items() if v is not None})
return e
def find_by_source(manifest, source, prefix=""):
"""(relpath, entry) for the page imported from this source file, or
(None, None).
The path is derived from the title, so a retitle moves it — and looking a
page up by its new path would find nothing, treat it as new, and publish a
duplicate beside the page it was meant to rename. Source is the one link
that survives a rename, which is why it is recorded at all.
Scoped by title prefix, so importing the same directory twice under two
prefixes gives two independent trees rather than one fighting over itself.
"""
for path, e in manifest.get("pages", {}).items():
if e.get("source") != source:
continue
if prefix and not (e.get("title", "") == prefix
or e.get("title", "").startswith(prefix + "/")):
continue
return path, e
return None, None
def sort_key(relpath, e):
"""Order a tree for display and for an index.
Directory by directory, `order` first and unnumbered pages after — an
explicit `NN-` is a decision, its absence is not. Ties break on title so
the output is stable."""
d = os.path.dirname(relpath)
o = e.get("order")
return (d, 0 if o is not None else 1, o if o is not None else 0,
e.get("title", relpath))
def sorted_pages(manifest):
"""[(relpath, entry)] in tree order."""
return sorted(manifest.get("pages", {}).items(),
key=lambda kv: sort_key(kv[0], kv[1]))
def by_title(manifest):
return {e["title"]: (p, e) for p, e in manifest.get("pages", {}).items()
if e.get("title")}
def children_of(manifest, prefix):
"""Every page at or under a title prefix.
The wiki this feeds is flat, so "children" is a prefix test on the title
and nothing more — there is no tree to walk, only a naming convention to
trust."""
out = []
for p, e in sorted_pages(manifest):
t = e.get("title", "")
if t == prefix or t.startswith(prefix + "/"):
out.append((p, e))
return out
def body_hash(text):
"""sha1 of the exact bytes a page would be published as.
This is the whole of change detection: a page is worth pushing when what is
on disk hashes differently from what was pushed last. No timestamps, no
drift model — the same stance the issue store takes."""
if isinstance(text, str):
text = text.encode("utf-8")
return hashlib.sha1(text).hexdigest()
# --------------------------------------------------------------------------
# importing a directory of markdown
# --------------------------------------------------------------------------
SKIP_DIRS = {".git", ".svn", "__pycache__", "node_modules"}
MD_EXT = (".md", ".markdown")
def walk_markdown(src):
"""Every markdown file under `src`, as paths relative to it, depth first
and sorted so an import is reproducible."""
out = []
for dirpath, dirnames, filenames in os.walk(src):
dirnames[:] = sorted(d for d in dirnames
if d not in SKIP_DIRS and not d.startswith("."))
rel = os.path.relpath(dirpath, src)
rel = "" if rel == "." else rel
for f in sorted(filenames):
if f.lower().endswith(MD_EXT) and not f.startswith("."):
out.append(os.path.join(rel, f) if rel else f)
return out
def title_for_source(relpath, text, prefix=""):
"""The title a source file gets on import.
Three rules, in this order, and the reference doc spells out why:
1. `order 0` (`00-intro.md`, or a literal `index`/`readme`) is the page for
the directory it sits in. Its title comes from the DIRECTORY name, not
from its own heading — a child's title must extend its parent's exactly,
and `ideas/00-intro.md` opens with "Ideas for chain business
requirements", which no child would ever be prefixed by.
2. Any other file takes its first heading, sanitized.
3. No heading: the file name, made readable.
"""
parts = relpath.replace(os.sep, "/").split("/")
name = parts[-1]
dirs = [sanitize_title(title_from_name(d)) for d in parts[:-1]]
stem = strip_ext(name).lower()
if order_of(name) == 0 or stem in ("index", "readme"):
# The directory's own page. At the root of the import that is the
# prefix itself.
return join_title(prefix, *dirs)
own = title_from_body(text)
own = sanitize_title(own) if own else sanitize_title(title_from_name(name))
return join_title(prefix, *dirs, own)
def plan_import(src, prefix="", read=None):
"""Work out what an import would produce, without writing anything.
Returns (pages, collisions):
pages [{"source", "path", "title", "order", "text"}] in tree order
collisions [(path, [title, title, ...])] — two sources landing on one
file. Reported, never resolved: the wiki would end up with
two pages fighting over one local copy, and picking a winner
for the operator is how a discussion loses a document."""
def default_read(p):
with open(p, encoding="utf-8") as f:
return f.read()
read = read or default_read
pages, seen = [], {}
for rel in walk_markdown(src):
source = os.path.join(src, rel)
text = read(source)
title = title_for_source(rel, text, prefix)
path = path_for_title(title)
seen.setdefault(path, []).append(title)
# `source` is kept relative to the import root, not absolute: it is the
# only durable link between a file on the far side and the page it
# became, and it has to survive the artifacts directory being moved.
pages.append({"source": source, "rel": rel.replace(os.sep, "/"),
"path": path, "title": title,
"order": order_of(os.path.basename(rel)), "text": text})
pages.sort(key=lambda p: sort_key(p["path"], p))
collisions = [(p, t) for p, t in sorted(seen.items()) if len(t) > 1]
return pages, collisions
# --------------------------------------------------------------------------
# rendering
# --------------------------------------------------------------------------
def title_tree(manifest, prefix=""):
"""Group pages into a parent -> children map keyed by title.
Built from the titles, not from the manifest's path order. Those two
disagree: on disk `Simple-Chains/System.md` sorts before
`Simple-Chains/Ideas/Scale.md`, while in the hierarchy Scale is a
grandchild of Simple Chains and System is a child. Nesting has to follow
the titles, because the titles are the only hierarchy there is.
A parent with no page of its own still gets a node: `Simple Chains/Parked`
can have children while nothing is published at that title, and dropping
its children because it is missing would hide them entirely."""
kids, entries = {}, {}
for _, e in manifest.get("pages", {}).items():
title = e.get("title")
if not title:
continue
if prefix and not (title == prefix or title.startswith(prefix + "/")):
continue
entries[title] = e
parts = title.split("/")
# Every ancestor gets a node, so a gap in the chain does not orphan a
# subtree.
for i in range(len(parts), 0, -1):
kids.setdefault("/".join(parts[:i - 1]), set()).add("/".join(parts[:i]))
return kids, entries
def render_index(manifest, prefix="", heading=None):
"""A table-of-contents page for a space or a subtree.
Nested markdown list, indented by title depth. The wiki is flat and will
not draw this for you, so the index IS the navigation.
Links: a published page is linked by its `sub_url`, which is the only
address Gitea guarantees. A page that has never been pushed has no sub_url
yet, so it gets Gitea's own `[[Title]]` wiki-link syntax — which resolves
the escaping itself, at render time, on the server. Rebuilding the index
after a push upgrades those links to exact ones."""
kids, entries = title_tree(manifest, prefix)
lines = ["# %s" % (heading or prefix or "Contents"), ""]
def order_key(title):
e = entries.get(title) or {}
o = e.get("order")
return (0 if o is not None else 1, o if o is not None else 0, title)
def walk(node, depth):
for child in sorted(kids.get(node, ()), key=order_key):
e = entries.get(child) or {}
label = child.split("/")[-1]
sub = e.get("sub_url")
link = "[%s](%s)" % (label, sub) if sub else "[[%s|%s]]" % (child, label)
lines.append("%s- %s" % (" " * depth, link))
walk(child, depth + 1)
walk(prefix, 0)
lines.append("")
return "\n".join(lines)
def tree_lines(manifest, mark=None):
"""The space as an ascii tree, for a terminal.
`mark(relpath, entry)` returns a short state tag shown after the title —
the wiki layer passes sync state through it, and this module stays unaware
of what the tags mean."""
out, last_dir = [], None
for path, e in sorted_pages(manifest):
d = os.path.dirname(path)
if d != last_dir:
out.append("%s/" % d if d else ".")
last_dir = d
tag = mark(path, e) if mark else ""
out.append(" %-40s %s%s" % (os.path.basename(path),
e.get("title", ""),
(" " + tag) if tag else ""))
return out
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""
page_import.py — pull a directory of markdown into a space. Offline.
This is the "wiki organization" step, and it is the only step where a page gets
its name. A discussion produces artifacts wherever the discussion happened:
~/…/mpns/feat/simple-chains/tmp/simple-chains/
handoff.md scope.md
ideas/00-intro.md ideas/02-chain-core.md
questions/03-q-01-do-we-know-the-chain-participant-by-name.md
Import copies that tree into a space under `tmp/wiki/`, gives every file a
title, and records both in the manifest. Nothing here talks to a wiki; the
result is a complete, readable, greppable tree whether or not it is ever
published.
page_import.py --from DIR --space claude-skills/tea --prefix "Simple Chains"
Simple-Chains/Handoff.md Simple Chains/Handoff
Simple-Chains/Ideas.md Simple Chains/Ideas
Simple-Chains/Ideas/Chain-core.md Simple Chains/Ideas/Chain core
Re-importing is safe and is the normal way to refresh: a page already in the
manifest keeps its title (a title is a decision, not a derivation) and only its
body is replaced. `--retitle` opts into re-deriving titles, which is a rename
and, for pages already published, will orphan the old ones — so it is never the
default.
Usage:
page_import.py --from DIR [--space SPACE] [--prefix TITLE]
[--retitle] [--dry-run] [--out DIR]
"""
import argparse
import os
import shutil
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import page # noqa: E402
def die(msg, code=1):
sys.stderr.write("%s: %s\n" % (os.path.basename(sys.argv[0]), msg))
sys.exit(code)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--from", dest="src", required=True,
help="directory of markdown to import")
ap.add_argument("--space", default="local",
help="space to import into (default: local)")
ap.add_argument("--prefix", default="",
help="title every imported page hangs under")
ap.add_argument("--retitle", action="store_true",
help="re-derive titles of pages already in the manifest "
"(a rename; orphans published pages)")
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--out", help="wiki cache root (default: <repo>/tmp/wiki)")
a = ap.parse_args()
src = os.path.abspath(a.src)
if not os.path.isdir(src):
die("not a directory: %s" % a.src)
root = a.out or page.WIKI_ROOT
prefix = page.sanitize_title(a.prefix) if a.prefix else ""
pages, collisions = page.plan_import(src, prefix)
if not pages:
die("no markdown found under %s" % src)
if collisions:
for path, titles in collisions:
sys.stderr.write("collision: %s <- %s\n" % (path, " | ".join(titles)))
die("%d path collision(s); rename the sources and retry" % len(collisions))
manifest = page.load_manifest(a.space, root)
known = manifest["pages"]
dest_root = page.space_root(a.space, root)
# Asked before anything is written: nothing should create a space as a
# silent side effect of a write, and saying so on stderr is how the
# operator learns a typo in --space made a second one.
created = not os.path.isdir(dest_root)
new = changed = same = moved = 0
for p in pages:
# Looked up by SOURCE, not by path: a retitle moves the path, and a
# lookup that missed would treat the page as new and publish a
# duplicate beside the one it was meant to rename.
prior_path, prior = page.find_by_source(manifest, p["rel"], prefix)
if prior is None:
prior_path, prior = p["path"], known.get(p["path"])
# A title already in the manifest is a decision that was made once.
# Re-deriving it on every import would let an edited heading silently
# rename a published page — which does not rename it, it creates a
# second one and abandons the first.
title = p["title"] if (a.retitle or not prior) else prior["title"]
relpath = page.path_for_title(title)
dest = os.path.join(dest_root, relpath)
state = "new"
if prior and relpath != prior_path:
state = "moved"
elif prior and os.path.isfile(dest):
with open(dest, encoding="utf-8") as f:
state = "same" if f.read() == p["text"] else "changed"
elif prior:
state = "changed"
new += state == "new"
changed += state == "changed"
same += state == "same"
moved += state == "moved"
print("%-7s %-44s %s" % (state, relpath, title))
if a.dry_run:
continue
os.makedirs(os.path.dirname(dest), exist_ok=True)
shutil.copyfile(p["source"], dest)
# Passthrough keys survive: a re-import must not cost a page its
# sub_url, or the next push would publish a duplicate.
e = dict(prior or {})
e.update(page.entry(title, p["order"], p["rel"]))
if state == "moved":
# The old copy goes, the entry moves with its bookkeeping intact.
# The page in the wiki is still at its old sub_url; the next push
# sends the new title, which is what renames it there.
old = os.path.join(dest_root, prior_path)
if os.path.isfile(old):
os.remove(old)
known.pop(prior_path, None)
# A rename can leave the body byte-identical, and push decides by
# body hash alone. Clearing it is what makes the next push send the
# new title instead of skipping the page as unchanged.
e.pop("pushed", None)
known[relpath] = e
if a.dry_run:
print("\ndry run — nothing written")
return 0
path = page.save_manifest(manifest, root)
if created:
sys.stderr.write("created space %s\n" % dest_root)
print("\n%d new, %d changed, %d unchanged%s -> %s"
% (new, changed, same,
", %d renamed" % moved if moved else "", os.path.dirname(path)))
if moved:
sys.stderr.write(
"%d page(s) renamed. A published page is renamed in the wiki by "
"the next push, not by this import.\n" % moved)
return 0
if __name__ == "__main__":
sys.exit(main())
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""
page_index.py — write a table-of-contents page into a space. Offline.
The wiki this feeds is flat: a title like `Simple Chains/Ideas/Chain core` has
hierarchy in its name and nowhere else, and Gitea will not draw you a tree from
it. An index page is therefore not a nicety, it is the navigation.
Written as an ordinary page in the space, so it is pushed by the same command
as everything else and needs no special case anywhere downstream. Links are
written by TITLE rather than by URL — the wiki resolves those itself, and a
link written that way survives every filename-escaping rule this layer
deliberately refuses to model.
page_index.py --space claude-skills/tea --prefix "Simple Chains"
-> Simple-Chains.md, title `Simple Chains`
page_index.py --space claude-skills/tea --title Home
-> Home.md, title `Home`, listing the whole space
Usage:
page_index.py [--space SPACE] [--prefix TITLE] [--title TITLE]
[--dry-run] [--out DIR]
"""
import argparse
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import page # noqa: E402
def die(msg, code=1):
sys.stderr.write("%s: %s\n" % (os.path.basename(sys.argv[0]), msg))
sys.exit(code)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--space", default="local")
ap.add_argument("--prefix", default="",
help="index only this subtree; also the index's own title")
ap.add_argument("--title", help="title for the index page "
"(default: --prefix, else Home)")
ap.add_argument("--dry-run", action="store_true")
ap.add_argument("--out", help="wiki cache root (default: <repo>/tmp/wiki)")
a = ap.parse_args()
root = a.out or page.WIKI_ROOT
space_dir = page.space_root(a.space, root)
if not os.path.isdir(space_dir):
die("no such space: %s (looked in %s)" % (a.space, space_dir))
manifest = page.load_manifest(a.space, root)
prefix = page.sanitize_title(a.prefix) if a.prefix else ""
title = a.title or prefix or "Home"
body = page.render_index(manifest, prefix, heading=title)
relpath = page.path_for_title(title)
if a.dry_run:
sys.stdout.write(body)
print("-> %s (%s)" % (relpath, title))
return 0
dest = os.path.join(space_dir, relpath)
os.makedirs(os.path.dirname(dest), exist_ok=True)
with open(dest, "w", encoding="utf-8") as f:
f.write(body)
# Carries the entry's passthrough keys forward: rebuilding an index must
# update the page that is already published, never publish a second one.
prior = manifest["pages"].get(relpath, {})
e = dict(prior)
e.update(page.entry(title, prior.get("order")))
manifest["pages"][relpath] = e
page.save_manifest(manifest, root)
n = len(page.children_of(manifest, prefix) if prefix
else page.sorted_pages(manifest))
print("%s -> %s (%d entr%s)" % (title, relpath, n - 1,
"y" if n - 1 == 1 else "ies"))
return 0
if __name__ == "__main__":
sys.exit(main())
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""
page_ls.py — show what a space holds. Offline.
The tree, the titles, and one state tag per page. The tag is the only place
this layer acknowledges that a wiki exists, and it reads it the way the issue
index reads `origin:` — as an opaque fact recorded by somebody else:
local never published; a complete state, not a pending one
synced published, and the file matches what was pushed
ahead published, and the local file has changed since
? published, but nothing recorded what was pushed
Usage:
page_ls.py [--space SPACE] [--prefix TITLE] [--titles] [--out DIR]
"""
import argparse
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import page # noqa: E402
def die(msg, code=1):
sys.stderr.write("%s: %s\n" % (os.path.basename(sys.argv[0]), msg))
sys.exit(code)
def state_of(space_dir, relpath, e):
if not e.get("sub_url"):
return "local"
pushed = e.get("pushed")
if not pushed:
return "?"
full = os.path.join(space_dir, relpath)
if not os.path.isfile(full):
return "missing"
with open(full, encoding="utf-8") as f:
return "synced" if page.body_hash(f.read()) == pushed else "ahead"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--space", default="local")
ap.add_argument("--prefix", default="", help="only titles at or under this")
ap.add_argument("--titles", action="store_true",
help="print one title per line and nothing else")
ap.add_argument("--out", help="wiki cache root (default: <repo>/tmp/wiki)")
a = ap.parse_args()
root = a.out or page.WIKI_ROOT
space_dir = page.space_root(a.space, root)
# "Does not exist" and "is empty" are different answers and get different
# messages — an empty space is a space somebody made on purpose.
if not os.path.isdir(space_dir):
die("no such space: %s (looked in %s)" % (a.space, space_dir))
manifest = page.load_manifest(a.space, root)
pages = (page.children_of(manifest, a.prefix) if a.prefix
else page.sorted_pages(manifest))
if not pages:
print("space %s is empty" % a.space if not a.prefix
else "nothing at or under %r" % a.prefix)
return 0
if a.titles:
for _, e in pages:
print(e.get("title", ""))
return 0
sub = {p: e for p, e in pages}
view = dict(manifest, pages=sub)
for line in page.tree_lines(view, mark=lambda p, e: state_of(space_dir, p, e)):
print(line)
counts = {}
for p, e in pages:
s = state_of(space_dir, p, e)
counts[s] = counts.get(s, 0) + 1
print("\n%d page(s): %s" % (len(pages),
", ".join("%d %s" % (v, k)
for k, v in sorted(counts.items()))))
return 0
if __name__ == "__main__":
sys.exit(main())