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 
19 use dokuwiki\Extension\Plugin;
20 use dokuwiki\Logger;
21 use geoPHP\Geometry\Point;
22 
23 /**
24  * DokuWiki Plugin spatialhelper (index component).
25  *
26  * @license BSD license
27  * @author  Mark Prins
28  */
29 class 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