15fa3d185Stracker-user# Annotations Plugin — Design & Architecture 25fa3d185Stracker-user 35fa3d185Stracker-userA developer reference for the annotations plugin. For installation and end-user 45fa3d185Stracker-userbehaviour see [README.md](README.md); for the wider review/environment 55fa3d185Stracker-userconventions see `CLAUDE.md` in the plugins root. 65fa3d185Stracker-user 75fa3d185Stracker-user## Concept 85fa3d185Stracker-user 95fa3d185Stracker-userWord- and sentence-level annotations on wiki pages, in the spirit of 105fa3d185Stracker-userHypothes.is and `ep_comments_page`: 115fa3d185Stracker-user 125fa3d185Stracker-user- **Out-of-band.** Annotations live in a separate per-page JSON file, never in 135fa3d185Stracker-user the page text or the wiki changelog. Creating one needs only `AUTH_READ`, so 145fa3d185Stracker-user a group whose page *edit* access is blocked can still annotate. 155fa3d185Stracker-user- **Text-quote anchored.** Each annotation is tied to the quoted text plus a 165fa3d185Stracker-user little surrounding context, not to a character position, so it survives minor 175fa3d185Stracker-user edits and is re-found in the rendered DOM on each page load. 18ee9dbf15Stracker-user- **Threaded.** Annotations carry replies, and a reply may itself reply to 19ee9dbf15Stracker-user another reply (each records a `parentId`), so a discussion nests into a tree. 20ee9dbf15Stracker-user Open/resolved status lives at the annotation level. 215fa3d185Stracker-user- **Orphan-aware.** When the quoted text disappears from the page the annotation 225fa3d185Stracker-user becomes an *orphan* — still stored, surfaced through a counter, and bulk- 235fa3d185Stracker-user removable by an admin. 245fa3d185Stracker-user 255fa3d185Stracker-user## Components 265fa3d185Stracker-user 275fa3d185Stracker-user| File | Owns | 285fa3d185Stracker-user|------|------| 298d8701f5Stracker-user| `plugin.info.txt` | Manifest: name, author, version date, description, repository URL. | 305fa3d185Stracker-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. | 315fa3d185Stracker-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). | 32*72d60f2dStracker-user| `admin.php` | Admin-only wiki-wide overview (**Admin → Annotations**): lists annotated pages with normal/resolved/orphaned counts; per-page and wiki-wide "clear resolved" and "clear orphaned". Reuses the `lastseen`/`usersettings` sortable + filterable + paginated table machinery. | 335fa3d185Stracker-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. | 3486c7806dStracker-user| `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). | 35da56206cStracker-user| `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`. | 365fa3d185Stracker-user 378d8701f5Stracker-userDocumentation lives in [`README.md`](README.md) (end users) and this file 388d8701f5Stracker-user(developers); the licence is in `LICENSE` (GPL 2). 398d8701f5Stracker-user 405fa3d185Stracker-user## Data model & storage 415fa3d185Stracker-user 425fa3d185Stracker-userOne pretty-printed JSON file per page at `metaFN($id, '.annotations')` 435fa3d185Stracker-user(`data/meta/<namespace>/<page>.annotations`): 445fa3d185Stracker-user 455fa3d185Stracker-user```json 465fa3d185Stracker-user{ 475fa3d185Stracker-user "version": 1, 485fa3d185Stracker-user "annotations": [ 495fa3d185Stracker-user { 505fa3d185Stracker-user "id": "a1b2c3d4e5f6g7h8", 515fa3d185Stracker-user "anchor": { "exact": "...", "prefix": "...", "suffix": "...", "start": 123 }, 525fa3d185Stracker-user "author": "alice", 535fa3d185Stracker-user "created": 1716336000, 545fa3d185Stracker-user "modified": 1716336000, 555fa3d185Stracker-user "body": "Does this cover remuxes?", 565fa3d185Stracker-user "status": "open", 575fa3d185Stracker-user "resolved_by": "", 585fa3d185Stracker-user "resolved_at": 0, 595fa3d185Stracker-user "replies": [ 605fa3d185Stracker-user { 615fa3d185Stracker-user "id": "x1y2z3a4b5c6d7e8", 62ee9dbf15Stracker-user "parentId": "", 635fa3d185Stracker-user "author": "bob", 645fa3d185Stracker-user "created": 1716336100, 655fa3d185Stracker-user "modified": 1716336100, 665fa3d185Stracker-user "body": "Yes, remuxes count." 675fa3d185Stracker-user } 685fa3d185Stracker-user ] 695fa3d185Stracker-user } 705fa3d185Stracker-user ] 715fa3d185Stracker-user} 725fa3d185Stracker-user``` 735fa3d185Stracker-user 74ee9dbf15Stracker-userReplies are stored as a **flat** list; `parentId` (empty for a top-level reply, 75ee9dbf15Stracker-userotherwise the id of the reply being answered) lets the client rebuild the nested 76ee9dbf15Stracker-userthread (`buildReplyTree`). The `reply`, `edit_reply` and `delete_reply` actions 77ee9dbf15Stracker-userreturn the **full updated annotation**, so the panel re-renders the whole thread 78ee9dbf15Stracker-userin a single round-trip. 79ee9dbf15Stracker-user 8086c7806dStracker-userLimits and identifiers: `SCHEMA_VERSION = 1` and `MAX_QUOTE = 1000` are 8186c7806dStracker-user`helper.php` constants; the context-slice length and body cap are now config 8286c7806dStracker-user(`context_length`, `body_cap`, defaulting to 64 and 10000 via the 8386c7806dStracker-user`DEFAULT_CONTEXT` / `DEFAULT_BODY` fallbacks). IDs are 845fa3d185Stracker-user`bin2hex(random_bytes(8))` — 16 hex chars. Writes go through `io_lock()` → 855fa3d185Stracker-usermodify → `io_saveFile()` → `io_unlock()` (the `mutate()` helper); a modifier 865fa3d185Stracker-userreturning `false` aborts the write (used for "target not found"). 875fa3d185Stracker-user 885fa3d185Stracker-user## Text-quote anchoring 895fa3d185Stracker-user 905fa3d185Stracker-userAn anchor is `{exact, prefix, suffix, start}`: 915fa3d185Stracker-user 925fa3d185Stracker-user- `exact` — the selected text, whitespace-normalised (runs collapsed to one 935fa3d185Stracker-user space, trimmed). The same normalisation is applied on capture (JS), on 945fa3d185Stracker-user storage (PHP), and on matching, so client and server agree. 955fa3d185Stracker-user- `prefix` / `suffix` — context on each side to disambiguate a quote that 965fa3d185Stracker-user appears more than once. Client captures ~30 chars; server caps at 64. 975fa3d185Stracker-user- `start` — a character-offset hint into the page text, used only as a 985fa3d185Stracker-user tie-breaker. 995fa3d185Stracker-user 100da56206cStracker-user**Re-anchoring (client, `locate` + `buildRange`)**: collect the content text 101da56206cStracker-userwith a `TreeWalker`, normalise it once with `normalizeWithMap` — which returns 102da56206cStracker-userthe normalised string **and** a normalised→raw index map built in lockstep (they 103da56206cStracker-usermust share the same trimming, or every highlight shifts by a character) — search 104da56206cStracker-userfor the normalised `exact`, disambiguate repeats with `prefix`/`suffix`, 105da56206cStracker-usertie-break with the `start` hint, then map the chosen offset back to a DOM `Range` 106da56206cStracker-userand wrap it in a highlight `<span>`. All matches are located first and wrapped 107da56206cStracker-userlast-to-first, so wrapping (which splits text nodes) never disturbs a 108da56206cStracker-usernot-yet-wrapped offset. A quote that cannot be located is an orphan (no 109da56206cStracker-userhighlight, no gutter marker). 1105fa3d185Stracker-user 1115fa3d185Stracker-user## Orphan detection (two layers) 1125fa3d185Stracker-user 1135fa3d185Stracker-user- **Client (live UI).** Anything `findRange` cannot anchor on page load is 1145fa3d185Stracker-user counted as orphaned; the count feeds the counter bar, and the orphaned link 115*72d60f2dStracker-user opens a drawer at the bottom of the content area with those threads. Orphan 116*72d60f2dStracker-user threads render **read-only**: `buildThreadEntry` gates the Resolve/Reopen and 117*72d60f2dStracker-user Edit buttons on `!ann._orphaned`, leaving only Delete (for the author or an 118*72d60f2dStracker-user admin), since the quoted text is gone and there is nothing left to anchor. 1195fa3d185Stracker-user- **Server (authoritative, `findOrphaned`).** For the admin "clear orphaned" 1205fa3d185Stracker-user action the page is rendered with `p_wiki_xhtml`, block-closing tags are turned 1215fa3d185Stracker-user into spaces, tags/entities are stripped, whitespace normalised, and each 1225fa3d185Stracker-user annotation's `exact` is searched with `mb_strpos`. This re-check is the source 1235fa3d185Stracker-user of truth for deletion, so a stale client can't cause data loss. 1245fa3d185Stracker-user 1259fd890c3Stracker-user## Admin overview (`admin.php`) 1269fd890c3Stracker-user 1279fd890c3Stracker-user`admin_plugin_annotations` (**Admin → Annotations**, admin-only) is the wiki-wide companion to 128*72d60f2dStracker-userthe per-page counter-bar buttons: it lists every annotated page with its **Normal** (present), 129*72d60f2dStracker-user**Resolved** and **Orphaned** counts and offers a per-page and a wiki-wide clear for each of 130*72d60f2dStracker-user"resolved" and "orphaned". 1319fd890c3Stracker-user 1329fd890c3Stracker-user- **Enumeration.** `helper::getAnnotatedPages()` runs DokuWiki's `search()` over 1339fd890c3Stracker-user `$conf['metadir']` with the `searchAnnotations` callback, which maps every `*.annotations` 1349fd890c3Stracker-user file back to a page id via `pathID()` (and drops files left empty after all their annotations 1359fd890c3Stracker-user were deleted). Counts come from `helper::pageCounts($id)`, which renders the page once 1369fd890c3Stracker-user (`getPageText`) and applies the shared `quoteMissing()` rule — the same rule `findOrphaned()` 1379fd890c3Stracker-user uses, so "Normal" on the overview means exactly "not orphaned". 138*72d60f2dStracker-user- **Overlapping facets.** `Normal`/`Orphaned` partition by anchoring (present vs. quote-gone); 139*72d60f2dStracker-user `Resolved` counts `status === 'resolved'` regardless of anchoring. They deliberately overlap so 140*72d60f2dStracker-user each column matches exactly what its clear button removes: `clearResolved()` deletes every 141*72d60f2dStracker-user resolved annotation (present *or* orphaned), `clearOrphaned()` deletes every orphaned one 142*72d60f2dStracker-user (open *or* resolved). A resolved-and-present annotation is therefore counted in both Normal and 143*72d60f2dStracker-user Resolved; a resolved-and-orphaned one in both Orphaned and Resolved. 1449fd890c3Stracker-user- **Cost.** Detecting orphans requires rendering each annotated page; because the overview sorts 1459fd890c3Stracker-user and filters on the counts, every annotated page is processed per load. `p_wiki_xhtml` is 1469fd890c3Stracker-user render-cached, so steady-state cost is low; the first load after edits re-renders. 1479fd890c3Stracker-user- **Table.** Reuses the JS-free sortable-header + per-column-filter + numbered-pager pattern from 1489fd890c3Stracker-user `lastseen`/`usersettings`. Only the Page column is filterable (matching title **or** id); the 1499fd890c3Stracker-user count columns and the actions column are not. The `entries_per_page` config controls paging. 150*72d60f2dStracker-user- **Clearing.** All four clears are POST forms guarded by `checkSecurityToken()` and 1519fd890c3Stracker-user `helper::canClear(auth_isadmin())`, with Post/Redirect/Get back to the same view. To avoid an 152*72d60f2dStracker-user illegal nested `<form>` (the table sits inside the GET filter form), the POST forms 153*72d60f2dStracker-user (`ann_clear_resolved_single`/`_all`, `ann_clear_orphaned_single`/`_all`, built by the shared 154*72d60f2dStracker-user `clearForm()`) are rendered as siblings *after* it and the buttons reach them via the HTML5 155*72d60f2dStracker-user `form=` attribute; each per-row button supplies its page id through `name="clearpage"` (**not** 156*72d60f2dStracker-user `page`, which selects the admin component). `clearOrphanedAll()` / `clearResolvedAll()` loop the 157*72d60f2dStracker-user annotated pages applying the per-page clear, so the authoritative re-check runs for each. 158*72d60f2dStracker-user Destructive buttons confirm via an inline `onclick` (the message is `json_encode`d then `hsc`d, 159*72d60f2dStracker-user safe at both the JS-string and attribute layers). 1609fd890c3Stracker-user 1615fa3d185Stracker-user## JSINFO injection (important gotcha) 1625fa3d185Stracker-user 1635fa3d185Stracker-user`script.js` needs per-page facts at boot without an extra round-trip, but you 1645fa3d185Stracker-user**cannot** add them by writing `$JSINFO` inside `TPL_METAHEADER_OUTPUT`: 1655fa3d185Stracker-user`tpl_metaheaders()` calls `jsinfo()` and serialises `$JSINFO` into the inline 1665fa3d185Stracker-user`var JSINFO = …;` script **before** firing that event. Instead `handleMetaHeader` 1675fa3d185Stracker-userfinds that inline `<script>` in `$event->data['script']` and appends a 1685fa3d185Stracker-user`JSINFO.annotations = {…};` statement so it runs in the same scope. Injection is 1695fa3d185Stracker-usergated to `show` / `export_xhtml` views. 1705fa3d185Stracker-user 171108f92bdStracker-userPayload: `{ enabled, pageId, stats, user, isAdmin, token, annotations? }`. 172108f92bdStracker-user`user`, `isAdmin` and `token` are included because stock `JSINFO` exposes no 173108f92bdStracker-useruser identity and no security token — the script reads them from 174108f92bdStracker-user`JSINFO.annotations`, not from `JSINFO.userinfo` (which does not exist) or the 175108f92bdStracker-user`#dw__token` field. UI strings are **not** in this payload: they travel through 176108f92bdStracker-userDokuWiki's per-plugin JS lang bundle, `LANG.plugins.annotations`, built from 177108f92bdStracker-user`$lang['js']`. 178108f92bdStracker-user 179108f92bdStracker-userThe optional `annotations` key carries the page's **full annotation list**, so 180108f92bdStracker-user`script.js` renders on boot with no `load` round-trip (that AJAX call re-boots 181108f92bdStracker-userDokuWiki — ~300 ms — only to re-read this same file). `handleMetaHeader` reads 182108f92bdStracker-userthe list once and derives `stats` from it via `helper::statsFor()` rather than 183108f92bdStracker-userre-reading through `getStats()`. The key is omitted when the feature is off for 18486c7806dStracker-userthe user, or when the serialized list exceeds the `embed_max_bytes` config 18586c7806dStracker-user(default 128 KB; `DEFAULT_EMBED_MAX_BYTES` is the fallback) — in 186108f92bdStracker-userwhich case `script.js` falls back to the `load` endpoint. Because the inline 187108f92bdStracker-user`JSINFO` script is regenerated every request (it is not in the parser page 188108f92bdStracker-usercache), the embedded list is always current. 1895fa3d185Stracker-user 1905fa3d185Stracker-user## Per-user toggle 1915fa3d185Stracker-user 1925fa3d185Stracker-userRegistered with the **usersettings** plugin via `PLUGIN_USERSETTINGS_REGISTER` 1935fa3d185Stracker-user(key `annotations_enabled`, checkbox, default on). `isEnabledForUser()` reads the 1945fa3d185Stracker-userpreference through the usersettings helper; if that plugin is absent, or the 1955fa3d185Stracker-usertoggle has not been registered yet, the feature defaults to **on**. When a user 1965fa3d185Stracker-userturns it off, `boot()` returns early and nothing is rendered (annotations are 1975fa3d185Stracker-userstill stored). 1985fa3d185Stracker-user 1995fa3d185Stracker-user## Permission model 2005fa3d185Stracker-user 2015fa3d185Stracker-userThe rules live in `helper.php` and are pure; `action.php` gathers the facts and 202da56206cStracker-usercalls them. `isAdmin` is DokuWiki's `auth_isadmin()` (superuser / admin group). 2035fa3d185Stracker-user 2045fa3d185Stracker-user| Action | Rule (helper method) | 2055fa3d185Stracker-user|--------|----------------------| 2065fa3d185Stracker-user| Create annotation / reply / resolve / reopen | logged in **and** `AUTH_READ` on the page — *not* `AUTH_EDIT` (`canAnnotate`) | 2075fa3d185Stracker-user| Edit / delete own annotation | author (`canEditAnnotation`) | 2085fa3d185Stracker-user| Edit / delete own reply | author (`canEditReply`) | 2095fa3d185Stracker-user| Edit / delete **any** annotation or reply | admin (`canEditAnnotation` / `canEditReply`) | 210*72d60f2dStracker-user| Clear resolved / clear orphaned (per page on-page, and per-page or wiki-wide in the admin overview) | admin (`canClear`) | 2115fa3d185Stracker-user| Load (read) annotations | `AUTH_READ` on the page | 2125fa3d185Stracker-user 2135fa3d185Stracker-user## Security 2145fa3d185Stracker-user 2155fa3d185Stracker-user- **CSRF.** Every state-changing action requires a valid DokuWiki security 2165fa3d185Stracker-user token. The token is injected into `JSINFO.annotations.token` and sent back as 217da56206cStracker-user `sectok` in the JSON body. `handleAjax` reads it from the parsed body and 218da56206cStracker-user passes it straight to `checkSecurityToken($token)`. The read-only `load` 219da56206cStracker-user action is exempt (GET, no token) but still ACL-checked. 2205fa3d185Stracker-user- **ACL.** `auth_quickaclcheck($id)` gates both reading and writing. 2215fa3d185Stracker-user- **Output.** Bodies are stored as plain text (newlines kept, length-capped) and 2225fa3d185Stracker-user rendered client-side via `textContent`, so user content is never interpolated 2235fa3d185Stracker-user as HTML. 2245fa3d185Stracker-user 2255fa3d185Stracker-user## AJAX endpoint 2265fa3d185Stracker-user 2275fa3d185Stracker-user`…/lib/exe/ajax.php?call=annotations` (handled on `AJAX_CALL_UNKNOWN`). The 2285fa3d185Stracker-user`load` action is a GET with query params; everything else is `POST` with an 2295fa3d185Stracker-user`application/json` body. Every response is `{ "success": true, … }` or 230108f92bdStracker-user`{ "success": false, "error": "…" }`. `load` is now only a **fallback** for the 231108f92bdStracker-userinline-embedded list (see JSINFO injection above); the mutating actions are the 232108f92bdStracker-userhot path. 2335fa3d185Stracker-user 2345fa3d185Stracker-user| Action | Method | Token | Extra fields | 2355fa3d185Stracker-user|--------|--------|-------|--------------| 2365fa3d185Stracker-user| `load` | GET | — | — | 2375fa3d185Stracker-user| `create` | POST | ✓ | `anchor`, `body` | 2385fa3d185Stracker-user| `reply` | POST | ✓ | `annId`, `body` | 2395fa3d185Stracker-user| `edit_annotation` | POST | ✓ | `annId`, `body` | 2405fa3d185Stracker-user| `edit_reply` | POST | ✓ | `annId`, `replyId`, `body` | 2415fa3d185Stracker-user| `delete_annotation` | POST | ✓ | `annId` | 2425fa3d185Stracker-user| `delete_reply` | POST | ✓ | `annId`, `replyId` | 2435fa3d185Stracker-user| `resolve` | POST | ✓ | `annId`, `status` (`open`\|`resolved`) | 2445fa3d185Stracker-user| `clear_resolved` | POST | ✓ | — | 2455fa3d185Stracker-user| `clear_orphaned` | POST | ✓ | — | 2465fa3d185Stracker-user 2475fa3d185Stracker-userAll actions also take the page `id`. 2485fa3d185Stracker-user 2495fa3d185Stracker-user## Constraints 2505fa3d185Stracker-user 2515fa3d185Stracker-user- **JS/CSS floor: Firefox 78 ESR.** No `#private` fields, `??=`/`||=`/`&&=`, 2525fa3d185Stracker-user `Array.at`, `structuredClone`, `Object.hasOwn`, native `<dialog>`; no CSS 2535fa3d185Stracker-user `:has()`, selector `:not()`, `aspect-ratio`, container queries, or nesting. 2545fa3d185Stracker-user `async`/`await`, `fetch`, classes, `?.`, `??`, `Map`/`Set` are fine. 2555fa3d185Stracker-user- **PHP:** developed against 8.3; requires the `mbstring` extension. 2565fa3d185Stracker-user 257da56206cStracker-user## Resolved (kept here for history) 258da56206cStracker-user 259da56206cStracker-user- **UI localisation — done.** Front-end strings live under `$lang['js']` and are 260da56206cStracker-user read in `script.js` via `LANG.plugins.annotations`, each with an English 261da56206cStracker-user fallback (the `t()` / `fmt()` helpers). `toggle_label` / `toggle_desc` stay 262da56206cStracker-user PHP-side (`getLang`). 263da56206cStracker-user- **Translations — done.** `en`, `de`, `ru`, `ja` ship, all carrying the same 264da56206cStracker-user `$lang['js']` keys. 265da56206cStracker-user- **Tests — done.** `_test/` has `GeneralTest` (manifest + the 266da56206cStracker-user `default.php`↔`metadata.php` invariant) and `HelperTest` (permission rules, 267da56206cStracker-user CRUD, input cleaning, `findOrphaned` against a rendered page). Run: 268da56206cStracker-user `composer run test -- --group plugin_annotations`. 269da56206cStracker-user- **Cleanup — done.** The unused `ann-highlight-orphaned` constant is gone, and 270da56206cStracker-user the panel sets `data-status` so the resolved accent in `style.css` applies. 27186c7806dStracker-user- **Config — done.** `conf/default.php` + `conf/metadata.php` expose 27286c7806dStracker-user `color_open`, `color_resolved`, `embed_max_bytes`, `context_length` and 27386c7806dStracker-user `body_cap` (labels in `lang/<iso>/settings.php`). The two colours are injected 27486c7806dStracker-user as CSS custom properties (`--ann-open-rgb` / `--ann-resolved-rgb`) by 27586c7806dStracker-user `action.php::injectColourVars()`; `style.css` derives every opacity variant 27686c7806dStracker-user from them and ships `:root` fallbacks. `GeneralTest::testPluginConf` enforces 27786c7806dStracker-user the `default.php`↔`metadata.php` invariant. 278