feat: Add or and template folder media matchers (#2071)

- For: #1748
This commit is contained in:
Dermot Duffy
2025-05-29 21:21:59 -07:00
committed by GitHub
parent c077f8bf70
commit dc63dcdab9
25 changed files with 502 additions and 145 deletions
+3
View File
@@ -1,3 +1,4 @@
import { ConditionState } from '../../conditions/types';
import { FolderConfig, FolderType, folderTypeSchema } from '../../config/schema/folders';
import { HomeAssistant } from '../../ha/types';
import { Endpoint } from '../../types';
@@ -23,12 +24,14 @@ export class FoldersExecutor {
public async expandFolder(
hass: HomeAssistant,
query: FolderQuery,
conditionState?: ConditionState,
engineOptions?: EngineOptions,
): Promise<ViewItem[] | null> {
const results =
(await this._getFolderEngine(query.folder.type)?.expandFolder(
hass,
query,
conditionState,
engineOptions,
)) ?? null;
return results ? sortItems(results) : null;
+7 -5
View File
@@ -1,4 +1,5 @@
import { NonEmptyTuple } from 'type-fest';
import { ConditionState } from '../../../conditions/types';
import {
FolderConfig,
folderTypeSchema,
@@ -112,6 +113,7 @@ export class HAFoldersEngine implements FoldersEngine {
public async expandFolder(
hass: HomeAssistant,
query: FolderQuery,
conditionState?: ConditionState,
engineOptions?: EngineOptions,
): Promise<ViewItem[] | null> {
if (query.folder.type !== folderTypeSchema.enum.ha) {
@@ -163,13 +165,13 @@ export class HAFoldersEngine implements FoldersEngine {
...(nextComponent && {
matcher: (media: BrowseMedia) =>
this._mediaMatcher.match(
media,
nextComponent.ha?.matchers,
this._mediaMatcher.match(hass, media, {
matchers: nextComponent.ha?.matchers,
// Set foldersOnly to true if there are more stages in the path,
// as by definition only folders can be matched at this point.
pathComponents.length > 0,
),
foldersOnly: pathComponents.length > 0,
conditionState,
}),
advance: (targets) => (pathComponents.length ? generateStep(targets) : []),
}),
},
+61 -11
View File
@@ -1,29 +1,79 @@
import { Matcher } from '../../../config/schema/folders';
import { ConditionState } from '../../../conditions/types';
import { Matcher, TemplateMatcher, TitleMatcher } from '../../../config/schema/folders';
import { BrowseMedia } from '../../../ha/browse-media/types';
import { HomeAssistant } from '../../../ha/types';
import { regexpExtract } from '../../../utils/regexp-extract';
import { TemplateRenderer } from '../../templates';
import { REGEXP_GROUP_VALUE_KEY } from './types';
export class MediaMatcher {
public match(media: BrowseMedia, matchers?: Matcher[], foldersOnly = false): boolean {
if (foldersOnly && !media.can_expand) {
private _templateRenderer = new TemplateRenderer();
public match(
hass: HomeAssistant,
media: BrowseMedia,
options?: {
foldersOnly?: boolean;
matchers?: Matcher[];
conditionState?: ConditionState;
},
): boolean {
if (options?.foldersOnly && !media.can_expand) {
return false;
}
for (const matcher of matchers ?? []) {
if (matcher.type === 'title') {
if (!this._matchTitle(matcher, media.title)) {
return false;
}
for (const matcher of options?.matchers ?? []) {
switch (matcher.type) {
case 'template':
if (!this._matchTemplate(hass, matcher, media, options?.conditionState)) {
return false;
}
break;
case 'title':
if (!this._matchTitle(matcher, media)) {
return false;
}
break;
case 'or':
if (
!matcher.matchers.some((subMatcher) =>
this.match(hass, media, {
foldersOnly: options?.foldersOnly,
matchers: [subMatcher],
conditionState: options?.conditionState,
}),
)
) {
return false;
}
break;
}
}
return true;
}
private _matchTitle(matcher: Matcher, src: string): boolean {
private _matchTemplate(
hass: HomeAssistant,
matcher: TemplateMatcher,
media: BrowseMedia,
conditionState?: ConditionState,
): boolean {
return (
this._templateRenderer.renderRecursively(hass, matcher.value_template, {
conditionState,
mediaData: {
title: media.title,
is_folder: media.can_expand,
},
}) === true
);
}
private _matchTitle(matcher: TitleMatcher, media: BrowseMedia): boolean {
const valueToMatch = matcher.regexp
? regexpExtract(matcher.regexp, src, { groupName: REGEXP_GROUP_VALUE_KEY })
: src;
? regexpExtract(matcher.regexp, media.title, { groupName: REGEXP_GROUP_VALUE_KEY })
: media.title;
if (!valueToMatch) {
return false;
+5 -1
View File
@@ -1,4 +1,5 @@
import { cloneDeep } from 'lodash-es';
import { ConditionState } from '../../conditions/types';
import { FolderConfig } from '../../config/schema/folders';
import { localize } from '../../localize/localize';
import { Endpoint } from '../../types';
@@ -63,10 +64,13 @@ export class FoldersManager {
public async expandFolder(
query: FolderQuery,
conditionState?: ConditionState,
engineOptions?: EngineOptions,
): Promise<ViewItem[] | null> {
const hass = this._api.getHASSManager().getHASS();
return hass ? this._executor.expandFolder(hass, query, engineOptions) : null;
return hass
? this._executor.expandFolder(hass, query, conditionState, engineOptions)
: null;
}
public getItemCapabilities(item: ViewItem): ViewItemCapabilities | null {
+2
View File
@@ -1,4 +1,5 @@
import { NonEmptyTuple } from 'type-fest';
import { ConditionState } from '../../conditions/types';
import { FolderConfig, HAFolderPathComponent } from '../../config/schema/folders';
import { ResolvedMediaCache } from '../../ha/resolved-media';
import { HomeAssistant } from '../../ha/types';
@@ -47,6 +48,7 @@ export interface FoldersEngine {
expandFolder(
hass: HomeAssistant,
query: FolderQuery,
conditionState?: ConditionState,
engineOptions?: EngineOptions,
): Promise<ViewItem[] | null>;
+22 -12
View File
@@ -2,10 +2,15 @@ import { HASS, renderTemplate } from 'ha-nunjucks/dist';
import { ConditionState, ConditionsTriggerData } from '../../conditions/types';
import { HomeAssistant } from '../../ha/types';
interface TemplateMediaData {
title: string;
is_folder: boolean;
}
interface TemplateContextInternal {
camera?: string;
view?: string;
trigger?: ConditionsTriggerData;
media?: TemplateMediaData;
}
interface TemplateContext {
@@ -22,30 +27,35 @@ export class TemplateRenderer {
options?: {
conditionState?: ConditionState;
triggerData?: ConditionsTriggerData;
mediaData?: TemplateMediaData;
},
): unknown => {
return this._renderTemplateRecursively(
hass,
data,
this._conditionStateToTemplateContext(
options?.conditionState,
options?.triggerData,
),
this._generateTemplateContext(options),
);
};
protected _conditionStateToTemplateContext(
conditionState?: ConditionState,
triggerData?: ConditionsTriggerData,
): TemplateContext | undefined {
if (!conditionState?.camera && !conditionState?.view && !triggerData) {
protected _generateTemplateContext(options?: {
conditionState?: ConditionState;
triggerData?: ConditionsTriggerData;
mediaData?: TemplateMediaData;
}): TemplateContext | undefined {
if (
!options?.conditionState?.camera &&
!options?.conditionState?.view &&
!options?.triggerData &&
!options?.mediaData
) {
return;
}
const advancedCameraCardContext: TemplateContextInternal = {
...(conditionState?.camera && { camera: conditionState.camera }),
...(conditionState?.view && { view: conditionState.view }),
...(triggerData && { trigger: triggerData }),
...(options?.conditionState?.camera && { camera: options.conditionState.camera }),
...(options?.conditionState?.view && { view: options.conditionState.view }),
...(options?.triggerData && { trigger: options.triggerData }),
...(options?.mediaData && { media: options.mediaData }),
};
return {
+3 -1
View File
@@ -159,7 +159,9 @@ export class QueryExecutor {
}
const itemArray = await this._api
.getFoldersManager()
.expandFolder(rawQuery, { useCache: executorOptions?.useCache });
.expandFolder(rawQuery, this._api.getConditionStateManager().getState(), {
useCache: executorOptions?.useCache,
});
const queryResults = itemArray
? this._generateQueriesResults(itemArray, executorOptions)