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