1<?php 2 3use dokuwiki\Extension\ActionPlugin; 4use dokuwiki\Extension\EventHandler; 5use dokuwiki\Extension\Event; 6 7/** 8 * DokuWiki Plugin Mizar Verifiable Docs (Action Component) 9 * 10 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html 11 * @author Yamada, M. 12 */ 13 14class action_plugin_mizarverifiabledocs extends ActionPlugin 15{ 16 /** 17 * Registers a callback function for a given event 18 * 19 * @param EventHandler $controller DokuWiki's event controller object 20 * @return void 21 */ 22 public function register(EventHandler $controller) 23 { 24 $controller->register_hook('AJAX_CALL_UNKNOWN', 'BEFORE', $this, 'handle_ajax_call'); 25 $controller->register_hook('TPL_CONTENT_DISPLAY', 'BEFORE', $this, 'handle_tpl_content_display'); 26 } 27 28 /** 29 * Handles the TPL_CONTENT_DISPLAY event to insert "Hide All" button 30 * 31 * @param Event $event 32 * @param mixed $_param Unused parameter 33 * @return void 34 */ 35 public function handle_tpl_content_display(Event $event, $_param) 36 { 37 // データが文字列かどうか確認 38 if (!is_string($event->data)) { 39 error_log('handle_tpl_content_display: data is not a string! ' . print_r($event->data, true)); 40 return; 41 } 42 43 $html = $event->data; 44 45 // "mizarWrapper" クラスが存在するか確認 46 if (strpos($html, 'mizarWrapper') !== false) { 47 // 既にボタンが挿入されているか確認(複数回挿入しないため) 48 if (strpos($html, 'id="hideAllButton"') === false) { 49 $buttonHtml = '<div class="hideAllContainer">' 50 . '<button id="hideAllButton" class="hide-all-button">Hide All</button>' 51 . '<button id="showAllButton" class="hide-all-button" style="display:none;">Show All</button>' 52 . '<button id="resetAllButton" class="reset-all-button">Reset All</button>' // ★ 追加 53 . '</div>'; 54 55 // 先頭にボタンを挿入 56 $html = $buttonHtml . $html; 57 $event->data = $html; 58 error_log('handle_tpl_content_display: "Hide All" ボタンを挿入しました。'); 59 } else { 60 // ボタンが既に存在する場合 61 error_log('handle_tpl_content_display: "Hide All" ボタンは既に存在します。'); 62 } 63 } 64 } 65 66 /** 67 * Handles AJAX requests 68 * 69 * @param Event $event 70 * @param $param 71 */ 72 public function handle_ajax_call(Event $event, $param) 73 { 74 unset($param); // 未使用のパラメータを無効化 75 76 switch ($event->data) { 77 case 'clear_temp_files': 78 $event->preventDefault(); 79 $event->stopPropagation(); 80 $this->clearTempFiles(); 81 break; 82 case 'source_sse': 83 $event->preventDefault(); 84 $event->stopPropagation(); 85 $this->handleSourceSSERequest(); 86 break; 87 case 'source_compile': 88 $event->preventDefault(); 89 $event->stopPropagation(); 90 $this->handleSourceCompileRequest(); 91 break; 92 case 'view_compile': 93 $event->preventDefault(); 94 $event->stopPropagation(); 95 $this->handleViewCompileRequest(); 96 break; 97 case 'view_sse': 98 $event->preventDefault(); 99 $event->stopPropagation(); 100 $this->handleViewSSERequest(); 101 break; 102 case 'create_combined_file': 103 $event->preventDefault(); 104 $event->stopPropagation(); 105 $this->handle_create_combined_file(); 106 break; 107 } 108 } 109 110 // source用のコンパイルリクエスト処理 111 private function handleSourceCompileRequest() 112 { 113 global $INPUT; 114 $pageContent = $INPUT->post->str('content'); 115 $mizarData = $this->extractMizarContent($pageContent); 116 117 // エラーチェックを追加 118 if ($mizarData === null) { 119 $this->sendAjaxResponse(false, 'Mizar content not found'); 120 return; 121 } elseif (isset($mizarData['error'])) { 122 $this->sendAjaxResponse(false, $mizarData['error']); 123 return; 124 } 125 126 $filePath = $this->saveMizarContent($mizarData); 127 128 session_start(); 129 $_SESSION['source_filepath'] = $filePath; 130 131 $this->sendAjaxResponse(true, 'Mizar content processed successfully'); 132 } 133 134 // source用のSSEリクエスト処理 135 private function handleSourceSSERequest() 136 { 137 header('Content-Type: text/event-stream'); 138 header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); 139 header('Pragma: no-cache'); 140 header('Expires: 0'); 141 142 session_start(); 143 if (!isset($_SESSION['source_filepath'])) { 144 echo "data: Mizar file path not found in session\n\n"; 145 ob_flush(); 146 flush(); 147 return; 148 } 149 150 $filePath = $_SESSION['source_filepath']; 151 $this->streamSourceOutput($filePath); 152 153 echo "event: end\n"; 154 echo "data: Compilation complete\n\n"; 155 ob_flush(); 156 flush(); 157 } 158 159 // view用のコンパイルリクエスト処理 160 private function handleViewCompileRequest() 161 { 162 global $INPUT; 163 $content = $INPUT->post->str('content'); 164 165 $filePath = $this->createTempFile($content); 166 167 session_start(); 168 $_SESSION['view_filepath'] = $filePath; 169 170 $this->sendAjaxResponse(true, 'Mizar content processed successfully'); 171 } 172 173 // view用のSSEリクエスト処理 174 private function handleViewSSERequest() 175 { 176 header('Content-Type: text/event-stream'); 177 header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); 178 header('Pragma: no-cache'); 179 header('Expires: 0'); 180 181 session_start(); 182 if (!isset($_SESSION['view_filepath'])) { 183 echo "data: Mizar file path not found in session\n\n"; 184 ob_flush(); 185 flush(); 186 return; 187 } 188 189 $filePath = $_SESSION['view_filepath']; 190 $this->streamViewCompileOutput($filePath); 191 192 echo "event: end\n"; 193 echo "data: Compilation complete\n\n"; 194 ob_flush(); 195 flush(); 196 } 197 198 // Mizarコンテンツの抽出 199 private function extractMizarContent($pageContent) 200 { 201 $pattern = '/<mizar\s+([^>]+)>(.*?)<\/mizar>/s'; 202 preg_match_all($pattern, $pageContent, $matches, PREG_SET_ORDER); 203 204 if (empty($matches)) { 205 return null; 206 } 207 208 // 最初のファイル名を取得し、拡張子を除去 209 $fileName = trim($matches[0][1]); 210 $fileNameWithoutExt = preg_replace('/\.miz$/i', '', $fileName); 211 212 // ファイル名のバリデーションを追加 213 if (!$this->isValidFileName($fileNameWithoutExt)) { 214 return ['error' => "Invalid characters in file name: '{$fileNameWithoutExt}'. Only letters, numbers, underscores (_), and apostrophes (') are allowed, up to 8 characters."]; 215 } 216 217 $combinedContent = ''; 218 219 foreach ($matches as $match) { 220 $currentFileName = trim($match[1]); 221 $currentFileNameWithoutExt = preg_replace('/\.miz$/i', '', $currentFileName); 222 223 if ($currentFileNameWithoutExt !== $fileNameWithoutExt) { 224 return ['error' => "File name mismatch in <mizar> tags: '{$fileNameWithoutExt}' and '{$currentFileNameWithoutExt}'"]; 225 } 226 227 // バリデーションを各ファイル名にも適用 228 if (!$this->isValidFileName($currentFileNameWithoutExt)) { 229 return ['error' => "Invalid characters in file name: '{$currentFileNameWithoutExt}'. Only letters, numbers, underscores (_), and apostrophes (') are allowed, up to 8 characters."]; 230 } 231 232 $combinedContent .= trim($match[2]) . "\n"; 233 } 234 235 // ファイル名に拡張子を付加 236 $fullFileName = $fileNameWithoutExt . '.miz'; 237 238 return ['fileName' => $fullFileName, 'content' => $combinedContent]; 239 } 240 241 // ファイル名のバリデーション関数を追加 242 private function isValidFileName($fileName) 243 { 244 // ファイル名の長さをチェック(最大8文字) 245 if (strlen($fileName) > 8) { 246 return false; 247 } 248 249 // 許可される文字のみを含むかチェック 250 if (!preg_match('/^[A-Za-z0-9_\']+$/', $fileName)) { 251 return false; 252 } 253 254 return true; 255 } 256 257 // Mizarコンテンツの保存 258 private function saveMizarContent($mizarData) 259 { 260 $workPath = rtrim($this->getConf('mizar_work_dir'), '/\\'); 261 $filePath = $workPath . "/TEXT/" . $mizarData['fileName']; 262 file_put_contents($filePath, $mizarData['content']); 263 return $filePath; 264 } 265 266 // source用の出力をストリーム 267 private function streamSourceOutput($filePath) 268 { 269 $workPath = rtrim($this->getConf('mizar_work_dir'), '/\\'); 270 $sharePath = rtrim($this->getConf('mizar_share_dir'), '/\\'); 271 272 // ★追加:環境変数 MIZFILESをPHP内で設定 273 putenv("MIZFILES=$sharePath"); 274 275 chdir($workPath); 276 277 $command = "miz2prel " . escapeshellarg($filePath); 278 $process = proc_open($command, array(1 => array("pipe", "w")), $pipes); 279 280 if (is_resource($process)) { 281 while ($line = fgets($pipes[1])) { 282 echo "data: " . $line . "\n\n"; 283 ob_flush(); 284 flush(); 285 } 286 fclose($pipes[1]); 287 288 // エラー処理の追加 289 $errFilename = str_replace('.miz', '.err', $filePath); 290 if ($this->handleCompilationErrors($errFilename, rtrim($this->getConf('mizar_share_dir'), '/\\') . '/mizar.msg')) { 291 // エラーがあった場合は処理を終了 292 proc_close($process); 293 return; 294 } 295 296 proc_close($process); 297 } 298 } 299 300 // view用の一時ファイル作成 301 private function createTempFile($content) 302 { 303 $workPath = rtrim($this->getConf('mizar_work_dir'), '/\\') . '/TEXT/'; 304 $uniqueName = str_replace('.', '_', uniqid('tmp', true)); 305 $tempFilename = $workPath . $uniqueName . ".miz"; 306 file_put_contents($tempFilename, $content); 307 return $tempFilename; 308 } 309 310 // 一時ファイルの削除 (clearTempFiles関数の修正版) 311 private function clearTempFiles() 312 { 313 $workPath = rtrim($this->getConf('mizar_work_dir'), '/\\') . '/TEXT/'; 314 $files = glob($workPath . '*'); // TEXTフォルダ内のすべてのファイルを取得 315 316 $errors = []; 317 foreach ($files as $file) { 318 if (is_file($file)) { 319 // ファイルが使用中かどうか確認 320 if (!$this->is_file_locked($file)) { 321 $retries = 5; // リトライを増やす 322 while ($retries > 0) { 323 if (@unlink($file)) { // ← エラー抑制を追加 324 break; // 削除成功 325 } 326 $errors[] = "Error deleting $file: " . error_get_last()['message']; 327 $retries--; 328 sleep(2); // 2秒待ってリトライ 329 } 330 if ($retries === 0) { 331 $errors[] = "Failed to delete: $file"; // 削除失敗 332 } 333 } else { 334 $errors[] = "File is locked: $file"; // ファイルがロックされている 335 } 336 } 337 } 338 339 if (empty($errors)) { 340 $this->sendAjaxResponse(true, 'Temporary files cleared successfully'); 341 } else { 342 $this->sendAjaxResponse(false, 'Some files could not be deleted', $errors); 343 } 344 } 345 346 347 // ファイルがロックされているかをチェックする関数 348 private function is_file_locked($file) 349 { 350 $fileHandle = @fopen($file, "r+"); 351 352 if ($fileHandle === false) { 353 return true; // ファイルが開けない、つまりロックされているかアクセス権がない 354 } 355 356 $locked = !flock($fileHandle, LOCK_EX | LOCK_NB); // ロックの取得を試みる(非ブロッキングモード) 357 358 fclose($fileHandle); 359 return $locked; // ロックが取得できなければファイルはロックされている 360 } 361 362 // View用コンパイル出力のストリーム 363 private function streamViewCompileOutput($filePath) 364 { 365 $workPath = $this->getConf('mizar_work_dir'); 366 $sharePath = rtrim($this->getConf('mizar_share_dir'), '/\\') . '/'; 367 368 // ★追加:環境変数 MIZFILESをPHP内で設定 369 putenv("MIZFILES=$sharePath"); 370 371 chdir($workPath); 372 373 $errFilename = str_replace('.miz', '.err', $filePath); 374 $command = "makeenv " . escapeshellarg($filePath); 375 $process = proc_open($command, array(1 => array("pipe", "w"), 2 => array("pipe", "w")), $pipes); 376 377 if (is_resource($process)) { 378 while ($line = fgets($pipes[1])) { 379 echo "data: " . mb_convert_encoding($line, 'UTF-8', 'SJIS') . "\n\n"; 380 ob_flush(); 381 flush(); 382 } 383 fclose($pipes[1]); 384 385 while ($line = fgets($pipes[2])) { 386 echo "data: ERROR: " . mb_convert_encoding($line, 'UTF-8', 'SJIS') . "\n\n"; 387 ob_flush(); 388 flush(); 389 } 390 fclose($pipes[2]); 391 proc_close($process); 392 393 // makeenvのエラー処理 394 if ($this->handleCompilationErrors($errFilename, $sharePath . '/mizar.msg')) { 395 return; 396 } 397 398 // verifierの実行 399 $exePath = rtrim($this->getConf('mizar_exe_dir'), '/\\') . '/'; 400 $verifierPath = escapeshellarg($exePath . "verifier"); 401 if (strtoupper(substr(PHP_OS, 0, 3)) === 'WIN') { 402 $verifierPath .= ".exe"; 403 } 404 $verifierCommand = $verifierPath . " -q -l " . escapeshellarg("TEXT/" . basename($filePath)); 405 406 $verifierProcess = proc_open($verifierCommand, array(1 => array("pipe", "w"), 2 => array("pipe", "w")), $verifierPipes); 407 408 if (is_resource($verifierProcess)) { 409 while ($line = fgets($verifierPipes[1])) { 410 echo "data: " . mb_convert_encoding($line, 'UTF-8', 'SJIS') . "\n\n"; 411 ob_flush(); 412 flush(); 413 } 414 fclose($verifierPipes[1]); 415 416 while ($line = fgets($verifierPipes[2])) { 417 echo "data: ERROR: " . mb_convert_encoding($line, 'UTF-8', 'SJIS') . "\n\n"; 418 ob_flush(); 419 flush(); 420 } 421 fclose($verifierPipes[2]); 422 proc_close($verifierProcess); 423 424 // verifierのエラー処理 425 if ($this->handleCompilationErrors($errFilename, $sharePath . '/mizar.msg')) { 426 return; 427 } 428 } else { 429 echo "data: ERROR: Failed to execute verifier command.\n\n"; 430 ob_flush(); 431 flush(); 432 } 433 } else { 434 echo "data: ERROR: Failed to execute makeenv command.\n\n"; 435 ob_flush(); 436 flush(); 437 } 438 } 439 440 private function getMizarErrorMessages($mizarMsgFile) 441 { 442 $errorMessages = []; 443 $lines = file($mizarMsgFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); 444 445 $isReadingErrorMsg = false; 446 $key = 0; 447 448 foreach ($lines as $line) { 449 if (preg_match('/# (\d+)/', $line, $matches)) { 450 $isReadingErrorMsg = true; 451 $key = intval($matches[1]); 452 } elseif ($isReadingErrorMsg) { 453 $errorMessages[$key] = $line; 454 $isReadingErrorMsg = false; 455 } 456 } 457 458 return $errorMessages; 459 } 460 461 private function sendAjaxResponse($success, $message, $data = '') 462 { 463 header('Content-Type: application/json'); 464 header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); 465 header('Pragma: no-cache'); 466 header('Expires: 0'); 467 echo json_encode(['success' => $success, 'message' => $message, 'data' => $data]); 468 exit; 469 } 470 471 private function handleCompilationErrors($errFilename, $mizarMsgFilePath) 472 { 473 if (file_exists($errFilename)) { 474 $errors = []; 475 $errorLines = file($errFilename, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); 476 foreach ($errorLines as $errorLine) { 477 if (preg_match('/(\d+)\s+(\d+)\s+(\d+)/', $errorLine, $matches)) { 478 $errorCode = intval($matches[3]); 479 $errors[] = [ 480 'code' => $errorCode, 481 'line' => intval($matches[1]), 482 'column' => intval($matches[2]), 483 'message' => $this->getMizarErrorMessages($mizarMsgFilePath)[$errorCode] ?? 'Unknown error' 484 ]; 485 } 486 } 487 if (!empty($errors)) { 488 echo "event: compileErrors\n"; 489 echo "data: " . json_encode($errors) . "\n\n"; 490 ob_flush(); 491 flush(); 492 return true; 493 } 494 } 495 return false; 496 } 497 498 private function handle_create_combined_file() 499 { 500 global $INPUT; 501 502 // 投稿されたコンテンツを取得 503 $combinedContent = $INPUT->post->str('content'); 504 $filename = $INPUT->post->str('filename', 'combined_file.miz'); // デフォルトのファイル名を指定 505 506 // ファイルを保存せず、コンテンツを直接返す 507 if (!empty($combinedContent)) { 508 // ファイルの内容をレスポンスで返す(PHP側でファイルを作成しない) 509 $this->sendAjaxResponse(true, 'File created successfully', [ 510 'filename' => $filename, 511 'content' => $combinedContent 512 ]); 513 error_log("File content sent: " . $filename); 514 } else { 515 $this->sendAjaxResponse(false, 'Content is empty, no file created'); 516 } 517 } 518} 519