tntsearch.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523
  1. <?php
  2. namespace Grav\Plugin;
  3. use Composer\Autoload\ClassLoader;
  4. use Grav\Common\Grav;
  5. use Grav\Common\Language\Language;
  6. use Grav\Common\Page\Page;
  7. use Grav\Common\Page\Pages;
  8. use Grav\Common\Plugin;
  9. use Grav\Common\Scheduler\Scheduler;
  10. use Grav\Common\Uri;
  11. use Grav\Plugin\TNTSearch\GravTNTSearch;
  12. use RocketTheme\Toolbox\Event\Event;
  13. use TeamTNT\TNTSearch\Exceptions\IndexNotFoundException;
  14. /**
  15. * Class TNTSearchPlugin
  16. * @package Grav\Plugin
  17. */
  18. class TNTSearchPlugin extends Plugin
  19. {
  20. /** @var array|object|string */
  21. protected $results = [];
  22. /** @var string */
  23. protected $query;
  24. /** @var bool */
  25. protected $built_in_search_page;
  26. /** @var string */
  27. protected $query_route;
  28. /** @var string */
  29. protected $search_route;
  30. /** @var string */
  31. protected $current_route;
  32. /** @var string */
  33. protected $admin_route;
  34. /**
  35. * @return array
  36. *
  37. * The getSubscribedEvents() gives the core a list of events
  38. * that the plugin wants to listen to. The key of each
  39. * array section is the event that the plugin listens to
  40. * and the value (in the form of an array) contains the
  41. * callable (or function) as well as the priority. The
  42. * higher the number the higher the priority.
  43. */
  44. public static function getSubscribedEvents(): array
  45. {
  46. return [
  47. 'onPluginsInitialized' => [
  48. ['autoload', 100000],
  49. ['onPluginsInitialized', 0]
  50. ],
  51. 'onSchedulerInitialized' => ['onSchedulerInitialized', 0],
  52. 'onTwigLoader' => ['onTwigLoader', 0],
  53. 'onTNTSearchReIndex' => ['onTNTSearchReIndex', 0],
  54. 'onTNTSearchIndex' => ['onTNTSearchIndex', 0],
  55. 'onTNTSearchQuery' => ['onTNTSearchQuery', 0],
  56. ];
  57. }
  58. /**
  59. * [onPluginsInitialized:100000] Composer autoload.
  60. *is
  61. * @return ClassLoader
  62. */
  63. public function autoload(): ClassLoader
  64. {
  65. return require __DIR__ . '/vendor/autoload.php';
  66. }
  67. /**
  68. * Initialize the plugin
  69. */
  70. public function onPluginsInitialized(): void
  71. {
  72. if ($this->isAdmin()) {
  73. $this->GravTNTSearch();
  74. $route = $this->config->get('plugins.admin.route');
  75. $base = '/' . trim($route, '/');
  76. $this->admin_route = $this->grav['base_url'] . $base;
  77. $this->enable([
  78. 'onAdminMenu' => ['onAdminMenu', 0],
  79. 'onAdminTaskExecute' => ['onAdminTaskExecute', 0],
  80. 'onTwigSiteVariables' => ['onTwigAdminVariables', 0],
  81. 'onTwigLoader' => ['addAdminTwigTemplates', 0],
  82. ]);
  83. if ($this->config->get('plugins.tntsearch.enable_admin_page_events', true)) {
  84. $this->enable([
  85. 'onAdminAfterSave' => ['onObjectSave', 0],
  86. 'onAdminAfterDelete' => ['onObjectDelete', 0],
  87. 'onFlexObjectSave' => ['onObjectSave', 0],
  88. 'onFlexObjectDelete' => ['onObjectDelete', 0],
  89. ]);
  90. }
  91. return;
  92. }
  93. $this->enable([
  94. 'onPagesInitialized' => ['onPagesInitialized', 1000],
  95. 'onTwigSiteVariables' => ['onTwigSiteVariables', 0],
  96. ]);
  97. }
  98. /**
  99. * Add index job to Grav Scheduler
  100. * Requires Grav 1.6.0 - Scheduler
  101. */
  102. public function onSchedulerInitialized(Event $e): void
  103. {
  104. if ($this->config->get('plugins.tntsearch.scheduled_index.enabled')) {
  105. /** @var Scheduler $scheduler */
  106. $scheduler = $e['scheduler'];
  107. $at = $this->config->get('plugins.tntsearch.scheduled_index.at');
  108. $logs = $this->config->get('plugins.tntsearch.scheduled_index.logs');
  109. $job = $scheduler->addCommand('bin/plugin', ['tntsearch', 'index'], 'tntsearch-index');
  110. $job->at($at);
  111. $job->output($logs);
  112. $job->backlink('/plugins/tntsearch');
  113. }
  114. }
  115. /**
  116. * Function to force a reindex from your own plugins
  117. */
  118. public function onTNTSearchReIndex(): void
  119. {
  120. $this->GravTNTSearch()->createIndex();
  121. }
  122. /**
  123. * A sample event to show how easy it is to extend the indexing fields
  124. *
  125. * @param Event $e
  126. */
  127. public function onTNTSearchIndex(Event $e): void
  128. {
  129. $page = $e['page'];
  130. $fields = $e['fields'];
  131. if ($page && $page instanceof Page && isset($page->header()->author)) {
  132. $author = $page->header()->author;
  133. if (is_string($author)) {
  134. $fields->author = $author;
  135. }
  136. }
  137. }
  138. public function onTNTSearchQuery(Event $e): void
  139. {
  140. $page = $e['page'];
  141. $query = $e['query'];
  142. $options = $e['options'];
  143. $fields = $e['fields'];
  144. $gtnt = $e['gtnt'];
  145. $content = $gtnt->getCleanContent($page);
  146. $title = $page->title();
  147. $relevant = $gtnt->tnt->snippet($query, $content, $options['snippet']);
  148. if (strlen($relevant) <= 6) {
  149. $relevant = substr($content, 0, $options['snippet']);
  150. }
  151. $fields->hits[] = [
  152. 'link' => $page->route(),
  153. 'title' => $gtnt->tnt->highlight($title, $query, 'em', ['wholeWord' => false]),
  154. 'content' => $gtnt->tnt->highlight($relevant, $query, 'em', ['wholeWord' => false]),
  155. ];
  156. }
  157. /**
  158. * Create pages and perform the search actions
  159. */
  160. public function onPagesInitialized(): void
  161. {
  162. /** @var Uri $uri */
  163. $uri = $this->grav['uri'];
  164. $options = [];
  165. $this->current_route = $uri->path();
  166. $this->built_in_search_page = $this->config->get('plugins.tntsearch.built_in_search_page');
  167. $this->search_route = $this->config->get('plugins.tntsearch.search_route');
  168. $this->query_route = $this->config->get('plugins.tntsearch.query_route');
  169. $pages = $this->grav['pages'];
  170. $page = $pages->dispatch($this->current_route);
  171. if (!$page) {
  172. if ($this->query_route && $this->query_route === $this->current_route) {
  173. $page = new Page;
  174. $page->init(new \SplFileInfo(__DIR__ . '/pages/tntquery.md'));
  175. $page->slug(basename($this->current_route));
  176. if ($uri->param('ajax') || $uri->query('ajax')) {
  177. $page->template('tntquery-ajax');
  178. }
  179. $pages->addPage($page, $this->current_route);
  180. } elseif ($this->built_in_search_page && $this->search_route == $this->current_route) {
  181. $page = new Page;
  182. $page->init(new \SplFileInfo(__DIR__ . '/pages/search.md'));
  183. $page->slug(basename($this->current_route));
  184. $pages->addPage($page, $this->current_route);
  185. }
  186. }
  187. $this->query = (string)($uri->param('q', null) ?: $uri->query('q') ?: '');
  188. if ($this->query) {
  189. $snippet = $this->getFormValue('sl');
  190. $limit = $this->getFormValue('l');
  191. if ($snippet) {
  192. $options['snippet'] = $snippet;
  193. }
  194. if ($limit) {
  195. $options['limit'] = $limit;
  196. }
  197. $this->grav['tntsearch'] = static::getSearchObjectType($options);
  198. if ($page) {
  199. $this->config->set('plugins.tntsearch', $this->mergeConfig($page));
  200. }
  201. try {
  202. $this->results = $this->GravTNTSearch()->search($this->query);
  203. } catch (IndexNotFoundException $e) {
  204. $this->results = ['number_of_hits' => 0, 'hits' => [], 'execution_time' => 'missing index'];
  205. }
  206. }
  207. }
  208. /**
  209. * Add the Twig template paths to the Twig laoder
  210. */
  211. public function onTwigLoader(): void
  212. {
  213. $this->grav['twig']->addPath(__DIR__ . '/templates');
  214. }
  215. /**
  216. * Add the current template paths to the admin Twig loader
  217. */
  218. public function addAdminTwigTemplates(): void
  219. {
  220. $this->grav['twig']->addPath($this->grav['locator']->findResource('theme://templates'));
  221. }
  222. /**
  223. * Add results and query to Twig as well as CSS/JS assets
  224. */
  225. public function onTwigSiteVariables(): void
  226. {
  227. $twig = $this->grav['twig'];
  228. if ($this->query) {
  229. $twig->twig_vars['query'] = $this->query;
  230. $twig->twig_vars['tntsearch_results'] = $this->results;
  231. }
  232. if ($this->config->get('plugins.tntsearch.built_in_css')) {
  233. $this->grav['assets']->addCss('plugin://tntsearch/assets/tntsearch.css');
  234. }
  235. if ($this->config->get('plugins.tntsearch.built_in_js')) {
  236. // $this->grav['assets']->addJs('plugin://tntsearch/assets/tntsearch.js');
  237. $this->grav['assets']->addJs('plugin://tntsearch/assets/tntsearch.js');
  238. }
  239. }
  240. /**
  241. * Handle the Reindex task from the admin
  242. *
  243. * @param Event $e
  244. */
  245. public function onAdminTaskExecute(Event $e): void
  246. {
  247. if ($e['method'] === 'taskReindexTNTSearch') {
  248. $controller = $e['controller'];
  249. header('Content-type: application/json');
  250. if (!$controller->authorizeTask('reindexTNTSearch', ['admin.configuration', 'admin.super'])) {
  251. $json_response = [
  252. 'status' => 'error',
  253. 'message' => '<i class="fa fa-warning"></i> Index not created',
  254. 'details' => 'Insufficient permissions to reindex the search engine database.'
  255. ];
  256. echo json_encode($json_response);
  257. exit;
  258. }
  259. // disable warnings
  260. error_reporting(1);
  261. // disable execution time
  262. set_time_limit(0);
  263. list($status, $msg, $output) = static::indexJob();
  264. $json_response = [
  265. 'status' => $status ? 'success' : 'error',
  266. 'message' => $msg
  267. ];
  268. echo json_encode($json_response);
  269. exit;
  270. }
  271. }
  272. /**
  273. * Perform an 'add' or 'update' for index data as needed
  274. *
  275. * @param Event $event
  276. * @return bool
  277. */
  278. public function onObjectSave($event): bool
  279. {
  280. if (defined('CLI_DISABLE_TNTSEARCH')) {
  281. return true;
  282. }
  283. $obj = $event['object'] ?: $event['page'];
  284. if ($obj) {
  285. $this->GravTNTSearch()->updateIndex($obj);
  286. }
  287. return true;
  288. }
  289. /**
  290. * Perform a 'delete' for index data as needed
  291. *
  292. * @param Event $event
  293. * @return bool
  294. */
  295. public function onObjectDelete($event): bool
  296. {
  297. if (defined('CLI_DISABLE_TNTSEARCH')) {
  298. return true;
  299. }
  300. $obj = $event['object'] ?: $event['page'];
  301. if ($obj) {
  302. $this->GravTNTSearch()->deleteIndex($obj);
  303. }
  304. return true;
  305. }
  306. /**
  307. * Set some twig vars and load CSS/JS assets for admin
  308. */
  309. public function onTwigAdminVariables(): void
  310. {
  311. $twig = $this->grav['twig'];
  312. $gtnt = $this->GravTNTSearch();
  313. [$status, $msg] = static::getIndexCount($gtnt);
  314. if ($status === false) {
  315. $message = '<i class="fa fa-binoculars"></i> <a href="/'. trim($this->admin_route, '/') . '/plugins/tntsearch">TNTSearch must be indexed before it will function properly.</a>';
  316. $this->grav['admin']->addTempMessage($message, 'error');
  317. }
  318. $twig->twig_vars['tntsearch_index_status'] = ['status' => $status, 'msg' => $msg];
  319. $this->grav['assets']->addCss('plugin://tntsearch/assets/admin/tntsearch.css');
  320. $this->grav['assets']->addJs('plugin://tntsearch/assets/admin/tntsearch.js');
  321. }
  322. /**
  323. * Add reindex button to the admin QuickTray
  324. */
  325. public function onAdminMenu(): void
  326. {
  327. $options = [
  328. 'authorize' => 'taskReindexTNTSearch',
  329. 'hint' => 'reindexes the TNT Search index',
  330. 'class' => 'tntsearch-reindex',
  331. 'icon' => 'fa-binoculars'
  332. ];
  333. $this->grav['twig']->plugins_quick_tray['TNT Search'] = $options;
  334. }
  335. /**
  336. * Wrapper to get the number of documents currently indexed
  337. *
  338. * @param GravTNTSearch $gtnt
  339. * @return array
  340. */
  341. protected static function getIndexCount($gtnt): array
  342. {
  343. $status = true;
  344. try {
  345. $msg = '';
  346. $gtnt->selectIndex();
  347. $doc_count = $gtnt->tnt->totalDocumentsInCollection();
  348. $language = Grav::instance()['language'];
  349. if ($language->enabled()) {
  350. $msg .= 'Processed ' . count($language->getLanguages()) . ' languages, each with ';
  351. }
  352. $msg .= $doc_count . ' documents reindexed';
  353. } catch (IndexNotFoundException $e) {
  354. $status = false;
  355. $msg = 'Index not created';
  356. }
  357. return [$status, $msg];
  358. }
  359. /**
  360. * Helper function to read form/url values
  361. *
  362. * @param string $val
  363. * @return mixed
  364. */
  365. protected function getFormValue($val)
  366. {
  367. $uri = $this->grav['uri'];
  368. return $uri->param($val) ?: $uri->query($val) ?: filter_input(INPUT_POST, $val, FILTER_SANITIZE_ENCODED);
  369. }
  370. /**
  371. * @param array $options
  372. * @return GravTNTSearch
  373. */
  374. public static function getSearchObjectType($options = [])
  375. {
  376. $type = 'Grav\\Plugin\\TNTSearch\\' . Grav::instance()['config']->get('plugins.tntsearch.search_object_type', 'Grav') . 'TNTSearch';
  377. if (class_exists($type)) {
  378. return new $type($options);
  379. }
  380. throw new \RuntimeException('Search class: ' . $type . ' does not exist');
  381. }
  382. /**
  383. * @param string|null $langCode
  384. * @return array
  385. */
  386. public static function indexJob(string $langCode = null)
  387. {
  388. $grav = Grav::instance();
  389. $grav['debugger']->enabled(false);
  390. /** @var Pages $pages */
  391. $pages = $grav['pages'];
  392. if (method_exists($pages, 'enablePages')) {
  393. $pages->enablePages();
  394. }
  395. ob_start();
  396. /** @var Language $language */
  397. $language = $grav['language'];
  398. $langEnabled = $language->enabled();
  399. // TODO: can be removed when Grav minimum >= v1.6.22
  400. $hasReset = method_exists($pages, 'reset');
  401. if (!$hasReset && !$langCode) {
  402. $langCode = $language->getActive();
  403. }
  404. if ($langCode && (!$langEnabled || !$language->validate($langCode))) {
  405. $langCode = null;
  406. }
  407. $langCodes = $langCode ? [$langCode] : $language->getLanguages();
  408. if ($langCodes) {
  409. foreach ($langCodes as $lang) {
  410. if ($lang !== $language->getActive()) {
  411. $language->init();
  412. $language->setActive($lang);
  413. // TODO: $hasReset test can be removed (keep reset!) when Grav minimum >= v1.6.22
  414. if ($hasReset) {
  415. $pages->reset();
  416. }
  417. }
  418. echo "\nLanguage: {$lang}\n";
  419. $gtnt = static::getSearchObjectType();
  420. $gtnt->createIndex();
  421. }
  422. } else {
  423. $gtnt = static::getSearchObjectType();
  424. $gtnt->createIndex();
  425. }
  426. $output = ob_get_clean();
  427. // Reset and get index count and status
  428. $gtnt = static::getSearchObjectType();
  429. [$status, $msg] = static::getIndexCount($gtnt);
  430. return [$status, $msg, $output];
  431. }
  432. /**
  433. * Helper to initialize TNTSearch if required
  434. *
  435. * @return GravTNTSearch
  436. */
  437. protected function GravTNTSearch()
  438. {
  439. if (!isset($this->grav['tntsearch'])) {
  440. $this->grav['tntsearch'] = static::getSearchObjectType();
  441. }
  442. return $this->grav['tntsearch'];
  443. }
  444. }