NodeSearch.php 29.7 KB
Newer Older
1
2
3
4
<?php

namespace Drupal\node\Plugin\Search;

5
use Drupal\Core\Access\AccessibleInterface;
6
use Drupal\Core\Access\AccessResult;
7
use Drupal\Core\Cache\CacheableMetadata;
8
9
use Drupal\Core\Config\Config;
use Drupal\Core\Database\Connection;
10
use Drupal\Core\Database\Query\Condition;
11
use Drupal\Core\Database\Query\SelectExtender;
12
use Drupal\Core\Database\StatementInterface;
13
14
use Drupal\Core\DependencyInjection\DeprecatedServicePropertyTrait;
use Drupal\Core\Entity\EntityTypeManagerInterface;
15
use Drupal\Core\Extension\ModuleHandlerInterface;
16
use Drupal\Core\Form\FormStateInterface;
17
use Drupal\Core\Language\LanguageInterface;
18
use Drupal\Core\Language\LanguageManagerInterface;
19
use Drupal\Core\Messenger\MessengerInterface;
20
use Drupal\Core\Render\RendererInterface;
21
use Drupal\Core\Security\TrustedCallbackInterface;
22
use Drupal\Core\Session\AccountInterface;
23
use Drupal\node\NodeInterface;
24
use Drupal\search\Plugin\ConfigurableSearchPluginBase;
25
use Drupal\search\Plugin\SearchIndexingInterface;
26
use Drupal\search\SearchIndexInterface;
27
use Drupal\Search\SearchQuery;
28
29
30
31
32
33
34
use Symfony\Component\DependencyInjection\ContainerInterface;

/**
 * Handles searching for node entities using the Search module index.
 *
 * @SearchPlugin(
 *   id = "node_search",
35
 *   title = @Translation("Content")
36
37
 * )
 */
38
class NodeSearch extends ConfigurableSearchPluginBase implements AccessibleInterface, SearchIndexingInterface, TrustedCallbackInterface {
39
40
41
42
43
44
  use DeprecatedServicePropertyTrait;

  /**
   * {@inheritdoc}
   */
  protected $deprecatedProperties = ['entityManager' => 'entity.manager'];
45
46

  /**
47
   * The current database connection.
48
49
50
51
52
   *
   * @var \Drupal\Core\Database\Connection
   */
  protected $database;

53
54
55
56
57
58
59
  /**
   * The replica database connection.
   *
   * @var \Drupal\Core\Database\Connection
   */
  protected $databaseReplica;

60
  /**
61
   * The entity type manager.
62
   *
63
   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
64
   */
65
  protected $entityTypeManager;
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80

  /**
   * A module manager object.
   *
   * @var \Drupal\Core\Extension\ModuleHandlerInterface
   */
  protected $moduleHandler;

  /**
   * A config object for 'search.settings'.
   *
   * @var \Drupal\Core\Config\Config
   */
  protected $searchSettings;

81
82
83
84
85
86
87
  /**
   * The language manager.
   *
   * @var \Drupal\Core\Language\LanguageManagerInterface
   */
  protected $languageManager;

88
89
90
91
92
93
94
  /**
   * The Drupal account to use for checking for access to advanced search.
   *
   * @var \Drupal\Core\Session\AccountInterface
   */
  protected $account;

95
96
97
98
99
100
101
  /**
   * The Renderer service to format the username and node.
   *
   * @var \Drupal\Core\Render\RendererInterface
   */
  protected $renderer;

102
103
104
105
106
107
108
  /**
   * The search index.
   *
   * @var \Drupal\search\SearchIndexInterface
   */
  protected $searchIndex;

109
110
111
112
113
114
115
  /**
   * An array of additional rankings from hook_ranking().
   *
   * @var array
   */
  protected $rankings;

116
117
118
  /**
   * The list of options and info for advanced search filters.
   *
119
   * Each entry in the array has the option as the key and for its value, an
120
121
122
123
124
125
126
127
128
   * array that determines how the value is matched in the database query. The
   * possible keys in that array are:
   * - column: (required) Name of the database column to match against.
   * - join: (optional) Information on a table to join. By default the data is
   *   matched against the {node_field_data} table.
   * - operator: (optional) OR or AND, defaults to OR.
   *
   * @var array
   */
129
130
131
132
133
134
  protected $advanced = [
    'type' => ['column' => 'n.type'],
    'language' => ['column' => 'i.langcode'],
    'author' => ['column' => 'n.uid'],
    'term' => ['column' => 'ti.tid', 'join' => ['table' => 'taxonomy_index', 'alias' => 'ti', 'condition' => 'n.nid = ti.nid']],
  ];
135

136
137
138
139
140
  /**
   * A constant for setting and checking the query string.
   */
  const ADVANCED_FORM = 'advanced-form';

141
142
143
144
145
146
147
  /**
   * The messenger.
   *
   * @var \Drupal\Core\Messenger\MessengerInterface
   */
  protected $messenger;

148
149
150
  /**
   * {@inheritdoc}
   */
151
  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
152
153
154
155
156
    return new static(
      $configuration,
      $plugin_id,
      $plugin_definition,
      $container->get('database'),
157
      $container->get('entity_type.manager'),
158
159
      $container->get('module_handler'),
      $container->get('config.factory')->get('search.settings'),
160
      $container->get('language_manager'),
161
      $container->get('renderer'),
162
      $container->get('messenger'),
163
      $container->get('current_user'),
164
165
      $container->get('database.replica'),
      $container->get('search.index')
166
167
168
169
170
171
172
173
174
175
    );
  }

  /**
   * Constructs a \Drupal\node\Plugin\Search\NodeSearch object.
   *
   * @param array $configuration
   *   A configuration array containing information about the plugin instance.
   * @param string $plugin_id
   *   The plugin_id for the plugin instance.
176
   * @param mixed $plugin_definition
177
178
   *   The plugin implementation definition.
   * @param \Drupal\Core\Database\Connection $database
179
   *   The current database connection.
180
181
   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
   *   The entity type manager.
182
183
184
185
   * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
   *   A module manager object.
   * @param \Drupal\Core\Config\Config $search_settings
   *   A config object for 'search.settings'.
186
187
   * @param \Drupal\Core\Language\LanguageManagerInterface $language_manager
   *   The language manager.
188
189
   * @param \Drupal\Core\Render\RendererInterface $renderer
   *   The renderer.
190
191
   * @param \Drupal\Core\Messenger\MessengerInterface $messenger
   *   The messenger.
192
193
   * @param \Drupal\Core\Session\AccountInterface $account
   *   The $account object to use for checking for access to advanced search.
194
195
   * @param \Drupal\Core\Database\Connection|null $database_replica
   *   (Optional) the replica database connection.
196
197
   * @param \Drupal\search\SearchIndexInterface $search_index
   *   The search index.
198
   */
199
  public function __construct(array $configuration, $plugin_id, $plugin_definition, Connection $database, EntityTypeManagerInterface $entity_type_manager, ModuleHandlerInterface $module_handler, Config $search_settings, LanguageManagerInterface $language_manager, RendererInterface $renderer, MessengerInterface $messenger, AccountInterface $account = NULL, Connection $database_replica = NULL, SearchIndexInterface $search_index = NULL) {
200
    $this->database = $database;
201
    $this->databaseReplica = $database_replica ?: $database;
202
    $this->entityTypeManager = $entity_type_manager;
203
204
    $this->moduleHandler = $module_handler;
    $this->searchSettings = $search_settings;
205
    $this->languageManager = $language_manager;
206
    $this->renderer = $renderer;
207
    $this->messenger = $messenger;
208
209
    $this->account = $account;
    parent::__construct($configuration, $plugin_id, $plugin_definition);
210
211

    $this->addCacheTags(['node_list']);
212
213
214
215
216
    if (!$search_index) {
      @trigger_error('Calling NodeSearch::__construct() without the $search_index argument is deprecated in drupal:8.8.0 and is required in drupal:9.0.0. See https://www.drupal.org/node/3075696', E_USER_DEPRECATED);
      $search_index = \Drupal::service('search.index');
    }
    $this->searchIndex = $search_index;
217
218
219
220
221
  }

  /**
   * {@inheritdoc}
   */
222
223
224
  public function access($operation = 'view', AccountInterface $account = NULL, $return_as_object = FALSE) {
    $result = AccessResult::allowedIfHasPermission($account, 'access content');
    return $return_as_object ? $result : $result->isAllowed();
225
226
  }

227
228
229
230
231
232
233
234
235
236
237
  /**
   * {@inheritdoc}
   */
  public function isSearchExecutable() {
    // Node search is executable if we have keywords or an advanced parameter.
    // At least, we should parse out the parameters and see if there are any
    // keyword matches in that case, rather than just printing out the
    // "Please enter keywords" message.
    return !empty($this->keywords) || (isset($this->searchParameters['f']) && count($this->searchParameters['f']));
  }

238
239
240
241
242
243
244
  /**
   * {@inheritdoc}
   */
  public function getType() {
    return $this->getPluginId();
  }

245
246
247
248
  /**
   * {@inheritdoc}
   */
  public function execute() {
249
250
251
252
253
254
    if ($this->isSearchExecutable()) {
      $results = $this->findResults();

      if ($results) {
        return $this->prepareResults($results);
      }
255
    }
256

257
    return [];
258
259
260
261
262
263
264
265
266
267
268
269
270
  }

  /**
   * Queries to find search results, and sets status messages.
   *
   * This method can assume that $this->isSearchExecutable() has already been
   * checked and returned TRUE.
   *
   * @return \Drupal\Core\Database\StatementInterface|null
   *   Results from search query execute() method, or NULL if the search
   *   failed.
   */
  protected function findResults() {
271
272
273
    $keys = $this->keywords;

    // Build matching conditions.
274
275
    $query = $this->databaseReplica
      ->select('search_index', 'i')
276
277
      ->extend('Drupal\search\SearchQuery')
      ->extend('Drupal\Core\Database\Query\PagerSelectExtender');
278
    $query->join('node_field_data', 'n', 'n.nid = i.sid AND n.langcode = i.langcode');
279
280
281
282
283
    $query->condition('n.status', 1)
      ->addTag('node_access')
      ->searchExpression($keys, $this->getPluginId());

    // Handle advanced search filters in the f query string.
284
285
    // \Drupal::request()->query->get('f') is an array that looks like this in
    // the URL: ?f[]=type:page&f[]=term:27&f[]=term:13&f[]=langcode:en
286
287
    // So $parameters['f'] looks like:
    // array('type:page', 'term:27', 'term:13', 'langcode:en');
288
289
    // We need to parse this out into query conditions, some of which go into
    // the keywords string, and some of which are separate conditions.
290
291
    $parameters = $this->getParameters();
    if (!empty($parameters['f']) && is_array($parameters['f'])) {
292
      $filters = [];
293
294
295
296
297
298
299
300
301
      // Match any query value that is an expected option and a value
      // separated by ':' like 'term:27'.
      $pattern = '/^(' . implode('|', array_keys($this->advanced)) . '):([^ ]*)/i';
      foreach ($parameters['f'] as $item) {
        if (preg_match($pattern, $item, $m)) {
          // Use the matched value as the array key to eliminate duplicates.
          $filters[$m[1]][$m[2]] = $m[2];
        }
      }
302

303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
      // Now turn these into query conditions. This assumes that everything in
      // $filters is a known type of advanced search.
      foreach ($filters as $option => $matched) {
        $info = $this->advanced[$option];
        // Insert additional conditions. By default, all use the OR operator.
        $operator = empty($info['operator']) ? 'OR' : $info['operator'];
        $where = new Condition($operator);
        foreach ($matched as $value) {
          $where->condition($info['column'], $value);
        }
        $query->condition($where);
        if (!empty($info['join'])) {
          $query->join($info['join']['table'], $info['join']['alias'], $info['join']['condition']);
        }
      }
    }

    // Add the ranking expressions.
    $this->addNodeRankings($query);

323
    // Run the query.
324
325
326
    $find = $query
      // Add the language code of the indexed item to the result of the query,
      // since the node will be rendered using the respective language.
327
      ->fields('i', ['langcode'])
328
329
330
331
      // And since SearchQuery makes these into GROUP BY queries, if we add
      // a field, for PostgreSQL we also need to make it an aggregate or a
      // GROUP BY. In this case, we want GROUP BY.
      ->groupBy('i.langcode')
332
333
334
      ->limit(10)
      ->execute();

335
336
337
338
    // Check query status and set messages if needed.
    $status = $query->getStatus();

    if ($status & SearchQuery::EXPRESSIONS_IGNORED) {
339
      $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')]));
340
341
342
    }

    if ($status & SearchQuery::LOWER_CASE_OR) {
343
      $this->messenger->addWarning($this->t('Search for either of the two terms with uppercase <strong>OR</strong>. For example, <strong>cats OR dogs</strong>.'));
344
345
346
    }

    if ($status & SearchQuery::NO_POSITIVE_KEYWORDS) {
347
      $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.'));
348
349
    }

350
351
352
353
354
355
356
357
358
359
360
361
362
    return $find;
  }

  /**
   * Prepares search results for rendering.
   *
   * @param \Drupal\Core\Database\StatementInterface $found
   *   Results found from a successful search query execute() method.
   *
   * @return array
   *   Array of search result item render arrays (empty array if no results).
   */
  protected function prepareResults(StatementInterface $found) {
363
    $results = [];
364

365
366
    $node_storage = $this->entityTypeManager->getStorage('node');
    $node_render = $this->entityTypeManager->getViewBuilder('node');
367
    $keys = $this->keywords;
368

369
    foreach ($found as $item) {
370
      // Render the node.
371
      /** @var \Drupal\node\NodeInterface $node */
372
      $node = $node_storage->load($item->sid)->getTranslation($item->langcode);
373
      $build = $node_render->view($node, 'search_result', $item->langcode);
374
375

      /** @var \Drupal\node\NodeTypeInterface $type*/
376
      $type = $this->entityTypeManager->getStorage('node_type')->load($node->bundle());
377

378
      unset($build['#theme']);
379
      $build['#pre_render'][] = [$this, 'removeSubmittedInfo'];
380

381
382
      // Fetch comments for snippet.
      $rendered = $this->renderer->renderPlain($build);
383
      $this->addCacheableDependency(CacheableMetadata::createFromRenderArray($build));
384
      $rendered .= ' ' . $this->moduleHandler->invoke('comment', 'node_update_index', [$node]);
385

386
      $extra = $this->moduleHandler->invokeAll('node_search_result', [$node]);
387

388
      $username = [
389
        '#theme' => 'username',
390
        '#account' => $node->getOwner(),
391
      ];
392

393
      $result = [
394
        'link' => $node->toUrl('canonical', ['absolute' => TRUE])->toString(),
395
        'type' => $type->label(),
396
        'title' => $node->label(),
397
398
399
        'node' => $node,
        'extra' => $extra,
        'score' => $item->calculated_score,
400
        'snippet' => search_excerpt($keys, $rendered, $item->langcode),
401
        'langcode' => $node->language()->getId(),
402
      ];
403

404
405
406
407
408
409
410
411
      $this->addCacheableDependency($node);

      // We have to separately add the node owner's cache tags because search
      // module doesn't use the rendering system, it does its own rendering
      // without taking cacheability metadata into account. So we have to do it
      // explicitly here.
      $this->addCacheableDependency($node->getOwner());

412
      if ($type->displaySubmitted()) {
413
        $result += [
414
          'user' => $this->renderer->renderPlain($username),
415
          'date' => $node->getChangedTime(),
416
        ];
417
418
419
420
      }

      $results[] = $result;

421
422
423
424
    }
    return $results;
  }

425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
  /**
   * Removes the submitted by information from the build array.
   *
   * This information is being removed from the rendered node that is used to
   * build the search result snippet. It just doesn't make sense to have it
   * displayed in the snippet.
   *
   * @param array $build
   *   The build array.
   *
   * @return array
   *   The modified build array.
   */
  public function removeSubmittedInfo(array $build) {
    unset($build['created']);
    unset($build['uid']);
    return $build;
  }

444
  /**
445
   * Adds the configured rankings to the search query.
446
447
448
449
450
   *
   * @param $query
   *   A query object that has been extended with the Search DB Extender.
   */
  protected function addNodeRankings(SelectExtender $query) {
451
    if ($ranking = $this->getRankings()) {
452
453
      $tables = &$query->getTables();
      foreach ($ranking as $rank => $values) {
454
455
        if (isset($this->configuration['rankings'][$rank]) && !empty($this->configuration['rankings'][$rank])) {
          $node_rank = $this->configuration['rankings'][$rank];
456
457
458
459
          // If the table defined in the ranking isn't already joined, then add it.
          if (isset($values['join']) && !isset($tables[$values['join']['alias']])) {
            $query->addJoin($values['join']['type'], $values['join']['table'], $values['join']['alias'], $values['join']['on']);
          }
460
          $arguments = isset($values['arguments']) ? $values['arguments'] : [];
461
462
463
464
465
466
467
468
469
470
          $query->addScore($values['score'], $arguments, $node_rank);
        }
      }
    }
  }

  /**
   * {@inheritdoc}
   */
  public function updateIndex() {
471
472
    // Interpret the cron limit setting as the maximum number of nodes to index
    // per cron run.
473
474
    $limit = (int) $this->searchSettings->get('index.cron_limit');

475
    $query = $this->databaseReplica->select('node', 'n');
476
    $query->addField('n', 'nid');
477
    $query->leftJoin('search_dataset', 'sd', 'sd.sid = n.nid AND sd.type = :type', [':type' => $this->getPluginId()]);
478
479
480
481
    $query->addExpression('CASE MAX(sd.reindex) WHEN NULL THEN 0 ELSE 1 END', 'ex');
    $query->addExpression('MAX(sd.reindex)', 'ex2');
    $query->condition(
        $query->orConditionGroup()
482
483
          ->where('sd.sid IS NULL')
          ->condition('sd.reindex', 0, '<>')
484
485
486
487
488
489
490
491
      );
    $query->orderBy('ex', 'DESC')
      ->orderBy('ex2')
      ->orderBy('n.nid')
      ->groupBy('n.nid')
      ->range(0, $limit);

    $nids = $query->execute()->fetchCol();
492
493
494
495
    if (!$nids) {
      return;
    }

496
    $node_storage = $this->entityTypeManager->getStorage('node');
497
498
499
    $words = [];
    try {
      foreach ($node_storage->loadMultiple($nids) as $node) {
500
        $words += $this->indexNode($node);
501
502
503
504
      }
    }
    finally {
      $this->searchIndex->updateWordWeights($words);
505
506
507
508
509
510
    }
  }

  /**
   * Indexes a single node.
   *
511
   * @param \Drupal\node\NodeInterface $node
512
   *   The node to index.
513
514
515
   *
   * @return array
   *   An array of words to update after indexing.
516
   */
517
518
  protected function indexNode(NodeInterface $node) {
    $words = [];
519
    $languages = $node->getTranslationLanguages();
520
    $node_render = $this->entityTypeManager->getViewBuilder('node');
521
522

    foreach ($languages as $language) {
523
      $node = $node->getTranslation($language->getId());
524
      // Render the node.
525
      $build = $node_render->view($node, 'search_index', $language->getId());
526
527
528

      unset($build['#theme']);

529
530
531
      // Add the title to text so it is searchable.
      $build['search_title'] = [
        '#prefix' => '<h1>',
532
        '#plain_text' => $node->label(),
533
        '#suffix' => '</h1>',
534
        '#weight' => -1000,
535
536
      ];
      $text = $this->renderer->renderPlain($build);
537
538

      // Fetch extra data normally not visible.
539
      $extra = $this->moduleHandler->invokeAll('node_update_index', [$node]);
540
541
542
543
      foreach ($extra as $t) {
        $text .= $t;
      }

544
      // Update index, using search index "type" equal to the plugin ID.
545
      $words += $this->searchIndex->index($this->getPluginId(), $node->id(), $language->getId(), $text, FALSE);
546
    }
547
    return $words;
548
549
550
551
552
  }

  /**
   * {@inheritdoc}
   */
553
554
555
  public function indexClear() {
    // All NodeSearch pages share a common search index "type" equal to
    // the plugin ID.
556
    $this->searchIndex->clear($this->getPluginId());
557
558
559
560
561
562
563
564
  }

  /**
   * {@inheritdoc}
   */
  public function markForReindex() {
    // All NodeSearch pages share a common search index "type" equal to
    // the plugin ID.
565
    $this->searchIndex->markForReindex($this->getPluginId());
566
567
568
569
570
571
572
  }

  /**
   * {@inheritdoc}
   */
  public function indexStatus() {
    $total = $this->database->query('SELECT COUNT(*) FROM {node}')->fetchField();
573
    $remaining = $this->database->query("SELECT COUNT(DISTINCT n.nid) FROM {node} n LEFT JOIN {search_dataset} sd ON sd.sid = n.nid AND sd.type = :type WHERE sd.sid IS NULL OR sd.reindex <> 0", [':type' => $this->getPluginId()])->fetchField();
574

575
    return ['remaining' => $remaining, 'total' => $total];
576
577
578
579
580
  }

  /**
   * {@inheritdoc}
   */
581
  public function searchFormAlter(array &$form, FormStateInterface $form_state) {
582
583
584
585
    $parameters = $this->getParameters();
    $keys = $this->getKeywords();
    $used_advanced = !empty($parameters[self::ADVANCED_FORM]);
    if ($used_advanced) {
586
      $f = isset($parameters['f']) ? (array) $parameters['f'] : [];
587
      $defaults = $this->parseAdvancedDefaults($f, $keys);
588
589
    }
    else {
590
      $defaults = ['keys' => $keys];
591
592
593
594
    }

    $form['basic']['keys']['#default_value'] = $defaults['keys'];

595
    // Add advanced search keyword-related boxes.
596
    $form['advanced'] = [
597
598
      '#type' => 'details',
      '#title' => t('Advanced search'),
599
      '#attributes' => ['class' => ['search-advanced']],
600
      '#access' => $this->account && $this->account->hasPermission('use advanced search'),
601
      '#open' => $used_advanced,
602
603
    ];
    $form['advanced']['keywords-fieldset'] = [
604
605
      '#type' => 'fieldset',
      '#title' => t('Keywords'),
606
    ];
607

608
    $form['advanced']['keywords'] = [
609
610
      '#prefix' => '<div class="criterion">',
      '#suffix' => '</div>',
611
    ];
612

613
    $form['advanced']['keywords-fieldset']['keywords']['or'] = [
614
615
616
617
      '#type' => 'textfield',
      '#title' => t('Containing any of the words'),
      '#size' => 30,
      '#maxlength' => 255,
618
      '#default_value' => isset($defaults['or']) ? $defaults['or'] : '',
619
    ];
620

621
    $form['advanced']['keywords-fieldset']['keywords']['phrase'] = [
622
623
624
625
      '#type' => 'textfield',
      '#title' => t('Containing the phrase'),
      '#size' => 30,
      '#maxlength' => 255,
626
      '#default_value' => isset($defaults['phrase']) ? $defaults['phrase'] : '',
627
    ];
628

629
    $form['advanced']['keywords-fieldset']['keywords']['negative'] = [
630
631
632
633
      '#type' => 'textfield',
      '#title' => t('Containing none of the words'),
      '#size' => 30,
      '#maxlength' => 255,
634
      '#default_value' => isset($defaults['negative']) ? $defaults['negative'] : '',
635
    ];
636
637

    // Add node types.
638
639
    $types = array_map(['\Drupal\Component\Utility\Html', 'escape'], node_type_get_names());
    $form['advanced']['types-fieldset'] = [
640
641
      '#type' => 'fieldset',
      '#title' => t('Types'),
642
643
    ];
    $form['advanced']['types-fieldset']['type'] = [
644
645
646
647
648
      '#type' => 'checkboxes',
      '#title' => t('Only of the type(s)'),
      '#prefix' => '<div class="criterion">',
      '#suffix' => '</div>',
      '#options' => $types,
649
650
      '#default_value' => isset($defaults['type']) ? $defaults['type'] : [],
    ];
651

652
    $form['advanced']['submit'] = [
653
654
655
656
657
      '#type' => 'submit',
      '#value' => t('Advanced search'),
      '#prefix' => '<div class="action">',
      '#suffix' => '</div>',
      '#weight' => 100,
658
    ];
659
660

    // Add languages.
661
    $language_options = [];
662
    $language_list = $this->languageManager->getLanguages(LanguageInterface::STATE_ALL);
663
    foreach ($language_list as $langcode => $language) {
664
      // Make locked languages appear special in the list.
665
      $language_options[$langcode] = $language->isLocked() ? t('- @name -', ['@name' => $language->getName()]) : $language->getName();
666
667
    }
    if (count($language_options) > 1) {
668
      $form['advanced']['lang-fieldset'] = [
669
670
        '#type' => 'fieldset',
        '#title' => t('Languages'),
671
672
      ];
      $form['advanced']['lang-fieldset']['language'] = [
673
674
675
676
677
        '#type' => 'checkboxes',
        '#title' => t('Languages'),
        '#prefix' => '<div class="criterion">',
        '#suffix' => '</div>',
        '#options' => $language_options,
678
679
        '#default_value' => isset($defaults['language']) ? $defaults['language'] : [],
      ];
680
681
682
    }
  }

683
  /**
684
   * {@inheritdoc}
685
   */
686
  public function buildSearchUrlQuery(FormStateInterface $form_state) {
687
688
    // Read keyword and advanced search information from the form values,
    // and put these into the GET parameters.
689
    $keys = trim($form_state->getValue('keys'));
690
    $advanced = FALSE;
691

692
    // Collect extra filters.
693
    $filters = [];
694
    if ($form_state->hasValue('type') && is_array($form_state->getValue('type'))) {
695
696
      // Retrieve selected types - Form API sets the value of unselected
      // checkboxes to 0.
697
      foreach ($form_state->getValue('type') as $type) {
698
        if ($type) {
699
          $advanced = TRUE;
700
701
702
703
704
          $filters[] = 'type:' . $type;
        }
      }
    }

705
706
    if ($form_state->hasValue('term') && is_array($form_state->getValue('term'))) {
      foreach ($form_state->getValue('term') as $term) {
707
        $filters[] = 'term:' . $term;
708
        $advanced = TRUE;
709
710
      }
    }
711
712
    if ($form_state->hasValue('language') && is_array($form_state->getValue('language'))) {
      foreach ($form_state->getValue('language') as $language) {
713
        if ($language) {
714
          $advanced = TRUE;
715
716
717
718
          $filters[] = 'language:' . $language;
        }
      }
    }
719
720
    if ($form_state->getValue('or') != '') {
      if (preg_match_all('/ ("[^"]+"|[^" ]+)/i', ' ' . $form_state->getValue('or'), $matches)) {
721
        $keys .= ' ' . implode(' OR ', $matches[1]);
722
        $advanced = TRUE;
723
724
      }
    }
725
726
    if ($form_state->getValue('negative') != '') {
      if (preg_match_all('/ ("[^"]+"|[^" ]+)/i', ' ' . $form_state->getValue('negative'), $matches)) {
727
        $keys .= ' -' . implode(' -', $matches[1]);
728
        $advanced = TRUE;
729
730
      }
    }
731
732
    if ($form_state->getValue('phrase') != '') {
      $keys .= ' "' . str_replace('"', ' ', $form_state->getValue('phrase')) . '"';
733
      $advanced = TRUE;
734
    }
735
736
737
738
739
    $keys = trim($keys);

    // Put the keywords and advanced parameters into GET parameters. Make sure
    // to put keywords into the query even if it is empty, because the page
    // controller uses that to decide it's time to check for search results.
740
    $query = ['keys' => $keys];
741
    if ($filters) {
742
      $query['f'] = $filters;
743
    }
744
745
746
747
    // Record that the person used the advanced search form, if they did.
    if ($advanced) {
      $query[self::ADVANCED_FORM] = '1';
    }
748

749
    return $query;
750
751
  }

752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
  /**
   * Parses the advanced search form default values.
   *
   * @param array $f
   *   The 'f' query parameter set up in self::buildUrlSearchQuery(), which
   *   contains the advanced query values.
   * @param string $keys
   *   The search keywords string, which contains some information from the
   *   advanced search form.
   *
   * @return array
   *   Array of default form values for the advanced search form, including
   *   a modified 'keys' element for the bare search keywords.
   */
  protected function parseAdvancedDefaults($f, $keys) {
767
    $defaults = [];
768
769
770
771
772

    // Split out the advanced search parameters.
    foreach ($f as $advanced) {
      list($key, $value) = explode(':', $advanced, 2);
      if (!isset($defaults[$key])) {
773
        $defaults[$key] = [];
774
775
776
777
778
779
780
      }
      $defaults[$key][] = $value;
    }

    // Split out the negative, phrase, and OR parts of keywords.

    // For phrases, the form only supports one phrase.
781
    $matches = [];
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
    $keys = ' ' . $keys . ' ';
    if (preg_match('/ "([^"]+)" /', $keys, $matches)) {
      $keys = str_replace($matches[0], ' ', $keys);
      $defaults['phrase'] = $matches[1];
    }

    // Negative keywords: pull all of them out.
    if (preg_match_all('/ -([^ ]+)/', $keys, $matches)) {
      $keys = str_replace($matches[0], ' ', $keys);
      $defaults['negative'] = implode(' ', $matches[1]);
    }

    // OR keywords: pull up to one set of them out of the query.
    if (preg_match('/ [^ ]+( OR [^ ]+)+ /', $keys, $matches)) {
      $keys = str_replace($matches[0], ' ', $keys);
      $words = explode(' OR ', trim($matches[0]));
      $defaults['or'] = implode(' ', $words);
    }

    // Put remaining keywords string back into keywords.
    $defaults['keys'] = trim($keys);

    return $defaults;
  }

807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
  /**
   * Gathers ranking definitions from hook_ranking().
   *
   * @return array
   *   An array of ranking definitions.
   */
  protected function getRankings() {
    if (!$this->rankings) {
      $this->rankings = $this->moduleHandler->invokeAll('ranking');
    }
    return $this->rankings;
  }

  /**
   * {@inheritdoc}
   */
  public function defaultConfiguration() {
824
825
826
    $configuration = [
      'rankings' => [],
    ];
827
    return $configuration;
828
829
830
831
832
  }

  /**
   * {@inheritdoc}
   */
833
  public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
834
    // Output form for defining rank factor weights.
835
    $form['content_ranking'] = [
836
837
      '#type' => 'details',
      '#title' => t('Content ranking'),
838
      '#open' => TRUE,
839
840
    ];
    $form['content_ranking']['info'] = [
841
      '#markup' => '<p><em>' . $this->t('Influence is a numeric multiplier used in ordering search results. A higher number means the corresponding factor has more influence on search results; zero means the factor is ignored. Changing these numbers does not require the search index to be rebuilt. Changes take effect immediately.') . '</em></p>',
842
    ];
843
844
    // Prepare table.
    $header = [$this->t('Factor'), $this->t('Influence')];
845
    $form['content_ranking']['rankings'] = [
846
847
      '#type' => 'table',
      '#header' => $header,
848
    ];
849
850

    // Note: reversed to reflect that higher number = higher ranking.
851
852
    $range = range(0, 10);
    $options = array_combine($range, $range);
853
    foreach ($this->getRankings() as $var => $values) {
854
      $form['content_ranking']['rankings'][$var]['name'] = [
855
        '#markup' => $values['title'],
856
857
      ];
      $form['content_ranking']['rankings'][$var]['value'] = [
858
859
        '#type' => 'select',
        '#options' => $options,
860
        '#attributes' => ['aria-label' => $this->t("Influence of '@title'", ['@title' => $values['title']])],
861
        '#default_value' => isset($this->configuration['rankings'][$var]) ? $this->configuration['rankings'][$var] : 0,
862
      ];
863
    }
864
    return $form;
865
866
  }

867
868
869
  /**
   * {@inheritdoc}
   */
870
  public function submitConfigurationForm(array &$form, FormStateInterface $form_state) {
871
    foreach ($this->getRankings() as $var => $values) {
872
873
      if (!$form_state->isValueEmpty(['rankings', $var, 'value'])) {
        $this->configuration['rankings'][$var] = $form_state->getValue(['rankings', $var, 'value']);
874
875
876
      }
      else {
        unset($this->configuration['rankings'][$var]);
877
878
879
      }
    }
  }
880

881
882
883
884
885
886
887
  /**
   * {@inheritdoc}
   */
  public static function trustedCallbacks() {
    return ['removeSubmittedInfo'];
  }

888
}