xref: /plugin/acknowledge/helper.php (revision ba917e3310e630013b2322a022ecb8eae04163fc)
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    // endregion
126    // region Page Data
127
128    /**
129     * Delete a page
130     *
131     * Cascades to delete all assigned data, etc.
132     *
133     * @param string $page Page ID
134     */
135    public function removePage($page)
136    {
137        $sqlite = $this->getDB();
138        if (!$sqlite) return;
139
140        $sql = "DELETE FROM pages WHERE page = ?";
141        $sqlite->exec($sql, $page);
142    }
143
144    /**
145     * Update last modified date of page if content has changed
146     *
147     * @param string $page Page ID
148     * @param int $lastmod timestamp of last non-minor change
149     */
150    public function storePageDate($page, $lastmod, $newContent)
151    {
152        $changelog = new PageChangeLog($page);
153        $revs = $changelog->getRevisions(0, 1);
154
155        // compare content
156        $oldContent = str_replace(NL, '', io_readFile(wikiFN($page, $revs[0])));
157        $newContent = str_replace(NL, '', $newContent);
158        if ($oldContent === $newContent) return;
159
160        $sqlite = $this->getDB();
161        if (!$sqlite) return;
162
163        $sql = "REPLACE INTO pages (page, lastmod) VALUES (?,?)";
164        $sqlite->exec($sql, [$page, $lastmod]);
165    }
166
167    // endregion
168    // region Assignments
169
170    /**
171     * Clears direct assignments for a page
172     *
173     * @param string $page Page ID
174     */
175    public function clearPageAssignments($page)
176    {
177        $sqlite = $this->getDB();
178        if (!$sqlite) return;
179
180        $sql = "UPDATE assignments SET pageassignees = '' WHERE page = ?";
181        $sqlite->exec($sql, $page);
182    }
183
184    /**
185     * Set assignees for a given page as manually specified
186     *
187     * @param string $page Page ID
188     * @param string $assignees
189     * @return void
190     */
191    public function setPageAssignees($page, $assignees)
192    {
193        $sqlite = $this->getDB();
194        if (!$sqlite) return;
195
196        $assignees = implode(',', array_unique(array_filter(array_map('trim', explode(',', $assignees)))));
197
198        $sql = "REPLACE INTO assignments ('page', 'pageassignees') VALUES (?,?)";
199        $sqlite->exec($sql, [$page, $assignees]);
200    }
201
202    /**
203     * Set assignees for a given page from the patterns
204     * @param string $page Page ID
205     */
206    public function setAutoAssignees($page)
207    {
208        $sqlite = $this->getDB();
209        if (!$sqlite) return;
210
211        $patterns = $this->getAssignmentPatterns();
212
213        // given assignees
214        $assignees = '';
215
216        // find all patterns that match the page and add the configured assignees
217        foreach ($patterns as $pattern => $assignees) {
218            if ($this->matchPagePattern($pattern, $page)) {
219                $assignees .= ',' . $assignees;
220            }
221        }
222
223        // remove duplicates and empty entries
224        $assignees = implode(',', array_unique(array_filter(array_map('trim', explode(',', $assignees)))));
225
226        // store the assignees
227        $sql = "REPLACE INTO assignments ('page', 'autoassignees') VALUES (?,?)";
228        $sqlite->exec($sql, [$page, $assignees]);
229    }
230
231    /**
232     * Is the given user one of the assignees for this page
233     *
234     * @param string $page Page ID
235     * @param string $user user name to check
236     * @param string[] $groups groups this user is in
237     * @return bool
238     */
239    public function isUserAssigned($page, $user, $groups)
240    {
241        $sqlite = $this->getDB();
242        if (!$sqlite) return false;
243
244        $sql = "SELECT pageassignees,autoassignees FROM assignments WHERE page = ?";
245        $record = $sqlite->queryRecord($sql, $page);
246        if (!$record) return false;
247        $assignees = $record['pageassignees'] . ',' . $record['autoassignees'];
248        return auth_isMember($assignees, $user, $groups);
249    }
250
251    /**
252     * Fetch all assignments for a given user, with additional page information,
253     * by default filtering already granted acknowledgements.
254     * Filter can be switched off via $includeDone
255     *
256     * @param string $user
257     * @param array $groups
258     * @param bool $includeDone
259     *
260     * @return array|bool
261     */
262    public function getUserAssignments($user, $groups, $includeDone = false)
263    {
264        $sqlite = $this->getDB();
265        if (!$sqlite) return false;
266
267        $sql = "SELECT A.page, A.pageassignees, A.autoassignees, B.lastmod, C.user, C.ack FROM assignments A
268                JOIN pages B
269                ON A.page = B.page
270                LEFT JOIN acks C
271                ON A.page = C.page AND ( (C.user = ? AND C.ack > B.lastmod) )
272                WHERE AUTH_ISMEMBER(A.pageassignees || ',' || A.autoassignees , ? , ?)";
273
274        if (!$includeDone) {
275            $sql .= ' AND ack IS NULL';
276        }
277
278        return $sqlite->queryAll($sql, $user, $user, implode('///', $groups));
279    }
280
281
282    /**
283     * Resolve names of users assigned to a given page
284     *
285     * This can be slow on huge user bases!
286     *
287     * @param string $page
288     * @return array|false
289     */
290    public function getPageAssignees($page)
291    {
292        $sqlite = $this->getDB();
293        if (!$sqlite) return false;
294        /** @var AuthPlugin $auth */
295        global $auth;
296
297        $sql = "SELECT pageassignees || ',' || autoassignees AS 'assignments'
298                  FROM assignments
299                 WHERE page = ?";
300        $assignments = $sqlite->queryValue($sql, $page);
301
302        $users = [];
303        foreach (explode(',', $assignments) as $item) {
304            $item = trim($item);
305            if ($item === '') continue;
306            if ($item[0] == '@') {
307                $users = array_merge(
308                    $users,
309                    array_keys($auth->retrieveUsers(0, 0, ['grps' => substr($item, 1)]))
310                );
311            } else {
312                $users[] = $item;
313            }
314        }
315
316        return array_unique($users);
317    }
318
319    // endregion
320    // region Assignment Patterns
321
322    /**
323     * Get all the assignment patterns
324     * @return array (pattern => assignees)
325     */
326    public function getAssignmentPatterns()
327    {
328        $sqlite = $this->getDB();
329        if (!$sqlite) return [];
330
331        $sql = "SELECT pattern, assignees FROM assignments_patterns";
332        return $sqlite->queryKeyValueList($sql);
333    }
334
335    /**
336     * Save new assignment patterns
337     *
338     * This resaves all patterns and reapplies them
339     *
340     * @param array $patterns (pattern => assignees)
341     */
342    public function saveAssignmentPatterns($patterns)
343    {
344        $sqlite = $this->getDB();
345        if (!$sqlite) return;
346
347        $sqlite->getPdo()->beginTransaction();
348        try {
349
350            /** @noinspection SqlWithoutWhere Remove all assignments */
351            $sql = "UPDATE assignments SET autoassignees = ''";
352            $sqlite->exec($sql);
353
354            /** @noinspection SqlWithoutWhere Remove all patterns */
355            $sql = "DELETE FROM assignments_patterns";
356            $sqlite->exec($sql);
357
358            // insert new patterns and gather affected pages
359            $pages = [];
360
361            $sql = "REPLACE INTO assignments_patterns (pattern, assignees) VALUES (?,?)";
362            foreach ($patterns as $pattern => $assignees) {
363                $pattern = trim($pattern);
364                $assignees = trim($assignees);
365                if (!$pattern || !$assignees) continue;
366                $sqlite->exec($sql, [$pattern, $assignees]);
367
368                // patterns may overlap, so we need to gather all affected pages first
369                $affectedPages = $this->getPagesMatchingPattern($pattern);
370                foreach ($affectedPages as $page) {
371                    if (isset($pages[$page])) {
372                        $pages[$page] .= ',' . $assignees;
373                    } else {
374                        $pages[$page] = $assignees;
375                    }
376                }
377            }
378
379            $sql = "INSERT INTO assignments (page, autoassignees) VALUES (?, ?)
380                ON CONFLICT(page)
381                DO UPDATE SET autoassignees = ?";
382            foreach ($pages as $page => $assignees) {
383                // remove duplicates and empty entries
384                $assignees = implode(',', array_unique(array_filter(array_map('trim', explode(',', $assignees)))));
385                $sqlite->exec($sql, [$page, $assignees, $assignees]);
386            }
387        } catch (Exception $e) {
388            $sqlite->getPdo()->rollBack();
389            throw $e;
390        }
391        $sqlite->getPdo()->commit();
392    }
393
394    /**
395     * Get all known pages that match the given pattern
396     *
397     * @param $pattern
398     * @return string[]
399     */
400    public function getPagesMatchingPattern($pattern)
401    {
402        $sqlite = $this->getDB();
403        if (!$sqlite) return [];
404
405        $sql = "SELECT page FROM pages WHERE MATCHES_PAGE_PATTERN(?, page)";
406        $pages = $sqlite->queryAll($sql, $pattern);
407
408        return array_column($pages, 'page');
409    }
410
411    // endregion
412    // region Acknowledgements
413
414    /**
415     * Has the given user acknowledged the given page?
416     *
417     * @param string $page
418     * @param string $user
419     * @return bool|int timestamp of acknowledgement or false
420     */
421    public function hasUserAcknowledged($page, $user)
422    {
423        $sqlite = $this->getDB();
424        if (!$sqlite) return false;
425
426        $sql = "SELECT ack
427                  FROM acks A, pages B
428                 WHERE A.page = B.page
429                   AND A.page = ?
430                   AND A.user = ?
431                   AND A.ack >= B.lastmod";
432
433        $acktime = $sqlite->queryValue($sql, $page, $user);
434
435        return $acktime ? (int)$acktime : false;
436    }
437
438    /**
439     * Timestamp of the latest acknowledgment of the given page
440     * by the given user
441     *
442     * @param string $page
443     * @param string $user
444     * @return bool|string
445     */
446    public function getLatestUserAcknowledgement($page, $user)
447    {
448        $sqlite = $this->getDB();
449        if (!$sqlite) return false;
450
451        $sql = "SELECT MAX(ack)
452                  FROM acks
453                 WHERE page = ?
454                   AND user = ?";
455
456        return $sqlite->queryValue($sql, [$page, $user]);
457    }
458
459    /**
460     * Save user's acknowledgement for a given page
461     *
462     * @param string $page
463     * @param string $user
464     * @return bool
465     */
466    public function saveAcknowledgement($page, $user)
467    {
468        $sqlite = $this->getDB();
469        if (!$sqlite) return false;
470
471        $sql = "INSERT INTO acks (page, user, ack) VALUES (?,?, strftime('%s','now'))";
472
473        $sqlite->exec($sql, $page, $user);
474        return true;
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 = implode(',', 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