1<?php
2/**
3 * Move Plugin Operation Planner
4 *
5 * @license    GPL 2 (http://www.gnu.org/licenses/gpl.html)
6 * @author     Michael Hamann <michael@content-space.de>
7 * @author     Andreas Gohr <gohr@cosmocode.de>
8 */
9// must be run within Dokuwiki
10if(!defined('DOKU_INC')) die();
11
12/**
13 * Class helper_plugin_move_plan
14 *
15 * This thing prepares and keeps progress info on complex move operations (eg. where more than a single
16 * object is affected).
17 *
18 * Please note: this has not a complex move resolver. Move operations may not depend on each other (eg. you
19 * can not use a namespace as source that will only be created by a different move operation) instead all given
20 * operations should be operations on the current state to come to a wanted future state. The tree manager takes
21 * care of that by abstracting all moves on a DOM representation first, then submitting the needed changes (eg.
22 * differences between now and wanted).
23 *
24 * Glossary:
25 *
26 *   document - refers to either a page or a media file here
27 */
28class helper_plugin_move_plan extends DokuWiki_Plugin {
29    /** Number of operations per step  */
30    const OPS_PER_RUN = 10;
31
32    const TYPE_PAGES = 1;
33    const TYPE_MEDIA = 2;
34    const CLASS_NS   = 4;
35    const CLASS_DOC  = 8;
36
37    /**
38     * @var array the options for this move plan
39     */
40    protected $options = array(); // defaults are set in loadOptions()
41
42    /**
43     * @var array holds the location of the different list and state files
44     */
45    protected $files = array();
46
47    /**
48     * @var array the planned moves
49     */
50    protected $plan = array();
51
52    /**
53     * @var array temporary holder of document lists
54     */
55    protected $tmpstore = array(
56        'pages' => array(),
57        'media' => array(),
58        'ns'    => array(),
59        'affpg' => array(),
60        'miss'  => array(),
61        'miss_media'  => array(),
62    );
63
64    /** @var helper_plugin_move_op $MoveOperator */
65    protected $MoveOperator = null;
66
67    /**
68     * Constructor
69     *
70     * initializes state (if any) for continuiation of a running move op
71     */
72    public function __construct() {
73        global $conf;
74
75        // set the file locations
76        $this->files = array(
77            'opts'       => $conf['metadir'] . '/__move_opts',
78            'pagelist'   => $conf['metadir'] . '/__move_pagelist',
79            'medialist'  => $conf['metadir'] . '/__move_medialist',
80            'affected'   => $conf['metadir'] . '/__move_affected',
81            'namespaces' => $conf['metadir'] . '/__move_namespaces',
82            'missing'    => $conf['metadir'] . '/__move_missing',
83            'missing_media'    => $conf['metadir'] . '/__move_missing_media',
84        );
85
86        $this->MoveOperator = plugin_load('helper', 'move_op');
87
88        $this->loadOptions();
89    }
90
91    /**
92     * Load the current options if any
93     *
94     * If no options are found, the default options will be extended by any available
95     * config options
96     */
97    protected function loadOptions() {
98        // (re)set defaults
99        $this->options = array(
100            // status
101            'committed'   => false,
102            'started'     => 0,
103
104            // counters
105            'pages_all'   => 0,
106            'pages_run'   => 0,
107            'media_all'   => 0,
108            'media_run'   => 0,
109            'affpg_all'   => 0,
110            'affpg_run'   => 0,
111
112            // options
113            'autoskip'    => $this->getConf('autoskip'),
114            'autorewrite' => $this->getConf('autorewrite'),
115
116            // errors
117            'lasterror'   => false
118        );
119
120        // merge whatever options are saved currently
121        $file = $this->files['opts'];
122        if(file_exists($file)) {
123            $options       = unserialize(io_readFile($file, false));
124            $this->options = array_merge($this->options, $options);
125        }
126    }
127
128    /**
129     * Save the current options
130     *
131     * @return bool
132     */
133    protected function saveOptions() {
134        return io_saveFile($this->files['opts'], serialize($this->options));
135    }
136
137    /**
138     * Return the current state of an option, null for unknown options
139     *
140     * @param $name
141     * @return mixed|null
142     */
143    public function getOption($name) {
144        if(isset($this->options[$name])) {
145            return $this->options[$name];
146        }
147        return null;
148    }
149
150    /**
151     * Set an option
152     *
153     * Note, this otpion will only be set to the current instance of this helper object. It will only
154     * be written to the option file once the plan gets committed
155     *
156     * @param $name
157     * @param $value
158     */
159    public function setOption($name, $value) {
160        $this->options[$name] = $value;
161    }
162
163    /**
164     * Returns the progress of this plan in percent
165     *
166     * @return float
167     */
168    public function getProgress() {
169        $max =
170            $this->options['pages_all'] +
171            $this->options['media_all'];
172
173        $remain =
174            $this->options['pages_run'] +
175            $this->options['media_run'];
176
177        if($this->options['autorewrite']) {
178            $max += $this->options['affpg_all'];
179            $remain += $this->options['affpg_run'];
180        }
181
182        if($max == 0) return 0;
183        return round((($max - $remain) * 100) / $max, 2);
184    }
185
186    /**
187     * Check if there is a move in progress currently
188     *
189     * @return bool
190     */
191    public function inProgress() {
192        return (bool) $this->options['started'];
193    }
194
195    /**
196     * Check if this plan has been committed, yet
197     *
198     * @return bool
199     */
200    public function isCommited() {
201        return $this->options['committed'];
202    }
203
204    /**
205     * Add a single page to be moved to the plan
206     *
207     * @param string $src
208     * @param string $dst
209     */
210    public function addPageMove($src, $dst) {
211        $this->addMove($src, $dst, self::CLASS_DOC, self::TYPE_PAGES);
212    }
213
214    /**
215     * Add a single media file to be moved to the plan
216     *
217     * @param string $src
218     * @param string $dst
219     */
220    public function addMediaMove($src, $dst) {
221        $this->addMove($src, $dst, self::CLASS_DOC, self::TYPE_MEDIA);
222    }
223
224    /**
225     * Add a page namespace to be moved to the plan
226     *
227     * @param string $src
228     * @param string $dst
229     */
230    public function addPageNamespaceMove($src, $dst) {
231        $this->addMove($src, $dst, self::CLASS_NS, self::TYPE_PAGES);
232    }
233
234    /**
235     * Add a media namespace to be moved to the plan
236     *
237     * @param string $src
238     * @param string $dst
239     */
240    public function addMediaNamespaceMove($src, $dst) {
241        $this->addMove($src, $dst, self::CLASS_NS, self::TYPE_MEDIA);
242    }
243
244    /**
245     * Plans the move of a namespace or document
246     *
247     * @param string $src   ID of the item to move
248     * @param string $dst   new ID of item namespace
249     * @param int    $class (self::CLASS_NS|self::CLASS_DOC)
250     * @param int    $type  (PLUGIN_MOVE_TYPE_PAGE|self::TYPE_MEDIA)
251     * @throws Exception
252     */
253    protected function addMove($src, $dst, $class = self::CLASS_NS, $type = self::TYPE_PAGES) {
254        if($this->options['committed']) throw new Exception('plan is committed already, can not be added to');
255
256        $src = cleanID($src);
257        $dst = cleanID($dst);
258
259        $this->plan[] = array(
260            'src'   => $src,
261            'dst'   => $dst,
262            'class' => $class,
263            'type'  => $type
264        );
265    }
266
267    /**
268     * Abort any move or plan in progress and reset the helper
269     */
270    public function abort() {
271        foreach($this->files as $file) {
272            @unlink($file);
273        }
274        $this->plan = array();
275        $this->loadOptions();
276        helper_plugin_move_rewrite::removeAllLocks();
277    }
278
279    /**
280     * This locks up the plan and prepares execution
281     *
282     * the plan is reordered an the needed move operations are gathered and stored in the appropriate
283     * list files
284     *
285     * @throws Exception if you try to commit a plan twice
286     * @return bool true if the plan was committed
287     */
288    public function commit() {
289        global $conf;
290
291        if($this->options['committed']) throw new Exception('plan is committed already, can not be committed again');
292
293        helper_plugin_move_rewrite::addLock();
294
295
296        usort($this->plan, array($this, 'planSorter'));
297
298        // get all the documents to be moved and store them in their lists
299        foreach($this->plan as $move) {
300            if($move['class'] == self::CLASS_DOC) {
301                // these can just be added
302                $this->addToDocumentList($move['src'], $move['dst'], $move['type']);
303            } else {
304                // here we need a list of content first, search for it
305                $docs = array();
306                $path = utf8_encodeFN(str_replace(':', '/', $move['src']));
307                $opts = array('depth' => 0, 'skipacl' => true);
308                if($move['type'] == self::TYPE_PAGES) {
309                    search($docs, $conf['datadir'], 'search_allpages', $opts, $path);
310                } else {
311                    search($docs, $conf['mediadir'], 'search_media', $opts, $path);
312                }
313
314                // how much namespace to strip?
315                if($move['src'] !== '') {
316                    $strip = strlen($move['src']) + 1;
317                } else {
318                    $strip = 0;
319                }
320                if($move['dst']) $move['dst'] .= ':';
321
322                // now add all the found documents to our lists
323                foreach($docs as $doc) {
324                    $from = $doc['id'];
325                    $to   = $move['dst'] . substr($doc['id'], $strip);
326                    $this->addToDocumentList($from, $to, $move['type']);
327                }
328
329                // remember the namespace move itself
330                if($move['type'] == self::TYPE_PAGES) {
331                    // FIXME we use this to move namespace subscriptions later on and for now only do it on
332                    //       page namespace moves, but subscriptions work for both, but what when only one of
333                    //       them is moved? Should it be copied then? Complicated. This is good enough for now
334                    $this->addToDocumentList($move['src'], $move['dst'], self::CLASS_NS);
335                }
336                $this->findMissingDocuments($move['src'] . ':', $move['dst'],$move['type']);
337            }
338            // store what pages are affected by this move
339            $this->findAffectedPages($move['src'], $move['dst'], $move['class'], $move['type']);
340        }
341
342        $this->storeDocumentLists();
343
344        if(!$this->options['pages_all'] && !$this->options['media_all']) {
345            msg($this->getLang('noaction'), -1);
346            return false;
347        }
348
349        $this->options['committed'] = true;
350        $this->saveOptions();
351
352        return true;
353    }
354
355    /**
356     * Execute the next steps
357     *
358     * @param bool $skip set to true to skip the next first step (skip error)
359     * @return bool|int false on errors, otherwise the number of remaining steps
360     * @throws Exception
361     */
362    public function nextStep($skip = false) {
363        if(!$this->options['committed']) throw new Exception('plan is not committed yet!');
364
365        // execution has started
366        if(!$this->options['started']) $this->options['started'] = time();
367
368        helper_plugin_move_rewrite::addLock();
369
370        if(@filesize($this->files['pagelist']) > 1) {
371            $todo = $this->stepThroughDocuments(self::TYPE_PAGES, $skip);
372            if($todo === false) return $this->storeError();
373            return max($todo, 1); // force one more call
374        }
375
376        if(@filesize($this->files['medialist']) > 1) {
377            $todo = $this->stepThroughDocuments(self::TYPE_MEDIA, $skip);
378            if($todo === false) return $this->storeError();
379            return max($todo, 1); // force one more call
380        }
381
382        if(@filesize($this->files['missing']) > 1 && @filesize($this->files['affected']) > 1) {
383            $todo = $this->stepThroughMissingDocuments(self::TYPE_PAGES);
384            if($todo === false) return $this->storeError();
385            return max($todo, 1); // force one more call
386        }
387
388        if(@filesize($this->files['missing_media']) > 1 && @filesize($this->files['affected']) > 1) {
389            $todo = $this->stepThroughMissingDocuments(self::TYPE_MEDIA);
390            if($todo === false)return $this->storeError();
391            return max($todo, 1); // force one more call
392        }
393
394        if(@filesize($this->files['namespaces']) > 1) {
395            $todo = $this->stepThroughNamespaces();
396            if($todo === false) return $this->storeError();
397            return max($todo, 1); // force one more call
398        }
399
400        helper_plugin_move_rewrite::removeAllLocks();
401
402        if($this->options['autorewrite'] && @filesize($this->files['affected']) > 1) {
403            $todo = $this->stepThroughAffectedPages();
404            if($todo === false) return $this->storeError();
405            return max($todo, 1); // force one more call
406        }
407
408        // we're done here, clean up
409        $this->abort();
410        return 0;
411    }
412
413    /**
414     * Returns the list of page and media moves and the affected pages as a HTML list
415     *
416     * @return string
417     */
418    public function previewHTML() {
419        $html = '';
420
421        $html .= '<ul>';
422        if(@file_exists($this->files['pagelist'])) {
423            $pagelist = file($this->files['pagelist']);
424            foreach($pagelist as $line) {
425                list($old, $new) = explode("\t", trim($line));
426
427                $html .= '<li class="page"><div class="li">';
428                $html .= hsc($old);
429                $html .= '→';
430                $html .= hsc($new);
431                $html .= '</div></li>';
432            }
433        }
434        if(@file_exists($this->files['medialist'])) {
435            $medialist = file($this->files['medialist']);
436            foreach($medialist as $line) {
437                list($old, $new) = explode("\t", trim($line));
438
439                $html .= '<li class="media"><div class="li">';
440                $html .= hsc($old);
441                $html .= '→';
442                $html .= hsc($new);
443                $html .= '</div></li>';
444            }
445        }
446        if(@file_exists($this->files['affected'])) {
447            $medialist = file($this->files['affected']);
448            foreach($medialist as $page) {
449                $html .= '<li class="affected"><div class="li">';
450                $html .= '↷';
451                $html .= hsc($page);
452                $html .= '</div></li>';
453            }
454        }
455        $html .= '</ul>';
456
457        return $html;
458    }
459
460    /**
461     * Step through the next bunch of pages or media files
462     *
463     * @param int  $type (self::TYPE_PAGES|self::TYPE_MEDIA)
464     * @param bool $skip should the first item be skipped?
465     * @return bool|int false on error, otherwise the number of remaining documents
466     */
467    protected function stepThroughDocuments($type = self::TYPE_PAGES, $skip = false) {
468
469        if($type == self::TYPE_PAGES) {
470            $file    = $this->files['pagelist'];
471            $mark    = 'P';
472            $call    = 'movePage';
473            $items_run_counter = 'pages_run';
474        } else {
475            $file    = $this->files['medialist'];
476            $mark    = 'M';
477            $call    = 'moveMedia';
478            $items_run_counter = 'media_run';
479        }
480
481        $doclist = fopen($file, 'a+');
482
483        for($i = 0; $i < helper_plugin_move_plan::OPS_PER_RUN; $i++) {
484            $log = "";
485            $line = $this->getLastLine($doclist);
486            if($line === false) {
487                break;
488            }
489            list($src, $dst) = explode("\t", trim($line));
490
491            // should this item be skipped?
492            if($skip === true) {
493                $skip = false;
494            } else {
495            // move the page
496                if(!$this->MoveOperator->$call($src, $dst)) {
497                    $log .= $this->build_log_line($mark, $src, $dst, false); // FAILURE!
498
499                    // automatically skip this item only if wanted...
500                    if(!$this->options['autoskip']) {
501                        // ...otherwise abort the operation
502                        fclose($doclist);
503                        $return_items_run = false;
504                        break;
505                    }
506                } else {
507                    $log .= $this->build_log_line($mark, $src, $dst, true); // SUCCESS!
508                }
509            }
510
511            /*
512             * This adjusts counters and truncates the document list correctly
513             * It is used to finalize a successful or skipped move
514             */
515
516            ftruncate($doclist, ftell($doclist));
517            $this->options[$items_run_counter]--;
518            $return_items_run = $this->options[$items_run_counter];
519            $this->write_log($log);
520            $this->saveOptions();
521        }
522
523        if ($return_items_run !== false) {
524            fclose($doclist);
525        }
526        return $return_items_run;
527    }
528
529    /**
530     * Step through the next bunch of pages that need link corrections
531     *
532     * @return bool|int false on error, otherwise the number of remaining documents
533     */
534    protected function stepThroughAffectedPages() {
535        /** @var helper_plugin_move_rewrite $Rewriter */
536        $Rewriter = plugin_load('helper', 'move_rewrite');
537
538        // handle affected pages
539        $doclist = fopen($this->files['affected'], 'a+');
540        for($i = 0; $i < helper_plugin_move_plan::OPS_PER_RUN; $i++) {
541            $page = $this->getLastLine($doclist);
542            if($page === false) break;
543
544            // rewrite it
545            $Rewriter->rewritePage($page);
546
547            // update the list file
548            ftruncate($doclist, ftell($doclist));
549            $this->options['affpg_run']--;
550            $this->saveOptions();
551        }
552
553        fclose($doclist);
554        return $this->options['affpg_run'];
555    }
556
557    /**
558     * Step through all the links to missing pages that should be moved
559     *
560     * This simply adds the moved missing pages to all affected pages meta data. This will add
561     * the meta data to pages not linking to the affected pages but this should still be faster
562     * than figuring out which pages need this info.
563     *
564     * This does not step currently, but handles all pages in one step.
565     *
566     * @param int $type
567     *
568     * @return int always 0
569     * @throws Exception
570     */
571    protected function stepThroughMissingDocuments($type = self::TYPE_PAGES) {
572        if($type != self::TYPE_PAGES && $type != self::TYPE_MEDIA) {
573            throw new Exception('wrong type specified');
574        }
575        /** @var helper_plugin_move_rewrite $Rewriter */
576        $Rewriter = plugin_load('helper', 'move_rewrite');
577
578        $miss = array();
579        if ($type == self::TYPE_PAGES) {
580            $missing_fn = $this->files['missing'];
581        } else {
582            $missing_fn = $this->files['missing_media'];
583        }
584        $missing = file($missing_fn);
585        foreach($missing as $line) {
586            $line = trim($line);
587            if($line == '') continue;
588            list($src, $dst) = explode("\t", $line);
589            $miss[$src] = $dst;
590        }
591
592        $affected = file($this->files['affected']);
593        foreach($affected as $page){
594            $page = trim($page);
595
596            if ($type == self::TYPE_PAGES) {
597                $Rewriter->setMoveMetas($page, $miss, 'pages');
598            } else {
599                $Rewriter->setMoveMetas($page, $miss, 'media');
600            }
601        }
602
603        unlink($missing_fn);
604        return 0;
605    }
606
607    /**
608     * Step through all the namespace moves
609     *
610     * This does not step currently, but handles all namespaces in one step.
611     *
612     * Currently moves namespace subscriptions only.
613     *
614     * @return int always 0
615     * @todo maybe add an event so plugins can move more stuff?
616     * @todo fixed that $src and $dst are seperated by tab, not newline. This method has no tests?
617     */
618    protected function stepThroughNamespaces() {
619        /** @var helper_plugin_move_file $FileMover */
620        $FileMover = plugin_load('helper', 'move_file');
621
622        $lines = io_readFile($this->files['namespaces']);
623        $lines = explode("\n", $lines);
624
625        foreach($lines as $line) {
626            // There is an empty line at the end of the list.
627            if ($line === '') continue;
628
629            list($src, $dst) = explode("\t", trim($line));
630            $FileMover->moveNamespaceSubscription($src, $dst);
631        }
632
633        @unlink($this->files['namespaces']);
634        return 0;
635    }
636
637    /**
638     * Retrieve the last error from the MSG array and store it in the options
639     *
640     * @todo rebuild error handling based on exceptions
641     *
642     * @return bool always false
643     */
644    protected function storeError() {
645        global $MSG;
646
647        if(is_array($MSG) && count($MSG)) {
648            $last                       = array_shift($MSG);
649            $this->options['lasterror'] = $last['msg'];
650            unset($GLOBALS['MSG']);
651        } else {
652            $this->options['lasterror'] = 'Unknown error';
653        }
654        $this->saveOptions();
655
656        return false;
657    }
658
659    /**
660     * Reset the error state
661     */
662    protected function clearError() {
663        $this->options['lasterror'] = false;
664        $this->saveOptions();
665    }
666
667    /**
668     * Get the last error message or false if no error occured
669     *
670     * @return bool|string
671     */
672    public function getLastError() {
673        return $this->options['lasterror'];
674    }
675
676    /**
677     * Appends a page move operation in the list file
678     *
679     * If the src has been added before, this is ignored. This makes sure you can move a single page
680     * out of a namespace first, then move the namespace somewhere else.
681     *
682     * @param string $src
683     * @param string $dst
684     * @param int    $type
685     * @throws Exception
686     */
687    protected function addToDocumentList($src, $dst, $type = self::TYPE_PAGES) {
688        if($type == self::TYPE_PAGES) {
689            $store = 'pages';
690        } else if($type == self::TYPE_MEDIA) {
691            $store = 'media';
692        } else if($type == self::CLASS_NS) {
693            $store = 'ns';
694        } else {
695            throw new Exception('Unknown type ' . $type);
696        }
697
698        if(!isset($this->tmpstore[$store][$src])) {
699            $this->tmpstore[$store][$src] = $dst;
700        }
701    }
702
703    /**
704     * Add the list of pages to the list of affected pages whose links need adjustment
705     *
706     * @param string|array $pages
707     */
708    protected function addToAffectedPagesList($pages) {
709        if(!is_array($pages)) $pages = array($pages);
710
711        foreach($pages as $page) {
712            if(!isset($this->tmpstore['affpg'][$page])) {
713                $this->tmpstore['affpg'][$page] = true;
714            }
715        }
716    }
717
718    /**
719     * Looks up pages that will be affected by a move of $src
720     *
721     * Calls addToAffectedPagesList() directly to store the result
722     *
723     * @param string $src source namespace
724     * @param string $dst destination namespace
725     * @param int    $class
726     * @param int    $type
727     */
728    protected function findAffectedPages($src, $dst, $class, $type) {
729        $idx = idx_get_indexer();
730
731        if($class == self::CLASS_NS) {
732            $src_ = "$src:*"; // use wildcard lookup for namespaces
733        } else {
734            $src_ = $src;
735        }
736
737        $pages = array();
738        if($type == self::TYPE_PAGES) {
739            $pages = $idx->lookupKey('relation_references', $src_);
740            $len = strlen($src);
741            foreach($pages as &$page) {
742                if (substr($page, 0, $len + 1) === "$src:") {
743                    $page = $dst . substr($page, $len + 1);
744                }
745            }
746            unset($page);
747        } else if($type == self::TYPE_MEDIA) {
748            $pages = $idx->lookupKey('relation_media', $src_);
749        }
750
751        $this->addToAffectedPagesList($pages);
752    }
753
754    /**
755     * Find missing pages in the $src namespace
756     *
757     * @param string $src source namespace
758     * @param string $dst destination namespace
759     * @param int    $type either self::TYPE_PAGES or self::TYPE_MEDIA
760     */
761    protected function findMissingDocuments($src, $dst, $type = self::TYPE_PAGES) {
762        global $conf;
763
764        // FIXME this duplicates Doku_Indexer::getIndex()
765        if ($type == self::TYPE_PAGES) {
766            $fn = $conf['indexdir'] . '/relation_references_w.idx';
767        } else {
768            $fn = $conf['indexdir'] . '/relation_media_w.idx';
769        }
770        if (!@file_exists($fn)){
771            $referenceidx = array();
772        } else {
773            $referenceidx = file($fn, FILE_IGNORE_NEW_LINES);
774        }
775
776        $len = strlen($src);
777        foreach($referenceidx as $idx => $page) {
778            if(substr($page, 0, $len) != "$src") continue;
779
780            // remember missing pages
781            if ($type == self::TYPE_PAGES) {
782                if(!page_exists($page)) {
783                    $newpage = $dst . substr($page, $len);
784                    $this->tmpstore['miss'][$page] = $newpage;
785                }
786            } else {
787                if(!file_exists(mediaFN($page))){
788                    $newpage = $dst . substr($page, $len);
789                    $this->tmpstore['miss_media'][$page] = $newpage;
790                }
791            }
792        }
793    }
794
795    /**
796     * Store the aggregated document lists in the file system and reset the internal storage
797     *
798     * @throws Exception
799     */
800    protected function storeDocumentLists() {
801        $lists = array(
802            'pages' => $this->files['pagelist'],
803            'media' => $this->files['medialist'],
804            'ns'    => $this->files['namespaces'],
805            'affpg' => $this->files['affected'],
806            'miss'  => $this->files['missing'],
807            'miss_media'  => $this->files['missing_media'],
808        );
809
810        foreach($lists as $store => $file) {
811            // anything to do?
812            $count = count($this->tmpstore[$store]);
813            if(!$count) continue;
814
815            // prepare and save content
816            $data                   = '';
817            $this->tmpstore[$store] = array_reverse($this->tmpstore[$store]); // store in reverse order
818            foreach($this->tmpstore[$store] as $src => $dst) {
819                if($dst === true) {
820                    $data .= "$src\n"; // for affected pages only one ID is saved
821                } else {
822                    $data .= "$src\t$dst\n";
823                }
824
825            }
826            io_saveFile($file, $data);
827
828            // set counters
829            if($store != 'ns') {
830                $this->options[$store . '_all'] = $count;
831                $this->options[$store . '_run'] = $count;
832            }
833
834            // reset the list
835            $this->tmpstore[$store] = array();
836        }
837    }
838
839    /**
840     * Get the last line from the list that is stored in the file that is referenced by the handle
841     * The handle is set to the newline before the file id
842     *
843     * @param resource $handle The file handle to read from
844     * @return string|bool the last id from the list or false if there is none
845     */
846    protected function getLastLine($handle) {
847        // begin the seek at the end of the file
848        fseek($handle, 0, SEEK_END);
849        $line = '';
850
851        // seek one backwards as long as it's possible
852        while(fseek($handle, -1, SEEK_CUR) >= 0) {
853            $c = fgetc($handle);
854            if($c === false) return false; // EOF, i.e. the file is empty
855            fseek($handle, -1, SEEK_CUR); // reset the position to the character that was read
856
857            if($c == "\n") {
858                if($line === '') {
859                    continue; // this line was empty, continue
860                } else {
861                    break; // we have a line, finish
862                }
863            }
864
865            $line = $c . $line; // prepend char to line
866        }
867
868        if($line === '') return false; // beginning of file reached and no content
869
870        return $line;
871    }
872
873    /**
874     * Callback for usort to sort the move plan
875     *
876     * @param $a
877     * @param $b
878     * @return int
879     */
880    public function planSorter($a, $b) {
881        // do page moves before namespace moves
882        if($a['class'] == self::CLASS_DOC && $b['class'] == self::CLASS_NS) {
883            return -1;
884        }
885        if($a['class'] == self::CLASS_NS && $b['class'] == self::CLASS_DOC) {
886            return 1;
887        }
888
889        // do pages before media
890        if($a['type'] == self::TYPE_PAGES && $b['type'] == self::TYPE_MEDIA) {
891            return -1;
892        }
893        if($a['type'] == self::TYPE_MEDIA && $b['type'] == self::TYPE_PAGES) {
894            return 1;
895        }
896
897        // from here on we compare only apples to apples
898        // we sort by depth of namespace, deepest namespaces first
899
900        $alen = substr_count($a['src'], ':');
901        $blen = substr_count($b['src'], ':');
902
903        if($alen > $blen) {
904            return -1;
905        } elseif($alen < $blen) {
906            return 1;
907        }
908        return 0;
909    }
910
911    /**
912     * Create line to log result of an operation
913     *
914     * @param string $type
915     * @param string $from
916     * @param string $to
917     * @param bool   $success
918     *
919     * @return string
920     *
921     * @author Andreas Gohr <gohr@cosmocode.de>
922     * @author Michael Große <grosse@cosmocode.de>
923     */
924    public function build_log_line ($type, $from, $to, $success) {
925        global $MSG;
926
927        $now = time();
928        $date   = date('Y-m-d H:i:s', $now); // for human readability
929        if($success) {
930            $ok  = 'success';
931            $msg = '';
932        } else {
933            $ok  = 'failed';
934            $msg = $MSG[count($MSG) - 1]['msg']; // get detail from message array
935        }
936
937        $log = "$now\t$date\t$type\t$from\t$to\t$ok\t$msg\n";
938        return $log;
939    }
940
941    /**
942     * write log to file
943     *
944     * @param $log
945     */
946    protected function write_log ($log) {
947        global $conf;
948        $optime = $this->options['started'];
949        $file   = $conf['cachedir'] . '/move/' . strftime('%Y%m%d-%H%M%S', $optime) . '.log';
950        io_saveFile($file, $log, true);
951    }
952
953}
954