xref: /plugin/annotations/action.php (revision 49d7ec0a0f9385eb9dab3ae4b2747fe04548a000)
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{
42108f92bdStracker-user    /**
4386c7806dStracker-user     * Fallback for the largest serialized annotation list (bytes) embedded
4486c7806dStracker-user     * inline in the page when the config value is unreadable. Below this, the
4586c7806dStracker-user     * list ships with the page so script.js renders without a second
4686c7806dStracker-user     * bootstrapped AJAX round-trip; above it, the client falls back to the GET
4786c7806dStracker-user     * 'load' endpoint so a heavily-annotated page can't bloat every view. The
4886c7806dStracker-user     * live value is the 'embed_max_bytes' config setting.
49108f92bdStracker-user     */
5086c7806dStracker-user    const DEFAULT_EMBED_MAX_BYTES = 131072;
51108f92bdStracker-user
5243d2073cStracker-user    // ------------------------------------------------------------------
5343d2073cStracker-user    //  Event registration
5443d2073cStracker-user    // ------------------------------------------------------------------
5543d2073cStracker-user
5643d2073cStracker-user    /**
5743d2073cStracker-user     * @param Doku_Event_Handler $controller
5843d2073cStracker-user     */
5943d2073cStracker-user    public function register(Doku_Event_Handler $controller)
6043d2073cStracker-user    {
6143d2073cStracker-user        // Register our toggle with the usersettings plugin.
6243d2073cStracker-user        $controller->register_hook(
6343d2073cStracker-user            'PLUGIN_USERSETTINGS_REGISTER',
6443d2073cStracker-user            'BEFORE',
6543d2073cStracker-user            $this,
6643d2073cStracker-user            'handleSettingsRegister'
6743d2073cStracker-user        );
6843d2073cStracker-user
6943d2073cStracker-user        // Inject annotation stats + user preference into JSINFO.
7043d2073cStracker-user        $controller->register_hook(
7143d2073cStracker-user            'TPL_METAHEADER_OUTPUT',
7243d2073cStracker-user            'BEFORE',
7343d2073cStracker-user            $this,
7443d2073cStracker-user            'handleMetaHeader'
7543d2073cStracker-user        );
7643d2073cStracker-user
7743d2073cStracker-user        // Handle the AJAX call.
7843d2073cStracker-user        $controller->register_hook(
7943d2073cStracker-user            'AJAX_CALL_UNKNOWN',
8043d2073cStracker-user            'BEFORE',
8143d2073cStracker-user            $this,
8243d2073cStracker-user            'handleAjax'
8343d2073cStracker-user        );
8443d2073cStracker-user    }
8543d2073cStracker-user
8643d2073cStracker-user    // ------------------------------------------------------------------
8743d2073cStracker-user    //  1. usersettings toggle registration
8843d2073cStracker-user    // ------------------------------------------------------------------
8943d2073cStracker-user
9043d2073cStracker-user    /**
9143d2073cStracker-user     * Append the annotations_enabled toggle definition to the event data.
9243d2073cStracker-user     *
9343d2073cStracker-user     * The event data is an array that the usersettings helper fires with
9443d2073cStracker-user     * createAndTrigger(); every handler appends its definition(s).
9543d2073cStracker-user     *
9643d2073cStracker-user     * @param Doku_Event $event PLUGIN_USERSETTINGS_REGISTER
9743d2073cStracker-user     * @param mixed       $param
9843d2073cStracker-user     */
9943d2073cStracker-user    public function handleSettingsRegister(Doku_Event $event, $param)
10043d2073cStracker-user    {
10143d2073cStracker-user        $event->data[] = [
10243d2073cStracker-user            'key'     => 'annotations_enabled',
10343d2073cStracker-user            'label'   => $this->getLang('toggle_label'),
10443d2073cStracker-user            'desc'    => $this->getLang('toggle_desc'),
10543d2073cStracker-user            'type'    => 'checkbox',
10643d2073cStracker-user            'default' => true,
10743d2073cStracker-user            'plugin'  => 'annotations',
10843d2073cStracker-user        ];
10943d2073cStracker-user    }
11043d2073cStracker-user
11143d2073cStracker-user    // ------------------------------------------------------------------
11243d2073cStracker-user    //  2. Inject into JSINFO
11343d2073cStracker-user    // ------------------------------------------------------------------
11443d2073cStracker-user
11543d2073cStracker-user    /**
116b8076f00Stracker-user     * Add annotation stats and the user preference to JSINFO so script.js
11743d2073cStracker-user     * does not need an extra round-trip on page load.
11843d2073cStracker-user     *
119b8076f00Stracker-user     * IMPORTANT: tpl_metaheaders() calls jsinfo() and then immediately
120b8076f00Stracker-user     * JSON-encodes $JSINFO into an inline <script> string BEFORE firing
121b8076f00Stracker-user     * TPL_METAHEADER_OUTPUT. Writing to $JSINFO here is therefore too late.
122b8076f00Stracker-user     * Instead we locate that inline script block in $event->data and append
123b8076f00Stracker-user     * a JSINFO.annotations = {...}; statement so it runs in the same scope.
124b8076f00Stracker-user     *
12543d2073cStracker-user     * @param Doku_Event $event TPL_METAHEADER_OUTPUT
12643d2073cStracker-user     * @param mixed       $param
12743d2073cStracker-user     */
12843d2073cStracker-user    public function handleMetaHeader(Doku_Event $event, $param)
12943d2073cStracker-user    {
130b8076f00Stracker-user        global $ID, $ACT;
131b8076f00Stracker-user
132b8076f00Stracker-user        // Only inject on normal page-view actions.
133b8076f00Stracker-user        if (!in_array(act_clean($ACT), ['show', 'export_xhtml'], true)) {
134b8076f00Stracker-user            return;
135b8076f00Stracker-user        }
13643d2073cStracker-user
13743d2073cStracker-user        /** @var helper_plugin_annotations $helper */
13843d2073cStracker-user        $helper = $this->loadHelper('annotations', false);
13943d2073cStracker-user        if (!$helper) {
14043d2073cStracker-user            return;
14143d2073cStracker-user        }
14243d2073cStracker-user
143da56206cStracker-user        global $INPUT;
1447d2714c7Stracker-user
14543d2073cStracker-user        $enabled = $this->isEnabledForUser();
146108f92bdStracker-user
147108f92bdStracker-user        // Read the annotation list once here and ship it inline with the page
148108f92bdStracker-user        // (see EMBED_MAX_BYTES). script.js then renders immediately instead of
149108f92bdStracker-user        // firing a second AJAX request that re-boots DokuWiki (~300 ms) just to
150108f92bdStracker-user        // re-read this same file. Stats are derived from the loaded list rather
151108f92bdStracker-user        // than calling getStats(), which would read the file a second time.
152108f92bdStracker-user        $annotations = $helper->getAnnotations($ID);
153108f92bdStracker-user        $stats       = $helper->statsFor($annotations);
15443d2073cStracker-user
1557d2714c7Stracker-user        // DokuWiki's jsinfo() does not expose user identity, so we inject it
1567d2714c7Stracker-user        // here. JS uses these to gate the selection tooltip and permission UI.
157da56206cStracker-user        $user    = $INPUT->server->str('REMOTE_USER');
158da56206cStracker-user        $isAdmin = auth_isadmin();
1597d2714c7Stracker-user
160108f92bdStracker-user        $data = [
16143d2073cStracker-user            'enabled'    => $enabled,
16243d2073cStracker-user            'pageId'     => $ID,
16343d2073cStracker-user            'stats'      => $stats,
1647d2714c7Stracker-user            'user'       => $user,
1657d2714c7Stracker-user            'isAdmin'    => $isAdmin,
1667d2714c7Stracker-user            'token'      => getSecurityToken(),  // CSRF token for AJAX POSTs
16786c7806dStracker-user            'contextLen' => max(0, (int) $this->getConf('context_length')),
168108f92bdStracker-user        ];
169108f92bdStracker-user
17086c7806dStracker-user        // Inject the configurable highlight colours as CSS custom properties so
17186c7806dStracker-user        // style.css can derive every opacity variant from one hex per state.
17286c7806dStracker-user        $this->injectColourVars($event);
17386c7806dStracker-user
174108f92bdStracker-user        // Embed the full list only when the feature is on for this user and the
175108f92bdStracker-user        // serialized list is small enough; otherwise script.js fetches it via
176108f92bdStracker-user        // the GET 'load' endpoint. The inline JSINFO script is regenerated every
177108f92bdStracker-user        // request (it is not part of the parser page cache), so this stays fresh.
178108f92bdStracker-user        if ($enabled) {
17986c7806dStracker-user            $embedMax = (int) $this->getConf('embed_max_bytes');
18086c7806dStracker-user            if ($embedMax <= 0) {
18186c7806dStracker-user                $embedMax = self::DEFAULT_EMBED_MAX_BYTES;
18286c7806dStracker-user            }
183*49d7ec0aStracker-user            $listJson = json_encode($annotations, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
18486c7806dStracker-user            if ($listJson !== false && strlen($listJson) <= $embedMax) {
185108f92bdStracker-user                $data['annotations'] = $annotations;
186108f92bdStracker-user            }
187108f92bdStracker-user        }
188108f92bdStracker-user
189*49d7ec0aStracker-user        // JSON_HEX_TAG escapes < and > to < / >. This payload is
190*49d7ec0aStracker-user        // appended inside the page's inline <script> (below), so a body
191*49d7ec0aStracker-user        // containing "</script>" would otherwise close the script element and
192*49d7ec0aStracker-user        // inject arbitrary HTML — a stored XSS reachable by anyone who can
193*49d7ec0aStracker-user        // annotate. HEX_TAG neutralises every tag-based breakout.
194*49d7ec0aStracker-user        $payload = json_encode($data, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
195b8076f00Stracker-user
196b8076f00Stracker-user        // The inline script block containing "var JSINFO = ...;" is in
197b8076f00Stracker-user        // $event->data['script']. Find it and append our assignment so it
198b8076f00Stracker-user        // runs in the same scope after JSINFO is already declared.
199b8076f00Stracker-user        if (!empty($event->data['script'])) {
200b8076f00Stracker-user            foreach ($event->data['script'] as &$scriptTag) {
201b8076f00Stracker-user                if (
202b8076f00Stracker-user                    isset($scriptTag['_data']) &&
203b8076f00Stracker-user                    strpos($scriptTag['_data'], 'var JSINFO') !== false
204b8076f00Stracker-user                ) {
205b8076f00Stracker-user                    $scriptTag['_data'] .= 'JSINFO.annotations=' . $payload . ';';
206b8076f00Stracker-user                    break;
207b8076f00Stracker-user                }
208b8076f00Stracker-user            }
209b8076f00Stracker-user            unset($scriptTag);
210b8076f00Stracker-user        }
21143d2073cStracker-user    }
21243d2073cStracker-user
21386c7806dStracker-user    /**
21486c7806dStracker-user     * Append a <style> metaheader declaring the two configurable highlight
21586c7806dStracker-user     * colours as CSS custom properties (--ann-open-rgb / --ann-resolved-rgb,
21686c7806dStracker-user     * each an "r,g,b" channel triplet). style.css consumes them via
21786c7806dStracker-user     * rgba(var(--ann-open-rgb), <alpha>) so a single hex per state drives every
21886c7806dStracker-user     * fill/border/marker/pill tint. style.css also ships :root fallbacks, so an
21986c7806dStracker-user     * unreadable colour just keeps the built-in palette.
22086c7806dStracker-user     *
22186c7806dStracker-user     * @param Doku_Event $event TPL_METAHEADER_OUTPUT
22286c7806dStracker-user     */
22386c7806dStracker-user    protected function injectColourVars(Doku_Event $event)
22486c7806dStracker-user    {
22586c7806dStracker-user        $open     = $this->hexToRgb($this->getConf('color_open'), '245,158,11');
22686c7806dStracker-user        $resolved = $this->hexToRgb($this->getConf('color_resolved'), '74,222,128');
22786c7806dStracker-user        $css = ':root{--ann-open-rgb:' . $open . ';--ann-resolved-rgb:' . $resolved . ';}';
22886c7806dStracker-user        $event->data['style'][] = ['type' => 'text/css', '_data' => $css];
22986c7806dStracker-user    }
23086c7806dStracker-user
23186c7806dStracker-user    /**
23286c7806dStracker-user     * Convert a #rrggbb hex colour to an "r,g,b" channel triplet, returning the
23386c7806dStracker-user     * supplied fallback for anything that is not a valid 6-digit hex colour.
23486c7806dStracker-user     *
23586c7806dStracker-user     * @param mixed  $hex
23686c7806dStracker-user     * @param string $fallback "r,g,b" used when $hex is invalid
23786c7806dStracker-user     * @return string
23886c7806dStracker-user     */
23986c7806dStracker-user    protected function hexToRgb($hex, $fallback)
24086c7806dStracker-user    {
24186c7806dStracker-user        if (is_string($hex) && preg_match('/^#([0-9a-fA-F]{6})$/', $hex, $m)) {
24286c7806dStracker-user            $int = hexdec($m[1]);
24386c7806dStracker-user            return (($int >> 16) & 255) . ',' . (($int >> 8) & 255) . ',' . ($int & 255);
24486c7806dStracker-user        }
24586c7806dStracker-user        return $fallback;
24686c7806dStracker-user    }
24786c7806dStracker-user
24843d2073cStracker-user    // ------------------------------------------------------------------
24943d2073cStracker-user    //  3. AJAX endpoint
25043d2073cStracker-user    // ------------------------------------------------------------------
25143d2073cStracker-user
25243d2073cStracker-user    /**
25343d2073cStracker-user     * Handle AJAX calls for the annotations plugin.
25443d2073cStracker-user     * Ignores calls not addressed to us.
25543d2073cStracker-user     *
25643d2073cStracker-user     * @param Doku_Event $event AJAX_CALL_UNKNOWN
25743d2073cStracker-user     * @param mixed       $param
25843d2073cStracker-user     */
25943d2073cStracker-user    public function handleAjax(Doku_Event $event, $param)
26043d2073cStracker-user    {
26143d2073cStracker-user        if ($event->data !== 'annotations') {
26243d2073cStracker-user            return;
26343d2073cStracker-user        }
26443d2073cStracker-user        $event->stopPropagation();
26543d2073cStracker-user        $event->preventDefault();
26643d2073cStracker-user
26743d2073cStracker-user        header('Content-Type: application/json; charset=utf-8');
26843d2073cStracker-user
26943d2073cStracker-user        // Parse JSON body; fall back to POST/GET fields for simple callers.
27043d2073cStracker-user        // The 'load' action is a GET request, so we accept query parameters too.
27143d2073cStracker-user        $payload = $this->readPayload();
27243d2073cStracker-user        if ($payload === null) {
27343d2073cStracker-user            $this->sendError('Invalid request body.');
27443d2073cStracker-user            return;
27543d2073cStracker-user        }
27643d2073cStracker-user
27743d2073cStracker-user        $action = isset($payload['action']) ? (string) $payload['action'] : '';
27843d2073cStracker-user        // For the read-only 'load' action, accept GET requests without a token.
27943d2073cStracker-user        // All state-changing actions require a valid DokuWiki security token.
280f58805fbStracker-user        // checkSecurityToken() reads from $_REQUEST (form fields), so when the
281f58805fbStracker-user        // request body is JSON we must inject the token from the parsed payload
282f58805fbStracker-user        // into $_POST / $_REQUEST before calling it.
283f58805fbStracker-user        if ($action !== 'load') {
284da56206cStracker-user            // checkSecurityToken() accepts the token directly, so we hand it the
285da56206cStracker-user            // value from the JSON body rather than poking it into $_REQUEST.
286f58805fbStracker-user            $jsonToken = isset($payload['sectok']) ? (string) $payload['sectok'] : '';
287da56206cStracker-user            if (!checkSecurityToken($jsonToken)) {
28843d2073cStracker-user                $this->sendError('Invalid security token.');
28943d2073cStracker-user                return;
29043d2073cStracker-user            }
291f58805fbStracker-user        }
29243d2073cStracker-user        $id = isset($payload['id']) ? cleanID((string) $payload['id']) : '';
29343d2073cStracker-user
29443d2073cStracker-user        if ($action === '' || $id === '') {
29543d2073cStracker-user            $this->sendError('Missing action or page id.');
29643d2073cStracker-user            return;
29743d2073cStracker-user        }
29843d2073cStracker-user
29943d2073cStracker-user        /** @var helper_plugin_annotations $helper */
30043d2073cStracker-user        $helper = $this->loadHelper('annotations', false);
30143d2073cStracker-user        if (!$helper) {
30243d2073cStracker-user            $this->sendError('Annotations helper unavailable.');
30343d2073cStracker-user            return;
30443d2073cStracker-user        }
30543d2073cStracker-user
30643d2073cStracker-user        // Gather facts once; pass them to the helper's permission methods.
307da56206cStracker-user        global $INPUT;
308da56206cStracker-user        $user     = $INPUT->server->str('REMOTE_USER');
309da56206cStracker-user        $isAdmin  = auth_isadmin();
31043d2073cStracker-user        $aclLevel = auth_quickaclcheck($id);
31143d2073cStracker-user
31243d2073cStracker-user        // Route to the correct handler method.
31343d2073cStracker-user        switch ($action) {
31443d2073cStracker-user            case 'load':
31543d2073cStracker-user                $this->actionLoad($helper, $id, $aclLevel);
31643d2073cStracker-user                break;
31743d2073cStracker-user            case 'create':
31843d2073cStracker-user                $this->actionCreate($helper, $id, $payload, $user, $aclLevel);
31943d2073cStracker-user                break;
32043d2073cStracker-user            case 'reply':
32143d2073cStracker-user                $this->actionReply($helper, $id, $payload, $user, $aclLevel);
32243d2073cStracker-user                break;
32343d2073cStracker-user            case 'edit_annotation':
32443d2073cStracker-user                $this->actionEditAnnotation($helper, $id, $payload, $user, $isAdmin);
32543d2073cStracker-user                break;
32643d2073cStracker-user            case 'edit_reply':
32743d2073cStracker-user                $this->actionEditReply($helper, $id, $payload, $user, $isAdmin);
32843d2073cStracker-user                break;
32943d2073cStracker-user            case 'delete_annotation':
33043d2073cStracker-user                $this->actionDeleteAnnotation($helper, $id, $payload, $user, $isAdmin);
33143d2073cStracker-user                break;
33243d2073cStracker-user            case 'delete_reply':
33343d2073cStracker-user                $this->actionDeleteReply($helper, $id, $payload, $user, $isAdmin);
33443d2073cStracker-user                break;
33543d2073cStracker-user            case 'resolve':
33643d2073cStracker-user                $this->actionResolve($helper, $id, $payload, $user, $aclLevel);
33743d2073cStracker-user                break;
33843d2073cStracker-user            case 'clear_resolved':
33943d2073cStracker-user                $this->actionClearResolved($helper, $id, $isAdmin);
34043d2073cStracker-user                break;
34143d2073cStracker-user            case 'clear_orphaned':
34243d2073cStracker-user                $this->actionClearOrphaned($helper, $id, $isAdmin);
34343d2073cStracker-user                break;
34443d2073cStracker-user            default:
345*49d7ec0aStracker-user                $this->sendError('Unknown action: ' . $action);
34643d2073cStracker-user        }
34743d2073cStracker-user    }
34843d2073cStracker-user
34943d2073cStracker-user    // ------------------------------------------------------------------
35043d2073cStracker-user    //  Action handlers (one per supported action)
35143d2073cStracker-user    // ------------------------------------------------------------------
35243d2073cStracker-user
35343d2073cStracker-user    /**
35443d2073cStracker-user     * Create a new annotation.
35543d2073cStracker-user     *
35643d2073cStracker-user     * Payload: { action, id, anchor:{exact,prefix,suffix,start}, body }
35743d2073cStracker-user     *
35843d2073cStracker-user     * @param helper_plugin_annotations $helper
35943d2073cStracker-user     * @param string                    $id
36043d2073cStracker-user     * @param array                     $payload
36143d2073cStracker-user     * @param string                    $user
36243d2073cStracker-user     * @param int                       $aclLevel
36343d2073cStracker-user     */
36443d2073cStracker-user    protected function actionCreate($helper, $id, array $payload, $user, $aclLevel)
36543d2073cStracker-user    {
36643d2073cStracker-user        if (!$helper->canAnnotate($user, $aclLevel)) {
36743d2073cStracker-user            $this->sendError('Permission denied.');
36843d2073cStracker-user            return;
36943d2073cStracker-user        }
37043d2073cStracker-user        $anchor = isset($payload['anchor']) && is_array($payload['anchor'])
37143d2073cStracker-user            ? $payload['anchor']
37243d2073cStracker-user            : [];
37343d2073cStracker-user        $body = isset($payload['body']) ? (string) $payload['body'] : '';
37443d2073cStracker-user
37543d2073cStracker-user        $result = $helper->createAnnotation($id, $anchor, $user, $body);
37643d2073cStracker-user        if ($result === false) {
37743d2073cStracker-user            $this->sendError('Invalid annotation data.');
37843d2073cStracker-user            return;
37943d2073cStracker-user        }
38043d2073cStracker-user        $this->sendSuccess(['annotation' => $result]);
38143d2073cStracker-user    }
38243d2073cStracker-user
38343d2073cStracker-user    /**
38443d2073cStracker-user     * Add a reply to an existing annotation.
38543d2073cStracker-user     *
38643d2073cStracker-user     * Payload: { action, id, annId, body }
38743d2073cStracker-user     *
38843d2073cStracker-user     * @param helper_plugin_annotations $helper
38943d2073cStracker-user     * @param string                    $id
39043d2073cStracker-user     * @param array                     $payload
39143d2073cStracker-user     * @param string                    $user
39243d2073cStracker-user     * @param int                       $aclLevel
39343d2073cStracker-user     */
39443d2073cStracker-user    protected function actionReply($helper, $id, array $payload, $user, $aclLevel)
39543d2073cStracker-user    {
39643d2073cStracker-user        if (!$helper->canAnnotate($user, $aclLevel)) {
39743d2073cStracker-user            $this->sendError('Permission denied.');
39843d2073cStracker-user            return;
39943d2073cStracker-user        }
40043d2073cStracker-user        $annId    = isset($payload['annId'])    ? (string) $payload['annId']    : '';
40143d2073cStracker-user        $body     = isset($payload['body'])     ? (string) $payload['body']     : '';
402ee9dbf15Stracker-user        $parentId = isset($payload['parentId']) ? (string) $payload['parentId'] : '';
40343d2073cStracker-user
40443d2073cStracker-user        if ($annId === '') {
40543d2073cStracker-user            $this->sendError('Missing annId.');
40643d2073cStracker-user            return;
40743d2073cStracker-user        }
408ee9dbf15Stracker-user        $result = $helper->addReply($id, $annId, $user, $body, $parentId);
40943d2073cStracker-user        if ($result === false) {
41043d2073cStracker-user            $this->sendError('Invalid reply data or annotation not found.');
41143d2073cStracker-user            return;
41243d2073cStracker-user        }
413ee9dbf15Stracker-user        $this->sendSuccess(['annotation' => $helper->getAnnotation($id, $annId)]);
41443d2073cStracker-user    }
41543d2073cStracker-user
41643d2073cStracker-user    /**
41743d2073cStracker-user     * Edit an annotation's body text.
41843d2073cStracker-user     *
41943d2073cStracker-user     * Payload: { action, id, annId, body }
42043d2073cStracker-user     *
42143d2073cStracker-user     * @param helper_plugin_annotations $helper
42243d2073cStracker-user     * @param string                    $id
42343d2073cStracker-user     * @param array                     $payload
42443d2073cStracker-user     * @param string                    $user
42543d2073cStracker-user     * @param bool                      $isAdmin
42643d2073cStracker-user     */
42743d2073cStracker-user    protected function actionEditAnnotation($helper, $id, array $payload, $user, $isAdmin)
42843d2073cStracker-user    {
42943d2073cStracker-user        $annId = isset($payload['annId']) ? (string) $payload['annId'] : '';
43043d2073cStracker-user        $body  = isset($payload['body'])  ? (string) $payload['body']  : '';
43143d2073cStracker-user
43243d2073cStracker-user        if ($annId === '') {
43343d2073cStracker-user            $this->sendError('Missing annId.');
43443d2073cStracker-user            return;
43543d2073cStracker-user        }
43643d2073cStracker-user        $annotation = $helper->getAnnotation($id, $annId);
43743d2073cStracker-user        if ($annotation === null) {
43843d2073cStracker-user            $this->sendError('Annotation not found.');
43943d2073cStracker-user            return;
44043d2073cStracker-user        }
44143d2073cStracker-user        if (!$helper->canEditAnnotation($annotation, $user, $isAdmin)) {
44243d2073cStracker-user            $this->sendError('Permission denied.');
44343d2073cStracker-user            return;
44443d2073cStracker-user        }
44543d2073cStracker-user        $ok = $helper->updateAnnotationBody($id, $annId, $body);
44643d2073cStracker-user        if (!$ok) {
44743d2073cStracker-user            $this->sendError('Invalid body or annotation not found.');
44843d2073cStracker-user            return;
44943d2073cStracker-user        }
45043d2073cStracker-user        $this->sendSuccess(['annotation' => $helper->getAnnotation($id, $annId)]);
45143d2073cStracker-user    }
45243d2073cStracker-user
45343d2073cStracker-user    /**
45443d2073cStracker-user     * Edit a reply's body text.
45543d2073cStracker-user     *
45643d2073cStracker-user     * Payload: { action, id, annId, replyId, body }
45743d2073cStracker-user     *
45843d2073cStracker-user     * @param helper_plugin_annotations $helper
45943d2073cStracker-user     * @param string                    $id
46043d2073cStracker-user     * @param array                     $payload
46143d2073cStracker-user     * @param string                    $user
46243d2073cStracker-user     * @param bool                      $isAdmin
46343d2073cStracker-user     */
46443d2073cStracker-user    protected function actionEditReply($helper, $id, array $payload, $user, $isAdmin)
46543d2073cStracker-user    {
46643d2073cStracker-user        $annId   = isset($payload['annId'])   ? (string) $payload['annId']   : '';
46743d2073cStracker-user        $replyId = isset($payload['replyId']) ? (string) $payload['replyId'] : '';
46843d2073cStracker-user        $body    = isset($payload['body'])    ? (string) $payload['body']    : '';
46943d2073cStracker-user
47043d2073cStracker-user        if ($annId === '' || $replyId === '') {
47143d2073cStracker-user            $this->sendError('Missing annId or replyId.');
47243d2073cStracker-user            return;
47343d2073cStracker-user        }
47443d2073cStracker-user        $annotation = $helper->getAnnotation($id, $annId);
47543d2073cStracker-user        if ($annotation === null) {
47643d2073cStracker-user            $this->sendError('Annotation not found.');
47743d2073cStracker-user            return;
47843d2073cStracker-user        }
47943d2073cStracker-user        // Find the reply to permission-check its author.
48043d2073cStracker-user        $reply = null;
48143d2073cStracker-user        foreach (($annotation['replies'] ?? []) as $r) {
48243d2073cStracker-user            if (($r['id'] ?? '') === $replyId) {
48343d2073cStracker-user                $reply = $r;
48443d2073cStracker-user                break;
48543d2073cStracker-user            }
48643d2073cStracker-user        }
48743d2073cStracker-user        if ($reply === null) {
48843d2073cStracker-user            $this->sendError('Reply not found.');
48943d2073cStracker-user            return;
49043d2073cStracker-user        }
49143d2073cStracker-user        if (!$helper->canEditReply($reply, $user, $isAdmin)) {
49243d2073cStracker-user            $this->sendError('Permission denied.');
49343d2073cStracker-user            return;
49443d2073cStracker-user        }
49543d2073cStracker-user        $ok = $helper->updateReply($id, $annId, $replyId, $body);
49643d2073cStracker-user        if (!$ok) {
49743d2073cStracker-user            $this->sendError('Invalid body or reply not found.');
49843d2073cStracker-user            return;
49943d2073cStracker-user        }
50043d2073cStracker-user        $this->sendSuccess(['annotation' => $helper->getAnnotation($id, $annId)]);
50143d2073cStracker-user    }
50243d2073cStracker-user
50343d2073cStracker-user    /**
50443d2073cStracker-user     * Delete an annotation and all its replies.
50543d2073cStracker-user     *
50643d2073cStracker-user     * Payload: { action, id, annId }
50743d2073cStracker-user     *
50843d2073cStracker-user     * @param helper_plugin_annotations $helper
50943d2073cStracker-user     * @param string                    $id
51043d2073cStracker-user     * @param array                     $payload
51143d2073cStracker-user     * @param string                    $user
51243d2073cStracker-user     * @param bool                      $isAdmin
51343d2073cStracker-user     */
51443d2073cStracker-user    protected function actionDeleteAnnotation($helper, $id, array $payload, $user, $isAdmin)
51543d2073cStracker-user    {
51643d2073cStracker-user        $annId = isset($payload['annId']) ? (string) $payload['annId'] : '';
51743d2073cStracker-user
51843d2073cStracker-user        if ($annId === '') {
51943d2073cStracker-user            $this->sendError('Missing annId.');
52043d2073cStracker-user            return;
52143d2073cStracker-user        }
52243d2073cStracker-user        $annotation = $helper->getAnnotation($id, $annId);
52343d2073cStracker-user        if ($annotation === null) {
52443d2073cStracker-user            $this->sendError('Annotation not found.');
52543d2073cStracker-user            return;
52643d2073cStracker-user        }
52743d2073cStracker-user        if (!$helper->canEditAnnotation($annotation, $user, $isAdmin)) {
52843d2073cStracker-user            $this->sendError('Permission denied.');
52943d2073cStracker-user            return;
53043d2073cStracker-user        }
53143d2073cStracker-user        $ok = $helper->deleteAnnotation($id, $annId);
53243d2073cStracker-user        if (!$ok) {
53343d2073cStracker-user            $this->sendError('Delete failed.');
53443d2073cStracker-user            return;
53543d2073cStracker-user        }
53643d2073cStracker-user        $this->sendSuccess(['stats' => $helper->getStats($id)]);
53743d2073cStracker-user    }
53843d2073cStracker-user
53943d2073cStracker-user    /**
54043d2073cStracker-user     * Delete a reply.
54143d2073cStracker-user     *
54243d2073cStracker-user     * Payload: { action, id, annId, replyId }
54343d2073cStracker-user     *
54443d2073cStracker-user     * @param helper_plugin_annotations $helper
54543d2073cStracker-user     * @param string                    $id
54643d2073cStracker-user     * @param array                     $payload
54743d2073cStracker-user     * @param string                    $user
54843d2073cStracker-user     * @param bool                      $isAdmin
54943d2073cStracker-user     */
55043d2073cStracker-user    protected function actionDeleteReply($helper, $id, array $payload, $user, $isAdmin)
55143d2073cStracker-user    {
55243d2073cStracker-user        $annId   = isset($payload['annId'])   ? (string) $payload['annId']   : '';
55343d2073cStracker-user        $replyId = isset($payload['replyId']) ? (string) $payload['replyId'] : '';
55443d2073cStracker-user
55543d2073cStracker-user        if ($annId === '' || $replyId === '') {
55643d2073cStracker-user            $this->sendError('Missing annId or replyId.');
55743d2073cStracker-user            return;
55843d2073cStracker-user        }
55943d2073cStracker-user        $annotation = $helper->getAnnotation($id, $annId);
56043d2073cStracker-user        if ($annotation === null) {
56143d2073cStracker-user            $this->sendError('Annotation not found.');
56243d2073cStracker-user            return;
56343d2073cStracker-user        }
56443d2073cStracker-user        $reply = null;
56543d2073cStracker-user        foreach (($annotation['replies'] ?? []) as $r) {
56643d2073cStracker-user            if (($r['id'] ?? '') === $replyId) {
56743d2073cStracker-user                $reply = $r;
56843d2073cStracker-user                break;
56943d2073cStracker-user            }
57043d2073cStracker-user        }
57143d2073cStracker-user        if ($reply === null) {
57243d2073cStracker-user            $this->sendError('Reply not found.');
57343d2073cStracker-user            return;
57443d2073cStracker-user        }
57543d2073cStracker-user        if (!$helper->canEditReply($reply, $user, $isAdmin)) {
57643d2073cStracker-user            $this->sendError('Permission denied.');
57743d2073cStracker-user            return;
57843d2073cStracker-user        }
57943d2073cStracker-user        $ok = $helper->deleteReply($id, $annId, $replyId);
58043d2073cStracker-user        if (!$ok) {
58143d2073cStracker-user            $this->sendError('Delete failed.');
58243d2073cStracker-user            return;
58343d2073cStracker-user        }
58443d2073cStracker-user        $this->sendSuccess(['annotation' => $helper->getAnnotation($id, $annId)]);
58543d2073cStracker-user    }
58643d2073cStracker-user
58743d2073cStracker-user    /**
58843d2073cStracker-user     * Resolve or reopen an annotation.
58943d2073cStracker-user     *
59043d2073cStracker-user     * Payload: { action, id, annId, status:"open"|"resolved" }
59143d2073cStracker-user     *
59243d2073cStracker-user     * @param helper_plugin_annotations $helper
59343d2073cStracker-user     * @param string                    $id
59443d2073cStracker-user     * @param array                     $payload
59543d2073cStracker-user     * @param string                    $user
59643d2073cStracker-user     * @param int                       $aclLevel
59743d2073cStracker-user     */
59843d2073cStracker-user    protected function actionResolve($helper, $id, array $payload, $user, $aclLevel)
59943d2073cStracker-user    {
60043d2073cStracker-user        if (!$helper->canAnnotate($user, $aclLevel)) {
60143d2073cStracker-user            $this->sendError('Permission denied.');
60243d2073cStracker-user            return;
60343d2073cStracker-user        }
60443d2073cStracker-user        $annId  = isset($payload['annId'])  ? (string) $payload['annId']  : '';
60543d2073cStracker-user        $status = isset($payload['status']) ? (string) $payload['status'] : '';
60643d2073cStracker-user
60743d2073cStracker-user        if ($annId === '') {
60843d2073cStracker-user            $this->sendError('Missing annId.');
60943d2073cStracker-user            return;
61043d2073cStracker-user        }
61143d2073cStracker-user        $ok = $helper->setStatus($id, $annId, $status, $user);
61243d2073cStracker-user        if (!$ok) {
61343d2073cStracker-user            $this->sendError('Invalid status or annotation not found.');
61443d2073cStracker-user            return;
61543d2073cStracker-user        }
61643d2073cStracker-user        $this->sendSuccess(['annotation' => $helper->getAnnotation($id, $annId)]);
61743d2073cStracker-user    }
61843d2073cStracker-user
61943d2073cStracker-user    /**
62043d2073cStracker-user     * Remove all resolved annotations on the page. Admin only.
62143d2073cStracker-user     *
62243d2073cStracker-user     * Payload: { action, id }
62343d2073cStracker-user     *
62443d2073cStracker-user     * @param helper_plugin_annotations $helper
62543d2073cStracker-user     * @param string                    $id
62643d2073cStracker-user     * @param bool                      $isAdmin
62743d2073cStracker-user     */
62843d2073cStracker-user    protected function actionClearResolved($helper, $id, $isAdmin)
62943d2073cStracker-user    {
63043d2073cStracker-user        if (!$helper->canClear($isAdmin)) {
63143d2073cStracker-user            $this->sendError('Permission denied.');
63243d2073cStracker-user            return;
63343d2073cStracker-user        }
63443d2073cStracker-user        $count = $helper->clearResolved($id);
63543d2073cStracker-user        if ($count === false) {
63643d2073cStracker-user            $this->sendError('Clear failed.');
63743d2073cStracker-user            return;
63843d2073cStracker-user        }
63943d2073cStracker-user        $this->sendSuccess(['removed' => $count, 'stats' => $helper->getStats($id)]);
64043d2073cStracker-user    }
64143d2073cStracker-user
64243d2073cStracker-user    /**
64343d2073cStracker-user     * Remove all orphaned annotations on the page. Admin only.
64443d2073cStracker-user     *
64543d2073cStracker-user     * Payload: { action, id }
64643d2073cStracker-user     *
64743d2073cStracker-user     * @param helper_plugin_annotations $helper
64843d2073cStracker-user     * @param string                    $id
64943d2073cStracker-user     * @param bool                      $isAdmin
65043d2073cStracker-user     */
65143d2073cStracker-user    protected function actionClearOrphaned($helper, $id, $isAdmin)
65243d2073cStracker-user    {
65343d2073cStracker-user        if (!$helper->canClear($isAdmin)) {
65443d2073cStracker-user            $this->sendError('Permission denied.');
65543d2073cStracker-user            return;
65643d2073cStracker-user        }
65743d2073cStracker-user        $count = $helper->clearOrphaned($id);
65843d2073cStracker-user        if ($count === false) {
65943d2073cStracker-user            $this->sendError('Clear failed.');
66043d2073cStracker-user            return;
66143d2073cStracker-user        }
66243d2073cStracker-user        $this->sendSuccess(['removed' => $count, 'stats' => $helper->getStats($id)]);
66343d2073cStracker-user    }
66443d2073cStracker-user
66543d2073cStracker-user    // ------------------------------------------------------------------
66643d2073cStracker-user    //  Utilities
66743d2073cStracker-user    // ------------------------------------------------------------------
66843d2073cStracker-user
66943d2073cStracker-user    /**
67043d2073cStracker-user     * Whether the current user has the annotations_enabled preference on.
67143d2073cStracker-user     *
67243d2073cStracker-user     * If the usersettings plugin is absent the feature defaults to enabled.
67343d2073cStracker-user     * Public so templates and tests can call it directly.
67443d2073cStracker-user     *
67543d2073cStracker-user     * @return bool
67643d2073cStracker-user     */
67743d2073cStracker-user    public function isEnabledForUser()
67843d2073cStracker-user    {
67943d2073cStracker-user        /** @var helper_plugin_usersettings|null $us */
68043d2073cStracker-user        $us = plugin_load('helper', 'usersettings');
68143d2073cStracker-user        if (!$us) {
68243d2073cStracker-user            return true; // usersettings not installed — default on
68343d2073cStracker-user        }
68443d2073cStracker-user        $value = $us->getPreference('annotations_enabled');
68543d2073cStracker-user        // getPreference returns null when the toggle is not registered yet
68643d2073cStracker-user        // (e.g. very first page load before the event has fired).
68743d2073cStracker-user        return ($value === null) ? true : (bool) $value;
68843d2073cStracker-user    }
68943d2073cStracker-user
69043d2073cStracker-user    /**
69143d2073cStracker-user     * Parse the request body as JSON; also accepts form-encoded POSTs for
69243d2073cStracker-user     * simpler test scripts.
69343d2073cStracker-user     *
69443d2073cStracker-user     * @return array|null
69543d2073cStracker-user     */
69643d2073cStracker-user    protected function readPayload()
69743d2073cStracker-user    {
698da56206cStracker-user        global $INPUT;
699da56206cStracker-user        $ct = $INPUT->server->str('CONTENT_TYPE');
70043d2073cStracker-user        if (strpos($ct, 'application/json') !== false) {
701da56206cStracker-user            $data = json_decode(file_get_contents('php://input'), true);
70243d2073cStracker-user            return is_array($data) ? $data : null;
70343d2073cStracker-user        }
704da56206cStracker-user        // The read-only 'load' action is a GET carrying action + id only.
705da56206cStracker-user        if ($INPUT->server->str('REQUEST_METHOD') === 'GET') {
706da56206cStracker-user            return [
707da56206cStracker-user                'action' => $INPUT->get->str('action'),
708da56206cStracker-user                'id'     => $INPUT->get->str('id'),
709da56206cStracker-user            ];
71043d2073cStracker-user        }
711da56206cStracker-user        // Form-encoded POST fallback (handy for simple curl tests).
712da56206cStracker-user        return [
713da56206cStracker-user            'action'  => $INPUT->post->str('action'),
714da56206cStracker-user            'id'      => $INPUT->post->str('id'),
715da56206cStracker-user            'sectok'  => $INPUT->post->str('sectok'),
716da56206cStracker-user            'annId'   => $INPUT->post->str('annId'),
717da56206cStracker-user            'replyId' => $INPUT->post->str('replyId'),
718da56206cStracker-user            'body'    => $INPUT->post->str('body'),
719da56206cStracker-user            'status'  => $INPUT->post->str('status'),
720da56206cStracker-user        ];
72143d2073cStracker-user    }
72243d2073cStracker-user
72343d2073cStracker-user    /**
72443d2073cStracker-user     * Return all annotations for a page (read-only, no token required).
72543d2073cStracker-user     *
72643d2073cStracker-user     * The ACL check is still enforced: only users with at least AUTH_READ
72743d2073cStracker-user     * on the page can read its annotations.
72843d2073cStracker-user     *
72943d2073cStracker-user     * @param helper_plugin_annotations $helper
73043d2073cStracker-user     * @param string                    $id
73143d2073cStracker-user     * @param int                       $aclLevel
73243d2073cStracker-user     */
73343d2073cStracker-user    protected function actionLoad($helper, $id, $aclLevel)
73443d2073cStracker-user    {
73543d2073cStracker-user        if ($aclLevel < AUTH_READ) {
73643d2073cStracker-user            $this->sendError('Permission denied.');
73743d2073cStracker-user            return;
73843d2073cStracker-user        }
73943d2073cStracker-user        $annotations = $helper->getAnnotations($id);
74043d2073cStracker-user        $this->sendSuccess(['annotations' => $annotations]);
74143d2073cStracker-user    }
74243d2073cStracker-user
74343d2073cStracker-user    /**
744ee9dbf15Stracker-user     * Emit a JSON success response. The caller has already prevented the
745ee9dbf15Stracker-user     * default AJAX handling, so the request ends after this output.
74643d2073cStracker-user     *
74743d2073cStracker-user     * @param array $extra additional fields merged into the response
74843d2073cStracker-user     */
74943d2073cStracker-user    protected function sendSuccess(array $extra = [])
75043d2073cStracker-user    {
751da56206cStracker-user        echo json_encode(array_merge(['success' => true], $extra), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
75243d2073cStracker-user    }
75343d2073cStracker-user
75443d2073cStracker-user    /**
755ee9dbf15Stracker-user     * Emit a JSON error response.
75643d2073cStracker-user     *
75743d2073cStracker-user     * @param string $message human-readable error
75843d2073cStracker-user     */
75943d2073cStracker-user    protected function sendError($message)
76043d2073cStracker-user    {
761da56206cStracker-user        echo json_encode(['success' => false, 'error' => $message], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
76243d2073cStracker-user    }
76343d2073cStracker-user}
764