xref: /plugin/annotations/DESIGN.md (revision 8d8701f5afabecdb73a7226eaf85b736f630ddd2)
1# Annotations Plugin — Design & Architecture
2
3A developer reference for the annotations plugin. For installation and end-user
4behaviour see [README.md](README.md); for the wider review/environment
5conventions see `CLAUDE.md` in the plugins root.
6
7## Concept
8
9Word- and sentence-level annotations on wiki pages, in the spirit of
10Hypothes.is and `ep_comments_page`:
11
12- **Out-of-band.** Annotations live in a separate per-page JSON file, never in
13  the page text or the wiki changelog. Creating one needs only `AUTH_READ`, so
14  a group whose page *edit* access is blocked can still annotate.
15- **Text-quote anchored.** Each annotation is tied to the quoted text plus a
16  little surrounding context, not to a character position, so it survives minor
17  edits and is re-found in the rendered DOM on each page load.
18- **Threaded.** Annotations carry replies; both have open/resolved status at the
19  annotation level.
20- **Orphan-aware.** When the quoted text disappears from the page the annotation
21  becomes an *orphan* — still stored, surfaced through a counter, and bulk-
22  removable by an admin.
23
24## Components
25
26| File | Owns |
27|------|------|
28| `plugin.info.txt` | Manifest: name, author, version date, description, repository URL. |
29| `helper.php` | The per-page store, all CRUD, server-side orphan detection, and the **permission rules as the single source of truth**. Pure logic — permission methods take facts (user, admin flag, ACL level) as parameters and read no globals. |
30| `action.php` | Event registration; injecting the page payload into `JSINFO`; the AJAX endpoint and **permission enforcement** (gathers facts from DokuWiki globals, calls the helper). |
31| `script.js` | All front-end behaviour: boot/gate, load + re-anchor, highlights, gutter markers, counter, selection→new-annotation flow, thread panels, and AJAX. Plain IIFE, vanilla JS. |
32| `style.css` | Styling via DokuWiki theme tokens (`__background__`, `__text__`, …). Only the amber (open) / green (resolved) highlight colours are hard-coded. |
33| `lang/en/lang.php` | The usersettings toggle label/description (used) plus a set of UI strings that are **not yet wired into the JS** — see *Known gaps*. |
34
35Documentation lives in [`README.md`](README.md) (end users) and this file
36(developers); the licence is in `LICENSE` (GPL 2).
37
38## Data model & storage
39
40One pretty-printed JSON file per page at `metaFN($id, '.annotations')`
41(`data/meta/<namespace>/<page>.annotations`):
42
43```json
44{
45  "version": 1,
46  "annotations": [
47    {
48      "id": "a1b2c3d4e5f6g7h8",
49      "anchor": { "exact": "...", "prefix": "...", "suffix": "...", "start": 123 },
50      "author": "alice",
51      "created": 1716336000,
52      "modified": 1716336000,
53      "body": "Does this cover remuxes?",
54      "status": "open",
55      "resolved_by": "",
56      "resolved_at": 0,
57      "replies": [
58        {
59          "id": "x1y2z3a4b5c6d7e8",
60          "author": "bob",
61          "created": 1716336100,
62          "modified": 1716336100,
63          "body": "Yes, remuxes count."
64        }
65      ]
66    }
67  ]
68}
69```
70
71Limits and identifiers (`helper.php` constants): `SCHEMA_VERSION = 1`,
72`MAX_QUOTE = 1000`, `MAX_CONTEXT = 64`, `MAX_BODY = 10000`. IDs are
73`bin2hex(random_bytes(8))` — 16 hex chars. Writes go through `io_lock()` →
74modify → `io_saveFile()` → `io_unlock()` (the `mutate()` helper); a modifier
75returning `false` aborts the write (used for "target not found").
76
77## Text-quote anchoring
78
79An anchor is `{exact, prefix, suffix, start}`:
80
81- `exact` — the selected text, whitespace-normalised (runs collapsed to one
82  space, trimmed). The same normalisation is applied on capture (JS), on
83  storage (PHP), and on matching, so client and server agree.
84- `prefix` / `suffix` — context on each side to disambiguate a quote that
85  appears more than once. Client captures ~30 chars; server caps at 64.
86- `start` — a character-offset hint into the page text, used only as a
87  tie-breaker.
88
89**Re-anchoring (client, `findRange`)**: collect the content text with a
90`TreeWalker`, search for the normalised `exact`, disambiguate repeats with
91`prefix`/`suffix`, tie-break with the `start` hint, then map the chosen
92character offset back to a DOM `Range` and wrap it in a highlight `<span>`. A
93quote that cannot be located is an orphan (no highlight, no gutter marker).
94
95## Orphan detection (two layers)
96
97- **Client (live UI).** Anything `findRange` cannot anchor on page load is
98  counted as orphaned; the count feeds the counter bar, and the orphaned link
99  opens a drawer at the bottom of the content area with those threads.
100- **Server (authoritative, `findOrphaned`).** For the admin "clear orphaned"
101  action the page is rendered with `p_wiki_xhtml`, block-closing tags are turned
102  into spaces, tags/entities are stripped, whitespace normalised, and each
103  annotation's `exact` is searched with `mb_strpos`. This re-check is the source
104  of truth for deletion, so a stale client can't cause data loss.
105
106## JSINFO injection (important gotcha)
107
108`script.js` needs per-page facts at boot without an extra round-trip, but you
109**cannot** add them by writing `$JSINFO` inside `TPL_METAHEADER_OUTPUT`:
110`tpl_metaheaders()` calls `jsinfo()` and serialises `$JSINFO` into the inline
111`var JSINFO = …;` script **before** firing that event. Instead `handleMetaHeader`
112finds that inline `<script>` in `$event->data['script']` and appends a
113`JSINFO.annotations = {…};` statement so it runs in the same scope. Injection is
114gated to `show` / `export_xhtml` views.
115
116Payload: `{ enabled, pageId, stats, user, isAdmin, token }`. `user`, `isAdmin`
117and `token` are included because stock `JSINFO` exposes no user identity and no
118security token — the script reads them from `JSINFO.annotations`, not from
119`JSINFO.userinfo` (which does not exist) or the `#dw__token` field.
120
121## Per-user toggle
122
123Registered with the **usersettings** plugin via `PLUGIN_USERSETTINGS_REGISTER`
124(key `annotations_enabled`, checkbox, default on). `isEnabledForUser()` reads the
125preference through the usersettings helper; if that plugin is absent, or the
126toggle has not been registered yet, the feature defaults to **on**. When a user
127turns it off, `boot()` returns early and nothing is rendered (annotations are
128still stored).
129
130## Permission model
131
132The rules live in `helper.php` and are pure; `action.php` gathers the facts and
133calls them. `isAdmin` is true for the `admin` group or DokuWiki's `$INFO['isadmin']`.
134
135| Action | Rule (helper method) |
136|--------|----------------------|
137| Create annotation / reply / resolve / reopen | logged in **and** `AUTH_READ` on the page — *not* `AUTH_EDIT` (`canAnnotate`) |
138| Edit / delete own annotation | author (`canEditAnnotation`) |
139| Edit / delete own reply | author (`canEditReply`) |
140| Edit / delete **any** annotation or reply | admin (`canEditAnnotation` / `canEditReply`) |
141| Clear resolved / clear orphaned (per page) | admin (`canClear`) |
142| Load (read) annotations | `AUTH_READ` on the page |
143
144## Security
145
146- **CSRF.** Every state-changing action requires a valid DokuWiki security
147  token. The token is injected into `JSINFO.annotations.token` and sent back as
148  `sectok` in the JSON body. Because `checkSecurityToken()` reads `$_REQUEST`
149  (empty for a JSON body), `handleAjax` copies `sectok` into `$_POST`/`$_REQUEST`
150  before validating. The read-only `load` action is exempt (GET, no token) but
151  still ACL-checked.
152- **ACL.** `auth_quickaclcheck($id)` gates both reading and writing.
153- **Output.** Bodies are stored as plain text (newlines kept, length-capped) and
154  rendered client-side via `textContent`, so user content is never interpolated
155  as HTML.
156
157## AJAX endpoint
158
159`…/lib/exe/ajax.php?call=annotations` (handled on `AJAX_CALL_UNKNOWN`). The
160`load` action is a GET with query params; everything else is `POST` with an
161`application/json` body. Every response is `{ "success": true, … }` or
162`{ "success": false, "error": "…" }`.
163
164| Action | Method | Token | Extra fields |
165|--------|--------|-------|--------------|
166| `load` | GET | — | — |
167| `create` | POST | ✓ | `anchor`, `body` |
168| `reply` | POST | ✓ | `annId`, `body` |
169| `edit_annotation` | POST | ✓ | `annId`, `body` |
170| `edit_reply` | POST | ✓ | `annId`, `replyId`, `body` |
171| `delete_annotation` | POST | ✓ | `annId` |
172| `delete_reply` | POST | ✓ | `annId`, `replyId` |
173| `resolve` | POST | ✓ | `annId`, `status` (`open`\|`resolved`) |
174| `clear_resolved` | POST | ✓ | — |
175| `clear_orphaned` | POST | ✓ | — |
176
177All actions also take the page `id`.
178
179## Constraints
180
181- **JS/CSS floor: Firefox 78 ESR.** No `#private` fields, `??=`/`||=`/`&&=`,
182  `Array.at`, `structuredClone`, `Object.hasOwn`, native `<dialog>`; no CSS
183  `:has()`, selector `:not()`, `aspect-ratio`, container queries, or nesting.
184  `async`/`await`, `fetch`, classes, `?.`, `??`, `Map`/`Set` are fine.
185- **PHP:** developed against 8.3; requires the `mbstring` extension.
186
187## Known gaps / next steps
188
189- **UI localisation.** `script.js` renders hardcoded English; `annInfo.lang` is
190  never populated, so the `counter_*`, `btn_*`, `status_*`, `placeholder_*`,
191  `tooltip_*`, `orphaned_*`, `error_*` and `confirm_*` strings in
192  `lang/en/lang.php` are currently dead. To localise: inject `lang` into the
193  `JSINFO.annotations` payload in `handleMetaHeader` and read `_lang` in the JS
194  string sites. Only `toggle_label` / `toggle_desc` are wired (via `getLang`).
195- **Translations.** No `de` / `ru` / `ja` yet (depends on the localisation work
196  above).
197- **Tests.** No `_test/` suite. Candidates: helper CRUD, input cleaning,
198  permission rules, and `findOrphaned` against a rendered page.
199- **Config.** No `conf/` — nothing is configurable (highlight colours, context
200  length, body cap are all constants/CSS).
201- **Cleanup.** The `ann-highlight-orphaned` JS constant has no CSS rule and no
202  call site (orphans have no in-page range to highlight).
203