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