Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 128
0.00% covered (danger)
0.00%
0 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
Hooks
0.00% covered (danger)
0.00%
0 / 128
0.00% covered (danger)
0.00%
0 / 13
2550
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 / 16
0.00% covered (danger)
0.00%
0 / 1
42
 doLinksUpdate
0.00% covered (danger)
0.00%
0 / 36
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 / 12
0.00% covered (danger)
0.00%
0 / 1
42
 onSearchIndexFields
0.00% covered (danger)
0.00%
0 / 6
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
1<?php
2
3namespace GeoData;
4
5use CirrusSearch\CirrusSearch;
6use GeoData\Search\CoordinatesIndexField;
7use MediaWiki\Config\Config;
8use MediaWiki\Content\Content;
9use MediaWiki\Content\ContentHandler;
10use MediaWiki\Content\Hook\SearchDataForIndexHook;
11use MediaWiki\Deferred\DeferredUpdates;
12use MediaWiki\Deferred\Hook\LinksUpdateCompleteHook;
13use MediaWiki\Deferred\LinksUpdate\LinksUpdate;
14use MediaWiki\FileRepo\File\File;
15use MediaWiki\FileRepo\Hook\FileUploadHook;
16use MediaWiki\FileRepo\RepoGroup;
17use MediaWiki\Linker\LinkTarget;
18use MediaWiki\Logging\ManualLogEntry;
19use MediaWiki\MainConfigNames;
20use MediaWiki\Output\Hook\OutputPageParserOutputHook;
21use MediaWiki\Page\Hook\ArticleDeleteCompleteHook;
22use MediaWiki\Page\WikiPage;
23use MediaWiki\Page\WikiPageFactory;
24use MediaWiki\Parser\Hook\ParserFirstCallInitHook;
25use MediaWiki\Parser\Parser;
26use MediaWiki\Parser\ParserOutput;
27use MediaWiki\Revision\RevisionRecord;
28use MediaWiki\Search\SearchEngine;
29use MediaWiki\User\User;
30use Wikimedia\Rdbms\IConnectionProvider;
31use Wikimedia\Rdbms\LBFactory;
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    public function __construct(
47        private readonly Config $config,
48        private readonly IConnectionProvider $connectionProvider,
49        private readonly LBFactory $lbFactory,
50        private readonly RepoGroup $repoGroup,
51        private readonly WikiPageFactory $wikiPageFactory,
52    ) {
53    }
54
55    /**
56     * ParserFirstCallInit hook handler
57     * @see https://www.mediawiki.org/wiki/Manual:Hooks/ParserFirstCallInit
58     *
59     * @param Parser $parser
60     */
61    public function onParserFirstCallInit( $parser ) {
62        $parser->setFunctionHook( 'coordinates',
63            ( new CoordinatesParserFunction( $this->config ) )->coordinates( ... ),
64            Parser::SFH_OBJECT_ARGS
65        );
66    }
67
68    /**
69     * ArticleDeleteComplete hook handler
70     *
71     * @see https://www.mediawiki.org/wiki/Manual:Hooks/ArticleDeleteComplete
72     *
73     * @param WikiPage $wikiPage
74     * @param User $user
75     * @param string $reason
76     * @param int $id
77     * @param Content|null $content
78     * @param ManualLogEntry $logEntry
79     * @param int $archivedRevisionCount
80     */
81    public function onArticleDeleteComplete(
82        $wikiPage, $user, $reason, $id,
83        $content, $logEntry, $archivedRevisionCount
84    ) {
85        $dbw = $this->connectionProvider->getPrimaryDatabase();
86        $dbw->newDeleteQueryBuilder()
87            ->deleteFrom( 'geo_tags' )
88            ->where( [ 'gt_page_id' => $id ] )
89            ->caller( __METHOD__ )
90            ->execute();
91    }
92
93    /**
94     * LinksUpdateComplete hook handler
95     * @see https://www.mediawiki.org/wiki/Manual:Hooks/LinksUpdateComplete
96     *
97     * @param LinksUpdate $linksUpdate
98     * @param int|null $ticket
99     */
100    public function onLinksUpdateComplete( $linksUpdate, $ticket ) {
101        $out = $linksUpdate->getParserOutput();
102        $data = [];
103        $coordFromMetadata = $this->getCoordinatesIfFile( $linksUpdate->getTitle() );
104        $coordsOutput = CoordinatesOutput::getFromParserOutput( $out );
105        if ( $coordsOutput ) {
106            // Use coordinates from file metadata unless overridden on description page
107            if ( $coordFromMetadata && !$coordsOutput->hasPrimary() ) {
108                $coordsOutput->addPrimary( $coordFromMetadata );
109            }
110            $data = $coordsOutput->getAll();
111        } elseif ( $coordFromMetadata ) {
112            $data[] = $coordFromMetadata;
113        }
114        $this->doLinksUpdate( $data, $linksUpdate->getPageId(), $ticket );
115    }
116
117    private function getCoordinatesIfFile( LinkTarget $title ): ?Coord {
118        if ( !$title->inNamespace( NS_FILE ) ) {
119            return null;
120        }
121        $file = $this->repoGroup->getLocalRepo()
122            ->findFile( $title, [ 'ignoreRedirect' => true ] );
123        if ( !$file ) {
124            return null;
125        }
126        $metadata = $file->getMetadataItems( [ 'GPSLatitude', 'GPSLongitude' ] );
127        // T165800: Skip files with meaningless 0, 0 coordinates
128        if ( !empty( $metadata['GPSLatitude'] ) || !empty( $metadata['GPSLongitude'] ) ) {
129            $lat = $metadata['GPSLatitude'] ?? 0;
130            $lon = $metadata['GPSLongitude'] ?? 0;
131            // We assume GPS exist only on Earth
132            $globe = new Globe( Globe::EARTH );
133            if ( $globe->coordinatesAreValid( $lat, $lon ) ) {
134                $coord = new Coord( $lat, $lon, $globe );
135                $coord->primary = true;
136                return $coord;
137            }
138        }
139        return null;
140    }
141
142    /**
143     * @param Coord[] $coords
144     * @param int $pageId
145     * @param int|null $ticket
146     * @throws \Wikimedia\Rdbms\DBUnexpectedError
147     */
148    private function doLinksUpdate( array $coords, $pageId, $ticket ) {
149        $indexGranularity = $this->config->get( 'GeoDataBackend' ) === 'db' ?
150            $this->config->get( 'GeoDataIndexGranularity' ) : null;
151
152        $add = [];
153        $delete = [];
154        $primary = isset( $coords[0] ) && $coords[0]->primary ? $coords[0] : null;
155        foreach ( GeoData::getAllCoordinates( $pageId, [], DB_PRIMARY ) as $old ) {
156            $delete[$old->id] = $old;
157        }
158        foreach ( $coords as $new ) {
159            if ( !$new->primary && $new->equalsTo( $primary ) ) {
160                // Don't save secondary coordinates pointing to the same place as the primary one
161                continue;
162            }
163            $match = false;
164            foreach ( $delete as $id => $old ) {
165                if ( $new->fullyEqualsTo( $old ) ) {
166                    unset( $delete[$id] );
167                    $match = true;
168                    break;
169                }
170            }
171            if ( !$match ) {
172                $add[] = $new->getRow( $pageId, $indexGranularity );
173            }
174        }
175
176        $dbw = $this->connectionProvider->getPrimaryDatabase();
177        $ticket = $ticket ?: $this->lbFactory->getEmptyTransactionTicket( __METHOD__ );
178        $batchSize = $this->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            $this->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            $this->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 = $this->wikiPageFactory->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( $outputPage, $parserOutput ): void {
230        $geoDataInJS = $this->config->get( 'GeoDataInJS' );
231        if ( !$geoDataInJS ) {
232            return;
233        }
234
235        $coord = CoordinatesOutput::getFromParserOutput( $parserOutput )?->getPrimary();
236        if ( !$coord ) {
237            return;
238        }
239
240        $result = [];
241        foreach ( $geoDataInJS as $param ) {
242            if ( isset( $coord->$param ) ) {
243                $result[$param] = $coord->$param;
244            }
245        }
246        if ( $result ) {
247            $outputPage->addJsConfigVars( 'wgCoordinates', $result );
248        }
249    }
250
251    /**
252     * Search index fields hook handler
253     * Adds our stuff to CirrusSearch/Elasticsearch schema
254     *
255     * @param array &$fields
256     * @param SearchEngine $engine
257     */
258    public function onSearchIndexFields( array &$fields, SearchEngine $engine ) {
259        if ( !$this->config->get( 'GeoDataUseCirrusSearch' ) &&
260            $this->config->get( 'GeoDataBackend' ) !== 'elastic'
261        ) {
262            return;
263        }
264
265        if ( $engine instanceof CirrusSearch ) {
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        if ( !$this->config->get( 'GeoDataUseCirrusSearch' ) &&
317            $this->config->get( 'GeoDataBackend' ) !== 'elastic'
318        ) {
319            return;
320        }
321
322        $coordsOutput = CoordinatesOutput::getFromParserOutput( $parserOutput );
323        $allCoords = $coordsOutput ? $coordsOutput->getAll() : [];
324        $coords = [];
325
326        foreach ( $allCoords as $coord ) {
327            if ( !$coord->sameGlobe( Globe::EARTH ) ) {
328                continue;
329            }
330            if ( !$coord->isValid() ) {
331                wfDebugLog( 'CirrusSearchChangeFailed',
332                    "Invalid coordinates [{$coord->lat}{$coord->lon}] on page "
333                    . $page->getTitle()->getPrefixedText()
334                );
335                continue;
336            }
337            $coords[] = $this->coordToElastic( $coord );
338        }
339        $fields['coordinates'] = $coords;
340    }
341
342    /**
343     * Transforms coordinates into an array for insertion onto Elasticsearch
344     */
345    private function coordToElastic( Coord $coord ): array {
346        $result = $coord->getAsArray();
347        $result['coord'] = [ 'lat' => $coord->lat, 'lon' => $coord->lon ];
348        unset( $result['id'] );
349        unset( $result['lat'] );
350        unset( $result['lon'] );
351
352        return $result;
353    }
354
355}