import { fuzzySearchCharacters, fuzzySearchGroups, fuzzySearchPersonas, fuzzySearchTags, fuzzySearchWorldInfo, power_user } from './power-user.js'; import { tag_map } from './tags.js'; import { includesIgnoreCaseAndAccents } from './utils.js'; /** * @typedef FilterType The filter type possible for this filter helper * @type {'search'|'tag'|'folder'|'fav'|'group'|'world_info_search'|'persona_search'} */ /** * The filter types * @type {{ SEARCH: 'search', TAG: 'tag', FOLDER: 'folder', FAV: 'fav', GROUP: 'group', WORLD_INFO_SEARCH: 'world_info_search', PERSONA_SEARCH: 'persona_search'}} */ export const FILTER_TYPES = { SEARCH: 'search', TAG: 'tag', FOLDER: 'folder', FAV: 'fav', GROUP: 'group', WORLD_INFO_SEARCH: 'world_info_search', PERSONA_SEARCH: 'persona_search', }; /** * @typedef FilterState One of the filter states * @property {string} key - The key of the state * @property {string} class - The css class for this state */ /** * The filter states * @type {{ SELECTED: FilterState, EXCLUDED: FilterState, UNDEFINED: FilterState, [key: string]: FilterState }} */ export const FILTER_STATES = { SELECTED: { key: 'SELECTED', class: 'selected' }, EXCLUDED: { key: 'EXCLUDED', class: 'excluded' }, UNDEFINED: { key: 'UNDEFINED', class: 'undefined' }, }; /** @type {string} the default filter state of `FILTER_STATES` */ export const DEFAULT_FILTER_STATE = FILTER_STATES.UNDEFINED.key; /** * Robust check if one state equals the other. It does not care whether it's the state key or the state value object. * @param {FilterState|string} a First state * @param {FilterState|string} b Second state * @returns {boolean} */ export function isFilterState(a, b) { const states = Object.keys(FILTER_STATES); const aKey = typeof a == 'string' && states.includes(a) ? a : states.find(key => FILTER_STATES[key] === a); const bKey = typeof b == 'string' && states.includes(b) ? b : states.find(key => FILTER_STATES[key] === b); return aKey === bKey; } /** * Helper class for filtering data. * @example * const filterHelper = new FilterHelper(() => console.log('data changed')); * filterHelper.setFilterData(FILTER_TYPES.SEARCH, 'test'); * data = filterHelper.applyFilters(data); */ export class FilterHelper { /** * Cache fuzzy search weighting scores for re-usability, sorting and stuff * * Contains maps of weighting numbers assigned to their uid/id, for each of the different `FILTER_TYPES` * @type {Map>} */ scoreCache; /** * Creates a new FilterHelper * @param {Function} onDataChanged Callback to trigger when the filter data changes */ constructor(onDataChanged) { this.onDataChanged = onDataChanged; this.scoreCache = new Map(); } /** * Checks if the filter data has any values. * @returns {boolean} Whether the filter data has any values */ hasAnyFilter() { /** * Checks if the object has any values. * @param {object} obj The object to check for values * @returns {boolean} Whether the object has any values */ function checkRecursive(obj) { if (typeof obj === 'string' && obj.length > 0 && obj !== 'UNDEFINED') { return true; } else if (typeof obj === 'boolean' && obj) { return true; } else if (Array.isArray(obj) && obj.length > 0) { return true; } else if (typeof obj === 'object' && obj !== null && Object.keys(obj.length > 0)) { for (const key in obj) { if (checkRecursive(obj[key])) { return true; } } } return false; } return checkRecursive(this.filterData); } /** * The filter functions. * @type {Object.} */ filterFunctions = { [FILTER_TYPES.SEARCH]: this.searchFilter.bind(this), [FILTER_TYPES.FAV]: this.favFilter.bind(this), [FILTER_TYPES.GROUP]: this.groupFilter.bind(this), [FILTER_TYPES.FOLDER]: this.folderFilter.bind(this), [FILTER_TYPES.TAG]: this.tagFilter.bind(this), [FILTER_TYPES.WORLD_INFO_SEARCH]: this.wiSearchFilter.bind(this), [FILTER_TYPES.PERSONA_SEARCH]: this.personaSearchFilter.bind(this), }; /** * The filter data. * @type {Object.} */ filterData = { [FILTER_TYPES.SEARCH]: '', [FILTER_TYPES.FAV]: false, [FILTER_TYPES.GROUP]: false, [FILTER_TYPES.FOLDER]: false, [FILTER_TYPES.TAG]: { excluded: [], selected: [] }, [FILTER_TYPES.WORLD_INFO_SEARCH]: '', [FILTER_TYPES.PERSONA_SEARCH]: '', }; /** * Applies a fuzzy search filter to the World Info data. * @param {any[]} data The data to filter. Must have a uid property. * @returns {any[]} The filtered data. */ wiSearchFilter(data) { const term = this.filterData[FILTER_TYPES.WORLD_INFO_SEARCH]; if (!term) { return data; } const fuzzySearchResults = fuzzySearchWorldInfo(data, term); this.cacheScores(FILTER_TYPES.WORLD_INFO_SEARCH, new Map(fuzzySearchResults.map(i => [i.item?.uid, i.score]))); const filteredData = data.filter(entity => fuzzySearchResults.find(x => x.item === entity)); return filteredData; } /** * Applies a search filter to Persona data. * @param {string[]} data The data to filter. * @returns {string[]} The filtered data. */ personaSearchFilter(data) { const term = this.filterData[FILTER_TYPES.PERSONA_SEARCH]; if (!term) { return data; } const fuzzySearchResults = fuzzySearchPersonas(data, term); this.cacheScores(FILTER_TYPES.PERSONA_SEARCH, new Map(fuzzySearchResults.map(i => [i.item.key, i.score]))); const filteredData = data.filter(name => fuzzySearchResults.find(x => x.item.key === name)); return filteredData; } /** * Checks if the given entity is tagged with the given tag ID. * @param {object} entity Searchable entity * @param {string} tagId Tag ID to check * @returns {boolean} Whether the entity is tagged with the given tag ID */ isElementTagged(entity, tagId) { const isCharacter = entity.type === 'character'; const lookupValue = isCharacter ? entity.item.avatar : String(entity.id); const isTagged = Array.isArray(tag_map[lookupValue]) && tag_map[lookupValue].includes(tagId); return isTagged; } /** * Applies a tag filter to the data. * @param {any[]} data The data to filter. * @returns {any[]} The filtered data. */ tagFilter(data) { const TAG_LOGIC_AND = true; // switch to false to use OR logic for combining tags const { selected, excluded } = this.filterData[FILTER_TYPES.TAG]; if (!selected.length && !excluded.length) { return data; } const getIsTagged = (entity) => { const isTag = entity.type === 'tag'; const tagFlags = selected.map(tagId => this.isElementTagged(entity, tagId)); const trueFlags = tagFlags.filter(x => x); const isTagged = TAG_LOGIC_AND ? tagFlags.length === trueFlags.length : trueFlags.length > 0; const excludedTagFlags = excluded.map(tagId => this.isElementTagged(entity, tagId)); const isExcluded = excludedTagFlags.includes(true); if (isTag) { return true; } else if (isExcluded) { return false; } else if (selected.length > 0 && !isTagged) { return false; } else { return true; } }; return data.filter(entity => getIsTagged(entity)); } /** * Applies a favorite filter to the data. * @param {any[]} data The data to filter. * @returns {any[]} The filtered data. */ favFilter(data) { const state = this.filterData[FILTER_TYPES.FAV]; const isFav = entity => entity.item.fav || entity.item.fav == 'true'; return this.filterDataByState(data, state, isFav, { includeFolders: true }); } /** * Applies a group type filter to the data. * @param {any[]} data The data to filter. * @returns {any[]} The filtered data. */ groupFilter(data) { const state = this.filterData[FILTER_TYPES.GROUP]; const isGroup = entity => entity.type === 'group'; return this.filterDataByState(data, state, isGroup, { includeFolders: true }); } /** * Applies a "folder" filter to the data. * @param {any[]} data The data to filter. * @returns {any[]} The filtered data. */ folderFilter(data) { const state = this.filterData[FILTER_TYPES.FOLDER]; // Slightly different than the other filters, as a positive folder filter means it doesn't filter anything (folders get "not hidden" at another place), // while a negative state should then filter out all folders. const isFolder = entity => isFilterState(state, FILTER_STATES.SELECTED) ? true : entity.type === 'tag'; return this.filterDataByState(data, state, isFolder); } filterDataByState(data, state, filterFunc, { includeFolders = false } = {}) { if (isFilterState(state, FILTER_STATES.SELECTED)) { return data.filter(entity => filterFunc(entity) || (includeFolders && entity.type == 'tag')); } if (isFilterState(state, FILTER_STATES.EXCLUDED)) { return data.filter(entity => !filterFunc(entity) || (includeFolders && entity.type == 'tag')); } return data; } /** * Applies a search filter to the data. Uses fuzzy search if enabled. * @param {any[]} data The data to filter. * @returns {any[]} The filtered data. */ searchFilter(data) { if (!this.filterData[FILTER_TYPES.SEARCH]) { return data; } const searchValue = this.filterData[FILTER_TYPES.SEARCH]; // Save fuzzy search results and scores if enabled if (power_user.fuzzy_search) { const fuzzySearchCharactersResults = fuzzySearchCharacters(searchValue); const fuzzySearchGroupsResults = fuzzySearchGroups(searchValue); const fuzzySearchTagsResult = fuzzySearchTags(searchValue); this.cacheScores(FILTER_TYPES.SEARCH, new Map(fuzzySearchCharactersResults.map(i => [`character.${i.refIndex}`, i.score]))); this.cacheScores(FILTER_TYPES.SEARCH, new Map(fuzzySearchGroupsResults.map(i => [`group.${i.item.id}`, i.score]))); this.cacheScores(FILTER_TYPES.SEARCH, new Map(fuzzySearchTagsResult.map(i => [`tag.${i.item.id}`, i.score]))); } const _this = this; function getIsValidSearch(entity) { if (power_user.fuzzy_search) { // We can filter easily by checking if we have saved a score const score = _this.getScore(FILTER_TYPES.SEARCH, `${entity.type}.${entity.id}`); return score !== undefined; } else { // Compare insensitive and without accents return includesIgnoreCaseAndAccents(entity.item?.name, searchValue); } } return data.filter(entity => getIsValidSearch(entity)); } /** * Sets the filter data for the given filter type. * @param {string} filterType The filter type to set data for. * @param {any} data The data to set. * @param {boolean} suppressDataChanged Whether to suppress the data changed callback. */ setFilterData(filterType, data, suppressDataChanged = false) { const oldData = this.filterData[filterType]; this.filterData[filterType] = data; // only trigger a data change if the data actually changed if (JSON.stringify(oldData) !== JSON.stringify(data) && !suppressDataChanged) { this.onDataChanged(); } } /** * Gets the filter data for the given filter type. * @param {FilterType} filterType The filter type to get data for. */ getFilterData(filterType) { return this.filterData[filterType]; } /** * Applies all filters to the given data. * @param {any[]} data - The data to filter. * @param {object} options - Optional call parameters * @param {boolean|FilterType} [options.clearScoreCache=true] - Whether the score * @returns {any[]} The filtered data. */ applyFilters(data, { clearScoreCache = true } = {}) { if (clearScoreCache) this.clearScoreCache(); return Object.values(this.filterFunctions) .reduce((data, fn) => fn(data), data); } /** * Cache scores for a specific filter type * @param {FilterType} type - The type of data being cached * @param {Map} results - The search results containing mapped item identifiers and their scores */ cacheScores(type, results) { /** @type {Map} */ const typeScores = this.scoreCache.get(type) || new Map(); for (const [uid, score] of results) { typeScores.set(uid, score); } this.scoreCache.set(type, typeScores); console.debug('search scores chached', type, typeScores); } /** * Get the cached score for an item by type and its identifier * @param {FilterType} type The type of data * @param {string|number} uid The unique identifier for an item * @returns {number|undefined} The cached score, or `undefined` if no score is present */ getScore(type, uid) { return this.scoreCache.get(type)?.get(uid) ?? undefined; } /** * Clear the score cache for a specific type, or completely if no type is specified * @param {FilterType} [type] The type of data to clear scores for. Clears all if unspecified. */ clearScoreCache(type) { if (type) { this.scoreCache.set(type, new Map()); } else { this.scoreCache = new Map(); } } }