1<?php
2
3namespace dokuwiki\Ui;
4
5use dokuwiki\ChangeLog\PageChangeLog;
6use dokuwiki\ChangeLog\MediaChangeLog;
7use dokuwiki\Extension\Event;
8use dokuwiki\Form\Form;
9
10/**
11 * DokuWiki Diff Interface
12 *
13 * @package dokuwiki\Ui
14 */
15class Diff extends Ui
16{
17    protected $text;
18    protected $showIntro;
19    protected $difftype;
20
21    /**
22     * Diff Ui constructor
23     *
24     * @param  string $text  when non-empty: compare with this text with most current version
25     * @param  bool   $showIntro display the intro text
26     * @param  string $difftype  diff view type (inline or sidebyside)
27     */
28    public function __construct($text = '', $showIntro = true, $difftype = null)
29    {
30        $this->text      = $text;
31        $this->showIntro = $showIntro;
32
33        // determine diff view type
34        if (isset($difftype)) {
35            $this->difftype  = $difftype;
36        } else {
37            global $INPUT;
38            global $INFO;
39            $this->difftype = $INPUT->str('difftype') ?: get_doku_pref('difftype', $difftype);
40            if (empty($this->difftype) && $INFO['ismobile']) {
41                $this->difftype = 'inline';
42            }
43        }
44        if ($this->difftype !== 'inline') $this->difftype = 'sidebyside';
45    }
46
47    /**
48     * Show diff
49     * between current page version and provided $text
50     * or between the revisions provided via GET or POST
51     *
52     * @author Andreas Gohr <andi@splitbrain.org>
53     *
54     * @return void
55     */
56    public function show()
57    {
58        global $ID;
59        global $REV;
60        global $lang;
61        global $INPUT;
62        global $INFO;
63        $pagelog = new PageChangeLog($ID);
64
65        /*
66         * Determine requested revision(s)
67         */
68        // we're trying to be clever here, revisions to compare can be either
69        // given as rev and rev2 parameters, with rev2 being optional. Or in an
70        // array in rev2.
71        $rev1 = $REV;
72
73        $rev2 = $INPUT->ref('rev2');
74        if (is_array($rev2)) {
75            $rev1 = (int) $rev2[0];
76            $rev2 = (int) $rev2[1];
77
78            if (!$rev1) {
79                $rev1 = $rev2;
80                unset($rev2);
81            }
82        } else {
83            $rev2 = $INPUT->int('rev2');
84        }
85
86        /*
87         * Determine left and right revision, its texts and the header
88         */
89        $r_minor = '';
90        $l_minor = '';
91
92        if ($this->text) { // compare text to the most current revision
93            $l_rev = '';
94            $l_text = rawWiki($ID, '');
95            $l_head = '<a class="wikilink1" href="'. wl($ID) .'">'
96                . $ID .' '. dformat((int) @filemtime(wikiFN($ID))) .'</a> '
97                . $lang['current'];
98
99            $r_rev = '';
100            $r_text = cleanText($this->text);
101            $r_head = $lang['yours'];
102        } else {
103            if ($rev1 && isset($rev2) && $rev2) { // two specific revisions wanted
104                // make sure order is correct (older on the left)
105                if ($rev1 < $rev2) {
106                    $l_rev = $rev1;
107                    $r_rev = $rev2;
108                } else {
109                    $l_rev = $rev2;
110                    $r_rev = $rev1;
111                }
112            } elseif ($rev1) { // single revision given, compare to current
113                $r_rev = '';
114                $l_rev = $rev1;
115            } else { // no revision was given, compare previous to current
116                $r_rev = '';
117                $revs = $pagelog->getRevisions(0, 1);
118                $l_rev = $revs[0];
119                $REV = $l_rev; // store revision back in $REV
120            }
121
122            // when both revisions are empty then the page was created just now
123            if (!$l_rev && !$r_rev) {
124                $l_text = '';
125            } else {
126                $l_text = rawWiki($ID, $l_rev);
127            }
128            $r_text = rawWiki($ID, $r_rev);
129
130            list($l_head, $r_head, $l_minor, $r_minor) = $this->diffHead(
131                $l_rev, $r_rev, null, false, ($this->difftype == 'inline')
132            );
133        }
134
135        /*
136         * Build navigation
137         */
138        $l_nav = '';
139        $r_nav = '';
140        if (!$this->text) {
141            list($l_nav, $r_nav) = $this->diffNavigation($pagelog, $l_rev, $r_rev);
142        }
143        /*
144         * Create diff object and the formatter
145         */
146        $diff = new \Diff(explode("\n", $l_text), explode("\n", $r_text));
147
148        if ($this->difftype == 'inline') {
149            $diffformatter = new \InlineDiffFormatter();
150        } else {
151            $diffformatter = new \TableDiffFormatter();
152        }
153        /*
154         * Display intro
155         */
156        if ($this->showIntro) print p_locale_xhtml('diff');
157
158        /*
159         * Display type and exact reference
160         */
161        if (!$this->text) {
162            print '<div class="diffoptions group">';
163
164            // create the form to select difftype
165            $form = new Form(['action' => wl()]);
166            $form->setHiddenField('id', $ID);
167            $form->setHiddenField('rev2[0]', $l_rev);
168            $form->setHiddenField('rev2[1]', $r_rev);
169            $form->setHiddenField('do', 'diff');
170            $options = array(
171                         'sidebyside' => $lang['diff_side'],
172                         'inline' => $lang['diff_inline']
173            );
174            $input = $form->addDropdown('difftype', $options, $lang['diff_type'])
175                ->val($this->difftype)->addClass('quickselect');
176            $input->useInput(false); // inhibit prefillInput() during toHTML() process
177            $form->addButton('do[diff]', 'Go')->attr('type','submit');
178            print $form->toHTML();
179
180            print '<p>';
181            // link to exactly this view FS#2835
182            print $this->diffViewlink('difflink', $l_rev, ($r_rev ?: $INFO['currentrev']));
183            print '</p>';
184
185            print '</div>'; // .diffoptions
186        }
187
188        /*
189         * Display diff view table
190         */
191        print '<div class="table">';
192        print '<table class="diff diff_'. $this->difftype .'">';
193
194        //navigation and header
195        if ($this->difftype == 'inline') {
196            if (!$this->text) {
197                print '<tr>'
198                    . '<td class="diff-lineheader">-</td>'
199                    . '<td class="diffnav">'. $l_nav .'</td>'
200                    . '</tr>';
201                print '<tr>'
202                    . '<th class="diff-lineheader">-</th>'
203                    . '<th '. $l_minor .'>'. $l_head .'</th>'
204                    .'</tr>';
205            }
206            print '<tr>'
207                . '<td class="diff-lineheader">+</td>'
208                . '<td class="diffnav">'. $r_nav .'</td>'
209                .'</tr>';
210            print '<tr>'
211                . '<th class="diff-lineheader">+</th>'
212                . '<th '. $r_minor .'>'. $r_head .'</th>'
213                . '</tr>';
214        } else {
215            if (!$this->text) {
216                print '<tr>'
217                    . '<td colspan="2" class="diffnav">'. $l_nav .'</td>'
218                    . '<td colspan="2" class="diffnav">'. $r_nav .'</td>'
219                    . '</tr>';
220            }
221            print '<tr>'
222                . '<th colspan="2" '. $l_minor .'>'. $l_head .'</th>'
223                . '<th colspan="2" '. $r_minor .'>'. $r_head .'</th>'
224                . '</tr>';
225        }
226
227        //diff view
228        print $this->insertSoftbreaks($diffformatter->format($diff));
229
230        print '</table>';
231        print '</div>';
232    }
233
234
235    /**
236     * Get header of diff HTML
237     *
238     * @param string $l_rev   Left revisions
239     * @param string $r_rev   Right revision
240     * @param string $id      Page id, if null $ID is used
241     * @param bool   $media   If it is for media files
242     * @param bool   $inline  Return the header on a single line
243     * @return string[] HTML snippets for diff header
244     */
245    public function diffHead($l_rev, $r_rev, $id = null, $media = false, $inline = false)
246    {
247        global $lang;
248        if ($id === null) {
249            global $ID;
250            $id = $ID;
251        }
252        $head_separator = $inline ? ' ' : '<br />';
253        $media_or_wikiFN = $media ? 'mediaFN' : 'wikiFN';
254        $ml_or_wl = $media ? 'ml' : 'wl';
255        $l_minor = $r_minor = '';
256
257        if ($media) {
258            $changelog = new MediaChangeLog($id);
259        } else {
260            $changelog = new PageChangeLog($id);
261        }
262        if (!$l_rev) {
263            $l_head = '&mdash;';
264        } else {
265            $l_info   = $changelog->getRevisionInfo($l_rev);
266            if ($l_info['user']) {
267                $l_user = '<bdi>'.editorinfo($l_info['user']).'</bdi>';
268                if (auth_ismanager()) $l_user .= ' <bdo dir="ltr">('.$l_info['ip'].')</bdo>';
269            } else {
270                $l_user = '<bdo dir="ltr">'.$l_info['ip'].'</bdo>';
271            }
272            $l_user  = '<span class="user">'.$l_user.'</span>';
273            $l_sum   = ($l_info['sum']) ? '<span class="sum"><bdi>'.hsc($l_info['sum']).'</bdi></span>' : '';
274            if ($l_info['type'] === DOKU_CHANGE_TYPE_MINOR_EDIT) $l_minor = 'class="minor"';
275
276            $l_head_title = ($media) ? dformat($l_rev) : $id.' ['.dformat($l_rev).']';
277            $l_head = '<bdi><a class="wikilink1" href="'.$ml_or_wl($id,"rev=$l_rev").'">'
278                . $l_head_title.'</a></bdi>'.$head_separator.$l_user.' '.$l_sum;
279        }
280
281        if ($r_rev) {
282            $r_info   = $changelog->getRevisionInfo($r_rev);
283            if ($r_info['user']) {
284                $r_user = '<bdi>'.editorinfo($r_info['user']).'</bdi>';
285                if (auth_ismanager()) $r_user .= ' <bdo dir="ltr">('.$r_info['ip'].')</bdo>';
286            } else {
287                $r_user = '<bdo dir="ltr">'.$r_info['ip'].'</bdo>';
288            }
289            $r_user = '<span class="user">'.$r_user.'</span>';
290            $r_sum  = ($r_info['sum']) ? '<span class="sum"><bdi>'.hsc($r_info['sum']).'</bdi></span>' : '';
291            if ($r_info['type'] === DOKU_CHANGE_TYPE_MINOR_EDIT) $r_minor = 'class="minor"';
292
293            $r_head_title = ($media) ? dformat($r_rev) : $id.' ['.dformat($r_rev).']';
294            $r_head = '<bdi><a class="wikilink1" href="'.$ml_or_wl($id,"rev=$r_rev").'">'
295                . $r_head_title.'</a></bdi>'.$head_separator.$r_user.' '.$r_sum;
296        } elseif ($_rev = @filemtime($media_or_wikiFN($id))) {
297            $_info   = $changelog->getRevisionInfo($_rev);
298            if ($_info['user']) {
299                $_user = '<bdi>'.editorinfo($_info['user']).'</bdi>';
300                if (auth_ismanager()) $_user .= ' <bdo dir="ltr">('.$_info['ip'].')</bdo>';
301            } else {
302                $_user = '<bdo dir="ltr">'.$_info['ip'].'</bdo>';
303            }
304            $_user = '<span class="user">'.$_user.'</span>';
305            $_sum  = ($_info['sum']) ? '<span class="sum"><bdi>'.hsc($_info['sum']).'</span></bdi>' : '';
306            if ($_info['type'] === DOKU_CHANGE_TYPE_MINOR_EDIT) $r_minor = 'class="minor"';
307
308            $r_head_title = ($media) ? dformat($_rev) : $id.' ['.dformat($_rev).']';
309            $r_head  = '<bdi><a class="wikilink1" href="'.$ml_or_wl($id).'">'
310                . $r_head_title.'</a></bdi> '.'('.$lang['current'].')'.$head_separator.$_user.' '.$_sum;
311        }else{
312            $r_head = '&mdash; ('.$lang['current'].')';
313        }
314
315        return array($l_head, $r_head, $l_minor, $r_minor);
316    }
317
318    /**
319     * Create html for revision navigation
320     *
321     * @param PageChangeLog $pagelog changelog object of current page
322     * @param int           $l_rev   left revision timestamp
323     * @param int           $r_rev   right revision timestamp
324     * @return string[] html of left and right navigation elements
325     */
326    protected function diffNavigation($pagelog, $l_rev, $r_rev)
327    {
328        global $INFO, $ID;
329
330        // last timestamp is not in changelog, retrieve timestamp from metadata
331        // note: when page is removed, the metadata timestamp is zero
332        if (!$r_rev) {
333            if (isset($INFO['meta']['last_change']['date'])) {
334                $r_rev = $INFO['meta']['last_change']['date'];
335            } else {
336                $r_rev = 0;
337            }
338        }
339
340        //retrieve revisions with additional info
341        list($l_revs, $r_revs) = $pagelog->getRevisionsAround($l_rev, $r_rev);
342        $l_revisions = array();
343        if (!$l_rev) {
344            //no left revision given, add dummy
345            $l_revisions[0]= array('label' => '', 'attrs' => []);
346        }
347        foreach ($l_revs as $rev) {
348            $info = $pagelog->getRevisionInfo($rev);
349            $l_revisions[$rev] = array(
350                'label' => dformat($info['date']) .' '. editorinfo($info['user'], true) .' '. $info['sum'],
351                'attrs' => ['title' => $rev],
352            );
353            if ($r_rev ? $rev >= $r_rev : false) $l_revisions[$rev]['attrs']['disabled'] = 'disabled';
354        }
355        $r_revisions = array();
356        if (!$r_rev) {
357            //no right revision given, add dummy
358            $r_revisions[0] = array('label' => '', 'attrs' => []);
359        }
360        foreach ($r_revs as $rev) {
361            $info = $pagelog->getRevisionInfo($rev);
362            $r_revisions[$rev] = array(
363                'label' => dformat($info['date']) .' '. editorinfo($info['user'], true) .' '. $info['sum'],
364                'attrs' => ['title' => $rev],
365            );
366            if ($rev <= $l_rev) $r_revisions[$rev]['attrs']['disabled'] = 'disabled';
367        }
368
369        //determine previous/next revisions
370        $l_index = array_search($l_rev, $l_revs);
371        $l_prev = $l_index < count($l_revs) - 1 ? $l_revs[$l_index + 1] : null;
372        $l_next = $l_index > 1 ? $l_revs[$l_index - 1] : null;
373        if ($r_rev) {
374            $r_index = array_search($r_rev, $r_revs);
375            $r_prev = $r_index < count($r_revs) - 1 ? $r_revs[$r_index + 1] : null;
376            $r_next = $r_index > 1 ? $r_revs[$r_index - 1] : null;
377        } else {
378            //removed page
379            if ($l_next) {
380                $r_prev = $r_revs[0];
381            } else {
382                $r_prev = null;
383            }
384            $r_next = null;
385        }
386
387        /*
388         * Left side:
389         */
390        $l_nav = '';
391        //move back
392        if ($l_prev) {
393            $l_nav .= $this->diffViewlink('diffbothprevrev', $l_prev, $r_prev);
394            $l_nav .= $this->diffViewlink('diffprevrev', $l_prev, $r_rev);
395        }
396        //dropdown
397        $form = new Form(['action' => wl()]);
398        $form->setHiddenField('id', $ID);
399        $form->setHiddenField('difftype', $this->difftype);
400        $form->setHiddenField('rev2[1]', $r_rev);
401        $form->setHiddenField('do', 'diff');
402        $input = $form->addDropdown('rev2[0]', $l_revisions)->val($l_rev)->addClass('quickselect');
403        $input->useInput(false); // inhibit prefillInput() during toHTML() process
404        $form->addButton('do[diff]', 'Go')->attr('type','submit');
405        $l_nav .= $form->toHTML();
406        //move forward
407        if ($l_next && ($l_next < $r_rev || !$r_rev)) {
408            $l_nav .= $this->diffViewlink('diffnextrev', $l_next, $r_rev);
409        }
410
411        /*
412         * Right side:
413         */
414        $r_nav = '';
415        //move back
416        if ($l_rev < $r_prev) {
417            $r_nav .= $this->diffViewlink('diffprevrev', $l_rev, $r_prev);
418        }
419        //dropdown
420        $form = new Form(['action' => wl()]);
421        $form->setHiddenField('id', $ID);
422        $form->setHiddenField('rev2[0]', $l_rev);
423        $form->setHiddenField('difftype', $this->difftype);
424        $form->setHiddenField('do', 'diff');
425        $input = $form->addDropdown('rev2[1]', $r_revisions)->val($r_rev)->addClass('quickselect');
426        $input->useInput(false); // inhibit prefillInput() during toHTML() process
427        $form->addButton('do[diff]', 'Go')->attr('type','submit');
428        $r_nav .= $form->toHTML();
429        //move forward
430        if ($r_next) {
431            if ($pagelog->isCurrentRevision($r_next)) {
432                //last revision is diff with current page
433                $r_nav .= $this->diffViewlink('difflastrev', $l_rev);
434            } else {
435                $r_nav .= $this->diffViewlink('diffnextrev', $l_rev, $r_next);
436            }
437        } else {
438            $r_nav .= $this->diffViewlink('diffbothnextrev', $l_next, $r_next);
439        }
440        return array($l_nav, $r_nav);
441    }
442
443    /**
444     * Create html link to a diff view defined by two revisions
445     *
446     * @param string $linktype
447     * @param int $lrev oldest revision
448     * @param int $rrev newest revision or null for diff with current revision
449     * @return string html of link to a diff view
450     */
451    protected function diffViewlink($linktype, $lrev, $rrev = null)
452    {
453        global $ID, $lang;
454        if ($rrev === null) {
455            $urlparam = array(
456                'do' => 'diff',
457                'rev' => $lrev,
458                'difftype' => $this->difftype,
459            );
460        } else {
461            $urlparam = array(
462                'do' => 'diff',
463                'rev2[0]' => $lrev,
464                'rev2[1]' => $rrev,
465                'difftype' => $this->difftype,
466            );
467        }
468        return  '<a class="'. $linktype .'" href="'. wl($ID, $urlparam) .'" title="'. $lang[$linktype] .'">'
469              . '<span>'. $lang[$linktype] .'</span>'
470              . '</a>';
471    }
472
473
474    /**
475     * Insert soft breaks in diff html
476     *
477     * @param string $diffhtml
478     * @return string
479     */
480    public function insertSoftbreaks($diffhtml)
481    {
482        // search the diff html string for both:
483        // - html tags, so these can be ignored
484        // - long strings of characters without breaking characters
485        return preg_replace_callback('/<[^>]*>|[^<> ]{12,}/', function ($match) {
486            // if match is an html tag, return it intact
487            if ($match[0][0] == '<') return $match[0];
488            // its a long string without a breaking character,
489            // make certain characters into breaking characters by inserting a
490            // word break opportunity (<wbr> tag) in front of them.
491            $regex = <<< REGEX
492(?(?=              # start a conditional expression with a positive look ahead ...
493&\#?\\w{1,6};)     # ... for html entities - we don't want to split them (ok to catch some invalid combinations)
494&\#?\\w{1,6};      # yes pattern - a quicker match for the html entity, since we know we have one
495|
496[?/,&\#;:]         # no pattern - any other group of 'special' characters to insert a breaking character after
497)+                 # end conditional expression
498REGEX;
499            return preg_replace('<'.$regex.'>xu', '\0<wbr>', $match[0]);
500        }, $diffhtml);
501    }
502
503}
504