u
This commit is contained in:
@@ -86,17 +86,248 @@ function matches(row, query) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether an article belongs to at least one of the selected categories.
|
||||
* 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().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<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 {Array<string>} selectedCategories Array of category ID strings.
|
||||
* @param {Set<string>} expandedCategoryIds Set of category ID strings.
|
||||
* @returns {boolean} Whether the article matches.
|
||||
*/
|
||||
function matchesCategory(row, selectedCategories) {
|
||||
if (!selectedCategories || selectedCategories.length === 0) return true;
|
||||
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 selectedCategories.some((catId) => rowCats.includes(String(catId)));
|
||||
return rowCats.some((catId) => expandedCategoryIds.has(String(catId)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,16 +339,32 @@ function matchesCategory(row, selectedCategories) {
|
||||
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]) {
|
||||
const catNode = categoriesMap[id];
|
||||
facets[id] = {
|
||||
count: 0,
|
||||
name: catNode?.name ?? `Kategorie ${id}`,
|
||||
name,
|
||||
parent: catNode?.parentId ?? null,
|
||||
};
|
||||
}
|
||||
@@ -153,19 +400,31 @@ const actions = {
|
||||
// Build facets for all text matches (before category filtering)
|
||||
const categoryFacets = buildCategoryFacets(textMatches);
|
||||
|
||||
// Apply category filter if set
|
||||
// Apply category filter if set (expanding to include all child categories)
|
||||
const selectedCategories = parseFilter(filter);
|
||||
const filteredArticles = selectedCategories.length > 0
|
||||
? textMatches.filter((row) => matchesCategory(row, selectedCategories))
|
||||
: textMatches;
|
||||
const categoriesMap = categoryAutomat.state?.categories ?? {};
|
||||
let filteredArticles = textMatches;
|
||||
|
||||
const totalCount = filteredArticles.length;
|
||||
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
|
||||
? filteredArticles.slice(offset, offset + safePageSize)
|
||||
: filteredArticles;
|
||||
? sortedArticles.slice(offset, offset + safePageSize)
|
||||
: sortedArticles;
|
||||
|
||||
const result = {
|
||||
categoryFacets,
|
||||
|
||||
@@ -15,6 +15,7 @@ export {
|
||||
setLastArticleSku,
|
||||
setLastSearchTerm,
|
||||
setLastArticleSearchPath,
|
||||
setLastCategoryId,
|
||||
setLastCategorySlug,
|
||||
setLastCategoryPath,
|
||||
clearNavigation,
|
||||
@@ -38,7 +39,11 @@ export { default as supplierAutomat } from './supplierAutomat';
|
||||
export { default as warehouseAutomat } from './warehouseAutomat';
|
||||
|
||||
/** Article search over the derived index (replaces StorageManager.doSearch). */
|
||||
export { default as articleSearchAutomat, selectSearchResult } from './articleSearchAutomat';
|
||||
export {
|
||||
default as articleSearchAutomat,
|
||||
selectSearchResult,
|
||||
searchArticlesIndex,
|
||||
} from './articleSearchAutomat';
|
||||
|
||||
/**
|
||||
* Derived article search index: the union of every loaded category view.
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
*/
|
||||
|
||||
import { Automat } from 'react-automat';
|
||||
import categoryAutomat from './categoryAutomat';
|
||||
import { buildCategoryPath, resolveCategoryIdFromSlugPath } from '../components/category/categoryRoute';
|
||||
import { buildSearchUrl } from '../components/article/artikelQuery';
|
||||
|
||||
/**
|
||||
* @typedef {Object} NavigationState
|
||||
@@ -18,6 +21,7 @@ import { Automat } from 'react-automat';
|
||||
* @property {string|null} lastArticleSku Last article SKU visited by the user.
|
||||
* @property {string|null} lastSearchTerm Last article search query term.
|
||||
* @property {string|null} lastArticleSearchPath Full last search path (e.g. `/artikel?target=apfel`).
|
||||
* @property {string|null} lastCategoryId Last visited category ID.
|
||||
* @property {string|null} lastCategorySlug Last visited category slug (e.g. `obst-gemuese/obst`).
|
||||
* @property {string|null} lastCategoryPath Full last category path (e.g. `/kategorien/obst-gemuese/obst`).
|
||||
*/
|
||||
@@ -28,6 +32,7 @@ const initialState = {
|
||||
lastArticleSku: null,
|
||||
lastSearchTerm: null,
|
||||
lastArticleSearchPath: null,
|
||||
lastCategoryId: null,
|
||||
lastCategorySlug: null,
|
||||
lastCategoryPath: null,
|
||||
};
|
||||
@@ -70,7 +75,8 @@ const navigationAutomat = new Automat(
|
||||
*/
|
||||
setLastSearchTerm(term, path = null) {
|
||||
const trimmed = term != null ? String(term) : '';
|
||||
const searchPath = path ?? (trimmed ? `/artikel?target=${encodeURIComponent(trimmed)}` : '/artikel');
|
||||
const currentCatId = navigationAutomat.state.lastCategoryId ?? '';
|
||||
const searchPath = path ?? buildSearchUrl(trimmed, currentCatId, 0);
|
||||
navigationAutomat.setState({
|
||||
lastSearchTerm: trimmed || null,
|
||||
lastArticleSearchPath: searchPath,
|
||||
@@ -91,6 +97,41 @@ const navigationAutomat = new Automat(
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the active category ID and keep categoryPath & articleSearchPath in sync.
|
||||
*
|
||||
* @param {string|number|null} categoryId Category ID or null for root.
|
||||
* @param {string|null} [path] Full category path if already known.
|
||||
* @returns {void}
|
||||
*/
|
||||
setLastCategoryId(categoryId, path = null) {
|
||||
const id = categoryId != null ? String(categoryId) : null;
|
||||
const categories = categoryAutomat.state?.categories;
|
||||
const rootCategoryId = categoryAutomat.state?.rootCategoryId;
|
||||
const isRoot = !id || (rootCategoryId != null && id === String(rootCategoryId));
|
||||
|
||||
let categoryPath = path;
|
||||
if (!categoryPath) {
|
||||
if (!isRoot && categories && rootCategoryId != null) {
|
||||
categoryPath = buildCategoryPath(categories, rootCategoryId, id);
|
||||
} else {
|
||||
categoryPath = '/kategorien';
|
||||
}
|
||||
}
|
||||
|
||||
const cleanSlug = categoryPath.replace(/^\/kategorien\/?/, '').replace(/\/+$/, '') || null;
|
||||
const currentTerm = navigationAutomat.state.lastSearchTerm ?? '';
|
||||
const filter = isRoot ? '' : id;
|
||||
const searchPath = buildSearchUrl(currentTerm, filter, 0);
|
||||
|
||||
navigationAutomat.setState({
|
||||
lastCategoryId: isRoot ? null : id,
|
||||
lastCategorySlug: isRoot ? null : cleanSlug,
|
||||
lastCategoryPath: categoryPath,
|
||||
lastArticleSearchPath: searchPath,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Set the last visited category slug.
|
||||
*
|
||||
@@ -100,10 +141,7 @@ const navigationAutomat = new Automat(
|
||||
setLastCategorySlug(slug) {
|
||||
const clean = slug ? String(slug).replace(/^\/+|\/+$/g, '') : '';
|
||||
const path = clean ? `/kategorien/${clean}` : '/kategorien';
|
||||
navigationAutomat.setState({
|
||||
lastCategorySlug: clean || null,
|
||||
lastCategoryPath: path,
|
||||
});
|
||||
navigationAutomat.actions.setLastCategoryPath(path);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -114,10 +152,24 @@ const navigationAutomat = new Automat(
|
||||
*/
|
||||
setLastCategoryPath(path) {
|
||||
const cleanPath = path ? String(path).replace(/^\/+/, '/') : '/kategorien';
|
||||
const slug = cleanPath.replace(/^\/kategorien\/?/, '').replace(/\/+$/, '');
|
||||
const slug = cleanPath.replace(/^\/kategorien\/?/, '').replace(/\/+$/, '') || null;
|
||||
const categories = categoryAutomat.state?.categories;
|
||||
const rootCategoryId = categoryAutomat.state?.rootCategoryId;
|
||||
const resolvedId = slug && categories && rootCategoryId != null
|
||||
? resolveCategoryIdFromSlugPath(categories, rootCategoryId, slug)
|
||||
: null;
|
||||
const isRoot = !resolvedId || (rootCategoryId != null && String(resolvedId) === String(rootCategoryId));
|
||||
const catId = isRoot ? null : String(resolvedId);
|
||||
|
||||
const currentTerm = navigationAutomat.state.lastSearchTerm ?? '';
|
||||
const filter = catId ?? '';
|
||||
const searchPath = buildSearchUrl(currentTerm, filter, 0);
|
||||
|
||||
navigationAutomat.setState({
|
||||
lastCategorySlug: slug || null,
|
||||
lastCategoryId: catId,
|
||||
lastCategorySlug: isRoot ? null : slug,
|
||||
lastCategoryPath: cleanPath,
|
||||
lastArticleSearchPath: searchPath,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -142,6 +194,7 @@ export const {
|
||||
setLastArticleSku,
|
||||
setLastSearchTerm,
|
||||
setLastArticleSearchPath,
|
||||
setLastCategoryId,
|
||||
setLastCategorySlug,
|
||||
setLastCategoryPath,
|
||||
clearNavigation,
|
||||
|
||||
Reference in New Issue
Block a user