1815d91cdf
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>
524 lines
20 KiB
Python
524 lines
20 KiB
Python
#!/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
|