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