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