# Annotations Plugin — Design & Architecture A developer reference for the annotations plugin. For installation and end-user behaviour see [README.md](README.md); for the wider review/environment conventions see `CLAUDE.md` in the plugins root. ## Concept Word- and sentence-level annotations on wiki pages, in the spirit of Hypothes.is and `ep_comments_page`: - **Out-of-band.** Annotations live in a separate per-page JSON file, never in the page text or the wiki changelog. Creating one needs only `AUTH_READ`, so a group whose page *edit* access is blocked can still annotate. - **Text-quote anchored.** Each annotation is tied to the quoted text plus a little surrounding context, not to a character position, so it survives minor edits and is re-found in the rendered DOM on each page load. - **Threaded.** Annotations carry replies, and a reply may itself reply to another reply (each records a `parentId`), so a discussion nests into a tree. Open/resolved status lives at the annotation level. - **Orphan-aware.** When the quoted text disappears from the page the annotation becomes an *orphan* — still stored, surfaced through a counter, and bulk- removable by an admin. ## Components | File | Owns | |------|------| | `plugin.info.txt` | Manifest: name, author, version date, description, repository URL. | | `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. | | `action.php` | Event registration; injecting the page payload into `JSINFO`; the AJAX endpoint and **permission enforcement** (gathers facts from DokuWiki globals, calls the helper). | | `admin.php` | Admin-only wiki-wide overview (**Admin → Annotations**): lists annotated pages with normal/orphaned counts; per-page and wiki-wide "clear orphaned". Reuses the `lastseen`/`usersettings` sortable + filterable + paginated table machinery. | | `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. | | `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). | | `lang//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`. | Documentation lives in [`README.md`](README.md) (end users) and this file (developers); the licence is in `LICENSE` (GPL 2). ## Data model & storage One pretty-printed JSON file per page at `metaFN($id, '.annotations')` (`data/meta//.annotations`): ```json { "version": 1, "annotations": [ { "id": "a1b2c3d4e5f6g7h8", "anchor": { "exact": "...", "prefix": "...", "suffix": "...", "start": 123 }, "author": "alice", "created": 1716336000, "modified": 1716336000, "body": "Does this cover remuxes?", "status": "open", "resolved_by": "", "resolved_at": 0, "replies": [ { "id": "x1y2z3a4b5c6d7e8", "parentId": "", "author": "bob", "created": 1716336100, "modified": 1716336100, "body": "Yes, remuxes count." } ] } ] } ``` Replies are stored as a **flat** list; `parentId` (empty for a top-level reply, otherwise the id of the reply being answered) lets the client rebuild the nested thread (`buildReplyTree`). The `reply`, `edit_reply` and `delete_reply` actions return the **full updated annotation**, so the panel re-renders the whole thread in a single round-trip. Limits and identifiers: `SCHEMA_VERSION = 1` and `MAX_QUOTE = 1000` are `helper.php` constants; the context-slice length and body cap are now config (`context_length`, `body_cap`, defaulting to 64 and 10000 via the `DEFAULT_CONTEXT` / `DEFAULT_BODY` fallbacks). IDs are `bin2hex(random_bytes(8))` — 16 hex chars. Writes go through `io_lock()` → modify → `io_saveFile()` → `io_unlock()` (the `mutate()` helper); a modifier returning `false` aborts the write (used for "target not found"). ## Text-quote anchoring An anchor is `{exact, prefix, suffix, start}`: - `exact` — the selected text, whitespace-normalised (runs collapsed to one space, trimmed). The same normalisation is applied on capture (JS), on storage (PHP), and on matching, so client and server agree. - `prefix` / `suffix` — context on each side to disambiguate a quote that appears more than once. Client captures ~30 chars; server caps at 64. - `start` — a character-offset hint into the page text, used only as a tie-breaker. **Re-anchoring (client, `locate` + `buildRange`)**: collect the content text with a `TreeWalker`, normalise it once with `normalizeWithMap` — which returns the normalised string **and** a normalised→raw index map built in lockstep (they must share the same trimming, or every highlight shifts by a character) — search for the normalised `exact`, disambiguate repeats with `prefix`/`suffix`, tie-break with the `start` hint, then map the chosen offset back to a DOM `Range` and wrap it in a highlight ``. All matches are located first and wrapped last-to-first, so wrapping (which splits text nodes) never disturbs a not-yet-wrapped offset. A quote that cannot be located is an orphan (no highlight, no gutter marker). ## Orphan detection (two layers) - **Client (live UI).** Anything `findRange` cannot anchor on page load is counted as orphaned; the count feeds the counter bar, and the orphaned link opens a drawer at the bottom of the content area with those threads. - **Server (authoritative, `findOrphaned`).** For the admin "clear orphaned" action the page is rendered with `p_wiki_xhtml`, block-closing tags are turned into spaces, tags/entities are stripped, whitespace normalised, and each annotation's `exact` is searched with `mb_strpos`. This re-check is the source of truth for deletion, so a stale client can't cause data loss. ## Admin overview (`admin.php`) `admin_plugin_annotations` (**Admin → Annotations**, admin-only) is the wiki-wide companion to the per-page counter-bar buttons: it lists every annotated page with its **Normal** (present) and **Orphaned** counts and offers a per-page and a wiki-wide "clear orphaned". - **Enumeration.** `helper::getAnnotatedPages()` runs DokuWiki's `search()` over `$conf['metadir']` with the `searchAnnotations` callback, which maps every `*.annotations` file back to a page id via `pathID()` (and drops files left empty after all their annotations were deleted). Counts come from `helper::pageCounts($id)`, which renders the page once (`getPageText`) and applies the shared `quoteMissing()` rule — the same rule `findOrphaned()` uses, so "Normal" on the overview means exactly "not orphaned". - **Cost.** Detecting orphans requires rendering each annotated page; because the overview sorts and filters on the counts, every annotated page is processed per load. `p_wiki_xhtml` is render-cached, so steady-state cost is low; the first load after edits re-renders. - **Table.** Reuses the JS-free sortable-header + per-column-filter + numbered-pager pattern from `lastseen`/`usersettings`. Only the Page column is filterable (matching title **or** id); the count columns and the actions column are not. The `entries_per_page` config controls paging. - **Clearing.** Both clears are POST forms guarded by `checkSecurityToken()` and `helper::canClear(auth_isadmin())`, with Post/Redirect/Get back to the same view. To avoid an illegal nested `
` (the table sits inside the GET filter form), the two POST forms are rendered as siblings *after* it and the buttons reach them via the HTML5 `form=` attribute; the per-row button supplies its page id through `name="clearpage"` (**not** `page`, which selects the admin component). `clearOrphanedAll()` loops the annotated pages applying the per-page `clearOrphaned()`, so the authoritative re-check runs for each. Destructive buttons confirm via an inline `onclick` (the message is `json_encode`d then `hsc`d, safe at both the JS-string and attribute layers). ## JSINFO injection (important gotcha) `script.js` needs per-page facts at boot without an extra round-trip, but you **cannot** add them by writing `$JSINFO` inside `TPL_METAHEADER_OUTPUT`: `tpl_metaheaders()` calls `jsinfo()` and serialises `$JSINFO` into the inline `var JSINFO = …;` script **before** firing that event. Instead `handleMetaHeader` finds that inline `