xref: /plugin/acknowledge/helper.php (revision 5966046c9f2860981ac6f31f94d13d9b1dc9b700)
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     * Get SQLiteDB instance
23     *
24     * @return SQLiteDB|null
25     */
26    public function getDB()
27    {
28        if ($this->db === null) {
29            try {
30                $this->db = new SQLiteDB('acknowledgement', __DIR__ . '/db');
31
32                // register our custom functions
33                $this->db->getPdo()->sqliteCreateFunction('AUTH_ISMEMBER', [$this, 'auth_isMember'], -1);
34                $this->db->getPdo()->sqliteCreateFunction('MATCHES_PAGE_PATTERN', [$this, 'matchPagePattern'], 2);
35            } catch (\Exception $exception) {
36                if (defined('DOKU_UNITTEST')) throw new \RuntimeException('Could not load SQLite', 0, $exception);
37                ErrorHandler::logException($exception);
38                msg($this->getLang('error sqlite plugin missing'), -1);
39                return null;
40            }
41        }
42        return $this->db;
43    }
44
45    /**
46     * Wrapper function for auth_isMember which accepts groups as string
47     *
48     * @param string $memberList
49     * @param string $user
50     * @param string $groups
51     *
52     * @return bool
53     */
54    // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
55    public function auth_isMember($memberList, $user, $groups)
56    {
57        return auth_isMember($memberList, $user, explode('///', $groups));
58    }
59
60    /**
61     * Fills the page index with all unknown pages from the fulltext index
62     * @return void
63     */
64    public function updatePageIndex()
65    {
66        $sqlite = $this->getDB();
67        if (!$sqlite) return;
68
69        $pages = idx_getIndex('page', '');
70        $sql = "INSERT OR IGNORE INTO pages (page, lastmod) VALUES (?,?)";
71
72        $sqlite->getPdo()->beginTransaction();
73        foreach ($pages as $page) {
74            $page = trim($page);
75            $lastmod = @filemtime(wikiFN($page));
76            if ($lastmod) {
77                try {
78                    $sqlite->exec($sql, [$page, $lastmod]);
79                } catch (\Exception $exception) {
80                    $sqlite->getPdo()->rollBack();
81                    throw $exception;
82                }
83            }
84        }
85        $sqlite->getPdo()->commit();
86    }
87
88    /**
89     * Check if the given pattern matches the given page
90     *
91     * @param string $pattern the pattern to check against
92     * @param string $page the cleaned pageid to check
93     * @return bool
94     */
95    public function matchPagePattern($pattern, $page)
96    {
97        if (trim($pattern, ':') == '**') return true; // match all
98
99        // regex patterns
100        if ($pattern[0] == '/') {
101            return (bool)preg_match($pattern, ":$page");
102        }
103
104        $pns = ':' . getNS($page) . ':';
105
106        $ans = ':' . cleanID($pattern) . ':';
107        if (substr($pattern, -2) == '**') {
108            // upper namespaces match
109            if (strpos($pns, $ans) === 0) {
110                return true;
111            }
112        } elseif (substr($pattern, -1) == '*') {
113            // namespaces match exact
114            if ($ans === $pns) {
115                return true;
116            }
117        } elseif (cleanID($pattern) == $page) {
118            // exact match
119            return true;
120        }
121
122        return false;
123    }
124
125    /**
126     * Returns all users, formatted for autocomplete
127     *
128     * @return array
129     */
130    public function getUsers()
131    {
132        /** @var AuthPlugin $auth */
133        global $auth;
134
135        if (!$auth->canDo('getUsers')) {
136            return [];
137        }
138
139        $cb = function ($k, $v) {
140            return [
141              'value' => $k,
142              'label' => $k  . ' (' . $v['name'] . ')'
143            ];
144        };
145        $users = $auth->retrieveUsers();
146        $users = array_map($cb, array_keys($users), array_values($users));
147
148        return $users;
149    }
150
151    // endregion
152    // region Page Data
153
154    /**
155     * Delete a page
156     *
157     * Cascades to delete all assigned data, etc.
158     *
159     * @param string $page Page ID
160     */
161    public function removePage($page)
162    {
163        $sqlite = $this->getDB();
164        if (!$sqlite) return;
165
166        $sql = "DELETE FROM pages WHERE page = ?";
167        $sqlite->exec($sql, $page);
168    }
169
170    /**
171     * Update last modified date of page if content has changed
172     *
173     * @param string $page Page ID
174     * @param int $lastmod timestamp of last non-minor change
175     */
176    public function storePageDate($page, $lastmod, $newContent)
177    {
178        $changelog = new PageChangeLog($page);
179        $revs = $changelog->getRevisions(0, 1);
180
181        // compare content
182        $oldContent = str_replace(NL, '', io_readFile(wikiFN($page, $revs[0])));
183        $newContent = str_replace(NL, '', $newContent);
184        if ($oldContent === $newContent) return;
185
186        $sqlite = $this->getDB();
187        if (!$sqlite) return;
188
189        $sql = "REPLACE INTO pages (page, lastmod) VALUES (?,?)";
190        $sqlite->exec($sql, [$page, $lastmod]);
191    }
192
193    // endregion
194    // region Assignments
195
196    /**
197     * Clears direct assignments for a page
198     *
199     * @param string $page Page ID
200     */
201    public function clearPageAssignments($page)
202    {
203        $sqlite = $this->getDB();
204        if (!$sqlite) return;
205
206        $sql = "UPDATE assignments SET pageassignees = '' WHERE page = ?";
207        $sqlite->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        $sqlite = $this->getDB();
220        if (!$sqlite) return;
221
222        $assignees = implode(',', array_unique(array_filter(array_map('trim', explode(',', $assignees)))));
223
224        $sql = "REPLACE INTO assignments ('page', 'pageassignees') VALUES (?,?)";
225        $sqlite->exec($sql, [$page, $assignees]);
226    }
227
228    /**
229     * Set assignees for a given page from the patterns
230     * @param string $page Page ID
231     */
232    public function setAutoAssignees($page)
233    {
234        $sqlite = $this->getDB();
235        if (!$sqlite) return;
236
237        $patterns = $this->getAssignmentPatterns();
238
239        // given assignees
240        $assignees = '';
241
242        // find all patterns that match the page and add the configured assignees
243        foreach ($patterns as $pattern => $assignees) {
244            if ($this->matchPagePattern($pattern, $page)) {
245                $assignees .= ',' . $assignees;
246            }
247        }
248
249        // remove duplicates and empty entries
250        $assignees = implode(',', array_unique(array_filter(array_map('trim', explode(',', $assignees)))));
251
252        // store the assignees
253        $sql = "REPLACE INTO assignments ('page', 'autoassignees') VALUES (?,?)";
254        $sqlite->exec($sql, [$page, $assignees]);
255    }
256
257    /**
258     * Is the given user one of the assignees for this page
259     *
260     * @param string $page Page ID
261     * @param string $user user name to check
262     * @param string[] $groups groups this user is in
263     * @return bool
264     */
265    public function isUserAssigned($page, $user, $groups)
266    {
267        $sqlite = $this->getDB();
268        if (!$sqlite) return false;
269
270        $sql = "SELECT pageassignees,autoassignees FROM assignments WHERE page = ?";
271        $record = $sqlite->queryRecord($sql, $page);
272        if (!$record) return false;
273        $assignees = $record['pageassignees'] . ',' . $record['autoassignees'];
274        return auth_isMember($assignees, $user, $groups);
275    }
276
277    /**
278     * Fetch all assignments for a given user, with additional page information,
279     * by default filtering already granted acknowledgements.
280     * Filter can be switched off via $includeDone
281     *
282     * @param string $user
283     * @param array $groups
284     * @param bool $includeDone
285     *
286     * @return array|bool
287     */
288    public function getUserAssignments($user, $groups, $includeDone = false)
289    {
290        $sqlite = $this->getDB();
291        if (!$sqlite) return false;
292
293        $sql = "SELECT A.page, A.pageassignees, A.autoassignees, B.lastmod, C.user, C.ack FROM assignments A
294                JOIN pages B
295                ON A.page = B.page
296                LEFT JOIN acks C
297                ON A.page = C.page AND ( (C.user = ? AND C.ack > B.lastmod) )
298                WHERE AUTH_ISMEMBER(A.pageassignees || ',' || A.autoassignees , ? , ?)";
299
300        if (!$includeDone) {
301            $sql .= ' AND ack IS NULL';
302        }
303
304        return $sqlite->queryAll($sql, $user, $user, implode('///', $groups));
305    }
306
307
308    /**
309     * Resolve names of users assigned to a given page
310     *
311     * This can be slow on huge user bases!
312     *
313     * @param string $page
314     * @return array|false
315     */
316    public function getPageAssignees($page)
317    {
318        $sqlite = $this->getDB();
319        if (!$sqlite) return false;
320        /** @var AuthPlugin $auth */
321        global $auth;
322
323        $sql = "SELECT pageassignees || ',' || autoassignees AS 'assignments'
324                  FROM assignments
325                 WHERE page = ?";
326        $assignments = $sqlite->queryValue($sql, $page);
327
328        $users = [];
329        foreach (explode(',', $assignments) as $item) {
330            $item = trim($item);
331            if ($item === '') continue;
332            if ($item[0] == '@') {
333                $users = array_merge(
334                    $users,
335                    array_keys($auth->retrieveUsers(0, 0, ['grps' => substr($item, 1)]))
336                );
337            } else {
338                $users[] = $item;
339            }
340        }
341
342        return array_unique($users);
343    }
344
345    // endregion
346    // region Assignment Patterns
347
348    /**
349     * Get all the assignment patterns
350     * @return array (pattern => assignees)
351     */
352    public function getAssignmentPatterns()
353    {
354        $sqlite = $this->getDB();
355        if (!$sqlite) return [];
356
357        $sql = "SELECT pattern, assignees FROM assignments_patterns";
358        return $sqlite->queryKeyValueList($sql);
359    }
360
361    /**
362     * Save new assignment patterns
363     *
364     * This resaves all patterns and reapplies them
365     *
366     * @param array $patterns (pattern => assignees)
367     */
368    public function saveAssignmentPatterns($patterns)
369    {
370        $sqlite = $this->getDB();
371        if (!$sqlite) return;
372
373        $sqlite->getPdo()->beginTransaction();
374        try {
375
376            /** @noinspection SqlWithoutWhere Remove all assignments */
377            $sql = "UPDATE assignments SET autoassignees = ''";
378            $sqlite->exec($sql);
379
380            /** @noinspection SqlWithoutWhere Remove all patterns */
381            $sql = "DELETE FROM assignments_patterns";
382            $sqlite->exec($sql);
383
384            // insert new patterns and gather affected pages
385            $pages = [];
386
387            $sql = "REPLACE INTO assignments_patterns (pattern, assignees) VALUES (?,?)";
388            foreach ($patterns as $pattern => $assignees) {
389                $pattern = trim($pattern);
390                $assignees = trim($assignees);
391                if (!$pattern || !$assignees) continue;
392                $sqlite->exec($sql, [$pattern, $assignees]);
393
394                // patterns may overlap, so we need to gather all affected pages first
395                $affectedPages = $this->getPagesMatchingPattern($pattern);
396                foreach ($affectedPages as $page) {
397                    if (isset($pages[$page])) {
398                        $pages[$page] .= ',' . $assignees;
399                    } else {
400                        $pages[$page] = $assignees;
401                    }
402                }
403            }
404
405            $sql = "INSERT INTO assignments (page, autoassignees) VALUES (?, ?)
406                ON CONFLICT(page)
407                DO UPDATE SET autoassignees = ?";
408            foreach ($pages as $page => $assignees) {
409                // remove duplicates and empty entries
410                $assignees = implode(',', array_unique(array_filter(array_map('trim', explode(',', $assignees)))));
411                $sqlite->exec($sql, [$page, $assignees, $assignees]);
412            }
413        } catch (Exception $e) {
414            $sqlite->getPdo()->rollBack();
415            throw $e;
416        }
417        $sqlite->getPdo()->commit();
418    }
419
420    /**
421     * Get all known pages that match the given pattern
422     *
423     * @param $pattern
424     * @return string[]
425     */
426    public function getPagesMatchingPattern($pattern)
427    {
428        $sqlite = $this->getDB();
429        if (!$sqlite) return [];
430
431        $sql = "SELECT page FROM pages WHERE MATCHES_PAGE_PATTERN(?, page)";
432        $pages = $sqlite->queryAll($sql, $pattern);
433
434        return array_column($pages, 'page');
435    }
436
437    // endregion
438    // region Acknowledgements
439
440    /**
441     * Has the given user acknowledged the given page?
442     *
443     * @param string $page
444     * @param string $user
445     * @return bool|int timestamp of acknowledgement or false
446     */
447    public function hasUserAcknowledged($page, $user)
448    {
449        $sqlite = $this->getDB();
450        if (!$sqlite) return false;
451
452        $sql = "SELECT ack
453                  FROM acks A, pages B
454                 WHERE A.page = B.page
455                   AND A.page = ?
456                   AND A.user = ?
457                   AND A.ack >= B.lastmod";
458
459        $acktime = $sqlite->queryValue($sql, $page, $user);
460
461        return $acktime ? (int)$acktime : false;
462    }
463
464    /**
465     * Timestamp of the latest acknowledgment of the given page
466     * by the given user
467     *
468     * @param string $page
469     * @param string $user
470     * @return bool|string
471     */
472    public function getLatestUserAcknowledgement($page, $user)
473    {
474        $sqlite = $this->getDB();
475        if (!$sqlite) return false;
476
477        $sql = "SELECT MAX(ack)
478                  FROM acks
479                 WHERE page = ?
480                   AND user = ?";
481
482        return $sqlite->queryValue($sql, [$page, $user]);
483    }
484
485    /**
486     * Save user's acknowledgement for a given page
487     *
488     * @param string $page
489     * @param string $user
490     * @return bool
491     */
492    public function saveAcknowledgement($page, $user)
493    {
494        $sqlite = $this->getDB();
495        if (!$sqlite) return false;
496
497        $sql = "INSERT INTO acks (page, user, ack) VALUES (?,?, strftime('%s','now'))";
498
499        $sqlite->exec($sql, $page, $user);
500        return true;
501    }
502
503    /**
504     * Get all pages that a user needs to acknowledge and/or the last acknowledgement infos
505     * depending on the (optional) filter based on status of the acknowledgements.
506     *
507     * @param string $user
508     * @param array $groups
509     * @param string $status Optional status filter, can be all (default), current or due
510     *
511     * @return array|bool
512     */
513    public function getUserAcknowledgements($user, $groups, $status = '')
514    {
515        $sqlite = $this->getDB();
516        if (!$sqlite) return false;
517
518        // filter clause
519        switch ($status) {
520            case 'current':
521                $having = ' HAVING ack >= B.lastmod ';
522                break;
523            case 'due':
524                $having = ' HAVING (ack IS NULL) OR (ack < B.lastmod) ';
525                break;
526            case 'outdated':
527                $having = ' HAVING ack < B.lastmod ';
528                break;
529            case 'all':
530            default:
531                $having = '';
532                break;
533        }
534
535        // query
536        $sql = "SELECT A.page, A.pageassignees, A.autoassignees, B.lastmod, C.user, MAX(C.ack) AS ack
537                  FROM assignments A
538                  JOIN pages B
539                    ON A.page = B.page
540             LEFT JOIN acks C
541                    ON A.page = C.page AND C.user = ?
542                 WHERE AUTH_ISMEMBER(A.pageassignees || ',' || A.autoassignees, ? , ?)
543              GROUP BY A.page";
544        $sql .= $having;
545        $sql .= "
546              ORDER BY A.page";
547
548        return $sqlite->queryAll($sql, [$user, $user, implode('///', $groups)]);
549    }
550
551    /**
552     * Get ack status for all assigned users of a given page
553     *
554     * This can be slow!
555     *
556     * @param string $page
557     * @param string $user
558     * @return array|false
559     */
560    public function getPageAcknowledgements($page, $max = 0, $user = '')
561    {
562        $users = $this->getPageAssignees($page);
563        if ($users === false) return false;
564        $sqlite = $this->getDB();
565        if (!$sqlite) return false;
566
567        $ulist = implode(',', array_map([$sqlite->getPdo(), 'quote'], $users));
568        $sql = "SELECT A.page, A.lastmod, B.user, MAX(B.ack) AS ack
569                  FROM pages A
570             LEFT JOIN acks B
571                    ON A.page = B.page
572                   AND B.user IN ($ulist)
573                WHERE  A.page = ?
574              GROUP BY A.page, B.user
575                 ";
576        if ($max) $sql .= " LIMIT $max";
577        $acknowledgements = $sqlite->queryAll($sql, $page);
578
579        // there should be at least one result, unless the page is unknown
580        if (!count($acknowledgements)) return false;
581
582        $baseinfo = [
583            'page' => $acknowledgements[0]['page'],
584            'lastmod' => $acknowledgements[0]['lastmod'],
585            'user' => null,
586            'ack' => null,
587        ];
588
589        // fill up the result with all users that never acknowledged the page
590        $combined = [];
591        foreach ($acknowledgements as $ack) {
592            if ($ack['user'] !== null) {
593                $combined[$ack['user']] = $ack;
594            }
595        }
596        foreach ($users as $user) {
597            if (!isset($combined[$user])) {
598                $combined[$user] = array_merge($baseinfo, ['user' => $user]);
599            }
600        }
601
602        ksort($combined);
603        return array_values($combined);
604    }
605
606    /**
607     * Returns all acknowledgements
608     *
609     * @param int $limit maximum number of results
610     * @return array|bool
611     */
612    public function getAcknowledgements($limit = 100)
613    {
614        $sqlite = $this->getDB();
615        if (!$sqlite) return false;
616
617        $sql = '
618            SELECT A.page, A.user, B.lastmod, max(A.ack) AS ack
619              FROM acks A, pages B
620             WHERE A.page = B.page
621          GROUP BY A.user, A.page
622          ORDER BY ack DESC
623             LIMIT ?
624              ';
625        $acknowledgements = $sqlite->queryAll($sql, $limit);
626
627        return $acknowledgements;
628    }
629
630    // endregion
631}
632