Replace PreviewData indirection in all shop LiveViews with direct Products context queries. Home, collection, product detail and error pages now query the database. Categories loaded once in ThemeHook. Cart hydration no longer falls back to mock data. PreviewData kept only for the theme editor. Search modal gains keyboard navigation (arrow keys, Enter, Escape), Cmd+K/Ctrl+K shortcut, full ARIA combobox pattern, LiveView navigate links, and 150ms debounce. SearchModal JS hook manages selection state and highlight. search.ex gets transaction safety on reindex and a public remove_product/1. 10 new integration tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
195 lines
5.0 KiB
Elixir
195 lines
5.0 KiB
Elixir
defmodule SimpleshopTheme.Search do
|
|
@moduledoc """
|
|
Full-text product search backed by SQLite FTS5.
|
|
|
|
Uses a contentless FTS5 index with BM25 ranking. The index is rebuilt
|
|
from the products table after each provider sync.
|
|
"""
|
|
|
|
import Ecto.Query
|
|
|
|
alias SimpleshopTheme.Products.Product
|
|
alias SimpleshopTheme.Repo
|
|
|
|
@listing_preloads [images: :image]
|
|
|
|
# BM25 column weights: title(10), category(5), variant_info(3), description(1)
|
|
@bm25_weights "10.0, 5.0, 3.0, 1.0"
|
|
|
|
@doc """
|
|
Searches products by query string. Returns ranked list of Product structs
|
|
with listing preloads, or empty list for blank/short queries.
|
|
"""
|
|
def search(query) when is_binary(query) do
|
|
query = String.trim(query)
|
|
|
|
if String.length(query) < 2 do
|
|
[]
|
|
else
|
|
fts_query = build_fts_query(query)
|
|
search_fts(fts_query)
|
|
end
|
|
end
|
|
|
|
def search(_), do: []
|
|
|
|
@doc """
|
|
Rebuilds the entire FTS5 index from visible, active products.
|
|
"""
|
|
def rebuild_index do
|
|
Repo.transaction(fn ->
|
|
# Clear existing index data
|
|
Repo.query!("DELETE FROM products_search_map")
|
|
Repo.query!("DELETE FROM products_search")
|
|
|
|
# Load all visible products with variants for indexing
|
|
products =
|
|
Product
|
|
|> where([p], p.visible == true and p.status == "active")
|
|
|> preload([:variants])
|
|
|> Repo.all()
|
|
|
|
Enum.each(products, &insert_into_index/1)
|
|
end)
|
|
end
|
|
|
|
@doc """
|
|
Indexes or reindexes a single product.
|
|
Removes existing entry first if present.
|
|
"""
|
|
def index_product(%Product{} = product) do
|
|
product = Repo.preload(product, [:variants], force: true)
|
|
|
|
Repo.transaction(fn ->
|
|
remove_from_index(product.id)
|
|
insert_into_index(product)
|
|
end)
|
|
end
|
|
|
|
@doc """
|
|
Removes a product from the search index.
|
|
"""
|
|
def remove_product(product_id) do
|
|
remove_from_index(product_id)
|
|
end
|
|
|
|
# Build an FTS5 MATCH query from user input.
|
|
# Strips special chars, splits into tokens, adds * prefix match to last token.
|
|
defp build_fts_query(input) do
|
|
tokens =
|
|
input
|
|
|> String.replace(~r/[^\w\s]/, "")
|
|
|> String.split(~r/\s+/, trim: true)
|
|
|
|
case tokens do
|
|
[] ->
|
|
nil
|
|
|
|
tokens ->
|
|
{complete, [last]} = Enum.split(tokens, -1)
|
|
|
|
parts =
|
|
Enum.map(complete, &~s("#{&1}")) ++
|
|
[~s("#{last}" *)]
|
|
|
|
Enum.join(parts, " ")
|
|
end
|
|
end
|
|
|
|
defp search_fts(nil), do: []
|
|
|
|
defp search_fts(fts_query) do
|
|
result =
|
|
Repo.query!(
|
|
"""
|
|
SELECT m.product_id, bm25(products_search, #{@bm25_weights}) AS rank
|
|
FROM products_search
|
|
JOIN products_search_map m ON m.rowid = products_search.rowid
|
|
WHERE products_search MATCH ?1
|
|
ORDER BY rank
|
|
LIMIT 20
|
|
""",
|
|
[fts_query]
|
|
)
|
|
|
|
product_ids = Enum.map(result.rows, fn [id, _rank] -> id end)
|
|
|
|
if product_ids == [] do
|
|
[]
|
|
else
|
|
# Fetch full structs preserving rank order
|
|
products_by_id =
|
|
Product
|
|
|> where([p], p.id in ^product_ids)
|
|
|> where([p], p.visible == true and p.status == "active")
|
|
|> preload(^@listing_preloads)
|
|
|> Repo.all()
|
|
|> Map.new(&{&1.id, &1})
|
|
|
|
Enum.flat_map(product_ids, fn id ->
|
|
case Map.get(products_by_id, id) do
|
|
nil -> []
|
|
product -> [product]
|
|
end
|
|
end)
|
|
end
|
|
end
|
|
|
|
defp insert_into_index(%Product{} = product) do
|
|
Repo.query!(
|
|
"INSERT INTO products_search_map (product_id) VALUES (?1)",
|
|
[product.id]
|
|
)
|
|
|
|
%{rows: [[rowid]]} = Repo.query!("SELECT last_insert_rowid()")
|
|
|
|
variant_info = build_variant_info(product.variants || [])
|
|
description = strip_html(product.description || "")
|
|
|
|
Repo.query!(
|
|
"""
|
|
INSERT INTO products_search (rowid, title, category, variant_info, description)
|
|
VALUES (?1, ?2, ?3, ?4, ?5)
|
|
""",
|
|
[rowid, product.title || "", product.category || "", variant_info, description]
|
|
)
|
|
end
|
|
|
|
defp remove_from_index(product_id) do
|
|
case Repo.query!(
|
|
"SELECT rowid FROM products_search_map WHERE product_id = ?1",
|
|
[product_id]
|
|
) do
|
|
%{rows: [[rowid]]} ->
|
|
Repo.query!("DELETE FROM products_search WHERE rowid = ?1", [rowid])
|
|
Repo.query!("DELETE FROM products_search_map WHERE rowid = ?1", [rowid])
|
|
|
|
_ ->
|
|
:ok
|
|
end
|
|
end
|
|
|
|
# Build searchable variant text from enabled variants
|
|
defp build_variant_info(variants) do
|
|
variants
|
|
|> Enum.filter(& &1.is_enabled)
|
|
|> Enum.flat_map(fn v -> [v.title | Map.values(v.options || %{})] end)
|
|
|> Enum.uniq()
|
|
|> Enum.join(" ")
|
|
end
|
|
|
|
# Strip HTML tags and decode common entities
|
|
defp strip_html(html) do
|
|
html
|
|
|> String.replace(~r/<[^>]+>/, " ")
|
|
|> String.replace("&", "&")
|
|
|> String.replace("<", "<")
|
|
|> String.replace(">", ">")
|
|
|> String.replace(""", "\"")
|
|
|> String.replace("'", "'")
|
|
|> String.replace(" ", " ")
|
|
|> String.replace(~r/\s+/, " ")
|
|
|> String.trim()
|
|
end
|
|
end
|