Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
5.04% covered (danger)
5.04%
7 / 139
7.14% covered (danger)
7.14%
1 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
Hooks
5.04% covered (danger)
5.04%
7 / 139
7.14% covered (danger)
7.14%
1 / 14
2839.45
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onParserFirstCallInit
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 onArticleDeleteComplete
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 onLinksUpdateComplete
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
30
 getCoordinatesIfFile
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
72
 doLinksUpdate
0.00% covered (danger)
0.00%
0 / 39
0.00% covered (danger)
0.00%
0 / 1
210
 onFileUpload
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 onOutputPageParserOutput
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
56
 onSearchIndexFields
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 onSearchDataForIndex
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 onSearchDataForIndex2
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 doSearchDataForIndex
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
56
 coordToElastic
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 createQueryGeoSearchBackend
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3namespace GeoData;
4
5use ApiQuery;
6use CirrusSearch\CirrusSearch;
7use Content;
8use ContentHandler;
9use File;
10use GeoData\Api\QueryGeoSearch;
11use GeoData\Api\QueryGeoSearchDb;
12use GeoData\Api\QueryGeoSearchElastic;
13use GeoData\Search\CoordinatesIndexField;
14use ManualLogEntry;
15use MediaWiki\Config\Config;
16use MediaWiki\Content\Hook\SearchDataForIndexHook;
17use MediaWiki\Deferred\DeferredUpdates;
18use MediaWiki\Deferred\LinksUpdate\LinksUpdate;
19use MediaWiki\Hook\FileUploadHook;
20use MediaWiki\Hook\LinksUpdateCompleteHook;
21use MediaWiki\Hook\ParserFirstCallInitHook;
22use MediaWiki\Linker\LinkTarget;
23use MediaWiki\MainConfigNames;
24use MediaWiki\MediaWikiServices;
25use MediaWiki\Output\Hook\OutputPageParserOutputHook;
26use MediaWiki\Page\Hook\ArticleDeleteCompleteHook;
27use MediaWiki\Parser\Parser;
28use MediaWiki\Parser\ParserOutput;
29use MediaWiki\Revision\RevisionRecord;
30use MediaWiki\User\User;
31use SearchEngine;
32use WikiPage;
33
34/**
35 * Hook handlers
36 * @todo: tests
37 */
38class Hooks implements
39    SearchDataForIndexHook,
40    OutputPageParserOutputHook,
41    ParserFirstCallInitHook,
42    ArticleDeleteCompleteHook,
43    LinksUpdateCompleteHook,
44    FileUploadHook
45{
46
47    private Config $config;
48
49    public function __construct( Config $config ) {
50        $this->config = $config;
51    }
52
53    /**
54     * ParserFirstCallInit hook handler
55     * @see https://www.mediawiki.org/wiki/Manual:Hooks/ParserFirstCallInit
56     *
57     * @param Parser $parser
58     */
59    public function onParserFirstCallInit( $parser ) {
60        $parser->setFunctionHook( 'coordinates',
61            [ new CoordinatesParserFunction(), 'coordinates' ],
62            Parser::SFH_OBJECT_ARGS
63        );
64    }
65
66    /**
67     * ArticleDeleteComplete hook handler
68     * @see https://www.mediawiki.org/wiki/Manual:Hooks/ArticleDeleteComplete
69     *
70     * @param WikiPage $article
71     * @param User $user
72     * @param string $reason
73     * @param int $id
74     * @param Content|null $content
75     * @param ManualLogEntry $logEntry
76     * @param int $archivedRevisionCount
77     */
78    public function onArticleDeleteComplete( $article, $user, $reason, $id,
79        $content, $logEntry, $archivedRevisionCount
80    ) {
81        $dbw = MediaWikiServices::getInstance()->getConnectionProvider()->getPrimaryDatabase();
82        $dbw->newDeleteQueryBuilder()
83            ->deleteFrom( 'geo_tags' )
84            ->where( [ 'gt_page_id' => $id ] )
85            ->caller( __METHOD__ )
86            ->execute();
87    }
88
89    /**
90     * LinksUpdateComplete hook handler
91     * @see https://www.mediawiki.org/wiki/Manual:Hooks/LinksUpdateComplete
92     *
93     * @param LinksUpdate $linksUpdate
94     * @param int|null $ticket
95     */
96    public function onLinksUpdateComplete( $linksUpdate, $ticket ) {
97        $out = $linksUpdate->getParserOutput();
98        $data = [];
99        $coordFromMetadata = self::getCoordinatesIfFile( $linksUpdate->getTitle() );
100        $coordsOutput = CoordinatesOutput::getFromParserOutput( $out );
101        if ( $coordsOutput ) {
102            // Use coordinates from file metadata unless overridden on description page
103            if ( $coordFromMetadata && !$coordsOutput->hasPrimary() ) {
104                $coordsOutput->addPrimary( $coordFromMetadata );
105            }
106            $data = $coordsOutput->getAll();
107        } elseif ( $coordFromMetadata ) {
108            $data[] = $coordFromMetadata;
109        }
110        self::doLinksUpdate( $data, $linksUpdate->getPageId(), $ticket );
111    }
112
113    private static function getCoordinatesIfFile( LinkTarget $title ): ?Coord {
114        if ( !$title->inNamespace( NS_FILE ) ) {
115            return null;
116        }
117        $file = MediaWikiServices::getInstance()->getRepoGroup()->getLocalRepo()
118            ->findFile( $title, [ 'ignoreRedirect' => true ] );
119        if ( !$file ) {
120            return null;
121        }
122        $metadata = $file->getMetadataItems( [ 'GPSLatitude', 'GPSLongitude' ] );
123        if ( isset( $metadata['GPSLatitude'] ) && isset( $metadata['GPSLongitude'] ) ) {
124            $lat = $metadata['GPSLatitude'];
125            $lon = $metadata['GPSLongitude'];
126            $globe = new Globe();
127            // T165800: Skip files with meaningless 0, 0 coordinates
128            if ( ( $lat || $lon ) &&
129                $globe->coordinatesAreValid( $lat, $lon )
130            ) {
131                $coord = new Coord( $lat, $lon, $globe->getName() );
132                $coord->primary = true;
133                return $coord;
134            }
135        }
136        return null;
137    }
138
139    /**
140     * @param Coord[] $coords
141     * @param int $pageId
142     * @param int|null $ticket
143     * @throws \Wikimedia\Rdbms\DBUnexpectedError
144     */
145    private static function doLinksUpdate( array $coords, $pageId, $ticket ) {
146        $services = MediaWikiServices::getInstance();
147        $config = $services->getMainConfig();
148        $indexGranularity = $config->get( 'GeoDataBackend' ) === 'db' ?
149            $config->get( 'GeoDataIndexGranularity' ) : null;
150
151        $add = [];
152        $delete = [];
153        $primary = ( isset( $coords[0] ) && $coords[0]->primary ) ? $coords[0] : null;
154        foreach ( GeoData::getAllCoordinates( $pageId, [], DB_PRIMARY ) as $old ) {
155            $delete[$old->id] = $old;
156        }
157        foreach ( $coords as $new ) {
158            if ( !$new->primary && $new->equalsTo( $primary ) ) {
159                // Don't save secondary coordinates pointing to the same place as the primary one
160                continue;
161            }
162            $match = false;
163            foreach ( $delete as $id => $old ) {
164                if ( $new->fullyEqualsTo( $old ) ) {
165                    unset( $delete[$id] );
166                    $match = true;
167                    break;
168                }
169            }
170            if ( !$match ) {
171                $add[] = $new->getRow( $pageId, $indexGranularity );
172            }
173        }
174
175        $dbw = $services->getConnectionProvider()->getPrimaryDatabase();
176        $lbFactory = $services->getDBLoadBalancerFactory();
177        $ticket = $ticket ?: $lbFactory->getEmptyTransactionTicket( __METHOD__ );
178        $batchSize = $config->get( MainConfigNames::UpdateRowsPerQuery );
179
180        $deleteIds = array_keys( $delete );
181        foreach ( array_chunk( $deleteIds, $batchSize ) as $deleteIdBatch ) {
182            $dbw->newDeleteQueryBuilder()
183                ->deleteFrom( 'geo_tags' )
184                ->where( [ 'gt_id' => $deleteIdBatch ] )
185                ->caller( __METHOD__ )
186                ->execute();
187            $lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
188        }
189
190        foreach ( array_chunk( $add, $batchSize ) as $addBatch ) {
191            $dbw->newInsertQueryBuilder()
192                ->insertInto( 'geo_tags' )
193                ->rows( $addBatch )
194                ->caller( __METHOD__ )
195                ->execute();
196            $lbFactory->commitAndWaitForReplication( __METHOD__, $ticket );
197        }
198    }
199
200    /**
201     * FileUpload hook handler
202     * @see https://www.mediawiki.org/wiki/Manual:Hooks/FileUpload
203     *
204     * @param File $file
205     * @param bool $reupload
206     * @param bool $hasDescription
207     */
208    public function onFileUpload( $file, $reupload, $hasDescription ) {
209        $wp = MediaWikiServices::getInstance()->getWikiPageFactory()->newFromTitle( $file->getTitle() );
210        $po = $wp->makeParserOptions( 'canonical' );
211        $pout = $wp->getParserOutput( $po );
212        if ( !$pout ) {
213            wfDebugLog( 'mobile',
214                __METHOD__ . "(): no parser output returned for file {$file->getName()}"
215            );
216        } else {
217            // Make sure this has outer transaction scope (though the hook fires
218            // in a deferred AutoCommitUdpate update, so it should be safe anyway).
219            $lu = new LinksUpdate( $file->getTitle(), $pout );
220            DeferredUpdates::addCallableUpdate( function () use ( $lu ) {
221                $this->onLinksUpdateComplete( $lu, null );
222            } );
223        }
224    }
225
226    /**
227     * @inheritDoc
228     */
229    public function onOutputPageParserOutput( $out, $po ): void {
230        $geoDataInJS = $this->config->get( 'GeoDataInJS' );
231
232        if ( $geoDataInJS && CoordinatesOutput::getFromParserOutput( $po ) ) {
233            $coord = CoordinatesOutput::getFromParserOutput( $po )->getPrimary();
234            if ( !$coord ) {
235                return;
236            }
237            $result = [];
238            foreach ( $geoDataInJS as $param ) {
239                if ( isset( $coord->$param ) ) {
240                    $result[$param] = $coord->$param;
241                }
242            }
243            if ( $result ) {
244                $out->addJsConfigVars( 'wgCoordinates', $result );
245            }
246        }
247    }
248
249    /**
250     * Search index fields hook handler
251     * Adds our stuff to CirrusSearch/Elasticsearch schema
252     *
253     * @param array &$fields
254     * @param SearchEngine $engine
255     */
256    public function onSearchIndexFields( array &$fields, SearchEngine $engine ) {
257        $useCirrus = $this->config->get( 'GeoDataUseCirrusSearch' );
258        $backend = $this->config->get( 'GeoDataBackend' );
259        if ( !$useCirrus && $backend !== 'elastic' ) {
260            return;
261        }
262        if ( $engine instanceof CirrusSearch ) {
263            /**
264             * @var CirrusSearch $engine
265             */
266            $fields['coordinates'] = CoordinatesIndexField::build(
267                'coordinates', $engine->getConfig(), $engine );
268        } else {
269            // Unsupported SearchEngine or explicitly disabled by config
270        }
271    }
272
273    /**
274     * @inheritDoc
275     */
276    public function onSearchDataForIndex(
277        &$fields,
278        $handler,
279        $page,
280        $output,
281        $engine
282    ) {
283        self::doSearchDataForIndex( $fields, $output, $page );
284    }
285
286    /**
287     * SearchDataForIndex hook handler
288     *
289     * @param array &$fields
290     * @param ContentHandler $handler
291     * @param WikiPage $page
292     * @param ParserOutput $output
293     * @param SearchEngine $engine
294     * @param RevisionRecord $revision
295     */
296    public function onSearchDataForIndex2(
297        array &$fields,
298        ContentHandler $handler,
299        WikiPage $page,
300        ParserOutput $output,
301        SearchEngine $engine,
302        RevisionRecord $revision
303    ) {
304        self::doSearchDataForIndex( $fields, $output, $page );
305    }
306
307    /**
308     * Attach coordinates to the index document
309     *
310     * @param array &$fields
311     * @param ParserOutput $parserOutput
312     * @param WikiPage $page
313     * @return void
314     */
315    private function doSearchDataForIndex( array &$fields, ParserOutput $parserOutput, WikiPage $page ): void {
316        $useCirrus = $this->config->get( 'GeoDataUseCirrusSearch' );
317        $backend = $this->config->get( 'GeoDataBackend' );
318
319        if ( ( $useCirrus || $backend == 'elastic' ) ) {
320            $coordsOutput = CoordinatesOutput::getFromParserOutput( $parserOutput );
321            $allCoords = $coordsOutput !== null ? $coordsOutput->getAll() : [];
322            $coords = [];
323
324            /** @var Coord $coord */
325            foreach ( $allCoords as $coord ) {
326                if ( $coord->globe !== Globe::EARTH ) {
327                    continue;
328                }
329                if ( !$coord->isValid() ) {
330                    wfDebugLog( 'CirrusSearchChangeFailed',
331                        "Invalid coordinates [{$coord->lat}{$coord->lon}] on page "
332                            . $page->getTitle()->getPrefixedText()
333                    );
334                    continue;
335                }
336                $coords[] = self::coordToElastic( $coord );
337            }
338            $fields['coordinates'] = $coords;
339        }
340    }
341
342    /**
343     * Transforms coordinates into an array for insertion onto Elasticsearch
344     *
345     * @param Coord $coord
346     * @return array
347     */
348    public static function coordToElastic( Coord $coord ) {
349        $result = $coord->getAsArray();
350        $result['coord'] = [ 'lat' => $coord->lat, 'lon' => $coord->lon ];
351        unset( $result['id'] );
352        unset( $result['lat'] );
353        unset( $result['lon'] );
354
355        return $result;
356    }
357
358    /**
359     * @param ApiQuery $query
360     * @param string $moduleName
361     * @return QueryGeoSearch
362     */
363    public static function createQueryGeoSearchBackend( ApiQuery $query, $moduleName ): QueryGeoSearch {
364        $geoDataBackend = $query->getConfig()->get( 'GeoDataBackend' );
365
366        switch ( strtolower( $geoDataBackend ) ) {
367            case 'db':
368                return new QueryGeoSearchDb( $query, $moduleName );
369            case 'elastic':
370                return new QueryGeoSearchElastic( $query, $moduleName );
371            default:
372                throw new \RuntimeException( 'GeoDataBackend data backend cannot be empty' );
373        }
374    }
375}