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