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