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