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