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