1<?php 2 3namespace dokuwiki\ChangeLog; 4 5/** 6 * methods for handling of changelog of pages or media files 7 */ 8abstract class ChangeLog 9{ 10 /** @var string */ 11 protected $id; 12 /** @var int */ 13 protected $currentRevision; 14 /** @var int */ 15 protected $chunk_size; 16 /** @var array */ 17 protected $cache; 18 19 /** 20 * Constructor 21 * 22 * @param string $id page id 23 * @param int $chunk_size maximum block size read from file 24 */ 25 public function __construct($id, $chunk_size = 8192) 26 { 27 global $cache_revinfo; 28 29 $this->cache =& $cache_revinfo; 30 if (!isset($this->cache[$id])) { 31 $this->cache[$id] = array(); 32 } 33 34 $this->id = $id; 35 $this->setChunkSize($chunk_size); 36 37 } 38 39 /** 40 * Set chunk size for file reading 41 * Chunk size zero let read whole file at once 42 * 43 * @param int $chunk_size maximum block size read from file 44 */ 45 public function setChunkSize($chunk_size) 46 { 47 if (!is_numeric($chunk_size)) $chunk_size = 0; 48 49 $this->chunk_size = (int)max($chunk_size, 0); 50 } 51 52 /** 53 * Returns path to changelog 54 * 55 * @return string path to file 56 */ 57 abstract protected function getChangelogFilename(); 58 59 /** 60 * Returns path to current page/media 61 * 62 * @return string path to file 63 */ 64 abstract protected function getFilename(); 65 66 /** 67 * Get the changelog information for a specific page id and revision (timestamp) 68 * 69 * Adjacent changelog lines are optimistically parsed and cached to speed up 70 * consecutive calls to getRevisionInfo. For large changelog files, only the chunk 71 * containing the requested changelog line is read. 72 * 73 * @param int $rev revision timestamp 74 * @return bool|array false or array with entries: 75 * - date: unix timestamp 76 * - ip: IPv4 address (127.0.0.1) 77 * - type: log line type 78 * - id: page id 79 * - user: user name 80 * - sum: edit summary (or action reason) 81 * - extra: extra data (varies by line type) 82 * - sizechange: change of filesize 83 * 84 * @author Ben Coburn <btcoburn@silicodon.net> 85 * @author Kate Arzamastseva <pshns@ukr.net> 86 */ 87 public function getRevisionInfo($rev) 88 { 89 $rev = max(0, $rev); 90 if (!$rev) return false; 91 92 // check if it's already in the memory cache 93 if (isset($this->cache[$this->id]) && isset($this->cache[$this->id][$rev])) { 94 return $this->cache[$this->id][$rev]; 95 } 96 97 //read lines from changelog 98 list($fp, $lines) = $this->readloglines($rev); 99 if ($fp) { 100 fclose($fp); 101 } 102 if (empty($lines)) return false; 103 104 // parse and cache changelog lines 105 foreach ($lines as $value) { 106 $info = parseChangelogLine($value); 107 if ($info !== false) { 108 $this->cache[$this->id][$info['date']] = $info; 109 } 110 } 111 if (!isset($this->cache[$this->id][$rev])) { 112 return false; 113 } 114 return $this->cache[$this->id][$rev]; 115 } 116 117 /** 118 * Return a list of page revisions numbers 119 * 120 * Does not guarantee that the revision exists in the attic, 121 * only that a line with the date exists in the changelog. 122 * By default the current revision is skipped. 123 * 124 * The current revision is automatically skipped when the page exists. 125 * See $INFO['meta']['last_change'] for the current revision. 126 * A negative $first let read the current revision too. 127 * 128 * For efficiency, the log lines are parsed and cached for later 129 * calls to getRevisionInfo. Large changelog files are read 130 * backwards in chunks until the requested number of changelog 131 * lines are recieved. 132 * 133 * @param int $first skip the first n changelog lines 134 * @param int $num number of revisions to return 135 * @return array with the revision timestamps 136 * 137 * @author Ben Coburn <btcoburn@silicodon.net> 138 * @author Kate Arzamastseva <pshns@ukr.net> 139 */ 140 public function getRevisions($first, $num) 141 { 142 $revs = array(); 143 $lines = array(); 144 $count = 0; 145 146 $num = max($num, 0); 147 if ($num == 0) { 148 return $revs; 149 } 150 151 if ($first < 0) { 152 $first = 0; 153 } else { 154 if (file_exists($this->getFilename()) && !$this->isExternalEdition()) { 155 // skip current revision if the page exist and not external edition 156 $first = max($first + 1, 0); 157 } 158 } 159 160 $file = $this->getChangelogFilename(); 161 162 if (!file_exists($file)) { 163 return $revs; 164 } 165 if (filesize($file) < $this->chunk_size || $this->chunk_size == 0) { 166 // read whole file 167 $lines = file($file); 168 if ($lines === false) { 169 return $revs; 170 } 171 } else { 172 // read chunks backwards 173 $fp = fopen($file, 'rb'); // "file pointer" 174 if ($fp === false) { 175 return $revs; 176 } 177 fseek($fp, 0, SEEK_END); 178 $tail = ftell($fp); 179 180 // chunk backwards 181 $finger = max($tail - $this->chunk_size, 0); 182 while ($count < $num + $first) { 183 $nl = $this->getNewlinepointer($fp, $finger); 184 185 // was the chunk big enough? if not, take another bite 186 if ($nl > 0 && $tail <= $nl) { 187 $finger = max($finger - $this->chunk_size, 0); 188 continue; 189 } else { 190 $finger = $nl; 191 } 192 193 // read chunk 194 $chunk = ''; 195 $read_size = max($tail - $finger, 0); // found chunk size 196 $got = 0; 197 while ($got < $read_size && !feof($fp)) { 198 $tmp = @fread($fp, max(min($this->chunk_size, $read_size - $got), 0)); 199 if ($tmp === false) { 200 break; 201 } //error state 202 $got += strlen($tmp); 203 $chunk .= $tmp; 204 } 205 $tmp = explode("\n", $chunk); 206 array_pop($tmp); // remove trailing newline 207 208 // combine with previous chunk 209 $count += count($tmp); 210 $lines = array_merge($tmp, $lines); 211 212 // next chunk 213 if ($finger == 0) { 214 break; 215 } else { // already read all the lines 216 $tail = $finger; 217 $finger = max($tail - $this->chunk_size, 0); 218 } 219 } 220 fclose($fp); 221 } 222 223 // skip parsing extra lines 224 $num = max(min(count($lines) - $first, $num), 0); 225 if ($first > 0 && $num > 0) { 226 $lines = array_slice($lines, max(count($lines) - $first - $num, 0), $num); 227 } else { 228 if ($first > 0 && $num == 0) { 229 $lines = array_slice($lines, 0, max(count($lines) - $first, 0)); 230 } elseif ($first == 0 && $num > 0) { 231 $lines = array_slice($lines, max(count($lines) - $num, 0)); 232 } 233 } 234 235 // handle lines in reverse order 236 for ($i = count($lines) - 1; $i >= 0; $i--) { 237 $info = parseChangelogLine($lines[$i]); 238 if ($info !== false) { 239 $this->cache[$this->id][$info['date']] = $info; 240 $revs[] = $info['date']; 241 } 242 } 243 244 return $revs; 245 } 246 247 /** 248 * Get the nth revision left or right handside for a specific page id and revision (timestamp) 249 * 250 * For large changelog files, only the chunk containing the 251 * reference revision $rev is read and sometimes a next chunck. 252 * 253 * Adjacent changelog lines are optimistically parsed and cached to speed up 254 * consecutive calls to getRevisionInfo. 255 * 256 * @param int $rev revision timestamp used as startdate (doesn't need to be revisionnumber) 257 * @param int $direction give position of returned revision with respect to $rev; positive=next, negative=prev 258 * @return bool|int 259 * timestamp of the requested revision 260 * otherwise false 261 */ 262 public function getRelativeRevision($rev, $direction) 263 { 264 $rev = max($rev, 0); 265 $direction = (int)$direction; 266 267 //no direction given or last rev, so no follow-up 268 if (!$direction || ($direction > 0 && $this->isCurrentRevision($rev))) { 269 return false; 270 } 271 272 //get lines from changelog 273 list($fp, $lines, $head, $tail, $eof) = $this->readloglines($rev); 274 if (empty($lines)) return false; 275 276 // look for revisions later/earlier than $rev, when founded count till the wanted revision is reached 277 // also parse and cache changelog lines for getRevisionInfo(). 278 $revcounter = 0; 279 $relativerev = false; 280 $checkotherchunck = true; //always runs once 281 while (!$relativerev && $checkotherchunck) { 282 $info = array(); 283 //parse in normal or reverse order 284 $count = count($lines); 285 if ($direction > 0) { 286 $start = 0; 287 $step = 1; 288 } else { 289 $start = $count - 1; 290 $step = -1; 291 } 292 for ($i = $start; $i >= 0 && $i < $count; $i = $i + $step) { 293 $info = parseChangelogLine($lines[$i]); 294 if ($info !== false) { 295 $this->cache[$this->id][$info['date']] = $info; 296 //look for revs older/earlier then reference $rev and select $direction-th one 297 if (($direction > 0 && $info['date'] > $rev) || ($direction < 0 && $info['date'] < $rev)) { 298 $revcounter++; 299 if ($revcounter == abs($direction)) { 300 $relativerev = $info['date']; 301 } 302 } 303 } 304 } 305 306 //true when $rev is found, but not the wanted follow-up. 307 $checkotherchunck = $fp 308 && ($info['date'] == $rev || ($revcounter > 0 && !$relativerev)) 309 && !(($tail == $eof && $direction > 0) || ($head == 0 && $direction < 0)); 310 311 if ($checkotherchunck) { 312 list($lines, $head, $tail) = $this->readAdjacentChunk($fp, $head, $tail, $direction); 313 314 if (empty($lines)) break; 315 } 316 } 317 if ($fp) { 318 fclose($fp); 319 } 320 321 return $relativerev; 322 } 323 324 /** 325 * Returns revisions around rev1 and rev2 326 * When available it returns $max entries for each revision 327 * 328 * @param int $rev1 oldest revision timestamp 329 * @param int $rev2 newest revision timestamp (0 looks up last revision) 330 * @param int $max maximum number of revisions returned 331 * @return array with two arrays with revisions surrounding rev1 respectively rev2 332 */ 333 public function getRevisionsAround($rev1, $rev2, $max = 50) 334 { 335 $max = intval(abs($max) / 2) * 2 + 1; 336 $rev1 = max($rev1, 0); 337 $rev2 = max($rev2, 0); 338 339 if ($rev2) { 340 if ($rev2 < $rev1) { 341 $rev = $rev2; 342 $rev2 = $rev1; 343 $rev1 = $rev; 344 } 345 } else { 346 //empty right side means a removed page. Look up last revision. 347 $rev2 = $this->currentRevision(); 348 } 349 //collect revisions around rev2 350 list($revs2, $allrevs, $fp, $lines, $head, $tail) = $this->retrieveRevisionsAround($rev2, $max); 351 352 if (empty($revs2)) return array(array(), array()); 353 354 //collect revisions around rev1 355 $index = array_search($rev1, $allrevs); 356 if ($index === false) { 357 //no overlapping revisions 358 list($revs1, , , , ,) = $this->retrieveRevisionsAround($rev1, $max); 359 if (empty($revs1)) $revs1 = array(); 360 } else { 361 //revisions overlaps, reuse revisions around rev2 362 $lastrev = array_pop($allrevs); //keep last entry that could be external edit 363 $revs1 = $allrevs; 364 while ($head > 0) { 365 for ($i = count($lines) - 1; $i >= 0; $i--) { 366 $info = parseChangelogLine($lines[$i]); 367 if ($info !== false) { 368 $this->cache[$this->id][$info['date']] = $info; 369 $revs1[] = $info['date']; 370 $index++; 371 372 if ($index > intval($max / 2)) break 2; 373 } 374 } 375 376 list($lines, $head, $tail) = $this->readAdjacentChunk($fp, $head, $tail, -1); 377 } 378 sort($revs1); 379 $revs1[] = $lastrev; //push back last entry 380 381 //return wanted selection 382 $revs1 = array_slice($revs1, max($index - intval($max / 2), 0), $max); 383 } 384 385 return array(array_reverse($revs1), array_reverse($revs2)); 386 } 387 388 389 /** 390 * Checks if the ID has old revisons 391 * @return boolean 392 */ 393 public function hasRevisions() { 394 $file = $this->getChangelogFilename(); 395 return file_exists($file); 396 } 397 398 /** 399 * Returns lines from changelog. 400 * If file larger than $chuncksize, only chunck is read that could contain $rev. 401 * 402 * When reference timestamp $rev is outside time range of changelog, readloglines() will return 403 * lines in first or last chunk, but they obviously does not contain $rev. 404 * 405 * @param int $rev revision timestamp 406 * @return array|false 407 * if success returns array(fp, array(changeloglines), $head, $tail, $eof) 408 * where fp only defined for chuck reading, needs closing. 409 * otherwise false 410 */ 411 protected function readloglines($rev) 412 { 413 $file = $this->getChangelogFilename(); 414 415 if (!file_exists($file)) { 416 return false; 417 } 418 419 $fp = null; 420 $head = 0; 421 $tail = 0; 422 $eof = 0; 423 424 if (filesize($file) < $this->chunk_size || $this->chunk_size == 0) { 425 // read whole file 426 $lines = file($file); 427 if ($lines === false) { 428 return false; 429 } 430 } else { 431 // read by chunk 432 $fp = fopen($file, 'rb'); // "file pointer" 433 if ($fp === false) { 434 return false; 435 } 436 $head = 0; 437 fseek($fp, 0, SEEK_END); 438 $eof = ftell($fp); 439 $tail = $eof; 440 441 // find chunk 442 while ($tail - $head > $this->chunk_size) { 443 $finger = $head + intval(($tail - $head) / 2); 444 $finger = $this->getNewlinepointer($fp, $finger); 445 $tmp = fgets($fp); 446 if ($finger == $head || $finger == $tail) { 447 break; 448 } 449 $info = parseChangelogLine($tmp); 450 $finger_rev = $info['date']; 451 452 if ($finger_rev > $rev) { 453 $tail = $finger; 454 } else { 455 $head = $finger; 456 } 457 } 458 459 if ($tail - $head < 1) { 460 // cound not find chunk, assume requested rev is missing 461 fclose($fp); 462 return false; 463 } 464 465 $lines = $this->readChunk($fp, $head, $tail); 466 } 467 return array( 468 $fp, 469 $lines, 470 $head, 471 $tail, 472 $eof, 473 ); 474 } 475 476 /** 477 * Read chunk and return array with lines of given chunck. 478 * Has no check if $head and $tail are really at a new line 479 * 480 * @param resource $fp resource filepointer 481 * @param int $head start point chunck 482 * @param int $tail end point chunck 483 * @return array lines read from chunck 484 */ 485 protected function readChunk($fp, $head, $tail) 486 { 487 $chunk = ''; 488 $chunk_size = max($tail - $head, 0); // found chunk size 489 $got = 0; 490 fseek($fp, $head); 491 while ($got < $chunk_size && !feof($fp)) { 492 $tmp = @fread($fp, max(min($this->chunk_size, $chunk_size - $got), 0)); 493 if ($tmp === false) { //error state 494 break; 495 } 496 $got += strlen($tmp); 497 $chunk .= $tmp; 498 } 499 $lines = explode("\n", $chunk); 500 array_pop($lines); // remove trailing newline 501 return $lines; 502 } 503 504 /** 505 * Set pointer to first new line after $finger and return its position 506 * 507 * @param resource $fp filepointer 508 * @param int $finger a pointer 509 * @return int pointer 510 */ 511 protected function getNewlinepointer($fp, $finger) 512 { 513 fseek($fp, $finger); 514 $nl = $finger; 515 if ($finger > 0) { 516 fgets($fp); // slip the finger forward to a new line 517 $nl = ftell($fp); 518 } 519 return $nl; 520 } 521 522 /** 523 * Check whether given revision is the current page 524 * 525 * @param int $rev timestamp of current page 526 * @return bool true if $rev is current revision, otherwise false 527 */ 528 public function isCurrentRevision($rev) 529 { 530 return $rev == $this->currentRevision(); 531 } 532 533 /** 534 * Return an existing revision for a specific date which is 535 * the current one or younger or equal then the date 536 * 537 * @param number $date_at timestamp 538 * @return string revision ('' for current) 539 */ 540 public function getLastRevisionAt($date_at) 541 { 542 //requested date_at(timestamp) younger or equal then modified_time($this->id) => load current 543 if (file_exists($this->getFilename()) && $date_at >= @filemtime($this->getFilename())) { 544 return ''; 545 } else { 546 if ($rev = $this->getRelativeRevision($date_at + 1, -1)) { //+1 to get also the requested date revision 547 return $rev; 548 } else { 549 return false; 550 } 551 } 552 } 553 554 /** 555 * Returns the next lines of the changelog of the chunck before head or after tail 556 * 557 * @param resource $fp filepointer 558 * @param int $head position head of last chunk 559 * @param int $tail position tail of last chunk 560 * @param int $direction positive forward, negative backward 561 * @return array with entries: 562 * - $lines: changelog lines of readed chunk 563 * - $head: head of chunk 564 * - $tail: tail of chunk 565 */ 566 protected function readAdjacentChunk($fp, $head, $tail, $direction) 567 { 568 if (!$fp) return array(array(), $head, $tail); 569 570 if ($direction > 0) { 571 //read forward 572 $head = $tail; 573 $tail = $head + intval($this->chunk_size * (2 / 3)); 574 $tail = $this->getNewlinepointer($fp, $tail); 575 } else { 576 //read backward 577 $tail = $head; 578 $head = max($tail - $this->chunk_size, 0); 579 while (true) { 580 $nl = $this->getNewlinepointer($fp, $head); 581 // was the chunk big enough? if not, take another bite 582 if ($nl > 0 && $tail <= $nl) { 583 $head = max($head - $this->chunk_size, 0); 584 } else { 585 $head = $nl; 586 break; 587 } 588 } 589 } 590 591 //load next chunck 592 $lines = $this->readChunk($fp, $head, $tail); 593 return array($lines, $head, $tail); 594 } 595 596 /** 597 * Collect the $max revisions near to the timestamp $rev 598 * 599 * Ideally, half of retrieved timestamps are older than $rev, another half are newer. 600 * The returned array $requestedrevs may not contain the reference timestamp $rev 601 * when it does not match any revision value recorded in changelog. 602 * 603 * @param int $rev revision timestamp 604 * @param int $max maximum number of revisions to be returned 605 * @return bool|array 606 * return array with entries: 607 * - $requestedrevs: array of with $max revision timestamps 608 * - $revs: all parsed revision timestamps 609 * - $fp: filepointer only defined for chuck reading, needs closing. 610 * - $lines: non-parsed changelog lines before the parsed revisions 611 * - $head: position of first readed changelogline 612 * - $lasttail: position of end of last readed changelogline 613 * otherwise false 614 */ 615 protected function retrieveRevisionsAround($rev, $max) 616 { 617 $revs = array(); 618 $aftercount = $beforecount = 0; 619 620 //get lines from changelog 621 list($fp, $lines, $starthead, $starttail, $eof) = $this->readloglines($rev); 622 if (empty($lines)) return false; 623 624 //parse changelog lines in chunk, and read forward more chunks until $max/2 is reached 625 $head = $starthead; 626 $tail = $starttail; 627 while (count($lines) > 0) { 628 foreach ($lines as $line) { 629 $info = parseChangelogLine($line); 630 if ($info !== false) { 631 $this->cache[$this->id][$info['date']] = $info; 632 $revs[] = $info['date']; 633 if ($info['date'] >= $rev) { 634 //count revs after reference $rev 635 $aftercount++; 636 if ($aftercount == 1) $beforecount = count($revs); 637 } 638 //enough revs after reference $rev? 639 if ($aftercount > intval($max / 2)) break 2; 640 } 641 } 642 //retrieve next chunk 643 list($lines, $head, $tail) = $this->readAdjacentChunk($fp, $head, $tail, 1); 644 } 645 $lasttail = $tail; 646 647 // add a possible revision of external edit, create or deletion 648 if ($lasttail == $eof && $aftercount <= intval($max / 2) && $this->isExternalEdition()) { 649 $revs[] = $this->currentRevision; 650 $aftercount++; 651 } 652 653 if ($aftercount == 0) { 654 //given timestamp $rev is newer than the most recent line in chunk 655 return false; //FIXME: or proceed to collect older revisions? 656 } 657 658 //read more chunks backward until $max/2 is reached and total number of revs is equal to $max 659 $lines = array(); 660 $i = 0; 661 if ($aftercount > 0) { 662 $head = $starthead; 663 $tail = $starttail; 664 while ($head > 0) { 665 list($lines, $head, $tail) = $this->readAdjacentChunk($fp, $head, $tail, -1); 666 667 for ($i = count($lines) - 1; $i >= 0; $i--) { 668 $info = parseChangelogLine($lines[$i]); 669 if ($info !== false) { 670 $this->cache[$this->id][$info['date']] = $info; 671 $revs[] = $info['date']; 672 $beforecount++; 673 //enough revs before reference $rev? 674 if ($beforecount > max(intval($max / 2), $max - $aftercount)) break 2; 675 } 676 } 677 } 678 } 679 //keep only non-parsed lines 680 $lines = array_slice($lines, 0, $i); 681 682 sort($revs); 683 684 //trunk desired selection 685 $requestedrevs = array_slice($revs, -$max, $max); 686 687 return array($requestedrevs, $revs, $fp, $lines, $head, $lasttail); 688 } 689 690 /** 691 * Returns revision logline in same format as @see ChangeLog::getRevisionInfo() 692 * 693 * @return bool|array false if not external edit/deletion, otherwise array with entries: 694 * - date: unix timestamp for external edit or 'unknown' for external deletion 695 * - ip: IPv4 address (127.0.0.1) 696 * - type: log line type 697 * - id: page id 698 * - user: user name 699 * - sum: edit summary (or action reason) 700 * - extra: extra data (varies by line type) 701 * - sizechange: change of filesize 702 * 703 * @author Gerrit Uitslag <klapinklapin@gmail.com> 704 */ 705 public function getExternalEditRevInfo() 706 { 707 global $lang; 708 global $cache_externaledit; //caches external edits per page 709 710 // check if it's already in the memory cache 711 if (isset($cache_externaledit[$this->id])) { 712 if ($cache_externaledit[$this->id] === false) { 713 return false; 714 } else { 715 return $this->cache[$this->id][$cache_externaledit[$this->id]]; 716 } 717 } 718 $externaleditRevinfo = false; 719 $cache_externaledit[$this->id] = false; 720 721 //in attic no revision of current existing wiki page, so external edit occurred 722 $fileLastMod = $this->getFilename(); 723 $lastMod = @filemtime($fileLastMod); // from wiki page, suppresses warning in case the file not exists 724 $lastRev = $this->getRevisions(-1, 1); // from changelog 725 $lastRev = (int) (empty($lastRev) ? 0 : $lastRev[0]); 726 if (!file_exists($this->getFilename($lastMod)) && file_exists($fileLastMod) && $lastRev < $lastMod) { 727 $cache_externaledit[$this->id] = $lastMod; 728 $fileLastRev = $this->getFilename($lastRev); //returns current wikipage path if $lastRev==false 729 $revinfo = $this->getRevisionInfo($lastRev); 730 if (empty($lastRev) || !file_exists($fileLastRev) || $revinfo['type'] == DOKU_CHANGE_TYPE_DELETE) { 731 $filesize_old = 0; 732 } else { 733 $filesize_old = io_getSizeFile($fileLastRev); 734 } 735 $filesize_new = filesize($fileLastMod); 736 $sizechange = $filesize_new - $filesize_old; 737 $isJustCreated = empty($lastRev) || !file_exists($fileLastRev); 738 739 $externaleditRevinfo = [ 740 'date' => $lastMod, 741 'ip' => '127.0.0.1', 742 'type' => $isJustCreated ? DOKU_CHANGE_TYPE_CREATE : DOKU_CHANGE_TYPE_EDIT, 743 'id' => $this->id, 744 'user' => '', 745 'sum' => ($isJustCreated ? $lang['created'] .' - ' : '') . $lang['external_edit'], 746 'extra' => '', 747 'sizechange' => $sizechange 748 ]; 749 $cache_externaledit[$this->id] = $externaleditRevinfo['date']; 750 $this->cache[$this->id][$externaleditRevinfo['date']] = $externaleditRevinfo; 751 } 752 753 $revinfo = $this->getRevisionInfo($lastRev); 754 //deleted wiki page, but not registered in changelog 755 if (!file_exists($fileLastMod) // there is no current page=>true 756 && !empty($lastRev) && $revinfo['type'] !== DOKU_CHANGE_TYPE_DELETE 757 ) { 758 $fileLastRev = $this->getFilename($lastRev); 759 $externaleditRevinfo = [ 760 'date' => time(), //unknown deletion date, always higher as latest rev 761 'ip' => '127.0.0.1', 762 'type' => DOKU_CHANGE_TYPE_EXTERNAL_DELETE, 763 'id' => $this->id, 764 'user' => '', 765 'sum' => $lang['deleted']. ' - ' . $lang['external_edit'], 766 'extra' => '', 767 'sizechange' => -io_getSizeFile($fileLastRev) 768 ]; 769 $cache_externaledit[$this->id] = $externaleditRevinfo['date']; 770 $this->cache[$this->id][$externaleditRevinfo['date']] = $externaleditRevinfo; 771 } 772 773 return $externaleditRevinfo; 774 } 775 776 /** 777 * Get the current revision information, considering external edit, create or deletion 778 * 779 * The "current" revison is the last timestamp of the page in the context of changelog. 780 * However it is often recognised that is in sight now from the DokuWiki user perspective. 781 * The current page is accessible without any revision identifier (eg. doku.php?id=foo), 782 * but it has unique modification time of the source txt file and kept in changelog. 783 * When the page is deleted by saving blank text in the DokuWiki editor, the deletion 784 * time is to be kept as its revision identifier in the changelog. 785 * 786 * External edit will break consistency between the file and changelog. A page source 787 * file might be modified, created or deleted without using DokuWiki editor, instead 788 * by accessing direct to the file stored in data directory via server console. 789 * Such editions are never recorded in changelog. However after external file edit, 790 * now we can see new "current" content of the edited page! 791 * 792 * A tentative revision should be assigned for the external edition to handle whole 793 * revisions successfully in DokuWiki revision list and diff view interface. 794 * As far as the source file of the edition exists, a unique revision can be decided 795 * using function filemtime(), but it could be unknown if the foo.txt file had deleted 796 * or moved to foo.bak file. 797 * In such case, we assume unknown revision as "last timestamp in chagelog" +1 798 * to ensure that current one should be newer than any revisions in changelog. 799 * Another case of external edit: when foo.bak file moved back to foo.txt, the current 800 * one could become older than latest timestamp in changelog. In this case, we should 801 * assume the revison as "last timestamp in chagelog" +1, instead of its timestamp. 802 * 803 * @return bool|array false when page had never existed or array with entries: 804 * - date: revision identifier (timestamp or last revision +1) 805 * - ip: IPv4 address (127.0.0.1) 806 * - type: log line type 807 * - id: id of page or media 808 * - user: user name 809 * - sum: edit summary (or action reason) 810 * - extra: extra data (varies by line type) 811 * - sizechange: change of filesize 812 * - timestamp: timestamp or 'unknown' (key set only for external edition) 813 * 814 * @author Satoshi Sahara <sahara.satoshi@gmail.com> 815 */ 816 public function getCurrentRevisionInfo() 817 { 818 global $lang; 819 820 if (isset($this->currentRevision)) return $this->getRevisionInfo($this->currentRevision); 821 822 // get revision id from the item file timestamp and chagelog 823 $fileRev = @filemtime($this->getFilename()); // false when the file not exist 824 $lastRev = @$this->getRevisions(-1, 1)[0]; // null when failed 825 826 if (!$fileRev && $lastRev === null) { // has never existed 827 return false; 828 } elseif ($fileRev === $lastRev) { // not external edit 829 $this->currentRevision = $lastRev; 830 $this->cache[$this->id][$this->currentRevision] += [ 831 'current' => true, 832 ]; 833 return $this->getRevisionInfo($this->currentRevision); 834 } 835 836 if (!$fileRev && $lastRev) { // item file does not exist 837 // check consistency against changelog 838 $revInfo = $this->getRevisionInfo($lastRev); 839 if ($revInfo['type'] == DOKU_CHANGE_TYPE_DELETE) { 840 $this->currentRevision = $lastRev; 841 $this->cache[$this->id][$this->currentRevision] += [ 842 'current' => true, 843 ]; 844 return $this->getRevisionInfo($this->currentRevision); 845 } 846 847 // externally deleted 848 $revInfo = [ 849 'date' => $lastRev +1, 850 'ip' => '127.0.0.1', 851 'type' => DOKU_CHANGE_TYPE_DELETE, 852 'id' => $this->id, 853 'user' => '', 854 'sum' => $lang['deleted'] .' - '. $lang['external_edit'], 855 'extra' => '', 856 'sizechange' => -io_getSizeFile($this->getFilename($lastRev)), 857 'current' => true, 858 'timestamp' => 'unknown', 859 ]; 860 861 } elseif ($fileRev) { // item file exist 862 // here, file timestamp is different with last revision in changelog 863 $isJustCreated = !boolval($lastRev); 864 $filesize_new = filesize($this->getFilename()); 865 $filesize_old = $lastRev ? io_getSizeFile($this->getFilename($lastRev)) : 0; 866 $sizechange = $filesize_new - $filesize_old; 867 868 if ($isJustCreated) { // lastRev is null 869 $rev = $timestamp = $fileRev; 870 $sum = $lang['created'] .' - '. $lang['external_edit']; 871 } elseif ($fileRev > $lastRev) { 872 $rev = $timestamp = $fileRev; 873 $sum = $lang['external_edit']; 874 } else { 875 // $fileRev is older than $lastRev, externally reverted an old file 876 $rev = max($fileRev, $lastRev +1); 877 $timestamp = 'unknown'; 878 $sum = $lang['external_edit']; 879 } 880 881 // externally created or edited 882 $revInfo = [ 883 'date' => $rev, 884 'ip' => '127.0.0.1', 885 'type' => $isJustCreated ? DOKU_CHANGE_TYPE_CREATE : DOKU_CHANGE_TYPE_EDIT, 886 'id' => $this->id, 887 'user' => '', 888 'sum' => $sum, 889 'extra' => '', 890 'sizechange' => $sizechange, 891 'current' => true, 892 'timestamp' => $timestamp, 893 ]; 894 } 895 896 // cache current revision information of external edition 897 $this->currentRevision = $revInfo['date']; 898 $this->cache[$this->id][$this->currentRevision] = $revInfo; 899 return $this->getRevisionInfo($this->currentRevision); 900 } 901 902 /** 903 * Checks if the revision is external edition 904 * @return boolean 905 */ 906 public function isExternalEdition($rev =null) 907 { 908 if ($rev === 'current' || $rev === null) { 909 $revInfo = $this->getCurrentRevisionInfo(); 910 } elseif (is_int($rev)) { 911 $revInfo = $this->getRevisionInfo($rev); 912 } elseif (is_array($rev)) { 913 $revInfo = $rev; 914 } 915 return array_key_exists('timestamp', $revInfo); 916 } 917 918 /** 919 * Return the current revision identifer 920 * @return int|false 921 */ 922 public function currentRevision() 923 { 924 if (!isset($this->currentRevision)) { 925 // set ChangeLog::currentRevision property 926 $this->getCurrentRevisionInfo(); 927 } 928 return $this->currentRevision; 929 } 930} 931