View source
<?php
namespace Drupal\search_api_algolia\Plugin\search_api\backend;
use Algolia\AlgoliaSearch\SearchClient;
use Algolia\AlgoliaSearch\Exceptions\AlgoliaException;
use Algolia\AlgoliaSearch\SearchIndex;
use Drupal\Component\Utility\Html;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Language\LanguageInterface;
use Drupal\Core\Language\LanguageManagerInterface;
use Drupal\Core\Plugin\PluginFormInterface;
use Drupal\search_api\Backend\BackendPluginBase;
use Drupal\search_api\IndexInterface;
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 Drupal\search_api\Utility\Utility;
use Drupal\search_api_algolia\SearchApiAlgoliaHelper;
use Psr\Log\LoggerInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Drupal\Core\Config\ConfigFactoryInterface;
class SearchApiAlgoliaBackend extends BackendPluginBase implements PluginFormInterface {
use PluginFormTrait;
protected $algoliaIndex = NULL;
protected $algoliaClient;
protected $logger;
protected $moduleHandler;
protected $languageManager;
protected $configFactory;
protected $helper;
public function __construct(array $configuration, $plugin_id, $plugin_definition, LanguageManagerInterface $language_manager, ConfigFactoryInterface $config_factory, SearchApiAlgoliaHelper $helper) {
parent::__construct($configuration, $plugin_id, $plugin_definition);
$this->languageManager = $language_manager;
$this->configFactory = $config_factory;
$this->helper = $helper;
}
public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
$backend = new static($configuration, $plugin_id, $plugin_definition, $container
->get('language_manager'), $container
->get('config.factory'), $container
->get('search_api_algolia.helper'));
$module_handler = $container
->get('module_handler');
$backend
->setModuleHandler($module_handler);
$logger = $container
->get('logger.channel.search_api_algolia');
$backend
->setLogger($logger);
return $backend;
}
public function defaultConfiguration() {
return [
'application_id' => '',
'api_key' => '',
'disable_truncate' => FALSE,
];
}
public function buildConfigurationForm(array $form, FormStateInterface $form_state) {
$form['help'] = [
'#markup' => '<p>' . $this
->t('The application ID and API key an be found and configured at <a href="@link" target="blank">@link</a>.', [
'@link' => 'https://www.algolia.com/licensing',
]) . '</p>',
];
$form['application_id'] = [
'#type' => 'textfield',
'#title' => $this
->t('Application ID'),
'#description' => $this
->t('The application ID from your Algolia subscription.'),
'#default_value' => $this
->getApplicationId(),
'#required' => TRUE,
'#size' => 60,
'#maxlength' => 128,
];
$form['api_key'] = [
'#type' => 'textfield',
'#title' => $this
->t('API Key'),
'#description' => $this
->t('The API key from your Algolia subscription.'),
'#default_value' => $this
->getApiKey(),
'#required' => TRUE,
'#size' => 60,
'#maxlength' => 128,
];
$form['disable_truncate'] = [
'#type' => 'checkbox',
'#title' => $this
->t('Disable truncation'),
'#description' => $this
->t('If checked, fields of type text and strong will not be truncated at 10000 characters. It will be site owner or developer responsibility to limit the characters.'),
'#default_value' => $this->configuration['disable_truncate'],
];
return $form;
}
public function viewSettings() {
try {
$this
->connect();
} catch (\Exception $e) {
$this
->getLogger()
->warning('Could not connect to Algolia backend.');
}
$info = [];
$info[] = [
'label' => $this
->t('Application ID'),
'info' => $this
->getApplicationId(),
];
$info[] = [
'label' => $this
->t('API Key'),
'info' => $this
->getApiKey(),
];
$indexes = $this
->getAlgolia()
->listIndices();
$indexes_list = [];
if (isset($indexes['items'])) {
foreach ($indexes['items'] as $index) {
$indexes_list[] = $index['name'];
}
}
$info[] = [
'label' => $this
->t('Available Algolia indexes'),
'info' => implode(', ', $indexes_list),
];
return $info;
}
public function removeIndex($index) {
if (!is_object($index) || empty($index
->get('read_only'))) {
$this
->deleteAllIndexItems($index);
}
}
public function indexItems(IndexInterface $index, array $items) {
$objects = [];
foreach ($items as $id => $item) {
$objects[$id] = $this
->prepareItem($index, $item);
}
$this
->alterAlgoliaObjects($objects, $index, $items);
if (count($objects) > 0) {
$itemsToIndex = [];
if ($this->languageManager
->isMultilingual()) {
foreach ($objects as $item) {
$itemsToIndex[$item['search_api_language']][] = $item;
}
}
else {
$itemsToIndex[''] = $objects;
}
foreach ($itemsToIndex as $language => $items) {
if ($this
->isDebugActive()) {
foreach ($items as $item) {
$this
->getLogger()
->notice('Data pushed to Algolia for Language @language : @data', [
'@data' => json_encode($item),
'@language' => $language,
]);
}
}
try {
$this
->connect($index, '', $language);
$this
->getAlgoliaIndex()
->saveObjects($items);
} catch (AlgoliaException $e) {
$this
->getLogger()
->warning(Html::escape($e
->getMessage()));
}
}
}
return array_keys($objects);
}
protected function indexItem(IndexInterface $index, ItemInterface $item) {
$this
->indexItems($index, [
$item
->getId() => $item,
]);
}
protected function prepareItem(IndexInterface $index, ItemInterface $item) {
$item_id = $item
->getId();
$item_to_index = [
'objectID' => $item_id,
];
$item_fields = $item
->getFields();
$item_fields += $this
->getSpecialFields($index, $item);
foreach ($item_fields as $field) {
$type = $field
->getType();
$values = NULL;
$field_values = $field
->getValues();
if (empty($field_values)) {
continue;
}
foreach ($field_values as $field_value) {
switch ($type) {
case 'uri':
$field_value .= '';
if (mb_strlen($field_value) > 10000) {
$field_value = mb_substr(trim($field_value), 0, 10000);
}
$values[] = $field_value;
break;
case 'text':
case 'string':
$field_value .= '';
if (empty($this->configuration['disable_truncate']) && mb_strlen($field_value) > 10000) {
$field_value = mb_substr(trim($field_value), 0, 10000);
}
$values[] = $field_value;
break;
case 'integer':
case 'duration':
case 'decimal':
$values[] = 0 + $field_value;
break;
case 'boolean':
$values[] = $field_value ? TRUE : FALSE;
break;
case 'date':
if (is_numeric($field_value) || !$field_value) {
$values[] = 0 + $field_value;
break;
}
$values[] = strtotime($field_value);
break;
default:
$values[] = $field_value;
}
}
if (is_array($values) && count($values) <= 1) {
$values = reset($values);
}
$item_to_index[$field
->getFieldIdentifier()] = $values;
}
return $item_to_index;
}
protected function alterAlgoliaObjects(array &$objects, IndexInterface $index, array $items) {
$this
->getModuleHandler()
->alter('search_api_algolia_objects', $objects, $index, $items);
}
public function deleteItems(IndexInterface $index, array $ids) {
if ($index
->getOption('object_id_field')) {
return;
}
foreach ($this
->getLanguages($index) as $key) {
if ($index
->getOption('algolia_index_batch_deletion')) {
$this->helper
->scheduleForDeletion($index, $ids, $key);
continue;
}
try {
$this
->connect($index, '', $key);
} catch (\Exception $e) {
$this
->getLogger()
->error('Failed to connect to Algolia index while deleting indexed items, Error: @message', [
'@message' => $e
->getMessage(),
]);
continue;
}
$response = $this
->getAlgoliaIndex()
->deleteObjects($ids);
if ($this
->isDebugActive()) {
$this
->getLogger()
->notice('Deletion requested for IDs: @ids on Algolia for Index: @index, Response: @response.', [
'@response' => json_encode($response),
'@index' => $this
->getAlgoliaIndex()
->getIndexName(),
'@ids' => implode(',', $ids),
]);
}
if ($this
->shouldWaitForDeleteToFinish()) {
$response
->wait();
}
}
}
public function deleteAllIndexItems(IndexInterface $index = NULL, $datasource_id = NULL) {
if (empty($index)) {
return;
}
foreach ($this
->getLanguages($index) as $key) {
$this
->connect($index, '', $key);
$response = $this
->getAlgoliaIndex()
->clearObjects();
if ($this
->isDebugActive()) {
$this
->getLogger()
->notice('Deletion requested for full index on Algolia Index: @index, Response: @response.', [
'@response' => json_encode($response),
'@index' => $this
->getAlgoliaIndex()
->getIndexName(),
]);
}
if ($this
->shouldWaitForDeleteToFinish()) {
$response
->wait();
}
}
}
public function search(QueryInterface $query) {
$results = $query
->getResults();
$options = $query
->getOptions();
$sorts = $query
->getSorts() ?? [];
$search_api_index = $query
->getIndex();
$suffix = '';
$this
->getModuleHandler()
->alter('search_api_algolia_sorts', $sorts, $search_api_index);
foreach ($sorts as $field => $direction) {
$suffix = '_' . strtolower($field . '_' . $direction);
break;
}
try {
$this
->connect($search_api_index, $suffix);
$index = $this
->getAlgoliaIndex();
} catch (\Exception $e) {
$this
->getLogger()
->error('Failed to connect to Algolia index while searching with suffix: @suffix, Error: @message', [
'@message' => $e
->getMessage(),
'@suffix' => $suffix,
]);
return $results;
}
$facets = isset($options['search_api_facets']) ? array_column($options['search_api_facets'], 'field') : [];
$algolia_options = [
'attributesToRetrieve' => [
'search_api_id',
],
'facets' => $facets,
'analytics' => TRUE,
];
if (!empty($options['limit'])) {
$algolia_options['length'] = $options['limit'];
$algolia_options['offset'] = $options['offset'];
}
if (isset($options['algolia_options']) && is_array($options['algolia_options'])) {
$algolia_options += $options['algolia_options'];
}
$this
->extractConditions($query
->getConditionGroup(), $algolia_options, $facets);
if (isset($algolia_options['facetFilters'])) {
$algolia_options['facetFilters'] = array_values($algolia_options['facetFilters']);
}
if (isset($algolia_options['disjunctiveFacets'])) {
$algolia_options['disjunctiveFacets'] = array_values($algolia_options['disjunctiveFacets']);
}
if (!empty($algolia_options['filters']) && !empty($algolia_options['disjunctiveFacets'])) {
unset($algolia_options['disjunctiveFacets']);
}
$keys = $query
->getOriginalKeys();
$search = empty($keys) ? '*' : $keys;
$data = $index
->search($search, $algolia_options);
$results
->setResultCount($data['nbHits']);
foreach ($data['hits'] ?? [] as $row) {
$item = $this
->getFieldsHelper()
->createItem($query
->getIndex(), $row['search_api_id']);
if (!empty($row['_snippetResult'])) {
$item
->setExcerpt(implode('…', array_column($row['_snippetResult'], 'value')));
}
$results
->addResultItem($item);
}
if (isset($data['facets'])) {
$results
->setExtraData('search_api_facets', $this
->extractFacetsData($facets, $data['facets']));
}
return $results;
}
protected function connect(?IndexInterface $index = NULL, $index_suffix = '', $langcode = '') {
if (!$this
->getAlgolia() instanceof SearchClient) {
$this->algoliaClient = SearchClient::create($this
->getApplicationId(), $this
->getApiKey());
}
if ($index && $index instanceof IndexInterface) {
$indexId = $index
->getOption('algolia_index_name') ? $index
->getOption('algolia_index_name') : $index
->get('id');
if ($this
->isLanguageSuffixEnabled($index)) {
$langcode = $langcode ?: $this->languageManager
->getCurrentLanguage()
->getId();
$indexId .= '_' . $langcode;
}
$indexId .= $index_suffix;
$this
->setAlgoliaIndex($this->algoliaClient
->initIndex($indexId));
}
}
public function listIndexes() {
$algoliaClient = SearchClient::create($this
->getApplicationId(), $this
->getApiKey());
$indexes = $algoliaClient
->listIndices();
$indexes_list = [];
if (isset($indexes['items'])) {
foreach ($indexes['items'] as $index) {
$indexes_list[$index['name']] = $index['name'];
}
}
return $indexes_list;
}
public function getLogger() {
return $this->logger;
}
public function setLogger(LoggerInterface $logger) {
$this->logger = $logger;
return $this;
}
public function getModuleHandler() {
return $this->moduleHandler ?? Drupal::moduleHandler();
}
public function setModuleHandler(ModuleHandlerInterface $module_handler) {
$this->moduleHandler = $module_handler;
return $this;
}
public function getAlgolia() {
return $this->algoliaClient;
}
protected function getAlgoliaIndex() {
return $this->algoliaIndex;
}
protected function setAlgoliaIndex(SearchIndex $index) {
$this->algoliaIndex = $index;
}
protected function getApplicationId() {
return $this->configuration['application_id'];
}
protected function getApiKey() {
return $this->configuration['api_key'];
}
public function getSupportedFeatures() {
return [
'search_api_autocomplete',
'search_api_facets',
'search_api_facets_operator_or',
];
}
private function extractFacetsData(array $facets, array $data) {
$facets_data = [];
foreach ($data as $field => $facet_data) {
if (!in_array($field, $facets)) {
continue;
}
foreach ($facet_data as $value => $count) {
$facets_data[$field][] = [
'count' => $count,
'filter' => '"' . $value . '"',
];
}
}
return $facets_data;
}
private function extractConditions(ConditionGroupInterface $condition_group, array &$options, array $facets) {
foreach ($condition_group
->getConditions() as $condition) {
if ($condition instanceof ConditionGroupInterface) {
$this
->extractConditions($condition, $options, $facets);
continue;
}
$field = $condition
->getField();
if ($condition
->getOperator() == '=') {
$query = $field . ':' . $condition
->getValue();
if (in_array($field, $facets)) {
$options['facetFilters'][$field][] = $query;
$options['disjunctiveFacets'][$field] = $field;
}
else {
$options['filters'] = isset($options['filters']) ? ' AND ' . $query : $query;
}
}
elseif (in_array($condition
->getOperator(), [
'<',
'>',
'<=',
'>=',
])) {
$options['numericFilters'][] = $field . ' ' . $condition
->getOperator() . ' ' . $condition
->getValue();
}
}
}
public function getAutocompleteSuggestions(QueryInterface $query, \Drupal\search_api_autocomplete\SearchInterface $search, $incomplete_key, $user_input) {
$suggestions = [];
try {
$factory = new \Drupal\search_api_autocomplete\Suggestion\SuggestionFactory($user_input);
} catch (\Exception $e) {
return $suggestions;
}
$search_api_index = $query
->getIndex();
try {
$this
->connect($search_api_index, '_query');
$index = $this
->getAlgoliaIndex();
} catch (\Exception $e) {
$this
->getLogger()
->error('Failed to connect to Algolia index with suffix: @suffix, Error: @message', [
'@message' => $e
->getMessage(),
'@suffix' => '_query',
]);
return $suggestions;
}
$algolia_options = [
'attributesToRetrieve' => [
'query',
],
'analytics' => TRUE,
];
try {
$data = $index
->search($user_input, $algolia_options);
} catch (\Exception $e) {
$this
->getLogger()
->error('Failed to load autocomplete suggestions from Algolia. Query: @query, Error: @message', [
'@message' => $e
->getMessage(),
'@query' => $user_input,
]);
return $suggestions;
}
foreach ($data['hits'] ?? [] as $row) {
$suggestions[] = $factory
->createFromSuggestedKeys($row['query']);
}
return $suggestions;
}
protected function isDebugActive() {
static $debug_active = NULL;
if (is_null($debug_active)) {
$debug_active = $this->configFactory
->get('search_api_algolia.settings')
->get('debug') ?? FALSE;
}
return $debug_active;
}
protected function shouldWaitForDeleteToFinish() {
static $should_wait = NULL;
if (is_null($should_wait)) {
$should_wait = $this->configFactory
->get('search_api_algolia.settings')
->get('wait_for_delete') ?? FALSE;
}
return $should_wait;
}
protected function isLanguageSuffixEnabled(IndexInterface $index) {
return $this->languageManager
->isMultilingual() && $index
->getOption('algolia_index_apply_suffix');
}
protected function getLanguages(IndexInterface $index) {
$languages = [];
if (!$this
->isLanguageSuffixEnabled($index)) {
return [
'',
];
}
foreach ($index
->getDatasources() as $datasource) {
$config = $datasource
->getConfiguration();
$always_valid = [
LanguageInterface::LANGCODE_NOT_SPECIFIED,
LanguageInterface::LANGCODE_NOT_APPLICABLE,
];
foreach ($this->languageManager
->getLanguages() as $language) {
if (Utility::matches($language
->getId(), $config['languages']) || in_array($language
->getId(), $always_valid)) {
$languages[$language
->getId()] = $language
->getId();
}
}
}
return $languages;
}
}