1<?php 2 3use dokuwiki\Extension\Event; 4 5/** 6 * DokuWiki Plugin elasticsearch (Action Component) 7 * 8 * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html 9 * @author Kieback&Peter IT <it-support@kieback-peter.de> 10 * @author Andreas Gohr <gohr@cosmocode.de> 11 */ 12 13class action_plugin_elasticsearch_indexing extends DokuWiki_Action_Plugin { 14 15 const MIME_DOKUWIKI = 'text/dokuwiki'; 16 const DOCTYPE_PAGE = 'page'; 17 const DOCTYPE_MEDIA = 'media'; 18 19 /** 20 * Registers a callback function for a given event 21 * 22 * @param Doku_Event_Handler $controller DokuWiki's event controller object 23 * @return void 24 */ 25 public function register(Doku_Event_Handler $controller) { 26 $controller->register_hook('TPL_CONTENT_DISPLAY', 'BEFORE', $this, 'handle_tpl_content_display'); 27 $controller->register_hook('IO_WIKIPAGE_WRITE', 'BEFORE', $this, 'handle_delete'); 28 $controller->register_hook('MEDIA_UPLOAD_FINISH', 'AFTER', $this, 'handle_media_upload'); 29 $controller->register_hook('MEDIA_DELETE_FILE', 'AFTER', $this, 'handle_media_delete'); 30 } 31 32 /** 33 * Add pages to index 34 * 35 * @param Doku_Event $event event object by reference 36 * @return void 37 */ 38 public function handle_tpl_content_display(Doku_Event $event) { 39 global $ID, $INFO; 40 $logs = []; 41 $logs[] = 'BEGIN content display'; 42 $logs[] = metaFN($ID, '.elasticsearch_indexed'); 43 $logs[] = wikiFN($ID); 44 $logs[] = wikiFN($INFO['id']); 45 $logs[] = $this->needs_indexing($ID) ? 'needs indexing' : 'no indexing needed'; 46 $logs[] = 'END content display'; 47 $this->log($logs); 48 if ($this->needs_indexing($ID)) { 49 $this->index_page($ID); 50 } 51 } 52 53 /** 54 * Update index on media upload 55 * 56 * @param Doku_Event $event 57 * @throws Exception 58 */ 59 public function handle_media_upload(Doku_Event $event) 60 { 61 $this->index_file($event->data[2]); 62 } 63 64 /** 65 * Remove pages from index 66 * 67 * @param Doku_Event $event event object by reference 68 * @return void 69 */ 70 public function handle_delete(Doku_Event $event) { 71 if ($event->data[3]) return; // is old revision stuff 72 if (!empty($event->data[0][1])) return; // page still exists 73 // still here? delete from index 74 $this->delete_entry($event->data[2], self::DOCTYPE_PAGE); 75 } 76 77 /** 78 * Remove deleted media from index 79 * 80 * @param Doku_Event $event 81 * @param $param 82 */ 83 public function handle_media_delete(Doku_Event $event, $param) 84 { 85 if ($event->data['unl']) $this->delete_entry($event->data['id'], self::DOCTYPE_MEDIA); 86 } 87 88 /** 89 * Check if the page $id has changed since the last indexing. 90 * 91 * @param string $id 92 * @return boolean 93 */ 94 protected function needs_indexing($id) { 95 $indexStateFile = metaFN($id, '.elasticsearch_indexed'); 96 $refreshStateFile = metaFN($id, '.elasticsearch_refresh'); 97 $dataFile = wikiFN($id); 98 99 // no data file or page is hidden ('hidepages' configuration option) -> no indexing 100 if (!file_exists($dataFile) || isHiddenPage($id)) { 101 // page should not be indexed but has a state file, try to remove from index 102 if (file_exists($indexStateFile)) { 103 $this->delete_entry($id, self::DOCTYPE_PAGE); 104 } 105 return false; 106 } 107 108 // force indexing if we're called via cli (e.g. cron) 109 if (php_sapi_name() == 'cli') { 110 return true; 111 } 112 // check if latest indexing attempt is done after page update 113 // and after other updates related to the page made by plugins 114 if (file_exists($indexStateFile)) { 115 if ( 116 (filemtime($indexStateFile) > filemtime($dataFile)) && 117 (!file_exists($refreshStateFile) || filemtime($indexStateFile) > filemtime($refreshStateFile)) 118 ) { 119 return false; 120 } 121 } 122 return true; 123 } 124 125 /** 126 * @param array $data 127 */ 128 protected function write_index($data) 129 { 130 /** @var helper_plugin_elasticsearch_client $hlp */ 131 $hlp = plugin_load('helper', 'elasticsearch_client'); 132 133 $indexName = $this->getConf('indexname'); 134 $documentType = $this->getConf('documenttype'); 135 $client = $hlp->connect(); 136 $index = $client->getIndex($indexName); 137 $type = $index->getType($documentType); 138 $documentId = $data['doctype'] . '_' . $data['uri']; 139 140 // check if the document still exists to update it or add it as a new one 141 try { 142 $client->updateDocument($documentId, ['doc' => $data], $index->getName(), $type->getName()); 143 } catch (\Elastica\Exception\NotFoundException $e) { 144 $document = new \Elastica\Document($documentId, $data); 145 $type->addDocument($document); 146 } catch (\Elastica\Exception\ResponseException $e) { 147 if ($e->getResponse()->getStatus() == 404) { 148 $document = new \Elastica\Document($documentId, $data); 149 $type->addDocument($document); 150 } else { 151 throw $e; 152 } 153 } catch (Exception $e) { 154 msg( 155 'Something went wrong on indexing please try again later or ask an admin for help.<br /><pre>' . 156 hsc(get_class($e) . ' ' . $e->getMessage()) . '</pre>', 157 -1 158 ); 159 return; 160 } 161 $index->refresh(); 162 $this->update_indexstate($data['uri']); 163 } 164 165 /** 166 * Save indexed state for a page or a media file 167 * 168 * @param string $id 169 * @param string $doctype 170 * @return bool 171 */ 172 protected function update_indexstate($id, $doctype = self::DOCTYPE_PAGE) { 173 $indexStateFile = ($doctype === self::DOCTYPE_MEDIA) ? 174 mediaMetaFN($id, '.elasticsearch_indexed') : 175 metaFN($id, '.elasticsearch_indexed'); 176 return io_saveFile($indexStateFile, ''); 177 } 178 179 /** 180 * Remove the given document from the index 181 * 182 * @param $id 183 * @param $doctype 184 */ 185 public function delete_entry($id, $doctype) { 186 /** @var helper_plugin_elasticsearch_client $hlp */ 187 $hlp = plugin_load('helper', 'elasticsearch_client'); 188 $indexName = $this->getConf('indexname'); 189 $documentType = $this->getConf('documenttype'); 190 $client = $hlp->connect(); 191 $index = $client->getIndex($indexName); 192 $type = $index->getType($documentType); 193 $documentId = $doctype . '_' . $id; 194 195 try { 196 $type->deleteById($documentId); 197 $index->refresh(); 198 $this->log($documentId.' deleted '); 199 } catch(Exception $e) { 200 // we ignore this 201 $this->log($documentId.' not deleted '.$e->getMessage()); 202 } 203 204 // delete state file 205 $stateFile = ($doctype === self::DOCTYPE_MEDIA) ? 206 mediaMetaFN($id, '.elasticsearch_indexed') : 207 metaFN($id, '.elasticsearch_indexed'); 208 @unlink($stateFile); 209 } 210 211 /** 212 * Index a page 213 * 214 * @param $id 215 * @return void 216 */ 217 public function index_page($id) { 218 global $conf; 219 220 $this->log('Indexing page ' . $id); 221 222 // collect the date which should be indexed 223 $meta = p_get_metadata($id, '', METADATA_RENDER_UNLIMITED); 224 225 $data = array(); 226 $data['uri'] = $id; 227 $data['created'] = date('Y-m-d\TH:i:s\Z', $meta['date']['created']); 228 $data['modified'] = date('Y-m-d\TH:i:s\Z', $meta['date']['modified']); 229 $data['user'] = $meta['user']; 230 $data['title'] = $meta['title']; 231 $data['abstract'] = $meta['description']['abstract']; 232 $data['syntax'] = rawWiki($id); 233 $data['mime'] = self::MIME_DOKUWIKI; 234 $data['doctype'] = self::DOCTYPE_PAGE; 235 236 // prefer rendered plaintext over raw syntax output 237 /** @var \renderer_plugin_text $textRenderer */ 238 $textRenderer = plugin_load('renderer', 'text'); 239 if ($textRenderer) { 240 $data['content'] = p_cached_output(wikiFN($id),'text'); 241 } else { 242 $data['content'] = $data['syntax']; 243 } 244 245 /** @var helper_plugin_translation $trans */ 246 $trans = plugin_load('helper', 'translation'); 247 if($trans) { 248 // translation plugin available 249 $lc = $trans->getLangPart($id); 250 $data['language'] = $trans->realLC($lc); 251 } else { 252 // no translation plugin 253 $data['language'] = $conf['lang']; 254 } 255 256 $data['namespace'] = getNS($id); 257 if(trim($data['namespace']) == '') { 258 unset($data['namespace']); 259 } 260 261 /** @var helper_plugin_elasticsearch_acl $hlpAcl */ 262 $hlpAcl = plugin_load('helper', 'elasticsearch_acl'); 263 264 $fullACL = $hlpAcl->getPageACL($id); 265 $queryACL = $hlpAcl->splitRules($fullACL); 266 $data = array_merge($data, $queryACL); 267 268 // let plugins add their own data to index 269 $pluginData = $this->getPluginData($data['uri']); 270 $data = array_merge($data, $pluginData); 271 272 $this->write_index($data); 273 } 274 275 /** 276 * Index a file 277 * 278 * @param string $fileId 279 * @return void 280 * @throws Exception 281 */ 282 public function index_file($fileId) { 283 $this->log('Indexing file ' . $fileId); 284 285 $docparser = new \helper_plugin_elasticsearch_docparser(); 286 287 $file = mediaFN($fileId); 288 289 try { 290 $data = $docparser->parse($file); 291 $data['uri'] = $fileId; 292 $data['doctype'] = self::DOCTYPE_MEDIA; 293 $data['modified'] = date('Y-m-d\TH:i:s\Z', filemtime($file)); 294 $data['namespace'] = getNS($fileId); 295 if (trim($data['namespace']) == '') { 296 unset($data['namespace']); 297 } 298 299 /** @var helper_plugin_elasticsearch_acl $hlpAcl */ 300 $hlpAcl = plugin_load('helper', 'elasticsearch_acl'); 301 302 $fullACL = $hlpAcl->getPageACL($fileId); 303 $queryACL = $hlpAcl->splitRules($fullACL); 304 $data = array_merge($data, $queryACL); 305 306 $this->write_index($data); 307 } catch (RuntimeException $e) { 308 $this->log('Skipping ' . $fileId . ': ' . $e->getMessage()); 309 } 310 } 311 312 313 /** 314 * Get plugin data to feed into the index. 315 * If data does not match previously defined mappings, it will be ignored. 316 * 317 * @param $id 318 * @return array 319 */ 320 protected function getPluginData($id): array 321 { 322 $pluginData = ['uri' => $id]; 323 Event::createAndTrigger('PLUGIN_ELASTICSEARCH_INDEXPAGE', $pluginData); 324 return $pluginData; 325 } 326 327 /** 328 * Log something to the debug log 329 * 330 * @param string|string[] $txt 331 */ 332 protected function log($txt) { 333 if (!$this->getConf('debug')) { 334 return; 335 } 336 $logs = (array)$txt; 337 338 foreach ($logs as $entry) { 339 dbglog($entry); 340 } 341 } 342} 343