xref: /dokuwiki/inc/changelog.php (revision 91459163e4ff1c28a910340507960898f4d8e126)
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)?clientIP(true):'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 * Add's an entry to the media changelog
100 *
101 * @author Michael Hamann <michael@content-space.de>
102 * @author Andreas Gohr <andi@splitbrain.org>
103 * @author Esther Brunner <wikidesign@gmail.com>
104 * @author Ben Coburn <btcoburn@silicodon.net>
105 */
106function addMediaLogEntry($date, $id, $type=DOKU_CHANGE_TYPE_EDIT, $summary='', $extra='', $flags=null){
107  global $conf, $INFO;
108
109  $id = cleanid($id);
110
111  if(!$date) $date = time(); //use current time if none supplied
112  $remote = clientIP(true);
113  $user   = $_SERVER['REMOTE_USER'];
114
115  $strip = array("\t", "\n");
116  $logline = array(
117    'date'  => $date,
118    'ip'    => $remote,
119    'type'  => str_replace($strip, '', $type),
120    'id'    => $id,
121    'user'  => $user,
122    'sum'   => str_replace($strip, '', $summary),
123    'extra' => str_replace($strip, '', $extra)
124  );
125
126  // add changelog lines
127  $logline = implode("\t", $logline)."\n";
128  io_saveFile($conf['media_changelog'],$logline,true); //global media changelog cache
129}
130
131/**
132 * returns an array of recently changed files using the
133 * changelog
134 *
135 * The following constants can be used to control which changes are
136 * included. Add them together as needed.
137 *
138 * RECENTS_SKIP_DELETED   - don't include deleted pages
139 * RECENTS_SKIP_MINORS    - don't include minor changes
140 * RECENTS_SKIP_SUBSPACES - don't include subspaces
141 * RECENTS_MEDIA_CHANGES  - return media changes instead of page changes
142 *
143 * @param int    $first   number of first entry returned (for paginating
144 * @param int    $num     return $num entries
145 * @param string $ns      restrict to given namespace
146 * @param bool   $flags   see above
147 *
148 * @author Ben Coburn <btcoburn@silicodon.net>
149 */
150function getRecents($first,$num,$ns='',$flags=0){
151  global $conf;
152  $recent = array();
153  $count  = 0;
154
155  if(!$num)
156    return $recent;
157
158  // read all recent changes. (kept short)
159  if ($flags & RECENTS_MEDIA_CHANGES) {
160    $lines = @file($conf['media_changelog']);
161  } else {
162    $lines = @file($conf['changelog']);
163  }
164
165
166  // handle lines
167  $seen = array(); // caches seen lines, _handleRecent() skips them
168  for($i = count($lines)-1; $i >= 0; $i--){
169    $rec = _handleRecent($lines[$i], $ns, $flags, $seen);
170    if($rec !== false) {
171      if(--$first >= 0) continue; // skip first entries
172      $recent[] = $rec;
173      $count++;
174      // break when we have enough entries
175      if($count >= $num){ break; }
176    }
177  }
178
179  return $recent;
180}
181
182/**
183 * returns an array of files changed since a given time using the
184 * changelog
185 *
186 * The following constants can be used to control which changes are
187 * included. Add them together as needed.
188 *
189 * RECENTS_SKIP_DELETED   - don't include deleted pages
190 * RECENTS_SKIP_MINORS    - don't include minor changes
191 * RECENTS_SKIP_SUBSPACES - don't include subspaces
192 * RECENTS_MEDIA_CHANGES  - return media changes instead of page changes
193 *
194 * @param int    $from    date of the oldest entry to return
195 * @param int    $to      date of the newest entry to return (for pagination, optional)
196 * @param string $ns      restrict to given namespace (optional)
197 * @param bool   $flags   see above (optional)
198 *
199 * @author Michael Hamann <michael@content-space.de>
200 * @author Ben Coburn <btcoburn@silicodon.net>
201 */
202function getRecentsSince($from,$to=null,$ns='',$flags=0){
203  global $conf;
204  $recent = array();
205
206  if($to && $to < $from)
207    return $recent;
208
209  // read all recent changes. (kept short)
210  if ($flags & RECENTS_MEDIA_CHANGES) {
211    $lines = @file($conf['media_changelog']);
212  } else {
213    $lines = @file($conf['changelog']);
214  }
215
216  // we start searching at the end of the list
217  $lines = array_reverse($lines);
218
219  // handle lines
220  $seen = array(); // caches seen lines, _handleRecent() skips them
221
222  foreach($lines as $line){
223    $rec = _handleRecent($line, $ns, $flags, $seen);
224    if($rec !== false) {
225      if ($rec['date'] >= $from) {
226        if (!$to || $rec['date'] <= $to) {
227          $recent[] = $rec;
228        }
229      } else {
230        break;
231      }
232    }
233  }
234
235  return array_reverse($recent);
236}
237
238/**
239 * Internal function used by getRecents
240 *
241 * don't call directly
242 *
243 * @see getRecents()
244 * @author Andreas Gohr <andi@splitbrain.org>
245 * @author Ben Coburn <btcoburn@silicodon.net>
246 */
247function _handleRecent($line,$ns,$flags,&$seen){
248  if(empty($line)) return false;   //skip empty lines
249
250  // split the line into parts
251  $recent = parseChangelogLine($line);
252  if ($recent===false) { return false; }
253
254  // skip seen ones
255  if(isset($seen[$recent['id']])) return false;
256
257  // skip minors
258  if($recent['type']===DOKU_CHANGE_TYPE_MINOR_EDIT && ($flags & RECENTS_SKIP_MINORS)) return false;
259
260  // remember in seen to skip additional sights
261  $seen[$recent['id']] = 1;
262
263  // check if it's a hidden page
264  if(isHiddenPage($recent['id'])) return false;
265
266  // filter namespace
267  if (($ns) && (strpos($recent['id'],$ns.':') !== 0)) return false;
268
269  // exclude subnamespaces
270  if (($flags & RECENTS_SKIP_SUBSPACES) && (getNS($recent['id']) != $ns)) return false;
271
272  // check ACL
273  $recent['perms'] = auth_quickaclcheck($recent['id']);
274  if ($recent['perms'] < AUTH_READ) return false;
275
276  // check existance
277  $fn = (($flags & RECENTS_MEDIA_CHANGES) ? mediaFN($recent['id']) : wikiFN($recent['id']));
278  if((!@file_exists($fn)) && ($flags & RECENTS_SKIP_DELETED)) return false;
279
280  return $recent;
281}
282
283/**
284 * Get the changelog information for a specific page id
285 * and revision (timestamp). Adjacent changelog lines
286 * are optimistically parsed and cached to speed up
287 * consecutive calls to getRevisionInfo. For large
288 * changelog files, only the chunk containing the
289 * requested changelog line is read.
290 *
291 * @author Ben Coburn <btcoburn@silicodon.net>
292 */
293function getRevisionInfo($id, $rev, $chunk_size=8192) {
294  global $cache_revinfo;
295  $cache =& $cache_revinfo;
296  if (!isset($cache[$id])) { $cache[$id] = array(); }
297  $rev = max($rev, 0);
298
299  // check if it's already in the memory cache
300  if (isset($cache[$id]) && isset($cache[$id][$rev])) {
301    return $cache[$id][$rev];
302  }
303
304  $file = metaFN($id, '.changes');
305  if (!@file_exists($file)) { return false; }
306  if (filesize($file)<$chunk_size || $chunk_size==0) {
307    // read whole file
308    $lines = file($file);
309    if ($lines===false) { return false; }
310  } else {
311    // read by chunk
312    $fp = fopen($file, 'rb'); // "file pointer"
313    if ($fp===false) { return false; }
314    $head = 0;
315    fseek($fp, 0, SEEK_END);
316    $tail = ftell($fp);
317    $finger = 0;
318    $finger_rev = 0;
319
320    // find chunk
321    while ($tail-$head>$chunk_size) {
322      $finger = $head+floor(($tail-$head)/2.0);
323      fseek($fp, $finger);
324      fgets($fp); // slip the finger forward to a new line
325      $finger = ftell($fp);
326      $tmp = fgets($fp); // then read at that location
327      $tmp = parseChangelogLine($tmp);
328      $finger_rev = $tmp['date'];
329      if ($finger==$head || $finger==$tail) { break; }
330      if ($finger_rev>$rev) {
331        $tail = $finger;
332      } else {
333        $head = $finger;
334      }
335    }
336
337    if ($tail-$head<1) {
338      // cound not find chunk, assume requested rev is missing
339      fclose($fp);
340      return false;
341    }
342
343    // read chunk
344    $chunk = '';
345    $chunk_size = max($tail-$head, 0); // found chunk size
346    $got = 0;
347    fseek($fp, $head);
348    while ($got<$chunk_size && !feof($fp)) {
349      $tmp = @fread($fp, max($chunk_size-$got, 0));
350      if ($tmp===false) { break; } //error state
351      $got += strlen($tmp);
352      $chunk .= $tmp;
353    }
354    $lines = explode("\n", $chunk);
355    array_pop($lines); // remove trailing newline
356    fclose($fp);
357  }
358
359  // parse and cache changelog lines
360  foreach ($lines as $value) {
361    $tmp = parseChangelogLine($value);
362    if ($tmp!==false) {
363      $cache[$id][$tmp['date']] = $tmp;
364    }
365  }
366  if (!isset($cache[$id][$rev])) { return false; }
367  return $cache[$id][$rev];
368}
369
370/**
371 * Return a list of page revisions numbers
372 * Does not guarantee that the revision exists in the attic,
373 * only that a line with the date exists in the changelog.
374 * By default the current revision is skipped.
375 *
376 * id:    the page of interest
377 * first: skip the first n changelog lines
378 * num:   number of revisions to return
379 *
380 * The current revision is automatically skipped when the page exists.
381 * See $INFO['meta']['last_change'] for the current revision.
382 *
383 * For efficiency, the log lines are parsed and cached for later
384 * calls to getRevisionInfo. Large changelog files are read
385 * backwards in chunks untill the requested number of changelog
386 * lines are recieved.
387 *
388 * @author Ben Coburn <btcoburn@silicodon.net>
389 */
390function getRevisions($id, $first, $num, $chunk_size=8192) {
391  global $cache_revinfo;
392  $cache =& $cache_revinfo;
393  if (!isset($cache[$id])) { $cache[$id] = array(); }
394
395  $revs = array();
396  $lines = array();
397  $count  = 0;
398  $file = metaFN($id, '.changes');
399  $num = max($num, 0);
400  $chunk_size = max($chunk_size, 0);
401  if ($first<0) { $first = 0; }
402  else if (@file_exists(wikiFN($id))) {
403     // skip current revision if the page exists
404    $first = max($first+1, 0);
405  }
406
407  if (!@file_exists($file)) { return $revs; }
408  if (filesize($file)<$chunk_size || $chunk_size==0) {
409    // read whole file
410    $lines = file($file);
411    if ($lines===false) { return $revs; }
412  } else {
413    // read chunks backwards
414    $fp = fopen($file, 'rb'); // "file pointer"
415    if ($fp===false) { return $revs; }
416    fseek($fp, 0, SEEK_END);
417    $tail = ftell($fp);
418
419    // chunk backwards
420    $finger = max($tail-$chunk_size, 0);
421    while ($count<$num+$first) {
422      fseek($fp, $finger);
423      if ($finger>0) {
424        fgets($fp); // slip the finger forward to a new line
425        $finger = ftell($fp);
426      }
427
428      // read chunk
429      if ($tail<=$finger) { break; }
430      $chunk = '';
431      $read_size = max($tail-$finger, 0); // found chunk size
432      $got = 0;
433      while ($got<$read_size && !feof($fp)) {
434        $tmp = @fread($fp, max($read_size-$got, 0));
435        if ($tmp===false) { break; } //error state
436        $got += strlen($tmp);
437        $chunk .= $tmp;
438      }
439      $tmp = explode("\n", $chunk);
440      array_pop($tmp); // remove trailing newline
441
442      // combine with previous chunk
443      $count += count($tmp);
444      $lines = array_merge($tmp, $lines);
445
446      // next chunk
447      if ($finger==0) { break; } // already read all the lines
448      else {
449        $tail = $finger;
450        $finger = max($tail-$chunk_size, 0);
451      }
452    }
453    fclose($fp);
454  }
455
456  // skip parsing extra lines
457  $num = max(min(count($lines)-$first, $num), 0);
458  if      ($first>0 && $num>0)  { $lines = array_slice($lines, max(count($lines)-$first-$num, 0), $num); }
459  else if ($first>0 && $num==0) { $lines = array_slice($lines, 0, max(count($lines)-$first, 0)); }
460  else if ($first==0 && $num>0) { $lines = array_slice($lines, max(count($lines)-$num, 0)); }
461
462  // handle lines in reverse order
463  for ($i = count($lines)-1; $i >= 0; $i--) {
464    $tmp = parseChangelogLine($lines[$i]);
465    if ($tmp!==false) {
466      $cache[$id][$tmp['date']] = $tmp;
467      $revs[] = $tmp['date'];
468    }
469  }
470
471  return $revs;
472}
473
474
475