mirror of
				https://github.com/SillyTavern/SillyTavern.git
				synced 2025-06-05 21:59:27 +02:00 
			
		
		
		
	
		
			
				
	
	
		
			415 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
			
		
		
	
	
			415 lines
		
	
	
		
			15 KiB
		
	
	
	
		
			JavaScript
		
	
	
	
	
	
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<FilterType, Map<string|number,number>>}
 | 
						|
     */
 | 
						|
    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.<string, Function>}
 | 
						|
     */
 | 
						|
    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.<string, any>}
 | 
						|
     */
 | 
						|
    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];
 | 
						|
        // Filter directly on folder. Special rules on still displaying characters with active folder filter are implemented in 'getEntitiesList' directly.
 | 
						|
        const isFolder = entity => 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} [options.clearScoreCache=true] - Whether the score cache should be cleared.
 | 
						|
     * @param {Object.<FilterType, any>} [options.tempOverrides={}] - Temporarily override specific filters for this filter application
 | 
						|
     * @returns {any[]} The filtered data.
 | 
						|
     */
 | 
						|
    applyFilters(data, { clearScoreCache = true, tempOverrides = {} } = {}) {
 | 
						|
        if (clearScoreCache) this.clearScoreCache();
 | 
						|
 | 
						|
        // Save original filter states
 | 
						|
        const originalStates = {};
 | 
						|
        for (const key in tempOverrides) {
 | 
						|
            originalStates[key] = this.filterData[key];
 | 
						|
            this.filterData[key] = tempOverrides[key];
 | 
						|
        }
 | 
						|
 | 
						|
        try {
 | 
						|
            const result = Object.values(this.filterFunctions)
 | 
						|
                .reduce((data, fn) => fn(data), data);
 | 
						|
 | 
						|
            // Restore original filter states
 | 
						|
            for (const key in originalStates) {
 | 
						|
                this.filterData[key] = originalStates[key];
 | 
						|
            }
 | 
						|
 | 
						|
            return result;
 | 
						|
        } catch (error) {
 | 
						|
            // Restore original filter states in case of an error
 | 
						|
            for (const key in originalStates) {
 | 
						|
                this.filterData[key] = originalStates[key];
 | 
						|
            }
 | 
						|
            throw error;
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
 | 
						|
    /**
 | 
						|
     * Cache scores for a specific filter type
 | 
						|
     * @param {FilterType} type - The type of data being cached
 | 
						|
     * @param {Map<string|number, number>} results - The search results containing mapped item identifiers and their scores
 | 
						|
     */
 | 
						|
    cacheScores(type, results) {
 | 
						|
        /** @type {Map<string|number, number>} */
 | 
						|
        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();
 | 
						|
        }
 | 
						|
    }
 | 
						|
}
 |