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