From c077f8bf70abcd0635a612dac7ad00454acbaa92 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Tue, 27 May 2025 07:50:51 -0700 Subject: [PATCH] feat: Add folder title matching and parsing (#2067) This is a breaking change for users of the [experimental 'folder' functionality](https://card.camera/#/configuration/folders). Related: #1748 --- docs/configuration/folders.md | 221 +++++++++++++-- docs/examples.md | 45 +++- docs/images/folder-hierarchy-1.png | Bin 0 -> 9446 bytes docs/images/folder-hierarchy-2.png | Bin 0 -> 7061 bytes docs/images/folder-hierarchy-3.png | Bin 0 -> 10368 bytes docs/images/folder-hierarchy-4.png | Bin 0 -> 19361 bytes package.json | 1 + .../motioneye/engine-motioneye.ts | 6 +- src/camera-manager/reolink/engine-reolink.ts | 9 +- src/card-controller/folders/ha/engine.ts | 121 +++++---- .../folders/ha/media-matcher.ts | 38 +++ .../folders/ha/metadata-generator.ts | 99 +++++++ src/card-controller/folders/ha/types.ts | 1 + src/card-controller/folders/types.ts | 6 +- .../gallery/folder-gallery-controller.ts | 2 +- .../thumbnail/details-controller.ts | 7 +- src/components/thumbnail-carousel.ts | 5 +- src/config/schema/folders.ts | 36 ++- .../browse-media-to-view-media.ts | 4 +- src/ha/browse-media/item-factory.ts | 2 +- src/ha/browse-media/item.ts | 11 +- .../sort-browse-media-by-start-date.ts | 8 - src/ha/browse-media/types.ts | 6 +- src/ha/browse-media/within-dates.ts | 2 +- src/utils/regexp-extract.ts | 22 ++ .../card-controller/folders/ha/engine.test.ts | 252 +++++++----------- .../folders/ha/media-matcher.test.ts | 166 ++++++++++++ .../folders/ha/metadata-generator.test.ts | 191 +++++++++++++ .../gallery/folder-gallery-controller.test.ts | 6 +- .../thumbnail/details-controller.test.ts | 3 +- tests/config/schema/folders.test.ts | 2 +- .../sort-browse-media-by-start-date.test.ts | 89 ------- tests/utils/regexp-extract.test.ts | 46 ++++ vite.config.ts | 1 + yarn.lock | 8 + 35 files changed, 1038 insertions(+), 378 deletions(-) create mode 100644 docs/images/folder-hierarchy-1.png create mode 100644 docs/images/folder-hierarchy-2.png create mode 100644 docs/images/folder-hierarchy-3.png create mode 100644 docs/images/folder-hierarchy-4.png create mode 100644 src/card-controller/folders/ha/media-matcher.ts create mode 100644 src/card-controller/folders/ha/metadata-generator.ts create mode 100644 src/card-controller/folders/ha/types.ts delete mode 100644 src/ha/browse-media/sort-browse-media-by-start-date.ts create mode 100644 src/utils/regexp-extract.ts create mode 100644 tests/card-controller/folders/ha/media-matcher.test.ts create mode 100644 tests/card-controller/folders/ha/metadata-generator.test.ts delete mode 100644 tests/ha/sort-browse-media-by-start-date.test.ts create mode 100644 tests/utils/regexp-extract.test.ts diff --git a/docs/configuration/folders.md b/docs/configuration/folders.md index fedf89e3..9f93ee13 100644 --- a/docs/configuration/folders.md +++ b/docs/configuration/folders.md @@ -21,7 +21,7 @@ folders: ## `ha` -Used to specify a Home Assistant media folder. +Used to specify a path to Home Assistant media. ```yaml folders: @@ -30,19 +30,29 @@ folders: # [...] ``` -| Option | Default | Description | -| ------ | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `url` | | An optional Home Assistant `Media` browser URL to use as the query base. If `path` is also specified, those matchers are applied against folders "below" the folder specified in `url`. | -| `path` | [`{ id: media-source:// }`] | An optional array of matchers to dynamically compare against the Home Assistant media folder hierarchy. See below. | +| Option | Default | Description | +| ------ | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `url` | | An optional Home Assistant `Media` browser URL to use as the query base. If `path` is also specified, those matchers/parsers are applied against folders "below" the folder specified in `url`. | +| `path` | [`{ id: media-source:// }`] | An optional array of parsers and matchers to dynamically compare and extract metadata from the Home Assistant media folder hierarchy. See below. | ?> `url` is never fetched, nor sent over the network. It is only processed locally in your browser. The host part of the URL can optionally be removed. ### `path` -An array of matchers to navigate "down" a folder hierarchy. If `url` is also -specified, matchers are applied starting at that folder, otherwise they are -applied at the media source root (i.e. `media-source://`). +An array of values that represents the path to a Home Assistant media item, e.g. +a media item at a path of `one/two/three` would be represented by a path array +of length three. + +| Option | Default | Description | +| ---------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | | An optional exact media item to select, usually the parent of where `matchers` and `parsers` should apply. | +| `matchers` | | An optional array of matchers to evaluate whether to return a given media item. If no matcher is specified, everything in the given folder matches. See below. | +| `parsers` | | An optional array of parsers that extract data out of a media item. See below. | + +If `url` is also specified, parsers/matchers are applied starting at that +folder, otherwise they are applied from the media source root (i.e. +`media-source://`). ```yaml folders: @@ -52,21 +62,93 @@ folders: # [...] ``` -| Option | Default | Description | -| ---------- | ------- | ------------------------------------------------------ | -| `id` | | An optional media source `id` to match against. | -| `title` | | An optional title name to match against. | -| `title_re` | | An optional title regular expression to match against. | +?> To match everything at a given level whilst parsing nothing would simply be +represented by an empty object `{}` -?> Specifying multiple `path` matchers (other than `id`) requires a query at -each level of the folder hierarchy and is slower than directly specifying the -media source `id` (if known) or the `url` of the folder. +#### Matchers -#### Examples +Matches are used to match a given media item. Multiple matchers may be specified +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. -See [Folder Examples](../examples.md?id=folders). +##### Matcher: `title` -#### Understanding Media Source IDs and "parent folders" +Match against the media item title. + +```yaml +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. | + +#### Parsers + +Parsers are used to extract data from a media item (e.g. an event start time). +Parsed data is propagated down the hierarcy, e.g. a given media item inherits +the metadata of its parents. + +##### Parser `date` / `startdate` + +Parses a start date from a media title. `date` is an convenient alias for +`startdate`. + +```yaml +type: date +# [...] +``` + +| Parameter | Description | +| --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `type` | Must be `date` or `startdate`. | +| `format` | A [`date-fns` format string](https://date-fns.org/docs/parse). If unspecified, [`any-date-parser`](https://www.npmjs.com/package/any-date-parser) is used to parse a date and/or time which covers many common cases. In the event of missing or inaccurate metadata, specifying a precise format may help. | +| `regexp` | An optional regular expression to first match the title against before parsing. May be used to match against a subset of the string, see [tip below](#regular-expression-matching). | + +## Advanced + +### Regular Expression Matching + +For `matchers` and `parsers` that support the `regexp` option, it may be used to +compare against only a portion of the media title. By default, that portion is +whatever part of the title matches the given regexp. For extra-precision, use a +named regexp group called `value`. + +For example, pulling time values out of a media title, before parsing them with +a `HH:mm:ss` format. + +```yaml +parsers: + - type: date + regexp: "\d{2}:\d{2}:\d{2}" + format: HH:mm:ss +``` + +Similarly, this example uses a named group to refer to the text at the end of +the title. + +```yaml +parsers: + - type: date + regexp: '^Time: (?.*)+' + format: HH:mm:ss +``` + +In this contrived example, the regular expression is used to extract either +`Low` or `High` from the title, and then match only the `High` entry. + +```yaml +matchers: + - type: title + regexp: '(?Low|High) Resolution' + title: High +``` + +### Understanding Media Source IDs and "parent folders" Home Assistant Media Source IDs are typically long integration-specific non-user friendly strings that refer to a media item, or folder of media items. Media @@ -74,6 +156,93 @@ source "folders" do not have an intrinsic parent as with filesystem folders, rather a trail is built as the user navigates "downwards" -- but anything could theoretically be the parent of anything. +## Worked Example + +Imagine a media folder hierarchy that starts with a choice of resolution (Low or +High). Lets start by specifying a basic folder referring to the URL of the Home +Assistant Media Browser for that folder (via copy and paste of the URL from +another browser window with the folder open): + +```yaml +folders: + - type: ha + ha: + url: >- + /media-browser/browser/app%2Cmedia-source%3A%2F%2Freolink/playlist%2Cmedia-source%3A%2F%2Freolink%2FCAM%7C01J8XAATNH77WE5D654K07KY1F%7C0 +``` + +The result: + +![](../images/folder-hierarchy-1.png 'Folder Hierarchy 1 :size=400') + +Now lets include selecting the High resolution folder: + +```yaml +folders: + - type: ha + ha: + url: >- + /media-browser/browser/app%2Cmedia-source%3A%2F%2Freolink/playlist%2Cmedia-source%3A%2F%2Freolink%2FCAM%7C01J8XAATNH77WE5D654K07KY1F%7C0 + # Added below: + path: + - matchers: + - type: title + title: High resolution +``` + +The result: + +![](../images/folder-hierarchy-2.png 'Folder Hierarchy 2 :size=400') + +The next step is to navigate down to the date folder, parsing the date as we go +(auto-detecting the format): + +```yaml +folders: + - type: ha + ha: + url: >- + /media-browser/browser/app%2Cmedia-source%3A%2F%2Freolink/playlist%2Cmedia-source%3A%2F%2Freolink%2FCAM%7C01J8XAATNH77WE5D654K07KY1F%7C0 + path: + - matchers: + - type: title + title: High resolution + # Added below: + - parsers: + - type: startdate +``` + +The result: + +![](../images/folder-hierarchy-3.png 'Folder Hierarchy 3 :size=400') + +The final step is to navigate down to the media item themselves, automatically parsing the time out of them: + +```yaml +folders: + - type: ha + ha: + url: >- + /media-browser/browser/app%2Cmedia-source%3A%2F%2Freolink/playlist%2Cmedia-source%3A%2F%2Freolink%2FCAM%7C01J8XAATNH77WE5D654K07KY1F%7C0 + path: + - matchers: + - type: title + title: High resolution + - parsers: + - type: startdate + # Added below: + - parsers: + - type: startdate +``` + +The final result: + +![](../images/folder-hierarchy-4.png 'Folder Hierarchy 2 :size=400') + +### Other Examples + +See [Folder Examples](../examples.md?id=folders). + ## Fully expanded reference [](common/expanded-warning.md ':include') @@ -85,7 +254,15 @@ folders: url: https://my-ha-instance.local/media-browser/browser/app%2Cmedia-source%3A%2F%2Ffrigate path: - id: 'media-source://' - - title: 'Frigate' - - title_re: 'Clips.*' - - title_re: 'Person.*' + - matchers: + - type: title + regexp: (?.*) resolution + title: Low + - parsers: + - type: date + format: yyyy/MM/dd + - parsers: + - type: startdate + format: HH:mm:ss + regexp: 'File (?.*)' ``` diff --git a/docs/examples.md b/docs/examples.md index c7e6b7ed..24cd64e1 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -356,8 +356,8 @@ folders: ### Folder within the Home Assistant default root This example applies a title match against the Home Assistant media root folder -looking for a folder entitled `Frigate`. The resulting media will be the -contents of that folder (if found). +looking for a folder entitled `Frigate`, and shows all items within it (since no +matcher is specified at the lowest level). ```yaml type: custom:advanced-camera-card @@ -367,7 +367,38 @@ folders: - type: ha ha: path: - - title: 'Frigate' + - matchers: + - type: title + title: 'Frigate' + - {} +``` + +### Folder Parsing + +This example parses dates from a folder, and times from the media items themselves. + +```yaml +type: custom:advanced-camera-card +cameras: + - camera_entity: camera.office +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 + path: + # Matches against the "Low resolution" folder. + - matchers: + - type: title + regexp: (?.*) resolution + title: Low + # Parses the date out of the next level (auto-detected format). + - parsers: + - type: startdate + # Parses the time out of the items themselves (user-specified format). + - parsers: + - type: startdate + format: 'HH:mm:ss' ``` ### Folder URLs @@ -387,8 +418,12 @@ folders: ha: url: https://my-ha-instance.local/media-browser/browser/app%2Cmedia-source%3A%2F%2Ffrigate path: - - title_re: 'Clips.*' - - title_re: 'Person.*' + - matchers: + - type: title + regexp: 'Clips.*' + - matchers: + - type: title + regexp: 'Person.*' ``` ### Folder Paths diff --git a/docs/images/folder-hierarchy-1.png b/docs/images/folder-hierarchy-1.png new file mode 100644 index 0000000000000000000000000000000000000000..c4ec821cabb6c51dda8e581fabd849caf4587b18 GIT binary patch literal 9446 zcmc(lXH-+&)98;NUBE{X6r`w>2dUCUs-ScbLg+>5geD~*9Yu;FMXGd=9-4GWgs8NK zUK6CFfIvcml+XkB_^kWCYuyj;hxfyKZ`NWZoY`luz0b^jF1PcIKg z5np>BM@LUz7cajpO0*IkNAE0 zd(!d}5{e0WBr1+YJ1U6mE^#Z%p;t~}e`R@N_}n(K#q@3+!d@`f6YF%6KkjU?6Jr6-v( zscCo6>~9-XSs_W$4~Mz=3SCo@rP_8QmAI!c`1%0iT%hcWwX~7M6JLQgE72AJh`2Qz z2fqLSIRms70B|XFj+BuiV1Qd6EAnZx@3TBklmuNrdJHOjs z=}VFKS)ska0I1L=Cnrn!t&c`L!xQ9{g7*Ht1zwDu^K41rQe^+_?KM?5uu6Z@y-%F4 zR1DrNA+jiEDaNV9;ru04so zz>~BfF*ysLo0*v@i<~9+d(0!D5BxWiI)Bh^v^h1HU**>w?Rs-HEi-evY-&pgSLzAG z7FSgXqi0WUHtLr_IAq+PSX)~kVzjcPT$7y!KNRlm?Zw^{yNn~M&jXju|6-i+m~D0? zACne{9hzEtdYHjXNvN?g%h+~zL3V1rn z2>giC9Idfv(l)7-u1}+ST4yE|es&srGY&bD0Ipo&w8D=^KLoW*DxsAo)$}KF zlbktcHnPBVzVd?VnwkV$Oj!&D28F}n`S}L(g&VbS%82*F?2WU{9+Dwd)v};qrrE>t zpOGSB$A%Xu91CK!D4VDt;iohz(<898wqj)yH}=jZIrGkpWC3-xT2<7u&gfX%tTDFU zMDD3*Mvhkt`N+-*TW}{XwAUN`QHq;2Uj_$tud%Sa2>)hiYAS%555tmg8wT(i2KZ4~ zf|KZcDVRsX-SU|&EG!V1O4G{vdZTOCt|7|He@A=DO({}CAaZbyC}fckmm9@sIMt;Y z&IkUlLq37CLQPCy`Yyik4|w9i;H<<|YDA}cXt7D^sX$L1K#OKd*mZ*8p zjCs$*IHb_syCI@UHYopUaACX^yNgAj*!lc~h!QJa)<6tFzAhf|fED-)I!`*#vnjcPW=IZLI`Es>FWR%9m7RSU3E(cPoyD=tz zaM7#0&{2Kf+}yl>a8LpN!9o1w;*|>I!5Ztq@ga(BWD>=8;x(P6tXRdciGsE+PZ!wM zwtxncbxKY68cCxjuj%SqK~GQrnfsU9R1JyR7!GgJ*3lvRUt?ooap_MLTre>c52_TI zL{e`1dDHy7;l;G7^U4qoK6G^9;`i_C^9y7h{l7nMhvmmFpI*A}Bbhq;`H^`{_NyTM zMi^y8!!}1Fcj%s>CQYV198oM;-Ht%q)ytK~%w~a#tisk%ei^NipzLa^DYD(&oa_WHO3jwRHFW&&+pFL&L&9##>Mj5w&pv_-jqp1pt0~H;W#03^yzY<5Y_Yv0R)6gX6O1*Ux7N#);48-m{kv9NLiCFo zMwRoQEqFOaMaA_R*^xs8`YEZ30%~ndXlEsLoDe5U^|Fsozvk!VE#U17{ciZTv-C~S zA6V@Q9Yt@Z3PQfIVbLDv+HozHaP__j#LBYZ{D3{<$4&Y;-*C-Q*jPOdjWx>ER zP!0HrG%FjMnkKiYpm1im%+9B0ok5|(sYpd3lR((E@Z+&|4B@_$oPF;5ss~xa-T#`9 zKWF0gd$)@t-DesFmoDBiaKjeit5$k?dXjn{1^=;`5=m8V++AqlTfcd0@O4Gh*N>NF z6ciMELA8qwSW`nRTmNX-u=mG@>V4RL61!1*g%#Hr~4lal$_Sh5<)!#A0vpu!Ot1=)N$8CLk%wQ zjDuz##b)z&!}QH@`R2T9Zx!t8^4>fGJ&fW$>9tz9h-Sm|9KJp%wBo@Bd3FvC@QueT zn}mN&m|}+>B=hM|C}H7_@&Rn7r?lYQ~~DS9@=N;Jcgp0Mu)#;J*868=uuTKeRq@lekFiBEMhxcx`q zbG!dCy^8=D)lQz+m;}R+v8gFr7#!!GJNV&V=Wp|($@w?nZtHJzHFEi*tSwtSF$tUx z9@K!z3+Fdpx40l;PXSF8X)8Hh@tVY*q) zsiE(xrG~OOUr$ZQdk1$_IhT47;WzYY*Z7|3qpQ*Tr=o*_^j$H;HQbx40;j|^MhmxD zKra{%AwS>q9_oipEky(%sGb%VagHOgEQ&}mC`YZrqJVqA&HLTpjcWYm!&fG>9~^#Tr0w_{@A+a#yKR~+xFjb> z?;VP5I};PWh$rJ0X6=sj59?7k7QXcYfuYR_FflW6TBRLnp%ecT3LA-@> z97tDc(nQ6$+a^+WrL;27WM#2?OxHh>mX>}ka=Gj@>RI`%X+;OcDifhKr7IRzG#*n| zN1Ph|YF5cXZj@#_$Yck*Gt0PBMpaF_n`mjhS+u8obK5f#yZA=qJH>XNyUf9T_Yv0m z`}gnvRedeCx%JYFia?$^C_;!m)A{G4axAKVAmX4%YssS&b*1^W@Nrx#{mj#c*vL;* zKOy4(4tbM}cTuA=XEyjQVNM~QFysXB_4WP77B(?4K~{1eNEc~Wsx2&3Wn^SjMJIr( z@bnb@@vik6o9b*v5{bwC!g7^)!(*9j5m@cobGhR=9mhH|V=Jq{HQ=ZIASxjk|8VDk z5wobJE-P_4u&JrZka7CCd9?{)u1yl_2q<<*xt#bAtjx`CJY%>%_~Us_Q=BXp_=KWH zfUevRR336Ew`eVamvXtoW*NX?Y~OVcUZ1b;^)R}#> z(K&#pqQdkK!6~21eYhUgdL@Om8lBQAHgO#6HccTX3wn~RHFbS`ecf-$4jx;sN2is3 zC;iMTJAq#W@etXHmHox?3K6YjofDgO(-sk{is%STDgvBTi4pb+}zxCsNKaB2?ibM z6J#HIE-nFn@80qZ`7<#Ko}Jzvu(_As6=rKE^B7O}kET_Djfu%GvdM!0q7^be!q@B| zvwLW0=pLf$x@+KsG-~BaNq2X5#`c}}iy0r%txQZnpT_$Bsj#5nSgZFkt@+Z;wM_Gz z?Ci>4zkVs%Msnv{!7SQU4m#j%>7qgttx_#3-%Fa#o7G1=4poNt>azUfAH^&*;_}1l zbr}@ef1Mv@W0eeRWEboEUAbyA14NCEn(m}wmk;$Z%7HO=&*W`PL!4q3zWcr>o?whZ zL%Rf<2o`0|`4!mgS^r8!d|X!G)a^6_vJT8M?~w3n7m8 z!;izCz}@mD;_$MFv(Bsi9eeUa=rSwG-b*{XvhvWDJsCx8P(~PeqW10s^YOx|y)V{c zcbC83o&U6^G{3#)sb$ppt&%hxe_ra`VOo$*+=|PYIoRK|*dEU}M&Z>mecqCq>Vq4f z9&2XuaxVP-)Y0%$VJIiCDKFTh-cyaeDAZzE7e$rWv_d z;Va}oUVn}tW@_vWOwCF=5h*S92o>YM;IPA1e1tn{#(%2Z1_F2PzMaO#iQzck*JDKXU(zV%rtT1042}cHfgjg zNj{`P(W6lhN5oKSN8DATi5X|xd##^z^xVP<-a@%uCar}UiQNKHC#SM|hX<_qgA+>3 zWzDxc=MfSwV*X zi|@^?jgjp0ntLs0C|0q*pJWyPjGJjZ{~DA(Ki;!|*b3j`=r{1yq2fHwKMyn1i_zB% zJX8KT(X&{%wfI1zNH14pJbea+DYLH453xt=(6p7fc};B&9jqUOPgisF{95l+JegFo zOK{TXDjJJas@;h(N}U(q^NSD19A`zc+Z_9fd{8oSWv`fK7$7M#oI}0b7iw!0C7xbt z@2nxzqD4=>d*R<(qKh|GJslU13q0rj%WOh+t(&a2p(UEy_reW#xsjH#@3hkstcw%f z21v(eiL2vlEJKe)R2g^*a|kgxXpDPx%D|>(pAR za#&*v)LEjWe`U8X_jZZ1Ai)oWl#i*q36=90(Txyg4ZZh9x)Obo75`LF>d{Qvg_;If zk=^nYC5M%Toi$$W)Se<9PbpNOh;xX_TI54hUGziQMY-07$FrgYx084DaXmQ|-iI&r zI@T4)!~1nsoYWTy+pJ_lWD2`d^^+o++_MMNs}-MIif!iRJ|-p#xVG|Uj;ATP6!5mB z3TGP48KHDU-xN?qsGM3aq+G8wD{*-~%w}xnXPChpSY`yH8DQaYQM|+$mEh0Umh8ap zTSOGgE_I*CG;c3`H`cG2E_KF4KB=54KNIu9jd-CyzI6^O{2_a)tHZ`BXq~LXpGobf z=<-(T%lv&g*C+k0#h$f-naT6xK#T4@ZWhut=T$&uYnU()9Fl3&keYkUOmJ%su0_M! z5(ci}MxDKqV_eqP3nki$H@&0o3d@syH{@0mPpc-MnUcBlCT4n6?+PEK6VIm`jtL(J z_O@rkNB8e&NjScPhGnVg1vBNhJ^tQipeN$Wp1I}qFg;bqrC=u4!zUN_nI8++E+p$! zOe0?yXDPYQh@sX3YfPl2ImePd*Y%5cPw}bHu{F|-J-YZseC>EuQE6})4zDxEnx=1* zFrHV(hll8+52puUu(%Z*3xE9Cj;S<}8^8C$u?ct3QtrAe8Pw{Y$9lfK<)h^zW9)X6 z+}jA-z0kB7X325Z2pLwr(J!SXk%+-e#JC(V4D5~nqLdF=53LSAcEyxtxGZFd{oW1Q zV7GZ*XXM|GaJHQ-N5-EQI_Fyy25DrVU7uv>a_SRK@cz=9$}_u{`!er-c1j*^QLIKx z`Inb8JtEaAmO$f=j%PN%4V%2U`KG}KC_o!XO`uvvJ&sxD zWG}?%W_oIk_ip~Eoyf!vUrTxad)|o;_(m7+HhRg^7GcSb#wP)`=a6c%I!zAUz$|}^ zsqqKHfYQ9G+oA1;=H$Z-?u}*pG>hhfg0*{Z*>D2Pr;pKA+eyC^0*$pfYTJ}U)LY(> z0<;X>`TmsTcynzD<1Yu6E1SND#zO@C@zoS{;yYqa@WC6KWW}S^BE$Oz%NujMraE53 zZ6mQvrfb*NH=GPD3CqZ3ffFdwJ=2p;yz=^(EO<|-1uk4l@dq+tWW)0|@cV}`*~@dv zV=?q0e(^zZ`hm@uSl+4Fw6ExtV<&YFQtqN)4VnoI`Ct;a-zrH1>@jb!%kIu*!N?_C zwudppdPOx8YRp4~&rYm#w`wA>+}T5=VE%TEjjb2S0*L3hcBLUtDumzxRSc^6-Jw%; z=sMOB2U-C5X+1~d;q6_wXt}^Q2onb)!bFIXCOeA=z4DSNFhMtYc=3;z-ym9Z0a(+q$5-s+WO-rX*Wf!xb8}WDWo0J>AFw)^b%o!B4@~|% z$AxM?|CDw2kjZwJu~fI|wW})!3zxThDS$mMx-t0XGns6VcKto8k0}7TGCt!tY*BHs zdRtqYB5MP1`NmnaH9I@IUA=^5`I?;C<5*d`8;wTJ2Q}Sx@4@<tF1$i&4z83yz_>IQH6ihdB zTvT4~$sCDuWez#<-@7fE3juwrk3tHw8w}>qF#S{J`H)kCkB<+nd6&p5R+U;>aUKOa zcpx(Xyovt*mC*korAYtZz=n&4u+D{1%vi$kZoSYjgy-z=fhlT-njqadJb}cfx=dc= zp~YK3{D#CA^#%ST*WN$X=x${Jc`23G)x-qERhkecfRdB2uHel*pMi)sJ&wK$@{4j% zUZnt;n6cv1HkT~*d`X2M=cBE|K0`XzDD#^guP03wdc;zfE3Cr--$unkq7_Vz(gTec0Mx0Nf#Xdhy-0fC7A;I7H5fv5i=iv>7n}Tu20*u^8@IoFP%*5rGM3W~b>D;>TrN-IH$r5SyMg!HAE_2I9VyS6pzWAO{JzMRQ6DkO; z`4<)91wk14>D$e2AwRoy(-3aFzYT?^!a{)_EUr0=`dAX z75`jr<+m zfDj1DTm#}3!mOYJ0eLY0I4e6lQiS%uFx|4H>7EhA@Bf=9`D)#BFH zy7x*a2N$-=d~qz1EyE29ZD>3sg!?HuPsxKZ+cFEjngNwK$Zt9&Ft=B0^_8R#jjaEe ztsP&TsF}$34#7;}!hM-DS}E^qTkSDm9*q&f5V zW5Z4cN8r28ix@Gs^M~P{ddVwB6ItBlb{HCV_!ND0@HX9$(mA8zS$lU_fj&LgiUp==jd-d*JL6QWy2;_Gig8K0kvFrys+L& zJL9VtV?ApdZoOZ(h*fL})0#GCgN;`+4r7MaiTk!!{;#EkPuZ zJ>#H)9d}SUgh7-(j*$>vP9+Ew6tk2#=uQQ2f3|EWJ5PC>JrAp>HVK^}A=fw{Y03(g z%QfxR4ZB&S9$WtbzCKsC$-uooBQa0UvEJU4R7Jn44!Mvf znJszVllLC-soqf?_u(xjpBt=2ssmx4a%=C|a#S>`)OzO?I~dSDfER2;SX6D{5-P(9 z4VFDeF{_(N5qOW&qEdsl)tSN?= zIzD}q)e9`H5hs2HF-J}ppJNYbf!nVWS>Owr1(mNmGF4#BqFiaE%-Q<`#0?5~6QW`x zHhLu<8r>%%XkFABu3Flo>_XUgFsIiLv_E9q>wYHFZrHBnG5x52gx1I3bLTQ6r=l@` zMyg`Xlc+~!;i8;nr|a_vVY}uC48BO>o{y}12>OGSqS0-t~ z{gBe^|3gn`XzqXDiTDkkYve2>`ElznK%H7u=xA;PyNEE{E+<1!`{X8l(k!lBnK4)? zUFbd}YD(AWxVa2Unqg{cY%V`*Dr-dCv$f5({yzOJHFf_;N4mdBL$9m{@we9}hknCn zKQ0qyLp38smO~O(Pu#(XDEa3sfJJG$r0ZS=8sU9zR@N(22}PqH>2c(bbvo`g9wvrw zT^i{;uU>>Im6p^JMmr+ICwfR67(WTMOf?$qUvzR}jk*sKGno>(*;+BPBvN?&#}($0-y&RkD_Cq?t<{g?c;VzS@FyrGex zK{~A8!Ziz|6koU_MM5c$Gv*uomjp)Hi1;r6%qd1SFw4T{jZKJ81?z(|a|5zpLOqK6 z&WAr6}204JMRavit*6bXOg`N{7EkC8^K%yY_`@< zyTd#^6QDN~FWrz(H*yC1hY`=aLnMqrHwB`Eoi%v0C3@*k-{aE%Y~}y|Zap}^k`ctL zy#&+Vu^OuN12FTPAJzfhtm%$siR}h{nM6v@Gy!Cr#NC?NKsui77m?UpMR5GBbJQyn zk;{*?1ZJgI!c7m|@nQcv^X*5Et-SywZ_9B@m?x>xDUMl4{WQX3Yi z;p3h$*K2Rc{hX+yoi>=!m#YY#(AoEms;i+@;l7BIXSxYCVR&3rz!UHt$6 literal 0 HcmV?d00001 diff --git a/docs/images/folder-hierarchy-2.png b/docs/images/folder-hierarchy-2.png new file mode 100644 index 0000000000000000000000000000000000000000..ef5f47a13a6fb016ef2a81378345f2a8d848a1a7 GIT binary patch literal 7061 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 aPnq(Rb<>zEDr`U{fR@@L)pBKrH~#}-(`exU literal 0 HcmV?d00001 diff --git a/docs/images/folder-hierarchy-3.png b/docs/images/folder-hierarchy-3.png new file mode 100644 index 0000000000000000000000000000000000000000..bc9f3d833ae98c0e6a719292a0b8557dca4e137a GIT binary patch literal 10368 zcmd6NWmKKbvL+tf-7UC>4Fs1Bgy5FowsCj2;O+zq?jGFTB}i~j(BLjX=jGhFcfNJj znm_Yn*4gXb+SR?gy1J{Lr|Jz?QjkVNCPs#Wf1V zCvh1yMBw3#XdDK7Cvuk5a8|W5b#^m!G=Va+wX-o{b%HpWnAkd*+c}@Yb_oMQtgk{6 zjwXiA7IwCjY8EyoQ1ZYBE*?sGLk~(W4lWQS$9sNG5dS+aN+o$pC9qnZh1vxa6eW}l zSWM0R+wq#0d*bz)$eEi(2L3W;F6&eRxdAF;IC8QPY*J#5$M*;m$1FK|N)dP-dnXme z5*#`ShZs&q+X%U&{n4#=g9oB;Og7TeI}Xvu_KMIM__>TM^2k?3++89ce-`|B-{NGx zQGIyevb5cK>U}awf9ldEoaet;*Sz9~u{@zok*&Z4p5c+eq9z0b4 z;Gj5Dve8tI7$LtiLkI>%jA38M-^cXl?p9c4okKcm$9~Nat)dtb5|WPPW`*Lu^Q|F= zi*1=d*PgNS$s@*u?@Un#V<}?$`@z+E8`>2aE;v|!RaI0H)`mLJwE}zm1K(iewYRU{ z9zbnxZyO9o5ygEh^j*rLsc3@*M!#V1La>pPgugjnF-tqSOj%si*xuQp5)fFaUI9VY z8Z~&a;$QAq&2LZE4x6UKY2LqoKRh{kd&r~N1gXdf8+P+WT6Wv;As~((>`H#0qr~vt zcm%u4I?|Qjn~RDS-i|_B>O4_<;`%ojwBc4;^ke~atYLIVt>@VT~fodl-kX89GBn`0nuD~ zGtLyJhlTayo1FH{6f1gpZ{3cM%c*_-oL^H@qaB8ik53Ks=JNTLni^J|?MoaS99&XW z<@x}+@)T0Y7Ct^((P=MTNd|Oe(eGSUX;UZ;R47IXZ$&{ywq}cCV`35q=H(yrLNS)Y z-lXaKBOOF;v$5fl67%=V%${`S#Uzq?)d3wfGHsifwzl@+dB}KvM{lqAs|pwHE-s+5 z@^Tw;Sbi855s{7jZmxyhKWMWVtz8mQ z!nzKGGs+zA9|?H(en(~;$6A|wPUisMf@&2yw{8yKmjmk_cZ6W$E?+8ITv<8(bgs4J$4qM*m!d_R)8pa{WZY<;wMeP#!LbW2XQabK;;-FD za7W}?&h=HE-{I8A_&69ic0bO=FOSawgSV|Yzh`6|$%kZ3SYBS6!aMDbJ1~oumzN6> z2g7d8>Z+-2&nJCv8(X5;-O!fH)gi&UW^)akPt3qr6hK*+=9J4+FmwAjpE%|CDWjTK z-k#o=*Lv~Sxr2%5x7M|%Hjn$4KgzFcdG?9V546QUV*{eZmxkmJUsKqXxuzJc?AUpE3x^B*+;=?^fT$eJPT}r ze{~uv+Zjt)d7_+h;r??R3gb6t!wT%R%0tgJ-3fBe1N0!Js^~CJf;QD5mD1VcQr^pR zb*~=FPLx^T2(vEaRcY2CynXvtuj^d4P^Ea%gUiI+hcUqe*S2$);5*vV>Z(q5jLUtU z?Ym3v_VQ9gw-hI}jJiE>mxy`IH8A*#F|YX>G~(^^;UCjG9FmffJgOtW9+d_xvg4*W zDMik09yhZmSi0gp#N@=fo1yGX=91C0KUSmQH&6CKsOHrH)C#x=I{#6Ml**t@z zx-_33zpvOnIIxV3jpQ!}tomgl=k|(CJ?p}4ZNAZZDF8U!i{sd@?tuJu*EQv2cGqgn z2zh5Yti-KGAdgJgyLpJ@z*CwbXA5<$YGV~5j8yvlJ0=JOBJYZ+TW&AaE}m%-OkC74 zTXX|9`^Xdb_$(@2bC*r zJpW-a{Zz?V7~e{`rUdXYE491!XU=epi6h_EKTT$|u9fDuw!WV5wa$adwXFuDxemiV zgwRRN4Wd!)=ZdzCWI8#%794pbUN3c7OI^l$U58U!y#GGFC{U2WsGfKlX~E@G59ieE z?AY{l;tY0d4iUerKg!kY`9$wbqqN7|wVDLR%~ZW;Qe7T;H(ZJrlBqhX#usXJ9E)F< z)Pb;mYbf^iK6-<+0Pt{Ml|Mru`IGv?LcWjgO<$23x{cPp>?pk2Z}Th!50Drh89BGu z+LiSKJcSSX(zkDnYtNpYj+nZ-x!IYRI}DtMsx#=hYbIfMRW7g zRinwv(+ilnQMmWblGy@mH?aG4*}PY5ntIH+#mna+wA2(7(D7L`el;D}*zUcC#M8~q z%_CuQD#QS!v!lZeCMKrub{oQ7eonI1)+`qn7hr+;!B!h!FH*laILqo(2jD}YBESNH zDD2|Gaid&f=#Md=ZeBKMv{m5ytJH>1!0Z)9c_$Lw&W|-T4-*ZzA;k z4?yonnfjat*ZV7r+4=dr&Q5XyHXZ*SgYR-AgZ~a9$hmlZ&DOr)RRKD`$BD~%OHF-H zNeW-Nl5&d)z??n8dI0-S*UFJeOG|HU7rI)PSPsw=EKfTMEf!*3D>%e=uLa)ch!O>I z!nXS1n`UDNhg5ZV9xsF1#xw&yL8AJTBtD;652}wVSW15DvnzW`&Dl^7O~E_(tMFY7 zA6G}U{9;!7?1b=JWGr1Tbu7D=#Ok-^1Sw61-d$rFNvp(D;^D)cYkNlakB%aboK4j` zH6Cw2FBXHT23N~ZcNSl^t^Srgjs&}oD;Qm32S+T)kj?bX1MRs(devHWa5iHu_QVkk zLH&}sE1ViPjLLs%7Ru+{=Q&>?+<)n2GzFlx@Gvo1FqoG5zp&kZKtlwwEA$BiGjrdc z>3pihkpNv}-*o7m&fS-mmMSse`c_3Scn5h;;S&%@0H`U$uJemlrRgFz z{8W~ZSI(TJ&vJ-VzL=BKJ2j@{>@sR(b}AtkYjn+=Emao>hnj`#vhd+|CMG7rp%t1I z7L22#qmQ+$dG~I~2 z%rk>4(%xHpjDP(=`kcx zS5hfpJ|zwWS;WRt=0p5AwcI8(nHUFh!fynFbHRL{BivB%#f40;=}Ia34;F5>l7#b| zzVMKZZrpS3meL#j#0BjNH6lG&vS|z1b(cP6d^b8#lS}6&t4k3MbQ|joHh3Zib@gEs z%k>w?B=Y;23WtkiA6|Ug_!J9!NMJB`@^YxrB_9fVIM;Y6pB^+|r`ES?Rc?yIjm|d`2RFY6Nc_SH?okJBhHNFgai{FvQFu;327|4)H4GAwx4e(2 z+D#j%JIuE<5;*2sv~hq#UJq?+kxTSn0uiAUmvOH}(k9x!jw)wa^BG zpXb%CKa+NFz@U5*3i|eYWH|K#@$2MA@JtGxc{4FI;&qAO8N3l)Rd-&v#fJ~s{=u@Q zd49)KNfy=60r)!u$#HV}(I$0LG;janZ&%(yJLXv`-MH+f)~QzqQj&!@EWIBdojAB_ z0g)kLA6mk3tC99DZSObh`qD(f!#al`qJCOmZ_#tbm!cb5ItlAU2xVT$P0bVaP9k|Q zF%+F!w^?+P439^eHhM~v_@rdl0}weC^9HRhxO}b35S3EnVQl5$_!v*jNt=VfDDF#b zThz@5&BE-dpmzk+@nys?`_YovT(*+LuKBq#P(KqmJg%$blI`DN+X}MB_fty2r1JB^ z^hv^MZ6#u!vW&s8j>dwhZM4w2XnE&tZF&coeW=JNgZoFy(Zk82&6w-@hXyss-al&?5Eas1>*=6cSt6H$Ms{)X`DD zU5Bebm@YBPA_=;b%(?m%G1&Sav-L3e9S=K>Dma&qr=0W zAfI+Xt?$4SCut3i>+kuNuk^s_TlXZ3+HQtbgm#R&ad)L7r@Uie`D|I?_2^rmyBJ82 zwGqyg??L>zI}MV(xjAgnEHrY^TuF=6w91L6`Q8mO61(s_SKvO!TN|2(LU5yHypY|Y z*%XRS?%p?37_Iy3z@91{Tr1$xtsZ;bMccEFM#2*L7Uxl1w{MBbdQ_O zZ?egL7!<{ZuV2=bz+T$Lp#%9F+{@&zjOI|Hy?iVTgbRNkqDX1=JutOJ%E5h6FXA781G`y$$NyV_ zMJ_VrI;dgh$?}o(^n^bUD^kC2Hg{T-z2hK(MvFQRiu2P+H+$m~j>XtKWDd97$?mNR z?G-3@&iqgNQDG%fNaJ(dXhrPKJY?gLJ^#<>?b&SNB`4SVUqnvdDSKMkEA!Ub8uhJG zMP=U|9y!%kzEj>Zk%S%C>HF+R%pekaE3(SIU*YeE|13O#j!#qk-wi#J_z8HSOY^e! zU&$nL>}P@Qa&C~hSMZJQ`%Zt)nmnXHUvraAV`IVlE9pml*8fX`I-@^G;?xNdkYDA6 zW<+A;yPwk}yike9geN4ia;VWqIQba!W(vKYNU7mL)4Eb_sP=OT#8M@t`<-uiWqH|?#nTMhZ2O+GH(xqOotp zEp+{>+?d$mNa9bNr1M2az4;?+MNMR~|hZNoV^V&AlKAh{T z&xA?mDhvcdf%H`bMtk>?iTH)O)J&Bl$(V^KEoORHh-SAafY4ZghBt?TL5$T}*w0-X8O%%7PFPS!1 z($TTLOx3DvT>TbMQY`zR6Vm9BnPLZis(DN4V_ALM3%@~ggf*q~DatWQI?FQ5j7G)> z4c+@w=#_<=VL^qP+_fjou@h0%_R7mx$XZxqRaJ~UKuxibRMc(_V|dF)r3`nU@~58U zAda6uP~@vK;I5r$jza+df~z3TZ+<*&*#KZD6jveu#M&bL&1vA3*U{1%jR!J#!NGAD zTFDsuPluv$82$&mbU zc5W{C=#AzjQrtJLA|<>VF)Mktl)9y`$0#4k?Tqh&QUE;0W%~~R#{XBiUJGZVLp${o ze_SPOSW8x0T3-tSun}^v4$B|d^==pkfw3eI7-bx+mfu^0gsVcsu0O+lKH_>FU6(a5 zT>9ZMKsa`k1fUFDRhT#PNUJ2k)Rq>9=Bp_a{hPziD@rE7STQFZo@*zLuNh$|N`8J~ zdiDm0ggUcu%Tx^h!x&Ed@Ful@R2BRvVHDBSlPcFk5(BsM@toB{)spT6ojI^4h6Q2* zzM7E%nd1NE8yTvM?nj{vcRFPG4Gx{yMXD53dA}KQM4(fv5Q;wJtus*L$E~5Ojv9lb zyrBrc?W|kwLh6Yjv_+(?0cP*2F07^UC~JihYpVOcgOYJ6H_M};t75{&FF&{dj4Pj9(NzI&fAS`B9m4TM{%Ahveu`r#epg|X=T@^Sp0H&g4LtdLq=F?xY zX}w$a7nl|_A_^)v=a9UpQU)k;^E$H3mc!M{Ejn2LzW(yOng@o40t#W>$W18AMGjX= zu2{JC?-S53SM^x*Fit0gC4N{gp3|Hx0VUJ&O%+=6%uNehhefoQEUpwaam@1LIxs%$ zSBCCaA@Z8>-I}WZYE`(RxAw(L7<^NTnYUi4ed@9=Z-uIrXa94DUo*xuJRo2D^nFW6 z=6?*q%L};f)vCmIad83Im0o3bdOC8j`0u?kpt$hJ%plfgHc1Y6itNVRY4fSoRn+8< zJH=&T!+6nBTj%@$*LQ5u39=09mFNy^tf&~@ivuyUvcf}!u*MI2KiLZ!_QKsTUTZ-M zNtG`gvi|UcG1K6TL3zvYPPMWcR(|j#w+8q4XSP}fJ3^-x( z!nlDPm&6_eM8L~!2c_=$WBv^gJEZf~qC>U;7N>{p%P`wN`S|9J(C?}r{;GZtta@Hv zB4rcTV!b6uck49FHKHK61d>R?u4h7R-qY?4?iXn65HXiiCc#c)K^0pT%5TgAZ@QjA zt`y~gHK!cLr36MheYXY$?YWi&t}x?2b}r4ymuqJ_$-T8 z@8smU#7h2>eGZIN@wsvjg33o z1OI4~Q0Rj1Gckho%G7!6vPR%qhF^4Tkh=R;pY^ZP>aG2a_J~`&(_cvH`l9+@dIRHRyoi}AZ5@~ zhe2iLj?3|g#qzQNVYsV>LHj&pVBC&`i8@6@&EY_O$<*AKNX8`M|)7jzp9|!CyouK6F2k zPVDJSmf@$)&DaKrl+)Y`GxrAaX!?*s=srm;N8*|PUK_qXuR61^8tPb=+;S%O@is$g zwxG9niH1LR=0k%07@2B(vJ>yuM|U#v)_jc7COm`7p3BTby?g9XD+Ghyg41Qvjc~=u z0bF(TqTTAj)}d4B{*0~I`fB=WguE}Asi{v*o*L5Gc$M4t+z|d&)&k@x)C;h$k zB&xGeP)J;}BE6TEV-f$xm+!c}NtMPwFN;hZn0L`i#MfO(ZfoFw3#bUPt1(jKJdzyc zgo1kW||owk8R$ z(Nw>6w37z06Q6-}4bZ1YrY3kN7W;L7Ul0({yW$oXy6Jf4im39&P!xLKwFO59K2D4?8Dxe8R+Zt20A_9aoS2w1L-qJ@%d)G&dUrwPRm= zZRVMpqotkHbmJAwr^FEIqJ90lgVlUXCZCitNiw zTUAZ82fv7=NPX$FXDR=Ne=_Rc76@O3{6Nr=@rp~dzR_Xdt1)^g2G5GaFoWj1$3Kql%t4)sNUFfhmS- zkQUcsJDj0)q{8nM0&e8}rA}xi%eTx&d~|(_X^?pAw>i0+PTtJf>rebGcZR9_oJjh9 z8ew@6b><~csHfH|(FiTXGa*CJ^7@-+1k;38dv~$rPYI}LVWl(^hA#qb4h1))#7x5d z^O?nQIq3+6eX^usFvIG0GTX!HdEiU>jGEt1TYRe8A$ht(2IFelF@>!M|D&*Sc=J=| zL)_!!!uj}aS5DbY_Tp_@Cf~FjcG3E-k=mNXIiU@oRk^guITbfyb*|wP?A@4W`QA^0 ztslp+VvJ*h7@veALKfUMgQZ2pdn?_3dx?0Eqe!_tR`brE3~exJ8}?^tGeO`=Fcbs$u?})NaoEq1HzmAj`H_X0#7xTifTQ|q_m=2#}QNcptjQ$YI-0tlVyx zpXVtyclN4{whn;|yB|ftswYL3yM^X0mIpi5Yv~N?{siyr2dzU_u2lYXPII%y>;h&a zkb@P^MRX;~mDUf0&7Pz3S=McA(sz;(mC)oI|JFHhvH1HE!N~n#&$j*g9iYTru&%D|2T&y_O)g}1R!xBjXN@v(1h^J)z~sowDagY+ zjyx<_Sl90D?V({|AHb+u)YQ~ioUa@f-2Yl5#KsB)$}9h$8+v8H701;J+JS0_%BS;{ zx2;!2AOu!rzW6oU%kqMA_c&y#gSFOcy_EI(!JN0qG(|}xJYBZ{0gbT?hhf?DWWIm# z3jG1m^_n3L{K^moQ0ng8+ds0|)HT*bMxCsNzp%q!rGSrOMoU}(Yn}ZU17#h3q;QBA zl_kL>*s<57M$Y-#7}HtL$%j%jeTaEJCp9K9ogyqLK2S`V_lqN1FEp%2v%A5{HM=Rw z{FHq54N&WZtKgt^Jo#7Jq2!@J6?}p~>EeLNM4e}=Lg02)fb*4&P3d0+(M2DC@z~_0 zNP*Go>!I5wU9KQse)fcJX!`kET(x%NOM!U+Y7bra{L8TgQ4CWK3X_a1Ix%IrCbtX^ zVC~XlF4I=D)B->r!ZZ~*iIebM_kacN9C2kP#zW}}Uq8I=G literal 0 HcmV?d00001 diff --git a/docs/images/folder-hierarchy-4.png b/docs/images/folder-hierarchy-4.png new file mode 100644 index 0000000000000000000000000000000000000000..f0b73151e0ef833bd869812815d91e79fdbbfb88 GIT binary patch literal 19361 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 literal 0 HcmV?d00001 diff --git a/package.json b/package.json index ddf53742..ac627455 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@lit-labs/scoped-registry-mixin": "^1.0.3", "@lit-labs/task": "^1.1.3", "@types/bluebird": "^3.5.42", + "any-date-parser": "^2.2.0", "component-emitter": "^1.3.1", "compute-scroll-into-view": "^3.1.1", "crypto": "^1.0.1", diff --git a/src/camera-manager/motioneye/engine-motioneye.ts b/src/camera-manager/motioneye/engine-motioneye.ts index 0ba24707..1cb50109 100644 --- a/src/camera-manager/motioneye/engine-motioneye.ts +++ b/src/camera-manager/motioneye/engine-motioneye.ts @@ -152,7 +152,7 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine } return { cameraID: cameraID, - startDate: startDate, + startDate, endDate: parent?._metadata?.endDate ?? endOfDay(startDate), }; } @@ -389,8 +389,8 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine engineOptions, ); for (const dayDirectory of directories ?? []) { - if (dayDirectory._metadata) { - days.add(formatDate(dayDirectory._metadata?.startDate)); + if (dayDirectory._metadata?.startDate) { + days.add(formatDate(dayDirectory._metadata.startDate)); } } }; diff --git a/src/camera-manager/reolink/engine-reolink.ts b/src/camera-manager/reolink/engine-reolink.ts index aa4c557c..7ff0dba1 100644 --- a/src/camera-manager/reolink/engine-reolink.ts +++ b/src/camera-manager/reolink/engine-reolink.ts @@ -2,7 +2,6 @@ import { add, endOfDay, parse, startOfDay } from 'date-fns'; import { orderBy } from 'lodash-es'; import { CameraConfig } from '../../config/schema/cameras'; import { getViewMediaFromBrowseMediaArray } from '../../ha/browse-media/browse-media-to-view-media'; -import { sortMediaByStartDate } from '../../ha/browse-media/sort-browse-media-by-start-date'; import { BROWSE_MEDIA_CACHE_SECONDS, BrowseMedia, @@ -213,8 +212,6 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine { matcher: (media: RichBrowseMedia) => media.can_expand && isMediaWithinDates(media, matchOptions?.start, matchOptions?.end), - sorter: (media: RichBrowseMedia[]) => - sortMediaByStartDate(media), }, ], { @@ -278,8 +275,6 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine { matcher: (media: RichBrowseMedia) => !media.can_expand && isMediaWithinDates(media, perCameraQuery.start, perCameraQuery.end), - sorter: (media: RichBrowseMedia[]) => - sortMediaByStartDate(media), }, ], { @@ -357,8 +352,8 @@ export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine { for (const dayDirectory of directories ?? []) { /* istanbul ignore next: This situation cannot happen as the directory will not match without metadata -- @preserve */ - if (dayDirectory._metadata) { - days.add(formatDate(dayDirectory._metadata?.startDate)); + if (dayDirectory._metadata?.startDate) { + days.add(formatDate(dayDirectory._metadata.startDate)); } } }; diff --git a/src/card-controller/folders/ha/engine.ts b/src/card-controller/folders/ha/engine.ts index 1fed5a20..1fc8a896 100644 --- a/src/card-controller/folders/ha/engine.ts +++ b/src/card-controller/folders/ha/engine.ts @@ -7,7 +7,13 @@ import { HAFolderPathComponent, } from '../../../config/schema/folders'; import { getViewItemsFromBrowseMediaArray } from '../../../ha/browse-media/browse-media-to-view-media'; -import { BrowseMedia, BrowseMediaCache } from '../../../ha/browse-media/types'; +import { BrowseMediaViewFolder } from '../../../ha/browse-media/item'; +import { + BrowseMedia, + BrowseMediaCache, + BrowseMediaMetadata, + RichBrowseMedia, +} from '../../../ha/browse-media/types'; import { BrowseMediaStep, BrowseMediaTarget, @@ -26,13 +32,24 @@ import { FolderQuery, FoldersEngine, } from '../types'; +import { MediaMatcher } from './media-matcher'; +import { MetadataGenerator } from './metadata-generator.js'; export class HAFoldersEngine implements FoldersEngine { private _browseMediaManager: BrowseMediaWalker; - private _cache = new BrowseMediaCache(); + private _cache = new BrowseMediaCache(); - public constructor(browseMediaManager?: BrowseMediaWalker) { - this._browseMediaManager = browseMediaManager ?? new BrowseMediaWalker(); + private _metadataGenerator: MetadataGenerator; + private _mediaMatcher: MediaMatcher; + + public constructor(options?: { + browseMediaManager?: BrowseMediaWalker; + metadataGenerator?: MetadataGenerator; + mediaMatcher?: MediaMatcher; + }) { + this._browseMediaManager = options?.browseMediaManager ?? new BrowseMediaWalker(); + this._metadataGenerator = options?.metadataGenerator ?? new MetadataGenerator(); + this._mediaMatcher = options?.mediaMatcher ?? new MediaMatcher(); } public getItemCapabilities(item: ViewItem): ViewItemCapabilities | null { @@ -75,6 +92,23 @@ export class HAFoldersEngine implements FoldersEngine { }; } + private getDefaultFolderPathComponents( + haFolderConfig?: HAFolderConfig, + ): NonEmptyTuple { + const shouldAddDefaultRoot = !haFolderConfig?.url && !haFolderConfig?.path?.[0]?.id; + + const path: HAFolderPathComponent[] = [ + ...(shouldAddDefaultRoot ? [{ id: HA_MEDIA_SOURCE_ROOT }] : []), + ...(haFolderConfig?.url ?? []), + ...(haFolderConfig?.path ?? []), + ]; + + return path.map((component) => ({ ha: component })) as [ + FolderPathComponent, + ...FolderPathComponent[], + ]; + } + public async expandFolder( hass: HomeAssistant, query: FolderQuery, @@ -89,11 +123,16 @@ export class HAFoldersEngine implements FoldersEngine { // Search through the path components from the start to find the last // component with a precise media source id, which is where the queries // start (and may drill down from). - let start: string | null = null; + let start: string | RichBrowseMedia | null = null; while (pathComponents.length > 0) { - const id = pathComponents[0]?.id; - if (id) { - start = id; + const folderBrowseMedia = + pathComponents[0]?.folder instanceof BrowseMediaViewFolder + ? pathComponents[0].folder.getBrowseMedia() + : null; + + const validStart = folderBrowseMedia ?? pathComponents[0]?.ha?.id ?? null; + if (validStart) { + start = validStart; pathComponents.shift(); } else { break; @@ -106,37 +145,38 @@ export class HAFoldersEngine implements FoldersEngine { return null; } - // This matcher matches a browse media against a given path component. - const componentMatcher = ( - media: BrowseMedia, - component?: FolderPathComponent, - ): boolean => { - return ( - !component || - (media.can_expand && - (component.ha?.title === media.title || - (component.ha?.title_re && - new RegExp(component.ha.title_re).test(media.title)) || - component.id === media.media_content_id)) - ); - }; + await this._metadataGenerator.prepare( + pathComponents.flatMap((component) => component.ha?.parsers ?? []), + ); // Generate a walk step, optionally matching against the next path component // (if any), otherwise just returning all the media at this level. - const generateStep = (targets: BrowseMediaTarget[]): BrowseMediaStep[] => { + const generateStep = ( + targets: BrowseMediaTarget[], + ): BrowseMediaStep[] => { const nextComponent = pathComponents.shift(); return [ { targets, + metadataGenerator: (media: BrowseMedia, parent?: BrowseMedia) => + this._metadataGenerator.generate(media, parent, nextComponent?.ha?.parsers), + ...(nextComponent && { - matcher: (media: BrowseMedia) => componentMatcher(media, nextComponent), - advance: (targets) => generateStep(targets), + matcher: (media: BrowseMedia) => + this._mediaMatcher.match( + media, + 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, + ), + advance: (targets) => (pathComponents.length ? generateStep(targets) : []), }), }, ]; }; - const browseMedia = await this._browseMediaManager.walk( + const browseMedia = await this._browseMediaManager.walk( hass, generateStep([start]), { @@ -148,33 +188,4 @@ export class HAFoldersEngine implements FoldersEngine { folder: query.folder, }); } - - private getDefaultFolderPathComponents( - haFolderConfig?: HAFolderConfig, - ): NonEmptyTuple { - const shouldAddDefaultRoot = !haFolderConfig?.url && !haFolderConfig?.path?.[0]?.id; - - const defaultPath = [ - ...(shouldAddDefaultRoot ? [{ id: HA_MEDIA_SOURCE_ROOT }] : []), - ...(haFolderConfig?.url ?? []), - ...(haFolderConfig?.path ?? []), - ]; - - return defaultPath.map((component) => - this._convertHAPathComponentToFolderPathComponent(component), - ) as [FolderPathComponent, ...FolderPathComponent[]]; - } - - // Convert from the HA folder path component config schema to the general, - // which pulls `path` to the top level. - private _convertHAPathComponentToFolderPathComponent( - component: HAFolderPathComponent, - ): FolderPathComponent { - return { - id: component.id, - ha: { - ...component, - }, - }; - } } diff --git a/src/card-controller/folders/ha/media-matcher.ts b/src/card-controller/folders/ha/media-matcher.ts new file mode 100644 index 00000000..e9abc4d9 --- /dev/null +++ b/src/card-controller/folders/ha/media-matcher.ts @@ -0,0 +1,38 @@ +import { Matcher } from '../../../config/schema/folders'; +import { BrowseMedia } from '../../../ha/browse-media/types'; +import { regexpExtract } from '../../../utils/regexp-extract'; +import { REGEXP_GROUP_VALUE_KEY } from './types'; + +export class MediaMatcher { + public match(media: BrowseMedia, matchers?: Matcher[], foldersOnly = false): boolean { + if (foldersOnly && !media.can_expand) { + return false; + } + + for (const matcher of matchers ?? []) { + if (matcher.type === 'title') { + if (!this._matchTitle(matcher, media.title)) { + return false; + } + } + } + + return true; + } + + private _matchTitle(matcher: Matcher, src: string): boolean { + const valueToMatch = matcher.regexp + ? regexpExtract(matcher.regexp, src, { groupName: REGEXP_GROUP_VALUE_KEY }) + : src; + + if (!valueToMatch) { + return false; + } + + if (matcher.title) { + return valueToMatch === matcher.title; + } + + return true; + } +} diff --git a/src/card-controller/folders/ha/metadata-generator.ts b/src/card-controller/folders/ha/metadata-generator.ts new file mode 100644 index 00000000..dc322712 --- /dev/null +++ b/src/card-controller/folders/ha/metadata-generator.ts @@ -0,0 +1,99 @@ +import { parse } from 'date-fns'; +import { Parser } from '../../../config/schema/folders'; +import { + BrowseMedia, + BrowseMediaMetadata, + RichBrowseMedia, +} from '../../../ha/browse-media/types'; + +import parser from 'any-date-parser'; +import { isValidDate } from '../../../utils/basic'; +import { regexpExtract } from '../../../utils/regexp-extract'; +import { REGEXP_GROUP_VALUE_KEY } from './types'; + +export class MetadataGenerator { + protected _anyDateParser: typeof parser | null = null; + + public async prepare(parsers?: Parser[]): Promise { + if (this._anyDateParser) { + return; + } + + // Dynamically import the any-date-parser only if we have a parser that + // requires it, in order to save on bundle size. + if ( + parsers?.some( + (parser) => ['date', 'startdate'].includes(parser.type) && !parser.format, + ) + ) { + this._anyDateParser = (await import('any-date-parser')).default; + } + } + + public generate( + media: BrowseMedia, + parent?: RichBrowseMedia, + parsers?: Parser[], + ): BrowseMediaMetadata | null { + // Always propagate metadata from parent to children. + const metadata: BrowseMediaMetadata = { + ...parent?._metadata, + }; + + for (const parser of parsers ?? []) { + const valueToParse = parser.regexp + ? regexpExtract(parser.regexp, media.title, { + groupName: REGEXP_GROUP_VALUE_KEY, + }) + : media.title; + if (!valueToParse) { + continue; + } + if (parser.type === 'startdate' || parser.type === 'date') { + metadata.startDate = + this._parseDate(parser, valueToParse, parent?._metadata?.startDate) ?? + undefined; + } + } + + return Object.keys(metadata).length > 0 ? metadata : null; + } + + private _parseDate(parser: Parser, src: string, base?: Date): Date | undefined { + if (parser.format) { + return this._parseFormattedDate(parser.format, src, base); + } + return this._parseUnknownDate(src, base); + } + + private _parseFormattedDate( + format: string, + src: string, + base?: Date, + ): Date | undefined { + const result = parse(src, format, base ?? new Date()); + return isValidDate(result) ? result : undefined; + } + + private _parseUnknownDate(src: string, base?: Date): Date | undefined { + if (!this._anyDateParser) { + return undefined; + } + const result = this._anyDateParser.attempt(src); + if (!Object.keys(result).length) { + return undefined; + } + return this._anyDateParser.fromObject({ + ...(base && { + year: base.getFullYear(), + month: base.getMonth() + 1, + day: base.getDate(), + hour: base.getHours(), + minute: base.getMinutes(), + second: base.getSeconds(), + millisecond: base.getMilliseconds(), + }), + ...result, + }); + } +} diff --git a/src/card-controller/folders/ha/types.ts b/src/card-controller/folders/ha/types.ts new file mode 100644 index 00000000..83116b6a --- /dev/null +++ b/src/card-controller/folders/ha/types.ts @@ -0,0 +1 @@ +export const REGEXP_GROUP_VALUE_KEY = 'value'; diff --git a/src/card-controller/folders/types.ts b/src/card-controller/folders/types.ts index b59145b4..03c0e7ae 100644 --- a/src/card-controller/folders/types.ts +++ b/src/card-controller/folders/types.ts @@ -4,7 +4,7 @@ import { ResolvedMediaCache } from '../../ha/resolved-media'; import { HomeAssistant } from '../../ha/types'; import { Endpoint } from '../../types'; import { AdvancedCameraCardError } from '../../types.js'; -import { ViewItem } from '../../view/item'; +import { ViewFolder, ViewItem } from '../../view/item'; import { ViewItemCapabilities } from '../../view/types'; // ==== @@ -22,8 +22,8 @@ export class FolderInitializationError extends AdvancedCameraCardError {} // ============ export type FolderPathComponent = { - id?: string; - ha?: Omit; + folder?: ViewFolder; + ha?: HAFolderPathComponent; }; export interface FolderQuery { diff --git a/src/components-lib/gallery/folder-gallery-controller.ts b/src/components-lib/gallery/folder-gallery-controller.ts index 5291157e..bbbcfff9 100644 --- a/src/components-lib/gallery/folder-gallery-controller.ts +++ b/src/components-lib/gallery/folder-gallery-controller.ts @@ -72,7 +72,7 @@ export class FolderGalleryController { params: { query: view.query.clone().setQuery({ folder: rawQuery.folder, - path: [...rawQuery.path, { id }], + path: [...rawQuery.path, { folder: item }], }), }, }); diff --git a/src/components-lib/thumbnail/details-controller.ts b/src/components-lib/thumbnail/details-controller.ts index c504fef9..7e912afb 100644 --- a/src/components-lib/thumbnail/details-controller.ts +++ b/src/components-lib/thumbnail/details-controller.ts @@ -135,10 +135,9 @@ export class ThumbnailDetailsController { : []), ]; - // To avoid duplication, if the event already has a structured 'what' and a - // starttime, the title is omitted from the details. - const includeTitle = - !ViewItemClassifier.isEvent(item) || !item?.getWhat()?.length || !startTime; + // To avoid duplication, if the event has a starttime, the title is omitted + // from the details. + const includeTitle = !ViewItemClassifier.isEvent(item) || !startTime; this._details = [ ...(includeTitle && itemTitle ? [ diff --git a/src/components/thumbnail-carousel.ts b/src/components/thumbnail-carousel.ts index 875d9ad4..c854168d 100644 --- a/src/components/thumbnail-carousel.ts +++ b/src/components/thumbnail-carousel.ts @@ -128,9 +128,8 @@ export class AdvancedCameraCardThumbnailCarousel extends LitElement { QueryClassifier.isFolderQuery(query) && ViewItemClassifier.isFolder(item) ) { - const id = item.getID(); const rawQuery = query.getQuery(); - if (!id || !rawQuery) { + if (!rawQuery) { return; } @@ -138,7 +137,7 @@ export class AdvancedCameraCardThumbnailCarousel extends LitElement { params: { query: query.clone().setQuery({ folder: rawQuery.folder, - path: [...(rawQuery.path ?? []), { id }], + path: [...(rawQuery.path ?? []), { folder: item }], }), }, }); diff --git a/src/config/schema/folders.ts b/src/config/schema/folders.ts index b7ed632e..d533256c 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://'; @@ -14,11 +14,35 @@ const folderConfigDefault = { ha: {}, }; +const parserBaseSchema = z.object({ + regexp: regexSchema.optional(), +}); +const startdateParserSchema = parserBaseSchema.extend({ + type: z.literal('startdate'), + format: z.string().optional(), +}); +// Simple alias date -> startdate. +const dateParserSchema = startdateParserSchema.extend({ + type: z.literal('date'), +}); +const parserSchema = z.discriminatedUnion('type', [ + dateParserSchema, + startdateParserSchema, +]); +export type Parser = 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 Matcher = z.infer; + const haFolderPathComponentSchema = z.object({ id: z.string().optional(), - - title: z.string().optional(), - title_re: regexSchema.optional(), + parsers: parserSchema.array().optional(), + matchers: matcherSchema.array().optional(), }); export type HAFolderPathComponent = z.infer; @@ -50,9 +74,7 @@ export const transformPathURLToPathArray = ( for (const component of folderPath) { if (component.id && !component.id.startsWith(HA_MEDIA_SOURCE_ROOT)) { - throw new AdvancedCameraCardError( - `Could not parse valid media source URL: ${url}`, - ); + throw new AdvancedCameraCardError(`Could not parse media source URL: ${url}`); } } return folderPath; diff --git a/src/ha/browse-media/browse-media-to-view-media.ts b/src/ha/browse-media/browse-media-to-view-media.ts index 61d4d721..fbd3e5d1 100644 --- a/src/ha/browse-media/browse-media-to-view-media.ts +++ b/src/ha/browse-media/browse-media-to-view-media.ts @@ -13,9 +13,7 @@ export const getViewMediaFromBrowseMediaArray = ( ); }; -export const getViewItemsFromBrowseMediaArray = < - M extends BrowseMediaMetadata | undefined, ->( +export const getViewItemsFromBrowseMediaArray = ( browseMedia: RichBrowseMedia[], options?: ViewMediaSourceOptions, ): ViewItem[] => { diff --git a/src/ha/browse-media/item-factory.ts b/src/ha/browse-media/item-factory.ts index 0acc60f7..02a0ebbb 100644 --- a/src/ha/browse-media/item-factory.ts +++ b/src/ha/browse-media/item-factory.ts @@ -9,7 +9,7 @@ import { export class BrowseMediaViewItemFactory { static create( - browseMedia: RichBrowseMedia, + browseMedia: RichBrowseMedia, options?: ViewMediaSourceOptions, ): ViewItem | null { if (browseMedia.can_expand) { diff --git a/src/ha/browse-media/item.ts b/src/ha/browse-media/item.ts index 50e90c07..224f7585 100644 --- a/src/ha/browse-media/item.ts +++ b/src/ha/browse-media/item.ts @@ -10,7 +10,7 @@ import { ViewMediaSourceOptions, ViewMediaType, } from '../../view/item'; -import { BrowseMedia, BrowseMediaMetadata, RichBrowseMedia } from './types'; +import { BrowseMediaMetadata, RichBrowseMedia } from './types'; interface MediaClassBrowserSetting { icon: string; @@ -114,12 +114,19 @@ export class BrowseMediaEventViewMedia extends ViewMedia implements EventViewMed } export class BrowseMediaViewFolder extends ViewFolder { - constructor(folder: FolderConfig, browseMedia: BrowseMedia) { + private _browseMedia: RichBrowseMedia; + + constructor(folder: FolderConfig, browseMedia: RichBrowseMedia) { super(folder, { id: browseMedia.media_content_id, icon: getIcon(browseMedia.children_media_class ?? browseMedia.media_class), title: browseMedia.title, thumbnail: browseMedia.thumbnail, }); + this._browseMedia = browseMedia; + } + + public getBrowseMedia(): RichBrowseMedia { + return this._browseMedia; } } diff --git a/src/ha/browse-media/sort-browse-media-by-start-date.ts b/src/ha/browse-media/sort-browse-media-by-start-date.ts deleted file mode 100644 index 853ac0fd..00000000 --- a/src/ha/browse-media/sort-browse-media-by-start-date.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { orderBy } from 'lodash-es'; -import { BrowseMediaMetadata, RichBrowseMedia } from './types'; - -export const sortMediaByStartDate = ( - media: RichBrowseMedia[], -): RichBrowseMedia[] => { - return orderBy(media, (media) => media._metadata?.startDate, 'desc'); -}; diff --git a/src/ha/browse-media/types.ts b/src/ha/browse-media/types.ts index 72db28c6..815f9748 100644 --- a/src/ha/browse-media/types.ts +++ b/src/ha/browse-media/types.ts @@ -2,9 +2,9 @@ import { z } from 'zod'; import { ExpiringEqualityCache } from '../../cache/expiring-cache'; export interface BrowseMediaMetadata { - cameraID: string; - startDate: Date; - endDate: Date; + cameraID?: string; + startDate?: Date; + endDate?: Date; what?: string[]; } // Recursive type, cannot use type interference: diff --git a/src/ha/browse-media/within-dates.ts b/src/ha/browse-media/within-dates.ts index d001f81d..febec2dd 100644 --- a/src/ha/browse-media/within-dates.ts +++ b/src/ha/browse-media/within-dates.ts @@ -16,7 +16,7 @@ export const isMediaWithinDates = ( end?: Date, ): boolean => { // If there's no metadata, nothing matches. - if (!media._metadata) { + if (!media._metadata?.startDate || !media._metadata?.endDate) { return false; } diff --git a/src/utils/regexp-extract.ts b/src/utils/regexp-extract.ts new file mode 100644 index 00000000..86720546 --- /dev/null +++ b/src/utils/regexp-extract.ts @@ -0,0 +1,22 @@ +/** + * Extracts a substring from a string using a regular expression pattern, if + * groupName / groupNumber is specified but not found in the result, the full + * match is returned. + */ +export const regexpExtract = ( + pattern: string | RegExp, + val: string, + options?: { + groupName?: string; + groupNumber?: number; + }, +): string | null => { + const match = val.match(pattern); + if (options?.groupName && match?.groups?.[options.groupName]) { + return match.groups[options.groupName]; + } + if (options?.groupNumber !== undefined && match?.[options.groupNumber]) { + return match[options.groupNumber]; + } + return match ? match[0] : null; +}; diff --git a/tests/card-controller/folders/ha/engine.test.ts b/tests/card-controller/folders/ha/engine.test.ts index 1f186a36..3c20349f 100644 --- a/tests/card-controller/folders/ha/engine.test.ts +++ b/tests/card-controller/folders/ha/engine.test.ts @@ -3,6 +3,8 @@ 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 { BrowseMediaViewFolder } from '../../../../src/ha/browse-media/item'; +import { browseMediaSchema } from '../../../../src/ha/browse-media/types'; import { getMediaDownloadPath } from '../../../../src/ha/download'; import { homeAssistantWSRequest } from '../../../../src/ha/ws-request'; import { Endpoint } from '../../../../src/types'; @@ -73,17 +75,6 @@ describe('HAFoldersEngine', () => { }); }); - describe('expandFolder', () => { - it('should reject folders of the wrong type', async () => { - const query = { - folder: { type: 'UNKNOWN' }, - } as unknown as FolderQuery; - const engine = new HAFoldersEngine(); - - expect(await engine.expandFolder(createHASS(), query)).toBeNull(); - }); - }); - describe('should generate default folder query', () => { it('should generate default folder query', () => { const folder: FolderConfig = { type: 'ha' }; @@ -92,7 +83,7 @@ describe('HAFoldersEngine', () => { const query = engine.generateDefaultFolderQuery(folder); expect(query).toEqual({ folder, - path: [{ id: 'media-source://', ha: { id: 'media-source://' } }], + path: [{ ha: { id: 'media-source://' } }], }); }); @@ -113,10 +104,7 @@ describe('HAFoldersEngine', () => { const engine = new HAFoldersEngine(); expect(engine.generateDefaultFolderQuery(folder)).toEqual({ folder, - path: [ - { id: 'media-source://1', ha: { id: 'media-source://1' } }, - { id: 'media-source://2', ha: { id: 'media-source://2' } }, - ], + path: [{ ha: { id: 'media-source://1' } }, { ha: { id: 'media-source://2' } }], }); }); @@ -125,28 +113,39 @@ describe('HAFoldersEngine', () => { const engine = new HAFoldersEngine(); expect(engine.generateDefaultFolderQuery(folder)).toEqual({ folder, - path: [{ id: 'media-source://', ha: { id: 'media-source://' } }], + path: [{ ha: { id: 'media-source://' } }], }); }); it('should add default media root as necessary', async () => { - const folder = createFolder({ ha: { path: [{ title: 'Frigate' }] } }); + const folder = createFolder({ + ha: { path: [{ matchers: [{ type: 'title', title: 'Frigate' }] }] }, + }); const engine = new HAFoldersEngine(); expect(engine.generateDefaultFolderQuery(folder)).toEqual({ folder, path: [ - { id: 'media-source://', ha: { id: 'media-source://' } }, - { id: undefined, ha: { title: 'Frigate' } }, + { ha: { id: 'media-source://' } }, + { ha: { matchers: [{ type: 'title', title: 'Frigate' }] } }, ], }); }); }); describe('should expand folder', () => { + it('should reject folders of the wrong type', async () => { + const query = { + folder: { type: 'UNKNOWN' }, + } as unknown as FolderQuery; + const engine = new HAFoldersEngine(); + + expect(await engine.expandFolder(createHASS(), query)).toBeNull(); + }); + it('should expand folder with cache by default', async () => { const query: FolderQuery = { folder: { type: 'ha' }, - path: [{ id: 'media-source://id' }], + path: [{ ha: { id: 'media-source://id' } }], }; vi.mocked(homeAssistantWSRequest).mockResolvedValueOnce( @@ -183,7 +182,7 @@ describe('HAFoldersEngine', () => { it('should expand folder without cache when requested', async () => { const query: FolderQuery = { folder: { type: 'ha' }, - path: [{ id: 'media-source://id' }], + path: [{ ha: { id: 'media-source://id' } }], }; vi.mocked(homeAssistantWSRequest) @@ -221,150 +220,93 @@ describe('HAFoldersEngine', () => { expect(homeAssistantWSRequest).toBeCalledTimes(2); }); + it('should use id from browsemedia in folder in query', async () => { + const browseMedia = createBrowseMedia({ + media_content_id: 'media-source://id', + can_expand: true, + }); + + const query: FolderQuery = { + folder: { type: 'ha' }, + path: [ + { + folder: new BrowseMediaViewFolder(createFolder(), browseMedia), + }, + ], + }; + + vi.mocked(homeAssistantWSRequest).mockResolvedValueOnce( + createBrowseMedia({ + media_content_id: 'media-source://id', + can_expand: true, + children: [], + }), + ); + + const hass = createHASS(); + const engine = new HAFoldersEngine(); + await engine.expandFolder(hass, query); + + expect(homeAssistantWSRequest).toBeCalledWith(hass, browseMediaSchema, { + type: 'media_source/browse_media', + media_content_id: 'media-source://id', + }); + }); + it('should not expand without a folder with an id', async () => { const query: FolderQuery = { folder: { type: 'ha' }, // There's no component in the query with an id to start from. - path: [{ ha: { title: 'Frigate' } }], + path: [{ ha: {} }], }; const engine = new HAFoldersEngine(); expect(await engine.expandFolder(createHASS(), query)).toBeNull(); }); - it('should expand folder with title based query', async () => { - const query: FolderQuery = { - folder: { type: 'ha' }, - path: [{ id: 'media-source://' }, { ha: { title: 'Frigate' } }], - }; + describe('should apply matchers', async () => { + it('should expand folder with title based query', async () => { + const query: FolderQuery = { + folder: { type: 'ha' }, + path: [ + { ha: { id: 'media-source://' } }, + { ha: { matchers: [{ type: 'title', title: 'Frigate' }] } }, + {}, + ], + }; - vi.mocked(homeAssistantWSRequest) - .mockResolvedValueOnce( - createBrowseMedia({ - media_content_id: 'media-source://', - can_expand: true, - children: [ - createBrowseMedia({ - media_content_id: 'media-source://frigate', - title: 'Frigate', - can_expand: true, - }), - ], - }), - ) - .mockResolvedValueOnce( - createBrowseMedia({ - media_content_id: 'media-source://frigate', - can_expand: true, - children: [ - createBrowseMedia({ - media_content_id: 'media-source://frigate/result', - title: 'Result', - }), - ], - }), - ); + vi.mocked(homeAssistantWSRequest) + .mockResolvedValueOnce( + createBrowseMedia({ + media_content_id: 'media-source://', + can_expand: true, + children: [ + createBrowseMedia({ + media_content_id: 'media-source://frigate', + title: 'Frigate', + can_expand: true, + }), + ], + }), + ) + .mockResolvedValueOnce( + createBrowseMedia({ + media_content_id: 'media-source://frigate', + can_expand: true, + children: [ + createBrowseMedia({ + media_content_id: 'media-source://frigate/result', + title: 'Result', + }), + ], + }), + ); - 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'); - }); - - it('should expand folder with title_re based query', async () => { - const query: FolderQuery = { - folder: { type: 'ha' }, - path: [{ id: 'media-source://' }, { ha: { title_re: 'Fri.*' } }], - }; - - vi.mocked(homeAssistantWSRequest) - .mockResolvedValueOnce( - createBrowseMedia({ - media_content_id: 'media-source://', - can_expand: true, - children: [ - createBrowseMedia({ - media_content_id: 'media-source://frigate', - title: 'Frigate', - can_expand: true, - }), - ], - }), - ) - .mockResolvedValueOnce( - createBrowseMedia({ - media_content_id: 'media-source://frigate', - can_expand: true, - children: [ - createBrowseMedia({ - media_content_id: 'media-source://frigate/result', - title: 'Result', - }), - ], - }), - ); - - 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'); - }); - - it('should expand folder with ha id based query', async () => { - const query: FolderQuery = { - folder: { type: 'ha' }, - path: [ - { id: 'media-source://' }, - { ha: { title: 'Frigate' } }, - { id: 'media-source://frigate/subdir' }, - ], - }; - - vi.mocked(homeAssistantWSRequest) - .mockResolvedValueOnce( - createBrowseMedia({ - media_content_id: 'media-source://', - can_expand: true, - children: [ - createBrowseMedia({ - media_content_id: 'media-source://frigate', - title: 'Frigate', - can_expand: true, - }), - ], - }), - ) - .mockResolvedValueOnce( - createBrowseMedia({ - media_content_id: 'media-source://frigate', - can_expand: true, - children: [ - createBrowseMedia({ - media_content_id: 'media-source://frigate/subdir', - can_expand: true, - }), - ], - }), - ) - .mockResolvedValueOnce( - createBrowseMedia({ - media_content_id: 'media-source://frigate/subdir', - can_expand: true, - children: [ - createBrowseMedia({ - media_content_id: 'media-source://frigate/subdir/result', - title: 'Result', - }), - ], - }), - ); - - 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/subdir/result'); + 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'); + }); }); }); }); diff --git a/tests/card-controller/folders/ha/media-matcher.test.ts b/tests/card-controller/folders/ha/media-matcher.test.ts new file mode 100644 index 00000000..6939149c --- /dev/null +++ b/tests/card-controller/folders/ha/media-matcher.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } 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'; + +describe('MediaMatcher', () => { + describe('match', () => { + const createMediaItem = ( + title: string, + can_expand: boolean, + media_class = 'image', + ): BrowseMedia => ({ + title, + media_class, + media_content_type: media_class === 'directory' ? 'directory' : 'image/jpeg', + media_content_id: `${media_class}_${title.replace(/\s+/g, '_')}`, + can_play: media_class !== 'directory', + can_expand, + thumbnail: null, + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + describe('with title matcher', () => { + it('should return true when title matches exactly', () => { + 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); + }); + + 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); + }); + + it('should return true when title matches regexp and extracted value matches matcher.title', () => { + const mediaMatcher = new MediaMatcher(); + const media = createMediaItem('Prefix-ImportantPart-Suffix', false); + const matchers: Matcher[] = [ + { + type: 'title', + regexp: '^Prefix-(?ImportantPart)-Suffix$', + title: 'ImportantPart', + }, + ]; + expect(mediaMatcher.match(media, matchers)).toBe(true); + }); + + it('should return false when title matches regexp but extracted value does not match matcher.title', () => { + const mediaMatcher = new MediaMatcher(); + const media = createMediaItem('Prefix-ImportantPart-Suffix', false); + const matchers: Matcher[] = [ + { + type: 'title', + regexp: '^Prefix-(?ImportantPart)-Suffix$', + title: 'WrongPart', + }, + ]; + expect(mediaMatcher.match(media, matchers)).toBe(false); + }); + + it('should return true when title matches regexp with an explicit title value', () => { + const mediaMatcher = new MediaMatcher(); + const media = createMediaItem('Prefix-ImportantPart-Suffix', false); + const matchers: Matcher[] = [ + { + type: 'title', + regexp: '^Prefix-(?ImportantPart)-Suffix$', + // title is undefined. + }, + ]; + expect(mediaMatcher.match(media, matchers)).toBe(true); + }); + + it('should return false when title does not match regexp', () => { + const mediaMatcher = new MediaMatcher(); + const media = createMediaItem('Unrelated Title', false); + const matchers: Matcher[] = [ + { + type: 'title', + regexp: '^Prefix-(?ImportantPart)-Suffix$', + title: 'ImportantPart', + }, + ]; + expect(mediaMatcher.match(media, matchers)).toBe(false); + }); + + it('should return false when regexp is provided but does not extract the required group', () => { + const mediaMatcher = new MediaMatcher(); + const media = createMediaItem('Prefix-ImportantPart-Suffix', false); + const matchers: Matcher[] = [ + { + type: 'title', + regexp: `^Prefix-ImportantPart-Suffix$`, // No named group + title: 'ImportantPart', + }, + ]; + expect(mediaMatcher.match(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); + }); + }); + + it('should return false if one of multiple matchers fails', () => { + const mediaMatcher = new MediaMatcher(); + const media = createMediaItem('Test Media One', false); + const matchers: Matcher[] = [ + { type: 'title', title: 'Test Media One' }, // Pass + { type: 'title', title: 'Test Media Two' }, // Fail + ]; + expect(mediaMatcher.match(media, matchers)).toBe(false); + }); + + it('should return true if all multiple matchers pass', () => { + const mediaMatcher = new MediaMatcher(); + const media = createMediaItem('Test Media One', false); + const matchers: Matcher[] = [ + { type: 'title', title: 'Test Media One' }, + { + type: 'title', + regexp: `^(?Test Media One)$`, + title: 'Test Media One', + }, + ]; + expect(mediaMatcher.match(media, matchers)).toBe(true); + }); + + it('should ignore matchers of unknown types', () => { + const mediaMatcher = new MediaMatcher(); + const media = createMediaItem('Test Media', false); + + const matchers: Matcher[] = [{ type: 'unknownMatcherType' as 'title' }]; + expect(mediaMatcher.match(media, matchers)).toBe(true); + }); + }); +}); diff --git a/tests/card-controller/folders/ha/metadata-generator.test.ts b/tests/card-controller/folders/ha/metadata-generator.test.ts new file mode 100644 index 00000000..75c810ff --- /dev/null +++ b/tests/card-controller/folders/ha/metadata-generator.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from 'vitest'; +import { MetadataGenerator } from '../../../../src/card-controller/folders/ha/metadata-generator'; +import { Parser } from '../../../../src/config/schema/folders'; +import { createBrowseMedia, createRichBrowseMedia } from '../../../test-utils'; + +describe('MetadataGenerator', () => { + const browseMedia = createBrowseMedia({ + title: 'Test Media 2025-05-26 18:18', + }); + const expectedDate = new Date('2025-05-26T18:18:00.000Z'); + const formatlessDateParser: Parser = { + type: 'startdate', + }; + + describe('should dynamically import any-date-parser when needed', () => { + it('should not import any-date-parser if no parsers require it', async () => { + const generator = new MetadataGenerator(); + await generator.prepare([ + // Pretend no parsers require unknown date formats. + ]); + + expect( + generator.generate(browseMedia, undefined, [formatlessDateParser])?.startDate, + ).toBeUndefined(); + }); + + it('should import any-date-parser if a parser requires it', async () => { + const generator = new MetadataGenerator(); + + await generator.prepare([formatlessDateParser]); + expect( + generator.generate(browseMedia, undefined, [formatlessDateParser])?.startDate, + ).toEqual(expectedDate); + + // Re-preparing should nothing. + await generator.prepare([formatlessDateParser]); + expect( + generator.generate(browseMedia, undefined, [formatlessDateParser])?.startDate, + ).toEqual(expectedDate); + }); + }); + + describe('should generate metadata', () => { + it('should not generate metadata without an parsers', async () => { + const generator = new MetadataGenerator(); + await generator.prepare([]); + + expect(generator.generate(browseMedia)).toBeNull(); + }); + + it('should ignore unknown parsers', async () => { + const goodParser: Parser = { + type: 'date', + }; + const badParser: Parser = { + type: 'UNKNOWN' as 'date', + }; + const generator = new MetadataGenerator(); + await generator.prepare([goodParser, badParser]); + + expect( + generator.generate(browseMedia, undefined, [goodParser, badParser]), + ).toEqual({ + startDate: expectedDate, + }); + }); + + describe('should generate date metadata', () => { + describe('should generate date metadata without a date format', () => { + it('should generate start date without a date format', async () => { + const generator = new MetadataGenerator(); + await generator.prepare([formatlessDateParser]); + + expect( + generator.generate(browseMedia, undefined, [formatlessDateParser]) + ?.startDate, + ).toEqual(expectedDate); + }); + + it('should treat date as an alias for the startdate parser', async () => { + const parser: Parser = { + type: 'date', + }; + const generator = new MetadataGenerator(); + await generator.prepare([parser]); + + expect( + generator.generate(browseMedia, undefined, [parser])?.startDate, + ).toEqual(expectedDate); + }); + + it('should not parse when regexp fails to match', async () => { + const parser: Parser = { + type: 'date', + regexp: 'WILL_NOT_MATCH', + }; + const generator = new MetadataGenerator(); + await generator.prepare([parser]); + + expect( + generator.generate(browseMedia, undefined, [parser])?.startDate, + ).toBeUndefined(); + }); + + it('should fail to generate start date without a date format', async () => { + const badBrowseMedia = createBrowseMedia({ + title: 'Test Media NO DATE', + }); + const generator = new MetadataGenerator(); + await generator.prepare([formatlessDateParser]); + + expect( + generator.generate(badBrowseMedia, undefined, [formatlessDateParser]) + ?.startDate, + ).toBeUndefined(); + }); + + it('should incorporate parent metadata without a date format', async () => { + const parentBrowseMedia = createRichBrowseMedia({ + title: '2025-05-26', + _metadata: { + startDate: new Date('2025-05-26T00:00:00.000Z'), + }, + }); + const childBrowseMedia = createBrowseMedia({ + title: '22:42', + }); + const generator = new MetadataGenerator(); + await generator.prepare([formatlessDateParser]); + + expect( + generator.generate(childBrowseMedia, parentBrowseMedia, [ + formatlessDateParser, + ])?.startDate, + ).toEqual(new Date('2025-05-26T22:42:00.000Z')); + }); + }); + + describe('should generate date metadata with a date format', () => { + it('should generate start date with a date formater and a regexp', async () => { + const dateParser: Parser = { + type: 'startdate', + format: 'yyyy-MM-dd HH:mm', + regexp: 'Test Media (?.*)', + }; + const generator = new MetadataGenerator(); + await generator.prepare([dateParser]); + + expect( + generator.generate(browseMedia, undefined, [dateParser])?.startDate, + ).toEqual(expectedDate); + }); + + it('should fail to generate start date with a date formater without a regexp', async () => { + const dateParser: Parser = { + type: 'startdate', + format: 'yyyy-MM-dd HH:mm', + }; + const generator = new MetadataGenerator(); + await generator.prepare([dateParser]); + + expect( + generator.generate(browseMedia, undefined, [dateParser])?.startDate, + ).toBeUndefined(); + }); + + it('should incorporate parent metadata with a date format', async () => { + const parentBrowseMedia = createRichBrowseMedia({ + title: '2025-05-26', + _metadata: { + startDate: new Date('2025-05-26T00:00:00.000Z'), + }, + }); + const childBrowseMedia = createBrowseMedia({ + title: '22:42', + }); + const parser: Parser = { + type: 'startdate', + format: 'HH:mm', + }; + const generator = new MetadataGenerator(); + await generator.prepare([parser]); + + expect( + generator.generate(childBrowseMedia, parentBrowseMedia, [parser])?.startDate, + ).toEqual(new Date('2025-05-26T22:42:00.000Z')); + }); + }); + }); + }); +}); diff --git a/tests/components-lib/gallery/folder-gallery-controller.test.ts b/tests/components-lib/gallery/folder-gallery-controller.test.ts index 0e774e80..1441a8d3 100644 --- a/tests/components-lib/gallery/folder-gallery-controller.test.ts +++ b/tests/components-lib/gallery/folder-gallery-controller.test.ts @@ -161,7 +161,7 @@ describe('FolderGalleryController', () => { }), query: new FolderViewQuery({ folder, - path: [{ id: 'grandparent' }], + path: [{ ha: { id: 'grandparent' } }], }), }); @@ -182,7 +182,7 @@ describe('FolderGalleryController', () => { expect(newQuery).toBeInstanceOf(FolderViewQuery); expect(newQuery?.getQuery()).toEqual({ folder, - path: [{ id: 'grandparent' }, { id: folderItem.getID() }], + path: [{ ha: { id: 'grandparent' } }, { folder: folderItem }], }); }); @@ -214,7 +214,7 @@ describe('FolderGalleryController', () => { const view = createView({ query: new FolderViewQuery({ folder, - path: [{ id: 'id' }], + path: [{ ha: { id: 'id' } }], }), }); const viewManager = mock(); diff --git a/tests/components-lib/thumbnail/details-controller.test.ts b/tests/components-lib/thumbnail/details-controller.test.ts index 8eed8164..e2ddb055 100644 --- a/tests/components-lib/thumbnail/details-controller.test.ts +++ b/tests/components-lib/thumbnail/details-controller.test.ts @@ -118,10 +118,9 @@ describe('ThumbnailDetailsController', () => { ]); }); - it('should not have title with a what and a start time', () => { + it('should not have title with a start time', () => { const item = new TestViewMedia({ title: 'Test Event', - what: ['person', 'car'], startTime: new Date('2025-05-22T21:12:00Z'), }); diff --git a/tests/config/schema/folders.test.ts b/tests/config/schema/folders.test.ts index cd73e971..d9462979 100644 --- a/tests/config/schema/folders.test.ts +++ b/tests/config/schema/folders.test.ts @@ -60,7 +60,7 @@ describe('transformURLToMediaSourceRoot', () => { it.each(prefixes)('with prefix %s', (urlPrefix: string) => { const url = `${urlPrefix}media-browser/browser,does-not-start-with-media-source`; expect(() => transformPathURLToPathArray(url)).toThrowError( - /Could not parse valid media source URL/, + /Could not parse media source URL/, ); }); }); diff --git a/tests/ha/sort-browse-media-by-start-date.test.ts b/tests/ha/sort-browse-media-by-start-date.test.ts deleted file mode 100644 index 8c82cc8b..00000000 --- a/tests/ha/sort-browse-media-by-start-date.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { sortMediaByStartDate } from '../../src/ha/browse-media/sort-browse-media-by-start-date'; -import { BrowseMediaMetadata, RichBrowseMedia } from '../../src/ha/browse-media/types'; -import { createBrowseMedia, createRichBrowseMedia } from '../test-utils'; - -const createMetadata = ( - metadata: Partial, -): BrowseMediaMetadata => ({ - cameraID: 'camera.office', - startDate: new Date('2025-05-10T20:22:00Z'), - endDate: new Date('2025-05-10T20:22:10Z'), - ...metadata, -}); - -describe('sortMediaByStartDate', () => { - it('should return an empty array when given an empty array', () => { - const media: RichBrowseMedia[] = []; - expect(sortMediaByStartDate(media)).toEqual([]); - }); - - it('should sort media by startDate in descending order', () => { - const media: RichBrowseMedia[] = [ - createRichBrowseMedia({ - title: 'Media 1', - _metadata: createMetadata({ startDate: new Date('2025-05-10T20:29:00.000Z') }), - }), - createRichBrowseMedia({ - title: 'Media 2', - _metadata: createMetadata({ startDate: new Date('2025-05-12T20:29:00.000Z') }), - }), - createRichBrowseMedia({ - title: 'Media 3', - _metadata: createMetadata({ startDate: new Date('2025-05-11T20:29:00.000Z') }), - }), - ]; - const expected: RichBrowseMedia[] = [ - createRichBrowseMedia({ - title: 'Media 2', - _metadata: createMetadata({ startDate: new Date('2025-05-12T20:29:00.000Z') }), - }), - createRichBrowseMedia({ - title: 'Media 3', - _metadata: createMetadata({ startDate: new Date('2025-05-11T20:29:00.000Z') }), - }), - createRichBrowseMedia({ - title: 'Media 1', - _metadata: createMetadata({ startDate: new Date('2025-05-10T20:29:00.000Z') }), - }), - ]; - expect(sortMediaByStartDate(media)).toEqual(expected); - }); - - it('should handle items with undefined startDate by placing them at the end', () => { - const media: RichBrowseMedia[] = [ - createRichBrowseMedia({ - title: 'Media 1', - _metadata: createMetadata({ startDate: new Date('2025-05-10T20:29:00.000Z') }), - }), - createBrowseMedia({ - title: 'Media 2', - }), - createRichBrowseMedia({ - title: 'Media 3', - _metadata: createMetadata({ startDate: new Date('2025-05-12T20:29:00.000Z') }), - }), - createBrowseMedia({ - title: 'Media 4', - }), - ]; - const expected: RichBrowseMedia[] = [ - createBrowseMedia({ - title: 'Media 2', - }), - createBrowseMedia({ - title: 'Media 4', - }), - createRichBrowseMedia({ - title: 'Media 3', - _metadata: createMetadata({ startDate: new Date('2025-05-12T20:29:00.000Z') }), - }), - createRichBrowseMedia({ - title: 'Media 1', - _metadata: createMetadata({ startDate: new Date('2025-05-10T20:29:00.000Z') }), - }), - ]; - - expect(sortMediaByStartDate(media)).toEqual(expected); - }); -}); diff --git a/tests/utils/regexp-extract.test.ts b/tests/utils/regexp-extract.test.ts new file mode 100644 index 00000000..1071a5ad --- /dev/null +++ b/tests/utils/regexp-extract.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { regexpExtract } from '../../src/utils/regexp-extract'; + +describe('regexpExtract', () => { + it('returns null if no match is found', () => { + expect(regexpExtract(/foo/, 'bar')).toBeNull(); + }); + + it('returns the full match if no group is specified', () => { + expect(regexpExtract(/foo/, 'foo bar')).toBe('foo'); + }); + + it('returns the named group if groupName is specified', () => { + const pattern = /(?\w+)\s(?\w+)/; + expect(regexpExtract(pattern, 'hello world', { groupName: 'first' })).toBe('hello'); + expect(regexpExtract(pattern, 'hello world', { groupName: 'second' })).toBe('world'); + }); + + it('returns the numbered group if groupNumber is specified', () => { + expect(regexpExtract(/(\d+)-(\w+)/, '123-abc', { groupNumber: 1 })).toBe('123'); + expect(regexpExtract(/(\d+)-(\w+)/, '123-abc', { groupNumber: 2 })).toBe('abc'); + }); + + it('returns full match if groupName does not exist', () => { + expect(regexpExtract(/(?\w+)/, 'bar', { groupName: 'baz' })).toBe('bar'); + }); + + it('returns full match if groupNumber does not exist', () => { + expect(regexpExtract(/(\d+)/, '123', { groupNumber: 2 })).toBe('123'); + }); + + it('works with string pattern', () => { + expect(regexpExtract('(foo)', 'foo bar', { groupNumber: 1 })).toBe('foo'); + }); + + it('prefers groupName over groupNumber if both are provided', () => { + const pattern = /(?\w+)\s(\w+)/; + expect( + regexpExtract(pattern, 'hello world', { groupName: 'word', groupNumber: 2 }), + ).toBe('hello'); + }); + + it('returns null if match exists but group is undefined', () => { + expect(regexpExtract(/foo(bar)?/, 'foo', { groupNumber: 1 })).toBe('foo'); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index fa084e55..483319c8 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -36,6 +36,7 @@ const FULL_COVERAGE_FILES_RELATIVE = [ 'utils/media-layout.ts', 'utils/media.ts', 'utils/ptz.ts', + 'utils/regexp-extract.ts', 'utils/screenshot.ts', 'utils/scroll.ts', 'utils/substream.ts', diff --git a/yarn.lock b/yarn.lock index 7da752e8..ae75e320 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2500,6 +2500,7 @@ __metadata: "@typescript-eslint/eslint-plugin": "npm:^8.30.1" "@typescript-eslint/parser": "npm:^8.30.1" "@vitest/coverage-istanbul": "npm:^1.6.0" + any-date-parser: "npm:^2.2.0" component-emitter: "npm:^1.3.1" compute-scroll-into-view: "npm:^3.1.1" conventional-changelog-conventionalcommits: "npm:^8.0.0" @@ -2697,6 +2698,13 @@ __metadata: languageName: node linkType: hard +"any-date-parser@npm:^2.2.0": + version: 2.2.0 + resolution: "any-date-parser@npm:2.2.0" + checksum: 10c0/e0d3cbadedc761c20fc351497a3fc8f467e2dbf9f3d90637c0ee966b7fc249beb2032d96cfaf9c91200a7c91cf2bff116e617a29afd0bcaf868bf35d595b4956 + languageName: node + linkType: hard + "any-promise@npm:^1.0.0": version: 1.3.0 resolution: "any-promise@npm:1.3.0"