xref: /plugin/annotations/helper.php (revision 72d60f2d94b24cb66fabf596a2ec440f459ba88f)
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
4486c7806dStracker-user    /** length of the prefix/suffix context slices, in characters (config fallback) */
4586c7806dStracker-user    const DEFAULT_CONTEXT = 64;
4643d2073cStracker-user
4786c7806dStracker-user    /** longest annotation/reply body, in characters (config fallback) */
4886c7806dStracker-user    const DEFAULT_BODY = 10000;
4986c7806dStracker-user
5086c7806dStracker-user    /**
5186c7806dStracker-user     * Configured length of each prefix/suffix context slice, in characters.
5286c7806dStracker-user     *
5386c7806dStracker-user     * @return int
5486c7806dStracker-user     */
5586c7806dStracker-user    protected function contextLength()
5686c7806dStracker-user    {
5786c7806dStracker-user        $v = (int) $this->getConf('context_length');
5886c7806dStracker-user        return $v >= 0 ? $v : self::DEFAULT_CONTEXT;
5986c7806dStracker-user    }
6086c7806dStracker-user
6186c7806dStracker-user    /**
6286c7806dStracker-user     * Configured maximum annotation/reply body length, in characters.
6386c7806dStracker-user     *
6486c7806dStracker-user     * @return int
6586c7806dStracker-user     */
6686c7806dStracker-user    protected function bodyCap()
6786c7806dStracker-user    {
6886c7806dStracker-user        $v = (int) $this->getConf('body_cap');
6986c7806dStracker-user        return $v > 0 ? $v : self::DEFAULT_BODY;
7086c7806dStracker-user    }
7143d2073cStracker-user
7243d2073cStracker-user    // ---------------------------------------------------------------------
7343d2073cStracker-user    //  Storage
7443d2073cStracker-user    // ---------------------------------------------------------------------
7543d2073cStracker-user
7643d2073cStracker-user    /**
7743d2073cStracker-user     * Path of a page's annotation file.
7843d2073cStracker-user     *
7943d2073cStracker-user     * @param string $id page id
8043d2073cStracker-user     * @return string
8143d2073cStracker-user     */
8243d2073cStracker-user    protected function getFile($id)
8343d2073cStracker-user    {
8443d2073cStracker-user        return metaFN($id, '.annotations');
8543d2073cStracker-user    }
8643d2073cStracker-user
8743d2073cStracker-user    /**
8843d2073cStracker-user     * All annotations stored for a page.
8943d2073cStracker-user     *
9043d2073cStracker-user     * @param string $id page id
9143d2073cStracker-user     * @return array list of annotation arrays (empty if none)
9243d2073cStracker-user     */
9343d2073cStracker-user    public function getAnnotations($id)
9443d2073cStracker-user    {
9543d2073cStracker-user        $file = $this->getFile($id);
9643d2073cStracker-user        if (!file_exists($file)) {
9743d2073cStracker-user            return [];
9843d2073cStracker-user        }
9943d2073cStracker-user        $raw = io_readFile($file, false);
10043d2073cStracker-user        if ($raw === '') {
10143d2073cStracker-user            return [];
10243d2073cStracker-user        }
10343d2073cStracker-user        $data = json_decode($raw, true);
10443d2073cStracker-user        if (!is_array($data) || !isset($data['annotations']) || !is_array($data['annotations'])) {
10543d2073cStracker-user            return [];
10643d2073cStracker-user        }
10743d2073cStracker-user        return $data['annotations'];
10843d2073cStracker-user    }
10943d2073cStracker-user
11043d2073cStracker-user    /**
11143d2073cStracker-user     * A single annotation by id.
11243d2073cStracker-user     *
11343d2073cStracker-user     * @param string $id    page id
11443d2073cStracker-user     * @param string $annId annotation id
11543d2073cStracker-user     * @return array|null
11643d2073cStracker-user     */
11743d2073cStracker-user    public function getAnnotation($id, $annId)
11843d2073cStracker-user    {
11943d2073cStracker-user        foreach ($this->getAnnotations($id) as $a) {
12043d2073cStracker-user            if (($a['id'] ?? '') === $annId) {
12143d2073cStracker-user                return $a;
12243d2073cStracker-user            }
12343d2073cStracker-user        }
12443d2073cStracker-user        return null;
12543d2073cStracker-user    }
12643d2073cStracker-user
12743d2073cStracker-user    /**
12843d2073cStracker-user     * Counts for the on-page indicator. The orphan count is deliberately not
12943d2073cStracker-user     * here — it depends on the rendered page and is computed client-side.
13043d2073cStracker-user     *
13143d2073cStracker-user     * @param string $id page id
13243d2073cStracker-user     * @return array ['total'=>int, 'open'=>int, 'resolved'=>int]
13343d2073cStracker-user     */
13443d2073cStracker-user    public function getStats($id)
13543d2073cStracker-user    {
136108f92bdStracker-user        return $this->statsFor($this->getAnnotations($id));
137108f92bdStracker-user    }
138108f92bdStracker-user
139108f92bdStracker-user    /**
140108f92bdStracker-user     * Counts for the on-page indicator, computed from an already-loaded list.
141108f92bdStracker-user     * Split out from getStats() so callers that already hold the annotation
142108f92bdStracker-user     * array (e.g. the page-load JSINFO injector, which embeds the same list)
143108f92bdStracker-user     * don't re-read the file.
144108f92bdStracker-user     *
145108f92bdStracker-user     * @param array $annotations annotation list
146108f92bdStracker-user     * @return array ['total'=>int, 'open'=>int, 'resolved'=>int]
147108f92bdStracker-user     */
148108f92bdStracker-user    public function statsFor(array $annotations)
149108f92bdStracker-user    {
15043d2073cStracker-user        $open = 0;
15143d2073cStracker-user        $resolved = 0;
152108f92bdStracker-user        foreach ($annotations as $a) {
15343d2073cStracker-user            if (($a['status'] ?? 'open') === 'resolved') {
15443d2073cStracker-user                $resolved++;
15543d2073cStracker-user            } else {
15643d2073cStracker-user                $open++;
15743d2073cStracker-user            }
15843d2073cStracker-user        }
15943d2073cStracker-user        return ['total' => $open + $resolved, 'open' => $open, 'resolved' => $resolved];
16043d2073cStracker-user    }
16143d2073cStracker-user
16243d2073cStracker-user    /**
16343d2073cStracker-user     * Write a page's annotation list to disk.
16443d2073cStracker-user     *
16543d2073cStracker-user     * @param string $id   page id
16643d2073cStracker-user     * @param array  $list annotations
16743d2073cStracker-user     * @return bool
16843d2073cStracker-user     */
16943d2073cStracker-user    protected function writeFile($id, array $list)
17043d2073cStracker-user    {
17143d2073cStracker-user        $payload = [
17243d2073cStracker-user            'version'     => self::SCHEMA_VERSION,
17343d2073cStracker-user            'annotations' => array_values($list),
17443d2073cStracker-user        ];
175da56206cStracker-user        return (bool) io_saveFile(
176da56206cStracker-user            $this->getFile($id),
177da56206cStracker-user            json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
178da56206cStracker-user        );
17943d2073cStracker-user    }
18043d2073cStracker-user
18143d2073cStracker-user    /**
18243d2073cStracker-user     * Run a modification against a page's annotations under a write lock.
18343d2073cStracker-user     *
18443d2073cStracker-user     * The modifier receives the annotation list by reference and returns an
18543d2073cStracker-user     * outcome value. Returning the boolean false aborts the write (used for
18643d2073cStracker-user     * "target not found"); any other value is returned to the caller after a
18743d2073cStracker-user     * successful save.
18843d2073cStracker-user     *
18943d2073cStracker-user     * @param string   $id       page id
19043d2073cStracker-user     * @param callable $modifier function(array &$annotations): mixed
19143d2073cStracker-user     * @return mixed  the modifier's outcome on success, or false on failure
19243d2073cStracker-user     */
19343d2073cStracker-user    protected function mutate($id, callable $modifier)
19443d2073cStracker-user    {
19543d2073cStracker-user        $file = $this->getFile($id);
19649d7ec0aStracker-user        // Lock on a sentinel key, NOT $file itself: writeFile() below calls
19749d7ec0aStracker-user        // io_saveFile($file), which takes its own io_lock($file) internally.
19849d7ec0aStracker-user        // Locking $file here would collide with that inner lock — io_lock
19949d7ec0aStracker-user        // busy-waits ~3s for the stale-lock timeout on every write and then
20049d7ec0aStracker-user        // proceeds, defeating mutual exclusion (see DokuWiki TaskRunner). A
20149d7ec0aStracker-user        // distinct key serialises the read-modify-write across requests while
20249d7ec0aStracker-user        // leaving io_saveFile's lock uncontended.
20349d7ec0aStracker-user        $lock = $file . '.lock';
20449d7ec0aStracker-user        io_lock($lock);
20543d2073cStracker-user
20643d2073cStracker-user        $annotations = $this->getAnnotations($id);
20743d2073cStracker-user        $outcome = $modifier($annotations);
20843d2073cStracker-user
20943d2073cStracker-user        if ($outcome === false) {
21049d7ec0aStracker-user            io_unlock($lock);
21143d2073cStracker-user            return false;
21243d2073cStracker-user        }
21343d2073cStracker-user
21443d2073cStracker-user        $ok = $this->writeFile($id, $annotations);
21549d7ec0aStracker-user        io_unlock($lock);
21643d2073cStracker-user        return $ok ? $outcome : false;
21743d2073cStracker-user    }
21843d2073cStracker-user
21943d2073cStracker-user    // ---------------------------------------------------------------------
22043d2073cStracker-user    //  Annotation CRUD
22143d2073cStracker-user    // ---------------------------------------------------------------------
22243d2073cStracker-user
22343d2073cStracker-user    /**
22443d2073cStracker-user     * Create an annotation.
22543d2073cStracker-user     *
22643d2073cStracker-user     * @param string $id     page id
22743d2073cStracker-user     * @param array  $anchor raw anchor {exact, prefix, suffix, start}
22843d2073cStracker-user     * @param string $author username
22943d2073cStracker-user     * @param string $body   annotation text
23043d2073cStracker-user     * @return array|false  the created annotation, or false on invalid input
23143d2073cStracker-user     */
23243d2073cStracker-user    public function createAnnotation($id, $anchor, $author, $body)
23343d2073cStracker-user    {
23443d2073cStracker-user        if ($id === '' || $author === '' || $author === null) {
23543d2073cStracker-user            return false;
23643d2073cStracker-user        }
23743d2073cStracker-user        $body = $this->cleanBody($body);
23843d2073cStracker-user        if ($body === '') {
23943d2073cStracker-user            return false;
24043d2073cStracker-user        }
24143d2073cStracker-user        $anchor = $this->cleanAnchor($anchor);
24243d2073cStracker-user        if ($anchor === null) {
24343d2073cStracker-user            return false;
24443d2073cStracker-user        }
24543d2073cStracker-user
24643d2073cStracker-user        $now = time();
24743d2073cStracker-user        $new = [
24843d2073cStracker-user            'id'          => $this->newId(),
24943d2073cStracker-user            'anchor'      => $anchor,
25043d2073cStracker-user            'author'      => $author,
25143d2073cStracker-user            'created'     => $now,
25243d2073cStracker-user            'modified'    => $now,
25343d2073cStracker-user            'body'        => $body,
25443d2073cStracker-user            'status'      => 'open',
25543d2073cStracker-user            'resolved_by' => '',
25643d2073cStracker-user            'resolved_at' => 0,
25743d2073cStracker-user            'replies'     => [],
25843d2073cStracker-user        ];
25943d2073cStracker-user
26043d2073cStracker-user        return $this->mutate($id, function (array &$annotations) use ($new) {
26143d2073cStracker-user            $annotations[] = $new;
26243d2073cStracker-user            return $new;
26343d2073cStracker-user        });
26443d2073cStracker-user    }
26543d2073cStracker-user
26643d2073cStracker-user    /**
26743d2073cStracker-user     * Edit an annotation's body text.
26843d2073cStracker-user     *
26943d2073cStracker-user     * @param string $id    page id
27043d2073cStracker-user     * @param string $annId annotation id
27143d2073cStracker-user     * @param string $body  new text
27243d2073cStracker-user     * @return bool
27343d2073cStracker-user     */
27443d2073cStracker-user    public function updateAnnotationBody($id, $annId, $body)
27543d2073cStracker-user    {
27643d2073cStracker-user        $body = $this->cleanBody($body);
27743d2073cStracker-user        if ($body === '') {
27843d2073cStracker-user            return false;
27943d2073cStracker-user        }
28043d2073cStracker-user        return (bool) $this->mutate($id, function (array &$annotations) use ($annId, $body) {
28143d2073cStracker-user            foreach ($annotations as $i => $a) {
28243d2073cStracker-user                if (($a['id'] ?? '') === $annId) {
28343d2073cStracker-user                    $annotations[$i]['body']     = $body;
28443d2073cStracker-user                    $annotations[$i]['modified'] = time();
28543d2073cStracker-user                    return true;
28643d2073cStracker-user                }
28743d2073cStracker-user            }
28843d2073cStracker-user            return false;
28943d2073cStracker-user        });
29043d2073cStracker-user    }
29143d2073cStracker-user
29243d2073cStracker-user    /**
29343d2073cStracker-user     * Delete an annotation and all its replies.
29443d2073cStracker-user     *
29543d2073cStracker-user     * @param string $id    page id
29643d2073cStracker-user     * @param string $annId annotation id
29743d2073cStracker-user     * @return bool
29843d2073cStracker-user     */
29943d2073cStracker-user    public function deleteAnnotation($id, $annId)
30043d2073cStracker-user    {
30143d2073cStracker-user        return (bool) $this->mutate($id, function (array &$annotations) use ($annId) {
30243d2073cStracker-user            foreach ($annotations as $i => $a) {
30343d2073cStracker-user                if (($a['id'] ?? '') === $annId) {
30443d2073cStracker-user                    array_splice($annotations, $i, 1);
30543d2073cStracker-user                    return true;
30643d2073cStracker-user                }
30743d2073cStracker-user            }
30843d2073cStracker-user            return false;
30943d2073cStracker-user        });
31043d2073cStracker-user    }
31143d2073cStracker-user
31243d2073cStracker-user    /**
31343d2073cStracker-user     * Mark an annotation open or resolved.
31443d2073cStracker-user     *
31543d2073cStracker-user     * @param string $id     page id
31643d2073cStracker-user     * @param string $annId  annotation id
31743d2073cStracker-user     * @param string $status 'open' or 'resolved'
31843d2073cStracker-user     * @param string $actor  username making the change (recorded when resolving)
31943d2073cStracker-user     * @return bool
32043d2073cStracker-user     */
32143d2073cStracker-user    public function setStatus($id, $annId, $status, $actor)
32243d2073cStracker-user    {
32343d2073cStracker-user        if (!in_array($status, ['open', 'resolved'], true)) {
32443d2073cStracker-user            return false;
32543d2073cStracker-user        }
32643d2073cStracker-user        return (bool) $this->mutate($id, function (array &$annotations) use ($annId, $status, $actor) {
32743d2073cStracker-user            foreach ($annotations as $i => $a) {
32843d2073cStracker-user                if (($a['id'] ?? '') === $annId) {
32943d2073cStracker-user                    $annotations[$i]['status'] = $status;
33043d2073cStracker-user                    if ($status === 'resolved') {
33143d2073cStracker-user                        $annotations[$i]['resolved_by'] = $actor;
33243d2073cStracker-user                        $annotations[$i]['resolved_at'] = time();
33343d2073cStracker-user                    } else {
33443d2073cStracker-user                        $annotations[$i]['resolved_by'] = '';
33543d2073cStracker-user                        $annotations[$i]['resolved_at'] = 0;
33643d2073cStracker-user                    }
33743d2073cStracker-user                    return true;
33843d2073cStracker-user                }
33943d2073cStracker-user            }
34043d2073cStracker-user            return false;
34143d2073cStracker-user        });
34243d2073cStracker-user    }
34343d2073cStracker-user
34443d2073cStracker-user    // ---------------------------------------------------------------------
34543d2073cStracker-user    //  Reply CRUD
34643d2073cStracker-user    // ---------------------------------------------------------------------
34743d2073cStracker-user
34843d2073cStracker-user    /**
34943d2073cStracker-user     * Add a reply to an annotation.
35043d2073cStracker-user     *
35143d2073cStracker-user     * @param string $id       page id
35243d2073cStracker-user     * @param string $annId    annotation id
35343d2073cStracker-user     * @param string $author   username
35443d2073cStracker-user     * @param string $body     reply text
355ee9dbf15Stracker-user     * @param string $parentId id of the reply being replied to, or '' for root-level
35643d2073cStracker-user     * @return array|false  the created reply, or false on invalid input
35743d2073cStracker-user     */
358ee9dbf15Stracker-user    public function addReply($id, $annId, $author, $body, $parentId = '')
35943d2073cStracker-user    {
36043d2073cStracker-user        if ($author === '' || $author === null) {
36143d2073cStracker-user            return false;
36243d2073cStracker-user        }
36343d2073cStracker-user        $body = $this->cleanBody($body);
36443d2073cStracker-user        if ($body === '') {
36543d2073cStracker-user            return false;
36643d2073cStracker-user        }
36743d2073cStracker-user        $now = time();
36843d2073cStracker-user        $reply = [
36943d2073cStracker-user            'id'       => $this->newId(),
370ee9dbf15Stracker-user            'parentId' => preg_replace('/[^a-f0-9]/', '', (string) $parentId),
37143d2073cStracker-user            'author'   => $author,
37243d2073cStracker-user            'created'  => $now,
37343d2073cStracker-user            'modified' => $now,
37443d2073cStracker-user            'body'     => $body,
37543d2073cStracker-user        ];
37643d2073cStracker-user
37743d2073cStracker-user        return $this->mutate($id, function (array &$annotations) use ($annId, $reply) {
37843d2073cStracker-user            foreach ($annotations as $i => $a) {
37943d2073cStracker-user                if (($a['id'] ?? '') === $annId) {
38043d2073cStracker-user                    $annotations[$i]['replies'][] = $reply;
38143d2073cStracker-user                    return $reply;
38243d2073cStracker-user                }
38343d2073cStracker-user            }
38443d2073cStracker-user            return false;
38543d2073cStracker-user        });
38643d2073cStracker-user    }
38743d2073cStracker-user
38843d2073cStracker-user    /**
38943d2073cStracker-user     * Edit a reply's body text.
39043d2073cStracker-user     *
39143d2073cStracker-user     * @param string $id      page id
39243d2073cStracker-user     * @param string $annId   annotation id
39343d2073cStracker-user     * @param string $replyId reply id
39443d2073cStracker-user     * @param string $body    new text
39543d2073cStracker-user     * @return bool
39643d2073cStracker-user     */
39743d2073cStracker-user    public function updateReply($id, $annId, $replyId, $body)
39843d2073cStracker-user    {
39943d2073cStracker-user        $body = $this->cleanBody($body);
40043d2073cStracker-user        if ($body === '') {
40143d2073cStracker-user            return false;
40243d2073cStracker-user        }
40343d2073cStracker-user        return (bool) $this->mutate($id, function (array &$annotations) use ($annId, $replyId, $body) {
40443d2073cStracker-user            foreach ($annotations as $i => $a) {
40543d2073cStracker-user                if (($a['id'] ?? '') !== $annId) {
40643d2073cStracker-user                    continue;
40743d2073cStracker-user                }
40843d2073cStracker-user                foreach (($a['replies'] ?? []) as $j => $r) {
40943d2073cStracker-user                    if (($r['id'] ?? '') === $replyId) {
41043d2073cStracker-user                        $annotations[$i]['replies'][$j]['body']     = $body;
41143d2073cStracker-user                        $annotations[$i]['replies'][$j]['modified'] = time();
41243d2073cStracker-user                        return true;
41343d2073cStracker-user                    }
41443d2073cStracker-user                }
41543d2073cStracker-user            }
41643d2073cStracker-user            return false;
41743d2073cStracker-user        });
41843d2073cStracker-user    }
41943d2073cStracker-user
42043d2073cStracker-user    /**
42143d2073cStracker-user     * Delete a reply.
42243d2073cStracker-user     *
42343d2073cStracker-user     * @param string $id      page id
42443d2073cStracker-user     * @param string $annId   annotation id
42543d2073cStracker-user     * @param string $replyId reply id
42643d2073cStracker-user     * @return bool
42743d2073cStracker-user     */
42843d2073cStracker-user    public function deleteReply($id, $annId, $replyId)
42943d2073cStracker-user    {
43043d2073cStracker-user        return (bool) $this->mutate($id, function (array &$annotations) use ($annId, $replyId) {
43143d2073cStracker-user            foreach ($annotations as $i => $a) {
43243d2073cStracker-user                if (($a['id'] ?? '') !== $annId) {
43343d2073cStracker-user                    continue;
43443d2073cStracker-user                }
43543d2073cStracker-user                foreach (($a['replies'] ?? []) as $j => $r) {
43643d2073cStracker-user                    if (($r['id'] ?? '') === $replyId) {
43743d2073cStracker-user                        array_splice($annotations[$i]['replies'], $j, 1);
43843d2073cStracker-user                        return true;
43943d2073cStracker-user                    }
44043d2073cStracker-user                }
44143d2073cStracker-user            }
44243d2073cStracker-user            return false;
44343d2073cStracker-user        });
44443d2073cStracker-user    }
44543d2073cStracker-user
44643d2073cStracker-user    // ---------------------------------------------------------------------
44743d2073cStracker-user    //  Bulk maintenance (admin, per page)
44843d2073cStracker-user    // ---------------------------------------------------------------------
44943d2073cStracker-user
45043d2073cStracker-user    /**
45143d2073cStracker-user     * Remove every resolved annotation from a page.
45243d2073cStracker-user     *
45343d2073cStracker-user     * @param string $id page id
45443d2073cStracker-user     * @return int|false number removed, or false on write failure
45543d2073cStracker-user     */
45643d2073cStracker-user    public function clearResolved($id)
45743d2073cStracker-user    {
45843d2073cStracker-user        if (empty($this->getAnnotations($id))) {
45943d2073cStracker-user            return 0;
46043d2073cStracker-user        }
46143d2073cStracker-user        return $this->mutate($id, function (array &$annotations) {
46243d2073cStracker-user            $before = count($annotations);
46343d2073cStracker-user            $annotations = array_values(array_filter($annotations, function ($a) {
46443d2073cStracker-user                return ($a['status'] ?? 'open') !== 'resolved';
46543d2073cStracker-user            }));
46643d2073cStracker-user            return $before - count($annotations);
46743d2073cStracker-user        });
46843d2073cStracker-user    }
46943d2073cStracker-user
47043d2073cStracker-user    /**
471*72d60f2dStracker-user     * Remove every resolved annotation from every annotated page.
472*72d60f2dStracker-user     *
473*72d60f2dStracker-user     * The wiki-wide companion to clearResolved(), mirroring clearOrphanedAll():
474*72d60f2dStracker-user     * iterates the pages found by getAnnotatedPages() and applies the per-page
475*72d60f2dStracker-user     * clearResolved() to each. A page whose write fails is skipped rather than
476*72d60f2dStracker-user     * aborting the whole sweep.
477*72d60f2dStracker-user     *
478*72d60f2dStracker-user     * @return int total number of annotations removed across all pages
479*72d60f2dStracker-user     */
480*72d60f2dStracker-user    public function clearResolvedAll()
481*72d60f2dStracker-user    {
482*72d60f2dStracker-user        $removed = 0;
483*72d60f2dStracker-user        foreach ($this->getAnnotatedPages() as $id) {
484*72d60f2dStracker-user            $n = $this->clearResolved($id);
485*72d60f2dStracker-user            if ($n !== false) {
486*72d60f2dStracker-user                $removed += (int) $n;
487*72d60f2dStracker-user            }
488*72d60f2dStracker-user        }
489*72d60f2dStracker-user        return $removed;
490*72d60f2dStracker-user    }
491*72d60f2dStracker-user
492*72d60f2dStracker-user    /**
49343d2073cStracker-user     * Remove every orphaned annotation from a page — those whose quoted text
49443d2073cStracker-user     * no longer appears in the rendered page. The page is re-checked here, so
49543d2073cStracker-user     * this is authoritative regardless of what a client believed.
49643d2073cStracker-user     *
49743d2073cStracker-user     * @param string $id page id
49843d2073cStracker-user     * @return int|false number removed, or false on write failure
49943d2073cStracker-user     */
50043d2073cStracker-user    public function clearOrphaned($id)
50143d2073cStracker-user    {
50243d2073cStracker-user        $orphanIds = [];
50343d2073cStracker-user        foreach ($this->findOrphaned($id) as $a) {
50443d2073cStracker-user            $orphanIds[] = $a['id'];
50543d2073cStracker-user        }
50643d2073cStracker-user        if (empty($orphanIds)) {
50743d2073cStracker-user            return 0;
50843d2073cStracker-user        }
50943d2073cStracker-user        return $this->mutate($id, function (array &$annotations) use ($orphanIds) {
51043d2073cStracker-user            $before = count($annotations);
51143d2073cStracker-user            $annotations = array_values(array_filter($annotations, function ($a) use ($orphanIds) {
51243d2073cStracker-user                return !in_array($a['id'] ?? '', $orphanIds, true);
51343d2073cStracker-user            }));
51443d2073cStracker-user            return $before - count($annotations);
51543d2073cStracker-user        });
51643d2073cStracker-user    }
51743d2073cStracker-user
5189fd890c3Stracker-user    /**
5199fd890c3Stracker-user     * Remove every orphaned annotation from every annotated page.
5209fd890c3Stracker-user     *
5219fd890c3Stracker-user     * Iterates the pages found by getAnnotatedPages() and applies the per-page
5229fd890c3Stracker-user     * clearOrphaned() to each, so the same authoritative re-check runs for
5239fd890c3Stracker-user     * every page. A page whose write fails is skipped rather than aborting the
5249fd890c3Stracker-user     * whole sweep.
5259fd890c3Stracker-user     *
5269fd890c3Stracker-user     * @return int total number of annotations removed across all pages
5279fd890c3Stracker-user     */
5289fd890c3Stracker-user    public function clearOrphanedAll()
5299fd890c3Stracker-user    {
5309fd890c3Stracker-user        $removed = 0;
5319fd890c3Stracker-user        foreach ($this->getAnnotatedPages() as $id) {
5329fd890c3Stracker-user            $n = $this->clearOrphaned($id);
5339fd890c3Stracker-user            if ($n !== false) {
5349fd890c3Stracker-user                $removed += (int) $n;
5359fd890c3Stracker-user            }
5369fd890c3Stracker-user        }
5379fd890c3Stracker-user        return $removed;
5389fd890c3Stracker-user    }
5399fd890c3Stracker-user
54043d2073cStracker-user    // ---------------------------------------------------------------------
54143d2073cStracker-user    //  Orphan detection
54243d2073cStracker-user    // ---------------------------------------------------------------------
54343d2073cStracker-user
54443d2073cStracker-user    /**
54543d2073cStracker-user     * Render a page to normalised plain text, for quote searching.
54643d2073cStracker-user     *
54743d2073cStracker-user     * Block-level closing tags become spaces so adjacent blocks do not fuse
54843d2073cStracker-user     * into one run of text; then tags are stripped, entities decoded, and
54943d2073cStracker-user     * whitespace collapsed — the same normalisation applied to stored quotes.
55043d2073cStracker-user     *
55143d2073cStracker-user     * @param string $id page id
55243d2073cStracker-user     * @return string
55343d2073cStracker-user     */
55443d2073cStracker-user    public function getPageText($id)
55543d2073cStracker-user    {
55643d2073cStracker-user        if (!page_exists($id)) {
55743d2073cStracker-user            return '';
55843d2073cStracker-user        }
55943d2073cStracker-user        $xhtml = p_wiki_xhtml($id, '', false);
56043d2073cStracker-user        if (!is_string($xhtml) || $xhtml === '') {
56143d2073cStracker-user            return '';
56243d2073cStracker-user        }
56343d2073cStracker-user        $xhtml = preg_replace('#</(p|div|li|h[1-6]|td|th|tr|blockquote|pre|dt|dd)>#i', ' ', $xhtml);
56443d2073cStracker-user        $xhtml = preg_replace('#<br\s*/?>#i', ' ', $xhtml);
56543d2073cStracker-user        $text  = strip_tags($xhtml);
56643d2073cStracker-user        $text  = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
56743d2073cStracker-user        return $this->normalizeWhitespace($text);
56843d2073cStracker-user    }
56943d2073cStracker-user
57043d2073cStracker-user    /**
57143d2073cStracker-user     * The annotations on a page whose quoted text is no longer present.
57243d2073cStracker-user     *
57343d2073cStracker-user     * @param string $id page id
57443d2073cStracker-user     * @return array list of orphaned annotation arrays
57543d2073cStracker-user     */
57643d2073cStracker-user    public function findOrphaned($id)
57743d2073cStracker-user    {
57843d2073cStracker-user        $annotations = $this->getAnnotations($id);
57943d2073cStracker-user        if (empty($annotations)) {
58043d2073cStracker-user            return [];
58143d2073cStracker-user        }
58243d2073cStracker-user        $pageText = $this->getPageText($id);
58343d2073cStracker-user
58443d2073cStracker-user        $orphaned = [];
58543d2073cStracker-user        foreach ($annotations as $a) {
5869fd890c3Stracker-user            if ($this->quoteMissing($a, $pageText)) {
58743d2073cStracker-user                $orphaned[] = $a;
58843d2073cStracker-user            }
58943d2073cStracker-user        }
59043d2073cStracker-user        return $orphaned;
59143d2073cStracker-user    }
59243d2073cStracker-user
5939fd890c3Stracker-user    /**
5949fd890c3Stracker-user     * Whether an annotation's quoted text is absent from the page text.
5959fd890c3Stracker-user     *
5969fd890c3Stracker-user     * The single orphan rule, shared by findOrphaned() and pageCounts(): an
5979fd890c3Stracker-user     * annotation is orphaned when its (normalised) quoted text no longer
5989fd890c3Stracker-user     * appears in the (normalised) rendered page text.
5999fd890c3Stracker-user     *
6009fd890c3Stracker-user     * @param array  $annotation annotation array
6019fd890c3Stracker-user     * @param string $pageText   normalised plain-text page body (see getPageText)
6029fd890c3Stracker-user     * @return bool
6039fd890c3Stracker-user     */
6049fd890c3Stracker-user    protected function quoteMissing(array $annotation, $pageText)
6059fd890c3Stracker-user    {
6069fd890c3Stracker-user        $exact = $this->normalizeWhitespace($annotation['anchor']['exact'] ?? '');
6079fd890c3Stracker-user        return $exact === '' || mb_strpos($pageText, $exact) === false;
6089fd890c3Stracker-user    }
6099fd890c3Stracker-user
6109fd890c3Stracker-user    // ---------------------------------------------------------------------
6119fd890c3Stracker-user    //  Admin overview (enumeration & counts)
6129fd890c3Stracker-user    // ---------------------------------------------------------------------
6139fd890c3Stracker-user
6149fd890c3Stracker-user    /**
6159fd890c3Stracker-user     * Every page that currently has at least one stored annotation.
6169fd890c3Stracker-user     *
6179fd890c3Stracker-user     * Scans the meta directory for ".annotations" files and maps each back to a
6189fd890c3Stracker-user     * page id. Files left behind with an empty annotation list (every
6199fd890c3Stracker-user     * annotation since deleted) are skipped, so the result matches what the
6209fd890c3Stracker-user     * admin overview shows.
6219fd890c3Stracker-user     *
6229fd890c3Stracker-user     * @return string[] page ids, in the natural order search() yields
6239fd890c3Stracker-user     */
6249fd890c3Stracker-user    public function getAnnotatedPages()
6259fd890c3Stracker-user    {
6269fd890c3Stracker-user        global $conf;
6279fd890c3Stracker-user        $dir = $conf['metadir'];
6289fd890c3Stracker-user        if (!is_dir($dir)) {
6299fd890c3Stracker-user            return [];
6309fd890c3Stracker-user        }
6319fd890c3Stracker-user        $found = [];
6329fd890c3Stracker-user        search($found, $dir, [$this, 'searchAnnotations'], []);
6339fd890c3Stracker-user
6349fd890c3Stracker-user        $ids = [];
6359fd890c3Stracker-user        foreach ($found as $item) {
6369fd890c3Stracker-user            if (!empty($this->getAnnotations($item['id']))) {
6379fd890c3Stracker-user                $ids[] = $item['id'];
6389fd890c3Stracker-user            }
6399fd890c3Stracker-user        }
6409fd890c3Stracker-user        return $ids;
6419fd890c3Stracker-user    }
6429fd890c3Stracker-user
6439fd890c3Stracker-user    /**
6449fd890c3Stracker-user     * search() callback collecting ".annotations" files as page ids.
6459fd890c3Stracker-user     *
6469fd890c3Stracker-user     * Directories are always traversed; files are matched on the extension and
6479fd890c3Stracker-user     * the derived id is validated with cleanID() so anything that is not a real
6489fd890c3Stracker-user     * page id is skipped.
6499fd890c3Stracker-user     *
6509fd890c3Stracker-user     * @param array  $data &$ result accumulator (each entry ['id' => string])
6519fd890c3Stracker-user     * @param string $base search root (the meta directory)
6529fd890c3Stracker-user     * @param string $file current path relative to $base (leading slash)
6539fd890c3Stracker-user     * @param string $type 'd' for directory, 'f' for file
6549fd890c3Stracker-user     * @param int    $lvl  recursion depth
6559fd890c3Stracker-user     * @param array  $opts options passed to search() (unused)
6569fd890c3Stracker-user     * @return bool whether search() should recurse into a directory
6579fd890c3Stracker-user     */
6589fd890c3Stracker-user    public function searchAnnotations(&$data, $base, $file, $type, $lvl, $opts)
6599fd890c3Stracker-user    {
6609fd890c3Stracker-user        if ($type === 'd') {
6619fd890c3Stracker-user            return true; // recurse into namespaces
6629fd890c3Stracker-user        }
6639fd890c3Stracker-user        if (!str_ends_with($file, '.annotations')) {
6649fd890c3Stracker-user            return false;
6659fd890c3Stracker-user        }
6669fd890c3Stracker-user        $id = pathID(substr($file, 0, -strlen('.annotations')));
6679fd890c3Stracker-user        if ($id === '' || $id !== cleanID($id)) {
6689fd890c3Stracker-user            return false;
6699fd890c3Stracker-user        }
6709fd890c3Stracker-user        $data[] = ['id' => $id];
6719fd890c3Stracker-user        return false;
6729fd890c3Stracker-user    }
6739fd890c3Stracker-user
6749fd890c3Stracker-user    /**
675*72d60f2dStracker-user     * Annotation counts for one page, for the admin overview.
6769fd890c3Stracker-user     *
6779fd890c3Stracker-user     * The page is rendered once (getPageText) and every annotation tested with
6789fd890c3Stracker-user     * the shared quoteMissing() rule, so "normal" here means exactly "not
679*72d60f2dStracker-user     * orphaned" — the same definition the per-page clear-orphaned uses.
680*72d60f2dStracker-user     *
681*72d60f2dStracker-user     * "resolved" counts every annotation whose status is resolved, regardless of
682*72d60f2dStracker-user     * whether it is also orphaned, so it matches exactly what clearResolved()
683*72d60f2dStracker-user     * removes. The facets therefore overlap: a resolved-and-present annotation is
684*72d60f2dStracker-user     * counted in both "normal" and "resolved"; a resolved-and-orphaned one in
685*72d60f2dStracker-user     * both "orphaned" and "resolved".
6869fd890c3Stracker-user     *
6879fd890c3Stracker-user     * @param string $id page id
688*72d60f2dStracker-user     * @return array ['total'=>int, 'normal'=>int, 'resolved'=>int, 'orphaned'=>int]
6899fd890c3Stracker-user     */
6909fd890c3Stracker-user    public function pageCounts($id)
6919fd890c3Stracker-user    {
6929fd890c3Stracker-user        $annotations = $this->getAnnotations($id);
6939fd890c3Stracker-user        $total = count($annotations);
6949fd890c3Stracker-user        if ($total === 0) {
695*72d60f2dStracker-user            return ['total' => 0, 'normal' => 0, 'resolved' => 0, 'orphaned' => 0];
6969fd890c3Stracker-user        }
6979fd890c3Stracker-user        $pageText = $this->getPageText($id);
6989fd890c3Stracker-user        $orphaned = 0;
699*72d60f2dStracker-user        $resolved = 0;
7009fd890c3Stracker-user        foreach ($annotations as $a) {
7019fd890c3Stracker-user            if ($this->quoteMissing($a, $pageText)) {
7029fd890c3Stracker-user                $orphaned++;
7039fd890c3Stracker-user            }
704*72d60f2dStracker-user            if (($a['status'] ?? 'open') === 'resolved') {
705*72d60f2dStracker-user                $resolved++;
7069fd890c3Stracker-user            }
707*72d60f2dStracker-user        }
708*72d60f2dStracker-user        return [
709*72d60f2dStracker-user            'total'    => $total,
710*72d60f2dStracker-user            'normal'   => $total - $orphaned,
711*72d60f2dStracker-user            'resolved' => $resolved,
712*72d60f2dStracker-user            'orphaned' => $orphaned,
713*72d60f2dStracker-user        ];
7149fd890c3Stracker-user    }
7159fd890c3Stracker-user
71643d2073cStracker-user    // ---------------------------------------------------------------------
71743d2073cStracker-user    //  Permission rules (single source of truth)
71843d2073cStracker-user    // ---------------------------------------------------------------------
71943d2073cStracker-user
72043d2073cStracker-user    /**
72143d2073cStracker-user     * May this user create an annotation, reply, or change a resolve status?
72243d2073cStracker-user     *
72343d2073cStracker-user     * Requires only read access to the page — annotations are out-of-band, so
72443d2073cStracker-user     * a user whose page edit access is blocked may still annotate.
72543d2073cStracker-user     *
72643d2073cStracker-user     * @param string $user     current username ('' for anonymous)
72743d2073cStracker-user     * @param int    $aclLevel the user's ACL level on the page
72843d2073cStracker-user     * @return bool
72943d2073cStracker-user     */
73043d2073cStracker-user    public function canAnnotate($user, $aclLevel)
73143d2073cStracker-user    {
73243d2073cStracker-user        return $user !== '' && $user !== null && $aclLevel >= AUTH_READ;
73343d2073cStracker-user    }
73443d2073cStracker-user
73543d2073cStracker-user    /**
73643d2073cStracker-user     * May this user edit or delete the given annotation? Author or admin.
73743d2073cStracker-user     *
73843d2073cStracker-user     * @param array  $annotation
73943d2073cStracker-user     * @param string $user
74043d2073cStracker-user     * @param bool   $isAdmin
74143d2073cStracker-user     * @return bool
74243d2073cStracker-user     */
74343d2073cStracker-user    public function canEditAnnotation(array $annotation, $user, $isAdmin)
74443d2073cStracker-user    {
74543d2073cStracker-user        if ($user === '' || $user === null) {
74643d2073cStracker-user            return false;
74743d2073cStracker-user        }
74843d2073cStracker-user        return $isAdmin || (($annotation['author'] ?? '') === $user);
74943d2073cStracker-user    }
75043d2073cStracker-user
75143d2073cStracker-user    /**
75243d2073cStracker-user     * May this user edit or delete the given reply? Author or admin.
75343d2073cStracker-user     *
75443d2073cStracker-user     * @param array  $reply
75543d2073cStracker-user     * @param string $user
75643d2073cStracker-user     * @param bool   $isAdmin
75743d2073cStracker-user     * @return bool
75843d2073cStracker-user     */
75943d2073cStracker-user    public function canEditReply(array $reply, $user, $isAdmin)
76043d2073cStracker-user    {
76143d2073cStracker-user        if ($user === '' || $user === null) {
76243d2073cStracker-user            return false;
76343d2073cStracker-user        }
76443d2073cStracker-user        return $isAdmin || (($reply['author'] ?? '') === $user);
76543d2073cStracker-user    }
76643d2073cStracker-user
76743d2073cStracker-user    /**
76843d2073cStracker-user     * May this user run the per-page "clear resolved/orphaned" operations?
76943d2073cStracker-user     * Admins only.
77043d2073cStracker-user     *
77143d2073cStracker-user     * @param bool $isAdmin
77243d2073cStracker-user     * @return bool
77343d2073cStracker-user     */
77443d2073cStracker-user    public function canClear($isAdmin)
77543d2073cStracker-user    {
77643d2073cStracker-user        return (bool) $isAdmin;
77743d2073cStracker-user    }
77843d2073cStracker-user
77943d2073cStracker-user    // ---------------------------------------------------------------------
78043d2073cStracker-user    //  Input cleaning
78143d2073cStracker-user    // ---------------------------------------------------------------------
78243d2073cStracker-user
78343d2073cStracker-user    /**
78443d2073cStracker-user     * Validate and normalise a raw anchor.
78543d2073cStracker-user     *
78643d2073cStracker-user     * @param mixed $anchor
78743d2073cStracker-user     * @return array|null  the cleaned anchor, or null if unusable
78843d2073cStracker-user     */
78943d2073cStracker-user    protected function cleanAnchor($anchor)
79043d2073cStracker-user    {
79143d2073cStracker-user        if (!is_array($anchor)) {
79243d2073cStracker-user            return null;
79343d2073cStracker-user        }
79443d2073cStracker-user
79543d2073cStracker-user        $exact = (isset($anchor['exact']) && is_string($anchor['exact']))
79643d2073cStracker-user            ? $this->normalizeWhitespace($anchor['exact'])
79743d2073cStracker-user            : '';
79843d2073cStracker-user        if ($exact === '') {
79943d2073cStracker-user            return null; // an anchor without quoted text is unusable
80043d2073cStracker-user        }
80143d2073cStracker-user        if (mb_strlen($exact) > self::MAX_QUOTE) {
80243d2073cStracker-user            $exact = mb_substr($exact, 0, self::MAX_QUOTE);
80343d2073cStracker-user        }
80443d2073cStracker-user
80543d2073cStracker-user        $prefix = (isset($anchor['prefix']) && is_string($anchor['prefix']))
80643d2073cStracker-user            ? $this->normalizeWhitespace($anchor['prefix'])
80743d2073cStracker-user            : '';
80843d2073cStracker-user        $suffix = (isset($anchor['suffix']) && is_string($anchor['suffix']))
80943d2073cStracker-user            ? $this->normalizeWhitespace($anchor['suffix'])
81043d2073cStracker-user            : '';
81186c7806dStracker-user        $ctx = $this->contextLength();
81286c7806dStracker-user        if (mb_strlen($prefix) > $ctx) {
81386c7806dStracker-user            $prefix = mb_substr($prefix, -$ctx);
81443d2073cStracker-user        }
81586c7806dStracker-user        if (mb_strlen($suffix) > $ctx) {
81686c7806dStracker-user            $suffix = mb_substr($suffix, 0, $ctx);
81743d2073cStracker-user        }
81843d2073cStracker-user
81943d2073cStracker-user        $start = isset($anchor['start']) ? max(0, (int) $anchor['start']) : 0;
82043d2073cStracker-user
82143d2073cStracker-user        return [
82243d2073cStracker-user            'exact'  => $exact,
82343d2073cStracker-user            'prefix' => $prefix,
82443d2073cStracker-user            'suffix' => $suffix,
82543d2073cStracker-user            'start'  => $start,
82643d2073cStracker-user        ];
82743d2073cStracker-user    }
82843d2073cStracker-user
82943d2073cStracker-user    /**
83043d2073cStracker-user     * Clean an annotation/reply body: a plain-text string, trimmed, with
83143d2073cStracker-user     * normalised line endings and a length cap. Newlines are kept; the text
83243d2073cStracker-user     * is escaped by the consumer at render time.
83343d2073cStracker-user     *
83443d2073cStracker-user     * @param mixed $body
83543d2073cStracker-user     * @return string
83643d2073cStracker-user     */
83743d2073cStracker-user    protected function cleanBody($body)
83843d2073cStracker-user    {
83943d2073cStracker-user        if (!is_string($body)) {
84043d2073cStracker-user            return '';
84143d2073cStracker-user        }
84243d2073cStracker-user        $body = str_replace("\r\n", "\n", $body);
84343d2073cStracker-user        $body = str_replace("\r", "\n", $body);
84443d2073cStracker-user        $body = trim($body);
84586c7806dStracker-user        $cap = $this->bodyCap();
84686c7806dStracker-user        if (mb_strlen($body) > $cap) {
84786c7806dStracker-user            $body = mb_substr($body, 0, $cap);
84843d2073cStracker-user        }
84943d2073cStracker-user        return $body;
85043d2073cStracker-user    }
85143d2073cStracker-user
85243d2073cStracker-user    /**
85343d2073cStracker-user     * Collapse every run of whitespace to a single space and trim.
85443d2073cStracker-user     *
85543d2073cStracker-user     * @param mixed $text
85643d2073cStracker-user     * @return string
85743d2073cStracker-user     */
85843d2073cStracker-user    protected function normalizeWhitespace($text)
85943d2073cStracker-user    {
86043d2073cStracker-user        return trim(preg_replace('/\s+/u', ' ', (string) $text));
86143d2073cStracker-user    }
86243d2073cStracker-user
86343d2073cStracker-user    /**
86443d2073cStracker-user     * A fresh identifier for an annotation or reply.
86543d2073cStracker-user     *
86643d2073cStracker-user     * @return string 16 hex characters
86743d2073cStracker-user     */
86843d2073cStracker-user    protected function newId()
86943d2073cStracker-user    {
87043d2073cStracker-user        return bin2hex(random_bytes(8));
87143d2073cStracker-user    }
87243d2073cStracker-user}
873