1<?php
2
3use dokuwiki\ChangeLog\ChangeLog;
4use dokuwiki\Extension\SyntaxPlugin;
5use dokuwiki\File\PageResolver;
6
7/**
8 * Changes Plugin: List the most recent changes of the wiki
9 *
10 * @license    GPL 2 (http://www.gnu.org/licenses/gpl.html)
11 * @author     Andreas Gohr <andi@splitbrain.org>
12 * @author     Mykola Ostrovskyy <spambox03@mail.ru>
13 */
14/**
15 * Class syntax_plugin_changes
16 */
17class syntax_plugin_changes extends SyntaxPlugin
18{
19    /**
20     * What kind of syntax are we?
21     */
22    public function getType()
23    {
24        return 'substition';
25    }
26
27    /**
28     * What type of XHTML do we create?
29     */
30    public function getPType()
31    {
32        return 'block';
33    }
34
35    /**
36     * Where to sort in?
37     */
38    public function getSort()
39    {
40        return 105;
41    }
42
43    /**
44     * Connect pattern to lexer
45     * @param string $mode
46     */
47    public function connectTo($mode)
48    {
49        $this->Lexer->addSpecialPattern('\{\{changes>[^}]*\}\}', $mode, 'plugin_changes');
50    }
51
52    /**
53     * Handler to prepare matched data for the rendering process
54     *
55     * @param   string       $match   The text matched by the patterns
56     * @param   int          $state   The lexer state for the match
57     * @param   int          $pos     The character position of the matched text
58     * @param   Doku_Handler $handler The Doku_Handler object
59     * @return  array Return an array with all data you want to use in render
60     */
61    public function handle($match, $state, $pos, Doku_Handler $handler)
62    {
63        $match = substr($match, 10, -2);
64
65        $data = [
66            'ns' => [],
67            'excludedpages' => [],
68            'count' => 10,
69            'type' => [],
70            'render' => 'list',
71            'render-flags' => [],
72            'maxage' => null,
73            'reverse' => false,
74            'user' => [],
75            'excludedusers' => [],
76        ];
77
78        $match = explode('&', $match);
79        foreach ($match as $m) {
80            if (is_numeric($m)) {
81                $data['count'] = (int) $m;
82            } elseif (preg_match('/(\w+)\s*=(.+)/', $m, $temp) == 1) {
83                $this->handleNamedParameter($temp[1], trim($temp[2]), $data);
84            } else {
85                $this->addNamespace($data, trim($m));
86            }
87        }
88
89        return $data;
90    }
91
92    /**
93     * Handle parameters that are specified using <name>=<value> syntax
94     * @param string $name
95     * @param $value
96     * @param array $data
97     */
98    protected function handleNamedParameter($name, $value, &$data)
99    {
100        global $ID;
101
102        static $types = ['edit' => 'E', 'create' => 'C', 'delete' => 'D', 'minor' => 'e'];
103        static $renderers = ['list', 'pagelist'];
104
105        switch ($name) {
106            case 'count':
107            case 'maxage':
108                $data[$name] = (int) $value;
109                break;
110            case 'ns':
111                foreach (preg_split('/\s*,\s*/', $value) as $value) {
112                    $this->addNamespace($data, $value);
113                }
114                break;
115            case 'type':
116                foreach (preg_split('/\s*,\s*/', $value) as $value) {
117                    if (array_key_exists($value, $types)) {
118                        $data[$name][] = $types[$value];
119                    }
120                }
121                break;
122            case 'render':
123                // parse "name(flag1, flag2)" syntax
124                if (preg_match('/(\w+)(?:\((.*)\))?/', $value, $match) == 1) {
125                    if (in_array($match[1], $renderers)) {
126                        $data[$name] = $match[1];
127                        if (count($match) > 2) {
128                            $flags = trim($match[2]);
129                            if ($flags != '') {
130                                $data['render-flags'] = preg_split('/\s*,\s*/', $flags);
131                            }
132                        }
133                    }
134                }
135                break;
136            case 'user':
137            case 'excludedusers':
138                foreach (preg_split('/\s*,\s*/', $value) as $value) {
139                    $data[$name][] = $value;
140                }
141                break;
142            case 'excludedpages':
143                foreach (preg_split('/\s*,\s*/', $value) as $page) {
144                    if (!empty($page)) {
145                        $data[$name][] = (new PageResolver($ID))->resolveId($page);
146                    }
147                }
148                break;
149            case 'reverse':
150                $data[$name] = (bool)$value;
151                break;
152        }
153    }
154
155    /**
156     * Clean-up the namespace name and add it (if valid) into the $data array
157     * @param array $data
158     * @param string $namespace
159     */
160    protected function addNamespace(&$data, $namespace)
161    {
162        if (empty($namespace)) return;
163        $action = ($namespace[0] == '-') ? 'exclude' : 'include';
164        $namespace = cleanID(preg_replace('/^[+-]/', '', $namespace));
165        if (!empty($namespace)) {
166            $data['ns'][$action][] = $namespace;
167        }
168    }
169
170    /**
171     * Handles the actual output creation.
172     *
173     * @param string $format output format being rendered
174     * @param Doku_Renderer $renderer the current renderer object
175     * @param array $data data created by handler()
176     * @return  boolean rendered correctly?
177     */
178    public function render($format, Doku_Renderer $renderer, $data)
179    {
180        global $conf;
181
182        if ($format === 'xhtml') {
183            /* @var Doku_Renderer_xhtml $renderer */
184            $changes = $this->getChanges(
185                $data['count'],
186                $data['ns'],
187                $data['excludedpages'],
188                $data['type'],
189                $data['user'],
190                $data['maxage'],
191                $data['excludedusers'],
192                $data['reverse']
193            );
194            if (!count($changes)) return true;
195
196            switch ($data['render']) {
197                case 'list':
198                    $this->renderSimpleList($changes, $renderer, $data['render-flags']);
199                    break;
200                case 'pagelist':
201                    $this->renderPageList($changes, $renderer, $data['render-flags']);
202                    break;
203            }
204            return true;
205        } elseif ($format === 'metadata') {
206            /* @var Doku_Renderer_metadata $renderer */
207            $renderer->meta['relation']['depends']['rendering'][$conf['changelog']] = true;
208            $renderer->meta['relation']['depends']['rendering'][$conf['media_changelog']] = true;
209            return true;
210        }
211        return false;
212    }
213
214    /**
215     * Based on getRecents() from inc/changelog.php
216     *
217     * @param int   $num
218     * @param array $ns
219     * @param array $excludedpages
220     * @param array $type
221     * @param array $user
222     * @param int   $maxage
223     * @return array
224     */
225    protected function getChanges($num, $ns, $excludedpages, $type, $user, $maxage, $excludedusers, $reverse)
226    {
227        global $conf;
228        $changes = [];
229        $seen = [];
230        $count = 0;
231        $lines = [];
232
233        // Get global changelog
234        if (file_exists($conf['changelog']) && is_readable($conf['changelog'])) {
235            $lines = @file($conf['changelog']);
236        }
237
238        // Merge media changelog
239        if ($this->getConf('listmedia')) {
240            if (file_exists($conf['media_changelog']) && is_readable($conf['media_changelog'])) {
241                $linesMedia = @file($conf['media_changelog']);
242                // Add a tag to identiy the media lines
243                foreach ($linesMedia as $key => $value) {
244                    $value = ChangeLog::parseLogLine($value);
245                    $value['extra'] = 'media';
246                    $linesMedia[$key] = implode("\t", $value) . "\n";
247                }
248                $lines = array_merge($lines, $linesMedia);
249            }
250        }
251
252        if (is_null($maxage)) {
253            $maxage = (int) $conf['recent_days'] * 60 * 60 * 24;
254        }
255
256        for ($i = count($lines) - 1; $i >= 0; $i--) {
257            $change = $this->handleChangelogLine(
258                $lines[$i],
259                $ns,
260                $excludedpages,
261                $type,
262                $user,
263                $maxage,
264                $seen,
265                $excludedusers
266            );
267            if ($change !== false) {
268                $changes[] = $change;
269                // break when we have enough entries
270                if (++$count >= $num) break;
271            }
272        }
273
274        // Date sort merged page and media changes
275        if ($this->getConf('listmedia') || $reverse) {
276            $dates = [];
277            foreach ($changes as $change) {
278                $dates[] = $change['date'];
279            }
280            array_multisort($dates, ($reverse ? SORT_ASC : SORT_DESC), $changes);
281        }
282
283        return $changes;
284    }
285
286    /**
287     * Based on _handleRecent() from inc/changelog.php
288     *
289     * @param string $line
290     * @param array  $ns
291     * @param array  $excludedpages
292     * @param array  $type
293     * @param array  $user
294     * @param int    $maxage
295     * @param array  $seen
296     * @return array|bool
297     */
298    protected function handleChangelogLine($line, $ns, $excludedpages, $type, $user, $maxage, &$seen, $excludedusers)
299    {
300        // split the line into parts
301        $change = ChangeLog::parseLogLine($line);
302        if ($change === false) return false;
303
304        // skip seen ones
305        if (isset($seen[$change['id']])) return false;
306
307        // filter type
308        if (!empty($type) && !in_array($change['type'], $type)) return false;
309
310        // filter user
311        if (!empty($user) && (empty($change['user']) || !in_array($change['user'], $user))) return false;
312
313        // remember in seen to skip additional sights
314        $seen[$change['id']] = 1;
315
316        // show only not existing pages for delete
317        if ($change['extra'] != 'media' && $change['type'] != 'D' && !page_exists($change['id'])) return false;
318
319        // filter maxage
320        if ($maxage && $change['date'] < (time() - $maxage)) {
321            return false;
322        }
323
324        // check if it's a hidden page
325        if (isHiddenPage($change['id'])) return false;
326
327        // filter included namespaces
328        if (isset($ns['include'])) {
329            if (!$this->isInNamespace($ns['include'], $change['id'])) return false;
330        }
331
332        // filter excluded namespaces
333        if (isset($ns['exclude'])) {
334            if ($this->isInNamespace($ns['exclude'], $change['id'])) return false;
335        }
336        // exclude pages
337        if (!empty($excludedpages)) {
338            if (in_array($change['id'], $excludedpages)) {
339                return false;
340            }
341        }
342
343        // exclude users
344        if (!empty($excludedusers)) {
345            foreach ($excludedusers as $user) {
346                if ($change['user'] == $user) return false;
347            }
348        }
349
350        // check ACL
351        $change['perms'] = auth_quickaclcheck($change['id']);
352        if ($change['perms'] < AUTH_READ) return false;
353
354        return $change;
355    }
356
357    /**
358     * Check if page belongs to one of namespaces in the list
359     *
360     * @param array $namespaces
361     * @param string $id page id
362     * @return bool
363     */
364    protected function isInNamespace($namespaces, $id)
365    {
366        foreach ($namespaces as $ns) {
367            if ((strpos($id, $ns . ':') === 0)) return true;
368        }
369        return false;
370    }
371
372    /**
373     * Render via the Pagelist plugin
374     *
375     * @param $changes
376     * @param Doku_Renderer_xhtml $renderer
377     * @param $flags
378     */
379    protected function renderPageList($changes, $renderer, $flags)
380    {
381        /** @var helper_plugin_pagelist $pagelist */
382        $pagelist = @plugin_load('helper', 'pagelist');
383        if ($pagelist) {
384            $pagelist->setFlags($flags);
385            $pagelist->startList();
386            foreach ($changes as $change) {
387                if ($change['extra'] == 'media') continue;
388                $page['id'] = $change['id'];
389                $page['date'] = $change['date'];
390                $page['user'] = $this->getUserName($change);
391                $page['desc'] = $change['sum'];
392                $pagelist->addPage($page);
393            }
394            $renderer->doc .= $pagelist->finishList();
395        } else {
396            // Fallback to the simple list renderer
397            $this->renderSimpleList($changes, $renderer);
398        }
399    }
400
401    /**
402     * Render the day header
403     *
404     * @param Doku_Renderer $renderer
405     * @param int $date
406     */
407    protected function dayheader($renderer, $date)
408    {
409        if ($renderer->getFormat() == 'xhtml') {
410            /* @var Doku_Renderer_xhtml $renderer  */
411            $renderer->doc .= '<h3 class="changes">';
412            $renderer->cdata(dformat($date, $this->getConf('dayheaderfmt')));
413            $renderer->doc .= '</h3>';
414        } else {
415            $renderer->header(dformat($date, $this->getConf('dayheaderfmt')), 3, 0);
416        }
417    }
418
419    /**
420     * Render with a simple list render
421     *
422     * @param array $changes
423     * @param Doku_Renderer_xhtml $renderer
424     * @param array $flags
425     */
426    protected function renderSimpleList($changes, $renderer, $flags = [])
427    {
428        global $conf;
429        $flags = $this->parseSimpleListFlags($flags);
430
431        $dayheaders_date = '';
432        if ($flags['dayheaders']) {
433            $dayheaders_date = date('Ymd', $changes[0]['date']);
434            $this->dayheader($renderer, $changes[0]['date']);
435        }
436
437        $renderer->listu_open();
438        foreach ($changes as $change) {
439            if ($flags['dayheaders']) {
440                $tdate = date('Ymd', $change['date']);
441                if ($tdate !== $dayheaders_date) {
442                    $renderer->listu_close(); // break list to insert new header
443                    $this->dayheader($renderer, $change['date']);
444                    $renderer->listu_open();
445                    $dayheaders_date = $tdate;
446                }
447            }
448
449            $renderer->listitem_open(1);
450            $renderer->listcontent_open();
451            if (trim($change['extra']) == 'media') {
452                $renderer->internalmedia(':' . $change['id'], null, null, null, null, null, 'linkonly');
453            } else {
454                $renderer->internallink(':' . $change['id'], null, null, false, 'navigation');
455            }
456            if ($flags['summary']) {
457                $renderer->cdata(' ' . $change['sum']);
458            }
459            if ($flags['signature']) {
460                $user = $this->getUserName($change);
461                $date = strftime($conf['dformat'], $change['date']);
462                $renderer->cdata(' ');
463                $renderer->entity('---');
464                $renderer->cdata(' ');
465                $renderer->emphasis_open();
466                $renderer->cdata($user . ' ' . $date);
467                $renderer->emphasis_close();
468            }
469            $renderer->listcontent_close();
470            $renderer->listitem_close();
471        }
472        $renderer->listu_close();
473    }
474
475    /**
476     * Parse flags for the simple list render
477     *
478     * @param array $flags
479     * @return array
480     */
481    protected function parseSimpleListFlags($flags)
482    {
483        $outFlags = ['summary' => true, 'signature' => false, 'dayheaders' => false];
484        if (!empty($flags)) {
485            foreach ($flags as $flag) {
486                if (array_key_exists($flag, $outFlags)) {
487                    $outFlags[$flag] = true;
488                } elseif (substr($flag, 0, 2) == 'no') {
489                    $flag = substr($flag, 2);
490                    if (array_key_exists($flag, $outFlags)) {
491                        $outFlags[$flag] = false;
492                    }
493                }
494            }
495        }
496        return $outFlags;
497    }
498
499    /**
500     * Get username or fallback to ip
501     *
502     * @param array $change
503     * @return mixed
504     */
505    protected function getUserName($change)
506    {
507        /* @var DokuWiki_Auth_Plugin $auth */
508        global $auth;
509        if (!empty($change['user'])) {
510            $user = $auth->getUserData($change['user']);
511            if (empty($user)) {
512                return $change['user'];
513            } else {
514                return $user['name'];
515            }
516        } else {
517            return $change['ip'];
518        }
519    }
520}
521