xref: /plugin/acknowledge/helper.php (revision 18d15bb8c527bf7b4ba10c42f04713198fd1b718)
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     * filtering already granted acknowledgements.
253     *
254     * @param string $user
255     * @param array $groups
256     * @return array|bool
257     */
258    public function getUserAssignments($user, $groups)
259    {
260        $sqlite = $this->getDB();
261        if (!$sqlite) return false;
262
263        $sql = "SELECT A.page, A.pageassignees, A.autoassignees, B.lastmod, C.user, C.ack FROM assignments A
264                JOIN pages B
265                ON A.page = B.page
266                LEFT JOIN acks C
267                ON A.page = C.page AND ( (C.user = ? AND C.ack > B.lastmod) )
268                WHERE AUTH_ISMEMBER(A.pageassignees || ',' || A.autoassignees , ? , ?)
269                AND ack IS NULL";
270
271        return $sqlite->queryAll($sql, $user, $user, implode('///', $groups));
272    }
273
274
275    /**
276     * Resolve names of users assigned to a given page
277     *
278     * This can be slow on huge user bases!
279     *
280     * @param string $page
281     * @return array|false
282     */
283    public function getPageAssignees($page)
284    {
285        $sqlite = $this->getDB();
286        if (!$sqlite) return false;
287        /** @var AuthPlugin $auth */
288        global $auth;
289
290        $sql = "SELECT pageassignees || ',' || autoassignees AS 'assignments'
291                  FROM assignments
292                 WHERE page = ?";
293        $assignments = $sqlite->queryValue($sql, $page);
294
295        $users = [];
296        foreach (explode(',', $assignments) as $item) {
297            $item = trim($item);
298            if ($item === '') continue;
299            if ($item[0] == '@') {
300                $users = array_merge(
301                    $users,
302                    array_keys($auth->retrieveUsers(0, 0, ['grps' => substr($item, 1)]))
303                );
304            } else {
305                $users[] = $item;
306            }
307        }
308
309        return array_unique($users);
310    }
311
312    // endregion
313    // region Assignment Patterns
314
315    /**
316     * Get all the assignment patterns
317     * @return array (pattern => assignees)
318     */
319    public function getAssignmentPatterns()
320    {
321        $sqlite = $this->getDB();
322        if (!$sqlite) return [];
323
324        $sql = "SELECT pattern, assignees FROM assignments_patterns";
325        return $sqlite->queryKeyValueList($sql);
326    }
327
328    /**
329     * Save new assignment patterns
330     *
331     * This resaves all patterns and reapplies them
332     *
333     * @param array $patterns (pattern => assignees)
334     */
335    public function saveAssignmentPatterns($patterns)
336    {
337        $sqlite = $this->getDB();
338        if (!$sqlite) return;
339
340        $sqlite->getPdo()->beginTransaction();
341        try {
342
343            /** @noinspection SqlWithoutWhere Remove all assignments */
344            $sql = "UPDATE assignments SET autoassignees = ''";
345            $sqlite->exec($sql);
346
347            /** @noinspection SqlWithoutWhere Remove all patterns */
348            $sql = "DELETE FROM assignments_patterns";
349            $sqlite->exec($sql);
350
351            // insert new patterns and gather affected pages
352            $pages = [];
353
354            $sql = "REPLACE INTO assignments_patterns (pattern, assignees) VALUES (?,?)";
355            foreach ($patterns as $pattern => $assignees) {
356                $pattern = trim($pattern);
357                $assignees = trim($assignees);
358                if (!$pattern || !$assignees) continue;
359                $sqlite->exec($sql, [$pattern, $assignees]);
360
361                // patterns may overlap, so we need to gather all affected pages first
362                $affectedPages = $this->getPagesMatchingPattern($pattern);
363                foreach ($affectedPages as $page) {
364                    if (isset($pages[$page])) {
365                        $pages[$page] .= ',' . $assignees;
366                    } else {
367                        $pages[$page] = $assignees;
368                    }
369                }
370            }
371
372            $sql = "INSERT INTO assignments (page, autoassignees) VALUES (?, ?)
373                ON CONFLICT(page)
374                DO UPDATE SET autoassignees = ?";
375            foreach ($pages as $page => $assignees) {
376                // remove duplicates and empty entries
377                $assignees = join(',', array_unique(array_filter(array_map('trim', explode(',', $assignees)))));
378                $sqlite->exec($sql, [$page, $assignees, $assignees]);
379            }
380        } catch (Exception $e) {
381            $sqlite->getPdo()->rollBack();
382            throw $e;
383        }
384        $sqlite->getPdo()->commit();
385    }
386
387    /**
388     * Get all known pages that match the given pattern
389     *
390     * @param $pattern
391     * @return string[]
392     */
393    public function getPagesMatchingPattern($pattern)
394    {
395        $sqlite = $this->getDB();
396        if (!$sqlite) return [];
397
398        $sql = "SELECT page FROM pages WHERE MATCHES_PAGE_PATTERN(?, page)";
399        $pages = $sqlite->queryAll($sql, $pattern);
400
401        return array_column($pages, 'page');
402    }
403
404    // endregion
405    // region Acknowledgements
406
407    /**
408     * Has the given user acknowledged the given page?
409     *
410     * @param string $page
411     * @param string $user
412     * @return bool|int timestamp of acknowledgement or false
413     */
414    public function hasUserAcknowledged($page, $user)
415    {
416        $sqlite = $this->getDB();
417        if (!$sqlite) return false;
418
419        $sql = "SELECT ack
420                  FROM acks A, pages B
421                 WHERE A.page = B.page
422                   AND A.page = ?
423                   AND A.user = ?
424                   AND A.ack >= B.lastmod";
425
426        $acktime = $sqlite->queryValue($sql, $page, $user);
427
428        return $acktime ? (int)$acktime : false;
429    }
430
431    /**
432     * Timestamp of the latest acknowledgment of the given page
433     * by the given user
434     *
435     * @param string $page
436     * @param string $user
437     * @return bool|string
438     */
439    public function getLatestUserAcknowledgement($page, $user)
440    {
441        $sqlite = $this->getDB();
442        if (!$sqlite) return false;
443
444        $sql = "SELECT MAX(ack)
445                  FROM acks
446                 WHERE page = ?
447                   AND user = ?";
448
449        return $sqlite->queryValue($sql, [$page, $user]);
450    }
451
452    /**
453     * Save user's acknowledgement for a given page
454     *
455     * @param string $page
456     * @param string $user
457     * @return bool
458     */
459    public function saveAcknowledgement($page, $user)
460    {
461        $sqlite = $this->getDB();
462        if (!$sqlite) return false;
463
464        $sql = "INSERT INTO acks (page, user, ack) VALUES (?,?, strftime('%s','now'))";
465
466        $sqlite->exec($sql, $page, $user);
467        return true;
468
469    }
470
471    /**
472     * Get all pages a user needs to acknowledge and the last acknowledge date
473     *
474     * @param string $user
475     * @param array $groups
476     * @return array|bool
477     */
478    public function getUserAcknowledgements($user, $groups)
479    {
480        $sqlite = $this->getDB();
481        if (!$sqlite) return false;
482
483        $sql = "SELECT A.page, A.pageassignees, A.autoassignees, B.lastmod, C.user, MAX(C.ack) AS ack
484                  FROM assignments A
485                  JOIN pages B
486                    ON A.page = B.page
487             LEFT JOIN acks C
488                    ON A.page = C.page AND C.user = ?
489                 WHERE AUTH_ISMEMBER(A.pageassignees || ',' || A.autoassignees, ? , ?)
490            GROUP BY A.page
491            ORDER BY A.page
492            ";
493
494        return $sqlite->queryAll($sql, [$user, $user, implode('///', $groups)]);
495    }
496
497    /**
498     * Get ack status for all assigned users of a given page
499     *
500     * This can be slow!
501     *
502     * @param string $page
503     * @return array|false
504     */
505    public function getPageAcknowledgements($page, $max=0)
506    {
507        $users = $this->getPageAssignees($page);
508        if ($users === false) return false;
509        $sqlite = $this->getDB();
510        if (!$sqlite) return false;
511
512        $ulist = join(',', array_map([$sqlite->getPdo(), 'quote'], $users));
513        $sql = "SELECT A.page, A.lastmod, B.user, MAX(B.ack) AS ack
514                  FROM pages A
515             LEFT JOIN acks B
516                    ON A.page = B.page
517                   AND B.user IN ($ulist)
518                WHERE  A.page = ?
519              GROUP BY A.page, B.user
520                 ";
521        if($max) $sql .= " LIMIT $max";
522        $acknowledgements = $sqlite->queryAll($sql, $page);
523
524        // there should be at least one result, unless the page is unknown
525        if (!count($acknowledgements)) return false;
526
527        $baseinfo = [
528            'page' => $acknowledgements[0]['page'],
529            'lastmod' => $acknowledgements[0]['lastmod'],
530            'user' => null,
531            'ack' => null,
532        ];
533
534        // fill up the result with all users that never acknowledged the page
535        $combined = [];
536        foreach ($acknowledgements as $ack) {
537            if ($ack['user'] !== null) {
538                $combined[$ack['user']] = $ack;
539            }
540        }
541        foreach ($users as $user) {
542            if (!isset($combined[$user])) {
543                $combined[$user] = array_merge($baseinfo, ['user' => $user]);
544            }
545        }
546
547        ksort($combined);
548        return array_values($combined);
549    }
550
551    /**
552     * Returns all acknowledgements
553     *
554     * @param int $limit maximum number of results
555     * @return array|bool
556     */
557    public function getAcknowledgements($limit = 100)
558    {
559        $sqlite = $this->getDB();
560        if (!$sqlite) return false;
561
562        $sql = '
563            SELECT A.page, A.user, B.lastmod, max(A.ack) AS ack
564              FROM acks A, pages B
565             WHERE A.page = B.page
566          GROUP BY A.user, A.page
567          ORDER BY ack DESC
568             LIMIT ?
569              ';
570        $acknowledgements = $sqlite->queryAll($sql, $limit);
571
572        return $acknowledgements;
573    }
574
575    // endregion
576}
577
578