commit
4083c9b391
7 changed files with 373 additions and 0 deletions
@ -0,0 +1,173 @@
|
||||
# Solr sort: ignore leading articles |
||||
|
||||
Adds the `string_sort_ignore_articles` Solr field type and uses it for the |
||||
Search API sort field(s) listed in `solr_sort_ignore_articles.settings`, so |
||||
"The Matrix" sorts under M and "!Introduction" sorts under I. |
||||
|
||||
## Requirements |
||||
|
||||
* Drupal 10 or 11 |
||||
* `search_api_solr` 4.3 or later |
||||
* Write access to the `conf` directory of the Solr core, and the ability to |
||||
reload the core |
||||
|
||||
## How it works |
||||
|
||||
`search_api_solr` never sorts on the field you index. For every string and |
||||
fulltext field it copies the first value into a companion field named |
||||
`sort_;<langcode>_<field id>` (encoded as `sort_X3b_en_title`), and sorting |
||||
resolves to that field. Those companion fields are matched by the dynamic |
||||
field `sort_X3b_<langcode>_*`, which is typed as the language's ICU collation |
||||
— one collation per language, with no per-field override. |
||||
|
||||
This module subscribes to `PostConfigFilesGenerationEvent` and appends an |
||||
explicit `<field>` for the fields you name to `schema_extra_fields.xml`, plus |
||||
the field type to `schema_extra_types.xml`. In Solr an explicit field always |
||||
wins over a matching `dynamicField`, so only the named fields get the new |
||||
analysis and every other sort keeps the default collation. |
||||
|
||||
The module never talks to Solr itself. It only changes what |
||||
`search-api-solr:get-server-config` generates — deploying that to the core is |
||||
a manual step. |
||||
|
||||
## Deployment |
||||
|
||||
### 1. Install and enable |
||||
|
||||
```bash |
||||
composer require roblib/solr_sort_ignore_articles |
||||
drush en solr_sort_ignore_articles |
||||
``` |
||||
|
||||
### 2. Add a sort field to the index |
||||
|
||||
At `/admin/config/search/search-api/index/<index>/fields`, add *Content » |
||||
Title* a second time, change its machine name to `title_sort` and set its type |
||||
to **String**. In config it looks like this: |
||||
|
||||
```yaml |
||||
# search_api.index.<index>.yml, under field_settings: |
||||
title_sort: |
||||
label: 'Title (sort, ignore articles)' |
||||
datasource_id: 'entity:node' |
||||
property_path: title |
||||
type: string |
||||
dependencies: |
||||
module: |
||||
- node |
||||
``` |
||||
|
||||
Using a separate field keeps sorting on the existing `title` field unchanged. |
||||
To change `title` itself instead, put `title` in |
||||
`solr_sort_ignore_articles.settings:fields` and skip this step. |
||||
|
||||
The module ships with `title_sort` as the default. To override: |
||||
|
||||
```bash |
||||
drush config:set solr_sort_ignore_articles.settings fields.0 title_sort |
||||
drush config:set solr_sort_ignore_articles.settings fields.1 field_alt_title |
||||
``` |
||||
|
||||
Export config, and remember that any later change here means repeating |
||||
steps 3 and 4. |
||||
|
||||
### 3. Update the Solr schema |
||||
|
||||
Generate the config set, matching the Solr version actually running on the |
||||
target server: |
||||
|
||||
```bash |
||||
drush search-api-solr:get-server-config <server> /tmp/solr_config.zip <solr-version> |
||||
``` |
||||
|
||||
Sanity check the two generated files before deploying: |
||||
|
||||
```bash |
||||
unzip -p /tmp/solr_config.zip schema_extra_types.xml | grep -A3 string_sort_ignore_articles |
||||
unzip -p /tmp/solr_config.zip schema_extra_fields.xml | grep 'sort_X3b_.*_title_sort' |
||||
``` |
||||
|
||||
Then deploy to the core's `conf` directory by one of two routes. |
||||
|
||||
**Full replacement** — only when the core belongs to this site alone and the |
||||
config set was generated for the same Solr version the server runs: |
||||
|
||||
```bash |
||||
unzip -o /tmp/solr_config.zip -d /path/to/solr/server/solr/<core>/conf |
||||
``` |
||||
|
||||
**Hand-append ** — required when the core is shared with other |
||||
Drupal sites, or when the target runs a different Solr major version than the |
||||
config set was generated for. A full replacement would discard other sites' |
||||
additions, and a config set for the wrong Solr version brings an incompatible |
||||
`solrconfig.xml` along with it. |
||||
|
||||
Append the module's block to the end of each of the two files in the core's |
||||
`conf` directory. Both files are bare XML fragments with no root element, and |
||||
field types do not have to precede the fields that use them. |
||||
|
||||
`schema_extra_types.xml`: |
||||
|
||||
```xml |
||||
<!-- solr_sort_ignore_articles --> |
||||
<fieldType name="string_sort_ignore_articles" class="solr.TextField" sortMissingLast="true" omitNorms="true"> |
||||
<analyzer> |
||||
<tokenizer class="solr.KeywordTokenizerFactory"/> |
||||
<filter class="solr.PatternReplaceFilterFactory" pattern="^[^\p{L}\p{N}]+" replacement="" replace="all"/> |
||||
<filter class="solr.PatternReplaceFilterFactory" pattern="^(?i)(a\s+|an\s+|the\s+)" replacement="" replace="all"/> |
||||
<filter class="solr.PatternReplaceFilterFactory" pattern="^[^\p{L}\p{N}]+" replacement="" replace="all"/> |
||||
<filter class="solr.LowerCaseFilterFactory"/> |
||||
<filter class="solr.TrimFilterFactory"/> |
||||
</analyzer> |
||||
</fieldType> |
||||
``` |
||||
|
||||
`schema_extra_fields.xml`, one line per language, copied from the generated |
||||
zip: |
||||
|
||||
```xml |
||||
<!-- solr_sort_ignore_articles: these win over the matching sort_X3b_LANG_* dynamic fields --> |
||||
<field name="sort_X3b_en_title_sort" type="string_sort_ignore_articles" indexed="true" stored="false" multiValued="false"/> |
||||
<field name="sort_X3b_und_title_sort" type="string_sort_ignore_articles" indexed="true" stored="false" multiValued="false"/> |
||||
<field name="sort_X3b_zxx_title_sort" type="string_sort_ignore_articles" indexed="true" stored="false" multiValued="false"/> |
||||
``` |
||||
|
||||
Keep the comment markers — they are what makes the block findable and cleanly |
||||
removable later. |
||||
|
||||
Both blocks load unchanged on Solr 8.11 and 9.x. |
||||
|
||||
Reload the core: |
||||
|
||||
```bash |
||||
curl -sS "http://<solr-host>:8983/solr/admin/cores?action=RELOAD&core=<core>" |
||||
``` |
||||
|
||||
A malformed fragment fails the reload with a schema parse error and the core |
||||
keeps its previous schema, so this is safe to retry. |
||||
|
||||
### 4. Reindex |
||||
|
||||
The sort field is only populated at index time, so existing documents keep |
||||
their old values until reindexed: |
||||
|
||||
```bash |
||||
drush search-api:reset-tracker && drush search-api:index |
||||
``` |
||||
|
||||
### 5. Add the sort criterion |
||||
|
||||
In Views, add the new sort criterion (*Title (sort, ignore articles)*) to the |
||||
search view. |
||||
|
||||
## Verifying |
||||
|
||||
At `/solr/#/<core>/analysis`, field type `string_sort_ignore_articles` with |
||||
input `The !Matrix` should produce the single token `matrix`. |
||||
|
||||
The field itself can be confirmed with the Schema API: |
||||
|
||||
```bash |
||||
curl -sS "http://<solr-host>:8983/solr/<core>/schema/fields/sort_X3b_en_title_sort" |
||||
``` |
||||
|
||||
@ -0,0 +1,23 @@
|
||||
{ |
||||
"name": "roblib/solr_sort_ignore_articles", |
||||
"description": "Adds a Solr field type that strips leading punctuation and the articles \"a\", \"an\" and \"the\", and wires it to the Search API sort field(s) named in solr_sort_ignore_articles.settings.", |
||||
"type": "drupal-custom-module", |
||||
"license": "GPL-2.0-or-later", |
||||
"keywords": [ |
||||
"drupal", |
||||
"solr", |
||||
"search_api", |
||||
"search_api_solr", |
||||
"sorting" |
||||
], |
||||
"homepage": "https://git.library.upei.ca/Code/solr_sort_ignore_articles", |
||||
"support": { |
||||
"issues": "https://git.library.upei.ca/Code/solr_sort_ignore_articles/issues", |
||||
"source": "https://git.library.upei.ca/Code/solr_sort_ignore_articles" |
||||
}, |
||||
"require": { |
||||
"php": ">=8.1", |
||||
"drupal/core": "^10 || ^11", |
||||
"drupal/search_api_solr": "^4.3" |
||||
} |
||||
} |
||||
@ -0,0 +1,5 @@
|
||||
# Search API field IDs whose Solr sort field should ignore leading articles. |
||||
# Every field listed here must exist on the index as a "string" or fulltext |
||||
# field, otherwise Solr has nothing to sort on. |
||||
fields: |
||||
- title_sort |
||||
@ -0,0 +1,10 @@
|
||||
solr_sort_ignore_articles.settings: |
||||
type: config_object |
||||
label: 'Solr sort: ignore leading articles settings' |
||||
mapping: |
||||
fields: |
||||
type: sequence |
||||
label: 'Search API field IDs to override the sort field for' |
||||
sequence: |
||||
type: string |
||||
label: 'Search API field ID' |
||||
@ -0,0 +1,7 @@
|
||||
name: 'Solr sort: ignore leading articles' |
||||
type: module |
||||
description: 'Adds a Solr field type that strips leading punctuation and the articles "a", "an" and "the", and wires it to the Search API sort field(s) named in solr_sort_ignore_articles.settings.' |
||||
package: Search |
||||
core_version_requirement: ^10 || ^11 |
||||
dependencies: |
||||
- search_api_solr:search_api_solr |
||||
@ -0,0 +1,8 @@
|
||||
services: |
||||
solr_sort_ignore_articles.config_set_subscriber: |
||||
class: Drupal\solr_sort_ignore_articles\EventSubscriber\SolrConfigSetSubscriber |
||||
arguments: |
||||
- '@config.factory' |
||||
- '@language_manager' |
||||
tags: |
||||
- { name: event_subscriber } |
||||
@ -0,0 +1,147 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace Drupal\solr_sort_ignore_articles\EventSubscriber; |
||||
|
||||
use Drupal\Core\Config\ConfigFactoryInterface; |
||||
use Drupal\Core\Language\LanguageInterface; |
||||
use Drupal\Core\Language\LanguageManagerInterface; |
||||
use Drupal\search_api_solr\Event\PostConfigFilesGenerationEvent; |
||||
use Drupal\search_api_solr\SolrBackendInterface; |
||||
use Drupal\search_api_solr\Utility\Utility; |
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface; |
||||
|
||||
/** |
||||
* Adds an article-insensitive sort field type to the generated config set. |
||||
* |
||||
* search_api_solr always sorts string and fulltext fields through a dedicated |
||||
* "sort_;<langcode>_<field id>" field, which is matched by the dynamic field |
||||
* "sort_;<langcode>_*" and therefore typed as the language's ICU collation. |
||||
* There is no per-field hook for that, so we append an explicit <field> for |
||||
* the fields we care about: in Solr an explicit field always wins over a |
||||
* matching dynamicField. |
||||
* |
||||
* @see \Drupal\search_api_solr\Utility\Utility::getSortableSolrField() |
||||
* @see \Drupal\search_api_solr\Entity\SolrFieldType::getCollatedField() |
||||
*/ |
||||
class SolrConfigSetSubscriber implements EventSubscriberInterface { |
||||
|
||||
/** |
||||
* The name of the Solr field type added to schema_extra_types.xml. |
||||
*/ |
||||
public const FIELD_TYPE = 'string_sort_ignore_articles'; |
||||
|
||||
public function __construct( |
||||
protected ConfigFactoryInterface $configFactory, |
||||
protected LanguageManagerInterface $languageManager, |
||||
) {} |
||||
|
||||
/** |
||||
* {@inheritdoc} |
||||
*/ |
||||
public static function getSubscribedEvents(): array { |
||||
return [ |
||||
PostConfigFilesGenerationEvent::class => 'onPostConfigFilesGeneration', |
||||
]; |
||||
} |
||||
|
||||
/** |
||||
* Appends the field type and the sort field overrides to the config set. |
||||
*/ |
||||
public function onPostConfigFilesGeneration(PostConfigFilesGenerationEvent $event): void { |
||||
$fields = $this->getFieldIds(); |
||||
if (!$fields) { |
||||
return; |
||||
} |
||||
|
||||
$files = $event->getConfigFiles(); |
||||
if (!isset($files['schema_extra_types.xml'], $files['schema_extra_fields.xml'])) { |
||||
// Not a Drupal schema.xml based config set, nothing to extend. |
||||
return; |
||||
} |
||||
|
||||
$files['schema_extra_types.xml'] .= "\n" . $this->getFieldTypeXml(); |
||||
$files['schema_extra_fields.xml'] .= "\n" . $this->getSortFieldsXml($fields); |
||||
$event->setConfigFiles($files); |
||||
} |
||||
|
||||
/** |
||||
* Returns the Search API field IDs to override the sort field for. |
||||
* |
||||
* Duplicates are removed: listing a field twice would emit two <field> |
||||
* elements with the same name, and Solr refuses to load a core with a |
||||
* duplicate field definition. |
||||
* |
||||
* @return string[] |
||||
* The field IDs. |
||||
*/ |
||||
protected function getFieldIds(): array { |
||||
$fields = $this->configFactory |
||||
->get('solr_sort_ignore_articles.settings') |
||||
->get('fields'); |
||||
|
||||
return array_values(array_unique(array_filter((array) $fields))); |
||||
} |
||||
|
||||
/** |
||||
* Returns the <fieldType> definition for schema_extra_types.xml. |
||||
*/ |
||||
protected function getFieldTypeXml(): string { |
||||
$xml = <<<'XML' |
||||
<!-- solr_sort_ignore_articles --> |
||||
<fieldType name="FIELD_TYPE_NAME" class="solr.TextField" sortMissingLast="true" omitNorms="true"> |
||||
<analyzer> |
||||
<!-- 1. Keep the entire string as a single token. --> |
||||
<tokenizer class="solr.KeywordTokenizerFactory"/> |
||||
<!-- 2. Strip leading punctuation, symbols and spaces. |
||||
[^\p{L}\p{N}] is "anything that is NOT a letter or a number". --> |
||||
<filter class="solr.PatternReplaceFilterFactory" pattern="^[^\p{L}\p{N}]+" replacement="" replace="all"/> |
||||
<!-- 3. Remove a leading 'A ', 'An ' or 'The ' (case-insensitive). --> |
||||
<filter class="solr.PatternReplaceFilterFactory" pattern="^(?i)(a\s+|an\s+|the\s+)" replacement="" replace="all"/> |
||||
<!-- 4. Strip leading punctuation again, to catch "The !Matrix". --> |
||||
<filter class="solr.PatternReplaceFilterFactory" pattern="^[^\p{L}\p{N}]+" replacement="" replace="all"/> |
||||
<!-- 5. Lowercase so 'Matrix' and 'matrix' sort together. --> |
||||
<filter class="solr.LowerCaseFilterFactory"/> |
||||
<!-- 6. Trim any accidental leading/trailing whitespace. --> |
||||
<filter class="solr.TrimFilterFactory"/> |
||||
</analyzer> |
||||
</fieldType> |
||||
|
||||
XML; |
||||
|
||||
return str_replace('FIELD_TYPE_NAME', static::FIELD_TYPE, $xml); |
||||
} |
||||
|
||||
/** |
||||
* Returns the explicit <field> overrides for schema_extra_fields.xml. |
||||
* |
||||
* The sort fields have to be filled for every language the backend indexes |
||||
* for, so we override all of them. |
||||
* |
||||
* @param string[] $fields |
||||
* The Search API field IDs. |
||||
*/ |
||||
protected function getSortFieldsXml(array $fields): string { |
||||
$language_ids = array_keys($this->languageManager->getLanguages(LanguageInterface::STATE_ALL)); |
||||
$language_ids[] = LanguageInterface::LANGCODE_NOT_SPECIFIED; |
||||
$language_ids = array_unique($language_ids); |
||||
|
||||
$xml = "<!-- solr_sort_ignore_articles: these win over the matching sort_X3b_LANG_* dynamic fields -->\n"; |
||||
foreach ($fields as $field_id) { |
||||
foreach ($language_ids as $language_id) { |
||||
// A TextField cannot carry docValues, so unlike the collated dynamic |
||||
// fields these have to be indexed. The KeywordTokenizer guarantees a |
||||
// single token per document, which is what makes them sortable. |
||||
$xml .= sprintf( |
||||
'<field name="%s" type="%s" indexed="true" stored="false" multiValued="false"/>' . "\n", |
||||
Utility::encodeSolrName('sort' . SolrBackendInterface::SEARCH_API_SOLR_LANGUAGE_SEPARATOR . $language_id . '_' . $field_id), |
||||
static::FIELD_TYPE |
||||
); |
||||
} |
||||
} |
||||
|
||||
return $xml; |
||||
} |
||||
|
||||
} |
||||
Loading…
Reference in new issue