1<?php
2
3namespace dokuwiki\ChangeLog;
4
5use dokuwiki\Utf8\PhpString;
6
7/**
8 * Provides methods for handling of changelog
9 */
10trait ChangeLogTrait
11{
12    /**
13     * Adds an entry to the changelog file
14     *
15     * @return array added logline as revision info
16     */
17    abstract public function addLogEntry(array $info, $timestamp = null);
18
19    /**
20     * Parses a changelog line into it's components
21     *
22     * @author Ben Coburn <btcoburn@silicodon.net>
23     *
24     * @param string $line changelog line
25     * @return array|bool parsed line or false
26     */
27    public static function parseLogLine($line)
28    {
29        $info = explode("\t", rtrim($line, "\n"));
30        if ($info !== false && count($info) > 1) {
31            return [
32                'date'  => (int)$info[0], // unix timestamp
33                'ip'    => $info[1], // IPv4 address (127.0.0.1)
34                'type'  => $info[2], // log line type
35                'id'    => $info[3], // page id
36                'user'  => $info[4], // user name
37                'sum'   => $info[5], // edit summary (or action reason)
38                'extra' => $info[6], // extra data (varies by line type)
39                'sizechange' => (isset($info[7]) && $info[7] !== '') ? (int)$info[7] : null, //
40            ];
41        } else {
42            return false;
43        }
44    }
45
46    /**
47     * Build a changelog line from it's components
48     *
49     * @param array $info Revision info structure
50     * @param int $timestamp logline date (optional)
51     * @return string changelog line
52     */
53    public static function buildLogLine(array &$info, $timestamp = null)
54    {
55        $strip = ["\t", "\n"];
56        $entry = array(
57            'date'  => $timestamp ?? $info['date'],
58            'ip'    => $info['ip'],
59            'type'  => str_replace($strip, '', $info['type']),
60            'id'    => $info['id'],
61            'user'  => $info['user'],
62            'sum'   => PhpString::substr(str_replace($strip, '', $info['sum']), 0, 255),
63            'extra' => str_replace($strip, '', $info['extra']),
64            'sizechange' => $info['sizechange'],
65        );
66        $info = $entry;
67        return implode("\t", $entry) ."\n";
68    }
69
70    /**
71     * Returns path to changelog
72     *
73     * @return string path to file
74     */
75    abstract protected function getChangelogFilename();
76
77    /**
78     * Checks if the ID has old revisons
79     * @return boolean
80     */
81    public function hasRevisions()
82    {
83        $logfile = $this->getChangelogFilename();
84        return file_exists($logfile);
85    }
86
87
88    /** @var int */
89    protected $chunk_size;
90
91    /**
92     * Set chunk size for file reading
93     * Chunk size zero let read whole file at once
94     *
95     * @param int $chunk_size maximum block size read from file
96     */
97    public function setChunkSize($chunk_size)
98    {
99        if (!is_numeric($chunk_size)) $chunk_size = 0;
100
101        $this->chunk_size = (int)max($chunk_size, 0);
102    }
103
104    /**
105     * Returns lines from changelog.
106     * If file larger than $chuncksize, only chunck is read that could contain $rev.
107     *
108     * When reference timestamp $rev is outside time range of changelog, readloglines() will return
109     * lines in first or last chunk, but they obviously does not contain $rev.
110     *
111     * @param int $rev revision timestamp
112     * @return array|false
113     *     if success returns array(fp, array(changeloglines), $head, $tail, $eof)
114     *     where fp only defined for chuck reading, needs closing.
115     *     otherwise false
116     */
117    protected function readloglines($rev)
118    {
119        $file = $this->getChangelogFilename();
120
121        if (!file_exists($file)) {
122            return false;
123        }
124
125        $fp = null;
126        $head = 0;
127        $tail = 0;
128        $eof = 0;
129
130        if (filesize($file) < $this->chunk_size || $this->chunk_size == 0) {
131            // read whole file
132            $lines = file($file);
133            if ($lines === false) {
134                return false;
135            }
136        } else {
137            // read by chunk
138            $fp = fopen($file, 'rb'); // "file pointer"
139            if ($fp === false) {
140                return false;
141            }
142            fseek($fp, 0, SEEK_END);
143            $eof = ftell($fp);
144            $tail = $eof;
145
146            // find chunk
147            while ($tail - $head > $this->chunk_size) {
148                $finger = $head + intval(($tail - $head) / 2);
149                $finger = $this->getNewlinepointer($fp, $finger);
150                $tmp = fgets($fp);
151                if ($finger == $head || $finger == $tail) {
152                    break;
153                }
154                $info = $this->parseLogLine($tmp);
155                $finger_rev = $info['date'];
156
157                if ($finger_rev > $rev) {
158                    $tail = $finger;
159                } else {
160                    $head = $finger;
161                }
162            }
163
164            if ($tail - $head < 1) {
165                // cound not find chunk, assume requested rev is missing
166                fclose($fp);
167                return false;
168            }
169
170            $lines = $this->readChunk($fp, $head, $tail);
171        }
172        return array(
173            $fp,
174            $lines,
175            $head,
176            $tail,
177            $eof,
178        );
179    }
180
181    /**
182     * Read chunk and return array with lines of given chunck.
183     * Has no check if $head and $tail are really at a new line
184     *
185     * @param resource $fp resource filepointer
186     * @param int $head start point chunck
187     * @param int $tail end point chunck
188     * @return array lines read from chunck
189     */
190    protected function readChunk($fp, $head, $tail)
191    {
192        $chunk = '';
193        $chunk_size = max($tail - $head, 0); // found chunk size
194        $got = 0;
195        fseek($fp, $head);
196        while ($got < $chunk_size && !feof($fp)) {
197            $tmp = @fread($fp, max(min($this->chunk_size, $chunk_size - $got), 0));
198            if ($tmp === false) { //error state
199                break;
200            }
201            $got += strlen($tmp);
202            $chunk .= $tmp;
203        }
204        $lines = explode("\n", $chunk);
205        array_pop($lines); // remove trailing newline
206        return $lines;
207    }
208
209    /**
210     * Set pointer to first new line after $finger and return its position
211     *
212     * @param resource $fp filepointer
213     * @param int $finger a pointer
214     * @return int pointer
215     */
216    protected function getNewlinepointer($fp, $finger)
217    {
218        fseek($fp, $finger);
219        $nl = $finger;
220        if ($finger > 0) {
221            fgets($fp); // slip the finger forward to a new line
222            $nl = ftell($fp);
223        }
224        return $nl;
225    }
226
227    /**
228     * Returns the next lines of the changelog  of the chunck before head or after tail
229     *
230     * @param resource $fp filepointer
231     * @param int $head position head of last chunk
232     * @param int $tail position tail of last chunk
233     * @param int $direction positive forward, negative backward
234     * @return array with entries:
235     *    - $lines: changelog lines of readed chunk
236     *    - $head: head of chunk
237     *    - $tail: tail of chunk
238     */
239    protected function readAdjacentChunk($fp, $head, $tail, $direction)
240    {
241        if (!$fp) return array(array(), $head, $tail);
242
243        if ($direction > 0) {
244            //read forward
245            $head = $tail;
246            $tail = $head + intval($this->chunk_size * (2 / 3));
247            $tail = $this->getNewlinepointer($fp, $tail);
248        } else {
249            //read backward
250            $tail = $head;
251            $head = max($tail - $this->chunk_size, 0);
252            while (true) {
253                $nl = $this->getNewlinepointer($fp, $head);
254                // was the chunk big enough? if not, take another bite
255                if ($nl > 0 && $tail <= $nl) {
256                    $head = max($head - $this->chunk_size, 0);
257                } else {
258                    $head = $nl;
259                    break;
260                }
261            }
262        }
263
264        //load next chunck
265        $lines = $this->readChunk($fp, $head, $tail);
266        return array($lines, $head, $tail);
267    }
268
269}
270