PageDiff.php 20 KB

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