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        print('<!-- batchedit -->');
123        print('<div id="batchedit">');
124
125        $this->printJavascriptLang();
126
127        print('<form method="post">');
128        print('<input type="hidden" name="session" value="' . $sessionId . '" />');
129    }
130
131    /**
132     *
133     */
134    public function printEnding() {
135        print('</form>');
136        print('</div>');
137        print('<!-- /batchedit -->');
138    }
139
140    /**
141     *
142     */
143    public function printMessages($messages) {
144        if (empty($messages)) {
145            return;
146        }
147
148        print('<div id="be-messages">');
149
150        foreach ($messages as $message) {
151            print('<div class="' . $message->getClass() . '">');
152            print($this->getLang($message->getFormatId(), call_user_func_array(array($this, 'getLang'), $message->data)));
153            print('</div>');
154        }
155
156        print('</div>');
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        print('<div id="be-totalstats"><div>');
178
179        if ($editCount < $matchCount) {
180            $this->printApplyCheckBox('be-applyall', $stats, 'ttl_applyall');
181        }
182        else {
183            print($stats);
184        }
185
186        print('</div></div>');
187    }
188
189    /**
190     *
191     */
192    public function printMatches($pages) {
193        foreach ($pages as $page) {
194            print('<div class="be-file">');
195
196            $this->printPageStats($page);
197            $this->printPageActions($page->getId());
198            $this->printPageMatches($page);
199
200            print('</div>');
201        }
202    }
203
204    /**
205     *
206     */
207    public function printMainForm($enableApply) {
208        print('<div id="be-mainform">');
209
210        print('<div>');
211
212        print('<div id="be-editboxes">');
213        print('<table>');
214
215        $this->printFormEdit('lbl_ns', 'namespace');
216        $this->printFormEdit('lbl_search', 'search');
217        $this->printFormEdit('lbl_replace', 'replace');
218        $this->printFormEdit('lbl_summary', 'summary');
219
220        print('</table>');
221        print('</div>');
222
223        $this->printOptions();
224
225        print('</div>');
226
227        // Value for this hidden input is set before submit through jQuery, containing
228        // JSON-encoded list of all checked checkbox ids for single matches.
229        // Consolidating these inputs into a single string variable avoids problems for
230        // huge replacement sets exceeding `max_input_vars` in `php.ini`.
231        print('<input type="hidden" name="apply" value="" />');
232
233        print('<div id="be-submitbar">');
234
235        $this->printSubmitButton('cmd[preview]', 'btn_preview');
236        $this->printSubmitButton('cmd[apply]', 'btn_apply', $enableApply);
237
238        print('<div id="be-progressbar">');
239        print('<div id="be-progresswrap"><div id="be-progress"></div></div>');
240
241        $this->printButton('cancel', 'btn_cancel');
242
243        print('</div>');
244        print('</div>');
245
246        print('</div>');
247    }
248
249    /**
250     *
251     */
252    private function printJavascriptLang() {
253        print('<script type="text/javascript">');
254
255        $langIds = array('hnt_textsearch', 'hnt_textreplace', 'hnt_regexpsearch', 'hnt_regexpreplace',
256                'hnt_advregexpsearch', 'war_nosummary');
257        $lang = array();
258
259        foreach ($langIds as $id) {
260            $lang[$id] = $this->getLang($id);
261        }
262
263        print('var batcheditLang = ' . json_encode($lang) . ';');
264        print('</script>');
265    }
266
267    /**
268     *
269     */
270    private function printApplyCheckBox($id, $label, $title, $checked = FALSE) {
271        $checked = $checked ? ' checked="checked"' : '';
272
273        print('<span class="be-apply" title="' . $this->getLang($title) . '">');
274        print('<input type="checkbox" id="' . $id . '"' . $checked . ' />');
275        print('<label for="' . $id . '">' . $label . '</label>');
276        print('</span>');
277    }
278
279    /**
280     *
281     */
282    private function printPageStats($page) {
283        $stats = $this->getLang('sts_page', $page->getId(), $this->getLangPlural('sts_matches', count($page->getMatches())));
284
285        print('<div class="be-stats">');
286
287        if ($page->hasUnappliedMatches()) {
288            $this->printApplyCheckBox($page->getId(), $stats, 'ttl_applyfile', !$page->hasUnmarkedMatches());
289        }
290        else {
291            print($stats);
292        }
293
294        print('</div>');
295    }
296
297    /**
298     *
299     */
300    private function printPageActions($pageId) {
301        $link = wl($pageId);
302
303        print('<div class="be-actions">');
304
305        $this->printAction($link, 'ttl_view', 'file-document');
306        $this->printAction($link . (strpos($link, '?') === FALSE ? '?' : '&') . 'do=edit', 'ttl_edit', 'pencil');
307        $this->printAction('#be-mainform', 'ttl_mainform', 'arrow-down');
308
309        print('</div>');
310    }
311
312    /**
313     *
314     */
315    private function printAction($href, $titleId, $iconId) {
316        $action = '<a class="be-action" href="' . $href . '" title="' . $this->getLang($titleId) . '">';
317        $action .= $this->getSvg($iconId);
318        $action .= '</a>';
319
320        print($action);
321    }
322
323    /**
324     *
325     */
326    private function printPageMatches($page) {
327        foreach ($page->getMatches() as $match) {
328            print('<div class="be-match">');
329
330            $this->printMatchHeader($page->getId(), $match);
331            $this->printMatchTable($match);
332
333            print('</div>');
334        }
335    }
336
337    /**
338     *
339     */
340    private function printMatchHeader($pageId, $match) {
341        $id = $pageId . '#' . $match->getPageOffset();
342
343        print('<div class="be-matchid">');
344
345        if (!$match->isApplied()) {
346            $this->printApplyCheckBox($id, $id, 'ttl_applymatch', $match->isMarked());
347        }
348        else {
349            // Add hidden checked checkbox to ensure that marked status is not lost on
350            // applied matches if application is performed in multiple rounds. This can
351            // be the case when one apply command is timed out and user issues a second
352            // one to apply the remaining matches.
353            print('<input type="checkbox" id="' . $id . '" checked="checked" style="display:none;" />');
354            print($id);
355        }
356
357        print('</div>');
358    }
359
360    /**
361     *
362     */
363    private function printMatchTable($match) {
364        $original = $this->prepareText($match->getOriginalText(), $match->isApplied() ? ' be-replaced' : 'be-preview');
365        $replaced = $this->prepareText($match->getReplacedText(), $match->isApplied() ? ' be-applied' : 'be-preview');
366        $before = $this->prepareText($match->getContextBefore());
367        $after = $this->prepareText($match->getContextAfter());
368
369        print('<table><tr>');
370        print('<td class="be-text">' . $before . $original . $after . '</td>');
371        print('<td class="be-arrow">' . $this->getSvg('slide-arrow-right') . '</td>');
372        print('<td class="be-text">' . $before . $replaced . $after . '</td>');
373        print('</tr></table>');
374    }
375
376    /**
377     * Prepare wiki text to be displayed as html
378     */
379    private function prepareText($text, $highlight = '') {
380        $html = htmlspecialchars($text);
381        $html = str_replace("\n", '<br />', $html);
382
383        if ($highlight != '') {
384            $html = '<span class="' . $highlight . '">' . $html . '</span>';
385        }
386
387        return $html;
388    }
389
390    /**
391     *
392     */
393    private function printFormEdit($title, $name) {
394        print('<tr>');
395        print('<td class="be-title">' . $this->getLang($title) . '</td>');
396        print('<td class="be-edit">');
397
398        switch ($name) {
399            case 'namespace':
400                $this->printEditBox($name);
401                break;
402
403            case 'search':
404            case 'replace':
405                $multiline = isset($_REQUEST['multiline']);
406                $placeholder = $this->getLang($this->getPlaceholderId($name));
407
408                $this->printEditBox($name, FALSE, TRUE, !$multiline, $placeholder);
409                $this->printTextArea($name, $multiline, $placeholder);
410                break;
411
412            case 'summary':
413                $this->printEditBox($name);
414                $this->printCheckBox('minor', 'lbl_minor');
415                break;
416        }
417
418        print('</td>');
419        print('</tr>');
420    }
421
422    /**
423     *
424     */
425    private function getPlaceholderId($editName) {
426        switch ($editName) {
427            case 'search':
428                switch ($_REQUEST['searchmode']) {
429                    case 'text':
430                        return 'hnt_textsearch';
431                    case 'regexp':
432                        return isset($_REQUEST['advregexp']) ? 'hnt_advregexpsearch' : 'hnt_regexpsearch';
433                }
434            case 'replace':
435                return 'hnt_' . $_REQUEST['searchmode'] . 'replace';
436        }
437
438        return '';
439    }
440
441    /**
442     *
443     */
444    private function printOptions() {
445        $style = 'min-width: ' . $this->getLang('dim_options') . ';';
446
447        print('<div id="be-options" style="' . $style . '">');
448
449        print('<div class="be-radiogroup">');
450        print('<div>' . $this->getLang('lbl_searchmode') . '</div>');
451
452        $this->printRadioButton('searchmode', 'text', 'lbl_searchtext');
453        $this->printRadioButton('searchmode', 'regexp', 'lbl_searchregexp');
454
455        print('</div>');
456
457        $this->printCheckBox('matchcase', 'lbl_matchcase');
458        $this->printCheckBox('multiline', 'lbl_multiline');
459
460        print('</div>');
461
462        print('<div class="be-actions">');
463
464        $this->printAction('javascript:openAdvancedOptions();', 'ttl_extoptions', 'settings');
465
466        print('</div>');
467
468        $style = 'width: ' . $this->getLang('dim_extoptions') . ';';
469
470        print('<div id="be-extoptions" style="' . $style . '">');
471        print('<div class="be-actions">');
472
473        $this->printAction('javascript:closeAdvancedOptions();', '', 'close');
474
475        print('</div>');
476
477        $this->printCheckBox('advregexp', 'lbl_advregexp');
478        $this->printCheckBox('matchctx', 'printMatchContextLabel');
479        $this->printCheckBox('searchlimit', 'printSearchLimitLabel');
480        $this->printCheckBox('keepmarks', 'printKeepMarksLabel');
481        $this->printCheckBox('tplpatterns', 'lbl_tplpatterns');
482        $this->printCheckBox('checksummary', 'lbl_checksummary');
483
484        print('</div>');
485    }
486
487    /**
488     *
489     */
490    private function printMatchContextLabel() {
491        $label = preg_split('/(\{\d\})/', $this->getLang('lbl_matchctx'), -1, PREG_SPLIT_DELIM_CAPTURE);
492        $edits = array('{1}' => 'ctxchars', '{2}' => 'ctxlines');
493
494        $this->printLabel('matchctx', $label[0]);
495        $this->printEditBox($edits[$label[1]], TRUE, isset($_REQUEST['matchctx']));
496        $this->printLabel('matchctx', $label[2]);
497        $this->printEditBox($edits[$label[3]], TRUE, isset($_REQUEST['matchctx']));
498        $this->printLabel('matchctx', $label[4]);
499    }
500
501    /**
502     *
503     */
504    private function printSearchLimitLabel() {
505        $label = explode('{1}', $this->getLang('lbl_searchlimit'));
506
507        $this->printLabel('searchlimit', $label[0]);
508        $this->printEditBox('searchmax', TRUE, isset($_REQUEST['searchlimit']));
509        $this->printLabel('searchlimit', $label[1]);
510    }
511
512    /**
513     *
514     */
515    private function printKeepMarksLabel() {
516        $label = explode('{1}', $this->getLang('lbl_keepmarks'));
517        $disabled = isset($_REQUEST['keepmarks']) ? '' : ' disabled="disabled"';
518
519        $this->printLabel('keepmarks', $label[0]);
520
521        print('<select name="markpolicy"' . $disabled . '>');
522
523        for ($i = 1; $i <= 4; $i++) {
524            $selected = $_REQUEST['markpolicy'] == $i ? ' selected="selected"' : '';
525
526            print('<option value="' . $i . '"' . $selected . '>' . $this->getLang('lbl_keepmarks' . $i) . '</option>');
527        }
528
529        print('</select>');
530
531        $this->printLabel('keepmarks', $label[1]);
532    }
533
534    /**
535     *
536     */
537    private function printEditBox($name, $submitted = TRUE, $enabled = TRUE, $visible = TRUE, $placeholder = '') {
538        $html = '<input type="text" class="be-edit" id="be-' . $name . 'edit"';
539
540        if ($submitted) {
541            $html .= ' name="' . $name . '"';
542        }
543
544        if (!empty($placeholder)) {
545            $html .= ' placeholder="' . $placeholder . '"';
546        }
547
548        if (($submitted || $visible) && isset($_REQUEST[$name])) {
549            $html .= ' value="' . htmlspecialchars($_REQUEST[$name]) . '"';
550        }
551
552        if (!$enabled) {
553            $html .= ' disabled="disabled"';
554        }
555
556        if (!$visible) {
557            $html .= ' style="display: none;"';
558        }
559
560        print($html . ' />');
561    }
562
563    /**
564     *
565     */
566    private function printTextArea($name, $visible = TRUE, $placeholder = '') {
567        $html = '<textarea class="be-edit" id="be-' . $name . 'area" name="' . $name . '"';
568
569        if (!empty($placeholder)) {
570            $html .= ' placeholder="' . $placeholder . '"';
571        }
572
573        $style = array();
574
575        if (!$visible) {
576            $style[] = 'display: none;';
577        }
578
579        if (isset($_REQUEST[$name . 'height'])) {
580            $style[] = 'height: ' . $_REQUEST[$name . 'height'] . 'px;';
581        }
582
583        if (!empty($style)) {
584            $html .= ' style="' . join(' ', $style) . '"';
585        }
586
587        $html .= '>';
588
589        if (isset($_REQUEST[$name])) {
590            $value = $_REQUEST[$name];
591
592            // HACK: It seems that even with "white-space: pre" textarea trims one leading
593            // empty line. To workaround this duplicate the empty line.
594            if (preg_match("/^(\r?\n)/", $value, $match) == 1) {
595                $value = $match[1] . $value;
596            }
597
598            $html .= htmlspecialchars($value);
599        }
600
601        print($html . '</textarea>');
602    }
603
604    /**
605     *
606     */
607    private function printCheckBox($name, $label) {
608        $html = '<input type="checkbox" id="be-' . $name . '" name="' . $name . '" value="on"';
609
610        if (isset($_REQUEST[$name])) {
611            $html .= ' checked="checked"';
612        }
613
614        print('<div class="be-checkbox">');
615        print($html . ' />');
616
617        $this->printLabel($name, $label);
618
619        print('</div>');
620    }
621
622    /**
623     *
624     */
625    private function printRadioButton($group, $name, $label) {
626        $id = $group . $name;
627        $html = '<input type="radio" id="be-' . $id . '" name="' . $group . '" value="' . $name . '"';
628
629        if (isset($_REQUEST[$group]) && $_REQUEST[$group] == $name) {
630            $html .= ' checked="checked"';
631        }
632
633        print('<div class="be-radiobtn">');
634        print($html . ' />');
635
636        $this->printLabel($id, $label);
637
638        print('</div>');
639    }
640
641    /**
642     *
643     */
644    private function printLabel($name, $label) {
645        if (substr($label, 0, 5) == 'print') {
646            $this->$label();
647        }
648        else {
649            if (substr($label, 0, 4) == 'lbl_') {
650                $label = $this->getLang($label);
651            }
652            else {
653                $label = trim($label);
654            }
655
656            if (!empty($label)) {
657                print('<label for="be-' . $name . '">' . $label . '</label>');
658            }
659        }
660    }
661
662    /**
663     *
664     */
665    private function printSubmitButton($name, $label, $enabled = TRUE) {
666        $html = '<input type="submit" class="button be-button be-submit" name="' . $name . '" value="' . $this->getLang($label) . '"';
667
668        if (!$enabled) {
669            $html .= ' disabled="disabled"';
670        }
671
672        print($html . ' />');
673    }
674
675    /**
676     *
677     */
678    private function printButton($name, $label) {
679        print('<input type="button" class="button be-button" name="' . $name . '" value="' . $this->getLang($label) . '" />');
680    }
681
682    /**
683     *
684     */
685    private function getLang($id) {
686        $string = $this->plugin->getLang($id);
687
688        if (func_num_args() > 1) {
689            $search = array();
690            $replace = array();
691
692            for ($i = 1; $i < func_num_args(); $i++) {
693                $search[$i - 1] = '{' . $i . '}';
694                $replace[$i - 1] = func_get_arg($i);
695            }
696
697            $string = str_replace($search, $replace, $string);
698        }
699
700        return $string;
701    }
702
703    /**
704     *
705     */
706    private function getLangPlural($id, $quantity) {
707        $lang = $this->getLang($id . $this->getPluralForm($quantity), $quantity);
708
709        if (!empty($lang)) {
710            return $lang;
711        }
712
713        return $this->getLang($id . '#many', $quantity);
714    }
715
716    /**
717     *
718     */
719    private function getPluralForm($quantity) {
720        global $conf;
721
722        if ($conf['lang'] == 'ru') {
723            $quantity %= 100;
724
725            if ($quantity >= 5 && $quantity <= 20) {
726                return '#many';
727            }
728
729            $quantity %= 10;
730
731            if ($quantity >= 2 && $quantity <= 4) {
732                return '#few';
733            }
734        }
735
736        if ($quantity == 1) {
737            return '#one';
738        }
739
740        return '#many';
741    }
742
743    /**
744     *
745     */
746    private function getSvg($id) {
747        if (!array_key_exists($id, $this->svgCache)) {
748            $this->svgCache[$id] = file_get_contents(DOKU_PLUGIN . 'batchedit/images/' . $id . '.svg');
749        }
750
751        return $this->svgCache[$id];
752    }
753}
754