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     * @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 $imgId
204     *          a Dokuwiki image id
205     * @return bool true when image was successfully 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(string $imgId): bool
212    {
213        // test for supported files (jpeg only)
214        if (
215            (!str_ends_with(strtolower($imgId), '.jpg')) &&
216            (!str_ends_with(strtolower($imgId), '.jpeg'))
217        ) {
218            Logger::debug("indexImage:: " . $imgId . " is not a supported image file.");
219            return false;
220        }
221
222        $geometry = $this->getCoordsFromExif($imgId);
223        if (!$geometry) {
224            return false;
225        }
226        $geohash = $geometry->out('geohash');
227        // TODO truncate the geohash to something reasonable, otherwise they are
228        //   useless as an indexing mechanism eg. u1h73weckdrmskdqec3c9 is far too
229        //   precise, limit at ~9 as most GPS are not submeter accurate
230        return $this->addToIndex($geohash, 'media__' . $imgId);
231    }
232
233    /**
234     * retrieve GPS decimal coordinates from exif.
235     *
236     * @param string $id
237     * @return Point|false
238     * @throws Exception
239     */
240    final public function getCoordsFromExif(string $id): Point|false
241    {
242        $exif = exif_read_data(mediaFN($id), 0, true);
243        if (empty($exif ['GPS'])) {
244            return false;
245        }
246
247        $lat = $this->convertDMStoD(
248            [
249                $exif ['GPS'] ['GPSLatitude'] [0],
250                $exif ['GPS'] ['GPSLatitude'] [1],
251                $exif ['GPS'] ['GPSLatitude'] [2],
252                $exif ['GPS'] ['GPSLatitudeRef']
253            ]
254        );
255
256        $lon = $this->convertDMStoD(
257            [
258                $exif ['GPS'] ['GPSLongitude'] [0],
259                $exif ['GPS'] ['GPSLongitude'] [1],
260                $exif ['GPS'] ['GPSLongitude'] [2],
261                $exif ['GPS'] ['GPSLongitudeRef']
262            ]
263        );
264
265        return new Point($lon, $lat);
266    }
267
268    /**
269     * convert DegreesMinutesSeconds to Decimal degrees.
270     *
271     * @param array $param array of rational DMS
272     */
273    final  public function convertDMStoD(array $param): float
274    {
275        //        if (!(is_array($param))) {
276        //            $param = [$param];
277        //        }
278        $deg = $this->convertRationaltoFloat($param [0]);
279        $min = $this->convertRationaltoFloat($param [1]) / 60;
280        $sec = $this->convertRationaltoFloat($param [2]) / 60 / 60;
281        // Hemisphere (N, S, W or E)
282        $hem = ($param [3] === 'N' || $param [3] === 'E') ? 1 : -1;
283
284        return $hem * ($deg + $min + $sec);
285    }
286
287    final public function convertRationaltoFloat(string $param): float
288    {
289        // rational64u
290        $nums = explode('/', $param);
291        if ((int)$nums[1] > 0) {
292            return (float)$nums[0] / (int)$nums[1];
293        }
294
295        return (float)$nums[0];
296    }
297
298    /**
299     * Deletes the page from the index.
300     *
301     * @param string $id document ID
302     */
303    final public function deleteFromIndex(string $id): void
304    {
305        // check the index for document
306        $knownHashes = $this->findHashesForId($id, $this->spatial_idx);
307        if ($knownHashes === []) {
308            return;
309        }
310
311        // TODO shortcut, need to make sure there is only one element, if not the index is corrupt
312        $knownHash = $knownHashes [0];
313        $knownIds = $this->spatial_idx [$knownHash];
314        $i = array_search($id, $knownIds);
315        Logger::debug("removing: $knownIds[$i] from the index.");
316        unset($knownIds [$i]);
317        $this->spatial_idx [$knownHash] = $knownIds;
318        if (empty($this->spatial_idx [$knownHash])) {
319            unset($this->spatial_idx [$knownHash]);
320        }
321        $this->saveIndex();
322    }
323}
324