xref: /plugin/annotations/DESIGN.md (revision ee9dbf1506bc8a2e17701b4e3c1bc1caf77e1561)
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, and a reply may itself reply to
19  another reply (each records a `parentId`), so a discussion nests into a tree.
20  Open/resolved status lives at the annotation level.
21- **Orphan-aware.** When the quoted text disappears from the page the annotation
22  becomes an *orphan* — still stored, surfaced through a counter, and bulk-
23  removable by an admin.
24
25## Components
26
27| File | Owns |
28|------|------|
29| `plugin.info.txt` | Manifest: name, author, version date, description, repository URL. |
30| `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. |
31| `action.php` | Event registration; injecting the page payload into `JSINFO`; the AJAX endpoint and **permission enforcement** (gathers facts from DokuWiki globals, calls the helper). |
32| `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. |
33| `style.css` | Styling via DokuWiki theme tokens (`__background__`, `__text__`, …). Only the amber (open) / green (resolved) highlight colours are hard-coded. |
34| `lang/<iso>/lang.php` | The usersettings toggle label/description (PHP side) plus the front-end UI strings under `$lang['js']`, exposed to `script.js` as `LANG.plugins.annotations`. Ships `en`, `de`, `ru`, `ja`. |
35
36Documentation lives in [`README.md`](README.md) (end users) and this file
37(developers); the licence is in `LICENSE` (GPL 2).
38
39## Data model & storage
40
41One pretty-printed JSON file per page at `metaFN($id, '.annotations')`
42(`data/meta/<namespace>/<page>.annotations`):
43
44```json
45{
46  "version": 1,
47  "annotations": [
48    {
49      "id": "a1b2c3d4e5f6g7h8",
50      "anchor": { "exact": "...", "prefix": "...", "suffix": "...", "start": 123 },
51      "author": "alice",
52      "created": 1716336000,
53      "modified": 1716336000,
54      "body": "Does this cover remuxes?",
55      "status": "open",
56      "resolved_by": "",
57      "resolved_at": 0,
58      "replies": [
59        {
60          "id": "x1y2z3a4b5c6d7e8",
61          "parentId": "",
62          "author": "bob",
63          "created": 1716336100,
64          "modified": 1716336100,
65          "body": "Yes, remuxes count."
66        }
67      ]
68    }
69  ]
70}
71```
72
73Replies are stored as a **flat** list; `parentId` (empty for a top-level reply,
74otherwise the id of the reply being answered) lets the client rebuild the nested
75thread (`buildReplyTree`). The `reply`, `edit_reply` and `delete_reply` actions
76return the **full updated annotation**, so the panel re-renders the whole thread
77in a single round-trip.
78
79Limits and identifiers (`helper.php` constants): `SCHEMA_VERSION = 1`,
80`MAX_QUOTE = 1000`, `MAX_CONTEXT = 64`, `MAX_BODY = 10000`. IDs are
81`bin2hex(random_bytes(8))` — 16 hex chars. Writes go through `io_lock()` →
82modify → `io_saveFile()` → `io_unlock()` (the `mutate()` helper); a modifier
83returning `false` aborts the write (used for "target not found").
84
85## Text-quote anchoring
86
87An anchor is `{exact, prefix, suffix, start}`:
88
89- `exact` — the selected text, whitespace-normalised (runs collapsed to one
90  space, trimmed). The same normalisation is applied on capture (JS), on
91  storage (PHP), and on matching, so client and server agree.
92- `prefix` / `suffix` — context on each side to disambiguate a quote that
93  appears more than once. Client captures ~30 chars; server caps at 64.
94- `start` — a character-offset hint into the page text, used only as a
95  tie-breaker.
96
97**Re-anchoring (client, `locate` + `buildRange`)**: collect the content text
98with a `TreeWalker`, normalise it once with `normalizeWithMap` — which returns
99the normalised string **and** a normalised→raw index map built in lockstep (they
100must share the same trimming, or every highlight shifts by a character) — search
101for the normalised `exact`, disambiguate repeats with `prefix`/`suffix`,
102tie-break with the `start` hint, then map the chosen offset back to a DOM `Range`
103and wrap it in a highlight `<span>`. All matches are located first and wrapped
104last-to-first, so wrapping (which splits text nodes) never disturbs a
105not-yet-wrapped offset. A quote that cannot be located is an orphan (no
106highlight, no gutter marker).
107
108## Orphan detection (two layers)
109
110- **Client (live UI).** Anything `findRange` cannot anchor on page load is
111  counted as orphaned; the count feeds the counter bar, and the orphaned link
112  opens a drawer at the bottom of the content area with those threads.
113- **Server (authoritative, `findOrphaned`).** For the admin "clear orphaned"
114  action the page is rendered with `p_wiki_xhtml`, block-closing tags are turned
115  into spaces, tags/entities are stripped, whitespace normalised, and each
116  annotation's `exact` is searched with `mb_strpos`. This re-check is the source
117  of truth for deletion, so a stale client can't cause data loss.
118
119## JSINFO injection (important gotcha)
120
121`script.js` needs per-page facts at boot without an extra round-trip, but you
122**cannot** add them by writing `$JSINFO` inside `TPL_METAHEADER_OUTPUT`:
123`tpl_metaheaders()` calls `jsinfo()` and serialises `$JSINFO` into the inline
124`var JSINFO = …;` script **before** firing that event. Instead `handleMetaHeader`
125finds that inline `<script>` in `$event->data['script']` and appends a
126`JSINFO.annotations = {…};` statement so it runs in the same scope. Injection is
127gated to `show` / `export_xhtml` views.
128
129Payload: `{ enabled, pageId, stats, user, isAdmin, token }`. `user`, `isAdmin`
130and `token` are included because stock `JSINFO` exposes no user identity and no
131security token — the script reads them from `JSINFO.annotations`, not from
132`JSINFO.userinfo` (which does not exist) or the `#dw__token` field. UI strings
133are **not** in this payload: they travel through DokuWiki's per-plugin JS lang
134bundle, `LANG.plugins.annotations`, built from `$lang['js']`.
135
136## Per-user toggle
137
138Registered with the **usersettings** plugin via `PLUGIN_USERSETTINGS_REGISTER`
139(key `annotations_enabled`, checkbox, default on). `isEnabledForUser()` reads the
140preference through the usersettings helper; if that plugin is absent, or the
141toggle has not been registered yet, the feature defaults to **on**. When a user
142turns it off, `boot()` returns early and nothing is rendered (annotations are
143still stored).
144
145## Permission model
146
147The rules live in `helper.php` and are pure; `action.php` gathers the facts and
148calls them. `isAdmin` is DokuWiki's `auth_isadmin()` (superuser / admin group).
149
150| Action | Rule (helper method) |
151|--------|----------------------|
152| Create annotation / reply / resolve / reopen | logged in **and** `AUTH_READ` on the page — *not* `AUTH_EDIT` (`canAnnotate`) |
153| Edit / delete own annotation | author (`canEditAnnotation`) |
154| Edit / delete own reply | author (`canEditReply`) |
155| Edit / delete **any** annotation or reply | admin (`canEditAnnotation` / `canEditReply`) |
156| Clear resolved / clear orphaned (per page) | admin (`canClear`) |
157| Load (read) annotations | `AUTH_READ` on the page |
158
159## Security
160
161- **CSRF.** Every state-changing action requires a valid DokuWiki security
162  token. The token is injected into `JSINFO.annotations.token` and sent back as
163  `sectok` in the JSON body. `handleAjax` reads it from the parsed body and
164  passes it straight to `checkSecurityToken($token)`. The read-only `load`
165  action is exempt (GET, no token) but still ACL-checked.
166- **ACL.** `auth_quickaclcheck($id)` gates both reading and writing.
167- **Output.** Bodies are stored as plain text (newlines kept, length-capped) and
168  rendered client-side via `textContent`, so user content is never interpolated
169  as HTML.
170
171## AJAX endpoint
172
173`…/lib/exe/ajax.php?call=annotations` (handled on `AJAX_CALL_UNKNOWN`). The
174`load` action is a GET with query params; everything else is `POST` with an
175`application/json` body. Every response is `{ "success": true, … }` or
176`{ "success": false, "error": "…" }`.
177
178| Action | Method | Token | Extra fields |
179|--------|--------|-------|--------------|
180| `load` | GET | — | — |
181| `create` | POST | ✓ | `anchor`, `body` |
182| `reply` | POST | ✓ | `annId`, `body` |
183| `edit_annotation` | POST | ✓ | `annId`, `body` |
184| `edit_reply` | POST | ✓ | `annId`, `replyId`, `body` |
185| `delete_annotation` | POST | ✓ | `annId` |
186| `delete_reply` | POST | ✓ | `annId`, `replyId` |
187| `resolve` | POST | ✓ | `annId`, `status` (`open`\|`resolved`) |
188| `clear_resolved` | POST | ✓ | — |
189| `clear_orphaned` | POST | ✓ | — |
190
191All actions also take the page `id`.
192
193## Constraints
194
195- **JS/CSS floor: Firefox 78 ESR.** No `#private` fields, `??=`/`||=`/`&&=`,
196  `Array.at`, `structuredClone`, `Object.hasOwn`, native `<dialog>`; no CSS
197  `:has()`, selector `:not()`, `aspect-ratio`, container queries, or nesting.
198  `async`/`await`, `fetch`, classes, `?.`, `??`, `Map`/`Set` are fine.
199- **PHP:** developed against 8.3; requires the `mbstring` extension.
200
201## Resolved (kept here for history)
202
203- **UI localisation — done.** Front-end strings live under `$lang['js']` and are
204  read in `script.js` via `LANG.plugins.annotations`, each with an English
205  fallback (the `t()` / `fmt()` helpers). `toggle_label` / `toggle_desc` stay
206  PHP-side (`getLang`).
207- **Translations — done.** `en`, `de`, `ru`, `ja` ship, all carrying the same
208  `$lang['js']` keys.
209- **Tests — done.** `_test/` has `GeneralTest` (manifest + the
210  `default.php`↔`metadata.php` invariant) and `HelperTest` (permission rules,
211  CRUD, input cleaning, `findOrphaned` against a rendered page). Run:
212  `composer run test -- --group plugin_annotations`.
213- **Cleanup — done.** The unused `ann-highlight-orphaned` constant is gone, and
214  the panel sets `data-status` so the resolved accent in `style.css` applies.
215
216## Known gaps / next steps
217
218- **Config.** Still no `conf/` — highlight colours, context length and body cap
219  are constants/CSS. `GeneralTest::testPluginConf` already guards the
220  `default.php`↔`metadata.php` invariant should config be added.
221- **JS cachebuster.** The front-end bundle is keyed by config-file mtimes, not
222  plugin-file mtimes, so after editing `script.js` / `lang` you must bump a main
223  config file (saving any config option does this) for browsers to pull the new
224  bundle.
225