fix: Preserve parsers/matches on folder navigation (#2249)

- Closes #2231
This commit is contained in:
Dermot Duffy
2025-11-26 10:00:34 -08:00
committed by GitHub
parent 1f6a4ddce2
commit 993e288c14
11 changed files with 244 additions and 21 deletions
+13 -1
View File
@@ -2,7 +2,7 @@ import { ConditionState } from '../../conditions/types';
import { FolderConfig, FolderType, folderTypeSchema } from '../../config/schema/folders'; import { FolderConfig, FolderType, folderTypeSchema } from '../../config/schema/folders';
import { HomeAssistant } from '../../ha/types'; import { HomeAssistant } from '../../ha/types';
import { Endpoint } from '../../types'; import { Endpoint } from '../../types';
import { ViewItem } from '../../view/item'; import { ViewFolder, ViewItem } from '../../view/item';
import { ViewItemCapabilities } from '../../view/types'; import { ViewItemCapabilities } from '../../view/types';
import { sortItems } from '../view/sort'; import { sortItems } from '../view/sort';
import { HAFoldersEngine } from './ha/engine'; 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( public async expandFolder(
hass: HomeAssistant, hass: HomeAssistant,
query: FolderQuery, query: FolderQuery,
+28 -4
View File
@@ -4,8 +4,8 @@ import {
FolderConfig, FolderConfig,
folderTypeSchema, folderTypeSchema,
HA_MEDIA_SOURCE_ROOT, HA_MEDIA_SOURCE_ROOT,
HAFolderConfig,
HAFolderPathComponent, HAFolderPathComponent,
HAFolderConfig,
} from '../../../config/schema/folders'; } from '../../../config/schema/folders';
import { getViewItemsFromBrowseMediaArray } from '../../../ha/browse-media/browse-media-to-view-media'; import { getViewItemsFromBrowseMediaArray } from '../../../ha/browse-media/browse-media-to-view-media';
import { BrowseMediaViewFolder } from '../../../ha/browse-media/item'; import { BrowseMediaViewFolder } from '../../../ha/browse-media/item';
@@ -23,15 +23,15 @@ import {
import { getMediaDownloadPath } from '../../../ha/download'; import { getMediaDownloadPath } from '../../../ha/download';
import { HomeAssistant } from '../../../ha/types'; import { HomeAssistant } from '../../../ha/types';
import { Endpoint } from '../../../types'; import { Endpoint } from '../../../types';
import { ViewItem } from '../../../view/item'; import { ViewFolder, ViewItem } from '../../../view/item';
import { ViewItemClassifier } from '../../../view/item-classifier'; import { ViewItemClassifier } from '../../../view/item-classifier';
import { ViewItemCapabilities } from '../../../view/types'; import { ViewItemCapabilities } from '../../../view/types';
import { import {
DownloadHelpers, DownloadHelpers,
EngineOptions, EngineOptions,
FolderPathComponent,
FolderQuery, FolderQuery,
FoldersEngine, FoldersEngine,
FolderPathComponent,
} from '../types'; } from '../types';
import { MediaMatcher } from './media-matcher'; import { MediaMatcher } from './media-matcher';
import { MetadataGenerator } from './metadata-generator.js'; import { MetadataGenerator } from './metadata-generator.js';
@@ -96,7 +96,8 @@ export class HAFoldersEngine implements FoldersEngine {
private getDefaultFolderPathComponents( private getDefaultFolderPathComponents(
haFolderConfig?: HAFolderConfig, haFolderConfig?: HAFolderConfig,
): NonEmptyTuple<FolderPathComponent> { ): NonEmptyTuple<FolderPathComponent> {
const shouldAddDefaultRoot = !haFolderConfig?.url && !haFolderConfig?.path?.[0]?.id; const shouldAddDefaultRoot =
!haFolderConfig?.url && haFolderConfig?.path?.[0]?.id !== HA_MEDIA_SOURCE_ROOT;
const path: HAFolderPathComponent[] = [ const path: HAFolderPathComponent[] = [
...(shouldAddDefaultRoot ? [{ id: HA_MEDIA_SOURCE_ROOT }] : []), ...(shouldAddDefaultRoot ? [{ id: HA_MEDIA_SOURCE_ROOT }] : []),
@@ -190,4 +191,27 @@ export class HAFoldersEngine implements FoldersEngine {
folder: query.folder, 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 }],
};
}
} }
+8 -1
View File
@@ -3,7 +3,7 @@ import { ConditionState } from '../../conditions/types';
import { FolderConfig, FolderConfigWithoutID } from '../../config/schema/folders'; import { FolderConfig, FolderConfigWithoutID } from '../../config/schema/folders';
import { localize } from '../../localize/localize'; import { localize } from '../../localize/localize';
import { Endpoint } from '../../types'; import { Endpoint } from '../../types';
import { ViewItem } from '../../view/item'; import { ViewFolder, ViewItem } from '../../view/item';
import { ViewItemCapabilities } from '../../view/types'; import { ViewItemCapabilities } from '../../view/types';
import { CardFoldersAPI } from '../types'; import { CardFoldersAPI } from '../types';
import { FoldersExecutor } from './executor'; import { FoldersExecutor } from './executor';
@@ -62,6 +62,13 @@ export class FoldersManager {
return _folder ? this._executor.generateDefaultFolderQuery(_folder) : null; return _folder ? this._executor.generateDefaultFolderQuery(_folder) : null;
} }
public generateChildFolderQuery(
query: FolderQuery,
folder: ViewFolder,
): FolderQuery | null {
return this._executor.generateChildFolderQuery(query, folder);
}
public async expandFolder( public async expandFolder(
query: FolderQuery, query: FolderQuery,
conditionState?: ConditionState, conditionState?: ConditionState,
+2
View File
@@ -48,6 +48,8 @@ export interface DownloadHelpers {
export interface FoldersEngine { export interface FoldersEngine {
generateDefaultFolderQuery(folder: FolderConfig): FolderQuery | null; generateDefaultFolderQuery(folder: FolderConfig): FolderQuery | null;
generateChildFolderQuery(query: FolderQuery, folder: ViewFolder): FolderQuery | null;
expandFolder( expandFolder(
hass: HomeAssistant, hass: HomeAssistant,
query: FolderQuery, query: FolderQuery,
@@ -1,3 +1,4 @@
import { FoldersManager } from '../../card-controller/folders/manager';
import { ViewManagerInterface } from '../../card-controller/view/types'; import { ViewManagerInterface } from '../../card-controller/view/types';
import { THUMBNAIL_WIDTH_DEFAULT } from '../../config/schema/common/controls/thumbnails'; import { THUMBNAIL_WIDTH_DEFAULT } from '../../config/schema/common/controls/thumbnails';
import { MediaGalleryThumbnailsConfig } from '../../config/schema/media-gallery'; import { MediaGalleryThumbnailsConfig } from '../../config/schema/media-gallery';
@@ -43,6 +44,7 @@ export class FolderGalleryController {
viewManager: ViewManagerInterface, viewManager: ViewManagerInterface,
item: ViewItem, item: ViewItem,
ev: Event, ev: Event,
foldersManager?: FoldersManager,
): void { ): void {
stopEventFromActivatingCardWideActions(ev); stopEventFromActivatingCardWideActions(ev);
@@ -64,16 +66,18 @@ export class FolderGalleryController {
QueryClassifier.isFolderQuery(view.query) QueryClassifier.isFolderQuery(view.query)
) { ) {
const rawQuery = view.query.getQuery(); const rawQuery = view.query.getQuery();
const id = item.getID(); if (!rawQuery || !foldersManager) {
if (!rawQuery || !id) {
return; return;
} }
const newQuery = foldersManager.generateChildFolderQuery(rawQuery, item);
if (!newQuery) {
return;
}
viewManager.setViewByParametersWithExistingQuery({ viewManager.setViewByParametersWithExistingQuery({
params: { params: {
query: view.query.clone().setQuery({ query: view.query.clone().setQuery(newQuery),
folder: rawQuery.folder,
path: [...rawQuery.path, { folder: item }],
}),
}, },
}); });
} }
+5 -1
View File
@@ -8,6 +8,7 @@ import {
} from 'lit'; } from 'lit';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.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 { ViewItemManager } from '../../card-controller/view/item-manager.js';
import { ViewManagerEpoch } from '../../card-controller/view/types.js'; import { ViewManagerEpoch } from '../../card-controller/view/types.js';
import { import {
@@ -41,6 +42,9 @@ export class AdvancedCameraCardFolderGallery extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public galleryConfig?: MediaGalleryConfig; public galleryConfig?: MediaGalleryConfig;
@property({ attribute: false })
public foldersManager?: FoldersManager;
protected _controller = new FolderGalleryController(this); protected _controller = new FolderGalleryController(this);
protected willUpdate(changedProps: PropertyValues): void { protected willUpdate(changedProps: PropertyValues): void {
@@ -88,7 +92,7 @@ export class AdvancedCameraCardFolderGallery extends LitElement {
this._renderThumbnail(item, item === selected, (item: ViewItem, ev: Event) => { this._renderThumbnail(item, item === selected, (item: ViewItem, ev: Event) => {
const manager = this.viewManagerEpoch?.manager; const manager = this.viewManagerEpoch?.manager;
if (manager) { if (manager) {
this._controller.itemClickHandler(manager, item, ev); this._controller.itemClickHandler(manager, item, ev, this.foldersManager);
} }
}), }),
)} )}
+1
View File
@@ -218,6 +218,7 @@ export class AdvancedCameraCardViews extends LitElement {
.viewManagerEpoch=${this.viewManagerEpoch} .viewManagerEpoch=${this.viewManagerEpoch}
.viewItemManager=${this.viewItemManager} .viewItemManager=${this.viewItemManager}
.galleryConfig=${this.config.media_gallery} .galleryConfig=${this.config.media_gallery}
.foldersManager=${this.foldersManager}
></advanced-camera-card-folder-gallery>` ></advanced-camera-card-folder-gallery>`
: ``} : ``}
${ ${
@@ -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<HAFoldersEngine>();
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', () => { describe('expandFolder', () => {
it('should reject folders of the wrong type', async () => { it('should reject folders of the wrong type', async () => {
const query = { const query = {
@@ -76,7 +76,7 @@ describe('HAFoldersEngine', () => {
describe('should generate default folder query', () => { describe('should generate default folder query', () => {
it('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 engine = new HAFoldersEngine();
const query = engine.generateDefaultFolderQuery(folder); const query = engine.generateDefaultFolderQuery(folder);
@@ -143,7 +143,7 @@ describe('HAFoldersEngine', () => {
it('should expand folder with cache by default', async () => { it('should expand folder with cache by default', async () => {
const query: FolderQuery = { const query: FolderQuery = {
folder: { type: 'ha' }, folder: { type: 'ha', id: 'test' },
path: [{ ha: { id: 'media-source://id' } }], path: [{ ha: { id: 'media-source://id' } }],
}; };
@@ -180,7 +180,7 @@ describe('HAFoldersEngine', () => {
it('should expand folder without cache when requested', async () => { it('should expand folder without cache when requested', async () => {
const query: FolderQuery = { const query: FolderQuery = {
folder: { type: 'ha' }, folder: { type: 'ha', id: 'test' },
path: [{ ha: { id: 'media-source://id' } }], path: [{ ha: { id: 'media-source://id' } }],
}; };
@@ -231,7 +231,7 @@ describe('HAFoldersEngine', () => {
}); });
const query: FolderQuery = { const query: FolderQuery = {
folder: { type: 'ha' }, folder: { type: 'ha', id: 'test' },
path: [ path: [
{ {
folder: new BrowseMediaViewFolder(createFolder(), browseMedia), folder: new BrowseMediaViewFolder(createFolder(), browseMedia),
@@ -259,7 +259,7 @@ describe('HAFoldersEngine', () => {
it('should not expand without a folder with an id', async () => { it('should not expand without a folder with an id', async () => {
const query: FolderQuery = { const query: FolderQuery = {
folder: { type: 'ha' }, folder: { type: 'ha', id: 'test' },
// There's no component in the query with an id to start from. // There's no component in the query with an id to start from.
path: [{ ha: {} }], path: [{ ha: {} }],
}; };
@@ -293,7 +293,7 @@ describe('HAFoldersEngine', () => {
], ],
])('%s', async (_name: string, matcher: Matcher, expectedMatches: number) => { ])('%s', async (_name: string, matcher: Matcher, expectedMatches: number) => {
const query: FolderQuery = { const query: FolderQuery = {
folder: { type: 'ha' }, folder: { type: 'ha', id: 'test' },
path: [{ ha: { id: 'media-source://' } }, { ha: { matchers: [matcher] } }, {}], 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' },
},
],
});
});
});
}); });
@@ -6,6 +6,7 @@ import { FolderQuery } from '../../../src/card-controller/folders/types';
import { FolderConfig, FolderConfigWithoutID } from '../../../src/config/schema/folders'; import { FolderConfig, FolderConfigWithoutID } from '../../../src/config/schema/folders';
import { ResolvedMediaCache } from '../../../src/ha/resolved-media'; import { ResolvedMediaCache } from '../../../src/ha/resolved-media';
import { Endpoint } from '../../../src/types'; import { Endpoint } from '../../../src/types';
import { ViewFolder } from '../../../src/view/item';
import { ViewItemCapabilities } from '../../../src/view/types'; import { ViewItemCapabilities } from '../../../src/view/types';
import { import {
createCardAPI, 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<FoldersExecutor>();
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', () => { describe('should expand folder', () => {
it('should expand folder with hass', async () => { it('should expand folder with hass', async () => {
const hass = createHASS(); const hass = createHASS();
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { mock } from 'vitest-mock-extended'; import { mock } from 'vitest-mock-extended';
import { ViewManagerInterface } from '../../../src/card-controller/view/types'; import { ViewManagerInterface } from '../../../src/card-controller/view/types';
import { FoldersManager } from '../../../src/card-controller/folders/manager';
import { import {
FOLDER_GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN, FOLDER_GALLERY_THUMBNAIL_DETAILS_WIDTH_MIN,
FolderGalleryController, FolderGalleryController,
@@ -168,7 +169,16 @@ describe('FolderGalleryController', () => {
const viewManager = mock<ViewManagerInterface>(); const viewManager = mock<ViewManagerInterface>();
viewManager.getView.mockReturnValue(view); viewManager.getView.mockReturnValue(view);
controller.itemClickHandler(viewManager, folderItem, event); const foldersManager = mock<FoldersManager>();
foldersManager.generateChildFolderQuery.mockReturnValue({
folder,
path: [
{ ha: { id: 'grandparent' } },
{ folder: folderItem, ha: { id: 'parent' } },
],
});
controller.itemClickHandler(viewManager, folderItem, event, foldersManager);
expect(viewManager.setViewByParametersWithExistingQuery).toHaveBeenCalledWith({ expect(viewManager.setViewByParametersWithExistingQuery).toHaveBeenCalledWith({
params: { params: {
@@ -182,10 +192,41 @@ describe('FolderGalleryController', () => {
expect(newQuery).toBeInstanceOf(FolderViewQuery); expect(newQuery).toBeInstanceOf(FolderViewQuery);
expect(newQuery?.getQuery()).toEqual({ expect(newQuery?.getQuery()).toEqual({
folder, 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<ViewManagerInterface>();
viewManager.getView.mockReturnValue(view);
controller.itemClickHandler(viewManager, folderItem, event);
expect(viewManager.setViewByParametersWithExistingQuery).not.toHaveBeenCalled();
});
it('should handle folder click without query', () => { it('should handle folder click without query', () => {
const controller = new FolderGalleryController(document.createElement('div')); const controller = new FolderGalleryController(document.createElement('div'));