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