xref: /dokuwiki/inc/Ui/PageDiff.php (revision b428a50a44f32af0f11595d221760d20901b27d1)
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->showEditSummary().' '.$RevInfo->showEditor();
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        $oldPrevRev = $oldNextRev = false;
392        if (($index = array_search($oldRev, $oldRevs)) !== false) {
393            $oldPrevRev = ($index +1 < count($oldRevs)) ? $oldRevs[$index +1] : false;
394            $oldNextRev = ($index > 0)                  ? $oldRevs[$index -1] : false;
395        }
396        $newPrevRev = $newNextRev = false;
397        if (($index = array_search($newRev, $newRevs)) !== false) {
398            $newPrevRev = ($index +1 < count($newRevs)) ? $newRevs[$index +1] : false;
399            $newNextRev = ($index > 0)                  ? $newRevs[$index -1] : false;
400        }
401
402        /*
403         * navigation UI for older revisions / Left side:
404         */
405        $navOlderRevs = '';
406        // move backward both side: ◀◀
407        if ($oldPrevRev && $newPrevRev)
408            $navOlderRevs .= $this->diffViewlink('diffbothprevrev', $oldPrevRev, $newPrevRev);
409        // move backward left side: ◀
410        if ($oldPrevRev)
411            $navOlderRevs .= $this->diffViewlink('diffprevrev', $oldPrevRev, $newRev);
412        // dropdown
413        $navOlderRevs .= $this->buildDropdownSelector('older', $olderRevisions);
414        // move forward left side: ▶
415        if ($oldNextRev && ($oldNextRev < $newRev))
416            $navOlderRevs .= $this->diffViewlink('diffnextrev', $oldNextRev, $newRev);
417
418        /*
419         * navigation UI for newer revisions / Right side:
420         */
421        $navNewerRevs = '';
422        // move backward right side: ◀
423        if ($newPrevRev && ($oldRev < $newPrevRev))
424            $navNewerRevs .= $this->diffViewlink('diffprevrev', $oldRev, $newPrevRev);
425        // dropdown
426        $navNewerRevs .= $this->buildDropdownSelector('newer', $newerRevisions);
427        // move forward right side: ▶
428        if ($newNextRev) {
429            if ($changelog->isCurrentRevision($newNextRev)) {
430                $navNewerRevs .= $this->diffViewlink('difflastrev', $oldRev, $newNextRev);
431            } else {
432                $navNewerRevs .= $this->diffViewlink('diffnextrev', $oldRev, $newNextRev);
433            }
434        }
435        // move forward both side: ▶▶
436        if ($oldNextRev && $newNextRev)
437            $navNewerRevs .= $this->diffViewlink('diffbothnextrev', $oldNextRev, $newNextRev);
438
439        return array($navOlderRevs, $navNewerRevs);
440    }
441
442    /**
443     * prepare options for dropdwon selector
444     *
445     * @params string $side  "older" or "newer"
446     * @params array $revs  list of revsion
447     * @return array
448     */
449    protected function buildRevisionOptions($side, $revs)
450    {
451        $changelog =& $this->changelog;
452        $revisions = array();
453
454        // use timestamp for current revision
455        [$oldRev, $newRev] = [(int)$this->oldRevInfo['date'], (int)$this->newRevInfo['date']];
456
457        foreach ($revs as $rev) {
458            $info = $changelog->getRevisionInfo($rev);
459            // revision info may have timestamp key when external edits occurred
460            $info['timestamp'] = $info['timestamp'] ?? true;
461            $date = dformat($info['date']);
462            if ($info['timestamp'] === false) {
463                // exteranlly deleted or older file restored
464                $date = preg_replace('/[0-9a-zA-Z]/','_', $date);
465            }
466            $revisions[$rev] = array(
467                'label' => implode(' ', [
468                            $date,
469                            editorinfo($info['user'], true),
470                            $info['sum'],
471                           ]),
472                'attrs' => ['title' => $rev],
473            );
474            if (($side == 'older' && ($newRev && $rev >= $newRev))
475              ||($side == 'newer' && ($rev <= $oldRev))
476            ) {
477                $revisions[$rev]['attrs']['disabled'] = 'disabled';
478            }
479        }
480        return $revisions;
481    }
482
483    /**
484     * build Dropdown form for revisions navigation
485     *
486     * @params string $side  "older" or "newer"
487     * @params array $options  dropdown options
488     * @return string
489     */
490    protected function buildDropdownSelector($side, $options)
491    {
492        $form = new Form(['action' => wl($this->id)]);
493        $form->setHiddenField('id', $this->id);
494        $form->setHiddenField('do', 'diff');
495        $form->setHiddenField('difftype', $this->preference['difftype']);
496
497        // use timestamp for current revision
498        [$oldRev, $newRev] = [(int)$this->oldRevInfo['date'], (int)$this->newRevInfo['date']];
499
500        switch ($side) {
501            case 'older': // left side
502                $form->setHiddenField('rev2[1]', $newRev);
503                $input = $form->addDropdown('rev2[0]', $options)
504                    ->val($oldRev)->addClass('quickselect');
505                $input->useInput(false); // inhibit prefillInput() during toHTML() process
506                break;
507            case 'newer': // right side
508                $form->setHiddenField('rev2[0]', $oldRev);
509                $input = $form->addDropdown('rev2[1]', $options)
510                    ->val($newRev)->addClass('quickselect');
511                $input->useInput(false); // inhibit prefillInput() during toHTML() process
512                break;
513        }
514        $form->addButton('do[diff]', 'Go')->attr('type','submit');
515        return $form->toHTML();
516    }
517
518    /**
519     * Create html link to a diff view defined by two revisions
520     *
521     * @param string $linktype
522     * @param int $oldRev older revision
523     * @param int $newRev newer revision or null for diff with current revision
524     * @return string html of link to a diff view
525     */
526    protected function diffViewlink($linktype, $oldRev, $newRev = null)
527    {
528        global $lang;
529        if ($newRev === null) {
530            $urlparam = array(
531                'do' => 'diff',
532                'rev' => $oldRev,
533                'difftype' => $this->preference['difftype'],
534            );
535        } else {
536            $urlparam = array(
537                'do' => 'diff',
538                'rev2[0]' => $oldRev,
539                'rev2[1]' => $newRev,
540                'difftype' => $this->preference['difftype'],
541            );
542        }
543        $attr = array(
544            'class' => $linktype,
545            'href'  => wl($this->id, $urlparam, true, '&'),
546            'title' => $lang[$linktype],
547        );
548        return '<a '. buildAttributes($attr) .'><span>'. $lang[$linktype] .'</span></a>';
549    }
550
551
552    /**
553     * Insert soft breaks in diff html
554     *
555     * @param string $diffhtml
556     * @return string
557     */
558    public function insertSoftbreaks($diffhtml)
559    {
560        // search the diff html string for both:
561        // - html tags, so these can be ignored
562        // - long strings of characters without breaking characters
563        return preg_replace_callback('/<[^>]*>|[^<> ]{12,}/', function ($match) {
564            // if match is an html tag, return it intact
565            if ($match[0][0] == '<') return $match[0];
566            // its a long string without a breaking character,
567            // make certain characters into breaking characters by inserting a
568            // word break opportunity (<wbr> tag) in front of them.
569            $regex = <<< REGEX
570(?(?=              # start a conditional expression with a positive look ahead ...
571&\#?\\w{1,6};)     # ... for html entities - we don't want to split them (ok to catch some invalid combinations)
572&\#?\\w{1,6};      # yes pattern - a quicker match for the html entity, since we know we have one
573|
574[?/,&\#;:]         # no pattern - any other group of 'special' characters to insert a breaking character after
575)+                 # end conditional expression
576REGEX;
577            return preg_replace('<'.$regex.'>xu', '\0<wbr>', $match[0]);
578        }, $diffhtml);
579    }
580
581}
582