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>
Signed| @@ -67,6 +67,10 @@ | |||
| 67 | display: none; | 67 | display: none; |
| 68 | } | 68 | } |
| 69 | 69 | ||
| 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; |
| @@ -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> |
| @@ -953,7 +953,8 @@ export async function printCharacters(fullRefresh = false) { | |||
| 953 | 953 | ||
| 954 | // We are actually always reprinting filters, as it "doesn't hurt", and this way they are always up to date | 954 | // 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); | ||
| 957 | 958 | ||
| 958 | // We are also always reprinting the lists on character/group edit window, as these ones doesn't get updated otherwise | 959 | // We are also always reprinting the lists on character/group edit window, as these ones doesn't get updated otherwise |
| 959 | applyTagsOnCharacterSelect(); | 960 | applyTagsOnCharacterSelect(); |
| @@ -80,7 +80,7 @@ import { | |||
| 80 | chatElement, | 80 | chatElement, |
| 81 | ensureMessageMediaIsArray, | 81 | ensureMessageMediaIsArray, |
| 82 | } from '../script.js'; | 82 | } from '../script.js'; |
| 83 | import { printTagList, createTagMapFromList, applyTagsOnCharacterSelect, tag_map, applyTagsOnGroupSelect } from './tags.js'; | 83 | import { printTagList, createTagMapFromList, applyTagsOnCharacterSelect, tag_map, applyTagsOnGroupSelect, printTagFilters, tag_filter_type } from './tags.js'; |
| 84 | import { FILTER_TYPES, FilterHelper } from './filters.js'; | 84 | import { FILTER_TYPES, FilterHelper } from './filters.js'; |
| 85 | import { isExternalMediaAllowed } from './chats.js'; | 85 | import { isExternalMediaAllowed } from './chats.js'; |
| 86 | import { POPUP_TYPE, Popup, callGenericPopup } from './popup.js'; | 86 | import { POPUP_TYPE, Popup, callGenericPopup } from './popup.js'; |
| @@ -134,6 +134,7 @@ export const group_generation_mode = { | |||
| 134 | export const DEFAULT_AUTO_MODE_DELAY = 5; | 134 | export const DEFAULT_AUTO_MODE_DELAY = 5; |
| 135 | 135 | ||
| 136 | export const groupCandidatesFilter = new FilterHelper(debounce(printGroupCandidates, debounce_timeout.quick)); | 136 | export const groupCandidatesFilter = new FilterHelper(debounce(printGroupCandidates, debounce_timeout.quick)); |
| 137 | export const groupMembersFilter = new FilterHelper(debounce(printGroupMembers, debounce_timeout.quick)); | ||
| 137 | let autoModeWorker = null; | 138 | let autoModeWorker = null; |
| 138 | const saveGroupDebounced = debounce(async (group, reload) => await _save(group, reload), debounce_timeout.relaxed); | 139 | const 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(); |
| 1451 | 1452 | ||
| 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 objects | 1562 | * @returns {Array<{item: Character, id: number, type: string}>} Array of group character objects |
| 1558 | */ | 1563 | */ |
| 1559 | function getGroupCharacters({ doFilter = false, onlyMembers = false } = {}) { | 1564 | function 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 | } |
| 1566 | 1604 | ||
| 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' })); | ||
| 1571 | 1606 | ||
| 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])); |
| 1575 | 1610 | ||
| 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 | } |
| 1582 | 1619 | ||
| 1583 | groupCandidatesFilter.clearFuzzySearchCaches(); | 1620 | // Handle members with manual sort capability |
| 1584 | return candidates; | 1621 | return handleMembers(results, thisGroup); |
| 1585 | } | 1622 | } |
| 1586 | 1623 | ||
| 1587 | function printGroupCandidates() { | 1624 | function 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 | } |
| 2051 | 2089 | ||
| 2090 | function filterGroupMemberList() { | ||
| 2091 | const searchValue = String($(this).val()).toLowerCase(); | ||
| 2092 | groupMembersFilter.setFilterData(FILTER_TYPES.SEARCH, searchValue); | ||
| 2093 | } | ||
| 2094 | |||
| 2052 | async function createGroup() { | 2095 | async 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 () { |
| @@ -15,7 +15,7 @@ import { | |||
| 15 | } from '../script.js'; | 15 | } from '../script.js'; |
| 16 | import { FILTER_TYPES, FILTER_STATES, DEFAULT_FILTER_STATE, isFilterState, FilterHelper } from './filters.js'; | 16 | import { FILTER_TYPES, FILTER_STATES, DEFAULT_FILTER_STATE, isFilterState, FilterHelper } from './filters.js'; |
| 17 | 17 | ||
| 18 | import { groupCandidatesFilter, groups, selected_group } from './group-chats.js'; | 18 | import { groupCandidatesFilter, groupMembersFilter, groups, selected_group } from './group-chats.js'; |
| 19 | import { download, onlyUnique, parseJsonFile, uuidv4, getSortableDelay, flashHighlight, equalsIgnoreCaseAndAccents, includesIgnoreCaseAndAccents, removeFromArray, getFreeName, debounce, findChar } from './utils.js'; | 19 | import { download, onlyUnique, parseJsonFile, uuidv4, getSortableDelay, flashHighlight, equalsIgnoreCaseAndAccents, includesIgnoreCaseAndAccents, removeFromArray, getFreeName, debounce, findChar } from './utils.js'; |
| 20 | import { power_user } from './power-user.js'; | 20 | import { power_user } from './power-user.js'; |
| 21 | import { SlashCommandParser } from './slash-commands/SlashCommandParser.js'; | 21 | import { SlashCommandParser } from './slash-commands/SlashCommandParser.js'; |
| @@ -53,16 +53,113 @@ export { | |||
| 53 | removeTagFromMap, | 53 | removeTagFromMap, |
| 54 | }; | 54 | }; |
| 55 | 55 | ||
| 56 | /** @typedef {import('../script.js').Character} Character */ | ||
| 57 | |||
| 58 | const CHARACTER_FILTER_SELECTOR = '#rm_characters_block .rm_tag_filter'; | 56 | const CHARACTER_FILTER_SELECTOR = '#rm_characters_block .rm_tag_filter'; |
| 59 | const GROUP_FILTER_SELECTOR = '#rm_group_chats_block .rm_tag_filter'; | 57 | const GROUP_FILTER_SELECTOR = '#rm_group_add_members_header ~ .rm_tag_controls .rm_tag_filter'; |
| 58 | const GROUP_MEMBERS_FILTER_SELECTOR = '#rm_group_members_header ~ .rm_tag_controls .rm_tag_filter'; | ||
| 60 | const TAG_TEMPLATE = $('#tag_template .tag'); | 59 | const TAG_TEMPLATE = $('#tag_template .tag'); |
| 61 | const FOLDER_TEMPLATE = $('#bogus_folder_template .bogus_folder_select'); | 60 | const FOLDER_TEMPLATE = $('#bogus_folder_template .bogus_folder_select'); |
| 62 | const VIEW_TAG_TEMPLATE = $('#tag_view_template .tag_view_item'); | 61 | const VIEW_TAG_TEMPLATE = $('#tag_view_template .tag_view_item'); |
| 63 | 62 | ||
| 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 | */ | ||
| 69 | function 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 | */ | ||
| 64 | function getFilterHelper(listSelector) { | 94 | function 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 | */ | ||
| 116 | function 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 | */ | ||
| 126 | function 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 | */ | ||
| 150 | function 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 | } |
| 67 | 164 | ||
| 68 | const ACTIONABLE_FILTER_STORAGE_KEYS = Object.freeze({ | 165 | const 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 | }); |
| 73 | 170 | ||
| 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 | */ | ||
| 176 | function 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 | */ | ||
| 192 | function isMainCharacterList(filterHelper) { | ||
| 193 | return filterHelper === entitiesFilter; | ||
| 194 | } | ||
| 195 | |||
| 74 | /** @enum {number} */ | 196 | /** @enum {number} */ |
| 75 | export const tag_filter_type = { | 197 | export 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 | }; |
| 79 | 204 | ||
| 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 | */ | ||
| 210 | function 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 | */ | ||
| 228 | function 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 | */ | ||
| 238 | function setTagFilterVisibility(type, visible) { | ||
| 239 | const settingKey = getTagFilterVisibilitySetting(type); | ||
| 240 | power_user[settingKey] = visible; | ||
| 241 | saveSettingsDebounced(); | ||
| 242 | } | ||
| 243 | |||
| 80 | /** @enum {number} */ | 244 | /** @enum {number} */ |
| 81 | export const tag_import_setting = { | 245 | export const tag_import_setting = { |
| 82 | ASK: 1, | 246 | ASK: 1, |
| @@ -93,18 +257,33 @@ export const tag_sort_mode = { | |||
| 93 | }; | 257 | }; |
| 94 | 258 | ||
| 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 panel | 261 | * |
| 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 | */ | ||
| 99 | const ACTIONABLE_TAGS = { | 268 | const 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 | }; |
| 107 | 276 | ||
| 277 | /** | ||
| 278 | * Map of tag IDs to their corresponding filter types. | ||
| 279 | * Used for actionable tags (Favorites, Groups, Folders). | ||
| 280 | */ | ||
| 281 | const 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 */ |
| 109 | const InListActionable = { | 288 | const InListActionable = { |
| 110 | }; | 289 | }; |
| @@ -345,15 +524,68 @@ function getTagBlock(tag, entities, hidden = 0, isUseless = false) { | |||
| 345 | } | 524 | } |
| 346 | 525 | ||
| 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 | */ |
| 351 | function filterByFav(_filterHelper) { | 534 | function 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 | */ | ||
| 562 | function 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 | */ | ||
| 587 | function filterByFav(filterHelper) { | ||
| 588 | applyActionableTagFilter.call(this, filterHelper, ACTIONABLE_TAGS.FAV, FILTER_TYPES.FAV, ACTIONABLE_FILTER_STORAGE_KEYS.FAV); | ||
| 357 | } | 589 | } |
| 358 | 590 | ||
| 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 | */ |
| 363 | function filterByGroups(filterHelper) { | 595 | function 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 | } |
| 369 | 598 | ||
| 370 | /** | 599 | /** |
| @@ -379,10 +608,7 @@ function filterByFolder(filterHelper) { | |||
| 379 | return; | 608 | return; |
| 380 | } | 609 | } |
| 381 | 610 | ||
| 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 | } |
| 387 | 613 | ||
| 388 | function loadTagsSettings(settings) { | 614 | function 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 | */ |
| 946 | 1173 | ||
| 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 | */ |
| 958 | 1186 | ||
| 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 | */ |
| 965 | function printTagList(element, { tags = undefined, addTag = undefined, forEntityOrKey = undefined, empty = true, sort = true, tagActionSelector = undefined, tagOptions = {} } = {}) { | 1193 | function 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 | |||
| 1018 | 1246 | ||
| 1019 | // Check if we should print this tag | 1247 | // 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 | |||
| 1040 | 1270 | ||
| 1041 | // Do not bubble further, we are just expanding | 1271 | // 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 | }; |
| 1045 | 1275 | ||
| 1046 | // Print the placeholder object with its styling and action to show the remaining tags | 1276 | // 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 behavior | 1291 | * @param {TagOptions} [options={}] - Options for tag behavior |
| 1062 | * @returns {void} | 1292 | * @returns {void} |
| 1063 | */ | 1293 | */ |
| 1064 | function appendTagToList(listElement, tag, { removable = false, isFilter = false, action = undefined, removeAction = undefined, isGeneralList = false, skipExistsCheck = false } = {}) { | 1294 | function 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 | } | ||
| 1100 | 1333 | ||
| 1101 | // We could have multiple ways of actions passed in. The manual arguments have precendence in front of a specified tag action | 1334 | // 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; |
| 1103 | 1336 | ||
| 1104 | // If this is a tag for a general list and its either a filter or actionable, lets mark its current state | 1337 | // 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 | } |
| 1108 | 1347 | ||
| 1109 | if (isFilter) { | 1348 | if (isFilter) { |
| @@ -1127,16 +1366,77 @@ function onTagFilterClick(listElement) { | |||
| 1127 | 1366 | ||
| 1128 | let state = toggleTagThreeState($(this)); | 1367 | let state = toggleTagThreeState($(this)); |
| 1129 | 1368 | ||
| 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 | } |
| 1134 | 1376 | ||
| 1135 | // We don't print anything manually, updating the filter will automatically trigger a redraw of all relevant stuff | 1377 | // 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); |
| 1137 | 1386 | ||
| 1138 | // Focus the tag again we were at, if possible. To improve keyboard navigation | 1387 | // 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 | */ | ||
| 1398 | function 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 | } |
| 1141 | 1441 | ||
| 1142 | /** | 1442 | /** |
| @@ -1201,20 +1501,77 @@ function runTagFilters(listElement) { | |||
| 1201 | } | 1501 | } |
| 1202 | 1502 | ||
| 1203 | function printTagFilters(type = tag_filter_type.character) { | 1503 | function 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(); |
| 1206 | 1523 | ||
| 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 } }); |
| 1211 | 1534 | ||
| 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 } }); |
| 1214 | 1537 | ||
| 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 | |||
| 1218 | 1575 | ||
| 1219 | // Print bogus folder navigation | 1576 | // 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 | } |
| 1226 | 1583 | ||
| 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). | ||
| 1228 | 1587 | ||
| 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 | } | ||
| 1233 | 1591 | ||
| 1234 | updateTagFilterIndicator(); | 1592 | // Update button state to match the saved setting |
| 1235 | } | 1593 | showTagListButton.toggleClass('selected', shouldShowTags); |
| 1236 | 1594 | ||
| 1237 | function 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 | */ | ||
| 1608 | function 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 | } |
| 1244 | 1615 | ||
| 1245 | function onTagRemoveClick(event) { | 1616 | function onTagRemoveClick(event) { |
| @@ -1316,6 +1687,8 @@ export function applyTagsOnGroupSelect(groupId = null) { | |||
| 1316 | 1687 | ||
| 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 | } |
| 1320 | 1693 | ||
| 1321 | /** | 1694 | /** |
| @@ -1841,17 +2214,39 @@ function onTagListHintClick() { | |||
| 1841 | } | 2214 | } |
| 1842 | 2215 | ||
| 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 | } |
| 1848 | 2233 | ||
| 1849 | function onClearAllFiltersClick() { | 2234 | /** |
| 2235 | * Clears all filters for the current list context. | ||
| 2236 | * @param {FilterHelper} filterHelper - The filter helper for the current context | ||
| 2237 | */ | ||
| 2238 | function onClearAllFiltersClick(filterHelper) { | ||
| 1850 | console.debug('clear all filters clicked'); | 2239 | console.debug('clear all filters clicked'); |
| 1851 | 2240 | ||
| 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-toggles | 2248 | // 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 | } |
| 1861 | 2256 | ||
| 1862 | // Reset search too | 2257 | // Reset search input for this context |
| 1863 | $('#character_search_bar').val('').trigger('input'); | 2258 | $(context.searchInput).val('').trigger('input'); |
| 1864 | } | 2259 | } |
| 1865 | 2260 | ||
| 1866 | /** | 2261 | /** |
| @@ -1891,6 +2286,37 @@ function printViewTagList(tagContainer, empty = true) { | |||
| 1891 | } | 2286 | } |
| 1892 | } | 2287 | } |
| 1893 | 2288 | ||
| 2289 | function 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 | |||
| 1894 | function registerTagsSlashCommands() { | 2320 | function 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 | } |
| 2239 | 2665 | ||
| 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 | } |