From 4083c9b3910c7302b5c18ed7383504c537bd9d28 Mon Sep 17 00:00:00 2001 From: Paul Pound Date: Fri, 4 Sep 2026 14:35:15 -0300 Subject: [PATCH] first commit --- README.md | 173 ++++++++++++++++++ composer.json | 23 +++ .../solr_sort_ignore_articles.settings.yml | 5 + .../solr_sort_ignore_articles.schema.yml | 10 + solr_sort_ignore_articles.info.yml | 7 + solr_sort_ignore_articles.services.yml | 8 + .../SolrConfigSetSubscriber.php | 147 +++++++++++++++ 7 files changed, 373 insertions(+) create mode 100644 README.md create mode 100644 composer.json create mode 100644 config/install/solr_sort_ignore_articles.settings.yml create mode 100644 config/schema/solr_sort_ignore_articles.schema.yml create mode 100644 solr_sort_ignore_articles.info.yml create mode 100644 solr_sort_ignore_articles.services.yml create mode 100644 src/EventSubscriber/SolrConfigSetSubscriber.php diff --git a/README.md b/README.md new file mode 100644 index 0000000..8f16aac --- /dev/null +++ b/README.md @@ -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_;_` (encoded as `sort_X3b_en_title`), and sorting +resolves to that field. Those companion fields are matched by the dynamic +field `sort_X3b__*`, 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 `` 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//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..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 /tmp/solr_config.zip +``` + +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//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 + + + + + + + + + + + +``` + +`schema_extra_fields.xml`, one line per language, copied from the generated +zip: + +```xml + + + + +``` + +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://:8983/solr/admin/cores?action=RELOAD&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/#//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://:8983/solr//schema/fields/sort_X3b_en_title_sort" +``` + diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..be63e8b --- /dev/null +++ b/composer.json @@ -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" + } +} diff --git a/config/install/solr_sort_ignore_articles.settings.yml b/config/install/solr_sort_ignore_articles.settings.yml new file mode 100644 index 0000000..3a896b2 --- /dev/null +++ b/config/install/solr_sort_ignore_articles.settings.yml @@ -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 diff --git a/config/schema/solr_sort_ignore_articles.schema.yml b/config/schema/solr_sort_ignore_articles.schema.yml new file mode 100644 index 0000000..60a52c2 --- /dev/null +++ b/config/schema/solr_sort_ignore_articles.schema.yml @@ -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' diff --git a/solr_sort_ignore_articles.info.yml b/solr_sort_ignore_articles.info.yml new file mode 100644 index 0000000..f7e8040 --- /dev/null +++ b/solr_sort_ignore_articles.info.yml @@ -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 diff --git a/solr_sort_ignore_articles.services.yml b/solr_sort_ignore_articles.services.yml new file mode 100644 index 0000000..64144f6 --- /dev/null +++ b/solr_sort_ignore_articles.services.yml @@ -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 } diff --git a/src/EventSubscriber/SolrConfigSetSubscriber.php b/src/EventSubscriber/SolrConfigSetSubscriber.php new file mode 100644 index 0000000..f585dec --- /dev/null +++ b/src/EventSubscriber/SolrConfigSetSubscriber.php @@ -0,0 +1,147 @@ +_" field, which is matched by the dynamic field + * "sort_;_*" and therefore typed as the language's ICU collation. + * There is no per-field hook for that, so we append an explicit 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 + * 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 definition for schema_extra_types.xml. + */ + protected function getFieldTypeXml(): string { + $xml = <<<'XML' + + + + + + + + + + + + + + + + + + +XML; + + return str_replace('FIELD_TYPE_NAME', static::FIELD_TYPE, $xml); + } + + /** + * Returns the explicit 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 = "\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( + '' . "\n", + Utility::encodeSolrName('sort' . SolrBackendInterface::SEARCH_API_SOLR_LANGUAGE_SEPARATOR . $language_id . '_' . $field_id), + static::FIELD_TYPE + ); + } + } + + return $xml; + } + +}