1 <?php
2 
3 namespace dokuwiki\Ui;
4 
5 use dokuwiki\ChangeLog\PageChangeLog;
6 use dokuwiki\ChangeLog\RevisionInfo;
7 use dokuwiki\Form\Form;
8 use InlineDiffFormatter;
9 use 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  */
18 class 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
555 REGEX;
556             return preg_replace('<' . $regex . '>xu', '\0<wbr>', $match[0]);
557         }, $diffhtml);
558     }
559 }
560