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