xref: /dokuwiki/inc/changelog.php (revision 71b40da27911263b852a9c60b0243b04e4802d08)
1<?php
2/**
3 * Changelog handling functions
4 *
5 * @license    GPL 2 (http://www.gnu.org/licenses/gpl.html)
6 * @author     Andreas Gohr <andi@splitbrain.org>
7 */
8
9// Constants for known core changelog line types.
10// Use these in place of string literals for more readable code.
11define('DOKU_CHANGE_TYPE_CREATE',       'C');
12define('DOKU_CHANGE_TYPE_EDIT',         'E');
13define('DOKU_CHANGE_TYPE_MINOR_EDIT',   'e');
14define('DOKU_CHANGE_TYPE_DELETE',       'D');
15define('DOKU_CHANGE_TYPE_REVERT',       'R');
16
17/**
18 * parses a changelog line into it's components
19 *
20 * @author Ben Coburn <btcoburn@silicodon.net>
21 */
22function parseChangelogLine($line) {
23  $tmp = explode("\t", $line);
24    if ($tmp!==false && count($tmp)>1) {
25      $info = array();
26      $info['date']  = (int)$tmp[0]; // unix timestamp
27      $info['ip']    = $tmp[1]; // IPv4 address (127.0.0.1)
28      $info['type']  = $tmp[2]; // log line type
29      $info['id']    = $tmp[3]; // page id
30      $info['user']  = $tmp[4]; // user name
31      $info['sum']   = $tmp[5]; // edit summary (or action reason)
32      $info['extra'] = rtrim($tmp[6], "\n"); // extra data (varies by line type)
33      return $info;
34  } else { return false; }
35}
36
37/**
38 * Add's an entry to the changelog and saves the metadata for the page
39 *
40 * @author Andreas Gohr <andi@splitbrain.org>
41 * @author Esther Brunner <wikidesign@gmail.com>
42 * @author Ben Coburn <btcoburn@silicodon.net>
43 */
44function addLogEntry($date, $id, $type=DOKU_CHANGE_TYPE_EDIT, $summary='', $extra='', $flags=null){
45  global $conf, $INFO;
46
47  // check for special flags as keys
48  if (!is_array($flags)) { $flags = array(); }
49  $flagExternalEdit = isset($flags['ExternalEdit']);
50
51  $id = cleanid($id);
52  $file = wikiFN($id);
53  $created = @filectime($file);
54  $minor = ($type===DOKU_CHANGE_TYPE_MINOR_EDIT);
55  $wasRemoved = ($type===DOKU_CHANGE_TYPE_DELETE);
56
57  if(!$date) $date = time(); //use current time if none supplied
58  $remote = (!$flagExternalEdit)?$_SERVER['REMOTE_ADDR']:'127.0.0.1';
59  $user   = (!$flagExternalEdit)?$_SERVER['REMOTE_USER']:'';
60
61  $strip = array("\t", "\n");
62  $logline = array(
63    'date'  => $date,
64    'ip'    => $remote,
65    'type'  => str_replace($strip, '', $type),
66    'id'    => $id,
67    'user'  => $user,
68    'sum'   => str_replace($strip, '', $summary),
69    'extra' => str_replace($strip, '', $extra)
70  );
71
72  // update metadata
73  if (!$wasRemoved) {
74    $meta = array();
75    if (!$INFO['exists']){ // newly created
76      $meta['date']['created'] = $created;
77      if ($user) $meta['creator'] = $INFO['userinfo']['name'];
78    } elseif (!$minor) {   // non-minor modification
79      $meta['date']['modified'] = $date;
80      if ($user) $meta['contributor'][$user] = $INFO['userinfo']['name'];
81    }
82    $meta['last_change'] = $logline;
83    p_set_metadata($id, $meta, true);
84  }
85
86  // add changelog lines
87  $logline = implode("\t", $logline)."\n";
88  io_saveFile(metaFN($id,'.changes'),$logline,true); //page changelog
89  io_saveFile($conf['changelog'],$logline,true); //global changelog cache
90}
91
92/**
93 * returns an array of recently changed files using the
94 * changelog
95 *
96 * The following constants can be used to control which changes are
97 * included. Add them together as needed.
98 *
99 * RECENTS_SKIP_DELETED   - don't include deleted pages
100 * RECENTS_SKIP_MINORS    - don't include minor changes
101 * RECENTS_SKIP_SUBSPACES - don't include subspaces
102 *
103 * @param int    $first   number of first entry returned (for paginating
104 * @param int    $num     return $num entries
105 * @param string $ns      restrict to given namespace
106 * @param bool   $flags   see above
107 *
108 * @author Ben Coburn <btcoburn@silicodon.net>
109 */
110function getRecents($first,$num,$ns='',$flags=0){
111  global $conf;
112  $recent = array();
113  $count  = 0;
114
115  if(!$num)
116    return $recent;
117
118  // read all recent changes. (kept short)
119  $lines = file($conf['changelog']);
120
121
122  // handle lines
123  for($i = count($lines)-1; $i >= 0; $i--){
124    $rec = _handleRecent($lines[$i], $ns, $flags);
125    if($rec !== false) {
126      if(--$first >= 0) continue; // skip first entries
127      $recent[] = $rec;
128      $count++;
129      // break when we have enough entries
130      if($count >= $num){ break; }
131    }
132  }
133
134  return $recent;
135}
136
137/**
138 * Internal function used by getRecents
139 *
140 * don't call directly
141 *
142 * @see getRecents()
143 * @author Andreas Gohr <andi@splitbrain.org>
144 * @author Ben Coburn <btcoburn@silicodon.net>
145 */
146function _handleRecent($line,$ns,$flags){
147  static $seen  = array();         //caches seen pages and skip them
148  if(empty($line)) return false;   //skip empty lines
149
150  // split the line into parts
151  $recent = parseChangelogLine($line);
152  if ($recent===false) { return false; }
153
154  // skip seen ones
155  if(isset($seen[$recent['id']])) return false;
156
157  // skip minors
158  if($recent['type']===DOKU_CHANGE_TYPE_MINOR_EDIT && ($flags & RECENTS_SKIP_MINORS)) return false;
159
160  // remember in seen to skip additional sights
161  $seen[$recent['id']] = 1;
162
163  // check if it's a hidden page
164  if(isHiddenPage($recent['id'])) return false;
165
166  // filter namespace
167  if (($ns) && (strpos($recent['id'],$ns.':') !== 0)) return false;
168
169  // exclude subnamespaces
170  if (($flags & RECENTS_SKIP_SUBSPACES) && (getNS($recent['id']) != $ns)) return false;
171
172  // check ACL
173  if (auth_quickaclcheck($recent['id']) < AUTH_READ) return false;
174
175  // check existance
176  if((!@file_exists(wikiFN($recent['id']))) && ($flags & RECENTS_SKIP_DELETED)) return false;
177
178  return $recent;
179}
180
181/**
182 * Get the changelog information for a specific page id
183 * and revision (timestamp). Adjacent changelog lines
184 * are optimistically parsed and cached to speed up
185 * consecutive calls to getRevisionInfo. For large
186 * changelog files, only the chunk containing the
187 * requested changelog line is read.
188 *
189 * @author Ben Coburn <btcoburn@silicodon.net>
190 */
191function getRevisionInfo($id, $rev, $chunk_size=8192) {
192  global $cache_revinfo;
193  $cache =& $cache_revinfo;
194  if (!isset($cache[$id])) { $cache[$id] = array(); }
195  $rev = max($rev, 0);
196
197  // check if it's already in the memory cache
198  if (isset($cache[$id]) && isset($cache[$id][$rev])) {
199    return $cache[$id][$rev];
200  }
201
202  $file = metaFN($id, '.changes');
203  if (!@file_exists($file)) { return false; }
204  if (filesize($file)<$chunk_size || $chunk_size==0) {
205    // read whole file
206    $lines = file($file);
207    if ($lines===false) { return false; }
208  } else {
209    // read by chunk
210    $fp = fopen($file, 'rb'); // "file pointer"
211    if ($fp===false) { return false; }
212    $head = 0;
213    fseek($fp, 0, SEEK_END);
214    $tail = ftell($fp);
215    $finger = 0;
216    $finger_rev = 0;
217
218    // find chunk
219    while ($tail-$head>$chunk_size) {
220      $finger = $head+floor(($tail-$head)/2.0);
221      fseek($fp, $finger);
222      fgets($fp); // slip the finger forward to a new line
223      $finger = ftell($fp);
224      $tmp = fgets($fp); // then read at that location
225      $tmp = parseChangelogLine($tmp);
226      $finger_rev = $tmp['date'];
227      if ($finger==$head || $finger==$tail) { break; }
228      if ($finger_rev>$rev) {
229        $tail = $finger;
230      } else {
231        $head = $finger;
232      }
233    }
234
235    if ($tail-$head<1) {
236      // cound not find chunk, assume requested rev is missing
237      fclose($fp);
238      return false;
239    }
240
241    // read chunk
242    $chunk = '';
243    $chunk_size = max($tail-$head, 0); // found chunk size
244    $got = 0;
245    fseek($fp, $head);
246    while ($got<$chunk_size && !feof($fp)) {
247      $tmp = fread($fp, max($chunk_size-$got, 0));
248      if ($tmp===false) { break; } //error state
249      $got += strlen($tmp);
250      $chunk .= $tmp;
251    }
252    $lines = explode("\n", $chunk);
253    array_pop($lines); // remove trailing newline
254    fclose($fp);
255  }
256
257  // parse and cache changelog lines
258  foreach ($lines as $value) {
259    $tmp = parseChangelogLine($value);
260    if ($tmp!==false) {
261      $cache[$id][$tmp['date']] = $tmp;
262    }
263  }
264  if (!isset($cache[$id][$rev])) { return false; }
265  return $cache[$id][$rev];
266}
267
268/**
269 * Return a list of page revisions numbers
270 * Does not guarantee that the revision exists in the attic,
271 * only that a line with the date exists in the changelog.
272 * By default the current revision is skipped.
273 *
274 * id:    the page of interest
275 * first: skip the first n changelog lines
276 * num:   number of revisions to return
277 *
278 * The current revision is automatically skipped when the page exists.
279 * See $INFO['meta']['last_change'] for the current revision.
280 *
281 * For efficiency, the log lines are parsed and cached for later
282 * calls to getRevisionInfo. Large changelog files are read
283 * backwards in chunks untill the requested number of changelog
284 * lines are recieved.
285 *
286 * @author Ben Coburn <btcoburn@silicodon.net>
287 */
288function getRevisions($id, $first, $num, $chunk_size=8192) {
289  global $cache_revinfo;
290  $cache =& $cache_revinfo;
291  if (!isset($cache[$id])) { $cache[$id] = array(); }
292
293  $revs = array();
294  $lines = array();
295  $count  = 0;
296  $file = metaFN($id, '.changes');
297  $num = max($num, 0);
298  $chunk_size = max($chunk_size, 0);
299  if ($first<0) { $first = 0; }
300  else if (@file_exists(wikiFN($id))) {
301     // skip current revision if the page exists
302    $first = max($first+1, 0);
303  }
304
305  if (!@file_exists($file)) { return $revs; }
306  if (filesize($file)<$chunk_size || $chunk_size==0) {
307    // read whole file
308    $lines = file($file);
309    if ($lines===false) { return $revs; }
310  } else {
311    // read chunks backwards
312    $fp = fopen($file, 'rb'); // "file pointer"
313    if ($fp===false) { return $revs; }
314    fseek($fp, 0, SEEK_END);
315    $tail = ftell($fp);
316
317    // chunk backwards
318    $finger = max($tail-$chunk_size, 0);
319    while ($count<$num+$first) {
320      fseek($fp, $finger);
321      if ($finger>0) {
322        fgets($fp); // slip the finger forward to a new line
323        $finger = ftell($fp);
324      }
325
326      // read chunk
327      if ($tail<=$finger) { break; }
328      $chunk = '';
329      $read_size = max($tail-$finger, 0); // found chunk size
330      $got = 0;
331      while ($got<$read_size && !feof($fp)) {
332        $tmp = fread($fp, max($read_size-$got, 0));
333        if ($tmp===false) { break; } //error state
334        $got += strlen($tmp);
335        $chunk .= $tmp;
336      }
337      $tmp = explode("\n", $chunk);
338      array_pop($tmp); // remove trailing newline
339
340      // combine with previous chunk
341      $count += count($tmp);
342      $lines = array_merge($tmp, $lines);
343
344      // next chunk
345      if ($finger==0) { break; } // already read all the lines
346      else {
347        $tail = $finger;
348        $finger = max($tail-$chunk_size, 0);
349      }
350    }
351    fclose($fp);
352  }
353
354  // skip parsing extra lines
355  $num = max(min(count($lines)-$first, $num), 0);
356  if      ($first>0 && $num>0)  { $lines = array_slice($lines, max(count($lines)-$first-$num, 0), $num); }
357  else if ($first>0 && $num==0) { $lines = array_slice($lines, 0, max(count($lines)-$first, 0)); }
358  else if ($first==0 && $num>0) { $lines = array_slice($lines, max(count($lines)-$num, 0)); }
359
360  // handle lines in reverse order
361  for ($i = count($lines)-1; $i >= 0; $i--) {
362    $tmp = parseChangelogLine($lines[$i]);
363    if ($tmp!==false) {
364      $cache[$id][$tmp['date']] = $tmp;
365      $revs[] = $tmp['date'];
366    }
367  }
368
369  return $revs;
370}
371
372
373