xref: /plugin/acknowledge/helper.php (revision 639d4c5095add499f9d33c658a27ce7d769e3d80)
1<?php
2
3use dokuwiki\Extension\AuthPlugin;
4
5/**
6 * DokuWiki Plugin acknowledge (Helper Component)
7 *
8 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
9 * @author  Andreas Gohr, Anna Dabrowska <dokuwiki@cosmocode.de>
10 */
11class helper_plugin_acknowledge extends DokuWiki_Plugin
12{
13
14    // region Database Management
15
16    /**
17     * @return helper_plugin_sqlite|null
18     */
19    public function getDB()
20    {
21        /** @var \helper_plugin_sqlite $sqlite */
22        $sqlite = plugin_load('helper', 'sqlite');
23        if ($sqlite === null) {
24            msg($this->getLang('error sqlite plugin missing'), -1);
25            return null;
26        }
27        $sqlite->getAdapter()->setUseNativeAlter(true);
28        if (!$sqlite->init('acknowledgement', __DIR__ . '/db')) {
29            return null;
30        }
31
32        $this->registerUDF($sqlite);
33
34        return $sqlite;
35    }
36
37    /**
38     * Register user defined functions
39     *
40     * @param helper_plugin_sqlite $sqlite
41     */
42    protected function registerUDF($sqlite)
43    {
44        $sqlite->create_function('AUTH_ISMEMBER', [$this, 'auth_isMember'], -1);
45        $sqlite->create_function('MATCHES_PAGE_PATTERN', [$this, 'matchPagePattern'], 2);
46    }
47
48    /**
49     * Wrapper function for auth_isMember which accepts groups as string
50     *
51     * @param string $memberList
52     * @param string $user
53     * @param string $groups
54     * @return bool
55     */
56    public function auth_isMember($memberList, $user, $groups)
57    {
58        return auth_isMember($memberList, $user, explode('///', $groups));
59    }
60
61    /**
62     * Fills the page index with all unknown pages from the fulltext index
63     * @return void
64     */
65    public function updatePageIndex()
66    {
67        $sqlite = $this->getDB();
68        if (!$sqlite) return;
69
70        $pages = idx_getIndex('page', '');
71        $sql = "INSERT OR IGNORE INTO pages (page, lastmod) VALUES (?,?)";
72
73        $sqlite->query('BEGIN TRANSACTION');
74        foreach ($pages as $page) {
75            $page = trim($page);
76            $lastmod = @filemtime(wikiFN($page));
77            if ($lastmod) {
78                $sqlite->query($sql, $page, $lastmod);
79            }
80        }
81        $sqlite->query('COMMIT TRANSACTION');
82    }
83
84    /**
85     * Check if the given pattern matches the given page
86     *
87     * @param string $pattern the pattern to check against
88     * @param string $page the cleaned pageid to check
89     * @return bool
90     */
91    public function matchPagePattern($pattern, $page)
92    {
93        if (trim($pattern, ':') == '**') return true; // match all
94
95        // regex patterns
96        if ($pattern[0] == '/') {
97            return (bool)preg_match($pattern, ":$page");
98        }
99
100        $pns = ':' . getNS($page) . ':';
101
102        $ans = ':' . cleanID($pattern) . ':';
103        if (substr($pattern, -2) == '**') {
104            // upper namespaces match
105            if (strpos($pns, $ans) === 0) {
106                return true;
107            }
108        } elseif (substr($pattern, -1) == '*') {
109            // namespaces match exact
110            if ($ans == $pns) {
111                return true;
112            }
113        } else {
114            // exact match
115            if (cleanID($pattern) == $page) {
116                return true;
117            }
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->query($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 \dokuwiki\ChangeLog\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->query($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->query($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 = join(',', array_unique(array_filter(array_map('trim', explode(',', $assignees)))));
195
196        $sql = "REPLACE INTO assignments ('page', 'pageassignees') VALUES (?,?)";
197        $sqlite->query($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 = join(',', array_unique(array_filter(array_map('trim', explode(',', $assignees)))));
223
224        // store the assignees
225        $sql = "REPLACE INTO assignments ('page', 'autoassignees') VALUES (?,?)";
226        $sqlite->query($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        $result = $sqlite->query($sql, $page);
244        $row = $sqlite->res2row($result);
245        $sqlite->res_close($result);
246        $assignees = $row['pageassignees'] . ',' . $row['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        $result = $sqlite->query($sql, $user, $user, implode('///', $groups));
272        $assignments = $sqlite->res2arr($result);
273        $sqlite->res_close($result);
274
275        return $assignments;
276    }
277
278
279    /**
280     * Resolve names of users assigned to a given page
281     *
282     * This can be slow on huge user bases!
283     *
284     * @param string $page
285     * @return array|false
286     */
287    public function getPageAssignees($page)
288    {
289        $sqlite = $this->getDB();
290        if (!$sqlite) return false;
291        /** @var AuthPlugin $auth */
292        global $auth;
293
294        $sql = "SELECT pageassignees || ',' || autoassignees AS 'assignments'
295                  FROM assignments
296                 WHERE page = ?";
297        $result = $sqlite->query($sql, $page);
298        $assignments = $sqlite->res2single($result);
299        $sqlite->res_close($result);
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        $result = $sqlite->query($sql);
332        $patterns = $sqlite->res2arr($result);
333        $sqlite->res_close($result);
334
335        return array_combine(
336            array_column($patterns, 'pattern'),
337            array_column($patterns, 'assignees')
338        );
339    }
340
341    /**
342     * Save new assignment patterns
343     *
344     * This resaves all patterns and reapplies them
345     *
346     * @param array $patterns (pattern => assignees)
347     */
348    public function saveAssignmentPatterns($patterns)
349    {
350        $sqlite = $this->getDB();
351        if (!$sqlite) return;
352
353        $sqlite->query('BEGIN TRANSACTION');
354
355        /** @noinsp0ection SqlWithoutWhere Remove all assignments */
356        $sql = "UPDATE assignments SET autoassignees = ''";
357        $sqlite->query($sql);
358
359        /** @noinspection SqlWithoutWhere Remove all patterns */
360        $sql = "DELETE FROM assignments_patterns";
361        $sqlite->query($sql);
362
363        // insert new patterns and gather affected pages
364        $pages = [];
365
366        $sql = "REPLACE INTO assignments_patterns (pattern, assignees) VALUES (?,?)";
367        foreach ($patterns as $pattern => $assignees) {
368            $pattern = trim($pattern);
369            $assignees = trim($assignees);
370            if (!$pattern || !$assignees) continue;
371            $sqlite->query($sql, $pattern, $assignees);
372
373            // patterns may overlap, so we need to gather all affected pages first
374            $affectedPages = $this->getPagesMatchingPattern($pattern);
375            foreach ($affectedPages as $page) {
376                if (isset($pages[$page])) {
377                    $pages[$page] .= ',' . $assignees;
378                } else {
379                    $pages[$page] = $assignees;
380                }
381            }
382        }
383
384        $sql = "INSERT INTO assignments (page, autoassignees) VALUES (?, ?)
385                ON CONFLICT(page)
386                DO UPDATE SET autoassignees = ?";
387        foreach ($pages as $page => $assignees) {
388            // remove duplicates and empty entries
389            $assignees = join(',', array_unique(array_filter(array_map('trim', explode(',', $assignees)))));
390            $sqlite->query($sql, $page, $assignees, $assignees);
391        }
392
393        $sqlite->query('COMMIT TRANSACTION');
394    }
395
396    /**
397     * Get all known pages that match the given pattern
398     *
399     * @param $pattern
400     * @return string[]
401     */
402    public function getPagesMatchingPattern($pattern)
403    {
404        $sqlite = $this->getDB();
405        if (!$sqlite) return [];
406
407        $sql = "SELECT page FROM pages WHERE MATCHES_PAGE_PATTERN(?, page)";
408        $result = $sqlite->query($sql, $pattern);
409        $pages = $sqlite->res2arr($result);
410        $sqlite->res_close($result);
411
412        return array_column($pages, 'page');
413    }
414
415    // endregion
416    // region Acknowledgements
417
418    /**
419     * Has the given user acknowledged the given page?
420     *
421     * @param string $page
422     * @param string $user
423     * @return bool|int timestamp of acknowledgement or false
424     */
425    public function hasUserAcknowledged($page, $user)
426    {
427        $sqlite = $this->getDB();
428        if (!$sqlite) return false;
429
430        $sql = "SELECT ack
431                  FROM acks A, pages B
432                 WHERE A.page = B.page
433                   AND A.page = ?
434                   AND A.user = ?
435                   AND A.ack >= B.lastmod";
436
437        $result = $sqlite->query($sql, $page, $user);
438        $acktime = $sqlite->res2single($result);
439        $sqlite->res_close($result);
440
441        return $acktime ? (int)$acktime : false;
442    }
443
444    /**
445     * Timestamp of the latest acknowledgment of the given page
446     * by the given user
447     *
448     * @param string $page
449     * @param string $user
450     * @return bool|string
451     */
452    public function getLatestUserAcknowledgement($page, $user)
453    {
454        $sqlite = $this->getDB();
455        if (!$sqlite) return false;
456
457        $sql = "SELECT MAX(ack)
458                  FROM acks
459                 WHERE page = ?
460                   AND user = ?";
461
462        $result = $sqlite->query($sql, $page, $user);
463        $latestAck = $sqlite->res2single($result);
464        $sqlite->res_close($result);
465
466        return $latestAck;
467    }
468
469    /**
470     * Save user's acknowledgement for a given page
471     *
472     * @param string $page
473     * @param string $user
474     * @return bool
475     */
476    public function saveAcknowledgement($page, $user)
477    {
478        $sqlite = $this->getDB();
479        if (!$sqlite) return false;
480
481        $sql = "INSERT INTO acks (page, user, ack) VALUES (?,?, strftime('%s','now'))";
482
483        $result = $sqlite->query($sql, $page, $user);
484        $sqlite->res_close($result);
485        return true;
486
487    }
488
489    /**
490     * Get all pages a user needs to acknowledge and the last acknowledge date
491     *
492     * @param string $user
493     * @param array $groups
494     * @return array|bool
495     */
496    public function getUserAcknowledgements($user, $groups)
497    {
498        $sqlite = $this->getDB();
499        if (!$sqlite) return false;
500
501        $sql = "SELECT A.page, A.pageassignees, A.autoassignees, B.lastmod, C.user, MAX(C.ack) AS ack
502                  FROM assignments A
503                  JOIN pages B
504                    ON A.page = B.page
505             LEFT JOIN acks C
506                    ON A.page = C.page AND C.user = ?
507                 WHERE AUTH_ISMEMBER(A.pageassignees || ',' || A.autoassignees, ? , ?)
508            GROUP BY A.page
509            ORDER BY A.page
510            ";
511
512        $result = $sqlite->query($sql, $user, $user, implode('///', $groups));
513        $assignments = $sqlite->res2arr($result);
514        $sqlite->res_close($result);
515
516        return $assignments;
517    }
518
519    /**
520     * Get ack status for all assigned users of a given page
521     *
522     * This can be slow!
523     *
524     * @param string $page
525     * @return array|false
526     */
527    public function getPageAcknowledgements($page)
528    {
529        $users = $this->getPageAssignees($page);
530        if ($users === false) return false;
531        $sqlite = $this->getDB();
532        if (!$sqlite) return false;
533
534        $ulist = $sqlite->quote_and_join($users);
535        $sql = "SELECT A.page, A.lastmod, B.user, MAX(B.ack) AS ack
536                  FROM pages A
537             LEFT JOIN acks B
538                    ON A.page = B.page
539                   AND B.user IN ($ulist)
540                WHERE  A.page = ?
541              GROUP BY A.page, B.user
542                 ";
543        $result = $sqlite->query($sql, $page);
544        $acknowledgements = $sqlite->res2arr($result);
545        $sqlite->res_close($result);
546
547        // there should be at least one result, unless the page is unknown
548        if (!count($acknowledgements)) return false;
549
550        $baseinfo = [
551            'page' => $acknowledgements[0]['page'],
552            'lastmod' => $acknowledgements[0]['lastmod'],
553            'user' => null,
554            'ack' => null,
555        ];
556
557        // fill up the result with all users that never acknowledged the page
558        $combined = [];
559        foreach ($acknowledgements as $ack) {
560            if ($ack['user'] !== null) {
561                $combined[$ack['user']] = $ack;
562            }
563        }
564        foreach ($users as $user) {
565            if (!isset($combined[$user])) {
566                $combined[$user] = array_merge($baseinfo, ['user' => $user]);
567            }
568        }
569
570        ksort($combined);
571        return array_values($combined);
572    }
573
574    /**
575     * Returns all acknowledgements
576     *
577     * @param int $limit maximum number of results
578     * @return array|bool
579     */
580    public function getAcknowledgements($limit = 100)
581    {
582        $sqlite = $this->getDB();
583        if (!$sqlite) return false;
584
585        $sql = '
586            SELECT A.page, A.user, B.lastmod, max(A.ack) AS ack
587              FROM acks A, pages B
588             WHERE A.page = B.page
589          GROUP BY A.user, A.page
590          ORDER BY ack DESC
591             LIMIT ?
592              ';
593        $result = $sqlite->query($sql, $limit);
594        $acknowledgements = $sqlite->res2arr($result);
595        $sqlite->res_close($result);
596
597        return $acknowledgements;
598    }
599
600    // endregion
601}
602
603