xref: /plugin/combo/renderer/analytics.php (revision 04fd306c7c155fa133ebb3669986875d65988276)
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        array_push($revs, $dokuWikiMetadata['last_change']['date']);
238        $statExport[self::EDITS_COUNT] = count($revs);
239        foreach ($revs as $rev) {
240
241
242            /**
243             * Init the authors array
244             */
245            if (!array_key_exists('authors', $statExport)) {
246                $statExport['authors'] = [];
247            }
248            /**
249             * Analytics by users
250             */
251            $info = $changelog->getRevisionInfo($rev);
252            if (is_array($info)) {
253                $user = "*";
254                if (array_key_exists('user', $info)) {
255                    $user = $info['user'];
256                }
257                if (!array_key_exists('authors', $statExport['authors'])) {
258                    $statExport['authors'][$user] = 0;
259                }
260                $statExport['authors'][$user] += 1;
261            }
262        }
263
264        /**
265         * Word and chars count
266         * The word count does not take into account
267         * words with non-words characters such as < =
268         * Therefore the node and attribute are not taken in the count
269         */
270        $text = rawWiki($ID);
271        $statExport[self::CHAR_COUNT] = strlen($text);
272        $statExport[self::WORD_COUNT] = StringUtility::getWordCount($text);
273
274
275        /**
276         * Internal link distance summary calculation
277         */
278        if (array_key_exists(self::INTERNAL_LINK_DISTANCE, $statExport)) {
279            $linkLengths = $statExport[self::INTERNAL_LINK_DISTANCE];
280            unset($statExport[self::INTERNAL_LINK_DISTANCE]);
281            $countBacklinks = count($linkLengths);
282            $statExport[self::INTERNAL_LINK_DISTANCE]['avg'] = null;
283            $statExport[self::INTERNAL_LINK_DISTANCE]['max'] = null;
284            $statExport[self::INTERNAL_LINK_DISTANCE]['min'] = null;
285            if ($countBacklinks > 0) {
286                $statExport[self::INTERNAL_LINK_DISTANCE]['avg'] = array_sum($linkLengths) / $countBacklinks;
287                $statExport[self::INTERNAL_LINK_DISTANCE]['max'] = max($linkLengths);
288                $statExport[self::INTERNAL_LINK_DISTANCE]['min'] = min($linkLengths);
289            }
290        }
291
292        /**
293         * Quality Report / Rules
294         */
295        // The array that hold the results of the quality rules
296        $ruleResults = array();
297        // The array that hold the quality score details
298        $qualityScores = array();
299
300
301        /**
302         * No fixme
303         */
304        if (array_key_exists(self::FIXME, $this->stats)) {
305            $fixmeCount = $this->stats[self::FIXME];
306            $statExport[self::FIXME] = $fixmeCount == null ? 0 : $fixmeCount;
307            if ($fixmeCount != 0) {
308                $ruleResults[self::RULE_FIXME] = self::FAILED;
309                $qualityScores['no_' . self::FIXME] = 0;
310            } else {
311                $ruleResults[self::RULE_FIXME] = self::PASSED;
312                $qualityScores['no_' . self::FIXME] = $this->getConf(self::CONF_QUALITY_SCORE_NO_FIXME, 1);
313            }
314        }
315
316        /**
317         * A title should be present
318         */
319        $titleScore = $this->getConf(self::CONF_QUALITY_SCORE_TITLE_PRESENT, 10);
320        if (empty($this->metadata[PageTitle::TITLE])) {
321            $ruleResults[self::RULE_TITLE_PRESENT] = self::FAILED;
322            $ruleInfo[self::RULE_TITLE_PRESENT] = "Add a title for {$titleScore} points";
323            $this->metadata[PageTitle::TITLE] = $dokuWikiMetadata[PageTitle::TITLE];
324            $qualityScores[self::RULE_TITLE_PRESENT] = 0;
325        } else {
326            $qualityScores[self::RULE_TITLE_PRESENT] = $titleScore;
327            $ruleResults[self::RULE_TITLE_PRESENT] = self::PASSED;
328        }
329
330        /**
331         * A description should be present
332         */
333        $descScore = $this->getConf(self::CONF_QUALITY_SCORE_DESCRIPTION_PRESENT, 8);
334        if (empty($this->metadata[self::DESCRIPTION])) {
335            $ruleResults[self::RULE_DESCRIPTION_PRESENT] = self::FAILED;
336            $ruleInfo[self::RULE_DESCRIPTION_PRESENT] = "Add a description for {$descScore} points";
337            $this->metadata[self::DESCRIPTION] = $dokuWikiMetadata[self::DESCRIPTION]["abstract"];
338            $qualityScores[self::RULE_DESCRIPTION_PRESENT] = 0;
339        } else {
340            $qualityScores[self::RULE_DESCRIPTION_PRESENT] = $descScore;
341            $ruleResults[self::RULE_DESCRIPTION_PRESENT] = self::PASSED;
342        }
343
344        /**
345         * A canonical should be present
346         */
347        $canonicalScore = $this->getConf(self::CONF_QUALITY_SCORE_CANONICAL_PRESENT, 5);
348        if (empty($this->metadata[Canonical::PROPERTY_NAME])) {
349            global $conf;
350            $root = $conf['start'];
351            if ($ID !== $root) {
352                $qualityScores[self::RULE_CANONICAL_PRESENT] = 0;
353                $ruleResults[self::RULE_CANONICAL_PRESENT] = self::FAILED;
354                // no link to the documentation because we don't want any html in the json
355                $ruleInfo[self::RULE_CANONICAL_PRESENT] = "Add a canonical for {$canonicalScore} points";
356            }
357        } else {
358            $qualityScores[self::RULE_CANONICAL_PRESENT] = $canonicalScore;
359            $ruleResults[self::RULE_CANONICAL_PRESENT] = self::PASSED;
360        }
361
362        /**
363         * Outline / Header structure
364         */
365        $treeError = 0;
366        $headersCount = 0;
367        if (array_key_exists(self::HEADER_POSITION, $this->stats)) {
368            $headersCount = count($this->stats[self::HEADER_POSITION]);
369            unset($statExport[self::HEADER_POSITION]);
370            for ($i = 1; $i < $headersCount; $i++) {
371                $currentHeaderLevel = $this->stats[self::HEADER_STRUCT][$i];
372                $previousHeaderLevel = $this->stats[self::HEADER_STRUCT][$i - 1];
373                if ($currentHeaderLevel - $previousHeaderLevel > 1) {
374                    $treeError += 1;
375                    $ruleInfo[self::RULE_OUTLINE_STRUCTURE] = "The " . $i . " header (h" . $currentHeaderLevel . ") has a level bigger than its precedent (" . $previousHeaderLevel . ")";
376                }
377            }
378            unset($statExport[self::HEADER_STRUCT]);
379        }
380        $outlinePoints = $this->getConf(self::CONF_QUALITY_SCORE_CORRECT_HEADER_STRUCTURE, 3);
381        if ($treeError > 0 || $headersCount == 0) {
382            $qualityScores['correct_outline'] = 0;
383            $ruleResults[self::RULE_OUTLINE_STRUCTURE] = self::FAILED;
384            if ($headersCount == 0) {
385                $ruleInfo[self::RULE_OUTLINE_STRUCTURE] = "Add headings to create a document outline for {$outlinePoints} points";
386            }
387        } else {
388            $qualityScores['correct_outline'] = $outlinePoints;
389            $ruleResults[self::RULE_OUTLINE_STRUCTURE] = self::PASSED;
390        }
391
392
393        /**
394         * Document length
395         */
396        $minimalWordCount = 50;
397        $maximalWordCount = 1500;
398        $correctContentLength = true;
399        $correctLengthScore = $this->getConf(self::CONF_QUALITY_SCORE_CORRECT_CONTENT, 10);
400        $missingWords = $minimalWordCount - $statExport[self::WORD_COUNT];
401        if ($missingWords > 0) {
402            $ruleResults[self::RULE_WORDS_MINIMAL] = self::FAILED;
403            $correctContentLength = false;
404            $ruleInfo[self::RULE_WORDS_MINIMAL] = "Add {$missingWords} words to get {$correctLengthScore} points";
405        } else {
406            $ruleResults[self::RULE_WORDS_MINIMAL] = self::PASSED;
407        }
408        $tooMuchWords = $statExport[self::WORD_COUNT] - $maximalWordCount;
409        if ($tooMuchWords > 0) {
410            $ruleResults[self::RULE_WORDS_MAXIMAL] = self::FAILED;
411            $ruleInfo[self::RULE_WORDS_MAXIMAL] = "Delete {$tooMuchWords} words to get {$correctLengthScore} points";
412            $correctContentLength = false;
413        } else {
414            $ruleResults[self::RULE_WORDS_MAXIMAL] = self::PASSED;
415        }
416        if ($correctContentLength) {
417            $qualityScores['correct_content_length'] = $correctLengthScore;
418        } else {
419            $qualityScores['correct_content_length'] = 0;
420        }
421
422
423        /**
424         * Average Number of words by header section to text ratio
425         */
426        $headers = $this->stats[self::HEADING_COUNT];
427        if ($headers != null) {
428            $headerCount = array_sum($headers);
429            $headerCount--; // h1 is supposed to have no words
430            if ($headerCount > 0) {
431
432                $avgWordsCountBySection = round($this->stats[self::WORD_COUNT] / $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];
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->getPageId(),
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') $this->stats[self::FIXME]++;
712    }
713
714    public function linebreak()
715    {
716        if (!$this->tableopen) {
717            $this->stats['linebreak']++;
718        }
719    }
720
721    public function table_open($maxcols = null, $numrows = null, $pos = null) // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
722    {
723        $this->tableopen = true;
724    }
725
726    public function table_close($pos = null) // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
727    {
728        $this->tableopen = false;
729    }
730
731    public function hr()
732    {
733        $this->stats['hr']++;
734    }
735
736    public function quote_open() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
737    {
738        $this->stats['quote_count']++;
739        $this->quotelevel++;
740        $this->stats['quote_nest'] = max($this->quotelevel, $this->stats['quote_nest']);
741    }
742
743    public function quote_close() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
744    {
745        $this->quotelevel--;
746    }
747
748    public function strong_open() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
749    {
750        $this->formattingBracket++;
751    }
752
753    public function strong_close() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
754    {
755        $this->formattingBracket--;
756    }
757
758    public function emphasis_open() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
759    {
760        $this->formattingBracket++;
761    }
762
763    public function emphasis_close() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
764    {
765        $this->formattingBracket--;
766    }
767
768    public function underline_open() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
769    {
770        $this->formattingBracket++;
771    }
772
773    public function addToDescription($text)
774    {
775
776    }
777
778    public function underline_close() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps
779    {
780        $this->formattingBracket--;
781    }
782
783    public function cdata($text)
784    {
785
786        /**
787         * It seems that you receive cdata
788         * when emphasis_open / underline_open / strong_open
789         * Stats are not for them
790         */
791        if (!$this->formattingBracket) return;
792
793        $this->plainTextId++;
794
795        /**
796         * Length
797         */
798        $len = strlen($text);
799        $this->stats[self::PLAINTEXT][$this->plainTextId]['len'] = $len;
800
801
802        /**
803         * Multi-formatting
804         */
805        if ($this->formattingBracket > 1) {
806            $numberOfFormats = 1 * ($this->formattingBracket - 1);
807            $this->stats[self::PLAINTEXT][$this->plainTextId]['multiformat'] += $numberOfFormats;
808        }
809
810        /**
811         * Total
812         */
813        $this->stats[self::PLAINTEXT][0] += $len;
814    }
815
816    public function internalmedia($src, $title = null, $align = null, $width = null, $height = null, $cache = null, $linking = null)
817    {
818        $this->stats[self::INTERNAL_MEDIA_COUNT]++;
819    }
820
821    public function externalmedia($src, $title = null, $align = null, $width = null, $height = null, $cache = null, $linking = null)
822    {
823        $this->stats[self::EXTERNAL_MEDIA_COUNT]++;
824    }
825
826    public function reset()
827    {
828        $this->stats = array();
829        $this->metadata = array();
830        $this->headerId = 0;
831    }
832
833    public function setAnalyticsMetaForReporting($key, $value)
834    {
835        $this->metadata[$key] = $value;
836    }
837
838
839}
840
841