145 lines
4.5 KiB
TypeScript
145 lines
4.5 KiB
TypeScript
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<typeof folderTypeSchema>;
|
|
|
|
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<typeof parserSchema>;
|
|
|
|
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<typeof startDateMatcherSchema>;
|
|
|
|
// Simple alias date -> startdate.
|
|
const dateMatcherSchema = startDateMatcherSchema.extend({
|
|
type: z.literal('date'),
|
|
});
|
|
export type DateMatcher = z.infer<typeof dateMatcherSchema>;
|
|
|
|
const templateMatcherSchema = z.object({
|
|
type: z.literal('template'),
|
|
value_template: z.string(),
|
|
});
|
|
export type TemplateMatcher = z.infer<typeof templateMatcherSchema>;
|
|
|
|
const titleMatcherSchema = z.object({
|
|
type: z.literal('title'),
|
|
regexp: regexSchema.optional(),
|
|
title: z.string().optional(),
|
|
});
|
|
export type TitleMatcher = z.infer<typeof titleMatcherSchema>;
|
|
|
|
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<typeof matcherSchema>;
|
|
|
|
const haFolderPathComponentSchema = z.object({
|
|
id: z.string().optional(),
|
|
parsers: parserSchema.array().optional(),
|
|
matchers: matcherSchema.array().optional(),
|
|
});
|
|
export type HAFolderPathComponent = z.infer<typeof haFolderPathComponentSchema>;
|
|
|
|
export const transformPathURLToPathArray = (
|
|
url: string,
|
|
): NonEmptyTuple<HAFolderPathComponent> => {
|
|
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://<path>`
|
|
// - All components except the last will additionally include
|
|
// '/<media-class>'.
|
|
const folderPath: NonEmptyTuple<HAFolderPathComponent> = [
|
|
{ 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<typeof haFolderConfigSchema>;
|
|
|
|
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<typeof folderConfigSchema>;
|
|
|
|
export type FolderConfig = FolderConfigWithoutID & { id: string };
|
|
|
|
export const foldersConfigSchema = folderConfigSchema.array();
|