xref: /plugin/annotations/DESIGN.md (revision 86c7806d6d41bce7c6d00acbee1316c62845cabb)
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). |
325fa3d185Stracker-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. |
33*86c7806dStracker-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). |
34da56206cStracker-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`. |
355fa3d185Stracker-user
368d8701f5Stracker-userDocumentation lives in [`README.md`](README.md) (end users) and this file
378d8701f5Stracker-user(developers); the licence is in `LICENSE` (GPL 2).
388d8701f5Stracker-user
395fa3d185Stracker-user## Data model & storage
405fa3d185Stracker-user
415fa3d185Stracker-userOne pretty-printed JSON file per page at `metaFN($id, '.annotations')`
425fa3d185Stracker-user(`data/meta/<namespace>/<page>.annotations`):
435fa3d185Stracker-user
445fa3d185Stracker-user```json
455fa3d185Stracker-user{
465fa3d185Stracker-user  "version": 1,
475fa3d185Stracker-user  "annotations": [
485fa3d185Stracker-user    {
495fa3d185Stracker-user      "id": "a1b2c3d4e5f6g7h8",
505fa3d185Stracker-user      "anchor": { "exact": "...", "prefix": "...", "suffix": "...", "start": 123 },
515fa3d185Stracker-user      "author": "alice",
525fa3d185Stracker-user      "created": 1716336000,
535fa3d185Stracker-user      "modified": 1716336000,
545fa3d185Stracker-user      "body": "Does this cover remuxes?",
555fa3d185Stracker-user      "status": "open",
565fa3d185Stracker-user      "resolved_by": "",
575fa3d185Stracker-user      "resolved_at": 0,
585fa3d185Stracker-user      "replies": [
595fa3d185Stracker-user        {
605fa3d185Stracker-user          "id": "x1y2z3a4b5c6d7e8",
61ee9dbf15Stracker-user          "parentId": "",
625fa3d185Stracker-user          "author": "bob",
635fa3d185Stracker-user          "created": 1716336100,
645fa3d185Stracker-user          "modified": 1716336100,
655fa3d185Stracker-user          "body": "Yes, remuxes count."
665fa3d185Stracker-user        }
675fa3d185Stracker-user      ]
685fa3d185Stracker-user    }
695fa3d185Stracker-user  ]
705fa3d185Stracker-user}
715fa3d185Stracker-user```
725fa3d185Stracker-user
73ee9dbf15Stracker-userReplies are stored as a **flat** list; `parentId` (empty for a top-level reply,
74ee9dbf15Stracker-userotherwise the id of the reply being answered) lets the client rebuild the nested
75ee9dbf15Stracker-userthread (`buildReplyTree`). The `reply`, `edit_reply` and `delete_reply` actions
76ee9dbf15Stracker-userreturn the **full updated annotation**, so the panel re-renders the whole thread
77ee9dbf15Stracker-userin a single round-trip.
78ee9dbf15Stracker-user
79*86c7806dStracker-userLimits and identifiers: `SCHEMA_VERSION = 1` and `MAX_QUOTE = 1000` are
80*86c7806dStracker-user`helper.php` constants; the context-slice length and body cap are now config
81*86c7806dStracker-user(`context_length`, `body_cap`, defaulting to 64 and 10000 via the
82*86c7806dStracker-user`DEFAULT_CONTEXT` / `DEFAULT_BODY` fallbacks). IDs are
835fa3d185Stracker-user`bin2hex(random_bytes(8))` — 16 hex chars. Writes go through `io_lock()` →
845fa3d185Stracker-usermodify → `io_saveFile()` → `io_unlock()` (the `mutate()` helper); a modifier
855fa3d185Stracker-userreturning `false` aborts the write (used for "target not found").
865fa3d185Stracker-user
875fa3d185Stracker-user## Text-quote anchoring
885fa3d185Stracker-user
895fa3d185Stracker-userAn anchor is `{exact, prefix, suffix, start}`:
905fa3d185Stracker-user
915fa3d185Stracker-user- `exact` — the selected text, whitespace-normalised (runs collapsed to one
925fa3d185Stracker-user  space, trimmed). The same normalisation is applied on capture (JS), on
935fa3d185Stracker-user  storage (PHP), and on matching, so client and server agree.
945fa3d185Stracker-user- `prefix` / `suffix` — context on each side to disambiguate a quote that
955fa3d185Stracker-user  appears more than once. Client captures ~30 chars; server caps at 64.
965fa3d185Stracker-user- `start` — a character-offset hint into the page text, used only as a
975fa3d185Stracker-user  tie-breaker.
985fa3d185Stracker-user
99da56206cStracker-user**Re-anchoring (client, `locate` + `buildRange`)**: collect the content text
100da56206cStracker-userwith a `TreeWalker`, normalise it once with `normalizeWithMap` — which returns
101da56206cStracker-userthe normalised string **and** a normalised→raw index map built in lockstep (they
102da56206cStracker-usermust share the same trimming, or every highlight shifts by a character) — search
103da56206cStracker-userfor the normalised `exact`, disambiguate repeats with `prefix`/`suffix`,
104da56206cStracker-usertie-break with the `start` hint, then map the chosen offset back to a DOM `Range`
105da56206cStracker-userand wrap it in a highlight `<span>`. All matches are located first and wrapped
106da56206cStracker-userlast-to-first, so wrapping (which splits text nodes) never disturbs a
107da56206cStracker-usernot-yet-wrapped offset. A quote that cannot be located is an orphan (no
108da56206cStracker-userhighlight, no gutter marker).
1095fa3d185Stracker-user
1105fa3d185Stracker-user## Orphan detection (two layers)
1115fa3d185Stracker-user
1125fa3d185Stracker-user- **Client (live UI).** Anything `findRange` cannot anchor on page load is
1135fa3d185Stracker-user  counted as orphaned; the count feeds the counter bar, and the orphaned link
1145fa3d185Stracker-user  opens a drawer at the bottom of the content area with those threads.
1155fa3d185Stracker-user- **Server (authoritative, `findOrphaned`).** For the admin "clear orphaned"
1165fa3d185Stracker-user  action the page is rendered with `p_wiki_xhtml`, block-closing tags are turned
1175fa3d185Stracker-user  into spaces, tags/entities are stripped, whitespace normalised, and each
1185fa3d185Stracker-user  annotation's `exact` is searched with `mb_strpos`. This re-check is the source
1195fa3d185Stracker-user  of truth for deletion, so a stale client can't cause data loss.
1205fa3d185Stracker-user
1215fa3d185Stracker-user## JSINFO injection (important gotcha)
1225fa3d185Stracker-user
1235fa3d185Stracker-user`script.js` needs per-page facts at boot without an extra round-trip, but you
1245fa3d185Stracker-user**cannot** add them by writing `$JSINFO` inside `TPL_METAHEADER_OUTPUT`:
1255fa3d185Stracker-user`tpl_metaheaders()` calls `jsinfo()` and serialises `$JSINFO` into the inline
1265fa3d185Stracker-user`var JSINFO = …;` script **before** firing that event. Instead `handleMetaHeader`
1275fa3d185Stracker-userfinds that inline `<script>` in `$event->data['script']` and appends a
1285fa3d185Stracker-user`JSINFO.annotations = {…};` statement so it runs in the same scope. Injection is
1295fa3d185Stracker-usergated to `show` / `export_xhtml` views.
1305fa3d185Stracker-user
131108f92bdStracker-userPayload: `{ enabled, pageId, stats, user, isAdmin, token, annotations? }`.
132108f92bdStracker-user`user`, `isAdmin` and `token` are included because stock `JSINFO` exposes no
133108f92bdStracker-useruser identity and no security token — the script reads them from
134108f92bdStracker-user`JSINFO.annotations`, not from `JSINFO.userinfo` (which does not exist) or the
135108f92bdStracker-user`#dw__token` field. UI strings are **not** in this payload: they travel through
136108f92bdStracker-userDokuWiki's per-plugin JS lang bundle, `LANG.plugins.annotations`, built from
137108f92bdStracker-user`$lang['js']`.
138108f92bdStracker-user
139108f92bdStracker-userThe optional `annotations` key carries the page's **full annotation list**, so
140108f92bdStracker-user`script.js` renders on boot with no `load` round-trip (that AJAX call re-boots
141108f92bdStracker-userDokuWiki — ~300 ms — only to re-read this same file). `handleMetaHeader` reads
142108f92bdStracker-userthe list once and derives `stats` from it via `helper::statsFor()` rather than
143108f92bdStracker-userre-reading through `getStats()`. The key is omitted when the feature is off for
144*86c7806dStracker-userthe user, or when the serialized list exceeds the `embed_max_bytes` config
145*86c7806dStracker-user(default 128 KB; `DEFAULT_EMBED_MAX_BYTES` is the fallback) — in
146108f92bdStracker-userwhich case `script.js` falls back to the `load` endpoint. Because the inline
147108f92bdStracker-user`JSINFO` script is regenerated every request (it is not in the parser page
148108f92bdStracker-usercache), the embedded list is always current.
1495fa3d185Stracker-user
1505fa3d185Stracker-user## Per-user toggle
1515fa3d185Stracker-user
1525fa3d185Stracker-userRegistered with the **usersettings** plugin via `PLUGIN_USERSETTINGS_REGISTER`
1535fa3d185Stracker-user(key `annotations_enabled`, checkbox, default on). `isEnabledForUser()` reads the
1545fa3d185Stracker-userpreference through the usersettings helper; if that plugin is absent, or the
1555fa3d185Stracker-usertoggle has not been registered yet, the feature defaults to **on**. When a user
1565fa3d185Stracker-userturns it off, `boot()` returns early and nothing is rendered (annotations are
1575fa3d185Stracker-userstill stored).
1585fa3d185Stracker-user
1595fa3d185Stracker-user## Permission model
1605fa3d185Stracker-user
1615fa3d185Stracker-userThe rules live in `helper.php` and are pure; `action.php` gathers the facts and
162da56206cStracker-usercalls them. `isAdmin` is DokuWiki's `auth_isadmin()` (superuser / admin group).
1635fa3d185Stracker-user
1645fa3d185Stracker-user| Action | Rule (helper method) |
1655fa3d185Stracker-user|--------|----------------------|
1665fa3d185Stracker-user| Create annotation / reply / resolve / reopen | logged in **and** `AUTH_READ` on the page — *not* `AUTH_EDIT` (`canAnnotate`) |
1675fa3d185Stracker-user| Edit / delete own annotation | author (`canEditAnnotation`) |
1685fa3d185Stracker-user| Edit / delete own reply | author (`canEditReply`) |
1695fa3d185Stracker-user| Edit / delete **any** annotation or reply | admin (`canEditAnnotation` / `canEditReply`) |
1705fa3d185Stracker-user| Clear resolved / clear orphaned (per page) | admin (`canClear`) |
1715fa3d185Stracker-user| Load (read) annotations | `AUTH_READ` on the page |
1725fa3d185Stracker-user
1735fa3d185Stracker-user## Security
1745fa3d185Stracker-user
1755fa3d185Stracker-user- **CSRF.** Every state-changing action requires a valid DokuWiki security
1765fa3d185Stracker-user  token. The token is injected into `JSINFO.annotations.token` and sent back as
177da56206cStracker-user  `sectok` in the JSON body. `handleAjax` reads it from the parsed body and
178da56206cStracker-user  passes it straight to `checkSecurityToken($token)`. The read-only `load`
179da56206cStracker-user  action is exempt (GET, no token) but still ACL-checked.
1805fa3d185Stracker-user- **ACL.** `auth_quickaclcheck($id)` gates both reading and writing.
1815fa3d185Stracker-user- **Output.** Bodies are stored as plain text (newlines kept, length-capped) and
1825fa3d185Stracker-user  rendered client-side via `textContent`, so user content is never interpolated
1835fa3d185Stracker-user  as HTML.
1845fa3d185Stracker-user
1855fa3d185Stracker-user## AJAX endpoint
1865fa3d185Stracker-user
1875fa3d185Stracker-user`…/lib/exe/ajax.php?call=annotations` (handled on `AJAX_CALL_UNKNOWN`). The
1885fa3d185Stracker-user`load` action is a GET with query params; everything else is `POST` with an
1895fa3d185Stracker-user`application/json` body. Every response is `{ "success": true, … }` or
190108f92bdStracker-user`{ "success": false, "error": "…" }`. `load` is now only a **fallback** for the
191108f92bdStracker-userinline-embedded list (see JSINFO injection above); the mutating actions are the
192108f92bdStracker-userhot path.
1935fa3d185Stracker-user
1945fa3d185Stracker-user| Action | Method | Token | Extra fields |
1955fa3d185Stracker-user|--------|--------|-------|--------------|
1965fa3d185Stracker-user| `load` | GET | — | — |
1975fa3d185Stracker-user| `create` | POST | ✓ | `anchor`, `body` |
1985fa3d185Stracker-user| `reply` | POST | ✓ | `annId`, `body` |
1995fa3d185Stracker-user| `edit_annotation` | POST | ✓ | `annId`, `body` |
2005fa3d185Stracker-user| `edit_reply` | POST | ✓ | `annId`, `replyId`, `body` |
2015fa3d185Stracker-user| `delete_annotation` | POST | ✓ | `annId` |
2025fa3d185Stracker-user| `delete_reply` | POST | ✓ | `annId`, `replyId` |
2035fa3d185Stracker-user| `resolve` | POST | ✓ | `annId`, `status` (`open`\|`resolved`) |
2045fa3d185Stracker-user| `clear_resolved` | POST | ✓ | — |
2055fa3d185Stracker-user| `clear_orphaned` | POST | ✓ | — |
2065fa3d185Stracker-user
2075fa3d185Stracker-userAll actions also take the page `id`.
2085fa3d185Stracker-user
2095fa3d185Stracker-user## Constraints
2105fa3d185Stracker-user
2115fa3d185Stracker-user- **JS/CSS floor: Firefox 78 ESR.** No `#private` fields, `??=`/`||=`/`&&=`,
2125fa3d185Stracker-user  `Array.at`, `structuredClone`, `Object.hasOwn`, native `<dialog>`; no CSS
2135fa3d185Stracker-user  `:has()`, selector `:not()`, `aspect-ratio`, container queries, or nesting.
2145fa3d185Stracker-user  `async`/`await`, `fetch`, classes, `?.`, `??`, `Map`/`Set` are fine.
2155fa3d185Stracker-user- **PHP:** developed against 8.3; requires the `mbstring` extension.
2165fa3d185Stracker-user
217da56206cStracker-user## Resolved (kept here for history)
218da56206cStracker-user
219da56206cStracker-user- **UI localisation — done.** Front-end strings live under `$lang['js']` and are
220da56206cStracker-user  read in `script.js` via `LANG.plugins.annotations`, each with an English
221da56206cStracker-user  fallback (the `t()` / `fmt()` helpers). `toggle_label` / `toggle_desc` stay
222da56206cStracker-user  PHP-side (`getLang`).
223da56206cStracker-user- **Translations — done.** `en`, `de`, `ru`, `ja` ship, all carrying the same
224da56206cStracker-user  `$lang['js']` keys.
225da56206cStracker-user- **Tests — done.** `_test/` has `GeneralTest` (manifest + the
226da56206cStracker-user  `default.php`↔`metadata.php` invariant) and `HelperTest` (permission rules,
227da56206cStracker-user  CRUD, input cleaning, `findOrphaned` against a rendered page). Run:
228da56206cStracker-user  `composer run test -- --group plugin_annotations`.
229da56206cStracker-user- **Cleanup — done.** The unused `ann-highlight-orphaned` constant is gone, and
230da56206cStracker-user  the panel sets `data-status` so the resolved accent in `style.css` applies.
231*86c7806dStracker-user- **Config — done.** `conf/default.php` + `conf/metadata.php` expose
232*86c7806dStracker-user  `color_open`, `color_resolved`, `embed_max_bytes`, `context_length` and
233*86c7806dStracker-user  `body_cap` (labels in `lang/<iso>/settings.php`). The two colours are injected
234*86c7806dStracker-user  as CSS custom properties (`--ann-open-rgb` / `--ann-resolved-rgb`) by
235*86c7806dStracker-user  `action.php::injectColourVars()`; `style.css` derives every opacity variant
236*86c7806dStracker-user  from them and ships `:root` fallbacks. `GeneralTest::testPluginConf` enforces
237*86c7806dStracker-user  the `default.php`↔`metadata.php` invariant.
238