[ Index ] |
PHP Cross Reference of DokuWiki |
[Summary view] [Print] [Text view]
1 <?php 2 3 namespace dokuwiki\ChangeLog; 4 5 use dokuwiki\Logger; 6 7 /** 8 * ChangeLog Prototype; methods for handling changelog 9 */ 10 abstract class ChangeLog 11 { 12 use ChangeLogTrait; 13 14 /** @var string */ 15 protected $id; 16 /** @var false|int */ 17 protected $currentRevision; 18 /** @var array */ 19 protected $cache; 20 21 /** 22 * Constructor 23 * 24 * @param string $id page id 25 * @param int $chunk_size maximum block size read from file 26 */ 27 public function __construct($id, $chunk_size = 8192) 28 { 29 global $cache_revinfo; 30 31 $this->cache =& $cache_revinfo; 32 if (!isset($this->cache[$id])) { 33 $this->cache[$id] = array(); 34 } 35 36 $this->id = $id; 37 $this->setChunkSize($chunk_size); 38 } 39 40 /** 41 * Returns path to current page/media 42 * 43 * @return string path to file 44 */ 45 abstract protected function getFilename(); 46 47 /** 48 * Check whether given revision is the current page 49 * 50 * @param int $rev timestamp of current page 51 * @return bool true if $rev is current revision, otherwise false 52 */ 53 public function isCurrentRevision($rev) 54 { 55 return $rev == $this->currentRevision(); 56 } 57 58 /** 59 * Checks if the revision is last revision 60 * 61 * @param int $rev revision timestamp 62 * @return bool true if $rev is last revision, otherwise false 63 */ 64 public function isLastRevision($rev = null) 65 { 66 return $rev === $this->lastRevision(); 67 } 68 69 /** 70 * Return the current revision identifier 71 * 72 * The "current" revision means current version of the page or media file. It is either 73 * identical with or newer than the "last" revision, that depends on whether the file 74 * has modified, created or deleted outside of DokuWiki. 75 * The value of identifier can be determined by timestamp as far as the file exists, 76 * otherwise it must be assigned larger than any other revisions to keep them sortable. 77 * 78 * @return int|false revision timestamp 79 */ 80 public function currentRevision() 81 { 82 if (!isset($this->currentRevision)) { 83 // set ChangeLog::currentRevision property 84 $this->getCurrentRevisionInfo(); 85 } 86 return $this->currentRevision; 87 } 88 89 /** 90 * Return the last revision identifier, date value of the last entry of the changelog 91 * 92 * @return int|false revision timestamp 93 */ 94 public function lastRevision() 95 { 96 $revs = $this->getRevisions(-1, 1); 97 return empty($revs) ? false : $revs[0]; 98 } 99 100 /** 101 * Save revision info to the cache pool 102 * 103 * @param array $info Revision info structure 104 * @return bool 105 */ 106 protected function cacheRevisionInfo($info) 107 { 108 if (!is_array($info)) return false; 109 //$this->cache[$this->id][$info['date']] ??= $info; // since php 7.4 110 $this->cache[$this->id][$info['date']] = $this->cache[$this->id][$info['date']] ?? $info; 111 return true; 112 } 113 114 /** 115 * Get the changelog information for a specific revision (timestamp) 116 * 117 * Adjacent changelog lines are optimistically parsed and cached to speed up 118 * consecutive calls to getRevisionInfo. For large changelog files, only the chunk 119 * containing the requested changelog line is read. 120 * 121 * @param int $rev revision timestamp 122 * @param bool $retrieveCurrentRevInfo allows to skip for getting other revision info in the 123 * getCurrentRevisionInfo() where $currentRevision is not yet determined 124 * @return bool|array false or array with entries: 125 * - date: unix timestamp 126 * - ip: IPv4 address (127.0.0.1) 127 * - type: log line type 128 * - id: page id 129 * - user: user name 130 * - sum: edit summary (or action reason) 131 * - extra: extra data (varies by line type) 132 * - sizechange: change of filesize 133 * 134 * @author Ben Coburn <btcoburn@silicodon.net> 135 * @author Kate Arzamastseva <pshns@ukr.net> 136 */ 137 public function getRevisionInfo($rev, $retrieveCurrentRevInfo = true) 138 { 139 $rev = max(0, $rev); 140 if (!$rev) return false; 141 142 //ensure the external edits are cached as well 143 if (!isset($this->currentRevision) && $retrieveCurrentRevInfo) { 144 $this->getCurrentRevisionInfo(); 145 } 146 147 // check if it's already in the memory cache 148 if (isset($this->cache[$this->id]) && isset($this->cache[$this->id][$rev])) { 149 return $this->cache[$this->id][$rev]; 150 } 151 152 //read lines from changelog 153 list($fp, $lines) = $this->readloglines($rev); 154 if ($fp) { 155 fclose($fp); 156 } 157 if (empty($lines)) return false; 158 159 // parse and cache changelog lines 160 foreach ($lines as $value) { 161 $info = $this->parseLogLine($value); 162 $this->cacheRevisionInfo($info); 163 } 164 if (!isset($this->cache[$this->id][$rev])) { 165 return false; 166 } 167 return $this->cache[$this->id][$rev]; 168 } 169 170 /** 171 * Return a list of page revisions numbers 172 * 173 * Does not guarantee that the revision exists in the attic, 174 * only that a line with the date exists in the changelog. 175 * By default the current revision is skipped. 176 * 177 * The current revision is automatically skipped when the page exists. 178 * See $INFO['meta']['last_change'] for the current revision. 179 * A negative $first let read the current revision too. 180 * 181 * For efficiency, the log lines are parsed and cached for later 182 * calls to getRevisionInfo. Large changelog files are read 183 * backwards in chunks until the requested number of changelog 184 * lines are received. 185 * 186 * @param int $first skip the first n changelog lines 187 * @param int $num number of revisions to return 188 * @return array with the revision timestamps 189 * 190 * @author Ben Coburn <btcoburn@silicodon.net> 191 * @author Kate Arzamastseva <pshns@ukr.net> 192 */ 193 public function getRevisions($first, $num) 194 { 195 $revs = array(); 196 $lines = array(); 197 $count = 0; 198 199 $logfile = $this->getChangelogFilename(); 200 if (!file_exists($logfile)) return $revs; 201 202 $num = max($num, 0); 203 if ($num == 0) { 204 return $revs; 205 } 206 207 if ($first < 0) { 208 $first = 0; 209 } else { 210 $fileLastMod = $this->getFilename(); 211 if (file_exists($fileLastMod) && $this->isLastRevision(filemtime($fileLastMod))) { 212 // skip last revision if the page exists 213 $first = max($first + 1, 0); 214 } 215 } 216 217 if (filesize($logfile) < $this->chunk_size || $this->chunk_size == 0) { 218 // read whole file 219 $lines = file($logfile); 220 if ($lines === false) { 221 return $revs; 222 } 223 } else { 224 // read chunks backwards 225 $fp = fopen($logfile, 'rb'); // "file pointer" 226 if ($fp === false) { 227 return $revs; 228 } 229 fseek($fp, 0, SEEK_END); 230 $tail = ftell($fp); 231 232 // chunk backwards 233 $finger = max($tail - $this->chunk_size, 0); 234 while ($count < $num + $first) { 235 $nl = $this->getNewlinepointer($fp, $finger); 236 237 // was the chunk big enough? if not, take another bite 238 if ($nl > 0 && $tail <= $nl) { 239 $finger = max($finger - $this->chunk_size, 0); 240 continue; 241 } else { 242 $finger = $nl; 243 } 244 245 // read chunk 246 $chunk = ''; 247 $read_size = max($tail - $finger, 0); // found chunk size 248 $got = 0; 249 while ($got < $read_size && !feof($fp)) { 250 $tmp = @fread($fp, max(min($this->chunk_size, $read_size - $got), 0)); 251 if ($tmp === false) { 252 break; 253 } //error state 254 $got += strlen($tmp); 255 $chunk .= $tmp; 256 } 257 $tmp = explode("\n", $chunk); 258 array_pop($tmp); // remove trailing newline 259 260 // combine with previous chunk 261 $count += count($tmp); 262 $lines = array_merge($tmp, $lines); 263 264 // next chunk 265 if ($finger == 0) { 266 break; 267 } else { // already read all the lines 268 $tail = $finger; 269 $finger = max($tail - $this->chunk_size, 0); 270 } 271 } 272 fclose($fp); 273 } 274 275 // skip parsing extra lines 276 $num = max(min(count($lines) - $first, $num), 0); 277 if ($first > 0 && $num > 0) { 278 $lines = array_slice($lines, max(count($lines) - $first - $num, 0), $num); 279 } elseif ($first > 0 && $num == 0) { 280 $lines = array_slice($lines, 0, max(count($lines) - $first, 0)); 281 } elseif ($first == 0 && $num > 0) { 282 $lines = array_slice($lines, max(count($lines) - $num, 0)); 283 } 284 285 // handle lines in reverse order 286 for ($i = count($lines) - 1; $i >= 0; $i--) { 287 $info = $this->parseLogLine($lines[$i]); 288 if ($this->cacheRevisionInfo($info)) { 289 $revs[] = $info['date']; 290 } 291 } 292 293 return $revs; 294 } 295 296 /** 297 * Get the nth revision left or right-hand side for a specific page id and revision (timestamp) 298 * 299 * For large changelog files, only the chunk containing the 300 * reference revision $rev is read and sometimes a next chunk. 301 * 302 * Adjacent changelog lines are optimistically parsed and cached to speed up 303 * consecutive calls to getRevisionInfo. 304 * 305 * @param int $rev revision timestamp used as start date 306 * (doesn't need to be exact revision number) 307 * @param int $direction give position of returned revision with respect to $rev; 308 positive=next, negative=prev 309 * @return bool|int 310 * timestamp of the requested revision 311 * otherwise false 312 */ 313 public function getRelativeRevision($rev, $direction) 314 { 315 $rev = max($rev, 0); 316 $direction = (int)$direction; 317 318 //no direction given or last rev, so no follow-up 319 if (!$direction || ($direction > 0 && $this->isCurrentRevision($rev))) { 320 return false; 321 } 322 323 //get lines from changelog 324 list($fp, $lines, $head, $tail, $eof) = $this->readloglines($rev); 325 if (empty($lines)) return false; 326 327 // look for revisions later/earlier than $rev, when founded count till the wanted revision is reached 328 // also parse and cache changelog lines for getRevisionInfo(). 329 $revCounter = 0; 330 $relativeRev = false; 331 $checkOtherChunk = true; //always runs once 332 while (!$relativeRev && $checkOtherChunk) { 333 $info = array(); 334 //parse in normal or reverse order 335 $count = count($lines); 336 if ($direction > 0) { 337 $start = 0; 338 $step = 1; 339 } else { 340 $start = $count - 1; 341 $step = -1; 342 } 343 for ($i = $start; $i >= 0 && $i < $count; $i = $i + $step) { 344 $info = $this->parseLogLine($lines[$i]); 345 if ($this->cacheRevisionInfo($info)) { 346 //look for revs older/earlier then reference $rev and select $direction-th one 347 if (($direction > 0 && $info['date'] > $rev) || ($direction < 0 && $info['date'] < $rev)) { 348 $revCounter++; 349 if ($revCounter == abs($direction)) { 350 $relativeRev = $info['date']; 351 } 352 } 353 } 354 } 355 356 //true when $rev is found, but not the wanted follow-up. 357 $checkOtherChunk = $fp 358 && ($info['date'] == $rev || ($revCounter > 0 && !$relativeRev)) 359 && !(($tail == $eof && $direction > 0) || ($head == 0 && $direction < 0)); 360 361 if ($checkOtherChunk) { 362 list($lines, $head, $tail) = $this->readAdjacentChunk($fp, $head, $tail, $direction); 363 364 if (empty($lines)) break; 365 } 366 } 367 if ($fp) { 368 fclose($fp); 369 } 370 371 return $relativeRev; 372 } 373 374 /** 375 * Returns revisions around rev1 and rev2 376 * When available it returns $max entries for each revision 377 * 378 * @param int $rev1 oldest revision timestamp 379 * @param int $rev2 newest revision timestamp (0 looks up last revision) 380 * @param int $max maximum number of revisions returned 381 * @return array with two arrays with revisions surrounding rev1 respectively rev2 382 */ 383 public function getRevisionsAround($rev1, $rev2, $max = 50) 384 { 385 $max = intval(abs($max) / 2) * 2 + 1; 386 $rev1 = max($rev1, 0); 387 $rev2 = max($rev2, 0); 388 389 if ($rev2) { 390 if ($rev2 < $rev1) { 391 $rev = $rev2; 392 $rev2 = $rev1; 393 $rev1 = $rev; 394 } 395 } else { 396 //empty right side means a removed page. Look up last revision. 397 $rev2 = $this->currentRevision(); 398 } 399 //collect revisions around rev2 400 list($revs2, $allRevs, $fp, $lines, $head, $tail) = $this->retrieveRevisionsAround($rev2, $max); 401 402 if (empty($revs2)) return array(array(), array()); 403 404 //collect revisions around rev1 405 $index = array_search($rev1, $allRevs); 406 if ($index === false) { 407 //no overlapping revisions 408 list($revs1, , , , ,) = $this->retrieveRevisionsAround($rev1, $max); 409 if (empty($revs1)) $revs1 = array(); 410 } else { 411 //revisions overlaps, reuse revisions around rev2 412 $lastRev = array_pop($allRevs); //keep last entry that could be external edit 413 $revs1 = $allRevs; 414 while ($head > 0) { 415 for ($i = count($lines) - 1; $i >= 0; $i--) { 416 $info = $this->parseLogLine($lines[$i]); 417 if ($this->cacheRevisionInfo($info)) { 418 $revs1[] = $info['date']; 419 $index++; 420 421 if ($index > intval($max / 2)) break 2; 422 } 423 } 424 425 list($lines, $head, $tail) = $this->readAdjacentChunk($fp, $head, $tail, -1); 426 } 427 sort($revs1); 428 $revs1[] = $lastRev; //push back last entry 429 430 //return wanted selection 431 $revs1 = array_slice($revs1, max($index - intval($max / 2), 0), $max); 432 } 433 434 return array(array_reverse($revs1), array_reverse($revs2)); 435 } 436 437 /** 438 * Return an existing revision for a specific date which is 439 * the current one or younger or equal then the date 440 * 441 * @param number $date_at timestamp 442 * @return string revision ('' for current) 443 */ 444 public function getLastRevisionAt($date_at) 445 { 446 $fileLastMod = $this->getFilename(); 447 //requested date_at(timestamp) younger or equal then modified_time($this->id) => load current 448 if (file_exists($fileLastMod) && $date_at >= @filemtime($fileLastMod)) { 449 return ''; 450 } else { 451 if ($rev = $this->getRelativeRevision($date_at + 1, -1)) { //+1 to get also the requested date revision 452 return $rev; 453 } else { 454 return false; 455 } 456 } 457 } 458 459 /** 460 * Collect the $max revisions near to the timestamp $rev 461 * 462 * Ideally, half of retrieved timestamps are older than $rev, another half are newer. 463 * The returned array $requestedRevs may not contain the reference timestamp $rev 464 * when it does not match any revision value recorded in changelog. 465 * 466 * @param int $rev revision timestamp 467 * @param int $max maximum number of revisions to be returned 468 * @return bool|array 469 * return array with entries: 470 * - $requestedRevs: array of with $max revision timestamps 471 * - $revs: all parsed revision timestamps 472 * - $fp: file pointer only defined for chuck reading, needs closing. 473 * - $lines: non-parsed changelog lines before the parsed revisions 474 * - $head: position of first read changelog line 475 * - $lastTail: position of end of last read changelog line 476 * otherwise false 477 */ 478 protected function retrieveRevisionsAround($rev, $max) 479 { 480 $revs = array(); 481 $afterCount = $beforeCount = 0; 482 483 //get lines from changelog 484 list($fp, $lines, $startHead, $startTail, $eof) = $this->readloglines($rev); 485 if (empty($lines)) return false; 486 487 //parse changelog lines in chunk, and read forward more chunks until $max/2 is reached 488 $head = $startHead; 489 $tail = $startTail; 490 while (count($lines) > 0) { 491 foreach ($lines as $line) { 492 $info = $this->parseLogLine($line); 493 if ($this->cacheRevisionInfo($info)) { 494 $revs[] = $info['date']; 495 if ($info['date'] >= $rev) { 496 //count revs after reference $rev 497 $afterCount++; 498 if ($afterCount == 1) $beforeCount = count($revs); 499 } 500 //enough revs after reference $rev? 501 if ($afterCount > intval($max / 2)) break 2; 502 } 503 } 504 //retrieve next chunk 505 list($lines, $head, $tail) = $this->readAdjacentChunk($fp, $head, $tail, 1); 506 } 507 $lastTail = $tail; 508 509 // add a possible revision of external edit, create or deletion 510 if ($lastTail == $eof && $afterCount <= intval($max / 2) && 511 count($revs) && !$this->isCurrentRevision($revs[count($revs)-1]) 512 ) { 513 $revs[] = $this->currentRevision; 514 $afterCount++; 515 } 516 517 if ($afterCount == 0) { 518 //given timestamp $rev is newer than the most recent line in chunk 519 return false; //FIXME: or proceed to collect older revisions? 520 } 521 522 //read more chunks backward until $max/2 is reached and total number of revs is equal to $max 523 $lines = array(); 524 $i = 0; 525 if ($afterCount > 0) { 526 $head = $startHead; 527 $tail = $startTail; 528 while ($head > 0) { 529 list($lines, $head, $tail) = $this->readAdjacentChunk($fp, $head, $tail, -1); 530 531 for ($i = count($lines) - 1; $i >= 0; $i--) { 532 $info = $this->parseLogLine($lines[$i]); 533 if ($this->cacheRevisionInfo($info)) { 534 $revs[] = $info['date']; 535 $beforeCount++; 536 //enough revs before reference $rev? 537 if ($beforeCount > max(intval($max / 2), $max - $afterCount)) break 2; 538 } 539 } 540 } 541 } 542 //keep only non-parsed lines 543 $lines = array_slice($lines, 0, $i); 544 545 sort($revs); 546 547 //trunk desired selection 548 $requestedRevs = array_slice($revs, -$max, $max); 549 550 return array($requestedRevs, $revs, $fp, $lines, $head, $lastTail); 551 } 552 553 /** 554 * Get the current revision information, considering external edit, create or deletion 555 * 556 * When the file has not modified since its last revision, the information of the last 557 * change that had already recorded in the changelog is returned as current change info. 558 * Otherwise, the change information since the last revision caused outside DokuWiki 559 * should be returned, which is referred as "external revision". 560 * 561 * The change date of the file can be determined by timestamp as far as the file exists, 562 * however this is not possible when the file has already deleted outside of DokuWiki. 563 * In such case we assign 1 sec before current time() for the external deletion. 564 * As a result, the value of current revision identifier may change each time because: 565 * 1) the file has again modified outside of DokuWiki, or 566 * 2) the value is essentially volatile for deleted but once existed files. 567 * 568 * @return bool|array false when page had never existed or array with entries: 569 * - date: revision identifier (timestamp or last revision +1) 570 * - ip: IPv4 address (127.0.0.1) 571 * - type: log line type 572 * - id: id of page or media 573 * - user: user name 574 * - sum: edit summary (or action reason) 575 * - extra: extra data (varies by line type) 576 * - sizechange: change of filesize 577 * - timestamp: unix timestamp or false (key set only for external edit occurred) 578 * 579 * @author Satoshi Sahara <sahara.satoshi@gmail.com> 580 */ 581 public function getCurrentRevisionInfo() 582 { 583 global $lang; 584 585 if (isset($this->currentRevision)) return $this->getRevisionInfo($this->currentRevision); 586 587 // get revision id from the item file timestamp and changelog 588 $fileLastMod = $this->getFilename(); 589 $fileRev = @filemtime($fileLastMod); // false when the file not exist 590 $lastRev = $this->lastRevision(); // false when no changelog 591 592 if (!$fileRev && !$lastRev) { // has never existed 593 $this->currentRevision = false; 594 return false; 595 } elseif ($fileRev === $lastRev) { // not external edit 596 $this->currentRevision = $lastRev; 597 return $this->getRevisionInfo($lastRev); 598 } 599 600 if (!$fileRev && $lastRev) { // item file does not exist 601 // check consistency against changelog 602 $revInfo = $this->getRevisionInfo($lastRev, false); 603 if ($revInfo['type'] == DOKU_CHANGE_TYPE_DELETE) { 604 $this->currentRevision = $lastRev; 605 return $revInfo; 606 } 607 608 // externally deleted, set revision date as late as possible 609 $revInfo = [ 610 'date' => max($lastRev +1, time() -1), // 1 sec before now or new page save 611 'ip' => '127.0.0.1', 612 'type' => DOKU_CHANGE_TYPE_DELETE, 613 'id' => $this->id, 614 'user' => '', 615 'sum' => $lang['deleted'].' - '.$lang['external_edit'].' ('.$lang['unknowndate'].')', 616 'extra' => '', 617 'sizechange' => -io_getSizeFile($this->getFilename($lastRev)), 618 'timestamp' => false, 619 ]; 620 621 } else { // item file exists, with timestamp $fileRev 622 // here, file timestamp $fileRev is different with last revision timestamp $lastRev in changelog 623 $isJustCreated = $lastRev === false || ( 624 $fileRev > $lastRev && 625 $this->getRevisionInfo($lastRev, false)['type'] == DOKU_CHANGE_TYPE_DELETE 626 ); 627 $filesize_new = filesize($this->getFilename()); 628 $filesize_old = $isJustCreated ? 0 : io_getSizeFile($this->getFilename($lastRev)); 629 $sizechange = $filesize_new - $filesize_old; 630 631 if ($isJustCreated) { 632 $timestamp = $fileRev; 633 $sum = $lang['created'].' - '.$lang['external_edit']; 634 } elseif ($fileRev > $lastRev) { 635 $timestamp = $fileRev; 636 $sum = $lang['external_edit']; 637 } else { 638 // $fileRev is older than $lastRev, that is erroneous/incorrect occurrence. 639 $msg = "Warning: current file modification time is older than last revision date"; 640 $details = 'File revision: '.$fileRev.' '.dformat($fileRev, "%Y-%m-%d %H:%M:%S")."\n" 641 .'Last revision: '.$lastRev.' '.dformat($lastRev, "%Y-%m-%d %H:%M:%S"); 642 Logger::error($msg, $details, $this->getFilename()); 643 $timestamp = false; 644 $sum = $lang['external_edit'].' ('.$lang['unknowndate'].')'; 645 } 646 647 // externally created or edited 648 $revInfo = [ 649 'date' => $timestamp ?: $lastRev +1, 650 'ip' => '127.0.0.1', 651 'type' => $isJustCreated ? DOKU_CHANGE_TYPE_CREATE : DOKU_CHANGE_TYPE_EDIT, 652 'id' => $this->id, 653 'user' => '', 654 'sum' => $sum, 655 'extra' => '', 656 'sizechange' => $sizechange, 657 'timestamp' => $timestamp, 658 ]; 659 } 660 661 // cache current revision information of external edition 662 $this->currentRevision = $revInfo['date']; 663 $this->cache[$this->id][$this->currentRevision] = $revInfo; 664 return $this->getRevisionInfo($this->currentRevision); 665 } 666 667 /** 668 * Mechanism to trace no-actual external current revision 669 * @param int $rev 670 */ 671 public function traceCurrentRevision($rev) 672 { 673 if ($rev > $this->lastRevision()) { 674 $rev = $this->currentRevision(); 675 } 676 return $rev; 677 } 678 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body