/** * @file articleSearchAutomat.js * @description In-memory article search and pagination state for the `/artikel` * overview and search pages, querying against the derived `articleIndexAutomat`. * * Upstream Triggers: * - UI / Search input / FillSpace: Components call `actions.search({ target, filter, offset, pageSize })`. * - Reads data from `articleIndexAutomat`: Derives and slices matching records * synchronously, updating continuously as category views load during launch. * - `categoryAutomat`: Provides category hierarchy metadata for faceted counts. */ import { Automat } from 'react-automat'; import articleIndexAutomat from './articleIndexAutomat'; import categoryAutomat from './categoryAutomat'; import { parseFilter } from '../components/article/searchFormat'; /** * @typedef {Object} ArticleSearchResult * @property {Record} categoryFacets Facet counts per category id. * @property {number} totalCount Number of matching records. * @property {Array} recordset Sliced matching article index records for the current page. * @property {number} offset Current 0-based offset. * @property {number|null} pageSize Current page size. * @property {number} pageCount Total number of pages. * @property {number} currentPage Current 1-based page number. * @property {Error|null} error Search failure, or `null`. */ /** * @typedef {Object} ArticleSearchState * @property {ArticleSearchResult} result Latest search outcome. * @property {string} target Active search term. * @property {string} filter Active category filter string. * @property {number} offset Current 0-based offset. * @property {number|null} pageSize Current page size. * @property {boolean} loading Whether a search or index load is in flight. */ /** @type {ArticleSearchResult} */ const emptyResult = { categoryFacets: {}, totalCount: 0, recordset: [], offset: 0, pageSize: null, pageCount: 1, currentPage: 1, error: null, }; /** @type {ArticleSearchState} */ const initialState = { result: emptyResult, target: '', filter: '', offset: 0, pageSize: null, loading: false, }; let articleSearchAutomat; /** * Matches one index record against a search query (checks tokens in name, sku, brand, eans). * * @param {object} row Article index record. * @param {string} query Lowercased search term. * @returns {boolean} Whether the record matches. */ 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)), ); } /** * Calculates a search relevance score for an article record against a query. * * @param {object} article Article index record. * @param {string} query Lowercased search term. * @returns {number} Higher score indicates a better match. */ export function getArticleRelevanceScore(article, query) { if (!query) return 0; const q = String(query).trim().toLowerCase(); const sku = String(article.sku ?? '').toLowerCase(); const name = String(article.name ?? '').toLowerCase(); const brand = String(article.brand?.name || article.brandName || '').toLowerCase(); const eans = (article.eans ?? []).map((e) => String(e).toLowerCase()); // 1. Exact SKU match if (sku === q) return 10000; // 2. SKU starts with query if (sku.startsWith(q)) return 5000; // 3. Exact Name match if (name === q) return 4000; // 4. Name starts with query if (name.startsWith(q)) return 3000; // 5. SKU contains query if (sku.includes(q)) return 2000; // 6. Word in name starts with query const words = name.split(/\s+/); if (words.some((w) => w.startsWith(q))) return 1500; // 7. Name contains query if (name.includes(q)) return 1000; // 8. Exact EAN match if (eans.includes(q)) return 800; // 9. EAN starts with query if (eans.some((e) => e.startsWith(q))) return 600; // 10. Brand starts with query if (brand.startsWith(q)) return 400; // 11. Brand contains query if (brand.includes(q)) return 200; return 10; } /** * Sorts articles by relevance to the query, breaking ties alphabetically by name and SKU. * * @param {Array} articles List of articles to sort. * @param {string} query Search term. * @returns {Array} Sorted articles. */ export function sortArticlesByRelevance(articles, query) { const q = String(query ?? '').trim().toLowerCase(); return [...articles].sort((a, b) => { if (q) { const scoreA = getArticleRelevanceScore(a, q); const scoreB = getArticleRelevanceScore(b, q); if (scoreA !== scoreB) { return scoreB - scoreA; } } const nameA = String(a.name ?? ''); const nameB = String(b.name ?? ''); const comp = nameA.localeCompare(nameB, 'de', { sensitivity: 'base' }); if (comp !== 0) return comp; return String(a.sku ?? '').localeCompare(String(b.sku ?? ''), 'de'); }); } /** * Synchronously searches the article index using the search automat token matching and relevance order. * * @param {string} query Search input string. * @param {object} [options] * @param {Array} [options.excludeSkus] SKUs to exclude from results. * @param {boolean} [options.allowParent=false] Whether to include parent articles. * @param {Array} [options.categories] Category IDs to filter by. * @param {number} [options.offset=0] Pagination offset. * @param {number} [options.pageSize=10] Results per page. * @param {number} [options.limit] Alternative to pageSize. * @returns {Array} Array of matching article index records with attached pagination metadata. */ export function searchArticlesIndex(query, options = {}) { const { excludeSkus = [], allowParent = false, categories = [], offset = 0, pageSize = 10, limit, } = options; if (articleIndexAutomat.state.status === 'idle') { void articleIndexAutomat.actions.ensureLoaded().catch(() => {}); } const articles = articleIndexAutomat.state.items ?? []; const q = String(query ?? '').trim().toLowerCase(); const excludedSet = new Set((excludeSkus ?? []).map((s) => String(s).toLowerCase())); let pool = articles; if (!allowParent) { pool = pool.filter((a) => !a.isParent && !a.is_parent); } if (excludedSet.size > 0) { pool = pool.filter((a) => a.sku && !excludedSet.has(String(a.sku).toLowerCase())); } let matchesList = q ? pool.filter((row) => matches(row, q)) : pool; if (categories && categories.length > 0) { const categoriesMap = categoryAutomat.state?.categories ?? {}; const expandedIds = getCategoryAndDescendantIds(categories, categoriesMap); matchesList = matchesList.filter((row) => matchesCategory(row, expandedIds)); } const sorted = sortArticlesByRelevance(matchesList, q); const totalCount = sorted.length; const safePageSize = limit != null ? limit : (pageSize != null && pageSize > 0 ? pageSize : 10); const safeOffset = Math.max(0, offset || 0); const pageCount = Math.max(1, Math.ceil(totalCount / safePageSize)); const currentPage = Math.floor(safeOffset / safePageSize) + 1; const recordset = sorted.slice(safeOffset, safeOffset + safePageSize); Object.assign(recordset, { recordset, totalCount, pageCount, currentPage, offset: safeOffset, pageSize: safePageSize, }); return recordset; } /** * Determines whether a category ID or name represents the root category. * * @param {string|number} id Category ID. * @param {string|number|null} [rootCategoryId] Configured root category ID. * @param {string} [metaName] Optional category display name. * @returns {boolean} */ function isRootCategory(id, rootCategoryId, metaName) { if (String(id) === '1') return true; if (rootCategoryId != null && String(id) === String(rootCategoryId)) return true; if (metaName) { const lower = String(metaName).trim().toLowerCase(); if (lower === 'home' || lower === 'kategorie 1' || lower === 'category 1') return true; } return false; } /** * Recursively collects categoryId and all its descendant category IDs. * * @param {Array} selectedCategories Array of category ID strings. * @param {Record} categoriesMap Map of id -> categoryNode. * @param {Record} [facets] Optional fallback facet map. * @returns {Set} Set of category ID strings including selected and all descendants. */ export function getCategoryAndDescendantIds(selectedCategories, categoriesMap, facets) { const allIds = new Set(); const queue = []; for (const id of selectedCategories ?? []) { const sId = String(id).trim(); if (sId && !allIds.has(sId)) { allIds.add(sId); queue.push(sId); } } if (queue.length === 0) return allIds; if (categoriesMap && Object.keys(categoriesMap).length > 0) { const childrenByParent = new Map(); for (const [id, node] of Object.entries(categoriesMap)) { if (node && node.parentId != null) { const pId = String(node.parentId); if (!childrenByParent.has(pId)) { childrenByParent.set(pId, []); } childrenByParent.get(pId).push(String(node.id ?? id)); } } while (queue.length > 0) { const current = queue.shift(); const children = childrenByParent.get(current); if (children) { for (const childId of children) { if (!allIds.has(childId)) { allIds.add(childId); queue.push(childId); } } } } return allIds; } if (facets) { const childrenByParent = new Map(); for (const [id, meta] of Object.entries(facets)) { if (meta && meta.parent != null) { const pId = String(meta.parent); if (!childrenByParent.has(pId)) { childrenByParent.set(pId, []); } childrenByParent.get(pId).push(String(id)); } } while (queue.length > 0) { const current = queue.shift(); const children = childrenByParent.get(current); if (children) { for (const childId of children) { if (!allIds.has(childId)) { allIds.add(childId); queue.push(childId); } } } } } return allIds; } /** * Checks whether an article belongs to at least one of the target categories. * * @param {object} row Article index record. * @param {Set} expandedCategoryIds Set of category ID strings. * @returns {boolean} Whether the article matches. */ function matchesCategory(row, expandedCategoryIds) { if (!expandedCategoryIds || expandedCategoryIds.size === 0) return true; if (!row.categories) return false; const rowCats = String(row.categories).split(',').map((s) => s.trim()); return rowCats.some((catId) => expandedCategoryIds.has(String(catId))); } /** * Computes facet counts for each category represented in matched articles. * * @param {Array} matchedArticles Articles matching text query. * @returns {Record} Category facets. */ function buildCategoryFacets(matchedArticles) { const facets = {}; const categoriesMap = categoryAutomat.state?.categories ?? {}; const rootCategoryId = categoryAutomat.state?.rootCategoryId; for (const article of matchedArticles) { if (!article.categories) continue; const ids = String(article.categories).split(',').map((s) => s.trim()).filter(Boolean); // Each article rolls up to its assigned categories and all their ancestors (except root). const articleCats = new Set(); for (const id of ids) { let currentId = id; while (currentId && !isRootCategory(currentId, rootCategoryId)) { articleCats.add(String(currentId)); const node = categoriesMap[String(currentId)]; currentId = node?.parentId != null && node.parentId !== 0 ? String(node.parentId) : null; } } for (const id of articleCats) { const catNode = categoriesMap[id]; const name = catNode?.name ?? `Kategorie ${id}`; if (isRootCategory(id, rootCategoryId, name)) continue; if (!facets[id]) { facets[id] = { count: 0, name, parent: catNode?.parentId ?? null, }; } facets[id].count += 1; } } return facets; } const actions = { /** * Executes in-memory search and derivation synchronously. * * @param {object} [params] Search parameters to merge. * @returns {ArticleSearchResult} Search outcome. */ runSearch(params = {}) { const currentState = articleSearchAutomat.state; const target = params.target !== undefined ? String(params.target ?? '') : currentState.target; const filter = params.filter !== undefined ? String(params.filter ?? '') : currentState.filter; const pageSize = params.pageSize !== undefined ? params.pageSize : currentState.pageSize; const offset = params.offset !== undefined ? Math.max(0, Number(params.offset) || 0) : currentState.offset; const articles = articleIndexAutomat.state.items ?? []; const query = target.trim().toLowerCase(); // Filter by text query across all tokens const textMatches = query ? articles.filter((row) => matches(row, query)) : articles; // Build facets for all text matches (before category filtering) const categoryFacets = buildCategoryFacets(textMatches); // Apply category filter if set (expanding to include all child categories) const selectedCategories = parseFilter(filter); const categoriesMap = categoryAutomat.state?.categories ?? {}; let filteredArticles = textMatches; if (selectedCategories.length > 0) { const expandedCategoryIds = getCategoryAndDescendantIds( selectedCategories, categoriesMap, categoryFacets, ); filteredArticles = textMatches.filter((row) => matchesCategory(row, expandedCategoryIds), ); } const sortedArticles = sortArticlesByRelevance(filteredArticles, query); const totalCount = sortedArticles.length; const safePageSize = pageSize != null && pageSize > 0 ? pageSize : null; const pageCount = safePageSize ? Math.max(1, Math.ceil(totalCount / safePageSize)) : 1; const currentPage = safePageSize ? Math.floor(offset / safePageSize) + 1 : 1; const recordset = safePageSize ? sortedArticles.slice(offset, offset + safePageSize) : sortedArticles; const result = { categoryFacets, totalCount, recordset, offset, pageSize: safePageSize, pageCount, currentPage, error: null, }; articleSearchAutomat.setState({ result, target, filter, offset, pageSize: safePageSize, loading: articleIndexAutomat.state.status === 'loading', }); return result; }, /** * Runs a client-side search over the article index with pagination. * * Does not block on network calls: evaluates immediately against whatever * articles are currently loaded, and ensures index derivation is running in the background. * * @param {string|object} [targetOrParams] Search term or options object. * @param {object} [options] Secondary options when first argument is target string. * @returns {Promise} Search outcome. */ async search(targetOrParams, options = {}) { const params = typeof targetOrParams === 'object' && targetOrParams !== null ? targetOrParams : targetOrParams !== undefined ? { target: targetOrParams, ...options } : options; // Kick off index loading in the background if not ready yet if (articleIndexAutomat.state.status === 'idle') { void articleIndexAutomat.actions.ensureLoaded().catch(() => {}); } return actions.runSearch(params); }, /** * Updates the page size and re-derives the result set. * * @param {number} pageSize Number of articles per page. * @returns {ArticleSearchResult} */ setPageSize(pageSize) { return actions.runSearch({ pageSize }); }, /** * Sets page number (1-based) and re-derives the result set. * * @param {number} page 1-based page number. * @returns {ArticleSearchResult} */ setPage(page) { const safeSize = articleSearchAutomat.state.pageSize ?? 1; const offset = Math.max(0, (page - 1) * safeSize); return actions.runSearch({ offset }); }, /** * Sets 0-based offset and re-derives the result set. * * @param {number} offset 0-based record offset. * @returns {ArticleSearchResult} */ setOffset(offset) { return actions.runSearch({ offset }); }, /** Reset the search state. */ reset() { articleSearchAutomat.setState(initialState); }, }; /** * Article search and pagination state for the `/artikel` overview page. * * Components subscribe to `result` to receive paginated records, facets, * page counts, and total counts. * * @type {Automat} */ articleSearchAutomat = new Automat(initialState, actions, { name: 'articleSearch' }); // Continuously re-derive and update subscribers as category views load their article lists. articleIndexAutomat.subscribe(() => { actions.runSearch(); }); // Update facet names whenever the category tree is loaded/updated. categoryAutomat.subscribe((catState) => { if (catState.treeStatus === 'succeeded') { actions.runSearch(); } }); /** * Selects the latest search result. * * @param {ArticleSearchState} state Automat state. * @returns {ArticleSearchResult} Search outcome. */ export function selectSearchResult(state) { return state.result; } export default articleSearchAutomat;