xref: /plugin/mermaid/action.php (revision b74fbfffb01e27be7616243f9a31758864741e33)
146a60b4fSRobertWeinmeister<?php
246a60b4fSRobertWeinmeister/**
346a60b4fSRobertWeinmeister * DokuWiki Plugin mermaid (Action Component)
446a60b4fSRobertWeinmeister *
546a60b4fSRobertWeinmeister * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
646a60b4fSRobertWeinmeister * @author  Robert Weinmeister <develop@weinmeister.org>
746a60b4fSRobertWeinmeister */
846a60b4fSRobertWeinmeister
9b566ae41SRobert Weinmeisterdeclare(strict_types=1);
10b566ae41SRobert Weinmeister
11b566ae41SRobert Weinmeisterif (!defined('DOKU_INC')) {
12b566ae41SRobert Weinmeister    die();
13b566ae41SRobert Weinmeister}
14ea08b541SRobert Weinmeister
1546a60b4fSRobertWeinmeisterclass action_plugin_mermaid extends \dokuwiki\Extension\ActionPlugin
1646a60b4fSRobertWeinmeister{
1746a60b4fSRobertWeinmeister    /** @inheritDoc */
18b566ae41SRobert Weinmeister    public function register(Doku_Event_Handler $controller): void {
1946a60b4fSRobertWeinmeister        $controller->register_hook('TPL_METAHEADER_OUTPUT', 'BEFORE', $this, 'load');
20172fa282SRobert Weinmeister        $controller->register_hook('AJAX_CALL_UNKNOWN', 'BEFORE', $this, 'handleAjaxRequest');
21172fa282SRobert Weinmeister    }
22172fa282SRobert Weinmeister
23b566ae41SRobert Weinmeister    private function hasPermissionToEdit(string $ID): bool {
24b566ae41SRobert Weinmeister        return auth_quickaclcheck($ID) >= AUTH_EDIT;
25172fa282SRobert Weinmeister    }
26172fa282SRobert Weinmeister
27b566ae41SRobert Weinmeister    private function isPageLocked(string $ID): bool {
28b566ae41SRobert Weinmeister        return checklock($ID);
29b566ae41SRobert Weinmeister    }
30b566ae41SRobert Weinmeister
31b566ae41SRobert Weinmeister    private function lockMermaidDiagram(string $wikitext): string {
32b566ae41SRobert Weinmeister        preg_match_all('/<mermaid.*?>(.*?)<\/mermaid>/s', $wikitext, $matches, PREG_OFFSET_CAPTURE);
33b566ae41SRobert Weinmeister
34b566ae41SRobert Weinmeister        if (is_array($matches) && count($matches[0]) > (int)$_REQUEST['mermaidindex']) {
35b566ae41SRobert Weinmeister            $whereToInsert = $matches[1][(int)$_REQUEST['mermaidindex']][1];
36b566ae41SRobert Weinmeister            return substr($wikitext, 0, $whereToInsert) . "\n%%" . urldecode($_REQUEST['svg']) . "\n" . substr($wikitext, $whereToInsert);
37b566ae41SRobert Weinmeister        }
38b566ae41SRobert Weinmeister
39b566ae41SRobert Weinmeister        echo json_encode(['status' => 'failure', 'data' => ['Could not lock the Mermaid diagram as the request could not be matched.']]);
40b566ae41SRobert Weinmeister        exit();
41b566ae41SRobert Weinmeister    }
42b566ae41SRobert Weinmeister
43b566ae41SRobert Weinmeister    private function unlockMermaidDiagram(string $wikitext): string {
44b566ae41SRobert Weinmeister        $newWikitext = str_replace("\n%%" . urldecode($_REQUEST['svg']) . "\n", '', $wikitext, $count);
45b566ae41SRobert Weinmeister
46b566ae41SRobert Weinmeister        if ($count !== 1) {
47b566ae41SRobert Weinmeister            echo json_encode(['status' => 'failure', 'data' => ['Could not unlock the Mermaid diagram as the request could not be matched.']]);
48b566ae41SRobert Weinmeister            exit();
49b566ae41SRobert Weinmeister        }
50b566ae41SRobert Weinmeister
51b566ae41SRobert Weinmeister        return $newWikitext;
52b566ae41SRobert Weinmeister    }
53b566ae41SRobert Weinmeister
54b566ae41SRobert Weinmeister    private function isWikiTextChanged(string $wikitext, string $newWikitext): bool {
55b566ae41SRobert Weinmeister        return strlen($newWikitext) > 0 && $newWikitext !== $wikitext;
56b566ae41SRobert Weinmeister    }
57b566ae41SRobert Weinmeister
58b566ae41SRobert Weinmeister    private function saveWikiChanges(string $ID, string $newWikitext, string $mode): void {
59b566ae41SRobert Weinmeister        lock($ID);
60b566ae41SRobert Weinmeister        saveWikiText($ID, $newWikitext, "{$mode} Mermaid diagram", true);
61b566ae41SRobert Weinmeister        unlock($ID);
62b566ae41SRobert Weinmeister    }
63b566ae41SRobert Weinmeister
64b566ae41SRobert Weinmeister    public function handleAjaxRequest(Doku_Event $event, $param): void {
65b566ae41SRobert Weinmeister        if ($event->data !== 'plugin_mermaid') {
66b566ae41SRobert Weinmeister            return;
67b566ae41SRobert Weinmeister        }
68172fa282SRobert Weinmeister        $event->stopPropagation();
69172fa282SRobert Weinmeister        $event->preventDefault();
70172fa282SRobert Weinmeister
71b566ae41SRobert Weinmeister        if (!isset($_REQUEST['mermaidindex']) || !isset($_REQUEST['svg'])) {
72b566ae41SRobert Weinmeister            echo json_encode(['status' => 'failure', 'data' => ['Missing required parameters.']]);
73b566ae41SRobert Weinmeister            exit();
74b566ae41SRobert Weinmeister        }
75b566ae41SRobert Weinmeister
76172fa282SRobert Weinmeister        $ID = cleanID(urldecode($_REQUEST['pageid']));
77172fa282SRobert Weinmeister
78b566ae41SRobert Weinmeister        if(!$this->hasPermissionToEdit($ID)) {
79172fa282SRobert Weinmeister            echo json_encode(['status' => 'failure', 'data' => ['You do not have permission to edit this file.\nAccess was denied.']]);
80172fa282SRobert Weinmeister            exit();
81172fa282SRobert Weinmeister        }
82172fa282SRobert Weinmeister
83b566ae41SRobert Weinmeister        if($this->isPageLocked($ID)) {
84172fa282SRobert Weinmeister            echo json_encode(['status' => 'failure', 'data' => ['The page is currently locked.\nTry again later.']]);
85172fa282SRobert Weinmeister            exit();
86172fa282SRobert Weinmeister        }
87172fa282SRobert Weinmeister
88172fa282SRobert Weinmeister        $wikitext = rawWiki($ID);
89172fa282SRobert Weinmeister        $newWikitext = $wikitext;
90172fa282SRobert Weinmeister
91b566ae41SRobert Weinmeister        if($_REQUEST['mode'] === 'lock') {
92172fa282SRobert Weinmeister            preg_match_all('/<mermaid.*?>(.*?)<\/mermaid>/s', $wikitext, $matches, PREG_OFFSET_CAPTURE);
93172fa282SRobert Weinmeister
94172fa282SRobert Weinmeister            if(is_array($matches) && count($matches[0]) > $_REQUEST['mermaidindex'])
95172fa282SRobert Weinmeister            {
96172fa282SRobert Weinmeister                $whereToInsert = $matches[1][$_REQUEST['mermaidindex']][1];
97172fa282SRobert Weinmeister                $newWikitext = substr($wikitext, 0, $whereToInsert) . "\n%%" . urldecode($_REQUEST['svg']) . "\n" . substr($wikitext, $whereToInsert);
98172fa282SRobert Weinmeister            }
99172fa282SRobert Weinmeister            else
100172fa282SRobert Weinmeister            {
101172fa282SRobert Weinmeister                echo json_encode(['status' => 'failure', 'data' => ['Could not lock the Mermaid diagram as the request could not be matched.']]);
102172fa282SRobert Weinmeister                exit();
103172fa282SRobert Weinmeister            }
104172fa282SRobert Weinmeister        }
105172fa282SRobert Weinmeister
106172fa282SRobert Weinmeister        if($_REQUEST['mode'] == 'unlock')
107172fa282SRobert Weinmeister        {
108172fa282SRobert Weinmeister            $newWikitext = str_replace("\n%%" . urldecode($_REQUEST['svg']) . "\n", '', $wikitext, $count);
109172fa282SRobert Weinmeister            if($count != 1)
110172fa282SRobert Weinmeister            {
111172fa282SRobert Weinmeister                echo json_encode(['status' => 'failure', 'data' => ['Could not unlock the Mermaid diagram as the request could not be matched.']]);
112172fa282SRobert Weinmeister                exit();
113172fa282SRobert Weinmeister            }
114172fa282SRobert Weinmeister        }
115172fa282SRobert Weinmeister
116b566ae41SRobert Weinmeister        if($this->isWikiTextChanged($wikitext, $newWikitext)) {
117b566ae41SRobert Weinmeister            $this->saveWikiChanges($ID, $newWikitext, $_REQUEST['mode']);
118172fa282SRobert Weinmeister            echo json_encode(['status' => 'success', 'data' => []]);
119b566ae41SRobert Weinmeister        } else{
120172fa282SRobert Weinmeister            echo json_encode(['status' => 'failure', 'data' => ['Could not ' . $_REQUEST['mode'] . ' the Mermaid diagram.']]);
121b566ae41SRobert Weinmeister        }
122b566ae41SRobert Weinmeister
123172fa282SRobert Weinmeister        exit();
12446a60b4fSRobertWeinmeister    }
12546a60b4fSRobertWeinmeister
126b566ae41SRobert Weinmeister    private function addLocalScript(Doku_Event $event): void {
127b566ae41SRobert Weinmeister        $event->data['script'][] = [
128b566ae41SRobert Weinmeister            'type'    => 'text/javascript',
129b566ae41SRobert Weinmeister            'charset' => 'utf-8',
130b566ae41SRobert Weinmeister            'src'     => DOKU_BASE . 'lib/plugins/mermaid/mermaid.min.js',
131b566ae41SRobert Weinmeister        ];
132b566ae41SRobert Weinmeister    }
133b566ae41SRobert Weinmeister
134b566ae41SRobert Weinmeister    private function addEsmScript(Doku_Event $event, string $version, string $init): void {
135b566ae41SRobert Weinmeister        $data = "import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid{$version}/dist/mermaid.esm.min.mjs';{$init}";
136b566ae41SRobert Weinmeister        $event->data['script'][] = [
137b566ae41SRobert Weinmeister            'type'    => 'module',
138b566ae41SRobert Weinmeister            'charset' => 'utf-8',
139b566ae41SRobert Weinmeister            '_data'   => $data,
140b566ae41SRobert Weinmeister        ];
141b566ae41SRobert Weinmeister    }
142b566ae41SRobert Weinmeister
143b566ae41SRobert Weinmeister    private function addScript(Doku_Event $event, string $version, string $init): void {
144b566ae41SRobert Weinmeister        $event->data['script'][] = [
145b566ae41SRobert Weinmeister            'type'    => 'text/javascript',
146b566ae41SRobert Weinmeister            'charset' => 'utf-8',
147b566ae41SRobert Weinmeister            'src'     => "https://cdn.jsdelivr.net/npm/mermaid{$version}/dist/mermaid.min.js",
148b566ae41SRobert Weinmeister        ];
149b566ae41SRobert Weinmeister
150b566ae41SRobert Weinmeister        $event->data['script'][] = [
151b566ae41SRobert Weinmeister            'type'    => 'text/javascript',
152b566ae41SRobert Weinmeister            'charset' => 'utf-8',
153b566ae41SRobert Weinmeister            '_data'   => $init,
154b566ae41SRobert Weinmeister        ];
155b566ae41SRobert Weinmeister    }
156b566ae41SRobert Weinmeister
157*b74fbfffSRobert Weinmeister    private function pageIncludesMermaid(): bool {
158*b74fbfffSRobert Weinmeister        // true if the mermaid tag is used
159*b74fbfffSRobert Weinmeister        // the include plugin can hide this fact, so we need a separate check for it
160*b74fbfffSRobert Weinmeister        $wikiText = rawWiki(getID());
161*b74fbfffSRobert Weinmeister        if (str_contains($wikiText, '<mermaid') || str_contains($wikiText, '{{page>') || str_contains($wikiText, '{{section>') || str_contains($wikiText, '{{namespace>') || str_contains($wikiText, '{{tagtopic>')) {
162*b74fbfffSRobert Weinmeister            return true;
163*b74fbfffSRobert Weinmeister        }
164*b74fbfffSRobert Weinmeister
165*b74fbfffSRobert Weinmeister        return false;
166*b74fbfffSRobert Weinmeister    }
167*b74fbfffSRobert Weinmeister
168b566ae41SRobert Weinmeister    /**
169b566ae41SRobert Weinmeister     * Load the Mermaid library and configuration into the page.
170b566ae41SRobert Weinmeister     *
171b566ae41SRobert Weinmeister     * @param Doku_Event $event DokuWiki event object
172b566ae41SRobert Weinmeister     * @param mixed $param Unused parameter.
173b566ae41SRobert Weinmeister     */
174b566ae41SRobert Weinmeister    public function load(Doku_Event $event, $param): void {
175ea08b541SRobert Weinmeister         // only load mermaid if it is needed
176*b74fbfffSRobert Weinmeister        if (!$this->pageIncludesMermaid()) {
177ea08b541SRobert Weinmeister            return;
178ea08b541SRobert Weinmeister        }
179ea08b541SRobert Weinmeister
1804c8bd9ffSRobert Weinmeister        $theme = $this->getConf('theme');
18155e3db93SRobert Weinmeister        $look = $this->getConf('look');
18255e3db93SRobert Weinmeister        $logLevel = $this->getConf('logLevel');
183b566ae41SRobert Weinmeister        $init = "mermaid.initialize({startOnLoad: true, logLevel: '$logLevel', theme: '$theme', look: '$look'});";
1844c8bd9ffSRobert Weinmeister
185b566ae41SRobert Weinmeister        $location = $this->getConf('location');
186b566ae41SRobert Weinmeister        $versions = [
1876fcac025SRobert Weinmeister            'latest'     => '',
188a788b843SRobert Weinmeister            'remote1091' => '@10.9.1',
1896fcac025SRobert Weinmeister            'remote108'  => '@10.8.0',
1906fcac025SRobert Weinmeister            'remote106'  => '@10.6.1',
1916fcac025SRobert Weinmeister            'remote104'  => '@10.4.0',
1926fcac025SRobert Weinmeister            'remote103'  => '@10.3.1',
1936fcac025SRobert Weinmeister            'remote102'  => '@10.2.4',
1946fcac025SRobert Weinmeister            'remote101'  => '@10.1.0',
195b566ae41SRobert Weinmeister            'remote100'  => '@10.0.2',
196b566ae41SRobert Weinmeister            'remote94'   => '@9.4.3',
197b566ae41SRobert Weinmeister            'remote943'  => '@9.4.3',
198b566ae41SRobert Weinmeister            'remote93'   => '@9.3.0',
199b566ae41SRobert Weinmeister        ];
20046a60b4fSRobertWeinmeister
201b566ae41SRobert Weinmeister        // add the appropriate Mermaid script based on the location configuration
202b566ae41SRobert Weinmeister        match ($location) {
203b566ae41SRobert Weinmeister            'local' => $this->addLocalScript($event),
204b566ae41SRobert Weinmeister            'latest', 'remote1091', 'remote108', 'remote106', 'remote104', 'remote103', 'remote102', 'remote101', 'remote100'
205b566ae41SRobert Weinmeister                => $this->addEsmScript($event, $versions[$location], $init),
206b566ae41SRobert Weinmeister            'remote94', 'remote943', 'remote93'
207b566ae41SRobert Weinmeister                => $this->addScript($event, $versions[$location], $init),
208b566ae41SRobert Weinmeister            default => null,
209b566ae41SRobert Weinmeister        };
210b566ae41SRobert Weinmeister
211b566ae41SRobert Weinmeister        $event->data['link'][] = [
21246a60b4fSRobertWeinmeister            'rel'     => 'stylesheet',
21346a60b4fSRobertWeinmeister            'type'    => 'text/css',
21446a60b4fSRobertWeinmeister            'href'    => DOKU_BASE . "lib/plugins/mermaid/mermaid.css",
215b566ae41SRobert Weinmeister        ];
2166fcac025SRobert Weinmeister
2176fcac025SRobert Weinmeister        // remove the search highlight from DokuWiki as it interferes with the Mermaid parsing/rendering
218b566ae41SRobert Weinmeister        $event->data['script'][] = [
2196fcac025SRobert Weinmeister            'type'    => 'text/javascript',
2206fcac025SRobert Weinmeister            'charset' => 'utf-8',
2216fcac025SRobert Weinmeister            '_data' => "document.addEventListener('DOMContentLoaded', function() {
2226fcac025SRobert Weinmeister                            jQuery('.mermaid').each(function() {
2236fcac025SRobert Weinmeister                                var modifiedContent = jQuery(this).html().replace(/<span class=\"search_hit\">(.+?)<\/span>/g, '$1');
2246fcac025SRobert Weinmeister                                jQuery(this).html(modifiedContent);
2256fcac025SRobert Weinmeister                             })
2266fcac025SRobert Weinmeister                        });"
227b566ae41SRobert Weinmeister        ];
2281da12d6eSRobert Weinmeister
2291da12d6eSRobert Weinmeister        // adds image-save capability
2301da12d6eSRobert Weinmeister        // First: Wait until the DOM content is fully loaded
2311da12d6eSRobert Weinmeister        // Second: Wait until Mermaid has changed the dokuwiki content to an svg
2321da12d6eSRobert Weinmeister        $event->data['script'][] = array
2331da12d6eSRobert Weinmeister        (
2341da12d6eSRobert Weinmeister            'type'    => 'text/javascript',
2351da12d6eSRobert Weinmeister            'charset' => 'utf-8',
2361da12d6eSRobert Weinmeister            '_data' => "
2371da12d6eSRobert Weinmeisterdocument.addEventListener('DOMContentLoaded', function() {
2381da12d6eSRobert Weinmeister     var config = {
2391da12d6eSRobert Weinmeister        childList: true,
2401da12d6eSRobert Weinmeister        subtree: true,
2411da12d6eSRobert Weinmeister        characterData: true
2421da12d6eSRobert Weinmeister    };
2431da12d6eSRobert Weinmeister
244172fa282SRobert Weinmeister    function callDokuWikiPHP(mode, index, mermaidRaw, mermaidSvg) {
245172fa282SRobert Weinmeister    jQuery.post(
246172fa282SRobert Weinmeister        DOKU_BASE + 'lib/exe/ajax.php',
247172fa282SRobert Weinmeister        {
248172fa282SRobert Weinmeister            call: 'plugin_mermaid',
249172fa282SRobert Weinmeister            mode: mode,
250172fa282SRobert Weinmeister            mermaidindex: index,
251172fa282SRobert Weinmeister            pageid: '".getID()."',
252172fa282SRobert Weinmeister            svg: encodeURIComponent(mermaidSvg)
253172fa282SRobert Weinmeister        },
254172fa282SRobert Weinmeister        function(response) {
255172fa282SRobert Weinmeister            if(response.status == 'success') {
256172fa282SRobert Weinmeister                location.reload(true);
257172fa282SRobert Weinmeister            }
258172fa282SRobert Weinmeister            else {
259172fa282SRobert Weinmeister                alert(response.data[0]);
260172fa282SRobert Weinmeister            }
261172fa282SRobert Weinmeister        },
262172fa282SRobert Weinmeister        'json'
263172fa282SRobert Weinmeister    )};
264172fa282SRobert Weinmeister
265172fa282SRobert Weinmeister    jQuery('.mermaidlocked, .mermaid').each(function(index, element) {
2661da12d6eSRobert Weinmeister        document.getElementById('mermaidContainer' + index).addEventListener('mouseenter', function() {
267172fa282SRobert Weinmeister             document.getElementById('mermaidFieldset' + index).style.display = 'flex';
2681da12d6eSRobert Weinmeister        });
2691da12d6eSRobert Weinmeister        document.getElementById('mermaidContainer' + index).addEventListener('mouseleave', function() {
270172fa282SRobert Weinmeister            document.getElementById('mermaidFieldset' + index).style.display = 'none';
2711da12d6eSRobert Weinmeister        });
2721da12d6eSRobert Weinmeister
273172fa282SRobert Weinmeister        if(jQuery(element).hasClass('mermaidlocked')) {
274172fa282SRobert Weinmeister            document.getElementById('mermaidButtonSave' + index).addEventListener('click', () => {
2751da12d6eSRobert Weinmeister                var svgContent = element.innerHTML.trim();
2761da12d6eSRobert Weinmeister                var blob = new Blob([svgContent], { type: 'image/svg+xml' });
2771da12d6eSRobert Weinmeister                var link = document.createElement('a');
2781da12d6eSRobert Weinmeister                link.href = URL.createObjectURL(blob);
2791da12d6eSRobert Weinmeister                link.download = 'mermaid' + index + '.svg';
2801da12d6eSRobert Weinmeister                link.click();
2811da12d6eSRobert Weinmeister                URL.revokeObjectURL(link.href);
2821da12d6eSRobert Weinmeister            });
283172fa282SRobert Weinmeister
284172fa282SRobert Weinmeister            document.getElementById('mermaidButtonPermanent' + index).addEventListener('click', () => {
285172fa282SRobert Weinmeister                if(confirm('Unlock Mermaid diagram?')) {
286172fa282SRobert Weinmeister                    callDokuWikiPHP('unlock', index, originalMermaidContent, element.innerHTML.trim());
287172fa282SRobert Weinmeister                }
288172fa282SRobert Weinmeister            });
289172fa282SRobert Weinmeister        }
290172fa282SRobert Weinmeister
291172fa282SRobert Weinmeister        if(jQuery(element).hasClass('mermaid')) {
292172fa282SRobert Weinmeister            var originalMermaidContent = element.innerHTML;
293172fa282SRobert Weinmeister            var observer = new MutationObserver(function(mutations) {
294172fa282SRobert Weinmeister                mutations.forEach(function(mutation) {
295172fa282SRobert Weinmeister                    if (mutation.type === 'childList' && element.innerHTML.startsWith('<svg')) {
296172fa282SRobert Weinmeister                        document.getElementById('mermaidButtonSave' + index).addEventListener('click', () => {
297172fa282SRobert Weinmeister                            var svgContent = element.innerHTML.trim();
298172fa282SRobert Weinmeister                            var blob = new Blob([svgContent], { type: 'image/svg+xml' });
299172fa282SRobert Weinmeister                            var link = document.createElement('a');
300172fa282SRobert Weinmeister                            link.href = URL.createObjectURL(blob);
301172fa282SRobert Weinmeister                            link.download = 'mermaid' + index + '.svg';
302172fa282SRobert Weinmeister                            link.click();
303172fa282SRobert Weinmeister                            URL.revokeObjectURL(link.href);
304172fa282SRobert Weinmeister                        });
305172fa282SRobert Weinmeister
306172fa282SRobert Weinmeister                       document.getElementById('mermaidButtonPermanent' + index).addEventListener('click', () => {
307172fa282SRobert Weinmeister                            if(confirm('Lock Mermaid diagram? [experimental]')) {
308172fa282SRobert Weinmeister                                callDokuWikiPHP('lock', index, originalMermaidContent, element.innerHTML.trim());
309172fa282SRobert Weinmeister                            }
310172fa282SRobert Weinmeister                       });
3111da12d6eSRobert Weinmeister                    }
3121da12d6eSRobert Weinmeister                });
3131da12d6eSRobert Weinmeister            });
3141da12d6eSRobert Weinmeister            observer.observe(element, config);
315172fa282SRobert Weinmeister        }
3161da12d6eSRobert Weinmeister    });
3171da12d6eSRobert Weinmeister});"
3181da12d6eSRobert Weinmeister        );
31946a60b4fSRobertWeinmeister    }
32046a60b4fSRobertWeinmeister}