Add taxon filter controls to Group Chat member list (#5006)

* Add taxon controls to top of group member list

* Refactor/cleanup getGroupCharacters

* Fix favorites, refactor and cleanup code

* Fix clearing filters, only show relevant filters in groups

* Fix issues and add persistence

- Fix group member tag listing requiring character list to be init to display
- Fix character tag updates to actually show up in group member contexts
- Persist filter changes for group member tag controls

* Apply suggestions from code review

Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>

* Avoid sibling selectors

* Err on invalid tag type

* avoid hardcoded ids

* cleanup: don't use `group_member`, use `group_candidates_list`

* Support both selectors and jquery instances in getFilterHelper

* Sanitize missing tag filters before rendering

* Unscrew jsdoc formatting

* Show all tags, mark absents specially

* Improve JSDoc

* Fix tag indicator for group contexts

* Don't use deprecated fields

* Add a comment on potentially undefined group id

---------

Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>
This commit is contained in:
Jeff Sandberg
2026-01-15 17:27:59 -07:00
committed by GitHub
parent e77c1d33a6
commit b418ec5c37
5 changed files with 569 additions and 79 deletions
+4
View File
@@ -67,6 +67,10 @@
display: none;
}
.tag.tag-absent {
text-decoration: line-through;
}
.tag.actionable {
border-radius: 50%;
aspect-ratio: 1 / 1;
+7 -1
View File
@@ -6126,6 +6126,12 @@
</div>
<div class="inline-drawer-content">
<div id="currentGroupMembers" name="Current Group Members" class="flex-container flexFlowColumn overflowYAuto flex1">
<div id="rm_group_members_header">
<input id="rm_group_members_filter" class="text_pole margin0" type="search" data-i18n="[placeholder]Search..." placeholder="Search..." />
</div>
<div class="rm_tag_controls">
<div class="tags rm_tag_filter"></div>
</div>
<div id="rm_group_members_pagination" class="rm_group_members_pagination group_pagination"></div>
<div id="rm_group_members" class="rm_group_members overflowYAuto flex-container" group_empty_text="Group is empty." data-i18n="[group_empty_text]Group is empty."></div>
</div>
@@ -6137,7 +6143,7 @@
<div class="fa-solid fa-circle-chevron-down inline-drawer-icon down"></div>
</div>
<div class="inline-drawer-content">
<div name="Unadded Char List" class="flex-container flexFlowColumn overflowYAuto flex1">
<div id="unaddedCharList" name="Unadded Char List" class="flex-container flexFlowColumn overflowYAuto flex1">
<div id="rm_group_add_members_header">
<input id="rm_group_filter" class="text_pole margin0" type="search" data-i18n="[placeholder]Search..." placeholder="Search..." />
</div>
+2 -1
View File
@@ -953,7 +953,8 @@ export async function printCharacters(fullRefresh = false) {
// We are actually always reprinting filters, as it "doesn't hurt", and this way they are always up to date
printTagFilters(tag_filter_type.character);
printTagFilters(tag_filter_type.group_member);
printTagFilters(tag_filter_type.group_members_list);
printTagFilters(tag_filter_type.group_candidates_list);
// We are also always reprinting the lists on character/group edit window, as these ones doesn't get updated otherwise
applyTagsOnCharacterSelect();
+63 -19
View File
@@ -80,7 +80,7 @@ import {
chatElement,
ensureMessageMediaIsArray,
} from '../script.js';
import { printTagList, createTagMapFromList, applyTagsOnCharacterSelect, tag_map, applyTagsOnGroupSelect } from './tags.js';
import { printTagList, createTagMapFromList, applyTagsOnCharacterSelect, tag_map, applyTagsOnGroupSelect, printTagFilters, tag_filter_type } from './tags.js';
import { FILTER_TYPES, FilterHelper } from './filters.js';
import { isExternalMediaAllowed } from './chats.js';
import { POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
@@ -134,6 +134,7 @@ export const group_generation_mode = {
export const DEFAULT_AUTO_MODE_DELAY = 5;
export const groupCandidatesFilter = new FilterHelper(debounce(printGroupCandidates, debounce_timeout.quick));
export const groupMembersFilter = new FilterHelper(debounce(printGroupMembers, debounce_timeout.quick));
let autoModeWorker = null;
const saveGroupDebounced = debounce(async (group, reload) => await _save(group, reload), debounce_timeout.relaxed);
/** @type {Map<string, number>} */
@@ -1449,6 +1450,10 @@ async function modifyGroupMember(groupId, groupMember, isDelete) {
printGroupCandidates();
printGroupMembers();
// Refresh the tag filters for both lists to reflect any new tags
printTagFilters(tag_filter_type.group_candidates_list);
printTagFilters(tag_filter_type.group_members_list);
const groupHasMembers = getGroupCharacters({ doFilter: false, onlyMembers: true }).length > 0;
$('#rm_group_submit').prop('disabled', !groupHasMembers);
}
@@ -1557,31 +1562,63 @@ function isGroupMember(group, avatarId) {
* @returns {Array<{item: Character, id: number, type: string}>} Array of group character objects
*/
function getGroupCharacters({ doFilter = false, onlyMembers = false } = {}) {
function sortMembersFn(a, b) {
function applyFilterAndSort(results, filter, filterSelector) {
let filtered = results;
if (doFilter) {
filtered = filter.applyFilters(filtered);
}
const useFilterOrder = doFilter && !!$(filterSelector).val();
sortEntitiesList(filtered, useFilterOrder, filter);
filter.clearFuzzySearchCaches();
return filtered;
}
function handleMembers(results, thisGroup) {
const membersArray = thisGroup?.members ?? newGroupMembers;
const aIndex = membersArray.indexOf(a.item.avatar);
const bIndex = membersArray.indexOf(b.item.avatar);
return aIndex - bIndex;
// Create index map for O(1) lookups in member sort function
// (separate from characterIndexMap which maps character objects to their array indices)
const memberIndexMap = new Map(membersArray.map((avatar, index) => [avatar, index]));
function sortMembersFn(a, b) {
const aIndex = memberIndexMap.get(a.item.avatar) ?? -1;
const bIndex = memberIndexMap.get(b.item.avatar) ?? -1;
return aIndex - bIndex;
}
// Apply manual member sort before filter and sort
let filtered = results;
if (doFilter) {
filtered = groupMembersFilter.applyFilters(filtered);
}
filtered.sort(sortMembersFn);
// Apply conditional filter-based sort and cleanup
const useFilterOrder = doFilter && !!$('#rm_group_members_filter').val();
if (useFilterOrder) {
sortEntitiesList(filtered, useFilterOrder, groupMembersFilter);
}
groupMembersFilter.clearFuzzySearchCaches();
return filtered;
}
const thisGroup = openGroupId && groups.find((x) => x.id == openGroupId);
let candidates = characters
// Create index map for O(1) lookups when mapping characters to their array indices
// (separate from memberIndexMap used later for sorting members by their group order)
const characterIndexMap = new Map(characters.map((char, index) => [char, index]));
const results = characters
.filter((x) => isGroupMember(thisGroup, x.avatar) == onlyMembers)
.map((x) => ({ item: x, id: characters.indexOf(x), type: 'character' }));
.map((x) => ({ item: x, id: characterIndexMap.get(x), type: 'character' }));
if (doFilter) {
candidates = groupCandidatesFilter.applyFilters(candidates);
// Early return for candidates (non-members)
if (!onlyMembers) {
return applyFilterAndSort(results, groupCandidatesFilter, '#rm_group_filter');
}
if (onlyMembers) {
candidates.sort(sortMembersFn);
} else {
const useFilterOrder = doFilter && !!$('#rm_group_filter').val();
sortEntitiesList(candidates, useFilterOrder, groupCandidatesFilter);
}
groupCandidatesFilter.clearFuzzySearchCaches();
return candidates;
// Handle members with manual sort capability
return handleMembers(results, thisGroup);
}
function printGroupCandidates() {
@@ -1621,7 +1658,7 @@ function printGroupMembers() {
const pageSize = Number(accountStorage.getItem(storageKey)) || 5;
const sizeChangerOptions = [5, 10, 25, 50, 100, 200, 500, 1000];
$(this).pagination({
dataSource: getGroupCharacters({ doFilter: false, onlyMembers: true }),
dataSource: getGroupCharacters({ doFilter: true, onlyMembers: true }),
pageRange: 1,
position: 'top',
showPageNumbers: false,
@@ -1782,6 +1819,7 @@ function select_group_chats(groupId, skipAnimation) {
$('#group_avatar_preview').empty().append(getGroupAvatar(group));
$('#rm_group_restore_avatar').toggle(!!group && isValidImageUrl(group.avatar_url));
$('#rm_group_filter').val('').trigger('input');
$('#rm_group_members_filter').val('').trigger('input');
$('#rm_group_activation_strategy').val(replyStrategy);
$(`#rm_group_activation_strategy option[value="${replyStrategy}"]`).prop('selected', true);
$('#rm_group_generation_mode').val(generationMode);
@@ -2049,6 +2087,11 @@ function filterGroupMembers() {
groupCandidatesFilter.setFilterData(FILTER_TYPES.SEARCH, searchValue);
}
function filterGroupMemberList() {
const searchValue = String($(this).val()).toLowerCase();
groupMembersFilter.setFilterData(FILTER_TYPES.SEARCH, searchValue);
}
async function createGroup() {
let name = $('#rm_group_chat_name').val().toString();
let allowSelfResponses = !!$('#rm_group_allow_self_responses').prop('checked');
@@ -2424,6 +2467,7 @@ jQuery(() => {
openGroupById(groupId);
});
$('#rm_group_filter').on('input', filterGroupMembers);
$('#rm_group_members_filter').on('input', filterGroupMemberList);
$('#rm_group_submit').on('click', createGroup);
$('#rm_group_scenario').on('click', setCharacterSettingsOverrides);
$('#rm_group_automode').on('input', function () {
+493 -58
View File
@@ -15,7 +15,7 @@ import {
} from '../script.js';
import { FILTER_TYPES, FILTER_STATES, DEFAULT_FILTER_STATE, isFilterState, FilterHelper } from './filters.js';
import { groupCandidatesFilter, groups, selected_group } from './group-chats.js';
import { groupCandidatesFilter, groupMembersFilter, groups, selected_group } from './group-chats.js';
import { download, onlyUnique, parseJsonFile, uuidv4, getSortableDelay, flashHighlight, equalsIgnoreCaseAndAccents, includesIgnoreCaseAndAccents, removeFromArray, getFreeName, debounce, findChar } from './utils.js';
import { power_user } from './power-user.js';
import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
@@ -53,16 +53,113 @@ export {
removeTagFromMap,
};
/** @typedef {import('../script.js').Character} Character */
const CHARACTER_FILTER_SELECTOR = '#rm_characters_block .rm_tag_filter';
const GROUP_FILTER_SELECTOR = '#rm_group_chats_block .rm_tag_filter';
const GROUP_FILTER_SELECTOR = '#rm_group_add_members_header ~ .rm_tag_controls .rm_tag_filter';
const GROUP_MEMBERS_FILTER_SELECTOR = '#rm_group_members_header ~ .rm_tag_controls .rm_tag_filter';
const TAG_TEMPLATE = $('#tag_template .tag');
const FOLDER_TEMPLATE = $('#bogus_folder_template .bogus_folder_select');
const VIEW_TAG_TEMPLATE = $('#tag_view_template .tag_view_item');
/**
* Gets the context information (selector and search input) for a filter helper.
* Used to reduce code duplication when working with different filter contexts.
* @param {FilterHelper} filterHelper - The filter helper instance
* @returns {{selector: string, searchInput: string}|null} Context info or null if unknown
*/
function getFilterContext(filterHelper) {
if (filterHelper === entitiesFilter) {
return {
selector: CHARACTER_FILTER_SELECTOR,
searchInput: '#character_search_bar',
};
} else if (filterHelper === groupCandidatesFilter) {
return {
selector: GROUP_FILTER_SELECTOR,
searchInput: '#rm_group_filter',
};
} else if (filterHelper === groupMembersFilter) {
return {
selector: GROUP_MEMBERS_FILTER_SELECTOR,
searchInput: '#rm_group_members_filter',
};
}
return null;
}
/**
* Get the filter helper for a given list selector.
* @param {string|JQuery<HTMLElement>} listSelector - jQuery selector for the list
* @returns {FilterHelper} The appropriate filter helper instance
*/
function getFilterHelper(listSelector) {
return $(listSelector).is(GROUP_FILTER_SELECTOR) ? groupCandidatesFilter : entitiesFilter;
const $element = typeof listSelector === 'string' ? $(listSelector) : listSelector;
// Check if this filter is in the group members section
if ($element.closest('#currentGroupMembers').length > 0) {
return groupMembersFilter;
}
// Check if this filter is in the group candidates (add members) section
if ($element.closest('#unaddedCharList').length > 0) {
return groupCandidatesFilter;
}
// Default to character list filter
return entitiesFilter;
}
/**
* Checks if the given type is a group context.
* @param {tag_filter_type} type - The filter type to check
* @returns {boolean} True if this is a group context
*/
function isGroupContext(type) {
return [tag_filter_type.group_candidates_list, tag_filter_type.group_members_list].includes(type);
}
/**
* Gets visible character avatars for a group context.
* @param {tag_filter_type} type - The filter type
* @param {object} currentGroup - The current group object
* @returns {string[]} Array of visible character avatars
*/
function getVisibleAvatarsForGroupContext(type, currentGroup) {
if (!currentGroup || !Array.isArray(currentGroup.members)) {
return [];
}
switch (type) {
case tag_filter_type.group_members_list:
return currentGroup.members;
case tag_filter_type.group_candidates_list:
return characters
.filter(c => !currentGroup.members.includes(c.avatar))
.map(c => c.avatar);
default:
console.warn('getVisibleAvatarsForGroupContext got invalid type, expected 1 or 2, got ', type);
return [];
}
}
/**
* Filters actionable tags for group contexts.
* In group contexts, hide GROUP and FOLDER filters but keep Favorites and utility buttons.
* @param {object[]} actionTags - Array of actionable tag objects
* @returns {object[]} Filtered array of actionable tags
*/
function filterActionableTagsForGroupContext(actionTags) {
return actionTags.filter(tag => {
// Always show Favorites
if (tag.id === ACTIONABLE_TAGS.FAV.id) {
return true;
}
// Hide GROUP and FOLDER filters in group contexts (not relevant)
if (tag.id === ACTIONABLE_TAGS.GROUP.id || tag.id === ACTIONABLE_TAGS.FOLDER.id) {
return false;
}
// Show utility buttons (VIEW, HINT, UNFILTER)
return true;
});
}
const ACTIONABLE_FILTER_STORAGE_KEYS = Object.freeze({
@@ -71,12 +168,79 @@ const ACTIONABLE_FILTER_STORAGE_KEYS = Object.freeze({
FOLDER: 'TagFilterState_FOLDER',
});
/**
* Gets the storage key prefix for a filter helper to enable persistence.
* @param {FilterHelper} filterHelper - The filter helper to check
* @returns {string|null} Storage key prefix or null if no persistence
*/
function getFilterStorageKey(filterHelper) {
if (filterHelper === entitiesFilter) {
return 'CharacterList';
} else if (filterHelper === groupCandidatesFilter) {
return 'GroupCandidates';
} else if (filterHelper === groupMembersFilter) {
return 'GroupMembers';
}
return null;
}
/**
* Checks if the given filter helper is the main character list filter.
* @param {FilterHelper} filterHelper - The filter helper to check
* @returns {boolean} True if this is the main character list
*/
function isMainCharacterList(filterHelper) {
return filterHelper === entitiesFilter;
}
/** @enum {number} */
export const tag_filter_type = {
character: 0,
/** @deprecated use `group_candidates_list` instead */
group_member: 1,
group_candidates_list: 1,
group_members_list: 2,
};
/**
* Gets the power_user setting key for tag filter visibility for a given context.
* @param {number} type - The tag_filter_type
* @returns {string} The power_user setting key
*/
function getTagFilterVisibilitySetting(type) {
switch (type) {
case tag_filter_type.character:
return 'show_tag_filters';
case tag_filter_type.group_candidates_list:
return 'show_tag_filters_group_candidates';
case tag_filter_type.group_members_list:
return 'show_tag_filters_group_members';
default:
return 'show_tag_filters';
}
}
/**
* Gets the tag filter visibility state for a given context.
* @param {number} type - The tag_filter_type
* @returns {boolean} Whether tag filters should be shown
*/
function getTagFilterVisibility(type) {
const settingKey = getTagFilterVisibilitySetting(type);
return power_user[settingKey] ?? false;
}
/**
* Sets the tag filter visibility state for a given context.
* @param {number} type - The tag_filter_type
* @param {boolean} visible - Whether tag filters should be shown
*/
function setTagFilterVisibility(type, visible) {
const settingKey = getTagFilterVisibilitySetting(type);
power_user[settingKey] = visible;
saveSettingsDebounced();
}
/** @enum {number} */
export const tag_import_setting = {
ASK: 1,
@@ -93,18 +257,33 @@ export const tag_sort_mode = {
};
/**
* @type {{ FAV: Tag, GROUP: Tag, FOLDER: Tag, VIEW: Tag, HINT: Tag, UNFILTER: Tag }}
* A collection of global actional tags for the filter panel
* */
* A collection of global actionable tags for the filter panel.
*
* Tags with `filter_state` property (FAV, GROUP, FOLDER) maintain persistent state:
* - Each context (character list, group candidates, group members) saves state independently
* - Main character list also maintains tag.filter_state for backward compatibility
*
* Tags without `filter_state` (VIEW, HINT, UNFILTER) are action buttons only.
*/
const ACTIONABLE_TAGS = {
FAV: { id: '1', sort_order: 1, name: 'Show only favorites', color: 'rgba(255, 255, 0, 0.5)', action: filterByFav, icon: 'fa-solid fa-star', class: 'filterByFavorites' },
GROUP: { id: '0', sort_order: 2, name: 'Show only groups', color: 'rgba(100, 100, 100, 0.5)', action: filterByGroups, icon: 'fa-solid fa-users', class: 'filterByGroups' },
FOLDER: { id: '4', sort_order: 3, name: 'Show only folders', color: 'rgba(120, 120, 120, 0.5)', action: filterByFolder, icon: 'fa-solid fa-folder-plus', class: 'filterByFolder' },
FAV: { id: '1', sort_order: 1, name: 'Show only favorites', color: 'rgba(255, 255, 0, 0.5)', filter_state: undefined, action: filterByFav, icon: 'fa-solid fa-star', class: 'filterByFavorites' },
GROUP: { id: '0', sort_order: 2, name: 'Show only groups', color: 'rgba(100, 100, 100, 0.5)', filter_state: undefined, action: filterByGroups, icon: 'fa-solid fa-users', class: 'filterByGroups' },
FOLDER: { id: '4', sort_order: 3, name: 'Show only folders', color: 'rgba(120, 120, 120, 0.5)', filter_state: undefined, action: filterByFolder, icon: 'fa-solid fa-folder-plus', class: 'filterByFolder' },
VIEW: { id: '2', sort_order: 4, name: 'Manage tags', color: 'rgba(150, 100, 100, 0.5)', action: onViewTagsListClick, icon: 'fa-solid fa-gear', class: 'manageTags' },
HINT: { id: '3', sort_order: 5, name: 'Show Tag List', color: 'rgba(150, 100, 100, 0.5)', action: onTagListHintClick, icon: 'fa-solid fa-tags', class: 'showTagList' },
UNFILTER: { id: '5', sort_order: 6, name: 'Clear all filters', action: onClearAllFiltersClick, icon: 'fa-solid fa-filter-circle-xmark', class: 'clearAllFilters' },
};
/**
* Map of tag IDs to their corresponding filter types.
* Used for actionable tags (Favorites, Groups, Folders).
*/
const TAG_ID_TO_FILTER_TYPE = new Map([
[ACTIONABLE_TAGS.FAV.id, FILTER_TYPES.FAV],
[ACTIONABLE_TAGS.GROUP.id, FILTER_TYPES.GROUP],
[ACTIONABLE_TAGS.FOLDER.id, FILTER_TYPES.FOLDER],
]);
/** @type {{[key: string]: Tag}} An optional list of actionables that can be utilized by extensions */
const InListActionable = {
};
@@ -345,15 +524,68 @@ function getTagBlock(tag, entities, hidden = 0, isUseless = false) {
}
/**
* Applies the favorite filter to the character list.
* @param {FilterHelper} _filterHelper Instance of FilterHelper class. Unused since it needs to be applied to both filters.
* Common logic for applying actionable tag filters (Favorites, Groups, Folders).
* Persists state to storage for all filter contexts.
* @param {FilterHelper} filterHelper - Instance of FilterHelper class
* @param {object} tag - The actionable tag object
* @param {string} filterType - The filter type constant
* @param {string} storageKey - The storage key base for persistence
*/
function filterByFav(_filterHelper) {
function applyActionableTagFilter(filterHelper, tag, filterType, storageKey) {
const state = toggleTagThreeState($(this));
ACTIONABLE_TAGS.FAV.filter_state = state;
accountStorage.setItem(ACTIONABLE_FILTER_STORAGE_KEYS.FAV, state);
entitiesFilter.setFilterData(FILTER_TYPES.FAV, state);
groupCandidatesFilter.setFilterData(FILTER_TYPES.FAV, state);
// Persist to storage for all contexts
const storagePrefix = getFilterStorageKey(filterHelper);
if (storagePrefix) {
const contextStorageKey = `${storagePrefix}_${storageKey}`;
accountStorage.setItem(contextStorageKey, state);
}
// Also update global state for main character list (backward compatibility)
if (isMainCharacterList(filterHelper)) {
tag.filter_state = state;
}
// Update the filter helper for the current context
filterHelper.setFilterData(filterType, state);
}
/**
* Determines the filter state for a tag based on context.
* For actionable tags: reads from persisted state via filter helper.
* For regular tags: reads from the filter helper's TAG filter data.
* @param {FilterHelper} filterHelper - The filter helper for the current context
* @param {object} tag - The tag object
* @param {boolean} isFilterActionable - Whether the tag is an actionable filter tag
* @returns {string} The filter state
*/
function determineTagFilterState(filterHelper, tag, isFilterActionable) {
if (isFilterActionable) {
// For actionable tags: read from filter helper (which is loaded from storage)
const filterType = TAG_ID_TO_FILTER_TYPE.get(tag.id) || null;
if (filterType) {
return filterHelper.getFilterData(filterType) || DEFAULT_FILTER_STATE;
}
} else {
// For regular tags: read from the filter helper's TAG filter data
const tagFilterData = filterHelper.getFilterData(FILTER_TYPES.TAG);
if (tagFilterData.excluded.includes(tag.id)) {
return 'EXCLUDED';
}
if (tagFilterData.selected.includes(tag.id)) {
return 'SELECTED';
}
}
return DEFAULT_FILTER_STATE;
}
/**
* Applies the favorite filter to the character list.
* @param {FilterHelper} filterHelper Instance of FilterHelper class.
*/
function filterByFav(filterHelper) {
applyActionableTagFilter.call(this, filterHelper, ACTIONABLE_TAGS.FAV, FILTER_TYPES.FAV, ACTIONABLE_FILTER_STORAGE_KEYS.FAV);
}
/**
@@ -361,10 +593,7 @@ function filterByFav(_filterHelper) {
* @param {FilterHelper} filterHelper Instance of FilterHelper class.
*/
function filterByGroups(filterHelper) {
const state = toggleTagThreeState($(this));
ACTIONABLE_TAGS.GROUP.filter_state = state;
accountStorage.setItem(ACTIONABLE_FILTER_STORAGE_KEYS.GROUP, state);
filterHelper.setFilterData(FILTER_TYPES.GROUP, state);
applyActionableTagFilter.call(this, filterHelper, ACTIONABLE_TAGS.GROUP, FILTER_TYPES.GROUP, ACTIONABLE_FILTER_STORAGE_KEYS.GROUP);
}
/**
@@ -379,10 +608,7 @@ function filterByFolder(filterHelper) {
return;
}
const state = toggleTagThreeState($(this));
ACTIONABLE_TAGS.FOLDER.filter_state = state;
accountStorage.setItem(ACTIONABLE_FILTER_STORAGE_KEYS.FOLDER, state);
filterHelper.setFilterData(FILTER_TYPES.FOLDER, state);
applyActionableTagFilter.call(this, filterHelper, ACTIONABLE_TAGS.FOLDER, FILTER_TYPES.FOLDER, ACTIONABLE_FILTER_STORAGE_KEYS.FOLDER);
}
function loadTagsSettings(settings) {
@@ -942,6 +1168,7 @@ function newTag(tagName) {
* @property {boolean} [isGeneralList=false] - If true, indicates that this is the general list of tags.
* @property {boolean} [skipExistsCheck=false] - If true, the tag gets added even if a tag with the same id already exists.
* @property {boolean} [isCharacterList=false] - If true, indicates that this is the character's list of tags.
* @property {boolean} [isInactive=false] - If true, indicates that the tag is inactive (for styling purposes).
*/
/**
@@ -954,6 +1181,7 @@ function newTag(tagName) {
* @property {function(object): function} [tagActionSelector=undefined] - An optional override for the action property that can be assigned to each tag via tagOptions.
* If set, the selector is executed on each tag as input argument. This allows a list of tags to be provided and each tag can have it's action based on the tag object itself.
* @property {TagOptions} [tagOptions={}] - Options for tag behavior. (Same object will be passed into "appendTagToList")
* @property {string[]} [inactiveTags=[]] - List of tag IDs that are considered inactive (for styling purposes).
*/
/**
@@ -962,7 +1190,7 @@ function newTag(tagName) {
* @param {JQuery<HTMLElement>|string} element - The container element where the tags are to be printed. (Optionally can also be a string selector for the element, which will then be resolved)
* @param {PrintTagListOptions} [options] - Optional parameters for printing the tag list.
*/
function printTagList(element, { tags = undefined, addTag = undefined, forEntityOrKey = undefined, empty = true, sort = true, tagActionSelector = undefined, tagOptions = {} } = {}) {
function printTagList(element, { tags = undefined, addTag = undefined, forEntityOrKey = undefined, empty = true, sort = true, tagActionSelector = undefined, tagOptions = {}, inactiveTags = [] } = {}) {
const $element = (typeof element === 'string') ? $(element) : element;
const key = forEntityOrKey !== undefined ? getTagKeyForEntity(forEntityOrKey) : getTagKey();
let printableTags = tags ? (typeof tags === 'function' ? tags() : tags) : getTagsList(key, sort);
@@ -1018,7 +1246,9 @@ function printTagList(element, { tags = undefined, addTag = undefined, forEntity
// Check if we should print this tag
if (shouldPrintTag(tag) || additionalTagsPrinted++ < availableSlotsForAdditionalTags) {
appendTagToList($element, tag, tagOptions);
// Check if this tag is in the inactive list
const isInactive = inactiveTags.includes(tag.id);
appendTagToList($element, tag, { ...tagOptions, isInactive });
} else {
tagsSkipped++;
}
@@ -1040,7 +1270,7 @@ function printTagList(element, { tags = undefined, addTag = undefined, forEntity
// Do not bubble further, we are just expanding
event.stopPropagation();
printTagList($element, { tags: tags, addTag: addTag, forEntityOrKey: forEntityOrKey, empty: empty, tagActionSelector: tagActionSelector, tagOptions: tagOptions });
printTagList($element, { tags: tags, addTag: addTag, forEntityOrKey: forEntityOrKey, empty: empty, tagActionSelector: tagActionSelector, tagOptions: tagOptions, inactiveTags: inactiveTags });
};
// Print the placeholder object with its styling and action to show the remaining tags
@@ -1061,7 +1291,7 @@ function printTagList(element, { tags = undefined, addTag = undefined, forEntity
* @param {TagOptions} [options={}] - Options for tag behavior
* @returns {void}
*/
function appendTagToList(listElement, tag, { removable = false, isFilter = false, action = undefined, removeAction = undefined, isGeneralList = false, skipExistsCheck = false } = {}) {
function appendTagToList(listElement, tag, { removable = false, isFilter = false, action = undefined, removeAction = undefined, isGeneralList = false, skipExistsCheck = false, isInactive = false } = {}) {
if (!listElement) {
return;
}
@@ -1097,13 +1327,22 @@ function appendTagToList(listElement, tag, { removable = false, isFilter = false
tagElement.find('.tag_name').text('').attr('title', `${translate(tag.name)} ${tag.title || ''}`.trim()).addClass(tag.icon);
tagElement.addClass('actionable');
}
if (isInactive) {
tagElement.addClass('tag-absent');
}
// We could have multiple ways of actions passed in. The manual arguments have precendence in front of a specified tag action
const clickableAction = action ?? tag.action;
// If this is a tag for a general list and its either a filter or actionable, lets mark its current state
if ((isFilter || clickableAction) && isGeneralList) {
toggleTagThreeState(tagElement, { stateOverride: tag.filter_state ?? DEFAULT_FILTER_STATE });
const filterHelper = getFilterHelper($(listElement));
const isFilterActionable = clickableAction && 'filter_state' in tag;
if (isFilter || isFilterActionable) {
const filterState = determineTagFilterState(filterHelper, tag, isFilterActionable);
toggleTagThreeState(tagElement, { stateOverride: filterState });
}
}
if (isFilter) {
@@ -1127,16 +1366,77 @@ function onTagFilterClick(listElement) {
let state = toggleTagThreeState($(this));
if (existingTag) {
const filterHelper = getFilterHelper($(listElement));
// Update the tag's filter_state for the main character list (backward compatibility)
if (existingTag && isMainCharacterList(filterHelper)) {
existingTag.filter_state = state;
saveSettingsDebounced();
}
// We don't print anything manually, updating the filter will automatically trigger a redraw of all relevant stuff
// Persist to storage for all contexts
const storagePrefix = getFilterStorageKey(filterHelper);
if (storagePrefix && existingTag) {
const storageKey = `${storagePrefix}_tag_${tagId}`;
accountStorage.setItem(storageKey, state);
}
// Apply all tag filters by reading from DOM state (this triggers the filter helper update)
runTagFilters(listElement);
// Focus the tag again we were at, if possible. To improve keyboard navigation
setTimeout(() => parent.find(`.tag[id="${tagId}"]`).trigger('focus'), DEFAULT_PRINT_TIMEOUT + 1);
updateTagFilterIndicator(listElement);
}
/**
* Loads persisted filter states for a given filter context.
* @param {FilterHelper} filterHelper - The filter helper instance
* @param {string} storagePrefix - The storage key prefix for this context
*/
function loadFilterStatesForContext(filterHelper, storagePrefix) {
const validStates = new Set(Object.keys(FILTER_STATES));
const readState = (/** @type {string} */ storageKey) => {
const v = accountStorage.getItem(storageKey);
return v && validStates.has(v) ? v : null;
};
// Load actionable tag states (Favorites, Groups, Folders)
const favState = readState(`${storagePrefix}_${ACTIONABLE_FILTER_STORAGE_KEYS.FAV}`);
if (favState) {
filterHelper.setFilterData(FILTER_TYPES.FAV, favState, true);
}
const groupState = readState(`${storagePrefix}_${ACTIONABLE_FILTER_STORAGE_KEYS.GROUP}`);
if (groupState) {
filterHelper.setFilterData(FILTER_TYPES.GROUP, groupState, true);
}
const folderState = readState(`${storagePrefix}_${ACTIONABLE_FILTER_STORAGE_KEYS.FOLDER}`);
if (folderState) {
filterHelper.setFilterData(FILTER_TYPES.FOLDER, folderState, true);
}
// Load regular tag filter states
const tagFilterData = filterHelper.getFilterData(FILTER_TYPES.TAG);
for (const tag of tags) {
const storageKey = `${storagePrefix}_tag_${tag.id}`;
const state = readState(storageKey);
if (state) {
if (state === 'SELECTED') {
if (!tagFilterData.selected.includes(tag.id)) {
tagFilterData.selected.push(tag.id);
}
} else if (state === 'EXCLUDED') {
if (!tagFilterData.excluded.includes(tag.id)) {
tagFilterData.excluded.push(tag.id);
}
}
}
}
filterHelper.setFilterData(FILTER_TYPES.TAG, tagFilterData, true);
}
/**
@@ -1201,20 +1501,77 @@ function runTagFilters(listElement) {
}
function printTagFilters(type = tag_filter_type.character) {
const FILTER_SELECTOR = type === tag_filter_type.character ? CHARACTER_FILTER_SELECTOR : GROUP_FILTER_SELECTOR;
removeMissingTagFilters();
let FILTER_SELECTOR;
switch (type) {
case tag_filter_type.character:
FILTER_SELECTOR = CHARACTER_FILTER_SELECTOR;
break;
case tag_filter_type.group_candidates_list:
FILTER_SELECTOR = GROUP_FILTER_SELECTOR;
break;
case tag_filter_type.group_members_list:
FILTER_SELECTOR = GROUP_MEMBERS_FILTER_SELECTOR;
break;
default:
FILTER_SELECTOR = CHARACTER_FILTER_SELECTOR;
break;
}
$(FILTER_SELECTOR).empty();
// Print all action tags. (Rework 'Folder' button to some kind of onboarding if no folders are enabled yet)
const actionTags = Object.values(ACTIONABLE_TAGS);
let actionTags = Object.values(ACTIONABLE_TAGS);
actionTags.find(x => x == ACTIONABLE_TAGS.FOLDER).name = power_user.bogus_folders ? 'Show only folders' : 'Enable \'Tags as Folder\'\n\nAllows characters to be grouped in folders by their assigned tags.\nTags have to be explicitly chosen as folder to show up.\n\nClick here to start';
// For group contexts, filter actionable tags to only show relevant ones
if (isGroupContext(type)) {
actionTags = filterActionableTagsForGroupContext(actionTags);
}
printTagList($(FILTER_SELECTOR), { empty: false, sort: false, tags: actionTags, tagActionSelector: tag => tag.action, tagOptions: { isGeneralList: true } });
const inListActionTags = Object.values(InListActionable);
printTagList($(FILTER_SELECTOR), { empty: false, sort: false, tags: inListActionTags, tagActionSelector: tag => tag.action, tagOptions: { isGeneralList: true } });
const characterTagIds = Object.values(tag_map).flat();
const tagsToDisplay = tags.filter(x => characterTagIds.includes(x.id)).sort(compareTagsForSort);
printTagList($(FILTER_SELECTOR), { empty: false, tags: tagsToDisplay, tagOptions: { isFilter: true, isGeneralList: true } });
// Determine which character tags to display based on context
let tagsToDisplay;
let inactiveTags = [];
if (isGroupContext(type)) {
// For group contexts, show all tags but mark ones without presence in current context as inactive
// CAUTION: when called by openGroupById, the selected_group variable might not yet be updated
const currentGroup = selected_group ? groups.find(x => x.id == selected_group) : null;
const visibleAvatars = getVisibleAvatarsForGroupContext(type, currentGroup);
if (visibleAvatars.length > 0) {
// Get tags that are assigned to at least one visible character
const activeCharacterTagIds = visibleAvatars
.map(avatar => tag_map[avatar] || [])
.flat()
.filter(onlyUnique);
// Show all tags that exist in the tag_map
const allCharacterTagIds = Object.values(tag_map).flat().filter(onlyUnique);
tagsToDisplay = tags.filter(x => allCharacterTagIds.includes(x.id)).sort(compareTagsForSort);
// Mark tags that are not in the active set as inactive
inactiveTags = tagsToDisplay
.filter(x => !activeCharacterTagIds.includes(x.id))
.map(x => x.id);
} else {
// No group selected, show no tags
tagsToDisplay = [];
}
} else {
// For main character list, show all tags as before
const characterTagIds = Object.values(tag_map).flat();
tagsToDisplay = tags.filter(x => characterTagIds.includes(x.id)).sort(compareTagsForSort);
}
printTagList($(FILTER_SELECTOR), { empty: false, tags: tagsToDisplay, tagOptions: { isFilter: true, isGeneralList: true }, inactiveTags: inactiveTags });
// Print bogus folder navigation
const bogusDrilldown = $(FILTER_SELECTOR).siblings('.rm_tag_bogus_drilldown');
@@ -1224,22 +1581,36 @@ function printTagFilters(type = tag_filter_type.character) {
printTagList(bogusDrilldown, { tags: navigatedTags, tagOptions: { removable: true } });
}
runTagFilters(FILTER_SELECTOR);
// Don't call runTagFilters here - it would overwrite the loaded filter states with the DOM state.
// The visual state (CSS classes) already matches the filter helper state set by loadFilterStatesForContext.
// runTagFilters is only needed when user clicks a tag (handled in onTagFilterClick).
if (power_user.show_tag_filters) {
$('.rm_tag_controls .showTagList').addClass('selected');
$('.rm_tag_controls').find('.tag:not(.actionable)').show();
// Initialize the tag list visibility based on saved settings for this context
const shouldShowTags = getTagFilterVisibility(type);
const showTagListButton = $(FILTER_SELECTOR).closest('.rm_tag_controls').find('.showTagList');
// Update button state to match the saved setting
showTagListButton.toggleClass('selected', shouldShowTags);
if (shouldShowTags) {
$(FILTER_SELECTOR).find('.tag:not(.actionable)').show();
} else {
$(FILTER_SELECTOR).find('.tag:not(.actionable)').hide();
}
updateTagFilterIndicator();
updateTagFilterIndicator(FILTER_SELECTOR);
}
function updateTagFilterIndicator() {
if ($('.rm_tag_controls').find('.tag:not(.actionable)').is('.selected, .excluded')) {
$('.rm_tag_controls .showTagList').addClass('indicator');
} else {
$('.rm_tag_controls .showTagList').removeClass('indicator');
}
/**
* Updates the tag filter indicator based on the selected/excluded tags in the given filter selector
* @param {string|JQuery<HTMLElement>} filterSelector - The selector or jQuery element for the tag filter container
*/
function updateTagFilterIndicator(filterSelector) {
const selector = filterSelector || CHARACTER_FILTER_SELECTOR;
const tagFilter = typeof selector === 'string' ? $(selector) : selector;
const showTagListButton = tagFilter.closest('.rm_tag_controls').find('.showTagList');
const hasActiveTags = tagFilter.find('.tag:not(.actionable)').is('.selected, .excluded');
showTagListButton.toggleClass('indicator', hasActiveTags);
}
function onTagRemoveClick(event) {
@@ -1316,6 +1687,8 @@ export function applyTagsOnGroupSelect(groupId = null) {
groupId = groupId ?? (selected_group ? Number(selected_group) : undefined);
printTagList($('#groupTagList'), { forEntityOrKey: groupId, tagOptions: { removable: true } });
printTagFilters(tag_filter_type.group_candidates_list);
printTagFilters(tag_filter_type.group_members_list);
}
/**
@@ -1841,17 +2214,39 @@ function onTagListHintClick() {
}
$(this).siblings('.innerActionable').toggleClass('hidden');
power_user.show_tag_filters = $(this).hasClass('selected');
saveSettingsDebounced();
console.debug('show_tag_filters', power_user.show_tag_filters);
// Determine which context this button belongs to and save the setting
let filterType = tag_filter_type.character;
// Check which section we're in by looking at the sibling header
const $tagControls = $(this).closest('.rm_tag_controls');
if ($tagControls.prev().is('#rm_group_add_members_header')) {
filterType = tag_filter_type.group_candidates_list;
} else if ($tagControls.prev().is('#rm_group_members_header')) {
filterType = tag_filter_type.group_members_list;
}
const isSelected = $(this).hasClass('selected');
setTagFilterVisibility(filterType, isSelected);
console.debug('show_tag_filters for type', filterType, ':', isSelected);
}
function onClearAllFiltersClick() {
/**
* Clears all filters for the current list context.
* @param {FilterHelper} filterHelper - The filter helper for the current context
*/
function onClearAllFiltersClick(filterHelper) {
console.debug('clear all filters clicked');
const context = getFilterContext(filterHelper);
if (!context) {
console.warn('Unknown filter helper in onClearAllFiltersClick');
return;
}
// We have to manually go through the elements and unfilter by clicking...
// Thankfully nearly all filter controls are three-state-toggles
const filterTags = $('.rm_tag_controls .rm_tag_filter').find('.tag');
const filterTags = $(context.selector).find('.tag');
for (const tag of filterTags) {
const toggleState = $(tag).attr('data-toggle-state');
if (toggleState !== undefined && !isFilterState(toggleState ?? FILTER_STATES.UNDEFINED, FILTER_STATES.UNDEFINED)) {
@@ -1859,8 +2254,8 @@ function onClearAllFiltersClick() {
}
}
// Reset search too
$('#character_search_bar').val('').trigger('input');
// Reset search input for this context
$(context.searchInput).val('').trigger('input');
}
/**
@@ -1891,6 +2286,37 @@ function printViewTagList(tagContainer, empty = true) {
}
}
function removeMissingTagFilters() {
const tagIds = new Set(tags.map(tag => tag.id));
for (const helper of [groupCandidatesFilter, groupMembersFilter, entitiesFilter]) {
const { selected, excluded } = helper.getFilterData(FILTER_TYPES.TAG);
let anyRemoved = false;
if (Array.isArray(selected)) {
for (let i = selected.length - 1; i >= 0; i--) {
if (!tagIds.has(selected[i])) {
selected.splice(i, 1);
anyRemoved = true;
}
}
}
if (Array.isArray(excluded)) {
for (let i = excluded.length - 1; i >= 0; i--) {
if (!tagIds.has(excluded[i])) {
excluded.splice(i, 1);
anyRemoved = true;
}
}
}
if (anyRemoved) {
helper.setFilterData(FILTER_TYPES.TAG, { selected, excluded });
}
}
}
function registerTagsSlashCommands() {
/**
* Gets a tag by its name. Optionally can create the tag if it does not exist.
@@ -2237,7 +2663,8 @@ function normalizeTagName(name) {
.toLowerCase();
}
/** Extracts the character avatar file name from the avatar source URL.
/**
* Extracts the character avatar file name from the avatar source URL.
* @param {string} avatarSrc The source URL of the character avatar.
* @returns {string|null} The normalized avatar file name, or null if the input is falsy or doesn't contain a valid file name.
*/
@@ -2270,8 +2697,12 @@ function restoreSavedTagFilters() {
if (favState) {
ACTIONABLE_TAGS.FAV.filter_state = favState;
entitiesFilter.setFilterData(FILTER_TYPES.FAV, favState, true);
groupCandidatesFilter.setFilterData(FILTER_TYPES.FAV, favState, true);
}
// Load persisted filter states for all contexts (including character list)
loadFilterStatesForContext(entitiesFilter, 'CharacterList');
loadFilterStatesForContext(groupCandidatesFilter, 'GroupCandidates');
loadFilterStatesForContext(groupMembersFilter, 'GroupMembers');
if (groupState) {
ACTIONABLE_TAGS.GROUP.filter_state = groupState;
entitiesFilter.setFilterData(FILTER_TYPES.GROUP, groupState, true);
@@ -2280,6 +2711,10 @@ function restoreSavedTagFilters() {
ACTIONABLE_TAGS.FOLDER.filter_state = folderState;
entitiesFilter.setFilterData(FILTER_TYPES.FOLDER, folderState, true);
}
// Note: Regular tag filter states are now loaded from storage via loadFilterStatesForContext()
// The old tag.filter_state property is only maintained for backward compatibility with
// the main character list's actionable tags (Favorites, Groups, Folders)
} catch (e) {
console.warn('Failed to restore actionable filter states from account storage', e);
}