# SearchableDataProvider plugins

How to implement a search provider that exposes your module's data to the unified search system (used by the CKEditor integration, autocomplete endpoints, and any future search consumers).

## When to use this

- Your module stores a WoW entity type (items, creatures, quests, etc.) and you want it searchable from the editor modal or other search UIs.
- You want to provide local-first search with optional Blizzard Search API fallback for entities not yet imported.

## The interface

`Drupal\wow\SearchableDataProviderInterface`

```php
interface SearchableDataProviderInterface extends PluginInspectionInterface {
  public function getSearchLabel(): string;
  public function search(string $query, string $region, int $limit = 10): array;
  public function getCanonicalUrl(int $blizzardId): ?Url;
  public function importEntity(int $blizzardId, string $region): ?Url;
}
```

| Method | Returns | Purpose |
|---|---|---|
| `getSearchLabel()` | `string` | Human-readable label for this category (e.g. "Items", "Creatures"). Shown in search UIs. |
| `search()` | `SearchResult[]` | Query local storage first, optionally fall back to the Blizzard Search API if fewer than `$limit` results are found. |
| `getCanonicalUrl()` | `Url\|null` | Canonical page URL for an entity by Blizzard ID. NULL if the type has no canonical page. |
| `importEntity()` | `Url\|null` | Sync a remote entity from the Blizzard API, persist it locally, return the canonical URL. NULL if import failed or unsupported. |

## The attribute

```php
use Drupal\wow\Attribute\SearchableDataProvider;

#[SearchableDataProvider(
  id: 'my_things',
  label: new TranslatableMarkup('My things'),
  weight: 15,
)]
class MyThingSearch extends SearchableDataProviderBase {
  // ...
}
```

Plugins live in `modules/<module>/src/Plugin/SearchableDataProvider/`.

| Attribute property | Purpose |
|---|---|
| `id` | Plugin ID. Used as the `type` parameter in search queries. |
| `label` | Human-readable label for search UI dropdowns. |
| `weight` | Display order. Lower values appear first. Controls iteration order in multi-provider searches. |

## The SearchResult value object

`Drupal\wow\Search\SearchResult`

Every `search()` call returns an array of these:

```php
new SearchResult(
  blizzardId: 19019,
  name: 'Thunderfury, Blessed Blade of the Windseeker',
  type: 'items',
  isLocal: true,
  iconUrl: 'https://render.worldofwarcraft.com/icons/56/inv_sword_39.jpg',
  description: 'Unique legendary sword',
  quality: 'LEGENDARY',
);
```

| Property | Type | Notes |
|---|---|---|
| `blizzardId` | `int` | The Blizzard API ID. |
| `name` | `string` | Display name. |
| `type` | `string` | Plugin ID of the provider that returned this result. |
| `isLocal` | `bool` | `TRUE` = from local database. `FALSE` = from Blizzard Search API (not yet imported). |
| `iconUrl` | `?string` | Icon URL, or NULL. |
| `typeLabel` | `string` | Stamped by the SearchOrchestrator — providers do not need to set this. |
| `description` | `?string` | Short description or flavor text. |
| `quality` | `?string` | Quality tier (e.g. "RARE", "EPIC"). Primarily for items; NULL for most types. |

## Local vs remote search pattern

Most providers follow a two-phase search:

1. **Local search** — query the Drupal entity storage (`CONTAINS` on the name field). Fast, returns only imported entities.
2. **Remote fallback** — if the local search returned fewer than `$limit` results AND the data type supports the Blizzard Search API, query the API to fill the gap. Mark these results with `isLocal: false`.

Types with a full synced catalog (achievements, mounts, pets, toys, titles, quests) only need local search — the entire catalog is already in the database. Types with on-demand import (items, creatures) benefit from the remote fallback.

```php
public function search(string $query, string $region, int $limit = 10): array {
  // Phase 1: local.
  $results = $this->searchLocal($query, $limit);

  // Phase 2: remote fallback if under limit.
  if (count($results) < $limit) {
    $remaining = $limit - count($results);
    $remoteResults = $this->searchRemote($query, $region, $remaining);
    $results = array_merge($results, $remoteResults);
  }

  return $results;
}
```

## importEntity()

Called when a consumer (the CKEditor plugin, for example) selects a remote search result and needs it persisted before linking to it.

- Fetch the entity from the Blizzard API.
- Create or update the local entity.
- Return the canonical URL so the consumer can build the link.
- Return NULL if import fails or the provider doesn't support remote import.

Local-only providers (achievements, mounts, etc.) return NULL from `importEntity()` — their catalogs are already fully synced.

## The orchestrator

`Drupal\wow\Search\SearchOrchestrator` (service: `wow.search_orchestrator`)

Controllers and UI consumers should call the orchestrator rather than using the plugin manager directly. It handles:

- **Multi-provider iteration** — queries all enabled providers (or a single one if a `type` filter is passed).
- **Budget allocation** — distributes the total result limit across providers with a per-provider cap to ensure result diversity.
- **Failure isolation** — one provider throwing an exception doesn't abort the search. The error is logged and remaining providers continue.
- **Metadata stamping** — stamps `typeLabel` on each SearchResult from the provider's `getSearchLabel()`.
- **Minimum query length** — queries shorter than 2 characters return empty immediately.

```php
// Search all providers.
$results = \Drupal::service('wow.search_orchestrator')
  ->search('thunderfury', 'eu', limit: 20);

// Search a single provider.
$results = \Drupal::service('wow.search_orchestrator')
  ->search('thunderfury', 'eu', limit: 10, type: 'items');

// List available provider types (for building a dropdown).
$providers = \Drupal::service('wow.search_orchestrator')
  ->getAvailableProviders();
```

## JSON endpoints

### Search

| Path | Method | Parameters | Returns |
|---|---|---|---|
| `/wow/search` | GET | `q` (query), `limit`, `type` (optional) | JSON array of SearchResult objects |

The search endpoint always uses the configured default region from `wow.settings`. There is no `region` query parameter.

### Import

| Path | Method | Returns |
|---|---|---|
| `/wow/search/import` | POST | JSON with `url` (canonical) or error |

The import endpoint requires a CSRF token and a JSON request body:

```
POST /wow/search/import
Content-Type: application/json
X-CSRF-Token: {token}

{"type": "items", "id": 19019}
```

Response on success:

```json
{"url": "/wow/item/19019/thunderfury"}
```

The CSRF token is obtained from `/session/token`.

## Existing implementations

| Plugin ID | Module | Weight | Remote search | Import |
|---|---|---|---|---|
| `items` | `wow_item` | 0 | Yes (Blizzard Search API) | Yes |
| `creatures` | `wow_creature` | 0 | Yes (Blizzard Search API) | Yes |
| `achievements` | `wow_achievement` | 10 | No (full catalog synced) | No |
| `quests` | `wow_quest` | 10 | No (full catalog synced) | No |
| `mounts` | `wow_mount` | 20 | No (full catalog synced) | No |
| `pets` | `wow_pet` | 25 | No (full catalog synced) | No |
| `toys` | `wow_toy` | 30 | No (full catalog synced) | No |
| `titles` | `wow_title` | 35 | No (full catalog synced) | No |

## See also

- [Editor integration](../usage/editor-integration.md) — the CKEditor 5 consumer that drives the search modal
- [Adding submodules](adding-submodules.md) — the full submodule scaffold if your search provider accompanies a new entity type
- [Plugin system](../architecture/plugin-system.md) — how SearchableDataProvider fits alongside DashboardSectionProvider and CharacterDataProvider
