1<?php
2
3/*
4 * Copyright (c) 2011-2023 Mark C. Prins <mprins@users.sf.net>
5 *
6 * Permission to use, copy, modify, and distribute this software for any
7 * purpose with or without fee is hereby granted, provided that the above
8 * copyright notice and this permission notice appear in all copies.
9 *
10 * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
11 * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
12 * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
13 * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
14 * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
15 * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
16 * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
17 */
18
19use dokuwiki\Extension\Plugin;
20use dokuwiki\Logger;
21use geoPHP\Geometry\Point;
22
23/**
24 * DokuWiki Plugin spatialhelper (index component).
25 *
26 * @license BSD license
27 * @author  Mark Prins
28 */
29class helper_plugin_spatialhelper_index extends Plugin
30{
31    /**
32     * directory for index files.
33     *
34     * @var string
35     */
36    protected string $idx_dir = '';
37
38    /**
39     * spatial index, we'll look up list/array so we can do spatial queries.
40     * entries should be: array("geohash" => {"id1","id3",})
41     *
42     * @var array
43     */
44    protected array $spatial_idx = [];
45
46    /**
47     * Constructor, initialises the spatial index.
48     * @throws Exception
49     */
50    public function __construct()
51    {
52        if (plugin_load('helper', 'geophp') === null) {
53            $message = 'Required geophp plugin is not available';
54            msg($message, -1);
55        }
56
57        global $conf;
58        $this->idx_dir = $conf ['indexdir'];
59        // test if there is a spatialindex, if not build one for the wiki
60        if (!@file_exists($this->idx_dir . '/spatial.idx')) {
61            // creates and stores the index
62            $this->generateSpatialIndex();
63        } else {
64            $this->spatial_idx = unserialize(
65                io_readFile($this->idx_dir . '/spatial.idx', false),
66                ['allowed_classes' => false]
67            );
68            Logger::debug('done loading spatial index', $this->spatial_idx);
69        }
70    }
71
72    /**
73     * (re-)Generates the spatial index by running through all the pages in the wiki.
74     *
75     * @throws Exception
76     * @todo add an option to erase the old index
77     */
78    final public function generateSpatialIndex(): bool
79    {
80        global $conf;
81        require_once(DOKU_INC . 'inc/search.php');
82        $pages = [];
83        search($pages, $conf ['datadir'], 'search_allpages', []);
84        foreach ($pages as $page) {
85            $this->updateSpatialIndex($page ['id']);
86        }
87        // media
88        $media = [];
89        search($media, $conf ['mediadir'], 'search_media', []);
90        foreach ($media as $medium) {
91            if ($medium ['isimg']) {
92                $this->indexImage($medium);
93            }
94        }
95        return true;
96    }
97
98    /**
99     * Update the spatial index for the page.
100     *
101     * @param string $id
102     *          the document ID
103     * @throws Exception
104     */
105    final public function updateSpatialIndex(string $id): bool
106    {
107        $geotags = p_get_metadata($id, 'geo');
108        if (empty($geotags)) {
109            return false;
110        }
111        if (empty($geotags ['lon']) || empty($geotags ['lat'])) {
112            return false;
113        }
114        Logger::debug("Geo metadata found for page $id", $geotags);
115        $geometry = new Point($geotags ['lon'], $geotags ['lat']);
116        $geohash = $geometry->out('geohash');
117        Logger::debug('Update index for geohash: ' . $geohash);
118        return $this->addToIndex($geohash, $id);
119    }
120
121    /**
122     * Store the hash/id entry in the index.
123     *
124     * @param string $geohash
125     * @param string $id
126     *          page or media id
127     * @return bool true when succesful
128     */
129    private function addToIndex(string $geohash, string $id): bool
130    {
131        $pageIds = [];
132        // check index for key/geohash
133        if (!array_key_exists($geohash, $this->spatial_idx)) {
134            Logger::debug("Geohash $geohash not in index, just add $id.");
135            $pageIds [] = $id;
136        } else {
137            Logger::debug('Geohash for document is in index, find it.');
138            // check the index for document
139            $knownHashes = $this->findHashesForId($id, $this->spatial_idx);
140            if ($knownHashes === []) {
141                Logger::debug("No index record found for document $id, adding it to the index.");
142                $pageIds = $this->spatial_idx [$geohash];
143                $pageIds [] = $id;
144            }
145            // TODO shortcut, need to make sure there is only one element, if not the index is corrupt
146            $knownHash = $knownHashes [0];
147
148            if ($knownHash === $geohash) {
149                Logger::debug("Document $id was found in index and has the same geohash, nothing to do.");
150                return true;
151            }
152
153            if (!empty($knownHash)) {
154                Logger::debug("Document/media $id was found in index but has different geohash (it moved).");
155                $knownIds = $this->spatial_idx [$knownHash];
156                Logger::debug("Known id's for this hash:", $knownIds);
157                // remove it from the old geohash element
158                $i = array_search($id, $knownIds);
159                Logger::debug('Unsetting:' . $knownIds [$i]);
160                unset($knownIds [$i]);
161                $this->spatial_idx [$knownHash] = $knownIds;
162                // set on new geohash element
163                $pageIds = $this->spatial_idx[$geohash];
164                $pageIds [] = $id;
165            }
166        }
167        // store and save
168        $this->spatial_idx [$geohash] = $pageIds;
169        return $this->saveIndex();
170    }
171
172    /**
173     * Looks up the geohash(es) for the document in the index.
174     *
175     * @param String $id
176     *          document ID
177     * @param array $index
178     *          spatial index
179     */
180    final public function findHashesForId(string $id, array $index): array
181    {
182        $hashes = [];
183        foreach ($index as $hash => $docIds) {
184            if (in_array($id, $docIds)) {
185                $hashes [] = $hash;
186            }
187        }
188        Logger::debug("Found the following hashes for $id (should only be 1)", $hashes);
189        return $hashes;
190    }
191
192    /**
193     * Save spatial index.
194     */
195    private function saveIndex(): bool
196    {
197        return io_saveFile($this->idx_dir . '/spatial.idx', serialize($this->spatial_idx));
198    }
199
200    /**
201     * Add an index entry for this file having EXIF / IPTC data.
202     *
203     * @param $img
204     *          a Dokuwiki image
205     * @return bool true when image was succesfully added to the index.
206     * @throws Exception
207     * @see http://www.php.net/manual/en/function.iptcparse.php
208     * @see http://php.net/manual/en/function.exif-read-data.php
209     *
210     */
211    final  public function indexImage(array $img): bool
212    {
213        // test for supported files (jpeg only)
214        if (
215            (!str_ends_with($img ['file'], '.jpg')) &&
216            (!str_ends_with($img ['file'], '.jpeg'))
217        ) {
218            return false;
219        }
220
221        $geometry = $this->getCoordsFromExif($img ['id']);
222        if (!$geometry) {
223            return false;
224        }
225        $geohash = $geometry->out('geohash');
226        // TODO truncate the geohash to something reasonable, otherwise they are
227        // useless as an indexing mechanism eg. u1h73weckdrmskdqec3c9 is far too
228        // precise, limit at ~9 as most GPS are not submeter accurate
229        return $this->addToIndex($geohash, 'media__' . $img ['id']);
230    }
231
232    /**
233     * retrieve GPS decimal coordinates from exif.
234     *
235     * @param string $id
236     * @return Point|false
237     * @throws Exception
238     */
239    final public function getCoordsFromExif(string $id): Point|false
240    {
241        $exif = exif_read_data(mediaFN($id), 0, true);
242        if (empty($exif ['GPS'])) {
243            return false;
244        }
245
246        $lat = $this->convertDMStoD(
247            [
248                $exif ['GPS'] ['GPSLatitude'] [0],
249                $exif ['GPS'] ['GPSLatitude'] [1],
250                $exif ['GPS'] ['GPSLatitude'] [2],
251                $exif ['GPS'] ['GPSLatitudeRef']
252            ]
253        );
254
255        $lon = $this->convertDMStoD(
256            [
257                $exif ['GPS'] ['GPSLongitude'] [0],
258                $exif ['GPS'] ['GPSLongitude'] [1],
259                $exif ['GPS'] ['GPSLongitude'] [2],
260                $exif ['GPS'] ['GPSLongitudeRef']
261            ]
262        );
263
264        return new Point($lon, $lat);
265    }
266
267    /**
268     * convert DegreesMinutesSeconds to Decimal degrees.
269     *
270     * @param array $param array of rational DMS
271     */
272    final  public function convertDMStoD(array $param): float
273    {
274        //        if (!(is_array($param))) {
275        //            $param = [$param];
276        //        }
277        $deg = $this->convertRationaltoFloat($param [0]);
278        $min = $this->convertRationaltoFloat($param [1]) / 60;
279        $sec = $this->convertRationaltoFloat($param [2]) / 60 / 60;
280        // Hemisphere (N, S, W or E)
281        $hem = ($param [3] === 'N' || $param [3] === 'E') ? 1 : -1;
282
283        return $hem * ($deg + $min + $sec);
284    }
285
286    final public function convertRationaltoFloat(string $param): float
287    {
288        // rational64u
289        $nums = explode('/', $param);
290        if ((int)$nums[1] > 0) {
291            return (float)$nums[0] / (int)$nums[1];
292        }
293
294        return (float)$nums[0];
295    }
296
297    /**
298     * Deletes the page from the index.
299     *
300     * @param string $id document ID
301     */
302    final public function deleteFromIndex(string $id): void
303    {
304        // check the index for document
305        $knownHashes = $this->findHashesForId($id, $this->spatial_idx);
306        if ($knownHashes === []) {
307            return;
308        }
309
310        // TODO shortcut, need to make sure there is only one element, if not the index is corrupt
311        $knownHash = $knownHashes [0];
312        $knownIds = $this->spatial_idx [$knownHash];
313        $i = array_search($id, $knownIds);
314        Logger::debug("removing: $knownIds[$i] from the index.");
315        unset($knownIds [$i]);
316        $this->spatial_idx [$knownHash] = $knownIds;
317        if (empty($this->spatial_idx [$knownHash])) {
318            unset($this->spatial_idx [$knownHash]);
319        }
320        $this->saveIndex();
321    }
322}
323