import { NonEmptyTuple } from 'type-fest'; import { z } from 'zod'; import { AdvancedCameraCardError } from '../../types'; import { isTruthy } from '../../utils/basic'; import { regexSchema } from './common/regex'; export const HA_MEDIA_SOURCE_ROOT = 'media-source://'; export const folderTypeSchema = z.enum(['ha']); export type FolderType = z.infer; const folderConfigDefault = { type: 'ha' as const, ha: {}, }; const startdateParserSchema = z.object({ type: z.literal('startdate'), format: z.string().optional(), regexp: regexSchema.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 startDateMatcherSchema = z.object({ type: z.literal('startdate'), since: z.object({ years: z.number().int().min(0).optional(), months: z.number().int().min(0).optional(), days: z.number().int().min(0).optional(), hours: z.number().int().min(0).optional(), minutes: z.number().int().min(0).optional(), }), }); export type StartDateMatcher = z.infer; // Simple alias date -> startdate. const dateMatcherSchema = startDateMatcherSchema.extend({ type: z.literal('date'), }); export type DateMatcher = z.infer; const templateMatcherSchema = z.object({ type: z.literal('template'), value_template: z.string(), }); export type TemplateMatcher = z.infer; const titleMatcherSchema = z.object({ type: z.literal('title'), regexp: regexSchema.optional(), title: z.string().optional(), }); export type TitleMatcher = z.infer; const orMatcherSchema = z.object({ type: z.literal('or'), get matchers() { // Recursive schema. return z.array(matcherSchema); }, }); export const matcherSchema = z.union([ dateMatcherSchema, orMatcherSchema, startDateMatcherSchema, templateMatcherSchema, titleMatcherSchema, ]); export type Matcher = z.infer; const haFolderPathComponentSchema = z.object({ id: z.string().optional(), parsers: parserSchema.array().optional(), matchers: matcherSchema.array().optional(), }); export type HAFolderPathComponent = z.infer; export const transformPathURLToPathArray = ( url: string, ): NonEmptyTuple => { let urlPath = url; try { const urlObj = new URL(url); urlPath = urlObj.pathname; // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (e) {} const splitPath = decodeURIComponent(urlPath).split(',').filter(isTruthy).slice(1); // HA uses a pretty odd URL protocol for media-browser URLs: // - The URL is an encoded comma-separated value representing the folder // hierarchy // - The first component will be `media-browser/browser` representing the // root // - Each subsequent component will start with `media-source://` // - All components except the last will additionally include // '/'. const folderPath: NonEmptyTuple = [ { id: HA_MEDIA_SOURCE_ROOT }, ...splitPath.slice(0, -1).map((split) => ({ id: split.replace(/\/[^/]+$/, '') })), ...splitPath.slice(-1).map((split) => ({ id: split })), ]; for (const component of folderPath) { if (component.id && !component.id.startsWith(HA_MEDIA_SOURCE_ROOT)) { throw new AdvancedCameraCardError(`Could not parse media source URL: ${url}`); } } return folderPath; }; const haFolderConfigSchema = z.object({ url: z .string() .transform(transformPathURLToPathArray) // See: https://github.com/dermotduffy/advanced-camera-card/issues/2196 .or(haFolderPathComponentSchema.array().min(1)) .optional(), path: haFolderPathComponentSchema.array().nonempty().optional(), }); export type HAFolderConfig = z.infer; const folderConfigSchema = z.object({ type: folderTypeSchema.default(folderConfigDefault.type), id: z.string().optional(), ha: haFolderConfigSchema.default(folderConfigDefault.ha).optional(), title: z.string().optional(), icon: z.string().optional(), }); export type FolderConfigWithoutID = z.infer; export type FolderConfig = FolderConfigWithoutID & { id: string }; export const foldersConfigSchema = folderConfigSchema.array();