xref: /plugin/annotations/action.php (revision f58805fb9cf627da9470aa65cb6297e32c24dbdf)
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