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__`, …). The amber (open) / green (resolved) highlight hues come from the `--ann-open-rgb` / `--ann-resolved-rgb` custom properties that `action.php` injects from config (with `:root` fallbacks here). | 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: `SCHEMA_VERSION = 1` and `MAX_QUOTE = 1000` are 80`helper.php` constants; the context-slice length and body cap are now config 81(`context_length`, `body_cap`, defaulting to 64 and 10000 via the 82`DEFAULT_CONTEXT` / `DEFAULT_BODY` fallbacks). IDs are 83`bin2hex(random_bytes(8))` — 16 hex chars. Writes go through `io_lock()` → 84modify → `io_saveFile()` → `io_unlock()` (the `mutate()` helper); a modifier 85returning `false` aborts the write (used for "target not found"). 86 87## Text-quote anchoring 88 89An anchor is `{exact, prefix, suffix, start}`: 90 91- `exact` — the selected text, whitespace-normalised (runs collapsed to one 92 space, trimmed). The same normalisation is applied on capture (JS), on 93 storage (PHP), and on matching, so client and server agree. 94- `prefix` / `suffix` — context on each side to disambiguate a quote that 95 appears more than once. Client captures ~30 chars; server caps at 64. 96- `start` — a character-offset hint into the page text, used only as a 97 tie-breaker. 98 99**Re-anchoring (client, `locate` + `buildRange`)**: collect the content text 100with a `TreeWalker`, normalise it once with `normalizeWithMap` — which returns 101the normalised string **and** a normalised→raw index map built in lockstep (they 102must share the same trimming, or every highlight shifts by a character) — search 103for the normalised `exact`, disambiguate repeats with `prefix`/`suffix`, 104tie-break with the `start` hint, then map the chosen offset back to a DOM `Range` 105and wrap it in a highlight `<span>`. All matches are located first and wrapped 106last-to-first, so wrapping (which splits text nodes) never disturbs a 107not-yet-wrapped offset. A quote that cannot be located is an orphan (no 108highlight, no gutter marker). 109 110## Orphan detection (two layers) 111 112- **Client (live UI).** Anything `findRange` cannot anchor on page load is 113 counted as orphaned; the count feeds the counter bar, and the orphaned link 114 opens a drawer at the bottom of the content area with those threads. 115- **Server (authoritative, `findOrphaned`).** For the admin "clear orphaned" 116 action the page is rendered with `p_wiki_xhtml`, block-closing tags are turned 117 into spaces, tags/entities are stripped, whitespace normalised, and each 118 annotation's `exact` is searched with `mb_strpos`. This re-check is the source 119 of truth for deletion, so a stale client can't cause data loss. 120 121## JSINFO injection (important gotcha) 122 123`script.js` needs per-page facts at boot without an extra round-trip, but you 124**cannot** add them by writing `$JSINFO` inside `TPL_METAHEADER_OUTPUT`: 125`tpl_metaheaders()` calls `jsinfo()` and serialises `$JSINFO` into the inline 126`var JSINFO = …;` script **before** firing that event. Instead `handleMetaHeader` 127finds that inline `<script>` in `$event->data['script']` and appends a 128`JSINFO.annotations = {…};` statement so it runs in the same scope. Injection is 129gated to `show` / `export_xhtml` views. 130 131Payload: `{ enabled, pageId, stats, user, isAdmin, token, annotations? }`. 132`user`, `isAdmin` and `token` are included because stock `JSINFO` exposes no 133user identity and no security token — the script reads them from 134`JSINFO.annotations`, not from `JSINFO.userinfo` (which does not exist) or the 135`#dw__token` field. UI strings are **not** in this payload: they travel through 136DokuWiki's per-plugin JS lang bundle, `LANG.plugins.annotations`, built from 137`$lang['js']`. 138 139The optional `annotations` key carries the page's **full annotation list**, so 140`script.js` renders on boot with no `load` round-trip (that AJAX call re-boots 141DokuWiki — ~300 ms — only to re-read this same file). `handleMetaHeader` reads 142the list once and derives `stats` from it via `helper::statsFor()` rather than 143re-reading through `getStats()`. The key is omitted when the feature is off for 144the user, or when the serialized list exceeds the `embed_max_bytes` config 145(default 128 KB; `DEFAULT_EMBED_MAX_BYTES` is the fallback) — in 146which case `script.js` falls back to the `load` endpoint. Because the inline 147`JSINFO` script is regenerated every request (it is not in the parser page 148cache), the embedded list is always current. 149 150## Per-user toggle 151 152Registered with the **usersettings** plugin via `PLUGIN_USERSETTINGS_REGISTER` 153(key `annotations_enabled`, checkbox, default on). `isEnabledForUser()` reads the 154preference through the usersettings helper; if that plugin is absent, or the 155toggle has not been registered yet, the feature defaults to **on**. When a user 156turns it off, `boot()` returns early and nothing is rendered (annotations are 157still stored). 158 159## Permission model 160 161The rules live in `helper.php` and are pure; `action.php` gathers the facts and 162calls them. `isAdmin` is DokuWiki's `auth_isadmin()` (superuser / admin group). 163 164| Action | Rule (helper method) | 165|--------|----------------------| 166| Create annotation / reply / resolve / reopen | logged in **and** `AUTH_READ` on the page — *not* `AUTH_EDIT` (`canAnnotate`) | 167| Edit / delete own annotation | author (`canEditAnnotation`) | 168| Edit / delete own reply | author (`canEditReply`) | 169| Edit / delete **any** annotation or reply | admin (`canEditAnnotation` / `canEditReply`) | 170| Clear resolved / clear orphaned (per page) | admin (`canClear`) | 171| Load (read) annotations | `AUTH_READ` on the page | 172 173## Security 174 175- **CSRF.** Every state-changing action requires a valid DokuWiki security 176 token. The token is injected into `JSINFO.annotations.token` and sent back as 177 `sectok` in the JSON body. `handleAjax` reads it from the parsed body and 178 passes it straight to `checkSecurityToken($token)`. The read-only `load` 179 action is exempt (GET, no token) but still ACL-checked. 180- **ACL.** `auth_quickaclcheck($id)` gates both reading and writing. 181- **Output.** Bodies are stored as plain text (newlines kept, length-capped) and 182 rendered client-side via `textContent`, so user content is never interpolated 183 as HTML. 184 185## AJAX endpoint 186 187`…/lib/exe/ajax.php?call=annotations` (handled on `AJAX_CALL_UNKNOWN`). The 188`load` action is a GET with query params; everything else is `POST` with an 189`application/json` body. Every response is `{ "success": true, … }` or 190`{ "success": false, "error": "…" }`. `load` is now only a **fallback** for the 191inline-embedded list (see JSINFO injection above); the mutating actions are the 192hot path. 193 194| Action | Method | Token | Extra fields | 195|--------|--------|-------|--------------| 196| `load` | GET | — | — | 197| `create` | POST | ✓ | `anchor`, `body` | 198| `reply` | POST | ✓ | `annId`, `body` | 199| `edit_annotation` | POST | ✓ | `annId`, `body` | 200| `edit_reply` | POST | ✓ | `annId`, `replyId`, `body` | 201| `delete_annotation` | POST | ✓ | `annId` | 202| `delete_reply` | POST | ✓ | `annId`, `replyId` | 203| `resolve` | POST | ✓ | `annId`, `status` (`open`\|`resolved`) | 204| `clear_resolved` | POST | ✓ | — | 205| `clear_orphaned` | POST | ✓ | — | 206 207All actions also take the page `id`. 208 209## Constraints 210 211- **JS/CSS floor: Firefox 78 ESR.** No `#private` fields, `??=`/`||=`/`&&=`, 212 `Array.at`, `structuredClone`, `Object.hasOwn`, native `<dialog>`; no CSS 213 `:has()`, selector `:not()`, `aspect-ratio`, container queries, or nesting. 214 `async`/`await`, `fetch`, classes, `?.`, `??`, `Map`/`Set` are fine. 215- **PHP:** developed against 8.3; requires the `mbstring` extension. 216 217## Resolved (kept here for history) 218 219- **UI localisation — done.** Front-end strings live under `$lang['js']` and are 220 read in `script.js` via `LANG.plugins.annotations`, each with an English 221 fallback (the `t()` / `fmt()` helpers). `toggle_label` / `toggle_desc` stay 222 PHP-side (`getLang`). 223- **Translations — done.** `en`, `de`, `ru`, `ja` ship, all carrying the same 224 `$lang['js']` keys. 225- **Tests — done.** `_test/` has `GeneralTest` (manifest + the 226 `default.php`↔`metadata.php` invariant) and `HelperTest` (permission rules, 227 CRUD, input cleaning, `findOrphaned` against a rendered page). Run: 228 `composer run test -- --group plugin_annotations`. 229- **Cleanup — done.** The unused `ann-highlight-orphaned` constant is gone, and 230 the panel sets `data-status` so the resolved accent in `style.css` applies. 231- **Config — done.** `conf/default.php` + `conf/metadata.php` expose 232 `color_open`, `color_resolved`, `embed_max_bytes`, `context_length` and 233 `body_cap` (labels in `lang/<iso>/settings.php`). The two colours are injected 234 as CSS custom properties (`--ann-open-rgb` / `--ann-resolved-rgb`) by 235 `action.php::injectColourVars()`; `style.css` derives every opacity variant 236 from them and ships `:root` fallbacks. `GeneralTest::testPluginConf` enforces 237 the `default.php`↔`metadata.php` invariant. 238