UNPKG

tablor-core

Version:

Core features for data tables, grids, and advanced search, pagination, and sorting in Angular.

1,354 lines (1,342 loc) 134 kB
import { Subject } from 'rxjs'; /** * Utility functions for working with items. * * @remarks * This class contains methods that are useful when working with items. * Items are the data records managed by the data table library. */ class ItemsUtils { /** * Adds `tablorMeta` properties to each item in the items. * @param items - The items to extend. * @param getUuidAutoCounter - A function to generate unique UUIDs for items. * @returns Augmented items with added `tablorMeta` properties. */ static augmentItems(items, getUuidAutoCounter) { return items.map((data) => { return { ...data, tablorMeta: { uuid: getUuidAutoCounter(), isSelected: false, isLoaded: true, }, }; }); } /** * Finds the difference between two items. * @param item1 - The first item. * @param item2 - The second item. * @returns The differences between the items. */ static getItemUpdates(item1, item2) { const diff = {}; for (const key of Object.keys(item1)) { if (key === 'tablorMeta') continue; else if (item1[key] !== item2[key]) diff[key] = item1[key]; } // @ts-ignore diff['tablorMeta'] = item2.tablorMeta; return diff; } /** * Checks if two items are equal. * @param item1 - The first item. * @param item2 - The second item. * @returns `true` if items are equal, otherwise `false`. */ static itemsAreEqual(item1, item2) { if (typeof item1 === 'number') if (typeof item2 === 'number') return item1 === item2; else if (typeof item2 === 'object' && 'tablorMeta' in item2) return item1 === item2.tablorMeta.uuid; else return false; else if (typeof item2 === 'number') if (typeof item1 === 'object' && 'tablorMeta' in item1) return item1.tablorMeta.uuid === item2; else return false; else if (typeof item1 === 'object' && typeof item2 === 'object') for (const key of Object.keys(item1)) { // Ignore tablorMeta property if (key === 'tablorMeta') continue; if (item1[key] !== item2[key]) return false; } else if (item1 === undefined && item2 === undefined) return true; return false; } /** * Merges a new item with an existing item, creating a new item. * @param item1 - The new item with updated properties. * @param item2 - The existing item to update. * @returns The updated item. */ static mergeItemWith(item1, item2) { const newItem = JSON.parse(JSON.stringify(item2)); for (const key of Object.keys(item1)) { if (key === 'tablorMeta') continue; // @ts-expect-error newItem[key] = item1[key]; } return newItem; } /** * Merges a new item with an existing item in place. * @param item1 - The new item with updated properties. * @param item2 - The existing item to update. */ static mergeItemInPlace(item1, item2) { for (const key of Object.keys(item1)) { if (key === 'tablorMeta') continue; // @ts-expect-error item2[key] = item1[key]; } } /** * Replaces the current dataset with a new dataset. * @param dataRef - The reference to the existing dataset. * @param newDataSet - The new dataset to replace the existing one. * @param getUuidAutoCounter - A function to generate unique UUIDs for items. */ static replaceItemsInPlace(dataRef, newDataSet, getUuidAutoCounter) { dataRef.splice(0, dataRef.length); dataRef.push(...ItemsUtils.augmentItems(newDataSet, getUuidAutoCounter)); } /** * Removes items based on their UUIDs or data items. * @param dataSetRef - The dataset to update. * @param itemsOrUuids - The items or UUIDs to remove. * @param indexPicker - A function to determine the index of items to remove. * @returns The status of removals and removed items. */ static removeItemsInPlace(dataSetRef, itemsOrUuids, indexPicker) { if (itemsOrUuids.length === 0) return [[], []]; const uuidsRemovedStatus = Array(itemsOrUuids.length).fill(false); const removedItems = []; for (let _i = 0; _i < itemsOrUuids.length; _i++) { const i = indexPicker(itemsOrUuids[_i], _i); if (i === -1) continue; removedItems.push(dataSetRef[i]); dataSetRef.splice(i, 1); uuidsRemovedStatus[_i] = true; } return [uuidsRemovedStatus, removedItems]; } /** * Updates items in the dataset in place. * @param dataRef - The dataset to update. * @param itemIndexes - The indexes of items to update. * @param modificationsInItems - The modifications to apply. * @returns The status of modifications, modified items, and fields. */ static updateItemsInPlace(dataRef, itemIndexes, modificationsInItems) { if (itemIndexes.length !== modificationsInItems.length) throw new Error('The number of items and modifications must match'); if (itemIndexes.length === 0) return [[], [], []]; const modificationsStatus = []; modificationsStatus.length = itemIndexes.length; const modifiedItems = []; const modifiedFieldsInItems = []; for (let i = 0; i < modificationsInItems.length; i++) { const itemIndex = itemIndexes[i]; const modifications = modificationsInItems[i]; if (itemIndex < 0 || !modifications) { modificationsStatus[i] = false; continue; } if (itemIndex === -1) { modificationsStatus[i] = false; continue; } // Get the difference (which fields/properties to update) between the new item and the existing item // ignore the same fields/properties const itemsDifference = ItemsUtils.getItemUpdates(modifications, dataRef[itemIndex]); // Whether there is any difference to update or not // mark the item as updated modificationsStatus[i] = true; // if there is anything to update in the item, except the `tablorMeta` if (Object.keys(itemsDifference).length <= 1) { continue; } // Overwrite the item fields/properties with the new item fields/properties ItemsUtils.mergeItemInPlace(modifications, dataRef[itemIndex]); // Store the modified fields in the item modifiedFieldsInItems.push(itemsDifference); modifiedItems.push(dataRef[itemIndex]); } return [modificationsStatus, modifiedItems, modifiedFieldsInItems]; } /** * Filters an array of items by a specific field and value. * @param dataSetRef - The array of items to filter. * @param key - The field to check for the given value. * @param value - The value to compare the field against. * @returns An array of filtered items matching the key-value condition. */ filterItemsBy(dataSetRef, key, value) { return dataSetRef.filter(item => item[key] === value); } /** * Maps each item in the data to a new structure based on the provided field mappings. * @param data - The data to map. * @param fieldsArray - An array of fields that defines how to map each item. * @param markMissingItemsUndefined - Whether to set missing fields to `undefined`. * @returns A new array of mapped items. */ static mapItemsPropsToFields(data, fieldsArray, markMissingItemsUndefined) { const mappedData = []; for (const item of data) { // @ts-ignore const mappedItem = {}; for (const field of fieldsArray) { if (field.key in item) { // @ts-ignore mappedItem[field.key] = item[field.key]; } else if (markMissingItemsUndefined) { // @ts-ignore mappedItem[field.key] = undefined; } } if ('tablorMeta' in item) { // @ts-ignore mappedItem['tablorMeta'] = item.tablorMeta; } mappedData.push(mappedItem); } return mappedData; } /** * Finds the indexes of items that match the given UUIDs, items, or augmented items. * @param dataRef - Dataset to search in. * @param itemsOrUuids - Items or UUIDs to match against. * @returns Array of indexes of matching items, or -1 for no match. * * @remarks * - For UUIDs, matches are based on the `tablorMeta.uuid`. * - For augmented items, matching is done using the UUID in `tablorMeta`. * - For regular items, a deep equality check is performed. */ static findIndexes(dataRef, itemsOrUuids) { if (itemsOrUuids.length === 0) return []; const indexes = []; for (const itemOrUuid of itemsOrUuids) { if (typeof itemOrUuid === 'number') { indexes.push(dataRef.findIndex(item => item.tablorMeta.uuid === itemOrUuid)); } else if (typeof itemOrUuid === 'object' && 'tablorMeta' in itemOrUuid) { indexes.push(dataRef.findIndex(item => item.tablorMeta.uuid === itemOrUuid.tablorMeta.uuid)); } else if (typeof itemOrUuid === 'object') { indexes.push(dataRef.findIndex(item => ItemsUtils.itemsAreEqual(item, itemOrUuid))); } else { indexes.push(-1); } } return indexes; } /** * Finds all matching indexes for the given UUIDs, items, or augmented items. * @param dataRef - Dataset to search in. * @param itemsOrUuids - UUIDs, items, or augmented items to match. * @returns An array of arrays, each containing matching indexes for each item. * * @remarks * - Matches all items with the given UUID. * - For augmented items, matching is based on UUIDs within `tablorMeta`. * - Regular items are matched using deep equality. * - For unmatched items, an empty array is returned. */ static findAllIndexes(dataRef, itemsOrUuids) { if (itemsOrUuids.length === 0) return []; const indexes = []; for (const itemOrUuid of itemsOrUuids) { if (typeof itemOrUuid !== 'number' && typeof itemOrUuid === 'object') continue; const currentIndexes = []; dataRef.forEach((item, i) => { if (ItemsUtils.itemsAreEqual(item, itemOrUuid)) currentIndexes.push(i); }); // Add empty array if no match was found, or add the found indexes indexes.push(currentIndexes); } return indexes; } /** * Wraps a method to manage loading state during its execution. * @param method - The method to wrap and handle the loading state for. * @param loadingSetter - Function to update the loading state (true/false). * @returns A wrapped method that manages the loading state. * * @remarks * - Sets loading state to `true` before the method runs, and `false` afterward. * - If an error occurs, loading state is set to `false`, and the error is thrown. */ static handleLoading(method, loadingSetter) { return function (...args) { loadingSetter(true); let results = undefined; try { // Execute the original method with the provided arguments results = method(...args); loadingSetter(false); return results; } catch (e) { loadingSetter(false); throw e; } }; } } /** * Manages the items with methods for adding, removing, and updating them. */ class ItemsStore { getFieldsAsArray; allItems = []; _uuidCounter = 0; _loading = false; $loadingStateChanged = new Subject(); $itemsAdded = new Subject; $itemsRemoved = new Subject; $itemsUpdated = new Subject; constructor(getFieldsAsArray) { this.getFieldsAsArray = getFieldsAsArray; this.setLoading = this.setLoading.bind(this); } /** * Returns the total number of items in the store. */ getNbOfItems() { return this.allItems.length; } /** * Returns the loading state. */ getLoadingState() { return this._loading; } /** * Returns all items as immutable objects. */ getItems(strictlyTyped = true) { return this.allItems.map(item => item); } getMutableItems() { return this.allItems; } /** * Sets the loading state and triggers the corresponding event. */ setLoading(state) { if (state === this._loading) return; this._loading = state; this.$loadingStateChanged.next({ state }); } /** * Initializes the store with an array of items. */ initialize(items) { if (this.allItems.length !== 0) this.$itemsRemoved.next({ removedItems: this.allItems.map(item => item) }); if (items.length === 0) return; this.setLoading(true); const fieldsArray = this.getFieldsAsArray(); ItemsUtils.replaceItemsInPlace(this.allItems, // @ts-ignore ItemsUtils.mapItemsPropsToFields(items, fieldsArray, true), this.getNewUuid.bind(this)); this.setLoading(false); this.$itemsAdded.next({ addedItems: this.allItems.map(item => item) }); } /** * Adds new items to the store. */ add(items) { if (items.length === 0) return; this.setLoading(true); const fieldsArray = this.getFieldsAsArray(); const _items = ItemsUtils.augmentItems( // @ts-ignore ItemsUtils.mapItemsPropsToFields(items, fieldsArray, true), this.getNewUuid.bind(this)); this.allItems.push(..._items); this.setLoading(false); this.$itemsAdded.next({ addedItems: _items }); } /** * Removes items from the store by UUID or item reference. */ remove(itemsAndUuids) { this.setLoading(true); const indexPicker = (itemOrUuid) => { return this.findOneIndexForEach([itemOrUuid])[0]; }; const [itemsRemovedStatus, removedItems] = ItemsUtils.removeItemsInPlace(this.allItems, itemsAndUuids, indexPicker); this.setLoading(false); if (removedItems.length !== 0) { this.$itemsRemoved.next({ removedItems }); } return itemsRemovedStatus; } /** * Updates items in the store using augmented data. */ updateByInItemUuid(items) { if (items.length === 0) return []; this.setLoading(true); const indexes = this.findOneIndexForEach(items); const fieldsArray = this.getFieldsAsArray(); const updateState = this.updateByIndex(ItemsUtils.mapItemsPropsToFields(items, fieldsArray, false), indexes); this.setLoading(false); return updateState; } /** * Updates items in the store by matching UUIDs. */ updateByExternalUuids(items, uuids) { if (items.length !== uuids.length) throw new Error('The number of items and UUIDs must match'); if (items.length === 0) return []; this.setLoading(true); const indexes = this.findOneIndexForEach(uuids); const fieldsArray = this.getFieldsAsArray(); const updateState = this.updateByIndex(ItemsUtils.mapItemsPropsToFields(items, fieldsArray, false), indexes); this.setLoading(false); return updateState; } /** * Updates items at specified indexes. */ updateByIndex(items, indexes) { if (items.length !== indexes.length) throw new Error('The number of items and indexes must match'); if (items.length === 0) return []; this.setLoading(true); let unmodifiedItems = indexes.map(index => this.allItems[index]).filter(item => item !== undefined); unmodifiedItems = JSON.parse(JSON.stringify(unmodifiedItems)); const [modificationsStatus, modifiedItems, modifiedFieldsInItems] = ItemsUtils.updateItemsInPlace(this.allItems, indexes, items); unmodifiedItems = unmodifiedItems.filter((_, index) => modificationsStatus[index]); if (modifiedItems.length !== 0) { this.$itemsUpdated.next({ updatedItems: modifiedItems, prevUpdatedItems: unmodifiedItems, updatedItemsDifference: modifiedFieldsInItems, }); } this.setLoading(false); return modificationsStatus; } /** * Finds and returns items matching the given UUIDs or item references. */ findOneMatchingItemForEach(itemsAndUuids) { if (itemsAndUuids.length === 0) return []; return this.findOneIndexForEach(itemsAndUuids) .map(index => index === -1 ? undefined : this.allItems[index]); } /** * Finds and returns the indexes of items matching the given UUIDs or item references. */ findOneIndexForEach(itemsAndUuids) { return ItemsUtils.findIndexes(this.allItems, itemsAndUuids); } /** * Finds and returns all the possible indexes of items matching the given UUIDs or item references. */ findAllPossibleIndexesForEach(itemsAndUuids) { return ItemsUtils.findAllIndexes(this.allItems, itemsAndUuids); } /** * Generates a unique identifier (UUID) for items. */ getNewUuid() { this._uuidCounter++; return this._uuidCounter; } } /** * `FieldsStore` manages fields. */ class FieldsStore { $fieldsChanged = new Subject(); _allFields = {}; /** * Initialize with an event manager for handling field updates. */ constructor() { } /** * Get all fields as an object. * * @returns The fields object. */ getFields() { return this._allFields; } /** * Get a field by key. */ getField(key) { return this._allFields[key]; } /** * Get all field keys. */ getFieldsKeys() { return Object.keys(this._allFields); } /** * Check if a field exists. */ hasField(key) { return key in this._allFields; } /** * Get all fields as an array for easy iteration. * * @returns All the fields as array. */ getFieldsAsArray() { return Object.keys(this._allFields).map(key => ( // @ts-ignore: Typescript ignore due to dynamic key access { ...this._allFields[key] })); } /** * Initialize fields with provided configurations. * * @param fields - Initial field configurations. */ initialize(fields) { const _fields = this.prepareFields(fields); for (const key in _fields) { this._allFields[key] = _fields[key]; } } /** * Update fields. * * @param fields - Fields to update as object or array. */ updateFields(fields) { const prevFields = {}; if (Array.isArray(fields)) { for (const field of fields) { if (!field.key) throw new Error('Field must have a key.'); const updatedFieldValues = // @ts-ignore this.overwriteFieldInPlace(field, this._allFields[field.key]); if (Object.keys(updatedFieldValues).length <= 1) continue; // @ts-ignore prevFields[field.key] = { ...this._allFields[field.key], ...updatedFieldValues }; } } else if (typeof fields === 'object') { for (const key in fields) { // @ts-ignore fields[key].key = key; const updatedFieldValues = this.overwriteFieldInPlace(fields[key], this._allFields[key]); if (Object.keys(updatedFieldValues).length <= 1) continue; prevFields[key] = { ...this._allFields[key], ...updatedFieldValues }; } } this.$fieldsChanged.next({ fields: this._allFields, prevFields: { ...this._allFields, ...prevFields }, updatedFieldsKeys: Object.keys(prevFields), }); } /** * Update a field by merging old configurations with new. * * @param field - Field to update. * @param prevField - Previous field. */ overwriteFieldInPlace(field, prevField) { if (!prevField) return {}; let changed = { key: prevField.key, }; for (const key in field) { if (key === 'key' || key === undefined) continue; if (field[key] !== prevField[key]) { changed[key] = prevField[key]; prevField[key] = field[key]; } } return changed; } /** * Prepare fields by applying defaults to missing values. * * @param fields - Fields to prepare. */ prepareFields(fields) { const cols = {}; for (const key in fields) { const col = fields[key]; cols[key] = { key: key, title: col.title !== undefined ? col.title : '', colClasses: col.colClasses !== undefined ? col.colClasses : '', isVisibleByDefault: col.isVisibleByDefault !== undefined ? col.isVisibleByDefault : true, isSearchableByDefault: col.isSearchableByDefault !== undefined ? col.isSearchableByDefault : true, isSortableByDefault: col.isSortableByDefault !== undefined ? col.isSortableByDefault : true, isSortedByDefault: col.isSortedByDefault !== undefined ? col.isSortedByDefault : false, isSortedReverseByDefault: col.isSortedReverseByDefault !== undefined ? col.isSortedReverseByDefault : false, isSorted: col.isSortedByDefault !== undefined ? col.isSortedByDefault : false, isSortedReverse: col.isSortedReverseByDefault !== undefined ? col.isSortedReverseByDefault : false, isSearched: false, isVisible: col.isVisibleByDefault !== undefined ? col.isVisibleByDefault : true, render: col.render, defaultContent: col.defaultContent !== undefined ? col.defaultContent : '-', placeholderContent: col.placeholderContent !== undefined ? col.placeholderContent : '-', }; } return cols; } } /** * Represents a selector. */ class Selector { getAllItems; getPaginatedItems; findOneIndexForEach; $itemsRemoved; _selectedUuids = []; $itemsSelectionChanged = new Subject(); constructor(getAllItems, getPaginatedItems, findOneIndexForEach, $itemsRemoved) { this.getAllItems = getAllItems; this.getPaginatedItems = getPaginatedItems; this.findOneIndexForEach = findOneIndexForEach; this.$itemsRemoved = $itemsRemoved; this.$itemsRemoved.subscribe(this.verifySelectedItemsOnRemoval.bind(this)); } /** * Returns the number of selected items. */ getNbOfSelectedItems() { return this._selectedUuids.length; } getNbOfUnselectedItems() { return this.getAllItems().length - this._selectedUuids.length; } getNbOfSelectedPaginatedItems() { if (this.getAllItems().length === this.getPaginatedItems().length) return this.getNbOfSelectedItems(); return this.getPaginatedItems().filter(item => this._selectedUuids.includes(item.tablorMeta.uuid)).length; } getNbOfUnselectedPaginatedItems() { if (this.getAllItems().length === this.getPaginatedItems().length) return this.getNbOfUnselectedItems(); return this.getPaginatedItems().filter(item => !this._selectedUuids.includes(item.tablorMeta.uuid)).length; } /** * Returns the number of selected items in the given items. */ getNbOfSelectedItemsIn(items) { return items.reduce((c, item) => { if (item === undefined) return c; else if (typeof item === 'number') return c + (this._selectedUuids.includes(item) ? 1 : 0); else if (typeof item === 'object' && 'tablorMeta' in item) return c + (this._selectedUuids.includes(item.tablorMeta.uuid) ? 1 : 0); return c; }, 0); } getSelectedItems() { return this.getAllItems().filter(item => this._selectedUuids.includes(item.tablorMeta.uuid)); } getUnselectedItems() { return this.getAllItems().filter(item => !this._selectedUuids.includes(item.tablorMeta.uuid)); } getSelectedItemUuids() { return this._selectedUuids; } getUnselectedItemUuids() { return this.getAllItems().map(item => item.tablorMeta.uuid).filter(uuid => !this._selectedUuids.includes(uuid)); } getSelectedPaginatedItems() { return this.getPaginatedItems().filter(item => this._selectedUuids.includes(item.tablorMeta.uuid)); } getUnselectedPaginatedItems() { return this.getPaginatedItems().filter(item => !this._selectedUuids.includes(item.tablorMeta.uuid)); } /** * Selects or deselects an item. */ select(item, state) { const i = this.selectInternal(item, state); if (i === -1) return; this.$itemsSelectionChanged.next({ items: [this.getAllItems()[i]], }); } /** * Selects or deselects multiple items. */ selectMultiple(items, states) { if (Array.isArray(states) && items.length !== states.length) throw new Error('The number of items and states must match'); if (items.length === 0) return; const indexes = []; if (!Array.isArray(states)) { for (let i = 0; i < items.length; i++) { indexes.push(this.selectInternal(items[i], states)); } } else { for (let i = 0; i < items.length; i++) { indexes.push(this.selectInternal(items[i], states[i])); } } const selectedItems = indexes.map(i => this.getAllItems()[i]); this.$itemsSelectionChanged.next({ items: selectedItems, }); } /** * Verifies that the selected items are still valid after items have been removed. */ verifySelectedItemsOnRemoval({ removedItems }) { const removedUuids = removedItems.map(item => item.tablorMeta.uuid); this._selectedUuids = this._selectedUuids .filter(uuid => !removedUuids.includes(uuid)); } /** * Selects or deselects an item. */ selectInternal(item, state) { if (item === undefined) return -1; const i = this.findOneIndexForEach([item])[0]; if (i === -1) return -1; if (state === 'toggle') state = !this.getAllItems()[i].tablorMeta.isSelected; if (state) { if (!this._selectedUuids.includes(this.getAllItems()[i].tablorMeta.uuid)) this._selectedUuids.push(this.getAllItems()[i].tablorMeta.uuid); } else { if (this._selectedUuids.includes(this.getAllItems()[i].tablorMeta.uuid)) this._selectedUuids = this._selectedUuids .filter(uuid => uuid !== this.getAllItems()[i].tablorMeta.uuid); } this.getAllItems()[i].tablorMeta.isSelected = state; return i; } } /** * String query searcher. */ class StringQuerySearcher { getFields; hasField; constructor(getFields, hasField) { this.getFields = getFields; this.hasField = hasField; } /** * Processes string query options. */ processOptions(options) { let includeFields; let excludeFields = options.excludeFields || []; if (options.includeFields === undefined) includeFields = this.getFields() .map(field => field.key) .filter(field => !excludeFields.includes(field)); else includeFields = options.includeFields; const newOptions = { query: options.query, words: [], includeFields: includeFields, wordsInOrder: options.wordsInOrder === undefined ? false : options.wordsInOrder, consecutiveWords: options.consecutiveWords === undefined ? false : options.consecutiveWords, singleWordMatchCriteria: options.singleWordMatchCriteria !== undefined ? options.singleWordMatchCriteria : 'Contains', requireAllWords: options.requireAllWords === undefined ? true : options.requireAllWords, convertToString: { string: s => s, null: undefined, undefined: undefined, boolean: undefined, number: undefined, date: undefined, }, ignoreWhitespace: options.ignoreWhitespace === undefined ? true : options.ignoreWhitespace, wordSeparators: options.wordSeparators === undefined ? [' '] : options.wordSeparators, isCaseSensitive: options.isCaseSensitive === undefined ? false : options.isCaseSensitive, }; if (options.convertToString) { // @ts-ignore newOptions.convertToString = { string: undefined, null: undefined, undefined: undefined, boolean: undefined, number: undefined, date: undefined, ...options.convertToString, }; } if (!options.query) return newOptions; newOptions.words = this.genQuerySplitterIntoWords(newOptions.wordSeparators, newOptions.ignoreWhitespace, newOptions.isCaseSensitive)(newOptions.query); return newOptions; } /** * Checks if the given options are valid. */ checkKeys(options) { for (const field of options.includeFields) { if (!this.hasField(field)) return false; } return true; } /** * Search items by string query. */ search(items, options) { if (options.words.length === 0) return items; if (options.includeFields.length === 0) return []; return this._search(items, options); } /** * Search items by string query. */ _search(items, options) { const searchedItems = []; const splitQueryIntoWords = this.genQuerySplitterIntoWords(options.wordSeparators, options.ignoreWhitespace, options.isCaseSensitive); const matchWords = this.genWordsMatcherFn(options); const matchPhrases = this.genPhrasesMatcherFn(matchWords, options); items.forEach((item) => { const itemWords = []; for (const field of options.includeFields) { if (field === 'tablorMeta') throw new Error('Cannot search by tablorMeta field'); const valueType = item[field] instanceof Date ? 'date' : item[field] === null ? 'null' : typeof item[field]; // @ts-ignore if (options.convertToString[valueType] === undefined) continue; // @ts-ignore let value = options.convertToString[valueType](item[field]); const valueWords = splitQueryIntoWords(value); if (valueWords.length === 0) continue; itemWords.push(valueWords); } if (itemWords.length === 0) return; const itemPassed = matchPhrases(options.words, itemWords); if (itemPassed) searchedItems.push(item); }); return searchedItems; } /** * Generates field value extractor function. */ genQuerySplitterIntoWords(wordSeparators, ignoreWhitespace, isCaseSensitive) { const wordsSeparatorFns = wordSeparators.map(separator => { if (typeof separator === 'string') return (query) => query.split(separator); else if (typeof separator === 'function') return separator; else if (separator instanceof RegExp) return (query) => query.split(separator); else throw new Error('Invalid word separator'); }); return (query) => { let words = [query]; for (const separatorFn of wordsSeparatorFns) { words = words.map(word => separatorFn(word)).flat(); } if (ignoreWhitespace) words = words.map(word => word.trim()); words = words.filter(word => word.length > 0); if (!isCaseSensitive) words = words.map(word => word.toLowerCase()); return words; }; } genWordsMatcherFn(options) { switch (options.singleWordMatchCriteria) { case 'ExactMatch': return (subWord, word) => subWord === word; case 'Contains': return (subWord, word) => word.includes(subWord); case 'StartsWith': return (subWord, word) => word.startsWith(subWord); case 'EndsWith': return (subWord, word) => word.endsWith(subWord); } } genPhrasesMatcherFn(wordsMatcherFn, options) { if (options.requireAllWords) { if (options.wordsInOrder) { if (options.consecutiveWords) { return (sw, iw) => { // start with // iw: [ [ Fill Full Screen With Gray Color ], [ Command Set Ok Color ] ] // sw: f s wi: true // sw: f s g: false // sw: g c c: false // sw: g c o: true for (let itemFieldWords of iw) { for (let fieldWordIndex = 0; fieldWordIndex <= itemFieldWords.length - sw.length; fieldWordIndex++) { let match = true; for (let searchWordIndex = 0; searchWordIndex < sw.length; searchWordIndex++) { if (!wordsMatcherFn(sw[searchWordIndex], itemFieldWords[fieldWordIndex + searchWordIndex])) { match = false; break; } } if (match) return true; } } return false; }; } else { return (sw, iw) => { // start with // iw: [ [ Fill Full Screen With Gray Color ], [ Command Set Ok Color ] ] // sw: f s wi: true // sw: f s g: true // sw: g c c: false // sw: g c o: false for (let itemFieldWords of iw) { let currentIndex = 0; let isMatched = true; for (let i = 0; i < sw.length; i++) { let found = false; for (let j = currentIndex; j < itemFieldWords.length; j++) { if (wordsMatcherFn(sw[i], itemFieldWords[j])) { found = true; currentIndex = j + 1; break; } } if (!found) { isMatched = false; break; } } if (isMatched) return true; } return false; }; } } else { if (options.consecutiveWords) { const isPermutationMatch = (sw, slice, wordsMatcherFn) => { const matchedIndices = new Set(); for (let i = 0; i < sw.length; i++) { let found = false; for (let j = 0; j < slice.length; j++) { if (!matchedIndices.has(j) && wordsMatcherFn(sw[i], slice[j])) { matchedIndices.add(j); found = true; break; } } if (!found) return false; } return true; }; return (sw, iw) => { // start with // iw: [ [ Fill Full Screen With Gray Color ], [ Command Set Ok Color ] ] // sw: wi f s: true // sw: g f s: false // sw: c c s: false // sw: s c o: true for (let itemFieldWords of iw) { for (let i = 0; i <= itemFieldWords.length - sw.length; i++) { const slice = itemFieldWords.slice(i, i + sw.length); if (isPermutationMatch(sw, slice, wordsMatcherFn)) { return true; } } } return false; }; } else { return (sw, iw) => { // start with // iw: [ [ Fill Full Screen With Gray Color ], [ Command Set Ok Color ] ] // sw: s c wi: true // sw: s f g: true return sw.every((word) => iw.flat().some((itemWord) => wordsMatcherFn(word, itemWord))); }; } } } else { return (sw, iw) => { // start with // iw: [ [ Fill Full Screen With Gray Color ], [ Command Set Ok Color ] ] // sw: s a t: true // sw: a l l: false // sw: o m m: true return sw.some((word) => iw.flat().some((itemWord) => wordsMatcherFn(word, itemWord))); }; } } } function applyDateOffset(baseDate, offset) { if (!offset) return; if (offset.years) baseDate.setFullYear(baseDate.getFullYear() + offset.years); if (offset.months) baseDate.setMonth(baseDate.getMonth() + offset.months); if (offset.days) baseDate.setDate(baseDate.getDate() + offset.days); if (offset.hours) baseDate.setHours(baseDate.getHours() + offset.hours); if (offset.minutes) baseDate.setMinutes(baseDate.getMinutes() + offset.minutes); if (offset.seconds) baseDate.setSeconds(baseDate.getSeconds() + offset.seconds); } function convertToStrictDateRange(dateRange) { const { start, startOffset, end, endOffset } = dateRange; let strictStart = undefined; if (start) { if (typeof start === 'string') { if (start === 'Now') strictStart = new Date(); else strictStart = new Date(start); } else { strictStart = start; } applyDateOffset(strictStart, startOffset); } let strictEnd = undefined; if (end) { if (typeof end === 'string') { if (end === 'Now') strictEnd = new Date(); else strictEnd = new Date(end); } else { strictEnd = end; } applyDateOffset(strictEnd, endOffset); } // @ts-expect-error return { start: strictStart, includeStart: strictStart ? (dateRange.includeStart ? dateRange.includeStart : false) : undefined, end: strictEnd, includeEnd: strictEnd ? (dateRange.includeEnd ? dateRange.includeEnd : false) : undefined, }; } /** * `Date Range Searcher`. This class provides methods for searching items based on date ranges. */ class DateRangeSearcher { hasField; constructor(hasField) { this.hasField = hasField; } /** * Processes string query options. */ processOptions(options) { const dateRanges = {}; for (const field in options.ranges) { dateRanges[field] = options.ranges[field] .map(dateRange => convertToStrictDateRange(dateRange)); } return { mustMatchAllFields: options.mustMatchAllFields !== undefined ? options.mustMatchAllFields : true, ranges: dateRanges, }; } /** * Checks if the given options are valid. */ checkKeys(options) { for (const field in options.ranges) { if (!this.hasField(field)) return false; } return true; } /** * Searches items based on date ranges. */ search(items, options) { const { ranges, mustMatchAllFields } = options; return this.filterMatchingItemsUuids(ranges, mustMatchAllFields, items); } /** * Filters items based on date ranges. */ filterMatchingItemsUuids(dateRanges, mustMatchAllFields, items) { const searchedItems = []; for (const item of items) { let matchedFields = 0; for (const field in dateRanges) { const fieldDateRanges = dateRanges[field]; if (!fieldDateRanges || !fieldDateRanges.length) continue; const value = item[field]; if (!(value instanceof Date)) continue; for (const range of fieldDateRanges) { if (!value) { if (!range.start && !range.end) { matchedFields++; break; } continue; } if (((range.start ? value > range.start : true) || (range.includeStart ? value >= range.start : false)) && ((range.end ? value < range.end : true) || (range.includeEnd ? value <= range.end : false))) { matchedFields++; break; } } } if (matchedFields >= (mustMatchAllFields ? Object.keys(dateRanges).length : 0)) searchedItems.push(item); } return searchedItems; } } /** * Number ranges searcher. This class provides methods for searching items based on number ranges. */ class NumberRangesSearcher { hasField; constructor(hasField) { this.hasField = hasField; } /** * Processes string query options. */ processOptions(options) { const newOptions = { mustMatchAllFields: options.mustMatchAllFields !== undefined ? options.mustMatchAllFields : true, ranges: {}, }; for (const field in options.ranges) { const fieldNumberRanges = options.ranges[field]; if (!fieldNumberRanges || !fieldNumberRanges.length) continue; newOptions.ranges[field] = fieldNumberRanges.map(range => ({ min: range.min === undefined ? -Infinity : range.min, max: range.max === undefined ? Infinity : range.max, includeMin: range.includeMin !== undefined ? range.includeMin : false, includeMax: range.includeMax !== undefined ? range.includeMax : false, })); } return newOptions; } /** * Checks if the given options are valid. */ checkKeys(options) { for (const field in options.ranges) { if (!this.hasField(field)) return false; } return true; } /** * Searches items based on number ranges. */ search(items, options) { const { ranges, mustMatchAllFields } = options; return this.filterMatchingItemsUuids(ranges, mustMatchAllFields, items); } /** * Filters items based on number ranges. */ filterMatchingItemsUuids(multiFieldsRanges, mustMatchAllFields, items) { const searchedItems = []; for (const item of items) { let matchedFields = 0; for (const field in multiFieldsRanges) { const multiRanges = multiFieldsRanges[field]; if (!multiRanges || !multiRanges.length) continue; let value = item[field]; if (typeof value === 'string') value = Number(value); for (const range of multiRanges) { if (!value) { if (range.min === -Infinity && range.max === Infinity) matchedFields++; continue; } if (((range.min === -Infinity ? true : value > range.min) || (range.includeMin ? value === range.min : false)) && ((range.max === Infinity ? true : value < range.max) || (range.includeMax ? value === range.max : false))) { matchedFields++; break; } } } if (matchedFields >= (mustMatchAllFields ? Object.keys(multiFieldsRanges).length : 0)) searchedItems.push(item); } return searchedItems; } } /** * Custom searcher. This searcher is used to filter items based on a custom function. */ class CustomSearcher { constructor() { } /** * Processes the options. */ processOptions(options) { return options; } /** * Checks if the given options are valid. */ checkKeys() { return true; } /** * Filters items based on a custom function. */ search(items, options) { return this.filterMatchingItemsUuids(options.customFn, items); } /** * Filters items based on a custom function. */ filterMatchingItemsUuids(fn, items) { const searchedItems = []; for (const item of items) { if (fn(item, items)) searchedItems.push(item); } return searchedItems; } } /** * `Void searcher`. This class provides methods for searching items based on void query functionality. */ class VoidSearcher { constructor() { } /** * Processes string query options. */ processOptions() { return {}; } /** * Checks if the given options are valid. */ checkKeys() { return true; } /** * Searches items based on void query functionality. */ search(items) { return items; } } /** * `Exact values searcher`. This class provides methods for searching items based on exact values. */ class ExactValuesSearcher { hasField; constructor(hasField) { this.hasField = hasField; } /** * Processes string query options. */ processOptions(options) { const p = { values: options.values, mustMatchAllFields: options.mustMatchAllFields !== undefined ? options.mustMatchAllFields : true, customCompareFns: options.customCompareFns !== undefined ? options.customCompareFns : {}, }; for (const field in p.values) { // @ts-ignore p.customCompareFns[field] = p.customCompareFns[field] !== undefined ? p.customCompareFns[field] : ((actualVal, expectedVal) => actualVal === expectedVal); } return p; } /** * Checks if the given options are valid. */ checkKeys(options) { for (const field in options.values) { if (!this.hasField(field)) { return false; } } return true; } /**