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 4443d2073cStracker-user /** length of the prefix/suffix context slices, in characters */ 4543d2073cStracker-user const MAX_CONTEXT = 64; 4643d2073cStracker-user 4743d2073cStracker-user /** longest annotation/reply body, in characters */ 4843d2073cStracker-user const MAX_BODY = 10000; 4943d2073cStracker-user 5043d2073cStracker-user // --------------------------------------------------------------------- 5143d2073cStracker-user // Storage 5243d2073cStracker-user // --------------------------------------------------------------------- 5343d2073cStracker-user 5443d2073cStracker-user /** 5543d2073cStracker-user * Path of a page's annotation file. 5643d2073cStracker-user * 5743d2073cStracker-user * @param string $id page id 5843d2073cStracker-user * @return string 5943d2073cStracker-user */ 6043d2073cStracker-user protected function getFile($id) 6143d2073cStracker-user { 6243d2073cStracker-user return metaFN($id, '.annotations'); 6343d2073cStracker-user } 6443d2073cStracker-user 6543d2073cStracker-user /** 6643d2073cStracker-user * All annotations stored for a page. 6743d2073cStracker-user * 6843d2073cStracker-user * @param string $id page id 6943d2073cStracker-user * @return array list of annotation arrays (empty if none) 7043d2073cStracker-user */ 7143d2073cStracker-user public function getAnnotations($id) 7243d2073cStracker-user { 7343d2073cStracker-user $file = $this->getFile($id); 7443d2073cStracker-user if (!file_exists($file)) { 7543d2073cStracker-user return []; 7643d2073cStracker-user } 7743d2073cStracker-user $raw = io_readFile($file, false); 7843d2073cStracker-user if ($raw === '') { 7943d2073cStracker-user return []; 8043d2073cStracker-user } 8143d2073cStracker-user $data = json_decode($raw, true); 8243d2073cStracker-user if (!is_array($data) || !isset($data['annotations']) || !is_array($data['annotations'])) { 8343d2073cStracker-user return []; 8443d2073cStracker-user } 8543d2073cStracker-user return $data['annotations']; 8643d2073cStracker-user } 8743d2073cStracker-user 8843d2073cStracker-user /** 8943d2073cStracker-user * A single annotation by id. 9043d2073cStracker-user * 9143d2073cStracker-user * @param string $id page id 9243d2073cStracker-user * @param string $annId annotation id 9343d2073cStracker-user * @return array|null 9443d2073cStracker-user */ 9543d2073cStracker-user public function getAnnotation($id, $annId) 9643d2073cStracker-user { 9743d2073cStracker-user foreach ($this->getAnnotations($id) as $a) { 9843d2073cStracker-user if (($a['id'] ?? '') === $annId) { 9943d2073cStracker-user return $a; 10043d2073cStracker-user } 10143d2073cStracker-user } 10243d2073cStracker-user return null; 10343d2073cStracker-user } 10443d2073cStracker-user 10543d2073cStracker-user /** 10643d2073cStracker-user * Counts for the on-page indicator. The orphan count is deliberately not 10743d2073cStracker-user * here — it depends on the rendered page and is computed client-side. 10843d2073cStracker-user * 10943d2073cStracker-user * @param string $id page id 11043d2073cStracker-user * @return array ['total'=>int, 'open'=>int, 'resolved'=>int] 11143d2073cStracker-user */ 11243d2073cStracker-user public function getStats($id) 11343d2073cStracker-user { 114*108f92bdStracker-user return $this->statsFor($this->getAnnotations($id)); 115*108f92bdStracker-user } 116*108f92bdStracker-user 117*108f92bdStracker-user /** 118*108f92bdStracker-user * Counts for the on-page indicator, computed from an already-loaded list. 119*108f92bdStracker-user * Split out from getStats() so callers that already hold the annotation 120*108f92bdStracker-user * array (e.g. the page-load JSINFO injector, which embeds the same list) 121*108f92bdStracker-user * don't re-read the file. 122*108f92bdStracker-user * 123*108f92bdStracker-user * @param array $annotations annotation list 124*108f92bdStracker-user * @return array ['total'=>int, 'open'=>int, 'resolved'=>int] 125*108f92bdStracker-user */ 126*108f92bdStracker-user public function statsFor(array $annotations) 127*108f92bdStracker-user { 12843d2073cStracker-user $open = 0; 12943d2073cStracker-user $resolved = 0; 130*108f92bdStracker-user foreach ($annotations as $a) { 13143d2073cStracker-user if (($a['status'] ?? 'open') === 'resolved') { 13243d2073cStracker-user $resolved++; 13343d2073cStracker-user } else { 13443d2073cStracker-user $open++; 13543d2073cStracker-user } 13643d2073cStracker-user } 13743d2073cStracker-user return ['total' => $open + $resolved, 'open' => $open, 'resolved' => $resolved]; 13843d2073cStracker-user } 13943d2073cStracker-user 14043d2073cStracker-user /** 14143d2073cStracker-user * Write a page's annotation list to disk. 14243d2073cStracker-user * 14343d2073cStracker-user * @param string $id page id 14443d2073cStracker-user * @param array $list annotations 14543d2073cStracker-user * @return bool 14643d2073cStracker-user */ 14743d2073cStracker-user protected function writeFile($id, array $list) 14843d2073cStracker-user { 14943d2073cStracker-user $payload = [ 15043d2073cStracker-user 'version' => self::SCHEMA_VERSION, 15143d2073cStracker-user 'annotations' => array_values($list), 15243d2073cStracker-user ]; 153da56206cStracker-user return (bool) io_saveFile( 154da56206cStracker-user $this->getFile($id), 155da56206cStracker-user json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) 156da56206cStracker-user ); 15743d2073cStracker-user } 15843d2073cStracker-user 15943d2073cStracker-user /** 16043d2073cStracker-user * Run a modification against a page's annotations under a write lock. 16143d2073cStracker-user * 16243d2073cStracker-user * The modifier receives the annotation list by reference and returns an 16343d2073cStracker-user * outcome value. Returning the boolean false aborts the write (used for 16443d2073cStracker-user * "target not found"); any other value is returned to the caller after a 16543d2073cStracker-user * successful save. 16643d2073cStracker-user * 16743d2073cStracker-user * @param string $id page id 16843d2073cStracker-user * @param callable $modifier function(array &$annotations): mixed 16943d2073cStracker-user * @return mixed the modifier's outcome on success, or false on failure 17043d2073cStracker-user */ 17143d2073cStracker-user protected function mutate($id, callable $modifier) 17243d2073cStracker-user { 17343d2073cStracker-user $file = $this->getFile($id); 17443d2073cStracker-user io_lock($file); 17543d2073cStracker-user 17643d2073cStracker-user $annotations = $this->getAnnotations($id); 17743d2073cStracker-user $outcome = $modifier($annotations); 17843d2073cStracker-user 17943d2073cStracker-user if ($outcome === false) { 18043d2073cStracker-user io_unlock($file); 18143d2073cStracker-user return false; 18243d2073cStracker-user } 18343d2073cStracker-user 18443d2073cStracker-user $ok = $this->writeFile($id, $annotations); 18543d2073cStracker-user io_unlock($file); 18643d2073cStracker-user return $ok ? $outcome : false; 18743d2073cStracker-user } 18843d2073cStracker-user 18943d2073cStracker-user // --------------------------------------------------------------------- 19043d2073cStracker-user // Annotation CRUD 19143d2073cStracker-user // --------------------------------------------------------------------- 19243d2073cStracker-user 19343d2073cStracker-user /** 19443d2073cStracker-user * Create an annotation. 19543d2073cStracker-user * 19643d2073cStracker-user * @param string $id page id 19743d2073cStracker-user * @param array $anchor raw anchor {exact, prefix, suffix, start} 19843d2073cStracker-user * @param string $author username 19943d2073cStracker-user * @param string $body annotation text 20043d2073cStracker-user * @return array|false the created annotation, or false on invalid input 20143d2073cStracker-user */ 20243d2073cStracker-user public function createAnnotation($id, $anchor, $author, $body) 20343d2073cStracker-user { 20443d2073cStracker-user if ($id === '' || $author === '' || $author === null) { 20543d2073cStracker-user return false; 20643d2073cStracker-user } 20743d2073cStracker-user $body = $this->cleanBody($body); 20843d2073cStracker-user if ($body === '') { 20943d2073cStracker-user return false; 21043d2073cStracker-user } 21143d2073cStracker-user $anchor = $this->cleanAnchor($anchor); 21243d2073cStracker-user if ($anchor === null) { 21343d2073cStracker-user return false; 21443d2073cStracker-user } 21543d2073cStracker-user 21643d2073cStracker-user $now = time(); 21743d2073cStracker-user $new = [ 21843d2073cStracker-user 'id' => $this->newId(), 21943d2073cStracker-user 'anchor' => $anchor, 22043d2073cStracker-user 'author' => $author, 22143d2073cStracker-user 'created' => $now, 22243d2073cStracker-user 'modified' => $now, 22343d2073cStracker-user 'body' => $body, 22443d2073cStracker-user 'status' => 'open', 22543d2073cStracker-user 'resolved_by' => '', 22643d2073cStracker-user 'resolved_at' => 0, 22743d2073cStracker-user 'replies' => [], 22843d2073cStracker-user ]; 22943d2073cStracker-user 23043d2073cStracker-user return $this->mutate($id, function (array &$annotations) use ($new) { 23143d2073cStracker-user $annotations[] = $new; 23243d2073cStracker-user return $new; 23343d2073cStracker-user }); 23443d2073cStracker-user } 23543d2073cStracker-user 23643d2073cStracker-user /** 23743d2073cStracker-user * Edit an annotation's body text. 23843d2073cStracker-user * 23943d2073cStracker-user * @param string $id page id 24043d2073cStracker-user * @param string $annId annotation id 24143d2073cStracker-user * @param string $body new text 24243d2073cStracker-user * @return bool 24343d2073cStracker-user */ 24443d2073cStracker-user public function updateAnnotationBody($id, $annId, $body) 24543d2073cStracker-user { 24643d2073cStracker-user $body = $this->cleanBody($body); 24743d2073cStracker-user if ($body === '') { 24843d2073cStracker-user return false; 24943d2073cStracker-user } 25043d2073cStracker-user return (bool) $this->mutate($id, function (array &$annotations) use ($annId, $body) { 25143d2073cStracker-user foreach ($annotations as $i => $a) { 25243d2073cStracker-user if (($a['id'] ?? '') === $annId) { 25343d2073cStracker-user $annotations[$i]['body'] = $body; 25443d2073cStracker-user $annotations[$i]['modified'] = time(); 25543d2073cStracker-user return true; 25643d2073cStracker-user } 25743d2073cStracker-user } 25843d2073cStracker-user return false; 25943d2073cStracker-user }); 26043d2073cStracker-user } 26143d2073cStracker-user 26243d2073cStracker-user /** 26343d2073cStracker-user * Delete an annotation and all its replies. 26443d2073cStracker-user * 26543d2073cStracker-user * @param string $id page id 26643d2073cStracker-user * @param string $annId annotation id 26743d2073cStracker-user * @return bool 26843d2073cStracker-user */ 26943d2073cStracker-user public function deleteAnnotation($id, $annId) 27043d2073cStracker-user { 27143d2073cStracker-user return (bool) $this->mutate($id, function (array &$annotations) use ($annId) { 27243d2073cStracker-user foreach ($annotations as $i => $a) { 27343d2073cStracker-user if (($a['id'] ?? '') === $annId) { 27443d2073cStracker-user array_splice($annotations, $i, 1); 27543d2073cStracker-user return true; 27643d2073cStracker-user } 27743d2073cStracker-user } 27843d2073cStracker-user return false; 27943d2073cStracker-user }); 28043d2073cStracker-user } 28143d2073cStracker-user 28243d2073cStracker-user /** 28343d2073cStracker-user * Mark an annotation open or resolved. 28443d2073cStracker-user * 28543d2073cStracker-user * @param string $id page id 28643d2073cStracker-user * @param string $annId annotation id 28743d2073cStracker-user * @param string $status 'open' or 'resolved' 28843d2073cStracker-user * @param string $actor username making the change (recorded when resolving) 28943d2073cStracker-user * @return bool 29043d2073cStracker-user */ 29143d2073cStracker-user public function setStatus($id, $annId, $status, $actor) 29243d2073cStracker-user { 29343d2073cStracker-user if (!in_array($status, ['open', 'resolved'], true)) { 29443d2073cStracker-user return false; 29543d2073cStracker-user } 29643d2073cStracker-user return (bool) $this->mutate($id, function (array &$annotations) use ($annId, $status, $actor) { 29743d2073cStracker-user foreach ($annotations as $i => $a) { 29843d2073cStracker-user if (($a['id'] ?? '') === $annId) { 29943d2073cStracker-user $annotations[$i]['status'] = $status; 30043d2073cStracker-user if ($status === 'resolved') { 30143d2073cStracker-user $annotations[$i]['resolved_by'] = $actor; 30243d2073cStracker-user $annotations[$i]['resolved_at'] = time(); 30343d2073cStracker-user } else { 30443d2073cStracker-user $annotations[$i]['resolved_by'] = ''; 30543d2073cStracker-user $annotations[$i]['resolved_at'] = 0; 30643d2073cStracker-user } 30743d2073cStracker-user return true; 30843d2073cStracker-user } 30943d2073cStracker-user } 31043d2073cStracker-user return false; 31143d2073cStracker-user }); 31243d2073cStracker-user } 31343d2073cStracker-user 31443d2073cStracker-user // --------------------------------------------------------------------- 31543d2073cStracker-user // Reply CRUD 31643d2073cStracker-user // --------------------------------------------------------------------- 31743d2073cStracker-user 31843d2073cStracker-user /** 31943d2073cStracker-user * Add a reply to an annotation. 32043d2073cStracker-user * 32143d2073cStracker-user * @param string $id page id 32243d2073cStracker-user * @param string $annId annotation id 32343d2073cStracker-user * @param string $author username 32443d2073cStracker-user * @param string $body reply text 325ee9dbf15Stracker-user * @param string $parentId id of the reply being replied to, or '' for root-level 32643d2073cStracker-user * @return array|false the created reply, or false on invalid input 32743d2073cStracker-user */ 328ee9dbf15Stracker-user public function addReply($id, $annId, $author, $body, $parentId = '') 32943d2073cStracker-user { 33043d2073cStracker-user if ($author === '' || $author === null) { 33143d2073cStracker-user return false; 33243d2073cStracker-user } 33343d2073cStracker-user $body = $this->cleanBody($body); 33443d2073cStracker-user if ($body === '') { 33543d2073cStracker-user return false; 33643d2073cStracker-user } 33743d2073cStracker-user $now = time(); 33843d2073cStracker-user $reply = [ 33943d2073cStracker-user 'id' => $this->newId(), 340ee9dbf15Stracker-user 'parentId' => preg_replace('/[^a-f0-9]/', '', (string) $parentId), 34143d2073cStracker-user 'author' => $author, 34243d2073cStracker-user 'created' => $now, 34343d2073cStracker-user 'modified' => $now, 34443d2073cStracker-user 'body' => $body, 34543d2073cStracker-user ]; 34643d2073cStracker-user 34743d2073cStracker-user return $this->mutate($id, function (array &$annotations) use ($annId, $reply) { 34843d2073cStracker-user foreach ($annotations as $i => $a) { 34943d2073cStracker-user if (($a['id'] ?? '') === $annId) { 35043d2073cStracker-user $annotations[$i]['replies'][] = $reply; 35143d2073cStracker-user return $reply; 35243d2073cStracker-user } 35343d2073cStracker-user } 35443d2073cStracker-user return false; 35543d2073cStracker-user }); 35643d2073cStracker-user } 35743d2073cStracker-user 35843d2073cStracker-user /** 35943d2073cStracker-user * Edit a reply's body text. 36043d2073cStracker-user * 36143d2073cStracker-user * @param string $id page id 36243d2073cStracker-user * @param string $annId annotation id 36343d2073cStracker-user * @param string $replyId reply id 36443d2073cStracker-user * @param string $body new text 36543d2073cStracker-user * @return bool 36643d2073cStracker-user */ 36743d2073cStracker-user public function updateReply($id, $annId, $replyId, $body) 36843d2073cStracker-user { 36943d2073cStracker-user $body = $this->cleanBody($body); 37043d2073cStracker-user if ($body === '') { 37143d2073cStracker-user return false; 37243d2073cStracker-user } 37343d2073cStracker-user return (bool) $this->mutate($id, function (array &$annotations) use ($annId, $replyId, $body) { 37443d2073cStracker-user foreach ($annotations as $i => $a) { 37543d2073cStracker-user if (($a['id'] ?? '') !== $annId) { 37643d2073cStracker-user continue; 37743d2073cStracker-user } 37843d2073cStracker-user foreach (($a['replies'] ?? []) as $j => $r) { 37943d2073cStracker-user if (($r['id'] ?? '') === $replyId) { 38043d2073cStracker-user $annotations[$i]['replies'][$j]['body'] = $body; 38143d2073cStracker-user $annotations[$i]['replies'][$j]['modified'] = time(); 38243d2073cStracker-user return true; 38343d2073cStracker-user } 38443d2073cStracker-user } 38543d2073cStracker-user } 38643d2073cStracker-user return false; 38743d2073cStracker-user }); 38843d2073cStracker-user } 38943d2073cStracker-user 39043d2073cStracker-user /** 39143d2073cStracker-user * Delete a reply. 39243d2073cStracker-user * 39343d2073cStracker-user * @param string $id page id 39443d2073cStracker-user * @param string $annId annotation id 39543d2073cStracker-user * @param string $replyId reply id 39643d2073cStracker-user * @return bool 39743d2073cStracker-user */ 39843d2073cStracker-user public function deleteReply($id, $annId, $replyId) 39943d2073cStracker-user { 40043d2073cStracker-user return (bool) $this->mutate($id, function (array &$annotations) use ($annId, $replyId) { 40143d2073cStracker-user foreach ($annotations as $i => $a) { 40243d2073cStracker-user if (($a['id'] ?? '') !== $annId) { 40343d2073cStracker-user continue; 40443d2073cStracker-user } 40543d2073cStracker-user foreach (($a['replies'] ?? []) as $j => $r) { 40643d2073cStracker-user if (($r['id'] ?? '') === $replyId) { 40743d2073cStracker-user array_splice($annotations[$i]['replies'], $j, 1); 40843d2073cStracker-user return true; 40943d2073cStracker-user } 41043d2073cStracker-user } 41143d2073cStracker-user } 41243d2073cStracker-user return false; 41343d2073cStracker-user }); 41443d2073cStracker-user } 41543d2073cStracker-user 41643d2073cStracker-user // --------------------------------------------------------------------- 41743d2073cStracker-user // Bulk maintenance (admin, per page) 41843d2073cStracker-user // --------------------------------------------------------------------- 41943d2073cStracker-user 42043d2073cStracker-user /** 42143d2073cStracker-user * Remove every resolved annotation from a page. 42243d2073cStracker-user * 42343d2073cStracker-user * @param string $id page id 42443d2073cStracker-user * @return int|false number removed, or false on write failure 42543d2073cStracker-user */ 42643d2073cStracker-user public function clearResolved($id) 42743d2073cStracker-user { 42843d2073cStracker-user if (empty($this->getAnnotations($id))) { 42943d2073cStracker-user return 0; 43043d2073cStracker-user } 43143d2073cStracker-user return $this->mutate($id, function (array &$annotations) { 43243d2073cStracker-user $before = count($annotations); 43343d2073cStracker-user $annotations = array_values(array_filter($annotations, function ($a) { 43443d2073cStracker-user return ($a['status'] ?? 'open') !== 'resolved'; 43543d2073cStracker-user })); 43643d2073cStracker-user return $before - count($annotations); 43743d2073cStracker-user }); 43843d2073cStracker-user } 43943d2073cStracker-user 44043d2073cStracker-user /** 44143d2073cStracker-user * Remove every orphaned annotation from a page — those whose quoted text 44243d2073cStracker-user * no longer appears in the rendered page. The page is re-checked here, so 44343d2073cStracker-user * this is authoritative regardless of what a client believed. 44443d2073cStracker-user * 44543d2073cStracker-user * @param string $id page id 44643d2073cStracker-user * @return int|false number removed, or false on write failure 44743d2073cStracker-user */ 44843d2073cStracker-user public function clearOrphaned($id) 44943d2073cStracker-user { 45043d2073cStracker-user $orphanIds = []; 45143d2073cStracker-user foreach ($this->findOrphaned($id) as $a) { 45243d2073cStracker-user $orphanIds[] = $a['id']; 45343d2073cStracker-user } 45443d2073cStracker-user if (empty($orphanIds)) { 45543d2073cStracker-user return 0; 45643d2073cStracker-user } 45743d2073cStracker-user return $this->mutate($id, function (array &$annotations) use ($orphanIds) { 45843d2073cStracker-user $before = count($annotations); 45943d2073cStracker-user $annotations = array_values(array_filter($annotations, function ($a) use ($orphanIds) { 46043d2073cStracker-user return !in_array($a['id'] ?? '', $orphanIds, true); 46143d2073cStracker-user })); 46243d2073cStracker-user return $before - count($annotations); 46343d2073cStracker-user }); 46443d2073cStracker-user } 46543d2073cStracker-user 46643d2073cStracker-user // --------------------------------------------------------------------- 46743d2073cStracker-user // Orphan detection 46843d2073cStracker-user // --------------------------------------------------------------------- 46943d2073cStracker-user 47043d2073cStracker-user /** 47143d2073cStracker-user * Render a page to normalised plain text, for quote searching. 47243d2073cStracker-user * 47343d2073cStracker-user * Block-level closing tags become spaces so adjacent blocks do not fuse 47443d2073cStracker-user * into one run of text; then tags are stripped, entities decoded, and 47543d2073cStracker-user * whitespace collapsed — the same normalisation applied to stored quotes. 47643d2073cStracker-user * 47743d2073cStracker-user * @param string $id page id 47843d2073cStracker-user * @return string 47943d2073cStracker-user */ 48043d2073cStracker-user public function getPageText($id) 48143d2073cStracker-user { 48243d2073cStracker-user if (!page_exists($id)) { 48343d2073cStracker-user return ''; 48443d2073cStracker-user } 48543d2073cStracker-user $xhtml = p_wiki_xhtml($id, '', false); 48643d2073cStracker-user if (!is_string($xhtml) || $xhtml === '') { 48743d2073cStracker-user return ''; 48843d2073cStracker-user } 48943d2073cStracker-user $xhtml = preg_replace('#</(p|div|li|h[1-6]|td|th|tr|blockquote|pre|dt|dd)>#i', ' ', $xhtml); 49043d2073cStracker-user $xhtml = preg_replace('#<br\s*/?>#i', ' ', $xhtml); 49143d2073cStracker-user $text = strip_tags($xhtml); 49243d2073cStracker-user $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); 49343d2073cStracker-user return $this->normalizeWhitespace($text); 49443d2073cStracker-user } 49543d2073cStracker-user 49643d2073cStracker-user /** 49743d2073cStracker-user * The annotations on a page whose quoted text is no longer present. 49843d2073cStracker-user * 49943d2073cStracker-user * @param string $id page id 50043d2073cStracker-user * @return array list of orphaned annotation arrays 50143d2073cStracker-user */ 50243d2073cStracker-user public function findOrphaned($id) 50343d2073cStracker-user { 50443d2073cStracker-user $annotations = $this->getAnnotations($id); 50543d2073cStracker-user if (empty($annotations)) { 50643d2073cStracker-user return []; 50743d2073cStracker-user } 50843d2073cStracker-user $pageText = $this->getPageText($id); 50943d2073cStracker-user 51043d2073cStracker-user $orphaned = []; 51143d2073cStracker-user foreach ($annotations as $a) { 51243d2073cStracker-user $exact = $this->normalizeWhitespace($a['anchor']['exact'] ?? ''); 51343d2073cStracker-user if ($exact === '' || mb_strpos($pageText, $exact) === false) { 51443d2073cStracker-user $orphaned[] = $a; 51543d2073cStracker-user } 51643d2073cStracker-user } 51743d2073cStracker-user return $orphaned; 51843d2073cStracker-user } 51943d2073cStracker-user 52043d2073cStracker-user // --------------------------------------------------------------------- 52143d2073cStracker-user // Permission rules (single source of truth) 52243d2073cStracker-user // --------------------------------------------------------------------- 52343d2073cStracker-user 52443d2073cStracker-user /** 52543d2073cStracker-user * May this user create an annotation, reply, or change a resolve status? 52643d2073cStracker-user * 52743d2073cStracker-user * Requires only read access to the page — annotations are out-of-band, so 52843d2073cStracker-user * a user whose page edit access is blocked may still annotate. 52943d2073cStracker-user * 53043d2073cStracker-user * @param string $user current username ('' for anonymous) 53143d2073cStracker-user * @param int $aclLevel the user's ACL level on the page 53243d2073cStracker-user * @return bool 53343d2073cStracker-user */ 53443d2073cStracker-user public function canAnnotate($user, $aclLevel) 53543d2073cStracker-user { 53643d2073cStracker-user return $user !== '' && $user !== null && $aclLevel >= AUTH_READ; 53743d2073cStracker-user } 53843d2073cStracker-user 53943d2073cStracker-user /** 54043d2073cStracker-user * May this user edit or delete the given annotation? Author or admin. 54143d2073cStracker-user * 54243d2073cStracker-user * @param array $annotation 54343d2073cStracker-user * @param string $user 54443d2073cStracker-user * @param bool $isAdmin 54543d2073cStracker-user * @return bool 54643d2073cStracker-user */ 54743d2073cStracker-user public function canEditAnnotation(array $annotation, $user, $isAdmin) 54843d2073cStracker-user { 54943d2073cStracker-user if ($user === '' || $user === null) { 55043d2073cStracker-user return false; 55143d2073cStracker-user } 55243d2073cStracker-user return $isAdmin || (($annotation['author'] ?? '') === $user); 55343d2073cStracker-user } 55443d2073cStracker-user 55543d2073cStracker-user /** 55643d2073cStracker-user * May this user edit or delete the given reply? Author or admin. 55743d2073cStracker-user * 55843d2073cStracker-user * @param array $reply 55943d2073cStracker-user * @param string $user 56043d2073cStracker-user * @param bool $isAdmin 56143d2073cStracker-user * @return bool 56243d2073cStracker-user */ 56343d2073cStracker-user public function canEditReply(array $reply, $user, $isAdmin) 56443d2073cStracker-user { 56543d2073cStracker-user if ($user === '' || $user === null) { 56643d2073cStracker-user return false; 56743d2073cStracker-user } 56843d2073cStracker-user return $isAdmin || (($reply['author'] ?? '') === $user); 56943d2073cStracker-user } 57043d2073cStracker-user 57143d2073cStracker-user /** 57243d2073cStracker-user * May this user run the per-page "clear resolved/orphaned" operations? 57343d2073cStracker-user * Admins only. 57443d2073cStracker-user * 57543d2073cStracker-user * @param bool $isAdmin 57643d2073cStracker-user * @return bool 57743d2073cStracker-user */ 57843d2073cStracker-user public function canClear($isAdmin) 57943d2073cStracker-user { 58043d2073cStracker-user return (bool) $isAdmin; 58143d2073cStracker-user } 58243d2073cStracker-user 58343d2073cStracker-user // --------------------------------------------------------------------- 58443d2073cStracker-user // Input cleaning 58543d2073cStracker-user // --------------------------------------------------------------------- 58643d2073cStracker-user 58743d2073cStracker-user /** 58843d2073cStracker-user * Validate and normalise a raw anchor. 58943d2073cStracker-user * 59043d2073cStracker-user * @param mixed $anchor 59143d2073cStracker-user * @return array|null the cleaned anchor, or null if unusable 59243d2073cStracker-user */ 59343d2073cStracker-user protected function cleanAnchor($anchor) 59443d2073cStracker-user { 59543d2073cStracker-user if (!is_array($anchor)) { 59643d2073cStracker-user return null; 59743d2073cStracker-user } 59843d2073cStracker-user 59943d2073cStracker-user $exact = (isset($anchor['exact']) && is_string($anchor['exact'])) 60043d2073cStracker-user ? $this->normalizeWhitespace($anchor['exact']) 60143d2073cStracker-user : ''; 60243d2073cStracker-user if ($exact === '') { 60343d2073cStracker-user return null; // an anchor without quoted text is unusable 60443d2073cStracker-user } 60543d2073cStracker-user if (mb_strlen($exact) > self::MAX_QUOTE) { 60643d2073cStracker-user $exact = mb_substr($exact, 0, self::MAX_QUOTE); 60743d2073cStracker-user } 60843d2073cStracker-user 60943d2073cStracker-user $prefix = (isset($anchor['prefix']) && is_string($anchor['prefix'])) 61043d2073cStracker-user ? $this->normalizeWhitespace($anchor['prefix']) 61143d2073cStracker-user : ''; 61243d2073cStracker-user $suffix = (isset($anchor['suffix']) && is_string($anchor['suffix'])) 61343d2073cStracker-user ? $this->normalizeWhitespace($anchor['suffix']) 61443d2073cStracker-user : ''; 61543d2073cStracker-user if (mb_strlen($prefix) > self::MAX_CONTEXT) { 61643d2073cStracker-user $prefix = mb_substr($prefix, -self::MAX_CONTEXT); 61743d2073cStracker-user } 61843d2073cStracker-user if (mb_strlen($suffix) > self::MAX_CONTEXT) { 61943d2073cStracker-user $suffix = mb_substr($suffix, 0, self::MAX_CONTEXT); 62043d2073cStracker-user } 62143d2073cStracker-user 62243d2073cStracker-user $start = isset($anchor['start']) ? max(0, (int) $anchor['start']) : 0; 62343d2073cStracker-user 62443d2073cStracker-user return [ 62543d2073cStracker-user 'exact' => $exact, 62643d2073cStracker-user 'prefix' => $prefix, 62743d2073cStracker-user 'suffix' => $suffix, 62843d2073cStracker-user 'start' => $start, 62943d2073cStracker-user ]; 63043d2073cStracker-user } 63143d2073cStracker-user 63243d2073cStracker-user /** 63343d2073cStracker-user * Clean an annotation/reply body: a plain-text string, trimmed, with 63443d2073cStracker-user * normalised line endings and a length cap. Newlines are kept; the text 63543d2073cStracker-user * is escaped by the consumer at render time. 63643d2073cStracker-user * 63743d2073cStracker-user * @param mixed $body 63843d2073cStracker-user * @return string 63943d2073cStracker-user */ 64043d2073cStracker-user protected function cleanBody($body) 64143d2073cStracker-user { 64243d2073cStracker-user if (!is_string($body)) { 64343d2073cStracker-user return ''; 64443d2073cStracker-user } 64543d2073cStracker-user $body = str_replace("\r\n", "\n", $body); 64643d2073cStracker-user $body = str_replace("\r", "\n", $body); 64743d2073cStracker-user $body = trim($body); 64843d2073cStracker-user if (mb_strlen($body) > self::MAX_BODY) { 64943d2073cStracker-user $body = mb_substr($body, 0, self::MAX_BODY); 65043d2073cStracker-user } 65143d2073cStracker-user return $body; 65243d2073cStracker-user } 65343d2073cStracker-user 65443d2073cStracker-user /** 65543d2073cStracker-user * Collapse every run of whitespace to a single space and trim. 65643d2073cStracker-user * 65743d2073cStracker-user * @param mixed $text 65843d2073cStracker-user * @return string 65943d2073cStracker-user */ 66043d2073cStracker-user protected function normalizeWhitespace($text) 66143d2073cStracker-user { 66243d2073cStracker-user return trim(preg_replace('/\s+/u', ' ', (string) $text)); 66343d2073cStracker-user } 66443d2073cStracker-user 66543d2073cStracker-user /** 66643d2073cStracker-user * A fresh identifier for an annotation or reply. 66743d2073cStracker-user * 66843d2073cStracker-user * @return string 16 hex characters 66943d2073cStracker-user */ 67043d2073cStracker-user protected function newId() 67143d2073cStracker-user { 67243d2073cStracker-user return bin2hex(random_bytes(8)); 67343d2073cStracker-user } 67443d2073cStracker-user} 675