@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 (?<value>.*)'
|
||||
- type: ha
|
||||
ha:
|
||||
path:
|
||||
- id: 'media-source://'
|
||||
- matchers:
|
||||
- type: template
|
||||
value_template: "{{ acc.media.title == now().strftime('%Y/%-m/%d') }}"
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
+37
-4
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) : []),
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<typeof parserSchema>;
|
||||
|
||||
const templateMatcherSchema = parserBaseSchema.extend({
|
||||
type: z.literal('template'),
|
||||
value_template: z.string(),
|
||||
});
|
||||
export type TemplateMatcher = z.infer<typeof templateMatcherSchema>;
|
||||
|
||||
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<typeof titleMatcherSchema>;
|
||||
|
||||
type OrMatcher = {
|
||||
type: 'or';
|
||||
matchers: Matcher[];
|
||||
};
|
||||
const orMatcherSchema: z.ZodSchema<OrMatcher, z.ZodTypeDef> = 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<typeof matcherSchema>;
|
||||
|
||||
const haFolderPathComponentSchema = z.object({
|
||||
|
||||
@@ -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<HAFoldersEngine>();
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<FoldersExecutor>();
|
||||
@@ -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<FoldersExecutor>();
|
||||
@@ -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<FoldersExecutor>();
|
||||
@@ -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();
|
||||
|
||||
@@ -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<string>[] = [
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user