Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
65.32% covered (warning)
65.32%
113 / 173
41.18% covered (danger)
41.18%
7 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
WikiSEO
65.32% covered (warning)
65.32%
113 / 173
41.18% covered (danger)
41.18%
7 / 17
175.65
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setMetadataFromPageProps
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 setMetadata
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
6.56
 getMetadataValue
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addMetadataToPage
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 setMetadataGenerators
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
12
 loadPagePropsFromDb
63.64% covered (warning)
63.64%
7 / 11
0.00% covered (danger)
0.00%
0 / 1
4.77
 loadPagePropsFromOutputPage
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
5.03
 instantiateMetadataPlugins
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
3.21
 finalize
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 makeErrorHtml
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 modifyPageTitle
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
8
 saveMetadataToProps
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
5.01
 mergeValidParameterNames
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
2
 fromTag
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
2.02
 fromParserFunction
63.16% covered (warning)
63.16%
12 / 19
0.00% covered (danger)
0.00%
0 / 1
4.80
 protocolizeUrl
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
16 *
17 * @file
18 */
19
20declare( strict_types=1 );
21
22namespace MediaWiki\Extension\WikiSEO;
23
24use ConfigException;
25use ExtensionRegistry;
26use MediaWiki\Extension\WikiSEO\Generator\GeneratorInterface;
27use MediaWiki\Extension\WikiSEO\Generator\MetaTag;
28use MediaWiki\MediaWikiServices;
29use MediaWiki\Title\Title;
30use OutputPage;
31use PageImages\PageImages;
32use Parser;
33use ParserOutput;
34use PPFrame;
35use ReflectionClass;
36use ReflectionException;
37use WebRequest;
38
39class WikiSEO {
40    private const MODE_TAG = 'tag';
41    private const MODE_PARSER = 'parser';
42
43    /**
44     * @var string 'tag' or 'parser' used to determine the error message
45     */
46    private $mode;
47
48    /**
49     * prepend, append or replace the new title to the existing title
50     *
51     * @var string
52     */
53    private $titleMode = 'replace';
54
55    /**
56     * the separator to use when using append or prepend modes
57     *
58     * @var string
59     */
60    private $titleSeparator = ' - ';
61
62    /**
63     * @var string[] Array with generator names
64     */
65    private $generators;
66
67    /**
68     * @var GeneratorInterface[]
69     */
70    private $generatorInstances = [];
71
72    /**
73     * @var string[] Possible error messages
74     */
75    private $errors = [];
76
77    /**
78     * @var array
79     */
80    private $metadata = [];
81
82    /**
83     * WikiSEO constructor.
84     * Loads generator names from LocalSettings
85     *
86     * @param string $mode the parser mode
87     */
88    public function __construct( $mode = self::MODE_PARSER ) {
89        $this->setMetadataGenerators();
90
91        $this->mode = $mode;
92    }
93
94    /**
95     * Set the metadata by loading the page props from the db or the OutputPage object
96     *
97     * @param OutputPage $outputPage
98     */
99    public function setMetadataFromPageProps( OutputPage $outputPage ): void {
100        if ( $outputPage->getTitle() === null ) {
101            $this->errors[] = wfMessage( 'wiki-seo-missing-page-title' );
102
103            return;
104        }
105
106        $result =
107        $this->loadPagePropsFromDb( $outputPage->getTitle() ) ??
108        $this->loadPagePropsFromOutputPage( $outputPage ) ?? [];
109
110        $this->setMetadata( $result );
111    }
112
113    /**
114     * Set an array with metadata key value pairs
115     * Gets validated by Validator
116     *
117     * @param array $metadataArray
118     * @param ParserOutput|null $out ParserOutput is used to set a extension data flag to disable auto description,
119     * even when the flag is active.
120     * The reason is, if a description was provided and does not equal 'auto' or 'textextracts' we want to use it.
121     * @see Validator
122     */
123    public function setMetadata( array $metadataArray, ?ParserOutput $out = null ): void {
124        $validMetadata = [];
125
126        // We'll set a flag to don't overwrite manual descriptions
127        // If the AutoDescription setting is set
128        if ( $out !== null ) {
129            if ( isset( $metadataArray['manualDescription'] ) &&
130                !in_array( $metadataArray['manualDescription'], [ 'auto', 'textextracts' ], true ) ) {
131
132                $out->setPageProperty( 'manualDescription', '1' );
133
134                $metadataArray['description'] = $metadataArray['manualDescription'];
135                unset( $metadataArray['manualDescription'] );
136            } else {
137                $out->unsetPageProperty( 'manualDescription' );
138            }
139        }
140
141        foreach ( Validator::validateParams( $metadataArray ) as $k => $v ) {
142            if ( !empty( $v ) ) {
143                $validMetadata[$k] = $v;
144            }
145        }
146
147        $this->metadata = $validMetadata;
148    }
149
150    /**
151     * Get a value from the $metadata array, given a key (or null if
152     * no value exists).
153     *
154     * @param string $key
155     * @return string|null
156     */
157    public function getMetadataValue( $key ) {
158        return $this->metadata[$key] ?? null;
159    }
160
161    /**
162     * Add the metadata array as meta tags to the page
163     *
164     * @param OutputPage $out
165     */
166    public function addMetadataToPage( OutputPage $out ): void {
167        if ( $out->isArticle() && !isset( $out->getRequest()->getQueryValues()['diff'] ) ) {
168            $this->modifyPageTitle( $out );
169        }
170
171        MediaWikiServices::getInstance()->getHookContainer()->run(
172            'WikiSEOPreAddMetadata',
173            [
174                &$this->metadata,
175            ]
176        );
177
178        foreach ( $this->generatorInstances as $generatorInstance ) {
179            $generatorInstance->init( $this->metadata, $out );
180            $generatorInstance->addMetadata();
181        }
182    }
183
184    /**
185     * Set active metadata generators defined in $wgMetdataGenerators
186     * And merges all valid parameter names from the generator to the validator
187     */
188    private function setMetadataGenerators(): void {
189        $defaultGenerators = [
190            'OpenGraph',
191            'Twitter',
192            'SchemaOrg',
193        ];
194
195        try {
196            $generators = MediaWikiServices::getInstance()
197                ->getConfigFactory()
198                ->makeConfig( 'WikiSEO' )
199                ->get( 'MetadataGenerators' );
200
201            if ( empty( $generators ) ) {
202                $generators = $defaultGenerators;
203            }
204        } catch ( ConfigException $e ) {
205            wfLogWarning(
206                sprintf(
207                    'Could not get config for "$wgMetadataGenerators", using default. %s',
208                    $e->getMessage()
209                )
210            );
211
212            $generators = $defaultGenerators;
213        }
214
215        $this->generators = $generators;
216        $this->instantiateMetadataPlugins();
217        $this->mergeValidParameterNames();
218    }
219
220    /**
221     * Loads all page props for the given page with pp_propnames in Validator::getValidParams()
222     *
223     * @param Title $title
224     * @return null|array Null if empty
225     * @see Validator::getValidParams()
226     */
227    private function loadPagePropsFromDb( Title $title ): ?array {
228        $properties = MediaWikiServices::getInstance()->getPageProps()->getProperties(
229            $title,
230            Validator::getValidParams()
231        );
232
233        $properties = array_shift( $properties );
234
235        if ( $properties === null || count( $properties ) === 0 ) {
236            return null;
237        }
238
239        $result = [];
240
241        foreach ( $properties as $key => $value ) {
242            $result[$key] = $value;
243        }
244
245        return $result;
246    }
247
248    /**
249     * Tries to load the page props from OutputPage with keys from Validator::getValidParams()
250     *
251     * @see Validator::getValidParams()
252     *
253     * @param OutputPage $page
254     * @return array|null
255     */
256    private function loadPagePropsFromOutputPage( OutputPage $page ): ?array {
257        $result = [];
258
259        foreach ( Validator::getValidParams() as $param ) {
260            $prop = $page->getProperty( $param );
261
262            if ( $prop !== null ) {
263                // phpcs:ignore Generic.PHP.NoSilencedErrors.Discouraged
264                $value = @unserialize( $prop, [ 'allowed_classes' => false ] );
265
266                // Value was serialized
267                if ( $value !== false ) {
268                    $prop = $value;
269                }
270
271                $result[$param] = $prop;
272            }
273        }
274
275        return empty( $result ) ? null : $result;
276    }
277
278    /**
279     * Instantiates the metadata generators from $wgMetadataGenerators
280     */
281    private function instantiateMetadataPlugins(): void {
282        $this->generatorInstances[] = new MetaTag();
283
284        foreach ( $this->generators as $generator ) {
285            $classPath = "MediaWiki\\Extension\\WikiSEO\\Generator\\Plugins\\$generator";
286
287            try {
288                $class = new ReflectionClass( $classPath );
289                $this->generatorInstances[] = $class->newInstance();
290            } catch ( ReflectionException ) {
291                $this->errors[] = wfMessage( 'wiki-seo-invalid-generator', $generator )->parse();
292            }
293        }
294    }
295
296    /**
297     * Finalize everything.
298     * Check for errors and save to props if everything is ok.
299     *
300     * @param ParserOutput $output
301     *
302     * @return string String with errors that happened or empty
303     */
304    public function finalize( ParserOutput $output ): string {
305        if ( empty( $this->metadata ) ) {
306            $message = sprintf( 'wiki-seo-empty-attr-%s', $this->mode );
307            $this->errors[] = wfMessage( $message );
308
309            return $this->makeErrorHtml();
310        }
311
312        $this->saveMetadataToProps( $output );
313
314        return '';
315    }
316
317    /**
318     * @return string Concatenated error strings
319     */
320    private function makeErrorHtml(): string {
321        $text = implode( '<br>', $this->errors );
322
323        return sprintf( '<div class="errorbox">%s</div>', $text );
324    }
325
326    /**
327     * Modifies the page title based on 'titleMode'
328     *
329     * @param OutputPage $out
330     */
331    private function modifyPageTitle( OutputPage $out ): void {
332        if ( !array_key_exists( 'title', $this->metadata ) ) {
333            return;
334        }
335
336        $metaTitle = $this->metadata['title'];
337
338        if ( array_key_exists( 'title_separator', $this->metadata ) ) {
339            $this->titleSeparator = $this->metadata['title_separator'];
340        }
341
342        if ( array_key_exists( 'title_mode', $this->metadata ) ) {
343            $this->titleMode = $this->metadata['title_mode'];
344        }
345
346        $strippedTitle = strip_tags( $out->getPageTitle() );
347
348        switch ( $this->titleMode ) {
349            case 'append':
350                $pageTitle = sprintf( '%s%s%s', $strippedTitle, $this->titleSeparator, $metaTitle );
351                break;
352
353            case 'prepend':
354                $pageTitle = sprintf( '%s%s%s', $metaTitle, $this->titleSeparator, $strippedTitle );
355                break;
356
357            case 'replace':
358            default:
359                $pageTitle = $metaTitle;
360                break;
361        }
362
363        $pageTitle = preg_replace( "/[\r\n]/", '', $pageTitle );
364        $pageTitle = html_entity_decode( $pageTitle, ENT_QUOTES );
365
366        $out->setHTMLTitle( $pageTitle );
367    }
368
369    /**
370     * Save the metadata array json encoded to the page props table
371     *
372     * @param ParserOutput $outputPage
373     */
374    private function saveMetadataToProps( ParserOutput $outputPage ): void {
375        MediaWikiServices::getInstance()->getHookContainer()->run(
376            'WikiSEOPreAddPageProps',
377            [
378                &$this->metadata,
379            ]
380        );
381
382        foreach ( $this->metadata as $key => $value ) {
383            if ( $outputPage->getPageProperty( $key ) === null ) {
384                $outputPage->setPageProperty( $key, $value );
385            }
386
387            if ( ExtensionRegistry::getInstance()->isLoaded( 'PageImages' ) &&
388                $key === 'image' ) {
389                $outputPage->setPageProperty( PageImages::PROP_NAME_FREE, $value );
390            }
391        }
392    }
393
394    /**
395     * Adds valid tags from all generator instances to the Validator
396     * Automatically called after instantiating all active generators
397     */
398    private function mergeValidParameterNames(): void {
399        Validator::$validParameterNames = array_unique(
400            array_merge(
401                Validator::$validParameterNames,
402                array_reduce(
403                    array_map(
404                        static function ( GeneratorInterface $generator ) {
405                            return $generator->getAllowedParameterNames();
406                        },
407                        $this->generatorInstances
408                    ),
409                    static function ( array $carry, array $item ) {
410                        return array_merge( $carry, $item );
411                    },
412                    []
413                )
414            )
415        );
416    }
417
418    /**
419     * Parse the values input from the <seo> tag extension
420     *
421     * @param string|null $input The text content of the tag
422     * @param array $args The HTML attributes of the tag
423     * @param Parser $parser The active Parser instance
424     * @param PPFrame $frame
425     *
426     * @return string The HTML comments of cached attributes
427     */
428    public static function fromTag( ?string $input, array $args, Parser $parser, PPFrame $frame ): string {
429        $seo = new WikiSEO( self::MODE_TAG );
430        $tagParser = new TagParser();
431
432        $parsedInput = $tagParser->parseText( $input, $parser, $frame );
433        $tags = array_merge( $parsedInput, $args );
434        $tags = $tagParser->expandWikiTextTagArray( $tags, $parser, $frame );
435
436        if ( isset( $tags['description'] ) ) {
437            $tags['manualDescription'] = $tags['description'];
438            unset( $tags['description'] );
439        }
440        $tags = array_merge( $seo->loadPagePropsFromDb( $frame->getTitle() ) ?? [], $tags );
441
442        $seo->setMetadata( $tags, $parser->getOutput() );
443
444        return $seo->finalize( $parser->getOutput() );
445    }
446
447    /**
448     * Parse the values input from the {{#seo}} parser function
449     *
450     * @param Parser $parser The active Parser instance
451     * @param PPFrame $frame Frame
452     * @param array $args Arguments
453     *
454     * @return array Parser options and the HTML comments of cached attributes
455     */
456    public static function fromParserFunction( $parser, PPFrame $frame, array $args ): array {
457        $expandedArgs = [];
458
459        foreach ( $args as $arg ) {
460            $expandedArgs[] = trim( $frame->expand( $arg ) );
461        }
462
463        $seo = new WikiSEO( self::MODE_PARSER );
464        $tagParser = new TagParser();
465
466        $args = $tagParser->parseArgs( $expandedArgs, $parser, $frame );
467        if ( isset( $args['description'] ) ) {
468            $args['manualDescription'] = $args['description'];
469            unset( $args['description'] );
470        }
471
472        $args = array_merge( $seo->loadPagePropsFromDb( $frame->getTitle() ) ?? [], $args );
473
474        $seo->setMetadata( $args, $parser->getOutput() );
475
476        $fin = $seo->finalize( $parser->getOutput() );
477        if ( !empty( $fin ) ) {
478            return [
479                $fin,
480                'noparse' => true,
481                'isHTML' => true,
482            ];
483        }
484
485        // See https://github.com/octfx/wiki-seo/issues/30
486        return [ '<!-- WikiSEO -->' ];
487    }
488
489    /**
490     * Add the server protocol to the URL if it is missing
491     *
492     * @param string $url URL from getFullURL()
493     * @param WebRequest $request
494     *
495     * @return string
496     */
497    public static function protocolizeUrl( string $url, WebRequest $request ): string {
498        if ( parse_url( $url, PHP_URL_SCHEME ) === null ) {
499            $url = sprintf( '%s:%s', $request->getProtocol(), $url );
500        }
501
502        return $url;
503    }
504}