xref: /dokuwiki/inc/changelog.php (revision 46e875ce740bbc55abd094481b5bb12298985ef7)
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    $oldmeta = p_read_metadata($id);
75    $meta    = array();
76    if (!$INFO['exists'] && empty($oldmeta['persistent']['date']['created'])){ // newly created
77      $meta['date']['created'] = $created;
78      if ($user) $meta['creator'] = $INFO['userinfo']['name'];
79    } elseif (!$INFO['exists'] && !empty($oldmeta['persistent']['date']['created'])) { // re-created / restored
80      $meta['date']['created']  = $oldmeta['persistent']['date']['created'];
81      $meta['date']['modified'] = $created; // use the files ctime here
82      $meta['creator'] = $oldmeta['persistent']['creator'];
83      if ($user) $meta['contributor'][$user] = $INFO['userinfo']['name'];
84    } elseif (!$minor) {   // non-minor modification
85      $meta['date']['modified'] = $date;
86      if ($user) $meta['contributor'][$user] = $INFO['userinfo']['name'];
87    }
88    $meta['last_change'] = $logline;
89    p_set_metadata($id, $meta, true);
90  }
91
92  // add changelog lines
93  $logline = implode("\t", $logline)."\n";
94  io_saveFile(metaFN($id,'.changes'),$logline,true); //page changelog
95  io_saveFile($conf['changelog'],$logline,true); //global changelog cache
96}
97
98/**
99 * returns an array of recently changed files using the
100 * changelog
101 *
102 * The following constants can be used to control which changes are
103 * included. Add them together as needed.
104 *
105 * RECENTS_SKIP_DELETED   - don't include deleted pages
106 * RECENTS_SKIP_MINORS    - don't include minor changes
107 * RECENTS_SKIP_SUBSPACES - don't include subspaces
108 *
109 * @param int    $first   number of first entry returned (for paginating
110 * @param int    $num     return $num entries
111 * @param string $ns      restrict to given namespace
112 * @param bool   $flags   see above
113 *
114 * @author Ben Coburn <btcoburn@silicodon.net>
115 */
116function getRecents($first,$num,$ns='',$flags=0){
117  global $conf;
118  $recent = array();
119  $count  = 0;
120
121  if(!$num)
122    return $recent;
123
124  // read all recent changes. (kept short)
125  $lines = @file($conf['changelog']);
126
127
128  // handle lines
129  for($i = count($lines)-1; $i >= 0; $i--){
130    $rec = _handleRecent($lines[$i], $ns, $flags);
131    if($rec !== false) {
132      if(--$first >= 0) continue; // skip first entries
133      $recent[] = $rec;
134      $count++;
135      // break when we have enough entries
136      if($count >= $num){ break; }
137    }
138  }
139
140  return $recent;
141}
142
143/**
144 * Internal function used by getRecents
145 *
146 * don't call directly
147 *
148 * @see getRecents()
149 * @author Andreas Gohr <andi@splitbrain.org>
150 * @author Ben Coburn <btcoburn@silicodon.net>
151 */
152function _handleRecent($line,$ns,$flags){
153  static $seen  = array();         //caches seen pages and skip them
154  if(empty($line)) return false;   //skip empty lines
155
156  // split the line into parts
157  $recent = parseChangelogLine($line);
158  if ($recent===false) { return false; }
159
160  // skip seen ones
161  if(isset($seen[$recent['id']])) return false;
162
163  // skip minors
164  if($recent['type']===DOKU_CHANGE_TYPE_MINOR_EDIT && ($flags & RECENTS_SKIP_MINORS)) return false;
165
166  // remember in seen to skip additional sights
167  $seen[$recent['id']] = 1;
168
169  // check if it's a hidden page
170  if(isHiddenPage($recent['id'])) return false;
171
172  // filter namespace
173  if (($ns) && (strpos($recent['id'],$ns.':') !== 0)) return false;
174
175  // exclude subnamespaces
176  if (($flags & RECENTS_SKIP_SUBSPACES) && (getNS($recent['id']) != $ns)) return false;
177
178  // check ACL
179  if (auth_quickaclcheck($recent['id']) < AUTH_READ) return false;
180
181  // check existance
182  if((!@file_exists(wikiFN($recent['id']))) && ($flags & RECENTS_SKIP_DELETED)) return false;
183
184  return $recent;
185}
186
187/**
188 * Get the changelog information for a specific page id
189 * and revision (timestamp). Adjacent changelog lines
190 * are optimistically parsed and cached to speed up
191 * consecutive calls to getRevisionInfo. For large
192 * changelog files, only the chunk containing the
193 * requested changelog line is read.
194 *
195 * @author Ben Coburn <btcoburn@silicodon.net>
196 */
197function getRevisionInfo($id, $rev, $chunk_size=8192) {
198  global $cache_revinfo;
199  $cache =& $cache_revinfo;
200  if (!isset($cache[$id])) { $cache[$id] = array(); }
201  $rev = max($rev, 0);
202
203  // check if it's already in the memory cache
204  if (isset($cache[$id]) && isset($cache[$id][$rev])) {
205    return $cache[$id][$rev];
206  }
207
208  $file = metaFN($id, '.changes');
209  if (!@file_exists($file)) { return false; }
210  if (filesize($file)<$chunk_size || $chunk_size==0) {
211    // read whole file
212    $lines = file($file);
213    if ($lines===false) { return false; }
214  } else {
215    // read by chunk
216    $fp = fopen($file, 'rb'); // "file pointer"
217    if ($fp===false) { return false; }
218    $head = 0;
219    fseek($fp, 0, SEEK_END);
220    $tail = ftell($fp);
221    $finger = 0;
222    $finger_rev = 0;
223
224    // find chunk
225    while ($tail-$head>$chunk_size) {
226      $finger = $head+floor(($tail-$head)/2.0);
227      fseek($fp, $finger);
228      fgets($fp); // slip the finger forward to a new line
229      $finger = ftell($fp);
230      $tmp = fgets($fp); // then read at that location
231      $tmp = parseChangelogLine($tmp);
232      $finger_rev = $tmp['date'];
233      if ($finger==$head || $finger==$tail) { break; }
234      if ($finger_rev>$rev) {
235        $tail = $finger;
236      } else {
237        $head = $finger;
238      }
239    }
240
241    if ($tail-$head<1) {
242      // cound not find chunk, assume requested rev is missing
243      fclose($fp);
244      return false;
245    }
246
247    // read chunk
248    $chunk = '';
249    $chunk_size = max($tail-$head, 0); // found chunk size
250    $got = 0;
251    fseek($fp, $head);
252    while ($got<$chunk_size && !feof($fp)) {
253      $tmp = fread($fp, max($chunk_size-$got, 0));
254      if ($tmp===false) { break; } //error state
255      $got += strlen($tmp);
256      $chunk .= $tmp;
257    }
258    $lines = explode("\n", $chunk);
259    array_pop($lines); // remove trailing newline
260    fclose($fp);
261  }
262
263  // parse and cache changelog lines
264  foreach ($lines as $value) {
265    $tmp = parseChangelogLine($value);
266    if ($tmp!==false) {
267      $cache[$id][$tmp['date']] = $tmp;
268    }
269  }
270  if (!isset($cache[$id][$rev])) { return false; }
271  return $cache[$id][$rev];
272}
273
274/**
275 * Return a list of page revisions numbers
276 * Does not guarantee that the revision exists in the attic,
277 * only that a line with the date exists in the changelog.
278 * By default the current revision is skipped.
279 *
280 * id:    the page of interest
281 * first: skip the first n changelog lines
282 * num:   number of revisions to return
283 *
284 * The current revision is automatically skipped when the page exists.
285 * See $INFO['meta']['last_change'] for the current revision.
286 *
287 * For efficiency, the log lines are parsed and cached for later
288 * calls to getRevisionInfo. Large changelog files are read
289 * backwards in chunks untill the requested number of changelog
290 * lines are recieved.
291 *
292 * @author Ben Coburn <btcoburn@silicodon.net>
293 */
294function getRevisions($id, $first, $num, $chunk_size=8192) {
295  global $cache_revinfo;
296  $cache =& $cache_revinfo;
297  if (!isset($cache[$id])) { $cache[$id] = array(); }
298
299  $revs = array();
300  $lines = array();
301  $count  = 0;
302  $file = metaFN($id, '.changes');
303  $num = max($num, 0);
304  $chunk_size = max($chunk_size, 0);
305  if ($first<0) { $first = 0; }
306  else if (@file_exists(wikiFN($id))) {
307     // skip current revision if the page exists
308    $first = max($first+1, 0);
309  }
310
311  if (!@file_exists($file)) { return $revs; }
312  if (filesize($file)<$chunk_size || $chunk_size==0) {
313    // read whole file
314    $lines = file($file);
315    if ($lines===false) { return $revs; }
316  } else {
317    // read chunks backwards
318    $fp = fopen($file, 'rb'); // "file pointer"
319    if ($fp===false) { return $revs; }
320    fseek($fp, 0, SEEK_END);
321    $tail = ftell($fp);
322
323    // chunk backwards
324    $finger = max($tail-$chunk_size, 0);
325    while ($count<$num+$first) {
326      fseek($fp, $finger);
327      if ($finger>0) {
328        fgets($fp); // slip the finger forward to a new line
329        $finger = ftell($fp);
330      }
331
332      // read chunk
333      if ($tail<=$finger) { break; }
334      $chunk = '';
335      $read_size = max($tail-$finger, 0); // found chunk size
336      $got = 0;
337      while ($got<$read_size && !feof($fp)) {
338        $tmp = fread($fp, max($read_size-$got, 0));
339        if ($tmp===false) { break; } //error state
340        $got += strlen($tmp);
341        $chunk .= $tmp;
342      }
343      $tmp = explode("\n", $chunk);
344      array_pop($tmp); // remove trailing newline
345
346      // combine with previous chunk
347      $count += count($tmp);
348      $lines = array_merge($tmp, $lines);
349
350      // next chunk
351      if ($finger==0) { break; } // already read all the lines
352      else {
353        $tail = $finger;
354        $finger = max($tail-$chunk_size, 0);
355      }
356    }
357    fclose($fp);
358  }
359
360  // skip parsing extra lines
361  $num = max(min(count($lines)-$first, $num), 0);
362  if      ($first>0 && $num>0)  { $lines = array_slice($lines, max(count($lines)-$first-$num, 0), $num); }
363  else if ($first>0 && $num==0) { $lines = array_slice($lines, 0, max(count($lines)-$first, 0)); }
364  else if ($first==0 && $num>0) { $lines = array_slice($lines, max(count($lines)-$num, 0)); }
365
366  // handle lines in reverse order
367  for ($i = count($lines)-1; $i >= 0; $i--) {
368    $tmp = parseChangelogLine($lines[$i]);
369    if ($tmp!==false) {
370      $cache[$id][$tmp['date']] = $tmp;
371      $revs[] = $tmp['date'];
372    }
373  }
374
375  return $revs;
376}
377
378
379