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}