From 993e288c145bf48b6a33f1a283994c49006ca0f4 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Wed, 26 Nov 2025 10:00:34 -0800 Subject: [PATCH] fix: Preserve parsers/matches on folder navigation (#2249) - Closes #2231 --- src/card-controller/folders/executor.ts | 14 ++- src/card-controller/folders/ha/engine.ts | 32 ++++++- src/card-controller/folders/manager.ts | 9 +- src/card-controller/folders/types.ts | 2 + .../gallery/folder-gallery-controller.ts | 16 ++-- src/components/gallery/folder-gallery.ts | 6 +- src/components/views.ts | 1 + .../card-controller/folders/executor.test.ts | 32 +++++++ .../card-controller/folders/ha/engine.test.ts | 88 +++++++++++++++++-- tests/card-controller/folders/manager.test.ts | 20 +++++ .../gallery/folder-gallery-controller.test.ts | 45 +++++++++- 11 files changed, 244 insertions(+), 21 deletions(-) diff --git a/src/card-controller/folders/executor.ts b/src/card-controller/folders/executor.ts index 5ae62c49..a5ba346d 100644 --- a/src/card-controller/folders/executor.ts +++ b/src/card-controller/folders/executor.ts @@ -2,7 +2,7 @@ import { ConditionState } from '../../conditions/types'; import { FolderConfig, FolderType, folderTypeSchema } from '../../config/schema/folders'; import { HomeAssistant } from '../../ha/types'; import { Endpoint } from '../../types'; -import { ViewItem } from '../../view/item'; +import { ViewFolder, ViewItem } from '../../view/item'; import { ViewItemCapabilities } from '../../view/types'; import { sortItems } from '../view/sort'; import { HAFoldersEngine } from './ha/engine'; @@ -21,6 +21,18 @@ export class FoldersExecutor { ); } + public generateChildFolderQuery( + query: FolderQuery, + folder: ViewFolder, + ): FolderQuery | null { + return ( + this._getFolderEngine(query.folder.type)?.generateChildFolderQuery( + query, + folder, + ) ?? null + ); + } + public async expandFolder( hass: HomeAssistant, query: FolderQuery, diff --git a/src/card-controller/folders/ha/engine.ts b/src/card-controller/folders/ha/engine.ts index 4342a970..500c6381 100644 --- a/src/card-controller/folders/ha/engine.ts +++ b/src/card-controller/folders/ha/engine.ts @@ -4,8 +4,8 @@ import { FolderConfig, folderTypeSchema, HA_MEDIA_SOURCE_ROOT, - HAFolderConfig, HAFolderPathComponent, + HAFolderConfig, } from '../../../config/schema/folders'; import { getViewItemsFromBrowseMediaArray } from '../../../ha/browse-media/browse-media-to-view-media'; import { BrowseMediaViewFolder } from '../../../ha/browse-media/item'; @@ -23,15 +23,15 @@ import { import { getMediaDownloadPath } from '../../../ha/download'; import { HomeAssistant } from '../../../ha/types'; import { Endpoint } from '../../../types'; -import { ViewItem } from '../../../view/item'; +import { ViewFolder, ViewItem } from '../../../view/item'; import { ViewItemClassifier } from '../../../view/item-classifier'; import { ViewItemCapabilities } from '../../../view/types'; import { DownloadHelpers, EngineOptions, - FolderPathComponent, FolderQuery, FoldersEngine, + FolderPathComponent, } from '../types'; import { MediaMatcher } from './media-matcher'; import { MetadataGenerator } from './metadata-generator.js'; @@ -96,7 +96,8 @@ export class HAFoldersEngine implements FoldersEngine { private getDefaultFolderPathComponents( haFolderConfig?: HAFolderConfig, ): NonEmptyTuple { - const shouldAddDefaultRoot = !haFolderConfig?.url && !haFolderConfig?.path?.[0]?.id; + const shouldAddDefaultRoot = + !haFolderConfig?.url && haFolderConfig?.path?.[0]?.id !== HA_MEDIA_SOURCE_ROOT; const path: HAFolderPathComponent[] = [ ...(shouldAddDefaultRoot ? [{ id: HA_MEDIA_SOURCE_ROOT }] : []), @@ -190,4 +191,27 @@ export class HAFoldersEngine implements FoldersEngine { folder: query.folder, }); } + + public generateChildFolderQuery( + query: FolderQuery, + folder: ViewFolder, + ): FolderQuery | null { + const id = folder.getID(); + if (query.folder.type !== folderTypeSchema.enum.ha || !id) { + return null; + } + + // Get the full configured path to find parsers/matchers for this depth. + const fullPath = this.getDefaultFolderPathComponents(query.folder.ha); + const nextConfiguredComponent = fullPath[query.path.length]; + + // Use the configured component's parsers/matchers if available, otherwise + // just use the ID from the folder. + const ha = nextConfiguredComponent?.ha ?? { id }; + + return { + ...query, + path: [...query.path, { folder, ha }], + }; + } } diff --git a/src/card-controller/folders/manager.ts b/src/card-controller/folders/manager.ts index 955221e6..7d1beb64 100644 --- a/src/card-controller/folders/manager.ts +++ b/src/card-controller/folders/manager.ts @@ -3,7 +3,7 @@ import { ConditionState } from '../../conditions/types'; import { FolderConfig, FolderConfigWithoutID } from '../../config/schema/folders'; import { localize } from '../../localize/localize'; import { Endpoint } from '../../types'; -import { ViewItem } from '../../view/item'; +import { ViewFolder, ViewItem } from '../../view/item'; import { ViewItemCapabilities } from '../../view/types'; import { CardFoldersAPI } from '../types'; import { FoldersExecutor } from './executor'; @@ -62,6 +62,13 @@ export class FoldersManager { return _folder ? this._executor.generateDefaultFolderQuery(_folder) : null; } + public generateChildFolderQuery( + query: FolderQuery, + folder: ViewFolder, + ): FolderQuery | null { + return this._executor.generateChildFolderQuery(query, folder); + } + public async expandFolder( query: FolderQuery, conditionState?: ConditionState, diff --git a/src/card-controller/folders/types.ts b/src/card-controller/folders/types.ts index c2935d80..cea087c9 100644 --- a/src/card-controller/folders/types.ts +++ b/src/card-controller/folders/types.ts @@ -48,6 +48,8 @@ export interface DownloadHelpers { export interface FoldersEngine { generateDefaultFolderQuery(folder: FolderConfig): FolderQuery | null; + generateChildFolderQuery(query: FolderQuery, folder: ViewFolder): FolderQuery | null; + expandFolder( hass: HomeAssistant, query: FolderQuery, diff --git a/src/components-lib/gallery/folder-gallery-controller.ts b/src/components-lib/gallery/folder-gallery-controller.ts index bbbcfff9..c0a85248 100644 --- a/src/components-lib/gallery/folder-gallery-controller.ts +++ b/src/components-lib/gallery/folder-gallery-controller.ts @@ -1,3 +1,4 @@ +import { FoldersManager } from '../../card-controller/folders/manager'; import { ViewManagerInterface } from '../../card-controller/view/types'; import { THUMBNAIL_WIDTH_DEFAULT } from '../../config/schema/common/controls/thumbnails'; import { MediaGalleryThumbnailsConfig } from '../../config/schema/media-gallery'; @@ -43,6 +44,7 @@ export class FolderGalleryController { viewManager: ViewManagerInterface, item: ViewItem, ev: Event, + foldersManager?: FoldersManager, ): void { stopEventFromActivatingCardWideActions(ev); @@ -64,16 +66,18 @@ export class FolderGalleryController { QueryClassifier.isFolderQuery(view.query) ) { const rawQuery = view.query.getQuery(); - const id = item.getID(); - if (!rawQuery || !id) { + if (!rawQuery || !foldersManager) { return; } + + const newQuery = foldersManager.generateChildFolderQuery(rawQuery, item); + if (!newQuery) { + return; + } + viewManager.setViewByParametersWithExistingQuery({ params: { - query: view.query.clone().setQuery({ - folder: rawQuery.folder, - path: [...rawQuery.path, { folder: item }], - }), + query: view.query.clone().setQuery(newQuery), }, }); } diff --git a/src/components/gallery/folder-gallery.ts b/src/components/gallery/folder-gallery.ts index 4d499d0c..178600a3 100644 --- a/src/components/gallery/folder-gallery.ts +++ b/src/components/gallery/folder-gallery.ts @@ -8,6 +8,7 @@ import { } from 'lit'; import { customElement, property } from 'lit/decorators.js'; import { classMap } from 'lit/directives/class-map.js'; +import { FoldersManager } from '../../card-controller/folders/manager.js'; import { ViewItemManager } from '../../card-controller/view/item-manager.js'; import { ViewManagerEpoch } from '../../card-controller/view/types.js'; import { @@ -41,6 +42,9 @@ export class AdvancedCameraCardFolderGallery extends LitElement { @property({ attribute: false }) public galleryConfig?: MediaGalleryConfig; + @property({ attribute: false }) + public foldersManager?: FoldersManager; + protected _controller = new FolderGalleryController(this); protected willUpdate(changedProps: PropertyValues): void { @@ -88,7 +92,7 @@ export class AdvancedCameraCardFolderGallery extends LitElement { this._renderThumbnail(item, item === selected, (item: ViewItem, ev: Event) => { const manager = this.viewManagerEpoch?.manager; if (manager) { - this._controller.itemClickHandler(manager, item, ev); + this._controller.itemClickHandler(manager, item, ev, this.foldersManager); } }), )} diff --git a/src/components/views.ts b/src/components/views.ts index ae6616fd..c7506d77 100644 --- a/src/components/views.ts +++ b/src/components/views.ts @@ -218,6 +218,7 @@ export class AdvancedCameraCardViews extends LitElement { .viewManagerEpoch=${this.viewManagerEpoch} .viewItemManager=${this.viewItemManager} .galleryConfig=${this.config.media_gallery} + .foldersManager=${this.foldersManager} >` : ``} ${ diff --git a/tests/card-controller/folders/executor.test.ts b/tests/card-controller/folders/executor.test.ts index d005f75a..9bfe61e4 100644 --- a/tests/card-controller/folders/executor.test.ts +++ b/tests/card-controller/folders/executor.test.ts @@ -130,6 +130,38 @@ describe('FoldersExecutor', () => { }); }); + describe('generateChildFolderQuery', () => { + it('should generate child folder query', () => { + const folder: FolderConfig = createFolder(); + const query: FolderQuery = { + folder, + path: [{ ha: { id: 'media-source://' } }], + }; + const viewFolder = new ViewFolder(folder); + + const haFolderEngine = mock(); + haFolderEngine.generateChildFolderQuery.mockReturnValue(query); + const executor = new FoldersExecutor({ ha: haFolderEngine }); + + expect(executor.generateChildFolderQuery(query, viewFolder)).toEqual(query); + expect(haFolderEngine.generateChildFolderQuery).toBeCalledWith(query, viewFolder); + }); + + it('should return null for non-existent folder engine', () => { + const folder: FolderConfig = { + type: 'UNKNOWN', + } as unknown as FolderConfig; + const query: FolderQuery = { + folder, + path: [{ ha: { id: 'media-source://' } }], + }; + const viewFolder = new ViewFolder(folder); + const executor = new FoldersExecutor(); + + expect(executor.generateChildFolderQuery(query, viewFolder)).toBeNull(); + }); + }); + describe('expandFolder', () => { it('should reject folders of the wrong type', async () => { const query = { diff --git a/tests/card-controller/folders/ha/engine.test.ts b/tests/card-controller/folders/ha/engine.test.ts index c95276cd..a9dabfc4 100644 --- a/tests/card-controller/folders/ha/engine.test.ts +++ b/tests/card-controller/folders/ha/engine.test.ts @@ -76,7 +76,7 @@ describe('HAFoldersEngine', () => { describe('should generate default folder query', () => { it('should generate default folder query', () => { - const folder: FolderConfig = { type: 'ha' }; + const folder: FolderConfig = { type: 'ha', id: 'test' }; const engine = new HAFoldersEngine(); const query = engine.generateDefaultFolderQuery(folder); @@ -143,7 +143,7 @@ describe('HAFoldersEngine', () => { it('should expand folder with cache by default', async () => { const query: FolderQuery = { - folder: { type: 'ha' }, + folder: { type: 'ha', id: 'test' }, path: [{ ha: { id: 'media-source://id' } }], }; @@ -180,7 +180,7 @@ describe('HAFoldersEngine', () => { it('should expand folder without cache when requested', async () => { const query: FolderQuery = { - folder: { type: 'ha' }, + folder: { type: 'ha', id: 'test' }, path: [{ ha: { id: 'media-source://id' } }], }; @@ -231,7 +231,7 @@ describe('HAFoldersEngine', () => { }); const query: FolderQuery = { - folder: { type: 'ha' }, + folder: { type: 'ha', id: 'test' }, path: [ { folder: new BrowseMediaViewFolder(createFolder(), browseMedia), @@ -259,7 +259,7 @@ describe('HAFoldersEngine', () => { it('should not expand without a folder with an id', async () => { const query: FolderQuery = { - folder: { type: 'ha' }, + folder: { type: 'ha', id: 'test' }, // There's no component in the query with an id to start from. path: [{ ha: {} }], }; @@ -293,7 +293,7 @@ describe('HAFoldersEngine', () => { ], ])('%s', async (_name: string, matcher: Matcher, expectedMatches: number) => { const query: FolderQuery = { - folder: { type: 'ha' }, + folder: { type: 'ha', id: 'test' }, path: [{ ha: { id: 'media-source://' } }, { ha: { matchers: [matcher] } }, {}], }; @@ -330,4 +330,80 @@ describe('HAFoldersEngine', () => { }); }); }); + + describe('generateChildFolderQuery', () => { + it('should return null if folder type is not ha', () => { + const engine = new HAFoldersEngine(); + const query: FolderQuery = { + folder: { type: 'other' } as unknown as FolderConfig, + path: [{ ha: { id: 'root' } }], + }; + const folder = new ViewFolder(createFolder()); + + expect(engine.generateChildFolderQuery(query, folder)).toBeNull(); + }); + + it('should return null if folder has no id', () => { + const engine = new HAFoldersEngine(); + const query: FolderQuery = { + folder: { type: 'ha', id: 'test' }, + path: [{ ha: { id: 'root' } }], + }; + const folder = new ViewFolder(createFolder(), { id: '' }); + + expect(engine.generateChildFolderQuery(query, folder)).toBeNull(); + }); + + it('should extend query with configured component', () => { + const engine = new HAFoldersEngine(); + const folderConfig: FolderConfig = { + type: 'ha', + id: 'test', + ha: { + path: [{ id: 'child', matchers: [{ type: 'title', title: 'foo' }] }], + }, + }; + const query: FolderQuery = { + folder: folderConfig, + path: [{ ha: { id: 'media-source://' } }], + }; + const folder = new ViewFolder(createFolder(), { id: 'child' }); + + const result = engine.generateChildFolderQuery(query, folder); + + expect(result).toEqual({ + folder: folderConfig, + path: [ + { ha: { id: 'media-source://' } }, + { + folder, + ha: { id: 'child', matchers: [{ type: 'title', title: 'foo' }] }, + }, + ], + }); + }); + + it('should extend query with default component when no configuration exists', () => { + const engine = new HAFoldersEngine(); + const folderConfig: FolderConfig = { type: 'ha', id: 'test' }; + const query: FolderQuery = { + folder: folderConfig, + path: [{ ha: { id: 'media-source://' } }], + }; + const folder = new ViewFolder(createFolder(), { id: 'child' }); + + const result = engine.generateChildFolderQuery(query, folder); + + expect(result).toEqual({ + folder: folderConfig, + path: [ + { ha: { id: 'media-source://' } }, + { + folder, + ha: { id: 'child' }, + }, + ], + }); + }); + }); }); diff --git a/tests/card-controller/folders/manager.test.ts b/tests/card-controller/folders/manager.test.ts index 4c6bd332..050a09c7 100644 --- a/tests/card-controller/folders/manager.test.ts +++ b/tests/card-controller/folders/manager.test.ts @@ -6,6 +6,7 @@ import { FolderQuery } from '../../../src/card-controller/folders/types'; import { FolderConfig, FolderConfigWithoutID } from '../../../src/config/schema/folders'; import { ResolvedMediaCache } from '../../../src/ha/resolved-media'; import { Endpoint } from '../../../src/types'; +import { ViewFolder } from '../../../src/view/item'; import { ViewItemCapabilities } from '../../../src/view/types'; import { createCardAPI, @@ -180,6 +181,25 @@ describe('FoldersManager', () => { }); }); + describe('generateChildFolderQuery', () => { + it('should generate child folder query', () => { + const folder: FolderConfig = createFolder(); + const query: FolderQuery = { + folder, + path: [{ ha: { id: 'media-source://' } }], + }; + const viewFolder = new ViewFolder(folder); + + const executor = mock(); + vi.mocked(executor.generateChildFolderQuery).mockReturnValue(query); + + const manager = new FoldersManager(createCardAPI(), executor); + + expect(manager.generateChildFolderQuery(query, viewFolder)).toEqual(query); + expect(executor.generateChildFolderQuery).toBeCalledWith(query, viewFolder); + }); + }); + describe('should expand folder', () => { it('should expand folder with hass', async () => { const hass = createHASS(); diff --git a/tests/components-lib/gallery/folder-gallery-controller.test.ts b/tests/components-lib/gallery/folder-gallery-controller.test.ts index 1441a8d3..0771c5bb 100644 --- a/tests/components-lib/gallery/folder-gallery-controller.test.ts +++ b/tests/components-lib/gallery/folder-gallery-controller.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { mock } from 'vitest-mock-extended'; import { ViewManagerInterface } from '../../../src/card-controller/view/types'; +import { FoldersManager } from '../../../src/card-controller/folders/manager'; import { FOLDER_GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN, FolderGalleryController, @@ -168,7 +169,16 @@ describe('FolderGalleryController', () => { const viewManager = mock(); viewManager.getView.mockReturnValue(view); - controller.itemClickHandler(viewManager, folderItem, event); + const foldersManager = mock(); + foldersManager.generateChildFolderQuery.mockReturnValue({ + folder, + path: [ + { ha: { id: 'grandparent' } }, + { folder: folderItem, ha: { id: 'parent' } }, + ], + }); + + controller.itemClickHandler(viewManager, folderItem, event, foldersManager); expect(viewManager.setViewByParametersWithExistingQuery).toHaveBeenCalledWith({ params: { @@ -182,10 +192,41 @@ describe('FolderGalleryController', () => { expect(newQuery).toBeInstanceOf(FolderViewQuery); expect(newQuery?.getQuery()).toEqual({ folder, - path: [{ ha: { id: 'grandparent' } }, { folder: folderItem }], + path: [ + { ha: { id: 'grandparent' } }, + { folder: folderItem, ha: { id: 'parent' } }, + ], }); }); + it('should ignore folder click without folders manager', () => { + const folder = createFolder(); + const folderItem = new ViewFolder(folder, { + id: 'parent', + }); + + const controller = new FolderGalleryController(document.createElement('div')); + const event = new Event('click'); + + const view = createView({ + queryResults: new QueryResults({ + results: [new TestViewMedia(), folderItem], + selectedIndex: 0, + }), + query: new FolderViewQuery({ + folder, + path: [{ ha: { id: 'grandparent' } }], + }), + }); + + const viewManager = mock(); + viewManager.getView.mockReturnValue(view); + + controller.itemClickHandler(viewManager, folderItem, event); + + expect(viewManager.setViewByParametersWithExistingQuery).not.toHaveBeenCalled(); + }); + it('should handle folder click without query', () => { const controller = new FolderGalleryController(document.createElement('div'));