Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.48% covered (warning)
85.48%
53 / 62
60.00% covered (warning)
60.00%
6 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
ParserOutputPageProperties
85.48% covered (warning)
85.48%
53 / 62
60.00% covered (warning)
60.00%
6 / 10
31.57
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 initialize
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 finishInitializeBatch
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 finalize
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 finalizeReal
71.43% covered (warning)
71.43%
10 / 14
0.00% covered (danger)
0.00%
0 / 1
4.37
 extractDisplayTitle
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
10
 isSameString
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 fixAndFlagInvalidUTF8InSource
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 truncateFileTextContent
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
 truncateFileContent
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace CirrusSearch\BuildDocument;
4
5use CirrusSearch\CirrusSearch;
6use CirrusSearch\Search\CirrusIndexField;
7use CirrusSearch\SearchConfig;
8use Elastica\Document;
9use MediaWiki\Logger\LoggerFactory;
10use MediaWiki\MediaWikiServices;
11use MediaWiki\Revision\RevisionRecord;
12use ParserCache;
13use ParserOutput;
14use Sanitizer;
15use Title;
16use WikiPage;
17
18/**
19 * Extract searchable properties from the MediaWiki ParserOutput
20 */
21class ParserOutputPageProperties implements PagePropertyBuilder {
22    /** @var ParserCache */
23    private $parserCache;
24    /** @var bool */
25    private $forceParse;
26    /** @var SearchConfig */
27    private $config;
28
29    /**
30     * @param ParserCache $cache Cache to retrieve ParserOutput from
31     * @param bool $forceParse When true ignore the cache and re-parse
32     *  wikitext.
33     * @param SearchConfig $config
34     */
35    public function __construct( ParserCache $cache, bool $forceParse, SearchConfig $config ) {
36        $this->parserCache = $cache;
37        $this->forceParse = $forceParse;
38        $this->config = $config;
39    }
40
41    /**
42     * {@inheritDoc}
43     */
44    public function initialize( Document $doc, WikiPage $page, RevisionRecord $revision ): void {
45        // NOOP
46    }
47
48    /**
49     * {@inheritDoc}
50     */
51    public function finishInitializeBatch(): void {
52        // NOOP
53    }
54
55    /**
56     * {@inheritDoc}
57     */
58    public function finalize( Document $doc, Title $title, RevisionRecord $revision ): void {
59        $page = MediaWikiServices::getInstance()->getWikiPageFactory()->newFromTitle( $title );
60        // TODO: If parserCache is null here then we will parse for every
61        // cluster and every retry.  Maybe instead of forcing a parse, we could
62        // force a parser cache update during self::initialize?
63        $cache = $this->forceParse ? null : $this->parserCache;
64        $this->finalizeReal( $doc, $page, $cache, new CirrusSearch, $revision );
65    }
66
67    /**
68     * Visible for testing. Much simpler to test with all objects resolved.
69     *
70     * @param Document $doc Document to finalize
71     * @param WikiPage $page WikiPage to scope operation to
72     * @param ParserCache|null $cache Cache to fetch parser output from. When null the
73     * wikitext parser will be invoked.
74     * @param CirrusSearch $engine SearchEngine implementation
75     * @param RevisionRecord $revision The page revision to use
76     * @throws BuildDocumentException
77     */
78    public function finalizeReal(
79        Document $doc,
80        WikiPage $page,
81        ?ParserCache $cache,
82        CirrusSearch $engine,
83        RevisionRecord $revision
84    ): void {
85        $contentHandler = $page->getContentHandler();
86        // TODO: Should see if we can change content handler api to avoid
87        // the WikiPage god object, but currently parser cache is still
88        // tied to WikiPage as well.
89        $output = $contentHandler->getParserOutputForIndexing( $page, $cache, $revision );
90
91        if ( !$output ) {
92            throw new BuildDocumentException( "ParserOutput cannot be obtained." );
93        }
94
95        $fieldDefinitions = $engine->getSearchIndexFields();
96        $fieldContent = $contentHandler->getDataForSearchIndex( $page, $output, $engine, $revision );
97        $fieldContent = self::fixAndFlagInvalidUTF8InSource( $fieldContent, $page->getId() );
98        $fieldContent = $this->truncateFileContent( $fieldContent );
99        foreach ( $fieldContent as $field => $fieldData ) {
100            $doc->set( $field, $fieldData );
101            if ( isset( $fieldDefinitions[$field] ) ) {
102                $hints = $fieldDefinitions[$field]->getEngineHints( $engine );
103                CirrusIndexField::addIndexingHints( $doc, $field, $hints );
104            }
105        }
106
107        $doc->set( 'display_title', self::extractDisplayTitle( $page->getTitle(), $output ) );
108    }
109
110    /**
111     * @param Title $title
112     * @param ParserOutput $output
113     * @return string|null
114     */
115    private static function extractDisplayTitle( Title $title, ParserOutput $output ): ?string {
116        $titleText = $title->getText();
117        $titlePrefixedText = $title->getPrefixedText();
118
119        $raw = $output->getDisplayTitle();
120        if ( $raw === false ) {
121            return null;
122        }
123        $clean = Sanitizer::stripAllTags( $raw );
124        // Only index display titles that differ from the normal title
125        if ( self::isSameString( $clean, $titleText ) ||
126            self::isSameString( $clean, $titlePrefixedText )
127        ) {
128            return null;
129        }
130        if ( $title->getNamespace() === 0 || strpos( $clean, ':' ) === false ) {
131            return $clean;
132        }
133        // There is no official way that namespaces work in display title, it
134        // is an arbitrary string. Even so some use cases, such as the
135        // Translate extension, will translate the namespace as well. Here
136        // `Help:foo` will have a display title of `Aide:bar`. If we were to
137        // simply index as is the autocomplete and near matcher would see
138        // Help:Aide:bar, which doesn't seem particularly useful.
139        // The strategy here is to see if the portion before the : is a valid namespace
140        // in either the language of the wiki or the language of the page. If it is
141        // then we strip it from the display title.
142        list( $maybeNs, $maybeDisplayTitle ) = explode( ':', $clean, 2 );
143        $cleanTitle = Title::newFromText( $clean );
144        if ( $cleanTitle === null ) {
145            // The title is invalid, we cannot extract the ns prefix
146            return $clean;
147        }
148        if ( $cleanTitle->getNamespace() == $title->getNamespace() ) {
149            // While it doesn't really matter, $cleanTitle->getText() may
150            // have had ucfirst() applied depending on settings so we
151            // return the unmodified $maybeDisplayTitle.
152            return $maybeDisplayTitle;
153        }
154
155        $docLang = $title->getPageLanguage();
156        $nsIndex = $docLang->getNsIndex( $maybeNs );
157        if ( $nsIndex !== $title->getNamespace() ) {
158            // Valid namespace but not the same as the actual page.
159            // Keep the namespace in the display title.
160            return $clean;
161        }
162
163        return self::isSameString( $maybeDisplayTitle, $titleText )
164            ? null
165            : $maybeDisplayTitle;
166    }
167
168    private static function isSameString( string $a, string $b ): bool {
169        $a = mb_strtolower( strtr( $a, '_', ' ' ) );
170        $b = mb_strtolower( strtr( $b, '_', ' ' ) );
171        return $a === $b;
172    }
173
174    /**
175     * Find invalid UTF-8 sequence in the source text.
176     * Fix them and flag the doc with the CirrusSearchInvalidUTF8 template.
177     *
178     * Temporary solution to help investigate/fix T225200
179     *
180     * Visible for testing only
181     * @param array $fieldDefinitions
182     * @param int $pageId
183     * @return array
184     */
185    public static function fixAndFlagInvalidUTF8InSource( array $fieldDefinitions, int $pageId ): array {
186        if ( isset( $fieldDefinitions['source_text'] ) ) {
187            $fixedVersion = mb_convert_encoding( $fieldDefinitions['source_text'], 'UTF-8', 'UTF-8' );
188            if ( $fixedVersion !== $fieldDefinitions['source_text'] ) {
189                LoggerFactory::getInstance( 'CirrusSearch' )
190                    ->warning( 'Fixing invalid UTF-8 sequences in source text for page id {page_id}',
191                        [ 'page_id' => $pageId ] );
192                $fieldDefinitions['source_text'] = $fixedVersion;
193                $fieldDefinitions['template'][] = Title::makeTitle( NS_TEMPLATE, 'CirrusSearchInvalidUTF8' )->getPrefixedText();
194            }
195        }
196        return $fieldDefinitions;
197    }
198
199    /**
200     * Visible for testing only
201     * @param int $maxLen
202     * @param array $fieldContent
203     * @return array
204     */
205    public static function truncateFileTextContent( int $maxLen, array $fieldContent ): array {
206        if ( $maxLen >= 0 && isset( $fieldContent['file_text'] ) && strlen( $fieldContent['file_text'] ) > $maxLen ) {
207            $fieldContent['file_text'] = mb_strcut( $fieldContent['file_text'], 0, $maxLen );
208        }
209
210        return $fieldContent;
211    }
212
213    /**
214     * @param array $fieldContent
215     * @return array
216     */
217    private function truncateFileContent( array $fieldContent ): array {
218        return self::truncateFileTextContent( $this->config->get( 'CirrusSearchMaxFileTextLength' ) ?: -1, $fieldContent );
219    }
220}