143d2073cStracker-user<?php 243d2073cStracker-user 343d2073cStracker-user/** 443d2073cStracker-user * Annotations plugin — event registration and AJAX endpoint. 543d2073cStracker-user * 643d2073cStracker-user * Responsibilities: 743d2073cStracker-user * 843d2073cStracker-user * 1. Register a per-user "annotations_enabled" toggle via the usersettings 943d2073cStracker-user * plugin's PLUGIN_USERSETTINGS_REGISTER event (BEFORE, so it fires when 1043d2073cStracker-user * the usersettings helper calls getRegisteredToggles()). 1143d2073cStracker-user * 1243d2073cStracker-user * 2. Push the current user's preference and the page's annotation stats 1343d2073cStracker-user * into JSINFO on every normal page view, so script.js can gate itself 1443d2073cStracker-user * and seed the counter without an extra round-trip. 1543d2073cStracker-user * 1643d2073cStracker-user * 3. Serve the AJAX endpoint at: 1743d2073cStracker-user * /lib/exe/ajax.php?call=annotations 1843d2073cStracker-user * POST body (application/json) carries { action, id, ... }. 1943d2073cStracker-user * All state-changing actions require a valid DokuWiki security token. 2043d2073cStracker-user * Every response is JSON: { success:true, ... } or { success:false, error:"..." }. 2143d2073cStracker-user * 2243d2073cStracker-user * Supported actions (all POST): 2343d2073cStracker-user * create — body, anchor (object) 2443d2073cStracker-user * reply — annId, body 2543d2073cStracker-user * edit_annotation — annId, body 2643d2073cStracker-user * edit_reply — annId, replyId, body 2743d2073cStracker-user * delete_annotation — annId 2843d2073cStracker-user * delete_reply — annId, replyId 2943d2073cStracker-user * resolve — annId, status ("open"|"resolved") 3043d2073cStracker-user * clear_resolved — (no extra fields) 3143d2073cStracker-user * clear_orphaned — (no extra fields) 3243d2073cStracker-user * 3343d2073cStracker-user * Permission enforcement is done here; the helper's permission methods are 3443d2073cStracker-user * called with facts gathered from the DokuWiki global state. 3543d2073cStracker-user */ 3643d2073cStracker-user 3743d2073cStracker-user// must be run within DokuWiki 3843d2073cStracker-userif (!defined('DOKU_INC')) die(); 3943d2073cStracker-user 4043d2073cStracker-userclass action_plugin_annotations extends DokuWiki_Action_Plugin 4143d2073cStracker-user{ 4243d2073cStracker-user // ------------------------------------------------------------------ 4343d2073cStracker-user // Event registration 4443d2073cStracker-user // ------------------------------------------------------------------ 4543d2073cStracker-user 4643d2073cStracker-user /** 4743d2073cStracker-user * @param Doku_Event_Handler $controller 4843d2073cStracker-user */ 4943d2073cStracker-user public function register(Doku_Event_Handler $controller) 5043d2073cStracker-user { 5143d2073cStracker-user // Register our toggle with the usersettings plugin. 5243d2073cStracker-user $controller->register_hook( 5343d2073cStracker-user 'PLUGIN_USERSETTINGS_REGISTER', 5443d2073cStracker-user 'BEFORE', 5543d2073cStracker-user $this, 5643d2073cStracker-user 'handleSettingsRegister' 5743d2073cStracker-user ); 5843d2073cStracker-user 5943d2073cStracker-user // Inject annotation stats + user preference into JSINFO. 6043d2073cStracker-user $controller->register_hook( 6143d2073cStracker-user 'TPL_METAHEADER_OUTPUT', 6243d2073cStracker-user 'BEFORE', 6343d2073cStracker-user $this, 6443d2073cStracker-user 'handleMetaHeader' 6543d2073cStracker-user ); 6643d2073cStracker-user 6743d2073cStracker-user // Handle the AJAX call. 6843d2073cStracker-user $controller->register_hook( 6943d2073cStracker-user 'AJAX_CALL_UNKNOWN', 7043d2073cStracker-user 'BEFORE', 7143d2073cStracker-user $this, 7243d2073cStracker-user 'handleAjax' 7343d2073cStracker-user ); 7443d2073cStracker-user } 7543d2073cStracker-user 7643d2073cStracker-user // ------------------------------------------------------------------ 7743d2073cStracker-user // 1. usersettings toggle registration 7843d2073cStracker-user // ------------------------------------------------------------------ 7943d2073cStracker-user 8043d2073cStracker-user /** 8143d2073cStracker-user * Append the annotations_enabled toggle definition to the event data. 8243d2073cStracker-user * 8343d2073cStracker-user * The event data is an array that the usersettings helper fires with 8443d2073cStracker-user * createAndTrigger(); every handler appends its definition(s). 8543d2073cStracker-user * 8643d2073cStracker-user * @param Doku_Event $event PLUGIN_USERSETTINGS_REGISTER 8743d2073cStracker-user * @param mixed $param 8843d2073cStracker-user */ 8943d2073cStracker-user public function handleSettingsRegister(Doku_Event $event, $param) 9043d2073cStracker-user { 9143d2073cStracker-user $event->data[] = [ 9243d2073cStracker-user 'key' => 'annotations_enabled', 9343d2073cStracker-user 'label' => $this->getLang('toggle_label'), 9443d2073cStracker-user 'desc' => $this->getLang('toggle_desc'), 9543d2073cStracker-user 'type' => 'checkbox', 9643d2073cStracker-user 'default' => true, 9743d2073cStracker-user 'plugin' => 'annotations', 9843d2073cStracker-user ]; 9943d2073cStracker-user } 10043d2073cStracker-user 10143d2073cStracker-user // ------------------------------------------------------------------ 10243d2073cStracker-user // 2. Inject into JSINFO 10343d2073cStracker-user // ------------------------------------------------------------------ 10443d2073cStracker-user 10543d2073cStracker-user /** 106b8076f00Stracker-user * Add annotation stats and the user preference to JSINFO so script.js 10743d2073cStracker-user * does not need an extra round-trip on page load. 10843d2073cStracker-user * 109b8076f00Stracker-user * IMPORTANT: tpl_metaheaders() calls jsinfo() and then immediately 110b8076f00Stracker-user * JSON-encodes $JSINFO into an inline <script> string BEFORE firing 111b8076f00Stracker-user * TPL_METAHEADER_OUTPUT. Writing to $JSINFO here is therefore too late. 112b8076f00Stracker-user * Instead we locate that inline script block in $event->data and append 113b8076f00Stracker-user * a JSINFO.annotations = {...}; statement so it runs in the same scope. 114b8076f00Stracker-user * 11543d2073cStracker-user * @param Doku_Event $event TPL_METAHEADER_OUTPUT 11643d2073cStracker-user * @param mixed $param 11743d2073cStracker-user */ 11843d2073cStracker-user public function handleMetaHeader(Doku_Event $event, $param) 11943d2073cStracker-user { 120b8076f00Stracker-user global $ID, $ACT; 121b8076f00Stracker-user 122b8076f00Stracker-user // Only inject on normal page-view actions. 123b8076f00Stracker-user if (!in_array(act_clean($ACT), ['show', 'export_xhtml'], true)) { 124b8076f00Stracker-user return; 125b8076f00Stracker-user } 12643d2073cStracker-user 12743d2073cStracker-user /** @var helper_plugin_annotations $helper */ 12843d2073cStracker-user $helper = $this->loadHelper('annotations', false); 12943d2073cStracker-user if (!$helper) { 13043d2073cStracker-user return; 13143d2073cStracker-user } 13243d2073cStracker-user 1337d2714c7Stracker-user global $INFO; 1347d2714c7Stracker-user 13543d2073cStracker-user $enabled = $this->isEnabledForUser(); 13643d2073cStracker-user $stats = $helper->getStats($ID); 13743d2073cStracker-user 1387d2714c7Stracker-user // DokuWiki's jsinfo() does not expose user identity, so we inject it 1397d2714c7Stracker-user // here. JS uses these to gate the selection tooltip and permission UI. 1407d2714c7Stracker-user $user = (string) ($_SERVER['REMOTE_USER'] ?? ''); 1417d2714c7Stracker-user $isAdmin = !empty($INFO['isadmin']); 1427d2714c7Stracker-user 143b8076f00Stracker-user $payload = json_encode([ 14443d2073cStracker-user 'enabled' => $enabled, 14543d2073cStracker-user 'pageId' => $ID, 14643d2073cStracker-user 'stats' => $stats, 1477d2714c7Stracker-user 'user' => $user, 1487d2714c7Stracker-user 'isAdmin' => $isAdmin, 1497d2714c7Stracker-user 'token' => getSecurityToken(), // CSRF token for AJAX POSTs 150b8076f00Stracker-user ], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); 151b8076f00Stracker-user 152b8076f00Stracker-user // The inline script block containing "var JSINFO = ...;" is in 153b8076f00Stracker-user // $event->data['script']. Find it and append our assignment so it 154b8076f00Stracker-user // runs in the same scope after JSINFO is already declared. 155b8076f00Stracker-user if (!empty($event->data['script'])) { 156b8076f00Stracker-user foreach ($event->data['script'] as &$scriptTag) { 157b8076f00Stracker-user if ( 158b8076f00Stracker-user isset($scriptTag['_data']) && 159b8076f00Stracker-user strpos($scriptTag['_data'], 'var JSINFO') !== false 160b8076f00Stracker-user ) { 161b8076f00Stracker-user $scriptTag['_data'] .= 'JSINFO.annotations=' . $payload . ';'; 162b8076f00Stracker-user break; 163b8076f00Stracker-user } 164b8076f00Stracker-user } 165b8076f00Stracker-user unset($scriptTag); 166b8076f00Stracker-user } 16743d2073cStracker-user } 16843d2073cStracker-user 16943d2073cStracker-user // ------------------------------------------------------------------ 17043d2073cStracker-user // 3. AJAX endpoint 17143d2073cStracker-user // ------------------------------------------------------------------ 17243d2073cStracker-user 17343d2073cStracker-user /** 17443d2073cStracker-user * Handle AJAX calls for the annotations plugin. 17543d2073cStracker-user * Ignores calls not addressed to us. 17643d2073cStracker-user * 17743d2073cStracker-user * @param Doku_Event $event AJAX_CALL_UNKNOWN 17843d2073cStracker-user * @param mixed $param 17943d2073cStracker-user */ 18043d2073cStracker-user public function handleAjax(Doku_Event $event, $param) 18143d2073cStracker-user { 18243d2073cStracker-user if ($event->data !== 'annotations') { 18343d2073cStracker-user return; 18443d2073cStracker-user } 18543d2073cStracker-user $event->stopPropagation(); 18643d2073cStracker-user $event->preventDefault(); 18743d2073cStracker-user 18843d2073cStracker-user header('Content-Type: application/json; charset=utf-8'); 18943d2073cStracker-user 19043d2073cStracker-user // Parse JSON body; fall back to POST/GET fields for simple callers. 19143d2073cStracker-user // The 'load' action is a GET request, so we accept query parameters too. 19243d2073cStracker-user $payload = $this->readPayload(); 19343d2073cStracker-user if ($payload === null) { 19443d2073cStracker-user $this->sendError('Invalid request body.'); 19543d2073cStracker-user return; 19643d2073cStracker-user } 19743d2073cStracker-user 19843d2073cStracker-user $action = isset($payload['action']) ? (string) $payload['action'] : ''; 19943d2073cStracker-user // For the read-only 'load' action, accept GET requests without a token. 20043d2073cStracker-user // All state-changing actions require a valid DokuWiki security token. 201*f58805fbStracker-user // checkSecurityToken() reads from $_REQUEST (form fields), so when the 202*f58805fbStracker-user // request body is JSON we must inject the token from the parsed payload 203*f58805fbStracker-user // into $_POST / $_REQUEST before calling it. 204*f58805fbStracker-user if ($action !== 'load') { 205*f58805fbStracker-user $jsonToken = isset($payload['sectok']) ? (string) $payload['sectok'] : ''; 206*f58805fbStracker-user if ($jsonToken !== '' && !isset($_REQUEST['sectok'])) { 207*f58805fbStracker-user $_POST['sectok'] = $jsonToken; 208*f58805fbStracker-user $_REQUEST['sectok'] = $jsonToken; 209*f58805fbStracker-user } 210*f58805fbStracker-user if (!checkSecurityToken()) { 21143d2073cStracker-user $this->sendError('Invalid security token.'); 21243d2073cStracker-user return; 21343d2073cStracker-user } 214*f58805fbStracker-user } 21543d2073cStracker-user $id = isset($payload['id']) ? cleanID((string) $payload['id']) : ''; 21643d2073cStracker-user 21743d2073cStracker-user if ($action === '' || $id === '') { 21843d2073cStracker-user $this->sendError('Missing action or page id.'); 21943d2073cStracker-user return; 22043d2073cStracker-user } 22143d2073cStracker-user 22243d2073cStracker-user /** @var helper_plugin_annotations $helper */ 22343d2073cStracker-user $helper = $this->loadHelper('annotations', false); 22443d2073cStracker-user if (!$helper) { 22543d2073cStracker-user $this->sendError('Annotations helper unavailable.'); 22643d2073cStracker-user return; 22743d2073cStracker-user } 22843d2073cStracker-user 22943d2073cStracker-user // Gather facts once; pass them to the helper's permission methods. 23043d2073cStracker-user global $USERINFO; 23143d2073cStracker-user $user = (string) ($_SERVER['REMOTE_USER'] ?? ''); 23243d2073cStracker-user $isAdmin = (bool) ($USERINFO['grps'] ?? false) 23343d2073cStracker-user ? in_array('admin', (array) ($USERINFO['grps'] ?? []), true) 23443d2073cStracker-user : false; 23543d2073cStracker-user // also honour DokuWiki's own admin flag 23643d2073cStracker-user if (!$isAdmin) { 23743d2073cStracker-user global $INFO; 23843d2073cStracker-user $isAdmin = !empty($INFO['isadmin']); 23943d2073cStracker-user } 24043d2073cStracker-user $aclLevel = auth_quickaclcheck($id); 24143d2073cStracker-user 24243d2073cStracker-user // Route to the correct handler method. 24343d2073cStracker-user switch ($action) { 24443d2073cStracker-user case 'load': 24543d2073cStracker-user $this->actionLoad($helper, $id, $aclLevel); 24643d2073cStracker-user break; 24743d2073cStracker-user case 'create': 24843d2073cStracker-user $this->actionCreate($helper, $id, $payload, $user, $aclLevel); 24943d2073cStracker-user break; 25043d2073cStracker-user case 'reply': 25143d2073cStracker-user $this->actionReply($helper, $id, $payload, $user, $aclLevel); 25243d2073cStracker-user break; 25343d2073cStracker-user case 'edit_annotation': 25443d2073cStracker-user $this->actionEditAnnotation($helper, $id, $payload, $user, $isAdmin); 25543d2073cStracker-user break; 25643d2073cStracker-user case 'edit_reply': 25743d2073cStracker-user $this->actionEditReply($helper, $id, $payload, $user, $isAdmin); 25843d2073cStracker-user break; 25943d2073cStracker-user case 'delete_annotation': 26043d2073cStracker-user $this->actionDeleteAnnotation($helper, $id, $payload, $user, $isAdmin); 26143d2073cStracker-user break; 26243d2073cStracker-user case 'delete_reply': 26343d2073cStracker-user $this->actionDeleteReply($helper, $id, $payload, $user, $isAdmin); 26443d2073cStracker-user break; 26543d2073cStracker-user case 'resolve': 26643d2073cStracker-user $this->actionResolve($helper, $id, $payload, $user, $aclLevel); 26743d2073cStracker-user break; 26843d2073cStracker-user case 'clear_resolved': 26943d2073cStracker-user $this->actionClearResolved($helper, $id, $isAdmin); 27043d2073cStracker-user break; 27143d2073cStracker-user case 'clear_orphaned': 27243d2073cStracker-user $this->actionClearOrphaned($helper, $id, $isAdmin); 27343d2073cStracker-user break; 27443d2073cStracker-user default: 27543d2073cStracker-user $this->sendError('Unknown action: ' . hsc($action)); 27643d2073cStracker-user } 27743d2073cStracker-user } 27843d2073cStracker-user 27943d2073cStracker-user // ------------------------------------------------------------------ 28043d2073cStracker-user // Action handlers (one per supported action) 28143d2073cStracker-user // ------------------------------------------------------------------ 28243d2073cStracker-user 28343d2073cStracker-user /** 28443d2073cStracker-user * Create a new annotation. 28543d2073cStracker-user * 28643d2073cStracker-user * Payload: { action, id, anchor:{exact,prefix,suffix,start}, body } 28743d2073cStracker-user * 28843d2073cStracker-user * @param helper_plugin_annotations $helper 28943d2073cStracker-user * @param string $id 29043d2073cStracker-user * @param array $payload 29143d2073cStracker-user * @param string $user 29243d2073cStracker-user * @param int $aclLevel 29343d2073cStracker-user */ 29443d2073cStracker-user protected function actionCreate($helper, $id, array $payload, $user, $aclLevel) 29543d2073cStracker-user { 29643d2073cStracker-user if (!$helper->canAnnotate($user, $aclLevel)) { 29743d2073cStracker-user $this->sendError('Permission denied.'); 29843d2073cStracker-user return; 29943d2073cStracker-user } 30043d2073cStracker-user $anchor = isset($payload['anchor']) && is_array($payload['anchor']) 30143d2073cStracker-user ? $payload['anchor'] 30243d2073cStracker-user : []; 30343d2073cStracker-user $body = isset($payload['body']) ? (string) $payload['body'] : ''; 30443d2073cStracker-user 30543d2073cStracker-user $result = $helper->createAnnotation($id, $anchor, $user, $body); 30643d2073cStracker-user if ($result === false) { 30743d2073cStracker-user $this->sendError('Invalid annotation data.'); 30843d2073cStracker-user return; 30943d2073cStracker-user } 31043d2073cStracker-user $this->sendSuccess(['annotation' => $result]); 31143d2073cStracker-user } 31243d2073cStracker-user 31343d2073cStracker-user /** 31443d2073cStracker-user * Add a reply to an existing annotation. 31543d2073cStracker-user * 31643d2073cStracker-user * Payload: { action, id, annId, body } 31743d2073cStracker-user * 31843d2073cStracker-user * @param helper_plugin_annotations $helper 31943d2073cStracker-user * @param string $id 32043d2073cStracker-user * @param array $payload 32143d2073cStracker-user * @param string $user 32243d2073cStracker-user * @param int $aclLevel 32343d2073cStracker-user */ 32443d2073cStracker-user protected function actionReply($helper, $id, array $payload, $user, $aclLevel) 32543d2073cStracker-user { 32643d2073cStracker-user if (!$helper->canAnnotate($user, $aclLevel)) { 32743d2073cStracker-user $this->sendError('Permission denied.'); 32843d2073cStracker-user return; 32943d2073cStracker-user } 33043d2073cStracker-user $annId = isset($payload['annId']) ? (string) $payload['annId'] : ''; 33143d2073cStracker-user $body = isset($payload['body']) ? (string) $payload['body'] : ''; 33243d2073cStracker-user 33343d2073cStracker-user if ($annId === '') { 33443d2073cStracker-user $this->sendError('Missing annId.'); 33543d2073cStracker-user return; 33643d2073cStracker-user } 33743d2073cStracker-user $result = $helper->addReply($id, $annId, $user, $body); 33843d2073cStracker-user if ($result === false) { 33943d2073cStracker-user $this->sendError('Invalid reply data or annotation not found.'); 34043d2073cStracker-user return; 34143d2073cStracker-user } 34243d2073cStracker-user $this->sendSuccess(['reply' => $result]); 34343d2073cStracker-user } 34443d2073cStracker-user 34543d2073cStracker-user /** 34643d2073cStracker-user * Edit an annotation's body text. 34743d2073cStracker-user * 34843d2073cStracker-user * Payload: { action, id, annId, body } 34943d2073cStracker-user * 35043d2073cStracker-user * @param helper_plugin_annotations $helper 35143d2073cStracker-user * @param string $id 35243d2073cStracker-user * @param array $payload 35343d2073cStracker-user * @param string $user 35443d2073cStracker-user * @param bool $isAdmin 35543d2073cStracker-user */ 35643d2073cStracker-user protected function actionEditAnnotation($helper, $id, array $payload, $user, $isAdmin) 35743d2073cStracker-user { 35843d2073cStracker-user $annId = isset($payload['annId']) ? (string) $payload['annId'] : ''; 35943d2073cStracker-user $body = isset($payload['body']) ? (string) $payload['body'] : ''; 36043d2073cStracker-user 36143d2073cStracker-user if ($annId === '') { 36243d2073cStracker-user $this->sendError('Missing annId.'); 36343d2073cStracker-user return; 36443d2073cStracker-user } 36543d2073cStracker-user $annotation = $helper->getAnnotation($id, $annId); 36643d2073cStracker-user if ($annotation === null) { 36743d2073cStracker-user $this->sendError('Annotation not found.'); 36843d2073cStracker-user return; 36943d2073cStracker-user } 37043d2073cStracker-user if (!$helper->canEditAnnotation($annotation, $user, $isAdmin)) { 37143d2073cStracker-user $this->sendError('Permission denied.'); 37243d2073cStracker-user return; 37343d2073cStracker-user } 37443d2073cStracker-user $ok = $helper->updateAnnotationBody($id, $annId, $body); 37543d2073cStracker-user if (!$ok) { 37643d2073cStracker-user $this->sendError('Invalid body or annotation not found.'); 37743d2073cStracker-user return; 37843d2073cStracker-user } 37943d2073cStracker-user $this->sendSuccess(['annotation' => $helper->getAnnotation($id, $annId)]); 38043d2073cStracker-user } 38143d2073cStracker-user 38243d2073cStracker-user /** 38343d2073cStracker-user * Edit a reply's body text. 38443d2073cStracker-user * 38543d2073cStracker-user * Payload: { action, id, annId, replyId, body } 38643d2073cStracker-user * 38743d2073cStracker-user * @param helper_plugin_annotations $helper 38843d2073cStracker-user * @param string $id 38943d2073cStracker-user * @param array $payload 39043d2073cStracker-user * @param string $user 39143d2073cStracker-user * @param bool $isAdmin 39243d2073cStracker-user */ 39343d2073cStracker-user protected function actionEditReply($helper, $id, array $payload, $user, $isAdmin) 39443d2073cStracker-user { 39543d2073cStracker-user $annId = isset($payload['annId']) ? (string) $payload['annId'] : ''; 39643d2073cStracker-user $replyId = isset($payload['replyId']) ? (string) $payload['replyId'] : ''; 39743d2073cStracker-user $body = isset($payload['body']) ? (string) $payload['body'] : ''; 39843d2073cStracker-user 39943d2073cStracker-user if ($annId === '' || $replyId === '') { 40043d2073cStracker-user $this->sendError('Missing annId or replyId.'); 40143d2073cStracker-user return; 40243d2073cStracker-user } 40343d2073cStracker-user $annotation = $helper->getAnnotation($id, $annId); 40443d2073cStracker-user if ($annotation === null) { 40543d2073cStracker-user $this->sendError('Annotation not found.'); 40643d2073cStracker-user return; 40743d2073cStracker-user } 40843d2073cStracker-user // Find the reply to permission-check its author. 40943d2073cStracker-user $reply = null; 41043d2073cStracker-user foreach (($annotation['replies'] ?? []) as $r) { 41143d2073cStracker-user if (($r['id'] ?? '') === $replyId) { 41243d2073cStracker-user $reply = $r; 41343d2073cStracker-user break; 41443d2073cStracker-user } 41543d2073cStracker-user } 41643d2073cStracker-user if ($reply === null) { 41743d2073cStracker-user $this->sendError('Reply not found.'); 41843d2073cStracker-user return; 41943d2073cStracker-user } 42043d2073cStracker-user if (!$helper->canEditReply($reply, $user, $isAdmin)) { 42143d2073cStracker-user $this->sendError('Permission denied.'); 42243d2073cStracker-user return; 42343d2073cStracker-user } 42443d2073cStracker-user $ok = $helper->updateReply($id, $annId, $replyId, $body); 42543d2073cStracker-user if (!$ok) { 42643d2073cStracker-user $this->sendError('Invalid body or reply not found.'); 42743d2073cStracker-user return; 42843d2073cStracker-user } 42943d2073cStracker-user $this->sendSuccess(['annotation' => $helper->getAnnotation($id, $annId)]); 43043d2073cStracker-user } 43143d2073cStracker-user 43243d2073cStracker-user /** 43343d2073cStracker-user * Delete an annotation and all its replies. 43443d2073cStracker-user * 43543d2073cStracker-user * Payload: { action, id, annId } 43643d2073cStracker-user * 43743d2073cStracker-user * @param helper_plugin_annotations $helper 43843d2073cStracker-user * @param string $id 43943d2073cStracker-user * @param array $payload 44043d2073cStracker-user * @param string $user 44143d2073cStracker-user * @param bool $isAdmin 44243d2073cStracker-user */ 44343d2073cStracker-user protected function actionDeleteAnnotation($helper, $id, array $payload, $user, $isAdmin) 44443d2073cStracker-user { 44543d2073cStracker-user $annId = isset($payload['annId']) ? (string) $payload['annId'] : ''; 44643d2073cStracker-user 44743d2073cStracker-user if ($annId === '') { 44843d2073cStracker-user $this->sendError('Missing annId.'); 44943d2073cStracker-user return; 45043d2073cStracker-user } 45143d2073cStracker-user $annotation = $helper->getAnnotation($id, $annId); 45243d2073cStracker-user if ($annotation === null) { 45343d2073cStracker-user $this->sendError('Annotation not found.'); 45443d2073cStracker-user return; 45543d2073cStracker-user } 45643d2073cStracker-user if (!$helper->canEditAnnotation($annotation, $user, $isAdmin)) { 45743d2073cStracker-user $this->sendError('Permission denied.'); 45843d2073cStracker-user return; 45943d2073cStracker-user } 46043d2073cStracker-user $ok = $helper->deleteAnnotation($id, $annId); 46143d2073cStracker-user if (!$ok) { 46243d2073cStracker-user $this->sendError('Delete failed.'); 46343d2073cStracker-user return; 46443d2073cStracker-user } 46543d2073cStracker-user $this->sendSuccess(['stats' => $helper->getStats($id)]); 46643d2073cStracker-user } 46743d2073cStracker-user 46843d2073cStracker-user /** 46943d2073cStracker-user * Delete a reply. 47043d2073cStracker-user * 47143d2073cStracker-user * Payload: { action, id, annId, replyId } 47243d2073cStracker-user * 47343d2073cStracker-user * @param helper_plugin_annotations $helper 47443d2073cStracker-user * @param string $id 47543d2073cStracker-user * @param array $payload 47643d2073cStracker-user * @param string $user 47743d2073cStracker-user * @param bool $isAdmin 47843d2073cStracker-user */ 47943d2073cStracker-user protected function actionDeleteReply($helper, $id, array $payload, $user, $isAdmin) 48043d2073cStracker-user { 48143d2073cStracker-user $annId = isset($payload['annId']) ? (string) $payload['annId'] : ''; 48243d2073cStracker-user $replyId = isset($payload['replyId']) ? (string) $payload['replyId'] : ''; 48343d2073cStracker-user 48443d2073cStracker-user if ($annId === '' || $replyId === '') { 48543d2073cStracker-user $this->sendError('Missing annId or replyId.'); 48643d2073cStracker-user return; 48743d2073cStracker-user } 48843d2073cStracker-user $annotation = $helper->getAnnotation($id, $annId); 48943d2073cStracker-user if ($annotation === null) { 49043d2073cStracker-user $this->sendError('Annotation not found.'); 49143d2073cStracker-user return; 49243d2073cStracker-user } 49343d2073cStracker-user $reply = null; 49443d2073cStracker-user foreach (($annotation['replies'] ?? []) as $r) { 49543d2073cStracker-user if (($r['id'] ?? '') === $replyId) { 49643d2073cStracker-user $reply = $r; 49743d2073cStracker-user break; 49843d2073cStracker-user } 49943d2073cStracker-user } 50043d2073cStracker-user if ($reply === null) { 50143d2073cStracker-user $this->sendError('Reply not found.'); 50243d2073cStracker-user return; 50343d2073cStracker-user } 50443d2073cStracker-user if (!$helper->canEditReply($reply, $user, $isAdmin)) { 50543d2073cStracker-user $this->sendError('Permission denied.'); 50643d2073cStracker-user return; 50743d2073cStracker-user } 50843d2073cStracker-user $ok = $helper->deleteReply($id, $annId, $replyId); 50943d2073cStracker-user if (!$ok) { 51043d2073cStracker-user $this->sendError('Delete failed.'); 51143d2073cStracker-user return; 51243d2073cStracker-user } 51343d2073cStracker-user $this->sendSuccess(['annotation' => $helper->getAnnotation($id, $annId)]); 51443d2073cStracker-user } 51543d2073cStracker-user 51643d2073cStracker-user /** 51743d2073cStracker-user * Resolve or reopen an annotation. 51843d2073cStracker-user * 51943d2073cStracker-user * Payload: { action, id, annId, status:"open"|"resolved" } 52043d2073cStracker-user * 52143d2073cStracker-user * @param helper_plugin_annotations $helper 52243d2073cStracker-user * @param string $id 52343d2073cStracker-user * @param array $payload 52443d2073cStracker-user * @param string $user 52543d2073cStracker-user * @param int $aclLevel 52643d2073cStracker-user */ 52743d2073cStracker-user protected function actionResolve($helper, $id, array $payload, $user, $aclLevel) 52843d2073cStracker-user { 52943d2073cStracker-user if (!$helper->canAnnotate($user, $aclLevel)) { 53043d2073cStracker-user $this->sendError('Permission denied.'); 53143d2073cStracker-user return; 53243d2073cStracker-user } 53343d2073cStracker-user $annId = isset($payload['annId']) ? (string) $payload['annId'] : ''; 53443d2073cStracker-user $status = isset($payload['status']) ? (string) $payload['status'] : ''; 53543d2073cStracker-user 53643d2073cStracker-user if ($annId === '') { 53743d2073cStracker-user $this->sendError('Missing annId.'); 53843d2073cStracker-user return; 53943d2073cStracker-user } 54043d2073cStracker-user $ok = $helper->setStatus($id, $annId, $status, $user); 54143d2073cStracker-user if (!$ok) { 54243d2073cStracker-user $this->sendError('Invalid status or annotation not found.'); 54343d2073cStracker-user return; 54443d2073cStracker-user } 54543d2073cStracker-user $this->sendSuccess(['annotation' => $helper->getAnnotation($id, $annId)]); 54643d2073cStracker-user } 54743d2073cStracker-user 54843d2073cStracker-user /** 54943d2073cStracker-user * Remove all resolved annotations on the page. Admin only. 55043d2073cStracker-user * 55143d2073cStracker-user * Payload: { action, id } 55243d2073cStracker-user * 55343d2073cStracker-user * @param helper_plugin_annotations $helper 55443d2073cStracker-user * @param string $id 55543d2073cStracker-user * @param bool $isAdmin 55643d2073cStracker-user */ 55743d2073cStracker-user protected function actionClearResolved($helper, $id, $isAdmin) 55843d2073cStracker-user { 55943d2073cStracker-user if (!$helper->canClear($isAdmin)) { 56043d2073cStracker-user $this->sendError('Permission denied.'); 56143d2073cStracker-user return; 56243d2073cStracker-user } 56343d2073cStracker-user $count = $helper->clearResolved($id); 56443d2073cStracker-user if ($count === false) { 56543d2073cStracker-user $this->sendError('Clear failed.'); 56643d2073cStracker-user return; 56743d2073cStracker-user } 56843d2073cStracker-user $this->sendSuccess(['removed' => $count, 'stats' => $helper->getStats($id)]); 56943d2073cStracker-user } 57043d2073cStracker-user 57143d2073cStracker-user /** 57243d2073cStracker-user * Remove all orphaned annotations on the page. Admin only. 57343d2073cStracker-user * 57443d2073cStracker-user * Payload: { action, id } 57543d2073cStracker-user * 57643d2073cStracker-user * @param helper_plugin_annotations $helper 57743d2073cStracker-user * @param string $id 57843d2073cStracker-user * @param bool $isAdmin 57943d2073cStracker-user */ 58043d2073cStracker-user protected function actionClearOrphaned($helper, $id, $isAdmin) 58143d2073cStracker-user { 58243d2073cStracker-user if (!$helper->canClear($isAdmin)) { 58343d2073cStracker-user $this->sendError('Permission denied.'); 58443d2073cStracker-user return; 58543d2073cStracker-user } 58643d2073cStracker-user $count = $helper->clearOrphaned($id); 58743d2073cStracker-user if ($count === false) { 58843d2073cStracker-user $this->sendError('Clear failed.'); 58943d2073cStracker-user return; 59043d2073cStracker-user } 59143d2073cStracker-user $this->sendSuccess(['removed' => $count, 'stats' => $helper->getStats($id)]); 59243d2073cStracker-user } 59343d2073cStracker-user 59443d2073cStracker-user // ------------------------------------------------------------------ 59543d2073cStracker-user // Utilities 59643d2073cStracker-user // ------------------------------------------------------------------ 59743d2073cStracker-user 59843d2073cStracker-user /** 59943d2073cStracker-user * Whether the current user has the annotations_enabled preference on. 60043d2073cStracker-user * 60143d2073cStracker-user * If the usersettings plugin is absent the feature defaults to enabled. 60243d2073cStracker-user * Public so templates and tests can call it directly. 60343d2073cStracker-user * 60443d2073cStracker-user * @return bool 60543d2073cStracker-user */ 60643d2073cStracker-user public function isEnabledForUser() 60743d2073cStracker-user { 60843d2073cStracker-user /** @var helper_plugin_usersettings|null $us */ 60943d2073cStracker-user $us = plugin_load('helper', 'usersettings'); 61043d2073cStracker-user if (!$us) { 61143d2073cStracker-user return true; // usersettings not installed — default on 61243d2073cStracker-user } 61343d2073cStracker-user $value = $us->getPreference('annotations_enabled'); 61443d2073cStracker-user // getPreference returns null when the toggle is not registered yet 61543d2073cStracker-user // (e.g. very first page load before the event has fired). 61643d2073cStracker-user return ($value === null) ? true : (bool) $value; 61743d2073cStracker-user } 61843d2073cStracker-user 61943d2073cStracker-user /** 62043d2073cStracker-user * Parse the request body as JSON; also accepts form-encoded POSTs for 62143d2073cStracker-user * simpler test scripts. 62243d2073cStracker-user * 62343d2073cStracker-user * @return array|null 62443d2073cStracker-user */ 62543d2073cStracker-user protected function readPayload() 62643d2073cStracker-user { 62743d2073cStracker-user $ct = $_SERVER['CONTENT_TYPE'] ?? ''; 62843d2073cStracker-user if (strpos($ct, 'application/json') !== false) { 62943d2073cStracker-user $raw = file_get_contents('php://input'); 63043d2073cStracker-user $data = json_decode($raw, true); 63143d2073cStracker-user return is_array($data) ? $data : null; 63243d2073cStracker-user } 63343d2073cStracker-user // For GET requests (load action), read from query string. 63443d2073cStracker-user if ($_SERVER['REQUEST_METHOD'] === 'GET') { 63543d2073cStracker-user return $_GET ? (array) $_GET : []; 63643d2073cStracker-user } 63743d2073cStracker-user // Fall back to form-encoded POST (useful for simple curl tests). 63843d2073cStracker-user return $_POST ? (array) $_POST : []; 63943d2073cStracker-user } 64043d2073cStracker-user 64143d2073cStracker-user /** 64243d2073cStracker-user * Return all annotations for a page (read-only, no token required). 64343d2073cStracker-user * 64443d2073cStracker-user * The ACL check is still enforced: only users with at least AUTH_READ 64543d2073cStracker-user * on the page can read its annotations. 64643d2073cStracker-user * 64743d2073cStracker-user * @param helper_plugin_annotations $helper 64843d2073cStracker-user * @param string $id 64943d2073cStracker-user * @param int $aclLevel 65043d2073cStracker-user */ 65143d2073cStracker-user protected function actionLoad($helper, $id, $aclLevel) 65243d2073cStracker-user { 65343d2073cStracker-user if ($aclLevel < AUTH_READ) { 65443d2073cStracker-user $this->sendError('Permission denied.'); 65543d2073cStracker-user return; 65643d2073cStracker-user } 65743d2073cStracker-user $annotations = $helper->getAnnotations($id); 65843d2073cStracker-user $this->sendSuccess(['annotations' => $annotations]); 65943d2073cStracker-user } 66043d2073cStracker-user 66143d2073cStracker-user /** 66243d2073cStracker-user * Emit a JSON success response and exit. 66343d2073cStracker-user * 66443d2073cStracker-user * @param array $extra additional fields merged into the response 66543d2073cStracker-user */ 66643d2073cStracker-user protected function sendSuccess(array $extra = []) 66743d2073cStracker-user { 66843d2073cStracker-user echo json_encode(array_merge(['success' => true], $extra), JSON_PRETTY_PRINT); 66943d2073cStracker-user } 67043d2073cStracker-user 67143d2073cStracker-user /** 67243d2073cStracker-user * Emit a JSON error response and exit. 67343d2073cStracker-user * 67443d2073cStracker-user * @param string $message human-readable error 67543d2073cStracker-user */ 67643d2073cStracker-user protected function sendError($message) 67743d2073cStracker-user { 67843d2073cStracker-user echo json_encode(['success' => false, 'error' => $message], JSON_PRETTY_PRINT); 67943d2073cStracker-user } 68043d2073cStracker-user} 681