xref: /plugin/mermaid/action.php (revision b566ae41d43f34982a49c783a4196e63d98ad341)
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
9*b566ae41SRobert Weinmeisterdeclare(strict_types=1);
10*b566ae41SRobert Weinmeister
11*b566ae41SRobert Weinmeisterif (!defined('DOKU_INC')) {
12*b566ae41SRobert Weinmeister    die();
13*b566ae41SRobert Weinmeister}
14ea08b541SRobert Weinmeister
1546a60b4fSRobertWeinmeisterclass action_plugin_mermaid extends \dokuwiki\Extension\ActionPlugin
1646a60b4fSRobertWeinmeister{
1746a60b4fSRobertWeinmeister    /** @inheritDoc */
18*b566ae41SRobert 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
23*b566ae41SRobert Weinmeister    private function hasPermissionToEdit(string $ID): bool {
24*b566ae41SRobert Weinmeister        return auth_quickaclcheck($ID) >= AUTH_EDIT;
25172fa282SRobert Weinmeister    }
26172fa282SRobert Weinmeister
27*b566ae41SRobert Weinmeister    private function isPageLocked(string $ID): bool {
28*b566ae41SRobert Weinmeister        return checklock($ID);
29*b566ae41SRobert Weinmeister    }
30*b566ae41SRobert Weinmeister
31*b566ae41SRobert Weinmeister    private function lockMermaidDiagram(string $wikitext): string {
32*b566ae41SRobert Weinmeister        preg_match_all('/<mermaid.*?>(.*?)<\/mermaid>/s', $wikitext, $matches, PREG_OFFSET_CAPTURE);
33*b566ae41SRobert Weinmeister
34*b566ae41SRobert Weinmeister        if (is_array($matches) && count($matches[0]) > (int)$_REQUEST['mermaidindex']) {
35*b566ae41SRobert Weinmeister            $whereToInsert = $matches[1][(int)$_REQUEST['mermaidindex']][1];
36*b566ae41SRobert Weinmeister            return substr($wikitext, 0, $whereToInsert) . "\n%%" . urldecode($_REQUEST['svg']) . "\n" . substr($wikitext, $whereToInsert);
37*b566ae41SRobert Weinmeister        }
38*b566ae41SRobert Weinmeister
39*b566ae41SRobert Weinmeister        echo json_encode(['status' => 'failure', 'data' => ['Could not lock the Mermaid diagram as the request could not be matched.']]);
40*b566ae41SRobert Weinmeister        exit();
41*b566ae41SRobert Weinmeister    }
42*b566ae41SRobert Weinmeister
43*b566ae41SRobert Weinmeister    private function unlockMermaidDiagram(string $wikitext): string {
44*b566ae41SRobert Weinmeister        $newWikitext = str_replace("\n%%" . urldecode($_REQUEST['svg']) . "\n", '', $wikitext, $count);
45*b566ae41SRobert Weinmeister
46*b566ae41SRobert Weinmeister        if ($count !== 1) {
47*b566ae41SRobert Weinmeister            echo json_encode(['status' => 'failure', 'data' => ['Could not unlock the Mermaid diagram as the request could not be matched.']]);
48*b566ae41SRobert Weinmeister            exit();
49*b566ae41SRobert Weinmeister        }
50*b566ae41SRobert Weinmeister
51*b566ae41SRobert Weinmeister        return $newWikitext;
52*b566ae41SRobert Weinmeister    }
53*b566ae41SRobert Weinmeister
54*b566ae41SRobert Weinmeister    private function isWikiTextChanged(string $wikitext, string $newWikitext): bool {
55*b566ae41SRobert Weinmeister        return strlen($newWikitext) > 0 && $newWikitext !== $wikitext;
56*b566ae41SRobert Weinmeister    }
57*b566ae41SRobert Weinmeister
58*b566ae41SRobert Weinmeister    private function saveWikiChanges(string $ID, string $newWikitext, string $mode): void {
59*b566ae41SRobert Weinmeister        lock($ID);
60*b566ae41SRobert Weinmeister        saveWikiText($ID, $newWikitext, "{$mode} Mermaid diagram", true);
61*b566ae41SRobert Weinmeister        unlock($ID);
62*b566ae41SRobert Weinmeister    }
63*b566ae41SRobert Weinmeister
64*b566ae41SRobert Weinmeister    public function handleAjaxRequest(Doku_Event $event, $param): void {
65*b566ae41SRobert Weinmeister        if ($event->data !== 'plugin_mermaid') {
66*b566ae41SRobert Weinmeister            return;
67*b566ae41SRobert Weinmeister        }
68172fa282SRobert Weinmeister        $event->stopPropagation();
69172fa282SRobert Weinmeister        $event->preventDefault();
70172fa282SRobert Weinmeister
71*b566ae41SRobert Weinmeister        if (!isset($_REQUEST['mermaidindex']) || !isset($_REQUEST['svg'])) {
72*b566ae41SRobert Weinmeister            echo json_encode(['status' => 'failure', 'data' => ['Missing required parameters.']]);
73*b566ae41SRobert Weinmeister            exit();
74*b566ae41SRobert Weinmeister        }
75*b566ae41SRobert Weinmeister
76172fa282SRobert Weinmeister        $ID = cleanID(urldecode($_REQUEST['pageid']));
77172fa282SRobert Weinmeister
78*b566ae41SRobert 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
83*b566ae41SRobert 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
91*b566ae41SRobert 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
116*b566ae41SRobert Weinmeister        if($this->isWikiTextChanged($wikitext, $newWikitext)) {
117*b566ae41SRobert Weinmeister            $this->saveWikiChanges($ID, $newWikitext, $_REQUEST['mode']);
118172fa282SRobert Weinmeister            echo json_encode(['status' => 'success', 'data' => []]);
119*b566ae41SRobert Weinmeister        } else{
120172fa282SRobert Weinmeister            echo json_encode(['status' => 'failure', 'data' => ['Could not ' . $_REQUEST['mode'] . ' the Mermaid diagram.']]);
121*b566ae41SRobert Weinmeister        }
122*b566ae41SRobert Weinmeister
123172fa282SRobert Weinmeister        exit();
12446a60b4fSRobertWeinmeister    }
12546a60b4fSRobertWeinmeister
126*b566ae41SRobert Weinmeister    private function addLocalScript(Doku_Event $event): void {
127*b566ae41SRobert Weinmeister        $event->data['script'][] = [
128*b566ae41SRobert Weinmeister            'type'    => 'text/javascript',
129*b566ae41SRobert Weinmeister            'charset' => 'utf-8',
130*b566ae41SRobert Weinmeister            'src'     => DOKU_BASE . 'lib/plugins/mermaid/mermaid.min.js',
131*b566ae41SRobert Weinmeister        ];
132*b566ae41SRobert Weinmeister    }
133*b566ae41SRobert Weinmeister
134*b566ae41SRobert Weinmeister    private function addEsmScript(Doku_Event $event, string $version, string $init): void {
135*b566ae41SRobert Weinmeister        $data = "import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid{$version}/dist/mermaid.esm.min.mjs';{$init}";
136*b566ae41SRobert Weinmeister        $event->data['script'][] = [
137*b566ae41SRobert Weinmeister            'type'    => 'module',
138*b566ae41SRobert Weinmeister            'charset' => 'utf-8',
139*b566ae41SRobert Weinmeister            '_data'   => $data,
140*b566ae41SRobert Weinmeister        ];
141*b566ae41SRobert Weinmeister    }
142*b566ae41SRobert Weinmeister
143*b566ae41SRobert Weinmeister    private function addScript(Doku_Event $event, string $version, string $init): void {
144*b566ae41SRobert Weinmeister        $event->data['script'][] = [
145*b566ae41SRobert Weinmeister            'type'    => 'text/javascript',
146*b566ae41SRobert Weinmeister            'charset' => 'utf-8',
147*b566ae41SRobert Weinmeister            'src'     => "https://cdn.jsdelivr.net/npm/mermaid{$version}/dist/mermaid.min.js",
148*b566ae41SRobert Weinmeister        ];
149*b566ae41SRobert Weinmeister
150*b566ae41SRobert Weinmeister        $event->data['script'][] = [
151*b566ae41SRobert Weinmeister            'type'    => 'text/javascript',
152*b566ae41SRobert Weinmeister            'charset' => 'utf-8',
153*b566ae41SRobert Weinmeister            '_data'   => $init,
154*b566ae41SRobert Weinmeister        ];
155*b566ae41SRobert Weinmeister    }
156*b566ae41SRobert Weinmeister
157*b566ae41SRobert Weinmeister    /**
158*b566ae41SRobert Weinmeister     * Load the Mermaid library and configuration into the page.
159*b566ae41SRobert Weinmeister     *
160*b566ae41SRobert Weinmeister     * @param Doku_Event $event DokuWiki event object
161*b566ae41SRobert Weinmeister     * @param mixed $param Unused parameter.
162*b566ae41SRobert Weinmeister     */
163*b566ae41SRobert Weinmeister    public function load(Doku_Event $event, $param): void {
164ea08b541SRobert Weinmeister        // only load mermaid if it is needed
165*b566ae41SRobert Weinmeister        if (!str_contains(rawWiki(getID()), '<mermaid')) {
166ea08b541SRobert Weinmeister            return;
167ea08b541SRobert Weinmeister        }
168ea08b541SRobert Weinmeister
1694c8bd9ffSRobert Weinmeister        $theme = $this->getConf('theme');
17055e3db93SRobert Weinmeister        $look = $this->getConf('look');
17155e3db93SRobert Weinmeister        $logLevel = $this->getConf('logLevel');
172*b566ae41SRobert Weinmeister        $init = "mermaid.initialize({startOnLoad: true, logLevel: '$logLevel', theme: '$theme', look: '$look'});";
1734c8bd9ffSRobert Weinmeister
174*b566ae41SRobert Weinmeister        $location = $this->getConf('location');
175*b566ae41SRobert Weinmeister        $versions = [
1766fcac025SRobert Weinmeister            'latest'     => '',
177a788b843SRobert Weinmeister            'remote1091' => '@10.9.1',
1786fcac025SRobert Weinmeister            'remote108'  => '@10.8.0',
1796fcac025SRobert Weinmeister            'remote106'  => '@10.6.1',
1806fcac025SRobert Weinmeister            'remote104'  => '@10.4.0',
1816fcac025SRobert Weinmeister            'remote103'  => '@10.3.1',
1826fcac025SRobert Weinmeister            'remote102'  => '@10.2.4',
1836fcac025SRobert Weinmeister            'remote101'  => '@10.1.0',
184*b566ae41SRobert Weinmeister            'remote100'  => '@10.0.2',
185*b566ae41SRobert Weinmeister            'remote94'   => '@9.4.3',
186*b566ae41SRobert Weinmeister            'remote943'  => '@9.4.3',
187*b566ae41SRobert Weinmeister            'remote93'   => '@9.3.0',
188*b566ae41SRobert Weinmeister        ];
18946a60b4fSRobertWeinmeister
190*b566ae41SRobert Weinmeister        // add the appropriate Mermaid script based on the location configuration
191*b566ae41SRobert Weinmeister        match ($location) {
192*b566ae41SRobert Weinmeister            'local' => $this->addLocalScript($event),
193*b566ae41SRobert Weinmeister            'latest', 'remote1091', 'remote108', 'remote106', 'remote104', 'remote103', 'remote102', 'remote101', 'remote100'
194*b566ae41SRobert Weinmeister                => $this->addEsmScript($event, $versions[$location], $init),
195*b566ae41SRobert Weinmeister            'remote94', 'remote943', 'remote93'
196*b566ae41SRobert Weinmeister                => $this->addScript($event, $versions[$location], $init),
197*b566ae41SRobert Weinmeister            default => null,
198*b566ae41SRobert Weinmeister        };
199*b566ae41SRobert Weinmeister
200*b566ae41SRobert Weinmeister        $event->data['link'][] = [
20146a60b4fSRobertWeinmeister            'rel'     => 'stylesheet',
20246a60b4fSRobertWeinmeister            'type'    => 'text/css',
20346a60b4fSRobertWeinmeister            'href'    => DOKU_BASE . "lib/plugins/mermaid/mermaid.css",
204*b566ae41SRobert Weinmeister        ];
2056fcac025SRobert Weinmeister
2066fcac025SRobert Weinmeister        // remove the search highlight from DokuWiki as it interferes with the Mermaid parsing/rendering
207*b566ae41SRobert Weinmeister        $event->data['script'][] = [
2086fcac025SRobert Weinmeister            'type'    => 'text/javascript',
2096fcac025SRobert Weinmeister            'charset' => 'utf-8',
2106fcac025SRobert Weinmeister            '_data' => "document.addEventListener('DOMContentLoaded', function() {
2116fcac025SRobert Weinmeister                            jQuery('.mermaid').each(function() {
2126fcac025SRobert Weinmeister                                var modifiedContent = jQuery(this).html().replace(/<span class=\"search_hit\">(.+?)<\/span>/g, '$1');
2136fcac025SRobert Weinmeister                                jQuery(this).html(modifiedContent);
2146fcac025SRobert Weinmeister                             })
2156fcac025SRobert Weinmeister                        });"
216*b566ae41SRobert Weinmeister        ];
2171da12d6eSRobert Weinmeister
2181da12d6eSRobert Weinmeister        // adds image-save capability
2191da12d6eSRobert Weinmeister        // First: Wait until the DOM content is fully loaded
2201da12d6eSRobert Weinmeister        // Second: Wait until Mermaid has changed the dokuwiki content to an svg
2211da12d6eSRobert Weinmeister        $event->data['script'][] = array
2221da12d6eSRobert Weinmeister        (
2231da12d6eSRobert Weinmeister            'type'    => 'text/javascript',
2241da12d6eSRobert Weinmeister            'charset' => 'utf-8',
2251da12d6eSRobert Weinmeister            '_data' => "
2261da12d6eSRobert Weinmeisterdocument.addEventListener('DOMContentLoaded', function() {
2271da12d6eSRobert Weinmeister     var config = {
2281da12d6eSRobert Weinmeister        childList: true,
2291da12d6eSRobert Weinmeister        subtree: true,
2301da12d6eSRobert Weinmeister        characterData: true
2311da12d6eSRobert Weinmeister    };
2321da12d6eSRobert Weinmeister
233172fa282SRobert Weinmeister    function callDokuWikiPHP(mode, index, mermaidRaw, mermaidSvg) {
234172fa282SRobert Weinmeister    jQuery.post(
235172fa282SRobert Weinmeister        DOKU_BASE + 'lib/exe/ajax.php',
236172fa282SRobert Weinmeister        {
237172fa282SRobert Weinmeister            call: 'plugin_mermaid',
238172fa282SRobert Weinmeister            mode: mode,
239172fa282SRobert Weinmeister            mermaidindex: index,
240172fa282SRobert Weinmeister            pageid: '".getID()."',
241172fa282SRobert Weinmeister            svg: encodeURIComponent(mermaidSvg)
242172fa282SRobert Weinmeister        },
243172fa282SRobert Weinmeister        function(response) {
244172fa282SRobert Weinmeister            if(response.status == 'success') {
245172fa282SRobert Weinmeister                location.reload(true);
246172fa282SRobert Weinmeister            }
247172fa282SRobert Weinmeister            else {
248172fa282SRobert Weinmeister                alert(response.data[0]);
249172fa282SRobert Weinmeister            }
250172fa282SRobert Weinmeister        },
251172fa282SRobert Weinmeister        'json'
252172fa282SRobert Weinmeister    )};
253172fa282SRobert Weinmeister
254172fa282SRobert Weinmeister    jQuery('.mermaidlocked, .mermaid').each(function(index, element) {
2551da12d6eSRobert Weinmeister        document.getElementById('mermaidContainer' + index).addEventListener('mouseenter', function() {
256172fa282SRobert Weinmeister             document.getElementById('mermaidFieldset' + index).style.display = 'flex';
2571da12d6eSRobert Weinmeister        });
2581da12d6eSRobert Weinmeister        document.getElementById('mermaidContainer' + index).addEventListener('mouseleave', function() {
259172fa282SRobert Weinmeister            document.getElementById('mermaidFieldset' + index).style.display = 'none';
2601da12d6eSRobert Weinmeister        });
2611da12d6eSRobert Weinmeister
262172fa282SRobert Weinmeister        if(jQuery(element).hasClass('mermaidlocked')) {
263172fa282SRobert Weinmeister            document.getElementById('mermaidButtonSave' + index).addEventListener('click', () => {
2641da12d6eSRobert Weinmeister                var svgContent = element.innerHTML.trim();
2651da12d6eSRobert Weinmeister                var blob = new Blob([svgContent], { type: 'image/svg+xml' });
2661da12d6eSRobert Weinmeister                var link = document.createElement('a');
2671da12d6eSRobert Weinmeister                link.href = URL.createObjectURL(blob);
2681da12d6eSRobert Weinmeister                link.download = 'mermaid' + index + '.svg';
2691da12d6eSRobert Weinmeister                link.click();
2701da12d6eSRobert Weinmeister                URL.revokeObjectURL(link.href);
2711da12d6eSRobert Weinmeister            });
272172fa282SRobert Weinmeister
273172fa282SRobert Weinmeister            document.getElementById('mermaidButtonPermanent' + index).addEventListener('click', () => {
274172fa282SRobert Weinmeister                if(confirm('Unlock Mermaid diagram?')) {
275172fa282SRobert Weinmeister                    callDokuWikiPHP('unlock', index, originalMermaidContent, element.innerHTML.trim());
276172fa282SRobert Weinmeister                }
277172fa282SRobert Weinmeister            });
278172fa282SRobert Weinmeister        }
279172fa282SRobert Weinmeister
280172fa282SRobert Weinmeister        if(jQuery(element).hasClass('mermaid')) {
281172fa282SRobert Weinmeister            var originalMermaidContent = element.innerHTML;
282172fa282SRobert Weinmeister            var observer = new MutationObserver(function(mutations) {
283172fa282SRobert Weinmeister                mutations.forEach(function(mutation) {
284172fa282SRobert Weinmeister                    if (mutation.type === 'childList' && element.innerHTML.startsWith('<svg')) {
285172fa282SRobert Weinmeister                        document.getElementById('mermaidButtonSave' + index).addEventListener('click', () => {
286172fa282SRobert Weinmeister                            var svgContent = element.innerHTML.trim();
287172fa282SRobert Weinmeister                            var blob = new Blob([svgContent], { type: 'image/svg+xml' });
288172fa282SRobert Weinmeister                            var link = document.createElement('a');
289172fa282SRobert Weinmeister                            link.href = URL.createObjectURL(blob);
290172fa282SRobert Weinmeister                            link.download = 'mermaid' + index + '.svg';
291172fa282SRobert Weinmeister                            link.click();
292172fa282SRobert Weinmeister                            URL.revokeObjectURL(link.href);
293172fa282SRobert Weinmeister                        });
294172fa282SRobert Weinmeister
295172fa282SRobert Weinmeister                       document.getElementById('mermaidButtonPermanent' + index).addEventListener('click', () => {
296172fa282SRobert Weinmeister                            if(confirm('Lock Mermaid diagram? [experimental]')) {
297172fa282SRobert Weinmeister                                callDokuWikiPHP('lock', index, originalMermaidContent, element.innerHTML.trim());
298172fa282SRobert Weinmeister                            }
299172fa282SRobert Weinmeister                       });
3001da12d6eSRobert Weinmeister                    }
3011da12d6eSRobert Weinmeister                });
3021da12d6eSRobert Weinmeister            });
3031da12d6eSRobert Weinmeister            observer.observe(element, config);
304172fa282SRobert Weinmeister        }
3051da12d6eSRobert Weinmeister    });
3061da12d6eSRobert Weinmeister});"
3071da12d6eSRobert Weinmeister        );
30846a60b4fSRobertWeinmeister    }
30946a60b4fSRobertWeinmeister}
3101da12d6eSRobert Weinmeister
311