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 => $assignees) {
238            if ($this->matchPagePattern($pattern, $page)) {
239                $assignees .= ',' . $assignees;
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     * Get all pages that a user needs to acknowledge and/or the last acknowledgement infos
472     * depending on the (optional) filter based on status of the acknowledgements.
473     *
474     * @param string $user
475     * @param array $groups
476     * @param string $status Optional status filter, can be all (default), current or due
477     *
478     * @return array|bool
479     */
480    public function getUserAcknowledgements($user, $groups, $status = '')
481    {
482        $filterClause = $this->getFilterClause($status, 'B');
483
484        // query
485        $sql = "SELECT A.page, A.pageassignees, A.autoassignees, B.lastmod, C.user, MAX(C.ack) AS ack
486                  FROM assignments A
487                  JOIN pages B
488                    ON A.page = B.page
489             LEFT JOIN acks C
490                    ON A.page = C.page AND C.user = ?
491                 WHERE AUTH_ISMEMBER(A.pageassignees || ',' || A.autoassignees, ? , ?)
492              GROUP BY A.page";
493        $sql .= $filterClause;
494        $sql .= "
495              ORDER BY A.page";
496
497        return $this->db->queryAll($sql, [$user, $user, implode('///', $groups)]);
498    }
499
500    /**
501     * Get ack status for all assigned users of a given page
502     *
503     * This can be slow!
504     *
505     * @param string $page
506     * @param string $user
507     * @param string $status
508     * @param int $max
509     *
510     * @return array
511     */
512    public function getPageAcknowledgements($page, $user = '', $status = '', $max = 0)
513    {
514        $userClause = '';
515        $filterClause = '';
516        $params[] = $page;
517
518        // filtering for user from input or using saved assignees?
519        if ($user) {
520            $users = [$user];
521            $userClause = ' AND (B.user = ? OR B.user IS NULL) ';
522            $params[] = $user;
523        } else {
524            $users = $this->getPageAssignees($page);
525            if (!$users) return [];
526        }
527
528        if ($status === 'current') {
529            $filterClause = ' AND ACK >= A.lastmod ';
530        }
531
532        $ulist = implode(',', array_map([$this->db->getPdo(), 'quote'], $users));
533        $sql = "SELECT A.page, A.lastmod, B.user, MAX(B.ack) AS ack
534                  FROM pages A
535             LEFT JOIN acks B
536                    ON A.page = B.page
537                   AND B.user IN ($ulist)
538                WHERE  A.page = ? $userClause $filterClause";
539        $sql .= " GROUP BY A.page, B.user ";
540        if ($max) $sql .= " LIMIT $max";
541
542        $acknowledgements = $this->db->queryAll($sql, $params);
543
544        if ($status === 'current') {
545            return $acknowledgements;
546        }
547
548        // there should be at least one result, unless the page is unknown
549        if (!count($acknowledgements)) return $acknowledgements;
550
551        $baseinfo = [
552            'page' => $acknowledgements[0]['page'],
553            'lastmod' => $acknowledgements[0]['lastmod'],
554            'user' => null,
555            'ack' => null,
556        ];
557
558        // fill up the result with all users that never acknowledged the page
559        $combined = [];
560        foreach ($acknowledgements as $ack) {
561            if ($ack['user'] !== null) {
562                $combined[$ack['user']] = $ack;
563            }
564        }
565        foreach ($users as $user) {
566            if (!isset($combined[$user])) {
567                $combined[$user] = array_merge($baseinfo, ['user' => $user]);
568            }
569        }
570
571        // finally remove current acknowledgements if filter is used
572        // this cannot be done in SQL without loss of data,
573        // filtering must happen last, otherwise removed current acks will be re-added as due
574        if ($status === 'due') {
575            $combined = array_filter($combined, function ($info) {
576                return $info['ack'] < $info['lastmod'];
577            });
578        }
579
580        ksort($combined);
581        return array_values($combined);
582    }
583
584    /**
585     * Returns all acknowledgements
586     *
587     * @param int $limit maximum number of results
588     * @return array
589     */
590    public function getAcknowledgements($limit = 100)
591    {
592        $sql = '
593            SELECT A.page, A.user, B.lastmod, max(A.ack) AS ack
594              FROM acks A, pages B
595             WHERE A.page = B.page
596          GROUP BY A.user, A.page
597          ORDER BY ack DESC
598             LIMIT ?
599              ';
600        return $this->db->queryAll($sql, $limit);
601    }
602
603    /**
604     * Returns a filter clause for acknowledgement queries depending on wanted status.
605     *
606     * @param string $status
607     * @param string $alias Table alias used in the SQL query
608     * @return string
609     */
610    protected function getFilterClause($status, $alias)
611    {
612        switch ($status) {
613            case 'current':
614                $filterClause = " HAVING ack >= $alias.lastmod ";
615                break;
616            case 'due':
617                $filterClause = " HAVING (ack IS NULL) OR (ack < $alias.lastmod) ";
618                break;
619            case 'outdated':
620                $filterClause = " HAVING ack < $alias.lastmod ";
621                break;
622            case 'all':
623            default:
624                $filterClause = '';
625                break;
626        }
627        return $filterClause;
628    }
629
630    // endregion
631}
632