1<?php
2/**
3 * FarmSync DokuWiki Plugin
4 *
5 * @author Michael Große <grosse@cosmocode.de>
6 * @license GPL 2
7 */
8
9namespace dokuwiki\plugin\farmsync\meta;
10
11/**
12 * Utility methods for accessing animal and farmer data
13 */
14class FarmSyncUtil {
15
16    /** @var  \helper_plugin_farmer */
17    protected $farmer;
18
19    private $originalConfDirs;
20
21    /**
22     * FarmSyncUtil constructor.
23     */
24    function __construct() {
25        $this->farmer = plugin_load('helper', 'farmer');
26    }
27
28    function __destruct() {
29        if (!empty($this->originalConfDirs)) {
30            throw new \Exception('Animal context has not been reset!');
31        }
32    }
33
34    /**
35     * A list of available animals as provided by the Farmer plugin
36     *
37     * @return array
38     */
39    public function getAllAnimals() {
40        return $this->farmer->getAllAnimals();
41    }
42
43    /**
44     * Constructs the path to the data directory of a given animal
45     *
46     * @param string $animal
47     * @return string
48     */
49    public function getAnimalDataDir($animal) {
50        return $this->getAnimalDir($animal) . 'data/';
51    }
52
53    public function getAnimalDir($animal) {
54        return DOKU_FARMDIR . $animal . '/';
55    }
56
57    public function getAnimalLink($animal) {
58        return $this->farmer->getAnimalURL($animal);
59    }
60
61    public function clearAnimalCache($animal) {
62        $animalDir = $this->getAnimalDir($animal);
63        touch($animalDir.'conf/local.php');
64    }
65
66    private function setAnimalContext($animal) {
67        if (!empty($this->originalConfDirs)) {
68            throw new \Exception('Animal context has not been reset!');
69        }
70        global $conf;
71        $this->originalConfDirs = array();
72        $animaldir = $this->getAnimalDataDir($animal);
73        $this->originalConfDirs['mediaolddir'] = $conf['mediaolddir'];
74        $this->originalConfDirs['mediadir'] = $conf['mediadir'];
75        $this->originalConfDirs['datadir'] = $conf['datadir'];
76        $this->originalConfDirs['olddir'] = $conf['olddir'];
77        $this->originalConfDirs['metadir'] = $conf['metadir'];
78        $conf['mediaolddir'] = $animaldir . 'media_attic';
79        $conf['mediadir'] = $animaldir . 'media';
80        $conf['datadir'] = $animaldir . 'pages';
81        $conf['olddir'] = $animaldir . 'attic';
82        $conf['metadir'] = $animaldir . 'meta';
83    }
84
85    private function resetContext() {
86        global $conf;
87        if (empty($this->originalConfDirs)) {
88            throw new \Exception('No animal context set!');
89        }
90        $conf['mediaolddir'] = $this->originalConfDirs['mediaolddir'];
91        $conf['mediadir'] = $this->originalConfDirs['mediadir'];
92        $conf['datadir'] = $this->originalConfDirs['datadir'];
93        $conf['olddir'] = $this->originalConfDirs['olddir'];
94        $conf['metadir'] = $this->originalConfDirs['metadir'];
95        unset($this->originalConfDirs);
96    }
97
98    /**
99     * Saves a file with the given content and set its latmodified date if given
100     *
101     * @param string $remoteFile
102     * @param string $content
103     * @param int $timestamp
104     */
105    public function replaceRemoteFile($remoteFile, $content, $timestamp = 0) {
106        io_saveFile($remoteFile, $content);
107        if ($timestamp) touch($remoteFile, $timestamp);
108    }
109
110    /**
111     * Saves a page in the given animal and updates the timestamp if given
112     *
113     * @param string $animal
114     * @param string $page
115     * @param string $content
116     * @param int $timestamp
117     */
118    public function saveRemotePage($animal, $page, $content, $timestamp = 0) {
119        global $INPUT, $conf;
120        $this->checkForExternalEdit($animal, $page);
121        if (!$timestamp) $timestamp = time();
122        $changelogLine = join("\t", array($timestamp, clientIP(true), DOKU_CHANGE_TYPE_EDIT, $page, $INPUT->server->str('REMOTE_USER'), "Page updated from $conf[title] (" . DOKU_URL . ")"));
123        $this->addRemotePageChangelogRevision($animal, $page, $changelogLine);
124        $this->replaceRemoteFile($this->getRemoteFilename($animal, $page), $content, $timestamp);
125        $this->replaceRemoteFile($this->getRemoteFilename($animal, $page, $timestamp), $content);
126        // FIXME: update .meta ?
127    }
128
129    /**
130     * Saves the given local media file to the specified animal
131     *
132     * @param string $source
133     * @param string $target
134     * @param string $media a valid local MediaID
135     */
136    public function saveRemoteMedia($source, $target, $media) {
137        global $INPUT, $conf;
138        $timestamp = $this->getRemoteFilemtime($source, $media, true);
139        $changelogLine = join("\t", array($timestamp, clientIP(true), DOKU_CHANGE_TYPE_EDIT, $media, $INPUT->server->str('REMOTE_USER'), "Media updated from $conf[title] (" . DOKU_URL . ")"));
140        $this->addRemoteMediaChangelogRevision($target, $media, $changelogLine);
141        $sourceContent = $this->readRemoteMedia($source, $media);
142        $this->replaceRemoteFile($this->getRemoteMediaFilename($target, $media), $sourceContent, $timestamp);
143        $this->replaceRemoteFile($this->getRemoteMediaFilename($target, $media, $timestamp), $sourceContent, $timestamp);
144    }
145
146    /**
147     * Read the contents of a page in an animal
148     *
149     * @param string $animal
150     * @param string $page a page ID
151     * @param bool $clean does the pageID need cleaning?
152     * @return string
153     */
154    public function readRemotePage($animal, $page, $clean = true, $timestamp = null) {
155        return io_readFile($this->getRemoteFilename($animal, $page, $timestamp, $clean));
156    }
157
158    /**
159     * Read the contents of a media item in an animal
160     *
161     * @param string $animal
162     * @param string $media mediaID
163     * @param int $timestamp revision
164     * @return string
165     */
166    public function readRemoteMedia($animal, $media, $timestamp = 0) {
167        return io_readFile($this->getRemoteMediaFilename($animal, $media, $timestamp), false);
168    }
169
170    /**
171     * Get the path to a media item in an animal
172     *
173     * @param string $animal
174     * @param string $media
175     * @param int $timestamp
176     * @return string
177     */
178    public function getRemoteMediaFilename($animal, $media, $timestamp = 0, $clean = true) {
179        global $conf;
180        $animaldir = $this->getAnimalDataDir($animal);
181        $source_mediaolddir = $conf['mediaolddir'];
182        $conf['mediaolddir'] = $animaldir . 'media_attic';
183        $source_mediadir = $conf['mediadir'];
184        $conf['mediadir'] = $animaldir . 'media';
185
186        $mediaFN = mediaFN($media, $timestamp, $clean);
187
188        $conf['mediaolddir'] = $source_mediaolddir;
189        $conf['mediadir'] = $source_mediadir;
190
191        return $mediaFN;
192    }
193
194    /**
195     * Get the filename of a page at an animal
196     *
197     * @param string $animal the animal
198     * @param string $document the full pageid
199     * @param string|null $timestamp set to get a version in the attic
200     * @param bool $clean Should the pageid be cleaned?
201     * @return string The path to the page at the animal
202     */
203    public function getRemoteFilename($animal, $document, $timestamp = null, $clean = true) {
204        global $cache_wikifn;
205
206        $this->setAnimalContext($animal);
207
208        unset($cache_wikifn[str_replace(':', '/', $clean ? cleanID($document) : $document)]);
209        $FN = wikiFN($document, $timestamp, $clean);
210        unset($cache_wikifn[str_replace(':', '/', $clean ? cleanID($document) : $document)]);
211
212        $this->resetContext();
213
214        return $FN;
215    }
216
217    /**
218     * Get the last modified time of an animal's page or media file
219     *
220     * @param string $animal
221     * @param string $document Either the page-id or the media-id, colon-separated
222     * @param bool $ismedia
223     * @param bool $clean For pages only: define if the pageid should be cleaned
224     * @return int The modified time of the given document
225     */
226    public function getRemoteFilemtime($animal, $document, $ismedia = false, $clean = true) {
227        if ($ismedia) {
228            return filemtime($this->getRemoteMediaFilename($animal, $document));
229        }
230        return filemtime($this->getRemoteFilename($animal, $document, null, $clean));
231    }
232
233    /**
234     * Check if a page in a given animal exists
235     *
236     * @param string $animal
237     * @param string $page
238     * @param bool $clean
239     * @return bool
240     */
241    public function remotePageExists($animal, $page, $clean = true) {
242        return file_exists($this->getRemoteFilename($animal, $page, null, $clean));
243    }
244
245    public function remoteMediaExists($animal, $medium, $timestamp = null) {
246        return file_exists($this->getRemoteMediaFilename($animal, $medium, $timestamp));
247    }
248
249    /**
250     * Finds the common ancestor revision of two revisions of a page.
251     *
252     * The goal is to find the revision that exists at both target and animal with the same timestamp and content.
253     *
254     * @param string $page
255     * @param string $source
256     * @param string $target
257     * @return string
258     */
259    public function findCommonAncestor($page, $source, $target) {
260        $targetDataDir = $this->getAnimalDataDir($target);
261        $parts = explode(':', $page);
262        $pageid = array_pop($parts);
263        $atticdir = $targetDataDir . 'attic/' . join('/', $parts);
264        $atticdir = rtrim($atticdir, '/') . '/';
265        if (!file_exists($atticdir)) return "";
266        /** @var \Directory $dir */
267        $dir = dir($atticdir);
268        $oldrevisions = array();
269        while (false !== ($entry = $dir->read())) {
270            if ($entry == '.' || $entry == '..' || is_dir($atticdir . $entry)) {
271                continue;
272            }
273            list($atticpageid, $timestamp,) = explode('.', $entry);
274            if ($atticpageid == $pageid) $oldrevisions[] = $timestamp;
275        }
276        rsort($oldrevisions);
277        $sourceMtime = $this->getRemoteFilemtime($source, $page);
278        foreach ($oldrevisions as $rev) {
279            if (!file_exists($this->getRemoteFilename($source, $page, $rev)) && $rev != $sourceMtime) continue;
280            $sourceArchiveText = $rev == $sourceMtime ? $this->readRemotePage($source, $page) : $this->readRemotePage($source, $page, null, $rev);
281            $targetArchiveText = $this->readRemotePage($target, $page, null, $rev);
282            if ($sourceArchiveText == $targetArchiveText) {
283                return $sourceArchiveText;
284            }
285        }
286        return "";
287    }
288
289    /**
290     * @param string $animal
291     * @param string $page
292     * @param string $changelogLine
293     * @param bool $truncate
294     * @throws \Exception
295     */
296    public function addRemotePageChangelogRevision($animal, $page, $changelogLine, $truncate = true) {
297        $remoteChangelog = $this->getAnimalDataDir($animal) . 'meta/' . join('/', explode(':', $page)) . '.changes';
298        $revisionsToAdjust = $this->addRemoteChangelogRevision($remoteChangelog, $changelogLine, $truncate);
299        foreach ($revisionsToAdjust as $revision) {
300            $this->replaceRemoteFile($this->getRemoteFilename($animal, $page, intval($revision) - 1), io_readFile($this->getRemoteFilename($animal, $page, intval($revision))));
301        }
302    }
303
304    /**
305     * @param string $animal
306     * @param string $medium
307     * @param string $changelogLine
308     * @param bool $truncate
309     * @throws \Exception
310     */
311    public function addRemoteMediaChangelogRevision($animal, $medium, $changelogLine, $truncate = true) {
312        $remoteChangelog = $this->getAnimalDataDir($animal) . 'media_meta/' . join('/', explode(':', $medium)) . '.changes';
313        $revisionsToAdjust = $this->addRemoteChangelogRevision($remoteChangelog, $changelogLine, $truncate);
314        foreach ($revisionsToAdjust as $revision) {
315            $this->replaceRemoteFile($this->getRemoteMediaFilename($animal, $medium, intval($revision) - 1), io_readFile($this->getRemoteMediaFilename($animal, $medium, intval($revision))));
316        }
317    }
318
319    public function addRemoteChangelogRevision($remoteChangelog, $changelogLine, $truncate = true) {
320        $rev = substr($changelogLine, 0, 10);
321        if (!$this->isValidTimeStamp($rev)) {
322            throw new \Exception('2nd Argument must start with timestamp!');
323        };
324        $lines = explode("\n", io_readFile($remoteChangelog));
325        $lineindex = count($lines);
326        $revisionsToAdjust = array();
327        foreach ($lines as $index => $line) {
328            if (substr($line, 0, 10) == $rev) {
329                $revisionsToAdjust = $this->freeChangelogRevision($lines, $rev);
330                $lineindex = $index + 1;
331                break;
332            }
333            if (substr($line, 0, 10) > $rev) {
334                $lineindex = $index;
335                break;
336            }
337        }
338        array_splice($lines, $lineindex, $truncate ? count($lines) : 0, $changelogLine);
339
340        $this->replaceRemoteFile($remoteChangelog, join("\n", $lines) . "\n");
341        return $revisionsToAdjust;
342    }
343
344    /**
345 * Modify the changelog so that the revision $rev does not have a changelog entry. However modifying the timestamps
346 * in the changelog only works if we move the attic revisions as well.
347 *
348 * @param  string[] $lines the changelog lines. This array will be adjusted by this function
349 * @param  string $rev The timestamp which should not have an entry
350 * @return string[]         List of attic revisions that need to be moved 1s back in time
351 */
352    public function freeChangelogRevision(&$lines, $rev) {
353        $lineToMakeFree = -1;
354        foreach ($lines as $index => $line) {
355            if (substr($line, 0, 10) == $rev) {
356                $lineToMakeFree = $index;
357                break;
358            }
359        }
360        if ($lineToMakeFree == -1) return array();
361
362        $i = 0;
363        $revisionsToAdjust = array($rev);
364        while ($lineToMakeFree > 0 && substr($lines[$lineToMakeFree - ($i + 1)], 0, 10) == $rev - ($i + 1)) {
365            $revisionsToAdjust[] = $rev - ($i + 1);
366            $i += 1;
367        }
368
369        for (; $i >= 0; $i -= 1) {
370            $parts = explode("\t", $lines[$lineToMakeFree - $i]);
371            array_shift($parts);
372            array_unshift($parts, intval($rev) - $i - 1);
373
374            $lines[$lineToMakeFree - $i] = join("\t", $parts);
375        }
376        sort($revisionsToAdjust);
377        return $revisionsToAdjust;
378    }
379
380    /**
381     * taken from http://stackoverflow.com/questions/2524680/check-whether-the-string-is-a-unix-timestamp#2524761
382     *
383     * @param $timestamp
384     * @return bool
385     */
386    private function isValidTimeStamp($timestamp) {
387        return ((string)(int)$timestamp === (string)$timestamp);
388    }
389
390    public function getAllStructSchemasList($animal) {
391        /** @var \helper_plugin_struct_imexport $struct */
392        $struct = plugin_load('helper', 'struct_imexport');
393        if (empty($struct)) {
394            return array();
395        }
396        global $conf;
397
398        $remoteDataDir = $this->getAnimalDataDir($animal);
399        $farmer_metadir = $conf['metadir'];
400        $conf['metadir'] = $remoteDataDir . 'meta';
401        $schemas = $struct->getAllSchemasList();
402        $conf['metadir'] = $farmer_metadir;
403        return $schemas;
404    }
405
406    public function getAnimalStructAssignments($sourceAnimal, $schemas) {
407        /** @var \helper_plugin_struct_imexport $struct */
408        $struct = plugin_load('helper', 'struct_imexport');
409
410        $this->setAnimalContext($sourceAnimal);
411
412        foreach ($schemas as $key => $assignment) {
413            $schemas[$assignment] = $struct->getSchemaAssignmentPatterns($assignment);
414            unset($schemas[$key]);
415        }
416
417        $this->resetContext();
418
419        return $schemas;
420
421    }
422
423    /**
424     * @param string $targetAnimal      target-animal
425     * @param array  $assignments
426     */
427    public function replaceAnimalStructAssignments($targetAnimal, $assignments) {
428        /** @var \helper_plugin_struct_imexport $struct */
429        $struct = plugin_load('helper', 'struct_imexport');
430
431        $this->setAnimalContext($targetAnimal);
432
433        foreach ($assignments as $schema => $patterns) {
434            $struct->replaceSchemaAssignmentPatterns($schema, $patterns);
435        }
436
437        $this->resetContext();
438    }
439
440    public function getAnimalStructSchemasJSON($sourceAnimal, $schemas) {
441        /** @var \helper_plugin_struct_imexport $struct */
442        $struct = plugin_load('helper', 'struct_imexport');
443
444        $this->setAnimalContext($sourceAnimal);
445        foreach ($schemas as $key => $schema) {
446            $schemas[$schema] = json_decode($struct->getCurrentSchemaJSON($schema));
447            $schemas[$schema]->user = 'FARMSYNC';
448            $schemas[$schema]->id = 0;
449            $schemas[$schema] = json_encode($schemas[$schema]);
450            unset($schemas[$key]);
451        }
452
453        $this->resetContext();
454        return $schemas;
455    }
456
457    public function importAnimalStructSchema($targetAnimal, $schemaName, $json) {
458        /** @var \helper_plugin_struct_imexport $struct */
459        $struct = plugin_load('helper', 'struct_imexport');
460
461        $this->setAnimalContext($targetAnimal);
462
463        $struct->importSchema($schemaName, $json, 'FARMSYNC');
464
465        $this->resetContext();
466    }
467
468    public function updateAnimalStructSchema($targetAnimal, $schemaName, $json) {
469        $this->setAnimalContext($targetAnimal);
470        $result = $this->_updateAnimalStructSchema($targetAnimal, $schemaName, $json);
471        $this->resetContext();
472        return $result;
473    }
474
475    private function _updateAnimalStructSchema($target, $schemaName, $json) {
476        /** @var \helper_plugin_struct_imexport $struct */
477        $struct = plugin_load('helper', 'struct_imexport');
478        $result = new UpdateResults($schemaName, $target);
479        $targetJSON = $struct->getCurrentSchemaJSON($schemaName);
480
481        if ($targetJSON == false) {
482            $struct->importSchema($schemaName, $json, 'FARMSYNC');
483            $result->setMergeResult('new file');
484            return $result;
485        }
486        $targetSchema = json_decode($targetJSON);
487        if ($targetSchema->user == 'FARMSYNC') {
488            $targetSchema->id = 0;
489            if (json_encode($targetSchema) == $json) {
490                $result->setMergeResult('unchanged');
491                return $result;
492            }
493            $struct->importSchema($schemaName, $json, 'FARMSYNC');
494            $result->setMergeResult('file overwritten');
495            return $result;
496        }
497        $result = new StructConflict($schemaName, $target);
498        $result->setMergeResult('merged with conflicts');
499        return $result;
500    }
501
502    private function checkForExternalEdit($animal, $page) {
503        $this->setAnimalContext($animal);
504        detectExternalEdit($page);
505        $this->resetContext();
506    }
507
508}
509