1<?php
2
3use dokuwiki\Cache\Cache;
4use dokuwiki\Extension\ActionPlugin;
5use dokuwiki\Extension\EventHandler;
6use dokuwiki\Extension\Event;
7use dokuwiki\Form\Form;
8use dokuwiki\ChangeLog\PageChangeLog;
9use dokuwiki\plugin\sqlite\SQLiteDB;
10
11/**
12 * DokuWiki Plugin watchcycle (Action Component)
13 *
14 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
15 * @author  Szymon Olewniczak <dokuwiki@cosmocode.de>
16 */
17
18class action_plugin_watchcycle extends ActionPlugin
19{
20    /**
21     * Registers a callback function for a given event
22     *
23     * @param EventHandler $controller DokuWiki's event controller object
24     *
25     * @return void
26     */
27    public function register(EventHandler $controller)
28    {
29
30        $controller->register_hook('PARSER_METADATA_RENDER', 'AFTER', $this, 'handleParserMetadataRender');
31        $controller->register_hook('PARSER_CACHE_USE', 'AFTER', $this, 'handleParserCacheUse');
32        // ensure a page revision is created when summary changes:
33        $controller->register_hook('COMMON_WIKIPAGE_SAVE', 'BEFORE', $this, 'handlePagesaveBefore');
34        $controller->register_hook('SEARCH_RESULT_PAGELOOKUP', 'BEFORE', $this, 'addIconToPageLookupResult');
35        $controller->register_hook('SEARCH_RESULT_FULLPAGE', 'BEFORE', $this, 'addIconToFullPageResult');
36        $controller->register_hook('FORM_SEARCH_OUTPUT', 'BEFORE', $this, 'addFilterToSearchForm');
37        $controller->register_hook('FORM_QUICKSEARCH_OUTPUT', 'BEFORE', $this, 'handleFormQuicksearchOutput');
38        $controller->register_hook('SEARCH_QUERY_FULLPAGE', 'AFTER', $this, 'filterSearchResults');
39        $controller->register_hook('SEARCH_QUERY_PAGELOOKUP', 'AFTER', $this, 'filterSearchResults');
40
41        $controller->register_hook('TOOLBAR_DEFINE', 'AFTER', $this, 'handleToolbarDefine');
42        $controller->register_hook('AJAX_CALL_UNKNOWN', 'BEFORE', $this, 'handleAjaxGet');
43        $controller->register_hook('AJAX_CALL_UNKNOWN', 'BEFORE', $this, 'handleAjaxValidate');
44    }
45
46
47    /**
48     * Register a new toolbar button
49     *
50     * @param Event $event event object by reference
51     * @param mixed      $param  [the parameters passed as fifth argument to register_hook() when this
52     *                           handler was registered]
53     *
54     * @return void
55     */
56    public function handleToolbarDefine(Event $event, $param)
57    {
58        $event->data[] = [
59            'type' => 'plugin_watchcycle',
60            'title' => $this->getLang('title toolbar button'),
61            'icon' => '../../plugins/watchcycle/images/eye-plus16Green.png',
62        ];
63    }
64
65    /**
66     * Add a checkbox to the search form to allow limiting the search to maintained pages only
67     *
68     * @param Event $event
69     * @param            $param
70     */
71    public function addFilterToSearchForm(Event $event, $param)
72    {
73        /* @var \dokuwiki\Form\Form $searchForm */
74        $searchForm = $event->data;
75        $advOptionsPos = $searchForm->findPositionByAttribute('class', 'advancedOptions');
76        $searchForm->addCheckbox('watchcycle_only', $this->getLang('cb only maintained pages'), $advOptionsPos + 1)
77            ->addClass('plugin__watchcycle_searchform_cb');
78    }
79
80    /**
81     * Handles the FORM_QUICKSEARCH_OUTPUT event
82     *
83     * @param Event $event event object by reference
84     * @param mixed      $param  [the parameters passed as fifth argument to register_hook() when this
85     *                           handler was registered]
86     *
87     * @return void
88     */
89    public function handleFormQuicksearchOutput(Event $event, $param)
90    {
91        /** @var Form $qsearchForm */
92        $qsearchForm = $event->data;
93        if ($this->getConf('default_maintained_only')) {
94            $qsearchForm->setHiddenField('watchcycle_only', '1');
95        }
96    }
97
98    /**
99     * Filter the search results to show only maintained pages, if  watchcycle_only is true in $INPUT
100     *
101     * @param Event $event
102     * @param            $param
103     */
104    public function filterSearchResults(Event $event, $param)
105    {
106        global $INPUT;
107        if (!$INPUT->bool('watchcycle_only')) {
108            return;
109        }
110        $event->result = array_filter($event->result, function ($key) {
111            $watchcycle = p_get_metadata($key, 'plugin watchcycle');
112            return !empty($watchcycle);
113        }, ARRAY_FILTER_USE_KEY);
114    }
115
116    /**
117     * [Custom event handler which performs action]
118     *
119     * @param Event $event event object by reference
120     * @param mixed      $param  [the parameters passed as fifth argument to register_hook() when this
121     *                           handler was registered]
122     *
123     * @return void
124     */
125    public function handleParserMetadataRender(Event $event, $param)
126    {
127        global $ID;
128
129        /** @var \helper_plugin_watchcycle_db $dbHelper */
130        $dbHelper = plugin_load('helper', 'watchcycle_db');
131
132        /** @var SQLiteDB */
133        $sqlite = $dbHelper->getDB();
134
135        /* @var \helper_plugin_watchcycle $helper */
136        $helper = plugin_load('helper', 'watchcycle');
137
138        $page = $event->data['current']['last_change']['id'];
139
140        if (isset($event->data['current']['plugin']['watchcycle'])) {
141            $watchcycle = $event->data['current']['plugin']['watchcycle'];
142            $row = $sqlite->queryRecord('SELECT * FROM watchcycle WHERE page=?', $page);
143            $changes = $this->getLastMaintainerRev($event->data, $watchcycle['maintainer'], $last_maintainer_rev);
144            //false if page needs checking
145            $uptodate = $helper->daysAgo($last_maintainer_rev) <= (int)$watchcycle['cycle'];
146
147            if ($uptodate === false) {
148                $helper->informMaintainer($watchcycle['maintainer'], $ID);
149            }
150
151            if (!$row) {
152                $entry = $watchcycle;
153                $entry['page'] = $page;
154                $entry['last_maintainer_rev'] = $last_maintainer_rev;
155                // uptodate is an int in the database
156                $entry['uptodate'] = (int)$uptodate;
157                $sqlite->saveRecord('watchcycle', $entry);
158            } else { //check if we need to update something
159                $toupdate = [];
160
161                if ($row['cycle'] != $watchcycle['cycle']) {
162                    $toupdate['cycle'] = $watchcycle['cycle'];
163                }
164
165                if ($row['maintainer'] != $watchcycle['maintainer']) {
166                    $toupdate['maintainer'] = $watchcycle['maintainer'];
167                }
168
169                if ($row['last_maintainer_rev'] != $last_maintainer_rev) {
170                    $toupdate['last_maintainer_rev'] = $last_maintainer_rev;
171                }
172
173                //uptodate value has changed?
174                if ($row['uptodate'] !== (int)$uptodate) {
175                    $toupdate['uptodate'] = (int)$uptodate;
176                }
177
178                if ($toupdate !== []) {
179                    $set = implode(',', array_map(static fn($v) => "$v=?", array_keys($toupdate)));
180                    $toupdate[] = $page;
181                    $sqlite->query("UPDATE watchcycle SET $set WHERE page=?", array_values($toupdate));
182                }
183            }
184            $event->data['current']['plugin']['watchcycle']['last_maintainer_rev'] = $last_maintainer_rev;
185            $event->data['current']['plugin']['watchcycle']['changes'] = $changes;
186        } else { //maybe we've removed the syntax -> delete from the database
187            $sqlite->query('DELETE FROM watchcycle WHERE page=?', $page);
188        }
189    }
190
191    /**
192     * Returns JSON with filtered users and groups
193     *
194     * @param Event $event
195     * @param string $param
196     */
197    public function handleAjaxGet(Event $event, $param)
198    {
199        if ($event->data != 'plugin_watchcycle_get') return;
200        $event->preventDefault();
201        $event->stopPropagation();
202        global $conf;
203
204        header('Content-Type: application/json');
205        try {
206            $result = $this->fetchUsersAndGroups();
207        } catch (\Exception $e) {
208            $result = [
209                'error' => $e->getMessage() . ' ' . basename($e->getFile()) . ':' . $e->getLine()
210            ];
211            if ($conf['allowdebug']) {
212                $result['stacktrace'] = $e->getTraceAsString();
213            }
214            http_status(500);
215        }
216
217        echo json_encode($result);
218    }
219
220    /**
221     * JSON result of validation of maintainers definition
222     *
223     * @param Event $event
224     * @param $param
225     */
226    public function handleAjaxValidate(Event $event, $param)
227    {
228        if ($event->data != 'plugin_watchcycle_validate') return;
229        $event->preventDefault();
230        $event->stopPropagation();
231
232        global $INPUT;
233        $maintainers = $INPUT->str('param');
234
235        if (empty($maintainers)) return;
236
237        header('Content-Type: application/json');
238
239        /* @var \helper_plugin_watchcycle $helper */
240        $helper = plugin_load('helper', 'watchcycle');
241
242        echo json_encode($helper->validateMaintainerString($maintainers));
243    }
244
245    /**
246     * Returns filtered users and groups, if supported by the current authentication
247     *
248     * @return array
249     */
250    protected function fetchUsersAndGroups()
251    {
252        global $INPUT;
253        $term = $INPUT->str('param');
254
255        if (empty($term)) return [];
256
257        /* @var DokuWiki_Auth_Plugin $auth */
258        global $auth;
259
260        $users = [];
261        $foundUsers = $auth->retrieveUsers(0, 50, ['user' => $term]);
262        if (!empty($foundUsers)) {
263            $users = array_map(
264                static fn($name, $user) => ['label' => $user['name'] . " ($name)", 'value' => $name],
265                array_keys($foundUsers),
266                $foundUsers
267            );
268        }
269
270        $groups = [];
271
272        // check cache
273        $cachedGroups = new Cache('retrievedGroups', '.txt');
274        if ($cachedGroups->useCache(['age' => 30])) {
275            $foundGroups = unserialize($cachedGroups->retrieveCache());
276        } else {
277            $foundGroups = $auth->retrieveGroups();
278            $cachedGroups->storeCache(serialize($foundGroups));
279        }
280
281        if (!empty($foundGroups)) {
282            $groups = array_filter(
283                array_map(function ($grp) use ($term) {
284                    // filter groups
285                    if (strpos($grp, (string) $term) !== false) {
286                        return ['label' => '@' . $grp, 'value' => '@' . $grp];
287                    }
288                }, $foundGroups)
289            );
290        }
291
292        return array_merge($users, $groups);
293    }
294
295    /**
296     * @param array  $meta metadata of the page
297     * @param string $maintainer
298     * @param int    $rev  revision of the last page edition by maintainer or -1 if no edition was made
299     *
300     * @return int   number of changes since last maintainer's revision or -1 if no changes was made
301     */
302    protected function getLastMaintainerRev($meta, $maintainer, &$rev)
303    {
304        $changes = 0;
305
306        /* @var \helper_plugin_watchcycle $helper */
307        $helper = plugin_load('helper', 'watchcycle');
308
309        if ($helper->isMaintainer($meta['current']['last_change']['user'], $maintainer)) {
310            $rev = $meta['current']['last_change']['date'];
311            return $changes;
312        } else {
313            $page = $meta['current']['last_change']['id'];
314            $changelog = new PageChangeLog($page);
315            $first = 0;
316            $num = 100;
317            while (count($revs = $changelog->getRevisions($first, $num)) > 0) {
318                foreach ($revs as $rev) {
319                    ++$changes;
320                    $revInfo = $changelog->getRevisionInfo($rev);
321                    if ($helper->isMaintainer($revInfo['user'], $maintainer)) {
322                        $rev = $revInfo['date'];
323                        return $changes;
324                    }
325                }
326                $first += $num;
327            }
328        }
329
330        $rev = -1;
331        return -1;
332    }
333
334    /**
335     * clean the cache every 24 hours
336     *
337     * @param Event $event event object by reference
338     * @param mixed      $param  [the parameters passed as fifth argument to register_hook() when this
339     *                           handler was registered]
340     *
341     * @return void
342     */
343    public function handleParserCacheUse(Event $event, $param)
344    {
345        /* @var \helper_plugin_watchcycle $helper */
346        $helper = plugin_load('helper', 'watchcycle');
347
348        if ($helper->daysAgo($event->data->_time) >= 1) {
349            $event->result = false;
350        }
351    }
352
353    /**
354     * Check if the page has to be changed
355     *
356     * @param Event $event event object by reference
357     * @param mixed      $param  [the parameters passed as fifth argument to register_hook() when this
358     *                           handler was registered]
359     *
360     * @return void
361     */
362    public function handlePagesaveBefore(Event $event, $param)
363    {
364        if ($event->data['contentChanged']) {
365            return;
366        } // will be saved for page changes
367
368        //save page if summary is provided
369        if (!empty($event->data['summary'])) {
370            $event->data['contentChanged'] = true;
371        }
372    }
373
374    /**
375     * called for event SEARCH_RESULT_PAGELOOKUP
376     *
377     * @param Event $event
378     * @param            $param
379     */
380    public function addIconToPageLookupResult(Event $event, $param)
381    {
382        /* @var \helper_plugin_watchcycle $helper */
383        $helper = plugin_load('helper', 'watchcycle');
384
385        $icon = $helper->getSearchResultIconHTML($event->data['page']);
386        if ($icon) {
387            $event->data['listItemContent'][] = $icon;
388        }
389    }
390
391    /**
392     * called for event SEARCH_RESULT_FULLPAGE
393     *
394     * @param Event $event
395     * @param            $param
396     */
397    public function addIconToFullPageResult(Event $event, $param)
398    {
399        /* @var \helper_plugin_watchcycle $helper */
400        $helper = plugin_load('helper', 'watchcycle');
401
402        $icon = $helper->getSearchResultIconHTML($event->data['page']);
403        if ($icon) {
404            $event->data['resultHeader'][] = $icon;
405        }
406    }
407}
408