1<?php 2// SPDX-License-Identifier: GPL-2.0-or-later 3// SPDX-FileCopyrightText: 2024-2025 Yamada, M. 4// DokuWiki Plugin Mizar Verifiable Docs (Action Component) 5 6use dokuwiki\Extension\ActionPlugin; 7use dokuwiki\Extension\EventHandler; 8use dokuwiki\Extension\Event; 9 10 11class action_plugin_mizarverifiabledocs extends ActionPlugin 12{ 13 /* ===================== Register ===================== */ 14 15 public function register(EventHandler $controller) 16 { 17 $controller->register_hook('AJAX_CALL_UNKNOWN', 'BEFORE', $this, 'handle_ajax_call'); 18 $controller->register_hook('TPL_CONTENT_DISPLAY', 'BEFORE', $this, 'handle_tpl_content_display'); 19 } 20 21 public function handle_tpl_content_display(Event $event, $_param) 22 { 23 if (!is_string($event->data)) return; 24 25 $html = $event->data; 26 if (strpos($html, 'mizarWrapper') !== false && strpos($html, 'id="hideAllButton"') === false) { 27 $buttonHtml = '<div class="hideAllContainer">' 28 . '<button id="hideAllButton" class="hide-all-button">Hide All</button>' 29 . '<button id="showAllButton" class="hide-all-button" style="display:none;">Show All</button>' 30 . '<button id="resetAllButton" class="reset-all-button">Reset All</button>' 31 . '</div>'; 32 $event->data = $buttonHtml . $html; 33 } 34 } 35 36 public function handle_ajax_call(Event $event, $param) 37 { 38 unset($param); 39 switch ($event->data) { 40 case 'clear_temp_files': 41 $event->preventDefault(); $event->stopPropagation(); 42 $this->clearTempFiles(); break; 43 44 case 'source_compile': 45 $event->preventDefault(); $event->stopPropagation(); 46 $this->handleSourceCompileRequest(); break; 47 48 case 'source_sse': 49 $event->preventDefault(); $event->stopPropagation(); 50 $this->handleSourceSSERequest(); break; 51 52 case 'view_compile': 53 $event->preventDefault(); $event->stopPropagation(); 54 $this->handleViewCompileRequest(); break; 55 56 case 'view_sse': 57 $event->preventDefault(); $event->stopPropagation(); 58 $this->handleViewSSERequest(); break; 59 60 case 'create_combined_file': 61 $event->preventDefault(); $event->stopPropagation(); 62 $this->handle_create_combined_file(); break; 63 64 case 'view_graph': 65 $event->preventDefault(); $event->stopPropagation(); 66 $this->handleViewGraphRequest(); break; 67 } 68 } 69 70 /* ===================== Helpers ===================== */ 71 72 private function isWindows(): bool { 73 return strncasecmp(PHP_OS, 'WIN', 3) === 0; 74 } 75 76 /** 設定→未設定なら htdocs(= DOKU_INC の1つ上) 相対にフォールバックして正規化 */ 77 private function resolvePaths(): array 78 { 79 // DokuWiki ルート(末尾スラッシュなしに正規化) 80 $dokuroot = rtrim(realpath(DOKU_INC), '/\\'); 81 82 // htdocs を「dokuwiki の 1つ上」から求める 83 $htdocs = realpath($dokuroot . DIRECTORY_SEPARATOR . '..'); 84 if ($htdocs === false) $htdocs = dirname($dokuroot); 85 86 $defM = $htdocs . DIRECTORY_SEPARATOR . 'MIZAR'; 87 $defW = $htdocs . DIRECTORY_SEPARATOR . 'work'; 88 89 // 設定値取得(空ならフォールバック)。相対指定が来たら htdocs 基準に解決 90 $exe = trim((string)$this->getConf('mizar_exe_dir')); 91 $share = trim((string)$this->getConf('mizar_share_dir')); 92 $work = trim((string)$this->getConf('mizar_work_dir')); 93 94 $isAbs = function(string $p): bool { 95 // Windowsドライブ/UNC/Unix ざっくり対応 96 return $p !== '' && (preg_match('~^[A-Za-z]:[\\/]|^\\\\\\\\|^/~', $p) === 1); 97 }; 98 99 if ($exe !== '' && !$isAbs($exe)) $exe = $htdocs . DIRECTORY_SEPARATOR . $exe; 100 if ($share !== '' && !$isAbs($share)) $share = $htdocs . DIRECTORY_SEPARATOR . $share; 101 if ($work !== '' && !$isAbs($work)) $work = $htdocs . DIRECTORY_SEPARATOR . $work; 102 103 $exe = rtrim($exe ?: $defM, '/\\'); 104 $share = rtrim($share ?: $defM, '/\\'); 105 $work = rtrim($work ?: $defW, '/\\'); 106 107 return ['exe' => $exe, 'share' => $share, 'work' => $work]; 108 } 109 110 /** exeDir 直下 or exeDir\bin から実行ファイルを探す(.exe/.bat/.cmd 対応) */ 111 private function findExe(string $exeDir, string $name): ?string 112 { 113 if ($this->isWindows()) { 114 $candidates = [ 115 "$exeDir\\$name.exe", "$exeDir\\bin\\$name.exe", 116 "$exeDir\\$name.bat", "$exeDir\\bin\\$name.bat", 117 "$exeDir\\$name.cmd", "$exeDir\\bin\\$name.cmd", 118 "$exeDir\\windows\\bin\\$name.exe", 119 "$exeDir\\win\\bin\\$name.exe", 120 ]; 121 } else { 122 $candidates = [ 123 "$exeDir/$name", "$exeDir/bin/$name", 124 ]; 125 } 126 foreach ($candidates as $p) { 127 if (is_file($p)) return $p; 128 } 129 return null; 130 } 131 132 /** 出力をUTF-8へ(WinはSJIS-WIN想定) */ 133 private function outUTF8(string $s): string 134 { 135 return $this->isWindows() ? mb_convert_encoding($s, 'UTF-8', 'SJIS-WIN') : $s; 136 } 137 138 /** 139 * .exe は配列+bypass_shell、.bat/.cmd は「cmd /C ""...""」の文字列+shell経由で起動 140 * @return array [$proc, $pipes] 失敗時は [null, []] 141 */ 142 private function openProcess(string $exeOrBat, array $args, string $cwd): array 143 { 144 $des = [1 => ['pipe','w'], 2 => ['pipe','w']]; 145 146 if ($this->isWindows() && preg_match('/\.(bat|cmd)$/i', $exeOrBat)) { 147 // cmd /C ""C:\path\tool.bat" "arg1" "arg2"" 148 $cmd = 'cmd.exe /C "' 149 . '"' . $exeOrBat . '"'; 150 foreach ($args as $a) { 151 $cmd .= ' ' . escapeshellarg($a); 152 } 153 $cmd .= '"'; 154 $proc = proc_open($cmd, $des, $pipes, $cwd); // shell経由(bypass_shell=false) 155 } else { 156 $cmd = array_merge([$exeOrBat], $args); 157 $proc = proc_open($cmd, $des, $pipes, $cwd, null, ['bypass_shell' => true]); 158 } 159 160 if (!is_resource($proc)) return [null, []]; 161 return [$proc, $pipes]; 162 } 163 164 /* ===================== Source ===================== */ 165 166 private function handleSourceCompileRequest() 167 { 168 global $INPUT; 169 $pageContent = $INPUT->post->str('content'); 170 $mizarData = $this->extractMizarContent($pageContent); 171 172 if ($mizarData === null) { $this->sendAjaxResponse(false, 'Mizar content not found'); return; } 173 if (isset($mizarData['error'])) { $this->sendAjaxResponse(false, $mizarData['error']); return; } 174 175 $filePath = $this->saveMizarContent($mizarData); 176 if (session_status() !== PHP_SESSION_ACTIVE) session_start(); 177 $_SESSION['source_filepath'] = $filePath; 178 179 $this->sendAjaxResponse(true, 'Mizar content processed successfully'); 180 } 181 182 private function handleSourceSSERequest() 183 { 184 header('Content-Type: text/event-stream'); 185 header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); 186 header('Pragma: no-cache'); 187 header('Expires: 0'); 188 189 if (session_status() !== PHP_SESSION_ACTIVE) session_start(); 190 if (empty($_SESSION['source_filepath'])) { 191 echo "data: Mizar file path not found in session\n\n"; @ob_flush(); @flush(); return; 192 } 193 194 $this->streamSourceOutput($_SESSION['source_filepath']); 195 196 echo "event: end\n"; 197 echo "data: Compilation complete\n\n"; 198 @ob_flush(); @flush(); 199 } 200 201 /* ===================== View ===================== */ 202 203 private function handleViewCompileRequest() 204 { 205 global $INPUT; 206 $content = $INPUT->post->str('content'); 207 $filePath = $this->createTempFile($content); 208 209 if (session_status() !== PHP_SESSION_ACTIVE) session_start(); 210 $_SESSION['view_filepath'] = $filePath; 211 212 $this->sendAjaxResponse(true, 'Mizar content processed successfully'); 213 } 214 215 private function handleViewSSERequest() 216 { 217 header('Content-Type: text/event-stream'); 218 header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); 219 header('Pragma: no-cache'); 220 header('Expires: 0'); 221 222 if (session_status() !== PHP_SESSION_ACTIVE) session_start(); 223 if (empty($_SESSION['view_filepath'])) { 224 echo "data: Mizar file path not found in session\n\n"; @ob_flush(); @flush(); return; 225 } 226 227 $this->streamViewCompileOutput($_SESSION['view_filepath']); 228 229 echo "event: end\n"; 230 echo "data: Compilation complete\n\n"; 231 @ob_flush(); @flush(); 232 } 233 234 /***** view_graph: SVG を返す *****/ 235 private function handleViewGraphRequest() 236 { 237 global $INPUT; 238 $content = $INPUT->post->str('content', ''); 239 if ($content === '') { $this->sendAjaxResponse(false, 'Empty content'); return; } 240 241 $tmp = tempnam(sys_get_temp_dir(), 'miz'); 242 $miz = $tmp . '.miz'; 243 rename($tmp, $miz); 244 file_put_contents($miz, $content); 245 246 $parser = __DIR__ . '/script/miz2svg.py'; 247 $py = $this->getConf('py_cmd') ?: 'python'; 248 $svg = shell_exec(escapeshellcmd($py) . ' ' . escapeshellarg($parser) . ' ' . escapeshellarg($miz)); 249 @unlink($miz); 250 251 if ($svg) $this->sendAjaxResponse(true, 'success', ['svg' => $svg]); 252 else $this->sendAjaxResponse(false, 'conversion failed'); 253 } 254 255 /* ===================== Content utils ===================== */ 256 257 private function extractMizarContent($pageContent) 258 { 259 $pattern = '/<mizar\s+([^>]+)>(.*?)<\/mizar>/s'; 260 preg_match_all($pattern, $pageContent, $m, PREG_SET_ORDER); 261 if (empty($m)) return null; 262 263 $fn = trim($m[0][1]); 264 $stem = preg_replace('/\.miz$/i', '', $fn); 265 if (!$this->isValidFileName($stem)) { 266 return ['error' => "Invalid characters in file name: '{$stem}'. Only letters, numbers, underscores (_), and apostrophes (') are allowed, up to 8 characters."]; 267 } 268 269 $combined = ''; 270 foreach ($m as $mm) { 271 $cur = preg_replace('/\.miz$/i', '', trim($mm[1])); 272 if ($cur !== $stem) return ['error' => "File name mismatch in <mizar> tags: '{$stem}' and '{$cur}'"]; 273 if (!$this->isValidFileName($cur)) return ['error' => "Invalid characters in file name: '{$cur}'."]; 274 $combined .= trim($mm[2]) . "\n"; 275 } 276 return ['fileName' => $stem . '.miz', 'content' => $combined]; 277 } 278 279 private function isValidFileName($fileName) 280 { 281 if (strlen($fileName) > 8) return false; 282 return (bool)preg_match('/^[A-Za-z0-9_\']+$/', $fileName); 283 } 284 285 private function saveMizarContent($mizarData) 286 { 287 $paths = $this->resolvePaths(); 288 $textDir = $paths['work'] . DIRECTORY_SEPARATOR . 'TEXT'; 289 if (!is_dir($textDir)) @mkdir($textDir, 0777, true); 290 291 $filePath = $textDir . DIRECTORY_SEPARATOR . $mizarData['fileName']; 292 file_put_contents($filePath, $mizarData['content']); 293 return $filePath; 294 } 295 296 private function createTempFile($content) 297 { 298 $paths = $this->resolvePaths(); 299 $textDir = $paths['work'] . DIRECTORY_SEPARATOR . 'TEXT'; 300 if (!is_dir($textDir)) @mkdir($textDir, 0777, true); 301 302 $tempFilename = $textDir . DIRECTORY_SEPARATOR . str_replace('.', '_', uniqid('tmp', true)) . ".miz"; 303 file_put_contents($tempFilename, $content); 304 return $tempFilename; 305 } 306 307 private function clearTempFiles() 308 { 309 $paths = $this->resolvePaths(); 310 $dir = $paths['work'] . DIRECTORY_SEPARATOR . 'TEXT' . DIRECTORY_SEPARATOR; 311 $files = glob($dir . '*'); 312 313 $errors = []; 314 foreach ($files as $f) { 315 if (is_file($f)) { 316 if (!$this->is_file_locked($f)) { 317 $ok = false; $retries = 5; 318 while ($retries-- > 0) { if (@unlink($f)) { $ok = true; break; } sleep(2); } 319 if (!$ok) $errors[] = "Failed to delete: $f"; 320 } else { 321 $errors[] = "File is locked: $f"; 322 } 323 } 324 } 325 if ($errors) $this->sendAjaxResponse(false, 'Some files could not be deleted', $errors); 326 else $this->sendAjaxResponse(true, 'Temporary files cleared successfully'); 327 } 328 329 private function is_file_locked($file) 330 { 331 $fp = @fopen($file, "r+"); 332 if ($fp === false) return true; 333 $locked = !flock($fp, LOCK_EX | LOCK_NB); 334 fclose($fp); 335 return $locked; 336 } 337 338 /* ===================== Run (miz2prel/makeenv/verifier) ===================== */ 339 340 private function streamSourceOutput($filePath) 341 { 342 $paths = $this->resolvePaths(); 343 $workPath = $paths['work']; 344 $sharePath = $paths['share']; 345 putenv("MIZFILES={$sharePath}"); 346 347 $exe = $this->findExe($paths['exe'], 'miz2prel'); 348 if ($exe === null) { 349 echo "data: ERROR: miz2prel not found under {$paths['exe']} (or bin)\n\n"; @ob_flush(); @flush(); return; 350 } 351 352 [$proc, $pipes] = $this->openProcess($exe, [$filePath], $workPath); 353 if (!$proc) { echo "data: ERROR: Failed to execute miz2prel.\n\n"; @ob_flush(); @flush(); return; } 354 355 while (($line = fgets($pipes[1])) !== false) { echo "data: " . $this->outUTF8($line) . "\n\n"; @ob_flush(); @flush(); } 356 fclose($pipes[1]); 357 while (($line = fgets($pipes[2])) !== false) { echo "data: ERROR: " . $this->outUTF8($line) . "\n\n"; @ob_flush(); @flush(); } 358 fclose($pipes[2]); 359 proc_close($proc); 360 361 $errFilename = str_replace('.miz', '.err', $filePath); 362 $this->handleCompilationErrors($errFilename, $sharePath . DIRECTORY_SEPARATOR . 'mizar.msg'); 363 } 364 365 private function streamViewCompileOutput($filePath) 366 { 367 $paths = $this->resolvePaths(); 368 $workPath = $paths['work']; 369 $sharePath = $paths['share']; 370 putenv("MIZFILES={$sharePath}"); 371 372 // makeenv 373 $makeenv = $this->findExe($paths['exe'], 'makeenv'); 374 if ($makeenv === null) { echo "data: ERROR: makeenv not found under {$paths['exe']} (or bin)\n\n"; @ob_flush(); @flush(); return; } 375 [$proc, $pipes] = $this->openProcess($makeenv, [$filePath], $workPath); 376 if (!$proc) { echo "data: ERROR: Failed to execute makeenv.\n\n"; @ob_flush(); @flush(); return; } 377 while (($line = fgets($pipes[1])) !== false) { echo "data: " . $this->outUTF8($line) . "\n\n"; @ob_flush(); @flush(); } 378 fclose($pipes[1]); 379 while (($line = fgets($pipes[2])) !== false) { echo "data: ERROR: " . $this->outUTF8($line) . "\n\n"; @ob_flush(); @flush(); } 380 fclose($pipes[2]); 381 proc_close($proc); 382 383 $errFilename = str_replace('.miz', '.err', $filePath); 384 if ($this->handleCompilationErrors($errFilename, $sharePath . DIRECTORY_SEPARATOR . 'mizar.msg')) return; 385 386 // verifier 387 $verifier = $this->findExe($paths['exe'], 'verifier'); 388 if ($verifier === null) { echo "data: ERROR: verifier not found under {$paths['exe']} (or bin)\n\n"; @ob_flush(); @flush(); return; } 389 $rel = 'TEXT' . DIRECTORY_SEPARATOR . basename($filePath); 390 [$proc, $pipes] = $this->openProcess($verifier, ['-q','-l',$rel], $workPath); 391 if (!$proc) { echo "data: ERROR: Failed to execute verifier.\n\n"; @ob_flush(); @flush(); return; } 392 while (($line = fgets($pipes[1])) !== false) { echo "data: " . $this->outUTF8($line) . "\n\n"; @ob_flush(); @flush(); } 393 fclose($pipes[1]); 394 while (($line = fgets($pipes[2])) !== false) { echo "data: ERROR: " . $this->outUTF8($line) . "\n\n"; @ob_flush(); @flush(); } 395 fclose($pipes[2]); 396 proc_close($proc); 397 398 $this->handleCompilationErrors($errFilename, $sharePath . DIRECTORY_SEPARATOR . 'mizar.msg'); 399 } 400 401 /* ===================== Errors ===================== */ 402 403 private function getMizarErrorMessages($mizarMsgFile) 404 { 405 if (!is_file($mizarMsgFile)) return []; 406 $errorMessages = []; 407 $lines = file($mizarMsgFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); 408 $isReading = false; $key = 0; 409 foreach ($lines as $line) { 410 if (preg_match('/# (\d+)/', $line, $m)) { $isReading = true; $key = (int)$m[1]; } 411 elseif ($isReading) { $errorMessages[$key] = $line; $isReading = false; } 412 } 413 return $errorMessages; 414 } 415 416 private function handleCompilationErrors($errFilename, $mizarMsgFilePath) 417 { 418 if (!file_exists($errFilename)) return false; 419 $errs = []; $lines = file($errFilename, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); 420 foreach ($lines as $ln) { 421 if (preg_match('/(\d+)\s+(\d+)\s+(\d+)/', $ln, $m)) { 422 $code = (int)$m[3]; 423 $errs[] = [ 424 'code' => $code, 425 'line' => (int)$m[1], 426 'column' => (int)$m[2], 427 'message' => $this->getMizarErrorMessages($mizarMsgFilePath)[$code] ?? 'Unknown error' 428 ]; 429 } 430 } 431 if ($errs) { 432 echo "event: compileErrors\n"; 433 echo "data: " . json_encode($errs) . "\n\n"; 434 @ob_flush(); @flush(); 435 return true; 436 } 437 return false; 438 } 439 440 private function sendAjaxResponse($success, $message, $data = '') 441 { 442 header('Content-Type: application/json'); 443 header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); 444 header('Pragma: no-cache'); 445 header('Expires: 0'); 446 echo json_encode(['success' => $success, 'message' => $message, 'data' => $data]); 447 exit; 448 } 449 450 private function handle_create_combined_file() 451 { 452 global $INPUT; 453 $combinedContent = $INPUT->post->str('content'); 454 $filename = $INPUT->post->str('filename', 'combined_file.miz'); 455 456 if (!empty($combinedContent)) { 457 $this->sendAjaxResponse(true, 'File created successfully', [ 458 'filename' => $filename, 459 'content' => $combinedContent 460 ]); 461 } else { 462 $this->sendAjaxResponse(false, 'Content is empty, no file created'); 463 } 464 } 465} 466