coveo-1.0.0-alpha1/modules/coveo_search_api/src/Plugin/search_api/backend/SearchApiCoveoBackend.php

modules/coveo_search_api/src/Plugin/search_api/backend/SearchApiCoveoBackend.php
<?php

declare(strict_types=1);

namespace Drupal\coveo_search_api\Plugin\search_api\backend;

use Drupal\Component\Datetime\TimeInterface;
use Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException;
use Drupal\Component\Plugin\Exception\PluginNotFoundException;
use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Link;
use Drupal\Core\Plugin\PluginFormInterface;
use Drupal\Core\Url;
use Drupal\Core\Utility\Error as DrupalError;
use Drupal\coveo\API\SearchApiFactory;
use Drupal\coveo\Coveo\Index;
use Drupal\coveo\DocumentBody;
use Drupal\coveo\Entity\CoveoOrganizationInterface;
use Drupal\coveo\FieldConverter;
use Drupal\coveo_search_api\Event\CoveoDocumentAlter;
use Drupal\coveo_search_api\Event\CoveoDocumentsAlter;
use Drupal\coveo_search_api\SyncFields;
use Drupal\search_api\Backend\BackendPluginBase;
use Drupal\search_api\IndexInterface;
use Drupal\search_api\Item\FieldInterface;
use Drupal\search_api\Item\ItemInterface;
use Drupal\search_api\Plugin\PluginFormTrait;
use Drupal\search_api\Query\ConditionGroupInterface;
use Drupal\search_api\Query\QueryInterface;
use NecLimDul\Coveo\FieldApi\Api\FieldsApi;
use NecLimDul\Coveo\FieldApi\Model\FieldListingOptions;
use NecLimDul\Coveo\PushApi\Api\ItemApi;
use NecLimDul\Coveo\SearchApi\Api\SearchV2Api;
use NecLimDul\Coveo\SearchApi\Model\RestQueryParameters;
use NecLimDul\Coveo\SearchApi\Model\RestQueryResponse;
use Neclimdul\OpenapiPhp\Helper\Logging\Error as ApiError;
use Psr\Log\LoggerInterface;
use Psr\Log\LogLevel;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;

// cspell:ignore compressedbinarydatafileid clickableuri

/**
 * Coveo search API backend plugin.
 *
 * @SearchApiBackend(
 *   id = "coveo",
 *   label = @Translation("Coveo Content Backend"),
 *   description = @Translation("Index items using a Coveo Search.")
 * )
 */
class SearchApiCoveoBackend extends BackendPluginBase implements PluginFormInterface {

  use PluginFormTrait;

  /**
   * The module handler.
   */
  private ModuleHandlerInterface $moduleHandler;

  /**
   * Coveo Fields API connection.
   */
  private FieldsApi $fieldsApi;

  /**
   * Coveo SearchV2 API connection.
   */
  private SearchV2Api $searchApi;

  /**
   * The current Index.
   */
  private Index $coveoIndex;

  /**
   * Index API service.
   */
  private ItemApi $itemApi;

  public function __construct(
    array $configuration,
    $plugin_id,
    array $plugin_definition,
    ModuleHandlerInterface $moduleHandler,
    private readonly EntityTypeManagerInterface $entityTypeManager,
    private readonly EventDispatcherInterface $eventDispatcher,
    LoggerInterface $logger,
    private readonly TimeInterface $time,
    private readonly SearchApiFactory $searchFactory,
    private readonly SyncFields $syncFields,
  ) {
    parent::__construct($configuration, $plugin_id, $plugin_definition);
    $this->moduleHandler = $moduleHandler;
    $this->setLogger($logger);
  }

  /**
   * {@inheritdoc}
   */
  public static function create(
    ContainerInterface $container,
    array $configuration,
    $plugin_id,
    $plugin_definition,
  ): static {
    $plugin = new static(
      $configuration,
      $plugin_id,
      $plugin_definition,
      $container->get('module_handler'),
      $container->get('entity_type.manager'),
      $container->get('event_dispatcher'),
      $container->get('logger.channel.coveo'),
      $container->get('datetime.time'),
      $container->get('coveo.rest.search_api_factory'),
      $container->get('coveo_search_api.sync'),
    );

    // @todo correctly set these up in the constructor.
    $plugin->setFieldsHelper($container->get('search_api.fields_helper'));
    $plugin->setMessenger($container->get('messenger'));
    $plugin->setStringTranslation($container->get('string_translation'));

    return $plugin;
  }

  /**
   * {@inheritdoc}
   */
  public function defaultConfiguration() {
    return [
      'organization_name' => '',
      'search_key' => '',
      'view_all_content' => '',
    ];
  }

  /**
   * {@inheritdoc}
   */
  public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
    $form['organization_name'] = [
      '#type' => 'select',
      '#title' => 'Organization',
      '#default_value' => $this->getOrganizationName(),
      '#options' => array_map(
        fn(CoveoOrganizationInterface $org) => $org->label(),
        $this->getOrganizations(),
      ),
      '#description' => $this->t('If you don\'t see your organization, make sure its created and has a push source id. <a href=":manage">Manage organizations</a>', [
        ':manage' => Url::fromRoute('entity.coveo_organization.collection')->toString(),
      ]),
      '#required' => TRUE,
    ];

    // @todo How do you clear the search key?
    $form['search_key'] = [
      '#type' => 'password',
      '#title' => $this->t('Query/Search API Key'),
      // @todo Communicate this is populated.
      '#description' => $this->t('The API key from directly querying Coveo. (Only enter when creating/changing)'),
      '#default_value' => '',
      '#size' => 60,
      '#maxlength' => 128,
      '#previous_value' => $this->getSearchKey(),
    ];

    $form['view_all_content_delete_hack'] = [
      '#type' => 'checkbox',
      '#title' => $this->t('View all content delete hack'),
      '#description' => $this->t('Pass "View All Content" to Coveo SearchAPI searches during delete operations. This requires a Search API key with elevated permissions that needs to be rotated more often. It can safely be ignored if you are not doing anything weird with content permissions.'),
      '#default_value' => $this->configuration['view_all_content_delete_hack'] ?? FALSE,
    ];

    return $form;
  }

  /**
   * {@inheritdoc}
   */
  public function validateConfigurationForm(array &$form, FormStateInterface $form_state) {
    $values = $form_state->getValues();

    if (empty($values['search_key']) && !empty($form['search_key']['#previous_value'])) {
      $form_state->setValue('search_key', $form['search_key']['#previous_value']);
    }

    $create_fields = $this->syncFields->createInternalFields($this->getOrganization());
    if ($create_fields) {
      $this->getMessenger()->addMessage('Internal fields should be up to date!');
    }
    else {
      $form_state->setError($form, $this->t('There was a problem adding the necessary fields to your Coveo source, please check permissions.'));
    }
  }

  /**
   * {@inheritdoc}
   */
  public function viewSettings() {
    $info = [];
    $org = $this->getOrganization();
    $info[] = [
      'label' => $this->t('Organization'),
      'info' => Link::createFromRoute(
        $org?->label(),
        'entity.coveo_organization.view',
        ['coveo_organization' => $org?->id()],
      ),
    ];
    $info[] = [
      'label' => $this->t('Source ID'),
      'info' => $org?->getPushSourceId(),
    ];
    $info[] = [
      'label' => $this->t('Search Key'),
      'info' => !empty($this->getSearchKey()) ? $this->t('--- Hidden ---') : $this->t('Not provided'),
    ];
    if (empty($org->getPushSourceId())) {
      $info['missing_push_source_id'] = [
        'label' => 'Missing source id',
        'info' => $this->t('Missing source id will cause indexing to fail. <a href=":org_url">Fix organization</a>', [
          ':org_url' => $org->toUrl()->toString(),
        ]),
      ];
    }
    $missing_title = [];
    $missing_data = [];
    $indexes = $this->getServer()->getIndexes();
    foreach ($indexes as $index) {
      if ($index->getField('coveo_data') === NULL && $index->getField('coveo_compressedbinarydatafileid') === NULL) {
        $missing_data[] = $index->label();
      }
      if ($index->getField('coveo_title') === NULL) {
        $missing_title[] = $index->label();
      }
    }
    if (!empty($missing_data)) {
      $info['missing_data'] = [
        'label' => 'Missing data field',
        'info' => $this->t("Missing data field will impair Coveo's ability to index and show a meaningful description.<br> <em>:indexes</em>", [
          ':indexes' => implode(',', $missing_data),
        ]),
      ];
    }
    if (!empty($missing_title)) {
      $info['missing_title'] = [
        'label' => 'Missing title field',
        'info' => $this->t("Missing title field will impair Coveo's ability to show a meaningful titles in results.<br> <em>:indexes</em>", [
          ':indexes' => implode(',', $missing_title),
        ]),
      ];
    }

    return $info;
  }

  /**
   * {@inheritdoc}
   */
  public function getSupportedFeatures() {
    return [];
  }

  /**
   * {@inheritdoc}
   */
  public function indexItems(IndexInterface $index, array $items) {
    // If read only, just tell SearchAPI we finished without sending anything.
    if ($this->isReadOnly()) {
      return array_keys($items);
    }

    /** @var \NecLimDul\Coveo\PushApi\Model\DocumentBody[] $objects */
    $objects = array_map([$this, 'prepareItem'], $items);

    // Let other modules alter objects before sending them to Coveo.
    // This is a great place for them to interact with more complex features
    // like permissions.
    $this->eventDispatcher->dispatch(new CoveoDocumentsAlter($objects, $items, $index));
    $this->alterCoveoObjects($objects, $index, $items);

    if (count($objects) > 0) {
      $this->getIndexHelper()->addOrUpdate(
        $this->getOrganization()->getPushSourceId(),
        // Remove keys used for altering.
        array_values($objects),
      );
    }

    return array_keys($objects);
  }

  /**
   * Prepare a single item for indexing.
   *
   * Used as a helper method in indexItem()/indexItems().
   *
   * @param \Drupal\search_api\Item\ItemInterface $item
   *   The item to index.
   *
   * @return \Drupal\coveo\DocumentBody
   *   Documents to update.
   */
  private function prepareItem(ItemInterface $item): DocumentBody {
    $itemId = $item->getId();
    $field_converter = $this->getOrganization()
      ->getFieldConverter();

    $document = new DocumentBody();
    $document->setAdditionalProperties([
      // Incorrectly cased on purpose to match documentation.
      // cspell:ignore documenttype sourcetype
      'documenttype' => 'WebPage',
      'sourcetype' => 'Push',
      FieldConverter::COVEO_ITEM_ID_FIELD => $itemId,
      FieldConverter::COVEO_INDEX_ID_FIELD => $item->getIndex()->id(),
      FieldConverter::COVEO_PREFIX_FIELD => $field_converter->getPrefix(),
    ]);
    $this->setDocumentUrls($item, $document);

    $this->addDocumentFields($field_converter, $document, $item->getFields());

    // Allow altering of individual documents. This makes it easier to associate
    // the original item with the document.
    $this->eventDispatcher->dispatch(new CoveoDocumentAlter($document, $item));

    return $document;
  }

  /**
   * Resolve relevant urls onto Coveo document.
   *
   * @param \Drupal\search_api\Item\ItemInterface $item
   *   Search API item.
   * @param \Drupal\coveo\DocumentBody $document
   *   Document body being sent to Coveo.
   */
  private function setDocumentUrls(ItemInterface $item, DocumentBody $document): void {
    $real_entity = $item->getOriginalObject()->getValue();
    // We're going to tie this to content entities for now. If you need to index
    // something else you need a different backend.
    assert($real_entity instanceof ContentEntityInterface);
    $url = $real_entity->toUrl('canonical', ['absolute' => TRUE]);
    // Set a clickable url so users _see_ a pretty url.
    // @see https://docs.coveo.com/en/86/index-content/updating-the-printable-and-clickable-uris
    $document->setAdditionalProperty('clickableuri', $url->toString());
    // Turn of path processing so we can get a consistent url that can be
    // deleted and updated.
    // @todo This might need additional values to make things like shared
    //   development environments work.
    $url->setOption('path_processing', FALSE);
    $document->setAdditionalProperty('documentId', $url->toString());
  }

  /**
   * Add fields to document.
   *
   * @param \Drupal\coveo\FieldConverter $field_converter
   *   Field conversion service.
   * @param \Drupal\coveo\DocumentBody $document
   *   Document body being sent to Coveo.
   * @param \Drupal\search_api\Item\FieldInterface[] $item_fields
   *   List of item fields.
   */
  private function addDocumentFields(FieldConverter $field_converter, DocumentBody $document, array $item_fields): void {
    foreach ($item_fields as $field) {
      $field_id = $field->getFieldIdentifier();
      // Magically map to internal Coveo fields.
      if ($field_converter->isCoveoMagic($field_id)) {
        if ($field_id == 'coveo_data') {
          // Extra magic data property.
          // We wrap this in an HTML page because Coveo just doesn't listen to
          // file type. Based on consulting and testing we need basically a full
          // page around it because the parser tries to be clever. Things like a
          // title from a svg will replace the entire record title or if there
          // aren't enough tags, just treating it as plain text.
          $document->setData('<html><head></head><body>' . $this->prepareFieldValues($field) . '</body></html>');
          $document->setAdditionalProperty('fileExtension', '.html');
          $document->setAdditionalProperty('filetype', 'html');
        }
        elseif (strtolower($field_id) == 'coveo_compressedbinarydatafileid') {
          $filename = $this->prepareFieldValues($field);
          if ($filename) {
            try {
              // @todo Move this to prepareFieldValues?
              $file = new \SplFileObject($filename);
              $document->binaryFile = $file;
            }
            catch (\Exception $e) {
              DrupalError::logException(
                $this->getLogger(),
                $e,
                level: LogLevel::WARNING,
              );
            }
          }
        }
        else {
          // Magically map Coveo fields to native fields.
          $document->setAdditionalProperty(
            $field_converter->convertCoveoMagicField($field_id),
            $this->prepareFieldValues($field),
          );
        }
      }
      // Map normal fields.
      else {
        $document->setAdditionalProperty(
          $field_converter->convertDrupalToCoveo($field->getFieldIdentifier()),
          $this->prepareFieldValues($field),
        );
      }
    }
  }

  /**
   * Prepare fields for indexing.
   *
   * @param \Drupal\search_api\Item\FieldInterface $field
   *   Field being indexed.
   *
   * @return array|mixed
   *   Index value.
   */
  private function prepareFieldValues(FieldInterface $field): mixed {
    $type = $field->getType();
    $values = NULL;

    foreach ($field->getValues() as $field_value) {
      switch ($type) {
        case 'text':
        case 'string':
        case 'uri':
          $field_value .= '';
          // @todo This should be more defined.
          if (mb_strlen($field_value) > 10000) {
            $field_value = mb_substr(trim($field_value), 0, 10000);
          }
          $values[] = $field_value;
          break;

        case 'integer':
        case 'duration':
        case 'decimal':
          if (is_numeric($field_value)) {
            $values[] = 0 + $field_value;
          }
          else {
            // @todo warn... something is wrong here and it could break things.
            $values[] = $field_value;
          }
          break;

        case 'boolean':
          $values[] = (bool) $field_value;
          break;

        case 'date':
          if (is_numeric($field_value)) {
            $values[] = 0 + $field_value;
            break;
          }
          $values[] = strtotime($field_value);
          break;

        default:
          $values[] = $field_value;
      }
    }
    if (!empty($values) && count($values) === 1) {
      $values = reset($values);
    }
    return $values;
  }

  /**
   * Applies custom modifications to indexed Coveo objects.
   *
   * This method allows subclasses to easily apply custom changes before the
   * objects are sent to Coveo. The method is empty by default.
   *
   * @param \Drupal\coveo\DocumentBody[] $objects
   *   An array of objects to be indexed, generated from $items array.
   * @param \Drupal\search_api\IndexInterface $index
   *   The search index for which items are being indexed.
   * @param array $items
   *   An array of items being indexed.
   *
   * @see hook_coveo_objects_alter()
   */
  protected function alterCoveoObjects(array &$objects, IndexInterface $index, array $items) {
    $this->moduleHandler
      ->alter('coveo_objects', $objects, $index, $items);
  }

  /**
   * {@inheritdoc}
   */
  public function deleteItems(IndexInterface $index, array $ids) {
    $organization = $this->getOrganization();
    $organization_id = $organization->getOrganizationId();
    $source_id = $organization->getPushSourceId();

    $queries = array_map(
      fn($id) => '@' . FieldConverter::COVEO_ITEM_ID_FIELD . '="' . $id . '"',
      $ids,
    );
    $params = new RestQueryParameters([
      // @todo limit to source?
      'q' => '',
      'aq' => implode(' OR ', $queries),
      'cq' => '@' . FieldConverter::COVEO_PREFIX_FIELD . '=' . $organization->getPrefix() . ' AND @' . FieldConverter::COVEO_INDEX_ID_FIELD . '=' . $index->id(),
    ]);
    $searchResponse = $this->getSearchApi()->searchUsingPost(
      $organization_id,
      (bool) ($this->configuration['view_all_content'] ?? FALSE),
      $params,
    );

    if ($searchResponse->isSuccess()) {
      $item_api = $this->getItemApi();

      /** @var \NecLimDul\Coveo\SearchApi\Model\RestQueryResponse $search_result */
      $search_result = $searchResponse->getData();
      foreach ($search_result->getResults() as $result) {
        $item_api->organizationsOrganizationIdSourcesSourceIdDocumentsDelete(
          $source_id,
          $result->getUri(),
          $organization_id,
        );
      }
    }
    else {
      ApiError::logError(
        $this->getLogger(),
        $searchResponse,
        LogLevel::ERROR,
        'Coveo lookup error. This can cause content to remain in coveo after delete or unpublish. ' . ApiError::DEFAULT_ERROR_MESSAGE,
      );
    }
  }

  /**
   * {@inheritdoc}
   */
  public function deleteAllIndexItems(?IndexInterface $index = NULL, $datasource_id = NULL): void {
    // If read only, just tell SearchAPI we finished without sending anything.
    if ($index && !$this->isReadOnly()) {
      // @todo I think this clear's all indexes connected to this source which
      //   probably isn't ideal. Multiple "indexes" are in the same source.
      $this->getItemApi()->organizationsOrganizationIdSourcesSourceIdDocumentsOlderthanDelete(
        $this->getOrganization()->getPushSourceId(),
        // Convert current time to microseconds. We don't use
        // getCurrentMicroTime so we can round to avoid any server clock skew
        // creating a value in the future.
        (int) (1000 * $this->time->getCurrentTime()),
        $this->getOrganizationId(),
        // Shorter queue delay to avoid confusion. 15min might take unexpectedly
        // long.
        1
      );
    }
  }

  /**
   * {@inheritdoc}
   */
  public function search(QueryInterface $query): void {
    $query_parameters = new RestQueryParameters();

    // Get the keywords.
    // @todo This is assuming one filter for now for demo.
    $keys = $query->getKeys();
    unset($keys['#conjunction']);
    $query_parameters->setQ($keys ? $keys[0] : '');

    // @todo Does this need to account for views filters as well?
    $condition_group = $query->getConditionGroup();
    $condition_group->addCondition(FieldConverter::COVEO_INDEX_ID_FIELD, $query->getIndex()->id());
    if ($aq = $this->createFilterQuery($condition_group)) {
      $query_parameters->setAq($aq);
    }

    // Set Facets to group by.
    $query_parameters->setGroupBy($this->getFacets($query));

    // Set the sorting.
    $query_parameters->setSortCriteria($this->getSorts($query));

    // @todo add index query to limit results.
    // Do the search.
    $response = $this->getSearchApi()->searchUsingPost(
      $this->getOrganizationId(),
      rest_query_parameters: $query_parameters,
    );
    if (!$response->isSuccess()) {
      // $this->getLogger()->error('Something wrong here. Log it.');
      ApiError::logError(
        $this->getLogger(),
        $response,
        LogLevel::ERROR,
        'Direct search error. ' . ApiError::DEFAULT_ERROR_MESSAGE,
      );
      return;
    }

    // WTF is this?!
    $search_result = $response->getData();
    $results = $query->getResults();
    if (!$query->getOption('skip result count')) {
      $results->setResultCount($search_result->getTotalCountFiltered());
    }

    $index = $query->getIndex();
    foreach ($search_result->getResults() as $result) {
      if (isset($result->getRaw()[FieldConverter::COVEO_ITEM_ID_FIELD])) {
        $item_id = $result->getRaw()[FieldConverter::COVEO_ITEM_ID_FIELD];
        $item = $this->getFieldsHelper()->createItem($index, $item_id);
        $item->setScore($result->getScore());
        $results->addResultItem($item);
      }
    }

    if ($facet_results = $this->extractFacets($search_result)) {
      $results->setExtraData('search_api_facets', $facet_results);
    }
  }

  /**
   * Creates a query filter based off condition groups.
   *
   * @param \Drupal\search_api\Query\ConditionGroupInterface $condition_group
   *   The Condition group to parse.
   *
   * @return string
   *   The query Filter.
   *
   * @see https://docs.coveo.com/en/2830/searching-with-coveo/about-the-query-expression
   */
  private function createFilterQuery(ConditionGroupInterface $condition_group) {
    $conditions = $condition_group->getConditions();

    if (!empty($conditions)) {
      $cg = '(';

      $count = 1;
      $total = count($conditions);
      foreach ($conditions as $condition) {
        // Nested Groups.
        if ($condition instanceof ConditionGroupInterface) {
          $cg .= $this->createFilterQuery($condition);
        }
        else {
          $field = $condition->getField();
          if (!$this->getFieldConverter()->isDrupalField($field)) {
            $field = $this->getFieldConverter()->convertDrupalToCoveo($field);
          }
          $coveo_field = '@' . $field;
          $operator = $condition->getOperator();
          $values = $condition->getValue();

          // @todo Add missing operators.
          switch ($operator) {
            case 'IN':
              $cg .= $coveo_field . '=(' . implode(',', $values) . ')';
              break;

            case 'NOT IN':
              $cg .= '(';
              $i = 0;
              foreach ($values as $value) {
                if ($i) {
                  $cg .= ' AND ';
                }
                $cg .= $coveo_field . '<>' . $value;
                $i++;
              }
              $cg .= ')';

              break;

            case '=':
              $cg .= $coveo_field . '==' . $values;
              break;

            case '<>':
              $cg .= $coveo_field . '<>' . $values;
              break;

          }
        }

        if ($count < $total) {
          $cg .= ' ' . $condition_group->getConjunction() . ' ';
        }
        $count++;
      }

      $cg .= ')';
    }

    return !empty($cg) ? $cg : '';
  }

  /**
   * Sets the current facets to group by.
   *
   * @param \Drupal\search_api\Query\QueryInterface $query
   *   The query.
   */
  private function getFacets(QueryInterface $query): array {
    $group_by = [];
    $facet_fields = [];

    $facets = $query->getOption('search_api_facets', []);
    if (empty($facets)) {
      return [];
    }

    // @todo this is happening in a hot path. Refactor to cache or prebuild
    //   field list is state. "Sync" process is a good candidate for this.
    $fields_response = $this->getFieldsApi()->getFields(
      $this->getOrganizationId(),
      new FieldListingOptions()
    );
    if (!$fields_response->isSuccess()) {
      // @todo log error?
      return [];
    }

    $fields = $fields_response->getData();
    foreach ($fields->getItems() as $field) {
      if (!$this->getFieldConverter()->isDrupalField($field->getName())) {
        continue;
      }
      if ($field->getFacet() || $field->getMultiValueFacet()) {
        $facet_fields[$field->getName()] = $field;
      }
    }
    // Avoid complexity if we don't have any external facets to match.
    if (!empty($facet_fields)) {
      foreach ($facets as $info) {
        $prefixed_field = $this->getFieldConverter()->convertDrupalToCoveo($info['field']);
        if (isset($facet_fields[$prefixed_field])) {
          $group_by[] = [
            'field' => '@' . $prefixed_field,
            'maximumNumberOfValues' => empty($info['limit']) ? 100 : $info['limit'],
          ];
        }
      }
    }
    return $group_by;
  }

  /**
   * Extracts facets from a Coveo result set.
   *
   * @todo This is only showing facets based on the results. In the case of an
   *   'OR' Facet, we would need to show alternatives or a user can't select a
   *   different option.
   *
   * @return array
   *   An array describing facets that apply to the current results.
   */
  private function extractFacets(RestQueryResponse $results): array {
    $facets = [];

    // @todo If this is an OR filter, we want non-scoped facets to show.
    foreach ($results->getGroupByResults() as $facet) {
      // Strip prefix.
      $drupal_field = $this->getFieldConverter()->convertCoveoToDrupal($facet->getField());
      foreach ($facet->getValues() as $facet_result) {
        $value = $facet_result->getValue();
        $facets[$drupal_field][] = [
          'filter' => "\"$value\"",
          'count' => $facet_result->getNumberOfResults(),
        ];
      }
    }

    return $facets;
  }

  /**
   * Sets the sort criteria.
   *
   * @param \Drupal\search_api\Query\QueryInterface $query
   *   The Query.
   */
  private function getSorts(QueryInterface $query) {
    $sorts = $query->getSorts();

    // @todo This should be checking if a field in Coveo is sortable or else
    //   a false zero results could occur.
    $sc = '';
    foreach ($sorts as $field => $sort) {
      $order = $sort === 'DESC' ? 'descending' : 'ascending';

      // Coveo doesn't allow to search by relevancy asc.
      if ($field === 'search_api_relevance') {
        $sc .= 'relevancy';
      }
      else {
        $coveo_field = '@' . $this->getFieldConverter()->convertDrupalToCoveo($field);
        $sc .= $coveo_field . ' ' . $order;
      }
      $sc .= ',';
    }

    // Remove last comma.
    $sc = rtrim($sc, ',');
    return $sc;
  }

  /**
   * {@inheritdoc}
   */
  public function supportsDataType($type) {
    return in_array($type, [
      // Empty array.
    ]);
  }

  /**
   * Get field converter helper.
   *
   * @return \Drupal\coveo\FieldConverter
   *   Field converter helper.
   */
  private function getFieldConverter(): FieldConverter {
    return $this->getOrganization()->getFieldConverter();
  }

  /**
   * Get a Coveo FieldAPI instance connected to this backend.
   *
   * @return \NecLimDul\Coveo\FieldApi\Api\FieldsApi
   *   The FieldsAPI instance.
   */
  public function getFieldsApi(): FieldsApi {
    if (!isset($this->fieldsApi)) {
      $this->fieldsApi = $this->getOrganization()
        ->fieldApiCreate(FieldsApi::class);
    }
    return $this->fieldsApi;
  }

  /**
   * Get a Coveo Search V2 API instance.
   *
   * @return \NecLimDul\Coveo\SearchApi\Api\SearchV2Api
   *   The SearchV2API instance.
   */
  public function getSearchApi(): SearchV2Api {
    if (!isset($this->searchApi)) {
      $this->searchApi = $this->searchFactory->create(SearchV2Api::class, $this->getSearchKey());
    }
    return $this->searchApi;
  }

  /**
   * Get a Coveo Item API instance.
   *
   * @return \NecLimDul\Coveo\PushApi\Api\ItemApi
   *   The ItemAPI instance.
   */
  protected function getItemApi(): ItemApi {
    if (!isset($this->itemApi)) {
      $this->itemApi = $this->getOrganization()
        ->pushApiCreate(ItemApi::class);
    }
    return $this->itemApi;
  }

  /**
   * Get an indexing helper.
   *
   * @return \Drupal\coveo\Coveo\Index
   *   Coveo Index helper service instance.
   */
  private function getIndexHelper(): Index {
    if (!isset($this->coveoIndex)) {
      $this->coveoIndex = $this->getOrganization()->getIndexHelper();
    }
    return $this->coveoIndex;
  }

  /**
   * Get the organization configuration machine name.
   */
  public function getOrganizationName() {
    return $this->configuration['organization_name'];
  }

  /**
   * Get the API key (provided by Coveo).
   */
  private function getSearchKey() {
    return $this->configuration['search_key'];
  }

  /**
   * Get the organization ID (provided by Coveo).
   */
  public function getOrganizationId(): string|null {
    return $this->getOrganization()?->getOrganizationId();
  }

  /**
   * Check if backend is in a read only state.
   *
   * @return bool
   *   True if the backend is in a read only state.
   */
  public function isReadOnly(): bool {
    // If read only, just tell SearchAPI we finished without sending anything.
    $organization = $this->getOrganization();
    // Check if organization is null to be safe. Some edge cases during config
    // import can lead to this happening.
    return $organization === NULL || $organization->isReadOnly();
  }

  /**
   * Retrieves all available Coveo organizations.
   *
   * @return \Drupal\coveo\Entity\CoveoOrganizationInterface[]
   *   The available organizations.
   */
  private function getOrganizations(): array {
    try {
      $storage = $this->entityTypeManager->getStorage('coveo_organization');
      $q = $storage->getQuery()
        ->condition('push_source_id', '', '<>')
        ->condition('push_source_id', NULL, 'IS NOT NULL');
      return $storage->loadMultiple($q->execute());
    }
    catch (InvalidPluginDefinitionException | PluginNotFoundException) {
      // This should never happen.
      return [];
    }
  }

  /**
   * Get the organization config associated with this backend.
   */
  public function getOrganization(): CoveoOrganizationInterface|null {
    $id = $this->getOrganizationName();
    if ($id) {
      return $this->entityTypeManager
        ->getStorage('coveo_organization')
        ->load($id);
    }
    return NULL;
  }

}

Главная | Обратная связь

drupal hosting | друпал хостинг | it patrol .inc