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