1*43d2073cStracker-user<?php 2*43d2073cStracker-user 3*43d2073cStracker-user/** 4*43d2073cStracker-user * Annotations plugin — event registration and AJAX endpoint. 5*43d2073cStracker-user * 6*43d2073cStracker-user * Responsibilities: 7*43d2073cStracker-user * 8*43d2073cStracker-user * 1. Register a per-user "annotations_enabled" toggle via the usersettings 9*43d2073cStracker-user * plugin's PLUGIN_USERSETTINGS_REGISTER event (BEFORE, so it fires when 10*43d2073cStracker-user * the usersettings helper calls getRegisteredToggles()). 11*43d2073cStracker-user * 12*43d2073cStracker-user * 2. Push the current user's preference and the page's annotation stats 13*43d2073cStracker-user * into JSINFO on every normal page view, so script.js can gate itself 14*43d2073cStracker-user * and seed the counter without an extra round-trip. 15*43d2073cStracker-user * 16*43d2073cStracker-user * 3. Serve the AJAX endpoint at: 17*43d2073cStracker-user * /lib/exe/ajax.php?call=annotations 18*43d2073cStracker-user * POST body (application/json) carries { action, id, ... }. 19*43d2073cStracker-user * All state-changing actions require a valid DokuWiki security token. 20*43d2073cStracker-user * Every response is JSON: { success:true, ... } or { success:false, error:"..." }. 21*43d2073cStracker-user * 22*43d2073cStracker-user * Supported actions (all POST): 23*43d2073cStracker-user * create — body, anchor (object) 24*43d2073cStracker-user * reply — annId, body 25*43d2073cStracker-user * edit_annotation — annId, body 26*43d2073cStracker-user * edit_reply — annId, replyId, body 27*43d2073cStracker-user * delete_annotation — annId 28*43d2073cStracker-user * delete_reply — annId, replyId 29*43d2073cStracker-user * resolve — annId, status ("open"|"resolved") 30*43d2073cStracker-user * clear_resolved — (no extra fields) 31*43d2073cStracker-user * clear_orphaned — (no extra fields) 32*43d2073cStracker-user * 33*43d2073cStracker-user * Permission enforcement is done here; the helper's permission methods are 34*43d2073cStracker-user * called with facts gathered from the DokuWiki global state. 35*43d2073cStracker-user */ 36*43d2073cStracker-user 37*43d2073cStracker-user// must be run within DokuWiki 38*43d2073cStracker-userif (!defined('DOKU_INC')) die(); 39*43d2073cStracker-user 40*43d2073cStracker-userclass action_plugin_annotations extends DokuWiki_Action_Plugin 41*43d2073cStracker-user{ 42*43d2073cStracker-user // ------------------------------------------------------------------ 43*43d2073cStracker-user // Event registration 44*43d2073cStracker-user // ------------------------------------------------------------------ 45*43d2073cStracker-user 46*43d2073cStracker-user /** 47*43d2073cStracker-user * @param Doku_Event_Handler $controller 48*43d2073cStracker-user */ 49*43d2073cStracker-user public function register(Doku_Event_Handler $controller) 50*43d2073cStracker-user { 51*43d2073cStracker-user // Register our toggle with the usersettings plugin. 52*43d2073cStracker-user $controller->register_hook( 53*43d2073cStracker-user 'PLUGIN_USERSETTINGS_REGISTER', 54*43d2073cStracker-user 'BEFORE', 55*43d2073cStracker-user $this, 56*43d2073cStracker-user 'handleSettingsRegister' 57*43d2073cStracker-user ); 58*43d2073cStracker-user 59*43d2073cStracker-user // Inject annotation stats + user preference into JSINFO. 60*43d2073cStracker-user $controller->register_hook( 61*43d2073cStracker-user 'TPL_METAHEADER_OUTPUT', 62*43d2073cStracker-user 'BEFORE', 63*43d2073cStracker-user $this, 64*43d2073cStracker-user 'handleMetaHeader' 65*43d2073cStracker-user ); 66*43d2073cStracker-user 67*43d2073cStracker-user // Handle the AJAX call. 68*43d2073cStracker-user $controller->register_hook( 69*43d2073cStracker-user 'AJAX_CALL_UNKNOWN', 70*43d2073cStracker-user 'BEFORE', 71*43d2073cStracker-user $this, 72*43d2073cStracker-user 'handleAjax' 73*43d2073cStracker-user ); 74*43d2073cStracker-user } 75*43d2073cStracker-user 76*43d2073cStracker-user // ------------------------------------------------------------------ 77*43d2073cStracker-user // 1. usersettings toggle registration 78*43d2073cStracker-user // ------------------------------------------------------------------ 79*43d2073cStracker-user 80*43d2073cStracker-user /** 81*43d2073cStracker-user * Append the annotations_enabled toggle definition to the event data. 82*43d2073cStracker-user * 83*43d2073cStracker-user * The event data is an array that the usersettings helper fires with 84*43d2073cStracker-user * createAndTrigger(); every handler appends its definition(s). 85*43d2073cStracker-user * 86*43d2073cStracker-user * @param Doku_Event $event PLUGIN_USERSETTINGS_REGISTER 87*43d2073cStracker-user * @param mixed $param 88*43d2073cStracker-user */ 89*43d2073cStracker-user public function handleSettingsRegister(Doku_Event $event, $param) 90*43d2073cStracker-user { 91*43d2073cStracker-user $event->data[] = [ 92*43d2073cStracker-user 'key' => 'annotations_enabled', 93*43d2073cStracker-user 'label' => $this->getLang('toggle_label'), 94*43d2073cStracker-user 'desc' => $this->getLang('toggle_desc'), 95*43d2073cStracker-user 'type' => 'checkbox', 96*43d2073cStracker-user 'default' => true, 97*43d2073cStracker-user 'plugin' => 'annotations', 98*43d2073cStracker-user ]; 99*43d2073cStracker-user } 100*43d2073cStracker-user 101*43d2073cStracker-user // ------------------------------------------------------------------ 102*43d2073cStracker-user // 2. Inject into JSINFO 103*43d2073cStracker-user // ------------------------------------------------------------------ 104*43d2073cStracker-user 105*43d2073cStracker-user /** 106*43d2073cStracker-user * Add annotation stats and the user's preference to JSINFO so script.js 107*43d2073cStracker-user * does not need an extra round-trip on page load. 108*43d2073cStracker-user * 109*43d2073cStracker-user * @param Doku_Event $event TPL_METAHEADER_OUTPUT 110*43d2073cStracker-user * @param mixed $param 111*43d2073cStracker-user */ 112*43d2073cStracker-user public function handleMetaHeader(Doku_Event $event, $param) 113*43d2073cStracker-user { 114*43d2073cStracker-user global $ID, $INFO; 115*43d2073cStracker-user 116*43d2073cStracker-user /** @var helper_plugin_annotations $helper */ 117*43d2073cStracker-user $helper = $this->loadHelper('annotations', false); 118*43d2073cStracker-user if (!$helper) { 119*43d2073cStracker-user return; 120*43d2073cStracker-user } 121*43d2073cStracker-user 122*43d2073cStracker-user $enabled = $this->isEnabledForUser(); 123*43d2073cStracker-user $stats = $helper->getStats($ID); 124*43d2073cStracker-user 125*43d2073cStracker-user // Merge into the global JSINFO array that DokuWiki serialises. 126*43d2073cStracker-user global $JSINFO; 127*43d2073cStracker-user if (!is_array($JSINFO)) { 128*43d2073cStracker-user $JSINFO = []; 129*43d2073cStracker-user } 130*43d2073cStracker-user $JSINFO['annotations'] = [ 131*43d2073cStracker-user 'enabled' => $enabled, 132*43d2073cStracker-user 'pageId' => $ID, 133*43d2073cStracker-user 'stats' => $stats, 134*43d2073cStracker-user ]; 135*43d2073cStracker-user } 136*43d2073cStracker-user 137*43d2073cStracker-user // ------------------------------------------------------------------ 138*43d2073cStracker-user // 3. AJAX endpoint 139*43d2073cStracker-user // ------------------------------------------------------------------ 140*43d2073cStracker-user 141*43d2073cStracker-user /** 142*43d2073cStracker-user * Handle AJAX calls for the annotations plugin. 143*43d2073cStracker-user * Ignores calls not addressed to us. 144*43d2073cStracker-user * 145*43d2073cStracker-user * @param Doku_Event $event AJAX_CALL_UNKNOWN 146*43d2073cStracker-user * @param mixed $param 147*43d2073cStracker-user */ 148*43d2073cStracker-user public function handleAjax(Doku_Event $event, $param) 149*43d2073cStracker-user { 150*43d2073cStracker-user if ($event->data !== 'annotations') { 151*43d2073cStracker-user return; 152*43d2073cStracker-user } 153*43d2073cStracker-user $event->stopPropagation(); 154*43d2073cStracker-user $event->preventDefault(); 155*43d2073cStracker-user 156*43d2073cStracker-user header('Content-Type: application/json; charset=utf-8'); 157*43d2073cStracker-user 158*43d2073cStracker-user // Parse JSON body; fall back to POST/GET fields for simple callers. 159*43d2073cStracker-user // The 'load' action is a GET request, so we accept query parameters too. 160*43d2073cStracker-user $payload = $this->readPayload(); 161*43d2073cStracker-user if ($payload === null) { 162*43d2073cStracker-user $this->sendError('Invalid request body.'); 163*43d2073cStracker-user return; 164*43d2073cStracker-user } 165*43d2073cStracker-user 166*43d2073cStracker-user $action = isset($payload['action']) ? (string) $payload['action'] : ''; 167*43d2073cStracker-user // For the read-only 'load' action, accept GET requests without a token. 168*43d2073cStracker-user // All state-changing actions require a valid DokuWiki security token. 169*43d2073cStracker-user if ($action !== 'load' && !checkSecurityToken()) { 170*43d2073cStracker-user $this->sendError('Invalid security token.'); 171*43d2073cStracker-user return; 172*43d2073cStracker-user } 173*43d2073cStracker-user $id = isset($payload['id']) ? cleanID((string) $payload['id']) : ''; 174*43d2073cStracker-user 175*43d2073cStracker-user if ($action === '' || $id === '') { 176*43d2073cStracker-user $this->sendError('Missing action or page id.'); 177*43d2073cStracker-user return; 178*43d2073cStracker-user } 179*43d2073cStracker-user 180*43d2073cStracker-user /** @var helper_plugin_annotations $helper */ 181*43d2073cStracker-user $helper = $this->loadHelper('annotations', false); 182*43d2073cStracker-user if (!$helper) { 183*43d2073cStracker-user $this->sendError('Annotations helper unavailable.'); 184*43d2073cStracker-user return; 185*43d2073cStracker-user } 186*43d2073cStracker-user 187*43d2073cStracker-user // Gather facts once; pass them to the helper's permission methods. 188*43d2073cStracker-user global $USERINFO; 189*43d2073cStracker-user $user = (string) ($_SERVER['REMOTE_USER'] ?? ''); 190*43d2073cStracker-user $isAdmin = (bool) ($USERINFO['grps'] ?? false) 191*43d2073cStracker-user ? in_array('admin', (array) ($USERINFO['grps'] ?? []), true) 192*43d2073cStracker-user : false; 193*43d2073cStracker-user // also honour DokuWiki's own admin flag 194*43d2073cStracker-user if (!$isAdmin) { 195*43d2073cStracker-user global $INFO; 196*43d2073cStracker-user $isAdmin = !empty($INFO['isadmin']); 197*43d2073cStracker-user } 198*43d2073cStracker-user $aclLevel = auth_quickaclcheck($id); 199*43d2073cStracker-user 200*43d2073cStracker-user // Route to the correct handler method. 201*43d2073cStracker-user switch ($action) { 202*43d2073cStracker-user case 'load': 203*43d2073cStracker-user $this->actionLoad($helper, $id, $aclLevel); 204*43d2073cStracker-user break; 205*43d2073cStracker-user case 'create': 206*43d2073cStracker-user $this->actionCreate($helper, $id, $payload, $user, $aclLevel); 207*43d2073cStracker-user break; 208*43d2073cStracker-user case 'reply': 209*43d2073cStracker-user $this->actionReply($helper, $id, $payload, $user, $aclLevel); 210*43d2073cStracker-user break; 211*43d2073cStracker-user case 'edit_annotation': 212*43d2073cStracker-user $this->actionEditAnnotation($helper, $id, $payload, $user, $isAdmin); 213*43d2073cStracker-user break; 214*43d2073cStracker-user case 'edit_reply': 215*43d2073cStracker-user $this->actionEditReply($helper, $id, $payload, $user, $isAdmin); 216*43d2073cStracker-user break; 217*43d2073cStracker-user case 'delete_annotation': 218*43d2073cStracker-user $this->actionDeleteAnnotation($helper, $id, $payload, $user, $isAdmin); 219*43d2073cStracker-user break; 220*43d2073cStracker-user case 'delete_reply': 221*43d2073cStracker-user $this->actionDeleteReply($helper, $id, $payload, $user, $isAdmin); 222*43d2073cStracker-user break; 223*43d2073cStracker-user case 'resolve': 224*43d2073cStracker-user $this->actionResolve($helper, $id, $payload, $user, $aclLevel); 225*43d2073cStracker-user break; 226*43d2073cStracker-user case 'clear_resolved': 227*43d2073cStracker-user $this->actionClearResolved($helper, $id, $isAdmin); 228*43d2073cStracker-user break; 229*43d2073cStracker-user case 'clear_orphaned': 230*43d2073cStracker-user $this->actionClearOrphaned($helper, $id, $isAdmin); 231*43d2073cStracker-user break; 232*43d2073cStracker-user default: 233*43d2073cStracker-user $this->sendError('Unknown action: ' . hsc($action)); 234*43d2073cStracker-user } 235*43d2073cStracker-user } 236*43d2073cStracker-user 237*43d2073cStracker-user // ------------------------------------------------------------------ 238*43d2073cStracker-user // Action handlers (one per supported action) 239*43d2073cStracker-user // ------------------------------------------------------------------ 240*43d2073cStracker-user 241*43d2073cStracker-user /** 242*43d2073cStracker-user * Create a new annotation. 243*43d2073cStracker-user * 244*43d2073cStracker-user * Payload: { action, id, anchor:{exact,prefix,suffix,start}, body } 245*43d2073cStracker-user * 246*43d2073cStracker-user * @param helper_plugin_annotations $helper 247*43d2073cStracker-user * @param string $id 248*43d2073cStracker-user * @param array $payload 249*43d2073cStracker-user * @param string $user 250*43d2073cStracker-user * @param int $aclLevel 251*43d2073cStracker-user */ 252*43d2073cStracker-user protected function actionCreate($helper, $id, array $payload, $user, $aclLevel) 253*43d2073cStracker-user { 254*43d2073cStracker-user if (!$helper->canAnnotate($user, $aclLevel)) { 255*43d2073cStracker-user $this->sendError('Permission denied.'); 256*43d2073cStracker-user return; 257*43d2073cStracker-user } 258*43d2073cStracker-user $anchor = isset($payload['anchor']) && is_array($payload['anchor']) 259*43d2073cStracker-user ? $payload['anchor'] 260*43d2073cStracker-user : []; 261*43d2073cStracker-user $body = isset($payload['body']) ? (string) $payload['body'] : ''; 262*43d2073cStracker-user 263*43d2073cStracker-user $result = $helper->createAnnotation($id, $anchor, $user, $body); 264*43d2073cStracker-user if ($result === false) { 265*43d2073cStracker-user $this->sendError('Invalid annotation data.'); 266*43d2073cStracker-user return; 267*43d2073cStracker-user } 268*43d2073cStracker-user $this->sendSuccess(['annotation' => $result]); 269*43d2073cStracker-user } 270*43d2073cStracker-user 271*43d2073cStracker-user /** 272*43d2073cStracker-user * Add a reply to an existing annotation. 273*43d2073cStracker-user * 274*43d2073cStracker-user * Payload: { action, id, annId, body } 275*43d2073cStracker-user * 276*43d2073cStracker-user * @param helper_plugin_annotations $helper 277*43d2073cStracker-user * @param string $id 278*43d2073cStracker-user * @param array $payload 279*43d2073cStracker-user * @param string $user 280*43d2073cStracker-user * @param int $aclLevel 281*43d2073cStracker-user */ 282*43d2073cStracker-user protected function actionReply($helper, $id, array $payload, $user, $aclLevel) 283*43d2073cStracker-user { 284*43d2073cStracker-user if (!$helper->canAnnotate($user, $aclLevel)) { 285*43d2073cStracker-user $this->sendError('Permission denied.'); 286*43d2073cStracker-user return; 287*43d2073cStracker-user } 288*43d2073cStracker-user $annId = isset($payload['annId']) ? (string) $payload['annId'] : ''; 289*43d2073cStracker-user $body = isset($payload['body']) ? (string) $payload['body'] : ''; 290*43d2073cStracker-user 291*43d2073cStracker-user if ($annId === '') { 292*43d2073cStracker-user $this->sendError('Missing annId.'); 293*43d2073cStracker-user return; 294*43d2073cStracker-user } 295*43d2073cStracker-user $result = $helper->addReply($id, $annId, $user, $body); 296*43d2073cStracker-user if ($result === false) { 297*43d2073cStracker-user $this->sendError('Invalid reply data or annotation not found.'); 298*43d2073cStracker-user return; 299*43d2073cStracker-user } 300*43d2073cStracker-user $this->sendSuccess(['reply' => $result]); 301*43d2073cStracker-user } 302*43d2073cStracker-user 303*43d2073cStracker-user /** 304*43d2073cStracker-user * Edit an annotation's body text. 305*43d2073cStracker-user * 306*43d2073cStracker-user * Payload: { action, id, annId, body } 307*43d2073cStracker-user * 308*43d2073cStracker-user * @param helper_plugin_annotations $helper 309*43d2073cStracker-user * @param string $id 310*43d2073cStracker-user * @param array $payload 311*43d2073cStracker-user * @param string $user 312*43d2073cStracker-user * @param bool $isAdmin 313*43d2073cStracker-user */ 314*43d2073cStracker-user protected function actionEditAnnotation($helper, $id, array $payload, $user, $isAdmin) 315*43d2073cStracker-user { 316*43d2073cStracker-user $annId = isset($payload['annId']) ? (string) $payload['annId'] : ''; 317*43d2073cStracker-user $body = isset($payload['body']) ? (string) $payload['body'] : ''; 318*43d2073cStracker-user 319*43d2073cStracker-user if ($annId === '') { 320*43d2073cStracker-user $this->sendError('Missing annId.'); 321*43d2073cStracker-user return; 322*43d2073cStracker-user } 323*43d2073cStracker-user $annotation = $helper->getAnnotation($id, $annId); 324*43d2073cStracker-user if ($annotation === null) { 325*43d2073cStracker-user $this->sendError('Annotation not found.'); 326*43d2073cStracker-user return; 327*43d2073cStracker-user } 328*43d2073cStracker-user if (!$helper->canEditAnnotation($annotation, $user, $isAdmin)) { 329*43d2073cStracker-user $this->sendError('Permission denied.'); 330*43d2073cStracker-user return; 331*43d2073cStracker-user } 332*43d2073cStracker-user $ok = $helper->updateAnnotationBody($id, $annId, $body); 333*43d2073cStracker-user if (!$ok) { 334*43d2073cStracker-user $this->sendError('Invalid body or annotation not found.'); 335*43d2073cStracker-user return; 336*43d2073cStracker-user } 337*43d2073cStracker-user $this->sendSuccess(['annotation' => $helper->getAnnotation($id, $annId)]); 338*43d2073cStracker-user } 339*43d2073cStracker-user 340*43d2073cStracker-user /** 341*43d2073cStracker-user * Edit a reply's body text. 342*43d2073cStracker-user * 343*43d2073cStracker-user * Payload: { action, id, annId, replyId, body } 344*43d2073cStracker-user * 345*43d2073cStracker-user * @param helper_plugin_annotations $helper 346*43d2073cStracker-user * @param string $id 347*43d2073cStracker-user * @param array $payload 348*43d2073cStracker-user * @param string $user 349*43d2073cStracker-user * @param bool $isAdmin 350*43d2073cStracker-user */ 351*43d2073cStracker-user protected function actionEditReply($helper, $id, array $payload, $user, $isAdmin) 352*43d2073cStracker-user { 353*43d2073cStracker-user $annId = isset($payload['annId']) ? (string) $payload['annId'] : ''; 354*43d2073cStracker-user $replyId = isset($payload['replyId']) ? (string) $payload['replyId'] : ''; 355*43d2073cStracker-user $body = isset($payload['body']) ? (string) $payload['body'] : ''; 356*43d2073cStracker-user 357*43d2073cStracker-user if ($annId === '' || $replyId === '') { 358*43d2073cStracker-user $this->sendError('Missing annId or replyId.'); 359*43d2073cStracker-user return; 360*43d2073cStracker-user } 361*43d2073cStracker-user $annotation = $helper->getAnnotation($id, $annId); 362*43d2073cStracker-user if ($annotation === null) { 363*43d2073cStracker-user $this->sendError('Annotation not found.'); 364*43d2073cStracker-user return; 365*43d2073cStracker-user } 366*43d2073cStracker-user // Find the reply to permission-check its author. 367*43d2073cStracker-user $reply = null; 368*43d2073cStracker-user foreach (($annotation['replies'] ?? []) as $r) { 369*43d2073cStracker-user if (($r['id'] ?? '') === $replyId) { 370*43d2073cStracker-user $reply = $r; 371*43d2073cStracker-user break; 372*43d2073cStracker-user } 373*43d2073cStracker-user } 374*43d2073cStracker-user if ($reply === null) { 375*43d2073cStracker-user $this->sendError('Reply not found.'); 376*43d2073cStracker-user return; 377*43d2073cStracker-user } 378*43d2073cStracker-user if (!$helper->canEditReply($reply, $user, $isAdmin)) { 379*43d2073cStracker-user $this->sendError('Permission denied.'); 380*43d2073cStracker-user return; 381*43d2073cStracker-user } 382*43d2073cStracker-user $ok = $helper->updateReply($id, $annId, $replyId, $body); 383*43d2073cStracker-user if (!$ok) { 384*43d2073cStracker-user $this->sendError('Invalid body or reply not found.'); 385*43d2073cStracker-user return; 386*43d2073cStracker-user } 387*43d2073cStracker-user $this->sendSuccess(['annotation' => $helper->getAnnotation($id, $annId)]); 388*43d2073cStracker-user } 389*43d2073cStracker-user 390*43d2073cStracker-user /** 391*43d2073cStracker-user * Delete an annotation and all its replies. 392*43d2073cStracker-user * 393*43d2073cStracker-user * Payload: { action, id, annId } 394*43d2073cStracker-user * 395*43d2073cStracker-user * @param helper_plugin_annotations $helper 396*43d2073cStracker-user * @param string $id 397*43d2073cStracker-user * @param array $payload 398*43d2073cStracker-user * @param string $user 399*43d2073cStracker-user * @param bool $isAdmin 400*43d2073cStracker-user */ 401*43d2073cStracker-user protected function actionDeleteAnnotation($helper, $id, array $payload, $user, $isAdmin) 402*43d2073cStracker-user { 403*43d2073cStracker-user $annId = isset($payload['annId']) ? (string) $payload['annId'] : ''; 404*43d2073cStracker-user 405*43d2073cStracker-user if ($annId === '') { 406*43d2073cStracker-user $this->sendError('Missing annId.'); 407*43d2073cStracker-user return; 408*43d2073cStracker-user } 409*43d2073cStracker-user $annotation = $helper->getAnnotation($id, $annId); 410*43d2073cStracker-user if ($annotation === null) { 411*43d2073cStracker-user $this->sendError('Annotation not found.'); 412*43d2073cStracker-user return; 413*43d2073cStracker-user } 414*43d2073cStracker-user if (!$helper->canEditAnnotation($annotation, $user, $isAdmin)) { 415*43d2073cStracker-user $this->sendError('Permission denied.'); 416*43d2073cStracker-user return; 417*43d2073cStracker-user } 418*43d2073cStracker-user $ok = $helper->deleteAnnotation($id, $annId); 419*43d2073cStracker-user if (!$ok) { 420*43d2073cStracker-user $this->sendError('Delete failed.'); 421*43d2073cStracker-user return; 422*43d2073cStracker-user } 423*43d2073cStracker-user $this->sendSuccess(['stats' => $helper->getStats($id)]); 424*43d2073cStracker-user } 425*43d2073cStracker-user 426*43d2073cStracker-user /** 427*43d2073cStracker-user * Delete a reply. 428*43d2073cStracker-user * 429*43d2073cStracker-user * Payload: { action, id, annId, replyId } 430*43d2073cStracker-user * 431*43d2073cStracker-user * @param helper_plugin_annotations $helper 432*43d2073cStracker-user * @param string $id 433*43d2073cStracker-user * @param array $payload 434*43d2073cStracker-user * @param string $user 435*43d2073cStracker-user * @param bool $isAdmin 436*43d2073cStracker-user */ 437*43d2073cStracker-user protected function actionDeleteReply($helper, $id, array $payload, $user, $isAdmin) 438*43d2073cStracker-user { 439*43d2073cStracker-user $annId = isset($payload['annId']) ? (string) $payload['annId'] : ''; 440*43d2073cStracker-user $replyId = isset($payload['replyId']) ? (string) $payload['replyId'] : ''; 441*43d2073cStracker-user 442*43d2073cStracker-user if ($annId === '' || $replyId === '') { 443*43d2073cStracker-user $this->sendError('Missing annId or replyId.'); 444*43d2073cStracker-user return; 445*43d2073cStracker-user } 446*43d2073cStracker-user $annotation = $helper->getAnnotation($id, $annId); 447*43d2073cStracker-user if ($annotation === null) { 448*43d2073cStracker-user $this->sendError('Annotation not found.'); 449*43d2073cStracker-user return; 450*43d2073cStracker-user } 451*43d2073cStracker-user $reply = null; 452*43d2073cStracker-user foreach (($annotation['replies'] ?? []) as $r) { 453*43d2073cStracker-user if (($r['id'] ?? '') === $replyId) { 454*43d2073cStracker-user $reply = $r; 455*43d2073cStracker-user break; 456*43d2073cStracker-user } 457*43d2073cStracker-user } 458*43d2073cStracker-user if ($reply === null) { 459*43d2073cStracker-user $this->sendError('Reply not found.'); 460*43d2073cStracker-user return; 461*43d2073cStracker-user } 462*43d2073cStracker-user if (!$helper->canEditReply($reply, $user, $isAdmin)) { 463*43d2073cStracker-user $this->sendError('Permission denied.'); 464*43d2073cStracker-user return; 465*43d2073cStracker-user } 466*43d2073cStracker-user $ok = $helper->deleteReply($id, $annId, $replyId); 467*43d2073cStracker-user if (!$ok) { 468*43d2073cStracker-user $this->sendError('Delete failed.'); 469*43d2073cStracker-user return; 470*43d2073cStracker-user } 471*43d2073cStracker-user $this->sendSuccess(['annotation' => $helper->getAnnotation($id, $annId)]); 472*43d2073cStracker-user } 473*43d2073cStracker-user 474*43d2073cStracker-user /** 475*43d2073cStracker-user * Resolve or reopen an annotation. 476*43d2073cStracker-user * 477*43d2073cStracker-user * Payload: { action, id, annId, status:"open"|"resolved" } 478*43d2073cStracker-user * 479*43d2073cStracker-user * @param helper_plugin_annotations $helper 480*43d2073cStracker-user * @param string $id 481*43d2073cStracker-user * @param array $payload 482*43d2073cStracker-user * @param string $user 483*43d2073cStracker-user * @param int $aclLevel 484*43d2073cStracker-user */ 485*43d2073cStracker-user protected function actionResolve($helper, $id, array $payload, $user, $aclLevel) 486*43d2073cStracker-user { 487*43d2073cStracker-user if (!$helper->canAnnotate($user, $aclLevel)) { 488*43d2073cStracker-user $this->sendError('Permission denied.'); 489*43d2073cStracker-user return; 490*43d2073cStracker-user } 491*43d2073cStracker-user $annId = isset($payload['annId']) ? (string) $payload['annId'] : ''; 492*43d2073cStracker-user $status = isset($payload['status']) ? (string) $payload['status'] : ''; 493*43d2073cStracker-user 494*43d2073cStracker-user if ($annId === '') { 495*43d2073cStracker-user $this->sendError('Missing annId.'); 496*43d2073cStracker-user return; 497*43d2073cStracker-user } 498*43d2073cStracker-user $ok = $helper->setStatus($id, $annId, $status, $user); 499*43d2073cStracker-user if (!$ok) { 500*43d2073cStracker-user $this->sendError('Invalid status or annotation not found.'); 501*43d2073cStracker-user return; 502*43d2073cStracker-user } 503*43d2073cStracker-user $this->sendSuccess(['annotation' => $helper->getAnnotation($id, $annId)]); 504*43d2073cStracker-user } 505*43d2073cStracker-user 506*43d2073cStracker-user /** 507*43d2073cStracker-user * Remove all resolved annotations on the page. Admin only. 508*43d2073cStracker-user * 509*43d2073cStracker-user * Payload: { action, id } 510*43d2073cStracker-user * 511*43d2073cStracker-user * @param helper_plugin_annotations $helper 512*43d2073cStracker-user * @param string $id 513*43d2073cStracker-user * @param bool $isAdmin 514*43d2073cStracker-user */ 515*43d2073cStracker-user protected function actionClearResolved($helper, $id, $isAdmin) 516*43d2073cStracker-user { 517*43d2073cStracker-user if (!$helper->canClear($isAdmin)) { 518*43d2073cStracker-user $this->sendError('Permission denied.'); 519*43d2073cStracker-user return; 520*43d2073cStracker-user } 521*43d2073cStracker-user $count = $helper->clearResolved($id); 522*43d2073cStracker-user if ($count === false) { 523*43d2073cStracker-user $this->sendError('Clear failed.'); 524*43d2073cStracker-user return; 525*43d2073cStracker-user } 526*43d2073cStracker-user $this->sendSuccess(['removed' => $count, 'stats' => $helper->getStats($id)]); 527*43d2073cStracker-user } 528*43d2073cStracker-user 529*43d2073cStracker-user /** 530*43d2073cStracker-user * Remove all orphaned annotations on the page. Admin only. 531*43d2073cStracker-user * 532*43d2073cStracker-user * Payload: { action, id } 533*43d2073cStracker-user * 534*43d2073cStracker-user * @param helper_plugin_annotations $helper 535*43d2073cStracker-user * @param string $id 536*43d2073cStracker-user * @param bool $isAdmin 537*43d2073cStracker-user */ 538*43d2073cStracker-user protected function actionClearOrphaned($helper, $id, $isAdmin) 539*43d2073cStracker-user { 540*43d2073cStracker-user if (!$helper->canClear($isAdmin)) { 541*43d2073cStracker-user $this->sendError('Permission denied.'); 542*43d2073cStracker-user return; 543*43d2073cStracker-user } 544*43d2073cStracker-user $count = $helper->clearOrphaned($id); 545*43d2073cStracker-user if ($count === false) { 546*43d2073cStracker-user $this->sendError('Clear failed.'); 547*43d2073cStracker-user return; 548*43d2073cStracker-user } 549*43d2073cStracker-user $this->sendSuccess(['removed' => $count, 'stats' => $helper->getStats($id)]); 550*43d2073cStracker-user } 551*43d2073cStracker-user 552*43d2073cStracker-user // ------------------------------------------------------------------ 553*43d2073cStracker-user // Utilities 554*43d2073cStracker-user // ------------------------------------------------------------------ 555*43d2073cStracker-user 556*43d2073cStracker-user /** 557*43d2073cStracker-user * Whether the current user has the annotations_enabled preference on. 558*43d2073cStracker-user * 559*43d2073cStracker-user * If the usersettings plugin is absent the feature defaults to enabled. 560*43d2073cStracker-user * Public so templates and tests can call it directly. 561*43d2073cStracker-user * 562*43d2073cStracker-user * @return bool 563*43d2073cStracker-user */ 564*43d2073cStracker-user public function isEnabledForUser() 565*43d2073cStracker-user { 566*43d2073cStracker-user /** @var helper_plugin_usersettings|null $us */ 567*43d2073cStracker-user $us = plugin_load('helper', 'usersettings'); 568*43d2073cStracker-user if (!$us) { 569*43d2073cStracker-user return true; // usersettings not installed — default on 570*43d2073cStracker-user } 571*43d2073cStracker-user $value = $us->getPreference('annotations_enabled'); 572*43d2073cStracker-user // getPreference returns null when the toggle is not registered yet 573*43d2073cStracker-user // (e.g. very first page load before the event has fired). 574*43d2073cStracker-user return ($value === null) ? true : (bool) $value; 575*43d2073cStracker-user } 576*43d2073cStracker-user 577*43d2073cStracker-user /** 578*43d2073cStracker-user * Parse the request body as JSON; also accepts form-encoded POSTs for 579*43d2073cStracker-user * simpler test scripts. 580*43d2073cStracker-user * 581*43d2073cStracker-user * @return array|null 582*43d2073cStracker-user */ 583*43d2073cStracker-user protected function readPayload() 584*43d2073cStracker-user { 585*43d2073cStracker-user $ct = $_SERVER['CONTENT_TYPE'] ?? ''; 586*43d2073cStracker-user if (strpos($ct, 'application/json') !== false) { 587*43d2073cStracker-user $raw = file_get_contents('php://input'); 588*43d2073cStracker-user $data = json_decode($raw, true); 589*43d2073cStracker-user return is_array($data) ? $data : null; 590*43d2073cStracker-user } 591*43d2073cStracker-user // For GET requests (load action), read from query string. 592*43d2073cStracker-user if ($_SERVER['REQUEST_METHOD'] === 'GET') { 593*43d2073cStracker-user return $_GET ? (array) $_GET : []; 594*43d2073cStracker-user } 595*43d2073cStracker-user // Fall back to form-encoded POST (useful for simple curl tests). 596*43d2073cStracker-user return $_POST ? (array) $_POST : []; 597*43d2073cStracker-user } 598*43d2073cStracker-user 599*43d2073cStracker-user /** 600*43d2073cStracker-user * Return all annotations for a page (read-only, no token required). 601*43d2073cStracker-user * 602*43d2073cStracker-user * The ACL check is still enforced: only users with at least AUTH_READ 603*43d2073cStracker-user * on the page can read its annotations. 604*43d2073cStracker-user * 605*43d2073cStracker-user * @param helper_plugin_annotations $helper 606*43d2073cStracker-user * @param string $id 607*43d2073cStracker-user * @param int $aclLevel 608*43d2073cStracker-user */ 609*43d2073cStracker-user protected function actionLoad($helper, $id, $aclLevel) 610*43d2073cStracker-user { 611*43d2073cStracker-user if ($aclLevel < AUTH_READ) { 612*43d2073cStracker-user $this->sendError('Permission denied.'); 613*43d2073cStracker-user return; 614*43d2073cStracker-user } 615*43d2073cStracker-user $annotations = $helper->getAnnotations($id); 616*43d2073cStracker-user $this->sendSuccess(['annotations' => $annotations]); 617*43d2073cStracker-user } 618*43d2073cStracker-user 619*43d2073cStracker-user /** 620*43d2073cStracker-user * Emit a JSON success response and exit. 621*43d2073cStracker-user * 622*43d2073cStracker-user * @param array $extra additional fields merged into the response 623*43d2073cStracker-user */ 624*43d2073cStracker-user protected function sendSuccess(array $extra = []) 625*43d2073cStracker-user { 626*43d2073cStracker-user echo json_encode(array_merge(['success' => true], $extra), JSON_PRETTY_PRINT); 627*43d2073cStracker-user } 628*43d2073cStracker-user 629*43d2073cStracker-user /** 630*43d2073cStracker-user * Emit a JSON error response and exit. 631*43d2073cStracker-user * 632*43d2073cStracker-user * @param string $message human-readable error 633*43d2073cStracker-user */ 634*43d2073cStracker-user protected function sendError($message) 635*43d2073cStracker-user { 636*43d2073cStracker-user echo json_encode(['success' => false, 'error' => $message], JSON_PRETTY_PRINT); 637*43d2073cStracker-user } 638*43d2073cStracker-user} 639