@crombolo/api-delivery 0.0.0-stage → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/output.ts ADDED
@@ -0,0 +1,363 @@
1
+ /**
2
+ * The JSON the Delivery API answers with, built from what the published cache
3
+ * holds: content, media, links, blocks and each editor's value, in Umbraco
4
+ * 18.2's shapes. docs/delivery-api.md, "Output".
5
+ */
6
+ import {
7
+ type BlockGridItem,
8
+ type BlockItem,
9
+ convertValue,
10
+ ImageCropperValue,
11
+ type Link,
12
+ MediaWithCrops,
13
+ PublishedContent,
14
+ type PublishedElement,
15
+ PublishedMedia,
16
+ type PublishedProperty,
17
+ RawHtml,
18
+ } from '@crombolo/render'
19
+ import { type ExpansionNode, keeps, nextNode } from './expansion.ts'
20
+ import type { Access } from './protection.ts'
21
+
22
+ export interface ApiRoute {
23
+ path: string
24
+ queryString: string | null
25
+ startItem: { id: string; path: string }
26
+ }
27
+
28
+ /** What building output needs from the request and the cache. */
29
+ export interface OutputContext {
30
+ /** The route of a page in the request's culture; undefined when it has none. */
31
+ route(content: PublishedContent): ApiRoute | undefined
32
+ /** A page by key, in the request's culture. */
33
+ content(key: string): PublishedContent | undefined
34
+ allowedType(alias: string): boolean
35
+ /** Whether the caller may see a page's properties when a reference to it is expanded. */
36
+ access(content: PublishedContent): Access
37
+ }
38
+
39
+ const iso = (date: Date): string => date.toISOString()
40
+
41
+ /** A property's stored shape and settings, for the one alias in the culture in scope. */
42
+ function propertyOf(
43
+ properties: readonly PublishedProperty[],
44
+ alias: string,
45
+ culture: string | null,
46
+ ): PublishedProperty | undefined {
47
+ const own = properties.filter((p) => p.alias === alias)
48
+ return (
49
+ own.find((p) => p.culture && culture && p.culture.toLowerCase() === culture.toLowerCase()) ??
50
+ own.find((p) => p.culture === null) ??
51
+ own[0]
52
+ )
53
+ }
54
+
55
+ const empty = (value: unknown): boolean =>
56
+ value === null ||
57
+ value === undefined ||
58
+ value === '' ||
59
+ (Array.isArray(value) && value.length === 0)
60
+
61
+ /** A page as a reference: everything but `cultures`, its properties only when expanded. */
62
+ export function apiContent(
63
+ content: PublishedContent,
64
+ ctx: OutputContext,
65
+ expand: ExpansionNode | undefined,
66
+ include: ExpansionNode | undefined,
67
+ topLevel = false,
68
+ ): Record<string, unknown> | null {
69
+ const route = ctx.route(content)
70
+ if (!route) return null
71
+ const showProperties = topLevel || (expand !== undefined && ctx.access(content) === 'allowed')
72
+ return {
73
+ contentType: content.contentType.alias,
74
+ name: content.name,
75
+ createDate: iso(content.createDate),
76
+ updateDate: iso(content.updateDate),
77
+ route,
78
+ id: content.key,
79
+ properties: showProperties ? properties(content, ctx, expand, include) : {},
80
+ }
81
+ }
82
+
83
+ function properties(
84
+ item: PublishedContent | PublishedElement,
85
+ ctx: OutputContext,
86
+ expand: ExpansionNode | undefined,
87
+ include: ExpansionNode | undefined,
88
+ ): Record<string, unknown> {
89
+ const culture = item instanceof PublishedContent ? item.culture : null
90
+ const out: Record<string, unknown> = {}
91
+ const aliases = [...new Set(item.properties.map((p) => p.alias))]
92
+ for (const alias of aliases) {
93
+ if (!keeps(include, alias)) continue
94
+ const property = propertyOf(item.properties, alias, culture) as PublishedProperty
95
+ const converted = item.value(alias)
96
+ out[alias] = deliveryValue(
97
+ property,
98
+ converted,
99
+ ctx,
100
+ nextNode(expand, alias),
101
+ include === undefined || include.children.length === 0 ? include : nextNode(include, alias),
102
+ )
103
+ }
104
+ return out
105
+ }
106
+
107
+ /** An element (a block, a library element): its properties are always mapped. */
108
+ function apiElement(
109
+ element: PublishedElement | null,
110
+ ctx: OutputContext,
111
+ expand: ExpansionNode | undefined,
112
+ include: ExpansionNode | undefined,
113
+ ): Record<string, unknown> | null {
114
+ if (!element) return null
115
+ return {
116
+ contentType: element.contentType.alias ?? '',
117
+ id: element.key,
118
+ name: '',
119
+ properties: properties(element, ctx, expand, include ?? { key: '', children: [] }),
120
+ }
121
+ }
122
+
123
+ const numberOf = (media: PublishedMedia, alias: string): number | null => {
124
+ const value = Number(media.value(alias))
125
+ return media.value(alias) === null || Number.isNaN(value) ? null : value
126
+ }
127
+
128
+ /** Media's own fields; its properties exclude the `umbraco*` ones, and appear only when expanded. */
129
+ function mediaCore(
130
+ media: PublishedMedia,
131
+ expand: ExpansionNode | undefined,
132
+ topLevel: boolean,
133
+ ): Record<string, unknown> {
134
+ const extension = media.value('umbracoExtension')
135
+ return {
136
+ id: media.key,
137
+ name: media.name,
138
+ mediaType: media.mediaType.alias,
139
+ url: media.url,
140
+ extension: typeof extension === 'string' && extension ? extension : null,
141
+ width: numberOf(media, 'umbracoWidth'),
142
+ height: numberOf(media, 'umbracoHeight'),
143
+ bytes: numberOf(media, 'umbracoBytes'),
144
+ properties: topLevel || expand !== undefined ? mediaProperties(media) : {},
145
+ }
146
+ }
147
+
148
+ function mediaProperties(media: PublishedMedia): Record<string, unknown> {
149
+ const out: Record<string, unknown> = {}
150
+ for (const property of media.properties) {
151
+ if (property.alias.toLowerCase().startsWith('umbraco')) continue
152
+ const value = convertValue(property.editorAlias, property.value, undefined, property.config)
153
+ out[property.alias] = empty(value) ? null : plain(value)
154
+ }
155
+ return out
156
+ }
157
+
158
+ const crops = (value: ImageCropperValue) =>
159
+ value.crops.map((crop) => ({
160
+ alias: crop.alias,
161
+ width: crop.width,
162
+ height: crop.height,
163
+ coordinates: crop.coordinates ?? null,
164
+ }))
165
+
166
+ /** A media item as a query or item endpoint answers it: its tree path, dates, crops. */
167
+ export function apiMediaResponse(media: PublishedMedia, path: string): Record<string, unknown> {
168
+ const image = media.image
169
+ return {
170
+ path,
171
+ createDate: iso(media.createDate),
172
+ updateDate: iso(media.updateDate),
173
+ ...mediaCore(media, undefined, true),
174
+ focalPoint: image.focalPoint,
175
+ crops: image.src || image.crops.length > 0 ? crops(image) : null,
176
+ }
177
+ }
178
+
179
+ function mediaWithCrops(
180
+ pick: MediaWithCrops,
181
+ expand: ExpansionNode | undefined,
182
+ ): Record<string, unknown> {
183
+ // The pick's own crops and focal point win; the media's fill in what it lacks.
184
+ const own = pick.media.image
185
+ const merged = own.crops.map((crop) => {
186
+ const local = pick.crops.find((c) => c.alias === crop.alias)
187
+ return local ? { ...crop, ...local, coordinates: local.coordinates ?? crop.coordinates } : crop
188
+ })
189
+ return {
190
+ focalPoint: pick.focalPoint ?? own.focalPoint ?? null,
191
+ crops: crops(new ImageCropperValue({ crops: merged })),
192
+ ...mediaCore(pick.media, expand, false),
193
+ }
194
+ }
195
+
196
+ function link(linkValue: Link, ctx: OutputContext): Record<string, unknown> | null {
197
+ if (linkValue.type === 'document' && linkValue.key) {
198
+ const target = ctx.content(linkValue.key)
199
+ if (!target) return null
200
+ const route = ctx.route(target)
201
+ if (!route) return null
202
+ return {
203
+ url: null,
204
+ queryString: null,
205
+ title: linkValue.name || target.name,
206
+ target: linkValue.target,
207
+ destinationId: target.key,
208
+ destinationType: target.contentType.alias,
209
+ route,
210
+ linkType: 'Content',
211
+ culture: target.culture,
212
+ }
213
+ }
214
+ return {
215
+ url: linkValue.url,
216
+ queryString: null,
217
+ title: linkValue.name,
218
+ target: linkValue.target,
219
+ destinationId: linkValue.key,
220
+ destinationType: linkValue.type === 'media' ? 'Media' : null,
221
+ route: null,
222
+ linkType: linkValue.type === 'media' ? 'Media' : 'External',
223
+ culture: null,
224
+ }
225
+ }
226
+
227
+ function blockItem(
228
+ item: BlockItem,
229
+ ctx: OutputContext,
230
+ expand: ExpansionNode | undefined,
231
+ include: ExpansionNode | undefined,
232
+ ) {
233
+ return {
234
+ content: apiElement(item.content, ctx, expand, include),
235
+ settings: apiElement(item.settings, ctx, expand, include),
236
+ }
237
+ }
238
+
239
+ function gridItem(
240
+ item: BlockGridItem,
241
+ ctx: OutputContext,
242
+ columns: number,
243
+ expand: ExpansionNode | undefined,
244
+ include: ExpansionNode | undefined,
245
+ ): Record<string, unknown> {
246
+ return {
247
+ rowSpan: item.rowSpan ?? 1,
248
+ columnSpan: item.columnSpan ?? columns,
249
+ areaGridColumns: columns,
250
+ areas: item.areas.map((area) => ({
251
+ alias: area.key,
252
+ rowSpan: 1,
253
+ columnSpan: columns,
254
+ items: area.items.map((inner) => gridItem(inner, ctx, columns, expand, include)),
255
+ })),
256
+ ...blockItem(item, ctx, expand, include),
257
+ }
258
+ }
259
+
260
+ /** What a value is when nothing more specific applies: dates as ISO, markup as text. */
261
+ function plain(value: unknown): unknown {
262
+ if (value instanceof Date) return iso(value)
263
+ if (value instanceof RawHtml) return value.value
264
+ if (value instanceof ImageCropperValue)
265
+ return { url: value.src || null, focalPoint: value.focalPoint, crops: crops(value) }
266
+ if (Array.isArray(value)) return value.map(plain)
267
+ return value
268
+ }
269
+
270
+ const list = <T>(value: unknown): T[] =>
271
+ Array.isArray(value) ? (value as T[]) : value === null || value === undefined ? [] : [value as T]
272
+
273
+ /** One property's Delivery API value. Empty is `null`, as in Umbraco. */
274
+ export function deliveryValue(
275
+ property: PublishedProperty,
276
+ converted: unknown,
277
+ ctx: OutputContext,
278
+ expand: ExpansionNode | undefined,
279
+ include: ExpansionNode | undefined,
280
+ ): unknown {
281
+ if (empty(property.value) || empty(converted)) return null
282
+ const config = property.config ?? {}
283
+ switch (property.editorAlias) {
284
+ case 'Umbraco.MemberPicker':
285
+ return '(unsupported)'
286
+ case 'Umbraco.RichText':
287
+ return { markup: plain(converted), blocks: [] }
288
+ case 'Umbraco.ContentPicker': {
289
+ const content = converted instanceof PublishedContent ? converted : undefined
290
+ if (!content || !ctx.allowedType(content.contentType.alias)) return null
291
+ return apiContent(content, ctx, expand, include)
292
+ }
293
+ case 'Umbraco.MultiNodeTreePicker':
294
+ return list<unknown>(converted).flatMap((item) => {
295
+ if (item instanceof PublishedContent)
296
+ return ctx.allowedType(item.contentType.alias)
297
+ ? [apiContent(item, ctx, expand, include)].filter(Boolean)
298
+ : []
299
+ if (item instanceof PublishedMedia) return [mediaCore(item, expand, false)]
300
+ return []
301
+ })
302
+ case 'Umbraco.MediaPicker3':
303
+ return list<MediaWithCrops>(converted)
304
+ .filter((pick) => pick instanceof MediaWithCrops)
305
+ .map((pick) => mediaWithCrops(pick, expand))
306
+ case 'Umbraco.MultiUrlPicker':
307
+ return list<Link>(converted).flatMap((l) => {
308
+ const built = link(l, ctx)
309
+ return built ? [built] : []
310
+ })
311
+ case 'Umbraco.BlockList':
312
+ return { items: list<BlockItem>(converted).map((i) => blockItem(i, ctx, expand, include)) }
313
+ case 'Umbraco.BlockGrid': {
314
+ const columns = Number(config.gridColumns) || 12
315
+ return {
316
+ gridColumns: columns,
317
+ items: list<BlockGridItem>(converted).map((i) =>
318
+ gridItem(i, ctx, columns, expand, include),
319
+ ),
320
+ }
321
+ }
322
+ case 'Umbraco.ElementPicker':
323
+ return list<PublishedElement>(converted).map((e) => apiElement(e, ctx, expand, include))
324
+ case 'Umbraco.DropDown.Flexible': {
325
+ const values = list<string>(converted)
326
+ return config.multiple === true ? values : (values[0] ?? null)
327
+ }
328
+ case 'Umbraco.CheckBoxList':
329
+ case 'Umbraco.MultipleTextstring':
330
+ case 'Umbraco.Tags':
331
+ return list<string>(converted)
332
+ case 'Umbraco.DateTime':
333
+ // Umbraco's legacy date picker has no time zone, so no `Z`.
334
+ return typeof converted === 'string' ? converted.replace(' ', 'T') : plain(converted)
335
+ case 'Umbraco.ColorPicker': {
336
+ if (typeof converted === 'object' && converted !== null) {
337
+ const color = converted as { value?: unknown; label?: unknown }
338
+ return { value: color.value ?? null, label: color.label ?? color.value ?? null }
339
+ }
340
+ return { value: converted, label: converted }
341
+ }
342
+ default:
343
+ return plain(converted)
344
+ }
345
+ }
346
+
347
+ /** A page as an item or query endpoint answers it: a reference, plus its routes per culture. */
348
+ export function apiContentResponse(
349
+ content: PublishedContent,
350
+ ctx: OutputContext,
351
+ routeIn: (culture: string) => ApiRoute | undefined,
352
+ expand: ExpansionNode | undefined,
353
+ include: ExpansionNode | undefined,
354
+ ): Record<string, unknown> | null {
355
+ const built = apiContent(content, ctx, expand, include, true)
356
+ if (!built) return null
357
+ const cultures: Record<string, ApiRoute> = {}
358
+ for (const culture of content.cultures) {
359
+ const route = routeIn(culture)
360
+ if (route) cultures[culture] = route
361
+ }
362
+ return { ...built, cultures }
363
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Members-only content: the nearest protected ancestor governs, as on the
3
+ * site, and a member is let in by name or by one of their groups. Umbraco's
4
+ * `PublicAccessChecker`, over rules read once per request.
5
+ */
6
+
7
+ export interface ProtectionRule {
8
+ /** The protected node's id: its own, or the ancestor whose rule covers it. */
9
+ nodeId: number
10
+ memberUserNames: string[]
11
+ memberGroupNames: string[]
12
+ }
13
+
14
+ export interface DeliveryMember {
15
+ key: string
16
+ userName: string
17
+ groups: string[]
18
+ approved: boolean
19
+ lockedOut: boolean
20
+ }
21
+
22
+ /** `allowed`; `unauthorized` (401: nobody, or a member who may not sign in); `forbidden` (403). */
23
+ export type Access = 'allowed' | 'unauthorized' | 'forbidden'
24
+
25
+ /** The rule governing a node by its path (`-1,1050,1063`), deepest first. Pure. */
26
+ export function ruleFor(
27
+ path: string,
28
+ rules: ReadonlyMap<number, ProtectionRule>,
29
+ ): ProtectionRule | undefined {
30
+ const ids = path.split(',').map(Number)
31
+ for (let i = ids.length - 1; i >= 0; i--) {
32
+ const rule = rules.get(ids[i] as number)
33
+ if (rule) return rule
34
+ }
35
+ return undefined
36
+ }
37
+
38
+ /** What a caller may do with a node: a user name matches in any case, a group exactly. Pure. */
39
+ export function accessTo(
40
+ path: string,
41
+ rules: ReadonlyMap<number, ProtectionRule>,
42
+ member: DeliveryMember | undefined,
43
+ ): Access {
44
+ const rule = ruleFor(path, rules)
45
+ if (!rule) return 'allowed'
46
+ if (!member?.approved || member.lockedOut) return 'unauthorized'
47
+ const name = member.userName.toLowerCase()
48
+ if (rule.memberUserNames.some((n) => n.toLowerCase() === name)) return 'allowed'
49
+ if (rule.memberGroupNames.some((g) => member.groups.includes(g))) return 'allowed'
50
+ return 'forbidden'
51
+ }
package/src/query.ts ADDED
@@ -0,0 +1,198 @@
1
+ /**
2
+ * The query endpoints' `fetch`, `filter` and `sort`, parsed as Umbraco 18.2
3
+ * parses them: content and media differ, and so do their errors.
4
+ * docs/delivery-api.md, "Queries".
5
+ */
6
+
7
+ export type Selector =
8
+ | { kind: 'all' }
9
+ | { kind: 'ancestors' | 'children' | 'descendants'; target: string }
10
+
11
+ export type ContentFilter =
12
+ | { kind: 'contentType'; values: string[]; negate: boolean }
13
+ | { kind: 'name'; values: string[]; negate: boolean }
14
+ | {
15
+ kind: 'createDate' | 'updateDate'
16
+ operator: '=' | '>' | '>=' | '<' | '<=' | 'none'
17
+ date: Date
18
+ }
19
+
20
+ export type SortField = 'createDate' | 'updateDate' | 'level' | 'name' | 'sortOrder'
21
+ export interface Sort {
22
+ field: SortField
23
+ ascending: boolean
24
+ }
25
+
26
+ export type QueryError = 'selector' | 'filter' | 'sort'
27
+
28
+ export const QUERY_ERRORS: Record<QueryError, { title: string; detail: string }> = {
29
+ selector: {
30
+ title: 'Selector option not found',
31
+ detail: "The attempted 'fetch' option does not exist",
32
+ },
33
+ filter: {
34
+ title: 'Filter option not found',
35
+ detail: "One of the attempted 'filter' options does not exist",
36
+ },
37
+ sort: {
38
+ title: 'Sort option not found',
39
+ detail: "One of the attempted 'sort' options does not exist",
40
+ },
41
+ }
42
+
43
+ /** `ancestors:`, `children:` or `descendants:` and what follows; absent is everything. Pure. */
44
+ export function parseContentSelector(fetch: string | null): Selector | QueryError {
45
+ if (fetch === null || fetch === '') return { kind: 'all' }
46
+ const match = /^(ancestors|children|descendants):(.*)$/is.exec(fetch)
47
+ if (!match) return 'selector'
48
+ return {
49
+ kind: (match[1] as string).toLowerCase() as 'ancestors' | 'children' | 'descendants',
50
+ target: (match[2] as string).trim(),
51
+ }
52
+ }
53
+
54
+ const DATE_FILTER = /(createDate|updateDate)(?<operator>[><:]{1,2})(?<value>.*)/i
55
+
56
+ /** Every filter, or which kind of error; an unparsable date is dropped, as Umbraco drops it. Pure. */
57
+ export function parseContentFilters(filters: readonly string[]): ContentFilter[] | QueryError {
58
+ const out: ContentFilter[] = []
59
+ for (const raw of filters) {
60
+ if (!raw.trim()) continue
61
+ const listed = /^(contentType|name):(!?)(.*)$/is.exec(raw)
62
+ if (listed) {
63
+ out.push({
64
+ kind: (listed[1] as string).toLowerCase() === 'name' ? 'name' : 'contentType',
65
+ negate: listed[2] === '!',
66
+ values: (listed[3] as string).split(',').filter((v) => v !== ''),
67
+ })
68
+ continue
69
+ }
70
+ const dated = DATE_FILTER.exec(raw)
71
+ if (dated?.groups) {
72
+ const field = dated[1]?.toLowerCase() === 'createdate' ? 'createDate' : 'updateDate'
73
+ const date = new Date(dated.groups.value as string)
74
+ if (Number.isNaN(date.getTime())) continue
75
+ const operator =
76
+ ({ ':': '=', '>': '>', '>:': '>=', '<': '<', '<:': '<=' } as const)[
77
+ dated.groups.operator as ':'
78
+ ] ?? 'none'
79
+ out.push({ kind: field, operator, date })
80
+ continue
81
+ }
82
+ return 'filter'
83
+ }
84
+ return out
85
+ }
86
+
87
+ const SORT_FIELDS: Record<string, SortField> = {
88
+ createdate: 'createDate',
89
+ updatedate: 'updateDate',
90
+ level: 'level',
91
+ name: 'name',
92
+ sortorder: 'sortOrder',
93
+ }
94
+
95
+ /** In order, primary first; ascending only when the direction starts with `asc`. Pure. */
96
+ export function parseContentSorts(sorts: readonly string[]): Sort[] | QueryError {
97
+ const out: Sort[] = []
98
+ for (const raw of sorts) {
99
+ if (!raw.trim()) continue
100
+ const at = raw.indexOf(':')
101
+ const field = at < 0 ? undefined : SORT_FIELDS[raw.slice(0, at).toLowerCase()]
102
+ if (!field) return 'sort'
103
+ out.push({ field, ascending: raw.slice(at + 1).startsWith('asc') })
104
+ }
105
+ return out
106
+ }
107
+
108
+ /** Media's own rules: `children:` is required, filters are `key:value`, the last sort wins. Pure. */
109
+ export function parseMediaQuery(input: {
110
+ fetch: string | null
111
+ filters: readonly string[]
112
+ sorts: readonly string[]
113
+ }):
114
+ | {
115
+ parent: string
116
+ filters: Array<{ kind: 'mediaType' | 'name'; value: string }>
117
+ sort: Sort | undefined
118
+ }
119
+ | QueryError {
120
+ const fetch = /^children:(.*)$/is.exec(input.fetch ?? '')
121
+ if (!fetch) return 'selector'
122
+ const filters: Array<{ kind: 'mediaType' | 'name'; value: string }> = []
123
+ for (const raw of input.filters) {
124
+ const parts = raw.split(':')
125
+ if (parts.length !== 2 || (parts[0] !== 'mediaType' && parts[0] !== 'name')) return 'filter'
126
+ filters.push({ kind: parts[0], value: parts[1] as string })
127
+ }
128
+ let sort: Sort | undefined
129
+ for (const raw of input.sorts) {
130
+ const parts = raw.split(':')
131
+ const fields: Record<string, SortField> = {
132
+ createDate: 'createDate',
133
+ updateDate: 'updateDate',
134
+ name: 'name',
135
+ sortOrder: 'sortOrder',
136
+ }
137
+ const field = parts.length === 2 ? fields[parts[0] as string] : undefined
138
+ if (!field) return 'sort'
139
+ sort = { field, ascending: (parts[1] as string).startsWith('asc') }
140
+ }
141
+ return { parent: (fetch[1] as string).trim(), filters, sort }
142
+ }
143
+
144
+ /** The comparison a sort makes, for anything with these fields. Pure. */
145
+ export function compareBy<
146
+ T extends { createDate: Date; updateDate: Date; level: number; name: string; sortOrder: number },
147
+ >(sorts: readonly Sort[]): (a: T, b: T) => number {
148
+ return (a, b) => {
149
+ for (const { field, ascending } of sorts) {
150
+ const left = field === 'name' ? a.name.toLowerCase() : a[field]
151
+ const right = field === 'name' ? b.name.toLowerCase() : b[field]
152
+ const order =
153
+ left instanceof Date && right instanceof Date
154
+ ? left.getTime() - right.getTime()
155
+ : left < right
156
+ ? -1
157
+ : left > right
158
+ ? 1
159
+ : 0
160
+ if (order !== 0) return ascending ? order : -order
161
+ }
162
+ return 0
163
+ }
164
+ }
165
+
166
+ /** Whether an item passes every content filter: the filters AND, each one's values OR. Pure. */
167
+ export function passesFilters(
168
+ item: { contentType: string; name: string; createDate: Date; updateDate: Date },
169
+ filters: readonly ContentFilter[],
170
+ ): boolean {
171
+ return filters.every((filter) => {
172
+ if (filter.kind === 'contentType') {
173
+ const hit = filter.values.includes(item.contentType)
174
+ return filter.negate ? !hit : hit
175
+ }
176
+ if (filter.kind === 'name') {
177
+ const name = item.name.toLowerCase()
178
+ const hit = filter.values.some((v) => name.includes(v.toLowerCase()))
179
+ return filter.negate ? !hit : hit
180
+ }
181
+ const at = item[filter.kind].getTime()
182
+ const to = filter.date.getTime()
183
+ switch (filter.operator) {
184
+ case '=':
185
+ return at === to
186
+ case '>':
187
+ return at > to
188
+ case '>=':
189
+ return at >= to
190
+ case '<':
191
+ return at < to
192
+ case '<=':
193
+ return at <= to
194
+ default:
195
+ return false
196
+ }
197
+ })
198
+ }