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