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