143d2073cStracker-user<?php 243d2073cStracker-user 343d2073cStracker-user/** 443d2073cStracker-user * Annotations plugin — storage and data-logic helper. 543d2073cStracker-user * 643d2073cStracker-user * This component owns: 743d2073cStracker-user * 843d2073cStracker-user * 1. The per-page annotation store. One JSON file per page, obtained via 943d2073cStracker-user * metaFN($id, '.annotations'), holding {version, annotations:[...]}. 1043d2073cStracker-user * JSON and pretty-printed so the files are easy to inspect or back up. 1143d2073cStracker-user * The page text and the wiki changelog are never touched. 1243d2073cStracker-user * 1343d2073cStracker-user * 2. The text-quote anchor model. Each annotation stores an anchor of 1443d2073cStracker-user * {exact, prefix, suffix, start} — the quoted text, a short slice of the 1543d2073cStracker-user * surrounding context on each side (to disambiguate repeated quotes), 1643d2073cStracker-user * and a character-offset hint. This is the Hypothes.is approach. 1743d2073cStracker-user * 1843d2073cStracker-user * 3. CRUD on annotations and their threaded replies. 1943d2073cStracker-user * 2043d2073cStracker-user * 4. Server-side orphan detection: a page is rendered to plain text and an 2143d2073cStracker-user * annotation is "orphaned" when its quoted text no longer appears. Used 2243d2073cStracker-user * by the admin-only per-page "clear orphaned" operation. (The live UI 2343d2073cStracker-user * also detects orphans client-side for the on-page counter.) 2443d2073cStracker-user * 2543d2073cStracker-user * 5. The permission rules, as the single source of truth. They are pure 2643d2073cStracker-user * functions: the caller gathers the facts (current user, admin flag, the 2743d2073cStracker-user * page's ACL level) and passes them in. Because annotations live 2843d2073cStracker-user * out-of-band, creating one needs only AUTH_READ on the page, never 2943d2073cStracker-user * AUTH_EDIT — so a group whose page edit access is blocked can still 3043d2073cStracker-user * annotate. 3143d2073cStracker-user */ 3243d2073cStracker-user 3343d2073cStracker-user// must be run within DokuWiki 3443d2073cStracker-userif (!defined('DOKU_INC')) die(); 3543d2073cStracker-user 3643d2073cStracker-userclass helper_plugin_annotations extends DokuWiki_Plugin 3743d2073cStracker-user{ 3843d2073cStracker-user /** storage schema version, written into each file */ 3943d2073cStracker-user const SCHEMA_VERSION = 1; 4043d2073cStracker-user 4143d2073cStracker-user /** longest quoted selection stored, in characters */ 4243d2073cStracker-user const MAX_QUOTE = 1000; 4343d2073cStracker-user 4486c7806dStracker-user /** length of the prefix/suffix context slices, in characters (config fallback) */ 4586c7806dStracker-user const DEFAULT_CONTEXT = 64; 4643d2073cStracker-user 4786c7806dStracker-user /** longest annotation/reply body, in characters (config fallback) */ 4886c7806dStracker-user const DEFAULT_BODY = 10000; 4986c7806dStracker-user 5086c7806dStracker-user /** 5186c7806dStracker-user * Configured length of each prefix/suffix context slice, in characters. 5286c7806dStracker-user * 5386c7806dStracker-user * @return int 5486c7806dStracker-user */ 5586c7806dStracker-user protected function contextLength() 5686c7806dStracker-user { 5786c7806dStracker-user $v = (int) $this->getConf('context_length'); 5886c7806dStracker-user return $v >= 0 ? $v : self::DEFAULT_CONTEXT; 5986c7806dStracker-user } 6086c7806dStracker-user 6186c7806dStracker-user /** 6286c7806dStracker-user * Configured maximum annotation/reply body length, in characters. 6386c7806dStracker-user * 6486c7806dStracker-user * @return int 6586c7806dStracker-user */ 6686c7806dStracker-user protected function bodyCap() 6786c7806dStracker-user { 6886c7806dStracker-user $v = (int) $this->getConf('body_cap'); 6986c7806dStracker-user return $v > 0 ? $v : self::DEFAULT_BODY; 7086c7806dStracker-user } 7143d2073cStracker-user 7243d2073cStracker-user // --------------------------------------------------------------------- 7343d2073cStracker-user // Storage 7443d2073cStracker-user // --------------------------------------------------------------------- 7543d2073cStracker-user 7643d2073cStracker-user /** 7743d2073cStracker-user * Path of a page's annotation file. 7843d2073cStracker-user * 7943d2073cStracker-user * @param string $id page id 8043d2073cStracker-user * @return string 8143d2073cStracker-user */ 8243d2073cStracker-user protected function getFile($id) 8343d2073cStracker-user { 8443d2073cStracker-user return metaFN($id, '.annotations'); 8543d2073cStracker-user } 8643d2073cStracker-user 8743d2073cStracker-user /** 8843d2073cStracker-user * All annotations stored for a page. 8943d2073cStracker-user * 9043d2073cStracker-user * @param string $id page id 9143d2073cStracker-user * @return array list of annotation arrays (empty if none) 9243d2073cStracker-user */ 9343d2073cStracker-user public function getAnnotations($id) 9443d2073cStracker-user { 9543d2073cStracker-user $file = $this->getFile($id); 9643d2073cStracker-user if (!file_exists($file)) { 9743d2073cStracker-user return []; 9843d2073cStracker-user } 9943d2073cStracker-user $raw = io_readFile($file, false); 10043d2073cStracker-user if ($raw === '') { 10143d2073cStracker-user return []; 10243d2073cStracker-user } 10343d2073cStracker-user $data = json_decode($raw, true); 10443d2073cStracker-user if (!is_array($data) || !isset($data['annotations']) || !is_array($data['annotations'])) { 10543d2073cStracker-user return []; 10643d2073cStracker-user } 10743d2073cStracker-user return $data['annotations']; 10843d2073cStracker-user } 10943d2073cStracker-user 11043d2073cStracker-user /** 11143d2073cStracker-user * A single annotation by id. 11243d2073cStracker-user * 11343d2073cStracker-user * @param string $id page id 11443d2073cStracker-user * @param string $annId annotation id 11543d2073cStracker-user * @return array|null 11643d2073cStracker-user */ 11743d2073cStracker-user public function getAnnotation($id, $annId) 11843d2073cStracker-user { 11943d2073cStracker-user foreach ($this->getAnnotations($id) as $a) { 12043d2073cStracker-user if (($a['id'] ?? '') === $annId) { 12143d2073cStracker-user return $a; 12243d2073cStracker-user } 12343d2073cStracker-user } 12443d2073cStracker-user return null; 12543d2073cStracker-user } 12643d2073cStracker-user 12743d2073cStracker-user /** 12843d2073cStracker-user * Counts for the on-page indicator. The orphan count is deliberately not 12943d2073cStracker-user * here — it depends on the rendered page and is computed client-side. 13043d2073cStracker-user * 13143d2073cStracker-user * @param string $id page id 13243d2073cStracker-user * @return array ['total'=>int, 'open'=>int, 'resolved'=>int] 13343d2073cStracker-user */ 13443d2073cStracker-user public function getStats($id) 13543d2073cStracker-user { 136108f92bdStracker-user return $this->statsFor($this->getAnnotations($id)); 137108f92bdStracker-user } 138108f92bdStracker-user 139108f92bdStracker-user /** 140108f92bdStracker-user * Counts for the on-page indicator, computed from an already-loaded list. 141108f92bdStracker-user * Split out from getStats() so callers that already hold the annotation 142108f92bdStracker-user * array (e.g. the page-load JSINFO injector, which embeds the same list) 143108f92bdStracker-user * don't re-read the file. 144108f92bdStracker-user * 145108f92bdStracker-user * @param array $annotations annotation list 146108f92bdStracker-user * @return array ['total'=>int, 'open'=>int, 'resolved'=>int] 147108f92bdStracker-user */ 148108f92bdStracker-user public function statsFor(array $annotations) 149108f92bdStracker-user { 15043d2073cStracker-user $open = 0; 15143d2073cStracker-user $resolved = 0; 152108f92bdStracker-user foreach ($annotations as $a) { 15343d2073cStracker-user if (($a['status'] ?? 'open') === 'resolved') { 15443d2073cStracker-user $resolved++; 15543d2073cStracker-user } else { 15643d2073cStracker-user $open++; 15743d2073cStracker-user } 15843d2073cStracker-user } 15943d2073cStracker-user return ['total' => $open + $resolved, 'open' => $open, 'resolved' => $resolved]; 16043d2073cStracker-user } 16143d2073cStracker-user 16243d2073cStracker-user /** 16343d2073cStracker-user * Write a page's annotation list to disk. 16443d2073cStracker-user * 16543d2073cStracker-user * @param string $id page id 16643d2073cStracker-user * @param array $list annotations 16743d2073cStracker-user * @return bool 16843d2073cStracker-user */ 16943d2073cStracker-user protected function writeFile($id, array $list) 17043d2073cStracker-user { 17143d2073cStracker-user $payload = [ 17243d2073cStracker-user 'version' => self::SCHEMA_VERSION, 17343d2073cStracker-user 'annotations' => array_values($list), 17443d2073cStracker-user ]; 175da56206cStracker-user return (bool) io_saveFile( 176da56206cStracker-user $this->getFile($id), 177da56206cStracker-user json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) 178da56206cStracker-user ); 17943d2073cStracker-user } 18043d2073cStracker-user 18143d2073cStracker-user /** 18243d2073cStracker-user * Run a modification against a page's annotations under a write lock. 18343d2073cStracker-user * 18443d2073cStracker-user * The modifier receives the annotation list by reference and returns an 18543d2073cStracker-user * outcome value. Returning the boolean false aborts the write (used for 18643d2073cStracker-user * "target not found"); any other value is returned to the caller after a 18743d2073cStracker-user * successful save. 18843d2073cStracker-user * 18943d2073cStracker-user * @param string $id page id 19043d2073cStracker-user * @param callable $modifier function(array &$annotations): mixed 19143d2073cStracker-user * @return mixed the modifier's outcome on success, or false on failure 19243d2073cStracker-user */ 19343d2073cStracker-user protected function mutate($id, callable $modifier) 19443d2073cStracker-user { 19543d2073cStracker-user $file = $this->getFile($id); 19649d7ec0aStracker-user // Lock on a sentinel key, NOT $file itself: writeFile() below calls 19749d7ec0aStracker-user // io_saveFile($file), which takes its own io_lock($file) internally. 19849d7ec0aStracker-user // Locking $file here would collide with that inner lock — io_lock 19949d7ec0aStracker-user // busy-waits ~3s for the stale-lock timeout on every write and then 20049d7ec0aStracker-user // proceeds, defeating mutual exclusion (see DokuWiki TaskRunner). A 20149d7ec0aStracker-user // distinct key serialises the read-modify-write across requests while 20249d7ec0aStracker-user // leaving io_saveFile's lock uncontended. 20349d7ec0aStracker-user $lock = $file . '.lock'; 20449d7ec0aStracker-user io_lock($lock); 20543d2073cStracker-user 20643d2073cStracker-user $annotations = $this->getAnnotations($id); 20743d2073cStracker-user $outcome = $modifier($annotations); 20843d2073cStracker-user 20943d2073cStracker-user if ($outcome === false) { 21049d7ec0aStracker-user io_unlock($lock); 21143d2073cStracker-user return false; 21243d2073cStracker-user } 21343d2073cStracker-user 21443d2073cStracker-user $ok = $this->writeFile($id, $annotations); 21549d7ec0aStracker-user io_unlock($lock); 21643d2073cStracker-user return $ok ? $outcome : false; 21743d2073cStracker-user } 21843d2073cStracker-user 21943d2073cStracker-user // --------------------------------------------------------------------- 22043d2073cStracker-user // Annotation CRUD 22143d2073cStracker-user // --------------------------------------------------------------------- 22243d2073cStracker-user 22343d2073cStracker-user /** 22443d2073cStracker-user * Create an annotation. 22543d2073cStracker-user * 22643d2073cStracker-user * @param string $id page id 22743d2073cStracker-user * @param array $anchor raw anchor {exact, prefix, suffix, start} 22843d2073cStracker-user * @param string $author username 22943d2073cStracker-user * @param string $body annotation text 23043d2073cStracker-user * @return array|false the created annotation, or false on invalid input 23143d2073cStracker-user */ 23243d2073cStracker-user public function createAnnotation($id, $anchor, $author, $body) 23343d2073cStracker-user { 23443d2073cStracker-user if ($id === '' || $author === '' || $author === null) { 23543d2073cStracker-user return false; 23643d2073cStracker-user } 23743d2073cStracker-user $body = $this->cleanBody($body); 23843d2073cStracker-user if ($body === '') { 23943d2073cStracker-user return false; 24043d2073cStracker-user } 24143d2073cStracker-user $anchor = $this->cleanAnchor($anchor); 24243d2073cStracker-user if ($anchor === null) { 24343d2073cStracker-user return false; 24443d2073cStracker-user } 24543d2073cStracker-user 24643d2073cStracker-user $now = time(); 24743d2073cStracker-user $new = [ 24843d2073cStracker-user 'id' => $this->newId(), 24943d2073cStracker-user 'anchor' => $anchor, 25043d2073cStracker-user 'author' => $author, 25143d2073cStracker-user 'created' => $now, 25243d2073cStracker-user 'modified' => $now, 25343d2073cStracker-user 'body' => $body, 25443d2073cStracker-user 'status' => 'open', 25543d2073cStracker-user 'resolved_by' => '', 25643d2073cStracker-user 'resolved_at' => 0, 25743d2073cStracker-user 'replies' => [], 25843d2073cStracker-user ]; 25943d2073cStracker-user 26043d2073cStracker-user return $this->mutate($id, function (array &$annotations) use ($new) { 26143d2073cStracker-user $annotations[] = $new; 26243d2073cStracker-user return $new; 26343d2073cStracker-user }); 26443d2073cStracker-user } 26543d2073cStracker-user 26643d2073cStracker-user /** 26743d2073cStracker-user * Edit an annotation's body text. 26843d2073cStracker-user * 26943d2073cStracker-user * @param string $id page id 27043d2073cStracker-user * @param string $annId annotation id 27143d2073cStracker-user * @param string $body new text 27243d2073cStracker-user * @return bool 27343d2073cStracker-user */ 27443d2073cStracker-user public function updateAnnotationBody($id, $annId, $body) 27543d2073cStracker-user { 27643d2073cStracker-user $body = $this->cleanBody($body); 27743d2073cStracker-user if ($body === '') { 27843d2073cStracker-user return false; 27943d2073cStracker-user } 28043d2073cStracker-user return (bool) $this->mutate($id, function (array &$annotations) use ($annId, $body) { 28143d2073cStracker-user foreach ($annotations as $i => $a) { 28243d2073cStracker-user if (($a['id'] ?? '') === $annId) { 28343d2073cStracker-user $annotations[$i]['body'] = $body; 28443d2073cStracker-user $annotations[$i]['modified'] = time(); 28543d2073cStracker-user return true; 28643d2073cStracker-user } 28743d2073cStracker-user } 28843d2073cStracker-user return false; 28943d2073cStracker-user }); 29043d2073cStracker-user } 29143d2073cStracker-user 29243d2073cStracker-user /** 29343d2073cStracker-user * Delete an annotation and all its replies. 29443d2073cStracker-user * 29543d2073cStracker-user * @param string $id page id 29643d2073cStracker-user * @param string $annId annotation id 29743d2073cStracker-user * @return bool 29843d2073cStracker-user */ 29943d2073cStracker-user public function deleteAnnotation($id, $annId) 30043d2073cStracker-user { 30143d2073cStracker-user return (bool) $this->mutate($id, function (array &$annotations) use ($annId) { 30243d2073cStracker-user foreach ($annotations as $i => $a) { 30343d2073cStracker-user if (($a['id'] ?? '') === $annId) { 30443d2073cStracker-user array_splice($annotations, $i, 1); 30543d2073cStracker-user return true; 30643d2073cStracker-user } 30743d2073cStracker-user } 30843d2073cStracker-user return false; 30943d2073cStracker-user }); 31043d2073cStracker-user } 31143d2073cStracker-user 31243d2073cStracker-user /** 31343d2073cStracker-user * Mark an annotation open or resolved. 31443d2073cStracker-user * 31543d2073cStracker-user * @param string $id page id 31643d2073cStracker-user * @param string $annId annotation id 31743d2073cStracker-user * @param string $status 'open' or 'resolved' 31843d2073cStracker-user * @param string $actor username making the change (recorded when resolving) 31943d2073cStracker-user * @return bool 32043d2073cStracker-user */ 32143d2073cStracker-user public function setStatus($id, $annId, $status, $actor) 32243d2073cStracker-user { 32343d2073cStracker-user if (!in_array($status, ['open', 'resolved'], true)) { 32443d2073cStracker-user return false; 32543d2073cStracker-user } 32643d2073cStracker-user return (bool) $this->mutate($id, function (array &$annotations) use ($annId, $status, $actor) { 32743d2073cStracker-user foreach ($annotations as $i => $a) { 32843d2073cStracker-user if (($a['id'] ?? '') === $annId) { 32943d2073cStracker-user $annotations[$i]['status'] = $status; 33043d2073cStracker-user if ($status === 'resolved') { 33143d2073cStracker-user $annotations[$i]['resolved_by'] = $actor; 33243d2073cStracker-user $annotations[$i]['resolved_at'] = time(); 33343d2073cStracker-user } else { 33443d2073cStracker-user $annotations[$i]['resolved_by'] = ''; 33543d2073cStracker-user $annotations[$i]['resolved_at'] = 0; 33643d2073cStracker-user } 33743d2073cStracker-user return true; 33843d2073cStracker-user } 33943d2073cStracker-user } 34043d2073cStracker-user return false; 34143d2073cStracker-user }); 34243d2073cStracker-user } 34343d2073cStracker-user 34443d2073cStracker-user // --------------------------------------------------------------------- 34543d2073cStracker-user // Reply CRUD 34643d2073cStracker-user // --------------------------------------------------------------------- 34743d2073cStracker-user 34843d2073cStracker-user /** 34943d2073cStracker-user * Add a reply to an annotation. 35043d2073cStracker-user * 35143d2073cStracker-user * @param string $id page id 35243d2073cStracker-user * @param string $annId annotation id 35343d2073cStracker-user * @param string $author username 35443d2073cStracker-user * @param string $body reply text 355ee9dbf15Stracker-user * @param string $parentId id of the reply being replied to, or '' for root-level 35643d2073cStracker-user * @return array|false the created reply, or false on invalid input 35743d2073cStracker-user */ 358ee9dbf15Stracker-user public function addReply($id, $annId, $author, $body, $parentId = '') 35943d2073cStracker-user { 36043d2073cStracker-user if ($author === '' || $author === null) { 36143d2073cStracker-user return false; 36243d2073cStracker-user } 36343d2073cStracker-user $body = $this->cleanBody($body); 36443d2073cStracker-user if ($body === '') { 36543d2073cStracker-user return false; 36643d2073cStracker-user } 36743d2073cStracker-user $now = time(); 36843d2073cStracker-user $reply = [ 36943d2073cStracker-user 'id' => $this->newId(), 370ee9dbf15Stracker-user 'parentId' => preg_replace('/[^a-f0-9]/', '', (string) $parentId), 37143d2073cStracker-user 'author' => $author, 37243d2073cStracker-user 'created' => $now, 37343d2073cStracker-user 'modified' => $now, 37443d2073cStracker-user 'body' => $body, 37543d2073cStracker-user ]; 37643d2073cStracker-user 37743d2073cStracker-user return $this->mutate($id, function (array &$annotations) use ($annId, $reply) { 37843d2073cStracker-user foreach ($annotations as $i => $a) { 37943d2073cStracker-user if (($a['id'] ?? '') === $annId) { 38043d2073cStracker-user $annotations[$i]['replies'][] = $reply; 38143d2073cStracker-user return $reply; 38243d2073cStracker-user } 38343d2073cStracker-user } 38443d2073cStracker-user return false; 38543d2073cStracker-user }); 38643d2073cStracker-user } 38743d2073cStracker-user 38843d2073cStracker-user /** 38943d2073cStracker-user * Edit a reply's body text. 39043d2073cStracker-user * 39143d2073cStracker-user * @param string $id page id 39243d2073cStracker-user * @param string $annId annotation id 39343d2073cStracker-user * @param string $replyId reply id 39443d2073cStracker-user * @param string $body new text 39543d2073cStracker-user * @return bool 39643d2073cStracker-user */ 39743d2073cStracker-user public function updateReply($id, $annId, $replyId, $body) 39843d2073cStracker-user { 39943d2073cStracker-user $body = $this->cleanBody($body); 40043d2073cStracker-user if ($body === '') { 40143d2073cStracker-user return false; 40243d2073cStracker-user } 40343d2073cStracker-user return (bool) $this->mutate($id, function (array &$annotations) use ($annId, $replyId, $body) { 40443d2073cStracker-user foreach ($annotations as $i => $a) { 40543d2073cStracker-user if (($a['id'] ?? '') !== $annId) { 40643d2073cStracker-user continue; 40743d2073cStracker-user } 40843d2073cStracker-user foreach (($a['replies'] ?? []) as $j => $r) { 40943d2073cStracker-user if (($r['id'] ?? '') === $replyId) { 41043d2073cStracker-user $annotations[$i]['replies'][$j]['body'] = $body; 41143d2073cStracker-user $annotations[$i]['replies'][$j]['modified'] = time(); 41243d2073cStracker-user return true; 41343d2073cStracker-user } 41443d2073cStracker-user } 41543d2073cStracker-user } 41643d2073cStracker-user return false; 41743d2073cStracker-user }); 41843d2073cStracker-user } 41943d2073cStracker-user 42043d2073cStracker-user /** 42143d2073cStracker-user * Delete a reply. 42243d2073cStracker-user * 42343d2073cStracker-user * @param string $id page id 42443d2073cStracker-user * @param string $annId annotation id 42543d2073cStracker-user * @param string $replyId reply id 42643d2073cStracker-user * @return bool 42743d2073cStracker-user */ 42843d2073cStracker-user public function deleteReply($id, $annId, $replyId) 42943d2073cStracker-user { 43043d2073cStracker-user return (bool) $this->mutate($id, function (array &$annotations) use ($annId, $replyId) { 43143d2073cStracker-user foreach ($annotations as $i => $a) { 43243d2073cStracker-user if (($a['id'] ?? '') !== $annId) { 43343d2073cStracker-user continue; 43443d2073cStracker-user } 43543d2073cStracker-user foreach (($a['replies'] ?? []) as $j => $r) { 43643d2073cStracker-user if (($r['id'] ?? '') === $replyId) { 43743d2073cStracker-user array_splice($annotations[$i]['replies'], $j, 1); 43843d2073cStracker-user return true; 43943d2073cStracker-user } 44043d2073cStracker-user } 44143d2073cStracker-user } 44243d2073cStracker-user return false; 44343d2073cStracker-user }); 44443d2073cStracker-user } 44543d2073cStracker-user 44643d2073cStracker-user // --------------------------------------------------------------------- 44743d2073cStracker-user // Bulk maintenance (admin, per page) 44843d2073cStracker-user // --------------------------------------------------------------------- 44943d2073cStracker-user 45043d2073cStracker-user /** 45143d2073cStracker-user * Remove every resolved annotation from a page. 45243d2073cStracker-user * 45343d2073cStracker-user * @param string $id page id 45443d2073cStracker-user * @return int|false number removed, or false on write failure 45543d2073cStracker-user */ 45643d2073cStracker-user public function clearResolved($id) 45743d2073cStracker-user { 45843d2073cStracker-user if (empty($this->getAnnotations($id))) { 45943d2073cStracker-user return 0; 46043d2073cStracker-user } 46143d2073cStracker-user return $this->mutate($id, function (array &$annotations) { 46243d2073cStracker-user $before = count($annotations); 46343d2073cStracker-user $annotations = array_values(array_filter($annotations, function ($a) { 46443d2073cStracker-user return ($a['status'] ?? 'open') !== 'resolved'; 46543d2073cStracker-user })); 46643d2073cStracker-user return $before - count($annotations); 46743d2073cStracker-user }); 46843d2073cStracker-user } 46943d2073cStracker-user 47043d2073cStracker-user /** 471*72d60f2dStracker-user * Remove every resolved annotation from every annotated page. 472*72d60f2dStracker-user * 473*72d60f2dStracker-user * The wiki-wide companion to clearResolved(), mirroring clearOrphanedAll(): 474*72d60f2dStracker-user * iterates the pages found by getAnnotatedPages() and applies the per-page 475*72d60f2dStracker-user * clearResolved() to each. A page whose write fails is skipped rather than 476*72d60f2dStracker-user * aborting the whole sweep. 477*72d60f2dStracker-user * 478*72d60f2dStracker-user * @return int total number of annotations removed across all pages 479*72d60f2dStracker-user */ 480*72d60f2dStracker-user public function clearResolvedAll() 481*72d60f2dStracker-user { 482*72d60f2dStracker-user $removed = 0; 483*72d60f2dStracker-user foreach ($this->getAnnotatedPages() as $id) { 484*72d60f2dStracker-user $n = $this->clearResolved($id); 485*72d60f2dStracker-user if ($n !== false) { 486*72d60f2dStracker-user $removed += (int) $n; 487*72d60f2dStracker-user } 488*72d60f2dStracker-user } 489*72d60f2dStracker-user return $removed; 490*72d60f2dStracker-user } 491*72d60f2dStracker-user 492*72d60f2dStracker-user /** 49343d2073cStracker-user * Remove every orphaned annotation from a page — those whose quoted text 49443d2073cStracker-user * no longer appears in the rendered page. The page is re-checked here, so 49543d2073cStracker-user * this is authoritative regardless of what a client believed. 49643d2073cStracker-user * 49743d2073cStracker-user * @param string $id page id 49843d2073cStracker-user * @return int|false number removed, or false on write failure 49943d2073cStracker-user */ 50043d2073cStracker-user public function clearOrphaned($id) 50143d2073cStracker-user { 50243d2073cStracker-user $orphanIds = []; 50343d2073cStracker-user foreach ($this->findOrphaned($id) as $a) { 50443d2073cStracker-user $orphanIds[] = $a['id']; 50543d2073cStracker-user } 50643d2073cStracker-user if (empty($orphanIds)) { 50743d2073cStracker-user return 0; 50843d2073cStracker-user } 50943d2073cStracker-user return $this->mutate($id, function (array &$annotations) use ($orphanIds) { 51043d2073cStracker-user $before = count($annotations); 51143d2073cStracker-user $annotations = array_values(array_filter($annotations, function ($a) use ($orphanIds) { 51243d2073cStracker-user return !in_array($a['id'] ?? '', $orphanIds, true); 51343d2073cStracker-user })); 51443d2073cStracker-user return $before - count($annotations); 51543d2073cStracker-user }); 51643d2073cStracker-user } 51743d2073cStracker-user 5189fd890c3Stracker-user /** 5199fd890c3Stracker-user * Remove every orphaned annotation from every annotated page. 5209fd890c3Stracker-user * 5219fd890c3Stracker-user * Iterates the pages found by getAnnotatedPages() and applies the per-page 5229fd890c3Stracker-user * clearOrphaned() to each, so the same authoritative re-check runs for 5239fd890c3Stracker-user * every page. A page whose write fails is skipped rather than aborting the 5249fd890c3Stracker-user * whole sweep. 5259fd890c3Stracker-user * 5269fd890c3Stracker-user * @return int total number of annotations removed across all pages 5279fd890c3Stracker-user */ 5289fd890c3Stracker-user public function clearOrphanedAll() 5299fd890c3Stracker-user { 5309fd890c3Stracker-user $removed = 0; 5319fd890c3Stracker-user foreach ($this->getAnnotatedPages() as $id) { 5329fd890c3Stracker-user $n = $this->clearOrphaned($id); 5339fd890c3Stracker-user if ($n !== false) { 5349fd890c3Stracker-user $removed += (int) $n; 5359fd890c3Stracker-user } 5369fd890c3Stracker-user } 5379fd890c3Stracker-user return $removed; 5389fd890c3Stracker-user } 5399fd890c3Stracker-user 54043d2073cStracker-user // --------------------------------------------------------------------- 54143d2073cStracker-user // Orphan detection 54243d2073cStracker-user // --------------------------------------------------------------------- 54343d2073cStracker-user 54443d2073cStracker-user /** 54543d2073cStracker-user * Render a page to normalised plain text, for quote searching. 54643d2073cStracker-user * 54743d2073cStracker-user * Block-level closing tags become spaces so adjacent blocks do not fuse 54843d2073cStracker-user * into one run of text; then tags are stripped, entities decoded, and 54943d2073cStracker-user * whitespace collapsed — the same normalisation applied to stored quotes. 55043d2073cStracker-user * 55143d2073cStracker-user * @param string $id page id 55243d2073cStracker-user * @return string 55343d2073cStracker-user */ 55443d2073cStracker-user public function getPageText($id) 55543d2073cStracker-user { 55643d2073cStracker-user if (!page_exists($id)) { 55743d2073cStracker-user return ''; 55843d2073cStracker-user } 55943d2073cStracker-user $xhtml = p_wiki_xhtml($id, '', false); 56043d2073cStracker-user if (!is_string($xhtml) || $xhtml === '') { 56143d2073cStracker-user return ''; 56243d2073cStracker-user } 56343d2073cStracker-user $xhtml = preg_replace('#</(p|div|li|h[1-6]|td|th|tr|blockquote|pre|dt|dd)>#i', ' ', $xhtml); 56443d2073cStracker-user $xhtml = preg_replace('#<br\s*/?>#i', ' ', $xhtml); 56543d2073cStracker-user $text = strip_tags($xhtml); 56643d2073cStracker-user $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); 56743d2073cStracker-user return $this->normalizeWhitespace($text); 56843d2073cStracker-user } 56943d2073cStracker-user 57043d2073cStracker-user /** 57143d2073cStracker-user * The annotations on a page whose quoted text is no longer present. 57243d2073cStracker-user * 57343d2073cStracker-user * @param string $id page id 57443d2073cStracker-user * @return array list of orphaned annotation arrays 57543d2073cStracker-user */ 57643d2073cStracker-user public function findOrphaned($id) 57743d2073cStracker-user { 57843d2073cStracker-user $annotations = $this->getAnnotations($id); 57943d2073cStracker-user if (empty($annotations)) { 58043d2073cStracker-user return []; 58143d2073cStracker-user } 58243d2073cStracker-user $pageText = $this->getPageText($id); 58343d2073cStracker-user 58443d2073cStracker-user $orphaned = []; 58543d2073cStracker-user foreach ($annotations as $a) { 5869fd890c3Stracker-user if ($this->quoteMissing($a, $pageText)) { 58743d2073cStracker-user $orphaned[] = $a; 58843d2073cStracker-user } 58943d2073cStracker-user } 59043d2073cStracker-user return $orphaned; 59143d2073cStracker-user } 59243d2073cStracker-user 5939fd890c3Stracker-user /** 5949fd890c3Stracker-user * Whether an annotation's quoted text is absent from the page text. 5959fd890c3Stracker-user * 5969fd890c3Stracker-user * The single orphan rule, shared by findOrphaned() and pageCounts(): an 5979fd890c3Stracker-user * annotation is orphaned when its (normalised) quoted text no longer 5989fd890c3Stracker-user * appears in the (normalised) rendered page text. 5999fd890c3Stracker-user * 6009fd890c3Stracker-user * @param array $annotation annotation array 6019fd890c3Stracker-user * @param string $pageText normalised plain-text page body (see getPageText) 6029fd890c3Stracker-user * @return bool 6039fd890c3Stracker-user */ 6049fd890c3Stracker-user protected function quoteMissing(array $annotation, $pageText) 6059fd890c3Stracker-user { 6069fd890c3Stracker-user $exact = $this->normalizeWhitespace($annotation['anchor']['exact'] ?? ''); 6079fd890c3Stracker-user return $exact === '' || mb_strpos($pageText, $exact) === false; 6089fd890c3Stracker-user } 6099fd890c3Stracker-user 6109fd890c3Stracker-user // --------------------------------------------------------------------- 6119fd890c3Stracker-user // Admin overview (enumeration & counts) 6129fd890c3Stracker-user // --------------------------------------------------------------------- 6139fd890c3Stracker-user 6149fd890c3Stracker-user /** 6159fd890c3Stracker-user * Every page that currently has at least one stored annotation. 6169fd890c3Stracker-user * 6179fd890c3Stracker-user * Scans the meta directory for ".annotations" files and maps each back to a 6189fd890c3Stracker-user * page id. Files left behind with an empty annotation list (every 6199fd890c3Stracker-user * annotation since deleted) are skipped, so the result matches what the 6209fd890c3Stracker-user * admin overview shows. 6219fd890c3Stracker-user * 6229fd890c3Stracker-user * @return string[] page ids, in the natural order search() yields 6239fd890c3Stracker-user */ 6249fd890c3Stracker-user public function getAnnotatedPages() 6259fd890c3Stracker-user { 6269fd890c3Stracker-user global $conf; 6279fd890c3Stracker-user $dir = $conf['metadir']; 6289fd890c3Stracker-user if (!is_dir($dir)) { 6299fd890c3Stracker-user return []; 6309fd890c3Stracker-user } 6319fd890c3Stracker-user $found = []; 6329fd890c3Stracker-user search($found, $dir, [$this, 'searchAnnotations'], []); 6339fd890c3Stracker-user 6349fd890c3Stracker-user $ids = []; 6359fd890c3Stracker-user foreach ($found as $item) { 6369fd890c3Stracker-user if (!empty($this->getAnnotations($item['id']))) { 6379fd890c3Stracker-user $ids[] = $item['id']; 6389fd890c3Stracker-user } 6399fd890c3Stracker-user } 6409fd890c3Stracker-user return $ids; 6419fd890c3Stracker-user } 6429fd890c3Stracker-user 6439fd890c3Stracker-user /** 6449fd890c3Stracker-user * search() callback collecting ".annotations" files as page ids. 6459fd890c3Stracker-user * 6469fd890c3Stracker-user * Directories are always traversed; files are matched on the extension and 6479fd890c3Stracker-user * the derived id is validated with cleanID() so anything that is not a real 6489fd890c3Stracker-user * page id is skipped. 6499fd890c3Stracker-user * 6509fd890c3Stracker-user * @param array $data &$ result accumulator (each entry ['id' => string]) 6519fd890c3Stracker-user * @param string $base search root (the meta directory) 6529fd890c3Stracker-user * @param string $file current path relative to $base (leading slash) 6539fd890c3Stracker-user * @param string $type 'd' for directory, 'f' for file 6549fd890c3Stracker-user * @param int $lvl recursion depth 6559fd890c3Stracker-user * @param array $opts options passed to search() (unused) 6569fd890c3Stracker-user * @return bool whether search() should recurse into a directory 6579fd890c3Stracker-user */ 6589fd890c3Stracker-user public function searchAnnotations(&$data, $base, $file, $type, $lvl, $opts) 6599fd890c3Stracker-user { 6609fd890c3Stracker-user if ($type === 'd') { 6619fd890c3Stracker-user return true; // recurse into namespaces 6629fd890c3Stracker-user } 6639fd890c3Stracker-user if (!str_ends_with($file, '.annotations')) { 6649fd890c3Stracker-user return false; 6659fd890c3Stracker-user } 6669fd890c3Stracker-user $id = pathID(substr($file, 0, -strlen('.annotations'))); 6679fd890c3Stracker-user if ($id === '' || $id !== cleanID($id)) { 6689fd890c3Stracker-user return false; 6699fd890c3Stracker-user } 6709fd890c3Stracker-user $data[] = ['id' => $id]; 6719fd890c3Stracker-user return false; 6729fd890c3Stracker-user } 6739fd890c3Stracker-user 6749fd890c3Stracker-user /** 675*72d60f2dStracker-user * Annotation counts for one page, for the admin overview. 6769fd890c3Stracker-user * 6779fd890c3Stracker-user * The page is rendered once (getPageText) and every annotation tested with 6789fd890c3Stracker-user * the shared quoteMissing() rule, so "normal" here means exactly "not 679*72d60f2dStracker-user * orphaned" — the same definition the per-page clear-orphaned uses. 680*72d60f2dStracker-user * 681*72d60f2dStracker-user * "resolved" counts every annotation whose status is resolved, regardless of 682*72d60f2dStracker-user * whether it is also orphaned, so it matches exactly what clearResolved() 683*72d60f2dStracker-user * removes. The facets therefore overlap: a resolved-and-present annotation is 684*72d60f2dStracker-user * counted in both "normal" and "resolved"; a resolved-and-orphaned one in 685*72d60f2dStracker-user * both "orphaned" and "resolved". 6869fd890c3Stracker-user * 6879fd890c3Stracker-user * @param string $id page id 688*72d60f2dStracker-user * @return array ['total'=>int, 'normal'=>int, 'resolved'=>int, 'orphaned'=>int] 6899fd890c3Stracker-user */ 6909fd890c3Stracker-user public function pageCounts($id) 6919fd890c3Stracker-user { 6929fd890c3Stracker-user $annotations = $this->getAnnotations($id); 6939fd890c3Stracker-user $total = count($annotations); 6949fd890c3Stracker-user if ($total === 0) { 695*72d60f2dStracker-user return ['total' => 0, 'normal' => 0, 'resolved' => 0, 'orphaned' => 0]; 6969fd890c3Stracker-user } 6979fd890c3Stracker-user $pageText = $this->getPageText($id); 6989fd890c3Stracker-user $orphaned = 0; 699*72d60f2dStracker-user $resolved = 0; 7009fd890c3Stracker-user foreach ($annotations as $a) { 7019fd890c3Stracker-user if ($this->quoteMissing($a, $pageText)) { 7029fd890c3Stracker-user $orphaned++; 7039fd890c3Stracker-user } 704*72d60f2dStracker-user if (($a['status'] ?? 'open') === 'resolved') { 705*72d60f2dStracker-user $resolved++; 7069fd890c3Stracker-user } 707*72d60f2dStracker-user } 708*72d60f2dStracker-user return [ 709*72d60f2dStracker-user 'total' => $total, 710*72d60f2dStracker-user 'normal' => $total - $orphaned, 711*72d60f2dStracker-user 'resolved' => $resolved, 712*72d60f2dStracker-user 'orphaned' => $orphaned, 713*72d60f2dStracker-user ]; 7149fd890c3Stracker-user } 7159fd890c3Stracker-user 71643d2073cStracker-user // --------------------------------------------------------------------- 71743d2073cStracker-user // Permission rules (single source of truth) 71843d2073cStracker-user // --------------------------------------------------------------------- 71943d2073cStracker-user 72043d2073cStracker-user /** 72143d2073cStracker-user * May this user create an annotation, reply, or change a resolve status? 72243d2073cStracker-user * 72343d2073cStracker-user * Requires only read access to the page — annotations are out-of-band, so 72443d2073cStracker-user * a user whose page edit access is blocked may still annotate. 72543d2073cStracker-user * 72643d2073cStracker-user * @param string $user current username ('' for anonymous) 72743d2073cStracker-user * @param int $aclLevel the user's ACL level on the page 72843d2073cStracker-user * @return bool 72943d2073cStracker-user */ 73043d2073cStracker-user public function canAnnotate($user, $aclLevel) 73143d2073cStracker-user { 73243d2073cStracker-user return $user !== '' && $user !== null && $aclLevel >= AUTH_READ; 73343d2073cStracker-user } 73443d2073cStracker-user 73543d2073cStracker-user /** 73643d2073cStracker-user * May this user edit or delete the given annotation? Author or admin. 73743d2073cStracker-user * 73843d2073cStracker-user * @param array $annotation 73943d2073cStracker-user * @param string $user 74043d2073cStracker-user * @param bool $isAdmin 74143d2073cStracker-user * @return bool 74243d2073cStracker-user */ 74343d2073cStracker-user public function canEditAnnotation(array $annotation, $user, $isAdmin) 74443d2073cStracker-user { 74543d2073cStracker-user if ($user === '' || $user === null) { 74643d2073cStracker-user return false; 74743d2073cStracker-user } 74843d2073cStracker-user return $isAdmin || (($annotation['author'] ?? '') === $user); 74943d2073cStracker-user } 75043d2073cStracker-user 75143d2073cStracker-user /** 75243d2073cStracker-user * May this user edit or delete the given reply? Author or admin. 75343d2073cStracker-user * 75443d2073cStracker-user * @param array $reply 75543d2073cStracker-user * @param string $user 75643d2073cStracker-user * @param bool $isAdmin 75743d2073cStracker-user * @return bool 75843d2073cStracker-user */ 75943d2073cStracker-user public function canEditReply(array $reply, $user, $isAdmin) 76043d2073cStracker-user { 76143d2073cStracker-user if ($user === '' || $user === null) { 76243d2073cStracker-user return false; 76343d2073cStracker-user } 76443d2073cStracker-user return $isAdmin || (($reply['author'] ?? '') === $user); 76543d2073cStracker-user } 76643d2073cStracker-user 76743d2073cStracker-user /** 76843d2073cStracker-user * May this user run the per-page "clear resolved/orphaned" operations? 76943d2073cStracker-user * Admins only. 77043d2073cStracker-user * 77143d2073cStracker-user * @param bool $isAdmin 77243d2073cStracker-user * @return bool 77343d2073cStracker-user */ 77443d2073cStracker-user public function canClear($isAdmin) 77543d2073cStracker-user { 77643d2073cStracker-user return (bool) $isAdmin; 77743d2073cStracker-user } 77843d2073cStracker-user 77943d2073cStracker-user // --------------------------------------------------------------------- 78043d2073cStracker-user // Input cleaning 78143d2073cStracker-user // --------------------------------------------------------------------- 78243d2073cStracker-user 78343d2073cStracker-user /** 78443d2073cStracker-user * Validate and normalise a raw anchor. 78543d2073cStracker-user * 78643d2073cStracker-user * @param mixed $anchor 78743d2073cStracker-user * @return array|null the cleaned anchor, or null if unusable 78843d2073cStracker-user */ 78943d2073cStracker-user protected function cleanAnchor($anchor) 79043d2073cStracker-user { 79143d2073cStracker-user if (!is_array($anchor)) { 79243d2073cStracker-user return null; 79343d2073cStracker-user } 79443d2073cStracker-user 79543d2073cStracker-user $exact = (isset($anchor['exact']) && is_string($anchor['exact'])) 79643d2073cStracker-user ? $this->normalizeWhitespace($anchor['exact']) 79743d2073cStracker-user : ''; 79843d2073cStracker-user if ($exact === '') { 79943d2073cStracker-user return null; // an anchor without quoted text is unusable 80043d2073cStracker-user } 80143d2073cStracker-user if (mb_strlen($exact) > self::MAX_QUOTE) { 80243d2073cStracker-user $exact = mb_substr($exact, 0, self::MAX_QUOTE); 80343d2073cStracker-user } 80443d2073cStracker-user 80543d2073cStracker-user $prefix = (isset($anchor['prefix']) && is_string($anchor['prefix'])) 80643d2073cStracker-user ? $this->normalizeWhitespace($anchor['prefix']) 80743d2073cStracker-user : ''; 80843d2073cStracker-user $suffix = (isset($anchor['suffix']) && is_string($anchor['suffix'])) 80943d2073cStracker-user ? $this->normalizeWhitespace($anchor['suffix']) 81043d2073cStracker-user : ''; 81186c7806dStracker-user $ctx = $this->contextLength(); 81286c7806dStracker-user if (mb_strlen($prefix) > $ctx) { 81386c7806dStracker-user $prefix = mb_substr($prefix, -$ctx); 81443d2073cStracker-user } 81586c7806dStracker-user if (mb_strlen($suffix) > $ctx) { 81686c7806dStracker-user $suffix = mb_substr($suffix, 0, $ctx); 81743d2073cStracker-user } 81843d2073cStracker-user 81943d2073cStracker-user $start = isset($anchor['start']) ? max(0, (int) $anchor['start']) : 0; 82043d2073cStracker-user 82143d2073cStracker-user return [ 82243d2073cStracker-user 'exact' => $exact, 82343d2073cStracker-user 'prefix' => $prefix, 82443d2073cStracker-user 'suffix' => $suffix, 82543d2073cStracker-user 'start' => $start, 82643d2073cStracker-user ]; 82743d2073cStracker-user } 82843d2073cStracker-user 82943d2073cStracker-user /** 83043d2073cStracker-user * Clean an annotation/reply body: a plain-text string, trimmed, with 83143d2073cStracker-user * normalised line endings and a length cap. Newlines are kept; the text 83243d2073cStracker-user * is escaped by the consumer at render time. 83343d2073cStracker-user * 83443d2073cStracker-user * @param mixed $body 83543d2073cStracker-user * @return string 83643d2073cStracker-user */ 83743d2073cStracker-user protected function cleanBody($body) 83843d2073cStracker-user { 83943d2073cStracker-user if (!is_string($body)) { 84043d2073cStracker-user return ''; 84143d2073cStracker-user } 84243d2073cStracker-user $body = str_replace("\r\n", "\n", $body); 84343d2073cStracker-user $body = str_replace("\r", "\n", $body); 84443d2073cStracker-user $body = trim($body); 84586c7806dStracker-user $cap = $this->bodyCap(); 84686c7806dStracker-user if (mb_strlen($body) > $cap) { 84786c7806dStracker-user $body = mb_substr($body, 0, $cap); 84843d2073cStracker-user } 84943d2073cStracker-user return $body; 85043d2073cStracker-user } 85143d2073cStracker-user 85243d2073cStracker-user /** 85343d2073cStracker-user * Collapse every run of whitespace to a single space and trim. 85443d2073cStracker-user * 85543d2073cStracker-user * @param mixed $text 85643d2073cStracker-user * @return string 85743d2073cStracker-user */ 85843d2073cStracker-user protected function normalizeWhitespace($text) 85943d2073cStracker-user { 86043d2073cStracker-user return trim(preg_replace('/\s+/u', ' ', (string) $text)); 86143d2073cStracker-user } 86243d2073cStracker-user 86343d2073cStracker-user /** 86443d2073cStracker-user * A fresh identifier for an annotation or reply. 86543d2073cStracker-user * 86643d2073cStracker-user * @return string 16 hex characters 86743d2073cStracker-user */ 86843d2073cStracker-user protected function newId() 86943d2073cStracker-user { 87043d2073cStracker-user return bin2hex(random_bytes(8)); 87143d2073cStracker-user } 87243d2073cStracker-user} 873