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