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