PageRenderTime 54ms CodeModel.GetById 28ms RepoModel.GetById 1ms app.codeStats 0ms

/inc/wiki-item.php

https://bitbucket.org/yoander/mtrack
PHP | 609 lines | 461 code | 60 blank | 88 comment | 107 complexity | fd348f243d7ddfb3ab1e43617897c12b MD5 | raw file
Possible License(s): BSD-3-Clause, Apache-2.0
  1. <?php # vim:ts=2:sw=2:et:
  2. /* For licensing and copyright terms, see the file named LICENSE */
  3. class MTrackWikiItem {
  4. public $pagename = null;
  5. public $filename = null;
  6. public $version = null;
  7. public $file = null;
  8. static $wc = null;
  9. function __get($name) {
  10. if ($name == 'content' && $this->file) {
  11. $this->content = stream_get_contents($this->file->cat());
  12. return $this->content;
  13. }
  14. }
  15. static function commitNow() {
  16. /* force any delayed push to invoke right now */
  17. self::$wc = null;
  18. }
  19. static function loadByPageName($name) {
  20. $w = new MTrackWikiItem($name);
  21. if ($w->file) {
  22. return $w;
  23. }
  24. return null;
  25. }
  26. static function getWC() {
  27. if (self::$wc === null) {
  28. self::getRepoAndRoot($repo);
  29. self::$wc = $repo->getWorkingCopy();
  30. }
  31. return self::$wc;
  32. }
  33. static function getRepoAndRoot(&$repo) {
  34. $repo = MTrackRepo::loadByName('default/wiki');
  35. return $repo->getDefaultRoot();
  36. }
  37. function __construct($name, $version = null) {
  38. $this->pagename = $name;
  39. $this->filename = self::getRepoAndRoot($repo) . $name;
  40. $suf = MTrackConfig::get('core', 'wikifilenamesuffix');
  41. if ($suf) {
  42. $this->filename .= $suf;
  43. }
  44. if ($version !== null) {
  45. $this->file = $repo->file($this->filename, 'rev', $version);
  46. } else {
  47. $this->file = $repo->file($this->filename);
  48. }
  49. if ($this->file && $repo->history($this->filename, 1)) {
  50. $this->version = $this->file->rev;
  51. } else {
  52. $this->file = null;
  53. }
  54. }
  55. function save(MTrackChangeset $changeset) {
  56. $wc = self::getWC();
  57. $lfilename = $this->pagename;
  58. $suf = MTrackConfig::get('core', 'wikifilenamesuffix');
  59. if ($suf) {
  60. $lfilename .= $suf;
  61. }
  62. if (!strlen(trim($this->content))) {
  63. if ($wc->file_exists($lfilename)) {
  64. // removing
  65. $wc->delFile($lfilename);
  66. }
  67. } else {
  68. if (!$wc->file_exists($lfilename)) {
  69. // handle dirs
  70. $elements = explode('/', $lfilename);
  71. $accum = array();
  72. while (count($elements) > 1) {
  73. $ent = array_shift($elements);
  74. $accum[] = $ent;
  75. $base = join(DIRECTORY_SEPARATOR, $accum);
  76. if (!$wc->file_exists($base)) {
  77. if (!mkdir($wc->getDir() . DIRECTORY_SEPARATOR . $base)) {
  78. throw new Exception(
  79. "unable to mkdir(" . $wc->getDir() .
  80. DIRECTORY_SEPARATOR . "$base)");
  81. }
  82. $wc->addFile($base);
  83. } else if (!is_dir($wc->getDir() . DIRECTORY_SEPARATOR . $base)) {
  84. throw new Exception("$base is not a dir; cannot create $lfilename");
  85. }
  86. }
  87. file_put_contents($wc->getDir() . DIRECTORY_SEPARATOR . $lfilename,
  88. $this->content);
  89. $wc->addFile($lfilename);
  90. } else {
  91. file_put_contents($wc->getDir() . DIRECTORY_SEPARATOR . $lfilename,
  92. $this->content);
  93. }
  94. }
  95. $wc->commit($changeset);
  96. }
  97. static function index_item($object)
  98. {
  99. list($ignore, $ident) = explode(':', $object, 2);
  100. $w = MTrackWikiItem::loadByPageName($ident);
  101. if ($w && strlen($w->content)) {
  102. MTrackSearchDB::add("wiki:$w->pagename", array(
  103. 'type' => 'wiki',
  104. 'wiki' => $w->content,
  105. 'name' => $w->pagename,
  106. 'who' => $w->who,
  107. ), true);
  108. } else {
  109. MTrackSearchDB::remove($object);
  110. }
  111. }
  112. static function _get_parent_for_acl($objectid) {
  113. if (preg_match("/^(wiki:.*)\/([^\/]+)$/", $objectid, $M)) {
  114. return $M[1];
  115. }
  116. if (preg_match("/^wiki:.*$/", $objectid, $M)) {
  117. return 'Wiki';
  118. }
  119. return null;
  120. }
  121. static function _build_tree($tree, $repo, $dir, $suf) {
  122. $items = $repo->readdir($dir);
  123. foreach ($items as $file) {
  124. $label = basename($file->name);
  125. if ($file->is_dir) {
  126. $kid = new stdclass;
  127. self::_build_tree($kid, $repo, $file->name, $suf);
  128. $tree->{$label} = $kid;
  129. } else {
  130. if ($suf && substr($label, -strlen($suf)) == $suf) {
  131. $label = substr($label, 0, strlen($label) - strlen($suf));
  132. }
  133. $tree->{$label} = $label;
  134. }
  135. }
  136. }
  137. static function _build_tree_top() {
  138. $tree = new stdclass;
  139. $root = MTrackWikiItem::getRepoAndRoot($repo);
  140. $suf = MTrackConfig::get('core', 'wikifilenamesuffix');
  141. self::_build_tree($tree, $repo, '', $suf);
  142. return $tree;
  143. }
  144. static function get_wiki_tree() {
  145. return mtrack_cache(array('MTrackWikiItem', '_build_tree_top'),
  146. array(), 864000);
  147. }
  148. static function _get_recent($limit) {
  149. $recent = array();
  150. $root = MTrackWikiItem::getRepoAndRoot($repo);
  151. $suf = MTrackConfig::get('core', 'wikifilenamesuffix');
  152. $sql = <<<SQL
  153. select c.cid as cid, who, object, changedate, reason, value as json
  154. from changes c left
  155. join change_audit a on (c.cid = a.cid)
  156. where c.object = 'repo:$repo->repoid'
  157. and fieldname like '%:rev:%'
  158. order by c.cid desc limit $limit;
  159. SQL;
  160. foreach (MTrackDB::q($sql)->fetchAll(PDO::FETCH_OBJ) as $cs) {
  161. $j = json_decode($cs->json);
  162. if (!$j) continue;
  163. $r = new stdclass;
  164. $r->who = $j->changeby;
  165. $r->when = MTrackAPI::date8601($j->ctime);
  166. $r->rev = $j->rev;
  167. $r->changelog = $j->changelog;
  168. $r->changelog_html = MTrackWiki::format_to_html($r->changelog);
  169. $r->pages = array();
  170. foreach ($j->files as $name) {
  171. /* if a suffix is defined, only include pages that have the suffix,
  172. * otherwise include any pages. We remove the suffix from the names of
  173. * pages that we return */
  174. if ($suf) {
  175. if (preg_match("/^(.*)$suf$/", $name, $M)) {
  176. $r->pages[] = $M[1];
  177. }
  178. } else {
  179. $r->pages[] = $name;
  180. }
  181. }
  182. $recent[] = $r;
  183. }
  184. return $recent;
  185. }
  186. static function get_recent_changes($limit = 20) {
  187. return mtrack_cache(array('MTrackWikiItem', '_get_recent'),
  188. array($limit));
  189. }
  190. static function rest_wiki($method, $uri, $captures) {
  191. MTrackAPI::checkAllowed($method, 'GET', 'PUT', 'POST');
  192. $page = $captures['page'];
  193. $rev = MTrackAPI::getParam('rev');
  194. $W = new MTrackWikiItem($page, $rev);
  195. MTrackACL::requireAnyRights("wiki:$W->pagename",
  196. $method == 'GET' ? 'read' : 'modify');
  197. $w = MTrackAPI::makeObj($W, 'pagename');
  198. unset($w->file);
  199. $w->content = $W->content;
  200. if (!strlen($w->content) && $method == 'GET') {
  201. /* this is equivalent to the page not existing */
  202. MTrackAPI::error(404, "no such page", $page);
  203. }
  204. if ($W->file) {
  205. try {
  206. $hist = $W->file->getChangeEvent();
  207. } catch (Exception $e) {
  208. // Happens with certain older versions of mercurial; map it as
  209. // a 404
  210. if ($method == 'GET') {
  211. MTrackAPI::error(404, "no such page", array($page, $rev, $w));
  212. }
  213. // otherwise, pass it on
  214. throw $e;
  215. }
  216. $w->version = $hist->rev;
  217. }
  218. if ($method == 'GET' && (($rev && $w->version != $rev) || (!$W->file))) {
  219. MTrackAPI::error(404, "no such page", array($page, $rev, $w));
  220. }
  221. $conflicted = false;
  222. if ($method == 'PUT' || $method == 'POST') {
  223. /* we're being asked to create a new version of the wiki page.
  224. * If version is set in the incoming payload, it identifies the
  225. * version of the page that the user was basing their changes from.
  226. * We can use this to perform a 3-way merge with any potential
  227. * conflicting wiki page that may exist now */
  228. $in = MTrackAPI::getPayload();
  229. if (!isset($in->comment) || !strlen(trim($in->comment))) {
  230. $in->comment = 'Changed';
  231. }
  232. if (isset($in->version) && $in->version != $hist->rev) {
  233. $basis = new MTrackWikiItem($page, $in->version);
  234. $orig = self::normalize_text($basis->content);
  235. /* $orig = the basis of the users changes */
  236. /* $current = content at the tip */
  237. $current = self::normalize_text($w->content);
  238. /* $mine = the desired final content */
  239. $mine = self::normalize_text($in->content);
  240. $conflicted = self::is_content_conflicted($mine);
  241. if (!$conflicted) {
  242. $mine = self::perform_three_way_merge($orig, $current, $mine);
  243. }
  244. } else {
  245. $mine = self::normalize_text($in->content);
  246. }
  247. $conflicted = self::is_content_conflicted($mine);
  248. if ($conflicted) {
  249. /* we won't save it, but we will return the merged version to
  250. * allow the user to fix up the edits */
  251. $w->content = $mine;
  252. } else {
  253. $CS = MTrackChangeset::begin("wiki:$page", $in->comment);
  254. $W->content = $mine;
  255. $W->save($CS);
  256. $CS->commit();
  257. self::commitNow();
  258. /* reload and re-compute the returned data */
  259. $W = new MTrackWikiItem($page);
  260. $hist = $W->file->getChangeEvent();
  261. $w = MTrackAPI::makeObj($W, 'pagename');
  262. unset($w->file);
  263. $w->content = rtrim($mine);
  264. $w->version = $hist->rev;
  265. }
  266. }
  267. $w->content_html = MTrackWiki::format_to_html($w->content, "wiki:$page");
  268. $w->changelog = $hist->changelog;
  269. $w->changelog_html = MTrackWiki::format_to_html($hist->changelog);
  270. $w->who = mtrack_canon_username($hist->changeby);
  271. $w->when = MTrackAPI::date8601($hist->ctime);
  272. if ($conflicted) {
  273. MTrackAPI::error(409, "conflict detected", $w);
  274. }
  275. return $w;
  276. }
  277. static function rest_wiki_attachments($method, $uri, $captures) {
  278. MTrackAPI::checkAllowed($method, 'GET');
  279. $page = $captures['page'];
  280. MTrackACL::requireAnyRights("wiki:$page", "read");
  281. return MTrackAttachment::getList("wiki:$page");
  282. }
  283. static function perform_three_way_merge($orig, $current, $mine) {
  284. $tempdir = sys_get_temp_dir();
  285. $ofile = tempnam($tempdir, "mtrack");
  286. $nfile = tempnam($tempdir, "mtrack");
  287. $tfile = tempnam($tempdir, "mtrack");
  288. $pfile = tempnam($tempdir, "mtrack");
  289. $diff3 = MTrackConfig::get('tools', 'diff3');
  290. if (empty($diff3)) {
  291. $diff3 = 'diff3';
  292. }
  293. file_put_contents($ofile, $orig);
  294. file_put_contents($nfile, $mine);
  295. file_put_contents($tfile, $current);
  296. exec("$diff3 $nfile $ofile $tfile > $pfile",
  297. $output = array(), $retval = 0);
  298. if ($retval == 0) {
  299. /* see if there were merge conflicts */
  300. $content = self::merge3($nfile, $pfile);
  301. } else {
  302. $content = $mine;
  303. }
  304. unlink($ofile);
  305. unlink($nfile);
  306. unlink($tfile);
  307. unlink($pfile);
  308. return $content;
  309. }
  310. /* process the output of the diff3 command.
  311. * Included below is a description of the output format.
  312. * In our context, filename1 corresponds to the user file (mine),
  313. * filename2 corresponds to the data forming the basis of my changes,
  314. * (original) and filename3 corresponds to the changes made by someone
  315. * else (theirs).
  316. *
  317. * We pass in the filename of the "mine" file and the filename of
  318. * the file containing the diff3 output file.
  319. *
  320. * The return value is the merged result, which may contain conflict
  321. * markers.
  322. *
  323. * This function is needed because not all systems have the GNU diff3
  324. * command (which includes a merge option).
  325. diff3 compares three versions of a file. It publishes
  326. disagreeing ranges of text flagged with the following codes:
  327. ==== all three files differ
  328. ====1 filename1 is different
  329. ====2 filename2 is different
  330. ====3 filename3 is different
  331. The type of change suffered in converting a given range of a
  332. given file to some other is indicated in one of the follow-
  333. ing ways:
  334. The type of change suffered in converting a given range of a
  335. given file to some other is indicated in one of the follow-
  336. ing ways:
  337. f : n1 a Text is to be appended after line number n1
  338. in file f, where f = 1, 2, or 3.
  339. f : n1 , n2 c Text is to be changed in the range line n1
  340. to line n2. If n1 = n2, the range can be
  341. abbreviated to n1.
  342. The original contents of the range follows immediately after
  343. a c indication. When the contents of two files are identi-
  344. cal, the contents of the lower-numbered file is suppressed.
  345. */
  346. static function merge3($mfile, $sfile) {
  347. $instr = file($sfile);
  348. $mine = file($mfile);
  349. while (count($instr)) {
  350. $range = array_shift($instr);
  351. if (!preg_match("/^====(\d*)$/", $range, $M)) {
  352. throw new Exception("merge3: Expected file indicator! $range");
  353. }
  354. /* which file the change is from */
  355. $origin = $M[1];
  356. /* read rules for files */
  357. $frules = array();
  358. $data = array();
  359. while (count($instr)) {
  360. $rule = array_shift($instr);
  361. if ($rule[0] == '=') {
  362. array_unshift($instr, $rule);
  363. break;
  364. }
  365. if (preg_match("/^([123]):(\d+)a$/", $rule, $M)) {
  366. $file = (int)$M[1];
  367. $line = (int)$M[2];
  368. $frules[$file] = array('a', $line);
  369. continue;
  370. }
  371. if (preg_match("/^([123]):(\d+),(\d+)c$/", $rule, $M)) {
  372. $file = (int)$M[1];
  373. $start = (int)$M[2];
  374. $end = (int)$M[3];
  375. } else if (preg_match("/^([123]):(\d+)c$/", $rule, $M)) {
  376. $file = (int)$M[1];
  377. $start = (int)$M[2];
  378. $end = $start;
  379. } else {
  380. throw new Exception("ERROR: unknown rule $rule");
  381. }
  382. $frules[$file] = array('c', $start, $end);
  383. $nlines = ($end - $start) + 1;
  384. $data[$file] = array();
  385. /* data follows a 'c' indicator */
  386. while (count($instr)) {
  387. $line = array_shift($instr);
  388. if (strncmp($line, " ", 2)) {
  389. array_unshift($instr, $line);
  390. break;
  391. }
  392. $data[$file][] = substr($line, 2);
  393. }
  394. $data[$file] = join('', $data[$file]);
  395. }
  396. /* we're interested in changes to my file, so we only look at
  397. * the rules for file 1 */
  398. if (!isset($frules[1])) {
  399. throw new Exception("There is no rule for file 1!?");
  400. }
  401. /* when the contents of two files are identical, the contents of the
  402. * lower-numbered file is suppressed */
  403. for ($i = 1; $i <= 3; $i++) {
  404. if (!isset($data[$i])) {
  405. for ($j = $i + 1; $j <= 3; $j++) {
  406. if (isset($data[$j])) {
  407. $data[$i] = $data[$j];
  408. break;
  409. }
  410. }
  411. }
  412. }
  413. switch ($origin) {
  414. case '2':
  415. if ($data[1] == $data[2]) {
  416. $diff = $data[2];
  417. } else {
  418. $diff =
  419. "<<<<<<< original\n" .
  420. $data[2] .
  421. "=======\n" .
  422. $data[1] .
  423. ">>>>>>> current\n";
  424. }
  425. break;
  426. case '1':
  427. /* from myself */
  428. $diff = $data[1];
  429. break;
  430. case '3':
  431. if ($data[3] == $data[1]) {
  432. $diff = $data[1];
  433. } else {
  434. $diff =
  435. "<<<<<<< theirs\n" .
  436. $data[3] .
  437. "=======\n" .
  438. $data[1] .
  439. ">>>>>>> current\n";
  440. }
  441. break;
  442. case '':
  443. $diff =
  444. "<<<<<<< mine\n" .
  445. $data[1] .
  446. "||||||| original\n" .
  447. $data[2] .
  448. "=======\n" .
  449. $data[3] .
  450. ">>>>>>> theirs\n";
  451. break;
  452. default:
  453. error_log("unhandled origin $origin in merge3 " . json_encode($data));
  454. throw new Exception("Unhandled origin $origin");
  455. }
  456. $rule = $frules[1];
  457. if ($rule[0] == 'a') {
  458. /* append after line, where 0 means insert at start of file */
  459. $line = $rule[1];
  460. array_splice($mine, $line, 0, $diff);
  461. } else if ($rule[0] == 'c') {
  462. $line = $rule[1] - 1;
  463. $end = $rule[2];
  464. $nlines = $end - $line;
  465. array_splice($mine, $line, $nlines, $diff);
  466. } else {
  467. error_log("unknown rule in merge3 " . json_encode($rule));
  468. throw new Exception("Unknown rule!?");
  469. }
  470. }
  471. return join('', $mine);
  472. }
  473. static function is_content_conflicted($content)
  474. {
  475. if (preg_match("/^([<\|>]+)\s+(mine|theirs|original)\s*$/m", $content)) {
  476. return true;
  477. }
  478. return false;
  479. }
  480. /* normalize text so that we always have a single trailing newline.
  481. * If we don't do this, we get inconsistent behavior from the diff3
  482. * utility */
  483. static function normalize_text($text) {
  484. return rtrim($text) . "\n";
  485. }
  486. }
  487. class MTrackWikiCommitListener implements IMTrackCommitListener {
  488. function vetoChangeGroup(MTrackRepo $repo, $msg, $actions, $files) {
  489. return true;
  490. }
  491. function vetoCommit(MTrackRepo $repo,
  492. MTrackCommitHookChangeEvent $change,
  493. $actions) {
  494. return true;
  495. }
  496. function postCommit(MTrackRepo $repo,
  497. MTrackCommitHookChangeEvent $change,
  498. $actions) {
  499. return true;
  500. }
  501. function postChangeGroup(MTrackRepo $repo, $msg, $actions, $files) {
  502. /* is this affecting the wiki? */
  503. if ($repo->getBrowseRootName() == 'default/wiki') {
  504. mtrack_cache_blow(array('MTrackWikiItem', '_build_tree_top'), array());
  505. /* this is also an ideal time to update the search index for wiki
  506. * pages, if we are set to immediate updates. Normal objects that
  507. * live solely in our database wouldn't need such special treatment,
  508. * but repo changes are made partially in the context of the commit
  509. * hook and so won't have the same view consistency as the rest
  510. * of the system */
  511. if (MTrackConfig::get('core', 'update_search_immediate')) {
  512. $suf = MTrackConfig::get('core', 'wikifilenamesuffix');
  513. $len = strlen($suf);
  514. foreach ($files as $name) {
  515. $name = substr($name, strlen($repo->shortname) + 1);
  516. $is_wiki = $len == 0;
  517. if ($len && substr($name, -$len) == $suf) {
  518. $name = substr($name, 0, strlen($name) - $len);
  519. $is_wiki = true;
  520. }
  521. if ($is_wiki) {
  522. MTrackSearchDB::index_object("wiki:$name");
  523. }
  524. }
  525. }
  526. }
  527. return true;
  528. }
  529. static function register() {
  530. $l = new MTrackWikiCommitListener;
  531. MTrackCommitChecker::registerListener($l);
  532. }
  533. };
  534. MTrackSearchDB::register_indexer('wiki', array('MTrackWikiItem', 'index_item'));
  535. MTrackWikiCommitListener::register();
  536. MTrackACL::registerAncestry('wiki', array('MTrackWikiItem', '_get_parent_for_acl'));
  537. MTrackAPI::register('/wiki/page/*page', 'MTrackWikiItem::rest_wiki');
  538. MTrackAPI::register('/wiki/attach/*page',
  539. 'MTrackWikiItem::rest_wiki_attachments');