Compare commits
2 Commits
1b4b31b016
...
ea52753ef9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea52753ef9 | ||
|
|
33684fc860 |
@@ -16,6 +16,9 @@ import articleIndexAutomat from './articleIndexAutomat';
|
||||
import categoryAutomat from './categoryAutomat';
|
||||
import { parseFilter } from '../components/article/searchFormat';
|
||||
|
||||
// #region Types & Initial State
|
||||
// MARK: typedefs, initial state, and FlexSearch instance variables
|
||||
|
||||
/**
|
||||
* @typedef {Object} ArticleSearchResult
|
||||
* @property {Record<string,object>} categoryFacets Facet counts per category id.
|
||||
@@ -67,7 +70,51 @@ const { Document, Charset } = FlexSearch;
|
||||
let flexDocument = null;
|
||||
let lastIndexedArticles = null;
|
||||
let articleByInternalId = new Map();
|
||||
// #endregion
|
||||
|
||||
// #region Helpers
|
||||
|
||||
// #region expandSearchTerms
|
||||
// MARK: expands search strings by splitting camelCase and punctuation
|
||||
/**
|
||||
* Expands a string by splitting camelCase words and punctuation delimiters
|
||||
* into separate words so that sub-words (e.g. "BioBizz" -> "Bio Bizz", "Light-Mix" -> "Light Mix")
|
||||
* can be indexed and searched by prefix or infix.
|
||||
*
|
||||
* @param {string} str Raw text string.
|
||||
* @returns {string} Text with added spaced variants.
|
||||
*/
|
||||
function expandSearchTerms(str) {
|
||||
if (!str) return '';
|
||||
const camel = str.replace(/([a-z0-9])([A-Z])/g, '$1 $2');
|
||||
const normalized = camel.replace(/[-_./·+]/g, ' ');
|
||||
if (normalized === str) return str;
|
||||
return `${str} ${normalized}`;
|
||||
}
|
||||
// #endregion
|
||||
|
||||
// #region extractWords
|
||||
// MARK: extracts normalized word tokens from a string
|
||||
/**
|
||||
* Splits text into individual lowercase word tokens, splitting across
|
||||
* camelCase transitions and punctuation delimiters.
|
||||
*
|
||||
* @param {string} str Raw text.
|
||||
* @returns {Array<string>} Normalized word list.
|
||||
*/
|
||||
function extractWords(str) {
|
||||
if (!str) return [];
|
||||
return str
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.replace(/[-_./·+]/g, ' ')
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
}
|
||||
// #endregion
|
||||
|
||||
// #region getOrCreateFlexIndex
|
||||
// MARK: constructs or retrieves FlexSearch document index
|
||||
/**
|
||||
* Lazily constructs and maintains the FlexSearch Document index for the given articles array.
|
||||
*
|
||||
@@ -85,8 +132,8 @@ function getOrCreateFlexIndex(articles) {
|
||||
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: 'name', tokenize: 'full', resolution: 9 },
|
||||
{ field: 'brand', tokenize: 'full', resolution: 5 },
|
||||
{ field: 'eans', tokenize: 'forward', resolution: 9 },
|
||||
],
|
||||
},
|
||||
@@ -100,8 +147,10 @@ function getOrCreateFlexIndex(articles) {
|
||||
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 rawName = String(article.name ?? '');
|
||||
const rawBrand = String(article.brand?.name || article.brandName || '');
|
||||
const name = expandSearchTerms(rawName);
|
||||
const brand = expandSearchTerms(rawBrand);
|
||||
const eans = (article.eans ?? []).join(' ');
|
||||
const all = `${sku} ${name} ${brand} ${eans}`.trim();
|
||||
|
||||
@@ -120,7 +169,10 @@ function getOrCreateFlexIndex(articles) {
|
||||
articleByInternalId = byId;
|
||||
return { doc, byId };
|
||||
}
|
||||
// #endregion
|
||||
|
||||
// #region queryFlexSearch
|
||||
// MARK: queries FlexSearch document index
|
||||
/**
|
||||
* Searches the FlexSearch index with the query string.
|
||||
*
|
||||
@@ -158,7 +210,10 @@ function queryFlexSearch(query, articles) {
|
||||
}
|
||||
return matchesList;
|
||||
}
|
||||
// #endregion
|
||||
|
||||
// #region getArticleRelevanceScore
|
||||
// MARK: calculates search relevance score for an article
|
||||
/**
|
||||
* Calculates a search relevance score for an article record against a query.
|
||||
*
|
||||
@@ -170,8 +225,10 @@ 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 rawName = String(article.name ?? '');
|
||||
const name = rawName.toLowerCase();
|
||||
const rawBrand = String(article.brand?.name || article.brandName || '');
|
||||
const brand = rawBrand.toLowerCase();
|
||||
const eans = (article.eans ?? []).map((e) => String(e).toLowerCase());
|
||||
|
||||
// 1. Exact SKU match
|
||||
@@ -184,8 +241,9 @@ export function getArticleRelevanceScore(article, query) {
|
||||
if (name.startsWith(q)) return 3000;
|
||||
// 5. SKU contains query
|
||||
if (sku.includes(q)) return 2000;
|
||||
|
||||
const words = extractWords(rawName);
|
||||
// 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;
|
||||
@@ -193,14 +251,41 @@ export function getArticleRelevanceScore(article, query) {
|
||||
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
|
||||
|
||||
const brandWords = extractWords(rawBrand);
|
||||
// 10. Brand exact match
|
||||
if (brand === q) return 500;
|
||||
// 11. Brand starts with query
|
||||
if (brand.startsWith(q)) return 400;
|
||||
// 11. Brand contains query
|
||||
// 12. Word in brand starts with query
|
||||
if (brandWords.some((w) => w.startsWith(q))) return 350;
|
||||
// 13. Brand contains query
|
||||
if (brand.includes(q)) return 200;
|
||||
|
||||
// 14. Multi-word query where all terms match across article fields
|
||||
const qWords = q.split(/\s+/).filter(Boolean);
|
||||
if (qWords.length > 1) {
|
||||
const allMatch = qWords.every((term) =>
|
||||
words.some((w) => w.startsWith(term)) ||
|
||||
name.includes(term) ||
|
||||
brandWords.some((w) => w.startsWith(term)) ||
|
||||
brand.includes(term) ||
|
||||
sku.includes(term)
|
||||
);
|
||||
if (allMatch) {
|
||||
const prefixCount = qWords.filter((term) =>
|
||||
words.some((w) => w.startsWith(term)) || brandWords.some((w) => w.startsWith(term))
|
||||
).length;
|
||||
return 1200 + prefixCount * 300;
|
||||
}
|
||||
}
|
||||
|
||||
return 10;
|
||||
}
|
||||
// #endregion
|
||||
|
||||
// #region sortArticlesByRelevance
|
||||
// MARK: sorts articles by relevance score, name, and sku
|
||||
/**
|
||||
* Sorts articles by relevance to the query, breaking ties alphabetically by name and SKU.
|
||||
*
|
||||
@@ -225,7 +310,10 @@ export function sortArticlesByRelevance(articles, query) {
|
||||
return String(a.sku ?? '').localeCompare(String(b.sku ?? ''), 'de');
|
||||
});
|
||||
}
|
||||
// #endregion
|
||||
|
||||
// #region searchArticlesIndex
|
||||
// MARK: calls articleIndexAutomat.actions.ensureLoaded
|
||||
/**
|
||||
* Synchronously searches the article index using the search automat token matching and relevance order.
|
||||
*
|
||||
@@ -308,7 +396,10 @@ export function searchArticlesIndex(query, options = {}) {
|
||||
|
||||
return recordset;
|
||||
}
|
||||
// #endregion
|
||||
|
||||
// #region isRootCategory
|
||||
// MARK: checks if category id or name represents the root category
|
||||
/**
|
||||
* Determines whether a category ID or name represents the root category.
|
||||
*
|
||||
@@ -326,7 +417,10 @@ function isRootCategory(id, rootCategoryId, metaName) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// #endregion
|
||||
|
||||
// #region getCategoryAndDescendantIds
|
||||
// MARK: recursively collects category id and descendant category ids
|
||||
/**
|
||||
* Recursively collects categoryId and all its descendant category IDs.
|
||||
*
|
||||
@@ -404,7 +498,10 @@ export function getCategoryAndDescendantIds(selectedCategories, categoriesMap, f
|
||||
|
||||
return allIds;
|
||||
}
|
||||
// #endregion
|
||||
|
||||
// #region matchesCategory
|
||||
// MARK: checks whether article matches expanded category ids
|
||||
/**
|
||||
* Checks whether an article belongs to at least one of the target categories.
|
||||
*
|
||||
@@ -418,7 +515,10 @@ function matchesCategory(row, expandedCategoryIds) {
|
||||
const rowCats = String(row.categories).split(',').map((s) => s.trim());
|
||||
return rowCats.some((catId) => expandedCategoryIds.has(String(catId)));
|
||||
}
|
||||
// #endregion
|
||||
|
||||
// #region buildCategoryFacets
|
||||
// MARK: computes category facet counts from matched articles
|
||||
/**
|
||||
* Computes facet counts for each category represented in matched articles.
|
||||
*
|
||||
@@ -463,8 +563,14 @@ function buildCategoryFacets(matchedArticles) {
|
||||
|
||||
return facets;
|
||||
}
|
||||
// #endregion
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region Actions
|
||||
const actions = {
|
||||
// #region runSearch
|
||||
// MARK: runs in-memory search and derivation synchronously
|
||||
/**
|
||||
* Executes in-memory search and derivation synchronously.
|
||||
*
|
||||
@@ -537,7 +643,10 @@ const actions = {
|
||||
|
||||
return result;
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region search
|
||||
// MARK: calls articleIndexAutomat.actions.ensureLoaded
|
||||
/**
|
||||
* Runs a client-side search over the article index with pagination.
|
||||
*
|
||||
@@ -562,7 +671,10 @@ const actions = {
|
||||
|
||||
return actions.runSearch(params);
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region setPageSize
|
||||
// MARK: updates page size and re-derives result set
|
||||
/**
|
||||
* Updates the page size and re-derives the result set.
|
||||
*
|
||||
@@ -572,7 +684,10 @@ const actions = {
|
||||
setPageSize(pageSize) {
|
||||
return actions.runSearch({ pageSize });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region setPage
|
||||
// MARK: sets 1-based page number and re-derives result set
|
||||
/**
|
||||
* Sets page number (1-based) and re-derives the result set.
|
||||
*
|
||||
@@ -584,7 +699,10 @@ const actions = {
|
||||
const offset = Math.max(0, (page - 1) * safeSize);
|
||||
return actions.runSearch({ offset });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region setOffset
|
||||
// MARK: sets 0-based offset and re-derives result set
|
||||
/**
|
||||
* Sets 0-based offset and re-derives the result set.
|
||||
*
|
||||
@@ -594,7 +712,10 @@ const actions = {
|
||||
setOffset(offset) {
|
||||
return actions.runSearch({ offset });
|
||||
},
|
||||
// #endregion
|
||||
|
||||
// #region reset
|
||||
// MARK: resets search state and clears FlexSearch index
|
||||
/** Reset the search state and clear FlexSearch index. */
|
||||
reset() {
|
||||
flexDocument = null;
|
||||
@@ -602,7 +723,12 @@ const actions = {
|
||||
articleByInternalId = new Map();
|
||||
articleSearchAutomat.setState(initialState);
|
||||
},
|
||||
// #endregion
|
||||
};
|
||||
// #endregion
|
||||
|
||||
// #region Automat Instance & Subscriptions
|
||||
// MARK: initializes Automat instance and subscribes to articleIndexAutomat & categoryAutomat
|
||||
|
||||
/**
|
||||
* Article search and pagination state for the `/artikel` overview page.
|
||||
@@ -625,6 +751,10 @@ categoryAutomat.subscribe((catState) => {
|
||||
actions.runSearch();
|
||||
}
|
||||
});
|
||||
// #endregion
|
||||
|
||||
// #region Selectors & Exports
|
||||
// MARK: selectSearchResult selector and default export
|
||||
|
||||
/**
|
||||
* Selects the latest search result.
|
||||
@@ -637,3 +767,4 @@ export function selectSearchResult(state) {
|
||||
}
|
||||
|
||||
export default articleSearchAutomat;
|
||||
// #endregion
|
||||
|
||||
Reference in New Issue
Block a user