| 1 | import { fuzzySearchCharacters, fuzzySearchGroups, fuzzySearchPersonas, fuzzySearchTags, fuzzySearchWorldInfo, power_user } from './power-user.js'; |
| 2 | import { tag_map } from './tags.js'; |
| 3 | import { includesIgnoreCaseAndAccents } from './utils.js'; |
| 4 | |
| 5 | |
| 6 | /** |
| 7 | * @typedef FilterType The filter type possible for this filter helper |
| 8 | * @type {'search'|'tag'|'folder'|'fav'|'group'|'world_info_search'|'persona_search'} |
| 9 | */ |
| 10 | |
| 11 | /** |
| 12 | * The filter types |
| 13 | * @type {{ SEARCH: 'search', TAG: 'tag', FOLDER: 'folder', FAV: 'fav', GROUP: 'group', WORLD_INFO_SEARCH: 'world_info_search', PERSONA_SEARCH: 'persona_search'}} |
| 14 | */ |
| 15 | export const FILTER_TYPES = { |
| 16 | SEARCH: 'search', |
| 17 | TAG: 'tag', |
| 18 | FOLDER: 'folder', |
| 19 | FAV: 'fav', |
| 20 | GROUP: 'group', |
| 21 | WORLD_INFO_SEARCH: 'world_info_search', |
| 22 | PERSONA_SEARCH: 'persona_search', |
| 23 | }; |
| 24 | |
| 25 | /** |
| 26 | * @typedef FilterState One of the filter states |
| 27 | * @property {string} key - The key of the state |
| 28 | * @property {string} class - The css class for this state |
| 29 | */ |
| 30 | |
| 31 | /** |
| 32 | * The filter states |
| 33 | * @type {{ SELECTED: FilterState, EXCLUDED: FilterState, UNDEFINED: FilterState, [key: string]: FilterState }} |
| 34 | */ |
| 35 | export const FILTER_STATES = { |
| 36 | SELECTED: { key: 'SELECTED', class: 'selected' }, |
| 37 | EXCLUDED: { key: 'EXCLUDED', class: 'excluded' }, |
| 38 | UNDEFINED: { key: 'UNDEFINED', class: 'undefined' }, |
| 39 | }; |
| 40 | /** @type {string} the default filter state of `FILTER_STATES` */ |
| 41 | export const DEFAULT_FILTER_STATE = FILTER_STATES.UNDEFINED.key; |
| 42 | |
| 43 | /** |
| 44 | * Robust check if one state equals the other. It does not care whether it's the state key or the state value object. |
| 45 | * @param {FilterState|string} a First state |
| 46 | * @param {FilterState|string} b Second state |
| 47 | * @returns {boolean} |
| 48 | */ |
| 49 | export function isFilterState(a, b) { |
| 50 | const states = Object.keys(FILTER_STATES); |
| 51 | |
| 52 | const aKey = typeof a == 'string' && states.includes(a) ? a : states.find(key => FILTER_STATES[key] === a); |
| 53 | const bKey = typeof b == 'string' && states.includes(b) ? b : states.find(key => FILTER_STATES[key] === b); |
| 54 | |
| 55 | return aKey === bKey; |
| 56 | } |
| 57 | |
| 58 | /** |
| 59 | * The fuzzy search categories |
| 60 | * @type {{ characters: string, worldInfo: string, personas: string, tags: string, groups: string }} |
| 61 | */ |
| 62 | export const fuzzySearchCategories = Object.freeze({ |
| 63 | characters: 'characters', |
| 64 | worldInfo: 'worldInfo', |
| 65 | personas: 'personas', |
| 66 | tags: 'tags', |
| 67 | groups: 'groups', |
| 68 | }); |
| 69 | |
| 70 | |
| 71 | /** |
| 72 | * Helper class for filtering data. |
| 73 | * @example |
| 74 | * const filterHelper = new FilterHelper(() => console.log('data changed')); |
| 75 | * filterHelper.setFilterData(FILTER_TYPES.SEARCH, 'test'); |
| 76 | * data = filterHelper.applyFilters(data); |
| 77 | */ |
| 78 | export class FilterHelper { |
| 79 | /** |
| 80 | * Cache fuzzy search weighting scores for re-usability, sorting and stuff |
| 81 | * |
| 82 | * Contains maps of weighting numbers assigned to their uid/id, for each of the different `FILTER_TYPES` |
| 83 | * @type {Map<FilterType, Map<string|number,number>>} |
| 84 | */ |
| 85 | scoreCache; |
| 86 | |
| 87 | /** |
| 88 | * Cache for fuzzy search results per category. |
| 89 | * @type {Object.<string, { resultMap: Map<string, any> }>} |
| 90 | */ |
| 91 | fuzzySearchCaches; |
| 92 | |
| 93 | /** |
| 94 | * Creates a new FilterHelper |
| 95 | * @param {Function} onDataChanged Callback to trigger when the filter data changes |
| 96 | */ |
| 97 | constructor(onDataChanged) { |
| 98 | this.onDataChanged = onDataChanged; |
| 99 | this.scoreCache = new Map(); |
| 100 | this.fuzzySearchCaches = { |
| 101 | [fuzzySearchCategories.characters]: { resultMap: new Map() }, |
| 102 | [fuzzySearchCategories.worldInfo]: { resultMap: new Map() }, |
| 103 | [fuzzySearchCategories.personas]: { resultMap: new Map() }, |
| 104 | [fuzzySearchCategories.tags]: { resultMap: new Map() }, |
| 105 | [fuzzySearchCategories.groups]: { resultMap: new Map() }, |
| 106 | }; |
| 107 | } |
| 108 | |
| 109 | /** |
| 110 | * Checks if the filter data has any values. |
| 111 | * @returns {boolean} Whether the filter data has any values |
| 112 | */ |
| 113 | hasAnyFilter() { |
| 114 | /** |
| 115 | * Checks if the object has any values. |
| 116 | * @param {object} obj The object to check for values |
| 117 | * @returns {boolean} Whether the object has any values |
| 118 | */ |
| 119 | function checkRecursive(obj) { |
| 120 | if (typeof obj === 'string' && obj.length > 0 && obj !== 'UNDEFINED') { |
| 121 | return true; |
| 122 | } else if (typeof obj === 'boolean' && obj) { |
| 123 | return true; |
| 124 | } else if (Array.isArray(obj) && obj.length > 0) { |
| 125 | return true; |
| 126 | } else if (typeof obj === 'object' && obj !== null && Object.keys(obj.length > 0)) { |
| 127 | for (const key in obj) { |
| 128 | if (checkRecursive(obj[key])) { |
| 129 | return true; |
| 130 | } |
| 131 | } |
| 132 | } |
| 133 | return false; |
| 134 | } |
| 135 | |
| 136 | return checkRecursive(this.filterData); |
| 137 | } |
| 138 | |
| 139 | /** |
| 140 | * The filter functions. |
| 141 | * @type {Object.<string, Function>} |
| 142 | */ |
| 143 | filterFunctions = { |
| 144 | [FILTER_TYPES.SEARCH]: this.searchFilter.bind(this), |
| 145 | [FILTER_TYPES.FAV]: this.favFilter.bind(this), |
| 146 | [FILTER_TYPES.GROUP]: this.groupFilter.bind(this), |
| 147 | [FILTER_TYPES.FOLDER]: this.folderFilter.bind(this), |
| 148 | [FILTER_TYPES.TAG]: this.tagFilter.bind(this), |
| 149 | [FILTER_TYPES.WORLD_INFO_SEARCH]: this.wiSearchFilter.bind(this), |
| 150 | [FILTER_TYPES.PERSONA_SEARCH]: this.personaSearchFilter.bind(this), |
| 151 | }; |
| 152 | |
| 153 | /** |
| 154 | * The filter data. |
| 155 | * @type {Object.<string, any>} |
| 156 | */ |
| 157 | filterData = { |
| 158 | [FILTER_TYPES.SEARCH]: '', |
| 159 | [FILTER_TYPES.FAV]: false, |
| 160 | [FILTER_TYPES.GROUP]: false, |
| 161 | [FILTER_TYPES.FOLDER]: false, |
| 162 | [FILTER_TYPES.TAG]: { excluded: [], selected: [] }, |
| 163 | [FILTER_TYPES.WORLD_INFO_SEARCH]: '', |
| 164 | [FILTER_TYPES.PERSONA_SEARCH]: '', |
| 165 | }; |
| 166 | |
| 167 | /** |
| 168 | * Applies a fuzzy search filter to the World Info data. |
| 169 | * @param {any[]} data The data to filter. Must have a uid property. |
| 170 | * @returns {any[]} The filtered data. |
| 171 | */ |
| 172 | wiSearchFilter(data) { |
| 173 | const term = this.filterData[FILTER_TYPES.WORLD_INFO_SEARCH]; |
| 174 | |
| 175 | if (!term) { |
| 176 | return data; |
| 177 | } |
| 178 | |
| 179 | const fuzzySearchResults = fuzzySearchWorldInfo(data, term, this.fuzzySearchCaches); |
| 180 | this.cacheScores(FILTER_TYPES.WORLD_INFO_SEARCH, new Map(fuzzySearchResults.map(i => [i.item?.uid, i.score]))); |
| 181 | |
| 182 | const filteredData = data.filter(entity => fuzzySearchResults.find(x => x.item === entity)); |
| 183 | return filteredData; |
| 184 | } |
| 185 | |
| 186 | /** |
| 187 | * Applies a search filter to Persona data. |
| 188 | * @param {string[]} data The data to filter. |
| 189 | * @returns {string[]} The filtered data. |
| 190 | */ |
| 191 | personaSearchFilter(data) { |
| 192 | const term = this.filterData[FILTER_TYPES.PERSONA_SEARCH]; |
| 193 | |
| 194 | if (!term) { |
| 195 | return data; |
| 196 | } |
| 197 | |
| 198 | const fuzzySearchResults = fuzzySearchPersonas(data, term, this.fuzzySearchCaches); |
| 199 | this.cacheScores(FILTER_TYPES.PERSONA_SEARCH, new Map(fuzzySearchResults.map(i => [i.item.key, i.score]))); |
| 200 | |
| 201 | const filteredData = data.filter(name => fuzzySearchResults.find(x => x.item.key === name)); |
| 202 | return filteredData; |
| 203 | } |
| 204 | |
| 205 | /** |
| 206 | * Checks if the given entity is tagged with the given tag ID. |
| 207 | * @param {object} entity Searchable entity |
| 208 | * @param {string} tagId Tag ID to check |
| 209 | * @returns {boolean} Whether the entity is tagged with the given tag ID |
| 210 | */ |
| 211 | isElementTagged(entity, tagId) { |
| 212 | const isCharacter = entity.type === 'character'; |
| 213 | const lookupValue = isCharacter ? entity.item.avatar : String(entity.id); |
| 214 | const isTagged = Array.isArray(tag_map[lookupValue]) && tag_map[lookupValue].includes(tagId); |
| 215 | |
| 216 | return isTagged; |
| 217 | } |
| 218 | |
| 219 | /** |
| 220 | * Applies a tag filter to the data. |
| 221 | * @param {any[]} data The data to filter. |
| 222 | * @returns {any[]} The filtered data. |
| 223 | */ |
| 224 | tagFilter(data) { |
| 225 | const TAG_LOGIC_AND = true; // switch to false to use OR logic for combining tags |
| 226 | const { selected, excluded } = this.filterData[FILTER_TYPES.TAG]; |
| 227 | |
| 228 | if (!selected.length && !excluded.length) { |
| 229 | return data; |
| 230 | } |
| 231 | |
| 232 | const getIsTagged = (entity) => { |
| 233 | const isTag = entity.type === 'tag'; |
| 234 | const tagFlags = selected.map(tagId => this.isElementTagged(entity, tagId)); |
| 235 | const trueFlags = tagFlags.filter(x => x); |
| 236 | const isTagged = TAG_LOGIC_AND ? tagFlags.length === trueFlags.length : trueFlags.length > 0; |
| 237 | |
| 238 | const excludedTagFlags = excluded.map(tagId => this.isElementTagged(entity, tagId)); |
| 239 | const isExcluded = excludedTagFlags.includes(true); |
| 240 | |
| 241 | if (isTag) { |
| 242 | return true; |
| 243 | } else if (isExcluded) { |
| 244 | return false; |
| 245 | } else if (selected.length > 0 && !isTagged) { |
| 246 | return false; |
| 247 | } else { |
| 248 | return true; |
| 249 | } |
| 250 | }; |
| 251 | |
| 252 | return data.filter(entity => getIsTagged(entity)); |
| 253 | } |
| 254 | |
| 255 | /** |
| 256 | * Applies a favorite filter to the data. |
| 257 | * @param {any[]} data The data to filter. |
| 258 | * @returns {any[]} The filtered data. |
| 259 | */ |
| 260 | favFilter(data) { |
| 261 | const state = this.filterData[FILTER_TYPES.FAV]; |
| 262 | const isFav = entity => entity.item.fav || entity.item.fav == 'true'; |
| 263 | |
| 264 | return this.filterDataByState(data, state, isFav, { includeFolders: true }); |
| 265 | } |
| 266 | |
| 267 | /** |
| 268 | * Applies a group type filter to the data. |
| 269 | * @param {any[]} data The data to filter. |
| 270 | * @returns {any[]} The filtered data. |
| 271 | */ |
| 272 | groupFilter(data) { |
| 273 | const state = this.filterData[FILTER_TYPES.GROUP]; |
| 274 | const isGroup = entity => entity.type === 'group'; |
| 275 | |
| 276 | return this.filterDataByState(data, state, isGroup, { includeFolders: true }); |
| 277 | } |
| 278 | |
| 279 | /** |
| 280 | * Applies a "folder" filter to the data. |
| 281 | * @param {any[]} data The data to filter. |
| 282 | * @returns {any[]} The filtered data. |
| 283 | */ |
| 284 | folderFilter(data) { |
| 285 | const state = this.filterData[FILTER_TYPES.FOLDER]; |
| 286 | // Filter directly on folder. Special rules on still displaying characters with active folder filter are implemented in 'getEntitiesList' directly. |
| 287 | const isFolder = entity => entity.type === 'tag'; |
| 288 | |
| 289 | return this.filterDataByState(data, state, isFolder); |
| 290 | } |
| 291 | |
| 292 | /** |
| 293 | * Filters an array of entities based on a tri-state filter value. |
| 294 | * SELECTED keeps entities where filterFunc returns true; EXCLUDED removes them; UNDEFINED returns data unchanged. |
| 295 | * @param {any[]} data The data to filter |
| 296 | * @param {FilterState|string} state The tri-state filter value (SELECTED, EXCLUDED, or UNDEFINED) |
| 297 | * @param {Function} filterFunc A predicate function applied to each entity |
| 298 | * @param {object} [options] Options object |
| 299 | * @param {boolean} [options.includeFolders=false] If true, entities with type 'tag' always pass through |
| 300 | * @returns {any[]} The filtered data |
| 301 | */ |
| 302 | filterDataByState(data, state, filterFunc, { includeFolders = false } = {}) { |
| 303 | if (isFilterState(state, FILTER_STATES.SELECTED)) { |
| 304 | return data.filter(entity => filterFunc(entity) || (includeFolders && entity.type == 'tag')); |
| 305 | } |
| 306 | if (isFilterState(state, FILTER_STATES.EXCLUDED)) { |
| 307 | return data.filter(entity => !filterFunc(entity) || (includeFolders && entity.type == 'tag')); |
| 308 | } |
| 309 | |
| 310 | return data; |
| 311 | } |
| 312 | |
| 313 | /** |
| 314 | * Applies a search filter to the data. Uses fuzzy search if enabled. |
| 315 | * @param {any[]} data The data to filter. |
| 316 | * @returns {any[]} The filtered data. |
| 317 | */ |
| 318 | searchFilter(data) { |
| 319 | if (!this.filterData[FILTER_TYPES.SEARCH]) { |
| 320 | return data; |
| 321 | } |
| 322 | |
| 323 | const searchValue = this.filterData[FILTER_TYPES.SEARCH]; |
| 324 | |
| 325 | // Save fuzzy search results and scores if enabled |
| 326 | if (power_user.fuzzy_search) { |
| 327 | const fuzzySearchCharactersResults = fuzzySearchCharacters(searchValue, this.fuzzySearchCaches); |
| 328 | const fuzzySearchGroupsResults = fuzzySearchGroups(searchValue, this.fuzzySearchCaches); |
| 329 | const fuzzySearchTagsResult = fuzzySearchTags(searchValue, this.fuzzySearchCaches); |
| 330 | this.cacheScores(FILTER_TYPES.SEARCH, new Map(fuzzySearchCharactersResults.map(i => [`character.${i.refIndex}`, i.score]))); |
| 331 | this.cacheScores(FILTER_TYPES.SEARCH, new Map(fuzzySearchGroupsResults.map(i => [`group.${i.item.id}`, i.score]))); |
| 332 | this.cacheScores(FILTER_TYPES.SEARCH, new Map(fuzzySearchTagsResult.map(i => [`tag.${i.item.id}`, i.score]))); |
| 333 | } |
| 334 | |
| 335 | const _this = this; |
| 336 | function getIsValidSearch(entity) { |
| 337 | if (power_user.fuzzy_search) { |
| 338 | // We can filter easily by checking if we have saved a score |
| 339 | const score = _this.getScore(FILTER_TYPES.SEARCH, `${entity.type}.${entity.id}`); |
| 340 | return score !== undefined; |
| 341 | } else { |
| 342 | // Compare insensitive and without accents |
| 343 | return includesIgnoreCaseAndAccents(entity.item?.name, searchValue); |
| 344 | } |
| 345 | } |
| 346 | |
| 347 | return data.filter(entity => getIsValidSearch(entity)); |
| 348 | } |
| 349 | |
| 350 | /** |
| 351 | * Sets the filter data for the given filter type. |
| 352 | * @param {string} filterType The filter type to set data for. |
| 353 | * @param {any} data The data to set. |
| 354 | * @param {boolean} suppressDataChanged Whether to suppress the data changed callback. |
| 355 | */ |
| 356 | setFilterData(filterType, data, suppressDataChanged = false) { |
| 357 | const oldData = this.filterData[filterType]; |
| 358 | this.filterData[filterType] = data; |
| 359 | |
| 360 | // only trigger a data change if the data actually changed |
| 361 | if (JSON.stringify(oldData) !== JSON.stringify(data) && !suppressDataChanged) { |
| 362 | this.onDataChanged(); |
| 363 | } |
| 364 | } |
| 365 | |
| 366 | /** |
| 367 | * Gets the filter data for the given filter type. |
| 368 | * @param {FilterType} filterType The filter type to get data for. |
| 369 | */ |
| 370 | getFilterData(filterType) { |
| 371 | return this.filterData[filterType]; |
| 372 | } |
| 373 | |
| 374 | /** |
| 375 | * Applies all filters to the given data. |
| 376 | * @param {any[]} data - The data to filter. |
| 377 | * @param {object} options - Optional call parameters |
| 378 | * @param {boolean} [options.clearScoreCache=true] - Whether the score cache should be cleared. |
| 379 | * @param {Object.<FilterType, any>} [options.tempOverrides={}] - Temporarily override specific filters for this filter application |
| 380 | * @param {boolean} [options.clearFuzzySearchCaches=true] - Whether the fuzzy search caches should be cleared. |
| 381 | * @returns {any[]} The filtered data. |
| 382 | */ |
| 383 | applyFilters(data, { clearScoreCache = true, tempOverrides = {}, clearFuzzySearchCaches = true } = {}) { |
| 384 | if (clearScoreCache) this.clearScoreCache(); |
| 385 | |
| 386 | if (clearFuzzySearchCaches) this.clearFuzzySearchCaches(); |
| 387 | |
| 388 | // Save original filter states |
| 389 | const originalStates = {}; |
| 390 | for (const key in tempOverrides) { |
| 391 | originalStates[key] = this.filterData[key]; |
| 392 | this.filterData[key] = tempOverrides[key]; |
| 393 | } |
| 394 | |
| 395 | try { |
| 396 | const result = Object.values(this.filterFunctions) |
| 397 | .reduce((data, fn) => fn(data), data); |
| 398 | |
| 399 | // Restore original filter states |
| 400 | for (const key in originalStates) { |
| 401 | this.filterData[key] = originalStates[key]; |
| 402 | } |
| 403 | |
| 404 | return result; |
| 405 | } catch (error) { |
| 406 | // Restore original filter states in case of an error |
| 407 | for (const key in originalStates) { |
| 408 | this.filterData[key] = originalStates[key]; |
| 409 | } |
| 410 | throw error; |
| 411 | } |
| 412 | } |
| 413 | |
| 414 | |
| 415 | /** |
| 416 | * Cache scores for a specific filter type |
| 417 | * @param {FilterType} type - The type of data being cached |
| 418 | * @param {Map<string|number, number>} results - The search results containing mapped item identifiers and their scores |
| 419 | */ |
| 420 | cacheScores(type, results) { |
| 421 | /** @type {Map<string|number, number>} */ |
| 422 | const typeScores = this.scoreCache.get(type) || new Map(); |
| 423 | for (const [uid, score] of results) { |
| 424 | typeScores.set(uid, score); |
| 425 | } |
| 426 | this.scoreCache.set(type, typeScores); |
| 427 | console.debug('search scores cached', type, typeScores); |
| 428 | } |
| 429 | |
| 430 | /** |
| 431 | * Get the cached score for an item by type and its identifier |
| 432 | * @param {FilterType} type The type of data |
| 433 | * @param {string|number} uid The unique identifier for an item |
| 434 | * @returns {number|undefined} The cached score, or `undefined` if no score is present |
| 435 | */ |
| 436 | getScore(type, uid) { |
| 437 | return this.scoreCache.get(type)?.get(uid) ?? undefined; |
| 438 | } |
| 439 | |
| 440 | /** |
| 441 | * Clear the score cache for a specific type, or completely if no type is specified |
| 442 | * @param {FilterType} [type] The type of data to clear scores for. Clears all if unspecified. |
| 443 | */ |
| 444 | clearScoreCache(type) { |
| 445 | if (type) { |
| 446 | this.scoreCache.set(type, new Map()); |
| 447 | } else { |
| 448 | this.scoreCache = new Map(); |
| 449 | } |
| 450 | } |
| 451 | |
| 452 | /** |
| 453 | * Clears fuzzy search caches |
| 454 | */ |
| 455 | clearFuzzySearchCaches() { |
| 456 | for (const cache of Object.values(this.fuzzySearchCaches)) { |
| 457 | cache.resultMap.clear(); |
| 458 | } |
| 459 | } |
| 460 | } |