xref: /plugin/acknowledge/helper.php (revision 635f6fb77177971ee71b92b90be888a3b21b0d6f)
14d6d17d0SAndreas Gohr<?php
2c6d8c1d9SAndreas Gohr
33b76424dSanndause dokuwiki\Extension\Plugin;
43b76424dSanndause dokuwiki\ChangeLog\PageChangeLog;
5fea1a86fSAndreas Gohruse dokuwiki\ErrorHandler;
6c6d8c1d9SAndreas Gohruse dokuwiki\Extension\AuthPlugin;
7fea1a86fSAndreas Gohruse dokuwiki\plugin\sqlite\SQLiteDB;
8c6d8c1d9SAndreas Gohr
94d6d17d0SAndreas Gohr/**
104d6d17d0SAndreas Gohr * DokuWiki Plugin acknowledge (Helper Component)
114d6d17d0SAndreas Gohr *
124d6d17d0SAndreas Gohr * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
134d6d17d0SAndreas Gohr * @author  Andreas Gohr, Anna Dabrowska <dokuwiki@cosmocode.de>
144d6d17d0SAndreas Gohr */
153b76424dSanndaclass helper_plugin_acknowledge extends Plugin
164d6d17d0SAndreas Gohr{
17fea1a86fSAndreas Gohr    protected $db;
18fea1a86fSAndreas Gohr
19639d4c50SAndreas Gohr    // region Database Management
20639d4c50SAndreas Gohr
21cabb51d3SAndreas Gohr    /**
22dee5618cSAnna Dabrowska     * Constructor
23fea1a86fSAndreas Gohr     *
24dee5618cSAnna Dabrowska     * @return void
25dee5618cSAnna Dabrowska     * @throws Exception
26cabb51d3SAndreas Gohr     */
27dee5618cSAnna Dabrowska    public function __construct()
28cabb51d3SAndreas Gohr    {
29fea1a86fSAndreas Gohr        if ($this->db === null) {
30fea1a86fSAndreas Gohr            try {
31fea1a86fSAndreas Gohr                $this->db = new SQLiteDB('acknowledgement', __DIR__ . '/db');
32fea1a86fSAndreas Gohr
33fea1a86fSAndreas Gohr                // register our custom functions
34fea1a86fSAndreas Gohr                $this->db->getPdo()->sqliteCreateFunction('AUTH_ISMEMBER', [$this, 'auth_isMember'], -1);
35fea1a86fSAndreas Gohr                $this->db->getPdo()->sqliteCreateFunction('MATCHES_PAGE_PATTERN', [$this, 'matchPagePattern'], 2);
36fea1a86fSAndreas Gohr            } catch (\Exception $exception) {
37fea1a86fSAndreas Gohr                if (defined('DOKU_UNITTEST')) throw new \RuntimeException('Could not load SQLite', 0, $exception);
38fea1a86fSAndreas Gohr                ErrorHandler::logException($exception);
39cabb51d3SAndreas Gohr                msg($this->getLang('error sqlite plugin missing'), -1);
40dee5618cSAnna Dabrowska                throw $exception;
41cabb51d3SAndreas Gohr            }
42cabb51d3SAndreas Gohr        }
43dee5618cSAnna Dabrowska    }
44dee5618cSAnna Dabrowska
45dee5618cSAnna Dabrowska    /**
46dee5618cSAnna Dabrowska     * Wrapper for test DB access
47dee5618cSAnna Dabrowska     *
48dee5618cSAnna Dabrowska     * @return SQLiteDB
49dee5618cSAnna Dabrowska     */
50dee5618cSAnna Dabrowska    public function getDB()
51dee5618cSAnna Dabrowska    {
52fea1a86fSAndreas Gohr        return $this->db;
539c3eae1eSAnna Dabrowska    }
549c3eae1eSAnna Dabrowska
559c3eae1eSAnna Dabrowska    /**
569c3eae1eSAnna Dabrowska     * Wrapper function for auth_isMember which accepts groups as string
579c3eae1eSAnna Dabrowska     *
589c3eae1eSAnna Dabrowska     * @param string $memberList
599c3eae1eSAnna Dabrowska     * @param string $user
609c3eae1eSAnna Dabrowska     * @param string $groups
61ba917e33SAnna Dabrowska     *
629c3eae1eSAnna Dabrowska     * @return bool
639c3eae1eSAnna Dabrowska     */
64ba917e33SAnna Dabrowska    // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
659c3eae1eSAnna Dabrowska    public function auth_isMember($memberList, $user, $groups)
669c3eae1eSAnna Dabrowska    {
6795113ed8SAnna Dabrowska        return auth_isMember($memberList, $user, explode('///', $groups));
689c3eae1eSAnna Dabrowska    }
699c3eae1eSAnna Dabrowska
709c3eae1eSAnna Dabrowska    /**
71639d4c50SAndreas Gohr     * Fills the page index with all unknown pages from the fulltext index
72639d4c50SAndreas Gohr     * @return void
73639d4c50SAndreas Gohr     */
74639d4c50SAndreas Gohr    public function updatePageIndex()
75639d4c50SAndreas Gohr    {
76639d4c50SAndreas Gohr        $pages = idx_getIndex('page', '');
77639d4c50SAndreas Gohr        $sql = "INSERT OR IGNORE INTO pages (page, lastmod) VALUES (?,?)";
78639d4c50SAndreas Gohr
79dee5618cSAnna Dabrowska        $this->db->getPdo()->beginTransaction();
80639d4c50SAndreas Gohr        foreach ($pages as $page) {
81639d4c50SAndreas Gohr            $page = trim($page);
82639d4c50SAndreas Gohr            $lastmod = @filemtime(wikiFN($page));
83639d4c50SAndreas Gohr            if ($lastmod) {
84fea1a86fSAndreas Gohr                try {
85dee5618cSAnna Dabrowska                    $this->db->exec($sql, [$page, $lastmod]);
86fea1a86fSAndreas Gohr                } catch (\Exception $exception) {
87dee5618cSAnna Dabrowska                    $this->db->getPdo()->rollBack();
88fea1a86fSAndreas Gohr                    throw $exception;
89639d4c50SAndreas Gohr                }
90639d4c50SAndreas Gohr            }
91fea1a86fSAndreas Gohr        }
92dee5618cSAnna Dabrowska        $this->db->getPdo()->commit();
93639d4c50SAndreas Gohr    }
94639d4c50SAndreas Gohr
95639d4c50SAndreas Gohr    /**
96639d4c50SAndreas Gohr     * Check if the given pattern matches the given page
97639d4c50SAndreas Gohr     *
98639d4c50SAndreas Gohr     * @param string $pattern the pattern to check against
99639d4c50SAndreas Gohr     * @param string $page the cleaned pageid to check
100639d4c50SAndreas Gohr     * @return bool
101639d4c50SAndreas Gohr     */
102639d4c50SAndreas Gohr    public function matchPagePattern($pattern, $page)
103639d4c50SAndreas Gohr    {
104639d4c50SAndreas Gohr        if (trim($pattern, ':') == '**') return true; // match all
105639d4c50SAndreas Gohr
106639d4c50SAndreas Gohr        // regex patterns
107639d4c50SAndreas Gohr        if ($pattern[0] == '/') {
108639d4c50SAndreas Gohr            return (bool)preg_match($pattern, ":$page");
109639d4c50SAndreas Gohr        }
110639d4c50SAndreas Gohr
111639d4c50SAndreas Gohr        $pns = ':' . getNS($page) . ':';
112639d4c50SAndreas Gohr
113639d4c50SAndreas Gohr        $ans = ':' . cleanID($pattern) . ':';
114639d4c50SAndreas Gohr        if (substr($pattern, -2) == '**') {
115639d4c50SAndreas Gohr            // upper namespaces match
116639d4c50SAndreas Gohr            if (strpos($pns, $ans) === 0) {
117639d4c50SAndreas Gohr                return true;
118639d4c50SAndreas Gohr            }
119639d4c50SAndreas Gohr        } elseif (substr($pattern, -1) == '*') {
120639d4c50SAndreas Gohr            // namespaces match exact
1213b76424dSannda            if ($ans === $pns) {
122639d4c50SAndreas Gohr                return true;
123639d4c50SAndreas Gohr            }
1243b76424dSannda        } elseif (cleanID($pattern) == $page) {
125639d4c50SAndreas Gohr            // exact match
126639d4c50SAndreas Gohr            return true;
127639d4c50SAndreas Gohr        }
128639d4c50SAndreas Gohr
129639d4c50SAndreas Gohr        return false;
130639d4c50SAndreas Gohr    }
131639d4c50SAndreas Gohr
13245240794SAnna Dabrowska    /**
133c92ac04cSAnna Dabrowska     * Returns all users, formatted for autocomplete
13445240794SAnna Dabrowska     *
13545240794SAnna Dabrowska     * @return array
13645240794SAnna Dabrowska     */
13745240794SAnna Dabrowska    public function getUsers()
13845240794SAnna Dabrowska    {
13945240794SAnna Dabrowska        /** @var AuthPlugin $auth */
14045240794SAnna Dabrowska        global $auth;
14145240794SAnna Dabrowska
14245240794SAnna Dabrowska        if (!$auth->canDo('getUsers')) {
14345240794SAnna Dabrowska            return [];
14445240794SAnna Dabrowska        }
14545240794SAnna Dabrowska
14645240794SAnna Dabrowska        $cb = function ($k, $v) {
14745240794SAnna Dabrowska            return [
14845240794SAnna Dabrowska              'value' => $k,
14945240794SAnna Dabrowska              'label' => $k  . ' (' . $v['name'] . ')'
15045240794SAnna Dabrowska            ];
15145240794SAnna Dabrowska        };
15245240794SAnna Dabrowska        $users = $auth->retrieveUsers();
15345240794SAnna Dabrowska        $users = array_map($cb, array_keys($users), array_values($users));
15445240794SAnna Dabrowska
15545240794SAnna Dabrowska        return $users;
15645240794SAnna Dabrowska    }
15745240794SAnna Dabrowska
158639d4c50SAndreas Gohr    // endregion
159639d4c50SAndreas Gohr    // region Page Data
160639d4c50SAndreas Gohr
161639d4c50SAndreas Gohr    /**
162ef3ab392SAndreas Gohr     * Delete a page
163ef3ab392SAndreas Gohr     *
164ef3ab392SAndreas Gohr     * Cascades to delete all assigned data, etc.
165ef3ab392SAndreas Gohr     *
166ef3ab392SAndreas Gohr     * @param string $page Page ID
167ef3ab392SAndreas Gohr     */
168ef3ab392SAndreas Gohr    public function removePage($page)
169ef3ab392SAndreas Gohr    {
170ef3ab392SAndreas Gohr        $sql = "DELETE FROM pages WHERE page = ?";
171dee5618cSAnna Dabrowska        $this->db->exec($sql, $page);
172ef3ab392SAndreas Gohr    }
173ef3ab392SAndreas Gohr
174ef3ab392SAndreas Gohr    /**
1755dee13f7SAnna Dabrowska     * Update last modified date of page if content has changed
176ef3ab392SAndreas Gohr     *
177ef3ab392SAndreas Gohr     * @param string $page Page ID
178ef3ab392SAndreas Gohr     * @param int $lastmod timestamp of last non-minor change
179ef3ab392SAndreas Gohr     */
1805dee13f7SAnna Dabrowska    public function storePageDate($page, $lastmod, $newContent)
181ef3ab392SAndreas Gohr    {
1823b76424dSannda        $changelog = new PageChangeLog($page);
183789aa26fSAnna Dabrowska        $revs = $changelog->getRevisions(0, 1);
184ed4e8871SAnna Dabrowska
185ed4e8871SAnna Dabrowska        // compare content
186*635f6fb7SAnna Dabrowska        if (!empty($revs)) {
187ed4e8871SAnna Dabrowska            $oldContent = str_replace(NL, '', io_readFile(wikiFN($page, $revs[0])));
188ed4e8871SAnna Dabrowska            $newContent = str_replace(NL, '', $newContent);
189ed4e8871SAnna Dabrowska            if ($oldContent === $newContent) return;
190*635f6fb7SAnna Dabrowska        }
191ed4e8871SAnna Dabrowska
192ef3ab392SAndreas Gohr        $sql = "REPLACE INTO pages (page, lastmod) VALUES (?,?)";
193dee5618cSAnna Dabrowska        $this->db->exec($sql, [$page, $lastmod]);
194ef3ab392SAndreas Gohr    }
195ef3ab392SAndreas Gohr
196639d4c50SAndreas Gohr    // endregion
197639d4c50SAndreas Gohr    // region Assignments
198639d4c50SAndreas Gohr
199ef3ab392SAndreas Gohr    /**
200f09444ffSAndreas Gohr     * Clears direct assignments for a page
201f09444ffSAndreas Gohr     *
202cabb51d3SAndreas Gohr     * @param string $page Page ID
203cabb51d3SAndreas Gohr     */
204f09444ffSAndreas Gohr    public function clearPageAssignments($page)
205cabb51d3SAndreas Gohr    {
206f09444ffSAndreas Gohr        $sql = "UPDATE assignments SET pageassignees = '' WHERE page = ?";
207dee5618cSAnna Dabrowska        $this->db->exec($sql, $page);
208f09444ffSAndreas Gohr    }
209f09444ffSAndreas Gohr
210f09444ffSAndreas Gohr    /**
211639d4c50SAndreas Gohr     * Set assignees for a given page as manually specified
212639d4c50SAndreas Gohr     *
213639d4c50SAndreas Gohr     * @param string $page Page ID
214639d4c50SAndreas Gohr     * @param string $assignees
215639d4c50SAndreas Gohr     * @return void
216639d4c50SAndreas Gohr     */
217639d4c50SAndreas Gohr    public function setPageAssignees($page, $assignees)
218639d4c50SAndreas Gohr    {
2193b76424dSannda        $assignees = implode(',', array_unique(array_filter(array_map('trim', explode(',', $assignees)))));
220639d4c50SAndreas Gohr
221639d4c50SAndreas Gohr        $sql = "REPLACE INTO assignments ('page', 'pageassignees') VALUES (?,?)";
222dee5618cSAnna Dabrowska        $this->db->exec($sql, [$page, $assignees]);
223639d4c50SAndreas Gohr    }
224639d4c50SAndreas Gohr
225639d4c50SAndreas Gohr    /**
226639d4c50SAndreas Gohr     * Set assignees for a given page from the patterns
227639d4c50SAndreas Gohr     * @param string $page Page ID
228639d4c50SAndreas Gohr     */
229639d4c50SAndreas Gohr    public function setAutoAssignees($page)
230639d4c50SAndreas Gohr    {
231639d4c50SAndreas Gohr        $patterns = $this->getAssignmentPatterns();
232639d4c50SAndreas Gohr
233639d4c50SAndreas Gohr        // given assignees
234639d4c50SAndreas Gohr        $assignees = '';
235639d4c50SAndreas Gohr
236639d4c50SAndreas Gohr        // find all patterns that match the page and add the configured assignees
237639d4c50SAndreas Gohr        foreach ($patterns as $pattern => $assignees) {
238639d4c50SAndreas Gohr            if ($this->matchPagePattern($pattern, $page)) {
239639d4c50SAndreas Gohr                $assignees .= ',' . $assignees;
240639d4c50SAndreas Gohr            }
241639d4c50SAndreas Gohr        }
242639d4c50SAndreas Gohr
243639d4c50SAndreas Gohr        // remove duplicates and empty entries
2443b76424dSannda        $assignees = implode(',', array_unique(array_filter(array_map('trim', explode(',', $assignees)))));
245639d4c50SAndreas Gohr
246639d4c50SAndreas Gohr        // store the assignees
247639d4c50SAndreas Gohr        $sql = "REPLACE INTO assignments ('page', 'autoassignees') VALUES (?,?)";
248dee5618cSAnna Dabrowska        $this->db->exec($sql, [$page, $assignees]);
249639d4c50SAndreas Gohr    }
250639d4c50SAndreas Gohr
251639d4c50SAndreas Gohr    /**
252639d4c50SAndreas Gohr     * Is the given user one of the assignees for this page
253639d4c50SAndreas Gohr     *
254639d4c50SAndreas Gohr     * @param string $page Page ID
255639d4c50SAndreas Gohr     * @param string $user user name to check
256639d4c50SAndreas Gohr     * @param string[] $groups groups this user is in
257639d4c50SAndreas Gohr     * @return bool
258639d4c50SAndreas Gohr     */
259639d4c50SAndreas Gohr    public function isUserAssigned($page, $user, $groups)
260639d4c50SAndreas Gohr    {
261639d4c50SAndreas Gohr        $sql = "SELECT pageassignees,autoassignees FROM assignments WHERE page = ?";
262dee5618cSAnna Dabrowska        $record = $this->db->queryRecord($sql, $page);
263a806aa3dSSven        if (!$record) return false;
264fea1a86fSAndreas Gohr        $assignees = $record['pageassignees'] . ',' . $record['autoassignees'];
265639d4c50SAndreas Gohr        return auth_isMember($assignees, $user, $groups);
266639d4c50SAndreas Gohr    }
267639d4c50SAndreas Gohr
268639d4c50SAndreas Gohr    /**
269639d4c50SAndreas Gohr     * Fetch all assignments for a given user, with additional page information,
270833123deSAnna Dabrowska     * by default filtering already granted acknowledgements.
271833123deSAnna Dabrowska     * Filter can be switched off via $includeDone
272639d4c50SAndreas Gohr     *
273639d4c50SAndreas Gohr     * @param string $user
274639d4c50SAndreas Gohr     * @param array $groups
275833123deSAnna Dabrowska     * @param bool $includeDone
276833123deSAnna Dabrowska     *
277639d4c50SAndreas Gohr     * @return array|bool
278639d4c50SAndreas Gohr     */
279833123deSAnna Dabrowska    public function getUserAssignments($user, $groups, $includeDone = false)
280639d4c50SAndreas Gohr    {
281639d4c50SAndreas Gohr        $sql = "SELECT A.page, A.pageassignees, A.autoassignees, B.lastmod, C.user, C.ack FROM assignments A
282639d4c50SAndreas Gohr                JOIN pages B
283639d4c50SAndreas Gohr                ON A.page = B.page
284639d4c50SAndreas Gohr                LEFT JOIN acks C
285639d4c50SAndreas Gohr                ON A.page = C.page AND ( (C.user = ? AND C.ack > B.lastmod) )
286833123deSAnna Dabrowska                WHERE AUTH_ISMEMBER(A.pageassignees || ',' || A.autoassignees , ? , ?)";
287833123deSAnna Dabrowska
288833123deSAnna Dabrowska        if (!$includeDone) {
289833123deSAnna Dabrowska            $sql .= ' AND ack IS NULL';
290833123deSAnna Dabrowska        }
291639d4c50SAndreas Gohr
292dee5618cSAnna Dabrowska        return $this->db->queryAll($sql, $user, $user, implode('///', $groups));
293639d4c50SAndreas Gohr    }
294639d4c50SAndreas Gohr
295639d4c50SAndreas Gohr    /**
296639d4c50SAndreas Gohr     * Resolve names of users assigned to a given page
297639d4c50SAndreas Gohr     *
298639d4c50SAndreas Gohr     * This can be slow on huge user bases!
299639d4c50SAndreas Gohr     *
300639d4c50SAndreas Gohr     * @param string $page
301639d4c50SAndreas Gohr     * @return array|false
302639d4c50SAndreas Gohr     */
303639d4c50SAndreas Gohr    public function getPageAssignees($page)
304639d4c50SAndreas Gohr    {
305639d4c50SAndreas Gohr        /** @var AuthPlugin $auth */
306639d4c50SAndreas Gohr        global $auth;
307639d4c50SAndreas Gohr
308639d4c50SAndreas Gohr        $sql = "SELECT pageassignees || ',' || autoassignees AS 'assignments'
309639d4c50SAndreas Gohr                  FROM assignments
310639d4c50SAndreas Gohr                 WHERE page = ?";
311dee5618cSAnna Dabrowska        $assignments = $this->db->queryValue($sql, $page);
312639d4c50SAndreas Gohr
313639d4c50SAndreas Gohr        $users = [];
314639d4c50SAndreas Gohr        foreach (explode(',', $assignments) as $item) {
315639d4c50SAndreas Gohr            $item = trim($item);
316639d4c50SAndreas Gohr            if ($item === '') continue;
317639d4c50SAndreas Gohr            if ($item[0] == '@') {
318639d4c50SAndreas Gohr                $users = array_merge(
319639d4c50SAndreas Gohr                    $users,
320639d4c50SAndreas Gohr                    array_keys($auth->retrieveUsers(0, 0, ['grps' => substr($item, 1)]))
321639d4c50SAndreas Gohr                );
322639d4c50SAndreas Gohr            } else {
323639d4c50SAndreas Gohr                $users[] = $item;
324639d4c50SAndreas Gohr            }
325639d4c50SAndreas Gohr        }
326639d4c50SAndreas Gohr
327639d4c50SAndreas Gohr        return array_unique($users);
328639d4c50SAndreas Gohr    }
329639d4c50SAndreas Gohr
330639d4c50SAndreas Gohr    // endregion
331639d4c50SAndreas Gohr    // region Assignment Patterns
332639d4c50SAndreas Gohr
333639d4c50SAndreas Gohr    /**
334f09444ffSAndreas Gohr     * Get all the assignment patterns
335f09444ffSAndreas Gohr     * @return array (pattern => assignees)
336f09444ffSAndreas Gohr     */
337f09444ffSAndreas Gohr    public function getAssignmentPatterns()
338f09444ffSAndreas Gohr    {
339f09444ffSAndreas Gohr        $sql = "SELECT pattern, assignees FROM assignments_patterns";
340dee5618cSAnna Dabrowska        return $this->db->queryKeyValueList($sql);
341f09444ffSAndreas Gohr    }
342f09444ffSAndreas Gohr
343f09444ffSAndreas Gohr    /**
344f09444ffSAndreas Gohr     * Save new assignment patterns
345f09444ffSAndreas Gohr     *
346f09444ffSAndreas Gohr     * This resaves all patterns and reapplies them
347f09444ffSAndreas Gohr     *
348f09444ffSAndreas Gohr     * @param array $patterns (pattern => assignees)
349f09444ffSAndreas Gohr     */
350639d4c50SAndreas Gohr    public function saveAssignmentPatterns($patterns)
351639d4c50SAndreas Gohr    {
352dee5618cSAnna Dabrowska        $this->db->getPdo()->beginTransaction();
353fea1a86fSAndreas Gohr        try {
354f09444ffSAndreas Gohr
355fea1a86fSAndreas Gohr            /** @noinspection SqlWithoutWhere Remove all assignments */
356f09444ffSAndreas Gohr            $sql = "UPDATE assignments SET autoassignees = ''";
357dee5618cSAnna Dabrowska            $this->db->exec($sql);
358f09444ffSAndreas Gohr
359f09444ffSAndreas Gohr            /** @noinspection SqlWithoutWhere Remove all patterns */
360f09444ffSAndreas Gohr            $sql = "DELETE FROM assignments_patterns";
361dee5618cSAnna Dabrowska            $this->db->exec($sql);
362f09444ffSAndreas Gohr
363f09444ffSAndreas Gohr            // insert new patterns and gather affected pages
364f09444ffSAndreas Gohr            $pages = [];
365f09444ffSAndreas Gohr
366f09444ffSAndreas Gohr            $sql = "REPLACE INTO assignments_patterns (pattern, assignees) VALUES (?,?)";
367f09444ffSAndreas Gohr            foreach ($patterns as $pattern => $assignees) {
368f09444ffSAndreas Gohr                $pattern = trim($pattern);
369f09444ffSAndreas Gohr                $assignees = trim($assignees);
370f09444ffSAndreas Gohr                if (!$pattern || !$assignees) continue;
371dee5618cSAnna Dabrowska                $this->db->exec($sql, [$pattern, $assignees]);
372f09444ffSAndreas Gohr
373f09444ffSAndreas Gohr                // patterns may overlap, so we need to gather all affected pages first
374f09444ffSAndreas Gohr                $affectedPages = $this->getPagesMatchingPattern($pattern);
375f09444ffSAndreas Gohr                foreach ($affectedPages as $page) {
376f09444ffSAndreas Gohr                    if (isset($pages[$page])) {
377f09444ffSAndreas Gohr                        $pages[$page] .= ',' . $assignees;
378f09444ffSAndreas Gohr                    } else {
379f09444ffSAndreas Gohr                        $pages[$page] = $assignees;
380f09444ffSAndreas Gohr                    }
381f09444ffSAndreas Gohr                }
382f09444ffSAndreas Gohr            }
383f09444ffSAndreas Gohr
384f09444ffSAndreas Gohr            $sql = "INSERT INTO assignments (page, autoassignees) VALUES (?, ?)
385f09444ffSAndreas Gohr                ON CONFLICT(page)
386f09444ffSAndreas Gohr                DO UPDATE SET autoassignees = ?";
387f09444ffSAndreas Gohr            foreach ($pages as $page => $assignees) {
388f09444ffSAndreas Gohr                // remove duplicates and empty entries
3893b76424dSannda                $assignees = implode(',', array_unique(array_filter(array_map('trim', explode(',', $assignees)))));
390dee5618cSAnna Dabrowska                $this->db->exec($sql, [$page, $assignees, $assignees]);
391f09444ffSAndreas Gohr            }
392fea1a86fSAndreas Gohr        } catch (Exception $e) {
393dee5618cSAnna Dabrowska            $this->db->getPdo()->rollBack();
394fea1a86fSAndreas Gohr            throw $e;
395fea1a86fSAndreas Gohr        }
396dee5618cSAnna Dabrowska        $this->db->getPdo()->commit();
397f09444ffSAndreas Gohr    }
398f09444ffSAndreas Gohr
399f09444ffSAndreas Gohr    /**
400f09444ffSAndreas Gohr     * Get all known pages that match the given pattern
401f09444ffSAndreas Gohr     *
402f09444ffSAndreas Gohr     * @param $pattern
403f09444ffSAndreas Gohr     * @return string[]
404f09444ffSAndreas Gohr     */
405639d4c50SAndreas Gohr    public function getPagesMatchingPattern($pattern)
406639d4c50SAndreas Gohr    {
407f09444ffSAndreas Gohr        $sql = "SELECT page FROM pages WHERE MATCHES_PAGE_PATTERN(?, page)";
408dee5618cSAnna Dabrowska        $pages = $this->db->queryAll($sql, $pattern);
409f09444ffSAndreas Gohr
410f09444ffSAndreas Gohr        return array_column($pages, 'page');
411f09444ffSAndreas Gohr    }
412f09444ffSAndreas Gohr
413639d4c50SAndreas Gohr    // endregion
414639d4c50SAndreas Gohr    // region Acknowledgements
415ef3ab392SAndreas Gohr
416ef3ab392SAndreas Gohr    /**
417ef3ab392SAndreas Gohr     * Has the given user acknowledged the given page?
418ef3ab392SAndreas Gohr     *
419ef3ab392SAndreas Gohr     * @param string $page
420ef3ab392SAndreas Gohr     * @param string $user
4215773dd37SAnna Dabrowska     * @return bool|int timestamp of acknowledgement or false
422ef3ab392SAndreas Gohr     */
423ef3ab392SAndreas Gohr    public function hasUserAcknowledged($page, $user)
424ef3ab392SAndreas Gohr    {
425ef3ab392SAndreas Gohr        $sql = "SELECT ack
426ef3ab392SAndreas Gohr                  FROM acks A, pages B
427ef3ab392SAndreas Gohr                 WHERE A.page = B.page
4285773dd37SAnna Dabrowska                   AND A.page = ?
4295773dd37SAnna Dabrowska                   AND A.user = ?
430ef3ab392SAndreas Gohr                   AND A.ack >= B.lastmod";
431ef3ab392SAndreas Gohr
432dee5618cSAnna Dabrowska        $acktime = $this->db->queryValue($sql, $page, $user);
433ef3ab392SAndreas Gohr
434ef3ab392SAndreas Gohr        return $acktime ? (int)$acktime : false;
435ef3ab392SAndreas Gohr    }
4365773dd37SAnna Dabrowska
4375773dd37SAnna Dabrowska    /**
438d9a8334dSAnna Dabrowska     * Timestamp of the latest acknowledgment of the given page
439d9a8334dSAnna Dabrowska     * by the given user
440d9a8334dSAnna Dabrowska     *
441d9a8334dSAnna Dabrowska     * @param string $page
442d9a8334dSAnna Dabrowska     * @param string $user
443d9a8334dSAnna Dabrowska     * @return bool|string
444d9a8334dSAnna Dabrowska     */
445d9a8334dSAnna Dabrowska    public function getLatestUserAcknowledgement($page, $user)
446d9a8334dSAnna Dabrowska    {
447d9a8334dSAnna Dabrowska        $sql = "SELECT MAX(ack)
448d9a8334dSAnna Dabrowska                  FROM acks
449d9a8334dSAnna Dabrowska                 WHERE page = ?
450d9a8334dSAnna Dabrowska                   AND user = ?";
451d9a8334dSAnna Dabrowska
452dee5618cSAnna Dabrowska        return $this->db->queryValue($sql, [$page, $user]);
453d9a8334dSAnna Dabrowska    }
454d9a8334dSAnna Dabrowska
455d9a8334dSAnna Dabrowska    /**
4565773dd37SAnna Dabrowska     * Save user's acknowledgement for a given page
4575773dd37SAnna Dabrowska     *
4585773dd37SAnna Dabrowska     * @param string $page
4595773dd37SAnna Dabrowska     * @param string $user
4605773dd37SAnna Dabrowska     * @return bool
4615773dd37SAnna Dabrowska     */
4625773dd37SAnna Dabrowska    public function saveAcknowledgement($page, $user)
4635773dd37SAnna Dabrowska    {
4648e55e483SAnna Dabrowska        $sql = "INSERT INTO acks (page, user, ack) VALUES (?,?, strftime('%s','now'))";
4655773dd37SAnna Dabrowska
466dee5618cSAnna Dabrowska        $this->db->exec($sql, $page, $user);
4675773dd37SAnna Dabrowska        return true;
4685773dd37SAnna Dabrowska    }
46974126d4bSAnna Dabrowska
47074126d4bSAnna Dabrowska    /**
4715966046cSAnna Dabrowska     * Get all pages that a user needs to acknowledge and/or the last acknowledgement infos
4725966046cSAnna Dabrowska     * depending on the (optional) filter based on status of the acknowledgements.
473d6011abdSAnna Dabrowska     *
474863b6e48SAndreas Gohr     * @param string $user
475863b6e48SAndreas Gohr     * @param array $groups
4765966046cSAnna Dabrowska     * @param string $status Optional status filter, can be all (default), current or due
4775966046cSAnna Dabrowska     *
478d6011abdSAnna Dabrowska     * @return array|bool
479d6011abdSAnna Dabrowska     */
4805966046cSAnna Dabrowska    public function getUserAcknowledgements($user, $groups, $status = '')
481d6011abdSAnna Dabrowska    {
482dee5618cSAnna Dabrowska        $filterClause = $this->getFilterClause($status, 'B');
4835966046cSAnna Dabrowska
4845966046cSAnna Dabrowska        // query
485f09444ffSAndreas Gohr        $sql = "SELECT A.page, A.pageassignees, A.autoassignees, B.lastmod, C.user, MAX(C.ack) AS ack
486863b6e48SAndreas Gohr                  FROM assignments A
487863b6e48SAndreas Gohr                  JOIN pages B
488863b6e48SAndreas Gohr                    ON A.page = B.page
489863b6e48SAndreas Gohr             LEFT JOIN acks C
490863b6e48SAndreas Gohr                    ON A.page = C.page AND C.user = ?
491f09444ffSAndreas Gohr                 WHERE AUTH_ISMEMBER(A.pageassignees || ',' || A.autoassignees, ? , ?)
4925966046cSAnna Dabrowska              GROUP BY A.page";
493dee5618cSAnna Dabrowska        $sql .= $filterClause;
4945966046cSAnna Dabrowska        $sql .= "
4955966046cSAnna Dabrowska              ORDER BY A.page";
496863b6e48SAndreas Gohr
497dee5618cSAnna Dabrowska        return $this->db->queryAll($sql, [$user, $user, implode('///', $groups)]);
498863b6e48SAndreas Gohr    }
499863b6e48SAndreas Gohr
500863b6e48SAndreas Gohr    /**
501c6d8c1d9SAndreas Gohr     * Get ack status for all assigned users of a given page
502c6d8c1d9SAndreas Gohr     *
503c6d8c1d9SAndreas Gohr     * This can be slow!
504c6d8c1d9SAndreas Gohr     *
505c6d8c1d9SAndreas Gohr     * @param string $page
5065966046cSAnna Dabrowska     * @param string $user
507dee5618cSAnna Dabrowska     * @param string $status
508dee5618cSAnna Dabrowska     * @param int $max
509dee5618cSAnna Dabrowska     *
510dee5618cSAnna Dabrowska     * @return array
511c6d8c1d9SAndreas Gohr     */
512dee5618cSAnna Dabrowska    public function getPageAcknowledgements($page, $user = '', $status = '', $max = 0)
513c6d8c1d9SAndreas Gohr    {
514dee5618cSAnna Dabrowska        $userClause = '';
515b271ebc7SAnna Dabrowska        $filterClause = '';
516dee5618cSAnna Dabrowska        $params[] = $page;
517c6d8c1d9SAndreas Gohr
518dee5618cSAnna Dabrowska        // filtering for user from input or using saved assignees?
519dee5618cSAnna Dabrowska        if ($user) {
520dee5618cSAnna Dabrowska            $users = [$user];
521dee5618cSAnna Dabrowska            $userClause = ' AND (B.user = ? OR B.user IS NULL) ';
522dee5618cSAnna Dabrowska            $params[] = $user;
523dee5618cSAnna Dabrowska        } else {
524dee5618cSAnna Dabrowska            $users = $this->getPageAssignees($page);
525dee5618cSAnna Dabrowska            if (!$users) return [];
526dee5618cSAnna Dabrowska        }
527dee5618cSAnna Dabrowska
528b271ebc7SAnna Dabrowska        if ($status === 'current') {
529b271ebc7SAnna Dabrowska            $filterClause = ' AND ACK >= A.lastmod ';
530b271ebc7SAnna Dabrowska        }
531b271ebc7SAnna Dabrowska
532dee5618cSAnna Dabrowska        $ulist = implode(',', array_map([$this->db->getPdo(), 'quote'], $users));
533c6d8c1d9SAndreas Gohr        $sql = "SELECT A.page, A.lastmod, B.user, MAX(B.ack) AS ack
534c6d8c1d9SAndreas Gohr                  FROM pages A
535c6d8c1d9SAndreas Gohr             LEFT JOIN acks B
536c6d8c1d9SAndreas Gohr                    ON A.page = B.page
537c6d8c1d9SAndreas Gohr                   AND B.user IN ($ulist)
538b271ebc7SAnna Dabrowska                WHERE  A.page = ? $userClause $filterClause";
539dee5618cSAnna Dabrowska        $sql .= " GROUP BY A.page, B.user ";
540b6817aacSAndreas Gohr        if ($max) $sql .= " LIMIT $max";
541dee5618cSAnna Dabrowska
542dee5618cSAnna Dabrowska        $acknowledgements = $this->db->queryAll($sql, $params);
543dee5618cSAnna Dabrowska
544dee5618cSAnna Dabrowska        if ($status === 'current') {
545dee5618cSAnna Dabrowska            return $acknowledgements;
546dee5618cSAnna Dabrowska        }
547c6d8c1d9SAndreas Gohr
548c6d8c1d9SAndreas Gohr        // there should be at least one result, unless the page is unknown
549dee5618cSAnna Dabrowska        if (!count($acknowledgements)) return $acknowledgements;
550c6d8c1d9SAndreas Gohr
551c6d8c1d9SAndreas Gohr        $baseinfo = [
552c6d8c1d9SAndreas Gohr            'page' => $acknowledgements[0]['page'],
553c6d8c1d9SAndreas Gohr            'lastmod' => $acknowledgements[0]['lastmod'],
554c6d8c1d9SAndreas Gohr            'user' => null,
555c6d8c1d9SAndreas Gohr            'ack' => null,
556c6d8c1d9SAndreas Gohr        ];
557c6d8c1d9SAndreas Gohr
558c6d8c1d9SAndreas Gohr        // fill up the result with all users that never acknowledged the page
559c6d8c1d9SAndreas Gohr        $combined = [];
560c6d8c1d9SAndreas Gohr        foreach ($acknowledgements as $ack) {
561c6d8c1d9SAndreas Gohr            if ($ack['user'] !== null) {
562c6d8c1d9SAndreas Gohr                $combined[$ack['user']] = $ack;
563c6d8c1d9SAndreas Gohr            }
564c6d8c1d9SAndreas Gohr        }
565c6d8c1d9SAndreas Gohr        foreach ($users as $user) {
566c6d8c1d9SAndreas Gohr            if (!isset($combined[$user])) {
567c6d8c1d9SAndreas Gohr                $combined[$user] = array_merge($baseinfo, ['user' => $user]);
568c6d8c1d9SAndreas Gohr            }
569c6d8c1d9SAndreas Gohr        }
570c6d8c1d9SAndreas Gohr
571dee5618cSAnna Dabrowska        // finally remove current acknowledgements if filter is used
572dee5618cSAnna Dabrowska        // this cannot be done in SQL without loss of data,
573dee5618cSAnna Dabrowska        // filtering must happen last, otherwise removed current acks will be re-added as due
574dee5618cSAnna Dabrowska        if ($status === 'due') {
575dee5618cSAnna Dabrowska            $combined = array_filter($combined, function ($info) {
576dee5618cSAnna Dabrowska                return $info['ack'] < $info['lastmod'];
577dee5618cSAnna Dabrowska            });
578dee5618cSAnna Dabrowska        }
579dee5618cSAnna Dabrowska
580c6d8c1d9SAndreas Gohr        ksort($combined);
581c6d8c1d9SAndreas Gohr        return array_values($combined);
582c6d8c1d9SAndreas Gohr    }
583c6d8c1d9SAndreas Gohr
584c6d8c1d9SAndreas Gohr    /**
585863b6e48SAndreas Gohr     * Returns all acknowledgements
586863b6e48SAndreas Gohr     *
587863b6e48SAndreas Gohr     * @param int $limit maximum number of results
588dee5618cSAnna Dabrowska     * @return array
589863b6e48SAndreas Gohr     */
590863b6e48SAndreas Gohr    public function getAcknowledgements($limit = 100)
591863b6e48SAndreas Gohr    {
592863b6e48SAndreas Gohr        $sql = '
59384db77b6SAndreas Gohr            SELECT A.page, A.user, B.lastmod, max(A.ack) AS ack
59484db77b6SAndreas Gohr              FROM acks A, pages B
59584db77b6SAndreas Gohr             WHERE A.page = B.page
59684db77b6SAndreas Gohr          GROUP BY A.user, A.page
597863b6e48SAndreas Gohr          ORDER BY ack DESC
598863b6e48SAndreas Gohr             LIMIT ?
599863b6e48SAndreas Gohr              ';
600dee5618cSAnna Dabrowska        return $this->db->queryAll($sql, $limit);
601dee5618cSAnna Dabrowska    }
602d6011abdSAnna Dabrowska
603dee5618cSAnna Dabrowska    /**
604dee5618cSAnna Dabrowska     * Returns a filter clause for acknowledgement queries depending on wanted status.
605dee5618cSAnna Dabrowska     *
606dee5618cSAnna Dabrowska     * @param string $status
607dee5618cSAnna Dabrowska     * @param string $alias Table alias used in the SQL query
608dee5618cSAnna Dabrowska     * @return string
609dee5618cSAnna Dabrowska     */
61027e483c3SAnna Dabrowska    protected function getFilterClause($status, $alias)
611dee5618cSAnna Dabrowska    {
612dee5618cSAnna Dabrowska        switch ($status) {
613dee5618cSAnna Dabrowska            case 'current':
614dee5618cSAnna Dabrowska                $filterClause = " HAVING ack >= $alias.lastmod ";
615dee5618cSAnna Dabrowska                break;
616dee5618cSAnna Dabrowska            case 'due':
617dee5618cSAnna Dabrowska                $filterClause = " HAVING (ack IS NULL) OR (ack < $alias.lastmod) ";
618dee5618cSAnna Dabrowska                break;
619dee5618cSAnna Dabrowska            case 'outdated':
620dee5618cSAnna Dabrowska                $filterClause = " HAVING ack < $alias.lastmod ";
621dee5618cSAnna Dabrowska                break;
622dee5618cSAnna Dabrowska            case 'all':
623dee5618cSAnna Dabrowska            default:
624dee5618cSAnna Dabrowska                $filterClause = '';
625dee5618cSAnna Dabrowska                break;
626dee5618cSAnna Dabrowska        }
627dee5618cSAnna Dabrowska        return $filterClause;
628d6011abdSAnna Dabrowska    }
629f09444ffSAndreas Gohr
630639d4c50SAndreas Gohr    // endregion
6314d6d17d0SAndreas Gohr}
632