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