mirror of
https://github.com/DigiLive/mushroom-strategy.git
synced 2025-08-04 20:14:28 +02:00
Add modules
- debug module for centralized logging and debug level management. - localize module for localization functions in the global scope. - RegistryFilter module for advanced filtering and sorting of HASS registries. - auxiliaries module for genric utility functions.
This commit is contained in:
486
src/utilities/RegistryFilter.ts
Normal file
486
src/utilities/RegistryFilter.ts
Normal file
@@ -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<T extends RegistryEntry, K extends keyof T = keyof T> {
|
||||||
|
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<T>} A new RegistryFilter instance with the sorted entries and the current filters.
|
||||||
|
*/
|
||||||
|
orderBy<K extends keyof T>(keys: K[], direction: 'asc' | 'desc' = 'asc'): RegistryFilter<T> {
|
||||||
|
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;
|
38
src/utilities/auxiliaries.ts
Normal file
38
src/utilities/auxiliaries.ts
Normal file
@@ -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<T>(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;
|
||||||
|
}
|
||||||
|
}
|
93
src/utilities/debug.ts
Normal file
93
src/utilities/debug.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
74
src/utilities/localize.ts
Normal file
74
src/utilities/localize.ts
Normal file
@@ -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<string, unknown> = {
|
||||||
|
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<string, unknown>)[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);
|
||||||
|
}
|
Reference in New Issue
Block a user