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