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