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