Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 140 additions & 0 deletions lib/stack/links.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
"""Logical link construction — the emitter half of the `/go` namespace.

A link a bot posts into a Matrix room is there forever: the timeline is
append-only, so whatever URL was in the message is the URL a family
member clicks two years later. Point it straight at a service and it
dies the day the domain changes, the stack flips between port mode and
domain mode, or Paperless moves. Point it at `home.<domain>/go/docs/247`
and it re-resolves at click time.

That indirection has two halves, and they live apart on purpose:

* this module builds the *logical path* (`/docs/247`) and joins it onto
the configured `/go` base. It knows the shape of the namespace and
nothing else.
* `stacklets/core/tools-server/resolver.py` is the only place that
knows what a logical path resolves *to*. Nothing here may duplicate
that mapping.

So an emitter never concatenates a service URL. It asks for a logical
path, hands it the base it was given, and posts whatever comes back.
When the base is unset (core has not rendered `LINK_BASE_URL` yet) the
answer is the empty string, and callers fall back to their unlinked
view — a link that cannot resolve is worse than no link.

Stdlib only: the host CLI imports `lib/stack/` without third-party deps,
and the bot-runner mounts this same tree at `/app/stack`, so host and
containers build links from one implementation.
"""

from __future__ import annotations

from urllib.parse import quote


# ── Logical paths ─────────────────────────────────────────────────────
#
# Kinds are explicit nouns — the path says whether "camping" is a topic
# or a person, so the resolver never has to guess. They come in two
# families, and which one a new kind belongs to is the whole design:
#
# entities, addressed by NAME — `topic`, `person`
# A name a person could type, for something that has an identity
# beyond any one file. A trailing leaf (`todo`) selects a
# sub-page instead of the overview.
#
# records, addressed by ID — `docs`, `capture`
# One artefact, keyed by something assigned once and never
# re-derived. Never by path: a path encodes where a thing sat on
# the day the link was written, and these things move.
#
# A link posted into chat is permanent, so the cost of putting a kind in
# the wrong family is paid forever. When in doubt, ask what changes when
# a family renames a topic or corrects a title.

def go_docs(doc_id: int | str) -> str:
"""`/docs/<id>` — a document, wherever it is filed right now.

>>> go_docs(247)
'/docs/247'
"""
return f"/docs/{doc_id}"


def go_topic(scope: str, leaf: str | None = None) -> str:
"""`/topic/<scope>` — a shared or personal topic page.

`scope` is either a bare slug (`camping`, resolved under the shared
bucket) or an explicit vault path (`family/camping`, `homer/gravel`).
Both forms are passed through verbatim; the resolver takes either.

>>> go_topic("family/camping", "todo")
'/topic/family/camping/todo'
>>> go_topic("camping")
'/topic/camping'
"""
return _entity_path("topic", scope, leaf)


def go_capture(capture_id: str) -> str:
"""`/capture/<id>` — one captured note, bookmark or memo.

A record, so it is addressed by id and never by where it sits. Its
vault path carries the bucket, the topic slug and the title slug,
and each of those changes on its own under ordinary use: a capture
re-scopes when a second person joins the room, a topic gets
renamed, a title is rewritten by a correction. A path-keyed link
would break for three reasons the resolver cannot repair, and break
silently, which is worse than the service URLs it replaced.

The id is the Matrix event id, assigned once and never rewritten.
It starts with `$` and can carry `/` in older room versions, so it
is percent-encoded into a single path segment.

>>> go_capture("$abc123")
'/capture/%24abc123'
"""
return f"/capture/{quote(str(capture_id), safe='')}"


def go_person(slug: str, leaf: str | None = None) -> str:
"""`/person/<slug>` — a household member's page.

>>> go_person("homer")
'/person/homer'
>>> go_person("homer", "todo")
'/person/homer/todo'
"""
return _entity_path("person", slug, leaf)


def _entity_path(kind: str, scope: str, leaf: str | None) -> str:
"""Join `kind`, the scope segments, and an optional leaf into a path.

Scopes arrive from vault paths and room bindings, which carry stray
slashes often enough that stripping them here is cheaper than at
every call site.
"""
segments = [s for s in str(scope).strip("/").split("/") if s]
if leaf:
segments.append(leaf.strip("/"))
return "/".join(["", kind, *segments])


# ── Public URL ────────────────────────────────────────────────────────

def public(logical: str, base: str) -> str:
"""Absolute, clickable form of a logical path.

`base` is `LINK_BASE_URL` — core's mode-correct home URL with the
`/go` prefix already on it. Empty base means the namespace is not
reachable yet, so there is no honest link to post.

>>> public(go_docs(247), "https://home.example.org/go")
'https://home.example.org/go/docs/247'
>>> public(go_docs(247), "")
''
"""
if not base:
return ""
return f"{base.rstrip('/')}{logical}"
6 changes: 6 additions & 0 deletions stacklets/core/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ services:
# effect without a rebuild. Keep in sync with tools-server/Dockerfile.
- ./tools-server/server.py:/app/server.py:ro
- ./tools-server/resolver.py:/app/resolver.py:ro
- ./tools-server/capture_index.py:/app/capture_index.py:ro
# The brain projection, read-only: `/go/capture/<id>` finds where a
# capture is filed now by reading the same tree the wiki serves.
# `${DATA_DIR}` is the host path (BRAIN_REPO_DIR is the *container*
# path the bot-runner sees, so it cannot be a bind source here).
- ${DATA_DIR}/memory/brain:/brain:ro
restart: unless-stopped

networks:
Expand Down
1 change: 1 addition & 0 deletions stacklets/core/tools-server/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ RUN pip install --no-cache-dir -r requirements.txt

COPY server.py .
COPY resolver.py .
COPY capture_index.py .

# --gecos "" suppresses the interactive Full Name/Room/Phone prompts
RUN adduser --disabled-password --gecos "" --uid 1000 tools
Expand Down
86 changes: 86 additions & 0 deletions stacklets/core/tools-server/capture_index.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"""Find where a capture lives right now, by the id it was captured with.

`/go/capture/<id>` promises a link that survives the file moving. Every
other logical path can be resolved by rewriting the string, but this one
cannot: the id names *which* capture, and says nothing about where it is
today. Something has to look.

WHY A SCAN AND NOT AN INDEX

The obvious design is an index the curator maintains during the mirror.
It would be faster and it would be one more thing that can be wrong: an
index is a second copy of the truth, so it can go stale, disagree with
the tree, or be missing on a fresh clone, and every one of those failures
looks like a dead link.

A scan reads the same files the wiki serves, so it cannot disagree with
them, and there is nothing to rebuild or invalidate. A family vault is
hundreds to low thousands of small markdown files and a redirect happens
when a human clicks something, so the cost lands in the right place. If
a vault ever grows past that, an index can be added *behind this same
function* without touching the resolver or the link format.

WHY FRONTMATTER AND NOT THE BODY

`capture_id` is a frontmatter field, and the search path already treats
frontmatter as the machine-readable half of a page. Reading only the head
of each file keeps a scan cheap and avoids matching an id someone quoted
in prose.
"""

from __future__ import annotations

from pathlib import Path

# Frontmatter sits at the top; no capture page has a hundred lines of it.
# Bounding the read keeps a scan over a large vault from paging in whole
# documents to answer a question the header already answers.
_HEAD_BYTES = 4096

_FIELD = "capture_id:"


def _capture_id_of(path: Path) -> str:
"""The `capture_id` declared in a file's frontmatter, or "".

Only the frontmatter block counts. An id quoted in the body of a
note — a reply discussing another capture, say — must not make this
file answer to it.
"""
try:
with path.open("r", encoding="utf-8", errors="ignore") as fh:
head = fh.read(_HEAD_BYTES)
except OSError:
return ""
lines = head.splitlines()
if not lines or lines[0].strip() != "---":
return ""
for line in lines[1:]:
if line.strip() == "---":
return ""
if line.startswith(_FIELD):
return line[len(_FIELD):].strip().strip("\"'")
return ""


def find_capture(capture_id: str, *, brain_dir: Path) -> str | None:
"""Wiki-relative target for a capture id, or None if nothing carries it.

The target is the path the wiki serves, so the `.md` suffix comes off
— same shape `resolve_topic_target` returns for a topic page.

Returns None rather than a guess when the id is unknown: a 404 tells
the person the capture is gone, while a redirect to something else
would quietly show them the wrong note.
"""
wanted = (capture_id or "").strip()
if not wanted:
return None
brain_dir = Path(brain_dir)
if not brain_dir.is_dir():
return None
for path in sorted(brain_dir.rglob("*.md")):
if _capture_id_of(path) == wanted:
rel = path.relative_to(brain_dir)
return str(rel.with_suffix(""))
return None
23 changes: 18 additions & 5 deletions stacklets/core/tools-server/resolver.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@

from __future__ import annotations

from typing import Callable

# A trailing `todo`/`todos` segment points at the entity's task list instead of
# its overview page. Both spellings accepted — people type either.
_TODO_LEAVES = {"todo", "todos"}
Expand Down Expand Up @@ -65,20 +67,31 @@ def resolve_person_target(segments: list[str]) -> str | None:
def build_redirect(
kind: str, rest: list[str], *,
docs_base: str, wiki_base: str, shared_bucket: str,
find_capture: "Callable[[str], str | None] | None" = None,
) -> str | None:
"""Full redirect URL for a `/<prefix>/<kind>/<rest>` request, or None.

`kind` is `docs`, `topic`, or `person`; `rest` is the remaining path
segments. `docs_base`/`wiki_base` are the public base URLs of Paperless and
the wiki — already mode-correct, computed once by the HTTP layer from env,
so this stays a pure string join. None (→ 404) for an unknown kind, a
`kind` is `docs`, `topic`, `person`, or `capture`; `rest` is the remaining
path segments. `docs_base`/`wiki_base` are the public base URLs of Paperless
and the wiki — already mode-correct, computed once by the HTTP layer from
env, so this stays a pure string join. None (→ 404) for an unknown kind, a
non-numeric doc id, or an entity shape the resolver rejects.

`capture` is the one kind whose target cannot be computed from the path:
the id says *which* capture, never where it is now. `find_capture` is
injected by the HTTP layer for exactly that lookup, which keeps the I/O out
of here and this module unit-testable without a vault on disk. Left unset,
`/capture/...` 404s rather than guessing.
"""
if kind == "docs":
if len(rest) != 1 or not rest[0].isdigit():
return None
return f"{docs_base.rstrip('/')}/documents/{rest[0]}/details"
if kind == "topic":
if kind == "capture":
if len(rest) != 1 or find_capture is None:
return None
target = find_capture(rest[0])
elif kind == "topic":
target = resolve_topic_target(rest, shared_bucket=shared_bucket)
elif kind == "person":
target = resolve_person_target(rest)
Expand Down
23 changes: 23 additions & 0 deletions stacklets/core/tools-server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@
import json
import os
import socket
from pathlib import Path

import httpx
from fastapi import FastAPI
from fastapi.responses import JSONResponse, RedirectResponse

from capture_index import find_capture
from resolver import build_redirect


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


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


@app.get(f"/{LINK_PREFIX}/capture/{{capture_id:path}}",
summary="Resolve a capture link")
async def go_capture(capture_id: str):
"""Redirect a captured note or bookmark to wherever it is filed now.

Keyed by the capture's id rather than its path, so re-scoping it,
renaming its topic, or correcting its title all leave the link
working. 404 when no file carries that id — better than showing
someone a different note.
"""
return _go("capture", [capture_id])


# ── Logs ───────────────────────────────────────────────────────────────────

@app.get("/logs", summary="Get container logs")
Expand Down
Loading
Loading