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