1<?php 2 3namespace Drupal\help_topics\Plugin\Search; 4 5use Drupal\Core\Access\AccessibleInterface; 6use Drupal\Core\Access\AccessResult; 7use Drupal\Core\Config\Config; 8use Drupal\Core\Database\Connection; 9use Drupal\Core\Database\Query\Condition; 10use Drupal\Core\Database\Query\PagerSelectExtender; 11use Drupal\Core\Database\StatementInterface; 12use Drupal\Core\Language\LanguageInterface; 13use Drupal\Core\Language\LanguageManagerInterface; 14use Drupal\Core\Messenger\MessengerInterface; 15use Drupal\Core\Session\AccountInterface; 16use Drupal\Core\State\StateInterface; 17use Drupal\help\HelpSectionManager; 18use Drupal\help_topics\SearchableHelpInterface; 19use Drupal\search\Plugin\SearchIndexingInterface; 20use Drupal\search\Plugin\SearchPluginBase; 21use Drupal\search\SearchIndexInterface; 22use Drupal\search\SearchQuery; 23use Symfony\Component\DependencyInjection\ContainerInterface; 24 25/** 26 * Handles searching for help using the Search module index. 27 * 28 * Help items are indexed if their HelpSection plugin implements 29 * \Drupal\help\HelpSearchInterface. 30 * 31 * @see \Drupal\help\HelpSearchInterface 32 * @see \Drupal\help\HelpSectionPluginInterface 33 * 34 * @SearchPlugin( 35 * id = "help_search", 36 * title = @Translation("Help") 37 * ) 38 * 39 * @internal 40 * Help Topics is currently experimental and should only be leveraged by 41 * experimental modules and development releases of contributed modules. 42 * See https://www.drupal.org/core/experimental for more information. 43 */ 44class HelpSearch extends SearchPluginBase implements AccessibleInterface, SearchIndexingInterface { 45 46 /** 47 * The current database connection. 48 * 49 * @var \Drupal\Core\Database\Connection 50 */ 51 protected $database; 52 53 /** 54 * A config object for 'search.settings'. 55 * 56 * @var \Drupal\Core\Config\Config 57 */ 58 protected $searchSettings; 59 60 /** 61 * The language manager. 62 * 63 * @var \Drupal\Core\Language\LanguageManagerInterface 64 */ 65 protected $languageManager; 66 67 /** 68 * The Drupal account to use for checking for access to search. 69 * 70 * @var \Drupal\Core\Session\AccountInterface 71 */ 72 protected $account; 73 74 /** 75 * The messenger. 76 * 77 * @var \Drupal\Core\Messenger\MessengerInterface 78 */ 79 protected $messenger; 80 81 /** 82 * The state object. 83 * 84 * @var \Drupal\Core\State\StateInterface 85 */ 86 protected $state; 87 88 /** 89 * The help section plugin manager. 90 * 91 * @var \Drupal\help\HelpSectionManager 92 */ 93 protected $helpSectionManager; 94 95 /** 96 * The search index. 97 * 98 * @var \Drupal\search\SearchIndexInterface 99 */ 100 protected $searchIndex; 101 102 /** 103 * {@inheritdoc} 104 */ 105 public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { 106 return new static( 107 $configuration, 108 $plugin_id, 109 $plugin_definition, 110 $container->get('database'), 111 $container->get('config.factory')->get('search.settings'), 112 $container->get('language_manager'), 113 $container->get('messenger'), 114 $container->get('current_user'), 115 $container->get('state'), 116 $container->get('plugin.manager.help_section'), 117 $container->get('search.index') 118 ); 119 } 120 121 /** 122 * Constructs a \Drupal\help_search\Plugin\Search\HelpSearch object. 123 * 124 * @param array $configuration 125 * Configuration for the plugin. 126 * @param string $plugin_id 127 * The plugin_id for the plugin instance. 128 * @param mixed $plugin_definition 129 * The plugin implementation definition. 130 * @param \Drupal\Core\Database\Connection $database 131 * The current database connection. 132 * @param \Drupal\Core\Config\Config $search_settings 133 * A config object for 'search.settings'. 134 * @param \Drupal\Core\Language\LanguageManagerInterface $language_manager 135 * The language manager. 136 * @param \Drupal\Core\Messenger\MessengerInterface $messenger 137 * The messenger. 138 * @param \Drupal\Core\Session\AccountInterface $account 139 * The $account object to use for checking for access to view help. 140 * @param \Drupal\Core\State\StateInterface $state 141 * The state object. 142 * @param \Drupal\help\HelpSectionManager $help_section_manager 143 * The help section manager. 144 * @param \Drupal\search\SearchIndexInterface $search_index 145 * The search index. 146 */ 147 public function __construct(array $configuration, $plugin_id, $plugin_definition, Connection $database, Config $search_settings, LanguageManagerInterface $language_manager, MessengerInterface $messenger, AccountInterface $account, StateInterface $state, HelpSectionManager $help_section_manager, SearchIndexInterface $search_index) { 148 parent::__construct($configuration, $plugin_id, $plugin_definition); 149 $this->database = $database; 150 $this->searchSettings = $search_settings; 151 $this->languageManager = $language_manager; 152 $this->messenger = $messenger; 153 $this->account = $account; 154 $this->state = $state; 155 $this->helpSectionManager = $help_section_manager; 156 $this->searchIndex = $search_index; 157 } 158 159 /** 160 * {@inheritdoc} 161 */ 162 public function access($operation = 'view', AccountInterface $account = NULL, $return_as_object = FALSE) { 163 $result = AccessResult::allowedIfHasPermission($account, 'access administration pages'); 164 return $return_as_object ? $result : $result->isAllowed(); 165 } 166 167 /** 168 * {@inheritdoc} 169 */ 170 public function getType() { 171 return $this->getPluginId(); 172 } 173 174 /** 175 * {@inheritdoc} 176 */ 177 public function execute() { 178 if ($this->isSearchExecutable()) { 179 $results = $this->findResults(); 180 181 if ($results) { 182 return $this->prepareResults($results); 183 } 184 } 185 186 return []; 187 } 188 189 /** 190 * Finds the search results. 191 * 192 * @return \Drupal\Core\Database\StatementInterface|null 193 * Results from search query execute() method, or NULL if the search 194 * failed. 195 */ 196 protected function findResults() { 197 // We need to check access for the current user to see the topics that 198 // could be returned by search. Each entry in the help_search_items 199 // database has an optional permission that comes from the HelpSection 200 // plugin, in addition to the generic 'access administration pages' 201 // permission. In order to enforce these permissions so only topics that 202 // the current user has permission to view are selected by the query, make 203 // a list of the permission strings and pre-check those permissions. 204 $this->addCacheContexts(['user.permissions']); 205 if (!$this->account->hasPermission('access administration pages')) { 206 return NULL; 207 } 208 $permissions = $this->database 209 ->select('help_search_items', 'hsi') 210 ->distinct() 211 ->fields('hsi', ['permission']) 212 ->condition('permission', '', '<>') 213 ->execute() 214 ->fetchCol(); 215 $denied_permissions = array_filter($permissions, function ($permission) { 216 return !$this->account->hasPermission($permission); 217 }); 218 219 $query = $this->database 220 ->select('search_index', 'i') 221 // Restrict the search to the current interface language. 222 ->condition('i.langcode', $this->languageManager->getCurrentLanguage()->getId()) 223 ->extend(SearchQuery::class) 224 ->extend(PagerSelectExtender::class); 225 $query->innerJoin('help_search_items', 'hsi', 'i.sid = hsi.sid AND i.type = :type', [':type' => $this->getType()]); 226 if ($denied_permissions) { 227 $query->condition('hsi.permission', $denied_permissions, 'NOT IN'); 228 } 229 $query->searchExpression($this->getKeywords(), $this->getType()); 230 231 $find = $query 232 ->fields('i', ['langcode']) 233 ->fields('hsi', ['section_plugin_id', 'topic_id']) 234 // Since SearchQuery makes these into GROUP BY queries, if we add 235 // a field, for PostgreSQL we also need to make it an aggregate or a 236 // GROUP BY. In this case, we want GROUP BY. 237 ->groupBy('i.langcode') 238 ->groupBy('hsi.section_plugin_id') 239 ->groupBy('hsi.topic_id') 240 ->limit(10) 241 ->execute(); 242 243 // Check query status and set messages if needed. 244 $status = $query->getStatus(); 245 246 if ($status & SearchQuery::EXPRESSIONS_IGNORED) { 247 $this->messenger->addWarning($this->t('Your search used too many AND/OR expressions. Only the first @count terms were included in this search.', ['@count' => $this->searchSettings->get('and_or_limit')])); 248 } 249 250 if ($status & SearchQuery::LOWER_CASE_OR) { 251 $this->messenger->addWarning($this->t('Search for either of the two terms with uppercase <strong>OR</strong>. For example, <strong>cats OR dogs</strong>.')); 252 } 253 254 if ($status & SearchQuery::NO_POSITIVE_KEYWORDS) { 255 $this->messenger->addWarning($this->formatPlural($this->searchSettings->get('index.minimum_word_size'), 'You must include at least one keyword to match in the content, and punctuation is ignored.', 'You must include at least one keyword to match in the content. Keywords must be at least @count characters, and punctuation is ignored.')); 256 } 257 258 return $find; 259 } 260 261 /** 262 * Prepares search results for display. 263 * 264 * @param \Drupal\Core\Database\StatementInterface $found 265 * Results found from a successful search query execute() method. 266 * 267 * @return array 268 * List of search result render arrays, with links, snippets, etc. 269 */ 270 protected function prepareResults(StatementInterface $found) { 271 $results = []; 272 $plugins = []; 273 $languages = []; 274 $keys = $this->getKeywords(); 275 foreach ($found as $item) { 276 $section_plugin_id = $item->section_plugin_id; 277 if (!isset($plugins[$section_plugin_id])) { 278 $plugins[$section_plugin_id] = $this->getSectionPlugin($section_plugin_id); 279 } 280 if ($plugins[$section_plugin_id]) { 281 $langcode = $item->langcode; 282 if (!isset($languages[$langcode])) { 283 $languages[$langcode] = $this->languageManager->getLanguage($item->langcode); 284 } 285 $topic = $plugins[$section_plugin_id]->renderTopicForSearch($item->topic_id, $languages[$langcode]); 286 if ($topic) { 287 if (isset($topic['cacheable_metadata'])) { 288 $this->addCacheableDependency($topic['cacheable_metadata']); 289 } 290 $results[] = [ 291 'title' => $topic['title'], 292 'link' => $topic['url']->toString(), 293 'snippet' => search_excerpt($keys, $topic['title'] . ' ' . $topic['text'], $item->langcode), 294 'langcode' => $item->langcode, 295 ]; 296 } 297 } 298 } 299 300 return $results; 301 } 302 303 /** 304 * {@inheritdoc} 305 */ 306 public function updateIndex() { 307 // Update the list of items to be indexed. 308 $this->updateTopicList(); 309 310 // Find some items that need to be updated. Start with ones that have 311 // never been indexed. 312 $limit = (int) $this->searchSettings->get('index.cron_limit'); 313 314 $query = $this->database->select('help_search_items', 'hsi'); 315 $query->fields('hsi', ['sid', 'section_plugin_id', 'topic_id']); 316 $query->leftJoin('search_dataset', 'sd', 'sd.sid = hsi.sid AND sd.type = :type', [':type' => $this->getType()]); 317 $query->where('sd.sid IS NULL'); 318 $query->groupBy('hsi.sid') 319 ->groupBy('hsi.section_plugin_id') 320 ->groupBy('hsi.topic_id') 321 ->range(0, $limit); 322 $items = $query->execute()->fetchAll(); 323 324 // If there is still space in the indexing limit, index items that have 325 // been indexed before, but are currently marked as needing a re-index. 326 if (count($items) < $limit) { 327 $query = $this->database->select('help_search_items', 'hsi'); 328 $query->fields('hsi', ['sid', 'section_plugin_id', 'topic_id']); 329 $query->leftJoin('search_dataset', 'sd', 'sd.sid = hsi.sid AND sd.type = :type', [':type' => $this->getType()]); 330 $query->condition('sd.reindex', 0, '<>'); 331 $query->groupBy('hsi.sid') 332 ->groupBy('hsi.section_plugin_id') 333 ->groupBy('hsi.topic_id') 334 ->range(0, $limit - count($items)); 335 $items = $items + $query->execute()->fetchAll(); 336 } 337 338 // Index the items we have chosen, in all available languages. 339 $language_list = $this->languageManager->getLanguages(LanguageInterface::STATE_CONFIGURABLE); 340 $section_plugins = []; 341 342 $words = []; 343 try { 344 foreach ($items as $item) { 345 $section_plugin_id = $item->section_plugin_id; 346 if (!isset($section_plugins[$section_plugin_id])) { 347 $section_plugins[$section_plugin_id] = $this->getSectionPlugin($section_plugin_id); 348 } 349 350 if (!$section_plugins[$section_plugin_id]) { 351 $this->removeItemsFromIndex($item->sid); 352 continue; 353 } 354 355 $section_plugin = $section_plugins[$section_plugin_id]; 356 $this->searchIndex->clear($this->getType(), $item->sid); 357 foreach ($language_list as $langcode => $language) { 358 $topic = $section_plugin->renderTopicForSearch($item->topic_id, $language); 359 if ($topic) { 360 // Index the title plus body text. 361 $text = '<h1>' . $topic['title'] . '</h1>' . "\n" . $topic['text']; 362 $words += $this->searchIndex->index($this->getType(), $item->sid, $langcode, $text, FALSE); 363 } 364 } 365 } 366 } 367 finally { 368 $this->searchIndex->updateWordWeights($words); 369 } 370 } 371 372 /** 373 * {@inheritdoc} 374 */ 375 public function indexClear() { 376 $this->searchIndex->clear($this->getType()); 377 } 378 379 /** 380 * Rebuilds the database table containing topics to be indexed. 381 */ 382 public function updateTopicList() { 383 // Start by fetching the existing list, so we can remove items not found 384 // at the end. 385 $old_list = $this->database->select('help_search_items', 'hsi') 386 ->fields('hsi', ['sid', 'topic_id', 'section_plugin_id', 'permission']) 387 ->execute(); 388 $old_list_ordered = []; 389 $sids_to_remove = []; 390 foreach ($old_list as $item) { 391 $old_list_ordered[$item->section_plugin_id][$item->topic_id] = $item; 392 $sids_to_remove[$item->sid] = $item->sid; 393 } 394 395 $section_plugins = $this->helpSectionManager->getDefinitions(); 396 foreach ($section_plugins as $section_plugin_id => $section_plugin_definition) { 397 $plugin = $this->getSectionPlugin($section_plugin_id); 398 if (!$plugin) { 399 continue; 400 } 401 $permission = $section_plugin_definition['permission'] ?? ''; 402 foreach ($plugin->listSearchableTopics() as $topic_id) { 403 if (isset($old_list_ordered[$section_plugin_id][$topic_id])) { 404 $old_item = $old_list_ordered[$section_plugin_id][$topic_id]; 405 if ($old_item->permission == $permission) { 406 // Record has not changed. 407 unset($sids_to_remove[$old_item->sid]); 408 continue; 409 } 410 411 // Permission has changed, update record. 412 $this->database->update('help_search_items') 413 ->condition('sid', $old_item->sid) 414 ->fields(['permission' => $permission]) 415 ->execute(); 416 unset($sids_to_remove[$old_item->sid]); 417 continue; 418 } 419 420 // New record, create it. 421 $this->database->insert('help_search_items') 422 ->fields([ 423 'section_plugin_id' => $section_plugin_id, 424 'permission' => $permission, 425 'topic_id' => $topic_id, 426 ]) 427 ->execute(); 428 } 429 } 430 431 // Remove remaining items from the index. 432 $this->removeItemsFromIndex($sids_to_remove); 433 } 434 435 /** 436 * {@inheritdoc} 437 */ 438 public function markForReindex() { 439 $this->updateTopicList(); 440 $this->searchIndex->markForReindex($this->getType()); 441 } 442 443 /** 444 * {@inheritdoc} 445 */ 446 public function indexStatus() { 447 $this->updateTopicList(); 448 $total = $this->database->select('help_search_items', 'hsi') 449 ->countQuery() 450 ->execute() 451 ->fetchField(); 452 453 $query = $this->database->select('help_search_items', 'hsi'); 454 $query->addExpression('COUNT(DISTINCT(hsi.sid))'); 455 $query->leftJoin('search_dataset', 'sd', 'hsi.sid = sd.sid AND sd.type = :type', [':type' => $this->getType()]); 456 $condition = new Condition('OR'); 457 $condition->condition('sd.reindex', 0, '<>') 458 ->isNull('sd.sid'); 459 $query->condition($condition); 460 $remaining = $query->execute()->fetchField(); 461 462 return [ 463 'remaining' => $remaining, 464 'total' => $total, 465 ]; 466 } 467 468 /** 469 * Removes an item or items from the search index. 470 * 471 * @param int|int[] $sids 472 * Search ID (sid) of item or items to remove. 473 */ 474 protected function removeItemsFromIndex($sids) { 475 $sids = (array) $sids; 476 477 // Remove items from our table in batches of 100, to avoid problems 478 // with having too many placeholders in database queries. 479 foreach (array_chunk($sids, 100) as $this_list) { 480 $this->database->delete('help_search_items') 481 ->condition('sid', $this_list, 'IN') 482 ->execute(); 483 } 484 // Remove items from the search tables individually, as there is no bulk 485 // function to delete items from the search index. 486 foreach ($sids as $sid) { 487 $this->searchIndex->clear($this->getType(), $sid); 488 } 489 } 490 491 /** 492 * Instantiates a help section plugin and verifies it is searchable. 493 * 494 * @param string $section_plugin_id 495 * Type of plugin to instantiate. 496 * 497 * @return \Drupal\help_topics\SearchableHelpInterface|false 498 * Plugin object, or FALSE if it is not searchable. 499 */ 500 protected function getSectionPlugin($section_plugin_id) { 501 /** @var \Drupal\help\HelpSectionPluginInterface $section_plugin */ 502 $section_plugin = $this->helpSectionManager->createInstance($section_plugin_id); 503 // Intentionally return boolean to allow caching of results. 504 return $section_plugin instanceof SearchableHelpInterface ? $section_plugin : FALSE; 505 } 506 507} 508