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