626 lines
19 KiB
JavaScript
626 lines
19 KiB
JavaScript
/**
|
|
* @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 FlexSearch from 'flexsearch';
|
|
import articleIndexAutomat from './articleIndexAutomat';
|
|
import categoryAutomat from './categoryAutomat';
|
|
import { parseFilter } from '../components/article/searchFormat';
|
|
|
|
/**
|
|
* @typedef {Object} ArticleSearchResult
|
|
* @property {Record<string,object>} categoryFacets Facet counts per category id.
|
|
* @property {number} totalCount Number of matching records.
|
|
* @property {Array<object>} 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;
|
|
|
|
const { Document, Charset } = FlexSearch;
|
|
|
|
let flexDocument = null;
|
|
let lastIndexedArticles = null;
|
|
let articleByInternalId = new Map();
|
|
|
|
/**
|
|
* Lazily constructs and maintains the FlexSearch Document index for the given articles array.
|
|
*
|
|
* @param {Array<object>} articles Current article index items.
|
|
* @returns {{ doc: any, byId: Map<number, object> }}
|
|
*/
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 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<object>} articles List of articles to sort.
|
|
* @param {string} query Search term.
|
|
* @returns {Array<object>} 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<string>} [options.excludeSkus] SKUs to exclude from results.
|
|
* @param {boolean} [options.allowParent=false] Whether to include parent articles.
|
|
* @param {Array<string>} [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<object>} 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();
|
|
|
|
const rawMatches = q ? queryFlexSearch(q, articles) : articles;
|
|
const excludedSet = new Set((excludeSkus ?? []).map((s) => String(s).toLowerCase()));
|
|
|
|
let pool = rawMatches;
|
|
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 = 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<string|number>} selectedCategories Array of category ID strings.
|
|
* @param {Record<string, object>} categoriesMap Map of id -> categoryNode.
|
|
* @param {Record<string, object>} [facets] Optional fallback facet map.
|
|
* @returns {Set<string>} 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<string>} 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<object>} matchedArticles Articles matching text query.
|
|
* @returns {Record<string, {count: number, name: string, parent: any}>} 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();
|
|
|
|
// Query candidates using FlexSearch
|
|
const textMatches = query
|
|
? queryFlexSearch(query, articles)
|
|
: 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<ArticleSearchResult>} 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 and clear FlexSearch index. */
|
|
reset() {
|
|
flexDocument = null;
|
|
lastIndexedArticles = null;
|
|
articleByInternalId = new Map();
|
|
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<ArticleSearchState>}
|
|
*/
|
|
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;
|