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