xref: /plugin/mermaid/action.php (revision c769b8663e1b71922ff1a04831dc2aa10d4562ce)
1<?php
2/**
3 * DokuWiki Plugin mermaid (Action Component)
4 *
5 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
6 * @author  Robert Weinmeister <develop@weinmeister.org>
7 */
8
9declare(strict_types=1);
10
11if (!defined('DOKU_INC')) {
12    die();
13}
14
15class action_plugin_mermaid extends \dokuwiki\Extension\ActionPlugin
16{
17    /** @inheritDoc */
18    public function register(Doku_Event_Handler $controller): void {
19        $controller->register_hook('TPL_METAHEADER_OUTPUT', 'BEFORE', $this, 'load');
20        $controller->register_hook('AJAX_CALL_UNKNOWN', 'BEFORE', $this, 'handleAjaxRequest');
21    }
22
23    private function hasPermissionToEdit(string $ID): bool {
24        return auth_quickaclcheck($ID) >= AUTH_EDIT;
25    }
26
27    private function isPageLocked(string $ID): bool {
28        return checklock($ID);
29    }
30
31    private function lockMermaidDiagram(string $wikitext): string {
32        preg_match_all('/<mermaid.*?>(.*?)<\/mermaid>/s', $wikitext, $matches, PREG_OFFSET_CAPTURE);
33
34        if (is_array($matches) && count($matches[0]) > (int)$_REQUEST['mermaidindex']) {
35            $whereToInsert = $matches[1][(int)$_REQUEST['mermaidindex']][1];
36            return substr($wikitext, 0, $whereToInsert) . "\n%%" . urldecode($_REQUEST['svg']) . "\n" . substr($wikitext, $whereToInsert);
37        }
38
39        echo json_encode(['status' => 'failure', 'data' => ['Could not lock the Mermaid diagram as the request could not be matched.']]);
40        exit();
41    }
42
43    private function unlockMermaidDiagram(string $wikitext): string {
44        $newWikitext = str_replace("\n%%" . urldecode($_REQUEST['svg']) . "\n", '', $wikitext, $count);
45
46        if ($count !== 1) {
47            echo json_encode(['status' => 'failure', 'data' => ['Could not unlock the Mermaid diagram as the request could not be matched.']]);
48            exit();
49        }
50
51        return $newWikitext;
52    }
53
54    private function isWikiTextChanged(string $wikitext, string $newWikitext): bool {
55        return strlen($newWikitext) > 0 && $newWikitext !== $wikitext;
56    }
57
58    private function saveWikiChanges(string $ID, string $newWikitext, string $mode): void {
59        lock($ID);
60        saveWikiText($ID, $newWikitext, "{$mode} Mermaid diagram", true);
61        unlock($ID);
62    }
63
64    public function handleAjaxRequest(Doku_Event $event, $param): void {
65        if ($event->data !== 'plugin_mermaid') {
66            return;
67        }
68        $event->stopPropagation();
69        $event->preventDefault();
70
71        if (!isset($_REQUEST['mermaidindex']) || !isset($_REQUEST['svg'])) {
72            echo json_encode(['status' => 'failure', 'data' => ['Missing required parameters.']]);
73            exit();
74        }
75
76        $ID = cleanID(urldecode($_REQUEST['pageid']));
77
78        if(!$this->hasPermissionToEdit($ID)) {
79            echo json_encode(['status' => 'failure', 'data' => ['You do not have permission to edit this file.\nAccess was denied.']]);
80            exit();
81        }
82
83        if($this->isPageLocked($ID)) {
84            echo json_encode(['status' => 'failure', 'data' => ['The page is currently locked.\nTry again later.']]);
85            exit();
86        }
87
88        $wikitext = rawWiki($ID);
89        $newWikitext = $wikitext;
90
91        if($_REQUEST['mode'] === 'lock') {
92            preg_match_all('/<mermaid.*?>(.*?)<\/mermaid>/s', $wikitext, $matches, PREG_OFFSET_CAPTURE);
93
94            if(is_array($matches) && count($matches[0]) > $_REQUEST['mermaidindex'])
95            {
96                $whereToInsert = $matches[1][$_REQUEST['mermaidindex']][1];
97                $newWikitext = substr($wikitext, 0, $whereToInsert) . "\n%%" . urldecode($_REQUEST['svg']) . "\n" . substr($wikitext, $whereToInsert);
98            }
99            else
100            {
101                echo json_encode(['status' => 'failure', 'data' => ['Could not lock the Mermaid diagram as the request could not be matched.']]);
102                exit();
103            }
104        }
105
106        if($_REQUEST['mode'] == 'unlock')
107        {
108            $newWikitext = str_replace("\n%%" . urldecode($_REQUEST['svg']) . "\n", '', $wikitext, $count);
109            if($count != 1)
110            {
111                echo json_encode(['status' => 'failure', 'data' => ['Could not unlock the Mermaid diagram as the request could not be matched.']]);
112                exit();
113            }
114        }
115
116        if($this->isWikiTextChanged($wikitext, $newWikitext)) {
117            $this->saveWikiChanges($ID, $newWikitext, $_REQUEST['mode']);
118            echo json_encode(['status' => 'success', 'data' => []]);
119        } else{
120            echo json_encode(['status' => 'failure', 'data' => ['Could not ' . $_REQUEST['mode'] . ' the Mermaid diagram.']]);
121        }
122
123        exit();
124    }
125
126    private function addLocalScript(Doku_Event $event): void {
127        $event->data['script'][] = [
128            'type'    => 'text/javascript',
129            'charset' => 'utf-8',
130            'src'     => DOKU_BASE . 'lib/plugins/mermaid/mermaid.min.js',
131        ];
132    }
133
134    private function addEsmScript(Doku_Event $event, string $version, string $init): void {
135        $data = "import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid{$version}/dist/mermaid.esm.min.mjs';{$init}";
136        $event->data['script'][] = [
137            'type'    => 'module',
138            'charset' => 'utf-8',
139            '_data'   => $data,
140        ];
141    }
142
143    private function addScript(Doku_Event $event, string $version, string $init): void {
144        $event->data['script'][] = [
145            'type'    => 'text/javascript',
146            'charset' => 'utf-8',
147            'src'     => "https://cdn.jsdelivr.net/npm/mermaid{$version}/dist/mermaid.min.js",
148        ];
149
150        $event->data['script'][] = [
151            'type'    => 'text/javascript',
152            'charset' => 'utf-8',
153            '_data'   => $init,
154        ];
155    }
156
157    private function pageIncludesMermaid(): bool {
158        // true if the mermaid tag is used
159        // the include plugin can hide this fact, so we need a separate check for it
160        global $ACT;
161        global $TEXT;
162        if ('preview'==$ACT) {
163            $wikiText = $TEXT;
164        } else {
165            $wikiText = rawWiki(getID());
166        }
167
168        if ('edit'==$ACT) {
169            return false;
170        }
171        if (str_contains($wikiText, '<mermaid') || str_contains($wikiText, '{{page>') || str_contains($wikiText, '{{section>') || str_contains($wikiText, '{{namespace>') || str_contains($wikiText, '{{tagtopic>')) {
172            return true;
173        }
174
175        return false;
176    }
177
178    /**
179     * Load the Mermaid library and configuration into the page.
180     *
181     * @param Doku_Event $event DokuWiki event object
182     * @param mixed $param Unused parameter.
183     */
184    public function load(Doku_Event $event, $param): void {
185         // only load mermaid if it is needed
186        if (!$this->pageIncludesMermaid()) {
187            return;
188        }
189
190        $theme = $this->getConf('theme');
191        $look = $this->getConf('look');
192        $logLevel = $this->getConf('logLevel');
193        $init = "mermaid.initialize({startOnLoad: true, logLevel: '$logLevel', theme: '$theme', look: '$look'});";
194
195        $location = $this->getConf('location');
196        $versions = [
197            'latest'     => '',
198            'remote1095' => '@10.9.5',
199            'remote1091' => '@10.9.1',
200            'remote108'  => '@10.8.0',
201            'remote106'  => '@10.6.1',
202            'remote104'  => '@10.4.0',
203            'remote103'  => '@10.3.1',
204            'remote102'  => '@10.2.4',
205            'remote101'  => '@10.1.0',
206            'remote100'  => '@10.0.2',
207            'remote94'   => '@9.4.3',
208            'remote943'  => '@9.4.3',
209            'remote93'   => '@9.3.0',
210        ];
211
212        // add the appropriate Mermaid script based on the location configuration
213        match ($location) {
214            'local' => $this->addLocalScript($event),
215            'latest', 'remote1095', 'remote1091', 'remote108', 'remote106', 'remote104', 'remote103', 'remote102', 'remote101', 'remote100'
216                => $this->addEsmScript($event, $versions[$location], $init),
217            'remote94', 'remote943', 'remote93'
218                => $this->addScript($event, $versions[$location], $init),
219            default => null,
220        };
221
222        $event->data['link'][] = [
223            'rel'     => 'stylesheet',
224            'type'    => 'text/css',
225            'href'    => DOKU_BASE . "lib/plugins/mermaid/mermaid.css",
226        ];
227
228        // remove the search highlight from DokuWiki as it interferes with the Mermaid parsing/rendering
229        $event->data['script'][] = [
230            'type'    => 'text/javascript',
231            'charset' => 'utf-8',
232            '_data' => "document.addEventListener('DOMContentLoaded', function() {
233                            jQuery('.mermaid').each(function() {
234                                var modifiedContent = jQuery(this).html().replace(/<span class=\"search_hit\">(.+?)<\/span>/g, '$1');
235                                jQuery(this).html(modifiedContent);
236                             })
237                        });"
238        ];
239
240        // adds image-save capability
241        // First: Wait until the DOM content is fully loaded
242        // Second: Wait until Mermaid has changed the dokuwiki content to an svg
243        $event->data['script'][] = array
244        (
245            'type'    => 'text/javascript',
246            'charset' => 'utf-8',
247            '_data' => "
248document.addEventListener('DOMContentLoaded', function() {
249     var config = {
250        childList: true,
251        subtree: true,
252        characterData: true
253    };
254
255    function callDokuWikiPHP(mode, index, mermaidRaw, mermaidSvg) {
256    jQuery.post(
257        DOKU_BASE + 'lib/exe/ajax.php',
258        {
259            call: 'plugin_mermaid',
260            mode: mode,
261            mermaidindex: index,
262            pageid: '".getID()."',
263            svg: encodeURIComponent(mermaidSvg)
264        },
265        function(response) {
266            if(response.status == 'success') {
267                location.reload(true);
268            }
269            else {
270                alert(response.data[0]);
271            }
272        },
273        'json'
274    )};
275
276    jQuery('.mermaidlocked, .mermaid').each(function(index, element) {
277        document.getElementById('mermaidContainer' + index).addEventListener('mouseenter', function() {
278             var fieldset = document.getElementById('mermaidFieldset' + index);
279             if (fieldset) {
280                    fieldset.style.display = 'flex';
281             }
282        });
283        document.getElementById('mermaidContainer' + index).addEventListener('mouseleave', function() {
284             var fieldset = document.getElementById('mermaidFieldset' + index);
285             if (fieldset) {
286                    fieldset.style.display = 'none';
287             }
288        });
289
290        if(jQuery(element).hasClass('mermaidlocked')) {
291            var buttonSave = document.getElementById('mermaidButtonSave' + index);
292            if (buttonSave) {
293                buttonSave.addEventListener('click', () => {
294                    var svgContent = element.innerHTML.trim();
295                    var blob = new Blob([svgContent], { type: 'image/svg+xml' });
296                    var link = document.createElement('a');
297                    link.href = URL.createObjectURL(blob);
298                    link.download = 'mermaid' + index + '.svg';
299                    link.click();
300                    URL.revokeObjectURL(link.href);
301                });
302            }
303            var buttonPermanent = document.getElementById('mermaidButtonPermanent' + index);
304            if (buttonPermanent) {
305                buttonPermanent.addEventListener('click', () => {
306                    if(confirm('Unlock Mermaid diagram?')) {
307                        callDokuWikiPHP('unlock', index, originalMermaidContent, element.innerHTML.trim());
308                    }
309                });
310            }
311        }
312
313        if(jQuery(element).hasClass('mermaid')) {
314            var originalMermaidContent = element.innerHTML;
315            var observer = new MutationObserver(function(mutations) {
316                mutations.forEach(function(mutation) {
317                    if (mutation.type === 'childList' && element.innerHTML.startsWith('<svg')) {
318                        var saveButton = document.getElementById('mermaidButtonSave' + index);
319                        if (saveButton) {
320                            saveButton.addEventListener('click', () => {
321                                var svgContent = element.innerHTML.trim();
322                                var blob = new Blob([svgContent], { type: 'image/svg+xml' });
323                                var link = document.createElement('a');
324                                link.href = URL.createObjectURL(blob);
325                                link.download = 'mermaid' + index + '.svg';
326                                link.click();
327                                URL.revokeObjectURL(link.href);
328                            });
329                        }
330                        var buttonPermanent = document.getElementById('mermaidButtonPermanent' + index);
331                        if (buttonPermanent) {
332                            buttonPermanent.addEventListener('click', () => {
333                                    if(confirm('Lock Mermaid diagram? [experimental]')) {
334                                        callDokuWikiPHP('lock', index, originalMermaidContent, element.innerHTML.trim());
335                                    }
336                            });
337                        }
338                    }
339                });
340            });
341            observer.observe(element, config);
342        }
343    });
344});"
345        );
346    }
347}
348