xref: /plugin/mermaid/action.php (revision 92788e2adf6db51e15a23fbdf03e4583c834cd34)
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        $wikiText = rawWiki(getID());
161        if (str_contains($wikiText, '<mermaid') || str_contains($wikiText, '{{page>') || str_contains($wikiText, '{{section>') || str_contains($wikiText, '{{namespace>') || str_contains($wikiText, '{{tagtopic>')) {
162            return true;
163        }
164
165        return false;
166    }
167
168    /**
169     * Load the Mermaid library and configuration into the page.
170     *
171     * @param Doku_Event $event DokuWiki event object
172     * @param mixed $param Unused parameter.
173     */
174    public function load(Doku_Event $event, $param): void {
175         // only load mermaid if it is needed
176        if (!$this->pageIncludesMermaid()) {
177            return;
178        }
179
180        $theme = $this->getConf('theme');
181        $look = $this->getConf('look');
182        $logLevel = $this->getConf('logLevel');
183        $init = "mermaid.initialize({startOnLoad: true, logLevel: '$logLevel', theme: '$theme', look: '$look'});";
184
185        $location = $this->getConf('location');
186        $versions = [
187            'latest'     => '',
188            'remote1095' => '@10.9.5',
189            'remote1091' => '@10.9.1',
190            'remote108'  => '@10.8.0',
191            'remote106'  => '@10.6.1',
192            'remote104'  => '@10.4.0',
193            'remote103'  => '@10.3.1',
194            'remote102'  => '@10.2.4',
195            'remote101'  => '@10.1.0',
196            'remote100'  => '@10.0.2',
197            'remote94'   => '@9.4.3',
198            'remote943'  => '@9.4.3',
199            'remote93'   => '@9.3.0',
200        ];
201
202        // add the appropriate Mermaid script based on the location configuration
203        match ($location) {
204            'local' => $this->addLocalScript($event),
205            'latest', 'remote1095', 'remote1091', 'remote108', 'remote106', 'remote104', 'remote103', 'remote102', 'remote101', 'remote100'
206                => $this->addEsmScript($event, $versions[$location], $init),
207            'remote94', 'remote943', 'remote93'
208                => $this->addScript($event, $versions[$location], $init),
209            default => null,
210        };
211
212        $event->data['link'][] = [
213            'rel'     => 'stylesheet',
214            'type'    => 'text/css',
215            'href'    => DOKU_BASE . "lib/plugins/mermaid/mermaid.css",
216        ];
217
218        // remove the search highlight from DokuWiki as it interferes with the Mermaid parsing/rendering
219        $event->data['script'][] = [
220            'type'    => 'text/javascript',
221            'charset' => 'utf-8',
222            '_data' => "document.addEventListener('DOMContentLoaded', function() {
223                            jQuery('.mermaid').each(function() {
224                                var modifiedContent = jQuery(this).html().replace(/<span class=\"search_hit\">(.+?)<\/span>/g, '$1');
225                                jQuery(this).html(modifiedContent);
226                             })
227                        });"
228        ];
229
230        // adds image-save capability
231        // First: Wait until the DOM content is fully loaded
232        // Second: Wait until Mermaid has changed the dokuwiki content to an svg
233        $event->data['script'][] = array
234        (
235            'type'    => 'text/javascript',
236            'charset' => 'utf-8',
237            '_data' => "
238document.addEventListener('DOMContentLoaded', function() {
239     var config = {
240        childList: true,
241        subtree: true,
242        characterData: true
243    };
244
245    function callDokuWikiPHP(mode, index, mermaidRaw, mermaidSvg) {
246    jQuery.post(
247        DOKU_BASE + 'lib/exe/ajax.php',
248        {
249            call: 'plugin_mermaid',
250            mode: mode,
251            mermaidindex: index,
252            pageid: '".getID()."',
253            svg: encodeURIComponent(mermaidSvg)
254        },
255        function(response) {
256            if(response.status == 'success') {
257                location.reload(true);
258            }
259            else {
260                alert(response.data[0]);
261            }
262        },
263        'json'
264    )};
265
266    jQuery('.mermaidlocked, .mermaid').each(function(index, element) {
267        document.getElementById('mermaidContainer' + index).addEventListener('mouseenter', function() {
268             var fieldset = document.getElementById('mermaidFieldset' + index);
269             if (fieldset) {
270                    fieldset.style.display = 'flex';
271             }
272        });
273        document.getElementById('mermaidContainer' + index).addEventListener('mouseleave', function() {
274             var fieldset = document.getElementById('mermaidFieldset' + index);
275             if (fieldset) {
276                    fieldset.style.display = 'none';
277             }
278        });
279
280        if(jQuery(element).hasClass('mermaidlocked')) {
281            var buttonSave = document.getElementById('mermaidButtonSave' + index);
282            if (buttonSave) {
283                buttonSave.addEventListener('click', () => {
284                    var svgContent = element.innerHTML.trim();
285                    var blob = new Blob([svgContent], { type: 'image/svg+xml' });
286                    var link = document.createElement('a');
287                    link.href = URL.createObjectURL(blob);
288                    link.download = 'mermaid' + index + '.svg';
289                    link.click();
290                    URL.revokeObjectURL(link.href);
291                });
292            }
293            var buttonPermanent = document.getElementById('mermaidButtonPermanent' + index);
294            if (buttonPermanent) {
295                buttonPermanent.addEventListener('click', () => {
296                    if(confirm('Unlock Mermaid diagram?')) {
297                        callDokuWikiPHP('unlock', index, originalMermaidContent, element.innerHTML.trim());
298                    }
299                });
300            }
301        }
302
303        if(jQuery(element).hasClass('mermaid')) {
304            var originalMermaidContent = element.innerHTML;
305            var observer = new MutationObserver(function(mutations) {
306                mutations.forEach(function(mutation) {
307                    if (mutation.type === 'childList' && element.innerHTML.startsWith('<svg')) {
308                        var saveButton = document.getElementById('mermaidButtonSave' + index);
309                        if (saveButton) {
310                            saveButton.addEventListener('click', () => {
311                                var svgContent = element.innerHTML.trim();
312                                var blob = new Blob([svgContent], { type: 'image/svg+xml' });
313                                var link = document.createElement('a');
314                                link.href = URL.createObjectURL(blob);
315                                link.download = 'mermaid' + index + '.svg';
316                                link.click();
317                                URL.revokeObjectURL(link.href);
318                            });
319                        }
320                        var buttonPermanent = document.getElementById('mermaidButtonPermanent' + index);
321                        if (buttonPermanent) {
322                            buttonPermanent.addEventListener('click', () => {
323                                    if(confirm('Lock Mermaid diagram? [experimental]')) {
324                                        callDokuWikiPHP('lock', index, originalMermaidContent, element.innerHTML.trim());
325                                    }
326                            });
327                        }
328                    }
329                });
330            });
331            observer.observe(element, config);
332        }
333    });
334});"
335        );
336    }
337}