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