* @author Andreas Gohr */ // must be run within Dokuwiki if(!defined('DOKU_INC')) die(); /** * Class helper_plugin_move_plan * * This thing prepares and keeps progress info on complex move operations (eg. where more than a single * object is affected). * * Please note: this has not a complex move resolver. Move operations may not depend on each other (eg. you * can not use a namespace as source that will only be created by a different move operation) instead all given * operations should be operations on the current state to come to a wanted future state. The tree manager takes * care of that by abstracting all moves on a DOM representation first, then submitting the needed changes (eg. * differences between now and wanted). * * Glossary: * * document - refers to either a page or a media file here */ class helper_plugin_move_plan extends DokuWiki_Plugin { /** Number of operations per step */ const OPS_PER_RUN = 10; const TYPE_PAGES = 1; const TYPE_MEDIA = 2; const CLASS_NS = 4; const CLASS_DOC = 8; /** * @var array the options for this move plan */ protected $options = array(); // defaults are set in loadOptions() /** * @var array holds the location of the different list and state files */ protected $files = array(); /** * @var array the planned moves */ protected $plan = array(); /** * @var array temporary holder of document lists */ protected $tmpstore = array( 'pages' => array(), 'media' => array(), 'ns' => array(), 'affpg' => array(), 'miss' => array(), 'miss_media' => array(), ); /** @var helper_plugin_move_op $MoveOperator */ protected $MoveOperator = null; /** * Constructor * * initializes state (if any) for continuiation of a running move op */ public function __construct() { global $conf; // set the file locations $this->files = array( 'opts' => $conf['metadir'] . '/__move_opts', 'pagelist' => $conf['metadir'] . '/__move_pagelist', 'medialist' => $conf['metadir'] . '/__move_medialist', 'affected' => $conf['metadir'] . '/__move_affected', 'namespaces' => $conf['metadir'] . '/__move_namespaces', 'missing' => $conf['metadir'] . '/__move_missing', 'missing_media' => $conf['metadir'] . '/__move_missing_media', ); $this->MoveOperator = plugin_load('helper', 'move_op'); $this->loadOptions(); } /** * Load the current options if any * * If no options are found, the default options will be extended by any available * config options */ protected function loadOptions() { // (re)set defaults $this->options = array( // status 'committed' => false, 'started' => 0, // counters 'pages_all' => 0, 'pages_run' => 0, 'media_all' => 0, 'media_run' => 0, 'affpg_all' => 0, 'affpg_run' => 0, // options 'autoskip' => $this->getConf('autoskip'), 'autorewrite' => $this->getConf('autorewrite'), // errors 'lasterror' => false ); // merge whatever options are saved currently $file = $this->files['opts']; if(file_exists($file)) { $options = unserialize(io_readFile($file, false)); $this->options = array_merge($this->options, $options); } } /** * Save the current options * * @return bool */ protected function saveOptions() { return io_saveFile($this->files['opts'], serialize($this->options)); } /** * Return the current state of an option, null for unknown options * * @param $name * @return mixed|null */ public function getOption($name) { if(isset($this->options[$name])) { return $this->options[$name]; } return null; } /** * Set an option * * Note, this otpion will only be set to the current instance of this helper object. It will only * be written to the option file once the plan gets committed * * @param $name * @param $value */ public function setOption($name, $value) { $this->options[$name] = $value; } /** * Returns the progress of this plan in percent * * @return float */ public function getProgress() { $max = $this->options['pages_all'] + $this->options['media_all']; $remain = $this->options['pages_run'] + $this->options['media_run']; if($this->options['autorewrite']) { $max += $this->options['affpg_all']; $remain += $this->options['affpg_run']; } if($max == 0) return 0; return round((($max - $remain) * 100) / $max, 2); } /** * Check if there is a move in progress currently * * @return bool */ public function inProgress() { return (bool) $this->options['started']; } /** * Check if this plan has been committed, yet * * @return bool */ public function isCommited() { return $this->options['committed']; } /** * Add a single page to be moved to the plan * * @param string $src * @param string $dst */ public function addPageMove($src, $dst) { $this->addMove($src, $dst, self::CLASS_DOC, self::TYPE_PAGES); } /** * Add a single media file to be moved to the plan * * @param string $src * @param string $dst */ public function addMediaMove($src, $dst) { $this->addMove($src, $dst, self::CLASS_DOC, self::TYPE_MEDIA); } /** * Add a page namespace to be moved to the plan * * @param string $src * @param string $dst */ public function addPageNamespaceMove($src, $dst) { $this->addMove($src, $dst, self::CLASS_NS, self::TYPE_PAGES); } /** * Add a media namespace to be moved to the plan * * @param string $src * @param string $dst */ public function addMediaNamespaceMove($src, $dst) { $this->addMove($src, $dst, self::CLASS_NS, self::TYPE_MEDIA); } /** * Plans the move of a namespace or document * * @param string $src ID of the item to move * @param string $dst new ID of item namespace * @param int $class (self::CLASS_NS|self::CLASS_DOC) * @param int $type (PLUGIN_MOVE_TYPE_PAGE|self::TYPE_MEDIA) * @throws Exception */ protected function addMove($src, $dst, $class = self::CLASS_NS, $type = self::TYPE_PAGES) { if($this->options['committed']) throw new Exception('plan is committed already, can not be added to'); $src = cleanID($src); $dst = cleanID($dst); $this->plan[] = array( 'src' => $src, 'dst' => $dst, 'class' => $class, 'type' => $type ); } /** * Abort any move or plan in progress and reset the helper */ public function abort() { foreach($this->files as $file) { @unlink($file); } $this->plan = array(); $this->loadOptions(); helper_plugin_move_rewrite::removeAllLocks(); } /** * This locks up the plan and prepares execution * * the plan is reordered an the needed move operations are gathered and stored in the appropriate * list files * * @throws Exception if you try to commit a plan twice * @return bool true if the plan was committed */ public function commit() { global $conf; if($this->options['committed']) throw new Exception('plan is committed already, can not be committed again'); helper_plugin_move_rewrite::addLock(); usort($this->plan, array($this, 'planSorter')); // get all the documents to be moved and store them in their lists foreach($this->plan as $move) { if($move['class'] == self::CLASS_DOC) { // these can just be added $this->addToDocumentList($move['src'], $move['dst'], $move['type']); } else { // here we need a list of content first, search for it $docs = array(); $path = utf8_encodeFN(str_replace(':', '/', $move['src'])); $opts = array('depth' => 0, 'skipacl' => true); if($move['type'] == self::TYPE_PAGES) { search($docs, $conf['datadir'], 'search_allpages', $opts, $path); } else { search($docs, $conf['mediadir'], 'search_media', $opts, $path); } // how much namespace to strip? if($move['src'] !== '') { $strip = strlen($move['src']) + 1; } else { $strip = 0; } if($move['dst']) $move['dst'] .= ':'; // now add all the found documents to our lists foreach($docs as $doc) { $from = $doc['id']; $to = $move['dst'] . substr($doc['id'], $strip); $this->addToDocumentList($from, $to, $move['type']); } // remember the namespace move itself if($move['type'] == self::TYPE_PAGES) { // FIXME we use this to move namespace subscriptions later on and for now only do it on // page namespace moves, but subscriptions work for both, but what when only one of // them is moved? Should it be copied then? Complicated. This is good enough for now $this->addToDocumentList($move['src'], $move['dst'], self::CLASS_NS); } $this->findMissingDocuments($move['src'] . ':', $move['dst'],$move['type']); } // store what pages are affected by this move $this->findAffectedPages($move['src'], $move['dst'], $move['class'], $move['type']); } $this->storeDocumentLists(); if(!$this->options['pages_all'] && !$this->options['media_all']) { msg($this->getLang('noaction'), -1); return false; } $this->options['committed'] = true; $this->saveOptions(); return true; } /** * Execute the next steps * * @param bool $skip set to true to skip the next first step (skip error) * @return bool|int false on errors, otherwise the number of remaining steps * @throws Exception */ public function nextStep($skip = false) { if(!$this->options['committed']) throw new Exception('plan is not committed yet!'); // execution has started if(!$this->options['started']) $this->options['started'] = time(); helper_plugin_move_rewrite::addLock(); if(@filesize($this->files['pagelist']) > 1) { $todo = $this->stepThroughDocuments(self::TYPE_PAGES, $skip); if($todo === false) return $this->storeError(); return max($todo, 1); // force one more call } if(@filesize($this->files['medialist']) > 1) { $todo = $this->stepThroughDocuments(self::TYPE_MEDIA, $skip); if($todo === false) return $this->storeError(); return max($todo, 1); // force one more call } if(@filesize($this->files['missing']) > 1 && @filesize($this->files['affected']) > 1) { $todo = $this->stepThroughMissingDocuments(self::TYPE_PAGES); if($todo === false) return $this->storeError(); return max($todo, 1); // force one more call } if(@filesize($this->files['missing_media']) > 1 && @filesize($this->files['affected']) > 1) { $todo = $this->stepThroughMissingDocuments(self::TYPE_MEDIA); if($todo === false)return $this->storeError(); return max($todo, 1); // force one more call } if(@filesize($this->files['namespaces']) > 1) { $todo = $this->stepThroughNamespaces(); if($todo === false) return $this->storeError(); return max($todo, 1); // force one more call } helper_plugin_move_rewrite::removeAllLocks(); if($this->options['autorewrite'] && @filesize($this->files['affected']) > 1) { $todo = $this->stepThroughAffectedPages(); if($todo === false) return $this->storeError(); return max($todo, 1); // force one more call } // we're done here, clean up $this->abort(); return 0; } /** * Returns the list of page and media moves and the affected pages as a HTML list * * @return string */ public function previewHTML() { $html = ''; $html .= ''; return $html; } /** * Step through the next bunch of pages or media files * * @param int $type (self::TYPE_PAGES|self::TYPE_MEDIA) * @param bool $skip should the first item be skipped? * @return bool|int false on error, otherwise the number of remaining documents */ protected function stepThroughDocuments($type = self::TYPE_PAGES, $skip = false) { if($type == self::TYPE_PAGES) { $file = $this->files['pagelist']; $mark = 'P'; $call = 'movePage'; $items_run_counter = 'pages_run'; } else { $file = $this->files['medialist']; $mark = 'M'; $call = 'moveMedia'; $items_run_counter = 'media_run'; } $doclist = fopen($file, 'a+'); for($i = 0; $i < helper_plugin_move_plan::OPS_PER_RUN; $i++) { $log = ""; $line = $this->getLastLine($doclist); if($line === false) { break; } list($src, $dst) = explode("\t", trim($line)); // should this item be skipped? if($skip === true) { $skip = false; } else { // move the page if(!$this->MoveOperator->$call($src, $dst)) { $log .= $this->build_log_line($mark, $src, $dst, false); // FAILURE! // automatically skip this item only if wanted... if(!$this->options['autoskip']) { // ...otherwise abort the operation fclose($doclist); $return_items_run = false; break; } } else { $log .= $this->build_log_line($mark, $src, $dst, true); // SUCCESS! } } /* * This adjusts counters and truncates the document list correctly * It is used to finalize a successful or skipped move */ ftruncate($doclist, ftell($doclist)); $this->options[$items_run_counter]--; $return_items_run = $this->options[$items_run_counter]; $this->write_log($log); $this->saveOptions(); } if ($return_items_run !== false) { fclose($doclist); } return $return_items_run; } /** * Step through the next bunch of pages that need link corrections * * @return bool|int false on error, otherwise the number of remaining documents */ protected function stepThroughAffectedPages() { /** @var helper_plugin_move_rewrite $Rewriter */ $Rewriter = plugin_load('helper', 'move_rewrite'); // handle affected pages $doclist = fopen($this->files['affected'], 'a+'); for($i = 0; $i < helper_plugin_move_plan::OPS_PER_RUN; $i++) { $page = $this->getLastLine($doclist); if($page === false) break; // rewrite it $Rewriter->rewritePage($page); // update the list file ftruncate($doclist, ftell($doclist)); $this->options['affpg_run']--; $this->saveOptions(); } fclose($doclist); return $this->options['affpg_run']; } /** * Step through all the links to missing pages that should be moved * * This simply adds the moved missing pages to all affected pages meta data. This will add * the meta data to pages not linking to the affected pages but this should still be faster * than figuring out which pages need this info. * * This does not step currently, but handles all pages in one step. * * @param int $type * * @return int always 0 * @throws Exception */ protected function stepThroughMissingDocuments($type = self::TYPE_PAGES) { if($type != self::TYPE_PAGES && $type != self::TYPE_MEDIA) { throw new Exception('wrong type specified'); } /** @var helper_plugin_move_rewrite $Rewriter */ $Rewriter = plugin_load('helper', 'move_rewrite'); $miss = array(); if ($type == self::TYPE_PAGES) { $missing_fn = $this->files['missing']; } else { $missing_fn = $this->files['missing_media']; } $missing = file($missing_fn); foreach($missing as $line) { $line = trim($line); if($line == '') continue; list($src, $dst) = explode("\t", $line); $miss[$src] = $dst; } $affected = file($this->files['affected']); foreach($affected as $page){ $page = trim($page); if ($type == self::TYPE_PAGES) { $Rewriter->setMoveMetas($page, $miss, 'pages'); } else { $Rewriter->setMoveMetas($page, $miss, 'media'); } } unlink($missing_fn); return 0; } /** * Step through all the namespace moves * * This does not step currently, but handles all namespaces in one step. * * Currently moves namespace subscriptions only. * * @return int always 0 * @todo maybe add an event so plugins can move more stuff? * @todo fixed that $src and $dst are seperated by tab, not newline. This method has no tests? */ protected function stepThroughNamespaces() { /** @var helper_plugin_move_file $FileMover */ $FileMover = plugin_load('helper', 'move_file'); $lines = io_readFile($this->files['namespaces']); $lines = explode("\n", $lines); foreach($lines as $line) { // There is an empty line at the end of the list. if ($line === '') continue; list($src, $dst) = explode("\t", trim($line)); $FileMover->moveNamespaceSubscription($src, $dst); } @unlink($this->files['namespaces']); return 0; } /** * Retrieve the last error from the MSG array and store it in the options * * @todo rebuild error handling based on exceptions * * @return bool always false */ protected function storeError() { global $MSG; if(is_array($MSG) && count($MSG)) { $last = array_shift($MSG); $this->options['lasterror'] = $last['msg']; unset($GLOBALS['MSG']); } else { $this->options['lasterror'] = 'Unknown error'; } $this->saveOptions(); return false; } /** * Reset the error state */ protected function clearError() { $this->options['lasterror'] = false; $this->saveOptions(); } /** * Get the last error message or false if no error occured * * @return bool|string */ public function getLastError() { return $this->options['lasterror']; } /** * Appends a page move operation in the list file * * If the src has been added before, this is ignored. This makes sure you can move a single page * out of a namespace first, then move the namespace somewhere else. * * @param string $src * @param string $dst * @param int $type * @throws Exception */ protected function addToDocumentList($src, $dst, $type = self::TYPE_PAGES) { if($type == self::TYPE_PAGES) { $store = 'pages'; } else if($type == self::TYPE_MEDIA) { $store = 'media'; } else if($type == self::CLASS_NS) { $store = 'ns'; } else { throw new Exception('Unknown type ' . $type); } if(!isset($this->tmpstore[$store][$src])) { $this->tmpstore[$store][$src] = $dst; } } /** * Add the list of pages to the list of affected pages whose links need adjustment * * @param string|array $pages */ protected function addToAffectedPagesList($pages) { if(!is_array($pages)) $pages = array($pages); foreach($pages as $page) { if(!isset($this->tmpstore['affpg'][$page])) { $this->tmpstore['affpg'][$page] = true; } } } /** * Looks up pages that will be affected by a move of $src * * Calls addToAffectedPagesList() directly to store the result * * @param string $src source namespace * @param string $dst destination namespace * @param int $class * @param int $type */ protected function findAffectedPages($src, $dst, $class, $type) { $idx = idx_get_indexer(); if($class == self::CLASS_NS) { $src_ = "$src:*"; // use wildcard lookup for namespaces } else { $src_ = $src; } $pages = array(); if($type == self::TYPE_PAGES) { $pages = $idx->lookupKey('relation_references', $src_); $len = strlen($src); foreach($pages as &$page) { if (substr($page, 0, $len + 1) === "$src:") { $page = $dst . substr($page, $len + 1); } } unset($page); } else if($type == self::TYPE_MEDIA) { $pages = $idx->lookupKey('relation_media', $src_); } $this->addToAffectedPagesList($pages); } /** * Find missing pages in the $src namespace * * @param string $src source namespace * @param string $dst destination namespace * @param int $type either self::TYPE_PAGES or self::TYPE_MEDIA */ protected function findMissingDocuments($src, $dst, $type = self::TYPE_PAGES) { global $conf; // FIXME this duplicates Doku_Indexer::getIndex() if ($type == self::TYPE_PAGES) { $fn = $conf['indexdir'] . '/relation_references_w.idx'; } else { $fn = $conf['indexdir'] . '/relation_media_w.idx'; } if (!@file_exists($fn)){ $referenceidx = array(); } else { $referenceidx = file($fn, FILE_IGNORE_NEW_LINES); } $len = strlen($src); foreach($referenceidx as $idx => $page) { if(substr($page, 0, $len) != "$src") continue; // remember missing pages if ($type == self::TYPE_PAGES) { if(!page_exists($page)) { $newpage = $dst . substr($page, $len); $this->tmpstore['miss'][$page] = $newpage; } } else { if(!file_exists(mediaFN($page))){ $newpage = $dst . substr($page, $len); $this->tmpstore['miss_media'][$page] = $newpage; } } } } /** * Store the aggregated document lists in the file system and reset the internal storage * * @throws Exception */ protected function storeDocumentLists() { $lists = array( 'pages' => $this->files['pagelist'], 'media' => $this->files['medialist'], 'ns' => $this->files['namespaces'], 'affpg' => $this->files['affected'], 'miss' => $this->files['missing'], 'miss_media' => $this->files['missing_media'], ); foreach($lists as $store => $file) { // anything to do? $count = count($this->tmpstore[$store]); if(!$count) continue; // prepare and save content $data = ''; $this->tmpstore[$store] = array_reverse($this->tmpstore[$store]); // store in reverse order foreach($this->tmpstore[$store] as $src => $dst) { if($dst === true) { $data .= "$src\n"; // for affected pages only one ID is saved } else { $data .= "$src\t$dst\n"; } } io_saveFile($file, $data); // set counters if($store != 'ns') { $this->options[$store . '_all'] = $count; $this->options[$store . '_run'] = $count; } // reset the list $this->tmpstore[$store] = array(); } } /** * Get the last line from the list that is stored in the file that is referenced by the handle * The handle is set to the newline before the file id * * @param resource $handle The file handle to read from * @return string|bool the last id from the list or false if there is none */ protected function getLastLine($handle) { // begin the seek at the end of the file fseek($handle, 0, SEEK_END); $line = ''; // seek one backwards as long as it's possible while(fseek($handle, -1, SEEK_CUR) >= 0) { $c = fgetc($handle); if($c === false) return false; // EOF, i.e. the file is empty fseek($handle, -1, SEEK_CUR); // reset the position to the character that was read if($c == "\n") { if($line === '') { continue; // this line was empty, continue } else { break; // we have a line, finish } } $line = $c . $line; // prepend char to line } if($line === '') return false; // beginning of file reached and no content return $line; } /** * Callback for usort to sort the move plan * * @param $a * @param $b * @return int */ public function planSorter($a, $b) { // do page moves before namespace moves if($a['class'] == self::CLASS_DOC && $b['class'] == self::CLASS_NS) { return -1; } if($a['class'] == self::CLASS_NS && $b['class'] == self::CLASS_DOC) { return 1; } // do pages before media if($a['type'] == self::TYPE_PAGES && $b['type'] == self::TYPE_MEDIA) { return -1; } if($a['type'] == self::TYPE_MEDIA && $b['type'] == self::TYPE_PAGES) { return 1; } // from here on we compare only apples to apples // we sort by depth of namespace, deepest namespaces first $alen = substr_count($a['src'], ':'); $blen = substr_count($b['src'], ':'); if($alen > $blen) { return -1; } elseif($alen < $blen) { return 1; } return 0; } /** * Create line to log result of an operation * * @param string $type * @param string $from * @param string $to * @param bool $success * * @return string * * @author Andreas Gohr * @author Michael Große */ public function build_log_line ($type, $from, $to, $success) { global $MSG; $now = time(); $date = date('Y-m-d H:i:s', $now); // for human readability if($success) { $ok = 'success'; $msg = ''; } else { $ok = 'failed'; $msg = $MSG[count($MSG) - 1]['msg']; // get detail from message array } $log = "$now\t$date\t$type\t$from\t$to\t$ok\t$msg\n"; return $log; } /** * write log to file * * @param $log */ protected function write_log ($log) { global $conf; $optime = $this->options['started']; $file = $conf['cachedir'] . '/move/' . strftime('%Y%m%d-%H%M%S', $optime) . '.log'; io_saveFile($file, $log, true); } }