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\PagerSelectExtender; 10use Drupal\Core\Database\StatementInterface; 11use Drupal\Core\Language\LanguageInterface; 12use Drupal\Core\Language\LanguageManagerInterface; 13use Drupal\Core\Messenger\MessengerInterface; 14use Drupal\Core\Session\AccountInterface; 15use Drupal\Core\State\StateInterface; 16use Drupal\help\HelpSectionManager; 17use Drupal\help_topics\SearchableHelpInterface; 18use Drupal\search\Plugin\SearchIndexingInterface; 19use Drupal\search\Plugin\SearchPluginBase; 20use Drupal\search\SearchIndexInterface; 21use Drupal\search\SearchQuery; 22use Symfony\Component\DependencyInjection\ContainerInterface; 23 24/** 25 * Handles searching for help using the Search module index. 26 * 27 * Help items are indexed if their HelpSection plugin implements 28 * \Drupal\help\HelpSearchInterface. 29 * 30 * @see \Drupal\help\HelpSearchInterface 31 * @see \Drupal\help\HelpSectionPluginInterface 32 * 33 * @SearchPlugin( 34 * id = "help_search", 35 * title = @Translation("Help"), 36 * use_admin_theme = TRUE, 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 $unindexed = $this->state->get('help_search_unindexed_count', 1); 259 if ($unindexed) { 260 $this->messenger()->addWarning($this->t('Help search is not fully indexed. Some results may be missing or incorrect.')); 261 } 262 263 return $find; 264 } 265 266 /** 267 * Prepares search results for display. 268 * 269 * @param \Drupal\Core\Database\StatementInterface $found 270 * Results found from a successful search query execute() method. 271 * 272 * @return array 273 * List of search result render arrays, with links, snippets, etc. 274 */ 275 protected function prepareResults(StatementInterface $found) { 276 $results = []; 277 $plugins = []; 278 $languages = []; 279 $keys = $this->getKeywords(); 280 foreach ($found as $item) { 281 $section_plugin_id = $item->section_plugin_id; 282 if (!isset($plugins[$section_plugin_id])) { 283 $plugins[$section_plugin_id] = $this->getSectionPlugin($section_plugin_id); 284 } 285 if ($plugins[$section_plugin_id]) { 286 $langcode = $item->langcode; 287 if (!isset($languages[$langcode])) { 288 $languages[$langcode] = $this->languageManager->getLanguage($item->langcode); 289 } 290 $topic = $plugins[$section_plugin_id]->renderTopicForSearch($item->topic_id, $languages[$langcode]); 291 if ($topic) { 292 if (isset($topic['cacheable_metadata'])) { 293 $this->addCacheableDependency($topic['cacheable_metadata']); 294 } 295 $results[] = [ 296 'title' => $topic['title'], 297 'link' => $topic['url']->toString(), 298 'snippet' => search_excerpt($keys, $topic['title'] . ' ' . $topic['text'], $item->langcode), 299 'langcode' => $item->langcode, 300 ]; 301 } 302 } 303 } 304 305 return $results; 306 } 307 308 /** 309 * {@inheritdoc} 310 */ 311 public function updateIndex() { 312 // Update the list of items to be indexed. 313 $this->updateTopicList(); 314 315 // Find some items that need to be updated. Start with ones that have 316 // never been indexed. 317 $limit = (int) $this->searchSettings->get('index.cron_limit'); 318 319 $query = $this->database->select('help_search_items', 'hsi'); 320 $query->fields('hsi', ['sid', 'section_plugin_id', 'topic_id']); 321 $query->leftJoin('search_dataset', 'sd', '[sd].[sid] = [hsi].[sid] AND [sd].[type] = :type', [':type' => $this->getType()]); 322 $query->where('[sd].[sid] IS NULL'); 323 $query->groupBy('hsi.sid') 324 ->groupBy('hsi.section_plugin_id') 325 ->groupBy('hsi.topic_id') 326 ->range(0, $limit); 327 $items = $query->execute()->fetchAll(); 328 329 // If there is still space in the indexing limit, index items that have 330 // been indexed before, but are currently marked as needing a re-index. 331 if (count($items) < $limit) { 332 $query = $this->database->select('help_search_items', 'hsi'); 333 $query->fields('hsi', ['sid', 'section_plugin_id', 'topic_id']); 334 $query->leftJoin('search_dataset', 'sd', '[sd].[sid] = [hsi].[sid] AND [sd].[type] = :type', [':type' => $this->getType()]); 335 $query->condition('sd.reindex', 0, '<>'); 336 $query->groupBy('hsi.sid') 337 ->groupBy('hsi.section_plugin_id') 338 ->groupBy('hsi.topic_id') 339 ->range(0, $limit - count($items)); 340 $items = $items + $query->execute()->fetchAll(); 341 } 342 343 // Index the items we have chosen, in all available languages. 344 $language_list = $this->languageManager->getLanguages(LanguageInterface::STATE_CONFIGURABLE); 345 $section_plugins = []; 346 347 $words = []; 348 try { 349 foreach ($items as $item) { 350 $section_plugin_id = $item->section_plugin_id; 351 if (!isset($section_plugins[$section_plugin_id])) { 352 $section_plugins[$section_plugin_id] = $this->getSectionPlugin($section_plugin_id); 353 } 354 355 if (!$section_plugins[$section_plugin_id]) { 356 $this->removeItemsFromIndex($item->sid); 357 continue; 358 } 359 360 $section_plugin = $section_plugins[$section_plugin_id]; 361 $this->searchIndex->clear($this->getType(), $item->sid); 362 foreach ($language_list as $langcode => $language) { 363 $topic = $section_plugin->renderTopicForSearch($item->topic_id, $language); 364 if ($topic) { 365 // Index the title plus body text. 366 $text = '<h1>' . $topic['title'] . '</h1>' . "\n" . $topic['text']; 367 $words += $this->searchIndex->index($this->getType(), $item->sid, $langcode, $text, FALSE); 368 } 369 } 370 } 371 } 372 finally { 373 $this->searchIndex->updateWordWeights($words); 374 $this->updateIndexState(); 375 } 376 } 377 378 /** 379 * {@inheritdoc} 380 */ 381 public function indexClear() { 382 $this->searchIndex->clear($this->getType()); 383 } 384 385 /** 386 * Rebuilds the database table containing topics to be indexed. 387 */ 388 public function updateTopicList() { 389 // Start by fetching the existing list, so we can remove items not found 390 // at the end. 391 $old_list = $this->database->select('help_search_items', 'hsi') 392 ->fields('hsi', ['sid', 'topic_id', 'section_plugin_id', 'permission']) 393 ->execute(); 394 $old_list_ordered = []; 395 $sids_to_remove = []; 396 foreach ($old_list as $item) { 397 $old_list_ordered[$item->section_plugin_id][$item->topic_id] = $item; 398 $sids_to_remove[$item->sid] = $item->sid; 399 } 400 401 $section_plugins = $this->helpSectionManager->getDefinitions(); 402 foreach ($section_plugins as $section_plugin_id => $section_plugin_definition) { 403 $plugin = $this->getSectionPlugin($section_plugin_id); 404 if (!$plugin) { 405 continue; 406 } 407 $permission = $section_plugin_definition['permission'] ?? ''; 408 foreach ($plugin->listSearchableTopics() as $topic_id) { 409 if (isset($old_list_ordered[$section_plugin_id][$topic_id])) { 410 $old_item = $old_list_ordered[$section_plugin_id][$topic_id]; 411 if ($old_item->permission == $permission) { 412 // Record has not changed. 413 unset($sids_to_remove[$old_item->sid]); 414 continue; 415 } 416 417 // Permission has changed, update record. 418 $this->database->update('help_search_items') 419 ->condition('sid', $old_item->sid) 420 ->fields(['permission' => $permission]) 421 ->execute(); 422 unset($sids_to_remove[$old_item->sid]); 423 continue; 424 } 425 426 // New record, create it. 427 $this->database->insert('help_search_items') 428 ->fields([ 429 'section_plugin_id' => $section_plugin_id, 430 'permission' => $permission, 431 'topic_id' => $topic_id, 432 ]) 433 ->execute(); 434 } 435 } 436 437 // Remove remaining items from the index. 438 $this->removeItemsFromIndex($sids_to_remove); 439 } 440 441 /** 442 * Updates the 'help_search_unindexed_count' state variable. 443 * 444 * The state variable is a count of help topics that have never been indexed. 445 */ 446 public function updateIndexState() { 447 $query = $this->database->select('help_search_items', 'hsi'); 448 $query->addExpression('COUNT(DISTINCT(hsi.sid))'); 449 $query->leftJoin('search_dataset', 'sd', 'hsi.sid = sd.sid AND sd.type = :type', [':type' => $this->getType()]); 450 $query->isNull('sd.sid'); 451 $never_indexed = $query->execute()->fetchField(); 452 $this->state->set('help_search_unindexed_count', $never_indexed); 453 } 454 455 /** 456 * {@inheritdoc} 457 */ 458 public function markForReindex() { 459 $this->updateTopicList(); 460 $this->searchIndex->markForReindex($this->getType()); 461 } 462 463 /** 464 * {@inheritdoc} 465 */ 466 public function indexStatus() { 467 $this->updateTopicList(); 468 $total = $this->database->select('help_search_items', 'hsi') 469 ->countQuery() 470 ->execute() 471 ->fetchField(); 472 473 $query = $this->database->select('help_search_items', 'hsi'); 474 $query->addExpression('COUNT(DISTINCT([hsi].[sid]))'); 475 $query->leftJoin('search_dataset', 'sd', '[hsi].[sid] = [sd].[sid] AND [sd].[type] = :type', [':type' => $this->getType()]); 476 $condition = $this->database->condition('OR'); 477 $condition->condition('sd.reindex', 0, '<>') 478 ->isNull('sd.sid'); 479 $query->condition($condition); 480 $remaining = $query->execute()->fetchField(); 481 482 return [ 483 'remaining' => $remaining, 484 'total' => $total, 485 ]; 486 } 487 488 /** 489 * Removes an item or items from the search index. 490 * 491 * @param int|int[] $sids 492 * Search ID (sid) of item or items to remove. 493 */ 494 protected function removeItemsFromIndex($sids) { 495 $sids = (array) $sids; 496 497 // Remove items from our table in batches of 100, to avoid problems 498 // with having too many placeholders in database queries. 499 foreach (array_chunk($sids, 100) as $this_list) { 500 $this->database->delete('help_search_items') 501 ->condition('sid', $this_list, 'IN') 502 ->execute(); 503 } 504 // Remove items from the search tables individually, as there is no bulk 505 // function to delete items from the search index. 506 foreach ($sids as $sid) { 507 $this->searchIndex->clear($this->getType(), $sid); 508 } 509 } 510 511 /** 512 * Instantiates a help section plugin and verifies it is searchable. 513 * 514 * @param string $section_plugin_id 515 * Type of plugin to instantiate. 516 * 517 * @return \Drupal\help_topics\SearchableHelpInterface|false 518 * Plugin object, or FALSE if it is not searchable. 519 */ 520 protected function getSectionPlugin($section_plugin_id) { 521 /** @var \Drupal\help\HelpSectionPluginInterface $section_plugin */ 522 $section_plugin = $this->helpSectionManager->createInstance($section_plugin_id); 523 // Intentionally return boolean to allow caching of results. 524 return $section_plugin instanceof SearchableHelpInterface ? $section_plugin : FALSE; 525 } 526 527} 528