From 8ffdb66a5884d6b5a52c18465a113492e5508d88 Mon Sep 17 00:00:00 2001 From: RafaUC Date: Tue, 26 May 2026 20:48:12 -0600 Subject: [PATCH 01/11] feat: add quick option to exclude sub-locations when searching locations. close #124 - Add modifier key 'Alt' to locations panel to trigger the sub-location exclusion behavior. - Add corresponding options to the location context menu. --- .../Outliner/LocationsPanel/index.tsx | 114 ++++++++++++++---- 1 file changed, 93 insertions(+), 21 deletions(-) diff --git a/src/frontend/containers/Outliner/LocationsPanel/index.tsx b/src/frontend/containers/Outliner/LocationsPanel/index.tsx index 54209affe..495de268f 100644 --- a/src/frontend/containers/Outliner/LocationsPanel/index.tsx +++ b/src/frontend/containers/Outliner/LocationsPanel/index.tsx @@ -18,6 +18,7 @@ import { useStore } from '../../../contexts/StoreContext'; import { DnDLocationType, useLocationDnD } from '../../../contexts/TagDnDContext'; import { ClientLocation, ClientSubLocation } from '../../../entities/Location'; import { + ClientFileSearchCriteria, ClientStringSearchCriteria, ClientTagSearchCriteria, } from '../../../entities/SearchCriteria'; @@ -31,6 +32,8 @@ import LocationCreationDialog from './LocationCreationDialog'; import LocationRecoveryDialog from './LocationRecoveryDialog'; import { onDragOver as onDragOverFileDnD } from './dnd'; import { useFileDropHandling } from './useFileDnD'; +import { StringOperatorType } from 'src/api/data-storage-search'; +import UiStore from 'src/frontend/stores/UiStore'; export class LocationTreeItemRevealer extends TreeItemRevealer { private locationStore?: LocationStore; @@ -100,8 +103,57 @@ const isExpanded = (nodeData: ClientLocation | ClientSubLocation, treeData: ITre /** Add an additional / or \ in order to enforce files only in the specific directory are found, not in those starting with same name */ const pathAsSearchPath = (path: string) => `${path}${SysPath.sep}`; -const pathCriteria = (path: string) => - new ClientStringSearchCriteria(undefined, 'absolutePath', pathAsSearchPath(path), 'startsWith'); +const pathCriteria = (path: string, operator: StringOperatorType = 'startsWith') => + new ClientStringSearchCriteria(undefined, 'absolutePath', pathAsSearchPath(path), operator); + +const getHandleClickCallback = ( + uiStore: UiStore, + nodeData: ClientLocation | ClientSubLocation, + existingSearchCrit: ClientFileSearchCriteria | undefined, +) => { + const handleClickExclude = (event: React.MouseEvent) => { + const isAppendMode = event.ctrlKey || event.metaKey; + const shouldExcludeSublocations = event.altKey; + // Toggle criterias if already present + if (existingSearchCrit) { + uiStore.removeSearchCriteria(existingSearchCrit as ClientTagSearchCriteria); + // if alt include sublocations in the operation + if (shouldExcludeSublocations) { + nodeData.subLocations.forEach((sub) => { + const subPath = pathAsSearchPath(sub.path); + const subCriteria = uiStore.searchCriteriaList.find( + (c) => + c instanceof ClientStringSearchCriteria && + c.value === subPath && + c.operator === 'notStartsWith', + ); + if (subCriteria) { + uiStore.removeSearchCriteria(subCriteria as ClientTagSearchCriteria); + } + }); + } + return; + } + + // append/replace + const mainCriteria = pathCriteria(nodeData.path); + const extraCriterias: ClientStringSearchCriteria[] = []; + if (shouldExcludeSublocations) { + nodeData.subLocations.forEach((sub: any) => { + const excludeCriteria = pathCriteria(sub.path, 'notStartsWith'); + extraCriterias.push(excludeCriteria); + }); + } + if (isAppendMode) { + uiStore.addSearchCriteria(mainCriteria); + extraCriterias.forEach((crit) => uiStore.addSearchCriteria(crit)); + } else { + uiStore.replaceSearchCriteria(mainCriteria); + extraCriterias.forEach((crit) => uiStore.addSearchCriteria(crit)); + } + }; + return handleClickExclude; +}; const customKeys = ( search: (path: string) => void, @@ -161,14 +213,46 @@ const DirectoryMenu = observer( [path, uiStore], ); + const handleAddWithExclusions = useCallback(() => { + uiStore.addSearchCriteria(pathCriteria(path)); + location.subLocations.forEach((sub) => { + uiStore.addSearchCriteria(pathCriteria(sub.path, 'notStartsWith')); + }); + }, [path, location.subLocations, uiStore]); + + const handleReplaceWithExclusions = useCallback(() => { + uiStore.replaceSearchCriteria(pathCriteria(path)); + location.subLocations.forEach((sub) => { + uiStore.addSearchCriteria(pathCriteria(sub.path, 'notStartsWith')); + }); + }, [path, location.subLocations, uiStore]); + + const hasSublocations = location.subLocations.length > 0; + return ( <> - + + + {location instanceof ClientSubLocation && ( ) => { - existingSearchCrit // toggle search - ? uiStore.removeSearchCriteria(existingSearchCrit as ClientTagSearchCriteria) - : event.ctrlKey // otherwise add/replace depending on ctrl - ? uiStore.addSearchCriteria(pathCriteria(nodeData.path)) - : uiStore.replaceSearchCriteria(pathCriteria(nodeData.path)); - }, - [existingSearchCrit, nodeData.path, uiStore], + const handleClick = useMemo( + () => getHandleClickCallback(uiStore, nodeData, existingSearchCrit), + [existingSearchCrit, nodeData, uiStore], ); const { handleDragEnter, handleDragLeave, handleDrop } = useFileDropHandling( @@ -319,15 +397,9 @@ const Location = observer( (c: any) => c.value === pathAsSearchPath(nodeData.path), ); - const handleClick = useCallback( - (event: React.MouseEvent) => { - existingSearchCrit // toggle search - ? uiStore.removeSearchCriteria(existingSearchCrit as ClientTagSearchCriteria) - : event.ctrlKey - ? uiStore.addSearchCriteria(pathCriteria(nodeData.path)) - : uiStore.replaceSearchCriteria(pathCriteria(nodeData.path)); - }, - [existingSearchCrit, nodeData.path, uiStore], + const handleClick = useMemo( + () => getHandleClickCallback(uiStore, nodeData, existingSearchCrit), + [existingSearchCrit, nodeData, uiStore], ); const fileDnD = useFileDropHandling( From 3505f1201ea9ba8f4cc618e091a08184053826fc Mon Sep 17 00:00:00 2001 From: RafaUC Date: Wed, 27 May 2026 14:42:46 -0600 Subject: [PATCH 02/11] - Feature: Include sub-tags of a tag when matched on the tag selector suggestions. close #117 - Add an option under usage preferences and a shortcut to toggle this behavior --- src/frontend/components/FileTagsEditor.tsx | 30 ++++++++++++++----- src/frontend/components/TagSelector.tsx | 26 +++++++++------- .../containers/Settings/UsagePreferences.tsx | 6 ++++ src/frontend/stores/UiStore.ts | 13 ++++++++ 4 files changed, 58 insertions(+), 17 deletions(-) diff --git a/src/frontend/components/FileTagsEditor.tsx b/src/frontend/components/FileTagsEditor.tsx index cc5c6b4f6..bfc4e0bdd 100644 --- a/src/frontend/components/FileTagsEditor.tsx +++ b/src/frontend/components/FileTagsEditor.tsx @@ -310,19 +310,29 @@ const MatchingTagsList = observer( matches.push(CREATE_OPTION); return { matches: matches, widestItem: widest }; } else { + const includeSubtags = uiStore.isIncludeSubtagsOnMatchEnabled; let widest: ClientTag | undefined = undefined; const normalizedInput = normalizeBase(inputText); const exactMatches: ClientTag[] = []; const otherMatches: ClientTag[] = []; + const visited = new Set(); for (const tag of tagStore.tagList) { - const match = tag.isMatch(normalizedInput); - if (match === 1) { - exactMatches.push(tag); - } else if (match === 2) { - otherMatches.push(tag); + if (includeSubtags && visited.has(tag)) { + continue; } + const match = tag.isMatch(normalizedInput); if (match > 0) { - widest = widest ? (tag.pathCharLength > widest.pathCharLength ? tag : widest) : tag; + let matchTags: Iterable; + const targetArray = match === 1 ? exactMatches : otherMatches; + if (includeSubtags) { + matchTags = tag.getImpliedSubTree(visited); + } else { + matchTags = [tag]; + } + for (const t of matchTags) { + widest = widest ? (t.pathCharLength > widest.pathCharLength ? t : widest) : t; + targetArray.push(t); + } } } // Bring exact matches to the top of the suggestions. This helps find tags with short names @@ -338,7 +348,13 @@ const MatchingTagsList = observer( }; } }), - [counter, inputText, tagStore.tagList, uiStore.recentlyUsedTags], + [ + counter, + inputText, + tagStore.tagList, + uiStore.recentlyUsedTags, + uiStore.isIncludeSubtagsOnMatchEnabled, + ], ).get(); useEffect(() => { diff --git a/src/frontend/components/TagSelector.tsx b/src/frontend/components/TagSelector.tsx index 46539f47e..65e902761 100644 --- a/src/frontend/components/TagSelector.tsx +++ b/src/frontend/components/TagSelector.tsx @@ -362,27 +362,33 @@ const SuggestedTagsList = observer( } return { suggestions: matches, widestItem: widest }; } else { + const includeSubtags = uiStore.isIncludeSubtagsOnMatchEnabled; let widest: ClientTag | undefined = undefined; const normalizedQuery = normalizeBase(query); const exactMatches: ClientTag[] = []; const otherMatches: ClientTag[] = []; + const visited = new Set(); if (!forceCreateOption) { for (const tag of tagStore.tagList) { + if (includeSubtags && visited.has(tag)) { + continue; + } if (!filter(tag)) { continue; } const match = tag.isMatch(normalizedQuery); - if (match === 1) { - exactMatches.push(tag); - } else if (match === 2) { - otherMatches.push(tag); - } if (match > 0) { - widest = widest - ? tag.pathCharLength > widest.pathCharLength - ? tag - : widest - : tag; + let matchTags: Iterable; + const targetArray = match === 1 ? exactMatches : otherMatches; + if (includeSubtags) { + matchTags = tag.getImpliedSubTree(visited); + } else { + matchTags = [tag]; + } + for (const t of matchTags) { + widest = widest ? (t.pathCharLength > widest.pathCharLength ? t : widest) : t; + targetArray.push(t); + } } } } else { diff --git a/src/frontend/containers/Settings/UsagePreferences.tsx b/src/frontend/containers/Settings/UsagePreferences.tsx index a18311666..c30571213 100644 --- a/src/frontend/containers/Settings/UsagePreferences.tsx +++ b/src/frontend/containers/Settings/UsagePreferences.tsx @@ -52,6 +52,12 @@ export const UsagePreferences = observer(() => { Clear Tag Search Text After Select + + Include Sub-tags On Tag Selector Suggestion Matches +

Gallery

diff --git a/src/frontend/stores/UiStore.ts b/src/frontend/stores/UiStore.ts index ff4afd020..ed2c1a46e 100644 --- a/src/frontend/stores/UiStore.ts +++ b/src/frontend/stores/UiStore.ts @@ -91,6 +91,8 @@ export interface IHotkeyMap { toggleEditTagProperties: string; toggleLeftFileInfoViewer: string; + toggleIncludeSubtagsOnTagSelectorSuggestionMatches: string; + // Other openPreviewWindow: string; openExternal: string; @@ -104,6 +106,7 @@ export const defaultHotkeyMap: IHotkeyMap = { toggleEditTagProperties: '4', toggleExtraPropertiesEditor: '5', toggleLeftFileInfoViewer: '6', + toggleIncludeSubtagsOnTagSelectorSuggestionMatches: 'shift + 3', replaceQuery: 'q', toggleSettings: 's', toggleHelpCenter: 'h', @@ -180,6 +183,7 @@ type PersistentPreferenceFields = | 'recentlyUsedTagsMaxLength' | 'recentlyUsedTags' | 'isClearTagSelectorsOnSelectEnabled' + | 'isIncludeSubtagsOnMatchEnabled' // startup options | 'isRefreshLocationsStartupEnabled' | 'isRememberSearchEnabled' @@ -264,6 +268,7 @@ class UiStore { // Usage preferences @observable isClearTagSelectorsOnSelectEnabled: boolean = false; + @observable isIncludeSubtagsOnMatchEnabled: boolean = false; //recently used tags feature @observable recentlyUsedTagsMaxLength: number = 10; @@ -913,6 +918,10 @@ class UiStore { this.isClearTagSelectorsOnSelectEnabled = !this.isClearTagSelectorsOnSelectEnabled; } + @action.bound toggleIncludeSubtagsOnMatch(): void { + this.isIncludeSubtagsOnMatchEnabled = !this.isIncludeSubtagsOnMatchEnabled; + } + /////////////////// Recently used Tags ////////////////// @action.bound setRecentlyUsedTagsMaxLength(val: number): void { @@ -1446,6 +1455,8 @@ class UiStore { this.toggleFileExtraPropertiesEditor(); } else if (matches(hotkeyMap.toggleLeftFileInfoViewer)) { this.toggleFileExtifEditor(); + } else if (matches(hotkeyMap.toggleIncludeSubtagsOnTagSelectorSuggestionMatches)) { + this.toggleIncludeSubtagsOnMatch(); } else if (matches(hotkeyMap.toggleEditTagProperties)) { this.toggleEditTagProperties(); } else if (matches(hotkeyMap.refreshSearch)) { @@ -1618,6 +1629,7 @@ class UiStore { this.areFileEditorsDocked = Boolean(prefs.areFileEditorsDocked ?? false); this.isFileTagsEditorOpen = Boolean(prefs.isFileTagsEditorOpen ?? false); this.isClearTagSelectorsOnSelectEnabled = Boolean(prefs.isClearTagSelectorsOnSelectEnabled ?? false); // eslint-disable-line prettier/prettier + this.isIncludeSubtagsOnMatchEnabled = Boolean(prefs.isIncludeSubtagsOnMatchEnabled ?? false); // eslint-disable-line prettier/prettier this.isFileExtraPropertiesEditorOpen = Boolean(prefs.isFileExtraPropertiesEditorOpen ?? false); // eslint-disable-line prettier/prettier this.isFileExifEditorOpen = Boolean(prefs.isFileExifEditorOpen ?? false); // eslint-disable-line prettier/prettier this.outlinerWidth = Math.max(Number(prefs.outlinerWidth), UiStore.MIN_OUTLINER_WIDTH); @@ -1717,6 +1729,7 @@ class UiStore { recentlyUsedTags: Array.from(this.recentlyUsedTags, (t) => t.id), recentlyUsedTagsMaxLength: this.recentlyUsedTagsMaxLength, isClearTagSelectorsOnSelectEnabled: this.isClearTagSelectorsOnSelectEnabled, + isIncludeSubtagsOnMatchEnabled: this.isIncludeSubtagsOnMatchEnabled, }; return preferences; } From 92a6ff4694719125f8bc5087a58209cb29a91943 Mon Sep 17 00:00:00 2001 From: RafaUC Date: Wed, 27 May 2026 15:56:24 -0600 Subject: [PATCH 03/11] - Feature: Manual database optimization/cleanup option under import/Export settings. close #108 --- src/api/data-storage.ts | 1 + src/backend/backend.ts | 7 +++ .../containers/Settings/ImportExport.tsx | 49 ++++++++++++++++++- src/frontend/stores/RootStore.ts | 4 ++ 4 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/api/data-storage.ts b/src/api/data-storage.ts index 11c09161e..23bf132ec 100644 --- a/src/api/data-storage.ts +++ b/src/api/data-storage.ts @@ -81,6 +81,7 @@ export interface DataStorage { ): Promise>; clear(): Promise; setSeed(seed?: number): Promise; + optimizeDatabase(): Promise; } export function makeFileBatchFetcher( diff --git a/src/backend/backend.ts b/src/backend/backend.ts index d3acd1349..37f14a29c 100644 --- a/src/backend/backend.ts +++ b/src/backend/backend.ts @@ -847,6 +847,13 @@ export default class Backend implements DataStorage { this.#notifyChange(); } + async optimizeDatabase(): Promise { + console.info('SQLite: Optimize database and free space...'); + await sql`PRAGMA optimize;`.execute(this.#db); + await sql`VACUUM;`.execute(this.#db); + this.#notifyChange(); + } + async removeSearch(search: ID): Promise { console.info('SQLite: Removing search...', search); // Cascade delte in other tables deleting from savedSearches table. diff --git a/src/frontend/containers/Settings/ImportExport.tsx b/src/frontend/containers/Settings/ImportExport.tsx index b86aa1b57..143fa28a9 100644 --- a/src/frontend/containers/Settings/ImportExport.tsx +++ b/src/frontend/containers/Settings/ImportExport.tsx @@ -15,6 +15,7 @@ import FileInput from 'src/frontend/components/FileInput'; export const ImportExport = observer(() => { const rootStore = useStore(); const { fileStore, tagStore, exifTool } = rootStore; + const [isOptimizing, setIsOptimizing] = useState(false); const [isConfirmingMetadataExport, setConfirmingMetadataExport] = useState(false); const [isConfirmingFileImport, setConfirmingFileImport] = useState<{ path: string; @@ -59,6 +60,39 @@ export const ImportExport = observer(() => { } }; + const handleOptimizeDatabase = async () => { + if (isOptimizing) { + return; + } + setIsOptimizing(true); + const loadingToastKey = AppToaster.show({ + message: 'Optimizing database and freeing disk space... Please wait.', + type: 'info', + timeout: 0, + }); + + try { + await rootStore.optimizeDatabase(); + AppToaster.dismiss(loadingToastKey); + AppToaster.show({ + message: 'Database optimized and storage compacted successfully!', + type: 'success', + timeout: 5000, + }); + } catch (e) { + console.error(e); + AppToaster.dismiss(loadingToastKey); + AppToaster.show({ + message: 'Could not optimize database, open DevTools for more details', + type: 'error', + clickAction: { label: 'View', onClick: RendererMessenger.toggleDevTools }, + timeout: 5000, + }); + } finally { + setIsOptimizing(false); + } + }; + return ( <>

File Metadata

@@ -127,6 +161,10 @@ export const ImportExport = observer(() => { Automatic back-ups are created every 10 minutes in the{' '} backup directory. + + Optimizing the database frees up unused disk space left by deleted records and reorganizes + internal structures. This helps maintain long-term performance. + { > {IconSet.IMPORT} Restore database from file +