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