xref: /dokuwiki/inc/ChangeLog/ChangeLog.php (revision bd17ac908ab575f23e41c5fe73ab3f719c54b605)
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