# Changelog

All notable changes to Opensolr Search will be documented in this file.

## 1.0.22 — 2026-07-24

### Changed
- **Hybrid search switched to the Opensolr `{!hybrid}` query parser.** Keyword (BM25) and vector (KNN) scores are now fused per-document and normalized within the candidate set, so the Search Mode (Union / KW Required / Meaning Required / Intersection) genuinely shapes recall instead of only nudging scores. Vector search on documents (non-HTML content, e.g. PDFs) now returns results — previously the document candidate pool was starved by the web-page majority.
- **Removed edismax phrase boosts (pf/pf2/pf3 + ps) from the query.** With user-raised field weights the phrase boost ranked exact-phrase accessories above the actual products (e.g. a wall cabinet over bathroom cleaning products). Keyword field weights + minimum-match now drive lexical relevance directly.
- **Removed the Minimum Relevance Score control and its `{!frange}` post-filter.** Obsolete under the new parser (the candidate set is already a clean union of real keyword + vector matches) and actively harmful: it re-ran the whole query as a post-filter (~2x cost) and dropped legitimate keyword-only matches such as document results.

## 1.0.21 — 2026-04-27

### Fixed
- **File / Document / Audio / Video media mappings now emit the public attachment URL** instead of the bare attachment ID. Previous versions only auto-resolved IMAGE attachments — mapping a PDF / DOCX / MP4 / MP3 attachment ID through Facet Mapping silently kept the integer in Solr, breaking thumbnails and download links on the search page. Now any image, video, audio, or known document MIME (PDF / DOC / DOCX / XLS / XLSX / PPT / PPTX / ODT / ODS / TXT) auto-resolves to `wp_get_attachment_url()`. Numeric SKU / price / count meta values that happen to collide with non-media attachment IDs are still left untouched — the new `is_known_media_attachment()` gate stays conservative on purpose.
- **ACF "Image Array" / "File Array" return format no longer pollutes Solr with id + alt + title + url + width + height all glommed together.** The serialized-array meta storage (one row whose value is an associative array) is now detected explicitly: `flatten_meta_rows()` recognizes the canonical ACF attachment shape (descriptive keys present alongside `url`/`ID`) and emits ONE value per row — the `url` if it exists, otherwise the `ID` so the regular attachment-URL resolver can take over. Single-valued and multi-valued mappings both land clean URL values.
- **Values that are already URLs (`http://…` / `https://…`) pass through `maybe_resolve_attachment_url()` unchanged.** Covers ACF "Return Format = URL", hand-rolled custom URL fields, and any other source that already yields a usable string. Previous behavior was correct by accident (the resolver only triggered on numeric IDs) — this is now an explicit early return with a comment.
- **Meta tag emission path uses the same flatten + resolve helpers as ingestion.** `class-meta-tags.php` (the `<meta property="opensolr:*">` injector) used to inline its own row-flatten loop and a stricter `wp_attachment_is_image()` guard, so the crawler path silently dropped non-image attachment URLs even when the ingestion path had been fixed. Both paths now share `Opensolr_Ingest::flatten_meta_rows()` + `::maybe_resolve_attachment_url()`. One source of truth, identical Solr output regardless of indexing method.

### Upgrade notes
- Re-run "Ingest All Now" (Data Ingestion tab) after upgrading to flush the corrected URLs into Solr. Existing docs keep the old broken values until each page is re-indexed.
- If you were working around the bug by mapping ACF "Return Format = URL" or by hand-typing URLs into custom fields, those mappings continue to work — no action needed.

## 1.0.20 — 2026-04-24

### Added
- **Date-range quick presets on facet sidebar.** Every `date_range` facet now renders three pill buttons (Today / Last Week / Last Month) above the From/To inputs. One click fills both dates and submits the facet form — no more opening the native date picker twice for common queries. Uses LOCAL date components (not `toISOString`) so "Today" stays today in western timezones. Mirrors the opensolr.com search UI pill styling, with dark-theme support.
- **Data Ingestion tab: dynamic per-type counts + live "Total: N documents".** Each content-type checkbox now shows its real count when checked and `(0)` when unchecked, and a grand total at the bottom of the fieldset updates on every toggle. The "Include attached files" checkbox counts PDF/DOC/DOCX/XLS/XLSX/ODT attachments and also contributes to the total. Port of the Drupal module's `opensolr_content_types` library behavior. Scoped to `fieldset[data-opensolr-ingest-types]` so it can't accidentally attach to other WP admin checkbox lists.

### Changed
- **Bulk-ingestion admin script now uses `filemtime()` as cache-buster** (was `OPENSOLR_VERSION`). Consistent with the rest of the plugin's admin scripts — on-disk deploys are visible in browsers without bumping the plugin version.

## 1.0.19 — 2026-04-24

First public release on WordPress.org since 1.0.14. Rolls up every 1.0.15→1.0.18 deploy and the save-sanitizer bug fix.

### Added
- **Facet Mapping form now matches the Drupal module** — the "Solr Field Name" text box is split into three inputs: Solr Field Name (stem, e.g. `custom_brand`), Solr Type (dropdown: `String (single)` / `String (multi-value)` / `Float (single)` / `Float (multi-value)` / `Integer (single)` / `Integer (multi-value)` / `Date (single)` / `Date (multi-value)` / `String (single, legacy)`), and Display Label.
- **Type hints next to every WordPress Field entry** — `post_tag` shows `multi, string`; `_price` shows `single, float`; `post_date` shows `single, date`. Detection covers core post columns, all public taxonomies, WooCommerce synthetic aggregates (`featured_image_url`, `gallery_image_urls`, `pa_*`), `register_meta()` declarations (explicit type + single flag), and plain postmeta (samples up to 10 values per key — detects multi-row, serialized arrays, numeric, ISO date patterns).
- **Solr Type auto-fills when the admin picks a WordPress Field.** Pre-fills from detected suffix; admin can override — auto-fill respects manual changes.
- **Solr Field Name stem auto-fills** from the WP field name (sanitized `[a-z0-9_]`) when empty. One pick produces a fully-specified mapping.
- **Facet Mapping "WordPress Field" dropdown shows every indexable source.** Previous versions hid underscore-prefixed keys as "private" — which dropped the most valuable WooCommerce fields (`_price`, `_sku`, `_stock`, `_thumbnail_id`). Now includes: core post columns pinned at the top (`post_title`, `post_content`, `post_excerpt`, `post_date`, `post_status`, `post_type`, `post_author`), all postmeta keys (underscore-prefixed included, limit raised to 500), `register_meta()` keys, every public taxonomy, all `pa_*` WC attribute taxonomies, `featured_image_url`, `gallery_image_urls`.
- **Core post columns + WC synthetic aggregates are resolvable end-to-end.** Ingestion (`class-ingest.php::get_field_value()`) and emission (`class-meta-tags.php` field-mapping loop) both recognize `post_title`, `post_content`, `featured_image_url`, etc. directly on the post/product object.

### Fixed
- **Facet Mapping saves no longer silently drop every row.** The `register_setting()` sanitize callback (`opensolr_sanitize_settings`) expected the legacy flat `{source: meta_target}` scalar shape and called `is_scalar($dst)` on each value — but the Facet Mapping form now saves the rich `{wp_field, meta_name, field_type, label, solr_field}` shape. Every row was failing the `is_scalar()` check and being silently stripped under the `sanitize_option_opensolr_settings` filter. The callback now accepts both shapes (rich arrays pass through with per-field sanitization + suffix allowlist; legacy scalars auto-promote).
- **Multi-valued mappings stored as serialized arrays now emit correctly.** ACF Checkbox / Select-multiple / Relationship / Gallery fields and WooCommerce attribute arrays are saved as ONE postmeta row with a serialized array value. Both the ingestion path and the `<meta>` tag emission path used to stringify that array to the literal `Array`. Both paths now flatten across both WP storage patterns (multiple rows per key AND single-row-with-array-value).
- **Image-field mappings emit a real image URL** instead of the bare attachment ID. Mapping a meta key that stores an attachment ID (ACF image field with "Return Format = ID", `_thumbnail_id`, custom `image_attachment_id`) to a Solr field now auto-resolves the numeric value to `wp_get_attachment_url()` when the ID points at a valid image attachment. Guarded by `wp_attachment_is_image()` so numeric SKU / price / count fields are untouched.
- **Admin scripts use `filemtime()` as cache-buster** instead of `OPENSOLR_VERSION`. `OPENSOLR_VERSION` only moves on WP.org release, so every intermediate fix was deployed to disk but browsers kept serving stale JS (the auto-fill, type-suggest, stem-autofill features simply didn't run in any admin's browser). Admin scripts now self-invalidate on every deploy.
- **Facet Mapping table always renders at least one empty row** so the admin has a visible slot to click into. Previously an empty mappings array produced a bare table body with only a header — looked like a save-wipe. Saves are unaffected (empty rows drop on submit).
- **Backend stem fallback** — save handler derives the Solr Field Name from the WP field name when the admin leaves it blank, preventing silent row drops on incomplete input.
- **`+ Add Field Mapping` button no longer collides with the placeholder row.** `data-initial-index` was `count($field_mappings)` = 0 when the only row on screen was the placeholder at index 0. Clicking "+ Add" created a second row also at index 0, overwriting the placeholder's POST data. Now uses the rendered row count.
- **Persistent Filters Exclude (-) mode** was silently overwritten with Include (+) on save. `register_setting()` sanitize callback used `{op: include/exclude}` but admin form + query builder used `{mode: +/-}` — any `-` selection got dropped. Existing filters stuck as include should be re-saved after upgrading.

### Upgrade notes
- Re-crawl your content after upgrading so newly-mapped fields land in Solr. The plugin is responsible for emitting `opensolr:*` meta tags on page HTML; the Opensolr Web Crawler has to visit each page to pick them up.
- Existing saved mappings in the legacy `{wp_field, solr_field}` shape are auto-promoted to the rich shape on first render — no migration required.

## 1.0.18 — 2026-04-24

### Fixed
- **"I saved tags and they vanished" — for real this time.** Two bugs were stacked:
  1. Admin scripts were cache-busted with `OPENSOLR_VERSION` (`1.0.14`), which hasn't moved since the last WP.org release. Every code-only deploy (stem auto-fill, type-suggest guard, rich-shape save) deployed new PHP + JS to disk but browsers kept serving the `?ver=1.0.14` cached JS from weeks ago — so the auto-fill JS never ran, the stem stayed empty, and the save handler silently dropped the row. Admin scripts now use `filemtime()` as their version, so every disk update invalidates the browser cache automatically. No plugin version bump required.
  2. The save handler already refused rows where Solr Field Name was empty — the right behavior for intentional blanks, the wrong behavior when the auto-fill JS never got a chance to populate it. Save now derives the stem from the WP field name itself when left empty, so a row with just a WP field (e.g. `post_tag`) persists as `post_tag_s` instead of being dropped. Admin can still edit the stem afterwards.

## 1.0.17 — 2026-04-24

### Fixed
- **Empty Facet Mapping table looked like a save-wipe.** With no saved mappings, the table body rendered zero rows (only the header), which made admins think their data had been deleted after a save. Now renders a single empty row as a placeholder — same pattern used everywhere else in WP admin for editable lists. Saves are unaffected: empty rows get dropped on submit, they're only a visual placeholder.
- **Solr Type auto-fill locked itself out on first use.** The `change` listener that marks a select as `userChanged` didn't differentiate user clicks from the programmatic `.value=` + `dispatchEvent(change)` we fire when a WP field is picked — so the very first auto-fill flipped the flag and every subsequent field change was ignored. Programmatic updates now set `_opensolrProgrammatic=true` around the assignment; the listener returns early when the flag is present. Same guard added to the stem input.

### Added
- **Solr Field Name stem auto-fills from the WP field name.** Picking a WP field copies its name into the Solr Field Name input (sanitized to `[a-z0-9_]`) when it's empty and untouched. Combined with the Solr Type auto-fill, one field pick produces a fully-specified mapping (stem + type + hint) that the admin can save directly — previously an admin had to type the stem and the save handler silently dropped rows where stem was empty.

## 1.0.16 — 2026-04-24

### Added
- **Facet Mapping form now matches the Drupal module layout.** The "Solr Field Name" text box has been split into three inputs: **Solr Field Name** (the stem, e.g. `custom_brand`), **Solr Type** (dropdown with `String (single) / String (multi-value) / Float (single) / Float (multi-value) / Integer (single) / Integer (multi-value) / Date (single) / Date (multi-value) / String (single, legacy)` — identical to Drupal), and **Display Label**. The full Solr field (e.g. `custom_brand_s`) is derived from stem + suffix on save, so downstream ingestion / emission code is untouched.
- **Type hints next to every entry in the WordPress Field dropdown.** `post_tag` now shows "multi, string"; `_price` shows "single, float"; `post_date` shows "single, date". Detection covers core post columns, all public taxonomies, WC synthetic aggregates, `register_meta()` declarations (explicit type + single flag), and plain postmeta keys (sampling up to 10 values per key — detects multi-row, serialized arrays, numeric, ISO date patterns, strings).
- **Solr Type auto-fills when the admin picks a WordPress Field.** New admin JS (`js/opensolr-type-suggest.js`) listens to the field input and sets the Solr Type dropdown to the detected suffix. Admin can override — once they manually change Solr Type, auto-fill respects that choice. Suggestion map is inlined via `wp_localize_script`, no REST round-trip.
- **`+ Add Field Mapping` button now clones the full 4-column row** with `data-opensolr-*` hooks so cloned rows auto-fill too.

### Changed
- **Back-compat for existing saved mappings**: on form render, legacy `{wp_field, solr_field}` rows are parsed into the rich `{wp_field, meta_name, field_type, label, solr_field}` shape so old saves don't lose their data. Saves going forward store the rich shape.

## 1.0.15 — 2026-04-24

### Added
- **Facet Mapping "WordPress Field" list now shows every indexable source, not just a handful of postmeta keys.** `get_available_wp_fields()` previously hid all `_`-prefixed meta (the most common WooCommerce core fields — `_price`, `_sku`, `_stock`, `_thumbnail_id`, ...) and never listed core post columns at all. The discovery now returns: core post columns (pinned at the top — `post_title`, `post_content`, `post_excerpt`, `post_date`, `post_status`, `post_type`, `post_author`, ...), **all** postmeta keys (underscore-prefixed included, limit raised from 200 to 500), meta keys declared via `register_meta()` even when no row exists yet, every public taxonomy, all `pa_*` WooCommerce attribute taxonomies, plus synthetic `featured_image_url` and `gallery_image_urls` aggregates.
- **Core post columns and synthetic WC aggregates are now resolvable.** `class-ingest.php::get_field_value()` and `class-meta-tags.php` field-mapping loop both recognize `post_title`, `post_content`, etc. directly on the post object, and resolve `featured_image_url` / `gallery_image_urls` through WC APIs. Previously these would have returned empty because the lookup only checked postmeta and taxonomy.

### Fixed
- **Multi-valued mappings lost data stored as a serialized array in one postmeta row.** ACF Checkbox / Select-multiple / Relationship / Gallery fields and WooCommerce attributes save as a single `wp_postmeta` row whose `meta_value` is a serialized array. Both the ingestion path (`class-ingest.php::get_field_value()`) and the meta-tag emission path (`class-meta-tags.php` field_mappings loop) previously stringified array values, producing the literal `Array` in output. Both paths now flatten correctly across BOTH WordPress storage patterns (multiple rows per key AND single-row-with-array-value) so ACF-stored tags, checkboxes, gallery IDs, and similar multi-valued fields now emit one `<meta>` per value and serialize as Solr arrays. Ingestion now produces the same shape as the crawler does after aggregating repeated tags.

## 1.0.14 — 2026-04-24

### Fixed
- **Image-field Facet Mappings now emit a real image URL.** Mapping a meta key that stores an attachment ID (ACF image field with "Return Format = ID", `_thumbnail_id`, custom `image_attachment_id` meta, etc.) to a Solr field used to emit the bare integer as the meta content — which is useless as an image source. Both the ingestion path (`class-ingest.php::get_field_value()`) and the meta-tag emission path (`class-meta-tags.php` field_mappings loop) now auto-resolve numeric values to `wp_get_attachment_url()` when the ID points at a valid image attachment. Guarded by `wp_attachment_is_image()` so numeric SKU / price / count fields are untouched.

## 1.0.13 — 2026-04-24

### Fixed
- **Persistent Filters "Exclude" (`-`) mode was silently overwritten with "Include" (`+`) on save.** The `register_setting()` sanitize callback introduced in 1.0.9 used an `{op: include/exclude}` shape that didn't match the `{mode: +/-}` shape used by both the admin form handler (`class-admin-filters.php`) and the Solr query builder (`class-solr-query.php`). Any `-` selection the admin made was dropped by the sanitize path and defaulted back to `+`, making it effectively impossible to save an exclude filter from the Settings API path. Existing filters that got stuck as include will need to be re-saved after upgrading to pick up the corrected mode.

## 1.0.12 — 2026-04-24

### Added
- **Combobox autocomplete on "Solr field for result image".** Focus the input and the full alphabetical list of Solr fields indexed on your site drops down (pulled from the Luke handler via a new admin-only REST endpoint `/opensolr/v1/luke-fields`). Typing filters by case-insensitive **substring match** — matches anywhere in the field name, not just at the start. Keyboard navigation (arrow keys + Enter), mouse picking, and plain typing all supported. Strict WP.org compliance: JS and CSS enqueued via `wp_enqueue_script` / `wp_enqueue_style` (no inline `<script>`/`<style>`), URL + nonce injected via `wp_localize_script`, REST endpoint has a `manage_options` `permission_callback`, all output escaped (`esc_attr`, `esc_html`, `esc_url`), DOM-based rendering avoids innerHTML.

## 1.0.11 — 2026-04-24

### Added
- **Configurable Solr field for result thumbnails.** Search Display tab now has a **"Solr field for result image"** input (default `og_image`). Admins who map their own image source (post meta, WooCommerce gallery, custom URL field) to a Solr field via Facet Mapping can point the search page + AI Reader modal + Reader metadata endpoint at any single-valued Solr field. Multi-valued (`_sm`) sources are normalized to the first element. Empty or non-`[a-zA-Z0-9_]` input falls back to `og_image`.

## 1.0.10 — 2026-04-24

### Fixed
- **Field mapping ingestion path was silently skipping every entry.** `class-ingest.php` was reading `meta_name` and `field_type` keys that the admin UI never writes (the UI stores `{wp_field, solr_field}`). The `empty($meta_name)` guard fired on every mapping, so ingested docs never received any user-configured custom fields. Now `class-ingest.php::parse_solr_field()` derives `{name, type}` from the saved `solr_field` string. Legacy `{meta_name, field_type}` keys still honoured if present, so older settings don't break on upgrade.
- **Multi-valued field mappings were emitted as comma-joined strings.** `class-meta-tags.php` concatenated array values into a single `<meta>` tag (losing array semantics for values containing commas, and producing different Solr documents than ingestion). When the configured Solr field ends in `_sm`/`_fm`/`_im`/`_dtm`, the module now emits one `<meta property="opensolr:X_sm" content="...">` tag per value. The Opensolr crawler aggregates repeated tags into a Solr array (DOM order preserved), so crawler and ingestion produce identical docs.
- **Same fix applied to the hardcoded product_images_sm and variable-attribute `*_sm` emissions** — they were using `implode(',', $vals)` previously.

## 1.0.9 — 2026-04-23

### Security
- **`register_setting()` sanitize_callback does real per-field sanitization.** Previously the callback only merged input into existing settings. It now sanitizes every key with the appropriate function: `sanitize_email()` for the account email, `sanitize_text_field()` for plain strings, `absint()` for integers, `(float)` with clamping for field weights and boosts, strict allowlists for enumerated values (`browse_mode`, `pagination_style`, `tuning_bool_mode`, `tuning_mm`, `search_ui_mode`, `crawl_mode`, etc.), and recursive per-element sanitization for arrays (`crawler_content_types`, `ingest_content_types`, `field_mappings`, `facets`, `persistent_filters`). Unknown keys only pass through if they're scalars.
- **REST `/queue-stats` endpoint is now admin-only.** The endpoint feeds the Data Crawler admin UI (live preview counts for arbitrary content-type overrides), so it's an admin-configuration surface and shouldn't be reachable by anonymous users. `permission_callback` now requires `manage_options`.

### Compliance
- **JSON-LD structured data uses `wp_print_inline_script_tag()`.** The three JSON-LD blocks (WebSite + SearchAction on the search page, Article on single posts, BreadcrumbList on categorized content) now route through WordPress's official inline-script helper instead of echoing raw `<script type="application/ld+json">` tags. Same XSS protection as before (JSON_HEX_TAG | JSON_HEX_AMP hex-encode `< > &` so no value can break out of the script block) — just routed through the compliant WP API.

### Changed
- **Chart.js bundled library updated from 4.5.0 to 4.5.1.**

## 1.0.8 — 2026-04-22

### Improved
- **Locale facet — flags and readable language names.** The `meta_og_locale` sidebar facet now shows flag emojis and language names instead of raw locale codes: `hr_hr` → 🇭🇷 Croatian, `sr_sr` → 🇷🇸 Serbian, `bs_bs` → 🇧🇦 Bosnian, etc. Languages without a canonical country flag (e.g. Latin) show a 🌐 globe icon. Applies to sidebar facet item labels and active filter pills (both when the locale is set from the sidebar facet and when set from the locale selector).
- **Highlighting config aligned with Opensolr.** Switched to `hl.method=unified`, added `hl.maxAnalyzedChars=1000`, `hl.highlightMultiTerm`, and `hl.usePhraseHighlighter` for consistent snippet quality. The highlight query (`hl.q`) now correctly uses `{!edismax}` with the real search terms instead of the catch-all `*:*`.

### Fixed
- **Highlighting skipped on wildcard / browse-mode queries.** When the query is `*:*` (no search terms entered), all `hl.*` parameters are now omitted entirely — Solr highlighting on a catch-all wildcard is meaningless and wastes processing time.

## 1.0.7 — 2026-04-22

### Added
- **Lexical Only search mode** in Search Tuning → Search Mode. Explicit opt-out from vector — no embedding API call is made, search runs straight through the lexical edismax path. Useful for deterministic BM25 ranking, avoiding embed API latency, or users on plans without vector access who want explicit control instead of the implicit server-side fallback.
- **Auto-zero Minimum Relevance Score when Lexical Only is selected.** The `tuning_min_score` threshold is calibrated for the combined `{!bool}(lexical + vector)` score; in pure lexical mode a positive value would filter out every result. Backend forces the value to 0 on save and at query time; JS zeroes the slider live the moment the radio is picked. A yellow note inside Search Mode explains the coupling.

### Changed
- **Lexical-only fallback uses `defType=edismax` + raw `q`** (idiomatic Solr style) instead of `q={!bool should=$lexicalQuery}`. Equivalent in behavior, cleaner in Query Inspector, matches the opensolr `search_lexical()` method for cross-platform consistency.
- Bumped plugin version so browsers pick up the new `opensolr-admin-ui.js` handler for the bool_mode radios.

## 1.0.5 — 2026-04-21

### Added
- **Minimum Relevance Score slider** in Search Tuning. Drops results whose combined `{!bool}(lexical + vector)` score falls below the threshold, cleaning noise from facet counts and result totals. Applied as a Solr frange post-filter (`fq={!frange l=X cache=false cost=200}query($q)`). Needed because the `{!func}`-wrapped lexical side of the `{!bool}` always matches every doc (score 0 for non-lexical hits) and KNN topK returns N docs regardless of cosine similarity — without this filter, `numFound` balloons to the entire base filter set and facet counts get polluted with noise. Default `0.3`; legitimate lexical matches (`sqrt(BM25 + 1) - 1`) and decent vector matches easily clear it. Slider moves in 0.1 increments, 0 disables.

### Changed
- Bumped plugin version so the browser picks up the new `opensolr-admin-ui.js` (otherwise the cached old JS had no listener for the new slider and its live label wouldn't update while dragging).

## 1.0.4 — 2026-04-21

### Changed
- **Semantic ↔ Lexical Balance slider is now a true balance dial.** Previously it just scaled the `qf/pf` field-weight boosts linearly — mathematically equivalent to the user lowering the weights themselves. The lexical score is now reshaped via `pow(BM25 + 1, lexical_weight) - 1`, so the slider controls the SHAPE of the lexical contribution curve, not just its magnitude:
  - `1.0` → linear (full BM25, named entities dominate)
  - `0.5` → sqrt-like compression (balanced, new default)
  - `0.2` → heavy compression (vector takes over)
  - `0.1` → lexical almost off
- **Default Title field weight** raised from `0.03` to `0.1`.
- **Default Semantic ↔ Lexical Balance** raised from `0.20` to `0.50`.
  These defaults work better with the new pow reshape across a wide range of corpora.

### Removed
- **Misleading "field weights are automatically overridden to 0" banner** in Search Tuning — no longer applies after the hybrid ranking fix in 1.0.3.

## 1.0.3 — 2026-04-21

### Fixed
- **Hybrid search ranking ignored query keywords** — in vector hybrid mode the lexical `qf` was hardcoded to zero, so proper nouns, product names, and other specific terms contributed nothing to scoring. Documents were ranked purely on semantic similarity, which made highly specific queries surface near-duplicate neighbours ahead of the exact match. Field weights from Search Tuning now flow through to the combined `{!bool}` query as designed, alongside the KNN vector score.

## 1.0.0 — 2026-04-09

### Added
- Hybrid vector + keyword search powered by Opensolr
- Faceted navigation with list, slider, date range, and hierarchical widgets
- AI Hints — streaming AI-generated answers above search results
- AI Reader — full-screen AI summary of any search result
- Autocomplete with query history and Solr document results
- WooCommerce product support (prices, categories, SKUs, structured data)
- Web Crawler indexing via sitemap
- Data Ingestion API — push content directly from WordPress to Solr
- Real-time sync on post save/delete
- Async bulk ingestion via WordPress cron
- Search analytics dashboard (queries, clicks, CTR, no-results)
- Query Elevation — pin or exclude specific results per query
- Persistent admin filters (include/exclude fq applied to every search)
- Multilingual support (WPML, Polylang) with automatic locale filtering
- Dark theme for the search page
- Embeddable search widget mode (iframe)
- Meta tag and JSON-LD injection for SEO
- XML sitemap generation with chunking
- Security hardening (REST API user enumeration blocked, author archive protection)
