xref: /plugin/acknowledge/helper.php (revision 45240794bdbd02483d35a247625be40921d89e8d)
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     *
127     * @return array
128     */
129    public function getUsers()
130    {
131        /** @var AuthPlugin $auth */
132        global $auth;
133
134        if (!$auth->canDo('getUsers')) {
135            return [];
136        }
137
138        $cb = function($k, $v) {
139          return [
140              'value' => $k,
141              'label' => $k  . ' (' . $v['name'] . ')'
142          ];
143        };
144        $users = $auth->retrieveUsers();
145        $users = array_map($cb, array_keys($users), array_values($users));
146
147        return $users;
148    }
149
150    // endregion
151    // region Page Data
152
153    /**
154     * Delete a page
155     *
156     * Cascades to delete all assigned data, etc.
157     *
158     * @param string $page Page ID
159     */
160    public function removePage($page)
161    {
162        $sqlite = $this->getDB();
163        if (!$sqlite) return;
164
165        $sql = "DELETE FROM pages WHERE page = ?";
166        $sqlite->exec($sql, $page);
167    }
168
169    /**
170     * Update last modified date of page if content has changed
171     *
172     * @param string $page Page ID
173     * @param int $lastmod timestamp of last non-minor change
174     */
175    public function storePageDate($page, $lastmod, $newContent)
176    {
177        $changelog = new PageChangeLog($page);
178        $revs = $changelog->getRevisions(0, 1);
179
180        // compare content
181        $oldContent = str_replace(NL, '', io_readFile(wikiFN($page, $revs[0])));
182        $newContent = str_replace(NL, '', $newContent);
183        if ($oldContent === $newContent) return;
184
185        $sqlite = $this->getDB();
186        if (!$sqlite) return;
187
188        $sql = "REPLACE INTO pages (page, lastmod) VALUES (?,?)";
189        $sqlite->exec($sql, [$page, $lastmod]);
190    }
191
192    // endregion
193    // region Assignments
194
195    /**
196     * Clears direct assignments for a page
197     *
198     * @param string $page Page ID
199     */
200    public function clearPageAssignments($page)
201    {
202        $sqlite = $this->getDB();
203        if (!$sqlite) return;
204
205        $sql = "UPDATE assignments SET pageassignees = '' WHERE page = ?";
206        $sqlite->exec($sql, $page);
207    }
208
209    /**
210     * Set assignees for a given page as manually specified
211     *
212     * @param string $page Page ID
213     * @param string $assignees
214     * @return void
215     */
216    public function setPageAssignees($page, $assignees)
217    {
218        $sqlite = $this->getDB();
219        if (!$sqlite) return;
220
221        $assignees = implode(',', array_unique(array_filter(array_map('trim', explode(',', $assignees)))));
222
223        $sql = "REPLACE INTO assignments ('page', 'pageassignees') VALUES (?,?)";
224        $sqlite->exec($sql, [$page, $assignees]);
225    }
226
227    /**
228     * Set assignees for a given page from the patterns
229     * @param string $page Page ID
230     */
231    public function setAutoAssignees($page)
232    {
233        $sqlite = $this->getDB();
234        if (!$sqlite) return;
235
236        $patterns = $this->getAssignmentPatterns();
237
238        // given assignees
239        $assignees = '';
240
241        // find all patterns that match the page and add the configured assignees
242        foreach ($patterns as $pattern => $assignees) {
243            if ($this->matchPagePattern($pattern, $page)) {
244                $assignees .= ',' . $assignees;
245            }
246        }
247
248        // remove duplicates and empty entries
249        $assignees = implode(',', array_unique(array_filter(array_map('trim', explode(',', $assignees)))));
250
251        // store the assignees
252        $sql = "REPLACE INTO assignments ('page', 'autoassignees') VALUES (?,?)";
253        $sqlite->exec($sql, [$page, $assignees]);
254    }
255
256    /**
257     * Is the given user one of the assignees for this page
258     *
259     * @param string $page Page ID
260     * @param string $user user name to check
261     * @param string[] $groups groups this user is in
262     * @return bool
263     */
264    public function isUserAssigned($page, $user, $groups)
265    {
266        $sqlite = $this->getDB();
267        if (!$sqlite) return false;
268
269        $sql = "SELECT pageassignees,autoassignees FROM assignments WHERE page = ?";
270        $record = $sqlite->queryRecord($sql, $page);
271        if (!$record) return false;
272        $assignees = $record['pageassignees'] . ',' . $record['autoassignees'];
273        return auth_isMember($assignees, $user, $groups);
274    }
275
276    /**
277     * Fetch all assignments for a given user, with additional page information,
278     * by default filtering already granted acknowledgements.
279     * Filter can be switched off via $includeDone
280     *
281     * @param string $user
282     * @param array $groups
283     * @param bool $includeDone
284     *
285     * @return array|bool
286     */
287    public function getUserAssignments($user, $groups, $includeDone = false)
288    {
289        $sqlite = $this->getDB();
290        if (!$sqlite) return false;
291
292        $sql = "SELECT A.page, A.pageassignees, A.autoassignees, B.lastmod, C.user, C.ack FROM assignments A
293                JOIN pages B
294                ON A.page = B.page
295                LEFT JOIN acks C
296                ON A.page = C.page AND ( (C.user = ? AND C.ack > B.lastmod) )
297                WHERE AUTH_ISMEMBER(A.pageassignees || ',' || A.autoassignees , ? , ?)";
298
299        if (!$includeDone) {
300            $sql .= ' AND ack IS NULL';
301        }
302
303        return $sqlite->queryAll($sql, $user, $user, implode('///', $groups));
304    }
305
306
307    /**
308     * Resolve names of users assigned to a given page
309     *
310     * This can be slow on huge user bases!
311     *
312     * @param string $page
313     * @return array|false
314     */
315    public function getPageAssignees($page)
316    {
317        $sqlite = $this->getDB();
318        if (!$sqlite) return false;
319        /** @var AuthPlugin $auth */
320        global $auth;
321
322        $sql = "SELECT pageassignees || ',' || autoassignees AS 'assignments'
323                  FROM assignments
324                 WHERE page = ?";
325        $assignments = $sqlite->queryValue($sql, $page);
326
327        $users = [];
328        foreach (explode(',', $assignments) as $item) {
329            $item = trim($item);
330            if ($item === '') continue;
331            if ($item[0] == '@') {
332                $users = array_merge(
333                    $users,
334                    array_keys($auth->retrieveUsers(0, 0, ['grps' => substr($item, 1)]))
335                );
336            } else {
337                $users[] = $item;
338            }
339        }
340
341        return array_unique($users);
342    }
343
344    // endregion
345    // region Assignment Patterns
346
347    /**
348     * Get all the assignment patterns
349     * @return array (pattern => assignees)
350     */
351    public function getAssignmentPatterns()
352    {
353        $sqlite = $this->getDB();
354        if (!$sqlite) return [];
355
356        $sql = "SELECT pattern, assignees FROM assignments_patterns";
357        return $sqlite->queryKeyValueList($sql);
358    }
359
360    /**
361     * Save new assignment patterns
362     *
363     * This resaves all patterns and reapplies them
364     *
365     * @param array $patterns (pattern => assignees)
366     */
367    public function saveAssignmentPatterns($patterns)
368    {
369        $sqlite = $this->getDB();
370        if (!$sqlite) return;
371
372        $sqlite->getPdo()->beginTransaction();
373        try {
374
375            /** @noinspection SqlWithoutWhere Remove all assignments */
376            $sql = "UPDATE assignments SET autoassignees = ''";
377            $sqlite->exec($sql);
378
379            /** @noinspection SqlWithoutWhere Remove all patterns */
380            $sql = "DELETE FROM assignments_patterns";
381            $sqlite->exec($sql);
382
383            // insert new patterns and gather affected pages
384            $pages = [];
385
386            $sql = "REPLACE INTO assignments_patterns (pattern, assignees) VALUES (?,?)";
387            foreach ($patterns as $pattern => $assignees) {
388                $pattern = trim($pattern);
389                $assignees = trim($assignees);
390                if (!$pattern || !$assignees) continue;
391                $sqlite->exec($sql, [$pattern, $assignees]);
392
393                // patterns may overlap, so we need to gather all affected pages first
394                $affectedPages = $this->getPagesMatchingPattern($pattern);
395                foreach ($affectedPages as $page) {
396                    if (isset($pages[$page])) {
397                        $pages[$page] .= ',' . $assignees;
398                    } else {
399                        $pages[$page] = $assignees;
400                    }
401                }
402            }
403
404            $sql = "INSERT INTO assignments (page, autoassignees) VALUES (?, ?)
405                ON CONFLICT(page)
406                DO UPDATE SET autoassignees = ?";
407            foreach ($pages as $page => $assignees) {
408                // remove duplicates and empty entries
409                $assignees = implode(',', array_unique(array_filter(array_map('trim', explode(',', $assignees)))));
410                $sqlite->exec($sql, [$page, $assignees, $assignees]);
411            }
412        } catch (Exception $e) {
413            $sqlite->getPdo()->rollBack();
414            throw $e;
415        }
416        $sqlite->getPdo()->commit();
417    }
418
419    /**
420     * Get all known pages that match the given pattern
421     *
422     * @param $pattern
423     * @return string[]
424     */
425    public function getPagesMatchingPattern($pattern)
426    {
427        $sqlite = $this->getDB();
428        if (!$sqlite) return [];
429
430        $sql = "SELECT page FROM pages WHERE MATCHES_PAGE_PATTERN(?, page)";
431        $pages = $sqlite->queryAll($sql, $pattern);
432
433        return array_column($pages, 'page');
434    }
435
436    // endregion
437    // region Acknowledgements
438
439    /**
440     * Has the given user acknowledged the given page?
441     *
442     * @param string $page
443     * @param string $user
444     * @return bool|int timestamp of acknowledgement or false
445     */
446    public function hasUserAcknowledged($page, $user)
447    {
448        $sqlite = $this->getDB();
449        if (!$sqlite) return false;
450
451        $sql = "SELECT ack
452                  FROM acks A, pages B
453                 WHERE A.page = B.page
454                   AND A.page = ?
455                   AND A.user = ?
456                   AND A.ack >= B.lastmod";
457
458        $acktime = $sqlite->queryValue($sql, $page, $user);
459
460        return $acktime ? (int)$acktime : false;
461    }
462
463    /**
464     * Timestamp of the latest acknowledgment of the given page
465     * by the given user
466     *
467     * @param string $page
468     * @param string $user
469     * @return bool|string
470     */
471    public function getLatestUserAcknowledgement($page, $user)
472    {
473        $sqlite = $this->getDB();
474        if (!$sqlite) return false;
475
476        $sql = "SELECT MAX(ack)
477                  FROM acks
478                 WHERE page = ?
479                   AND user = ?";
480
481        return $sqlite->queryValue($sql, [$page, $user]);
482    }
483
484    /**
485     * Save user's acknowledgement for a given page
486     *
487     * @param string $page
488     * @param string $user
489     * @return bool
490     */
491    public function saveAcknowledgement($page, $user)
492    {
493        $sqlite = $this->getDB();
494        if (!$sqlite) return false;
495
496        $sql = "INSERT INTO acks (page, user, ack) VALUES (?,?, strftime('%s','now'))";
497
498        $sqlite->exec($sql, $page, $user);
499        return true;
500    }
501
502    /**
503     * Get all pages a user needs to acknowledge and the last acknowledge date
504     *
505     * @param string $user
506     * @param array $groups
507     * @return array|bool
508     */
509    public function getUserAcknowledgements($user, $groups)
510    {
511        $sqlite = $this->getDB();
512        if (!$sqlite) return false;
513
514        $sql = "SELECT A.page, A.pageassignees, A.autoassignees, B.lastmod, C.user, MAX(C.ack) AS ack
515                  FROM assignments A
516                  JOIN pages B
517                    ON A.page = B.page
518             LEFT JOIN acks C
519                    ON A.page = C.page AND C.user = ?
520                 WHERE AUTH_ISMEMBER(A.pageassignees || ',' || A.autoassignees, ? , ?)
521            GROUP BY A.page
522            ORDER BY A.page
523            ";
524
525        return $sqlite->queryAll($sql, [$user, $user, implode('///', $groups)]);
526    }
527
528    /**
529     * Get ack status for all assigned users of a given page
530     *
531     * This can be slow!
532     *
533     * @param string $page
534     * @return array|false
535     */
536    public function getPageAcknowledgements($page, $max = 0)
537    {
538        $users = $this->getPageAssignees($page);
539        if ($users === false) return false;
540        $sqlite = $this->getDB();
541        if (!$sqlite) return false;
542
543        $ulist = implode(',', array_map([$sqlite->getPdo(), 'quote'], $users));
544        $sql = "SELECT A.page, A.lastmod, B.user, MAX(B.ack) AS ack
545                  FROM pages A
546             LEFT JOIN acks B
547                    ON A.page = B.page
548                   AND B.user IN ($ulist)
549                WHERE  A.page = ?
550              GROUP BY A.page, B.user
551                 ";
552        if ($max) $sql .= " LIMIT $max";
553        $acknowledgements = $sqlite->queryAll($sql, $page);
554
555        // there should be at least one result, unless the page is unknown
556        if (!count($acknowledgements)) return false;
557
558        $baseinfo = [
559            'page' => $acknowledgements[0]['page'],
560            'lastmod' => $acknowledgements[0]['lastmod'],
561            'user' => null,
562            'ack' => null,
563        ];
564
565        // fill up the result with all users that never acknowledged the page
566        $combined = [];
567        foreach ($acknowledgements as $ack) {
568            if ($ack['user'] !== null) {
569                $combined[$ack['user']] = $ack;
570            }
571        }
572        foreach ($users as $user) {
573            if (!isset($combined[$user])) {
574                $combined[$user] = array_merge($baseinfo, ['user' => $user]);
575            }
576        }
577
578        ksort($combined);
579        return array_values($combined);
580    }
581
582    /**
583     * Returns all acknowledgements
584     *
585     * @param int $limit maximum number of results
586     * @return array|bool
587     */
588    public function getAcknowledgements($limit = 100)
589    {
590        $sqlite = $this->getDB();
591        if (!$sqlite) return false;
592
593        $sql = '
594            SELECT A.page, A.user, B.lastmod, max(A.ack) AS ack
595              FROM acks A, pages B
596             WHERE A.page = B.page
597          GROUP BY A.user, A.page
598          ORDER BY ack DESC
599             LIMIT ?
600              ';
601        $acknowledgements = $sqlite->queryAll($sql, $limit);
602
603        return $acknowledgements;
604    }
605
606    // endregion
607}
608