xref: /plugin/acknowledge/helper.php (revision c2e33bcede677b26c01ce484dd9ed10c198ffb08)
1<?php
2
3use dokuwiki\Extension\Plugin;
4use dokuwiki\ChangeLog\PageChangeLog;
5use dokuwiki\ErrorHandler;
6use dokuwiki\Extension\AuthPlugin;
7use dokuwiki\plugin\sqlite\SQLiteDB;
8
9/**
10 * DokuWiki Plugin acknowledge (Helper Component)
11 *
12 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
13 * @author  Andreas Gohr, Anna Dabrowska <dokuwiki@cosmocode.de>
14 */
15class helper_plugin_acknowledge extends Plugin
16{
17    protected $db;
18
19    // region Database Management
20
21    /**
22     * Constructor
23     *
24     * @return void
25     * @throws Exception
26     */
27    public function __construct()
28    {
29        if ($this->db === null) {
30            try {
31                $this->db = new SQLiteDB('acknowledgement', __DIR__ . '/db');
32
33                // register our custom functions
34                $this->db->getPdo()->sqliteCreateFunction('AUTH_ISMEMBER', [$this, 'auth_isMember'], -1);
35                $this->db->getPdo()->sqliteCreateFunction('MATCHES_PAGE_PATTERN', [$this, 'matchPagePattern'], 2);
36            } catch (\Exception $exception) {
37                if (defined('DOKU_UNITTEST')) throw new \RuntimeException('Could not load SQLite', 0, $exception);
38                ErrorHandler::logException($exception);
39                msg($this->getLang('error sqlite plugin missing'), -1);
40                throw $exception;
41            }
42        }
43    }
44
45    /**
46     * Wrapper for test DB access
47     *
48     * @return SQLiteDB
49     */
50    public function getDB()
51    {
52        return $this->db;
53    }
54
55    /**
56     * Wrapper function for auth_isMember which accepts groups as string
57     *
58     * @param string $memberList
59     * @param string $user
60     * @param string $groups
61     *
62     * @return bool
63     */
64    // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
65    public function auth_isMember($memberList, $user, $groups)
66    {
67        return auth_isMember($memberList, $user, explode('///', $groups));
68    }
69
70    /**
71     * Fills the page index with all unknown pages from the fulltext index
72     * @return void
73     */
74    public function updatePageIndex()
75    {
76        $pages = idx_getIndex('page', '');
77        $sql = "INSERT OR IGNORE INTO pages (page, lastmod) VALUES (?,?)";
78
79        $this->db->getPdo()->beginTransaction();
80        foreach ($pages as $page) {
81            $page = trim($page);
82            $lastmod = @filemtime(wikiFN($page));
83            if ($lastmod) {
84                try {
85                    $this->db->exec($sql, [$page, $lastmod]);
86                } catch (\Exception $exception) {
87                    $this->db->getPdo()->rollBack();
88                    throw $exception;
89                }
90            }
91        }
92        $this->db->getPdo()->commit();
93    }
94
95    /**
96     * Check if the given pattern matches the given page
97     *
98     * @param string $pattern the pattern to check against
99     * @param string $page the cleaned pageid to check
100     * @return bool
101     */
102    public function matchPagePattern($pattern, $page)
103    {
104        if (trim($pattern, ':') == '**') return true; // match all
105
106        // regex patterns
107        if ($pattern[0] == '/') {
108            return (bool)preg_match($pattern, ":$page");
109        }
110
111        $pns = ':' . getNS($page) . ':';
112
113        $ans = ':' . cleanID($pattern) . ':';
114        if (substr($pattern, -2) == '**') {
115            // upper namespaces match
116            if (strpos($pns, $ans) === 0) {
117                return true;
118            }
119        } elseif (substr($pattern, -1) == '*') {
120            // namespaces match exact
121            if ($ans === $pns) {
122                return true;
123            }
124        } elseif (cleanID($pattern) == $page) {
125            // exact match
126            return true;
127        }
128
129        return false;
130    }
131
132    /**
133     * Returns all users, formatted for autocomplete
134     *
135     * @return array
136     */
137    public function getUsers()
138    {
139        /** @var AuthPlugin $auth */
140        global $auth;
141
142        if (!$auth->canDo('getUsers')) {
143            return [];
144        }
145
146        $cb = function ($k, $v) {
147            return [
148              'value' => $k,
149              'label' => $k  . ' (' . $v['name'] . ')'
150            ];
151        };
152        $users = $auth->retrieveUsers();
153        $users = array_map($cb, array_keys($users), array_values($users));
154
155        return $users;
156    }
157
158    // endregion
159    // region Page Data
160
161    /**
162     * Delete a page
163     *
164     * Cascades to delete all assigned data, etc.
165     *
166     * @param string $page Page ID
167     */
168    public function removePage($page)
169    {
170        $sql = "DELETE FROM pages WHERE page = ?";
171        $this->db->exec($sql, $page);
172    }
173
174    /**
175     * Update last modified date of page if content has changed
176     *
177     * @param string $page Page ID
178     * @param int $lastmod timestamp of last non-minor change
179     */
180    public function storePageDate($page, $lastmod, $newContent)
181    {
182        $changelog = new PageChangeLog($page);
183        $revs = $changelog->getRevisions(0, 1);
184
185        // compare content
186        if (!empty($revs)) {
187            $oldContent = str_replace(NL, '', io_readFile(wikiFN($page, $revs[0])));
188            $newContent = str_replace(NL, '', $newContent);
189            if ($oldContent === $newContent) return;
190        }
191
192        $sql = "REPLACE INTO pages (page, lastmod) VALUES (?,?)";
193        $this->db->exec($sql, [$page, $lastmod]);
194    }
195
196    // endregion
197    // region Assignments
198
199    /**
200     * Clears direct assignments for a page
201     *
202     * @param string $page Page ID
203     */
204    public function clearPageAssignments($page)
205    {
206        $sql = "UPDATE assignments SET pageassignees = '' WHERE page = ?";
207        $this->db->exec($sql, $page);
208    }
209
210    /**
211     * Set assignees for a given page as manually specified
212     *
213     * @param string $page Page ID
214     * @param string $assignees
215     * @return void
216     */
217    public function setPageAssignees($page, $assignees)
218    {
219        $assignees = implode(',', array_unique(array_filter(array_map('trim', explode(',', $assignees)))));
220
221        $sql = "REPLACE INTO assignments ('page', 'pageassignees') VALUES (?,?)";
222        $this->db->exec($sql, [$page, $assignees]);
223    }
224
225    /**
226     * Set assignees for a given page from the patterns
227     * @param string $page Page ID
228     */
229    public function setAutoAssignees($page)
230    {
231        $patterns = $this->getAssignmentPatterns();
232
233        // given assignees
234        $assignees = '';
235
236        // find all patterns that match the page and add the configured assignees
237        foreach ($patterns as $pattern => $assign) {
238            if ($this->matchPagePattern($pattern, $page)) {
239                $assignees .= ',' . $assign;
240            }
241        }
242
243        // remove duplicates and empty entries
244        $assignees = implode(',', array_unique(array_filter(array_map('trim', explode(',', $assignees)))));
245
246        // store the assignees
247        $sql = "REPLACE INTO assignments ('page', 'autoassignees') VALUES (?,?)";
248        $this->db->exec($sql, [$page, $assignees]);
249    }
250
251    /**
252     * Is the given user one of the assignees for this page
253     *
254     * @param string $page Page ID
255     * @param string $user user name to check
256     * @param string[] $groups groups this user is in
257     * @return bool
258     */
259    public function isUserAssigned($page, $user, $groups)
260    {
261        $sql = "SELECT pageassignees,autoassignees FROM assignments WHERE page = ?";
262        $record = $this->db->queryRecord($sql, $page);
263        if (!$record) return false;
264        $assignees = $record['pageassignees'] . ',' . $record['autoassignees'];
265        return auth_isMember($assignees, $user, $groups);
266    }
267
268    /**
269     * Fetch all assignments for a given user, with additional page information,
270     * by default filtering already granted acknowledgements.
271     * Filter can be switched off via $includeDone
272     *
273     * @param string $user
274     * @param array $groups
275     * @param bool $includeDone
276     *
277     * @return array|bool
278     */
279    public function getUserAssignments($user, $groups, $includeDone = false)
280    {
281        $sql = "SELECT A.page, A.pageassignees, A.autoassignees, B.lastmod, C.user, C.ack FROM assignments A
282                JOIN pages B
283                ON A.page = B.page
284                LEFT JOIN acks C
285                ON A.page = C.page AND ( (C.user = ? AND C.ack > B.lastmod) )
286                WHERE AUTH_ISMEMBER(A.pageassignees || ',' || A.autoassignees , ? , ?)";
287
288        if (!$includeDone) {
289            $sql .= ' AND ack IS NULL';
290        }
291
292        return $this->db->queryAll($sql, $user, $user, implode('///', $groups));
293    }
294
295    /**
296     * Resolve names of users assigned to a given page
297     *
298     * This can be slow on huge user bases!
299     *
300     * @param string $page
301     * @return array|false
302     */
303    public function getPageAssignees($page)
304    {
305        /** @var AuthPlugin $auth */
306        global $auth;
307
308        $sql = "SELECT pageassignees || ',' || autoassignees AS 'assignments'
309                  FROM assignments
310                 WHERE page = ?";
311        $assignments = $this->db->queryValue($sql, $page);
312
313        $users = [];
314        foreach (explode(',', $assignments) as $item) {
315            $item = trim($item);
316            if ($item === '') continue;
317            if ($item[0] == '@') {
318                $users = array_merge(
319                    $users,
320                    array_keys($auth->retrieveUsers(0, 0, ['grps' => substr($item, 1)]))
321                );
322            } else {
323                $users[] = $item;
324            }
325        }
326
327        return array_unique($users);
328    }
329
330    // endregion
331    // region Assignment Patterns
332
333    /**
334     * Get all the assignment patterns
335     * @return array (pattern => assignees)
336     */
337    public function getAssignmentPatterns()
338    {
339        $sql = "SELECT pattern, assignees FROM assignments_patterns";
340        return $this->db->queryKeyValueList($sql);
341    }
342
343    /**
344     * Save new assignment patterns
345     *
346     * This resaves all patterns and reapplies them
347     *
348     * @param array $patterns (pattern => assignees)
349     */
350    public function saveAssignmentPatterns($patterns)
351    {
352        $this->db->getPdo()->beginTransaction();
353        try {
354
355            /** @noinspection SqlWithoutWhere Remove all assignments */
356            $sql = "UPDATE assignments SET autoassignees = ''";
357            $this->db->exec($sql);
358
359            /** @noinspection SqlWithoutWhere Remove all patterns */
360            $sql = "DELETE FROM assignments_patterns";
361            $this->db->exec($sql);
362
363            // insert new patterns and gather affected pages
364            $pages = [];
365
366            $sql = "REPLACE INTO assignments_patterns (pattern, assignees) VALUES (?,?)";
367            foreach ($patterns as $pattern => $assignees) {
368                $pattern = trim($pattern);
369                $assignees = trim($assignees);
370                if (!$pattern || !$assignees) continue;
371                $this->db->exec($sql, [$pattern, $assignees]);
372
373                // patterns may overlap, so we need to gather all affected pages first
374                $affectedPages = $this->getPagesMatchingPattern($pattern);
375                foreach ($affectedPages as $page) {
376                    if (isset($pages[$page])) {
377                        $pages[$page] .= ',' . $assignees;
378                    } else {
379                        $pages[$page] = $assignees;
380                    }
381                }
382            }
383
384            $sql = "INSERT INTO assignments (page, autoassignees) VALUES (?, ?)
385                ON CONFLICT(page)
386                DO UPDATE SET autoassignees = ?";
387            foreach ($pages as $page => $assignees) {
388                // remove duplicates and empty entries
389                $assignees = implode(',', array_unique(array_filter(array_map('trim', explode(',', $assignees)))));
390                $this->db->exec($sql, [$page, $assignees, $assignees]);
391            }
392        } catch (Exception $e) {
393            $this->db->getPdo()->rollBack();
394            throw $e;
395        }
396        $this->db->getPdo()->commit();
397    }
398
399    /**
400     * Get all known pages that match the given pattern
401     *
402     * @param $pattern
403     * @return string[]
404     */
405    public function getPagesMatchingPattern($pattern)
406    {
407        $sql = "SELECT page FROM pages WHERE MATCHES_PAGE_PATTERN(?, page)";
408        $pages = $this->db->queryAll($sql, $pattern);
409
410        return array_column($pages, 'page');
411    }
412
413    // endregion
414    // region Acknowledgements
415
416    /**
417     * Has the given user acknowledged the given page?
418     *
419     * @param string $page
420     * @param string $user
421     * @return bool|int timestamp of acknowledgement or false
422     */
423    public function hasUserAcknowledged($page, $user)
424    {
425        $sql = "SELECT ack
426                  FROM acks A, pages B
427                 WHERE A.page = B.page
428                   AND A.page = ?
429                   AND A.user = ?
430                   AND A.ack >= B.lastmod";
431
432        $acktime = $this->db->queryValue($sql, $page, $user);
433
434        return $acktime ? (int)$acktime : false;
435    }
436
437    /**
438     * Timestamp of the latest acknowledgment of the given page
439     * by the given user
440     *
441     * @param string $page
442     * @param string $user
443     * @return bool|string
444     */
445    public function getLatestUserAcknowledgement($page, $user)
446    {
447        $sql = "SELECT MAX(ack)
448                  FROM acks
449                 WHERE page = ?
450                   AND user = ?";
451
452        return $this->db->queryValue($sql, [$page, $user]);
453    }
454
455    /**
456     * Save user's acknowledgement for a given page
457     *
458     * @param string $page
459     * @param string $user
460     * @return bool
461     */
462    public function saveAcknowledgement($page, $user)
463    {
464        $sql = "INSERT INTO acks (page, user, ack) VALUES (?,?, strftime('%s','now'))";
465
466        $this->db->exec($sql, $page, $user);
467        return true;
468    }
469
470    /**
471     * Save an acknowledgement with an explicit timestamp.
472     * Useful for imports and tests. Ignores duplicates.
473     *
474     * @param string $page
475     * @param string $user
476     * @param int $time
477     * @return void
478     */
479    public function importAcknowledgement($page, $user, $time)
480    {
481        $sql = "INSERT OR IGNORE INTO acks (page, user, ack) VALUES (?,?,?)";
482        $this->db->exec($sql, [$page, $user, $time]);
483    }
484
485    /**
486     * Get all pages that a user needs to acknowledge and/or the last acknowledgement infos
487     * depending on the (optional) filter based on status of the acknowledgements.
488     *
489     * @param string $user
490     * @param array $groups
491     * @param string $status Optional status filter, can be all (default), current or due
492     *
493     * @return array|bool
494     */
495    public function getUserAcknowledgements($user, $groups, $status = '')
496    {
497        $filterClause = $this->getFilterClause($status, 'B');
498
499        // query
500        $sql = "SELECT A.page, A.pageassignees, A.autoassignees, B.lastmod, C.user, MAX(C.ack) AS ack
501                  FROM assignments A
502                  JOIN pages B
503                    ON A.page = B.page
504             LEFT JOIN acks C
505                    ON A.page = C.page AND C.user = ?
506                 WHERE AUTH_ISMEMBER(A.pageassignees || ',' || A.autoassignees, ? , ?)
507              GROUP BY A.page";
508        $sql .= $filterClause;
509        $sql .= "
510              ORDER BY A.page";
511
512        return $this->db->queryAll($sql, [$user, $user, implode('///', $groups)]);
513    }
514
515    /**
516     * Get ack status for all assigned users of a given page
517     *
518     * This can be slow!
519     *
520     * @param string $page
521     * @param string $user
522     * @param string $status
523     * @param int $max
524     *
525     * @return array
526     */
527    public function getPageAcknowledgements($page, $user = '', $status = '', $max = 0)
528    {
529        $userClause = '';
530        $filterClause = '';
531        $params[] = $page;
532
533        // filtering for user from input or using saved assignees?
534        if ($user) {
535            $users = [$user];
536            $userClause = ' AND (B.user = ? OR B.user IS NULL) ';
537            $params[] = $user;
538        } else {
539            $users = $this->getPageAssignees($page);
540            if (!$users) return [];
541        }
542
543        if ($status === 'current') {
544            $filterClause = ' AND ACK >= A.lastmod ';
545        }
546
547        $ulist = implode(',', array_map([$this->db->getPdo(), 'quote'], $users));
548        $sql = "SELECT A.page, A.lastmod, B.user, MAX(B.ack) AS ack
549                  FROM pages A
550             LEFT JOIN acks B
551                    ON A.page = B.page
552                   AND B.user IN ($ulist)
553                WHERE  A.page = ? $userClause $filterClause";
554        $sql .= " GROUP BY A.page, B.user ";
555        if ($max) $sql .= " LIMIT $max";
556
557        $acknowledgements = $this->db->queryAll($sql, $params);
558
559        if ($status === 'current') {
560            return $acknowledgements;
561        }
562
563        // there should be at least one result, unless the page is unknown
564        if (!count($acknowledgements)) return $acknowledgements;
565
566        $baseinfo = [
567            'page' => $acknowledgements[0]['page'],
568            'lastmod' => $acknowledgements[0]['lastmod'],
569            'user' => null,
570            'ack' => null,
571        ];
572
573        // fill up the result with all users that never acknowledged the page
574        $combined = [];
575        foreach ($acknowledgements as $ack) {
576            if ($ack['user'] !== null) {
577                $combined[$ack['user']] = $ack;
578            }
579        }
580        foreach ($users as $user) {
581            if (!isset($combined[$user])) {
582                $combined[$user] = array_merge($baseinfo, ['user' => $user]);
583            }
584        }
585
586        // finally remove current acknowledgements if filter is used
587        // this cannot be done in SQL without loss of data,
588        // filtering must happen last, otherwise removed current acks will be re-added as due
589        if ($status === 'due') {
590            $combined = array_filter($combined, function ($info) {
591                return $info['ack'] < $info['lastmod'];
592            });
593        }
594
595        ksort($combined);
596        return array_values($combined);
597    }
598
599    /**
600     * Count up-to-date and due acknowledgements for a given page
601     *
602     * Uses potentially slow getPageAssignees()
603     *
604     * @param string $page
605     * @return array{required:int, current:int, due:int}
606     */
607    public function getPageAcknowledgementCounts($page)
608    {
609        $users = $this->getPageAssignees($page);
610        if (!$users) {
611            return ['required' => 0, 'current' => 0, 'due' => 0];
612        }
613
614        $ulist = implode(',', array_map([$this->db->getPdo(), 'quote'], $users));
615        $sql = "SELECT COUNT(*) FROM (
616                    SELECT B.user
617                      FROM pages A
618                 LEFT JOIN acks B
619                        ON A.page = B.page
620                       AND B.user IN ($ulist)
621                     WHERE A.page = ?
622                  GROUP BY B.user
623                    HAVING MAX(B.ack) >= A.lastmod
624                )";
625        $current = (int)$this->db->queryValue($sql, $page);
626
627        $required = count($users);
628        return [
629            'required' => $required,
630            'current' => $current,
631            'due' => $required - $current,
632        ];
633    }
634
635    /**
636     * Returns all acknowledgements
637     *
638     * @param int $limit maximum number of results
639     * @return array
640     */
641    public function getAcknowledgements($limit = 100)
642    {
643        $sql = '
644            SELECT A.page, A.user, B.lastmod, max(A.ack) AS ack
645              FROM acks A, pages B
646             WHERE A.page = B.page
647          GROUP BY A.user, A.page
648          ORDER BY ack DESC
649             LIMIT ?
650              ';
651        return $this->db->queryAll($sql, $limit);
652    }
653
654    /**
655     * Aggregate acknowledgement statistics as a drill-down into a namespace, plus a wiki-wide
656     * total.
657     *
658     * @param string $ns namespace to drill into ('' = wiki root / top level)
659     * @return array {
660     *     namespaces: array<string, array{required:int, acked:int, pages:int, haschildren:bool}>,
661     *         keyed by the immediate child namespace within $ns ('' = root pages),
662     *     total: array{required:int, acked:int, pages:int} wiki-wide total
663     * }
664     */
665    public function getStatistics($ns = '')
666    {
667        $depth = $ns === '' ? 0 : count(explode(':', $ns));
668
669        $sql = "SELECT page FROM assignments WHERE TRIM(pageassignees || autoassignees) != ''";
670        $pages = $this->db->queryAll($sql);
671
672        $namespaces = [];
673        $total = ['required' => 0, 'acked' => 0, 'pages' => 0];
674
675        foreach ($pages as $row) {
676            $page = $row['page'];
677
678            $acknowledgements = $this->getPageAcknowledgements($page);
679            $required = count($acknowledgements);
680            if (!$required) continue;
681
682            $acked = 0;
683            foreach ($acknowledgements as $ack) {
684                if ($ack['ack'] !== null && $ack['ack'] >= $ack['lastmod']) {
685                    $acked++;
686                }
687            }
688
689            // total is always wiki-wide
690            $total['required'] += $required;
691            $total['acked'] += $acked;
692            $total['pages'] += 1;
693
694            $pageNS = getNS($page);
695            $pageNS = ($pageNS === false) ? '' : $pageNS;
696
697            // not in a namespace, or in current ns or it's subnamespaces
698            $inScope = $ns === '' || $pageNS === $ns || str_starts_with($pageNS . ':', $ns . ':');
699            if (!$inScope) continue;
700
701            // group namespaces
702            $segments = $pageNS === '' ? [] : explode(':', $pageNS);
703            $key = implode(':', array_slice($segments, 0, $depth + 1));
704
705            if (!isset($namespaces[$key])) {
706                $namespaces[$key] = ['required' => 0, 'acked' => 0, 'pages' => 0, 'haschildren' => false];
707            }
708            $namespaces[$key]['required'] += $required;
709            $namespaces[$key]['acked'] += $acked;
710            $namespaces[$key]['pages'] += 1;
711            if (count($segments) > $depth + 1) {
712                $namespaces[$key]['haschildren'] = true;
713            }
714        }
715
716        ksort($namespaces);
717
718        return ['namespaces' => $namespaces, 'total' => $total];
719    }
720
721    /**
722     * Returns a filter clause for acknowledgement queries depending on wanted status.
723     *
724     * @param string $status
725     * @param string $alias Table alias used in the SQL query
726     * @return string
727     */
728    protected function getFilterClause($status, $alias)
729    {
730        switch ($status) {
731            case 'current':
732                $filterClause = " HAVING ack >= $alias.lastmod ";
733                break;
734            case 'due':
735                $filterClause = " HAVING (ack IS NULL) OR (ack < $alias.lastmod) ";
736                break;
737            case 'outdated':
738                $filterClause = " HAVING ack < $alias.lastmod ";
739                break;
740            case 'all':
741            default:
742                $filterClause = '';
743                break;
744        }
745        return $filterClause;
746    }
747
748    // endregion
749    // region Plugin Integrations
750
751    /**
752     * Check status of the approve plugin (if installed and integration enabled in config)
753     *
754     * @param string $page page id
755     * @return bool true only if page is tracked by approve plugin but not in 'approved' status
756     */
757    public function isBlockedByApprove($page)
758    {
759        if (!$this->getConf('approve_integration')) return false;
760
761        /** @var helper_plugin_approve_db $approve */
762        $approve = plugin_load('helper', 'approve_db');
763        if (!$approve) return false;
764
765        // page not handled by approve
766        if ($approve->getPageMetadata($page) === null) return false;
767
768        // check if current revision is approved
769        $currentRev = (int)@filemtime(wikiFN($page));
770        $approveRev = $approve->getPageRevision($page, $currentRev);
771
772        return $approveRev['status'] !== 'approved';
773    }
774
775    // endregion
776}
777