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