xref: /plugin/annotations/DESIGN.md (revision 72d60f2d94b24cb66fabf596a2ec440f459ba88f)
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| `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. |
33| `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. |
34| `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). |
35| `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`. |
36
37Documentation lives in [`README.md`](README.md) (end users) and this file
38(developers); the licence is in `LICENSE` (GPL 2).
39
40## Data model & storage
41
42One pretty-printed JSON file per page at `metaFN($id, '.annotations')`
43(`data/meta/<namespace>/<page>.annotations`):
44
45```json
46{
47  "version": 1,
48  "annotations": [
49    {
50      "id": "a1b2c3d4e5f6g7h8",
51      "anchor": { "exact": "...", "prefix": "...", "suffix": "...", "start": 123 },
52      "author": "alice",
53      "created": 1716336000,
54      "modified": 1716336000,
55      "body": "Does this cover remuxes?",
56      "status": "open",
57      "resolved_by": "",
58      "resolved_at": 0,
59      "replies": [
60        {
61          "id": "x1y2z3a4b5c6d7e8",
62          "parentId": "",
63          "author": "bob",
64          "created": 1716336100,
65          "modified": 1716336100,
66          "body": "Yes, remuxes count."
67        }
68      ]
69    }
70  ]
71}
72```
73
74Replies are stored as a **flat** list; `parentId` (empty for a top-level reply,
75otherwise the id of the reply being answered) lets the client rebuild the nested
76thread (`buildReplyTree`). The `reply`, `edit_reply` and `delete_reply` actions
77return the **full updated annotation**, so the panel re-renders the whole thread
78in a single round-trip.
79
80Limits and identifiers: `SCHEMA_VERSION = 1` and `MAX_QUOTE = 1000` are
81`helper.php` constants; the context-slice length and body cap are now config
82(`context_length`, `body_cap`, defaulting to 64 and 10000 via the
83`DEFAULT_CONTEXT` / `DEFAULT_BODY` fallbacks). IDs are
84`bin2hex(random_bytes(8))` — 16 hex chars. Writes go through `io_lock()` →
85modify → `io_saveFile()` → `io_unlock()` (the `mutate()` helper); a modifier
86returning `false` aborts the write (used for "target not found").
87
88## Text-quote anchoring
89
90An anchor is `{exact, prefix, suffix, start}`:
91
92- `exact` — the selected text, whitespace-normalised (runs collapsed to one
93  space, trimmed). The same normalisation is applied on capture (JS), on
94  storage (PHP), and on matching, so client and server agree.
95- `prefix` / `suffix` — context on each side to disambiguate a quote that
96  appears more than once. Client captures ~30 chars; server caps at 64.
97- `start` — a character-offset hint into the page text, used only as a
98  tie-breaker.
99
100**Re-anchoring (client, `locate` + `buildRange`)**: collect the content text
101with a `TreeWalker`, normalise it once with `normalizeWithMap` — which returns
102the normalised string **and** a normalised→raw index map built in lockstep (they
103must share the same trimming, or every highlight shifts by a character) — search
104for the normalised `exact`, disambiguate repeats with `prefix`/`suffix`,
105tie-break with the `start` hint, then map the chosen offset back to a DOM `Range`
106and wrap it in a highlight `<span>`. All matches are located first and wrapped
107last-to-first, so wrapping (which splits text nodes) never disturbs a
108not-yet-wrapped offset. A quote that cannot be located is an orphan (no
109highlight, no gutter marker).
110
111## Orphan detection (two layers)
112
113- **Client (live UI).** Anything `findRange` cannot anchor on page load is
114  counted as orphaned; the count feeds the counter bar, and the orphaned link
115  opens a drawer at the bottom of the content area with those threads. Orphan
116  threads render **read-only**: `buildThreadEntry` gates the Resolve/Reopen and
117  Edit buttons on `!ann._orphaned`, leaving only Delete (for the author or an
118  admin), since the quoted text is gone and there is nothing left to anchor.
119- **Server (authoritative, `findOrphaned`).** For the admin "clear orphaned"
120  action the page is rendered with `p_wiki_xhtml`, block-closing tags are turned
121  into spaces, tags/entities are stripped, whitespace normalised, and each
122  annotation's `exact` is searched with `mb_strpos`. This re-check is the source
123  of truth for deletion, so a stale client can't cause data loss.
124
125## Admin overview (`admin.php`)
126
127`admin_plugin_annotations` (**Admin → Annotations**, admin-only) is the wiki-wide companion to
128the per-page counter-bar buttons: it lists every annotated page with its **Normal** (present),
129**Resolved** and **Orphaned** counts and offers a per-page and a wiki-wide clear for each of
130"resolved" and "orphaned".
131
132- **Enumeration.** `helper::getAnnotatedPages()` runs DokuWiki's `search()` over
133  `$conf['metadir']` with the `searchAnnotations` callback, which maps every `*.annotations`
134  file back to a page id via `pathID()` (and drops files left empty after all their annotations
135  were deleted). Counts come from `helper::pageCounts($id)`, which renders the page once
136  (`getPageText`) and applies the shared `quoteMissing()` rule — the same rule `findOrphaned()`
137  uses, so "Normal" on the overview means exactly "not orphaned".
138- **Overlapping facets.** `Normal`/`Orphaned` partition by anchoring (present vs. quote-gone);
139  `Resolved` counts `status === 'resolved'` regardless of anchoring. They deliberately overlap so
140  each column matches exactly what its clear button removes: `clearResolved()` deletes every
141  resolved annotation (present *or* orphaned), `clearOrphaned()` deletes every orphaned one
142  (open *or* resolved). A resolved-and-present annotation is therefore counted in both Normal and
143  Resolved; a resolved-and-orphaned one in both Orphaned and Resolved.
144- **Cost.** Detecting orphans requires rendering each annotated page; because the overview sorts
145  and filters on the counts, every annotated page is processed per load. `p_wiki_xhtml` is
146  render-cached, so steady-state cost is low; the first load after edits re-renders.
147- **Table.** Reuses the JS-free sortable-header + per-column-filter + numbered-pager pattern from
148  `lastseen`/`usersettings`. Only the Page column is filterable (matching title **or** id); the
149  count columns and the actions column are not. The `entries_per_page` config controls paging.
150- **Clearing.** All four clears are POST forms guarded by `checkSecurityToken()` and
151  `helper::canClear(auth_isadmin())`, with Post/Redirect/Get back to the same view. To avoid an
152  illegal nested `<form>` (the table sits inside the GET filter form), the POST forms
153  (`ann_clear_resolved_single`/`_all`, `ann_clear_orphaned_single`/`_all`, built by the shared
154  `clearForm()`) are rendered as siblings *after* it and the buttons reach them via the HTML5
155  `form=` attribute; each per-row button supplies its page id through `name="clearpage"` (**not**
156  `page`, which selects the admin component). `clearOrphanedAll()` / `clearResolvedAll()` loop the
157  annotated pages applying the per-page clear, so the authoritative re-check runs for each.
158  Destructive buttons confirm via an inline `onclick` (the message is `json_encode`d then `hsc`d,
159  safe at both the JS-string and attribute layers).
160
161## JSINFO injection (important gotcha)
162
163`script.js` needs per-page facts at boot without an extra round-trip, but you
164**cannot** add them by writing `$JSINFO` inside `TPL_METAHEADER_OUTPUT`:
165`tpl_metaheaders()` calls `jsinfo()` and serialises `$JSINFO` into the inline
166`var JSINFO = …;` script **before** firing that event. Instead `handleMetaHeader`
167finds that inline `<script>` in `$event->data['script']` and appends a
168`JSINFO.annotations = {…};` statement so it runs in the same scope. Injection is
169gated to `show` / `export_xhtml` views.
170
171Payload: `{ enabled, pageId, stats, user, isAdmin, token, annotations? }`.
172`user`, `isAdmin` and `token` are included because stock `JSINFO` exposes no
173user identity and no security token — the script reads them from
174`JSINFO.annotations`, not from `JSINFO.userinfo` (which does not exist) or the
175`#dw__token` field. UI strings are **not** in this payload: they travel through
176DokuWiki's per-plugin JS lang bundle, `LANG.plugins.annotations`, built from
177`$lang['js']`.
178
179The optional `annotations` key carries the page's **full annotation list**, so
180`script.js` renders on boot with no `load` round-trip (that AJAX call re-boots
181DokuWiki — ~300 ms — only to re-read this same file). `handleMetaHeader` reads
182the list once and derives `stats` from it via `helper::statsFor()` rather than
183re-reading through `getStats()`. The key is omitted when the feature is off for
184the user, or when the serialized list exceeds the `embed_max_bytes` config
185(default 128 KB; `DEFAULT_EMBED_MAX_BYTES` is the fallback) — in
186which case `script.js` falls back to the `load` endpoint. Because the inline
187`JSINFO` script is regenerated every request (it is not in the parser page
188cache), the embedded list is always current.
189
190## Per-user toggle
191
192Registered with the **usersettings** plugin via `PLUGIN_USERSETTINGS_REGISTER`
193(key `annotations_enabled`, checkbox, default on). `isEnabledForUser()` reads the
194preference through the usersettings helper; if that plugin is absent, or the
195toggle has not been registered yet, the feature defaults to **on**. When a user
196turns it off, `boot()` returns early and nothing is rendered (annotations are
197still stored).
198
199## Permission model
200
201The rules live in `helper.php` and are pure; `action.php` gathers the facts and
202calls them. `isAdmin` is DokuWiki's `auth_isadmin()` (superuser / admin group).
203
204| Action | Rule (helper method) |
205|--------|----------------------|
206| Create annotation / reply / resolve / reopen | logged in **and** `AUTH_READ` on the page — *not* `AUTH_EDIT` (`canAnnotate`) |
207| Edit / delete own annotation | author (`canEditAnnotation`) |
208| Edit / delete own reply | author (`canEditReply`) |
209| Edit / delete **any** annotation or reply | admin (`canEditAnnotation` / `canEditReply`) |
210| Clear resolved / clear orphaned (per page on-page, and per-page or wiki-wide in the admin overview) | admin (`canClear`) |
211| Load (read) annotations | `AUTH_READ` on the page |
212
213## Security
214
215- **CSRF.** Every state-changing action requires a valid DokuWiki security
216  token. The token is injected into `JSINFO.annotations.token` and sent back as
217  `sectok` in the JSON body. `handleAjax` reads it from the parsed body and
218  passes it straight to `checkSecurityToken($token)`. The read-only `load`
219  action is exempt (GET, no token) but still ACL-checked.
220- **ACL.** `auth_quickaclcheck($id)` gates both reading and writing.
221- **Output.** Bodies are stored as plain text (newlines kept, length-capped) and
222  rendered client-side via `textContent`, so user content is never interpolated
223  as HTML.
224
225## AJAX endpoint
226
227`…/lib/exe/ajax.php?call=annotations` (handled on `AJAX_CALL_UNKNOWN`). The
228`load` action is a GET with query params; everything else is `POST` with an
229`application/json` body. Every response is `{ "success": true, … }` or
230`{ "success": false, "error": "…" }`. `load` is now only a **fallback** for the
231inline-embedded list (see JSINFO injection above); the mutating actions are the
232hot path.
233
234| Action | Method | Token | Extra fields |
235|--------|--------|-------|--------------|
236| `load` | GET | — | — |
237| `create` | POST | ✓ | `anchor`, `body` |
238| `reply` | POST | ✓ | `annId`, `body` |
239| `edit_annotation` | POST | ✓ | `annId`, `body` |
240| `edit_reply` | POST | ✓ | `annId`, `replyId`, `body` |
241| `delete_annotation` | POST | ✓ | `annId` |
242| `delete_reply` | POST | ✓ | `annId`, `replyId` |
243| `resolve` | POST | ✓ | `annId`, `status` (`open`\|`resolved`) |
244| `clear_resolved` | POST | ✓ | — |
245| `clear_orphaned` | POST | ✓ | — |
246
247All actions also take the page `id`.
248
249## Constraints
250
251- **JS/CSS floor: Firefox 78 ESR.** No `#private` fields, `??=`/`||=`/`&&=`,
252  `Array.at`, `structuredClone`, `Object.hasOwn`, native `<dialog>`; no CSS
253  `:has()`, selector `:not()`, `aspect-ratio`, container queries, or nesting.
254  `async`/`await`, `fetch`, classes, `?.`, `??`, `Map`/`Set` are fine.
255- **PHP:** developed against 8.3; requires the `mbstring` extension.
256
257## Resolved (kept here for history)
258
259- **UI localisation — done.** Front-end strings live under `$lang['js']` and are
260  read in `script.js` via `LANG.plugins.annotations`, each with an English
261  fallback (the `t()` / `fmt()` helpers). `toggle_label` / `toggle_desc` stay
262  PHP-side (`getLang`).
263- **Translations — done.** `en`, `de`, `ru`, `ja` ship, all carrying the same
264  `$lang['js']` keys.
265- **Tests — done.** `_test/` has `GeneralTest` (manifest + the
266  `default.php`↔`metadata.php` invariant) and `HelperTest` (permission rules,
267  CRUD, input cleaning, `findOrphaned` against a rendered page). Run:
268  `composer run test -- --group plugin_annotations`.
269- **Cleanup — done.** The unused `ann-highlight-orphaned` constant is gone, and
270  the panel sets `data-status` so the resolved accent in `style.css` applies.
271- **Config — done.** `conf/default.php` + `conf/metadata.php` expose
272  `color_open`, `color_resolved`, `embed_max_bytes`, `context_length` and
273  `body_cap` (labels in `lang/<iso>/settings.php`). The two colours are injected
274  as CSS custom properties (`--ann-open-rgb` / `--ann-resolved-rgb`) by
275  `action.php::injectColourVars()`; `style.css` derives every opacity variant
276  from them and ships `:root` fallbacks. `GeneralTest::testPluginConf` enforces
277  the `default.php`↔`metadata.php` invariant.
278