1<?php
2
3/**
4 * Plugin BatchEdit: User interface
5 *
6 * @license    GPL 2 (http://www.gnu.org/licenses/gpl.html)
7 * @author     Mykola Ostrovskyy <dwpforge@gmail.com>
8 */
9
10class BatcheditMessage implements Serializable {
11    const ERROR = 1;
12    const WARNING = 2;
13
14    public $type;
15    public $data;
16
17    /**
18     *
19     */
20    public function getClass() {
21        switch ($this->type) {
22            case self::ERROR:
23                return 'error';
24            case self::WARNING:
25                return 'notify';
26        }
27
28        return '';
29    }
30
31    /**
32     *
33     */
34    public function getFormatId() {
35        switch ($this->type) {
36            case self::ERROR:
37                return 'msg_error';
38            case self::WARNING:
39                return 'msg_warning';
40        }
41
42        return '';
43    }
44
45    /**
46     *
47     */
48    public function getId() {
49        return $this->data[0];
50    }
51
52    /**
53     *
54     */
55    public function serialize() {
56        return serialize(array($this->type, $this->data));
57    }
58
59    /**
60     *
61     */
62    public function unserialize($data) {
63        list($this->type, $this->data) = unserialize($data);
64    }
65}
66
67class BatcheditErrorMessage extends BatcheditMessage {
68
69    /**
70     * Accepts message array that starts with message id followed by optional arguments.
71     */
72    public function __construct($message) {
73        $this->type = self::ERROR;
74        $this->data = $message;
75    }
76}
77
78class BatcheditWarningMessage extends BatcheditMessage {
79
80    /**
81     * Accepts message array that starts with message id followed by optional arguments.
82     */
83    public function __construct($message) {
84        $this->type = self::WARNING;
85        $this->data = $message;
86    }
87}
88
89class BatcheditInterface {
90
91    private $plugin;
92    private $indent;
93    private $svgCache;
94
95    /**
96     *
97     */
98    public function __construct($plugin) {
99        $this->plugin = $plugin;
100        $this->indent = 0;
101        $this->svgCache = array();
102    }
103
104    /**
105     *
106     */
107    public function configure($config) {
108        foreach ($config->getConfig() as $id => $value) {
109            if (!empty($value) || $value === 0) {
110                $_REQUEST[$id] = $value;
111            }
112            else {
113                unset($_REQUEST[$id]);
114            }
115        }
116    }
117
118    /**
119     *
120     */
121    public function printBeginning($sessionId) {
122        $this->ptln('<!-- batchedit -->');
123        $this->ptln('<div id="batchedit">');
124
125        $this->printJavascriptLang();
126
127        $this->ptln('<form method="post">');
128        $this->ptln('<input type="hidden" name="session" value="' . $sessionId . '" />');
129    }
130
131    /**
132     *
133     */
134    public function printEnding() {
135        $this->ptln('</form>');
136        $this->ptln('</div>');
137        $this->ptln('<!-- /batchedit -->');
138    }
139
140    /**
141     *
142     */
143    public function printMessages($messages) {
144        if (empty($messages)) {
145            return;
146        }
147
148        $this->ptln('<div id="be-messages">', +2);
149
150        foreach ($messages as $message) {
151            $this->ptln('<div class="' . $message->getClass() . '">', +2);
152            $this->ptln($this->getLang($message->getFormatId(), call_user_func_array(array($this, 'getLang'), $message->data)));
153            $this->ptln('</div>', -2);
154        }
155
156        $this->ptln('</div>', -2);
157    }
158
159    /**
160     *
161     */
162    public function printTotalStats($command, $matchCount, $pageCount, $editCount) {
163        $matches = $this->getLangPlural('sts_matches', $matchCount);
164        $pages = $this->getLangPlural('sts_pages', $pageCount);
165
166        switch ($command) {
167            case BatcheditRequest::COMMAND_PREVIEW:
168                $stats = $this->getLang('sts_preview', $matches, $pages);
169                break;
170
171            case BatcheditRequest::COMMAND_APPLY:
172                $edits = $this->getLangPlural('sts_edits', $editCount);
173                $stats = $this->getLang('sts_apply', $matches, $pages, $edits);
174                break;
175        }
176
177        $this->ptln('<div id="be-totalstats"><div>', +2);
178
179        if ($editCount < $matchCount) {
180            $this->printApplyCheckBox('be-applyall', $stats, 'ttl_applyall');
181        }
182        else {
183            $this->ptln($stats);
184        }
185
186        $this->ptln('</div></div>', -2);
187    }
188
189    /**
190     *
191     */
192    public function printMatches($pages) {
193        foreach ($pages as $page) {
194            $this->ptln('<div class="be-file">', +2);
195            $this->printPageStats($page);
196            $this->printPageActions($page->getId());
197            $this->printPageMatches($page);
198            $this->ptln('</div>', -2);
199        }
200    }
201
202    /**
203     *
204     */
205    public function printMainForm($enableApply) {
206        $this->ptln('<div id="be-mainform">', +2);
207
208        $this->ptln('<div>', +2);
209
210        $this->ptln('<div id="be-editboxes">', +2);
211        $this->ptln('<table>', +2);
212        $this->printFormEdit('lbl_ns', 'namespace');
213        $this->printFormEdit('lbl_search', 'search');
214        $this->printFormEdit('lbl_replace', 'replace');
215        $this->printFormEdit('lbl_summary', 'summary');
216        $this->ptln('</table>', -2);
217        $this->ptln('</div>', -2);
218
219        $this->printOptions();
220
221        $this->ptln('</div>', -2);
222
223        // Value for this hidden input is set before submit through jQuery, containing
224        // JSON-encoded list of all checked checkbox ids for single matches.
225        // Consolidating these inputs into a single string variable avoids problems for
226        // huge replacement sets exceeding `max_input_vars` in `php.ini`.
227        $this->ptln('<input type="hidden" name="apply" value="" />');
228
229        $this->ptln('<div id="be-submitbar">', +2);
230        $this->printSubmitButton('cmd[preview]', 'btn_preview');
231        $this->printSubmitButton('cmd[apply]', 'btn_apply', $enableApply);
232        $this->ptln('<div id="be-progressbar">', +2);
233        $this->ptln('<div id="be-progresswrap"><div id="be-progress"></div></div>');
234        $this->printButton('cancel', 'btn_cancel');
235        $this->ptln('</div>', -2);
236        $this->ptln('</div>', -2);
237
238        $this->ptln('</div>', -2);
239    }
240
241    /**
242     *
243     */
244    private function printJavascriptLang() {
245        $this->ptln('<script type="text/javascript">');
246
247        $langIds = array('hnt_textsearch', 'hnt_textreplace', 'hnt_regexpsearch', 'hnt_regexpreplace',
248                'hnt_advregexpsearch', 'war_nosummary');
249        $lang = array();
250
251        foreach ($langIds as $id) {
252            $lang[$id] = $this->getLang($id);
253        }
254
255        $this->ptln('var batcheditLang = ' . json_encode($lang) . ';');
256        $this->ptln('</script>');
257    }
258
259    /**
260     *
261     */
262    private function printApplyCheckBox($id, $label, $title, $checked = FALSE) {
263        $checked = $checked ? ' checked="checked"' : '';
264
265        $this->ptln('<span class="be-apply" title="' . $this->getLang($title) . '">', +2);
266        $this->ptln('<input type="checkbox" id="' . $id . '"' . $checked . ' />');
267        $this->ptln('<label for="' . $id . '">' . $label . '</label>');
268        $this->ptln('</span>', -2);
269    }
270
271    /**
272     *
273     */
274    private function printPageStats($page) {
275        $stats = $this->getLang('sts_page', $page->getId(), $this->getLangPlural('sts_matches', count($page->getMatches())));
276
277        $this->ptln('<div class="be-stats">', +2);
278
279        if ($page->hasUnappliedMatches()) {
280            $this->printApplyCheckBox($page->getId(), $stats, 'ttl_applyfile', !$page->hasUnmarkedMatches());
281        }
282        else {
283            $this->ptln($stats);
284        }
285
286        $this->ptln('</div>', -2);
287    }
288
289    /**
290     *
291     */
292    private function printPageActions($pageId) {
293        $link = wl($pageId);
294
295        $this->ptln('<div class="be-actions">', +2);
296        $this->printAction($link, 'ttl_view', 'file-document');
297        $this->printAction($link . (strpos($link, '?') === FALSE ? '?' : '&') . 'do=edit', 'ttl_edit', 'pencil');
298        $this->printAction('#be-mainform', 'ttl_mainform', 'arrow-down');
299        $this->ptln('</div>', -2);
300    }
301
302    /**
303     *
304     */
305    private function printAction($href, $titleId, $iconId) {
306        $action = '<a class="be-action" href="' . $href . '" title="' . $this->getLang($titleId) . '">';
307        $action .= $this->getSvg($iconId);
308        $action .= '</a>';
309
310        $this->ptln($action);
311    }
312
313    /**
314     *
315     */
316    private function printPageMatches($page) {
317        foreach ($page->getMatches() as $match) {
318            $this->ptln('<div class="be-match">', +2);
319            $this->printMatchHeader($page->getId(), $match);
320            $this->printMatchTable($match);
321            $this->ptln('</div>', -2);
322        }
323    }
324
325    /**
326     *
327     */
328    private function printMatchHeader($pageId, $match) {
329        $id = $pageId . '#' . $match->getPageOffset();
330
331        $this->ptln('<div class="be-matchid">', +2);
332
333        if (!$match->isApplied()) {
334            $this->printApplyCheckBox($id, $id, 'ttl_applymatch', $match->isMarked());
335        }
336        else {
337            // Add hidden checked checkbox to ensure that marked status is not lost on
338            // applied matches if application is performed in multiple rounds. This can
339            // be the case when one apply command is timed out and user issues a second
340            // one to apply the remaining matches.
341            $this->ptln('<input type="checkbox" id="' . $id . '" checked="checked" style="display:none;" />');
342            $this->ptln($id);
343        }
344
345        $this->ptln('</div>', -2);
346    }
347
348    /**
349     *
350     */
351    private function printMatchTable($match) {
352        $original = $this->prepareText($match->getOriginalText(), $match->isApplied() ? ' be-replaced' : 'be-preview');
353        $replaced = $this->prepareText($match->getReplacedText(), $match->isApplied() ? ' be-applied' : 'be-preview');
354        $before = $this->prepareText($match->getContextBefore());
355        $after = $this->prepareText($match->getContextAfter());
356
357        $this->ptln('<table><tr>', +2);
358        $this->ptln('<td class="be-text">' . $before . $original . $after . '</td>');
359        $this->ptln('<td class="be-arrow">' . $this->getSvg('slide-arrow-right') . '</td>');
360        $this->ptln('<td class="be-text">' . $before . $replaced . $after . '</td>');
361        $this->ptln('</tr></table>', -2);
362    }
363
364    /**
365     * Prepare wiki text to be displayed as html
366     */
367    private function prepareText($text, $highlight = '') {
368        $html = htmlspecialchars($text);
369        $html = str_replace("\n", '<br />', $html);
370
371        if ($highlight != '') {
372            $html = '<span class="' . $highlight . '">' . $html . '</span>';
373        }
374
375        return $html;
376    }
377
378    /**
379     *
380     */
381    private function printFormEdit($title, $name) {
382        $this->ptln('<tr>', +2);
383        $this->ptln('<td class="be-title">' . $this->getLang($title) . '</td>');
384        $this->ptln('<td class="be-edit">', +2);
385
386        switch ($name) {
387            case 'namespace':
388                $this->printEditBox($name);
389                break;
390
391            case 'search':
392            case 'replace':
393                $multiline = isset($_REQUEST['multiline']);
394                $placeholder = $this->getLang($this->getPlaceholderId($name));
395
396                $this->printEditBox($name, FALSE, TRUE, !$multiline, $placeholder);
397                $this->printTextArea($name, $multiline, $placeholder);
398                break;
399
400            case 'summary':
401                $this->printEditBox($name);
402                $this->printCheckBox('minor', 'lbl_minor');
403                break;
404        }
405
406        $this->ptln('</td>', -2);
407        $this->ptln('</tr>', -2);
408    }
409
410    /**
411     *
412     */
413    private function getPlaceholderId($editName) {
414        switch ($editName) {
415            case 'search':
416                switch ($_REQUEST['searchmode']) {
417                    case 'text':
418                        return 'hnt_textsearch';
419                    case 'regexp':
420                        return isset($_REQUEST['advregexp']) ? 'hnt_advregexpsearch' : 'hnt_regexpsearch';
421                }
422            case 'replace':
423                return 'hnt_' . $_REQUEST['searchmode'] . 'replace';
424        }
425
426        return '';
427    }
428
429    /**
430     *
431     */
432    private function printOptions() {
433        $style = 'min-width: ' . $this->getLang('dim_options') . ';';
434
435        $this->ptln('<div id="be-options" style="' . $style . '">', +2);
436
437        $this->ptln('<div class="be-radiogroup">', +2);
438        $this->ptln('<div>' . $this->getLang('lbl_searchmode') . '</div>');
439        $this->printRadioButton('searchmode', 'text', 'lbl_searchtext');
440        $this->printRadioButton('searchmode', 'regexp', 'lbl_searchregexp');
441        $this->ptln('</div>', -2);
442
443        $this->printCheckBox('matchcase', 'lbl_matchcase');
444        $this->printCheckBox('multiline', 'lbl_multiline');
445
446        $this->ptln('</div>', -2);
447
448        $this->ptln('<div class="be-actions">', +2);
449        $this->printAction('javascript:openAdvancedOptions();', 'ttl_extoptions', 'settings');
450        $this->ptln('</div>', -2);
451
452        $style = 'width: ' . $this->getLang('dim_extoptions') . ';';
453
454        $this->ptln('<div id="be-extoptions" style="' . $style . '">', +2);
455        $this->ptln('<div class="be-actions">', +2);
456        $this->printAction('javascript:closeAdvancedOptions();', '', 'close');
457        $this->ptln('</div>', -2);
458
459        $this->printCheckBox('advregexp', 'lbl_advregexp');
460        $this->printCheckBox('matchctx', 'printMatchContextLabel');
461        $this->printCheckBox('searchlimit', 'printSearchLimitLabel');
462        $this->printCheckBox('keepmarks', 'printKeepMarksLabel');
463        $this->printCheckBox('checksummary', 'lbl_checksummary');
464
465        $this->ptln('</div>', -2);
466    }
467
468    /**
469     *
470     */
471    private function printMatchContextLabel() {
472        $label = preg_split('/(\{\d\})/', $this->getLang('lbl_matchctx'), -1, PREG_SPLIT_DELIM_CAPTURE);
473        $edits = array('{1}' => 'ctxchars', '{2}' => 'ctxlines');
474
475        $this->printLabel('matchctx', $label[0]);
476        $this->printEditBox($edits[$label[1]], TRUE, isset($_REQUEST['matchctx']));
477        $this->printLabel('matchctx', $label[2]);
478        $this->printEditBox($edits[$label[3]], TRUE, isset($_REQUEST['matchctx']));
479        $this->printLabel('matchctx', $label[4]);
480    }
481
482    /**
483     *
484     */
485    private function printSearchLimitLabel() {
486        $label = explode('{1}', $this->getLang('lbl_searchlimit'));
487
488        $this->printLabel('searchlimit', $label[0]);
489        $this->printEditBox('searchmax', TRUE, isset($_REQUEST['searchlimit']));
490        $this->printLabel('searchlimit', $label[1]);
491    }
492
493    /**
494     *
495     */
496    private function printKeepMarksLabel() {
497        $label = explode('{1}', $this->getLang('lbl_keepmarks'));
498        $disabled = isset($_REQUEST['keepmarks']) ? '' : ' disabled="disabled"';
499
500        $this->printLabel('keepmarks', $label[0]);
501        $this->ptln('<select name="markpolicy"' . $disabled . '>', +2);
502
503        for ($i = 1; $i <= 4; $i++) {
504            $selected = $_REQUEST['markpolicy'] == $i ? ' selected="selected"' : '';
505
506            $this->ptln('<option value="' . $i . '"' . $selected . '>' . $this->getLang('lbl_keepmarks' . $i) . '</option>');
507        }
508
509        $this->ptln('</select>', -2);
510        $this->printLabel('keepmarks', $label[1]);
511    }
512
513    /**
514     *
515     */
516    private function printEditBox($name, $submitted = TRUE, $enabled = TRUE, $visible = TRUE, $placeholder = '') {
517        $html = '<input type="text" class="be-edit" id="be-' . $name . 'edit"';
518
519        if ($submitted) {
520            $html .= ' name="' . $name . '"';
521        }
522
523        if (!empty($placeholder)) {
524            $html .= ' placeholder="' . $placeholder . '"';
525        }
526
527        if (($submitted || $visible) && isset($_REQUEST[$name])) {
528            $html .= ' value="' . htmlspecialchars($_REQUEST[$name]) . '"';
529        }
530
531        if (!$enabled) {
532            $html .= ' disabled="disabled"';
533        }
534
535        if (!$visible) {
536            $html .= ' style="display: none;"';
537        }
538
539        $this->ptln($html . ' />');
540    }
541
542    /**
543     *
544     */
545    private function printTextArea($name, $visible = TRUE, $placeholder = '') {
546        $html = '<textarea class="be-edit" id="be-' . $name . 'area" name="' . $name . '"';
547
548        if (!empty($placeholder)) {
549            $html .= ' placeholder="' . $placeholder . '"';
550        }
551
552        $style = array();
553
554        if (!$visible) {
555            $style[] = 'display: none;';
556        }
557
558        if (isset($_REQUEST[$name . 'height'])) {
559            $style[] = 'height: ' . $_REQUEST[$name . 'height'] . 'px;';
560        }
561
562        if (!empty($style)) {
563            $html .= ' style="' . join(' ', $style) . '"';
564        }
565
566        $html .= '>';
567
568        if (isset($_REQUEST[$name])) {
569            $value = $_REQUEST[$name];
570
571            // HACK: It seems that even with "white-space: pre" textarea trims one leading
572            // empty line. To workaround this duplicate the empty line.
573            if (preg_match("/^(\r?\n)/", $value, $match) == 1) {
574                $value = $match[1] . $value;
575            }
576
577            $html .= htmlspecialchars($value);
578        }
579
580        $this->ptln($html . '</textarea>');
581    }
582
583    /**
584     *
585     */
586    private function printCheckBox($name, $label) {
587        $html = '<input type="checkbox" id="be-' . $name . '" name="' . $name . '" value="on"';
588
589        if (isset($_REQUEST[$name])) {
590            $html .= ' checked="checked"';
591        }
592
593        $this->ptln('<div class="be-checkbox">', +2);
594        $this->ptln($html . ' />');
595        $this->printLabel($name, $label);
596        $this->ptln('</div>', -2);
597    }
598
599    /**
600     *
601     */
602    private function printRadioButton($group, $name, $label) {
603        $id = $group . $name;
604        $html = '<input type="radio" id="be-' . $id . '" name="' . $group . '" value="' . $name . '"';
605
606        if (isset($_REQUEST[$group]) && $_REQUEST[$group] == $name) {
607            $html .= ' checked="checked"';
608        }
609
610        $this->ptln('<div class="be-radiobtn">', +2);
611        $this->ptln($html . ' />');
612        $this->printLabel($id, $label);
613        $this->ptln('</div>', -2);
614    }
615
616    /**
617     *
618     */
619    private function printLabel($name, $label) {
620        if (substr($label, 0, 5) == 'print') {
621            $this->$label();
622        }
623        else {
624            if (substr($label, 0, 4) == 'lbl_') {
625                $label = $this->getLang($label);
626            }
627            else {
628                $label = trim($label);
629            }
630
631            if (!empty($label)) {
632                $this->ptln('<label for="be-' . $name . '">' . $label . '</label>');
633            }
634        }
635    }
636
637    /**
638     *
639     */
640    private function printSubmitButton($name, $label, $enabled = TRUE) {
641        $html = '<input type="submit" class="button be-button be-submit" name="' . $name . '" value="' . $this->getLang($label) . '"';
642
643        if (!$enabled) {
644            $html .= ' disabled="disabled"';
645        }
646
647        $this->ptln($html . ' />');
648    }
649
650    /**
651     *
652     */
653    private function printButton($name, $label) {
654        $this->ptln('<input type="button" class="button be-button" name="' . $name . '" value="' . $this->getLang($label) . '" />');
655    }
656
657    /**
658     *
659     */
660    private function getLang($id) {
661        $string = $this->plugin->getLang($id);
662
663        if (func_num_args() > 1) {
664            $search = array();
665            $replace = array();
666
667            for ($i = 1; $i < func_num_args(); $i++) {
668                $search[$i - 1] = '{' . $i . '}';
669                $replace[$i - 1] = func_get_arg($i);
670            }
671
672            $string = str_replace($search, $replace, $string);
673        }
674
675        return $string;
676    }
677
678    /**
679     *
680     */
681    private function getLangPlural($id, $quantity) {
682        if ($quantity == 1) {
683            return $this->getLang($id . '#one', $quantity);
684        }
685
686        return $this->getLang($id . '#many', $quantity);
687    }
688
689    /**
690     *
691     */
692    private function ptln($string, $indentDelta = 0) {
693        if ($indentDelta < 0) {
694            $this->indent += $indentDelta;
695        }
696
697        ptln($string, $this->indent);
698
699        if ($indentDelta > 0) {
700            $this->indent += $indentDelta;
701        }
702    }
703
704    /**
705     *
706     */
707    private function getSvg($id) {
708        if (!array_key_exists($id, $this->svgCache)) {
709            $this->svgCache[$id] = file_get_contents(DOKU_PLUGIN . 'batchedit/images/' . $id . '.svg');
710        }
711
712        return $this->svgCache[$id];
713    }
714}
715