diff --git a/src/utilities/RegistryFilter.ts b/src/utilities/RegistryFilter.ts new file mode 100644 index 0000000..390c78e --- /dev/null +++ b/src/utilities/RegistryFilter.ts @@ -0,0 +1,486 @@ +import { Registry } from '../Registry'; +import { DeviceRegistryEntry } from '../types/homeassistant/data/device_registry'; +import { EntityCategory, EntityRegistryEntry } from '../types/homeassistant/data/entity_registry'; +import { RegistryEntry, StrategyConfig } from '../types/strategy/strategy-generics'; +import { logMessage, lvlDebug } from './debug'; + +/** + * A class for filtering and sorting arrays of Home Assistant's registry entries. + * + * Supports chaining for building complex filter queries. + * + * @template T The specific type of RegistryEntry being filtered. + */ +class RegistryFilter { + private readonly entries: T[]; + private filters: (((entry: T) => boolean) | ((entry: T, index: number) => boolean))[] = []; + private readonly entryIdentifier: ('entity_id' | 'floor_id' | 'id') & K; + private invertNext: boolean = false; + + /** + * Creates a RegistryFilter. + * + * @param {T[]} entries Registry entries to filter. + */ + constructor(entries: T[]) { + this.entries = entries; + this.entryIdentifier = ( + entries.length == 0 || 'entity_id' in entries[0] ? 'entity_id' : 'floor_id' in entries[0] ? 'floor_id' : 'id' + ) as ('entity_id' | 'floor_id' | 'id') & K; + } + + /** + * Inverts the outcome of the next filter method in the chain. + * + * @remarks + * Double chaining like `.not().not().whereX()` cancels out the inversion for whereX(). + */ + not(): this { + this.invertNext = !this.invertNext; + + return this; + } + + /** + * Resets the internal filter chain, allowing the instance to be reused for new filtering operations on the same set + * of entries. + */ + resetFilters(): this { + this.filters = []; + this.invertNext = false; + + return this; + } + + /** + * Adds a custom filter predicate to the filter chain. + * + * @param {(entry: T) => boolean} predicate A function that takes a registry entry and returns true if it should be + * included. + */ + where(predicate: (entry: T) => boolean): this { + this.filters.push(this.checkInversion(predicate)); + + return this; + } + + /** + * Filters entries **strictly** by their `area_id`. + * + * - Entries with a matching `area_id` are kept. + * - If `areaId` is `undefined` (or omitted), entries without an `area_id` property are kept. + * - If `expandToDevice` is `true` and the entry's `area_id` is `undisclosed`, the device's `area_id` is used. + * + * @param {string | undefined} areaId - The area id to match. + * @param {boolean} [expandToDevice=true] - Whether to use the device's `area_id` if the entry has none. + */ + whereAreaId(areaId?: string, expandToDevice: boolean = true): this { + const predicate = (entry: T) => { + let deviceAreaId: string | null | undefined = undefined; + const entryObject = entry as EntityRegistryEntry; + + if (expandToDevice && entryObject.device_id) { + deviceAreaId = Registry.devices.find((device) => device.id === entryObject.device_id)?.area_id; + } + + if (areaId === 'undisclosed' || areaId === undefined) { + return entry.area_id === areaId && (!expandToDevice || deviceAreaId === areaId); + } + + return entry.area_id === areaId || (expandToDevice && deviceAreaId === areaId); + }; + + this.filters.push(this.checkInversion(predicate)); + return this; + } + + /** + * Filters entries by whether their name contains a specific subString. + * + * It checks different name properties based on the entry type (name, original_name, name_by_user). + * + * @param {string} subString The subString to search for in the entry's name. + */ + whereNameContains(subString: string): this { + const lowered = subString.toLowerCase(); + const predicate = (entry: T) => { + const entryObj = entry as { name?: string; original_name?: string; name_by_user?: string }; + + return [entryObj.name, entryObj.original_name, entryObj.name_by_user] + .filter((field): field is string => typeof field === 'string') + .some((field) => field.toLowerCase().includes(lowered)); + }; + + this.filters.push(this.checkInversion(predicate)); + + return this; + } + + /** + * Filters entities by their domain (e.g., "light", "sensor"). + * + * @param {string} domain The domain to filter by. + * Entries whose entity_id starts with the domain are kept. + */ + whereDomain(domain: string): this { + const prefix = domain + '.'; + const predicate = (entry: T) => 'entity_id' in entry && entry.entity_id.startsWith(prefix); + + this.filters.push(this.checkInversion(predicate)); + + return this; + } + + /** + * Filters entries by their floor id. + * + * - Entries with a **strictly** matching `floor_id` are kept. + * - If `floorId` is undefined (or omitted), entries without a `floor_id` property are kept. + * + * @param {string | null | undefined} [floorId] The floor id to strictly match. + */ + whereFloorId(floorId?: string | null): this { + const predicate = (entry: T) => { + const hasFloorId = 'floor_id' in entry; + + return floorId === undefined ? !hasFloorId : hasFloorId && entry.floor_id === floorId; + }; + + this.filters.push(this.checkInversion(predicate)); + + return this; + } + + /** + * Filters entries by their device id. + * + * - Entries with a **strictly** matching `id` or `device_id` are kept. + * - If `deviceId` is undefined, only entries without both `id` and `device_id` are kept. + * + * @param {string | null | undefined} [deviceId] The device id to strictly match. + */ + whereDeviceId(deviceId?: string | null): this { + const predicate = (entry: T) => { + const hasId = 'id' in entry; + const hasDeviceId = 'device_id' in entry; + + if (deviceId === undefined) { + return !hasId && !hasDeviceId; + } + + return (hasId && entry.id === deviceId) || (hasDeviceId && entry.device_id === deviceId); + }; + + this.filters.push(this.checkInversion(predicate)); + return this; + } + + /** + * Filters entities by their id. + * + * - Entities with a matching `entity_id` are kept. + * - If `entityId` is undefined, only entries without an `entity_id` property are kept. + * + * @param {string | null | undefined} [entityId] The entity id to match. + */ + whereEntityId(entityId?: string | null): this { + const predicate = (entry: T) => + entityId === undefined ? !('entity_id' in entry) : 'entity_id' in entry && entry.entity_id === entityId; + + this.filters.push(this.checkInversion(predicate)); + return this; + } + + /** + * Filters entries **strictly** by their `disabled_by` status. + * + * @param {EntityRegistryEntry['disabled_by'] | DeviceRegistryEntry['disabled_by'] | undefined} [disabledBy] + * The reason the entry was disabled (e.g., "user", "integration", etc.). + * Entries with a matching `disabled_by` value are kept. + * If `disabledBy` is undefined, only entries without a `disabled_by` property are kept. + */ + whereDisabledBy(disabledBy?: EntityRegistryEntry['disabled_by'] | DeviceRegistryEntry['disabled_by']): this { + const predicate = (entry: T) => { + const hasDisabledBy = 'disabled_by' in entry; + + return disabledBy === undefined ? !hasDisabledBy : hasDisabledBy && entry.disabled_by === disabledBy; + }; + + this.filters.push(this.checkInversion(predicate)); + + return this; + } + + /** + * Filters entities by their `hidden_by` status. + * + * @param {EntityRegistryEntry['hidden_by'] | undefined} [hiddenBy] + * The reason the entity was hidden (e.g., "user", "integration", etc.). + * Entries with a matching `hidden_by` value are included. + * If undefined, only entries without a `hidden_by` property are included. + */ + whereHiddenBy(hiddenBy?: EntityRegistryEntry['hidden_by']): this { + const predicate = (entry: T) => { + const hasHiddenBy = 'hidden_by' in entry; + + return hiddenBy === undefined ? !hasHiddenBy : hasHiddenBy && entry.hidden_by === hiddenBy; + }; + + this.filters.push(this.checkInversion(predicate)); + + return this; + } + + /** + * Filters out entries that are hidden. + * + * Optionally, it can also filter out entries that are marked as hidden in the strategy options. + * + * @param {boolean} [applyStrategyOptions = true] If true, entries marked as hidden in the strategy options are also + * filtered out. + */ + isNotHidden(applyStrategyOptions: boolean = true): this { + const predicate = (entry: T) => { + const isHiddenByProperty = 'hidden_by' in entry && entry.hidden_by; + + if (!applyStrategyOptions) { + return !isHiddenByProperty; + } + + const id = entry[this.entryIdentifier] as keyof StrategyConfig['card_options']; + const isHiddenByConfig = Registry.strategyOptions?.card_options?.[id]?.hidden === true; + + return !isHiddenByProperty && !isHiddenByConfig; + }; + + this.filters.push(this.checkInversion(predicate)); + return this; + } + + /** + * Filters entries **strictly** by their `entity_category`. + * + * - Without `.not()`: returns only entries where `entity_category` exactly matches the given argument (e.g., + * 'config', 'diagnostic', null, or undefined). + * - With `.not()`: returns all entries where `entity_category` does NOT match the given argument. + * + * @param {EntityCategory | null} entityCategory The desired entity_category (e.g., 'config', 'diagnostic', null, or + * undefined) + * + * @remarks + * Visibility via the strategy options: + * - If `hide_{category}_entities: true` is set, entries of that category are NEVER kept, regardless of the filter. + * - If `hide_{category}_entities: false` is set, entries of that category are ALWAYS kept when filtering for that + * category, even when preceded by `.not()`. + * - If neither is set: + * - If preceded by not(), entries of that category are implicitly filtered out. + * - Otherwise they are implicitly kept. + * + * @example + * .whereEntityCategory('config') // Only 'config' entries (unless explicitly hidden) + * .not().whereEntityCategory('diagnostic') // All except 'diagnostic' entries + * .whereEntityCategory(null) // Only entries with 'entity_category: null' + * .whereEntityCategory() // Only entries without an 'entity_category' field + */ + whereEntityCategory(entityCategory?: EntityCategory | null): this { + const invert = this.invertNext; + this.invertNext = false; + + const predicate = (entry: T) => { + const category = 'entity_category' in entry ? entry.entity_category : undefined; + const hideOption = + typeof category === 'string' + ? Registry.strategyOptions?.domains?.['_']?.[`hide_${category}_entities`] + : undefined; + + if (hideOption === true) { + return false; + } + + if (hideOption === false && category === entityCategory) { + return true; + } + + return invert ? category !== entityCategory : category === entityCategory; + }; + this.filters.push(predicate); + return this; + } + + /** + * Sort the entries based in priority order of the provided keys. + * + * @template K A key to sort by, which must be a key of the registry entry types. + * @template T The specific type of RegistryEntry being sorted. + * + * @param {K[]} keys The keys to sort on, in order of priority. + * @param {'asc' | 'desc'} [direction='asc'] The sorting direction ('asc' for ascending, 'desc' for descending). + * + * @returns {RegistryFilter} A new RegistryFilter instance with the sorted entries and the current filters. + */ + orderBy(keys: K[], direction: 'asc' | 'desc' = 'asc'): RegistryFilter { + const getValue = (entry: T, keys: K[]): unknown => { + for (const k of keys) { + const value = entry[k]; + + if (value !== null && value !== undefined) { + return value; + } + } + + return undefined; + }; + + const sortedEntries = [...this.entries].sort((a, b) => { + const valueA = getValue(a, keys); + const valueB = getValue(b, keys); + + if (valueA === valueB) { + return 0; + } + + const ascendingMultiplier = direction === 'asc' ? 1 : -1; + + if (valueA === undefined || valueA === null) { + return ascendingMultiplier; + } + + if (valueB === undefined || valueB === null) { + return -ascendingMultiplier; + } + + if (typeof valueA === 'string' && typeof valueB === 'string') { + return valueA.localeCompare(valueB) * ascendingMultiplier; + } + + return (valueA < valueB ? -1 : 1) * ascendingMultiplier; + }); + + const newFilter = new RegistryFilter(sortedEntries); + + newFilter.filters = [...this.filters]; + + return newFilter; + } + + /** + * Takes a specified number of entries from the beginning of the filtered results. + * + * @param {number} count The number of entries to take. If negative, defaults to 0. + */ + take(count: number): this { + const safeCount = Math.max(0, count); + + this.filters.push((_, index: number) => index < safeCount); + + return this; + } + + /** + * Skips a specified number of entries from the beginning of the filtered results. + * + * @param {number} count The number of entries to skip. If negative, defaults to 0. + */ + skip(count: number): this { + const safeCount = Math.max(0, count); + + this.filters.push((_, index: number) => index >= safeCount); + + return this; + } + + /** + * Applies all the accumulated filters to the entries and returns the resulting array. + * + * @remarks + * - This method creates a forked (shallow-copied) RegistryFilter instance to ensure immutability. + * - The original `entries` and `filters` arrays are not mutated or affected by this operation. + * - This allows chainable and reusable filter logic, so you can call additional filtering methods on the original + * instance after calling this method. + */ + toList(): T[] { + const fork = new RegistryFilter(this.entries); + + fork.filters = [...this.filters]; + + return fork.entries.filter((entry, index) => fork.filters.every((filter) => filter(entry, index))); + } + + /** + * Applies all the accumulated filters to the entries and returns the first remaining entry. + * + * @remarks + * - This method creates a forked (shallow-copied) RegistryFilter instance to ensure immutability. + * - The original `entries` and `filters` arrays are not mutated or affected by this operation. + * - This allows chainable and reusable filter logic, so you can call additional filtering methods on the original + * instance after calling this method. + */ + first(): T | undefined { + const fork = new RegistryFilter(this.entries); + + fork.filters = [...this.filters]; + + return fork.entries.find((entry, index) => fork.filters.every((filter) => filter(entry, index))); + } + + /** + * Applies the filters on a forked instance and returns the single matching entry. + * + * @remarks + * - This method creates a forked (shallow-copied) RegistryFilter instance to ensure immutability. + * - The original `entries` and `filters` arrays are not mutated or affected by this operation. + */ + single(): T | undefined { + const fork = new RegistryFilter(this.entries); + + fork.filters = [...this.filters]; + + const result = fork.entries.filter((entry, index) => fork.filters.every((filter) => filter(entry, index))); + + if (result.length === 1) { + return result[0]; + } + + logMessage(lvlDebug, `Expected a single element, but found ${result.length}.`); + + return undefined; + } + + /** + * Applies the filters on a forked instance and returns the number of matching entries. + * The original RegistryFilter instance remains unchanged and can be reused for further filtering. + * + * @remarks + * - This method creates a forked (shallow-copied) RegistryFilter instance to ensure immutability. + * - The original `entries` and `filters` arrays are not mutated or affected by this operation. + */ + count(): number { + const fork = new RegistryFilter(this.entries); + + fork.filters = [...this.filters]; + + return fork.entries.filter((entry, index) => fork.filters.every((filter) => filter(entry, index))).length; + } + + /** + * Checks the inversion flag set by {@link not} to a filter predicate and applies the inversion if necessary. + * + * @param {((entry: T) => boolean)} predicate The filter predicate to apply the inversion to. + * + * @returns {((entry: T) => boolean)} The predicate with the inversion applied, or the original predicate if no + * inversion is to be applied. + * + * @private + */ + private checkInversion(predicate: (entry: T) => boolean): (entry: T) => boolean { + if (this.invertNext) { + this.invertNext = false; + + return (entry: T) => !predicate(entry); + } + + return predicate; + } +} + +export default RegistryFilter; diff --git a/src/utilities/auxiliaries.ts b/src/utilities/auxiliaries.ts new file mode 100644 index 0000000..233dc55 --- /dev/null +++ b/src/utilities/auxiliaries.ts @@ -0,0 +1,38 @@ +/** + * Sanitize a classname. + * + * The name is sanitized by capitalizing the first character of the name or after an underscore. + * The underscores are removed. + * + * @param {string} className Name of the class to sanitize. + */ +export function sanitizeClassName(className: string): string { + return className.replace(/^([a-z])|([-_][a-z])/g, (match) => match.toUpperCase().replace(/[-_]/g, '')); +} + +/** + * Creates a deep clone of the provided value. + * + * - It uses the native `structuredClone` if available (supports most built-in types, circular references, etc.). + * - Falls back to `JSON.parse(JSON.stringify(obj))` for plain objects and arrays if `structuredClone` is unavailable + * or fails. + * + * @template T + * @param {T} obj - The value to deep clone. + * @returns {T} A deep clone of the input value, or the original value if cloning fails. + */ +export function deepClone(obj: T): T { + if (typeof structuredClone === 'function') { + try { + return structuredClone(obj); + } catch { + // Ignore error: fallback to the next method + } + } + + try { + return JSON.parse(JSON.stringify(obj)); + } catch { + return obj; + } +} diff --git a/src/utilities/debug.ts b/src/utilities/debug.ts new file mode 100644 index 0000000..df09a11 --- /dev/null +++ b/src/utilities/debug.ts @@ -0,0 +1,93 @@ +import { deepClone } from './auxiliaries'; + +/** + * Log levels for the debug logger. + * + * - Off: Logging is disabled. + * - Debug: Diagnostic information that can be helpful for troubleshooting and debugging. + * - Info: General information about the status of the system + * - Warn: Signal for potential issues that are not necessarily a critical error. + * - Error: Significant problems that happened in the system. + * - Fatal: severe conditions that cause the system to terminate or operate in a significantly degraded state. + */ +export enum DebugLevel { + Off = 0, + Debug = 1, + Info = 2, + Warn = 3, + Error = 4, + Fatal = 5, +} + +/** + * Individually exported log level constants. + * + * @see DebugLevel + */ +export const { + Off: lvlOff, + Debug: lvlDebug, + Info: lvlInfo, + Warn: lvlWarn, + Error: lvlError, + Fatal: lvlFatal, +} = DebugLevel; + +/** + * The current global log level. + * + * Only messages with a level less than or equal to this will be logged. + * + * @default DebugLevel.Off + */ +let currentLevel: DebugLevel = DebugLevel.Off; + +/** + * Sets the global log level. + * + * @param {DebugLevel} level - The maximum level to log. + * @see DebugLevel + */ +export function setDebugLevel(level: DebugLevel) { + currentLevel = level; +} + +/** + * Logs a message in the console at the specified level if allowed by the current global log level. + * + * Only messages with a level less than or equal to the currentLevel are logged. + * + * @param {DebugLevel} level - The severity of the message. + * @param {string} message - The message to log. + * @param {unknown[]} [details] - Optional extra details (e.g., error object). + * + * @throws {Error} After logging, if the level is `Fatal` or `Error`. + */ +export function logMessage(level: DebugLevel, message: string, ...details: unknown[]): void { + if (currentLevel === DebugLevel.Off || level > currentLevel) { + return; + } + + const frontEndMessage: string = 'Mushroom Strategy - An error occurred. Check the console (F12) for details.'; + const prefix = `[${DebugLevel[level].toUpperCase()}]`; + const safeDetails = details.map(deepClone); + + switch (level) { + case DebugLevel.Debug: + console.debug(`${prefix} ${message}`, ...safeDetails); + break; + case DebugLevel.Info: + console.info(`${prefix} ${message}`, ...safeDetails); + break; + case DebugLevel.Warn: + console.warn(`${prefix} ${message}`, ...safeDetails); + break; + case DebugLevel.Error: + console.error(`${prefix} ${message}`, ...safeDetails); + throw frontEndMessage; + case DebugLevel.Fatal: + console.error(`${prefix} ${message}`, ...safeDetails); + alert?.(`${prefix} ${message}`); + throw frontEndMessage; + } +} diff --git a/src/utilities/localize.ts b/src/utilities/localize.ts new file mode 100644 index 0000000..35bc8f1 --- /dev/null +++ b/src/utilities/localize.ts @@ -0,0 +1,74 @@ +import * as de from '../translations/de.json'; +import * as en from '../translations/en.json'; +import * as es from '../translations/es.json'; +import * as nl from '../translations/nl.json'; +import { HomeAssistant } from '../types/homeassistant/types'; +import { logMessage, lvlWarn } from './debug'; + +/** Registry of currently supported languages */ +const languages: Record = { + de, + en, + es, + nl, +}; + +/** The fallback language if the user-defined language isn't defined */ +const DEFAULT_LANG = 'en'; + +/** + * Get a string by keyword and language. + * + * @param {string} key The key to look for in the object notation of the language file (E.g., `generic.home`). + * @param {string} lang The language to get the string from (E.g., `en`). + * + * @returns {string | undefined} The requested string or undefined if the keyword doesn't exist/on error. + */ +function getTranslatedString(key: string, lang: string): string | undefined { + try { + return key.split('.').reduce((o, i) => (o as Record)[i], languages[lang]) as string; + } catch { + return undefined; + } +} + +/** + * Singleton instance of the localization function. + * + * This variable is set by {@link setupCustomLocalize} and used by {@link localize}. + * + * - Must be initialized before {@link localize} is called. + * - Holds a closure that translates keys based on the language set during setup. + * + * @private + */ +let _localize: ((key: string) => string) | undefined = undefined; + +/** + * Set up the localization. + * + * It reads the user-defined language with a fall-back to English and returns a function to get strings from + * language-files by keyword. + * + * If the keyword is undefined, or on error, the keyword itself is returned. + * + * @param {HomeAssistant} hass The Home Assistant object. + */ +export default function setupCustomLocalize(hass?: HomeAssistant): void { + const lang = hass?.locale.language ?? DEFAULT_LANG; + + _localize = (key: string) => getTranslatedString(key, lang) ?? getTranslatedString(key, DEFAULT_LANG) ?? key; +} + +/** + * Translate a key using the globally configured localize function. + * Throws if not initialized. + */ +export function localize(key: string): string { + if (!_localize) { + logMessage(lvlWarn, 'localize is not initialized! Call setupCustomLocalize first.'); + + return key; + } + return _localize(key); +}