1*43d2073cStracker-user<?php 2*43d2073cStracker-user 3*43d2073cStracker-user/** 4*43d2073cStracker-user * Annotations plugin — storage and data-logic helper. 5*43d2073cStracker-user * 6*43d2073cStracker-user * This component owns: 7*43d2073cStracker-user * 8*43d2073cStracker-user * 1. The per-page annotation store. One JSON file per page, obtained via 9*43d2073cStracker-user * metaFN($id, '.annotations'), holding {version, annotations:[...]}. 10*43d2073cStracker-user * JSON and pretty-printed so the files are easy to inspect or back up. 11*43d2073cStracker-user * The page text and the wiki changelog are never touched. 12*43d2073cStracker-user * 13*43d2073cStracker-user * 2. The text-quote anchor model. Each annotation stores an anchor of 14*43d2073cStracker-user * {exact, prefix, suffix, start} — the quoted text, a short slice of the 15*43d2073cStracker-user * surrounding context on each side (to disambiguate repeated quotes), 16*43d2073cStracker-user * and a character-offset hint. This is the Hypothes.is approach. 17*43d2073cStracker-user * 18*43d2073cStracker-user * 3. CRUD on annotations and their threaded replies. 19*43d2073cStracker-user * 20*43d2073cStracker-user * 4. Server-side orphan detection: a page is rendered to plain text and an 21*43d2073cStracker-user * annotation is "orphaned" when its quoted text no longer appears. Used 22*43d2073cStracker-user * by the admin-only per-page "clear orphaned" operation. (The live UI 23*43d2073cStracker-user * also detects orphans client-side for the on-page counter.) 24*43d2073cStracker-user * 25*43d2073cStracker-user * 5. The permission rules, as the single source of truth. They are pure 26*43d2073cStracker-user * functions: the caller gathers the facts (current user, admin flag, the 27*43d2073cStracker-user * page's ACL level) and passes them in. Because annotations live 28*43d2073cStracker-user * out-of-band, creating one needs only AUTH_READ on the page, never 29*43d2073cStracker-user * AUTH_EDIT — so a group whose page edit access is blocked can still 30*43d2073cStracker-user * annotate. 31*43d2073cStracker-user */ 32*43d2073cStracker-user 33*43d2073cStracker-user// must be run within DokuWiki 34*43d2073cStracker-userif (!defined('DOKU_INC')) die(); 35*43d2073cStracker-user 36*43d2073cStracker-userclass helper_plugin_annotations extends DokuWiki_Plugin 37*43d2073cStracker-user{ 38*43d2073cStracker-user /** storage schema version, written into each file */ 39*43d2073cStracker-user const SCHEMA_VERSION = 1; 40*43d2073cStracker-user 41*43d2073cStracker-user /** longest quoted selection stored, in characters */ 42*43d2073cStracker-user const MAX_QUOTE = 1000; 43*43d2073cStracker-user 44*43d2073cStracker-user /** length of the prefix/suffix context slices, in characters */ 45*43d2073cStracker-user const MAX_CONTEXT = 64; 46*43d2073cStracker-user 47*43d2073cStracker-user /** longest annotation/reply body, in characters */ 48*43d2073cStracker-user const MAX_BODY = 10000; 49*43d2073cStracker-user 50*43d2073cStracker-user // --------------------------------------------------------------------- 51*43d2073cStracker-user // Storage 52*43d2073cStracker-user // --------------------------------------------------------------------- 53*43d2073cStracker-user 54*43d2073cStracker-user /** 55*43d2073cStracker-user * Path of a page's annotation file. 56*43d2073cStracker-user * 57*43d2073cStracker-user * @param string $id page id 58*43d2073cStracker-user * @return string 59*43d2073cStracker-user */ 60*43d2073cStracker-user protected function getFile($id) 61*43d2073cStracker-user { 62*43d2073cStracker-user return metaFN($id, '.annotations'); 63*43d2073cStracker-user } 64*43d2073cStracker-user 65*43d2073cStracker-user /** 66*43d2073cStracker-user * All annotations stored for a page. 67*43d2073cStracker-user * 68*43d2073cStracker-user * @param string $id page id 69*43d2073cStracker-user * @return array list of annotation arrays (empty if none) 70*43d2073cStracker-user */ 71*43d2073cStracker-user public function getAnnotations($id) 72*43d2073cStracker-user { 73*43d2073cStracker-user $file = $this->getFile($id); 74*43d2073cStracker-user if (!file_exists($file)) { 75*43d2073cStracker-user return []; 76*43d2073cStracker-user } 77*43d2073cStracker-user $raw = io_readFile($file, false); 78*43d2073cStracker-user if ($raw === '') { 79*43d2073cStracker-user return []; 80*43d2073cStracker-user } 81*43d2073cStracker-user $data = json_decode($raw, true); 82*43d2073cStracker-user if (!is_array($data) || !isset($data['annotations']) || !is_array($data['annotations'])) { 83*43d2073cStracker-user return []; 84*43d2073cStracker-user } 85*43d2073cStracker-user return $data['annotations']; 86*43d2073cStracker-user } 87*43d2073cStracker-user 88*43d2073cStracker-user /** 89*43d2073cStracker-user * A single annotation by id. 90*43d2073cStracker-user * 91*43d2073cStracker-user * @param string $id page id 92*43d2073cStracker-user * @param string $annId annotation id 93*43d2073cStracker-user * @return array|null 94*43d2073cStracker-user */ 95*43d2073cStracker-user public function getAnnotation($id, $annId) 96*43d2073cStracker-user { 97*43d2073cStracker-user foreach ($this->getAnnotations($id) as $a) { 98*43d2073cStracker-user if (($a['id'] ?? '') === $annId) { 99*43d2073cStracker-user return $a; 100*43d2073cStracker-user } 101*43d2073cStracker-user } 102*43d2073cStracker-user return null; 103*43d2073cStracker-user } 104*43d2073cStracker-user 105*43d2073cStracker-user /** 106*43d2073cStracker-user * Counts for the on-page indicator. The orphan count is deliberately not 107*43d2073cStracker-user * here — it depends on the rendered page and is computed client-side. 108*43d2073cStracker-user * 109*43d2073cStracker-user * @param string $id page id 110*43d2073cStracker-user * @return array ['total'=>int, 'open'=>int, 'resolved'=>int] 111*43d2073cStracker-user */ 112*43d2073cStracker-user public function getStats($id) 113*43d2073cStracker-user { 114*43d2073cStracker-user $open = 0; 115*43d2073cStracker-user $resolved = 0; 116*43d2073cStracker-user foreach ($this->getAnnotations($id) as $a) { 117*43d2073cStracker-user if (($a['status'] ?? 'open') === 'resolved') { 118*43d2073cStracker-user $resolved++; 119*43d2073cStracker-user } else { 120*43d2073cStracker-user $open++; 121*43d2073cStracker-user } 122*43d2073cStracker-user } 123*43d2073cStracker-user return ['total' => $open + $resolved, 'open' => $open, 'resolved' => $resolved]; 124*43d2073cStracker-user } 125*43d2073cStracker-user 126*43d2073cStracker-user /** 127*43d2073cStracker-user * Write a page's annotation list to disk. 128*43d2073cStracker-user * 129*43d2073cStracker-user * @param string $id page id 130*43d2073cStracker-user * @param array $list annotations 131*43d2073cStracker-user * @return bool 132*43d2073cStracker-user */ 133*43d2073cStracker-user protected function writeFile($id, array $list) 134*43d2073cStracker-user { 135*43d2073cStracker-user $payload = [ 136*43d2073cStracker-user 'version' => self::SCHEMA_VERSION, 137*43d2073cStracker-user 'annotations' => array_values($list), 138*43d2073cStracker-user ]; 139*43d2073cStracker-user return (bool) io_saveFile($this->getFile($id), json_encode($payload, JSON_PRETTY_PRINT)); 140*43d2073cStracker-user } 141*43d2073cStracker-user 142*43d2073cStracker-user /** 143*43d2073cStracker-user * Run a modification against a page's annotations under a write lock. 144*43d2073cStracker-user * 145*43d2073cStracker-user * The modifier receives the annotation list by reference and returns an 146*43d2073cStracker-user * outcome value. Returning the boolean false aborts the write (used for 147*43d2073cStracker-user * "target not found"); any other value is returned to the caller after a 148*43d2073cStracker-user * successful save. 149*43d2073cStracker-user * 150*43d2073cStracker-user * @param string $id page id 151*43d2073cStracker-user * @param callable $modifier function(array &$annotations): mixed 152*43d2073cStracker-user * @return mixed the modifier's outcome on success, or false on failure 153*43d2073cStracker-user */ 154*43d2073cStracker-user protected function mutate($id, callable $modifier) 155*43d2073cStracker-user { 156*43d2073cStracker-user $file = $this->getFile($id); 157*43d2073cStracker-user io_lock($file); 158*43d2073cStracker-user 159*43d2073cStracker-user $annotations = $this->getAnnotations($id); 160*43d2073cStracker-user $outcome = $modifier($annotations); 161*43d2073cStracker-user 162*43d2073cStracker-user if ($outcome === false) { 163*43d2073cStracker-user io_unlock($file); 164*43d2073cStracker-user return false; 165*43d2073cStracker-user } 166*43d2073cStracker-user 167*43d2073cStracker-user $ok = $this->writeFile($id, $annotations); 168*43d2073cStracker-user io_unlock($file); 169*43d2073cStracker-user return $ok ? $outcome : false; 170*43d2073cStracker-user } 171*43d2073cStracker-user 172*43d2073cStracker-user // --------------------------------------------------------------------- 173*43d2073cStracker-user // Annotation CRUD 174*43d2073cStracker-user // --------------------------------------------------------------------- 175*43d2073cStracker-user 176*43d2073cStracker-user /** 177*43d2073cStracker-user * Create an annotation. 178*43d2073cStracker-user * 179*43d2073cStracker-user * @param string $id page id 180*43d2073cStracker-user * @param array $anchor raw anchor {exact, prefix, suffix, start} 181*43d2073cStracker-user * @param string $author username 182*43d2073cStracker-user * @param string $body annotation text 183*43d2073cStracker-user * @return array|false the created annotation, or false on invalid input 184*43d2073cStracker-user */ 185*43d2073cStracker-user public function createAnnotation($id, $anchor, $author, $body) 186*43d2073cStracker-user { 187*43d2073cStracker-user if ($id === '' || $author === '' || $author === null) { 188*43d2073cStracker-user return false; 189*43d2073cStracker-user } 190*43d2073cStracker-user $body = $this->cleanBody($body); 191*43d2073cStracker-user if ($body === '') { 192*43d2073cStracker-user return false; 193*43d2073cStracker-user } 194*43d2073cStracker-user $anchor = $this->cleanAnchor($anchor); 195*43d2073cStracker-user if ($anchor === null) { 196*43d2073cStracker-user return false; 197*43d2073cStracker-user } 198*43d2073cStracker-user 199*43d2073cStracker-user $now = time(); 200*43d2073cStracker-user $new = [ 201*43d2073cStracker-user 'id' => $this->newId(), 202*43d2073cStracker-user 'anchor' => $anchor, 203*43d2073cStracker-user 'author' => $author, 204*43d2073cStracker-user 'created' => $now, 205*43d2073cStracker-user 'modified' => $now, 206*43d2073cStracker-user 'body' => $body, 207*43d2073cStracker-user 'status' => 'open', 208*43d2073cStracker-user 'resolved_by' => '', 209*43d2073cStracker-user 'resolved_at' => 0, 210*43d2073cStracker-user 'replies' => [], 211*43d2073cStracker-user ]; 212*43d2073cStracker-user 213*43d2073cStracker-user return $this->mutate($id, function (array &$annotations) use ($new) { 214*43d2073cStracker-user $annotations[] = $new; 215*43d2073cStracker-user return $new; 216*43d2073cStracker-user }); 217*43d2073cStracker-user } 218*43d2073cStracker-user 219*43d2073cStracker-user /** 220*43d2073cStracker-user * Edit an annotation's body text. 221*43d2073cStracker-user * 222*43d2073cStracker-user * @param string $id page id 223*43d2073cStracker-user * @param string $annId annotation id 224*43d2073cStracker-user * @param string $body new text 225*43d2073cStracker-user * @return bool 226*43d2073cStracker-user */ 227*43d2073cStracker-user public function updateAnnotationBody($id, $annId, $body) 228*43d2073cStracker-user { 229*43d2073cStracker-user $body = $this->cleanBody($body); 230*43d2073cStracker-user if ($body === '') { 231*43d2073cStracker-user return false; 232*43d2073cStracker-user } 233*43d2073cStracker-user return (bool) $this->mutate($id, function (array &$annotations) use ($annId, $body) { 234*43d2073cStracker-user foreach ($annotations as $i => $a) { 235*43d2073cStracker-user if (($a['id'] ?? '') === $annId) { 236*43d2073cStracker-user $annotations[$i]['body'] = $body; 237*43d2073cStracker-user $annotations[$i]['modified'] = time(); 238*43d2073cStracker-user return true; 239*43d2073cStracker-user } 240*43d2073cStracker-user } 241*43d2073cStracker-user return false; 242*43d2073cStracker-user }); 243*43d2073cStracker-user } 244*43d2073cStracker-user 245*43d2073cStracker-user /** 246*43d2073cStracker-user * Delete an annotation and all its replies. 247*43d2073cStracker-user * 248*43d2073cStracker-user * @param string $id page id 249*43d2073cStracker-user * @param string $annId annotation id 250*43d2073cStracker-user * @return bool 251*43d2073cStracker-user */ 252*43d2073cStracker-user public function deleteAnnotation($id, $annId) 253*43d2073cStracker-user { 254*43d2073cStracker-user return (bool) $this->mutate($id, function (array &$annotations) use ($annId) { 255*43d2073cStracker-user foreach ($annotations as $i => $a) { 256*43d2073cStracker-user if (($a['id'] ?? '') === $annId) { 257*43d2073cStracker-user array_splice($annotations, $i, 1); 258*43d2073cStracker-user return true; 259*43d2073cStracker-user } 260*43d2073cStracker-user } 261*43d2073cStracker-user return false; 262*43d2073cStracker-user }); 263*43d2073cStracker-user } 264*43d2073cStracker-user 265*43d2073cStracker-user /** 266*43d2073cStracker-user * Mark an annotation open or resolved. 267*43d2073cStracker-user * 268*43d2073cStracker-user * @param string $id page id 269*43d2073cStracker-user * @param string $annId annotation id 270*43d2073cStracker-user * @param string $status 'open' or 'resolved' 271*43d2073cStracker-user * @param string $actor username making the change (recorded when resolving) 272*43d2073cStracker-user * @return bool 273*43d2073cStracker-user */ 274*43d2073cStracker-user public function setStatus($id, $annId, $status, $actor) 275*43d2073cStracker-user { 276*43d2073cStracker-user if (!in_array($status, ['open', 'resolved'], true)) { 277*43d2073cStracker-user return false; 278*43d2073cStracker-user } 279*43d2073cStracker-user return (bool) $this->mutate($id, function (array &$annotations) use ($annId, $status, $actor) { 280*43d2073cStracker-user foreach ($annotations as $i => $a) { 281*43d2073cStracker-user if (($a['id'] ?? '') === $annId) { 282*43d2073cStracker-user $annotations[$i]['status'] = $status; 283*43d2073cStracker-user if ($status === 'resolved') { 284*43d2073cStracker-user $annotations[$i]['resolved_by'] = $actor; 285*43d2073cStracker-user $annotations[$i]['resolved_at'] = time(); 286*43d2073cStracker-user } else { 287*43d2073cStracker-user $annotations[$i]['resolved_by'] = ''; 288*43d2073cStracker-user $annotations[$i]['resolved_at'] = 0; 289*43d2073cStracker-user } 290*43d2073cStracker-user return true; 291*43d2073cStracker-user } 292*43d2073cStracker-user } 293*43d2073cStracker-user return false; 294*43d2073cStracker-user }); 295*43d2073cStracker-user } 296*43d2073cStracker-user 297*43d2073cStracker-user // --------------------------------------------------------------------- 298*43d2073cStracker-user // Reply CRUD 299*43d2073cStracker-user // --------------------------------------------------------------------- 300*43d2073cStracker-user 301*43d2073cStracker-user /** 302*43d2073cStracker-user * Add a reply to an annotation. 303*43d2073cStracker-user * 304*43d2073cStracker-user * @param string $id page id 305*43d2073cStracker-user * @param string $annId annotation id 306*43d2073cStracker-user * @param string $author username 307*43d2073cStracker-user * @param string $body reply text 308*43d2073cStracker-user * @return array|false the created reply, or false on invalid input 309*43d2073cStracker-user */ 310*43d2073cStracker-user public function addReply($id, $annId, $author, $body) 311*43d2073cStracker-user { 312*43d2073cStracker-user if ($author === '' || $author === null) { 313*43d2073cStracker-user return false; 314*43d2073cStracker-user } 315*43d2073cStracker-user $body = $this->cleanBody($body); 316*43d2073cStracker-user if ($body === '') { 317*43d2073cStracker-user return false; 318*43d2073cStracker-user } 319*43d2073cStracker-user $now = time(); 320*43d2073cStracker-user $reply = [ 321*43d2073cStracker-user 'id' => $this->newId(), 322*43d2073cStracker-user 'author' => $author, 323*43d2073cStracker-user 'created' => $now, 324*43d2073cStracker-user 'modified' => $now, 325*43d2073cStracker-user 'body' => $body, 326*43d2073cStracker-user ]; 327*43d2073cStracker-user 328*43d2073cStracker-user return $this->mutate($id, function (array &$annotations) use ($annId, $reply) { 329*43d2073cStracker-user foreach ($annotations as $i => $a) { 330*43d2073cStracker-user if (($a['id'] ?? '') === $annId) { 331*43d2073cStracker-user $annotations[$i]['replies'][] = $reply; 332*43d2073cStracker-user return $reply; 333*43d2073cStracker-user } 334*43d2073cStracker-user } 335*43d2073cStracker-user return false; 336*43d2073cStracker-user }); 337*43d2073cStracker-user } 338*43d2073cStracker-user 339*43d2073cStracker-user /** 340*43d2073cStracker-user * Edit a reply's body text. 341*43d2073cStracker-user * 342*43d2073cStracker-user * @param string $id page id 343*43d2073cStracker-user * @param string $annId annotation id 344*43d2073cStracker-user * @param string $replyId reply id 345*43d2073cStracker-user * @param string $body new text 346*43d2073cStracker-user * @return bool 347*43d2073cStracker-user */ 348*43d2073cStracker-user public function updateReply($id, $annId, $replyId, $body) 349*43d2073cStracker-user { 350*43d2073cStracker-user $body = $this->cleanBody($body); 351*43d2073cStracker-user if ($body === '') { 352*43d2073cStracker-user return false; 353*43d2073cStracker-user } 354*43d2073cStracker-user return (bool) $this->mutate($id, function (array &$annotations) use ($annId, $replyId, $body) { 355*43d2073cStracker-user foreach ($annotations as $i => $a) { 356*43d2073cStracker-user if (($a['id'] ?? '') !== $annId) { 357*43d2073cStracker-user continue; 358*43d2073cStracker-user } 359*43d2073cStracker-user foreach (($a['replies'] ?? []) as $j => $r) { 360*43d2073cStracker-user if (($r['id'] ?? '') === $replyId) { 361*43d2073cStracker-user $annotations[$i]['replies'][$j]['body'] = $body; 362*43d2073cStracker-user $annotations[$i]['replies'][$j]['modified'] = time(); 363*43d2073cStracker-user return true; 364*43d2073cStracker-user } 365*43d2073cStracker-user } 366*43d2073cStracker-user } 367*43d2073cStracker-user return false; 368*43d2073cStracker-user }); 369*43d2073cStracker-user } 370*43d2073cStracker-user 371*43d2073cStracker-user /** 372*43d2073cStracker-user * Delete a reply. 373*43d2073cStracker-user * 374*43d2073cStracker-user * @param string $id page id 375*43d2073cStracker-user * @param string $annId annotation id 376*43d2073cStracker-user * @param string $replyId reply id 377*43d2073cStracker-user * @return bool 378*43d2073cStracker-user */ 379*43d2073cStracker-user public function deleteReply($id, $annId, $replyId) 380*43d2073cStracker-user { 381*43d2073cStracker-user return (bool) $this->mutate($id, function (array &$annotations) use ($annId, $replyId) { 382*43d2073cStracker-user foreach ($annotations as $i => $a) { 383*43d2073cStracker-user if (($a['id'] ?? '') !== $annId) { 384*43d2073cStracker-user continue; 385*43d2073cStracker-user } 386*43d2073cStracker-user foreach (($a['replies'] ?? []) as $j => $r) { 387*43d2073cStracker-user if (($r['id'] ?? '') === $replyId) { 388*43d2073cStracker-user array_splice($annotations[$i]['replies'], $j, 1); 389*43d2073cStracker-user return true; 390*43d2073cStracker-user } 391*43d2073cStracker-user } 392*43d2073cStracker-user } 393*43d2073cStracker-user return false; 394*43d2073cStracker-user }); 395*43d2073cStracker-user } 396*43d2073cStracker-user 397*43d2073cStracker-user // --------------------------------------------------------------------- 398*43d2073cStracker-user // Bulk maintenance (admin, per page) 399*43d2073cStracker-user // --------------------------------------------------------------------- 400*43d2073cStracker-user 401*43d2073cStracker-user /** 402*43d2073cStracker-user * Remove every resolved annotation from a page. 403*43d2073cStracker-user * 404*43d2073cStracker-user * @param string $id page id 405*43d2073cStracker-user * @return int|false number removed, or false on write failure 406*43d2073cStracker-user */ 407*43d2073cStracker-user public function clearResolved($id) 408*43d2073cStracker-user { 409*43d2073cStracker-user if (empty($this->getAnnotations($id))) { 410*43d2073cStracker-user return 0; 411*43d2073cStracker-user } 412*43d2073cStracker-user return $this->mutate($id, function (array &$annotations) { 413*43d2073cStracker-user $before = count($annotations); 414*43d2073cStracker-user $annotations = array_values(array_filter($annotations, function ($a) { 415*43d2073cStracker-user return ($a['status'] ?? 'open') !== 'resolved'; 416*43d2073cStracker-user })); 417*43d2073cStracker-user return $before - count($annotations); 418*43d2073cStracker-user }); 419*43d2073cStracker-user } 420*43d2073cStracker-user 421*43d2073cStracker-user /** 422*43d2073cStracker-user * Remove every orphaned annotation from a page — those whose quoted text 423*43d2073cStracker-user * no longer appears in the rendered page. The page is re-checked here, so 424*43d2073cStracker-user * this is authoritative regardless of what a client believed. 425*43d2073cStracker-user * 426*43d2073cStracker-user * @param string $id page id 427*43d2073cStracker-user * @return int|false number removed, or false on write failure 428*43d2073cStracker-user */ 429*43d2073cStracker-user public function clearOrphaned($id) 430*43d2073cStracker-user { 431*43d2073cStracker-user $orphanIds = []; 432*43d2073cStracker-user foreach ($this->findOrphaned($id) as $a) { 433*43d2073cStracker-user $orphanIds[] = $a['id']; 434*43d2073cStracker-user } 435*43d2073cStracker-user if (empty($orphanIds)) { 436*43d2073cStracker-user return 0; 437*43d2073cStracker-user } 438*43d2073cStracker-user return $this->mutate($id, function (array &$annotations) use ($orphanIds) { 439*43d2073cStracker-user $before = count($annotations); 440*43d2073cStracker-user $annotations = array_values(array_filter($annotations, function ($a) use ($orphanIds) { 441*43d2073cStracker-user return !in_array($a['id'] ?? '', $orphanIds, true); 442*43d2073cStracker-user })); 443*43d2073cStracker-user return $before - count($annotations); 444*43d2073cStracker-user }); 445*43d2073cStracker-user } 446*43d2073cStracker-user 447*43d2073cStracker-user // --------------------------------------------------------------------- 448*43d2073cStracker-user // Orphan detection 449*43d2073cStracker-user // --------------------------------------------------------------------- 450*43d2073cStracker-user 451*43d2073cStracker-user /** 452*43d2073cStracker-user * Render a page to normalised plain text, for quote searching. 453*43d2073cStracker-user * 454*43d2073cStracker-user * Block-level closing tags become spaces so adjacent blocks do not fuse 455*43d2073cStracker-user * into one run of text; then tags are stripped, entities decoded, and 456*43d2073cStracker-user * whitespace collapsed — the same normalisation applied to stored quotes. 457*43d2073cStracker-user * 458*43d2073cStracker-user * @param string $id page id 459*43d2073cStracker-user * @return string 460*43d2073cStracker-user */ 461*43d2073cStracker-user public function getPageText($id) 462*43d2073cStracker-user { 463*43d2073cStracker-user if (!page_exists($id)) { 464*43d2073cStracker-user return ''; 465*43d2073cStracker-user } 466*43d2073cStracker-user $xhtml = p_wiki_xhtml($id, '', false); 467*43d2073cStracker-user if (!is_string($xhtml) || $xhtml === '') { 468*43d2073cStracker-user return ''; 469*43d2073cStracker-user } 470*43d2073cStracker-user $xhtml = preg_replace('#</(p|div|li|h[1-6]|td|th|tr|blockquote|pre|dt|dd)>#i', ' ', $xhtml); 471*43d2073cStracker-user $xhtml = preg_replace('#<br\s*/?>#i', ' ', $xhtml); 472*43d2073cStracker-user $text = strip_tags($xhtml); 473*43d2073cStracker-user $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); 474*43d2073cStracker-user return $this->normalizeWhitespace($text); 475*43d2073cStracker-user } 476*43d2073cStracker-user 477*43d2073cStracker-user /** 478*43d2073cStracker-user * The annotations on a page whose quoted text is no longer present. 479*43d2073cStracker-user * 480*43d2073cStracker-user * @param string $id page id 481*43d2073cStracker-user * @return array list of orphaned annotation arrays 482*43d2073cStracker-user */ 483*43d2073cStracker-user public function findOrphaned($id) 484*43d2073cStracker-user { 485*43d2073cStracker-user $annotations = $this->getAnnotations($id); 486*43d2073cStracker-user if (empty($annotations)) { 487*43d2073cStracker-user return []; 488*43d2073cStracker-user } 489*43d2073cStracker-user $pageText = $this->getPageText($id); 490*43d2073cStracker-user 491*43d2073cStracker-user $orphaned = []; 492*43d2073cStracker-user foreach ($annotations as $a) { 493*43d2073cStracker-user $exact = $this->normalizeWhitespace($a['anchor']['exact'] ?? ''); 494*43d2073cStracker-user if ($exact === '' || mb_strpos($pageText, $exact) === false) { 495*43d2073cStracker-user $orphaned[] = $a; 496*43d2073cStracker-user } 497*43d2073cStracker-user } 498*43d2073cStracker-user return $orphaned; 499*43d2073cStracker-user } 500*43d2073cStracker-user 501*43d2073cStracker-user // --------------------------------------------------------------------- 502*43d2073cStracker-user // Permission rules (single source of truth) 503*43d2073cStracker-user // --------------------------------------------------------------------- 504*43d2073cStracker-user 505*43d2073cStracker-user /** 506*43d2073cStracker-user * May this user create an annotation, reply, or change a resolve status? 507*43d2073cStracker-user * 508*43d2073cStracker-user * Requires only read access to the page — annotations are out-of-band, so 509*43d2073cStracker-user * a user whose page edit access is blocked may still annotate. 510*43d2073cStracker-user * 511*43d2073cStracker-user * @param string $user current username ('' for anonymous) 512*43d2073cStracker-user * @param int $aclLevel the user's ACL level on the page 513*43d2073cStracker-user * @return bool 514*43d2073cStracker-user */ 515*43d2073cStracker-user public function canAnnotate($user, $aclLevel) 516*43d2073cStracker-user { 517*43d2073cStracker-user return $user !== '' && $user !== null && $aclLevel >= AUTH_READ; 518*43d2073cStracker-user } 519*43d2073cStracker-user 520*43d2073cStracker-user /** 521*43d2073cStracker-user * May this user edit or delete the given annotation? Author or admin. 522*43d2073cStracker-user * 523*43d2073cStracker-user * @param array $annotation 524*43d2073cStracker-user * @param string $user 525*43d2073cStracker-user * @param bool $isAdmin 526*43d2073cStracker-user * @return bool 527*43d2073cStracker-user */ 528*43d2073cStracker-user public function canEditAnnotation(array $annotation, $user, $isAdmin) 529*43d2073cStracker-user { 530*43d2073cStracker-user if ($user === '' || $user === null) { 531*43d2073cStracker-user return false; 532*43d2073cStracker-user } 533*43d2073cStracker-user return $isAdmin || (($annotation['author'] ?? '') === $user); 534*43d2073cStracker-user } 535*43d2073cStracker-user 536*43d2073cStracker-user /** 537*43d2073cStracker-user * May this user edit or delete the given reply? Author or admin. 538*43d2073cStracker-user * 539*43d2073cStracker-user * @param array $reply 540*43d2073cStracker-user * @param string $user 541*43d2073cStracker-user * @param bool $isAdmin 542*43d2073cStracker-user * @return bool 543*43d2073cStracker-user */ 544*43d2073cStracker-user public function canEditReply(array $reply, $user, $isAdmin) 545*43d2073cStracker-user { 546*43d2073cStracker-user if ($user === '' || $user === null) { 547*43d2073cStracker-user return false; 548*43d2073cStracker-user } 549*43d2073cStracker-user return $isAdmin || (($reply['author'] ?? '') === $user); 550*43d2073cStracker-user } 551*43d2073cStracker-user 552*43d2073cStracker-user /** 553*43d2073cStracker-user * May this user run the per-page "clear resolved/orphaned" operations? 554*43d2073cStracker-user * Admins only. 555*43d2073cStracker-user * 556*43d2073cStracker-user * @param bool $isAdmin 557*43d2073cStracker-user * @return bool 558*43d2073cStracker-user */ 559*43d2073cStracker-user public function canClear($isAdmin) 560*43d2073cStracker-user { 561*43d2073cStracker-user return (bool) $isAdmin; 562*43d2073cStracker-user } 563*43d2073cStracker-user 564*43d2073cStracker-user // --------------------------------------------------------------------- 565*43d2073cStracker-user // Input cleaning 566*43d2073cStracker-user // --------------------------------------------------------------------- 567*43d2073cStracker-user 568*43d2073cStracker-user /** 569*43d2073cStracker-user * Validate and normalise a raw anchor. 570*43d2073cStracker-user * 571*43d2073cStracker-user * @param mixed $anchor 572*43d2073cStracker-user * @return array|null the cleaned anchor, or null if unusable 573*43d2073cStracker-user */ 574*43d2073cStracker-user protected function cleanAnchor($anchor) 575*43d2073cStracker-user { 576*43d2073cStracker-user if (!is_array($anchor)) { 577*43d2073cStracker-user return null; 578*43d2073cStracker-user } 579*43d2073cStracker-user 580*43d2073cStracker-user $exact = (isset($anchor['exact']) && is_string($anchor['exact'])) 581*43d2073cStracker-user ? $this->normalizeWhitespace($anchor['exact']) 582*43d2073cStracker-user : ''; 583*43d2073cStracker-user if ($exact === '') { 584*43d2073cStracker-user return null; // an anchor without quoted text is unusable 585*43d2073cStracker-user } 586*43d2073cStracker-user if (mb_strlen($exact) > self::MAX_QUOTE) { 587*43d2073cStracker-user $exact = mb_substr($exact, 0, self::MAX_QUOTE); 588*43d2073cStracker-user } 589*43d2073cStracker-user 590*43d2073cStracker-user $prefix = (isset($anchor['prefix']) && is_string($anchor['prefix'])) 591*43d2073cStracker-user ? $this->normalizeWhitespace($anchor['prefix']) 592*43d2073cStracker-user : ''; 593*43d2073cStracker-user $suffix = (isset($anchor['suffix']) && is_string($anchor['suffix'])) 594*43d2073cStracker-user ? $this->normalizeWhitespace($anchor['suffix']) 595*43d2073cStracker-user : ''; 596*43d2073cStracker-user if (mb_strlen($prefix) > self::MAX_CONTEXT) { 597*43d2073cStracker-user $prefix = mb_substr($prefix, -self::MAX_CONTEXT); 598*43d2073cStracker-user } 599*43d2073cStracker-user if (mb_strlen($suffix) > self::MAX_CONTEXT) { 600*43d2073cStracker-user $suffix = mb_substr($suffix, 0, self::MAX_CONTEXT); 601*43d2073cStracker-user } 602*43d2073cStracker-user 603*43d2073cStracker-user $start = isset($anchor['start']) ? max(0, (int) $anchor['start']) : 0; 604*43d2073cStracker-user 605*43d2073cStracker-user return [ 606*43d2073cStracker-user 'exact' => $exact, 607*43d2073cStracker-user 'prefix' => $prefix, 608*43d2073cStracker-user 'suffix' => $suffix, 609*43d2073cStracker-user 'start' => $start, 610*43d2073cStracker-user ]; 611*43d2073cStracker-user } 612*43d2073cStracker-user 613*43d2073cStracker-user /** 614*43d2073cStracker-user * Clean an annotation/reply body: a plain-text string, trimmed, with 615*43d2073cStracker-user * normalised line endings and a length cap. Newlines are kept; the text 616*43d2073cStracker-user * is escaped by the consumer at render time. 617*43d2073cStracker-user * 618*43d2073cStracker-user * @param mixed $body 619*43d2073cStracker-user * @return string 620*43d2073cStracker-user */ 621*43d2073cStracker-user protected function cleanBody($body) 622*43d2073cStracker-user { 623*43d2073cStracker-user if (!is_string($body)) { 624*43d2073cStracker-user return ''; 625*43d2073cStracker-user } 626*43d2073cStracker-user $body = str_replace("\r\n", "\n", $body); 627*43d2073cStracker-user $body = str_replace("\r", "\n", $body); 628*43d2073cStracker-user $body = trim($body); 629*43d2073cStracker-user if (mb_strlen($body) > self::MAX_BODY) { 630*43d2073cStracker-user $body = mb_substr($body, 0, self::MAX_BODY); 631*43d2073cStracker-user } 632*43d2073cStracker-user return $body; 633*43d2073cStracker-user } 634*43d2073cStracker-user 635*43d2073cStracker-user /** 636*43d2073cStracker-user * Collapse every run of whitespace to a single space and trim. 637*43d2073cStracker-user * 638*43d2073cStracker-user * @param mixed $text 639*43d2073cStracker-user * @return string 640*43d2073cStracker-user */ 641*43d2073cStracker-user protected function normalizeWhitespace($text) 642*43d2073cStracker-user { 643*43d2073cStracker-user return trim(preg_replace('/\s+/u', ' ', (string) $text)); 644*43d2073cStracker-user } 645*43d2073cStracker-user 646*43d2073cStracker-user /** 647*43d2073cStracker-user * A fresh identifier for an annotation or reply. 648*43d2073cStracker-user * 649*43d2073cStracker-user * @return string 16 hex characters 650*43d2073cStracker-user */ 651*43d2073cStracker-user protected function newId() 652*43d2073cStracker-user { 653*43d2073cStracker-user return bin2hex(random_bytes(8)); 654*43d2073cStracker-user } 655*43d2073cStracker-user} 656