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('tplpatterns', 'lbl_tplpatterns');
464        $this->printCheckBox('checksummary', 'lbl_checksummary');
465
466        $this->ptln('</div>', -2);
467    }
468
469    /**
470     *
471     */
472    private function printMatchContextLabel() {
473        $label = preg_split('/(\{\d\})/', $this->getLang('lbl_matchctx'), -1, PREG_SPLIT_DELIM_CAPTURE);
474        $edits = array('{1}' => 'ctxchars', '{2}' => 'ctxlines');
475
476        $this->printLabel('matchctx', $label[0]);
477        $this->printEditBox($edits[$label[1]], TRUE, isset($_REQUEST['matchctx']));
478        $this->printLabel('matchctx', $label[2]);
479        $this->printEditBox($edits[$label[3]], TRUE, isset($_REQUEST['matchctx']));
480        $this->printLabel('matchctx', $label[4]);
481    }
482
483    /**
484     *
485     */
486    private function printSearchLimitLabel() {
487        $label = explode('{1}', $this->getLang('lbl_searchlimit'));
488
489        $this->printLabel('searchlimit', $label[0]);
490        $this->printEditBox('searchmax', TRUE, isset($_REQUEST['searchlimit']));
491        $this->printLabel('searchlimit', $label[1]);
492    }
493
494    /**
495     *
496     */
497    private function printKeepMarksLabel() {
498        $label = explode('{1}', $this->getLang('lbl_keepmarks'));
499        $disabled = isset($_REQUEST['keepmarks']) ? '' : ' disabled="disabled"';
500
501        $this->printLabel('keepmarks', $label[0]);
502        $this->ptln('<select name="markpolicy"' . $disabled . '>', +2);
503
504        for ($i = 1; $i <= 4; $i++) {
505            $selected = $_REQUEST['markpolicy'] == $i ? ' selected="selected"' : '';
506
507            $this->ptln('<option value="' . $i . '"' . $selected . '>' . $this->getLang('lbl_keepmarks' . $i) . '</option>');
508        }
509
510        $this->ptln('</select>', -2);
511        $this->printLabel('keepmarks', $label[1]);
512    }
513
514    /**
515     *
516     */
517    private function printEditBox($name, $submitted = TRUE, $enabled = TRUE, $visible = TRUE, $placeholder = '') {
518        $html = '<input type="text" class="be-edit" id="be-' . $name . 'edit"';
519
520        if ($submitted) {
521            $html .= ' name="' . $name . '"';
522        }
523
524        if (!empty($placeholder)) {
525            $html .= ' placeholder="' . $placeholder . '"';
526        }
527
528        if (($submitted || $visible) && isset($_REQUEST[$name])) {
529            $html .= ' value="' . htmlspecialchars($_REQUEST[$name]) . '"';
530        }
531
532        if (!$enabled) {
533            $html .= ' disabled="disabled"';
534        }
535
536        if (!$visible) {
537            $html .= ' style="display: none;"';
538        }
539
540        $this->ptln($html . ' />');
541    }
542
543    /**
544     *
545     */
546    private function printTextArea($name, $visible = TRUE, $placeholder = '') {
547        $html = '<textarea class="be-edit" id="be-' . $name . 'area" name="' . $name . '"';
548
549        if (!empty($placeholder)) {
550            $html .= ' placeholder="' . $placeholder . '"';
551        }
552
553        $style = array();
554
555        if (!$visible) {
556            $style[] = 'display: none;';
557        }
558
559        if (isset($_REQUEST[$name . 'height'])) {
560            $style[] = 'height: ' . $_REQUEST[$name . 'height'] . 'px;';
561        }
562
563        if (!empty($style)) {
564            $html .= ' style="' . join(' ', $style) . '"';
565        }
566
567        $html .= '>';
568
569        if (isset($_REQUEST[$name])) {
570            $value = $_REQUEST[$name];
571
572            // HACK: It seems that even with "white-space: pre" textarea trims one leading
573            // empty line. To workaround this duplicate the empty line.
574            if (preg_match("/^(\r?\n)/", $value, $match) == 1) {
575                $value = $match[1] . $value;
576            }
577
578            $html .= htmlspecialchars($value);
579        }
580
581        $this->ptln($html . '</textarea>');
582    }
583
584    /**
585     *
586     */
587    private function printCheckBox($name, $label) {
588        $html = '<input type="checkbox" id="be-' . $name . '" name="' . $name . '" value="on"';
589
590        if (isset($_REQUEST[$name])) {
591            $html .= ' checked="checked"';
592        }
593
594        $this->ptln('<div class="be-checkbox">', +2);
595        $this->ptln($html . ' />');
596        $this->printLabel($name, $label);
597        $this->ptln('</div>', -2);
598    }
599
600    /**
601     *
602     */
603    private function printRadioButton($group, $name, $label) {
604        $id = $group . $name;
605        $html = '<input type="radio" id="be-' . $id . '" name="' . $group . '" value="' . $name . '"';
606
607        if (isset($_REQUEST[$group]) && $_REQUEST[$group] == $name) {
608            $html .= ' checked="checked"';
609        }
610
611        $this->ptln('<div class="be-radiobtn">', +2);
612        $this->ptln($html . ' />');
613        $this->printLabel($id, $label);
614        $this->ptln('</div>', -2);
615    }
616
617    /**
618     *
619     */
620    private function printLabel($name, $label) {
621        if (substr($label, 0, 5) == 'print') {
622            $this->$label();
623        }
624        else {
625            if (substr($label, 0, 4) == 'lbl_') {
626                $label = $this->getLang($label);
627            }
628            else {
629                $label = trim($label);
630            }
631
632            if (!empty($label)) {
633                $this->ptln('<label for="be-' . $name . '">' . $label . '</label>');
634            }
635        }
636    }
637
638    /**
639     *
640     */
641    private function printSubmitButton($name, $label, $enabled = TRUE) {
642        $html = '<input type="submit" class="button be-button be-submit" name="' . $name . '" value="' . $this->getLang($label) . '"';
643
644        if (!$enabled) {
645            $html .= ' disabled="disabled"';
646        }
647
648        $this->ptln($html . ' />');
649    }
650
651    /**
652     *
653     */
654    private function printButton($name, $label) {
655        $this->ptln('<input type="button" class="button be-button" name="' . $name . '" value="' . $this->getLang($label) . '" />');
656    }
657
658    /**
659     *
660     */
661    private function getLang($id) {
662        $string = $this->plugin->getLang($id);
663
664        if (func_num_args() > 1) {
665            $search = array();
666            $replace = array();
667
668            for ($i = 1; $i < func_num_args(); $i++) {
669                $search[$i - 1] = '{' . $i . '}';
670                $replace[$i - 1] = func_get_arg($i);
671            }
672
673            $string = str_replace($search, $replace, $string);
674        }
675
676        return $string;
677    }
678
679    /**
680     *
681     */
682    private function getLangPlural($id, $quantity) {
683        $lang = $this->getLang($id . $this->getPluralForm($quantity), $quantity);
684
685        if (!empty($lang)) {
686            return $lang;
687        }
688
689        return $this->getLang($id . '#many', $quantity);
690    }
691
692    /**
693     *
694     */
695    private function getPluralForm($quantity) {
696        global $conf;
697
698        if ($conf['lang'] == 'ru') {
699            $quantity %= 100;
700
701            if ($quantity >= 5 && $quantity <= 20) {
702                return '#many';
703            }
704
705            $quantity %= 10;
706
707            if ($quantity >= 2 && $quantity <= 4) {
708                return '#few';
709            }
710        }
711
712        if ($quantity == 1) {
713            return '#one';
714        }
715
716        return '#many';
717    }
718
719    /**
720     *
721     */
722    private function ptln($string, $indentDelta = 0) {
723        if ($indentDelta < 0) {
724            $this->indent += $indentDelta;
725        }
726
727        ptln($string, $this->indent);
728
729        if ($indentDelta > 0) {
730            $this->indent += $indentDelta;
731        }
732    }
733
734    /**
735     *
736     */
737    private function getSvg($id) {
738        if (!array_key_exists($id, $this->svgCache)) {
739            $this->svgCache[$id] = file_get_contents(DOKU_PLUGIN . 'batchedit/images/' . $id . '.svg');
740        }
741
742        return $this->svgCache[$id];
743    }
744}
745