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