xref: /plugin/annotations/helper.php (revision 108f92bd856af52ccb9e86517ad03d96f4a9273a)
143d2073cStracker-user<?php
243d2073cStracker-user
343d2073cStracker-user/**
443d2073cStracker-user * Annotations plugin — storage and data-logic helper.
543d2073cStracker-user *
643d2073cStracker-user * This component owns:
743d2073cStracker-user *
843d2073cStracker-user *   1. The per-page annotation store. One JSON file per page, obtained via
943d2073cStracker-user *      metaFN($id, '.annotations'), holding {version, annotations:[...]}.
1043d2073cStracker-user *      JSON and pretty-printed so the files are easy to inspect or back up.
1143d2073cStracker-user *      The page text and the wiki changelog are never touched.
1243d2073cStracker-user *
1343d2073cStracker-user *   2. The text-quote anchor model. Each annotation stores an anchor of
1443d2073cStracker-user *      {exact, prefix, suffix, start} — the quoted text, a short slice of the
1543d2073cStracker-user *      surrounding context on each side (to disambiguate repeated quotes),
1643d2073cStracker-user *      and a character-offset hint. This is the Hypothes.is approach.
1743d2073cStracker-user *
1843d2073cStracker-user *   3. CRUD on annotations and their threaded replies.
1943d2073cStracker-user *
2043d2073cStracker-user *   4. Server-side orphan detection: a page is rendered to plain text and an
2143d2073cStracker-user *      annotation is "orphaned" when its quoted text no longer appears. Used
2243d2073cStracker-user *      by the admin-only per-page "clear orphaned" operation. (The live UI
2343d2073cStracker-user *      also detects orphans client-side for the on-page counter.)
2443d2073cStracker-user *
2543d2073cStracker-user *   5. The permission rules, as the single source of truth. They are pure
2643d2073cStracker-user *      functions: the caller gathers the facts (current user, admin flag, the
2743d2073cStracker-user *      page's ACL level) and passes them in. Because annotations live
2843d2073cStracker-user *      out-of-band, creating one needs only AUTH_READ on the page, never
2943d2073cStracker-user *      AUTH_EDIT — so a group whose page edit access is blocked can still
3043d2073cStracker-user *      annotate.
3143d2073cStracker-user */
3243d2073cStracker-user
3343d2073cStracker-user// must be run within DokuWiki
3443d2073cStracker-userif (!defined('DOKU_INC')) die();
3543d2073cStracker-user
3643d2073cStracker-userclass helper_plugin_annotations extends DokuWiki_Plugin
3743d2073cStracker-user{
3843d2073cStracker-user    /** storage schema version, written into each file */
3943d2073cStracker-user    const SCHEMA_VERSION = 1;
4043d2073cStracker-user
4143d2073cStracker-user    /** longest quoted selection stored, in characters */
4243d2073cStracker-user    const MAX_QUOTE = 1000;
4343d2073cStracker-user
4443d2073cStracker-user    /** length of the prefix/suffix context slices, in characters */
4543d2073cStracker-user    const MAX_CONTEXT = 64;
4643d2073cStracker-user
4743d2073cStracker-user    /** longest annotation/reply body, in characters */
4843d2073cStracker-user    const MAX_BODY = 10000;
4943d2073cStracker-user
5043d2073cStracker-user    // ---------------------------------------------------------------------
5143d2073cStracker-user    //  Storage
5243d2073cStracker-user    // ---------------------------------------------------------------------
5343d2073cStracker-user
5443d2073cStracker-user    /**
5543d2073cStracker-user     * Path of a page's annotation file.
5643d2073cStracker-user     *
5743d2073cStracker-user     * @param string $id page id
5843d2073cStracker-user     * @return string
5943d2073cStracker-user     */
6043d2073cStracker-user    protected function getFile($id)
6143d2073cStracker-user    {
6243d2073cStracker-user        return metaFN($id, '.annotations');
6343d2073cStracker-user    }
6443d2073cStracker-user
6543d2073cStracker-user    /**
6643d2073cStracker-user     * All annotations stored for a page.
6743d2073cStracker-user     *
6843d2073cStracker-user     * @param string $id page id
6943d2073cStracker-user     * @return array list of annotation arrays (empty if none)
7043d2073cStracker-user     */
7143d2073cStracker-user    public function getAnnotations($id)
7243d2073cStracker-user    {
7343d2073cStracker-user        $file = $this->getFile($id);
7443d2073cStracker-user        if (!file_exists($file)) {
7543d2073cStracker-user            return [];
7643d2073cStracker-user        }
7743d2073cStracker-user        $raw = io_readFile($file, false);
7843d2073cStracker-user        if ($raw === '') {
7943d2073cStracker-user            return [];
8043d2073cStracker-user        }
8143d2073cStracker-user        $data = json_decode($raw, true);
8243d2073cStracker-user        if (!is_array($data) || !isset($data['annotations']) || !is_array($data['annotations'])) {
8343d2073cStracker-user            return [];
8443d2073cStracker-user        }
8543d2073cStracker-user        return $data['annotations'];
8643d2073cStracker-user    }
8743d2073cStracker-user
8843d2073cStracker-user    /**
8943d2073cStracker-user     * A single annotation by id.
9043d2073cStracker-user     *
9143d2073cStracker-user     * @param string $id    page id
9243d2073cStracker-user     * @param string $annId annotation id
9343d2073cStracker-user     * @return array|null
9443d2073cStracker-user     */
9543d2073cStracker-user    public function getAnnotation($id, $annId)
9643d2073cStracker-user    {
9743d2073cStracker-user        foreach ($this->getAnnotations($id) as $a) {
9843d2073cStracker-user            if (($a['id'] ?? '') === $annId) {
9943d2073cStracker-user                return $a;
10043d2073cStracker-user            }
10143d2073cStracker-user        }
10243d2073cStracker-user        return null;
10343d2073cStracker-user    }
10443d2073cStracker-user
10543d2073cStracker-user    /**
10643d2073cStracker-user     * Counts for the on-page indicator. The orphan count is deliberately not
10743d2073cStracker-user     * here — it depends on the rendered page and is computed client-side.
10843d2073cStracker-user     *
10943d2073cStracker-user     * @param string $id page id
11043d2073cStracker-user     * @return array ['total'=>int, 'open'=>int, 'resolved'=>int]
11143d2073cStracker-user     */
11243d2073cStracker-user    public function getStats($id)
11343d2073cStracker-user    {
114*108f92bdStracker-user        return $this->statsFor($this->getAnnotations($id));
115*108f92bdStracker-user    }
116*108f92bdStracker-user
117*108f92bdStracker-user    /**
118*108f92bdStracker-user     * Counts for the on-page indicator, computed from an already-loaded list.
119*108f92bdStracker-user     * Split out from getStats() so callers that already hold the annotation
120*108f92bdStracker-user     * array (e.g. the page-load JSINFO injector, which embeds the same list)
121*108f92bdStracker-user     * don't re-read the file.
122*108f92bdStracker-user     *
123*108f92bdStracker-user     * @param array $annotations annotation list
124*108f92bdStracker-user     * @return array ['total'=>int, 'open'=>int, 'resolved'=>int]
125*108f92bdStracker-user     */
126*108f92bdStracker-user    public function statsFor(array $annotations)
127*108f92bdStracker-user    {
12843d2073cStracker-user        $open = 0;
12943d2073cStracker-user        $resolved = 0;
130*108f92bdStracker-user        foreach ($annotations as $a) {
13143d2073cStracker-user            if (($a['status'] ?? 'open') === 'resolved') {
13243d2073cStracker-user                $resolved++;
13343d2073cStracker-user            } else {
13443d2073cStracker-user                $open++;
13543d2073cStracker-user            }
13643d2073cStracker-user        }
13743d2073cStracker-user        return ['total' => $open + $resolved, 'open' => $open, 'resolved' => $resolved];
13843d2073cStracker-user    }
13943d2073cStracker-user
14043d2073cStracker-user    /**
14143d2073cStracker-user     * Write a page's annotation list to disk.
14243d2073cStracker-user     *
14343d2073cStracker-user     * @param string $id   page id
14443d2073cStracker-user     * @param array  $list annotations
14543d2073cStracker-user     * @return bool
14643d2073cStracker-user     */
14743d2073cStracker-user    protected function writeFile($id, array $list)
14843d2073cStracker-user    {
14943d2073cStracker-user        $payload = [
15043d2073cStracker-user            'version'     => self::SCHEMA_VERSION,
15143d2073cStracker-user            'annotations' => array_values($list),
15243d2073cStracker-user        ];
153da56206cStracker-user        return (bool) io_saveFile(
154da56206cStracker-user            $this->getFile($id),
155da56206cStracker-user            json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
156da56206cStracker-user        );
15743d2073cStracker-user    }
15843d2073cStracker-user
15943d2073cStracker-user    /**
16043d2073cStracker-user     * Run a modification against a page's annotations under a write lock.
16143d2073cStracker-user     *
16243d2073cStracker-user     * The modifier receives the annotation list by reference and returns an
16343d2073cStracker-user     * outcome value. Returning the boolean false aborts the write (used for
16443d2073cStracker-user     * "target not found"); any other value is returned to the caller after a
16543d2073cStracker-user     * successful save.
16643d2073cStracker-user     *
16743d2073cStracker-user     * @param string   $id       page id
16843d2073cStracker-user     * @param callable $modifier function(array &$annotations): mixed
16943d2073cStracker-user     * @return mixed  the modifier's outcome on success, or false on failure
17043d2073cStracker-user     */
17143d2073cStracker-user    protected function mutate($id, callable $modifier)
17243d2073cStracker-user    {
17343d2073cStracker-user        $file = $this->getFile($id);
17443d2073cStracker-user        io_lock($file);
17543d2073cStracker-user
17643d2073cStracker-user        $annotations = $this->getAnnotations($id);
17743d2073cStracker-user        $outcome = $modifier($annotations);
17843d2073cStracker-user
17943d2073cStracker-user        if ($outcome === false) {
18043d2073cStracker-user            io_unlock($file);
18143d2073cStracker-user            return false;
18243d2073cStracker-user        }
18343d2073cStracker-user
18443d2073cStracker-user        $ok = $this->writeFile($id, $annotations);
18543d2073cStracker-user        io_unlock($file);
18643d2073cStracker-user        return $ok ? $outcome : false;
18743d2073cStracker-user    }
18843d2073cStracker-user
18943d2073cStracker-user    // ---------------------------------------------------------------------
19043d2073cStracker-user    //  Annotation CRUD
19143d2073cStracker-user    // ---------------------------------------------------------------------
19243d2073cStracker-user
19343d2073cStracker-user    /**
19443d2073cStracker-user     * Create an annotation.
19543d2073cStracker-user     *
19643d2073cStracker-user     * @param string $id     page id
19743d2073cStracker-user     * @param array  $anchor raw anchor {exact, prefix, suffix, start}
19843d2073cStracker-user     * @param string $author username
19943d2073cStracker-user     * @param string $body   annotation text
20043d2073cStracker-user     * @return array|false  the created annotation, or false on invalid input
20143d2073cStracker-user     */
20243d2073cStracker-user    public function createAnnotation($id, $anchor, $author, $body)
20343d2073cStracker-user    {
20443d2073cStracker-user        if ($id === '' || $author === '' || $author === null) {
20543d2073cStracker-user            return false;
20643d2073cStracker-user        }
20743d2073cStracker-user        $body = $this->cleanBody($body);
20843d2073cStracker-user        if ($body === '') {
20943d2073cStracker-user            return false;
21043d2073cStracker-user        }
21143d2073cStracker-user        $anchor = $this->cleanAnchor($anchor);
21243d2073cStracker-user        if ($anchor === null) {
21343d2073cStracker-user            return false;
21443d2073cStracker-user        }
21543d2073cStracker-user
21643d2073cStracker-user        $now = time();
21743d2073cStracker-user        $new = [
21843d2073cStracker-user            'id'          => $this->newId(),
21943d2073cStracker-user            'anchor'      => $anchor,
22043d2073cStracker-user            'author'      => $author,
22143d2073cStracker-user            'created'     => $now,
22243d2073cStracker-user            'modified'    => $now,
22343d2073cStracker-user            'body'        => $body,
22443d2073cStracker-user            'status'      => 'open',
22543d2073cStracker-user            'resolved_by' => '',
22643d2073cStracker-user            'resolved_at' => 0,
22743d2073cStracker-user            'replies'     => [],
22843d2073cStracker-user        ];
22943d2073cStracker-user
23043d2073cStracker-user        return $this->mutate($id, function (array &$annotations) use ($new) {
23143d2073cStracker-user            $annotations[] = $new;
23243d2073cStracker-user            return $new;
23343d2073cStracker-user        });
23443d2073cStracker-user    }
23543d2073cStracker-user
23643d2073cStracker-user    /**
23743d2073cStracker-user     * Edit an annotation's body text.
23843d2073cStracker-user     *
23943d2073cStracker-user     * @param string $id    page id
24043d2073cStracker-user     * @param string $annId annotation id
24143d2073cStracker-user     * @param string $body  new text
24243d2073cStracker-user     * @return bool
24343d2073cStracker-user     */
24443d2073cStracker-user    public function updateAnnotationBody($id, $annId, $body)
24543d2073cStracker-user    {
24643d2073cStracker-user        $body = $this->cleanBody($body);
24743d2073cStracker-user        if ($body === '') {
24843d2073cStracker-user            return false;
24943d2073cStracker-user        }
25043d2073cStracker-user        return (bool) $this->mutate($id, function (array &$annotations) use ($annId, $body) {
25143d2073cStracker-user            foreach ($annotations as $i => $a) {
25243d2073cStracker-user                if (($a['id'] ?? '') === $annId) {
25343d2073cStracker-user                    $annotations[$i]['body']     = $body;
25443d2073cStracker-user                    $annotations[$i]['modified'] = time();
25543d2073cStracker-user                    return true;
25643d2073cStracker-user                }
25743d2073cStracker-user            }
25843d2073cStracker-user            return false;
25943d2073cStracker-user        });
26043d2073cStracker-user    }
26143d2073cStracker-user
26243d2073cStracker-user    /**
26343d2073cStracker-user     * Delete an annotation and all its replies.
26443d2073cStracker-user     *
26543d2073cStracker-user     * @param string $id    page id
26643d2073cStracker-user     * @param string $annId annotation id
26743d2073cStracker-user     * @return bool
26843d2073cStracker-user     */
26943d2073cStracker-user    public function deleteAnnotation($id, $annId)
27043d2073cStracker-user    {
27143d2073cStracker-user        return (bool) $this->mutate($id, function (array &$annotations) use ($annId) {
27243d2073cStracker-user            foreach ($annotations as $i => $a) {
27343d2073cStracker-user                if (($a['id'] ?? '') === $annId) {
27443d2073cStracker-user                    array_splice($annotations, $i, 1);
27543d2073cStracker-user                    return true;
27643d2073cStracker-user                }
27743d2073cStracker-user            }
27843d2073cStracker-user            return false;
27943d2073cStracker-user        });
28043d2073cStracker-user    }
28143d2073cStracker-user
28243d2073cStracker-user    /**
28343d2073cStracker-user     * Mark an annotation open or resolved.
28443d2073cStracker-user     *
28543d2073cStracker-user     * @param string $id     page id
28643d2073cStracker-user     * @param string $annId  annotation id
28743d2073cStracker-user     * @param string $status 'open' or 'resolved'
28843d2073cStracker-user     * @param string $actor  username making the change (recorded when resolving)
28943d2073cStracker-user     * @return bool
29043d2073cStracker-user     */
29143d2073cStracker-user    public function setStatus($id, $annId, $status, $actor)
29243d2073cStracker-user    {
29343d2073cStracker-user        if (!in_array($status, ['open', 'resolved'], true)) {
29443d2073cStracker-user            return false;
29543d2073cStracker-user        }
29643d2073cStracker-user        return (bool) $this->mutate($id, function (array &$annotations) use ($annId, $status, $actor) {
29743d2073cStracker-user            foreach ($annotations as $i => $a) {
29843d2073cStracker-user                if (($a['id'] ?? '') === $annId) {
29943d2073cStracker-user                    $annotations[$i]['status'] = $status;
30043d2073cStracker-user                    if ($status === 'resolved') {
30143d2073cStracker-user                        $annotations[$i]['resolved_by'] = $actor;
30243d2073cStracker-user                        $annotations[$i]['resolved_at'] = time();
30343d2073cStracker-user                    } else {
30443d2073cStracker-user                        $annotations[$i]['resolved_by'] = '';
30543d2073cStracker-user                        $annotations[$i]['resolved_at'] = 0;
30643d2073cStracker-user                    }
30743d2073cStracker-user                    return true;
30843d2073cStracker-user                }
30943d2073cStracker-user            }
31043d2073cStracker-user            return false;
31143d2073cStracker-user        });
31243d2073cStracker-user    }
31343d2073cStracker-user
31443d2073cStracker-user    // ---------------------------------------------------------------------
31543d2073cStracker-user    //  Reply CRUD
31643d2073cStracker-user    // ---------------------------------------------------------------------
31743d2073cStracker-user
31843d2073cStracker-user    /**
31943d2073cStracker-user     * Add a reply to an annotation.
32043d2073cStracker-user     *
32143d2073cStracker-user     * @param string $id       page id
32243d2073cStracker-user     * @param string $annId    annotation id
32343d2073cStracker-user     * @param string $author   username
32443d2073cStracker-user     * @param string $body     reply text
325ee9dbf15Stracker-user     * @param string $parentId id of the reply being replied to, or '' for root-level
32643d2073cStracker-user     * @return array|false  the created reply, or false on invalid input
32743d2073cStracker-user     */
328ee9dbf15Stracker-user    public function addReply($id, $annId, $author, $body, $parentId = '')
32943d2073cStracker-user    {
33043d2073cStracker-user        if ($author === '' || $author === null) {
33143d2073cStracker-user            return false;
33243d2073cStracker-user        }
33343d2073cStracker-user        $body = $this->cleanBody($body);
33443d2073cStracker-user        if ($body === '') {
33543d2073cStracker-user            return false;
33643d2073cStracker-user        }
33743d2073cStracker-user        $now = time();
33843d2073cStracker-user        $reply = [
33943d2073cStracker-user            'id'       => $this->newId(),
340ee9dbf15Stracker-user            'parentId' => preg_replace('/[^a-f0-9]/', '', (string) $parentId),
34143d2073cStracker-user            'author'   => $author,
34243d2073cStracker-user            'created'  => $now,
34343d2073cStracker-user            'modified' => $now,
34443d2073cStracker-user            'body'     => $body,
34543d2073cStracker-user        ];
34643d2073cStracker-user
34743d2073cStracker-user        return $this->mutate($id, function (array &$annotations) use ($annId, $reply) {
34843d2073cStracker-user            foreach ($annotations as $i => $a) {
34943d2073cStracker-user                if (($a['id'] ?? '') === $annId) {
35043d2073cStracker-user                    $annotations[$i]['replies'][] = $reply;
35143d2073cStracker-user                    return $reply;
35243d2073cStracker-user                }
35343d2073cStracker-user            }
35443d2073cStracker-user            return false;
35543d2073cStracker-user        });
35643d2073cStracker-user    }
35743d2073cStracker-user
35843d2073cStracker-user    /**
35943d2073cStracker-user     * Edit a reply's body text.
36043d2073cStracker-user     *
36143d2073cStracker-user     * @param string $id      page id
36243d2073cStracker-user     * @param string $annId   annotation id
36343d2073cStracker-user     * @param string $replyId reply id
36443d2073cStracker-user     * @param string $body    new text
36543d2073cStracker-user     * @return bool
36643d2073cStracker-user     */
36743d2073cStracker-user    public function updateReply($id, $annId, $replyId, $body)
36843d2073cStracker-user    {
36943d2073cStracker-user        $body = $this->cleanBody($body);
37043d2073cStracker-user        if ($body === '') {
37143d2073cStracker-user            return false;
37243d2073cStracker-user        }
37343d2073cStracker-user        return (bool) $this->mutate($id, function (array &$annotations) use ($annId, $replyId, $body) {
37443d2073cStracker-user            foreach ($annotations as $i => $a) {
37543d2073cStracker-user                if (($a['id'] ?? '') !== $annId) {
37643d2073cStracker-user                    continue;
37743d2073cStracker-user                }
37843d2073cStracker-user                foreach (($a['replies'] ?? []) as $j => $r) {
37943d2073cStracker-user                    if (($r['id'] ?? '') === $replyId) {
38043d2073cStracker-user                        $annotations[$i]['replies'][$j]['body']     = $body;
38143d2073cStracker-user                        $annotations[$i]['replies'][$j]['modified'] = time();
38243d2073cStracker-user                        return true;
38343d2073cStracker-user                    }
38443d2073cStracker-user                }
38543d2073cStracker-user            }
38643d2073cStracker-user            return false;
38743d2073cStracker-user        });
38843d2073cStracker-user    }
38943d2073cStracker-user
39043d2073cStracker-user    /**
39143d2073cStracker-user     * Delete a reply.
39243d2073cStracker-user     *
39343d2073cStracker-user     * @param string $id      page id
39443d2073cStracker-user     * @param string $annId   annotation id
39543d2073cStracker-user     * @param string $replyId reply id
39643d2073cStracker-user     * @return bool
39743d2073cStracker-user     */
39843d2073cStracker-user    public function deleteReply($id, $annId, $replyId)
39943d2073cStracker-user    {
40043d2073cStracker-user        return (bool) $this->mutate($id, function (array &$annotations) use ($annId, $replyId) {
40143d2073cStracker-user            foreach ($annotations as $i => $a) {
40243d2073cStracker-user                if (($a['id'] ?? '') !== $annId) {
40343d2073cStracker-user                    continue;
40443d2073cStracker-user                }
40543d2073cStracker-user                foreach (($a['replies'] ?? []) as $j => $r) {
40643d2073cStracker-user                    if (($r['id'] ?? '') === $replyId) {
40743d2073cStracker-user                        array_splice($annotations[$i]['replies'], $j, 1);
40843d2073cStracker-user                        return true;
40943d2073cStracker-user                    }
41043d2073cStracker-user                }
41143d2073cStracker-user            }
41243d2073cStracker-user            return false;
41343d2073cStracker-user        });
41443d2073cStracker-user    }
41543d2073cStracker-user
41643d2073cStracker-user    // ---------------------------------------------------------------------
41743d2073cStracker-user    //  Bulk maintenance (admin, per page)
41843d2073cStracker-user    // ---------------------------------------------------------------------
41943d2073cStracker-user
42043d2073cStracker-user    /**
42143d2073cStracker-user     * Remove every resolved annotation from a page.
42243d2073cStracker-user     *
42343d2073cStracker-user     * @param string $id page id
42443d2073cStracker-user     * @return int|false number removed, or false on write failure
42543d2073cStracker-user     */
42643d2073cStracker-user    public function clearResolved($id)
42743d2073cStracker-user    {
42843d2073cStracker-user        if (empty($this->getAnnotations($id))) {
42943d2073cStracker-user            return 0;
43043d2073cStracker-user        }
43143d2073cStracker-user        return $this->mutate($id, function (array &$annotations) {
43243d2073cStracker-user            $before = count($annotations);
43343d2073cStracker-user            $annotations = array_values(array_filter($annotations, function ($a) {
43443d2073cStracker-user                return ($a['status'] ?? 'open') !== 'resolved';
43543d2073cStracker-user            }));
43643d2073cStracker-user            return $before - count($annotations);
43743d2073cStracker-user        });
43843d2073cStracker-user    }
43943d2073cStracker-user
44043d2073cStracker-user    /**
44143d2073cStracker-user     * Remove every orphaned annotation from a page — those whose quoted text
44243d2073cStracker-user     * no longer appears in the rendered page. The page is re-checked here, so
44343d2073cStracker-user     * this is authoritative regardless of what a client believed.
44443d2073cStracker-user     *
44543d2073cStracker-user     * @param string $id page id
44643d2073cStracker-user     * @return int|false number removed, or false on write failure
44743d2073cStracker-user     */
44843d2073cStracker-user    public function clearOrphaned($id)
44943d2073cStracker-user    {
45043d2073cStracker-user        $orphanIds = [];
45143d2073cStracker-user        foreach ($this->findOrphaned($id) as $a) {
45243d2073cStracker-user            $orphanIds[] = $a['id'];
45343d2073cStracker-user        }
45443d2073cStracker-user        if (empty($orphanIds)) {
45543d2073cStracker-user            return 0;
45643d2073cStracker-user        }
45743d2073cStracker-user        return $this->mutate($id, function (array &$annotations) use ($orphanIds) {
45843d2073cStracker-user            $before = count($annotations);
45943d2073cStracker-user            $annotations = array_values(array_filter($annotations, function ($a) use ($orphanIds) {
46043d2073cStracker-user                return !in_array($a['id'] ?? '', $orphanIds, true);
46143d2073cStracker-user            }));
46243d2073cStracker-user            return $before - count($annotations);
46343d2073cStracker-user        });
46443d2073cStracker-user    }
46543d2073cStracker-user
46643d2073cStracker-user    // ---------------------------------------------------------------------
46743d2073cStracker-user    //  Orphan detection
46843d2073cStracker-user    // ---------------------------------------------------------------------
46943d2073cStracker-user
47043d2073cStracker-user    /**
47143d2073cStracker-user     * Render a page to normalised plain text, for quote searching.
47243d2073cStracker-user     *
47343d2073cStracker-user     * Block-level closing tags become spaces so adjacent blocks do not fuse
47443d2073cStracker-user     * into one run of text; then tags are stripped, entities decoded, and
47543d2073cStracker-user     * whitespace collapsed — the same normalisation applied to stored quotes.
47643d2073cStracker-user     *
47743d2073cStracker-user     * @param string $id page id
47843d2073cStracker-user     * @return string
47943d2073cStracker-user     */
48043d2073cStracker-user    public function getPageText($id)
48143d2073cStracker-user    {
48243d2073cStracker-user        if (!page_exists($id)) {
48343d2073cStracker-user            return '';
48443d2073cStracker-user        }
48543d2073cStracker-user        $xhtml = p_wiki_xhtml($id, '', false);
48643d2073cStracker-user        if (!is_string($xhtml) || $xhtml === '') {
48743d2073cStracker-user            return '';
48843d2073cStracker-user        }
48943d2073cStracker-user        $xhtml = preg_replace('#</(p|div|li|h[1-6]|td|th|tr|blockquote|pre|dt|dd)>#i', ' ', $xhtml);
49043d2073cStracker-user        $xhtml = preg_replace('#<br\s*/?>#i', ' ', $xhtml);
49143d2073cStracker-user        $text  = strip_tags($xhtml);
49243d2073cStracker-user        $text  = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
49343d2073cStracker-user        return $this->normalizeWhitespace($text);
49443d2073cStracker-user    }
49543d2073cStracker-user
49643d2073cStracker-user    /**
49743d2073cStracker-user     * The annotations on a page whose quoted text is no longer present.
49843d2073cStracker-user     *
49943d2073cStracker-user     * @param string $id page id
50043d2073cStracker-user     * @return array list of orphaned annotation arrays
50143d2073cStracker-user     */
50243d2073cStracker-user    public function findOrphaned($id)
50343d2073cStracker-user    {
50443d2073cStracker-user        $annotations = $this->getAnnotations($id);
50543d2073cStracker-user        if (empty($annotations)) {
50643d2073cStracker-user            return [];
50743d2073cStracker-user        }
50843d2073cStracker-user        $pageText = $this->getPageText($id);
50943d2073cStracker-user
51043d2073cStracker-user        $orphaned = [];
51143d2073cStracker-user        foreach ($annotations as $a) {
51243d2073cStracker-user            $exact = $this->normalizeWhitespace($a['anchor']['exact'] ?? '');
51343d2073cStracker-user            if ($exact === '' || mb_strpos($pageText, $exact) === false) {
51443d2073cStracker-user                $orphaned[] = $a;
51543d2073cStracker-user            }
51643d2073cStracker-user        }
51743d2073cStracker-user        return $orphaned;
51843d2073cStracker-user    }
51943d2073cStracker-user
52043d2073cStracker-user    // ---------------------------------------------------------------------
52143d2073cStracker-user    //  Permission rules (single source of truth)
52243d2073cStracker-user    // ---------------------------------------------------------------------
52343d2073cStracker-user
52443d2073cStracker-user    /**
52543d2073cStracker-user     * May this user create an annotation, reply, or change a resolve status?
52643d2073cStracker-user     *
52743d2073cStracker-user     * Requires only read access to the page — annotations are out-of-band, so
52843d2073cStracker-user     * a user whose page edit access is blocked may still annotate.
52943d2073cStracker-user     *
53043d2073cStracker-user     * @param string $user     current username ('' for anonymous)
53143d2073cStracker-user     * @param int    $aclLevel the user's ACL level on the page
53243d2073cStracker-user     * @return bool
53343d2073cStracker-user     */
53443d2073cStracker-user    public function canAnnotate($user, $aclLevel)
53543d2073cStracker-user    {
53643d2073cStracker-user        return $user !== '' && $user !== null && $aclLevel >= AUTH_READ;
53743d2073cStracker-user    }
53843d2073cStracker-user
53943d2073cStracker-user    /**
54043d2073cStracker-user     * May this user edit or delete the given annotation? Author or admin.
54143d2073cStracker-user     *
54243d2073cStracker-user     * @param array  $annotation
54343d2073cStracker-user     * @param string $user
54443d2073cStracker-user     * @param bool   $isAdmin
54543d2073cStracker-user     * @return bool
54643d2073cStracker-user     */
54743d2073cStracker-user    public function canEditAnnotation(array $annotation, $user, $isAdmin)
54843d2073cStracker-user    {
54943d2073cStracker-user        if ($user === '' || $user === null) {
55043d2073cStracker-user            return false;
55143d2073cStracker-user        }
55243d2073cStracker-user        return $isAdmin || (($annotation['author'] ?? '') === $user);
55343d2073cStracker-user    }
55443d2073cStracker-user
55543d2073cStracker-user    /**
55643d2073cStracker-user     * May this user edit or delete the given reply? Author or admin.
55743d2073cStracker-user     *
55843d2073cStracker-user     * @param array  $reply
55943d2073cStracker-user     * @param string $user
56043d2073cStracker-user     * @param bool   $isAdmin
56143d2073cStracker-user     * @return bool
56243d2073cStracker-user     */
56343d2073cStracker-user    public function canEditReply(array $reply, $user, $isAdmin)
56443d2073cStracker-user    {
56543d2073cStracker-user        if ($user === '' || $user === null) {
56643d2073cStracker-user            return false;
56743d2073cStracker-user        }
56843d2073cStracker-user        return $isAdmin || (($reply['author'] ?? '') === $user);
56943d2073cStracker-user    }
57043d2073cStracker-user
57143d2073cStracker-user    /**
57243d2073cStracker-user     * May this user run the per-page "clear resolved/orphaned" operations?
57343d2073cStracker-user     * Admins only.
57443d2073cStracker-user     *
57543d2073cStracker-user     * @param bool $isAdmin
57643d2073cStracker-user     * @return bool
57743d2073cStracker-user     */
57843d2073cStracker-user    public function canClear($isAdmin)
57943d2073cStracker-user    {
58043d2073cStracker-user        return (bool) $isAdmin;
58143d2073cStracker-user    }
58243d2073cStracker-user
58343d2073cStracker-user    // ---------------------------------------------------------------------
58443d2073cStracker-user    //  Input cleaning
58543d2073cStracker-user    // ---------------------------------------------------------------------
58643d2073cStracker-user
58743d2073cStracker-user    /**
58843d2073cStracker-user     * Validate and normalise a raw anchor.
58943d2073cStracker-user     *
59043d2073cStracker-user     * @param mixed $anchor
59143d2073cStracker-user     * @return array|null  the cleaned anchor, or null if unusable
59243d2073cStracker-user     */
59343d2073cStracker-user    protected function cleanAnchor($anchor)
59443d2073cStracker-user    {
59543d2073cStracker-user        if (!is_array($anchor)) {
59643d2073cStracker-user            return null;
59743d2073cStracker-user        }
59843d2073cStracker-user
59943d2073cStracker-user        $exact = (isset($anchor['exact']) && is_string($anchor['exact']))
60043d2073cStracker-user            ? $this->normalizeWhitespace($anchor['exact'])
60143d2073cStracker-user            : '';
60243d2073cStracker-user        if ($exact === '') {
60343d2073cStracker-user            return null; // an anchor without quoted text is unusable
60443d2073cStracker-user        }
60543d2073cStracker-user        if (mb_strlen($exact) > self::MAX_QUOTE) {
60643d2073cStracker-user            $exact = mb_substr($exact, 0, self::MAX_QUOTE);
60743d2073cStracker-user        }
60843d2073cStracker-user
60943d2073cStracker-user        $prefix = (isset($anchor['prefix']) && is_string($anchor['prefix']))
61043d2073cStracker-user            ? $this->normalizeWhitespace($anchor['prefix'])
61143d2073cStracker-user            : '';
61243d2073cStracker-user        $suffix = (isset($anchor['suffix']) && is_string($anchor['suffix']))
61343d2073cStracker-user            ? $this->normalizeWhitespace($anchor['suffix'])
61443d2073cStracker-user            : '';
61543d2073cStracker-user        if (mb_strlen($prefix) > self::MAX_CONTEXT) {
61643d2073cStracker-user            $prefix = mb_substr($prefix, -self::MAX_CONTEXT);
61743d2073cStracker-user        }
61843d2073cStracker-user        if (mb_strlen($suffix) > self::MAX_CONTEXT) {
61943d2073cStracker-user            $suffix = mb_substr($suffix, 0, self::MAX_CONTEXT);
62043d2073cStracker-user        }
62143d2073cStracker-user
62243d2073cStracker-user        $start = isset($anchor['start']) ? max(0, (int) $anchor['start']) : 0;
62343d2073cStracker-user
62443d2073cStracker-user        return [
62543d2073cStracker-user            'exact'  => $exact,
62643d2073cStracker-user            'prefix' => $prefix,
62743d2073cStracker-user            'suffix' => $suffix,
62843d2073cStracker-user            'start'  => $start,
62943d2073cStracker-user        ];
63043d2073cStracker-user    }
63143d2073cStracker-user
63243d2073cStracker-user    /**
63343d2073cStracker-user     * Clean an annotation/reply body: a plain-text string, trimmed, with
63443d2073cStracker-user     * normalised line endings and a length cap. Newlines are kept; the text
63543d2073cStracker-user     * is escaped by the consumer at render time.
63643d2073cStracker-user     *
63743d2073cStracker-user     * @param mixed $body
63843d2073cStracker-user     * @return string
63943d2073cStracker-user     */
64043d2073cStracker-user    protected function cleanBody($body)
64143d2073cStracker-user    {
64243d2073cStracker-user        if (!is_string($body)) {
64343d2073cStracker-user            return '';
64443d2073cStracker-user        }
64543d2073cStracker-user        $body = str_replace("\r\n", "\n", $body);
64643d2073cStracker-user        $body = str_replace("\r", "\n", $body);
64743d2073cStracker-user        $body = trim($body);
64843d2073cStracker-user        if (mb_strlen($body) > self::MAX_BODY) {
64943d2073cStracker-user            $body = mb_substr($body, 0, self::MAX_BODY);
65043d2073cStracker-user        }
65143d2073cStracker-user        return $body;
65243d2073cStracker-user    }
65343d2073cStracker-user
65443d2073cStracker-user    /**
65543d2073cStracker-user     * Collapse every run of whitespace to a single space and trim.
65643d2073cStracker-user     *
65743d2073cStracker-user     * @param mixed $text
65843d2073cStracker-user     * @return string
65943d2073cStracker-user     */
66043d2073cStracker-user    protected function normalizeWhitespace($text)
66143d2073cStracker-user    {
66243d2073cStracker-user        return trim(preg_replace('/\s+/u', ' ', (string) $text));
66343d2073cStracker-user    }
66443d2073cStracker-user
66543d2073cStracker-user    /**
66643d2073cStracker-user     * A fresh identifier for an annotation or reply.
66743d2073cStracker-user     *
66843d2073cStracker-user     * @return string 16 hex characters
66943d2073cStracker-user     */
67043d2073cStracker-user    protected function newId()
67143d2073cStracker-user    {
67243d2073cStracker-user        return bin2hex(random_bytes(8));
67343d2073cStracker-user    }
67443d2073cStracker-user}
675