1<?php
2/*
3 * Copyright (c) 2011-2020 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
18use dokuwiki\Sitemap\Item;
19
20/**
21 * DokuWiki Plugin dokuwikispatial (Action Component).
22 *
23 * @license BSD license
24 * @author  Mark C. Prins <mprins@users.sf.net>
25 */
26class action_plugin_spatialhelper extends DokuWiki_Action_Plugin {
27
28    /**
29     * Register for events.
30     *
31     * @param Doku_Event_Handler $controller
32     *          DokuWiki's event controller object. Also available as global $EVENT_HANDLER
33     */
34    public function register(Doku_Event_Handler $controller): void {
35        // listen for page add / delete events
36        // http://www.dokuwiki.org/devel:event:indexer_page_add
37        $controller->register_hook('INDEXER_PAGE_ADD', 'BEFORE', $this, 'handleIndexerPageAdd');
38        $controller->register_hook('IO_WIKIPAGE_WRITE', 'BEFORE', $this, 'removeFromIndex');
39
40        // http://www.dokuwiki.org/devel:event:sitemap_generate
41        $controller->register_hook('SITEMAP_GENERATE', 'BEFORE', $this, 'handleSitemapGenerateBefore');
42        // using after will only trigger us if a sitemap was actually created
43        $controller->register_hook('SITEMAP_GENERATE', 'AFTER', $this, 'handleSitemapGenerateAfter');
44
45        // handle actions we know of
46        $controller->register_hook('ACTION_ACT_PREPROCESS', 'BEFORE', $this, 'handleActionActPreprocess', array());
47        // handle HTML eg. /dokuwiki/doku.php?id=start&do=findnearby&geohash=u15vk4
48        $controller->register_hook(
49            'TPL_ACT_UNKNOWN', 'BEFORE', $this, 'findnearby', array(
50                                 'format' => 'HTML'
51                             )
52        );
53        // handles AJAX/json eg: jQuery.post("/dokuwiki/lib/exe/ajax.php?id=start&call=findnearby&geohash=u15vk4");
54        $controller->register_hook(
55            'AJAX_CALL_UNKNOWN', 'BEFORE', $this, 'findnearby', array(
56                                   'format' => 'JSON'
57                               )
58        );
59
60        // listen for media uploads and deletes
61        $controller->register_hook('MEDIA_UPLOAD_FINISH', 'BEFORE', $this, 'handleMediaUploaded', array());
62        $controller->register_hook('MEDIA_DELETE_FILE', 'BEFORE', $this, 'handleMediaDeleted', array());
63
64        $controller->register_hook('TPL_METAHEADER_OUTPUT', 'BEFORE', $this, 'handleMetaheaderOutput');
65        $controller->register_hook('PLUGIN_POPULARITY_DATA_SETUP', 'AFTER', $this, 'popularity');
66    }
67
68    /**
69     * Update the spatial index for the page.
70     *
71     * @param Doku_Event $event
72     *          event object
73     * @param mixed      $param
74     *          the parameters passed to register_hook when this handler was registered
75     */
76    public function handleIndexerPageAdd(Doku_Event $event, $param): void {
77        // $event→data['page'] – the page id
78        // $event→data['body'] – empty, can be filled by additional content to index by your plugin
79        // $event→data['metadata'] – the metadata that shall be indexed. This is an array where the keys are the
80        //    metadata indexes and the value a string or an array of strings with the values.
81        //    title and relation_references will already be set.
82        $id      = $event->data ['page'];
83        $indexer = plugin_load('helper', 'spatialhelper_index');
84        $entries = $indexer->updateSpatialIndex($id);
85    }
86
87    /**
88     * Update the spatial index, removing the page.
89     *
90     * @param Doku_Event $event
91     *          event object
92     * @param mixed      $param
93     *          the parameters passed to register_hook when this handler was registered
94     */
95    public function removeFromIndex(Doku_Event $event, $param): void {
96        // event data:
97        // $data[0] – The raw arguments for io_saveFile as an array. Do not change file path.
98        // $data[0][0] – the file path.
99        // $data[0][1] – the content to be saved, and may be modified.
100        // $data[1] – ns: The colon separated namespace path minus the trailing page name. (false if root ns)
101        // $data[2] – page_name: The wiki page name.
102        // $data[3] – rev: The page revision, false for current wiki pages.
103
104        dbglog($event->data, "Event data in removeFromIndex.");
105        if(@file_exists($event->data [0] [0])) {
106            // file not new
107            if(!$event->data [0] [1]) {
108                // file is empty, page is being deleted
109                if(empty ($event->data [1])) {
110                    // root namespace
111                    $id = $event->data [2];
112                } else {
113                    $id = $event->data [1] . ":" . $event->data [2];
114                }
115                $indexer = plugin_load('helper', 'spatialhelper_index');
116                if($indexer) {
117                    $indexer->deleteFromIndex($id);
118                }
119            }
120        }
121    }
122
123    /**
124     * Add a new SitemapItem object that points to the KML of public geocoded pages.
125     *
126     * @param Doku_Event $event
127     * @param mixed      $param
128     */
129    public function handleSitemapGenerateBefore(Doku_Event $event, $param): void {
130        $path                     = mediaFN($this->getConf('media_kml'));
131        $lastmod                  = @filemtime($path);
132        $event->data ['items'] [] = new Item(ml($this->getConf('media_kml'), '', true, '&amp;', true), $lastmod);
133        //dbglog($event->data ['items'],
134        //  "Added a new SitemapItem object that points to the KML of public geocoded pages.");
135    }
136
137    /**
138     * Create a spatial sitemap or attach the geo/kml map to the sitemap.
139     *
140     * @param Doku_Event $event
141     *          event object, not used
142     * @param mixed      $param
143     *          parameter array, not used
144     */
145    public function handleSitemapGenerateAfter(Doku_Event $event, $param): bool {
146        // $event→data['items']: Array of SitemapItem instances, the array of sitemap items that already
147        //      contains all public pages of the wiki
148        // $event→data['sitemap']: The path of the file the sitemap will be saved to.
149        if($helper = plugin_load('helper', 'spatialhelper_sitemap')) {
150            // dbglog($helper, "createSpatialSitemap loaded helper.");
151
152            $kml = $helper->createKMLSitemap($this->getConf('media_kml'));
153            $rss = $helper->createGeoRSSSitemap($this->getConf('media_georss'));
154
155            if(!empty ($this->getConf('sitemap_namespaces'))) {
156                $namespaces = array_map('trim', explode("\n", $this->getConf('sitemap_namespaces')));
157                foreach($namespaces as $namespace) {
158                    $kmlN = $helper->createKMLSitemap($namespace . $this->getConf('media_kml'));
159                    $rssN = $helper->createGeoRSSSitemap($namespace . $this->getConf('media_georss'));
160                    dbglog(
161                        $kmlN && $rssN,
162                        "handleSitemapGenerateAfter, created KML / GeoRSS sitemap in $namespace, succes: "
163                    );
164                }
165            }
166            return $kml && $rss;
167        } else {
168            dbglog($helper, "createSpatialSitemap NOT loaded helper.");
169        }
170    }
171
172    /**
173     * trap findnearby action.
174     * This addional handler is required as described at: https://www.dokuwiki.org/devel:event:tpl_act_unknown
175     *
176     * @param Doku_Event $event
177     *          event object
178     * @param mixed      $param
179     *          not used
180     */
181    public function handleActionActPreprocess(Doku_Event $event, $param): void {
182        if($event->data !== 'findnearby') {
183            return;
184        }
185        $event->preventDefault();
186    }
187
188    /**
189     * handle findnearby action.
190     *
191     * @param Doku_Event $event
192     *          event object
193     * @param mixed      $param
194     *          associative array with keys
195     *          'format'=> HTML | JSON
196     */
197    public function findnearby(Doku_Event $event, $param): void {
198        if($event->data !== 'findnearby') {
199            return;
200        }
201        $event->preventDefault();
202        $results = array();
203        global $INPUT;
204        if($helper = plugin_load('helper', 'spatialhelper_search')) {
205            if($INPUT->has('lat') && $INPUT->has('lon')) {
206                $results = $helper->findNearbyLatLon($INPUT->param('lat'), $INPUT->param('lon'));
207            } elseif($INPUT->has('geohash')) {
208                $results = $helper->findNearby($INPUT->str('geohash'));
209            } else {
210                $results = array(
211                    'error' => hsc($this->getLang('invalidinput'))
212                );
213            }
214        }
215
216        $showMedia = $INPUT->bool('showMedia', true);
217
218        switch($param['format']) {
219            case 'JSON' :
220                $this->printJSON($results);
221                break;
222            case 'HTML' :
223                // fall through to default
224            default :
225                $this->printHTML($results, $showMedia);
226                break;
227        }
228    }
229
230    /**
231     * Print seachresults as HTML lists.
232     *
233     * @param array $searchresults
234     */
235    private function printJSON(array $searchresults): void {
236        require_once DOKU_INC . 'inc/JSON.php';
237        $json = new JSON();
238        header('Content-Type: application/json');
239        print $json->encode($searchresults);
240    }
241
242    /**
243     * Print seachresults as HTML lists.
244     *
245     * @param array $searchresults
246     * @param bool  $showMedia
247     */
248    private function printHTML(array $searchresults, bool $showMedia = true): void {
249        $pages   = (array) ($searchresults ['pages']);
250        $media   = (array) $searchresults ['media'];
251        $lat     = (float) $searchresults ['lat'];
252        $lon     = (float) $searchresults ['lon'];
253        $geohash = (string) $searchresults ['geohash'];
254
255        if(isset ($searchresults ['error'])) {
256            print '<div class="level1"><p>' . hsc($searchresults ['error']) . '</p></div>';
257            return;
258        }
259
260        // print a HTML list
261        print '<h1>' . $this->getLang('results_header') . '</h1>' . DOKU_LF;
262        print '<div class="level1">' . DOKU_LF;
263        if(!empty ($pages)) {
264            $pagelist = '<ol>' . DOKU_LF;
265            foreach($pages as $page) {
266                $pagelist .= '<li>' . html_wikilink(
267                        ':' . $page ['id'], useHeading('navigation') ? null :
268                        noNS($page ['id'])
269                    ) . ' (' . $this->getLang('results_distance_prefix')
270                    . $page ['distance'] . '&nbsp;m) ' . $page ['description'] . '</li>' . DOKU_LF;
271            }
272            $pagelist .= '</ol>' . DOKU_LF;
273
274            print '<h2>' . $this->getLang('results_pages') . hsc(
275                    ' lat;lon: ' . $lat . ';' . $lon
276                    . ' (geohash: ' . $geohash . ')'
277                ) . '</h2>';
278            print '<div class="level2">' . DOKU_LF;
279            print $pagelist;
280            print '</div>' . DOKU_LF;
281        } else {
282            print '<p>' . hsc($this->getLang('nothingfound')) . '</p>';
283        }
284
285        if(!empty ($media) && $showMedia) {
286            $pagelist = '<ol>' . DOKU_LF;
287            foreach($media as $m) {
288                $opts       = array();
289                $link       = ml($m ['id'], $opts, false, '&amp;', false);
290                $opts ['w'] = '100';
291                $src        = ml($m ['id'], $opts);
292                $pagelist   .= '<li><a href="' . $link . '"><img src="' . $src . '"></a> ('
293                    . $this->getLang('results_distance_prefix') . $page ['distance'] . '&nbsp;m) ' . hsc($desc)
294                    . '</li>' . DOKU_LF;
295            }
296            $pagelist .= '</ol>' . DOKU_LF;
297
298            print '<h2>' . $this->getLang('results_media') . hsc(
299                    ' lat;lon: ' . $lat . ';' . $lon
300                    . ' (geohash: ' . $geohash . ')'
301                ) . '</h2>' . DOKU_LF;
302            print '<div class="level2">' . DOKU_LF;
303            print $pagelist;
304            print '</div>' . DOKU_LF;
305        }
306        print '<p>' . $this->getLang('results_precision') . $searchresults ['precision'] . ' m. ';
307        if(strlen($geohash) > 1) {
308            $url = wl(
309                getID(), array(
310                           'do'      => 'findnearby',
311                           'geohash' => substr($geohash, 0, -1)
312                       )
313            );
314            print '<a href="' . $url . '" class="findnearby">' . $this->getLang('search_largerarea') . '</a>.</p>'
315                . DOKU_LF;
316        }
317        print '</div>' . DOKU_LF;
318    }
319
320    /**
321     * add media to spatial index.
322     *
323     * @param Doku_Event $event
324     * @param mixed      $param
325     */
326    public function handleMediaUploaded(Doku_Event $event, $param): void {
327        // data[0] temporary file name (read from $_FILES)
328        // data[1] file name of the file being uploaded
329        // data[2] future directory id of the file being uploaded
330        // data[3] the mime type of the file being uploaded
331        // data[4] true if the uploaded file exists already
332        // data[5] (since 2011-02-06) the PHP function used to move the file to the correct location
333
334        dbglog($event->data, "handleMediaUploaded::event data");
335
336        // check the list of mimetypes
337        // if it's a supported type call appropriate index function
338        if(substr_compare($event->data [3], 'image/jpeg', 0)) {
339            $indexer = plugin_load('helper', 'spatialhelper_index');
340            if($indexer) {
341                $indexer->indexImage($event->data [2], $event->data [1]);
342            }
343        }
344        // TODO add image/tiff
345        // TODO kml, gpx, geojson...
346    }
347
348    /**
349     * removes the media from the index.
350     */
351    public function handleMediaDeleted(Doku_Event $event, $param): void {
352        // data['id'] ID data['unl'] unlink return code
353        // data['del'] Namespace directory unlink return code
354        // data['name'] file name data['path'] full path to the file
355        // data['size'] file size
356
357        dbglog($event->data, "handleMediaDeleted::event data");
358
359        // remove the media id from the index
360        $indexer = plugin_load('helper', 'spatialhelper_index');
361        if($indexer) {
362            $indexer->deleteFromIndex('media__' . $event->data ['id']);
363        }
364    }
365
366    /**
367     * add a link to the spatial sitemap files in the header.
368     *
369     * @param Doku_Event $event
370     *          the DokuWiki event. $event->data is a two-dimensional
371     *          array of all meta headers. The keys are meta, link and script.
372     * @param mixed      $param
373     *
374     * @see http://www.dokuwiki.org/devel:event:tpl_metaheader_output
375     */
376    public function handleMetaheaderOutput(Doku_Event $event, $param): void {
377        // TODO maybe test for exist
378        $event->data ["link"] [] = array(
379            "type"  => "application/atom+xml",
380            "rel"   => "alternate",
381            "href"  => ml($this->getConf('media_georss')),
382            "title" => "Spatial ATOM Feed"
383        );
384        $event->data ["link"] [] = array(
385            "type"  => "application/vnd.google-earth.kml+xml",
386            "rel"   => "alternate",
387            "href"  => ml($this->getConf('media_kml')),
388            "title" => "KML Sitemap"
389        );
390    }
391
392    /**
393     * Add spatialhelper popularity data.
394     *
395     * @param Doku_Event $event
396     *          the DokuWiki event
397     */
398    final public function popularity(Doku_Event $event): void {
399        global $updateVersion;
400        $plugin_info                                     = $this->getInfo();
401        $event->data['spatialhelper']['version']         = $plugin_info['date'];
402        $event->data['spatialhelper']['dwversion']       = $updateVersion;
403        $event->data['spatialhelper']['combinedversion'] = $updateVersion . '_' . $plugin_info['date'];
404    }
405
406    /**
407     * Calculate a new coordinate based on start, distance and bearing
408     *
409     * @param $start array
410     *               - start coordinate as decimal lat/lon pair
411     * @param $dist  float
412     *               - distance in kilometers
413     * @param $brng  float
414     *               - bearing in degrees (compass direction)
415     */
416    private function geoDestination(array $start, float $dist, float $brng): array {
417        $lat1 = $this->toRad($start [0]);
418        $lon1 = $this->toRad($start [1]);
419        // http://en.wikipedia.org/wiki/Earth_radius
420        // average earth radius in km
421        $dist = $dist / 6371.01;
422        $brng = $this->toRad($brng);
423
424        $lon2 = $lon1 + atan2(sin($brng) * sin($dist) * cos($lat1), cos($dist) - sin($lat1) * sin($lat2));
425        $lon2 = fmod(($lon2 + 3 * M_PI), (2 * M_PI)) - M_PI;
426
427        return array(
428            $this->toDeg($lat2),
429            $this->toDeg($lon2)
430        );
431    }
432
433    private function toRad(float $deg): float {
434        return $deg * M_PI / 180;
435    }
436
437    private function toDeg(float $rad): float {
438        return $rad * 180 / M_PI;
439    }
440}
441