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