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