1<?php 2 3/** 4 * Plugin BatchEdit: Search and replace engine 5 * 6 * @license GPL 2 (http://www.gnu.org/licenses/gpl.html) 7 * @author Mykola Ostrovskyy <dwpforge@gmail.com> 8 */ 9 10require_once(DOKU_PLUGIN . 'batchedit/interface.php'); 11 12class BatcheditException extends Exception { 13 14 private $arguments; 15 16 /** 17 * Accepts message id followed by optional arguments. 18 */ 19 public function __construct($messageId) { 20 parent::__construct($messageId); 21 22 $this->arguments = func_get_args(); 23 } 24 25 /** 26 * 27 */ 28 public function getArguments() { 29 return $this->arguments; 30 } 31} 32 33class BatcheditEmptyNamespaceException extends BatcheditException { 34 35 /** 36 * 37 */ 38 public function __construct($namespace) { 39 parent::__construct('err_emptyns', $namespace); 40 } 41} 42 43class BatcheditPageApplyException extends BatcheditException { 44 45 /** 46 * Accepts message and page ids followed by optional arguments. 47 */ 48 public function __construct($messageId, $pageId) { 49 call_user_func_array('parent::__construct', func_get_args()); 50 } 51} 52 53class BatcheditAccessControlException extends BatcheditPageApplyException { 54 55 /** 56 * 57 */ 58 public function __construct($pageId) { 59 parent::__construct('war_norights', $pageId); 60 } 61} 62 63class BatcheditPageLockedException extends BatcheditPageApplyException { 64 65 /** 66 * 67 */ 68 public function __construct($pageId, $lockedBy) { 69 parent::__construct('war_pagelock', $pageId, $lockedBy); 70 } 71} 72 73class BatcheditMatchApplyException extends BatcheditPageApplyException { 74 75 /** 76 * 77 */ 78 public function __construct($matchId) { 79 parent::__construct('war_matchfail', $matchId); 80 } 81} 82 83class BatcheditMatch implements Serializable { 84 85 private $pageOffset; 86 private $originalText; 87 private $replacedText; 88 private $contextBefore; 89 private $contextAfter; 90 private $marked; 91 private $applied; 92 93 /** 94 * 95 */ 96 public function __construct($pageText, $pageOffset, $text, $regexp, $replacement, $contextChars, $contextLines) { 97 $this->pageOffset = $pageOffset; 98 $this->originalText = $text; 99 $this->replacedText = preg_replace($regexp, $replacement, $text); 100 $this->contextBefore = $this->cropContextBefore($pageText, $pageOffset, $contextChars, $contextLines); 101 $this->contextAfter = $this->cropContextAfter($pageText, $pageOffset + strlen($text), $contextChars, $contextLines); 102 $this->marked = FALSE; 103 $this->applied = FALSE; 104 } 105 106 /** 107 * 108 */ 109 public function getPageOffset() { 110 return $this->pageOffset; 111 } 112 113 /** 114 * 115 */ 116 public function getOriginalText() { 117 return $this->originalText; 118 } 119 120 /** 121 * 122 */ 123 public function getReplacedText() { 124 return $this->replacedText; 125 } 126 127 /** 128 * 129 */ 130 public function getContextBefore() { 131 return $this->contextBefore; 132 } 133 134 /** 135 * 136 */ 137 public function getContextAfter() { 138 return $this->contextAfter; 139 } 140 141 /** 142 * 143 */ 144 public function mark($marked = TRUE) { 145 $this->marked = $marked; 146 } 147 148 /** 149 * 150 */ 151 public function isMarked() { 152 return $this->marked; 153 } 154 155 /** 156 * 157 */ 158 public function apply($pageText, $offsetDelta) { 159 $pageOffset = $this->pageOffset + $offsetDelta; 160 $currentText = substr($pageText, $pageOffset, strlen($this->originalText)); 161 162 if ($currentText != $this->originalText) { 163 throw new BatcheditMatchApplyException('#' . $this->pageOffset); 164 } 165 166 $before = substr($pageText, 0, $pageOffset); 167 $after = substr($pageText, $pageOffset + strlen($this->originalText)); 168 169 $this->applied = TRUE; 170 171 return $before . $this->replacedText . $after; 172 } 173 174 /** 175 * 176 */ 177 public function rollback() { 178 $this->applied = FALSE; 179 } 180 181 /** 182 * 183 */ 184 public function isApplied() { 185 return $this->applied; 186 } 187 188 /** 189 * 190 */ 191 public function serialize() { 192 return serialize(array($this->pageOffset, $this->originalText, $this->replacedText, 193 $this->contextBefore, $this->contextAfter, $this->marked, $this->applied)); 194 } 195 196 /** 197 * 198 */ 199 public function unserialize($data) { 200 list($this->pageOffset, $this->originalText, $this->replacedText, 201 $this->contextBefore, $this->contextAfter, $this->marked, $this->applied) = unserialize($data); 202 } 203 204 /** 205 * 206 */ 207 private function cropContextBefore($pageText, $pageOffset, $contextChars, $contextLines) { 208 if ($contextChars == 0) { 209 return ''; 210 } 211 212 $context = \dokuwiki\Utf8\PhpString::substr(substr($pageText, 0, $pageOffset), -$contextChars); 213 $count = preg_match_all('/\n/', $context, $match, PREG_SET_ORDER | PREG_OFFSET_CAPTURE); 214 215 if ($count > $contextLines) { 216 $context = substr($context, $match[$count - $contextLines - 1][0][1] + 1); 217 } 218 219 return $context; 220 } 221 222 /** 223 * 224 */ 225 private function cropContextAfter($pageText, $pageOffset, $contextChars, $contextLines) { 226 if ($contextChars == 0) { 227 return ''; 228 } 229 230 $context = \dokuwiki\Utf8\PhpString::substr(substr($pageText, $pageOffset), 0, $contextChars); 231 $count = preg_match_all('/\n/', $context, $match, PREG_SET_ORDER | PREG_OFFSET_CAPTURE); 232 233 if ($count > $contextLines) { 234 $context = substr($context, 0, $match[$contextLines][0][1]); 235 } 236 237 return $context; 238 } 239} 240 241class BatcheditPage implements Serializable { 242 243 private $id; 244 private $matches; 245 246 /** 247 * 248 */ 249 public function __construct($id) { 250 $this->id = $id; 251 $this->matches = array(); 252 } 253 254 /** 255 * 256 */ 257 public function getId() { 258 return $this->id; 259 } 260 261 /** 262 * 263 */ 264 public function findMatches($regexp, $replacement, $limit, $contextChars, $contextLines, $applyTemplatePatterns) { 265 $text = rawWiki($this->id); 266 $count = @preg_match_all($regexp, $text, $match, PREG_SET_ORDER | PREG_OFFSET_CAPTURE); 267 268 if ($count === FALSE) { 269 throw new BatcheditException('err_pregfailed'); 270 } 271 272 $interrupted = FALSE; 273 274 if ($limit >= 0 && $count > $limit) { 275 $count = $limit; 276 $interrupted = TRUE; 277 } 278 279 if ($applyTemplatePatterns) { 280 $data = array( 281 'id' => $this->id, 282 'tpl' => $replacement, 283 'tplfile' => '', 284 'doreplace' => true 285 ); 286 287 $replacement = parsePageTemplate($data); 288 } 289 290 for ($i = 0; $i < $count; $i++) { 291 $this->addMatch($text, $match[$i][0][1], $match[$i][0][0], $regexp, $replacement, 292 $contextChars, $contextLines); 293 } 294 295 return $interrupted; 296 } 297 298 /** 299 * 300 */ 301 public function getMatches() { 302 return $this->matches; 303 } 304 305 /** 306 * 307 */ 308 public function markMatch($offset) { 309 if (array_key_exists($offset, $this->matches)) { 310 $this->matches[$offset]->mark(); 311 } 312 } 313 314 /** 315 * 316 */ 317 public function hasMarkedMatches() { 318 $result = FALSE; 319 320 foreach ($this->matches as $match) { 321 if ($match->isMarked()) { 322 $result = TRUE; 323 break; 324 } 325 } 326 327 return $result; 328 } 329 330 /** 331 * 332 */ 333 public function hasUnmarkedMatches() { 334 $result = FALSE; 335 336 foreach ($this->matches as $match) { 337 if (!$match->isMarked()) { 338 $result = TRUE; 339 break; 340 } 341 } 342 343 return $result; 344 } 345 346 /** 347 * 348 */ 349 public function hasUnappliedMatches() { 350 $result = FALSE; 351 352 foreach ($this->matches as $match) { 353 if (!$match->isApplied()) { 354 $result = TRUE; 355 break; 356 } 357 } 358 359 return $result; 360 } 361 362 /** 363 * 364 */ 365 public function applyMatches($summary, $minorEdit) { 366 try { 367 $this->lock(); 368 369 $text = rawWiki($this->id); 370 $originalLength = strlen($text); 371 $count = 0; 372 373 foreach ($this->matches as $match) { 374 if ($match->isMarked()) { 375 $text = $match->apply($text, strlen($text) - $originalLength); 376 $count++; 377 } 378 } 379 380 saveWikiText($this->id, $text, $summary, $minorEdit); 381 unlock($this->id); 382 } 383 catch (Exception $error) { 384 $this->rollbackMatches(); 385 386 if ($error instanceof BatcheditMatchApplyException) { 387 $error = new BatcheditMatchApplyException($this->id . $error->getArguments()[1]); 388 } 389 390 throw $error; 391 } 392 393 return $count; 394 } 395 396 /** 397 * 398 */ 399 public function serialize() { 400 return serialize(array($this->id, $this->matches)); 401 } 402 403 /** 404 * 405 */ 406 public function unserialize($data) { 407 list($this->id, $this->matches) = unserialize($data); 408 } 409 410 /** 411 * 412 */ 413 private function addMatch($text, $offset, $matched, $regexp, $replacement, $contextChars, $contextLines) { 414 $this->matches[$offset] = new BatcheditMatch($text, $offset, $matched, $regexp, $replacement, $contextChars, $contextLines); 415 } 416 417 /** 418 * 419 */ 420 private function lock() { 421 if (auth_quickaclcheck($this->id) < AUTH_EDIT) { 422 throw new BatcheditAccessControlException($this->id); 423 } 424 425 $lockedBy = checklock($this->id); 426 427 if ($lockedBy != FALSE) { 428 throw new BatcheditPageLockedException($this->id, $lockedBy); 429 } 430 431 lock($this->id); 432 } 433 434 /** 435 * 436 */ 437 private function rollbackMatches() { 438 foreach ($this->matches as $match) { 439 $match->rollback(); 440 } 441 } 442} 443 444class BatcheditSessionCache { 445 446 const PRUNE_PERIOD = 3600; 447 448 private $expirationTime; 449 450 /** 451 * 452 */ 453 public static function getFileName($name, $ext = '') { 454 global $conf; 455 456 return $conf['cachedir'] . '/batchedit/' . $name . (!empty($ext) ? '.' . $ext : ''); 457 } 458 459 /** 460 * 461 */ 462 public function __construct($expirationTime) { 463 $this->expirationTime = $expirationTime; 464 465 io_mkdir_p(dirname(self::getFileName('dummy'))); 466 } 467 468 /** 469 * 470 */ 471 public function __destruct() { 472 $this->prune(); 473 } 474 475 /** 476 * 477 */ 478 public function save($id, $name, $data) { 479 file_put_contents(self::getFileName($id, $name), serialize($data)); 480 } 481 482 /** 483 * 484 */ 485 public function load($id, $name) { 486 return @unserialize(file_get_contents(self::getFileName($id, $name))); 487 } 488 489 /** 490 * 491 */ 492 public function isValid($id) { 493 global $conf; 494 495 $propsTime = @filemtime(self::getFileName($id, 'props')); 496 $matchesTime = @filemtime(self::getFileName($id, 'matches')); 497 498 if ($propsTime === FALSE || $matchesTime === FALSE) { 499 return FALSE; 500 } 501 502 $now = time(); 503 504 if ($propsTime + $this->expirationTime < $now || $matchesTime + $this->expirationTime < $now) { 505 return FALSE; 506 } 507 508 $changeLogTime = @filemtime($conf['changelog']); 509 510 if ($changeLogTime !== FALSE && ($changeLogTime > $propsTime || $changeLogTime > $matchesTime)) { 511 return FALSE; 512 } 513 514 return TRUE; 515 } 516 517 /** 518 * 519 */ 520 public function expire($id) { 521 @unlink(self::getFileName($id, 'props')); 522 @unlink(self::getFileName($id, 'matches')); 523 @unlink(self::getFileName($id, 'progress')); 524 @unlink(self::getFileName($id, 'cancel')); 525 } 526 527 /** 528 * 529 */ 530 private function prune() { 531 $marker = self::getFileName('_prune'); 532 $lastPrune = @filemtime($marker); 533 $now = time(); 534 535 if ($lastPrune !== FALSE && $lastPrune + self::PRUNE_PERIOD > $now) { 536 return; 537 } 538 539 $directory = new GlobIterator(self::getFileName('*.*')); 540 $expired = array(); 541 542 foreach ($directory as $fileInfo) { 543 if ($fileInfo->getMTime() + $this->expirationTime < $now) { 544 $expired[pathinfo($fileInfo->getFilename(), PATHINFO_FILENAME)] = TRUE; 545 } 546 } 547 548 foreach ($expired as $id => $dummy) { 549 $this->expire($id); 550 } 551 552 @touch($marker); 553 } 554} 555 556class BatcheditSession { 557 558 private $id; 559 private $error; 560 private $warnings; 561 private $pages; 562 private $matches; 563 private $edits; 564 private $cache; 565 566 private static $persistentWarnings = array( 567 'war_nomatches', 568 'war_searchlimit' 569 ); 570 571 /** 572 * 573 */ 574 public function __construct($expirationTime) { 575 $this->id = $this->generateId(); 576 $this->error = NULL; 577 $this->warnings = array(); 578 $this->pages = array(); 579 $this->matches = 0; 580 $this->edits = 0; 581 $this->cache = new BatcheditSessionCache($expirationTime); 582 } 583 584 /** 585 * 586 */ 587 public function setId($id) { 588 $this->id = $id; 589 590 @unlink(BatcheditSessionCache::getFileName($id, 'cancel')); 591 } 592 593 /** 594 * 595 */ 596 public function getId() { 597 return $this->id; 598 } 599 600 /** 601 * 602 */ 603 public function load($request, $config) { 604 $this->setId($request->getSessionId()); 605 606 if (!$this->cache->isValid($this->id)) { 607 return FALSE; 608 } 609 610 $properties = $this->loadArray('props'); 611 612 if (!is_array($properties) || !empty(array_diff_assoc($properties, $this->getProperties($request, $config)))) { 613 return FALSE; 614 } 615 616 $matches = $this->loadArray('matches'); 617 618 if (!is_array($matches)) { 619 return FALSE; 620 } 621 622 list($warnings, $this->matches, $this->pages) = $matches; 623 624 $this->warnings = array_filter($warnings, function ($message) { 625 return in_array($message->getId(), self::$persistentWarnings); 626 }); 627 628 return TRUE; 629 } 630 631 /** 632 * 633 */ 634 public function save($request, $config) { 635 $this->saveArray('props', $this->getProperties($request, $config)); 636 $this->saveArray('matches', array($this->warnings, $this->matches, $this->pages)); 637 } 638 639 /** 640 * 641 */ 642 public function expire() { 643 $this->cache->expire($this->id); 644 } 645 646 /** 647 * 648 */ 649 public function setError($error) { 650 $this->error = new BatcheditErrorMessage($error->getArguments()); 651 $this->pages = array(); 652 $this->matches = 0; 653 $this->edits = 0; 654 } 655 656 /** 657 * Accepts BatcheditException instance or message id followed by optional arguments. 658 */ 659 public function addWarning($warning) { 660 if ($warning instanceof BatcheditException) { 661 $this->warnings[] = new BatcheditWarningMessage($warning->getArguments()); 662 } 663 else { 664 $this->warnings[] = new BatcheditWarningMessage(func_get_args()); 665 } 666 } 667 668 /** 669 * 670 */ 671 public function getMessages() { 672 if ($this->error != NULL) { 673 return array($this->error); 674 } 675 676 return $this->warnings; 677 } 678 679 /** 680 * 681 */ 682 public function addPage($page) { 683 $this->pages[$page->getId()] = $page; 684 $this->matches += count($page->getMatches()); 685 } 686 687 /** 688 * 689 */ 690 public function getPages() { 691 return $this->pages; 692 } 693 694 /** 695 * 696 */ 697 public function getCachedPages() { 698 if (!$this->cache->isValid($this->id)) { 699 return array(); 700 } 701 702 $matches = $this->loadArray('matches'); 703 704 return is_array($matches) ? $matches[2] : array(); 705 } 706 707 /** 708 * 709 */ 710 public function getPageCount() { 711 return count($this->pages); 712 } 713 714 /** 715 * 716 */ 717 public function getMatchCount() { 718 return $this->matches; 719 } 720 721 /** 722 * 723 */ 724 public function addEdits($edits) { 725 $this->edits += $edits; 726 } 727 728 /** 729 * 730 */ 731 public function getEditCount() { 732 return $this->edits; 733 } 734 735 /** 736 * 737 */ 738 private function generateId() { 739 global $USERINFO; 740 741 $time = gettimeofday(); 742 743 return md5($time['sec'] . $time['usec'] . $USERINFO['name'] . $USERINFO['mail']); 744 } 745 746 /** 747 * 748 */ 749 private function getProperties($request, $config) { 750 global $USERINFO; 751 752 $properties = array(); 753 754 $properties['username'] = $USERINFO['name']; 755 $properties['usermail'] = $USERINFO['mail']; 756 $properties['namespace'] = $request->getNamespace(); 757 $properties['regexp'] = $request->getRegexp(); 758 $properties['replacement'] = $request->getReplacement(); 759 $properties['searchlimit'] = $config->getConf('searchlimit') ? $config->getConf('searchmax') : 0; 760 $properties['matchctx'] = $config->getConf('matchctx') ? $config->getConf('ctxchars') . ',' . $config->getConf('ctxlines') : 0; 761 $properties['tplpatterns'] = $config->getConf('tplpatterns'); 762 763 return $properties; 764 } 765 766 /** 767 * 768 */ 769 private function saveArray($name, $array) { 770 $this->cache->save($this->id, $name, $array); 771 } 772 773 /** 774 * 775 */ 776 private function loadArray($name) { 777 return $this->cache->load($this->id, $name); 778 } 779} 780 781abstract class BatcheditMarkPolicy { 782 783 protected $pages; 784 785 /** 786 * 787 */ 788 public function __construct($pages) { 789 $this->pages = $pages; 790 } 791 792 /** 793 * 794 */ 795 abstract public function markMatch($pageId, $offset); 796} 797 798class BatcheditMarkPolicyVerifyBoth extends BatcheditMarkPolicy { 799 800 protected $cache; 801 802 /** 803 * 804 */ 805 public function __construct($pages, $cache) { 806 parent::__construct($pages); 807 808 $this->cache = $cache; 809 } 810 811 /** 812 * 813 */ 814 public function markMatch($pageId, $offset) { 815 if (!array_key_exists($pageId, $this->pages) || !array_key_exists($pageId, $this->cache)) { 816 return; 817 } 818 819 $matches = $this->pages[$pageId]->getMatches(); 820 $cache = $this->cache[$pageId]->getMatches(); 821 822 if (!array_key_exists($offset, $matches) || !array_key_exists($offset, $cache)) { 823 return; 824 } 825 826 if ($this->compareMatches($matches[$offset], $cache[$offset])) { 827 $this->pages[$pageId]->markMatch($offset); 828 } 829 } 830 831 /** 832 * 833 */ 834 protected function compareMatches($match, $cache) { 835 return $match->getOriginalText() == $cache->getOriginalText() && 836 $match->getReplacedText() == $cache->getReplacedText(); 837 } 838} 839 840class BatcheditMarkPolicyVerifyMatched extends BatcheditMarkPolicyVerifyBoth { 841 842 /** 843 * 844 */ 845 protected function compareMatches($match, $cache) { 846 return $match->getOriginalText() == $cache->getOriginalText(); 847 } 848} 849 850class BatcheditMarkPolicyVerifyOffset extends BatcheditMarkPolicy { 851 852 /** 853 * 854 */ 855 public function markMatch($pageId, $offset) { 856 if (array_key_exists($pageId, $this->pages)) { 857 $this->pages[$pageId]->markMatch($offset); 858 } 859 } 860} 861 862class BatcheditMarkPolicyVerifyContext extends BatcheditMarkPolicy { 863 864 /** 865 * 866 */ 867 public function markMatch($pageId, $offset) { 868 if (!array_key_exists($pageId, $this->pages)) { 869 return; 870 } 871 872 if (array_key_exists($offset, $this->pages[$pageId]->getMatches())) { 873 $this->pages[$pageId]->markMatch($offset); 874 875 return; 876 } 877 878 $minDelta = PHP_INT_MAX; 879 $minOffset = -1; 880 881 foreach ($this->pages[$pageId]->getMatches() as $match) { 882 $matchOffset = $match->getPageOffset(); 883 884 if ($offset < $matchOffset - strlen($match->getContextBefore())) { 885 continue; 886 } 887 888 if ($offset >= $matchOffset + strlen($match->getOriginalText()) + strlen($match->getContextAfter())) { 889 continue; 890 } 891 892 $delta = abs($matchOffset - $offset); 893 894 if ($delta >= $minDelta) { 895 break; 896 } 897 898 $minDelta = $delta; 899 $minOffset = $matchOffset; 900 } 901 902 if ($minDelta != PHP_INT_MAX) { 903 $this->pages[$pageId]->markMatch($minOffset); 904 } 905 } 906} 907 908class BatcheditProgress { 909 910 const UNKNOWN = 0; 911 const SEARCH = 1; 912 const APPLY = 2; 913 const SCALE = 1000; 914 const SAVE_PERIOD = 0.25; 915 916 private $fileName; 917 private $operation; 918 private $range; 919 private $progress; 920 private $lastSave; 921 922 /** 923 * 924 */ 925 public function __construct($sessionId, $operation = self::UNKNOWN, $range = 0) { 926 $this->fileName = BatcheditSessionCache::getFileName($sessionId, 'progress'); 927 $this->operation = $operation; 928 $this->range = $range; 929 $this->progress = 0; 930 $this->lastSave = 0; 931 932 if ($this->operation != self::UNKNOWN && $this->range > 0) { 933 $this->save(); 934 } 935 } 936 937 /** 938 * 939 */ 940 public function update($progressDelta = 1) { 941 $this->progress += $progressDelta; 942 943 if (microtime(TRUE) > $this->lastSave + self::SAVE_PERIOD) { 944 $this->save(); 945 } 946 } 947 948 /** 949 * 950 */ 951 public function get() { 952 $progress = @filesize($this->fileName); 953 954 if ($progress === FALSE) { 955 return array(self::UNKNOWN, 0); 956 } 957 958 if ($progress <= self::SCALE) { 959 return array(self::SEARCH, $progress); 960 } 961 962 return array(self::APPLY, $progress - self::SCALE); 963 } 964 965 /** 966 * 967 */ 968 private function save() { 969 $progress = max(round(self::SCALE * $this->progress / $this->range), 1); 970 971 if ($this->operation == self::APPLY) { 972 $progress += self::SCALE; 973 } 974 975 @file_put_contents($this->fileName, str_pad('', $progress, '.')); 976 977 $this->lastSave = microtime(TRUE); 978 } 979} 980 981class BatcheditEngine { 982 983 const VERIFY_BOTH = 1; 984 const VERIFY_MATCHED = 2; 985 const VERIFY_OFFSET = 3; 986 const VERIFY_CONTEXT = 4; 987 988 // These constants are used to take into account the time that plugin spends outside 989 // of the engine. For example, this can be time spent by DokuWiki itself, time for 990 // request parsing, session loading and saving, etc. 991 const NON_ENGINE_TIME_RATIO = 0.1; 992 const NON_ENGINE_TIME_MAX = 5; 993 994 private $session; 995 private $startTime; 996 private $timeLimit; 997 998 /** 999 * 1000 */ 1001 public static function cancelOperation($sessionId) { 1002 @touch(BatcheditSessionCache::getFileName($sessionId, 'cancel')); 1003 } 1004 1005 /** 1006 * 1007 */ 1008 public function __construct($session) { 1009 $this->session = $session; 1010 $this->startTime = time(); 1011 $this->timeLimit = $this->getTimeLimit(); 1012 } 1013 1014 /** 1015 * 1016 */ 1017 public function findMatches($namespace, $regexp, $replacement, $limit, $contextChars, $contextLines, $applyTemplatePatterns) { 1018 $index = $this->getPageIndex($namespace); 1019 $progress = new BatcheditProgress($this->session->getId(), BatcheditProgress::SEARCH, count($index)); 1020 1021 foreach ($index as $pageId) { 1022 $page = new BatcheditPage(trim($pageId)); 1023 $interrupted = $page->findMatches($regexp, $replacement, $limit - $this->session->getMatchCount(), 1024 $contextChars, $contextLines, $applyTemplatePatterns); 1025 1026 if (count($page->getMatches()) > 0) { 1027 $this->session->addPage($page); 1028 } 1029 1030 $progress->update(); 1031 1032 if ($interrupted) { 1033 $this->session->addWarning('war_searchlimit'); 1034 break; 1035 } 1036 1037 if ($this->isOperationTimedOut()) { 1038 $this->session->addWarning('war_timeout'); 1039 break; 1040 } 1041 1042 if ($this->isOperationCancelled()) { 1043 $this->session->addWarning('war_cancelled'); 1044 break; 1045 } 1046 } 1047 1048 if ($this->session->getMatchCount() == 0) { 1049 $this->session->addWarning('war_nomatches'); 1050 } 1051 } 1052 1053 /** 1054 * 1055 */ 1056 public function markRequestedMatches($request, $policy = self::VERIFY_OFFSET) { 1057 switch ($policy) { 1058 case self::VERIFY_BOTH: 1059 $policy = new BatcheditMarkPolicyVerifyBoth($this->session->getPages(), $this->session->getCachedPages()); 1060 break; 1061 1062 case self::VERIFY_MATCHED: 1063 $policy = new BatcheditMarkPolicyVerifyMatched($this->session->getPages(), $this->session->getCachedPages()); 1064 break; 1065 1066 case self::VERIFY_OFFSET: 1067 $policy = new BatcheditMarkPolicyVerifyOffset($this->session->getPages()); 1068 break; 1069 1070 case self::VERIFY_CONTEXT: 1071 $policy = new BatcheditMarkPolicyVerifyContext($this->session->getPages()); 1072 break; 1073 } 1074 1075 foreach ($request as $matchId) { 1076 list($pageId, $offset) = explode('#', $matchId); 1077 1078 $policy->markMatch($pageId, $offset); 1079 } 1080 } 1081 1082 /** 1083 * 1084 */ 1085 public function applyMatches($summary, $minorEdit) { 1086 $progress = new BatcheditProgress($this->session->getId(), BatcheditProgress::APPLY, 1087 array_reduce($this->session->getPages(), function ($marks, $page) { 1088 return $marks + ($page->hasMarkedMatches() && $page->hasUnappliedMatches() ? 1 : 0); 1089 }, 0)); 1090 1091 foreach ($this->session->getPages() as $page) { 1092 if (!$page->hasMarkedMatches() || !$page->hasUnappliedMatches()) { 1093 continue; 1094 } 1095 1096 try { 1097 $this->session->addEdits($page->applyMatches($summary, $minorEdit)); 1098 } 1099 catch (BatcheditPageApplyException $error) { 1100 $this->session->addWarning($error); 1101 } 1102 1103 $progress->update(); 1104 1105 if ($this->isOperationTimedOut()) { 1106 $this->session->addWarning('war_timeout'); 1107 break; 1108 } 1109 1110 if ($this->isOperationCancelled()) { 1111 $this->session->addWarning('war_cancelled'); 1112 break; 1113 } 1114 } 1115 } 1116 1117 /** 1118 * 1119 */ 1120 private function getPageIndex($namespace) { 1121 global $conf; 1122 1123 if (!@file_exists($conf['indexdir'] . '/page.idx')) { 1124 throw new BatcheditException('err_idxaccess'); 1125 } 1126 1127 require_once(DOKU_INC . 'inc/indexer.php'); 1128 1129 $index = idx_getIndex('page', ''); 1130 1131 if (count($index) == 0) { 1132 throw new BatcheditException('err_emptyidx'); 1133 } 1134 1135 if ($namespace != '') { 1136 $positiveFilter = array(); 1137 $negativeFilter = array(); 1138 1139 foreach (explode(',', $namespace) as $ns) { 1140 $negative = false; 1141 1142 if ($ns[0] == '-') { 1143 $negative = true; 1144 $ns = substr($ns, 1); 1145 } 1146 1147 if ($ns == ':') { 1148 $ns = "[^:]+$"; 1149 } 1150 1151 if ($negative) { 1152 $negativeFilter[] = $ns; 1153 } 1154 else { 1155 $positiveFilter[] = $ns; 1156 } 1157 } 1158 1159 if (!empty($positiveFilter)) { 1160 $positiveFilter = "\033^(?:" . implode('|', $positiveFilter) . ")\033"; 1161 } 1162 1163 if (!empty($negativeFilter)) { 1164 $negativeFilter = "\033^(?:" . implode('|', $negativeFilter) . ")\033"; 1165 } 1166 1167 $index = array_filter($index, function ($pageId) use ($positiveFilter, $negativeFilter) { 1168 $matched = true; 1169 1170 if (!empty($positiveFilter)) { 1171 $matched = preg_match($positiveFilter, $pageId) == 1; 1172 } 1173 1174 if ($matched && !empty($negativeFilter)) { 1175 $matched = preg_match($negativeFilter, $pageId) == 0; 1176 } 1177 1178 return $matched; 1179 }); 1180 1181 if (count($index) == 0) { 1182 throw new BatcheditEmptyNamespaceException($namespace); 1183 } 1184 } 1185 1186 return $index; 1187 } 1188 1189 /** 1190 * 1191 */ 1192 private function getTimeLimit() { 1193 $timeLimit = ini_get('max_execution_time'); 1194 $timeLimit -= ceil(min($timeLimit * self::NON_ENGINE_TIME_RATIO, self::NON_ENGINE_TIME_MAX)); 1195 1196 return $timeLimit; 1197 } 1198 1199 /** 1200 * 1201 */ 1202 private function isOperationTimedOut() { 1203 // On different systems max_execution_time can be used in diffenent ways: it can track 1204 // either real time or only user time excluding all system calls. Here we enforce real 1205 // time limit, which could be more strict then what PHP would do, but is easier to 1206 // implement in a cross-platform way and easier for a user to understand. 1207 return time() - $this->startTime >= $this->timeLimit; 1208 } 1209 1210 /** 1211 * 1212 */ 1213 private function isOperationCancelled() { 1214 return file_exists(BatcheditSessionCache::getFileName($this->session->getId(), 'cancel')); 1215 } 1216} 1217