xref: /dokuwiki/inc/Ui/PageDiff.php (revision 79a2d7845d5e5e48fe3be8f192717de9294aaba5)
1<?php
2
3namespace dokuwiki\Ui;
4
5use dokuwiki\ChangeLog\PageChangeLog;
6use dokuwiki\Form\Form;
7use InlineDiffFormatter;
8use TableDiffFormatter;
9
10/**
11 * DokuWiki PageDiff Interface
12 *
13 * @author Andreas Gohr <andi@splitbrain.org>
14 * @author Satoshi Sahara <sahara.satoshi@gmail.com>
15 * @package dokuwiki\Ui
16 */
17class PageDiff extends Diff
18{
19    /* @var PageChangeLog */
20    protected $changelog;
21
22    /* @var array */
23    protected $oldRevInfo;
24    protected $newRevInfo;
25
26    /* @var string */
27    protected $text;
28
29    /**
30     * PageDiff Ui constructor
31     *
32     * @param string $id  page id
33     */
34    public function __construct($id = null)
35    {
36        global $INFO;
37        if (!isset($id)) $id = $INFO['id'];
38
39        // init preference
40        $this->preference['showIntro'] = true;
41        $this->preference['difftype'] = 'sidebyside'; // diff view type: inline or sidebyside
42
43        parent::__construct($id);
44    }
45
46    /** @inheritdoc */
47    protected function setChangeLog()
48    {
49        $this->changelog = new PageChangeLog($this->id);
50    }
51
52    /**
53     * Set text to be compared with most current version
54     * when it has been externally edited
55     * exclusively use of the compare($old, $new) method
56     *
57     * @param string $text
58     * @return $this
59     */
60    public function compareWith($text = null)
61    {
62        global $lang;
63
64        if (isset($text)) {
65            $this->text = $text;
66            $changelog =& $this->changelog;
67
68            // revision info of older file (left side)
69            $this->oldRevInfo = $changelog->getCurrentRevisionInfo() + [
70                'current' => true,
71                'rev'  => '',
72                'navTitle' => $this->revisionTitle($changelog->getCurrentRevisionInfo()),
73                'text' => rawWiki($this->id),
74            ];
75
76            // revision info of newer file (right side)
77            $this->newRevInfo = [
78                'date' => null,
79              //'ip'   => '127.0.0.1',
80              //'type' => DOKU_CHANGE_TYPE_CREATE,
81                'id'   => $this->id,
82              //'user' => '',
83              //'sum'  => '',
84              //'extra' => '',
85                'sizechange' => strlen($this->text) - io_getSizeFile(wikiFN($this->id)),
86                'timestamp' => false,
87                'current' => false,
88                'rev'  => false,
89                'navTitle' => $lang['yours'],
90                'text' => cleanText($this->text),
91            ];
92        }
93        return $this;
94    }
95
96    /**
97     * Handle requested revision(s) and diff view preferences
98     *
99     * @return void
100     */
101    protected function handle()
102    {
103        global $INPUT;
104
105        // requested rev or rev2
106        if (!isset($this->oldRevInfo, $this->newRevInfo)) {
107            parent::handle();
108        }
109
110        // requested diff view type
111        if ($INPUT->has('difftype')) {
112            $this->preference['difftype'] = $INPUT->str('difftype');
113        } else {
114            // read preference from DokuWiki cookie. PageDiff only
115            $mode = get_doku_pref('difftype', null);
116            if (isset($mode)) $this->preference['difftype'] = $mode;
117        }
118
119        if (!$INPUT->has('rev') && !$INPUT->has('rev2')) {
120            global $INFO, $REV;
121            if ($this->id == $INFO['id'])
122                $REV = $this->oldRev; // store revision back in $REV
123        }
124    }
125
126    /**
127     * Prepare revision info of comparison pair
128     */
129    protected function preProcess()
130    {
131        $changelog =& $this->changelog;
132
133        // revision info of older file (left side)
134        $this->oldRevInfo = $changelog->getRevisionInfo($this->oldRev);
135        // revision info of newer file (right side)
136        $this->newRevInfo = $changelog->getRevisionInfo($this->newRev);
137
138        foreach ([&$this->oldRevInfo, &$this->newRevInfo] as &$revInfo) {
139            // use timestamp and '' properly as $rev for the current file
140            $isCurrent = $changelog->isCurrentRevision($revInfo['date']);
141            $revInfo += [
142                'current' => $isCurrent,
143                'rev'     => $isCurrent ? '' : $revInfo['date'],
144            ];
145
146            // headline in the Diff view navigation
147            $revInfo['navTitle'] = $this->revisionTitle($revInfo);
148
149            if ($revInfo['type'] == DOKU_CHANGE_TYPE_DELETE) {
150                //attic stores complete last page version for a deleted page
151                $revInfo['text'] = '';
152            } else {
153                $revInfo['text'] = rawWiki($this->id, $revInfo['rev']);
154            }
155        }
156    }
157
158    /**
159     * Show diff
160     * between current page version and provided $text
161     * or between the revisions provided via GET or POST
162     *
163     * @author Andreas Gohr <andi@splitbrain.org>
164     *
165     * @return void
166     */
167    public function show()
168    {
169        if (!isset($this->oldRevInfo, $this->newRevInfo)) {
170            // retrieve form parameters: rev, rev2, difftype
171            $this->handle();
172            // prepare revision info of comparison pair, except PageConfrict or PageDraft
173            $this->preProcess();
174        }
175
176        // create difference engine object
177        $Difference = new \Diff(
178                explode("\n", $this->oldRevInfo['text']),
179                explode("\n", $this->newRevInfo['text'])
180        );
181
182        // build paired navigation
183        [$navOlderRevisions, $navNewerRevisions] = $this->buildRevisionsNavigation();
184
185        // display intro
186        if ($this->preference['showIntro']) echo p_locale_xhtml('diff');
187
188        // print form to choose diff view type, and exact url reference to the view
189        if ($this->newRevInfo['rev'] !== false) {
190            $this->showDiffViewSelector();
191        }
192
193        // assign minor edit checker to the variable
194        $classEditType = function ($info) {
195            return ($info['type'] === DOKU_CHANGE_TYPE_MINOR_EDIT) ? ' class="minor"' : '';
196        };
197
198        // display diff view table
199        echo '<div class="table">';
200        echo '<table class="diff diff_'.$this->preference['difftype'] .'">';
201
202        //navigation and header
203        switch ($this->preference['difftype']) {
204            case 'inline':
205                if ($this->newRevInfo['rev'] !== false) {
206                    echo '<tr>'
207                        .'<td class="diff-lineheader">-</td>'
208                        .'<td class="diffnav">'. $navOlderRevisions .'</td>'
209                        .'</tr>';
210                    echo '<tr>'
211                        .'<th class="diff-lineheader">-</th>'
212                        .'<th'.$classEditType($this->oldRevInfo).'>'.$this->oldRevInfo['navTitle'].'</th>'
213                        .'</tr>';
214                }
215                echo '<tr>'
216                    .'<td class="diff-lineheader">+</td>'
217                    .'<td class="diffnav">'. $navNewerRevisions .'</td>'
218                    .'</tr>';
219                echo '<tr>'
220                    .'<th class="diff-lineheader">+</th>'
221                    .'<th'.$classEditType($this->newRevInfo).'>'.$this->newRevInfo['navTitle'].'</th>'
222                    .'</tr>';
223                // create formatter object
224                $DiffFormatter = new InlineDiffFormatter();
225                break;
226
227            case 'sidebyside':
228            default:
229                if ($this->newRevInfo['rev'] !== false) {
230                    echo '<tr>'
231                        .'<td colspan="2" class="diffnav">'. $navOlderRevisions .'</td>'
232                        .'<td colspan="2" class="diffnav">'. $navNewerRevisions .'</td>'
233                        .'</tr>';
234                }
235                echo '<tr>'
236                    .'<th colspan="2"'.$classEditType($this->oldRevInfo).'>'.$this->oldRevInfo['navTitle'].'</th>'
237                    .'<th colspan="2"'.$classEditType($this->newRevInfo).'>'.$this->newRevInfo['navTitle'].'</th>'
238                    .'</tr>';
239                // create formatter object
240                $DiffFormatter = new TableDiffFormatter();
241                break;
242        }
243
244        // output formatted difference
245        echo $this->insertSoftbreaks($DiffFormatter->format($Difference));
246
247        echo '</table>';
248        echo '</div>';
249    }
250
251    /**
252     * Revision Title for PageDiff table headline
253     *
254     * @param array $info  Revision info structure of a page
255     * @return string
256     */
257    protected function revisionTitle(array $info)
258    {
259        global $lang;
260
261        // use designated title when compare current page source with given text
262        if (array_key_exists('date', $info) && is_null($info['date'])) {
263            return $lang['yours'];
264        }
265
266        // revision info may have timestamp key when external edits occurred
267        $info['timestamp'] = $info['timestamp'] ?? true;
268
269        if (isset($info['date'])) {
270            $rev = $info['date'];
271            if ($info['timestamp'] === false) {
272                // exteranlly deleted or older file restored
273                $title = '<bdi><a class="wikilink2" href="'.wl($this->id).'">'
274                   . $this->id .' ['. $lang['unknowndate'] .']'.'</a></bdi>';
275            } else {
276                $title = '<bdi><a class="wikilink1" href="'.wl($this->id, ['rev' => $rev]).'">'
277                   . $this->id .' ['. dformat($rev) .']'.'</a></bdi>';
278            }
279        } else {
280            $title = '&mdash;';
281        }
282        if ($info['current']) {
283            $title .= '&nbsp;('.$lang['current'].')';
284        }
285
286        // append separator
287        $title .= ($this->preference['difftype'] === 'inline') ? ' ' : '<br />';
288
289        // supplement
290        if (isset($info['date'])) {
291            $objRevInfo = (new PageRevisions($this->id))->getObjRevInfo($info);
292            $title .= $objRevInfo->editSummary().' '.$objRevInfo->editor();
293        }
294        return $title;
295    }
296
297    /**
298     * Print form to choose diff view type, and exact url reference to the view
299     */
300    protected function showDiffViewSelector()
301    {
302        global $lang;
303
304        // use timestamp for current revision
305        [$oldRev, $newRev] = [(int)$this->oldRevInfo['date'], (int)$this->newRevInfo['date']];
306
307        echo '<div class="diffoptions group">';
308
309        // create the form to select difftype
310        $form = new Form(['action' => wl()]);
311        $form->setHiddenField('id', $this->id);
312        $form->setHiddenField('rev2[0]', $oldRev);
313        $form->setHiddenField('rev2[1]', $newRev);
314        $form->setHiddenField('do', 'diff');
315        $options = array(
316                     'sidebyside' => $lang['diff_side'],
317                     'inline' => $lang['diff_inline'],
318        );
319        $input = $form->addDropdown('difftype', $options, $lang['diff_type'])
320            ->val($this->preference['difftype'])
321            ->addClass('quickselect');
322        $input->useInput(false); // inhibit prefillInput() during toHTML() process
323        $form->addButton('do[diff]', 'Go')->attr('type','submit');
324        echo $form->toHTML();
325
326        // show exact url reference to the view when it is meaningful
327        echo '<p>';
328        if ($oldRev && $newRev) {
329            // link to exactly this view FS#2835
330            $viewUrl = $this->diffViewlink('difflink', $oldRev, $newRev);
331        }
332        echo $viewUrl ?? '<br />';
333        echo '</p>';
334
335        echo '</div>'; // .diffoptions
336    }
337
338    /**
339     * Create html for revision navigation
340     *
341     * The navigation consists of older and newer revisions selectors, each
342     * state mutually depends on the selected revision of opposite side.
343     *
344     * @return string[] html of navigation for both older and newer sides
345     */
346    protected function buildRevisionsNavigation()
347    {
348        $changelog =& $this->changelog;
349
350        if ($this->newRevInfo['rev'] === false) {
351            // no revisions selector for PageConflict or PageDraft
352            return array('', '');
353        }
354
355        // use timestamp for current revision
356        [$oldRev, $newRev] = [(int)$this->oldRevInfo['date'], (int)$this->newRevInfo['date']];
357
358        // retrieve revisions with additional info
359        [$oldRevs, $newRevs] = $changelog->getRevisionsAround($oldRev, $newRev);
360
361        // build options for dropdown selector
362        $olderRevisions = $this->buildRevisionOptions('older', $oldRevs);
363        $newerRevisions = $this->buildRevisionOptions('newer', $newRevs);
364
365        // determine previous/next revisions
366        $index = array_search($oldRev, $oldRevs);
367        $oldPrevRev = ($index +1 < count($oldRevs)) ? $oldRevs[$index +1] : false;
368        $oldNextRev = ($index > 0)                  ? $oldRevs[$index -1] : false;
369        $index = array_search($newRev, $newRevs);
370        $newPrevRev = ($index +1 < count($newRevs)) ? $newRevs[$index +1] : false;
371        $newNextRev = ($index > 0)                  ? $newRevs[$index -1] : false;
372
373        /*
374         * navigation UI for older revisions / Left side:
375         */
376        $navOlderRevs = '';
377        // move backward both side: ◀◀
378        if ($oldPrevRev && $newPrevRev)
379            $navOlderRevs .= $this->diffViewlink('diffbothprevrev', $oldPrevRev, $newPrevRev);
380        // move backward left side: ◀
381        if ($oldPrevRev)
382            $navOlderRevs .= $this->diffViewlink('diffprevrev', $oldPrevRev, $newRev);
383        // dropdown
384        $navOlderRevs .= $this->buildDropdownSelector('older', $olderRevisions);
385        // move forward left side: ▶
386        if ($oldNextRev && ($oldNextRev < $newRev))
387            $navOlderRevs .= $this->diffViewlink('diffnextrev', $oldNextRev, $newRev);
388
389        /*
390         * navigation UI for newer revisions / Right side:
391         */
392        $navNewerRevs = '';
393        // move backward right side: ◀
394        if ($newPrevRev && ($oldRev < $newPrevRev))
395            $navNewerRevs .= $this->diffViewlink('diffprevrev', $oldRev, $newPrevRev);
396        // dropdown
397        $navNewerRevs .= $this->buildDropdownSelector('newer', $newerRevisions);
398        // move forward right side: ▶
399        if ($newNextRev) {
400            if ($changelog->isCurrentRevision($newNextRev)) {
401                $navNewerRevs .= $this->diffViewlink('difflastrev', $oldRev, $newNextRev);
402            } else {
403                $navNewerRevs .= $this->diffViewlink('diffnextrev', $oldRev, $newNextRev);
404            }
405        }
406        // move forward both side: ▶▶
407        if ($oldNextRev && $newNextRev)
408            $navNewerRevs .= $this->diffViewlink('diffbothnextrev', $oldNextRev, $newNextRev);
409
410        return array($navOlderRevs, $navNewerRevs);
411    }
412
413    /**
414     * prepare options for dropdwon selector
415     *
416     * @params string $side  "older" or "newer"
417     * @params array $revs  list of revsion
418     * @return array
419     */
420    protected function buildRevisionOptions($side, $revs)
421    {
422        $changelog =& $this->changelog;
423        $revisions = array();
424
425        // use timestamp for current revision
426        [$oldRev, $newRev] = [(int)$this->oldRevInfo['date'], (int)$this->newRevInfo['date']];
427
428        foreach ($revs as $rev) {
429            $info = $changelog->getRevisionInfo($rev);
430            // revision info may have timestamp key when external edits occurred
431            $info['timestamp'] = $info['timestamp'] ?? true;
432            $date = dformat($info['date']);
433            if ($info['timestamp'] === false) {
434                // exteranlly deleted or older file restored
435                $date = preg_replace('/[0-9a-zA-Z]/','_', $date);
436            }
437            $revisions[$rev] = array(
438                'label' => implode(' ', [
439                            $date,
440                            editorinfo($info['user'], true),
441                            $info['sum'],
442                           ]),
443                'attrs' => ['title' => $rev],
444            );
445            if (($side == 'older' && ($newRev && $rev >= $newRev))
446              ||($side == 'newer' && ($rev <= $oldRev))
447            ) {
448                $revisions[$rev]['attrs']['disabled'] = 'disabled';
449            }
450        }
451        return $revisions;
452    }
453
454    /**
455     * build Dropdown form for revisions navigation
456     *
457     * @params string $side  "older" or "newer"
458     * @params array $options  dropdown options
459     * @return string
460     */
461    protected function buildDropdownSelector($side, $options)
462    {
463        $form = new Form(['action' => wl($this->id)]);
464        $form->setHiddenField('id', $this->id);
465        $form->setHiddenField('do', 'diff');
466        $form->setHiddenField('difftype', $this->preference['difftype']);
467
468        // use timestamp for current revision
469        [$oldRev, $newRev] = [(int)$this->oldRevInfo['date'], (int)$this->newRevInfo['date']];
470
471        switch ($side) {
472            case 'older': // left side
473                $form->setHiddenField('rev2[1]', $newRev);
474                $input = $form->addDropdown('rev2[0]', $options)
475                    ->val($oldRev)->addClass('quickselect');
476                $input->useInput(false); // inhibit prefillInput() during toHTML() process
477                break;
478            case 'newer': // right side
479                $form->setHiddenField('rev2[0]', $oldRev);
480                $input = $form->addDropdown('rev2[1]', $options)
481                    ->val($newRev)->addClass('quickselect');
482                $input->useInput(false); // inhibit prefillInput() during toHTML() process
483                break;
484        }
485        $form->addButton('do[diff]', 'Go')->attr('type','submit');
486        return $form->toHTML();
487    }
488
489    /**
490     * Create html link to a diff view defined by two revisions
491     *
492     * @param string $linktype
493     * @param int $oldRev older revision
494     * @param int $newRev newer revision or null for diff with current revision
495     * @return string html of link to a diff view
496     */
497    protected function diffViewlink($linktype, $oldRev, $newRev = null)
498    {
499        global $lang;
500        if ($newRev === null) {
501            $urlparam = array(
502                'do' => 'diff',
503                'rev' => $oldRev,
504                'difftype' => $this->preference['difftype'],
505            );
506        } else {
507            $urlparam = array(
508                'do' => 'diff',
509                'rev2[0]' => $oldRev,
510                'rev2[1]' => $newRev,
511                'difftype' => $this->preference['difftype'],
512            );
513        }
514        $attr = array(
515            'class' => $linktype,
516            'href'  => wl($this->id, $urlparam, true, '&'),
517            'title' => $lang[$linktype],
518        );
519        return '<a '. buildAttributes($attr) .'><span>'. $lang[$linktype] .'</span></a>';
520    }
521
522
523    /**
524     * Insert soft breaks in diff html
525     *
526     * @param string $diffhtml
527     * @return string
528     */
529    public function insertSoftbreaks($diffhtml)
530    {
531        // search the diff html string for both:
532        // - html tags, so these can be ignored
533        // - long strings of characters without breaking characters
534        return preg_replace_callback('/<[^>]*>|[^<> ]{12,}/', function ($match) {
535            // if match is an html tag, return it intact
536            if ($match[0][0] == '<') return $match[0];
537            // its a long string without a breaking character,
538            // make certain characters into breaking characters by inserting a
539            // word break opportunity (<wbr> tag) in front of them.
540            $regex = <<< REGEX
541(?(?=              # start a conditional expression with a positive look ahead ...
542&\#?\\w{1,6};)     # ... for html entities - we don't want to split them (ok to catch some invalid combinations)
543&\#?\\w{1,6};      # yes pattern - a quicker match for the html entity, since we know we have one
544|
545[?/,&\#;:]         # no pattern - any other group of 'special' characters to insert a breaking character after
546)+                 # end conditional expression
547REGEX;
548            return preg_replace('<'.$regex.'>xu', '\0<wbr>', $match[0]);
549        }, $diffhtml);
550    }
551
552}
553