[ 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($lang['page_nonexist_rev'],
 160                  $this->id,
 161                  wl($this->id, ['do'=>'edit']),
 162                  $this->id), -1);
 163          } elseif (!$this->rev1 || $this->rev1 == $this->rev2) {
 164              msg('no way to compare when less than two revisions', -1);
 165          }
 166      }
 167  
 168      /**
 169       * Show diff
 170       * between current page version and provided $text
 171       * or between the revisions provided via GET or POST
 172       *
 173       * @author Andreas Gohr <andi@splitbrain.org>
 174       *
 175       * @return void
 176       */
 177      public function show()
 178      {
 179          global $lang;
 180  
 181          if (!isset($this->RevInfo1, $this->RevInfo2)) {
 182              // retrieve form parameters: rev, rev2, difftype
 183              $this->handle();
 184              // prepare revision info of comparison pair, except PageConfrict or PageDraft
 185              $this->preProcess();
 186          }
 187  
 188          // revision title
 189          $rev1Title = trim($this->RevInfo1->showRevisionTitle() .' '. $this->RevInfo1->showCurrentIndicator());
 190          $rev1Summary = ($this->RevInfo1->val('date'))
 191              ? $this->RevInfo1->showEditSummary() .' '. $this->RevInfo1->showEditor()
 192              : '';
 193  
 194          if ($this->RevInfo2->val('extra') == 'compareWith') {
 195              $rev2Title = $lang['yours'];
 196              $rev2Summary = '';
 197          } else {
 198              $rev2Title = trim($this->RevInfo2->showRevisionTitle() .' '. $this->RevInfo2->showCurrentIndicator());
 199              $rev2Summary = ($this->RevInfo2->val('date'))
 200                  ? $this->RevInfo2->showEditSummary() .' '. $this->RevInfo2->showEditor()
 201                  : '';
 202          }
 203  
 204          // create difference engine object
 205          $Difference = new \Diff(
 206                  explode("\n", $this->RevInfo1->val('text')),
 207                  explode("\n", $this->RevInfo2->val('text'))
 208          );
 209  
 210          // build paired navigation
 211          [$rev1Navi, $rev2Navi] = $this->buildRevisionsNavigation();
 212  
 213          // display intro
 214          if ($this->preference['showIntro']) echo p_locale_xhtml('diff');
 215  
 216          // print form to choose diff view type, and exact url reference to the view
 217          $this->showDiffViewSelector();
 218  
 219          // assign minor edit checker to the variable
 220          $classEditType = function ($changeType) {
 221              return ($changeType === DOKU_CHANGE_TYPE_MINOR_EDIT) ? ' class="minor"' : '';
 222          };
 223  
 224          // display diff view table
 225          echo '<div class="table">';
 226          echo '<table class="diff diff_'.hsc($this->preference['difftype']) .'">';
 227  
 228          //navigation and header
 229          switch ($this->preference['difftype']) {
 230              case 'inline':
 231                  $title1 = $rev1Title . ($rev1Summary ? '<br />'.$rev1Summary : '');
 232                  $title2 = $rev2Title . ($rev2Summary ? '<br />'.$rev2Summary : '');
 233                  // no navigation for PageConflict or PageDraft
 234                  if ($this->RevInfo2->val('extra') !== 'compareWith') {
 235                      echo '<tr>'
 236                          .'<td class="diff-lineheader">-</td>'
 237                          .'<td class="diffnav">'. $rev1Navi .'</td>'
 238                          .'</tr>';
 239                      echo '<tr>'
 240                          .'<th class="diff-lineheader">-</th>'
 241                          .'<th'.$classEditType($this->RevInfo1->val('type')).'>'. $title1 .'</th>'
 242                          .'</tr>';
 243                  }
 244                  echo '<tr>'
 245                      .'<td class="diff-lineheader">+</td>'
 246                      .'<td class="diffnav">'. $rev2Navi .'</td>'
 247                      .'</tr>';
 248                  echo '<tr>'
 249                      .'<th class="diff-lineheader">+</th>'
 250                      .'<th'.$classEditType($this->RevInfo2->val('type')).'>'. $title2 .'</th>'
 251                      .'</tr>';
 252                  // create formatter object
 253                  $DiffFormatter = new InlineDiffFormatter();
 254                  break;
 255  
 256              case 'sidebyside':
 257              default:
 258                  $title1 = $rev1Title . ($rev1Summary ? ' '.$rev1Summary : '');
 259                  $title2 = $rev2Title . ($rev2Summary ? ' '.$rev2Summary : '');
 260                  // no navigation for PageConflict or PageDraft
 261                  if ($this->RevInfo2->val('extra') !== 'compareWith') {
 262                      echo '<tr>'
 263                          .'<td colspan="2" class="diffnav">'. $rev1Navi .'</td>'
 264                          .'<td colspan="2" class="diffnav">'. $rev2Navi .'</td>'
 265                          .'</tr>';
 266                  }
 267                  echo '<tr>'
 268                      .'<th colspan="2"'.$classEditType($this->RevInfo1->val('type')).'>'.$title1.'</th>'
 269                      .'<th colspan="2"'.$classEditType($this->RevInfo2->val('type')).'>'.$title2.'</th>'
 270                      .'</tr>';
 271                  // create formatter object
 272                  $DiffFormatter = new TableDiffFormatter();
 273                  break;
 274          }
 275  
 276          // output formatted difference
 277          echo $this->insertSoftbreaks($DiffFormatter->format($Difference));
 278  
 279          echo '</table>';
 280          echo '</div>';
 281      }
 282  
 283      /**
 284       * Print form to choose diff view type, and exact url reference to the view
 285       */
 286      protected function showDiffViewSelector()
 287      {
 288          global $lang;
 289  
 290          // no revisions selector for PageConflict or PageDraft
 291          if ($this->RevInfo2->val('extra') == 'compareWith') return;
 292  
 293          // use timestamp for current revision, date may be false when revisions < 2
 294          [$rev1, $rev2] = [(int)$this->RevInfo1->val('date'), (int)$this->RevInfo2->val('date')];
 295  
 296          echo '<div class="diffoptions group">';
 297  
 298          // create the form to select difftype
 299          $form = new Form(['action' => wl()]);
 300          $form->setHiddenField('id', $this->id);
 301          $form->setHiddenField('rev2[0]', $rev1);
 302          $form->setHiddenField('rev2[1]', $rev2);
 303          $form->setHiddenField('do', 'diff');
 304          $options = array(
 305                       'sidebyside' => $lang['diff_side'],
 306                       'inline' => $lang['diff_inline'],
 307          );
 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 array('', '');
 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  
 357          // determine previous/next revisions (older/left side)
 358          $rev1Prev = $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 = $rev2Next = false;
 365          if (($index = array_search($rev2, $revs2)) !== false) {
 366              $rev2Prev = ($index +1 < count($revs2)) ? $revs2[$index +1] : false;
 367              $rev2Next = ($index > 0)                ? $revs2[$index -1] : false;
 368          }
 369  
 370          /*
 371           * navigation UI for older revisions / Left side:
 372           */
 373          $rev1Navi = '';
 374          // move backward both side: ◀◀
 375          if ($rev1Prev && $rev2Prev)
 376              $rev1Navi .= $this->diffViewlink('diffbothprevrev', $rev1Prev, $rev2Prev);
 377          // move backward left side: â—€
 378          if ($rev1Prev)
 379              $rev1Navi .= $this->diffViewlink('diffprevrev', $rev1Prev, $rev2);
 380          // dropdown
 381          $rev1Navi .= $this->buildDropdownSelector('older', $rev1Options);
 382          // move forward left side: â–¶
 383          if ($rev1Next && ($rev1Next < $rev2))
 384              $rev1Navi .= $this->diffViewlink('diffnextrev', $rev1Next, $rev2);
 385  
 386          /*
 387           * navigation UI for newer revisions / Right side:
 388           */
 389          $rev2Navi = '';
 390          // move backward right side: â—€
 391          if ($rev2Prev && ($rev1 < $rev2Prev))
 392              $rev2Navi .= $this->diffViewlink('diffprevrev', $rev1, $rev2Prev);
 393          // dropdown
 394          $rev2Navi .= $this->buildDropdownSelector('newer', $rev2Options);
 395          // move forward right side: â–¶
 396          if ($rev2Next) {
 397              if ($changelog->isCurrentRevision($rev2Next)) {
 398                  $rev2Navi .= $this->diffViewlink('difflastrev', $rev1, $rev2Next);
 399              } else {
 400                  $rev2Navi .= $this->diffViewlink('diffnextrev', $rev1, $rev2Next);
 401              }
 402          }
 403          // move forward both side: â–¶â–¶
 404          if ($rev1Next && $rev2Next)
 405              $rev2Navi .= $this->diffViewlink('diffbothnextrev', $rev1Next, $rev2Next);
 406  
 407          return array($rev1Navi, $rev2Navi);
 408      }
 409  
 410      /**
 411       * prepare options for dropdwon selector
 412       *
 413       * @params string $side  "older" or "newer"
 414       * @params array $revs  list of revsion
 415       * @return array
 416       */
 417      protected function buildRevisionOptions($side, $revs)
 418      {
 419          // use timestamp for current revision, date may be false when revisions < 2
 420          [$rev1, $rev2] = [(int)$this->RevInfo1->val('date'), (int)$this->RevInfo2->val('date')];
 421  
 422          $changelog =& $this->changelog;
 423          $options = [];
 424  
 425          foreach ($revs as $rev) {
 426              $info = $changelog->getRevisionInfo($rev);
 427              // revision info may have timestamp key when external edits occurred
 428              $info['timestamp'] = $info['timestamp'] ?? true;
 429              $date = dformat($info['date']);
 430              if ($info['timestamp'] === false) {
 431                  // exteranlly deleted or older file restored
 432                  $date = preg_replace('/[0-9a-zA-Z]/','_', $date);
 433              }
 434              $options[$rev] = array(
 435                  'label' => implode(' ', [
 436                              $date,
 437                              editorinfo($info['user'], true),
 438                              $info['sum'],
 439                             ]),
 440                  'attrs' => ['title' => $rev],
 441              );
 442              if (($side == 'older' && ($rev2 && $rev >= $rev2))
 443                ||($side == 'newer' && ($rev <= $rev1))
 444              ) {
 445                  $options[$rev]['attrs']['disabled'] = 'disabled';
 446              }
 447          }
 448          return $options;
 449      }
 450  
 451      /**
 452       * build Dropdown form for revisions navigation
 453       *
 454       * @params string $side  "older" or "newer"
 455       * @params array $options  dropdown options
 456       * @return string
 457       */
 458      protected function buildDropdownSelector($side, $options)
 459      {
 460          // use timestamp for current revision, date may be false when revisions < 2
 461          [$rev1, $rev2] = [(int)$this->RevInfo1->val('date'), (int)$this->RevInfo2->val('date')];
 462  
 463          $form = new Form(['action' => wl($this->id)]);
 464          $form->setHiddenField('id', $this->id);
 465          $form->setHiddenField('do', 'diff');
 466          $form->setHiddenField('difftype', $this->preference['difftype']);
 467  
 468          switch ($side) {
 469              case 'older': // left side
 470                  $form->setHiddenField('rev2[1]', $rev2);
 471                  $input = $form->addDropdown('rev2[0]', $options)
 472                      ->val($rev1)->addClass('quickselect');
 473                  $input->useInput(false); // inhibit prefillInput() during toHTML() process
 474                  break;
 475              case 'newer': // right side
 476                  $form->setHiddenField('rev2[0]', $rev1);
 477                  $input = $form->addDropdown('rev2[1]', $options)
 478                      ->val($rev2)->addClass('quickselect');
 479                  $input->useInput(false); // inhibit prefillInput() during toHTML() process
 480                  break;
 481          }
 482          $form->addButton('do[diff]', 'Go')->attr('type','submit');
 483          return $form->toHTML();
 484      }
 485  
 486      /**
 487       * Create html link to a diff view defined by two revisions
 488       *
 489       * @param string $linktype
 490       * @param int $rev1 older revision
 491       * @param int $rev2 newer revision or null for diff with current revision
 492       * @return string html of link to a diff view
 493       */
 494      protected function diffViewlink($linktype, $rev1, $rev2 = null)
 495      {
 496          global $lang;
 497          if ($rev1 === false) return '';
 498  
 499          if ($rev2 === null) {
 500              $urlparam = array(
 501                  'do' => 'diff',
 502                  'rev' => $rev1,
 503                  'difftype' => $this->preference['difftype'],
 504              );
 505          } else {
 506              $urlparam = array(
 507                  'do' => 'diff',
 508                  'rev2[0]' => $rev1,
 509                  'rev2[1]' => $rev2,
 510                  'difftype' => $this->preference['difftype'],
 511              );
 512          }
 513          $attr = array(
 514              'class' => $linktype,
 515              'href'  => wl($this->id, $urlparam, true, '&'),
 516              'title' => $lang[$linktype],
 517          );
 518          return '<a '. buildAttributes($attr) .'><span>'. $lang[$linktype] .'</span></a>';
 519      }
 520  
 521  
 522      /**
 523       * Insert soft breaks in diff html
 524       *
 525       * @param string $diffhtml
 526       * @return string
 527       */
 528      public function insertSoftbreaks($diffhtml)
 529      {
 530          // search the diff html string for both:
 531          // - html tags, so these can be ignored
 532          // - long strings of characters without breaking characters
 533          return preg_replace_callback('/<[^>]*>|[^<> ]{12,}/', function ($match) {
 534              // if match is an html tag, return it intact
 535              if ($match[0][0] == '<') return $match[0];
 536              // its a long string without a breaking character,
 537              // make certain characters into breaking characters by inserting a
 538              // word break opportunity (<wbr> tag) in front of them.
 539              $regex = <<< REGEX
 540  (?(?=              # start a conditional expression with a positive look ahead ...
 541  &\#?\\w{1,6};)     # ... for html entities - we don't want to split them (ok to catch some invalid combinations)
 542  &\#?\\w{1,6};      # yes pattern - a quicker match for the html entity, since we know we have one
 543  |
 544  [?/,&\#;:]         # no pattern - any other group of 'special' characters to insert a breaking character after
 545  )+                 # end conditional expression
 546  REGEX;
 547              return preg_replace('<'.$regex.'>xu', '\0<wbr>', $match[0]);
 548          }, $diffhtml);
 549      }
 550  
 551  }