Skip to content

Commit d01b02a

Browse files
authored
feat(docs): make chat links outlive the server, the path, and the title (#58)
* fix: keep links posted in chat working after the server moves Every link the archivist posted into a room carried the document server's address as it looked at that moment. Chat history is append-only, so changing the domain, switching between port and domain mode, or moving Paperless left every one of those links dead, with no way to rewrite them. Links now point at the stack's own resolver, which looks up where the document lives at the moment someone clicks. The resolver already existed and only the todo links used it; the five places that built an address by hand now share one builder. Document pages mirrored into the vault still store an absolute address. That is a change to the vault format and follows separately. * feat(core): link a captured note by what it is, not where it sits A search hit for a note or bookmark linked straight into Forgejo, at the path the file happened to have that day. That path carries the bucket, the topic slug and the title slug, and all three change under ordinary use: a capture re-scopes when a second person joins the room, a topic gets renamed, a correction rewrites the title. The link then breaks silently, still looking valid. Captures now get `/go/capture/<capture id>`, resolved at click time by finding the file that carries that id. The id is the Matrix event the capture came from, assigned once and never rewritten, so the link survives every one of those moves. This settles which links the namespace takes: entities (topic, person) are addressed by name, records (docs, capture) by an id that is never re-derived, and nothing is addressed by path. Pages with no id, such as hand-written wiki entries, keep the old link until they have one.
1 parent d271e14 commit d01b02a

22 files changed

Lines changed: 769 additions & 58 deletions

‎lib/stack/links.py‎

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
"""Logical link construction — the emitter half of the `/go` namespace.
2+
3+
A link a bot posts into a Matrix room is there forever: the timeline is
4+
append-only, so whatever URL was in the message is the URL a family
5+
member clicks two years later. Point it straight at a service and it
6+
dies the day the domain changes, the stack flips between port mode and
7+
domain mode, or Paperless moves. Point it at `home.<domain>/go/docs/247`
8+
and it re-resolves at click time.
9+
10+
That indirection has two halves, and they live apart on purpose:
11+
12+
* this module builds the *logical path* (`/docs/247`) and joins it onto
13+
the configured `/go` base. It knows the shape of the namespace and
14+
nothing else.
15+
* `stacklets/core/tools-server/resolver.py` is the only place that
16+
knows what a logical path resolves *to*. Nothing here may duplicate
17+
that mapping.
18+
19+
So an emitter never concatenates a service URL. It asks for a logical
20+
path, hands it the base it was given, and posts whatever comes back.
21+
When the base is unset (core has not rendered `LINK_BASE_URL` yet) the
22+
answer is the empty string, and callers fall back to their unlinked
23+
view — a link that cannot resolve is worse than no link.
24+
25+
Stdlib only: the host CLI imports `lib/stack/` without third-party deps,
26+
and the bot-runner mounts this same tree at `/app/stack`, so host and
27+
containers build links from one implementation.
28+
"""
29+
30+
from __future__ import annotations
31+
32+
from urllib.parse import quote
33+
34+
35+
# ── Logical paths ─────────────────────────────────────────────────────
36+
#
37+
# Kinds are explicit nouns — the path says whether "camping" is a topic
38+
# or a person, so the resolver never has to guess. They come in two
39+
# families, and which one a new kind belongs to is the whole design:
40+
#
41+
# entities, addressed by NAME — `topic`, `person`
42+
# A name a person could type, for something that has an identity
43+
# beyond any one file. A trailing leaf (`todo`) selects a
44+
# sub-page instead of the overview.
45+
#
46+
# records, addressed by ID — `docs`, `capture`
47+
# One artefact, keyed by something assigned once and never
48+
# re-derived. Never by path: a path encodes where a thing sat on
49+
# the day the link was written, and these things move.
50+
#
51+
# A link posted into chat is permanent, so the cost of putting a kind in
52+
# the wrong family is paid forever. When in doubt, ask what changes when
53+
# a family renames a topic or corrects a title.
54+
55+
def go_docs(doc_id: int | str) -> str:
56+
"""`/docs/<id>` — a document, wherever it is filed right now.
57+
58+
>>> go_docs(247)
59+
'/docs/247'
60+
"""
61+
return f"/docs/{doc_id}"
62+
63+
64+
def go_topic(scope: str, leaf: str | None = None) -> str:
65+
"""`/topic/<scope>` — a shared or personal topic page.
66+
67+
`scope` is either a bare slug (`camping`, resolved under the shared
68+
bucket) or an explicit vault path (`family/camping`, `homer/gravel`).
69+
Both forms are passed through verbatim; the resolver takes either.
70+
71+
>>> go_topic("family/camping", "todo")
72+
'/topic/family/camping/todo'
73+
>>> go_topic("camping")
74+
'/topic/camping'
75+
"""
76+
return _entity_path("topic", scope, leaf)
77+
78+
79+
def go_capture(capture_id: str) -> str:
80+
"""`/capture/<id>` — one captured note, bookmark or memo.
81+
82+
A record, so it is addressed by id and never by where it sits. Its
83+
vault path carries the bucket, the topic slug and the title slug,
84+
and each of those changes on its own under ordinary use: a capture
85+
re-scopes when a second person joins the room, a topic gets
86+
renamed, a title is rewritten by a correction. A path-keyed link
87+
would break for three reasons the resolver cannot repair, and break
88+
silently, which is worse than the service URLs it replaced.
89+
90+
The id is the Matrix event id, assigned once and never rewritten.
91+
It starts with `$` and can carry `/` in older room versions, so it
92+
is percent-encoded into a single path segment.
93+
94+
>>> go_capture("$abc123")
95+
'/capture/%24abc123'
96+
"""
97+
return f"/capture/{quote(str(capture_id), safe='')}"
98+
99+
100+
def go_person(slug: str, leaf: str | None = None) -> str:
101+
"""`/person/<slug>` — a household member's page.
102+
103+
>>> go_person("homer")
104+
'/person/homer'
105+
>>> go_person("homer", "todo")
106+
'/person/homer/todo'
107+
"""
108+
return _entity_path("person", slug, leaf)
109+
110+
111+
def _entity_path(kind: str, scope: str, leaf: str | None) -> str:
112+
"""Join `kind`, the scope segments, and an optional leaf into a path.
113+
114+
Scopes arrive from vault paths and room bindings, which carry stray
115+
slashes often enough that stripping them here is cheaper than at
116+
every call site.
117+
"""
118+
segments = [s for s in str(scope).strip("/").split("/") if s]
119+
if leaf:
120+
segments.append(leaf.strip("/"))
121+
return "/".join(["", kind, *segments])
122+
123+
124+
# ── Public URL ────────────────────────────────────────────────────────
125+
126+
def public(logical: str, base: str) -> str:
127+
"""Absolute, clickable form of a logical path.
128+
129+
`base` is `LINK_BASE_URL` — core's mode-correct home URL with the
130+
`/go` prefix already on it. Empty base means the namespace is not
131+
reachable yet, so there is no honest link to post.
132+
133+
>>> public(go_docs(247), "https://home.example.org/go")
134+
'https://home.example.org/go/docs/247'
135+
>>> public(go_docs(247), "")
136+
''
137+
"""
138+
if not base:
139+
return ""
140+
return f"{base.rstrip('/')}{logical}"

‎stacklets/core/docker-compose.yml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,12 @@ services:
7979
# effect without a rebuild. Keep in sync with tools-server/Dockerfile.
8080
- ./tools-server/server.py:/app/server.py:ro
8181
- ./tools-server/resolver.py:/app/resolver.py:ro
82+
- ./tools-server/capture_index.py:/app/capture_index.py:ro
83+
# The brain projection, read-only: `/go/capture/<id>` finds where a
84+
# capture is filed now by reading the same tree the wiki serves.
85+
# `${DATA_DIR}` is the host path (BRAIN_REPO_DIR is the *container*
86+
# path the bot-runner sees, so it cannot be a bind source here).
87+
- ${DATA_DIR}/memory/brain:/brain:ro
8288
restart: unless-stopped
8389

8490
networks:

‎stacklets/core/tools-server/Dockerfile‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ RUN pip install --no-cache-dir -r requirements.txt
77

88
COPY server.py .
99
COPY resolver.py .
10+
COPY capture_index.py .
1011

1112
# --gecos "" suppresses the interactive Full Name/Room/Phone prompts
1213
RUN adduser --disabled-password --gecos "" --uid 1000 tools
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""Find where a capture lives right now, by the id it was captured with.
2+
3+
`/go/capture/<id>` promises a link that survives the file moving. Every
4+
other logical path can be resolved by rewriting the string, but this one
5+
cannot: the id names *which* capture, and says nothing about where it is
6+
today. Something has to look.
7+
8+
WHY A SCAN AND NOT AN INDEX
9+
10+
The obvious design is an index the curator maintains during the mirror.
11+
It would be faster and it would be one more thing that can be wrong: an
12+
index is a second copy of the truth, so it can go stale, disagree with
13+
the tree, or be missing on a fresh clone, and every one of those failures
14+
looks like a dead link.
15+
16+
A scan reads the same files the wiki serves, so it cannot disagree with
17+
them, and there is nothing to rebuild or invalidate. A family vault is
18+
hundreds to low thousands of small markdown files and a redirect happens
19+
when a human clicks something, so the cost lands in the right place. If
20+
a vault ever grows past that, an index can be added *behind this same
21+
function* without touching the resolver or the link format.
22+
23+
WHY FRONTMATTER AND NOT THE BODY
24+
25+
`capture_id` is a frontmatter field, and the search path already treats
26+
frontmatter as the machine-readable half of a page. Reading only the head
27+
of each file keeps a scan cheap and avoids matching an id someone quoted
28+
in prose.
29+
"""
30+
31+
from __future__ import annotations
32+
33+
from pathlib import Path
34+
35+
# Frontmatter sits at the top; no capture page has a hundred lines of it.
36+
# Bounding the read keeps a scan over a large vault from paging in whole
37+
# documents to answer a question the header already answers.
38+
_HEAD_BYTES = 4096
39+
40+
_FIELD = "capture_id:"
41+
42+
43+
def _capture_id_of(path: Path) -> str:
44+
"""The `capture_id` declared in a file's frontmatter, or "".
45+
46+
Only the frontmatter block counts. An id quoted in the body of a
47+
note — a reply discussing another capture, say — must not make this
48+
file answer to it.
49+
"""
50+
try:
51+
with path.open("r", encoding="utf-8", errors="ignore") as fh:
52+
head = fh.read(_HEAD_BYTES)
53+
except OSError:
54+
return ""
55+
lines = head.splitlines()
56+
if not lines or lines[0].strip() != "---":
57+
return ""
58+
for line in lines[1:]:
59+
if line.strip() == "---":
60+
return ""
61+
if line.startswith(_FIELD):
62+
return line[len(_FIELD):].strip().strip("\"'")
63+
return ""
64+
65+
66+
def find_capture(capture_id: str, *, brain_dir: Path) -> str | None:
67+
"""Wiki-relative target for a capture id, or None if nothing carries it.
68+
69+
The target is the path the wiki serves, so the `.md` suffix comes off
70+
— same shape `resolve_topic_target` returns for a topic page.
71+
72+
Returns None rather than a guess when the id is unknown: a 404 tells
73+
the person the capture is gone, while a redirect to something else
74+
would quietly show them the wrong note.
75+
"""
76+
wanted = (capture_id or "").strip()
77+
if not wanted:
78+
return None
79+
brain_dir = Path(brain_dir)
80+
if not brain_dir.is_dir():
81+
return None
82+
for path in sorted(brain_dir.rglob("*.md")):
83+
if _capture_id_of(path) == wanted:
84+
rel = path.relative_to(brain_dir)
85+
return str(rel.with_suffix(""))
86+
return None

‎stacklets/core/tools-server/resolver.py‎

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121

2222
from __future__ import annotations
2323

24+
from typing import Callable
25+
2426
# A trailing `todo`/`todos` segment points at the entity's task list instead of
2527
# its overview page. Both spellings accepted — people type either.
2628
_TODO_LEAVES = {"todo", "todos"}
@@ -65,20 +67,31 @@ def resolve_person_target(segments: list[str]) -> str | None:
6567
def build_redirect(
6668
kind: str, rest: list[str], *,
6769
docs_base: str, wiki_base: str, shared_bucket: str,
70+
find_capture: "Callable[[str], str | None] | None" = None,
6871
) -> str | None:
6972
"""Full redirect URL for a `/<prefix>/<kind>/<rest>` request, or None.
7073
71-
`kind` is `docs`, `topic`, or `person`; `rest` is the remaining path
72-
segments. `docs_base`/`wiki_base` are the public base URLs of Paperless and
73-
the wiki — already mode-correct, computed once by the HTTP layer from env,
74-
so this stays a pure string join. None (→ 404) for an unknown kind, a
74+
`kind` is `docs`, `topic`, `person`, or `capture`; `rest` is the remaining
75+
path segments. `docs_base`/`wiki_base` are the public base URLs of Paperless
76+
and the wiki — already mode-correct, computed once by the HTTP layer from
77+
env, so this stays a pure string join. None (→ 404) for an unknown kind, a
7578
non-numeric doc id, or an entity shape the resolver rejects.
79+
80+
`capture` is the one kind whose target cannot be computed from the path:
81+
the id says *which* capture, never where it is now. `find_capture` is
82+
injected by the HTTP layer for exactly that lookup, which keeps the I/O out
83+
of here and this module unit-testable without a vault on disk. Left unset,
84+
`/capture/...` 404s rather than guessing.
7685
"""
7786
if kind == "docs":
7887
if len(rest) != 1 or not rest[0].isdigit():
7988
return None
8089
return f"{docs_base.rstrip('/')}/documents/{rest[0]}/details"
81-
if kind == "topic":
90+
if kind == "capture":
91+
if len(rest) != 1 or find_capture is None:
92+
return None
93+
target = find_capture(rest[0])
94+
elif kind == "topic":
8295
target = resolve_topic_target(rest, shared_bucket=shared_bucket)
8396
elif kind == "person":
8497
target = resolve_person_target(rest)

‎stacklets/core/tools-server/server.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,13 @@
1212
import json
1313
import os
1414
import socket
15+
from pathlib import Path
1516

1617
import httpx
1718
from fastapi import FastAPI
1819
from fastapi.responses import JSONResponse, RedirectResponse
1920

21+
from capture_index import find_capture
2022
from resolver import build_redirect
2123

2224

@@ -48,6 +50,13 @@
4850
LINK_SHARED_BUCKET = os.environ.get("SHARED_BUCKET", "family")
4951
# The one knob: the path namespace persistent links live under (home.tld/go/…).
5052
LINK_PREFIX = os.environ.get("LINK_PREFIX", "go").strip("/")
53+
# The brain projection, read-only — the same tree the wiki serves. Only
54+
# `/go/capture/<id>` needs it, to find where a capture sits now. This is the
55+
# mount point, not `BRAIN_REPO_DIR`: that variable is the path the *bot-runner*
56+
# sees, and pointing this at it would name a directory that does not exist in
57+
# this container. Unmounted (memory not installed) simply means capture
58+
# links 404.
59+
BRAIN_DIR = Path("/brain")
5160

5261

5362
app = FastAPI(
@@ -137,6 +146,7 @@ def _go(kind: str, rest: list[str]):
137146
url = build_redirect(
138147
kind, rest, docs_base=DOCS_PUBLIC_URL, wiki_base=WIKI_PUBLIC_URL,
139148
shared_bucket=LINK_SHARED_BUCKET,
149+
find_capture=lambda cid: find_capture(cid, brain_dir=BRAIN_DIR),
140150
)
141151
if not url:
142152
return _error("no such resource", status=404)
@@ -161,6 +171,19 @@ async def go_person(name: str):
161171
return _go("person", [s for s in name.split("/") if s])
162172

163173

174+
@app.get(f"/{LINK_PREFIX}/capture/{{capture_id:path}}",
175+
summary="Resolve a capture link")
176+
async def go_capture(capture_id: str):
177+
"""Redirect a captured note or bookmark to wherever it is filed now.
178+
179+
Keyed by the capture's id rather than its path, so re-scoping it,
180+
renaming its topic, or correcting its title all leave the link
181+
working. 404 when no file carries that id — better than showing
182+
someone a different note.
183+
"""
184+
return _go("capture", [capture_id])
185+
186+
164187
# ── Logs ───────────────────────────────────────────────────────────────────
165188

166189
@app.get("/logs", summary="Get container logs")

0 commit comments

Comments
 (0)