xref: /plugin/annotations/helper.php (revision 43d2073c014d8cf78420fa47c6568a01e7249305)
1*43d2073cStracker-user<?php
2*43d2073cStracker-user
3*43d2073cStracker-user/**
4*43d2073cStracker-user * Annotations plugin — storage and data-logic helper.
5*43d2073cStracker-user *
6*43d2073cStracker-user * This component owns:
7*43d2073cStracker-user *
8*43d2073cStracker-user *   1. The per-page annotation store. One JSON file per page, obtained via
9*43d2073cStracker-user *      metaFN($id, '.annotations'), holding {version, annotations:[...]}.
10*43d2073cStracker-user *      JSON and pretty-printed so the files are easy to inspect or back up.
11*43d2073cStracker-user *      The page text and the wiki changelog are never touched.
12*43d2073cStracker-user *
13*43d2073cStracker-user *   2. The text-quote anchor model. Each annotation stores an anchor of
14*43d2073cStracker-user *      {exact, prefix, suffix, start} — the quoted text, a short slice of the
15*43d2073cStracker-user *      surrounding context on each side (to disambiguate repeated quotes),
16*43d2073cStracker-user *      and a character-offset hint. This is the Hypothes.is approach.
17*43d2073cStracker-user *
18*43d2073cStracker-user *   3. CRUD on annotations and their threaded replies.
19*43d2073cStracker-user *
20*43d2073cStracker-user *   4. Server-side orphan detection: a page is rendered to plain text and an
21*43d2073cStracker-user *      annotation is "orphaned" when its quoted text no longer appears. Used
22*43d2073cStracker-user *      by the admin-only per-page "clear orphaned" operation. (The live UI
23*43d2073cStracker-user *      also detects orphans client-side for the on-page counter.)
24*43d2073cStracker-user *
25*43d2073cStracker-user *   5. The permission rules, as the single source of truth. They are pure
26*43d2073cStracker-user *      functions: the caller gathers the facts (current user, admin flag, the
27*43d2073cStracker-user *      page's ACL level) and passes them in. Because annotations live
28*43d2073cStracker-user *      out-of-band, creating one needs only AUTH_READ on the page, never
29*43d2073cStracker-user *      AUTH_EDIT — so a group whose page edit access is blocked can still
30*43d2073cStracker-user *      annotate.
31*43d2073cStracker-user */
32*43d2073cStracker-user
33*43d2073cStracker-user// must be run within DokuWiki
34*43d2073cStracker-userif (!defined('DOKU_INC')) die();
35*43d2073cStracker-user
36*43d2073cStracker-userclass helper_plugin_annotations extends DokuWiki_Plugin
37*43d2073cStracker-user{
38*43d2073cStracker-user    /** storage schema version, written into each file */
39*43d2073cStracker-user    const SCHEMA_VERSION = 1;
40*43d2073cStracker-user
41*43d2073cStracker-user    /** longest quoted selection stored, in characters */
42*43d2073cStracker-user    const MAX_QUOTE = 1000;
43*43d2073cStracker-user
44*43d2073cStracker-user    /** length of the prefix/suffix context slices, in characters */
45*43d2073cStracker-user    const MAX_CONTEXT = 64;
46*43d2073cStracker-user
47*43d2073cStracker-user    /** longest annotation/reply body, in characters */
48*43d2073cStracker-user    const MAX_BODY = 10000;
49*43d2073cStracker-user
50*43d2073cStracker-user    // ---------------------------------------------------------------------
51*43d2073cStracker-user    //  Storage
52*43d2073cStracker-user    // ---------------------------------------------------------------------
53*43d2073cStracker-user
54*43d2073cStracker-user    /**
55*43d2073cStracker-user     * Path of a page's annotation file.
56*43d2073cStracker-user     *
57*43d2073cStracker-user     * @param string $id page id
58*43d2073cStracker-user     * @return string
59*43d2073cStracker-user     */
60*43d2073cStracker-user    protected function getFile($id)
61*43d2073cStracker-user    {
62*43d2073cStracker-user        return metaFN($id, '.annotations');
63*43d2073cStracker-user    }
64*43d2073cStracker-user
65*43d2073cStracker-user    /**
66*43d2073cStracker-user     * All annotations stored for a page.
67*43d2073cStracker-user     *
68*43d2073cStracker-user     * @param string $id page id
69*43d2073cStracker-user     * @return array list of annotation arrays (empty if none)
70*43d2073cStracker-user     */
71*43d2073cStracker-user    public function getAnnotations($id)
72*43d2073cStracker-user    {
73*43d2073cStracker-user        $file = $this->getFile($id);
74*43d2073cStracker-user        if (!file_exists($file)) {
75*43d2073cStracker-user            return [];
76*43d2073cStracker-user        }
77*43d2073cStracker-user        $raw = io_readFile($file, false);
78*43d2073cStracker-user        if ($raw === '') {
79*43d2073cStracker-user            return [];
80*43d2073cStracker-user        }
81*43d2073cStracker-user        $data = json_decode($raw, true);
82*43d2073cStracker-user        if (!is_array($data) || !isset($data['annotations']) || !is_array($data['annotations'])) {
83*43d2073cStracker-user            return [];
84*43d2073cStracker-user        }
85*43d2073cStracker-user        return $data['annotations'];
86*43d2073cStracker-user    }
87*43d2073cStracker-user
88*43d2073cStracker-user    /**
89*43d2073cStracker-user     * A single annotation by id.
90*43d2073cStracker-user     *
91*43d2073cStracker-user     * @param string $id    page id
92*43d2073cStracker-user     * @param string $annId annotation id
93*43d2073cStracker-user     * @return array|null
94*43d2073cStracker-user     */
95*43d2073cStracker-user    public function getAnnotation($id, $annId)
96*43d2073cStracker-user    {
97*43d2073cStracker-user        foreach ($this->getAnnotations($id) as $a) {
98*43d2073cStracker-user            if (($a['id'] ?? '') === $annId) {
99*43d2073cStracker-user                return $a;
100*43d2073cStracker-user            }
101*43d2073cStracker-user        }
102*43d2073cStracker-user        return null;
103*43d2073cStracker-user    }
104*43d2073cStracker-user
105*43d2073cStracker-user    /**
106*43d2073cStracker-user     * Counts for the on-page indicator. The orphan count is deliberately not
107*43d2073cStracker-user     * here — it depends on the rendered page and is computed client-side.
108*43d2073cStracker-user     *
109*43d2073cStracker-user     * @param string $id page id
110*43d2073cStracker-user     * @return array ['total'=>int, 'open'=>int, 'resolved'=>int]
111*43d2073cStracker-user     */
112*43d2073cStracker-user    public function getStats($id)
113*43d2073cStracker-user    {
114*43d2073cStracker-user        $open = 0;
115*43d2073cStracker-user        $resolved = 0;
116*43d2073cStracker-user        foreach ($this->getAnnotations($id) as $a) {
117*43d2073cStracker-user            if (($a['status'] ?? 'open') === 'resolved') {
118*43d2073cStracker-user                $resolved++;
119*43d2073cStracker-user            } else {
120*43d2073cStracker-user                $open++;
121*43d2073cStracker-user            }
122*43d2073cStracker-user        }
123*43d2073cStracker-user        return ['total' => $open + $resolved, 'open' => $open, 'resolved' => $resolved];
124*43d2073cStracker-user    }
125*43d2073cStracker-user
126*43d2073cStracker-user    /**
127*43d2073cStracker-user     * Write a page's annotation list to disk.
128*43d2073cStracker-user     *
129*43d2073cStracker-user     * @param string $id   page id
130*43d2073cStracker-user     * @param array  $list annotations
131*43d2073cStracker-user     * @return bool
132*43d2073cStracker-user     */
133*43d2073cStracker-user    protected function writeFile($id, array $list)
134*43d2073cStracker-user    {
135*43d2073cStracker-user        $payload = [
136*43d2073cStracker-user            'version'     => self::SCHEMA_VERSION,
137*43d2073cStracker-user            'annotations' => array_values($list),
138*43d2073cStracker-user        ];
139*43d2073cStracker-user        return (bool) io_saveFile($this->getFile($id), json_encode($payload, JSON_PRETTY_PRINT));
140*43d2073cStracker-user    }
141*43d2073cStracker-user
142*43d2073cStracker-user    /**
143*43d2073cStracker-user     * Run a modification against a page's annotations under a write lock.
144*43d2073cStracker-user     *
145*43d2073cStracker-user     * The modifier receives the annotation list by reference and returns an
146*43d2073cStracker-user     * outcome value. Returning the boolean false aborts the write (used for
147*43d2073cStracker-user     * "target not found"); any other value is returned to the caller after a
148*43d2073cStracker-user     * successful save.
149*43d2073cStracker-user     *
150*43d2073cStracker-user     * @param string   $id       page id
151*43d2073cStracker-user     * @param callable $modifier function(array &$annotations): mixed
152*43d2073cStracker-user     * @return mixed  the modifier's outcome on success, or false on failure
153*43d2073cStracker-user     */
154*43d2073cStracker-user    protected function mutate($id, callable $modifier)
155*43d2073cStracker-user    {
156*43d2073cStracker-user        $file = $this->getFile($id);
157*43d2073cStracker-user        io_lock($file);
158*43d2073cStracker-user
159*43d2073cStracker-user        $annotations = $this->getAnnotations($id);
160*43d2073cStracker-user        $outcome = $modifier($annotations);
161*43d2073cStracker-user
162*43d2073cStracker-user        if ($outcome === false) {
163*43d2073cStracker-user            io_unlock($file);
164*43d2073cStracker-user            return false;
165*43d2073cStracker-user        }
166*43d2073cStracker-user
167*43d2073cStracker-user        $ok = $this->writeFile($id, $annotations);
168*43d2073cStracker-user        io_unlock($file);
169*43d2073cStracker-user        return $ok ? $outcome : false;
170*43d2073cStracker-user    }
171*43d2073cStracker-user
172*43d2073cStracker-user    // ---------------------------------------------------------------------
173*43d2073cStracker-user    //  Annotation CRUD
174*43d2073cStracker-user    // ---------------------------------------------------------------------
175*43d2073cStracker-user
176*43d2073cStracker-user    /**
177*43d2073cStracker-user     * Create an annotation.
178*43d2073cStracker-user     *
179*43d2073cStracker-user     * @param string $id     page id
180*43d2073cStracker-user     * @param array  $anchor raw anchor {exact, prefix, suffix, start}
181*43d2073cStracker-user     * @param string $author username
182*43d2073cStracker-user     * @param string $body   annotation text
183*43d2073cStracker-user     * @return array|false  the created annotation, or false on invalid input
184*43d2073cStracker-user     */
185*43d2073cStracker-user    public function createAnnotation($id, $anchor, $author, $body)
186*43d2073cStracker-user    {
187*43d2073cStracker-user        if ($id === '' || $author === '' || $author === null) {
188*43d2073cStracker-user            return false;
189*43d2073cStracker-user        }
190*43d2073cStracker-user        $body = $this->cleanBody($body);
191*43d2073cStracker-user        if ($body === '') {
192*43d2073cStracker-user            return false;
193*43d2073cStracker-user        }
194*43d2073cStracker-user        $anchor = $this->cleanAnchor($anchor);
195*43d2073cStracker-user        if ($anchor === null) {
196*43d2073cStracker-user            return false;
197*43d2073cStracker-user        }
198*43d2073cStracker-user
199*43d2073cStracker-user        $now = time();
200*43d2073cStracker-user        $new = [
201*43d2073cStracker-user            'id'          => $this->newId(),
202*43d2073cStracker-user            'anchor'      => $anchor,
203*43d2073cStracker-user            'author'      => $author,
204*43d2073cStracker-user            'created'     => $now,
205*43d2073cStracker-user            'modified'    => $now,
206*43d2073cStracker-user            'body'        => $body,
207*43d2073cStracker-user            'status'      => 'open',
208*43d2073cStracker-user            'resolved_by' => '',
209*43d2073cStracker-user            'resolved_at' => 0,
210*43d2073cStracker-user            'replies'     => [],
211*43d2073cStracker-user        ];
212*43d2073cStracker-user
213*43d2073cStracker-user        return $this->mutate($id, function (array &$annotations) use ($new) {
214*43d2073cStracker-user            $annotations[] = $new;
215*43d2073cStracker-user            return $new;
216*43d2073cStracker-user        });
217*43d2073cStracker-user    }
218*43d2073cStracker-user
219*43d2073cStracker-user    /**
220*43d2073cStracker-user     * Edit an annotation's body text.
221*43d2073cStracker-user     *
222*43d2073cStracker-user     * @param string $id    page id
223*43d2073cStracker-user     * @param string $annId annotation id
224*43d2073cStracker-user     * @param string $body  new text
225*43d2073cStracker-user     * @return bool
226*43d2073cStracker-user     */
227*43d2073cStracker-user    public function updateAnnotationBody($id, $annId, $body)
228*43d2073cStracker-user    {
229*43d2073cStracker-user        $body = $this->cleanBody($body);
230*43d2073cStracker-user        if ($body === '') {
231*43d2073cStracker-user            return false;
232*43d2073cStracker-user        }
233*43d2073cStracker-user        return (bool) $this->mutate($id, function (array &$annotations) use ($annId, $body) {
234*43d2073cStracker-user            foreach ($annotations as $i => $a) {
235*43d2073cStracker-user                if (($a['id'] ?? '') === $annId) {
236*43d2073cStracker-user                    $annotations[$i]['body']     = $body;
237*43d2073cStracker-user                    $annotations[$i]['modified'] = time();
238*43d2073cStracker-user                    return true;
239*43d2073cStracker-user                }
240*43d2073cStracker-user            }
241*43d2073cStracker-user            return false;
242*43d2073cStracker-user        });
243*43d2073cStracker-user    }
244*43d2073cStracker-user
245*43d2073cStracker-user    /**
246*43d2073cStracker-user     * Delete an annotation and all its replies.
247*43d2073cStracker-user     *
248*43d2073cStracker-user     * @param string $id    page id
249*43d2073cStracker-user     * @param string $annId annotation id
250*43d2073cStracker-user     * @return bool
251*43d2073cStracker-user     */
252*43d2073cStracker-user    public function deleteAnnotation($id, $annId)
253*43d2073cStracker-user    {
254*43d2073cStracker-user        return (bool) $this->mutate($id, function (array &$annotations) use ($annId) {
255*43d2073cStracker-user            foreach ($annotations as $i => $a) {
256*43d2073cStracker-user                if (($a['id'] ?? '') === $annId) {
257*43d2073cStracker-user                    array_splice($annotations, $i, 1);
258*43d2073cStracker-user                    return true;
259*43d2073cStracker-user                }
260*43d2073cStracker-user            }
261*43d2073cStracker-user            return false;
262*43d2073cStracker-user        });
263*43d2073cStracker-user    }
264*43d2073cStracker-user
265*43d2073cStracker-user    /**
266*43d2073cStracker-user     * Mark an annotation open or resolved.
267*43d2073cStracker-user     *
268*43d2073cStracker-user     * @param string $id     page id
269*43d2073cStracker-user     * @param string $annId  annotation id
270*43d2073cStracker-user     * @param string $status 'open' or 'resolved'
271*43d2073cStracker-user     * @param string $actor  username making the change (recorded when resolving)
272*43d2073cStracker-user     * @return bool
273*43d2073cStracker-user     */
274*43d2073cStracker-user    public function setStatus($id, $annId, $status, $actor)
275*43d2073cStracker-user    {
276*43d2073cStracker-user        if (!in_array($status, ['open', 'resolved'], true)) {
277*43d2073cStracker-user            return false;
278*43d2073cStracker-user        }
279*43d2073cStracker-user        return (bool) $this->mutate($id, function (array &$annotations) use ($annId, $status, $actor) {
280*43d2073cStracker-user            foreach ($annotations as $i => $a) {
281*43d2073cStracker-user                if (($a['id'] ?? '') === $annId) {
282*43d2073cStracker-user                    $annotations[$i]['status'] = $status;
283*43d2073cStracker-user                    if ($status === 'resolved') {
284*43d2073cStracker-user                        $annotations[$i]['resolved_by'] = $actor;
285*43d2073cStracker-user                        $annotations[$i]['resolved_at'] = time();
286*43d2073cStracker-user                    } else {
287*43d2073cStracker-user                        $annotations[$i]['resolved_by'] = '';
288*43d2073cStracker-user                        $annotations[$i]['resolved_at'] = 0;
289*43d2073cStracker-user                    }
290*43d2073cStracker-user                    return true;
291*43d2073cStracker-user                }
292*43d2073cStracker-user            }
293*43d2073cStracker-user            return false;
294*43d2073cStracker-user        });
295*43d2073cStracker-user    }
296*43d2073cStracker-user
297*43d2073cStracker-user    // ---------------------------------------------------------------------
298*43d2073cStracker-user    //  Reply CRUD
299*43d2073cStracker-user    // ---------------------------------------------------------------------
300*43d2073cStracker-user
301*43d2073cStracker-user    /**
302*43d2073cStracker-user     * Add a reply to an annotation.
303*43d2073cStracker-user     *
304*43d2073cStracker-user     * @param string $id     page id
305*43d2073cStracker-user     * @param string $annId  annotation id
306*43d2073cStracker-user     * @param string $author username
307*43d2073cStracker-user     * @param string $body   reply text
308*43d2073cStracker-user     * @return array|false  the created reply, or false on invalid input
309*43d2073cStracker-user     */
310*43d2073cStracker-user    public function addReply($id, $annId, $author, $body)
311*43d2073cStracker-user    {
312*43d2073cStracker-user        if ($author === '' || $author === null) {
313*43d2073cStracker-user            return false;
314*43d2073cStracker-user        }
315*43d2073cStracker-user        $body = $this->cleanBody($body);
316*43d2073cStracker-user        if ($body === '') {
317*43d2073cStracker-user            return false;
318*43d2073cStracker-user        }
319*43d2073cStracker-user        $now = time();
320*43d2073cStracker-user        $reply = [
321*43d2073cStracker-user            'id'       => $this->newId(),
322*43d2073cStracker-user            'author'   => $author,
323*43d2073cStracker-user            'created'  => $now,
324*43d2073cStracker-user            'modified' => $now,
325*43d2073cStracker-user            'body'     => $body,
326*43d2073cStracker-user        ];
327*43d2073cStracker-user
328*43d2073cStracker-user        return $this->mutate($id, function (array &$annotations) use ($annId, $reply) {
329*43d2073cStracker-user            foreach ($annotations as $i => $a) {
330*43d2073cStracker-user                if (($a['id'] ?? '') === $annId) {
331*43d2073cStracker-user                    $annotations[$i]['replies'][] = $reply;
332*43d2073cStracker-user                    return $reply;
333*43d2073cStracker-user                }
334*43d2073cStracker-user            }
335*43d2073cStracker-user            return false;
336*43d2073cStracker-user        });
337*43d2073cStracker-user    }
338*43d2073cStracker-user
339*43d2073cStracker-user    /**
340*43d2073cStracker-user     * Edit a reply's body text.
341*43d2073cStracker-user     *
342*43d2073cStracker-user     * @param string $id      page id
343*43d2073cStracker-user     * @param string $annId   annotation id
344*43d2073cStracker-user     * @param string $replyId reply id
345*43d2073cStracker-user     * @param string $body    new text
346*43d2073cStracker-user     * @return bool
347*43d2073cStracker-user     */
348*43d2073cStracker-user    public function updateReply($id, $annId, $replyId, $body)
349*43d2073cStracker-user    {
350*43d2073cStracker-user        $body = $this->cleanBody($body);
351*43d2073cStracker-user        if ($body === '') {
352*43d2073cStracker-user            return false;
353*43d2073cStracker-user        }
354*43d2073cStracker-user        return (bool) $this->mutate($id, function (array &$annotations) use ($annId, $replyId, $body) {
355*43d2073cStracker-user            foreach ($annotations as $i => $a) {
356*43d2073cStracker-user                if (($a['id'] ?? '') !== $annId) {
357*43d2073cStracker-user                    continue;
358*43d2073cStracker-user                }
359*43d2073cStracker-user                foreach (($a['replies'] ?? []) as $j => $r) {
360*43d2073cStracker-user                    if (($r['id'] ?? '') === $replyId) {
361*43d2073cStracker-user                        $annotations[$i]['replies'][$j]['body']     = $body;
362*43d2073cStracker-user                        $annotations[$i]['replies'][$j]['modified'] = time();
363*43d2073cStracker-user                        return true;
364*43d2073cStracker-user                    }
365*43d2073cStracker-user                }
366*43d2073cStracker-user            }
367*43d2073cStracker-user            return false;
368*43d2073cStracker-user        });
369*43d2073cStracker-user    }
370*43d2073cStracker-user
371*43d2073cStracker-user    /**
372*43d2073cStracker-user     * Delete a reply.
373*43d2073cStracker-user     *
374*43d2073cStracker-user     * @param string $id      page id
375*43d2073cStracker-user     * @param string $annId   annotation id
376*43d2073cStracker-user     * @param string $replyId reply id
377*43d2073cStracker-user     * @return bool
378*43d2073cStracker-user     */
379*43d2073cStracker-user    public function deleteReply($id, $annId, $replyId)
380*43d2073cStracker-user    {
381*43d2073cStracker-user        return (bool) $this->mutate($id, function (array &$annotations) use ($annId, $replyId) {
382*43d2073cStracker-user            foreach ($annotations as $i => $a) {
383*43d2073cStracker-user                if (($a['id'] ?? '') !== $annId) {
384*43d2073cStracker-user                    continue;
385*43d2073cStracker-user                }
386*43d2073cStracker-user                foreach (($a['replies'] ?? []) as $j => $r) {
387*43d2073cStracker-user                    if (($r['id'] ?? '') === $replyId) {
388*43d2073cStracker-user                        array_splice($annotations[$i]['replies'], $j, 1);
389*43d2073cStracker-user                        return true;
390*43d2073cStracker-user                    }
391*43d2073cStracker-user                }
392*43d2073cStracker-user            }
393*43d2073cStracker-user            return false;
394*43d2073cStracker-user        });
395*43d2073cStracker-user    }
396*43d2073cStracker-user
397*43d2073cStracker-user    // ---------------------------------------------------------------------
398*43d2073cStracker-user    //  Bulk maintenance (admin, per page)
399*43d2073cStracker-user    // ---------------------------------------------------------------------
400*43d2073cStracker-user
401*43d2073cStracker-user    /**
402*43d2073cStracker-user     * Remove every resolved annotation from a page.
403*43d2073cStracker-user     *
404*43d2073cStracker-user     * @param string $id page id
405*43d2073cStracker-user     * @return int|false number removed, or false on write failure
406*43d2073cStracker-user     */
407*43d2073cStracker-user    public function clearResolved($id)
408*43d2073cStracker-user    {
409*43d2073cStracker-user        if (empty($this->getAnnotations($id))) {
410*43d2073cStracker-user            return 0;
411*43d2073cStracker-user        }
412*43d2073cStracker-user        return $this->mutate($id, function (array &$annotations) {
413*43d2073cStracker-user            $before = count($annotations);
414*43d2073cStracker-user            $annotations = array_values(array_filter($annotations, function ($a) {
415*43d2073cStracker-user                return ($a['status'] ?? 'open') !== 'resolved';
416*43d2073cStracker-user            }));
417*43d2073cStracker-user            return $before - count($annotations);
418*43d2073cStracker-user        });
419*43d2073cStracker-user    }
420*43d2073cStracker-user
421*43d2073cStracker-user    /**
422*43d2073cStracker-user     * Remove every orphaned annotation from a page — those whose quoted text
423*43d2073cStracker-user     * no longer appears in the rendered page. The page is re-checked here, so
424*43d2073cStracker-user     * this is authoritative regardless of what a client believed.
425*43d2073cStracker-user     *
426*43d2073cStracker-user     * @param string $id page id
427*43d2073cStracker-user     * @return int|false number removed, or false on write failure
428*43d2073cStracker-user     */
429*43d2073cStracker-user    public function clearOrphaned($id)
430*43d2073cStracker-user    {
431*43d2073cStracker-user        $orphanIds = [];
432*43d2073cStracker-user        foreach ($this->findOrphaned($id) as $a) {
433*43d2073cStracker-user            $orphanIds[] = $a['id'];
434*43d2073cStracker-user        }
435*43d2073cStracker-user        if (empty($orphanIds)) {
436*43d2073cStracker-user            return 0;
437*43d2073cStracker-user        }
438*43d2073cStracker-user        return $this->mutate($id, function (array &$annotations) use ($orphanIds) {
439*43d2073cStracker-user            $before = count($annotations);
440*43d2073cStracker-user            $annotations = array_values(array_filter($annotations, function ($a) use ($orphanIds) {
441*43d2073cStracker-user                return !in_array($a['id'] ?? '', $orphanIds, true);
442*43d2073cStracker-user            }));
443*43d2073cStracker-user            return $before - count($annotations);
444*43d2073cStracker-user        });
445*43d2073cStracker-user    }
446*43d2073cStracker-user
447*43d2073cStracker-user    // ---------------------------------------------------------------------
448*43d2073cStracker-user    //  Orphan detection
449*43d2073cStracker-user    // ---------------------------------------------------------------------
450*43d2073cStracker-user
451*43d2073cStracker-user    /**
452*43d2073cStracker-user     * Render a page to normalised plain text, for quote searching.
453*43d2073cStracker-user     *
454*43d2073cStracker-user     * Block-level closing tags become spaces so adjacent blocks do not fuse
455*43d2073cStracker-user     * into one run of text; then tags are stripped, entities decoded, and
456*43d2073cStracker-user     * whitespace collapsed — the same normalisation applied to stored quotes.
457*43d2073cStracker-user     *
458*43d2073cStracker-user     * @param string $id page id
459*43d2073cStracker-user     * @return string
460*43d2073cStracker-user     */
461*43d2073cStracker-user    public function getPageText($id)
462*43d2073cStracker-user    {
463*43d2073cStracker-user        if (!page_exists($id)) {
464*43d2073cStracker-user            return '';
465*43d2073cStracker-user        }
466*43d2073cStracker-user        $xhtml = p_wiki_xhtml($id, '', false);
467*43d2073cStracker-user        if (!is_string($xhtml) || $xhtml === '') {
468*43d2073cStracker-user            return '';
469*43d2073cStracker-user        }
470*43d2073cStracker-user        $xhtml = preg_replace('#</(p|div|li|h[1-6]|td|th|tr|blockquote|pre|dt|dd)>#i', ' ', $xhtml);
471*43d2073cStracker-user        $xhtml = preg_replace('#<br\s*/?>#i', ' ', $xhtml);
472*43d2073cStracker-user        $text  = strip_tags($xhtml);
473*43d2073cStracker-user        $text  = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
474*43d2073cStracker-user        return $this->normalizeWhitespace($text);
475*43d2073cStracker-user    }
476*43d2073cStracker-user
477*43d2073cStracker-user    /**
478*43d2073cStracker-user     * The annotations on a page whose quoted text is no longer present.
479*43d2073cStracker-user     *
480*43d2073cStracker-user     * @param string $id page id
481*43d2073cStracker-user     * @return array list of orphaned annotation arrays
482*43d2073cStracker-user     */
483*43d2073cStracker-user    public function findOrphaned($id)
484*43d2073cStracker-user    {
485*43d2073cStracker-user        $annotations = $this->getAnnotations($id);
486*43d2073cStracker-user        if (empty($annotations)) {
487*43d2073cStracker-user            return [];
488*43d2073cStracker-user        }
489*43d2073cStracker-user        $pageText = $this->getPageText($id);
490*43d2073cStracker-user
491*43d2073cStracker-user        $orphaned = [];
492*43d2073cStracker-user        foreach ($annotations as $a) {
493*43d2073cStracker-user            $exact = $this->normalizeWhitespace($a['anchor']['exact'] ?? '');
494*43d2073cStracker-user            if ($exact === '' || mb_strpos($pageText, $exact) === false) {
495*43d2073cStracker-user                $orphaned[] = $a;
496*43d2073cStracker-user            }
497*43d2073cStracker-user        }
498*43d2073cStracker-user        return $orphaned;
499*43d2073cStracker-user    }
500*43d2073cStracker-user
501*43d2073cStracker-user    // ---------------------------------------------------------------------
502*43d2073cStracker-user    //  Permission rules (single source of truth)
503*43d2073cStracker-user    // ---------------------------------------------------------------------
504*43d2073cStracker-user
505*43d2073cStracker-user    /**
506*43d2073cStracker-user     * May this user create an annotation, reply, or change a resolve status?
507*43d2073cStracker-user     *
508*43d2073cStracker-user     * Requires only read access to the page — annotations are out-of-band, so
509*43d2073cStracker-user     * a user whose page edit access is blocked may still annotate.
510*43d2073cStracker-user     *
511*43d2073cStracker-user     * @param string $user     current username ('' for anonymous)
512*43d2073cStracker-user     * @param int    $aclLevel the user's ACL level on the page
513*43d2073cStracker-user     * @return bool
514*43d2073cStracker-user     */
515*43d2073cStracker-user    public function canAnnotate($user, $aclLevel)
516*43d2073cStracker-user    {
517*43d2073cStracker-user        return $user !== '' && $user !== null && $aclLevel >= AUTH_READ;
518*43d2073cStracker-user    }
519*43d2073cStracker-user
520*43d2073cStracker-user    /**
521*43d2073cStracker-user     * May this user edit or delete the given annotation? Author or admin.
522*43d2073cStracker-user     *
523*43d2073cStracker-user     * @param array  $annotation
524*43d2073cStracker-user     * @param string $user
525*43d2073cStracker-user     * @param bool   $isAdmin
526*43d2073cStracker-user     * @return bool
527*43d2073cStracker-user     */
528*43d2073cStracker-user    public function canEditAnnotation(array $annotation, $user, $isAdmin)
529*43d2073cStracker-user    {
530*43d2073cStracker-user        if ($user === '' || $user === null) {
531*43d2073cStracker-user            return false;
532*43d2073cStracker-user        }
533*43d2073cStracker-user        return $isAdmin || (($annotation['author'] ?? '') === $user);
534*43d2073cStracker-user    }
535*43d2073cStracker-user
536*43d2073cStracker-user    /**
537*43d2073cStracker-user     * May this user edit or delete the given reply? Author or admin.
538*43d2073cStracker-user     *
539*43d2073cStracker-user     * @param array  $reply
540*43d2073cStracker-user     * @param string $user
541*43d2073cStracker-user     * @param bool   $isAdmin
542*43d2073cStracker-user     * @return bool
543*43d2073cStracker-user     */
544*43d2073cStracker-user    public function canEditReply(array $reply, $user, $isAdmin)
545*43d2073cStracker-user    {
546*43d2073cStracker-user        if ($user === '' || $user === null) {
547*43d2073cStracker-user            return false;
548*43d2073cStracker-user        }
549*43d2073cStracker-user        return $isAdmin || (($reply['author'] ?? '') === $user);
550*43d2073cStracker-user    }
551*43d2073cStracker-user
552*43d2073cStracker-user    /**
553*43d2073cStracker-user     * May this user run the per-page "clear resolved/orphaned" operations?
554*43d2073cStracker-user     * Admins only.
555*43d2073cStracker-user     *
556*43d2073cStracker-user     * @param bool $isAdmin
557*43d2073cStracker-user     * @return bool
558*43d2073cStracker-user     */
559*43d2073cStracker-user    public function canClear($isAdmin)
560*43d2073cStracker-user    {
561*43d2073cStracker-user        return (bool) $isAdmin;
562*43d2073cStracker-user    }
563*43d2073cStracker-user
564*43d2073cStracker-user    // ---------------------------------------------------------------------
565*43d2073cStracker-user    //  Input cleaning
566*43d2073cStracker-user    // ---------------------------------------------------------------------
567*43d2073cStracker-user
568*43d2073cStracker-user    /**
569*43d2073cStracker-user     * Validate and normalise a raw anchor.
570*43d2073cStracker-user     *
571*43d2073cStracker-user     * @param mixed $anchor
572*43d2073cStracker-user     * @return array|null  the cleaned anchor, or null if unusable
573*43d2073cStracker-user     */
574*43d2073cStracker-user    protected function cleanAnchor($anchor)
575*43d2073cStracker-user    {
576*43d2073cStracker-user        if (!is_array($anchor)) {
577*43d2073cStracker-user            return null;
578*43d2073cStracker-user        }
579*43d2073cStracker-user
580*43d2073cStracker-user        $exact = (isset($anchor['exact']) && is_string($anchor['exact']))
581*43d2073cStracker-user            ? $this->normalizeWhitespace($anchor['exact'])
582*43d2073cStracker-user            : '';
583*43d2073cStracker-user        if ($exact === '') {
584*43d2073cStracker-user            return null; // an anchor without quoted text is unusable
585*43d2073cStracker-user        }
586*43d2073cStracker-user        if (mb_strlen($exact) > self::MAX_QUOTE) {
587*43d2073cStracker-user            $exact = mb_substr($exact, 0, self::MAX_QUOTE);
588*43d2073cStracker-user        }
589*43d2073cStracker-user
590*43d2073cStracker-user        $prefix = (isset($anchor['prefix']) && is_string($anchor['prefix']))
591*43d2073cStracker-user            ? $this->normalizeWhitespace($anchor['prefix'])
592*43d2073cStracker-user            : '';
593*43d2073cStracker-user        $suffix = (isset($anchor['suffix']) && is_string($anchor['suffix']))
594*43d2073cStracker-user            ? $this->normalizeWhitespace($anchor['suffix'])
595*43d2073cStracker-user            : '';
596*43d2073cStracker-user        if (mb_strlen($prefix) > self::MAX_CONTEXT) {
597*43d2073cStracker-user            $prefix = mb_substr($prefix, -self::MAX_CONTEXT);
598*43d2073cStracker-user        }
599*43d2073cStracker-user        if (mb_strlen($suffix) > self::MAX_CONTEXT) {
600*43d2073cStracker-user            $suffix = mb_substr($suffix, 0, self::MAX_CONTEXT);
601*43d2073cStracker-user        }
602*43d2073cStracker-user
603*43d2073cStracker-user        $start = isset($anchor['start']) ? max(0, (int) $anchor['start']) : 0;
604*43d2073cStracker-user
605*43d2073cStracker-user        return [
606*43d2073cStracker-user            'exact'  => $exact,
607*43d2073cStracker-user            'prefix' => $prefix,
608*43d2073cStracker-user            'suffix' => $suffix,
609*43d2073cStracker-user            'start'  => $start,
610*43d2073cStracker-user        ];
611*43d2073cStracker-user    }
612*43d2073cStracker-user
613*43d2073cStracker-user    /**
614*43d2073cStracker-user     * Clean an annotation/reply body: a plain-text string, trimmed, with
615*43d2073cStracker-user     * normalised line endings and a length cap. Newlines are kept; the text
616*43d2073cStracker-user     * is escaped by the consumer at render time.
617*43d2073cStracker-user     *
618*43d2073cStracker-user     * @param mixed $body
619*43d2073cStracker-user     * @return string
620*43d2073cStracker-user     */
621*43d2073cStracker-user    protected function cleanBody($body)
622*43d2073cStracker-user    {
623*43d2073cStracker-user        if (!is_string($body)) {
624*43d2073cStracker-user            return '';
625*43d2073cStracker-user        }
626*43d2073cStracker-user        $body = str_replace("\r\n", "\n", $body);
627*43d2073cStracker-user        $body = str_replace("\r", "\n", $body);
628*43d2073cStracker-user        $body = trim($body);
629*43d2073cStracker-user        if (mb_strlen($body) > self::MAX_BODY) {
630*43d2073cStracker-user            $body = mb_substr($body, 0, self::MAX_BODY);
631*43d2073cStracker-user        }
632*43d2073cStracker-user        return $body;
633*43d2073cStracker-user    }
634*43d2073cStracker-user
635*43d2073cStracker-user    /**
636*43d2073cStracker-user     * Collapse every run of whitespace to a single space and trim.
637*43d2073cStracker-user     *
638*43d2073cStracker-user     * @param mixed $text
639*43d2073cStracker-user     * @return string
640*43d2073cStracker-user     */
641*43d2073cStracker-user    protected function normalizeWhitespace($text)
642*43d2073cStracker-user    {
643*43d2073cStracker-user        return trim(preg_replace('/\s+/u', ' ', (string) $text));
644*43d2073cStracker-user    }
645*43d2073cStracker-user
646*43d2073cStracker-user    /**
647*43d2073cStracker-user     * A fresh identifier for an annotation or reply.
648*43d2073cStracker-user     *
649*43d2073cStracker-user     * @return string 16 hex characters
650*43d2073cStracker-user     */
651*43d2073cStracker-user    protected function newId()
652*43d2073cStracker-user    {
653*43d2073cStracker-user        return bin2hex(random_bytes(8));
654*43d2073cStracker-user    }
655*43d2073cStracker-user}
656