Blame Raw
Cohee · 51ad27fb · · 460 lines (16.8 KB)
2 contributors
1import { fuzzySearchCharacters, fuzzySearchGroups, fuzzySearchPersonas, fuzzySearchTags, fuzzySearchWorldInfo, power_user } from './power-user.js';
2import { tag_map } from './tags.js';
3import { 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 */
15export 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 */
35export 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` */
41export 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 */
49export 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 */
62export 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 */
78export 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}