[ Index ]

PHP Cross Reference of DokuWiki

title

Body

[close]

/inc/Ui/ -> PageDiff.php (source)

   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  }