GravTNTSearch.php 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
  1. <?php
  2. namespace Grav\Plugin\TNTSearch;
  3. use Grav\Common\Config\Config;
  4. use Grav\Common\Grav;
  5. use Grav\Common\Language\Language;
  6. use Grav\Common\Page\Interfaces\PageInterface;
  7. use Grav\Common\Page\Pages;
  8. use Grav\Common\Twig\Twig;
  9. use Grav\Common\Uri;
  10. use Grav\Common\Yaml;
  11. use Grav\Common\Page\Collection;
  12. use Grav\Common\Page\Page;
  13. use RocketTheme\Toolbox\Event\Event;
  14. use RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator;
  15. use TeamTNT\TNTSearch\Exceptions\IndexNotFoundException;
  16. use TeamTNT\TNTSearch\TNTSearch;
  17. class GravTNTSearch
  18. {
  19. /** @var TNTSearch */
  20. public $tnt;
  21. /** @var array */
  22. protected $options;
  23. /** @var string[] */
  24. protected $bool_characters = ['-', '(', ')', 'or'];
  25. /** @var string */
  26. protected $index = 'grav.index';
  27. /** @var false|string */
  28. protected $language;
  29. /**
  30. * GravTNTSearch constructor.
  31. * @param array $options
  32. */
  33. public function __construct($options = [])
  34. {
  35. /** @var Config $config */
  36. $config = Grav::instance()['config'];
  37. /** @var UniformResourceLocator $locator */
  38. $locator = Grav::instance()['locator'];
  39. $search_type = $config->get('plugins.tntsearch.search_type', 'auto');
  40. $fuzzy = $config->get('plugins.tntsearch.fuzzy', false);
  41. $distance = $config->get('plugins.tntsearch.distance', 2);
  42. $stemmer = $config->get('plugins.tntsearch.stemmer', 'no');
  43. $limit = $config->get('plugins.tntsearch.limit', 20);
  44. $snippet = $config->get('plugins.tntsearch.snippet', 300);
  45. $data_path = $locator->findResource('user://data', true) . '/tntsearch';
  46. /** @var Language $language */
  47. $language = Grav::instance()['language'];
  48. if ($language->enabled()) {
  49. $active = $language->getActive();
  50. $default = $language->getDefault();
  51. $this->language = $active ?: $default;
  52. $this->index = $this->language . '.index';
  53. }
  54. if (!file_exists($data_path)) {
  55. mkdir($data_path);
  56. }
  57. $defaults = [
  58. 'json' => false,
  59. 'search_type' => $search_type,
  60. 'fuzzy' => $fuzzy,
  61. 'distance' => $distance,
  62. 'stemmer' => $stemmer,
  63. 'limit' => $limit,
  64. 'as_you_type' => true,
  65. 'snippet' => $snippet,
  66. 'phrases' => true,
  67. ];
  68. $this->options = array_replace($defaults, $options);
  69. $this->tnt = new TNTSearch();
  70. $this->tnt->loadConfig(
  71. [
  72. 'storage' => $data_path,
  73. 'driver' => 'sqlite',
  74. 'charset' => 'utf8'
  75. ]
  76. );
  77. }
  78. /**
  79. * @param string $query
  80. * @return object|string
  81. * @throws IndexNotFoundException
  82. */
  83. public function search($query)
  84. {
  85. /** @var Uri $uri */
  86. $uri = Grav::instance()['uri'];
  87. $type = $uri->query('search_type');
  88. $this->tnt->selectIndex($this->index);
  89. $this->tnt->asYouType = $this->options['as_you_type'];
  90. if (isset($this->options['fuzzy']) && $this->options['fuzzy']) {
  91. $this->tnt->fuzziness = true;
  92. $this->tnt->fuzzy_distance = $this->options['distance'];
  93. }
  94. $limit = (int)$this->options['limit'];
  95. $type = $type ?? $this->options['search_type'];
  96. // TODO: Multiword parameter has been removed from $tnt->search(), please check if this works
  97. $multiword = null;
  98. if (isset($this->options['phrases']) && $this->options['phrases']) {
  99. if (strlen($query) > 2) {
  100. if ($query[0] === '"' && $query[strlen($query) - 1] === '"') {
  101. $multiword = substr($query, 1, -1);
  102. $type = 'basic';
  103. $query = $multiword;
  104. }
  105. }
  106. }
  107. switch ($type) {
  108. case 'basic':
  109. $results = $this->tnt->search($query, $limit);
  110. break;
  111. case 'boolean':
  112. $results = $this->tnt->searchBoolean($query, $limit);
  113. break;
  114. case 'default':
  115. case 'auto':
  116. default:
  117. $guess = 'search';
  118. foreach ($this->bool_characters as $char) {
  119. if (strpos($query, $char) !== false) {
  120. $guess = 'searchBoolean';
  121. break;
  122. }
  123. }
  124. $results = $this->tnt->{$guess}($query, $limit);
  125. }
  126. return $this->processResults($results, $query);
  127. }
  128. /**
  129. * @param array $res
  130. * @param string $query
  131. * @return object|string
  132. */
  133. protected function processResults($res, $query)
  134. {
  135. $data = new \stdClass();
  136. $data->number_of_hits = $res['hits'] ?? 0;
  137. $data->execution_time = $res['execution_time'];
  138. /** @var Pages $pages */
  139. $pages = Grav::instance()['pages'];
  140. $counter = 0;
  141. foreach ($res['ids'] as $path) {
  142. if ($counter++ > $this->options['limit']) {
  143. break;
  144. }
  145. $page = $pages->find($path);
  146. if ($page) {
  147. $event = new Event(
  148. [
  149. 'page' => $page,
  150. 'query' => $query,
  151. 'options' => $this->options,
  152. 'fields' => $data,
  153. 'gtnt' => $this
  154. ]
  155. );
  156. Grav::instance()->fireEvent('onTNTSearchQuery', $event);
  157. }
  158. }
  159. if ($this->options['json']) {
  160. return json_encode($data, JSON_PRETTY_PRINT) ?: '';
  161. }
  162. return $data;
  163. }
  164. /**
  165. * @param PageInterface $page
  166. * @return string
  167. */
  168. public static function getCleanContent($page)
  169. {
  170. $grav = Grav::instance();
  171. $activePage = $grav['page'];
  172. // Set active page in grav to the one we are currently processing.
  173. unset($grav['page']);
  174. $grav['page'] = $page;
  175. /** @var Twig $twig */
  176. $twig = $grav['twig'];
  177. $header = $page->header();
  178. // @phpstan-ignore-next-line
  179. if (isset($header->tntsearch['template'])) {
  180. $processed_page = $twig->processTemplate($header->tntsearch['template'] . '.html.twig', ['page' => $page]);
  181. $content = $processed_page;
  182. } else {
  183. $content = $page->content();
  184. }
  185. $content = strip_tags($content);
  186. $content = preg_replace(['/[ \t]+/', '/\s*$^\s*/m'], [' ', "\n"], $content) ?? $content;
  187. // Restore active page in Grav.
  188. unset($grav['page']);
  189. $grav['page'] = $activePage;
  190. return $content;
  191. }
  192. /**
  193. * @return void
  194. */
  195. public function createIndex()
  196. {
  197. $this->tnt->setDatabaseHandle(new GravConnector);
  198. $indexer = $this->tnt->createIndex($this->index);
  199. // Disable stemmer for users with older configuration.
  200. if ($this->options['stemmer'] == 'default') {
  201. $indexer->setLanguage('no');
  202. } else {
  203. $indexer->setLanguage($this->options['stemmer']);
  204. }
  205. $indexer->run();
  206. }
  207. /**
  208. * @return void
  209. * @throws IndexNotFoundException
  210. */
  211. public function selectIndex()
  212. {
  213. $this->tnt->selectIndex($this->index);
  214. }
  215. /**
  216. * @param object $object
  217. * @return void
  218. */
  219. public function deleteIndex($object)
  220. {
  221. if (!$object instanceof Page) {
  222. return;
  223. }
  224. $this->tnt->setDatabaseHandle(new GravConnector);
  225. try {
  226. $this->tnt->selectIndex($this->index);
  227. } catch (IndexNotFoundException $e) {
  228. return;
  229. }
  230. $indexer = $this->tnt->getIndex();
  231. // Delete existing if it exists
  232. $indexer->delete($object->route());
  233. }
  234. /**
  235. * @param object $object
  236. * @return void
  237. */
  238. public function updateIndex($object)
  239. {
  240. if (!$object instanceof Page) {
  241. return;
  242. }
  243. $this->tnt->setDatabaseHandle(new GravConnector);
  244. try {
  245. $this->tnt->selectIndex($this->index);
  246. } catch (IndexNotFoundException $e) {
  247. return;
  248. }
  249. $indexer = $this->tnt->getIndex();
  250. // Delete existing if it exists
  251. $indexer->delete($object->route());
  252. $filter = Grav::instance()['config']->get('plugins.tntsearch.filter');
  253. if ($filter && array_key_exists('items', $filter)) {
  254. if (is_string($filter['items'])) {
  255. $filter['items'] = Yaml::parse($filter['items']);
  256. }
  257. $apage = new Page;
  258. /** @var Collection $collection */
  259. $collection = $apage->collection($filter, false);
  260. $path = $object->path();
  261. if ($path && array_key_exists($path, $collection->toArray())) {
  262. $fields = $this->indexPageData($object);
  263. $document = (array) $fields;
  264. // Insert document
  265. $indexer->insert($document);
  266. }
  267. }
  268. }
  269. /**
  270. * @param PageInterface $page
  271. * @return object
  272. */
  273. public function indexPageData($page)
  274. {
  275. $header = (array) $page->header();
  276. $redirect = (bool) $page->redirect();
  277. if (!$page->published()) {
  278. throw new \RuntimeException('not published...');
  279. }
  280. if (!$page->routable()) {
  281. throw new \RuntimeException('not routable...');
  282. }
  283. if ($redirect || (isset($header['tntsearch']['index']) && $header['tntsearch']['index'] === false )) {
  284. throw new \RuntimeException('redirect only...');
  285. }
  286. $route = $page->route();
  287. $fields = new \stdClass();
  288. $fields->id = $route;
  289. $fields->name = $page->title();
  290. $fields->content = static::getCleanContent($page);
  291. Grav::instance()->fireEvent('onTNTSearchIndex', new Event(['page' => $page, 'fields' => $fields]));
  292. return $fields;
  293. }
  294. }