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