You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

560 lines
20 KiB

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