This commit is contained in:
seb
2026-10-05 17:57:33 +02:00
parent f627ff5b5a
commit 0a1002227c
9 changed files with 548 additions and 69 deletions

View File

@@ -11,6 +11,7 @@
*/
import { Automat } from 'react-automat';
import FlexSearch from 'flexsearch';
import articleIndexAutomat from './articleIndexAutomat';
import categoryAutomat from './categoryAutomat';
import { parseFilter } from '../components/article/searchFormat';
@@ -61,28 +62,101 @@ const initialState = {
let articleSearchAutomat;
const { Document, Charset } = FlexSearch;
let flexDocument = null;
let lastIndexedArticles = null;
let articleByInternalId = new Map();
/**
* Matches one index record against a search query (checks tokens in name, sku, brand, eans).
* Lazily constructs and maintains the FlexSearch Document index for the given articles array.
*
* @param {object} row Article index record.
* @param {string} query Lowercased search term.
* @returns {boolean} Whether the record matches.
* @param {Array<object>} articles Current article index items.
* @returns {{ doc: any, byId: Map<number, object> }}
*/
function matches(row, query) {
if (!query) return true;
const tokens = query.split(/\s+/).filter(Boolean);
if (tokens.length === 0) return true;
const name = String(row.name ?? '').toLowerCase();
const sku = String(row.sku ?? '').toLowerCase();
const brand = String(row.brand?.name || row.brandName || '').toLowerCase();
const eans = (row.eans ?? []).map((e) => String(e).toLowerCase());
return tokens.every(
(token) =>
name.includes(token) ||
sku.includes(token) ||
brand.includes(token) ||
eans.some((ean) => ean.includes(token)),
);
function getOrCreateFlexIndex(articles) {
if (flexDocument && lastIndexedArticles === articles) {
return { doc: flexDocument, byId: articleByInternalId };
}
const doc = new Document({
document: {
id: 'id',
index: [
{ field: 'all', tokenize: 'forward', resolution: 9 },
{ field: 'sku', tokenize: 'full', resolution: 9 },
{ field: 'name', tokenize: 'forward', resolution: 9 },
{ field: 'brand', tokenize: 'forward', resolution: 5 },
{ field: 'eans', tokenize: 'forward', resolution: 9 },
],
},
tokenize: 'forward',
charset: Charset.LatinAdvanced,
});
const byId = new Map();
for (let i = 0; i < articles.length; i++) {
const article = articles[i];
const docId = i;
byId.set(docId, article);
const sku = String(article.sku ?? '');
const name = String(article.name ?? '');
const brand = String(article.brand?.name || article.brandName || '');
const eans = (article.eans ?? []).join(' ');
const all = `${sku} ${name} ${brand} ${eans}`.trim();
doc.add({
id: docId,
sku,
name,
brand,
eans,
all,
});
}
flexDocument = doc;
lastIndexedArticles = articles;
articleByInternalId = byId;
return { doc, byId };
}
/**
* Searches the FlexSearch index with the query string.
*
* @param {string} query Search query.
* @param {Array<object>} articles Full articles collection.
* @returns {Array<object>} Articles matching the search query.
*/
function queryFlexSearch(query, articles) {
const q = String(query ?? '').trim();
if (!q) return articles;
const { doc, byId } = getOrCreateFlexIndex(articles);
let rawResults;
try {
rawResults = doc.search(q);
} catch {
rawResults = [];
}
const matchedIds = new Set();
for (const fieldResult of rawResults) {
if (Array.isArray(fieldResult.result)) {
for (const id of fieldResult.result) {
matchedIds.add(id);
}
}
}
const matchesList = [];
for (const id of matchedIds) {
const art = byId.get(id);
if (art) {
matchesList.push(art);
}
}
return matchesList;
}
/**
@@ -180,11 +254,12 @@ export function searchArticlesIndex(query, options = {}) {
}
const articles = articleIndexAutomat.state.items ?? [];
const q = String(query ?? '').trim().toLowerCase();
const q = String(query ?? '').trim();
const rawMatches = q ? queryFlexSearch(q, articles) : articles;
const excludedSet = new Set((excludeSkus ?? []).map((s) => String(s).toLowerCase()));
let pool = articles;
let pool = rawMatches;
if (!allowParent) {
pool = pool.filter((a) => !a.isParent && !a.is_parent);
}
@@ -192,7 +267,7 @@ export function searchArticlesIndex(query, options = {}) {
pool = pool.filter((a) => a.sku && !excludedSet.has(String(a.sku).toLowerCase()));
}
let matchesList = q ? pool.filter((row) => matches(row, q)) : pool;
let matchesList = pool;
if (categories && categories.length > 0) {
const categoriesMap = categoryAutomat.state?.categories ?? {};
@@ -390,11 +465,11 @@ const actions = {
const offset = params.offset !== undefined ? Math.max(0, Number(params.offset) || 0) : currentState.offset;
const articles = articleIndexAutomat.state.items ?? [];
const query = target.trim().toLowerCase();
const query = target.trim();
// Filter by text query across all tokens
// Query candidates using FlexSearch
const textMatches = query
? articles.filter((row) => matches(row, query))
? queryFlexSearch(query, articles)
: articles;
// Build facets for all text matches (before category filtering)
@@ -506,8 +581,11 @@ const actions = {
return actions.runSearch({ offset });
},
/** Reset the search state. */
/** Reset the search state and clear FlexSearch index. */
reset() {
flexDocument = null;
lastIndexedArticles = null;
articleByInternalId = new Map();
articleSearchAutomat.setState(initialState);
},
};