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>

b418ec5c37cf03c47d1807df54072500c2d91034

Jeff Sandberg <jeff@pdx.su>

Signed
5 files changed, +569 -79Ignore whitespace
public/css/tags.css+4 -0
@@ -67,6 +67,10 @@
67 display: none;67 display: none;
68}68}
6969
70.tag.tag-absent {
71 text-decoration: line-through;
72}
73
70.tag.actionable {74.tag.actionable {
71 border-radius: 50%;75 border-radius: 50%;
72 aspect-ratio: 1 / 1;76 aspect-ratio: 1 / 1;
public/index.html+7 -1
@@ -6126,6 +6126,12 @@
6126 </div>6126 </div>
6127 <div class="inline-drawer-content">6127 <div class="inline-drawer-content">
6128 <div id="currentGroupMembers" name="Current Group Members" class="flex-container flexFlowColumn overflowYAuto flex1">6128 <div id="currentGroupMembers" name="Current Group Members" class="flex-container flexFlowColumn overflowYAuto flex1">
6129 <div id="rm_group_members_header">
6130 <input id="rm_group_members_filter" class="text_pole margin0" type="search" data-i18n="[placeholder]Search..." placeholder="Search..." />
6131 </div>
6132 <div class="rm_tag_controls">
6133 <div class="tags rm_tag_filter"></div>
6134 </div>
6129 <div id="rm_group_members_pagination" class="rm_group_members_pagination group_pagination"></div>6135 <div id="rm_group_members_pagination" class="rm_group_members_pagination group_pagination"></div>
6130 <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>6136 <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>
6131 </div>6137 </div>
@@ -6137,7 +6143,7 @@
6137 <div class="fa-solid fa-circle-chevron-down inline-drawer-icon down"></div>6143 <div class="fa-solid fa-circle-chevron-down inline-drawer-icon down"></div>
6138 </div>6144 </div>
6139 <div class="inline-drawer-content">6145 <div class="inline-drawer-content">
6140 <div name="Unadded Char List" class="flex-container flexFlowColumn overflowYAuto flex1">6146 <div id="unaddedCharList" name="Unadded Char List" class="flex-container flexFlowColumn overflowYAuto flex1">
6141 <div id="rm_group_add_members_header">6147 <div id="rm_group_add_members_header">
6142 <input id="rm_group_filter" class="text_pole margin0" type="search" data-i18n="[placeholder]Search..." placeholder="Search..." />6148 <input id="rm_group_filter" class="text_pole margin0" type="search" data-i18n="[placeholder]Search..." placeholder="Search..." />
6143 </div>6149 </div>
public/script.js+2 -1
@@ -953,7 +953,8 @@ export async function printCharacters(fullRefresh = false) {
953953
954 // We are actually always reprinting filters, as it "doesn't hurt", and this way they are always up to date954 // We are actually always reprinting filters, as it "doesn't hurt", and this way they are always up to date
955 printTagFilters(tag_filter_type.character);955 printTagFilters(tag_filter_type.character);
956 printTagFilters(tag_filter_type.group_member);956 printTagFilters(tag_filter_type.group_members_list);
957 printTagFilters(tag_filter_type.group_candidates_list);
957958
958 // We are also always reprinting the lists on character/group edit window, as these ones doesn't get updated otherwise959 // We are also always reprinting the lists on character/group edit window, as these ones doesn't get updated otherwise
959 applyTagsOnCharacterSelect();960 applyTagsOnCharacterSelect();
public/scripts/group-chats.js+63 -19
@@ -80,7 +80,7 @@ import {
80 chatElement,80 chatElement,
81 ensureMessageMediaIsArray,81 ensureMessageMediaIsArray,
82} from '../script.js';82} from '../script.js';
83import { printTagList, createTagMapFromList, applyTagsOnCharacterSelect, tag_map, applyTagsOnGroupSelect } from './tags.js';83import { printTagList, createTagMapFromList, applyTagsOnCharacterSelect, tag_map, applyTagsOnGroupSelect, printTagFilters, tag_filter_type } from './tags.js';
84import { FILTER_TYPES, FilterHelper } from './filters.js';84import { FILTER_TYPES, FilterHelper } from './filters.js';
85import { isExternalMediaAllowed } from './chats.js';85import { isExternalMediaAllowed } from './chats.js';
86import { POPUP_TYPE, Popup, callGenericPopup } from './popup.js';86import { POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
@@ -134,6 +134,7 @@ export const group_generation_mode = {
134export const DEFAULT_AUTO_MODE_DELAY = 5;134export const DEFAULT_AUTO_MODE_DELAY = 5;
135135
136export const groupCandidatesFilter = new FilterHelper(debounce(printGroupCandidates, debounce_timeout.quick));136export const groupCandidatesFilter = new FilterHelper(debounce(printGroupCandidates, debounce_timeout.quick));
137export const groupMembersFilter = new FilterHelper(debounce(printGroupMembers, debounce_timeout.quick));
137let autoModeWorker = null;138let autoModeWorker = null;
138const saveGroupDebounced = debounce(async (group, reload) => await _save(group, reload), debounce_timeout.relaxed);139const saveGroupDebounced = debounce(async (group, reload) => await _save(group, reload), debounce_timeout.relaxed);
139/** @type {Map<string, number>} */140/** @type {Map<string, number>} */
@@ -1449,6 +1450,10 @@ async function modifyGroupMember(groupId, groupMember, isDelete) {
1449 printGroupCandidates();1450 printGroupCandidates();
1450 printGroupMembers();1451 printGroupMembers();
14511452
1453 // Refresh the tag filters for both lists to reflect any new tags
1454 printTagFilters(tag_filter_type.group_candidates_list);
1455 printTagFilters(tag_filter_type.group_members_list);
1456
1452 const groupHasMembers = getGroupCharacters({ doFilter: false, onlyMembers: true }).length > 0;1457 const groupHasMembers = getGroupCharacters({ doFilter: false, onlyMembers: true }).length > 0;
1453 $('#rm_group_submit').prop('disabled', !groupHasMembers);1458 $('#rm_group_submit').prop('disabled', !groupHasMembers);
1454}1459}
@@ -1557,31 +1562,63 @@ function isGroupMember(group, avatarId) {
1557 * @returns {Array<{item: Character, id: number, type: string}>} Array of group character objects1562 * @returns {Array<{item: Character, id: number, type: string}>} Array of group character objects
1558 */1563 */
1559function getGroupCharacters({ doFilter = false, onlyMembers = false } = {}) {1564function getGroupCharacters({ doFilter = false, onlyMembers = false } = {}) {
1560 function sortMembersFn(a, b) {1565 function applyFilterAndSort(results, filter, filterSelector) {
1566 let filtered = results;
1567 if (doFilter) {
1568 filtered = filter.applyFilters(filtered);
1569 }
1570 const useFilterOrder = doFilter && !!$(filterSelector).val();
1571 sortEntitiesList(filtered, useFilterOrder, filter);
1572 filter.clearFuzzySearchCaches();
1573 return filtered;
1574 }
1575
1576 function handleMembers(results, thisGroup) {
1561 const membersArray = thisGroup?.members ?? newGroupMembers;1577 const membersArray = thisGroup?.members ?? newGroupMembers;
1562 const aIndex = membersArray.indexOf(a.item.avatar);1578
1563 const bIndex = membersArray.indexOf(b.item.avatar);1579 // Create index map for O(1) lookups in member sort function
1564 return aIndex - bIndex;1580 // (separate from characterIndexMap which maps character objects to their array indices)
1581 const memberIndexMap = new Map(membersArray.map((avatar, index) => [avatar, index]));
1582
1583 function sortMembersFn(a, b) {
1584 const aIndex = memberIndexMap.get(a.item.avatar) ?? -1;
1585 const bIndex = memberIndexMap.get(b.item.avatar) ?? -1;
1586 return aIndex - bIndex;
1587 }
1588
1589 // Apply manual member sort before filter and sort
1590 let filtered = results;
1591 if (doFilter) {
1592 filtered = groupMembersFilter.applyFilters(filtered);
1593 }
1594 filtered.sort(sortMembersFn);
1595
1596 // Apply conditional filter-based sort and cleanup
1597 const useFilterOrder = doFilter && !!$('#rm_group_members_filter').val();
1598 if (useFilterOrder) {
1599 sortEntitiesList(filtered, useFilterOrder, groupMembersFilter);
1600 }
1601 groupMembersFilter.clearFuzzySearchCaches();
1602 return filtered;
1565 }1603 }
15661604
1567 const thisGroup = openGroupId && groups.find((x) => x.id == openGroupId);1605 const thisGroup = openGroupId && groups.find((x) => x.id == openGroupId);
1568 let candidates = characters
1569 .filter((x) => isGroupMember(thisGroup, x.avatar) == onlyMembers)
1570 .map((x) => ({ item: x, id: characters.indexOf(x), type: 'character' }));
15711606
1572 if (doFilter) {1607 // Create index map for O(1) lookups when mapping characters to their array indices
1573 candidates = groupCandidatesFilter.applyFilters(candidates);1608 // (separate from memberIndexMap used later for sorting members by their group order)
1574 }1609 const characterIndexMap = new Map(characters.map((char, index) => [char, index]));
15751610
1576 if (onlyMembers) {1611 const results = characters
1577 candidates.sort(sortMembersFn);1612 .filter((x) => isGroupMember(thisGroup, x.avatar) == onlyMembers)
1578 } else {1613 .map((x) => ({ item: x, id: characterIndexMap.get(x), type: 'character' }));
1579 const useFilterOrder = doFilter && !!$('#rm_group_filter').val();1614
1580 sortEntitiesList(candidates, useFilterOrder, groupCandidatesFilter);1615 // Early return for candidates (non-members)
1616 if (!onlyMembers) {
1617 return applyFilterAndSort(results, groupCandidatesFilter, '#rm_group_filter');
1581 }1618 }
15821619
1583 groupCandidatesFilter.clearFuzzySearchCaches();1620 // Handle members with manual sort capability
1584 return candidates;1621 return handleMembers(results, thisGroup);
1585}1622}
15861623
1587function printGroupCandidates() {1624function printGroupCandidates() {
@@ -1621,7 +1658,7 @@ function printGroupMembers() {
1621 const pageSize = Number(accountStorage.getItem(storageKey)) || 5;1658 const pageSize = Number(accountStorage.getItem(storageKey)) || 5;
1622 const sizeChangerOptions = [5, 10, 25, 50, 100, 200, 500, 1000];1659 const sizeChangerOptions = [5, 10, 25, 50, 100, 200, 500, 1000];
1623 $(this).pagination({1660 $(this).pagination({
1624 dataSource: getGroupCharacters({ doFilter: false, onlyMembers: true }),1661 dataSource: getGroupCharacters({ doFilter: true, onlyMembers: true }),
1625 pageRange: 1,1662 pageRange: 1,
1626 position: 'top',1663 position: 'top',
1627 showPageNumbers: false,1664 showPageNumbers: false,
@@ -1782,6 +1819,7 @@ function select_group_chats(groupId, skipAnimation) {
1782 $('#group_avatar_preview').empty().append(getGroupAvatar(group));1819 $('#group_avatar_preview').empty().append(getGroupAvatar(group));
1783 $('#rm_group_restore_avatar').toggle(!!group && isValidImageUrl(group.avatar_url));1820 $('#rm_group_restore_avatar').toggle(!!group && isValidImageUrl(group.avatar_url));
1784 $('#rm_group_filter').val('').trigger('input');1821 $('#rm_group_filter').val('').trigger('input');
1822 $('#rm_group_members_filter').val('').trigger('input');
1785 $('#rm_group_activation_strategy').val(replyStrategy);1823 $('#rm_group_activation_strategy').val(replyStrategy);
1786 $(`#rm_group_activation_strategy option[value="${replyStrategy}"]`).prop('selected', true);1824 $(`#rm_group_activation_strategy option[value="${replyStrategy}"]`).prop('selected', true);
1787 $('#rm_group_generation_mode').val(generationMode);1825 $('#rm_group_generation_mode').val(generationMode);
@@ -2049,6 +2087,11 @@ function filterGroupMembers() {
2049 groupCandidatesFilter.setFilterData(FILTER_TYPES.SEARCH, searchValue);2087 groupCandidatesFilter.setFilterData(FILTER_TYPES.SEARCH, searchValue);
2050}2088}
20512089
2090function filterGroupMemberList() {
2091 const searchValue = String($(this).val()).toLowerCase();
2092 groupMembersFilter.setFilterData(FILTER_TYPES.SEARCH, searchValue);
2093}
2094
2052async function createGroup() {2095async function createGroup() {
2053 let name = $('#rm_group_chat_name').val().toString();2096 let name = $('#rm_group_chat_name').val().toString();
2054 let allowSelfResponses = !!$('#rm_group_allow_self_responses').prop('checked');2097 let allowSelfResponses = !!$('#rm_group_allow_self_responses').prop('checked');
@@ -2424,6 +2467,7 @@ jQuery(() => {
2424 openGroupById(groupId);2467 openGroupById(groupId);
2425 });2468 });
2426 $('#rm_group_filter').on('input', filterGroupMembers);2469 $('#rm_group_filter').on('input', filterGroupMembers);
2470 $('#rm_group_members_filter').on('input', filterGroupMemberList);
2427 $('#rm_group_submit').on('click', createGroup);2471 $('#rm_group_submit').on('click', createGroup);
2428 $('#rm_group_scenario').on('click', setCharacterSettingsOverrides);2472 $('#rm_group_scenario').on('click', setCharacterSettingsOverrides);
2429 $('#rm_group_automode').on('input', function () {2473 $('#rm_group_automode').on('input', function () {
public/scripts/tags.js+493 -58
@@ -15,7 +15,7 @@ import {
15} from '../script.js';15} from '../script.js';
16import { FILTER_TYPES, FILTER_STATES, DEFAULT_FILTER_STATE, isFilterState, FilterHelper } from './filters.js';16import { FILTER_TYPES, FILTER_STATES, DEFAULT_FILTER_STATE, isFilterState, FilterHelper } from './filters.js';
1717
18import { groupCandidatesFilter, groups, selected_group } from './group-chats.js';18import { groupCandidatesFilter, groupMembersFilter, groups, selected_group } from './group-chats.js';
19import { download, onlyUnique, parseJsonFile, uuidv4, getSortableDelay, flashHighlight, equalsIgnoreCaseAndAccents, includesIgnoreCaseAndAccents, removeFromArray, getFreeName, debounce, findChar } from './utils.js';19import { download, onlyUnique, parseJsonFile, uuidv4, getSortableDelay, flashHighlight, equalsIgnoreCaseAndAccents, includesIgnoreCaseAndAccents, removeFromArray, getFreeName, debounce, findChar } from './utils.js';
20import { power_user } from './power-user.js';20import { power_user } from './power-user.js';
21import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';21import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
@@ -53,16 +53,113 @@ export {
53 removeTagFromMap,53 removeTagFromMap,
54};54};
5555
56/** @typedef {import('../script.js').Character} Character */
57
58const CHARACTER_FILTER_SELECTOR = '#rm_characters_block .rm_tag_filter';56const CHARACTER_FILTER_SELECTOR = '#rm_characters_block .rm_tag_filter';
59const GROUP_FILTER_SELECTOR = '#rm_group_chats_block .rm_tag_filter';57const GROUP_FILTER_SELECTOR = '#rm_group_add_members_header ~ .rm_tag_controls .rm_tag_filter';
58const GROUP_MEMBERS_FILTER_SELECTOR = '#rm_group_members_header ~ .rm_tag_controls .rm_tag_filter';
60const TAG_TEMPLATE = $('#tag_template .tag');59const TAG_TEMPLATE = $('#tag_template .tag');
61const FOLDER_TEMPLATE = $('#bogus_folder_template .bogus_folder_select');60const FOLDER_TEMPLATE = $('#bogus_folder_template .bogus_folder_select');
62const VIEW_TAG_TEMPLATE = $('#tag_view_template .tag_view_item');61const VIEW_TAG_TEMPLATE = $('#tag_view_template .tag_view_item');
6362
63/**
64 * Gets the context information (selector and search input) for a filter helper.
65 * Used to reduce code duplication when working with different filter contexts.
66 * @param {FilterHelper} filterHelper - The filter helper instance
67 * @returns {{selector: string, searchInput: string}|null} Context info or null if unknown
68 */
69function getFilterContext(filterHelper) {
70 if (filterHelper === entitiesFilter) {
71 return {
72 selector: CHARACTER_FILTER_SELECTOR,
73 searchInput: '#character_search_bar',
74 };
75 } else if (filterHelper === groupCandidatesFilter) {
76 return {
77 selector: GROUP_FILTER_SELECTOR,
78 searchInput: '#rm_group_filter',
79 };
80 } else if (filterHelper === groupMembersFilter) {
81 return {
82 selector: GROUP_MEMBERS_FILTER_SELECTOR,
83 searchInput: '#rm_group_members_filter',
84 };
85 }
86 return null;
87}
88
89/**
90 * Get the filter helper for a given list selector.
91 * @param {string|JQuery<HTMLElement>} listSelector - jQuery selector for the list
92 * @returns {FilterHelper} The appropriate filter helper instance
93 */
64function getFilterHelper(listSelector) {94function getFilterHelper(listSelector) {
65 return $(listSelector).is(GROUP_FILTER_SELECTOR) ? groupCandidatesFilter : entitiesFilter;95 const $element = typeof listSelector === 'string' ? $(listSelector) : listSelector;
96
97 // Check if this filter is in the group members section
98 if ($element.closest('#currentGroupMembers').length > 0) {
99 return groupMembersFilter;
100 }
101
102 // Check if this filter is in the group candidates (add members) section
103 if ($element.closest('#unaddedCharList').length > 0) {
104 return groupCandidatesFilter;
105 }
106
107 // Default to character list filter
108 return entitiesFilter;
109}
110
111/**
112 * Checks if the given type is a group context.
113 * @param {tag_filter_type} type - The filter type to check
114 * @returns {boolean} True if this is a group context
115 */
116function isGroupContext(type) {
117 return [tag_filter_type.group_candidates_list, tag_filter_type.group_members_list].includes(type);
118}
119
120/**
121 * Gets visible character avatars for a group context.
122 * @param {tag_filter_type} type - The filter type
123 * @param {object} currentGroup - The current group object
124 * @returns {string[]} Array of visible character avatars
125 */
126function getVisibleAvatarsForGroupContext(type, currentGroup) {
127 if (!currentGroup || !Array.isArray(currentGroup.members)) {
128 return [];
129 }
130
131 switch (type) {
132 case tag_filter_type.group_members_list:
133 return currentGroup.members;
134 case tag_filter_type.group_candidates_list:
135 return characters
136 .filter(c => !currentGroup.members.includes(c.avatar))
137 .map(c => c.avatar);
138 default:
139 console.warn('getVisibleAvatarsForGroupContext got invalid type, expected 1 or 2, got ', type);
140 return [];
141 }
142}
143
144/**
145 * Filters actionable tags for group contexts.
146 * In group contexts, hide GROUP and FOLDER filters but keep Favorites and utility buttons.
147 * @param {object[]} actionTags - Array of actionable tag objects
148 * @returns {object[]} Filtered array of actionable tags
149 */
150function filterActionableTagsForGroupContext(actionTags) {
151 return actionTags.filter(tag => {
152 // Always show Favorites
153 if (tag.id === ACTIONABLE_TAGS.FAV.id) {
154 return true;
155 }
156 // Hide GROUP and FOLDER filters in group contexts (not relevant)
157 if (tag.id === ACTIONABLE_TAGS.GROUP.id || tag.id === ACTIONABLE_TAGS.FOLDER.id) {
158 return false;
159 }
160 // Show utility buttons (VIEW, HINT, UNFILTER)
161 return true;
162 });
66}163}
67164
68const ACTIONABLE_FILTER_STORAGE_KEYS = Object.freeze({165const ACTIONABLE_FILTER_STORAGE_KEYS = Object.freeze({
@@ -71,12 +168,79 @@ const ACTIONABLE_FILTER_STORAGE_KEYS = Object.freeze({
71 FOLDER: 'TagFilterState_FOLDER',168 FOLDER: 'TagFilterState_FOLDER',
72});169});
73170
171/**
172 * Gets the storage key prefix for a filter helper to enable persistence.
173 * @param {FilterHelper} filterHelper - The filter helper to check
174 * @returns {string|null} Storage key prefix or null if no persistence
175 */
176function getFilterStorageKey(filterHelper) {
177 if (filterHelper === entitiesFilter) {
178 return 'CharacterList';
179 } else if (filterHelper === groupCandidatesFilter) {
180 return 'GroupCandidates';
181 } else if (filterHelper === groupMembersFilter) {
182 return 'GroupMembers';
183 }
184 return null;
185}
186
187/**
188 * Checks if the given filter helper is the main character list filter.
189 * @param {FilterHelper} filterHelper - The filter helper to check
190 * @returns {boolean} True if this is the main character list
191 */
192function isMainCharacterList(filterHelper) {
193 return filterHelper === entitiesFilter;
194}
195
74/** @enum {number} */196/** @enum {number} */
75export const tag_filter_type = {197export const tag_filter_type = {
76 character: 0,198 character: 0,
199 /** @deprecated use `group_candidates_list` instead */
77 group_member: 1,200 group_member: 1,
201 group_candidates_list: 1,
202 group_members_list: 2,
78};203};
79204
205/**
206 * Gets the power_user setting key for tag filter visibility for a given context.
207 * @param {number} type - The tag_filter_type
208 * @returns {string} The power_user setting key
209 */
210function getTagFilterVisibilitySetting(type) {
211 switch (type) {
212 case tag_filter_type.character:
213 return 'show_tag_filters';
214 case tag_filter_type.group_candidates_list:
215 return 'show_tag_filters_group_candidates';
216 case tag_filter_type.group_members_list:
217 return 'show_tag_filters_group_members';
218 default:
219 return 'show_tag_filters';
220 }
221}
222
223/**
224 * Gets the tag filter visibility state for a given context.
225 * @param {number} type - The tag_filter_type
226 * @returns {boolean} Whether tag filters should be shown
227 */
228function getTagFilterVisibility(type) {
229 const settingKey = getTagFilterVisibilitySetting(type);
230 return power_user[settingKey] ?? false;
231}
232
233/**
234 * Sets the tag filter visibility state for a given context.
235 * @param {number} type - The tag_filter_type
236 * @param {boolean} visible - Whether tag filters should be shown
237 */
238function setTagFilterVisibility(type, visible) {
239 const settingKey = getTagFilterVisibilitySetting(type);
240 power_user[settingKey] = visible;
241 saveSettingsDebounced();
242}
243
80/** @enum {number} */244/** @enum {number} */
81export const tag_import_setting = {245export const tag_import_setting = {
82 ASK: 1,246 ASK: 1,
@@ -93,18 +257,33 @@ export const tag_sort_mode = {
93};257};
94258
95/**259/**
96 * @type {{ FAV: Tag, GROUP: Tag, FOLDER: Tag, VIEW: Tag, HINT: Tag, UNFILTER: Tag }}260 * A collection of global actionable tags for the filter panel.
97 * A collection of global actional tags for the filter panel261 *
98 * */262 * Tags with `filter_state` property (FAV, GROUP, FOLDER) maintain persistent state:
263 * - Each context (character list, group candidates, group members) saves state independently
264 * - Main character list also maintains tag.filter_state for backward compatibility
265 *
266 * Tags without `filter_state` (VIEW, HINT, UNFILTER) are action buttons only.
267 */
99const ACTIONABLE_TAGS = {268const ACTIONABLE_TAGS = {
100 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' },269 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' },
101 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' },270 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' },
102 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' },271 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' },
103 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' },272 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' },
104 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' },273 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' },
105 UNFILTER: { id: '5', sort_order: 6, name: 'Clear all filters', action: onClearAllFiltersClick, icon: 'fa-solid fa-filter-circle-xmark', class: 'clearAllFilters' },274 UNFILTER: { id: '5', sort_order: 6, name: 'Clear all filters', action: onClearAllFiltersClick, icon: 'fa-solid fa-filter-circle-xmark', class: 'clearAllFilters' },
106};275};
107276
277/**
278 * Map of tag IDs to their corresponding filter types.
279 * Used for actionable tags (Favorites, Groups, Folders).
280 */
281const TAG_ID_TO_FILTER_TYPE = new Map([
282 [ACTIONABLE_TAGS.FAV.id, FILTER_TYPES.FAV],
283 [ACTIONABLE_TAGS.GROUP.id, FILTER_TYPES.GROUP],
284 [ACTIONABLE_TAGS.FOLDER.id, FILTER_TYPES.FOLDER],
285]);
286
108/** @type {{[key: string]: Tag}} An optional list of actionables that can be utilized by extensions */287/** @type {{[key: string]: Tag}} An optional list of actionables that can be utilized by extensions */
109const InListActionable = {288const InListActionable = {
110};289};
@@ -345,15 +524,68 @@ function getTagBlock(tag, entities, hidden = 0, isUseless = false) {
345}524}
346525
347/**526/**
348 * Applies the favorite filter to the character list.527 * Common logic for applying actionable tag filters (Favorites, Groups, Folders).
349 * @param {FilterHelper} _filterHelper Instance of FilterHelper class. Unused since it needs to be applied to both filters.528 * Persists state to storage for all filter contexts.
529 * @param {FilterHelper} filterHelper - Instance of FilterHelper class
530 * @param {object} tag - The actionable tag object
531 * @param {string} filterType - The filter type constant
532 * @param {string} storageKey - The storage key base for persistence
350 */533 */
351function filterByFav(_filterHelper) {534function applyActionableTagFilter(filterHelper, tag, filterType, storageKey) {
352 const state = toggleTagThreeState($(this));535 const state = toggleTagThreeState($(this));
353 ACTIONABLE_TAGS.FAV.filter_state = state;536
354 accountStorage.setItem(ACTIONABLE_FILTER_STORAGE_KEYS.FAV, state);537 // Persist to storage for all contexts
355 entitiesFilter.setFilterData(FILTER_TYPES.FAV, state);538 const storagePrefix = getFilterStorageKey(filterHelper);
356 groupCandidatesFilter.setFilterData(FILTER_TYPES.FAV, state);539 if (storagePrefix) {
540 const contextStorageKey = `${storagePrefix}_${storageKey}`;
541 accountStorage.setItem(contextStorageKey, state);
542 }
543
544 // Also update global state for main character list (backward compatibility)
545 if (isMainCharacterList(filterHelper)) {
546 tag.filter_state = state;
547 }
548
549 // Update the filter helper for the current context
550 filterHelper.setFilterData(filterType, state);
551}
552
553/**
554 * Determines the filter state for a tag based on context.
555 * For actionable tags: reads from persisted state via filter helper.
556 * For regular tags: reads from the filter helper's TAG filter data.
557 * @param {FilterHelper} filterHelper - The filter helper for the current context
558 * @param {object} tag - The tag object
559 * @param {boolean} isFilterActionable - Whether the tag is an actionable filter tag
560 * @returns {string} The filter state
561 */
562function determineTagFilterState(filterHelper, tag, isFilterActionable) {
563 if (isFilterActionable) {
564 // For actionable tags: read from filter helper (which is loaded from storage)
565 const filterType = TAG_ID_TO_FILTER_TYPE.get(tag.id) || null;
566 if (filterType) {
567 return filterHelper.getFilterData(filterType) || DEFAULT_FILTER_STATE;
568 }
569 } else {
570 // For regular tags: read from the filter helper's TAG filter data
571 const tagFilterData = filterHelper.getFilterData(FILTER_TYPES.TAG);
572 if (tagFilterData.excluded.includes(tag.id)) {
573 return 'EXCLUDED';
574 }
575 if (tagFilterData.selected.includes(tag.id)) {
576 return 'SELECTED';
577 }
578 }
579
580 return DEFAULT_FILTER_STATE;
581}
582
583/**
584 * Applies the favorite filter to the character list.
585 * @param {FilterHelper} filterHelper Instance of FilterHelper class.
586 */
587function filterByFav(filterHelper) {
588 applyActionableTagFilter.call(this, filterHelper, ACTIONABLE_TAGS.FAV, FILTER_TYPES.FAV, ACTIONABLE_FILTER_STORAGE_KEYS.FAV);
357}589}
358590
359/**591/**
@@ -361,10 +593,7 @@ function filterByFav(_filterHelper) {
361 * @param {FilterHelper} filterHelper Instance of FilterHelper class.593 * @param {FilterHelper} filterHelper Instance of FilterHelper class.
362 */594 */
363function filterByGroups(filterHelper) {595function filterByGroups(filterHelper) {
364 const state = toggleTagThreeState($(this));596 applyActionableTagFilter.call(this, filterHelper, ACTIONABLE_TAGS.GROUP, FILTER_TYPES.GROUP, ACTIONABLE_FILTER_STORAGE_KEYS.GROUP);
365 ACTIONABLE_TAGS.GROUP.filter_state = state;
366 accountStorage.setItem(ACTIONABLE_FILTER_STORAGE_KEYS.GROUP, state);
367 filterHelper.setFilterData(FILTER_TYPES.GROUP, state);
368}597}
369598
370/**599/**
@@ -379,10 +608,7 @@ function filterByFolder(filterHelper) {
379 return;608 return;
380 }609 }
381610
382 const state = toggleTagThreeState($(this));611 applyActionableTagFilter.call(this, filterHelper, ACTIONABLE_TAGS.FOLDER, FILTER_TYPES.FOLDER, ACTIONABLE_FILTER_STORAGE_KEYS.FOLDER);
383 ACTIONABLE_TAGS.FOLDER.filter_state = state;
384 accountStorage.setItem(ACTIONABLE_FILTER_STORAGE_KEYS.FOLDER, state);
385 filterHelper.setFilterData(FILTER_TYPES.FOLDER, state);
386}612}
387613
388function loadTagsSettings(settings) {614function loadTagsSettings(settings) {
@@ -942,6 +1168,7 @@ function newTag(tagName) {
942 * @property {boolean} [isGeneralList=false] - If true, indicates that this is the general list of tags.1168 * @property {boolean} [isGeneralList=false] - If true, indicates that this is the general list of tags.
943 * @property {boolean} [skipExistsCheck=false] - If true, the tag gets added even if a tag with the same id already exists.1169 * @property {boolean} [skipExistsCheck=false] - If true, the tag gets added even if a tag with the same id already exists.
944 * @property {boolean} [isCharacterList=false] - If true, indicates that this is the character's list of tags.1170 * @property {boolean} [isCharacterList=false] - If true, indicates that this is the character's list of tags.
1171 * @property {boolean} [isInactive=false] - If true, indicates that the tag is inactive (for styling purposes).
945 */1172 */
9461173
947/**1174/**
@@ -954,6 +1181,7 @@ function newTag(tagName) {
954 * @property {function(object): function} [tagActionSelector=undefined] - An optional override for the action property that can be assigned to each tag via tagOptions.1181 * @property {function(object): function} [tagActionSelector=undefined] - An optional override for the action property that can be assigned to each tag via tagOptions.
955 * 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.1182 * 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.
956 * @property {TagOptions} [tagOptions={}] - Options for tag behavior. (Same object will be passed into "appendTagToList")1183 * @property {TagOptions} [tagOptions={}] - Options for tag behavior. (Same object will be passed into "appendTagToList")
1184 * @property {string[]} [inactiveTags=[]] - List of tag IDs that are considered inactive (for styling purposes).
957 */1185 */
9581186
959/**1187/**
@@ -962,7 +1190,7 @@ function newTag(tagName) {
962 * @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)1190 * @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)
963 * @param {PrintTagListOptions} [options] - Optional parameters for printing the tag list.1191 * @param {PrintTagListOptions} [options] - Optional parameters for printing the tag list.
964 */1192 */
965function printTagList(element, { tags = undefined, addTag = undefined, forEntityOrKey = undefined, empty = true, sort = true, tagActionSelector = undefined, tagOptions = {} } = {}) {1193function printTagList(element, { tags = undefined, addTag = undefined, forEntityOrKey = undefined, empty = true, sort = true, tagActionSelector = undefined, tagOptions = {}, inactiveTags = [] } = {}) {
966 const $element = (typeof element === 'string') ? $(element) : element;1194 const $element = (typeof element === 'string') ? $(element) : element;
967 const key = forEntityOrKey !== undefined ? getTagKeyForEntity(forEntityOrKey) : getTagKey();1195 const key = forEntityOrKey !== undefined ? getTagKeyForEntity(forEntityOrKey) : getTagKey();
968 let printableTags = tags ? (typeof tags === 'function' ? tags() : tags) : getTagsList(key, sort);1196 let printableTags = tags ? (typeof tags === 'function' ? tags() : tags) : getTagsList(key, sort);
@@ -1018,7 +1246,9 @@ function printTagList(element, { tags = undefined, addTag = undefined, forEntity
10181246
1019 // Check if we should print this tag1247 // Check if we should print this tag
1020 if (shouldPrintTag(tag) || additionalTagsPrinted++ < availableSlotsForAdditionalTags) {1248 if (shouldPrintTag(tag) || additionalTagsPrinted++ < availableSlotsForAdditionalTags) {
1021 appendTagToList($element, tag, tagOptions);1249 // Check if this tag is in the inactive list
1250 const isInactive = inactiveTags.includes(tag.id);
1251 appendTagToList($element, tag, { ...tagOptions, isInactive });
1022 } else {1252 } else {
1023 tagsSkipped++;1253 tagsSkipped++;
1024 }1254 }
@@ -1040,7 +1270,7 @@ function printTagList(element, { tags = undefined, addTag = undefined, forEntity
10401270
1041 // Do not bubble further, we are just expanding1271 // Do not bubble further, we are just expanding
1042 event.stopPropagation();1272 event.stopPropagation();
1043 printTagList($element, { tags: tags, addTag: addTag, forEntityOrKey: forEntityOrKey, empty: empty, tagActionSelector: tagActionSelector, tagOptions: tagOptions });1273 printTagList($element, { tags: tags, addTag: addTag, forEntityOrKey: forEntityOrKey, empty: empty, tagActionSelector: tagActionSelector, tagOptions: tagOptions, inactiveTags: inactiveTags });
1044 };1274 };
10451275
1046 // Print the placeholder object with its styling and action to show the remaining tags1276 // 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
1061 * @param {TagOptions} [options={}] - Options for tag behavior1291 * @param {TagOptions} [options={}] - Options for tag behavior
1062 * @returns {void}1292 * @returns {void}
1063 */1293 */
1064function appendTagToList(listElement, tag, { removable = false, isFilter = false, action = undefined, removeAction = undefined, isGeneralList = false, skipExistsCheck = false } = {}) {1294function appendTagToList(listElement, tag, { removable = false, isFilter = false, action = undefined, removeAction = undefined, isGeneralList = false, skipExistsCheck = false, isInactive = false } = {}) {
1065 if (!listElement) {1295 if (!listElement) {
1066 return;1296 return;
1067 }1297 }
@@ -1097,13 +1327,22 @@ function appendTagToList(listElement, tag, { removable = false, isFilter = false
1097 tagElement.find('.tag_name').text('').attr('title', `${translate(tag.name)} ${tag.title || ''}`.trim()).addClass(tag.icon);1327 tagElement.find('.tag_name').text('').attr('title', `${translate(tag.name)} ${tag.title || ''}`.trim()).addClass(tag.icon);
1098 tagElement.addClass('actionable');1328 tagElement.addClass('actionable');
1099 }1329 }
1330 if (isInactive) {
1331 tagElement.addClass('tag-absent');
1332 }
11001333
1101 // We could have multiple ways of actions passed in. The manual arguments have precendence in front of a specified tag action1334 // We could have multiple ways of actions passed in. The manual arguments have precendence in front of a specified tag action
1102 const clickableAction = action ?? tag.action;1335 const clickableAction = action ?? tag.action;
11031336
1104 // If this is a tag for a general list and its either a filter or actionable, lets mark its current state1337 // If this is a tag for a general list and its either a filter or actionable, lets mark its current state
1105 if ((isFilter || clickableAction) && isGeneralList) {1338 if ((isFilter || clickableAction) && isGeneralList) {
1106 toggleTagThreeState(tagElement, { stateOverride: tag.filter_state ?? DEFAULT_FILTER_STATE });1339 const filterHelper = getFilterHelper($(listElement));
1340 const isFilterActionable = clickableAction && 'filter_state' in tag;
1341
1342 if (isFilter || isFilterActionable) {
1343 const filterState = determineTagFilterState(filterHelper, tag, isFilterActionable);
1344 toggleTagThreeState(tagElement, { stateOverride: filterState });
1345 }
1107 }1346 }
11081347
1109 if (isFilter) {1348 if (isFilter) {
@@ -1127,16 +1366,77 @@ function onTagFilterClick(listElement) {
11271366
1128 let state = toggleTagThreeState($(this));1367 let state = toggleTagThreeState($(this));
11291368
1130 if (existingTag) {1369 const filterHelper = getFilterHelper($(listElement));
1370
1371 // Update the tag's filter_state for the main character list (backward compatibility)
1372 if (existingTag && isMainCharacterList(filterHelper)) {
1131 existingTag.filter_state = state;1373 existingTag.filter_state = state;
1132 saveSettingsDebounced();1374 saveSettingsDebounced();
1133 }1375 }
11341376
1135 // We don't print anything manually, updating the filter will automatically trigger a redraw of all relevant stuff1377 // Persist to storage for all contexts
1378 const storagePrefix = getFilterStorageKey(filterHelper);
1379 if (storagePrefix && existingTag) {
1380 const storageKey = `${storagePrefix}_tag_${tagId}`;
1381 accountStorage.setItem(storageKey, state);
1382 }
1383
1384 // Apply all tag filters by reading from DOM state (this triggers the filter helper update)
1136 runTagFilters(listElement);1385 runTagFilters(listElement);
11371386
1138 // Focus the tag again we were at, if possible. To improve keyboard navigation1387 // Focus the tag again we were at, if possible. To improve keyboard navigation
1139 setTimeout(() => parent.find(`.tag[id="${tagId}"]`).trigger('focus'), DEFAULT_PRINT_TIMEOUT + 1);1388 setTimeout(() => parent.find(`.tag[id="${tagId}"]`).trigger('focus'), DEFAULT_PRINT_TIMEOUT + 1);
1389
1390 updateTagFilterIndicator(listElement);
1391}
1392
1393/**
1394 * Loads persisted filter states for a given filter context.
1395 * @param {FilterHelper} filterHelper - The filter helper instance
1396 * @param {string} storagePrefix - The storage key prefix for this context
1397 */
1398function loadFilterStatesForContext(filterHelper, storagePrefix) {
1399 const validStates = new Set(Object.keys(FILTER_STATES));
1400 const readState = (/** @type {string} */ storageKey) => {
1401 const v = accountStorage.getItem(storageKey);
1402 return v && validStates.has(v) ? v : null;
1403 };
1404
1405 // Load actionable tag states (Favorites, Groups, Folders)
1406 const favState = readState(`${storagePrefix}_${ACTIONABLE_FILTER_STORAGE_KEYS.FAV}`);
1407 if (favState) {
1408 filterHelper.setFilterData(FILTER_TYPES.FAV, favState, true);
1409 }
1410
1411 const groupState = readState(`${storagePrefix}_${ACTIONABLE_FILTER_STORAGE_KEYS.GROUP}`);
1412 if (groupState) {
1413 filterHelper.setFilterData(FILTER_TYPES.GROUP, groupState, true);
1414 }
1415
1416 const folderState = readState(`${storagePrefix}_${ACTIONABLE_FILTER_STORAGE_KEYS.FOLDER}`);
1417 if (folderState) {
1418 filterHelper.setFilterData(FILTER_TYPES.FOLDER, folderState, true);
1419 }
1420
1421 // Load regular tag filter states
1422 const tagFilterData = filterHelper.getFilterData(FILTER_TYPES.TAG);
1423 for (const tag of tags) {
1424 const storageKey = `${storagePrefix}_tag_${tag.id}`;
1425 const state = readState(storageKey);
1426
1427 if (state) {
1428 if (state === 'SELECTED') {
1429 if (!tagFilterData.selected.includes(tag.id)) {
1430 tagFilterData.selected.push(tag.id);
1431 }
1432 } else if (state === 'EXCLUDED') {
1433 if (!tagFilterData.excluded.includes(tag.id)) {
1434 tagFilterData.excluded.push(tag.id);
1435 }
1436 }
1437 }
1438 }
1439 filterHelper.setFilterData(FILTER_TYPES.TAG, tagFilterData, true);
1140}1440}
11411441
1142/**1442/**
@@ -1201,20 +1501,77 @@ function runTagFilters(listElement) {
1201}1501}
12021502
1203function printTagFilters(type = tag_filter_type.character) {1503function printTagFilters(type = tag_filter_type.character) {
1204 const FILTER_SELECTOR = type === tag_filter_type.character ? CHARACTER_FILTER_SELECTOR : GROUP_FILTER_SELECTOR;1504 removeMissingTagFilters();
1505
1506 let FILTER_SELECTOR;
1507 switch (type) {
1508 case tag_filter_type.character:
1509 FILTER_SELECTOR = CHARACTER_FILTER_SELECTOR;
1510 break;
1511 case tag_filter_type.group_candidates_list:
1512 FILTER_SELECTOR = GROUP_FILTER_SELECTOR;
1513 break;
1514 case tag_filter_type.group_members_list:
1515 FILTER_SELECTOR = GROUP_MEMBERS_FILTER_SELECTOR;
1516 break;
1517 default:
1518 FILTER_SELECTOR = CHARACTER_FILTER_SELECTOR;
1519 break;
1520 }
1521
1205 $(FILTER_SELECTOR).empty();1522 $(FILTER_SELECTOR).empty();
12061523
1207 // Print all action tags. (Rework 'Folder' button to some kind of onboarding if no folders are enabled yet)1524 // Print all action tags. (Rework 'Folder' button to some kind of onboarding if no folders are enabled yet)
1208 const actionTags = Object.values(ACTIONABLE_TAGS);1525 let actionTags = Object.values(ACTIONABLE_TAGS);
1209 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';1526 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';
1527
1528 // For group contexts, filter actionable tags to only show relevant ones
1529 if (isGroupContext(type)) {
1530 actionTags = filterActionableTagsForGroupContext(actionTags);
1531 }
1532
1210 printTagList($(FILTER_SELECTOR), { empty: false, sort: false, tags: actionTags, tagActionSelector: tag => tag.action, tagOptions: { isGeneralList: true } });1533 printTagList($(FILTER_SELECTOR), { empty: false, sort: false, tags: actionTags, tagActionSelector: tag => tag.action, tagOptions: { isGeneralList: true } });
12111534
1212 const inListActionTags = Object.values(InListActionable);1535 const inListActionTags = Object.values(InListActionable);
1213 printTagList($(FILTER_SELECTOR), { empty: false, sort: false, tags: inListActionTags, tagActionSelector: tag => tag.action, tagOptions: { isGeneralList: true } });1536 printTagList($(FILTER_SELECTOR), { empty: false, sort: false, tags: inListActionTags, tagActionSelector: tag => tag.action, tagOptions: { isGeneralList: true } });
12141537
1215 const characterTagIds = Object.values(tag_map).flat();1538 // Determine which character tags to display based on context
1216 const tagsToDisplay = tags.filter(x => characterTagIds.includes(x.id)).sort(compareTagsForSort);1539 let tagsToDisplay;
1217 printTagList($(FILTER_SELECTOR), { empty: false, tags: tagsToDisplay, tagOptions: { isFilter: true, isGeneralList: true } });1540 let inactiveTags = [];
1541
1542 if (isGroupContext(type)) {
1543 // For group contexts, show all tags but mark ones without presence in current context as inactive
1544 // CAUTION: when called by openGroupById, the selected_group variable might not yet be updated
1545 const currentGroup = selected_group ? groups.find(x => x.id == selected_group) : null;
1546 const visibleAvatars = getVisibleAvatarsForGroupContext(type, currentGroup);
1547
1548 if (visibleAvatars.length > 0) {
1549 // Get tags that are assigned to at least one visible character
1550 const activeCharacterTagIds = visibleAvatars
1551 .map(avatar => tag_map[avatar] || [])
1552 .flat()
1553 .filter(onlyUnique);
1554
1555 // Show all tags that exist in the tag_map
1556 const allCharacterTagIds = Object.values(tag_map).flat().filter(onlyUnique);
1557 tagsToDisplay = tags.filter(x => allCharacterTagIds.includes(x.id)).sort(compareTagsForSort);
1558
1559 // Mark tags that are not in the active set as inactive
1560 inactiveTags = tagsToDisplay
1561 .filter(x => !activeCharacterTagIds.includes(x.id))
1562 .map(x => x.id);
1563 } else {
1564 // No group selected, show no tags
1565 tagsToDisplay = [];
1566 }
1567 } else {
1568 // For main character list, show all tags as before
1569 const characterTagIds = Object.values(tag_map).flat();
1570 tagsToDisplay = tags.filter(x => characterTagIds.includes(x.id)).sort(compareTagsForSort);
1571 }
1572
1573 printTagList($(FILTER_SELECTOR), { empty: false, tags: tagsToDisplay, tagOptions: { isFilter: true, isGeneralList: true }, inactiveTags: inactiveTags });
1574
12181575
1219 // Print bogus folder navigation1576 // Print bogus folder navigation
1220 const bogusDrilldown = $(FILTER_SELECTOR).siblings('.rm_tag_bogus_drilldown');1577 const bogusDrilldown = $(FILTER_SELECTOR).siblings('.rm_tag_bogus_drilldown');
@@ -1224,22 +1581,36 @@ function printTagFilters(type = tag_filter_type.character) {
1224 printTagList(bogusDrilldown, { tags: navigatedTags, tagOptions: { removable: true } });1581 printTagList(bogusDrilldown, { tags: navigatedTags, tagOptions: { removable: true } });
1225 }1582 }
12261583
1227 runTagFilters(FILTER_SELECTOR);1584 // Don't call runTagFilters here - it would overwrite the loaded filter states with the DOM state.
1585 // The visual state (CSS classes) already matches the filter helper state set by loadFilterStatesForContext.
1586 // runTagFilters is only needed when user clicks a tag (handled in onTagFilterClick).
12281587
1229 if (power_user.show_tag_filters) {1588 // Initialize the tag list visibility based on saved settings for this context
1230 $('.rm_tag_controls .showTagList').addClass('selected');1589 const shouldShowTags = getTagFilterVisibility(type);
1231 $('.rm_tag_controls').find('.tag:not(.actionable)').show();1590 const showTagListButton = $(FILTER_SELECTOR).closest('.rm_tag_controls').find('.showTagList');
1232 }
12331591
1234 updateTagFilterIndicator();1592 // Update button state to match the saved setting
1235}1593 showTagListButton.toggleClass('selected', shouldShowTags);
12361594
1237function updateTagFilterIndicator() {1595 if (shouldShowTags) {
1238 if ($('.rm_tag_controls').find('.tag:not(.actionable)').is('.selected, .excluded')) {1596 $(FILTER_SELECTOR).find('.tag:not(.actionable)').show();
1239 $('.rm_tag_controls .showTagList').addClass('indicator');
1240 } else {1597 } else {
1241 $('.rm_tag_controls .showTagList').removeClass('indicator');1598 $(FILTER_SELECTOR).find('.tag:not(.actionable)').hide();
1242 }1599 }
1600
1601 updateTagFilterIndicator(FILTER_SELECTOR);
1602}
1603
1604/**
1605 * Updates the tag filter indicator based on the selected/excluded tags in the given filter selector
1606 * @param {string|JQuery<HTMLElement>} filterSelector - The selector or jQuery element for the tag filter container
1607 */
1608function updateTagFilterIndicator(filterSelector) {
1609 const selector = filterSelector || CHARACTER_FILTER_SELECTOR;
1610 const tagFilter = typeof selector === 'string' ? $(selector) : selector;
1611 const showTagListButton = tagFilter.closest('.rm_tag_controls').find('.showTagList');
1612 const hasActiveTags = tagFilter.find('.tag:not(.actionable)').is('.selected, .excluded');
1613 showTagListButton.toggleClass('indicator', hasActiveTags);
1243}1614}
12441615
1245function onTagRemoveClick(event) {1616function onTagRemoveClick(event) {
@@ -1316,6 +1687,8 @@ export function applyTagsOnGroupSelect(groupId = null) {
13161687
1317 groupId = groupId ?? (selected_group ? Number(selected_group) : undefined);1688 groupId = groupId ?? (selected_group ? Number(selected_group) : undefined);
1318 printTagList($('#groupTagList'), { forEntityOrKey: groupId, tagOptions: { removable: true } });1689 printTagList($('#groupTagList'), { forEntityOrKey: groupId, tagOptions: { removable: true } });
1690 printTagFilters(tag_filter_type.group_candidates_list);
1691 printTagFilters(tag_filter_type.group_members_list);
1319}1692}
13201693
1321/**1694/**
@@ -1841,17 +2214,39 @@ function onTagListHintClick() {
1841 }2214 }
18422215
1843 $(this).siblings('.innerActionable').toggleClass('hidden');2216 $(this).siblings('.innerActionable').toggleClass('hidden');
1844 power_user.show_tag_filters = $(this).hasClass('selected');2217
1845 saveSettingsDebounced();2218 // Determine which context this button belongs to and save the setting
1846 console.debug('show_tag_filters', power_user.show_tag_filters);2219 let filterType = tag_filter_type.character;
2220
2221 // Check which section we're in by looking at the sibling header
2222 const $tagControls = $(this).closest('.rm_tag_controls');
2223 if ($tagControls.prev().is('#rm_group_add_members_header')) {
2224 filterType = tag_filter_type.group_candidates_list;
2225 } else if ($tagControls.prev().is('#rm_group_members_header')) {
2226 filterType = tag_filter_type.group_members_list;
2227 }
2228
2229 const isSelected = $(this).hasClass('selected');
2230 setTagFilterVisibility(filterType, isSelected);
2231 console.debug('show_tag_filters for type', filterType, ':', isSelected);
1847}2232}
18482233
1849function onClearAllFiltersClick() {2234/**
2235 * Clears all filters for the current list context.
2236 * @param {FilterHelper} filterHelper - The filter helper for the current context
2237 */
2238function onClearAllFiltersClick(filterHelper) {
1850 console.debug('clear all filters clicked');2239 console.debug('clear all filters clicked');
18512240
2241 const context = getFilterContext(filterHelper);
2242 if (!context) {
2243 console.warn('Unknown filter helper in onClearAllFiltersClick');
2244 return;
2245 }
2246
1852 // We have to manually go through the elements and unfilter by clicking...2247 // We have to manually go through the elements and unfilter by clicking...
1853 // Thankfully nearly all filter controls are three-state-toggles2248 // Thankfully nearly all filter controls are three-state-toggles
1854 const filterTags = $('.rm_tag_controls .rm_tag_filter').find('.tag');2249 const filterTags = $(context.selector).find('.tag');
1855 for (const tag of filterTags) {2250 for (const tag of filterTags) {
1856 const toggleState = $(tag).attr('data-toggle-state');2251 const toggleState = $(tag).attr('data-toggle-state');
1857 if (toggleState !== undefined && !isFilterState(toggleState ?? FILTER_STATES.UNDEFINED, FILTER_STATES.UNDEFINED)) {2252 if (toggleState !== undefined && !isFilterState(toggleState ?? FILTER_STATES.UNDEFINED, FILTER_STATES.UNDEFINED)) {
@@ -1859,8 +2254,8 @@ function onClearAllFiltersClick() {
1859 }2254 }
1860 }2255 }
18612256
1862 // Reset search too2257 // Reset search input for this context
1863 $('#character_search_bar').val('').trigger('input');2258 $(context.searchInput).val('').trigger('input');
1864}2259}
18652260
1866/**2261/**
@@ -1891,6 +2286,37 @@ function printViewTagList(tagContainer, empty = true) {
1891 }2286 }
1892}2287}
18932288
2289function removeMissingTagFilters() {
2290 const tagIds = new Set(tags.map(tag => tag.id));
2291
2292 for (const helper of [groupCandidatesFilter, groupMembersFilter, entitiesFilter]) {
2293 const { selected, excluded } = helper.getFilterData(FILTER_TYPES.TAG);
2294 let anyRemoved = false;
2295
2296 if (Array.isArray(selected)) {
2297 for (let i = selected.length - 1; i >= 0; i--) {
2298 if (!tagIds.has(selected[i])) {
2299 selected.splice(i, 1);
2300 anyRemoved = true;
2301 }
2302 }
2303 }
2304
2305 if (Array.isArray(excluded)) {
2306 for (let i = excluded.length - 1; i >= 0; i--) {
2307 if (!tagIds.has(excluded[i])) {
2308 excluded.splice(i, 1);
2309 anyRemoved = true;
2310 }
2311 }
2312 }
2313
2314 if (anyRemoved) {
2315 helper.setFilterData(FILTER_TYPES.TAG, { selected, excluded });
2316 }
2317 }
2318}
2319
1894function registerTagsSlashCommands() {2320function registerTagsSlashCommands() {
1895 /**2321 /**
1896 * Gets a tag by its name. Optionally can create the tag if it does not exist.2322 * Gets a tag by its name. Optionally can create the tag if it does not exist.
@@ -2237,7 +2663,8 @@ function normalizeTagName(name) {
2237 .toLowerCase();2663 .toLowerCase();
2238}2664}
22392665
2240/** Extracts the character avatar file name from the avatar source URL.2666/**
2667 * Extracts the character avatar file name from the avatar source URL.
2241 * @param {string} avatarSrc The source URL of the character avatar.2668 * @param {string} avatarSrc The source URL of the character avatar.
2242 * @returns {string|null} The normalized avatar file name, or null if the input is falsy or doesn't contain a valid file name.2669 * @returns {string|null} The normalized avatar file name, or null if the input is falsy or doesn't contain a valid file name.
2243 */2670 */
@@ -2270,8 +2697,12 @@ function restoreSavedTagFilters() {
2270 if (favState) {2697 if (favState) {
2271 ACTIONABLE_TAGS.FAV.filter_state = favState;2698 ACTIONABLE_TAGS.FAV.filter_state = favState;
2272 entitiesFilter.setFilterData(FILTER_TYPES.FAV, favState, true);2699 entitiesFilter.setFilterData(FILTER_TYPES.FAV, favState, true);
2273 groupCandidatesFilter.setFilterData(FILTER_TYPES.FAV, favState, true);
2274 }2700 }
2701
2702 // Load persisted filter states for all contexts (including character list)
2703 loadFilterStatesForContext(entitiesFilter, 'CharacterList');
2704 loadFilterStatesForContext(groupCandidatesFilter, 'GroupCandidates');
2705 loadFilterStatesForContext(groupMembersFilter, 'GroupMembers');
2275 if (groupState) {2706 if (groupState) {
2276 ACTIONABLE_TAGS.GROUP.filter_state = groupState;2707 ACTIONABLE_TAGS.GROUP.filter_state = groupState;
2277 entitiesFilter.setFilterData(FILTER_TYPES.GROUP, groupState, true);2708 entitiesFilter.setFilterData(FILTER_TYPES.GROUP, groupState, true);
@@ -2280,6 +2711,10 @@ function restoreSavedTagFilters() {
2280 ACTIONABLE_TAGS.FOLDER.filter_state = folderState;2711 ACTIONABLE_TAGS.FOLDER.filter_state = folderState;
2281 entitiesFilter.setFilterData(FILTER_TYPES.FOLDER, folderState, true);2712 entitiesFilter.setFilterData(FILTER_TYPES.FOLDER, folderState, true);
2282 }2713 }
2714
2715 // Note: Regular tag filter states are now loaded from storage via loadFilterStatesForContext()
2716 // The old tag.filter_state property is only maintained for backward compatibility with
2717 // the main character list's actionable tags (Favorites, Groups, Folders)
2283 } catch (e) {2718 } catch (e) {
2284 console.warn('Failed to restore actionable filter states from account storage', e);2719 console.warn('Failed to restore actionable filter states from account storage', e);
2285 }2720 }