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 = utf8_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 = utf8_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) {
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        for ($i = 0; $i < $count; $i++) {
280            $this->addMatch($text, $match[$i][0][1], $match[$i][0][0], $regexp, $replacement,
281                    $contextChars, $contextLines);
282        }
283
284        return $interrupted;
285    }
286
287    /**
288     *
289     */
290    public function getMatches() {
291        return $this->matches;
292    }
293
294    /**
295     *
296     */
297    public function markMatch($offset) {
298        if (array_key_exists($offset, $this->matches)) {
299            $this->matches[$offset]->mark();
300        }
301    }
302
303    /**
304     *
305     */
306    public function hasMarkedMatches() {
307        $result = FALSE;
308
309        foreach ($this->matches as $match) {
310            if ($match->isMarked()) {
311                $result = TRUE;
312                break;
313            }
314        }
315
316        return $result;
317    }
318
319    /**
320     *
321     */
322    public function hasUnmarkedMatches() {
323        $result = FALSE;
324
325        foreach ($this->matches as $match) {
326            if (!$match->isMarked()) {
327                $result = TRUE;
328                break;
329            }
330        }
331
332        return $result;
333    }
334
335    /**
336     *
337     */
338    public function hasUnappliedMatches() {
339        $result = FALSE;
340
341        foreach ($this->matches as $match) {
342            if (!$match->isApplied()) {
343                $result = TRUE;
344                break;
345            }
346        }
347
348        return $result;
349    }
350
351    /**
352     *
353     */
354    public function applyMatches($summary, $minorEdit) {
355        try {
356            $this->lock();
357
358            $text = rawWiki($this->id);
359            $originalLength = strlen($text);
360            $count = 0;
361
362            foreach ($this->matches as $match) {
363                if ($match->isMarked()) {
364                    $text = $match->apply($text, strlen($text) - $originalLength);
365                    $count++;
366                }
367            }
368
369            saveWikiText($this->id, $text, $summary, $minorEdit);
370            unlock($this->id);
371        }
372        catch (Exception $error) {
373            $this->rollbackMatches();
374
375            if ($error instanceof BatcheditMatchApplyException) {
376                $error = new BatcheditMatchApplyException($this->id . $error->getArguments()[1]);
377            }
378
379            throw $error;
380        }
381
382        return $count;
383    }
384
385    /**
386     *
387     */
388    public function serialize() {
389        return serialize(array($this->id, $this->matches));
390    }
391
392    /**
393     *
394     */
395    public function unserialize($data) {
396        list($this->id, $this->matches) = unserialize($data);
397    }
398
399    /**
400     *
401     */
402    private function addMatch($text, $offset, $matched, $regexp, $replacement, $contextChars, $contextLines) {
403        $this->matches[$offset] = new BatcheditMatch($text, $offset, $matched, $regexp, $replacement, $contextChars, $contextLines);
404    }
405
406    /**
407     *
408     */
409    private function lock() {
410        if (auth_quickaclcheck($this->id) < AUTH_EDIT) {
411            throw new BatcheditAccessControlException($this->id);
412        }
413
414        $lockedBy = checklock($this->id);
415
416        if ($lockedBy != FALSE) {
417            throw new BatcheditPageLockedException($this->id, $lockedBy);
418        }
419
420        lock($this->id);
421    }
422
423    /**
424     *
425     */
426    private function rollbackMatches() {
427        foreach ($this->matches as $match) {
428            $match->rollback();
429        }
430    }
431}
432
433class BatcheditSessionCache {
434
435    const PRUNE_PERIOD = 3600;
436
437    private $expirationTime;
438
439    /**
440     *
441     */
442    public static function getFileName($name, $ext = '') {
443        global $conf;
444
445        return $conf['cachedir'] . '/batchedit/' . $name . (!empty($ext) ? '.' . $ext : '');
446    }
447
448    /**
449     *
450     */
451    public function __construct($expirationTime) {
452        $this->expirationTime = $expirationTime;
453
454        io_mkdir_p(dirname(self::getFileName('dummy')));
455    }
456
457    /**
458     *
459     */
460    public function __destruct() {
461        $this->prune();
462    }
463
464    /**
465     *
466     */
467    public function save($id, $name, $data) {
468        file_put_contents(self::getFileName($id, $name), serialize($data));
469    }
470
471    /**
472     *
473     */
474    public function load($id, $name) {
475        return @unserialize(file_get_contents(self::getFileName($id, $name)));
476    }
477
478    /**
479     *
480     */
481    public function isValid($id) {
482        global $conf;
483
484        $propsTime = @filemtime(self::getFileName($id, 'props'));
485        $matchesTime = @filemtime(self::getFileName($id, 'matches'));
486
487        if ($propsTime === FALSE || $matchesTime === FALSE) {
488            return FALSE;
489        }
490
491        $now = time();
492
493        if ($propsTime + $this->expirationTime < $now || $matchesTime + $this->expirationTime < $now) {
494            return FALSE;
495        }
496
497        $changeLogTime = @filemtime($conf['changelog']);
498
499        if ($changeLogTime !== FALSE && ($changeLogTime > $propsTime || $changeLogTime > $matchesTime)) {
500            return FALSE;
501        }
502
503        return TRUE;
504    }
505
506    /**
507     *
508     */
509    public function expire($id) {
510        @unlink(self::getFileName($id, 'props'));
511        @unlink(self::getFileName($id, 'matches'));
512        @unlink(self::getFileName($id, 'progress'));
513        @unlink(self::getFileName($id, 'cancel'));
514    }
515
516    /**
517     *
518     */
519    private function prune() {
520        $marker = self::getFileName('_prune');
521        $lastPrune = @filemtime($marker);
522        $now = time();
523
524        if ($lastPrune !== FALSE && $lastPrune + self::PRUNE_PERIOD > $now) {
525            return;
526        }
527
528        $directory = new GlobIterator(self::getFileName('*.*'));
529        $expired = array();
530
531        foreach ($directory as $fileInfo) {
532            if ($fileInfo->getMTime() + $this->expirationTime < $now) {
533                $expired[pathinfo($fileInfo->getFilename(), PATHINFO_FILENAME)] = TRUE;
534            }
535        }
536
537        foreach ($expired as $id => $dummy) {
538            $this->expire($id);
539        }
540
541        @touch($marker);
542    }
543}
544
545class BatcheditSession {
546
547    private $id;
548    private $error;
549    private $warnings;
550    private $pages;
551    private $matches;
552    private $edits;
553    private $cache;
554
555    private static $persistentWarnings = array(
556        'war_nomatches',
557        'war_searchlimit'
558    );
559
560    /**
561     *
562     */
563    public function __construct($expirationTime) {
564        $this->id = $this->generateId();
565        $this->error = NULL;
566        $this->warnings = array();
567        $this->pages = array();
568        $this->matches = 0;
569        $this->edits = 0;
570        $this->cache = new BatcheditSessionCache($expirationTime);
571    }
572
573    /**
574     *
575     */
576    public function setId($id) {
577        $this->id = $id;
578
579        @unlink(BatcheditSessionCache::getFileName($id, 'cancel'));
580    }
581
582    /**
583     *
584     */
585    public function getId() {
586        return $this->id;
587    }
588
589    /**
590     *
591     */
592    public function load($request, $config) {
593        $this->setId($request->getSessionId());
594
595        if (!$this->cache->isValid($this->id)) {
596            return FALSE;
597        }
598
599        $properties = $this->loadArray('props');
600
601        if (!is_array($properties) || !empty(array_diff_assoc($properties, $this->getProperties($request, $config)))) {
602            return FALSE;
603        }
604
605        $matches = $this->loadArray('matches');
606
607        if (!is_array($matches)) {
608            return FALSE;
609        }
610
611        list($warnings, $this->matches, $this->pages) = $matches;
612
613        $this->warnings = array_filter($warnings, function ($message) {
614            return in_array($message->getId(), self::$persistentWarnings);
615        });
616
617        return TRUE;
618    }
619
620    /**
621     *
622     */
623    public function save($request, $config) {
624        $this->saveArray('props', $this->getProperties($request, $config));
625        $this->saveArray('matches', array($this->warnings, $this->matches, $this->pages));
626    }
627
628    /**
629     *
630     */
631    public function expire() {
632        $this->cache->expire($this->id);
633    }
634
635    /**
636     *
637     */
638    public function setError($error) {
639        $this->error = new BatcheditErrorMessage($error->getArguments());
640        $this->pages = array();
641        $this->matches = 0;
642        $this->edits = 0;
643    }
644
645    /**
646     * Accepts BatcheditException instance or message id followed by optional arguments.
647     */
648    public function addWarning($warning) {
649        if ($warning instanceof BatcheditException) {
650            $this->warnings[] = new BatcheditWarningMessage($warning->getArguments());
651        }
652        else {
653            $this->warnings[] = new BatcheditWarningMessage(func_get_args());
654        }
655    }
656
657    /**
658     *
659     */
660    public function getMessages() {
661        if ($this->error != NULL) {
662            return array($this->error);
663        }
664
665        return $this->warnings;
666    }
667
668    /**
669     *
670     */
671    public function addPage($page) {
672        $this->pages[$page->getId()] = $page;
673        $this->matches += count($page->getMatches());
674    }
675
676    /**
677     *
678     */
679    public function getPages() {
680        return $this->pages;
681    }
682
683    /**
684     *
685     */
686    public function getCachedPages() {
687        if (!$this->cache->isValid($this->id)) {
688            return array();
689        }
690
691        $matches = $this->loadArray('matches');
692
693        return is_array($matches) ? $matches[2] : array();
694    }
695
696    /**
697     *
698     */
699    public function getPageCount() {
700        return count($this->pages);
701    }
702
703    /**
704     *
705     */
706    public function getMatchCount() {
707        return $this->matches;
708    }
709
710    /**
711     *
712     */
713    public function addEdits($edits) {
714        $this->edits += $edits;
715    }
716
717    /**
718     *
719     */
720    public function getEditCount() {
721        return $this->edits;
722    }
723
724    /**
725     *
726     */
727    private function generateId() {
728        global $USERINFO;
729
730        $time = gettimeofday();
731
732        return md5($time['sec'] . $time['usec'] . $USERINFO['name'] . $USERINFO['mail']);
733    }
734
735    /**
736     *
737     */
738    private function getProperties($request, $config) {
739        global $USERINFO;
740
741        $properties = array();
742
743        $properties['username'] = $USERINFO['name'];
744        $properties['usermail'] = $USERINFO['mail'];
745        $properties['namespace'] = $request->getNamespace();
746        $properties['regexp'] = $request->getRegexp();
747        $properties['replacement'] = $request->getReplacement();
748        $properties['searchlimit'] = $config->getConf('searchlimit') ? $config->getConf('searchmax') : 0;
749        $properties['matchctx'] = $config->getConf('matchctx') ? $config->getConf('ctxchars') . ',' . $config->getConf('ctxlines') : 0;
750
751        return $properties;
752    }
753
754    /**
755     *
756     */
757    private function saveArray($name, $array) {
758        $this->cache->save($this->id, $name, $array);
759    }
760
761    /**
762     *
763     */
764    private function loadArray($name) {
765        return $this->cache->load($this->id, $name);
766    }
767}
768
769abstract class BatcheditMarkPolicy {
770
771    protected $pages;
772
773    /**
774     *
775     */
776    public function __construct($pages) {
777        $this->pages = $pages;
778    }
779
780    /**
781     *
782     */
783    abstract public function markMatch($pageId, $offset);
784}
785
786class BatcheditMarkPolicyVerifyBoth extends BatcheditMarkPolicy {
787
788    protected $cache;
789
790    /**
791     *
792     */
793    public function __construct($pages, $cache) {
794        parent::__construct($pages);
795
796        $this->cache = $cache;
797    }
798
799    /**
800     *
801     */
802    public function markMatch($pageId, $offset) {
803        if (!array_key_exists($pageId, $this->pages) || !array_key_exists($pageId, $this->cache)) {
804            return;
805        }
806
807        $matches = $this->pages[$pageId]->getMatches();
808        $cache = $this->cache[$pageId]->getMatches();
809
810        if (!array_key_exists($offset, $matches) || !array_key_exists($offset, $cache)) {
811            return;
812        }
813
814        if ($this->compareMatches($matches[$offset], $cache[$offset])) {
815            $this->pages[$pageId]->markMatch($offset);
816        }
817    }
818
819    /**
820     *
821     */
822    protected function compareMatches($match, $cache) {
823        return $match->getOriginalText() == $cache->getOriginalText() &&
824                $match->getReplacedText() == $cache->getReplacedText();
825    }
826}
827
828class BatcheditMarkPolicyVerifyMatched extends BatcheditMarkPolicyVerifyBoth {
829
830    /**
831     *
832     */
833    protected function compareMatches($match, $cache) {
834        return $match->getOriginalText() == $cache->getOriginalText();
835    }
836}
837
838class BatcheditMarkPolicyVerifyOffset extends BatcheditMarkPolicy {
839
840    /**
841     *
842     */
843    public function markMatch($pageId, $offset) {
844        if (array_key_exists($pageId, $this->pages)) {
845            $this->pages[$pageId]->markMatch($offset);
846        }
847    }
848}
849
850class BatcheditMarkPolicyVerifyContext extends BatcheditMarkPolicy {
851
852    /**
853     *
854     */
855    public function markMatch($pageId, $offset) {
856        if (!array_key_exists($pageId, $this->pages)) {
857            return;
858        }
859
860        if (array_key_exists($offset, $this->pages[$pageId]->getMatches())) {
861            $this->pages[$pageId]->markMatch($offset);
862
863            return;
864        }
865
866        $minDelta = PHP_INT_MAX;
867        $minOffset = -1;
868
869        foreach ($this->pages[$pageId]->getMatches() as $match) {
870            $matchOffset = $match->getPageOffset();
871
872            if ($offset < $matchOffset - strlen($match->getContextBefore())) {
873                continue;
874            }
875
876            if ($offset >= $matchOffset + strlen($match->getOriginalText()) + strlen($match->getContextAfter())) {
877                continue;
878            }
879
880            $delta = abs($matchOffset - $offset);
881
882            if ($delta >= $minDelta) {
883                break;
884            }
885
886            $minDelta = $delta;
887            $minOffset = $matchOffset;
888        }
889
890        if ($minDelta != PHP_INT_MAX) {
891            $this->pages[$pageId]->markMatch($minOffset);
892        }
893    }
894}
895
896class BatcheditProgress {
897
898    const UNKNOWN = 0;
899    const SEARCH = 1;
900    const APPLY = 2;
901    const SCALE = 1000;
902    const SAVE_PERIOD = 0.25;
903
904    private $fileName;
905    private $operation;
906    private $range;
907    private $progress;
908    private $lastSave;
909
910    /**
911     *
912     */
913    public function __construct($sessionId, $operation = self::UNKNOWN, $range = 0) {
914        $this->fileName = BatcheditSessionCache::getFileName($sessionId, 'progress');
915        $this->operation = $operation;
916        $this->range = $range;
917        $this->progress = 0;
918        $this->lastSave = 0;
919
920        if ($this->operation != self::UNKNOWN && $this->range > 0) {
921            $this->save();
922        }
923    }
924
925    /**
926     *
927     */
928    public function update($progressDelta = 1) {
929        $this->progress += $progressDelta;
930
931        if (microtime(TRUE) > $this->lastSave + self::SAVE_PERIOD) {
932            $this->save();
933        }
934    }
935
936    /**
937     *
938     */
939    public function get() {
940        $progress = @filesize($this->fileName);
941
942        if ($progress === FALSE) {
943            return array(self::UNKNOWN, 0);
944        }
945
946        if ($progress <= self::SCALE) {
947            return array(self::SEARCH, $progress);
948        }
949
950        return array(self::APPLY, $progress - self::SCALE);
951    }
952
953    /**
954     *
955     */
956    private function save() {
957        $progress = max(round(self::SCALE * $this->progress / $this->range), 1);
958
959        if ($this->operation == self::APPLY) {
960            $progress += self::SCALE;
961        }
962
963        @file_put_contents($this->fileName, str_pad('', $progress, '.'));
964
965        $this->lastSave = microtime(TRUE);
966    }
967}
968
969class BatcheditEngine {
970
971    const VERIFY_BOTH = 1;
972    const VERIFY_MATCHED = 2;
973    const VERIFY_OFFSET = 3;
974    const VERIFY_CONTEXT = 4;
975
976    // These constants are used to take into account the time that plugin spends outside
977    // of the engine. For example, this can be time spent by DokuWiki itself, time for
978    // request parsing, session loading and saving, etc.
979    const NON_ENGINE_TIME_RATIO = 0.1;
980    const NON_ENGINE_TIME_MAX = 5;
981
982    private $session;
983    private $startTime;
984    private $timeLimit;
985
986    /**
987     *
988     */
989    public static function cancelOperation($sessionId) {
990        @touch(BatcheditSessionCache::getFileName($sessionId, 'cancel'));
991    }
992
993    /**
994     *
995     */
996    public function __construct($session) {
997        $this->session = $session;
998        $this->startTime = time();
999        $this->timeLimit = $this->getTimeLimit();
1000    }
1001
1002    /**
1003     *
1004     */
1005    public function findMatches($namespace, $regexp, $replacement, $limit, $contextChars, $contextLines) {
1006        $index = $this->getPageIndex($namespace);
1007        $progress = new BatcheditProgress($this->session->getId(), BatcheditProgress::SEARCH, count($index));
1008
1009        foreach ($index as $pageId) {
1010            $page = new BatcheditPage(trim($pageId));
1011            $interrupted = $page->findMatches($regexp, $replacement, $limit - $this->session->getMatchCount(),
1012                    $contextChars, $contextLines);
1013
1014            if (count($page->getMatches()) > 0) {
1015                $this->session->addPage($page);
1016            }
1017
1018            $progress->update();
1019
1020            if ($interrupted) {
1021                $this->session->addWarning('war_searchlimit');
1022                break;
1023            }
1024
1025            if ($this->isOperationTimedOut()) {
1026                $this->session->addWarning('war_timeout');
1027                break;
1028            }
1029
1030            if ($this->isOperationCancelled()) {
1031                $this->session->addWarning('war_cancelled');
1032                break;
1033            }
1034        }
1035
1036        if ($this->session->getMatchCount() == 0) {
1037            $this->session->addWarning('war_nomatches');
1038        }
1039    }
1040
1041    /**
1042     *
1043     */
1044    public function markRequestedMatches($request, $policy = self::VERIFY_OFFSET) {
1045        switch ($policy) {
1046            case self::VERIFY_BOTH:
1047                $policy = new BatcheditMarkPolicyVerifyBoth($this->session->getPages(), $this->session->getCachedPages());
1048                break;
1049
1050            case self::VERIFY_MATCHED:
1051                $policy = new BatcheditMarkPolicyVerifyMatched($this->session->getPages(), $this->session->getCachedPages());
1052                break;
1053
1054            case self::VERIFY_OFFSET:
1055                $policy = new BatcheditMarkPolicyVerifyOffset($this->session->getPages());
1056                break;
1057
1058            case self::VERIFY_CONTEXT:
1059                $policy = new BatcheditMarkPolicyVerifyContext($this->session->getPages());
1060                break;
1061        }
1062
1063        foreach ($request as $matchId) {
1064            list($pageId, $offset) = explode('#', $matchId);
1065
1066            $policy->markMatch($pageId, $offset);
1067        }
1068    }
1069
1070    /**
1071     *
1072     */
1073    public function applyMatches($summary, $minorEdit) {
1074        $progress = new BatcheditProgress($this->session->getId(), BatcheditProgress::APPLY,
1075                array_reduce($this->session->getPages(), function ($marks, $page) {
1076                    return $marks + ($page->hasMarkedMatches() && $page->hasUnappliedMatches() ? 1 : 0);
1077                }, 0));
1078
1079        foreach ($this->session->getPages() as $page) {
1080            if (!$page->hasMarkedMatches() || !$page->hasUnappliedMatches()) {
1081                continue;
1082            }
1083
1084            try {
1085                $this->session->addEdits($page->applyMatches($summary, $minorEdit));
1086            }
1087            catch (BatcheditPageApplyException $error) {
1088                $this->session->addWarning($error);
1089            }
1090
1091            $progress->update();
1092
1093            if ($this->isOperationTimedOut()) {
1094                $this->session->addWarning('war_timeout');
1095                break;
1096            }
1097
1098            if ($this->isOperationCancelled()) {
1099                $this->session->addWarning('war_cancelled');
1100                break;
1101            }
1102        }
1103    }
1104
1105    /**
1106     *
1107     */
1108    private function getPageIndex($namespace) {
1109        global $conf;
1110
1111        if (!@file_exists($conf['indexdir'] . '/page.idx')) {
1112            throw new BatcheditException('err_idxaccess');
1113        }
1114
1115        require_once(DOKU_INC . 'inc/indexer.php');
1116
1117        $index = idx_getIndex('page', '');
1118
1119        if (count($index) == 0) {
1120            throw new BatcheditException('err_emptyidx');
1121        }
1122
1123        if ($namespace != '') {
1124            if ($namespace == ':') {
1125                $pattern = "\033^[^:]+$\033";
1126            }
1127            else {
1128                $pattern = "\033^" . $namespace . "\033";
1129            }
1130
1131            $index = array_filter($index, function ($pageId) use ($pattern) {
1132                return preg_match($pattern, $pageId) == 1;
1133            });
1134
1135            if (count($index) == 0) {
1136                throw new BatcheditEmptyNamespaceException($namespace);
1137            }
1138        }
1139
1140        return $index;
1141    }
1142
1143    /**
1144     *
1145     */
1146    private function getTimeLimit() {
1147        $timeLimit = ini_get('max_execution_time');
1148        $timeLimit -= ceil(min($timeLimit * self::NON_ENGINE_TIME_RATIO, self::NON_ENGINE_TIME_MAX));
1149
1150        return $timeLimit;
1151    }
1152
1153    /**
1154     *
1155     */
1156    private function isOperationTimedOut() {
1157        // On different systems max_execution_time can be used in diffenent ways: it can track
1158        // either real time or only user time excluding all system calls. Here we enforce real
1159        // time limit, which could be more strict then what PHP would do, but is easier to
1160        // implement in a cross-platform way and easier for a user to understand.
1161        return time() - $this->startTime >= $this->timeLimit;
1162    }
1163
1164    /**
1165     *
1166     */
1167    private function isOperationCancelled() {
1168        return file_exists(BatcheditSessionCache::getFileName($this->session->getId(), 'cancel'));
1169    }
1170}
1171