1<?php 2/** 3 * Copyright (c) 2021. ComboStrap, Inc. and its affiliates. All Rights Reserved. 4 * 5 * This source code is licensed under the GPL license found in the 6 * COPYING file in the root directory of this source tree. 7 * 8 * @license GPL 3 (https://www.gnu.org/licenses/gpl-3.0.en.html) 9 * @author ComboStrap <support@combostrap.com> 10 * 11 */ 12 13use ComboStrap\DatabasePageRow; 14use ComboStrap\Event; 15use ComboStrap\ExceptionBadSyntax; 16use ComboStrap\ExceptionCompile; 17use ComboStrap\ExceptionNotExists; 18use ComboStrap\ExceptionNotFound; 19use ComboStrap\ExceptionRuntime; 20use ComboStrap\ExecutionContext; 21use ComboStrap\FsWikiUtility; 22use ComboStrap\LogUtility; 23use ComboStrap\MarkupPath; 24use ComboStrap\Meta\Field\BacklinkCount; 25use ComboStrap\Meta\Field\PageH1; 26use ComboStrap\MetadataFrontmatterStore; 27use ComboStrap\Sqlite; 28use dokuwiki\Extension\PluginInterface; 29use splitbrain\phpcli\Options; 30 31/** 32 * All dependency are loaded 33 */ 34require_once(__DIR__ . '/vendor/autoload.php'); 35 36/** 37 * The memory of the server 128 is not enough 38 */ 39ini_set('memory_limit', '256M'); 40 41 42/** 43 * Class cli_plugin_combo 44 * 45 * This is a cli: 46 * https://www.dokuwiki.org/devel:cli_plugins#example 47 * 48 * Usage: 49 * 50 * ``` 51 * docker exec -ti $(CONTAINER) /bin/bash 52 * ``` 53 * ``` 54 * set animal=animal-directory-name 55 * php ./bin/plugin.php combo --help 56 * ``` 57 * or via the IDE 58 * 59 * 60 * Example: 61 * https://www.dokuwiki.org/tips:grapher 62 * 63 */ 64class cli_plugin_combo extends DokuWiki_CLI_Plugin 65{ 66 67 const METADATA_TO_DATABASE = "metadata-to-database"; 68 const ANALYTICS = "analytics"; 69 const METADATA_TO_FRONTMATTER = "metadata-to-frontmatter"; 70 const SYNC = "sync"; 71 const PLUGINS_TO_UPDATE = "plugins-to-update"; 72 const FORCE_OPTION = 'force'; 73 const PORT_OPTION = 'port'; 74 const HOST_OPTION = 'host'; 75 const CANONICAL = "combo-cli"; 76 77 78 /** 79 * register options and arguments 80 * @param Options $options 81 * 82 * Note the animal is set in {@link DokuWikiFarmCore::detectAnimal()} 83 * via the environment variable `animal` that is passed in the $_SERVER variable 84 */ 85 protected function setup(Options $options) 86 { 87 $help = <<<EOF 88ComboStrap Administrative Commands 89 90 91Example: 92 * Replicate all pages into the database 93```bash 94php ./bin/plugin.php combo metadata-to-database --host serverHostName --port 80 : 95# or 96php ./bin/plugin.php combo metadata-to-database --host serverHostName --port 80 / 97``` 98 * Replicate only the page `:namespace:my-page` 99```bash 100php ./bin/plugin.php combo metadata-to-database --host serverHostName --port 80 :namespace:my-page 101# or 102php ./bin/plugin.php combo metadata-to-database --host serverHostName --port 80 /namespace/my-page 103``` 104 105Animal: If you want to use it for an animal farm, you need to set first the animal directory name in a environment variable 106```bash 107animal=animal-directory-name php ./bin/plugin.php combo 108``` 109 110EOF; 111 112 $options->setHelp($help); 113 $options->registerOption('version', 'print version', 'v'); 114 $options->registerCommand(self::METADATA_TO_DATABASE, "Replicate the file system metadata into the database"); 115 $options->registerCommand(self::ANALYTICS, "Start the analytics and export optionally the data"); 116 $options->registerCommand(self::PLUGINS_TO_UPDATE, "List the plugins to update"); 117 $options->registerCommand(self::METADATA_TO_FRONTMATTER, "Replicate the file system metadata into the page frontmatter"); 118 $options->registerCommand(self::SYNC, "Delete the non-existing pages in the database"); 119 $options->registerOption( 120 'output', 121 "Optional, where to store the analytical data as csv eg. a filename.", 122 'o', 123 true 124 ); 125 $options->registerOption( 126 self::HOST_OPTION, 127 "The http host name of your server. This value is used by dokuwiki in the rendering cache key", 128 null, 129 true, 130 self::METADATA_TO_DATABASE 131 ); 132 $options->registerOption( 133 self::PORT_OPTION, 134 "The http host port of your server. This value is used by dokuwiki in the rendering cache key", 135 null, 136 true, 137 self::METADATA_TO_DATABASE 138 ); 139 $options->registerOption( 140 self::FORCE_OPTION, 141 "Replicate with force", 142 'f', 143 false, 144 self::METADATA_TO_DATABASE 145 ); 146 $startPathArgName = 'startPath'; 147 $startPathHelpDescription = "The start path (a page or a directory). For all pages, type the root directory '/' or ':'"; 148 $options->registerArgument( 149 $startPathArgName, 150 $startPathHelpDescription, 151 true, 152 self::METADATA_TO_DATABASE 153 ); 154 $options->registerArgument( 155 $startPathArgName, 156 $startPathHelpDescription, 157 true, 158 self::METADATA_TO_FRONTMATTER 159 ); 160 $options->registerArgument( 161 $startPathArgName, 162 $startPathHelpDescription, 163 true, 164 self::SYNC 165 ); 166 $options->registerOption( 167 'dry', 168 "Optional, dry-run", 169 'd', false); 170 171 172 } 173 174 /** 175 * The main entry 176 * @param Options $options 177 * @throws ExceptionCompile 178 */ 179 protected function main(Options $options) 180 { 181 182 183 if (isset($_REQUEST['animal'])) { 184 // on linux 185 echo "Animal detected: " . $_REQUEST['animal'] . "\n"; 186 } else { 187 // on windows 188 echo "No Animal detected\n"; 189 echo "Conf: " . DOKU_CONF . "\n"; 190 } 191 192 $args = $options->getArgs(); 193 194 195 $depth = $options->getOpt('depth', 0); 196 $cmd = $options->getCmd(); 197 198 try { 199 switch ($cmd) { 200 case self::METADATA_TO_DATABASE: 201 $startPath = $this->getStartPath($args); 202 $force = $options->getOpt(self::FORCE_OPTION, false); 203 $hostOptionValue = $options->getOpt(self::HOST_OPTION, null); 204 if ($hostOptionValue === null) { 205 fwrite(STDERR, "The host name is mandatory"); 206 return; 207 } 208 $_SERVER['HTTP_HOST'] = $hostOptionValue; 209 $portOptionName = $options->getOpt(self::PORT_OPTION, null); 210 if ($portOptionName === null) { 211 fwrite(STDERR, "The host port is mandatory"); 212 return; 213 } 214 $_SERVER['SERVER_PORT'] = $portOptionName; 215 $this->index($startPath, $force, $depth); 216 break; 217 case self::METADATA_TO_FRONTMATTER: 218 $startPath = $this->getStartPath($args); 219 $this->frontmatter($startPath, $depth); 220 break; 221 case self::ANALYTICS: 222 $startPath = $this->getStartPath($args); 223 $output = $options->getOpt('output', ''); 224 //if ($output == '-') $output = 'php://stdout'; 225 $this->analytics($startPath, $output, $depth); 226 break; 227 case self::SYNC: 228 $this->deleteNonExistingPageFromDatabase(); 229 break; 230 case self::PLUGINS_TO_UPDATE: 231 /** 232 * Endpoint: 233 * self::EXTENSION_REPOSITORY_API.'?fmt=php&ext[]='.urlencode($name) 234 * `http://www.dokuwiki.org/lib/plugins/pluginrepo/api.php?fmt=php&ext[]=`.urlencode($name) 235 */ 236 $pluginList = plugin_list('', true); 237 $extension = $this->loadHelper('extension_extension'); 238 if ($extension instanceof PluginInterface) { 239 /** 240 * Release 2025-05-14 "Librarian" 241 * https://www.dokuwiki.org/changes#release_2025-05-14_librarian 242 * https://www.patreon.com/posts/new-extension-116501986 243 * ./bin/plugin.php extension list 244 * @link lib/plugins/extension/cli.php 245 */ 246 echo "The new extension plugin system is not yet supported"; 247 echo "Check the cli instead: ./bin/plugin.php extension list"; 248 exit(1); 249 } 250 if ($extension == null) { 251 echo "The plugin (extension_extension) could not be loaded"; 252 exit(1); 253 } 254 foreach ($pluginList as $name) { 255 256 /* @var helper_plugin_extension_extension $extension 257 * @noinspection PhpUndefinedClassInspection 258 */ 259 $extension->setExtension($name); 260 /** @noinspection PhpUndefinedMethodInspection */ 261 if ($extension->updateAvailable()) { 262 echo "The extension $name should be updated"; 263 } 264 265 } 266 267 break; 268 default: 269 if ($cmd !== "") { 270 fwrite(STDERR, "Combo: Command unknown (" . $cmd . ")"); 271 } else { 272 echo $options->help(); 273 } 274 exit(1); 275 } 276 } catch (\Exception $exception) { 277 fwrite(STDERR, "An internal error has occured. " . $exception->getMessage() . "\n" . $exception->getTraceAsString()); 278 exit(1); 279 } 280 281 282 } 283 284 /** 285 * @param array $namespaces 286 * @param bool $rebuild 287 * @param int $depth recursion depth. 0 for unlimited 288 * @throws ExceptionCompile 289 */ 290 private function index($namespaces = array(), $rebuild = false, $depth = 0) 291 { 292 293 /** 294 * Run as admin to overcome the fact that 295 * anonymous user cannot see all links and backlinks 296 */ 297 global $USERINFO; 298 $USERINFO['grps'] = array('admin'); 299 global $INPUT; 300 $INPUT->server->set('REMOTE_USER', "cli"); 301 302 $pages = FsWikiUtility::getPages($namespaces, $depth); 303 304 $pageCounter = 0; 305 $totalNumberOfPages = sizeof($pages); 306 while ($pageArray = array_shift($pages)) { 307 $id = $pageArray['id']; 308 global $ID; 309 $ID = $id; 310 /** 311 * Indexing the page start the database replication 312 * See {@link action_plugin_combo_indexer} 313 */ 314 $pageCounter++; 315 $executionContext = ExecutionContext::getActualOrCreateFromEnv(); 316 try { 317 /** 318 * If the page does not need to be indexed, there is no run 319 * and false is returned 320 */ 321 $indexedOrNot = idx_addPage($id, true, true); 322 if ($indexedOrNot) { 323 LogUtility::msg("The page {$id} ($pageCounter / $totalNumberOfPages) was indexed and replicated", LogUtility::LVL_MSG_INFO); 324 } else { 325 LogUtility::msg("The page {$id} ($pageCounter / $totalNumberOfPages) has an error", LogUtility::LVL_MSG_ERROR); 326 } 327 } catch (ExceptionRuntime $e) { 328 LogUtility::msg("The page {$id} ($pageCounter / $totalNumberOfPages) has an error: " . $e->getMessage(), LogUtility::LVL_MSG_ERROR); 329 } finally { 330 $executionContext->close(); 331 } 332 } 333 /** 334 * Process all backlinks 335 */ 336 echo "Processing Replication Request\n"; 337 Event::dispatchEvent(PHP_INT_MAX); 338 339 } 340 341 private function analytics($namespaces = array(), $output = null, $depth = 0) 342 { 343 344 $fileHandle = null; 345 if (!empty($output)) { 346 $fileHandle = @fopen($output, 'w'); 347 if (!$fileHandle) $this->fatal("Failed to open $output"); 348 } 349 350 /** 351 * Run as admin to overcome the fact that 352 * anonymous user cannot see all links and backlinks 353 */ 354 global $USERINFO; 355 $USERINFO['grps'] = array('admin'); 356 global $INPUT; 357 $INPUT->server->set('REMOTE_USER', "cli"); 358 359 $pages = FsWikiUtility::getPages($namespaces, $depth); 360 361 362 if (!empty($fileHandle)) { 363 $header = array( 364 'id', 365 'backlinks', 366 'broken_links', 367 'changes', 368 'chars', 369 'external_links', 370 'external_medias', 371 'h1', 372 'h2', 373 'h3', 374 'h4', 375 'h5', 376 'internal_links', 377 'internal_medias', 378 'words', 379 'score' 380 ); 381 fwrite($fileHandle, implode(",", $header) . PHP_EOL); 382 } 383 $pageCounter = 0; 384 $totalNumberOfPages = sizeof($pages); 385 while ($pageArray = array_shift($pages)) { 386 $id = $pageArray['id']; 387 $page = MarkupPath::createMarkupFromId($id); 388 389 390 $pageCounter++; 391 /** 392 * Analytics 393 */ 394 echo "Analytics Processing for the page {$id} ($pageCounter / $totalNumberOfPages)\n"; 395 $executionContext = ExecutionContext::getActualOrCreateFromEnv(); 396 try { 397 $analyticsPath = $page->fetchAnalyticsPath(); 398 } catch (ExceptionNotExists $e) { 399 LogUtility::error("The analytics document for the page ($page) was not found"); 400 continue; 401 } catch (ExceptionCompile $e) { 402 LogUtility::error("Error when get the analytics.", self::CANONICAL, $e); 403 continue; 404 } finally { 405 $executionContext->close(); 406 } 407 408 try { 409 $data = \ComboStrap\Json::createFromPath($analyticsPath)->toArray(); 410 } catch (ExceptionBadSyntax $e) { 411 LogUtility::error("The analytics json of the page ($page) is not conform"); 412 continue; 413 } catch (ExceptionNotFound|ExceptionNotExists $e) { 414 LogUtility::error("The analytics document ({$analyticsPath}) for the page ($page) was not found"); 415 continue; 416 } 417 418 if (!empty($fileHandle)) { 419 $statistics = $data[renderer_plugin_combo_analytics::STATISTICS]; 420 $row = array( 421 'id' => $id, 422 'backlinks' => $statistics[BacklinkCount::getPersistentName()], 423 'broken_links' => $statistics[renderer_plugin_combo_analytics::INTERNAL_LINK_BROKEN_COUNT], 424 'changes' => $statistics[renderer_plugin_combo_analytics::EDITS_COUNT], 425 'chars' => $statistics[renderer_plugin_combo_analytics::CHAR_COUNT], 426 'external_links' => $statistics[renderer_plugin_combo_analytics::EXTERNAL_LINK_COUNT], 427 'external_medias' => $statistics[renderer_plugin_combo_analytics::EXTERNAL_MEDIA_COUNT], 428 PageH1::PROPERTY_NAME => $statistics[renderer_plugin_combo_analytics::HEADING_COUNT][PageH1::PROPERTY_NAME], 429 'h2' => $statistics[renderer_plugin_combo_analytics::HEADING_COUNT]['h2'], 430 'h3' => $statistics[renderer_plugin_combo_analytics::HEADING_COUNT]['h3'], 431 'h4' => $statistics[renderer_plugin_combo_analytics::HEADING_COUNT]['h4'], 432 'h5' => $statistics[renderer_plugin_combo_analytics::HEADING_COUNT]['h5'], 433 'internal_links' => $statistics[renderer_plugin_combo_analytics::INTERNAL_LINK_COUNT], 434 'internal_medias' => $statistics[renderer_plugin_combo_analytics::INTERNAL_MEDIA_COUNT], 435 'words' => $statistics[renderer_plugin_combo_analytics::WORD_COUNT], 436 'low' => $data[renderer_plugin_combo_analytics::QUALITY]['low'] 437 ); 438 fwrite($fileHandle, implode(",", $row) . PHP_EOL); 439 } 440 441 } 442 if (!empty($fileHandle)) { 443 fclose($fileHandle); 444 } 445 446 } 447 448 449 /** 450 * @throws \ComboStrap\ExceptionSqliteNotAvailable 451 */ 452 private function deleteNonExistingPageFromDatabase() 453 { 454 LogUtility::msg("Starting: Deleting non-existing page from database"); 455 $sqlite = Sqlite::createOrGetSqlite(); 456 $request = $sqlite 457 ->createRequest() 458 ->setQuery("select id as \"id\" from pages"); 459 $rows = []; 460 try { 461 $rows = $request 462 ->execute() 463 ->getRows(); 464 } catch (ExceptionCompile $e) { 465 LogUtility::msg("Error while getting the id pages. {$e->getMessage()}"); 466 return; 467 } finally { 468 $request->close(); 469 } 470 $counter = 0; 471 472 foreach ($rows as $row) { 473 /** 474 * Context 475 * PHP Fatal error: Allowed memory size of 268435456 bytes exhausted (tried to allocate 20480 bytes) 476 * in /opt/www/datacadamia.com/inc/ErrorHandler.php on line 102 477 */ 478 $executionContext = ExecutionContext::getActualOrCreateFromEnv(); 479 try { 480 $counter++; 481 $id = $row['id']; 482 if (!page_exists($id)) { 483 echo 'Page does not exist on the file system. Delete from the database (' . $id . ")\n"; 484 try { 485 $dbRow = DatabasePageRow::getFromDokuWikiId($id); 486 $dbRow->delete(); 487 } catch (ExceptionNotFound $e) { 488 // ok 489 } 490 } 491 } finally { 492 $executionContext->close(); 493 } 494 495 } 496 LogUtility::msg("Sync finished ($counter pages checked)"); 497 498 } 499 500 private function frontmatter($namespaces, $depth) 501 { 502 $pages = FsWikiUtility::getPages($namespaces, $depth); 503 $pageCounter = 0; 504 $totalNumberOfPages = sizeof($pages); 505 $pagesWithChanges = []; 506 $pagesWithError = []; 507 $pagesWithOthers = []; 508 $notChangedCounter = 0; 509 while ($pageArray = array_shift($pages)) { 510 $id = $pageArray['id']; 511 global $ID; 512 $ID = $id; 513 $page = MarkupPath::createMarkupFromId($id); 514 $pageCounter++; 515 LogUtility::msg("Processing page {$id} ($pageCounter / $totalNumberOfPages) ", LogUtility::LVL_MSG_INFO); 516 $executionContext = ExecutionContext::getActualOrCreateFromEnv(); 517 try { 518 $message = MetadataFrontmatterStore::createFromPage($page) 519 ->sync(); 520 switch ($message->getStatus()) { 521 case syntax_plugin_combo_frontmatter::UPDATE_EXIT_CODE_NOT_CHANGED: 522 $notChangedCounter++; 523 break; 524 case syntax_plugin_combo_frontmatter::UPDATE_EXIT_CODE_DONE: 525 $pagesWithChanges[] = $id; 526 break; 527 case syntax_plugin_combo_frontmatter::UPDATE_EXIT_CODE_ERROR: 528 $pagesWithError[$id] = $message->getPlainTextContent(); 529 break; 530 default: 531 $pagesWithOthers[$id] = $message->getPlainTextContent(); 532 break; 533 534 } 535 } catch (ExceptionCompile $e) { 536 $pagesWithError[$id] = $e->getMessage(); 537 } finally { 538 $executionContext->close(); 539 } 540 541 } 542 543 echo "\n"; 544 echo "Result:\n"; 545 echo "$notChangedCounter pages without any frontmatter modifications\n"; 546 547 if (sizeof($pagesWithError) > 0) { 548 echo "\n"; 549 echo "The following pages had errors\n"; 550 $pageCounter = 0; 551 $totalNumberOfPages = sizeof($pagesWithError); 552 foreach ($pagesWithError as $id => $message) { 553 $pageCounter++; 554 LogUtility::msg("Page {$id} ($pageCounter / $totalNumberOfPages): " . $message, LogUtility::LVL_MSG_ERROR); 555 } 556 } else { 557 echo "No error\n"; 558 } 559 560 if (sizeof($pagesWithChanges) > 0) { 561 echo "\n"; 562 echo "The following pages had changed:\n"; 563 $pageCounter = 0; 564 $totalNumberOfPages = sizeof($pagesWithChanges); 565 foreach ($pagesWithChanges as $id) { 566 $pageCounter++; 567 LogUtility::msg("Page {$id} ($pageCounter / $totalNumberOfPages) ", LogUtility::LVL_MSG_ERROR); 568 } 569 } else { 570 echo "No changes\n"; 571 } 572 573 if (sizeof($pagesWithOthers) > 0) { 574 echo "\n"; 575 echo "The following pages had an other status"; 576 $pageCounter = 0; 577 $totalNumberOfPages = sizeof($pagesWithOthers); 578 foreach ($pagesWithOthers as $id => $message) { 579 $pageCounter++; 580 LogUtility::msg("Page {$id} ($pageCounter / $totalNumberOfPages) " . $message, LogUtility::LVL_MSG_ERROR); 581 } 582 } 583 } 584 585 private function getStartPath($args) 586 { 587 $sizeof = sizeof($args); 588 switch ($sizeof) { 589 case 0: 590 fwrite(STDERR, "The start path is mandatory and was not given"); 591 exit(1); 592 case 1: 593 $startPath = $args[0]; 594 if (!in_array($startPath, [":", "/"])) { 595 // cleanId would return blank for a root 596 $startPath = cleanID($startPath); 597 } 598 break; 599 default: 600 fwrite(STDERR, "Too much arguments given $sizeof"); 601 exit(1); 602 } 603 return $startPath; 604 } 605} 606