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