*/ require_once(DOKU_PLUGIN . 'batchedit/interface.php'); class BatcheditException extends Exception { private $arguments; /** * Accepts message id followed by optional arguments. */ public function __construct($messageId) { parent::__construct($messageId); $this->arguments = func_get_args(); } /** * */ public function getArguments() { return $this->arguments; } } class BatcheditEmptyNamespaceException extends BatcheditException { /** * */ public function __construct($namespace) { parent::__construct('err_emptyns', $namespace); } } class BatcheditPageApplyException extends BatcheditException { /** * Accepts message and page ids followed by optional arguments. */ public function __construct($messageId, $pageId) { call_user_func_array('parent::__construct', func_get_args()); } } class BatcheditAccessControlException extends BatcheditPageApplyException { /** * */ public function __construct($pageId) { parent::__construct('war_norights', $pageId); } } class BatcheditPageLockedException extends BatcheditPageApplyException { /** * */ public function __construct($pageId, $lockedBy) { parent::__construct('war_pagelock', $pageId, $lockedBy); } } class BatcheditMatchApplyException extends BatcheditPageApplyException { /** * */ public function __construct($matchId) { parent::__construct('war_matchfail', $matchId); } } class BatcheditMatch implements Serializable { private $pageOffset; private $originalText; private $replacedText; private $contextBefore; private $contextAfter; private $marked; private $applied; /** * */ public function __construct($pageText, $pageOffset, $text, $regexp, $replacement, $contextChars, $contextLines) { $this->pageOffset = $pageOffset; $this->originalText = $text; $this->replacedText = preg_replace($regexp, $replacement, $text); $this->contextBefore = $this->cropContextBefore($pageText, $pageOffset, $contextChars, $contextLines); $this->contextAfter = $this->cropContextAfter($pageText, $pageOffset + strlen($text), $contextChars, $contextLines); $this->marked = FALSE; $this->applied = FALSE; } /** * */ public function getPageOffset() { return $this->pageOffset; } /** * */ public function getOriginalText() { return $this->originalText; } /** * */ public function getReplacedText() { return $this->replacedText; } /** * */ public function getContextBefore() { return $this->contextBefore; } /** * */ public function getContextAfter() { return $this->contextAfter; } /** * */ public function mark($marked = TRUE) { $this->marked = $marked; } /** * */ public function isMarked() { return $this->marked; } /** * */ public function apply($pageText, $offsetDelta) { $pageOffset = $this->pageOffset + $offsetDelta; $currentText = substr($pageText, $pageOffset, strlen($this->originalText)); if ($currentText != $this->originalText) { throw new BatcheditMatchApplyException('#' . $this->pageOffset); } $before = substr($pageText, 0, $pageOffset); $after = substr($pageText, $pageOffset + strlen($this->originalText)); $this->applied = TRUE; return $before . $this->replacedText . $after; } /** * */ public function rollback() { $this->applied = FALSE; } /** * */ public function isApplied() { return $this->applied; } /** * */ public function serialize() { return serialize(array($this->pageOffset, $this->originalText, $this->replacedText, $this->contextBefore, $this->contextAfter, $this->marked, $this->applied)); } /** * */ public function unserialize($data) { list($this->pageOffset, $this->originalText, $this->replacedText, $this->contextBefore, $this->contextAfter, $this->marked, $this->applied) = unserialize($data); } /** * */ private function cropContextBefore($pageText, $pageOffset, $contextChars, $contextLines) { if ($contextChars == 0) { return ''; } $context = \dokuwiki\Utf8\PhpString::substr(substr($pageText, 0, $pageOffset), -$contextChars); $count = preg_match_all('/\n/', $context, $match, PREG_SET_ORDER | PREG_OFFSET_CAPTURE); if ($count > $contextLines) { $context = substr($context, $match[$count - $contextLines - 1][0][1] + 1); } return $context; } /** * */ private function cropContextAfter($pageText, $pageOffset, $contextChars, $contextLines) { if ($contextChars == 0) { return ''; } $context = \dokuwiki\Utf8\PhpString::substr(substr($pageText, $pageOffset), 0, $contextChars); $count = preg_match_all('/\n/', $context, $match, PREG_SET_ORDER | PREG_OFFSET_CAPTURE); if ($count > $contextLines) { $context = substr($context, 0, $match[$contextLines][0][1]); } return $context; } } class BatcheditPage implements Serializable { private $id; private $matches; /** * */ public function __construct($id) { $this->id = $id; $this->matches = array(); } /** * */ public function getId() { return $this->id; } /** * */ public function findMatches($regexp, $replacement, $limit, $contextChars, $contextLines, $applyTemplatePatterns) { $text = rawWiki($this->id); $count = @preg_match_all($regexp, $text, $match, PREG_SET_ORDER | PREG_OFFSET_CAPTURE); if ($count === FALSE) { throw new BatcheditException('err_pregfailed'); } $interrupted = FALSE; if ($limit >= 0 && $count > $limit) { $count = $limit; $interrupted = TRUE; } if ($applyTemplatePatterns) { $data = array( 'id' => $this->id, 'tpl' => $replacement, 'tplfile' => '', 'doreplace' => true ); $replacement = parsePageTemplate($data); } for ($i = 0; $i < $count; $i++) { $this->addMatch($text, $match[$i][0][1], $match[$i][0][0], $regexp, $replacement, $contextChars, $contextLines); } return $interrupted; } /** * */ public function getMatches() { return $this->matches; } /** * */ public function markMatch($offset) { if (array_key_exists($offset, $this->matches)) { $this->matches[$offset]->mark(); } } /** * */ public function hasMarkedMatches() { $result = FALSE; foreach ($this->matches as $match) { if ($match->isMarked()) { $result = TRUE; break; } } return $result; } /** * */ public function hasUnmarkedMatches() { $result = FALSE; foreach ($this->matches as $match) { if (!$match->isMarked()) { $result = TRUE; break; } } return $result; } /** * */ public function hasUnappliedMatches() { $result = FALSE; foreach ($this->matches as $match) { if (!$match->isApplied()) { $result = TRUE; break; } } return $result; } /** * */ public function applyMatches($summary, $minorEdit) { try { $this->lock(); $text = rawWiki($this->id); $originalLength = strlen($text); $count = 0; foreach ($this->matches as $match) { if ($match->isMarked()) { $text = $match->apply($text, strlen($text) - $originalLength); $count++; } } saveWikiText($this->id, $text, $summary, $minorEdit); unlock($this->id); } catch (Exception $error) { $this->rollbackMatches(); if ($error instanceof BatcheditMatchApplyException) { $error = new BatcheditMatchApplyException($this->id . $error->getArguments()[1]); } throw $error; } return $count; } /** * */ public function serialize() { return serialize(array($this->id, $this->matches)); } /** * */ public function unserialize($data) { list($this->id, $this->matches) = unserialize($data); } /** * */ private function addMatch($text, $offset, $matched, $regexp, $replacement, $contextChars, $contextLines) { $this->matches[$offset] = new BatcheditMatch($text, $offset, $matched, $regexp, $replacement, $contextChars, $contextLines); } /** * */ private function lock() { if (auth_quickaclcheck($this->id) < AUTH_EDIT) { throw new BatcheditAccessControlException($this->id); } $lockedBy = checklock($this->id); if ($lockedBy != FALSE) { throw new BatcheditPageLockedException($this->id, $lockedBy); } lock($this->id); } /** * */ private function rollbackMatches() { foreach ($this->matches as $match) { $match->rollback(); } } } class BatcheditSessionCache { const PRUNE_PERIOD = 3600; private $expirationTime; /** * */ public static function getFileName($name, $ext = '') { global $conf; return $conf['cachedir'] . '/batchedit/' . $name . (!empty($ext) ? '.' . $ext : ''); } /** * */ public function __construct($expirationTime) { $this->expirationTime = $expirationTime; io_mkdir_p(dirname(self::getFileName('dummy'))); } /** * */ public function __destruct() { $this->prune(); } /** * */ public function save($id, $name, $data) { file_put_contents(self::getFileName($id, $name), serialize($data)); } /** * */ public function load($id, $name) { return @unserialize(file_get_contents(self::getFileName($id, $name))); } /** * */ public function isValid($id) { global $conf; $propsTime = @filemtime(self::getFileName($id, 'props')); $matchesTime = @filemtime(self::getFileName($id, 'matches')); if ($propsTime === FALSE || $matchesTime === FALSE) { return FALSE; } $now = time(); if ($propsTime + $this->expirationTime < $now || $matchesTime + $this->expirationTime < $now) { return FALSE; } $changeLogTime = @filemtime($conf['changelog']); if ($changeLogTime !== FALSE && ($changeLogTime > $propsTime || $changeLogTime > $matchesTime)) { return FALSE; } return TRUE; } /** * */ public function expire($id) { @unlink(self::getFileName($id, 'props')); @unlink(self::getFileName($id, 'matches')); @unlink(self::getFileName($id, 'progress')); @unlink(self::getFileName($id, 'cancel')); } /** * */ private function prune() { $marker = self::getFileName('_prune'); $lastPrune = @filemtime($marker); $now = time(); if ($lastPrune !== FALSE && $lastPrune + self::PRUNE_PERIOD > $now) { return; } $directory = new GlobIterator(self::getFileName('*.*')); $expired = array(); foreach ($directory as $fileInfo) { if ($fileInfo->getMTime() + $this->expirationTime < $now) { $expired[pathinfo($fileInfo->getFilename(), PATHINFO_FILENAME)] = TRUE; } } foreach ($expired as $id => $dummy) { $this->expire($id); } @touch($marker); } } class BatcheditSession { private $id; private $error; private $warnings; private $pages; private $matches; private $edits; private $cache; private static $persistentWarnings = array( 'war_nomatches', 'war_searchlimit' ); /** * */ public function __construct($expirationTime) { $this->id = $this->generateId(); $this->error = NULL; $this->warnings = array(); $this->pages = array(); $this->matches = 0; $this->edits = 0; $this->cache = new BatcheditSessionCache($expirationTime); } /** * */ public function setId($id) { $this->id = $id; @unlink(BatcheditSessionCache::getFileName($id, 'cancel')); } /** * */ public function getId() { return $this->id; } /** * */ public function load($request, $config) { $this->setId($request->getSessionId()); if (!$this->cache->isValid($this->id)) { return FALSE; } $properties = $this->loadArray('props'); if (!is_array($properties) || !empty(array_diff_assoc($properties, $this->getProperties($request, $config)))) { return FALSE; } $matches = $this->loadArray('matches'); if (!is_array($matches)) { return FALSE; } list($warnings, $this->matches, $this->pages) = $matches; $this->warnings = array_filter($warnings, function ($message) { return in_array($message->getId(), self::$persistentWarnings); }); return TRUE; } /** * */ public function save($request, $config) { $this->saveArray('props', $this->getProperties($request, $config)); $this->saveArray('matches', array($this->warnings, $this->matches, $this->pages)); } /** * */ public function expire() { $this->cache->expire($this->id); } /** * */ public function setError($error) { $this->error = new BatcheditErrorMessage($error->getArguments()); $this->pages = array(); $this->matches = 0; $this->edits = 0; } /** * Accepts BatcheditException instance or message id followed by optional arguments. */ public function addWarning($warning) { if ($warning instanceof BatcheditException) { $this->warnings[] = new BatcheditWarningMessage($warning->getArguments()); } else { $this->warnings[] = new BatcheditWarningMessage(func_get_args()); } } /** * */ public function getMessages() { if ($this->error != NULL) { return array($this->error); } return $this->warnings; } /** * */ public function addPage($page) { $this->pages[$page->getId()] = $page; $this->matches += count($page->getMatches()); } /** * */ public function getPages() { return $this->pages; } /** * */ public function getCachedPages() { if (!$this->cache->isValid($this->id)) { return array(); } $matches = $this->loadArray('matches'); return is_array($matches) ? $matches[2] : array(); } /** * */ public function getPageCount() { return count($this->pages); } /** * */ public function getMatchCount() { return $this->matches; } /** * */ public function addEdits($edits) { $this->edits += $edits; } /** * */ public function getEditCount() { return $this->edits; } /** * */ private function generateId() { global $USERINFO; $time = gettimeofday(); return md5($time['sec'] . $time['usec'] . $USERINFO['name'] . $USERINFO['mail']); } /** * */ private function getProperties($request, $config) { global $USERINFO; $properties = array(); $properties['username'] = $USERINFO['name']; $properties['usermail'] = $USERINFO['mail']; $properties['namespace'] = $request->getNamespace(); $properties['regexp'] = $request->getRegexp(); $properties['replacement'] = $request->getReplacement(); $properties['searchlimit'] = $config->getConf('searchlimit') ? $config->getConf('searchmax') : 0; $properties['matchctx'] = $config->getConf('matchctx') ? $config->getConf('ctxchars') . ',' . $config->getConf('ctxlines') : 0; $properties['tplpatterns'] = $config->getConf('tplpatterns'); return $properties; } /** * */ private function saveArray($name, $array) { $this->cache->save($this->id, $name, $array); } /** * */ private function loadArray($name) { return $this->cache->load($this->id, $name); } } abstract class BatcheditMarkPolicy { protected $pages; /** * */ public function __construct($pages) { $this->pages = $pages; } /** * */ abstract public function markMatch($pageId, $offset); } class BatcheditMarkPolicyVerifyBoth extends BatcheditMarkPolicy { protected $cache; /** * */ public function __construct($pages, $cache) { parent::__construct($pages); $this->cache = $cache; } /** * */ public function markMatch($pageId, $offset) { if (!array_key_exists($pageId, $this->pages) || !array_key_exists($pageId, $this->cache)) { return; } $matches = $this->pages[$pageId]->getMatches(); $cache = $this->cache[$pageId]->getMatches(); if (!array_key_exists($offset, $matches) || !array_key_exists($offset, $cache)) { return; } if ($this->compareMatches($matches[$offset], $cache[$offset])) { $this->pages[$pageId]->markMatch($offset); } } /** * */ protected function compareMatches($match, $cache) { return $match->getOriginalText() == $cache->getOriginalText() && $match->getReplacedText() == $cache->getReplacedText(); } } class BatcheditMarkPolicyVerifyMatched extends BatcheditMarkPolicyVerifyBoth { /** * */ protected function compareMatches($match, $cache) { return $match->getOriginalText() == $cache->getOriginalText(); } } class BatcheditMarkPolicyVerifyOffset extends BatcheditMarkPolicy { /** * */ public function markMatch($pageId, $offset) { if (array_key_exists($pageId, $this->pages)) { $this->pages[$pageId]->markMatch($offset); } } } class BatcheditMarkPolicyVerifyContext extends BatcheditMarkPolicy { /** * */ public function markMatch($pageId, $offset) { if (!array_key_exists($pageId, $this->pages)) { return; } if (array_key_exists($offset, $this->pages[$pageId]->getMatches())) { $this->pages[$pageId]->markMatch($offset); return; } $minDelta = PHP_INT_MAX; $minOffset = -1; foreach ($this->pages[$pageId]->getMatches() as $match) { $matchOffset = $match->getPageOffset(); if ($offset < $matchOffset - strlen($match->getContextBefore())) { continue; } if ($offset >= $matchOffset + strlen($match->getOriginalText()) + strlen($match->getContextAfter())) { continue; } $delta = abs($matchOffset - $offset); if ($delta >= $minDelta) { break; } $minDelta = $delta; $minOffset = $matchOffset; } if ($minDelta != PHP_INT_MAX) { $this->pages[$pageId]->markMatch($minOffset); } } } class BatcheditProgress { const UNKNOWN = 0; const SEARCH = 1; const APPLY = 2; const SCALE = 1000; const SAVE_PERIOD = 0.25; private $fileName; private $operation; private $range; private $progress; private $lastSave; /** * */ public function __construct($sessionId, $operation = self::UNKNOWN, $range = 0) { $this->fileName = BatcheditSessionCache::getFileName($sessionId, 'progress'); $this->operation = $operation; $this->range = $range; $this->progress = 0; $this->lastSave = 0; if ($this->operation != self::UNKNOWN && $this->range > 0) { $this->save(); } } /** * */ public function update($progressDelta = 1) { $this->progress += $progressDelta; if (microtime(TRUE) > $this->lastSave + self::SAVE_PERIOD) { $this->save(); } } /** * */ public function get() { $progress = @filesize($this->fileName); if ($progress === FALSE) { return array(self::UNKNOWN, 0); } if ($progress <= self::SCALE) { return array(self::SEARCH, $progress); } return array(self::APPLY, $progress - self::SCALE); } /** * */ private function save() { $progress = max(round(self::SCALE * $this->progress / $this->range), 1); if ($this->operation == self::APPLY) { $progress += self::SCALE; } @file_put_contents($this->fileName, str_pad('', $progress, '.')); $this->lastSave = microtime(TRUE); } } class BatcheditEngine { const VERIFY_BOTH = 1; const VERIFY_MATCHED = 2; const VERIFY_OFFSET = 3; const VERIFY_CONTEXT = 4; // These constants are used to take into account the time that plugin spends outside // of the engine. For example, this can be time spent by DokuWiki itself, time for // request parsing, session loading and saving, etc. const NON_ENGINE_TIME_RATIO = 0.1; const NON_ENGINE_TIME_MAX = 5; private $session; private $startTime; private $timeLimit; /** * */ public static function cancelOperation($sessionId) { @touch(BatcheditSessionCache::getFileName($sessionId, 'cancel')); } /** * */ public function __construct($session) { $this->session = $session; $this->startTime = time(); $this->timeLimit = $this->getTimeLimit(); } /** * */ public function findMatches($namespace, $regexp, $replacement, $limit, $contextChars, $contextLines, $applyTemplatePatterns) { $index = $this->getPageIndex($namespace); $progress = new BatcheditProgress($this->session->getId(), BatcheditProgress::SEARCH, count($index)); foreach ($index as $pageId) { $page = new BatcheditPage(trim($pageId)); $interrupted = $page->findMatches($regexp, $replacement, $limit - $this->session->getMatchCount(), $contextChars, $contextLines, $applyTemplatePatterns); if (count($page->getMatches()) > 0) { $this->session->addPage($page); } $progress->update(); if ($interrupted) { $this->session->addWarning('war_searchlimit'); break; } if ($this->isOperationTimedOut()) { $this->session->addWarning('war_timeout'); break; } if ($this->isOperationCancelled()) { $this->session->addWarning('war_cancelled'); break; } } if ($this->session->getMatchCount() == 0) { $this->session->addWarning('war_nomatches'); } } /** * */ public function markRequestedMatches($request, $policy = self::VERIFY_OFFSET) { switch ($policy) { case self::VERIFY_BOTH: $policy = new BatcheditMarkPolicyVerifyBoth($this->session->getPages(), $this->session->getCachedPages()); break; case self::VERIFY_MATCHED: $policy = new BatcheditMarkPolicyVerifyMatched($this->session->getPages(), $this->session->getCachedPages()); break; case self::VERIFY_OFFSET: $policy = new BatcheditMarkPolicyVerifyOffset($this->session->getPages()); break; case self::VERIFY_CONTEXT: $policy = new BatcheditMarkPolicyVerifyContext($this->session->getPages()); break; } foreach ($request as $matchId) { list($pageId, $offset) = explode('#', $matchId); $policy->markMatch($pageId, $offset); } } /** * */ public function applyMatches($summary, $minorEdit) { $progress = new BatcheditProgress($this->session->getId(), BatcheditProgress::APPLY, array_reduce($this->session->getPages(), function ($marks, $page) { return $marks + ($page->hasMarkedMatches() && $page->hasUnappliedMatches() ? 1 : 0); }, 0)); foreach ($this->session->getPages() as $page) { if (!$page->hasMarkedMatches() || !$page->hasUnappliedMatches()) { continue; } try { $this->session->addEdits($page->applyMatches($summary, $minorEdit)); } catch (BatcheditPageApplyException $error) { $this->session->addWarning($error); } $progress->update(); if ($this->isOperationTimedOut()) { $this->session->addWarning('war_timeout'); break; } if ($this->isOperationCancelled()) { $this->session->addWarning('war_cancelled'); break; } } } /** * */ private function getPageIndex($namespace) { global $conf; if (!@file_exists($conf['indexdir'] . '/page.idx')) { throw new BatcheditException('err_idxaccess'); } require_once(DOKU_INC . 'inc/indexer.php'); $index = idx_getIndex('page', ''); if (count($index) == 0) { throw new BatcheditException('err_emptyidx'); } if ($namespace != '') { $positiveFilter = array(); $negativeFilter = array(); foreach (explode(',', $namespace) as $ns) { $negative = false; if ($ns[0] == '-') { $negative = true; $ns = substr($ns, 1); } if ($ns == ':') { $ns = "[^:]+$"; } if ($negative) { $negativeFilter[] = $ns; } else { $positiveFilter[] = $ns; } } if (!empty($positiveFilter)) { $positiveFilter = "\033^(?:" . implode('|', $positiveFilter) . ")\033"; } if (!empty($negativeFilter)) { $negativeFilter = "\033^(?:" . implode('|', $negativeFilter) . ")\033"; } $index = array_filter($index, function ($pageId) use ($positiveFilter, $negativeFilter) { $matched = true; if (!empty($positiveFilter)) { $matched = preg_match($positiveFilter, $pageId) == 1; } if ($matched && !empty($negativeFilter)) { $matched = preg_match($negativeFilter, $pageId) == 0; } return $matched; }); if (count($index) == 0) { throw new BatcheditEmptyNamespaceException($namespace); } } return $index; } /** * */ private function getTimeLimit() { $timeLimit = ini_get('max_execution_time'); $timeLimit -= ceil(min($timeLimit * self::NON_ENGINE_TIME_RATIO, self::NON_ENGINE_TIME_MAX)); return $timeLimit; } /** * */ private function isOperationTimedOut() { // On different systems max_execution_time can be used in diffenent ways: it can track // either real time or only user time excluding all system calls. Here we enforce real // time limit, which could be more strict then what PHP would do, but is easier to // implement in a cross-platform way and easier for a user to understand. return time() - $this->startTime >= $this->timeLimit; } /** * */ private function isOperationCancelled() { return file_exists(BatcheditSessionCache::getFileName($this->session->getId(), 'cancel')); } }