From dc63dcdab9334f052febf1ba48e78536d72df5e4 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Thu, 29 May 2025 21:21:59 -0700 Subject: [PATCH] feat: Add `or` and `template` folder media matchers (#2071) - For: #1748 --- docs/configuration/_sidebar.md | 1 + docs/configuration/actions/_sidebar.md | 2 +- docs/configuration/actions/custom/_sidebar.md | 2 +- docs/configuration/actions/stock/_sidebar.md | 2 +- docs/configuration/actions/templates.md | 60 ------- docs/configuration/cameras/_sidebar.md | 1 + docs/configuration/elements/_sidebar.md | 1 + .../configuration/elements/custom/_sidebar.md | 1 + docs/configuration/elements/stock/_sidebar.md | 1 + docs/configuration/folders.md | 45 +++++- docs/configuration/templates.md | 76 +++++++++ docs/examples.md | 41 ++++- src/card-controller/folders/executor.ts | 3 + src/card-controller/folders/ha/engine.ts | 12 +- .../folders/ha/media-matcher.ts | 72 +++++++-- src/card-controller/folders/manager.ts | 6 +- src/card-controller/folders/types.ts | 2 + src/card-controller/templates/index.ts | 34 ++-- src/card-controller/view/query-executor.ts | 4 +- src/config/schema/folders.ts | 24 ++- .../card-controller/folders/executor.test.ts | 4 +- .../card-controller/folders/ha/engine.test.ts | 49 ++++-- .../folders/ha/media-matcher.test.ts | 148 ++++++++++++++++-- tests/card-controller/folders/manager.test.ts | 25 ++- tests/config/schema/folders.test.ts | 31 +++- 25 files changed, 502 insertions(+), 145 deletions(-) delete mode 100644 docs/configuration/actions/templates.md create mode 100644 docs/configuration/templates.md diff --git a/docs/configuration/_sidebar.md b/docs/configuration/_sidebar.md index 94db7468..bf360e84 100644 --- a/docs/configuration/_sidebar.md +++ b/docs/configuration/_sidebar.md @@ -19,6 +19,7 @@ - [`status_bar`](status-bar.md) - [`timeline`](timeline.md) - [`view`](view.md) + - [Templates](templates.md) - [Examples](../examples.md) - [Screenshots](../screenshots.md) - [Troubleshooting](../troubleshooting.md) diff --git a/docs/configuration/actions/_sidebar.md b/docs/configuration/actions/_sidebar.md index 70d82433..21e53312 100644 --- a/docs/configuration/actions/_sidebar.md +++ b/docs/configuration/actions/_sidebar.md @@ -3,7 +3,6 @@ - [`actions`](README.md) - [Custom Actions](./custom/README.md) - [Stock Actions](./stock/README.md) - - [Templates](./templates.md) - [`automations`](../automations.md) - [`cameras`](../cameras/README.md) - [`conditions`](../conditions.md) @@ -22,6 +21,7 @@ - [`status_bar`](../status-bar.md) - [`timeline`](../timeline.md) - [`view`](../view.md) + - [Templates](../templates.md) - [Examples](../../examples.md) - [Screenshots](../../screenshots.md) - [Troubleshooting](../../troubleshooting.md) diff --git a/docs/configuration/actions/custom/_sidebar.md b/docs/configuration/actions/custom/_sidebar.md index ee3398dd..910f3716 100644 --- a/docs/configuration/actions/custom/_sidebar.md +++ b/docs/configuration/actions/custom/_sidebar.md @@ -3,7 +3,6 @@ - [`actions`](../README.md) - [Custom Actions](README.md) - [Stock Actions](../stock/README.md) - - [Templates](../templates.md) - [`automations`](../../automations.md) - [`cameras`](../../cameras/README.md) - [`conditions`](../../conditions.md) @@ -22,6 +21,7 @@ - [`status_bar`](../../status-bar.md) - [`timeline`](../../timeline.md) - [`view`](../../view.md) + - [Templates](../../templates.md) - [Examples](../../../examples.md) - [Screenshots](../../../screenshots.md) - [Troubleshooting](../../../troubleshooting.md) diff --git a/docs/configuration/actions/stock/_sidebar.md b/docs/configuration/actions/stock/_sidebar.md index 04169528..e1bfea59 100644 --- a/docs/configuration/actions/stock/_sidebar.md +++ b/docs/configuration/actions/stock/_sidebar.md @@ -3,7 +3,6 @@ - [`actions`](../README.md) - [Custom Actions](../custom/README.md) - [Stock Actions](README.md) - - [Templates](../templates.md) - [`automations`](../../automations.md) - [`cameras`](../../cameras/README.md) - [`conditions`](../../conditions.md) @@ -22,6 +21,7 @@ - [`status_bar`](../../status-bar.md) - [`timeline`](../../timeline.md) - [`view`](../../view.md) + - [Templates](../../templates.md) - [Examples](../../../examples.md) - [Screenshots](../../../screenshots.md) - [Troubleshooting](../../../troubleshooting.md) diff --git a/docs/configuration/actions/templates.md b/docs/configuration/actions/templates.md deleted file mode 100644 index fa4a813e..00000000 --- a/docs/configuration/actions/templates.md +++ /dev/null @@ -1,60 +0,0 @@ -# Templates - -Before actions are executed, template values (if present) are replaced within -each action. This allows a variety of Home Assistant data to be automatically -populated in your actions, as well as some Advanced Camera Card data. - -## Stock Templates - -The Advanced Camera Card uses -[ha-nunjucks](https://github.com/Nerwyn/ha-nunjucks) to process templates. -Consult its documentation for the wide variety of different template values -supported. - -See [an example](../../examples.md?id=accessing-home-assistant-state) that -accesses Home Assistant state. - -## Custom Templates - -Custom template values must be proceeded by `advanced_camera_card` (or `acc` for -short). - -| Template | Replaced with | -| -------- | -------------------------------------------------- | -| `camera` | The currently selected camera. | -| `view` | The current [view](../view.md?id=supported-views). | - -See [an example](../../examples.md?id=accessing-advanced-camera-card-state) that -accesses Advanced Camera Card state. - -## Triggers - -If the action is called by an [Advanced Camera Card -Automation](../automations.md), additional data is available representing the -current and prior state of whatever triggered the action. - -Trigger template values must be proceeded by `advanced_camera_card.trigger` (or -`acc.trigger` for short). - -| Template | Replaced with | -| -------------- | ------------------------------------------------------------------------------------------------- | -| `camera.to` | For [camera conditions](../conditions.md?id=camera), the currently selected camera. | -| `camera.from` | For [camera conditions](../conditions.md?id=camera), the previously selected camera. | -| `view.to` | For [view conditions](../conditions.md?id=view), the currently selected view. | -| `view.from` | For [view conditions](../conditions.md?id=view), the previously selected view. | -| `state.entity` | For [state conditions](../conditions.md?id=state), the entity state that triggered the condition. | -| `state.to` | For [state conditions](../conditions.md?id=state), the current state of the entity. | -| `state.from` | For [state conditions](../conditions.md?id=state), the previous state of the entity. | - -!> If an action is triggered with multiple [state -conditions](../conditions.md?id=state), only data from the last listed state -condition is available. - -!> If you use an [`or`](../conditions.md?id=or) condition, only the trigger data -for the first matching trigger will be included. - -Please [request](https://github.com/dermotduffy/advanced-camera-card/issues) if -you need data from additional conditions. - -See [an example](../../examples.md?id=accessing-trigger-state) that accesses -trigger state. diff --git a/docs/configuration/cameras/_sidebar.md b/docs/configuration/cameras/_sidebar.md index 6c6e0563..4e05ccdd 100644 --- a/docs/configuration/cameras/_sidebar.md +++ b/docs/configuration/cameras/_sidebar.md @@ -21,6 +21,7 @@ - [`status_bar`](../status-bar.md) - [`timeline`](../timeline.md) - [`view`](../view.md) + - [Templates](../templates.md) - [Examples](../../examples.md) - [Screenshots](../../screenshots.md) - [Troubleshooting](../../troubleshooting.md) diff --git a/docs/configuration/elements/_sidebar.md b/docs/configuration/elements/_sidebar.md index a8be3894..bab3145f 100644 --- a/docs/configuration/elements/_sidebar.md +++ b/docs/configuration/elements/_sidebar.md @@ -21,6 +21,7 @@ - [`status_bar`](../status-bar.md) - [`timeline`](../timeline.md) - [`view`](../view.md) + - [Templates](../templates.md) - [Examples](../../examples.md) - [Screenshots](../../screenshots.md) - [Troubleshooting](../../troubleshooting.md) diff --git a/docs/configuration/elements/custom/_sidebar.md b/docs/configuration/elements/custom/_sidebar.md index f173fcd2..f9f848dc 100644 --- a/docs/configuration/elements/custom/_sidebar.md +++ b/docs/configuration/elements/custom/_sidebar.md @@ -21,6 +21,7 @@ - [`status_bar`](../../status-bar.md) - [`timeline`](../../timeline.md) - [`view`](../../view.md) + - [Templates](../../templates.md) - [Examples](../../../examples.md) - [Screenshots](../../../screenshots.md) - [Troubleshooting](../../../troubleshooting.md) diff --git a/docs/configuration/elements/stock/_sidebar.md b/docs/configuration/elements/stock/_sidebar.md index 0103fa5d..c522bd65 100644 --- a/docs/configuration/elements/stock/_sidebar.md +++ b/docs/configuration/elements/stock/_sidebar.md @@ -21,6 +21,7 @@ - [`status_bar`](../../status-bar.md) - [`timeline`](../../timeline.md) - [`view`](../../view.md) + - [Templates](../../templates.md) - [Examples](../../../examples.md) - [Screenshots](../../../screenshots.md) - [Troubleshooting](../../../troubleshooting.md) diff --git a/docs/configuration/folders.md b/docs/configuration/folders.md index 9f93ee13..9aa7f62c 100644 --- a/docs/configuration/folders.md +++ b/docs/configuration/folders.md @@ -72,6 +72,34 @@ to perform multiple tests. A given match may match multiple items. If an item does not match, it will not be returned to the user nor (in case of subfolders) feature in future traversals. +##### Matcher: `or` + +Match if any single matcher matches. + +```yaml +type: or +# [...] +``` + +| Parameter | Description | +| --------- | ------------------------------------------------------------ | +| `type` | Must be `or`. | +| `matches` | An array of other matchers only one of which needs to match. | + +##### Matcher: `template` + +Match against a template. + +```yaml +type: template +# [...] +``` + +| Parameter | Description | +| ---------------- | ---------------------------------------------------------------------- | +| `type` | Must be `template`. | +| `value_template` | A [template](./templates.md?id=media-matching) to match media against. | + ##### Matcher: `title` Match against the media item title. @@ -81,11 +109,11 @@ type: title # [...] ``` -| Parameter | Description | -| --------- | ---------------------------------------------------------------------- | -| `type` | Must be `title`. | -| `regexp` | An optional regular expression to matches against the title. | -| `title` | An optional exact value (case-sensitive) to matches against the title. | +| Parameter | Description | +| --------- | -------------------------------------------------------------------- | +| `type` | Must be `title`. | +| `regexp` | An optional regular expression to match against the title. | +| `title` | An optional exact value (case-sensitive) to match against the title. | #### Parsers @@ -265,4 +293,11 @@ folders: - type: startdate format: HH:mm:ss regexp: 'File (?.*)' + - type: ha + ha: + path: + - id: 'media-source://' + - matchers: + - type: template + value_template: "{{ acc.media.title == now().strftime('%Y/%-m/%d') }}" ``` diff --git a/docs/configuration/templates.md b/docs/configuration/templates.md new file mode 100644 index 00000000..a8c4cf1a --- /dev/null +++ b/docs/configuration/templates.md @@ -0,0 +1,76 @@ +# Templates + +Templates may be used in a certain places to allow template values (if present) +to be dynamically replaced. This allows a variety of Home Assistant data, and +Advanced Camera Card data, to be accessible. Templates may be used in: + +- [Actions / Automations](./actions/README.md) +- [Folder Media Matchers](./folders.md?id=matchers) + +## Stock Templates + +The Advanced Camera Card uses +[ha-nunjucks](https://github.com/Nerwyn/ha-nunjucks) to process templates. +Consult its documentation for the wide variety of different template values +supported. + +See [an example](../examples.md?id=accessing-home-assistant-state) that +accesses Home Assistant state. + +## Custom Templates + +Custom template values must be proceeded by `advanced_camera_card` (or `acc` for +short). + +| Template | Replaced with | +| -------- | ------------------------------------------------- | +| `camera` | The currently selected camera. | +| `view` | The current [view](./view.md?id=supported-views). | + +See [an example](../examples.md?id=accessing-advanced-camera-card-state) that +accesses Advanced Camera Card state. + +### Media Matching + +If templates are used for [Folder Media Matching](./folders.md?id=matchers) an +additional `media` variable is available with these properties: + +Media template values must be proceeded by `advanced_camera_card.media` (or +`acc.media` for short). + +| Template | Replaced with | +| ----------- | --------------------------------------------------------------------------------- | +| `title` | The media title being matched. | +| `is_folder` | Whether the media item is a folder that may be expanded (vs a single media item). | + +### Triggers + +If the action is called by an [Advanced Camera Card +Automation](./automations.md), additional data is available representing the +current and prior state of whatever triggered the action. + +Trigger template values must be proceeded by `advanced_camera_card.trigger` (or +`acc.trigger` for short). + +| Template | Replaced with | +| -------------- | ------------------------------------------------------------------------------------------------ | +| `camera.to` | For [camera conditions](./conditions.md?id=camera), the currently selected camera. | +| `camera.from` | For [camera conditions](./conditions.md?id=camera), the previously selected camera. | +| `view.to` | For [view conditions](./conditions.md?id=view), the currently selected view. | +| `view.from` | For [view conditions](./conditions.md?id=view), the previously selected view. | +| `state.entity` | For [state conditions](./conditions.md?id=state), the entity state that triggered the condition. | +| `state.to` | For [state conditions](./conditions.md?id=state), the current state of the entity. | +| `state.from` | For [state conditions](./conditions.md?id=state), the previous state of the entity. | + +!> If an action is triggered with multiple [state +conditions](./conditions.md?id=state), only data from the last listed state +condition is available. + +!> If you use an [`or`](./conditions.md?id=or) condition, only the trigger data +for the first matching trigger will be included. + +Please [request](https://github.com/dermotduffy/advanced-camera-card/issues) if +you need data from additional conditions. + +See [an example](../examples.md?id=accessing-trigger-state) that accesses +trigger state. diff --git a/docs/examples.md b/docs/examples.md index 24cd64e1..155467cd 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -385,7 +385,7 @@ folders: - type: ha ha: url: >- - https://ha.ondu.org/media-browser/browser/app%2Cmedia-source%3A%2F%2Freolink/playlist%2Cmedia-source%3A%2F%2Freolink%2FCAM%7C01J8XHYTNH77WE3C654K03KX1F%7C0 + https://my-ha-instance.local/media-browser/browser/app%2Cmedia-source%3A%2F%2Freolink/playlist%2Cmedia-source%3A%2F%2Freolink%2FCAM%7C01J8XHYTNH77WE3C654K03KX1F%7C0 path: # Matches against the "Low resolution" folder. - matchers: @@ -426,6 +426,39 @@ folders: regexp: 'Person.*' ``` +### Folder `or` matching + +This example dynamically includes media from two subfolders, one for today and +one for yesterday both in `%Y/%-m/%d` +[format](https://www.man7.org/linux/man-pages/man3/strftime.3.html). +[Templating](https://www.home-assistant.io/docs/configuration/templating/#time) +is used to dynamically refer to "today" and "yesterday". + +```yaml +type: custom:advanced-camera-card +cameras: + - camera_entity: camera.office +folders: + - type: ha + ha: + url: https://my-ha-instance.local/media-browser/browser/app%2Cmedia-source%3A%2F%2Freolink/playlist%2Cmedia-source%3A%2F%2Freolink%2FCAM%7C01J8XAATNH77WE5D654K07KY1F%7C0 + path: + - matchers: + - type: title + title: 'Low resolution' + - parsers: + - type: startdate + matchers: + - type: or + matchers: + - type: template + value_template: "{{ acc.media.title == now().strftime('%Y/%-m/%d') }}" + - type: template + value_template: "{{ acc.media.title == (now() - dt.timedelta(days=1)) | timestamp_custom('%Y/%-m/%d') }}" + - parsers: + - type: startdate +``` + ### Folder Paths This example starts with the `media-source://frigate` folder, and looks for a @@ -1072,7 +1105,7 @@ tap_action: key: '{{ hass.states["light.sunroom_ceiling"].state }}' ``` -See [Stock Templates](./configuration/actions/templates.md?id=stock-templates). +See [Stock Templates](./configuration/templates.md?id=stock-templates). ### Accessing Advanced Camera Card state @@ -1089,7 +1122,7 @@ tap_action: view: '{{ advanced_camera_card.view }}' ``` -See [Custom Templates](./configuration/actions/templates.md?id=custom-templates). +See [Custom Templates](./configuration/templates.md?id=custom-templates). ### Accessing Trigger state @@ -1108,7 +1141,7 @@ automations: to_camera: '{{ acc.trigger.camera.to }}' ``` -See [Trigger Templates](./configuration/actions/templates.md?id=triggers). +See [Trigger Templates](./configuration/templates.md?id=triggers). ## Trigger actions diff --git a/src/card-controller/folders/executor.ts b/src/card-controller/folders/executor.ts index 0d376c39..5ae62c49 100644 --- a/src/card-controller/folders/executor.ts +++ b/src/card-controller/folders/executor.ts @@ -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 { const results = (await this._getFolderEngine(query.folder.type)?.expandFolder( hass, query, + conditionState, engineOptions, )) ?? null; return results ? sortItems(results) : null; diff --git a/src/card-controller/folders/ha/engine.ts b/src/card-controller/folders/ha/engine.ts index 1fc8a896..7f0da583 100644 --- a/src/card-controller/folders/ha/engine.ts +++ b/src/card-controller/folders/ha/engine.ts @@ -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 { 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) : []), }), }, diff --git a/src/card-controller/folders/ha/media-matcher.ts b/src/card-controller/folders/ha/media-matcher.ts index e9abc4d9..7c9bc95f 100644 --- a/src/card-controller/folders/ha/media-matcher.ts +++ b/src/card-controller/folders/ha/media-matcher.ts @@ -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; diff --git a/src/card-controller/folders/manager.ts b/src/card-controller/folders/manager.ts index 010216ed..70b6ac7a 100644 --- a/src/card-controller/folders/manager.ts +++ b/src/card-controller/folders/manager.ts @@ -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 { 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 { diff --git a/src/card-controller/folders/types.ts b/src/card-controller/folders/types.ts index 03c0e7ae..27ee911a 100644 --- a/src/card-controller/folders/types.ts +++ b/src/card-controller/folders/types.ts @@ -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; diff --git a/src/card-controller/templates/index.ts b/src/card-controller/templates/index.ts index b455bd41..fd8573ff 100644 --- a/src/card-controller/templates/index.ts +++ b/src/card-controller/templates/index.ts @@ -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 { diff --git a/src/card-controller/view/query-executor.ts b/src/card-controller/view/query-executor.ts index 032e686f..ce605350 100644 --- a/src/card-controller/view/query-executor.ts +++ b/src/card-controller/view/query-executor.ts @@ -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) diff --git a/src/config/schema/folders.ts b/src/config/schema/folders.ts index d533256c..fdfe9a6c 100644 --- a/src/config/schema/folders.ts +++ b/src/config/schema/folders.ts @@ -1,8 +1,8 @@ import { NonEmptyTuple } from 'type-fest'; import { z } from 'zod'; +import { AdvancedCameraCardError } from '../../types'; import { isTruthy } from '../../utils/basic'; import { regexSchema } from './common/regex'; -import { AdvancedCameraCardError } from '../../types'; export const HA_MEDIA_SOURCE_ROOT = 'media-source://'; @@ -31,12 +31,32 @@ const parserSchema = z.discriminatedUnion('type', [ ]); export type Parser = z.infer; +const templateMatcherSchema = parserBaseSchema.extend({ + type: z.literal('template'), + value_template: z.string(), +}); +export type TemplateMatcher = z.infer; + const titleMatcherSchema = parserBaseSchema.extend({ type: z.literal('title'), regexp: regexSchema.optional(), title: z.string().optional(), }); -const matcherSchema = z.discriminatedUnion('type', [titleMatcherSchema]); +export type TitleMatcher = z.infer; + +type OrMatcher = { + type: 'or'; + matchers: Matcher[]; +}; +const orMatcherSchema: z.ZodSchema = z.object({ + type: z.literal('or'), + matchers: z.array(z.lazy(() => matcherSchema)), +}); +export const matcherSchema = z.union([ + orMatcherSchema, + templateMatcherSchema, + titleMatcherSchema, +]); export type Matcher = z.infer; const haFolderPathComponentSchema = z.object({ diff --git a/tests/card-controller/folders/executor.test.ts b/tests/card-controller/folders/executor.test.ts index 4ec409e4..d005f75a 100644 --- a/tests/card-controller/folders/executor.test.ts +++ b/tests/card-controller/folders/executor.test.ts @@ -110,7 +110,7 @@ describe('FoldersExecutor', () => { const folder: FolderConfig = createFolder(); const query: FolderQuery = { folder, - path: ['media-source://'], + path: [{ ha: { id: 'media-source://' } }], }; const haFolderEngine = mock(); @@ -145,7 +145,7 @@ describe('FoldersExecutor', () => { const folder = createFolder(); const query: FolderQuery = { folder, - path: ['media-source://'], + path: [{ ha: { id: 'media-source://' } }], }; const mediaItem = new TestViewMedia({ diff --git a/tests/card-controller/folders/ha/engine.test.ts b/tests/card-controller/folders/ha/engine.test.ts index 3c20349f..c95276cd 100644 --- a/tests/card-controller/folders/ha/engine.test.ts +++ b/tests/card-controller/folders/ha/engine.test.ts @@ -1,8 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; - import { HAFoldersEngine } from '../../../../src/card-controller/folders/ha/engine'; import { FolderQuery } from '../../../../src/card-controller/folders/types'; -import { FolderConfig } from '../../../../src/config/schema/folders'; +import { FolderConfig, Matcher } from '../../../../src/config/schema/folders'; import { BrowseMediaViewFolder } from '../../../../src/ha/browse-media/item'; import { browseMediaSchema } from '../../../../src/ha/browse-media/types'; import { getMediaDownloadPath } from '../../../../src/ha/download'; @@ -206,9 +205,14 @@ describe('HAFoldersEngine', () => { .mockResolvedValueOnce([]); const engine = new HAFoldersEngine(); - const results = await engine.expandFolder(createHASS(), query, { - useCache: false, - }); + const results = await engine.expandFolder( + createHASS(), + query, + {}, + { + useCache: false, + }, + ); expect(results?.length).toBe(2); expect(results?.[0]).toBeInstanceOf(ViewMedia); expect(results?.[1]).toBeInstanceOf(ViewFolder); @@ -263,15 +267,34 @@ describe('HAFoldersEngine', () => { expect(await engine.expandFolder(createHASS(), query)).toBeNull(); }); + // See additional matcher testing in media-matcher.test.ts . describe('should apply matchers', async () => { - it('should expand folder with title based query', async () => { + it.each([ + ['title exact', { type: 'title' as const, title: 'Frigate' }, 1], + ['title regexp', { type: 'title' as const, regexp: 'rig' }, 1], + [ + 'or positive', + { + type: 'or' as const, + matchers: [ + { type: 'title' as const, title: 'UNKNOWN' }, + { type: 'title' as const, title: 'Frigate' }, + ], + }, + 1, + ], + [ + 'or negative', + { + type: 'or' as const, + matchers: [{ type: 'title' as const, title: 'UNKNOWN' }], + }, + 0, + ], + ])('%s', async (_name: string, matcher: Matcher, expectedMatches: number) => { const query: FolderQuery = { folder: { type: 'ha' }, - path: [ - { ha: { id: 'media-source://' } }, - { ha: { matchers: [{ type: 'title', title: 'Frigate' }] } }, - {}, - ], + path: [{ ha: { id: 'media-source://' } }, { ha: { matchers: [matcher] } }, {}], }; vi.mocked(homeAssistantWSRequest) @@ -303,9 +326,7 @@ describe('HAFoldersEngine', () => { const engine = new HAFoldersEngine(); const results = await engine.expandFolder(createHASS(), query); - expect(results?.length).toBe(1); - expect(results?.[0]).toBeInstanceOf(ViewMedia); - expect(results?.[0].getID()).toBe('media-source://frigate/result'); + expect(results?.length).toBe(expectedMatches); }); }); }); diff --git a/tests/card-controller/folders/ha/media-matcher.test.ts b/tests/card-controller/folders/ha/media-matcher.test.ts index 6939149c..dd0ccbb1 100644 --- a/tests/card-controller/folders/ha/media-matcher.test.ts +++ b/tests/card-controller/folders/ha/media-matcher.test.ts @@ -1,7 +1,11 @@ -import { describe, expect, it } from 'vitest'; +import { renderTemplate } from 'ha-nunjucks'; +import { describe, expect, it, vi } from 'vitest'; import { MediaMatcher } from '../../../../src/card-controller/folders/ha/media-matcher'; import { Matcher } from '../../../../src/config/schema/folders'; import { BrowseMedia } from '../../../../src/ha/browse-media/types'; +import { createHASS } from '../../../test-utils'; + +vi.mock('ha-nunjucks'); describe('MediaMatcher', () => { describe('match', () => { @@ -22,25 +26,31 @@ describe('MediaMatcher', () => { it('should return false if foldersOnly is true and media.can_expand is false', () => { const mediaMatcher = new MediaMatcher(); const media = createMediaItem('Test File', false); - expect(mediaMatcher.match(media, [], true)).toBe(false); + expect( + mediaMatcher.match(createHASS(), media, { matchers: [], foldersOnly: true }), + ).toBe(false); }); it('should return true if foldersOnly is true and media.can_expand is true', () => { const mediaMatcher = new MediaMatcher(); const media = createMediaItem('Test Folder', true, 'directory'); - expect(mediaMatcher.match(media, [], true)).toBe(true); + expect( + mediaMatcher.match(createHASS(), media, { matchers: [], foldersOnly: true }), + ).toBe(true); }); it('should return true if matchers array is empty', () => { const mediaMatcher = new MediaMatcher(); const media = createMediaItem('Test Media', false); - expect(mediaMatcher.match(media, [])).toBe(true); + expect(mediaMatcher.match(createHASS(), media, { matchers: [] })).toBe(true); }); it('should return true if matchers array is undefined', () => { const mediaMatcher = new MediaMatcher(); const media = createMediaItem('Test Media', false); - expect(mediaMatcher.match(media, undefined)).toBe(true); + expect(mediaMatcher.match(createHASS(), media, { matchers: undefined })).toBe( + true, + ); }); describe('with title matcher', () => { @@ -48,14 +58,14 @@ describe('MediaMatcher', () => { const mediaMatcher = new MediaMatcher(); const media = createMediaItem('Exact Title', false); const matchers: Matcher[] = [{ type: 'title', title: 'Exact Title' }]; - expect(mediaMatcher.match(media, matchers)).toBe(true); + expect(mediaMatcher.match(createHASS(), media, { matchers })).toBe(true); }); it('should return false when title does not match exactly', () => { const mediaMatcher = new MediaMatcher(); const media = createMediaItem('DOES NOT MATCH', false); const matchers: Matcher[] = [{ type: 'title', title: 'Exact Title' }]; - expect(mediaMatcher.match(media, matchers)).toBe(false); + expect(mediaMatcher.match(createHASS(), media, { matchers })).toBe(false); }); it('should return true when title matches regexp and extracted value matches matcher.title', () => { @@ -68,7 +78,7 @@ describe('MediaMatcher', () => { title: 'ImportantPart', }, ]; - expect(mediaMatcher.match(media, matchers)).toBe(true); + expect(mediaMatcher.match(createHASS(), media, { matchers })).toBe(true); }); it('should return false when title matches regexp but extracted value does not match matcher.title', () => { @@ -81,7 +91,7 @@ describe('MediaMatcher', () => { title: 'WrongPart', }, ]; - expect(mediaMatcher.match(media, matchers)).toBe(false); + expect(mediaMatcher.match(createHASS(), media, { matchers })).toBe(false); }); it('should return true when title matches regexp with an explicit title value', () => { @@ -94,7 +104,7 @@ describe('MediaMatcher', () => { // title is undefined. }, ]; - expect(mediaMatcher.match(media, matchers)).toBe(true); + expect(mediaMatcher.match(createHASS(), media, { matchers })).toBe(true); }); it('should return false when title does not match regexp', () => { @@ -107,7 +117,7 @@ describe('MediaMatcher', () => { title: 'ImportantPart', }, ]; - expect(mediaMatcher.match(media, matchers)).toBe(false); + expect(mediaMatcher.match(createHASS(), media, { matchers })).toBe(false); }); it('should return false when regexp is provided but does not extract the required group', () => { @@ -120,14 +130,120 @@ describe('MediaMatcher', () => { title: 'ImportantPart', }, ]; - expect(mediaMatcher.match(media, matchers)).toBe(false); + expect(mediaMatcher.match(createHASS(), media, { matchers })).toBe(false); }); it('should return true when no regexp and no matcher.title (matches any title)', () => { const mediaMatcher = new MediaMatcher(); const media = createMediaItem('Any Title Will Do', false); const matchers: Matcher[] = [{ type: 'title' }]; - expect(mediaMatcher.match(media, matchers)).toBe(true); + expect(mediaMatcher.match(createHASS(), media, { matchers })).toBe(true); + }); + }); + + describe('with template matcher', () => { + it('should return true when template value matches', () => { + const mediaMatcher = new MediaMatcher(); + const title = 'Any Title Will Do'; + const media = createMediaItem(title, false); + + vi.mocked(renderTemplate).mockReturnValue(true); + + const matchers: Matcher[] = [ + { + type: 'template', + value_template: '{{ acc.media.title == "Any Title Will Do" }}', + }, + ]; + const hass = createHASS(); + expect(mediaMatcher.match(hass, media, { matchers })).toBe(true); + + expect(renderTemplate).toHaveBeenCalledWith( + hass, + '{{ acc.media.title == "Any Title Will Do" }}', + { + acc: { + media: { + title, + is_folder: false, + }, + }, + advanced_camera_card: { + media: { + title, + is_folder: false, + }, + }, + }, + ); + }); + + it('should return false when template value does not match', () => { + const mediaMatcher = new MediaMatcher(); + const title = 'Any Title Will Do'; + const media = createMediaItem(title, false); + + vi.mocked(renderTemplate).mockReturnValue(false); + + const matchers: Matcher[] = [ + { + type: 'template', + value_template: '{{ acc.media.title == "Any Title Will Do" }}', + }, + ]; + const hass = createHASS(); + expect(mediaMatcher.match(hass, media, { matchers })).toBe(false); + + expect(renderTemplate).toHaveBeenCalledWith( + hass, + '{{ acc.media.title == "Any Title Will Do" }}', + { + acc: { + media: { + title, + is_folder: false, + }, + }, + advanced_camera_card: { + media: { + title, + is_folder: false, + }, + }, + }, + ); + }); + }); + + describe('with or matcher', () => { + it('should return true if at least one sub-matcher matches', () => { + const mediaMatcher = new MediaMatcher(); + const media = createMediaItem('Test Media', false); + const matcher: Matcher = { + type: 'or', + matchers: [ + { type: 'title', title: 'Non-Matching Title' }, // Fails + { type: 'title', title: 'Test Media' }, // Passes + ], + }; + expect(mediaMatcher.match(createHASS(), media, { matchers: [matcher] })).toBe( + true, + ); + }); + + it('should return false if no sub-matcher matches', () => { + const mediaMatcher = new MediaMatcher(); + const media = createMediaItem('Test Media', false); + const matcher: Matcher = { + type: 'or', + matchers: [ + { type: 'title', title: 'Non-Matching Title One' }, // Fails + { type: 'title', title: 'Non-Matching Title Two' }, // Fails + ], + }; + expect(mediaMatcher.match(createHASS(), media, { matchers: [matcher] })).toBe( + false, + ); }); }); @@ -138,7 +254,7 @@ describe('MediaMatcher', () => { { type: 'title', title: 'Test Media One' }, // Pass { type: 'title', title: 'Test Media Two' }, // Fail ]; - expect(mediaMatcher.match(media, matchers)).toBe(false); + expect(mediaMatcher.match(createHASS(), media, { matchers })).toBe(false); }); it('should return true if all multiple matchers pass', () => { @@ -152,7 +268,7 @@ describe('MediaMatcher', () => { title: 'Test Media One', }, ]; - expect(mediaMatcher.match(media, matchers)).toBe(true); + expect(mediaMatcher.match(createHASS(), media, { matchers })).toBe(true); }); it('should ignore matchers of unknown types', () => { @@ -160,7 +276,7 @@ describe('MediaMatcher', () => { const media = createMediaItem('Test Media', false); const matchers: Matcher[] = [{ type: 'unknownMatcherType' as 'title' }]; - expect(mediaMatcher.match(media, matchers)).toBe(true); + expect(mediaMatcher.match(createHASS(), media, { matchers })).toBe(true); }); }); }); diff --git a/tests/card-controller/folders/manager.test.ts b/tests/card-controller/folders/manager.test.ts index 59feedc9..8b99581d 100644 --- a/tests/card-controller/folders/manager.test.ts +++ b/tests/card-controller/folders/manager.test.ts @@ -130,7 +130,7 @@ describe('FoldersManager', () => { const folder: FolderConfig = createFolder(); const query: FolderQuery = { folder, - path: [{ id: 'media-source://' }], + path: [{ ha: { id: 'media-source://' } }], }; const executor = mock(); @@ -146,7 +146,7 @@ describe('FoldersManager', () => { const folder: FolderConfig = createFolder(); const query: FolderQuery = { folder, - path: [{ id: 'media-source://' }], + path: [{ ha: { id: 'media-source://' } }], }; const executor = mock(); @@ -162,7 +162,7 @@ describe('FoldersManager', () => { const folder: FolderConfig = createFolder(); const query: FolderQuery = { folder, - path: [{ id: 'media-source://' }], + path: [{ ha: { id: 'media-source://' } }], }; const executor = mock(); @@ -187,15 +187,23 @@ describe('FoldersManager', () => { const manager = new FoldersManager(api, executor); const folder = createFolder({ id: 'folder-1' }); + const conditionState = {}; const engineOptions = {}; const query: FolderQuery = { folder, - path: [{ id: 'media-source://' }], + path: [{ ha: { id: 'media-source://' } }], }; - expect(await manager.expandFolder(query, engineOptions)).toEqual([media]); + expect(await manager.expandFolder(query, conditionState, engineOptions)).toEqual([ + media, + ]); - expect(executor.expandFolder).toBeCalledWith(hass, query, engineOptions); + expect(executor.expandFolder).toBeCalledWith( + hass, + query, + conditionState, + engineOptions, + ); }); it('should not expand folder with hass', async () => { @@ -205,7 +213,10 @@ describe('FoldersManager', () => { const folder = createFolder({ id: 'folder-1' }); expect( - await manager.expandFolder({ folder, path: [{ id: 'media-source://' }] }), + await manager.expandFolder({ + folder, + path: [{ ha: { id: 'media-source://' } }], + }), ).toBeNull(); expect(executor.expandFolder).not.toBeCalled(); diff --git a/tests/config/schema/folders.test.ts b/tests/config/schema/folders.test.ts index d9462979..b53130e2 100644 --- a/tests/config/schema/folders.test.ts +++ b/tests/config/schema/folders.test.ts @@ -1,6 +1,9 @@ -import { describe, expect, it } from 'vitest'; -import { transformPathURLToPathArray } from '../../../src/config/schema/folders'; import { NonEmptyTuple } from 'type-fest'; +import { describe, expect, it } from 'vitest'; +import { + matcherSchema, + transformPathURLToPathArray, +} from '../../../src/config/schema/folders'; describe('transformURLToMediaSourceRoot', () => { const prefixes: NonEmptyTuple[] = [ @@ -65,3 +68,27 @@ describe('transformURLToMediaSourceRoot', () => { }); }); }); + +describe('should lazy evaluate schemas', () => { + it('should lazy evaluate or matcher', () => { + expect( + matcherSchema.parse({ + type: 'or', + matchers: [ + { + type: 'title', + title: 'Test Title', + }, + ], + }), + ).toEqual({ + type: 'or', + matchers: [ + { + type: 'title', + title: 'Test Title', + }, + ], + }); + }); +});