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