1<?php
2
3/*
4 * Copyright (c) 2011-2024 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['id']);
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     * @param bool $verbose
104     *         if true, echo debug info
105     * @throws Exception
106     */
107    final public function updateSpatialIndex(string $id, bool $verbose = false): bool
108    {
109        $geotags = p_get_metadata($id, 'geo');
110        if (empty($geotags)) {
111            if ($verbose) echo "No geo metadata found for page $id" . DOKU_LF;
112            return false;
113        }
114        if (empty($geotags ['lon']) || empty($geotags ['lat'])) {
115            if ($verbose) echo "No valid geo metadata found for page $id" . DOKU_LF;
116            return false;
117        }
118        Logger::debug("Geo metadata found for page $id", $geotags);
119        $geometry = new Point($geotags ['lon'], $geotags ['lat']);
120        $geohash = $geometry->out('geohash');
121        Logger::debug('Update index for geohash: ' . $geohash);
122        return $this->addToIndex($geohash, $id);
123    }
124
125    /**
126     * Store the hash/id entry in the index.
127     *
128     * @param string $geohash
129     * @param string $id
130     *          page or media id
131     * @return bool true when succesful
132     */
133    private function addToIndex(string $geohash, string $id): bool
134    {
135        $pageIds = [];
136        // check index for key/geohash
137        if (!array_key_exists($geohash, $this->spatial_idx)) {
138            Logger::debug("Geohash $geohash not in index, just add $id.");
139            $pageIds [] = $id;
140        } else {
141            Logger::debug('Geohash for document is in index, find it.');
142            // check the index for document
143            $knownHashes = $this->findHashesForId($id, $this->spatial_idx);
144            if ($knownHashes === []) {
145                Logger::debug("No index record found for document $id, adding it to the index.");
146                $pageIds = $this->spatial_idx [$geohash];
147                $pageIds [] = $id;
148            }
149            // TODO shortcut, need to make sure there is only one element, if not the index is corrupt
150            $knownHash = $knownHashes [0] ?? '';
151
152            if ($knownHash === $geohash) {
153                Logger::debug("Document $id was found in index and has the same geohash, nothing to do.");
154                return true;
155            }
156
157            if (!empty($knownHash)) {
158                Logger::debug("Document/media $id was found in index but has different geohash (it moved).");
159                $knownIds = $this->spatial_idx [$knownHash];
160                Logger::debug("Known id's for this hash:", $knownIds);
161                // remove it from the old geohash element
162                $i = array_search($id, $knownIds);
163                Logger::debug('Unsetting:' . $knownIds [$i]);
164                unset($knownIds [$i]);
165                $this->spatial_idx [$knownHash] = $knownIds;
166                // set on new geohash element
167                $pageIds = $this->spatial_idx[$geohash];
168                $pageIds [] = $id;
169            }
170        }
171        // store and save
172        $this->spatial_idx [$geohash] = $pageIds;
173        return $this->saveIndex();
174    }
175
176    /**
177     * Looks up the geohash(es) for the document in the index.
178     *
179     * @param String $id
180     *          document ID
181     * @param array $index
182     *          spatial index
183     */
184    final public function findHashesForId(string $id, array $index): array
185    {
186        $hashes = [];
187        foreach ($index as $hash => $docIds) {
188            if (in_array($id, $docIds)) {
189                $hashes [] = $hash;
190            }
191        }
192        Logger::debug("Found the following hashes for $id (should only be 1)", $hashes);
193        return $hashes;
194    }
195
196    /**
197     * Save spatial index.
198     */
199    private function saveIndex(): bool
200    {
201        return io_saveFile($this->idx_dir . '/spatial.idx', serialize($this->spatial_idx));
202    }
203
204    /**
205     * Add an index entry for this file having EXIF / IPTC data.
206     *
207     * @param $imgId
208     *          a Dokuwiki image id
209     * @return bool true when image was successfully added to the index.
210     * @throws Exception
211     * @see http://www.php.net/manual/en/function.iptcparse.php
212     * @see http://php.net/manual/en/function.exif-read-data.php
213     *
214     */
215    final public function indexImage(string $imgId): bool
216    {
217        // test for supported files (jpeg only)
218        if (
219            (!str_ends_with(strtolower($imgId), '.jpg')) &&
220            (!str_ends_with(strtolower($imgId), '.jpeg'))
221        ) {
222            Logger::debug("indexImage:: " . $imgId . " is not a supported image file.");
223            return false;
224        }
225
226        $geometry = $this->getCoordsFromExif($imgId);
227        if (!$geometry) {
228            return false;
229        }
230        $geohash = $geometry->out('geohash');
231        // TODO truncate the geohash to something reasonable, otherwise they are
232        //   useless as an indexing mechanism eg. u1h73weckdrmskdqec3c9 is far too
233        //   precise, limit at ~9 as most GPS are not submeter accurate
234        return $this->addToIndex($geohash, 'media__' . $imgId);
235    }
236
237    /**
238     * retrieve GPS decimal coordinates from exif.
239     *
240     * @param string $id
241     * @return Point|false
242     * @throws Exception
243     */
244    final public function getCoordsFromExif(string $id): Point|false
245    {
246        $exif = exif_read_data(mediaFN($id), 0, true);
247        if (!$exif || empty($exif ['GPS'])) {
248            return false;
249        }
250
251        $lat = $this->convertDMStoD(
252            [
253                $exif ['GPS'] ['GPSLatitude'] [0],
254                $exif ['GPS'] ['GPSLatitude'] [1],
255                $exif ['GPS'] ['GPSLatitude'] [2],
256                $exif ['GPS'] ['GPSLatitudeRef'] ?? 'N'
257            ]
258        );
259
260        $lon = $this->convertDMStoD(
261            [
262                $exif ['GPS'] ['GPSLongitude'] [0],
263                $exif ['GPS'] ['GPSLongitude'] [1],
264                $exif ['GPS'] ['GPSLongitude'] [2],
265                $exif ['GPS'] ['GPSLongitudeRef'] ?? 'E'
266            ]
267        );
268
269        return new Point($lon, $lat);
270    }
271
272    /**
273     * convert DegreesMinutesSeconds to Decimal degrees.
274     *
275     * @param array $param array of rational DMS
276     */
277    final  public function convertDMStoD(array $param): float
278    {
279        //        if (!(is_array($param))) {
280        //            $param = [$param];
281        //        }
282        $deg = $this->convertRationaltoFloat($param [0]);
283        $min = $this->convertRationaltoFloat($param [1]) / 60;
284        $sec = $this->convertRationaltoFloat($param [2]) / 60 / 60;
285        // Hemisphere (N, S, W or E)
286        $hem = ($param [3] === 'N' || $param [3] === 'E') ? 1 : -1;
287
288        return $hem * ($deg + $min + $sec);
289    }
290
291    final public function convertRationaltoFloat(string $param): float
292    {
293        // rational64u
294        $nums = explode('/', $param);
295        if ((int)$nums[1] > 0) {
296            return (float)$nums[0] / (int)$nums[1];
297        }
298
299        return (float)$nums[0];
300    }
301
302    /**
303     * Deletes the page from the index.
304     *
305     * @param string $id document ID
306     */
307    final public function deleteFromIndex(string $id): void
308    {
309        // check the index for document
310        $knownHashes = $this->findHashesForId($id, $this->spatial_idx);
311        if ($knownHashes === []) {
312            return;
313        }
314
315        // TODO shortcut, need to make sure there is only one element, if not the index is corrupt
316        $knownHash = $knownHashes [0];
317        $knownIds = $this->spatial_idx [$knownHash];
318        $i = array_search($id, $knownIds);
319        Logger::debug("removing: $knownIds[$i] from the index.");
320        unset($knownIds [$i]);
321        $this->spatial_idx [$knownHash] = $knownIds;
322        if (empty($this->spatial_idx [$knownHash])) {
323            unset($this->spatial_idx [$knownHash]);
324        }
325        $this->saveIndex();
326    }
327}
328