1<?php 2 3 4use ComboStrap\Meta\Field\BacklinkCount; 5use ComboStrap\Canonical; 6use ComboStrap\ExceptionCompile; 7use ComboStrap\ExceptionNotExists; 8use ComboStrap\ExceptionNotFound; 9use ComboStrap\ExceptionRuntimeInternal; 10use ComboStrap\FetcherMarkup; 11use ComboStrap\LogUtility; 12use ComboStrap\MarkupPath; 13use ComboStrap\Meta\Field\PageH1; 14use ComboStrap\Meta\Store\MetadataDbStore; 15use ComboStrap\Meta\Store\MetadataDokuWikiStore; 16use ComboStrap\Mime; 17use ComboStrap\PageTitle; 18use ComboStrap\StringUtility; 19use ComboStrap\WikiPath; 20use dokuwiki\ChangeLog\PageChangeLog; 21 22 23require_once(__DIR__ . '/../ComboStrap/PluginUtility.php'); 24 25 26/** 27 * A analysis Renderer that exports stats/quality/metadata in a json format 28 * You can export the data with 29 * doku.php?id=somepage&do=export_combo_analytics 30 * 31 * TODO: Move the metadata part to the metadata render and the quality part to the indexer. 32 */ 33class renderer_plugin_combo_analytics extends Doku_Renderer 34{ 35 36 const PLAINTEXT = 'formatted'; 37 const RESULT = "result"; 38 const DESCRIPTION = "description"; 39 const PASSED = "Passed"; 40 const FAILED = "Failed"; 41 const FIXME = 'fixme'; 42 43 /** 44 * Rules key 45 */ 46 const RULE_WORDS_MINIMAL = 'words_min'; 47 const RULE_OUTLINE_STRUCTURE = "outline_structure"; 48 const RULE_INTERNAL_BACKLINKS_MIN = 'internal_backlinks_min'; 49 const RULE_WORDS_MAXIMAL = "words_max"; 50 const RULE_AVERAGE_WORDS_BY_SECTION_MIN = 'words_by_section_avg_min'; 51 const RULE_AVERAGE_WORDS_BY_SECTION_MAX = 'words_by_section_avg_max'; 52 const RULE_INTERNAL_LINKS_MIN = 'internal_links_min'; 53 const RULE_INTERNAL_BROKEN_LINKS_MAX = 'internal_links_broken_max'; 54 const RULE_DESCRIPTION_PRESENT = 'description_present'; 55 const RULE_FIXME = "fixme_min"; 56 const RULE_TITLE_PRESENT = "title_present"; 57 const RULE_CANONICAL_PRESENT = "canonical_present"; 58 const QUALITY_RULES = [ 59 self::RULE_CANONICAL_PRESENT, 60 self::RULE_DESCRIPTION_PRESENT, 61 self::RULE_FIXME, 62 self::RULE_INTERNAL_BACKLINKS_MIN, 63 self::RULE_INTERNAL_BROKEN_LINKS_MAX, 64 self::RULE_INTERNAL_LINKS_MIN, 65 self::RULE_OUTLINE_STRUCTURE, 66 self::RULE_TITLE_PRESENT, 67 self::RULE_WORDS_MINIMAL, 68 self::RULE_WORDS_MAXIMAL, 69 self::RULE_AVERAGE_WORDS_BY_SECTION_MIN, 70 self::RULE_AVERAGE_WORDS_BY_SECTION_MAX 71 ]; 72 73 /** 74 * The default man 75 */ 76 const CONF_MANDATORY_QUALITY_RULES_DEFAULT_VALUE = [ 77 self::RULE_WORDS_MINIMAL, 78 self::RULE_INTERNAL_BACKLINKS_MIN, 79 self::RULE_INTERNAL_LINKS_MIN 80 ]; 81 const CONF_MANDATORY_QUALITY_RULES = "mandatoryQualityRules"; 82 83 /** 84 * Quality Score factors 85 * They are used to calculate the score 86 */ 87 const CONF_QUALITY_SCORE_INTERNAL_BACKLINK_FACTOR = 'qualityScoreInternalBacklinksFactor'; 88 const CONF_QUALITY_SCORE_INTERNAL_LINK_FACTOR = 'qualityScoreInternalLinksFactor'; 89 const CONF_QUALITY_SCORE_TITLE_PRESENT = 'qualityScoreTitlePresent'; 90 const CONF_QUALITY_SCORE_CORRECT_HEADER_STRUCTURE = 'qualityScoreCorrectOutline'; 91 const CONF_QUALITY_SCORE_CORRECT_CONTENT = 'qualityScoreCorrectContentLength'; 92 const CONF_QUALITY_SCORE_NO_FIXME = 'qualityScoreNoFixMe'; 93 const CONF_QUALITY_SCORE_CORRECT_WORD_SECTION_AVERAGE = 'qualityScoreCorrectWordSectionAvg'; 94 const CONF_QUALITY_SCORE_INTERNAL_LINK_BROKEN_FACTOR = 'qualityScoreNoBrokenLinks'; 95 const CONF_QUALITY_SCORE_CHANGES_FACTOR = 'qualityScoreChangesFactor'; 96 const CONF_QUALITY_SCORE_DESCRIPTION_PRESENT = 'qualityScoreDescriptionPresent'; 97 const CONF_QUALITY_SCORE_CANONICAL_PRESENT = 'qualityScoreCanonicalPresent'; 98 const SCORING = "scoring"; 99 const SCORE = "score"; 100 const HEADER_STRUCT = 'header_struct'; 101 const RENDERER_NAME_MODE = "combo_" . renderer_plugin_combo_analytics::RENDERER_FORMAT; 102 103 104 /** 105 * The format returned by the renderer 106 */ 107 const RENDERER_FORMAT = "analytics"; 108 public const QUALITY = 'quality'; 109 public const DETAILS = 'details'; 110 /** 111 * An array of info for errors mostly 112 */ 113 public const INFO = "info"; 114 public const INTERNAL_LINK_COUNT = 'internal_link_count'; 115 public const CHAR_COUNT = 'char_count'; 116 public const FAILED_MANDATORY_RULES = 'failed_mandatory_rules'; 117 public const EDITS_COUNT = 'edits_count'; 118 public const LOCAL_LINK_COUNT = "local_link_count"; 119 public const WINDOWS_SHARE_COUNT = "windows_share_count"; 120 public const SYNTAX_COUNT = "syntax_count"; 121 /** 122 * Constant in Key or value 123 */ 124 public const HEADER_POSITION = 'header_id'; 125 public const INTERNAL_BROKEN_MEDIA_COUNT = 'internal_broken_media_count'; 126 public const TEMPLATE_LINK_COUNT = 'template_link_count'; 127 public const STATISTICS = "statistics"; 128 public const INTERWIKI_LINK_COUNT = "interwiki_link_count"; 129 public const HEADING_COUNT = 'heading_count'; 130 public const MEDIA_COUNT = 'media_count'; 131 public const EXTERNAL_MEDIA_COUNT = 'external_media_count'; 132 public const INTERNAL_LINK_DISTANCE = 'internal_link_distance'; 133 public const INTERNAL_LINK_BROKEN_COUNT = 'internal_broken_link_count'; 134 public const EMAIL_COUNT = "email_count"; 135 public const EXTERNAL_LINK_COUNT = 'external_link_count'; 136 public const LOW = "low"; 137 public const WORD_COUNT = 'word_count'; 138 public const RULES = "rules"; 139 public const METADATA = 'metadata'; 140 public const INTERNAL_MEDIA_COUNT = 'internal_media_count'; 141 142 143 /** 144 * The processing data 145 * that should be {@link renderer_plugin_combo_analysis::reset()} 146 */ 147 public $stats = array(); // the stats 148 protected $metadata = array(); // the metadata in frontmatter 149 protected $headerId = 0; // the id of the header on the page (first, second, ...) 150 151 /** 152 * Don't known this variable ? 153 */ 154 protected $quotelevel = 0; 155 protected $formattingBracket = 0; 156 protected $tableopen = false; 157 private $plainTextId = 0; 158 /** 159 * @var MarkupPath 160 */ 161 private MarkupPath $page; 162 163 /** 164 * @throws ExceptionNotExists - if the file does not exists 165 */ 166 public static function createAnalyticsFetcherForPageFragment(MarkupPath $markupPath): FetcherMarkup 167 { 168 $path = $markupPath->getPathObject(); 169 if (!($path instanceof WikiPath)) { 170 throw new ExceptionRuntimeInternal("The path ($path) is not a wiki path"); 171 } 172 return FetcherMarkup::confRoot() 173 ->setRequestedExecutingPath($path) 174 ->setRequestedContextPath($path) 175 ->setRequestedMime(Mime::getJson()) 176 ->setRequestedRenderer(self::RENDERER_NAME_MODE) 177 ->build(); 178 179 } 180 181 public static function getMime(): Mime 182 { 183 return Mime::create(self::RENDERER_NAME_MODE . "/json"); 184 } 185 186 /** 187 * Get and unset a value from an array 188 * @param array $array 189 * @param $key 190 * @param $default 191 * @return mixed 192 */ 193 private static function getAndUnset(array &$array, $key, $default) 194 { 195 if (isset($array[$key])) { 196 $value = $array[$key]; 197 unset($array[$key]); 198 return $value; 199 } 200 return $default; 201 202 } 203 204 public function document_start() 205 { 206 $this->reset(); 207 try { 208 $this->page = MarkupPath::createPageFromExecutingId(); 209 } catch (ExceptionCompile $e) { 210 LogUtility::msg("The global ID is unknown, we were unable to instantiate the requested page in analytics"); 211 } 212 213 } 214 215 216 /** 217 * Here the score is calculated 218 */ 219 public function document_end() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps 220 { 221 /** 222 * The exported object 223 */ 224 $statExport = $this->stats; 225 226 /** 227 * The metadata 228 */ 229 global $ID; 230 $dokuWikiMetadata = p_get_metadata($ID); 231 232 /** 233 * Edit author stats 234 */ 235 $changelog = new PageChangeLog($ID); 236 $revs = $changelog->getRevisions(0, 10000); 237 $statExport[self::EDITS_COUNT] = count($revs); 238 foreach ($revs as $rev) { 239 240 241 /** 242 * Init the authors array 243 */ 244 if (!array_key_exists('authors', $statExport)) { 245 $statExport['authors'] = []; 246 } 247 /** 248 * Analytics by users 249 */ 250 $info = $changelog->getRevisionInfo($rev); 251 if (is_array($info)) { 252 $user = "*"; 253 if (array_key_exists('user', $info)) { 254 $user = $info['user']; 255 } 256 if (!array_key_exists('authors', $statExport['authors'])) { 257 $statExport['authors'][$user] = 0; 258 } 259 $statExport['authors'][$user] += 1; 260 } 261 } 262 263 /** 264 * Word and chars count 265 * The word count does not take into account 266 * words with non-words characters such as < = 267 * Therefore the node and attribute are not taken in the count 268 */ 269 $text = rawWiki($ID); 270 $statExport[self::CHAR_COUNT] = strlen($text); 271 $statExport[self::WORD_COUNT] = StringUtility::getWordCount($text); 272 273 274 /** 275 * Internal link distance summary calculation 276 */ 277 if (array_key_exists(self::INTERNAL_LINK_DISTANCE, $statExport)) { 278 $linkLengths = $statExport[self::INTERNAL_LINK_DISTANCE]; 279 unset($statExport[self::INTERNAL_LINK_DISTANCE]); 280 $countBacklinks = count($linkLengths); 281 $statExport[self::INTERNAL_LINK_DISTANCE]['avg'] = null; 282 $statExport[self::INTERNAL_LINK_DISTANCE]['max'] = null; 283 $statExport[self::INTERNAL_LINK_DISTANCE]['min'] = null; 284 if ($countBacklinks > 0) { 285 $statExport[self::INTERNAL_LINK_DISTANCE]['avg'] = array_sum($linkLengths) / $countBacklinks; 286 $statExport[self::INTERNAL_LINK_DISTANCE]['max'] = max($linkLengths); 287 $statExport[self::INTERNAL_LINK_DISTANCE]['min'] = min($linkLengths); 288 } 289 } 290 291 /** 292 * Quality Report / Rules 293 */ 294 // The array that hold the results of the quality rules 295 $ruleResults = array(); 296 // The array that hold the quality score details 297 $qualityScores = array(); 298 299 300 /** 301 * No fixme 302 */ 303 if (array_key_exists(self::FIXME, $this->stats)) { 304 $fixmeCount = $this->stats[self::FIXME]; 305 $statExport[self::FIXME] = $fixmeCount == null ? 0 : $fixmeCount; 306 if ($fixmeCount != 0) { 307 $ruleResults[self::RULE_FIXME] = self::FAILED; 308 $qualityScores['no_' . self::FIXME] = 0; 309 } else { 310 $ruleResults[self::RULE_FIXME] = self::PASSED; 311 $qualityScores['no_' . self::FIXME] = $this->getConf(self::CONF_QUALITY_SCORE_NO_FIXME, 1); 312 } 313 } 314 315 /** 316 * A title should be present 317 */ 318 $titleScore = $this->getConf(self::CONF_QUALITY_SCORE_TITLE_PRESENT, 10); 319 if (empty($this->metadata[PageTitle::TITLE])) { 320 $ruleResults[self::RULE_TITLE_PRESENT] = self::FAILED; 321 $ruleInfo[self::RULE_TITLE_PRESENT] = "Add a title for {$titleScore} points"; 322 $this->metadata[PageTitle::TITLE] = $dokuWikiMetadata[PageTitle::TITLE] ?? null; 323 $qualityScores[self::RULE_TITLE_PRESENT] = 0; 324 } else { 325 $qualityScores[self::RULE_TITLE_PRESENT] = $titleScore; 326 $ruleResults[self::RULE_TITLE_PRESENT] = self::PASSED; 327 } 328 329 /** 330 * A description should be present 331 */ 332 $descScore = $this->getConf(self::CONF_QUALITY_SCORE_DESCRIPTION_PRESENT, 8); 333 if (empty($this->metadata[self::DESCRIPTION])) { 334 $ruleResults[self::RULE_DESCRIPTION_PRESENT] = self::FAILED; 335 $ruleInfo[self::RULE_DESCRIPTION_PRESENT] = "Add a description for {$descScore} points"; 336 $this->metadata[self::DESCRIPTION] = $dokuWikiMetadata[self::DESCRIPTION]["abstract"]; 337 $qualityScores[self::RULE_DESCRIPTION_PRESENT] = 0; 338 } else { 339 $qualityScores[self::RULE_DESCRIPTION_PRESENT] = $descScore; 340 $ruleResults[self::RULE_DESCRIPTION_PRESENT] = self::PASSED; 341 } 342 343 /** 344 * A canonical should be present 345 */ 346 $canonicalScore = $this->getConf(self::CONF_QUALITY_SCORE_CANONICAL_PRESENT, 5); 347 if (empty($this->metadata[Canonical::PROPERTY_NAME])) { 348 global $conf; 349 $root = $conf['start']; 350 if ($ID !== $root) { 351 $qualityScores[self::RULE_CANONICAL_PRESENT] = 0; 352 $ruleResults[self::RULE_CANONICAL_PRESENT] = self::FAILED; 353 // no link to the documentation because we don't want any html in the json 354 $ruleInfo[self::RULE_CANONICAL_PRESENT] = "Add a canonical for {$canonicalScore} points"; 355 } 356 } else { 357 $qualityScores[self::RULE_CANONICAL_PRESENT] = $canonicalScore; 358 $ruleResults[self::RULE_CANONICAL_PRESENT] = self::PASSED; 359 } 360 361 /** 362 * Outline / Header structure 363 */ 364 $treeError = 0; 365 $headersCount = 0; 366 if (array_key_exists(self::HEADER_POSITION, $this->stats)) { 367 $headersCount = count($this->stats[self::HEADER_POSITION]); 368 unset($statExport[self::HEADER_POSITION]); 369 for ($i = 1; $i < $headersCount; $i++) { 370 $currentHeaderLevel = $this->stats[self::HEADER_STRUCT][$i]; 371 $previousHeaderLevel = $this->stats[self::HEADER_STRUCT][$i - 1]; 372 if ($currentHeaderLevel - $previousHeaderLevel > 1) { 373 $treeError += 1; 374 $ruleInfo[self::RULE_OUTLINE_STRUCTURE] = "The " . $i . " header (h" . $currentHeaderLevel . ") has a level bigger than its precedent (" . $previousHeaderLevel . ")"; 375 } 376 } 377 unset($statExport[self::HEADER_STRUCT]); 378 } 379 $outlinePoints = $this->getConf(self::CONF_QUALITY_SCORE_CORRECT_HEADER_STRUCTURE, 3); 380 if ($treeError > 0 || $headersCount == 0) { 381 $qualityScores['correct_outline'] = 0; 382 $ruleResults[self::RULE_OUTLINE_STRUCTURE] = self::FAILED; 383 if ($headersCount == 0) { 384 $ruleInfo[self::RULE_OUTLINE_STRUCTURE] = "Add headings to create a document outline for {$outlinePoints} points"; 385 } 386 } else { 387 $qualityScores['correct_outline'] = $outlinePoints; 388 $ruleResults[self::RULE_OUTLINE_STRUCTURE] = self::PASSED; 389 } 390 391 392 /** 393 * Document length 394 */ 395 $minimalWordCount = 50; 396 $maximalWordCount = 1500; 397 $correctContentLength = true; 398 $correctLengthScore = $this->getConf(self::CONF_QUALITY_SCORE_CORRECT_CONTENT, 10); 399 $missingWords = $minimalWordCount - $statExport[self::WORD_COUNT]; 400 if ($missingWords > 0) { 401 $ruleResults[self::RULE_WORDS_MINIMAL] = self::FAILED; 402 $correctContentLength = false; 403 $ruleInfo[self::RULE_WORDS_MINIMAL] = "Add {$missingWords} words to get {$correctLengthScore} points"; 404 } else { 405 $ruleResults[self::RULE_WORDS_MINIMAL] = self::PASSED; 406 } 407 $tooMuchWords = $statExport[self::WORD_COUNT] - $maximalWordCount; 408 if ($tooMuchWords > 0) { 409 $ruleResults[self::RULE_WORDS_MAXIMAL] = self::FAILED; 410 $ruleInfo[self::RULE_WORDS_MAXIMAL] = "Delete {$tooMuchWords} words to get {$correctLengthScore} points"; 411 $correctContentLength = false; 412 } else { 413 $ruleResults[self::RULE_WORDS_MAXIMAL] = self::PASSED; 414 } 415 if ($correctContentLength) { 416 $qualityScores['correct_content_length'] = $correctLengthScore; 417 } else { 418 $qualityScores['correct_content_length'] = 0; 419 } 420 421 422 /** 423 * Average Number of words by header section to text ratio 424 */ 425 $headers = $this->stats[self::HEADING_COUNT] ?? null; 426 if ($headers != null) { 427 $headerCount = array_sum($headers); 428 $headerCount--; // h1 is supposed to have no words 429 if ($headerCount > 0) { 430 431 $wordCount = $this->stats[self::WORD_COUNT] ?? 0; 432 $avgWordsCountBySection = round($wordCount / $headerCount); 433 $statExport['word_section_count']['avg'] = $avgWordsCountBySection; 434 435 /** 436 * Min words by header section 437 */ 438 $wordsByHeaderMin = 20; 439 /** 440 * Max words by header section 441 */ 442 $wordsByHeaderMax = 300; 443 $correctAverageWordsBySection = true; 444 if ($avgWordsCountBySection < $wordsByHeaderMin) { 445 $ruleResults[self::RULE_AVERAGE_WORDS_BY_SECTION_MIN] = self::FAILED; 446 $correctAverageWordsBySection = false; 447 $ruleInfo[self::RULE_AVERAGE_WORDS_BY_SECTION_MIN] = "The number of words by section is less than {$wordsByHeaderMin}"; 448 } else { 449 $ruleResults[self::RULE_AVERAGE_WORDS_BY_SECTION_MIN] = self::PASSED; 450 } 451 if ($avgWordsCountBySection > $wordsByHeaderMax) { 452 $ruleResults[self::RULE_AVERAGE_WORDS_BY_SECTION_MAX] = self::FAILED; 453 $correctAverageWordsBySection = false; 454 $ruleInfo[self::RULE_AVERAGE_WORDS_BY_SECTION_MAX] = "The number of words by section is more than {$wordsByHeaderMax}"; 455 } else { 456 $ruleResults[self::RULE_AVERAGE_WORDS_BY_SECTION_MAX] = self::PASSED; 457 } 458 if ($correctAverageWordsBySection) { 459 $qualityScores['correct_word_avg_by_section'] = $this->getConf(self::CONF_QUALITY_SCORE_CORRECT_WORD_SECTION_AVERAGE, 10); 460 } else { 461 $qualityScores['correct_word_avg_by_section'] = 0; 462 } 463 464 } 465 } 466 467 /** 468 * Internal Backlinks rule 469 * 470 * We used the database table to get the backlinks 471 * because the replication is based on it 472 * If the dokuwiki index is not up to date, we may got 473 * inconsistency 474 */ 475 try { 476 $countBacklinks = BacklinkCount::createFromResource($this->page) 477 ->setReadStore(MetadataDbStore::class) 478 ->getValueOrDefault(); 479 } catch (ExceptionNotFound $e) { 480 $countBacklinks = 0; 481 } 482 $statExport[BacklinkCount::getPersistentName()] = $countBacklinks; 483 $backlinkScore = $this->getConf(self::CONF_QUALITY_SCORE_INTERNAL_BACKLINK_FACTOR, 1); 484 if ($countBacklinks == 0) { 485 486 $qualityScores[BacklinkCount::getPersistentName()] = 0; 487 $ruleResults[self::RULE_INTERNAL_BACKLINKS_MIN] = self::FAILED; 488 $ruleInfo[self::RULE_INTERNAL_BACKLINKS_MIN] = "Add backlinks for {$backlinkScore} point each"; 489 490 } else { 491 492 $qualityScores[BacklinkCount::getPersistentName()] = $countBacklinks * $backlinkScore; 493 $ruleResults[self::RULE_INTERNAL_BACKLINKS_MIN] = self::PASSED; 494 } 495 496 /** 497 * Internal links 498 */ 499 $internalLinksCount = $this->stats[self::INTERNAL_LINK_COUNT] ?? null; 500 $internalLinkScore = $this->getConf(self::CONF_QUALITY_SCORE_INTERNAL_LINK_FACTOR, 1); 501 if ($internalLinksCount == 0) { 502 $qualityScores[self::INTERNAL_LINK_COUNT] = 0; 503 $ruleResults[self::RULE_INTERNAL_LINKS_MIN] = self::FAILED; 504 $ruleInfo[self::RULE_INTERNAL_LINKS_MIN] = "Add internal links for {$internalLinkScore} point each"; 505 } else { 506 $ruleResults[self::RULE_INTERNAL_LINKS_MIN] = self::PASSED; 507 $qualityScores[self::INTERNAL_LINK_COUNT] = $countBacklinks * $internalLinkScore; 508 } 509 510 /** 511 * Broken Links 512 */ 513 $brokenLinkScore = $this->getConf(self::CONF_QUALITY_SCORE_INTERNAL_LINK_BROKEN_FACTOR, 2); 514 $brokenLinksCount = 0; 515 if (array_key_exists(self::INTERNAL_LINK_BROKEN_COUNT, $this->stats)) { 516 $brokenLinksCount = $this->stats[self::INTERNAL_LINK_BROKEN_COUNT]; 517 } 518 if ($brokenLinksCount > 2) { 519 $qualityScores['no_' . self::INTERNAL_LINK_BROKEN_COUNT] = 0; 520 $ruleResults[self::RULE_INTERNAL_BROKEN_LINKS_MAX] = self::FAILED; 521 $ruleInfo[self::RULE_INTERNAL_BROKEN_LINKS_MAX] = "Delete the {$brokenLinksCount} broken links and add {$brokenLinkScore} points"; 522 } else { 523 $qualityScores['no_' . self::INTERNAL_LINK_BROKEN_COUNT] = $brokenLinkScore; 524 $ruleResults[self::RULE_INTERNAL_BROKEN_LINKS_MAX] = self::PASSED; 525 } 526 527 /** 528 * Media 529 */ 530 $mediasStats = [ 531 "total_count" => self::getAndUnset($statExport, self::MEDIA_COUNT, 0), 532 "internal_count" => self::getAndUnset($statExport, self::INTERNAL_MEDIA_COUNT, 0), 533 "internal_broken_count" => self::getAndUnset($statExport, self::INTERNAL_BROKEN_MEDIA_COUNT, 0), 534 "external_count" => self::getAndUnset($statExport, self::EXTERNAL_MEDIA_COUNT, 0) 535 ]; 536 $statExport['media'] = $mediasStats; 537 538 /** 539 * Changes, the more changes the better 540 */ 541 $qualityScores[self::EDITS_COUNT] = $statExport[self::EDITS_COUNT] * $this->getConf(self::CONF_QUALITY_SCORE_CHANGES_FACTOR, 0.25); 542 543 544 /** 545 * Quality Score 546 */ 547 ksort($qualityScores); 548 $qualityScoring = array(); 549 $qualityScoring[self::SCORE] = array_sum($qualityScores); 550 $qualityScoring["scores"] = $qualityScores; 551 552 553 /** 554 * The rule that if broken will set the quality level to low 555 */ 556 $brokenRules = array(); 557 foreach ($ruleResults as $ruleName => $ruleResult) { 558 if ($ruleResult == self::FAILED) { 559 $brokenRules[] = $ruleName; 560 } 561 } 562 $ruleErrorCount = sizeof($brokenRules); 563 if ($ruleErrorCount > 0) { 564 $qualityResult = $ruleErrorCount . " quality rules errors"; 565 } else { 566 $qualityResult = "All quality rules passed"; 567 } 568 569 /** 570 * Low level Computation 571 */ 572 $mandatoryRules = preg_split("/,/", $this->getConf(self::CONF_MANDATORY_QUALITY_RULES)); 573 $mandatoryRulesBroken = []; 574 foreach ($mandatoryRules as $lowLevelRule) { 575 if (in_array($lowLevelRule, $brokenRules)) { 576 $mandatoryRulesBroken[] = $lowLevelRule; 577 } 578 } 579 /** 580 * Low Level 581 */ 582 $lowLevel = false; 583 $brokenRulesCount = sizeof($mandatoryRulesBroken); 584 if ($brokenRulesCount > 0) { 585 $lowLevel = true; 586 $quality["message"] = "$brokenRulesCount mandatory rules broken."; 587 } else { 588 $quality["message"] = "No mandatory rules broken"; 589 } 590 if ($this->page->isSlot()) { 591 $lowLevel = false; 592 } 593 try { 594 $this->page->setLowQualityIndicatorCalculation($lowLevel); 595 } catch (ExceptionCompile $e) { 596 LogUtility::msg("An error has occurred while saving the low quality level. Error: {$e->getMessage()}"); 597 } 598 599 /** 600 * Building the quality object in order 601 */ 602 $quality[self::LOW] = $lowLevel; 603 if (sizeof($mandatoryRulesBroken) > 0) { 604 ksort($mandatoryRulesBroken); 605 $quality[self::FAILED_MANDATORY_RULES] = $mandatoryRulesBroken; 606 } 607 $quality[self::SCORING] = $qualityScoring; 608 $quality[self::RULES][self::RESULT] = $qualityResult; 609 if (!empty($ruleInfo)) { 610 $quality[self::RULES]["info"] = $ruleInfo; 611 } 612 613 ksort($ruleResults); 614 $quality[self::RULES][self::DETAILS] = $ruleResults; 615 616 /** 617 * Metadata 618 */ 619 try { 620 $requestedPage = MarkupPath::createPageFromExecutingId(); 621 } catch (ExceptionCompile $e) { 622 LogUtility::msg("The global ID is unknown, we can't find the requested page. Analytics was stopped"); 623 return; 624 } 625 $meta = $requestedPage->getMetadataForRendering(); 626 foreach ($meta as $key => $value) { 627 /** 628 * The metadata may have been set 629 * by frontmatter 630 */ 631 if (!isset($this->metadata[$key])) { 632 $this->metadata[$key] = $value; 633 if ($key === PageH1::getName()) { 634 $this->metadata[PageH1::H1_PARSED] = MetadataDokuWikiStore::getOrCreateFromResource($requestedPage)->getFromName(PageH1::H1_PARSED); 635 } 636 } 637 } 638 639 640 /** 641 * Building the Top JSON in order 642 */ 643 $finalStats = array(); 644 $finalStats["date"] = date('Y-m-d H:i:s', time()); 645 ksort($this->metadata); 646 $finalStats[self::METADATA] = $this->metadata; 647 ksort($statExport); 648 $finalStats[self::STATISTICS] = $statExport; 649 $finalStats[self::QUALITY] = $quality; // Quality after the sort to get them at the end 650 651 652 /** 653 * The result can be seen with 654 * doku.php?id=somepage&do=export_combo_analysis 655 * 656 * Set the header temporarily for the export.php file 657 * 658 * The mode in the export is 659 */ 660 $mode = "combo_" . $this->getPluginComponent(); 661 p_set_metadata( 662 $requestedPage->getWikiId(), 663 array("format" => array($mode => array("Content-Type" => 'application/json'))), 664 false, 665 false // Persistence is needed because there is a cache 666 ); 667 $json_encoded = json_encode($finalStats, JSON_PRETTY_PRINT); 668 669 $this->doc = $json_encoded; 670 671 } 672 673 /** 674 */ 675 public function getFormat() 676 { 677 return self::RENDERER_FORMAT; 678 } 679 680 681 public function header($text, $level, $pos) 682 { 683 if (!array_key_exists(self::HEADING_COUNT, $this->stats)) { 684 $this->stats[self::HEADING_COUNT] = []; 685 } 686 $heading = 'h' . $level; 687 if (!array_key_exists( 688 $heading, 689 $this->stats[self::HEADING_COUNT])) { 690 $this->stats[self::HEADING_COUNT][$heading] = 0; 691 } 692 $this->stats[self::HEADING_COUNT][$heading]++; 693 694 $this->headerId++; 695 $this->stats[self::HEADER_POSITION][$this->headerId] = $heading; 696 697 /** 698 * Store the level of each heading 699 * They should only go from low to highest value 700 * for a good outline 701 */ 702 if (!array_key_exists(self::HEADING_COUNT, $this->stats)) { 703 $this->stats[self::HEADER_STRUCT] = []; 704 } 705 $this->stats[self::HEADER_STRUCT][] = $level; 706 707 } 708 709 public function smiley($smiley) 710 { 711 if ($smiley == 'FIXME') { 712 $totalFixme = $this->stats[self::FIXME] ?? 0; 713 $this->stats[self::FIXME] = $totalFixme + 1; 714 } 715 } 716 717 public function linebreak() 718 { 719 if (!$this->tableopen) { 720 $linebreak = $this->stats['linebreak'] ?? 0; 721 $this->stats['linebreak'] = $linebreak + 1; 722 } 723 } 724 725 public function table_open($maxcols = null, $numrows = null, $pos = null) // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps 726 { 727 $this->tableopen = true; 728 } 729 730 public function table_close($pos = null) // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps 731 { 732 $this->tableopen = false; 733 } 734 735 public function hr() 736 { 737 $hr = $this->stats['hr'] ?? 0; 738 $this->stats['hr'] = $hr + 1; 739 } 740 741 public function quote_open() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps 742 { 743 $quoteCount = $this->stats['quote_count'] ?? 0; 744 $this->stats['quote_count'] = $quoteCount + 1; 745 $this->quotelevel++; 746 $quoteNest = $this->stats['quote_nest'] ?? 0; 747 $this->stats['quote_nest'] = max($this->quotelevel, $quoteNest); 748 } 749 750 public function quote_close() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps 751 { 752 $this->quotelevel--; 753 } 754 755 public function strong_open() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps 756 { 757 $this->formattingBracket++; 758 } 759 760 public function strong_close() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps 761 { 762 $this->formattingBracket--; 763 } 764 765 public function emphasis_open() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps 766 { 767 $this->formattingBracket++; 768 } 769 770 public function emphasis_close() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps 771 { 772 $this->formattingBracket--; 773 } 774 775 public function underline_open() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps 776 { 777 $this->formattingBracket++; 778 } 779 780 public function addToDescription($text) 781 { 782 783 } 784 785 public function underline_close() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps 786 { 787 $this->formattingBracket--; 788 } 789 790 public function cdata($text) 791 { 792 793 /** 794 * It seems that you receive cdata 795 * when emphasis_open / underline_open / strong_open 796 * Stats are not for them 797 */ 798 if (!$this->formattingBracket) return; 799 800 $this->plainTextId++; 801 802 /** 803 * Length 804 */ 805 $len = strlen($text); 806 $this->stats[self::PLAINTEXT][$this->plainTextId]['len'] = $len; 807 808 809 /** 810 * Multi-formatting 811 */ 812 if ($this->formattingBracket > 1) { 813 $numberOfFormats = 1 * ($this->formattingBracket - 1); 814 $this->stats[self::PLAINTEXT][$this->plainTextId]['multiformat'] += $numberOfFormats; 815 } 816 817 /** 818 * Total 819 */ 820 $totalLen = $this->stats[self::PLAINTEXT][0] ?? 0; 821 $this->stats[self::PLAINTEXT][0] = $totalLen + $len; 822 823 } 824 825 public function internalmedia($src, $title = null, $align = null, $width = null, $height = null, $cache = null, $linking = null) 826 { 827 $this->stats[self::INTERNAL_MEDIA_COUNT]++; 828 } 829 830 public function externalmedia($src, $title = null, $align = null, $width = null, $height = null, $cache = null, $linking = null) 831 { 832 $this->stats[self::EXTERNAL_MEDIA_COUNT]++; 833 } 834 835 public function reset() 836 { 837 $this->stats = array(); 838 $this->metadata = array(); 839 $this->headerId = 0; 840 } 841 842 public function setAnalyticsMetaForReporting($key, $value) 843 { 844 $this->metadata[$key] = $value; 845 } 846 847 848} 849 850