Tag Folders: Improve global tag filters

- Update global tag filters to three-state filters
- Add filter for folders (showing empty folders or no folders)
- Final fix of filtering (should be correct now)
This commit is contained in:
Wolfsblvt 2024-03-06 23:13:22 +01:00
parent fc6146fa00
commit 18379ec602
5 changed files with 130 additions and 47 deletions

View File

@ -160,11 +160,11 @@
.tag.excluded::after {
position: absolute;
top: 0;
bottom: 0;
height: calc(var(--mainFontSize)*1.5);
left: 0;
right: 0;
content: "\d7";
pointer-events: none;
font-size: calc(var(--mainFontSize) *3);
color: red;
line-height: calc(var(--mainFontSize)*1.3);

View File

@ -1309,23 +1309,36 @@ export function getEntitiesList({ doFilter = false, doSort = true } = {}) {
...(power_user.bogus_folders ? tags.filter(isBogusFolder).sort(compareTagsForSort).map(item => tagToEntity(item)) : []),
];
// We need to do multiple filter runs in a specific order, otherwise different settings might override each other
// and screw up tags and search filter, sub lists or similar.
// The specific filters are written inside the "filterByTagState" method and its different parameters.
// Generally what we do is the following:
// 1. First swipe over the list to remove the most obvious things
// 2. Build sub entity lists for all folders, filtering them similarly to the second swipe
// 3. We do the last run, where global filters are applied, and the search filters last
// First run filters, that will hide what should never be displayed
if (doFilter) {
entities = entitiesFilter.applyFilters(entities);
entities = filterByTagState(entities);
}
// Run over all entities between first and second filter to save some states
for(const entity of entities) {
// For folders, we remember the sub entities so they can be displayed later, even if they might be filtered
// Those sub entities should be filtered and have the search filters applied too
if (entity.type === 'tag') {
entity.entities = filterByTagState(entities, { subForEntity: entity });
let subEntities = filterByTagState(entities, { subForEntity: entity });
if (doFilter) {
subEntities = entitiesFilter.applyFilters(subEntities);
}
entity.entities = subEntities;
}
}
// Second run filters, hiding whatever should be filtered later
if (doFilter) {
entities = filterByTagState(entities, { globalDisplayFilters: true });
entities = entitiesFilter.applyFilters(entities);
}
if (doSort) {

View File

@ -8,12 +8,37 @@ import { tag_map } from './tags.js';
export const FILTER_TYPES = {
SEARCH: 'search',
TAG: 'tag',
FOLDER: 'folder',
FAV: 'fav',
GROUP: 'group',
WORLD_INFO_SEARCH: 'world_info_search',
PERSONA_SEARCH: 'persona_search',
};
/**
* The filter states.
* @type {Object.<string, Object>}
*/
export const FILTER_STATES = {
SELECTED: { key: 'SELECTED', class: 'selected' },
EXCLUDED: { key: 'EXCLUDED', class: 'excluded' },
UNDEFINED: { key: 'UNDEFINED', class: undefined },
};
/**
* Robust check if one state equals the other. It does not care whether it's the state key or the state value object.
* @param {Object} a First state
* @param {Object} b Second state
*/
export function isFilterState(a, b) {
const states = Object.keys(FILTER_STATES);
const aKey = states.includes(a) ? a : states.find(key => FILTER_STATES[key] === a);
const bKey = states.includes(b) ? b : states.find(key => FILTER_STATES[key] === b);
return aKey === bKey;
}
/**
* Helper class for filtering data.
* @example
@ -36,8 +61,9 @@ export class FilterHelper {
*/
filterFunctions = {
[FILTER_TYPES.SEARCH]: this.searchFilter.bind(this),
[FILTER_TYPES.GROUP]: this.groupFilter.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),
@ -49,8 +75,9 @@ export class FilterHelper {
*/
filterData = {
[FILTER_TYPES.SEARCH]: '',
[FILTER_TYPES.GROUP]: false,
[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]: '',
@ -144,11 +171,10 @@ export class FilterHelper {
* @returns {any[]} The filtered data.
*/
favFilter(data) {
if (!this.filterData[FILTER_TYPES.FAV]) {
return data;
}
const state = this.filterData[FILTER_TYPES.FAV];
const isFav = entity => entity.item.fav || entity.item.fav == 'true';
return data.filter(entity => entity.item.fav || entity.item.fav == 'true');
return this.filterDataByState(data, state, isFav, { includeFolders: true });
}
/**
@ -157,11 +183,35 @@ export class FilterHelper {
* @returns {any[]} The filtered data.
*/
groupFilter(data) {
if (!this.filterData[FILTER_TYPES.GROUP]) {
return 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 } = {}) {
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.filter(entity => entity.type === 'group');
return data;
}
/**

View File

@ -10,7 +10,7 @@ import {
buildAvatarList,
} from '../script.js';
// eslint-disable-next-line no-unused-vars
import { FILTER_TYPES, FilterHelper } from './filters.js';
import { FILTER_TYPES, FILTER_STATES, isFilterState, FilterHelper } from './filters.js';
import { groupCandidatesFilter, groups, selected_group } from './group-chats.js';
import { download, onlyUnique, parseJsonFile, uuidv4, getSortableDelay } from './utils.js';
@ -50,8 +50,9 @@ export const tag_filter_types = {
};
const ACTIONABLE_TAGS = {
FAV: { id: 1, name: 'Show only favorites', color: 'rgba(255, 255, 0, 0.5)', action: applyFavFilter, icon: 'fa-solid fa-star', class: 'filterByFavorites' },
FAV: { id: 1, name: 'Show only favorites', color: 'rgba(255, 255, 0, 0.5)', action: filterByFav, icon: 'fa-solid fa-star', class: 'filterByFavorites' },
GROUP: { id: 0, name: 'Show only groups', color: 'rgba(100, 100, 100, 0.5)', action: filterByGroups, icon: 'fa-solid fa-users', class: 'filterByGroups' },
FOLDER: { id: 4, name: 'Always show folders', color: 'rgba(120, 120, 120, 0.5)', action: filterByFolder, icon: 'fa-solid fa-folder-plus', class: 'filterByFolder' },
VIEW: { id: 2, name: 'Manage tags', color: 'rgba(150, 100, 100, 0.5)', action: onViewTagsListClick, icon: 'fa-solid fa-gear', class: 'manageTags' },
HINT: { id: 3, name: 'Show Tag List', color: 'rgba(150, 100, 100, 0.5)', action: onTagListHintClick, icon: 'fa-solid fa-tags', class: 'showTagList' },
};
@ -81,7 +82,11 @@ let tag_map = {};
/**
* Applies the basic filter for the current state of the tags and their selection on an entity list.
* @param {*} entities List of entities for display, consisting of tags, characters and groups.
* @param {Array<Object>} entities List of entities for display, consisting of tags, characters and groups.
* @param {Object} param1 Optional parameters, explained below.
* @param {Boolean} [param1.globalDisplayFilters] When enabled, applies the final filter for the global list. Icludes filtering out entities in closed/hidden folders and empty folders.
* @param {Object} [param1.subForEntity] When given an entity, the list of entities gets filtered specifically for that one as a "sub list", filtering out other tags, elements not tagged for this and hidden elements.
* @returns The filtered list of entities
*/
function filterByTagState(entities, { globalDisplayFilters = false, subForEntity = undefined } = {}) {
const filterData = structuredClone(entitiesFilter.getFilterData(FILTER_TYPES.TAG));
@ -108,8 +113,9 @@ function filterByTagState(entities, { globalDisplayFilters = false, subForEntity
}
// Hide folders that have 0 visible sub entities after the first filtering round
const alwaysFolder = isFilterState(entitiesFilter.getFilterData(FILTER_TYPES.FOLDER), FILTER_STATES.SELECTED);
if (entity.type === 'tag') {
return entity.entities.length > 0;
return alwaysFolder || entity.entities.length > 0;
}
return true;
@ -218,12 +224,9 @@ function getTagBlock(item, entities) {
* Applies the favorite filter to the character list.
* @param {FilterHelper} filterHelper Instance of FilterHelper class.
*/
function applyFavFilter(filterHelper) {
const isSelected = $(this).hasClass('selected');
const displayFavoritesOnly = !isSelected;
$(this).toggleClass('selected', displayFavoritesOnly);
filterHelper.setFilterData(FILTER_TYPES.FAV, displayFavoritesOnly);
function filterByFav(filterHelper) {
const state = toggleTagThreeState($(this));
filterHelper.setFilterData(FILTER_TYPES.FAV, state);
}
/**
@ -231,11 +234,17 @@ function applyFavFilter(filterHelper) {
* @param {FilterHelper} filterHelper Instance of FilterHelper class.
*/
function filterByGroups(filterHelper) {
const isSelected = $(this).hasClass('selected');
const displayGroupsOnly = !isSelected;
$(this).toggleClass('selected', displayGroupsOnly);
const state = toggleTagThreeState($(this));
filterHelper.setFilterData(FILTER_TYPES.GROUP, state);
}
filterHelper.setFilterData(FILTER_TYPES.GROUP, displayGroupsOnly);
/**
* Applies the "only folder" filter to the character list.
* @param {FilterHelper} filterHelper Instance of FilterHelper class.
*/
function filterByFolder(filterHelper) {
const state = toggleTagThreeState($(this));
filterHelper.setFilterData(FILTER_TYPES.FOLDER, state);
}
function loadTagsSettings(settings) {
@ -475,7 +484,7 @@ function appendTagToList(listElement, tag, { removable, selectable, action, isGe
}
if (tag.excluded && isGeneralList) {
$(tagElement).addClass('excluded');
toggleTagThreeState(tagElement, FILTER_STATES.EXCLUDED);
}
if (selectable) {
@ -498,27 +507,13 @@ function onTagFilterClick(listElement) {
const tagId = $(this).attr('id');
const existingTag = tags.find((tag) => tag.id === tagId);
let excludeTag;
if ($(this).hasClass('selected')) {
$(this).removeClass('selected');
$(this).addClass('excluded');
excludeTag = true;
}
else if ($(this).hasClass('excluded')) {
$(this).removeClass('excluded');
excludeTag = false;
}
else {
$(this).addClass('selected');
}
let state = toggleTagThreeState($(this));
// Manual undefined check required for three-state boolean
if (excludeTag !== undefined) {
if (existingTag) {
existingTag.excluded = excludeTag;
if (existingTag) {
existingTag.excluded = isFilterState(state, FILTER_STATES.EXCLUDED);
saveSettingsDebounced();
}
saveSettingsDebounced();
}
// Update bogus folder if applicable
@ -535,6 +530,28 @@ function onTagFilterClick(listElement) {
updateTagFilterIndicator();
}
function toggleTagThreeState(element, stateOverride = undefined) {
const states = Object.keys(FILTER_STATES);
const overrideKey = states.includes(stateOverride) ? stateOverride : states.find(key => FILTER_STATES[key] === stateOverride);
const currentState = element.attr('data-toggle-state') ?? states[states.length - 1];
const nextState = overrideKey ?? states[(states.indexOf(currentState) + 1) % states.length];
element.attr('data-toggle-state', nextState);
console.debug('toggle three-way filter on', element, 'from', currentState, 'to', nextState);
// Update css class and remove all others
Object.keys(FILTER_STATES).forEach(x => {
if (!isFilterState(x, FILTER_STATES.UNDEFINED)) {
element.toggleClass(FILTER_STATES[x].class, x === nextState);
}
});
return nextState;
}
function runTagFilters(listElement) {
const tagIds = [...($(listElement).find('.tag.selected:not(.actionable)').map((_, el) => $(el).attr('id')))];
const excludedTagIds = [...($(listElement).find('.tag.excluded:not(.actionable)').map((_, el) => $(el).attr('id')))];
@ -950,7 +967,7 @@ function onTagAsFolderClick() {
// Cycle through folder types
const types = Object.keys(TAG_FOLDER_TYPES);
let currentTypeIndex = types.indexOf(tag.folder_type);
const currentTypeIndex = types.indexOf(tag.folder_type);
tag.folder_type = types[(currentTypeIndex + 1) % types.length];
updateDrawTagFolder(element, tag);

View File

@ -955,6 +955,9 @@ hr {
margin-left: calc(var(--avatar-base-border-radius));
margin-bottom: calc(var(--avatar-base-border-radius));
}
.avatars_inline .avatar:last-of-type {
margin-right: calc(var(--avatar-base-border-radius));
}
.group_select_block_list {
display: flex;