|
| 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}" |
0 commit comments