homebridge-plugin-utils 1.34.0 → 2.0.0
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/README.md +130 -2
- package/build/eslint-plugin/README.md +164 -0
- package/build/eslint-plugin/config.mjs +308 -0
- package/build/eslint-plugin/index.mjs +7 -0
- package/build/eslint-plugin/plugin.mjs +46 -0
- package/build/eslint-plugin/rules/blank-line-after-open-brace.mjs +137 -0
- package/build/eslint-plugin/rules/blank-line-after-open-brace.test.mjs +112 -0
- package/build/eslint-plugin/rules/comment-style.mjs +190 -0
- package/build/eslint-plugin/rules/comment-style.test.mjs +190 -0
- package/build/eslint-plugin/rules/enforce-node-protocol.mjs +114 -0
- package/build/eslint-plugin/rules/enforce-node-protocol.test.mjs +116 -0
- package/build/eslint-plugin/rules/paren-comparisons-in-logical.mjs +94 -0
- package/build/eslint-plugin/rules/paren-comparisons-in-logical.test.mjs +107 -0
- package/build/eslint-plugin/rules/split-type-imports.mjs +354 -0
- package/build/eslint-plugin/rules/split-type-imports.test.mjs +268 -0
- package/build/eslint-plugin/test-setup.mjs +21 -0
- package/build/fs-ops.mjs +184 -0
- package/build/tsconfig.json +18 -3
- package/dist/backpressure.d.ts +128 -0
- package/dist/backpressure.js +273 -0
- package/dist/backpressure.js.map +1 -0
- package/dist/cli/index.d.ts +95 -0
- package/dist/cli/index.js +355 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/clock-double.d.ts +93 -0
- package/dist/clock-double.js +141 -0
- package/dist/clock-double.js.map +1 -0
- package/dist/clock.d.ts +39 -0
- package/dist/clock.js +34 -0
- package/dist/clock.js.map +1 -0
- package/dist/eslint-plugin/config.d.mts +193 -0
- package/dist/eslint-plugin/index.d.mts +2 -0
- package/dist/eslint-plugin/plugin.d.mts +87 -0
- package/dist/eslint-plugin/rules/blank-line-after-open-brace.d.mts +19 -0
- package/dist/eslint-plugin/rules/comment-style.d.mts +15 -0
- package/dist/eslint-plugin/rules/enforce-node-protocol.d.mts +18 -0
- package/dist/eslint-plugin/rules/paren-comparisons-in-logical.d.mts +15 -0
- package/dist/eslint-plugin/rules/split-type-imports.d.mts +16 -0
- package/dist/featureOptions-docs.d.ts +96 -0
- package/dist/featureOptions-docs.js +193 -0
- package/dist/featureOptions-docs.js.map +1 -0
- package/dist/featureOptions.d.ts +674 -0
- package/dist/featureOptions.js +870 -0
- package/dist/featureOptions.js.map +1 -0
- package/dist/ffmpeg/codecs.d.ts +256 -72
- package/dist/ffmpeg/codecs.js +477 -257
- package/dist/ffmpeg/codecs.js.map +1 -1
- package/dist/ffmpeg/dgram-util.d.ts +46 -0
- package/dist/ffmpeg/dgram-util.js +38 -0
- package/dist/ffmpeg/dgram-util.js.map +1 -0
- package/dist/ffmpeg/exec.d.ts +83 -64
- package/dist/ffmpeg/exec.js +77 -86
- package/dist/ffmpeg/exec.js.map +1 -1
- package/dist/ffmpeg/fmp4.d.ts +70 -4
- package/dist/ffmpeg/fmp4.js +100 -11
- package/dist/ffmpeg/fmp4.js.map +1 -1
- package/dist/ffmpeg/hap-enums.d.ts +214 -0
- package/dist/ffmpeg/hap-enums.js +92 -0
- package/dist/ffmpeg/hap-enums.js.map +1 -0
- package/dist/ffmpeg/index.d.ts +16 -9
- package/dist/ffmpeg/index.js +6 -0
- package/dist/ffmpeg/index.js.map +1 -1
- package/dist/ffmpeg/mp4-assembler.d.ts +120 -0
- package/dist/ffmpeg/mp4-assembler.js +387 -0
- package/dist/ffmpeg/mp4-assembler.js.map +1 -0
- package/dist/ffmpeg/mp4-parser.d.ts +94 -0
- package/dist/ffmpeg/mp4-parser.js +130 -0
- package/dist/ffmpeg/mp4-parser.js.map +1 -0
- package/dist/ffmpeg/options.d.ts +62 -149
- package/dist/ffmpeg/options.js +608 -499
- package/dist/ffmpeg/options.js.map +1 -1
- package/dist/ffmpeg/process.d.ts +143 -91
- package/dist/ffmpeg/process.js +406 -265
- package/dist/ffmpeg/process.js.map +1 -1
- package/dist/ffmpeg/record.d.ts +302 -188
- package/dist/ffmpeg/record.js +408 -560
- package/dist/ffmpeg/record.js.map +1 -1
- package/dist/ffmpeg/recording-process-double.d.ts +144 -0
- package/dist/ffmpeg/recording-process-double.js +168 -0
- package/dist/ffmpeg/recording-process-double.js.map +1 -0
- package/dist/ffmpeg/rtp-parser.d.ts +70 -0
- package/dist/ffmpeg/rtp-parser.js +77 -0
- package/dist/ffmpeg/rtp-parser.js.map +1 -0
- package/dist/ffmpeg/rtp.d.ts +198 -141
- package/dist/ffmpeg/rtp.js +473 -244
- package/dist/ffmpeg/rtp.js.map +1 -1
- package/dist/ffmpeg/settings.d.ts +5 -1
- package/dist/ffmpeg/settings.js +20 -2
- package/dist/ffmpeg/settings.js.map +1 -1
- package/dist/ffmpeg/stream.d.ts +57 -107
- package/dist/ffmpeg/stream.js +121 -148
- package/dist/ffmpeg/stream.js.map +1 -1
- package/dist/formatters.d.ts +106 -0
- package/dist/formatters.js +174 -0
- package/dist/formatters.js.map +1 -0
- package/dist/homebridge-enums.d.ts +30 -0
- package/dist/homebridge-enums.js +17 -0
- package/dist/homebridge-enums.js.map +1 -0
- package/dist/index.d.ts +12 -5
- package/dist/index.js +8 -2
- package/dist/index.js.map +1 -1
- package/dist/logclient/auth.d.ts +114 -0
- package/dist/logclient/auth.js +199 -0
- package/dist/logclient/auth.js.map +1 -0
- package/dist/logclient/cli-run.d.ts +76 -0
- package/dist/logclient/cli-run.js +639 -0
- package/dist/logclient/cli-run.js.map +1 -0
- package/dist/logclient/cli.d.ts +3 -0
- package/dist/logclient/cli.js +97 -0
- package/dist/logclient/cli.js.map +1 -0
- package/dist/logclient/client.d.ts +145 -0
- package/dist/logclient/client.js +600 -0
- package/dist/logclient/client.js.map +1 -0
- package/dist/logclient/config.d.ts +173 -0
- package/dist/logclient/config.js +199 -0
- package/dist/logclient/config.js.map +1 -0
- package/dist/logclient/endpoints.d.ts +54 -0
- package/dist/logclient/endpoints.js +73 -0
- package/dist/logclient/endpoints.js.map +1 -0
- package/dist/logclient/filter.d.ts +45 -0
- package/dist/logclient/filter.js +51 -0
- package/dist/logclient/filter.js.map +1 -0
- package/dist/logclient/frame.d.ts +93 -0
- package/dist/logclient/frame.js +203 -0
- package/dist/logclient/frame.js.map +1 -0
- package/dist/logclient/index.d.ts +31 -0
- package/dist/logclient/index.js +12 -0
- package/dist/logclient/index.js.map +1 -0
- package/dist/logclient/parser.d.ts +211 -0
- package/dist/logclient/parser.js +393 -0
- package/dist/logclient/parser.js.map +1 -0
- package/dist/logclient/rest.d.ts +41 -0
- package/dist/logclient/rest.js +111 -0
- package/dist/logclient/rest.js.map +1 -0
- package/dist/logclient/settings.d.ts +15 -0
- package/dist/logclient/settings.js +64 -0
- package/dist/logclient/settings.js.map +1 -0
- package/dist/logclient/socket-double.d.ts +201 -0
- package/dist/logclient/socket-double.js +384 -0
- package/dist/logclient/socket-double.js.map +1 -0
- package/dist/logclient/socket.d.ts +257 -0
- package/dist/logclient/socket.js +620 -0
- package/dist/logclient/socket.js.map +1 -0
- package/dist/logclient/stitch.d.ts +83 -0
- package/dist/logclient/stitch.js +146 -0
- package/dist/logclient/stitch.js.map +1 -0
- package/dist/logclient/time-expression.d.ts +42 -0
- package/dist/logclient/time-expression.js +181 -0
- package/dist/logclient/time-expression.js.map +1 -0
- package/dist/logclient/time-window.d.ts +38 -0
- package/dist/logclient/time-window.js +53 -0
- package/dist/logclient/time-window.js.map +1 -0
- package/dist/logclient/types.d.ts +107 -0
- package/dist/logclient/types.js +6 -0
- package/dist/logclient/types.js.map +1 -0
- package/dist/mqttClient.d.ts +287 -0
- package/dist/mqttClient.js +433 -0
- package/dist/mqttClient.js.map +1 -0
- package/dist/service.d.ts +64 -15
- package/dist/service.js +93 -66
- package/dist/service.js.map +1 -1
- package/dist/ui/featureOptions.js +870 -0
- package/dist/ui/featureOptions.js.map +1 -0
- package/dist/ui/formatters.js +174 -0
- package/dist/ui/formatters.js.map +1 -0
- package/dist/ui/pluginConfigSession.mjs +141 -0
- package/dist/ui/webUi-featureOptions/categoryState.mjs +135 -0
- package/dist/ui/webUi-featureOptions/effects/keyboard.mjs +61 -0
- package/dist/ui/webUi-featureOptions/effects/persist.mjs +226 -0
- package/dist/ui/webUi-featureOptions/effects/theme.mjs +398 -0
- package/dist/ui/webUi-featureOptions/effects/tokens.mjs +152 -0
- package/dist/ui/webUi-featureOptions/rendering.mjs +431 -0
- package/dist/ui/webUi-featureOptions/selectors.mjs +360 -0
- package/dist/ui/webUi-featureOptions/state.mjs +319 -0
- package/dist/ui/webUi-featureOptions/store.mjs +181 -0
- package/dist/ui/webUi-featureOptions/utils.mjs +200 -0
- package/dist/ui/webUi-featureOptions/views/connectionError.mjs +152 -0
- package/dist/ui/webUi-featureOptions/views/deviceInfo.mjs +80 -0
- package/dist/ui/webUi-featureOptions/views/header.mjs +77 -0
- package/dist/ui/webUi-featureOptions/views/nav.mjs +327 -0
- package/dist/ui/webUi-featureOptions/views/options.mjs +521 -0
- package/dist/ui/webUi-featureOptions/views/search.mjs +395 -0
- package/dist/ui/webUi-featureOptions.mjs +677 -0
- package/dist/ui/webUi.mjs +192 -87
- package/dist/util.d.ts +597 -45
- package/dist/util.js +787 -68
- package/dist/util.js.map +1 -1
- package/package.json +33 -15
- package/build/eslint-rules.mjs +0 -511
- package/dist/featureoptions.d.ts +0 -264
- package/dist/featureoptions.js +0 -480
- package/dist/featureoptions.js.map +0 -1
- package/dist/mqttclient.d.ts +0 -178
- package/dist/mqttclient.js +0 -310
- package/dist/mqttclient.js.map +0 -1
- package/dist/ui/featureoptions.js +0 -480
- package/dist/ui/featureoptions.js.map +0 -1
- package/dist/ui/webUi-featureoptions.mjs +0 -3765
|
@@ -0,0 +1,870 @@
|
|
|
1
|
+
/* Copyright(C) 2017-2026, HJD (https://github.com/hjdhjd). All rights reserved.
|
|
2
|
+
*
|
|
3
|
+
* featureOptions.ts: Hierarchical feature option capabilities for use in plugins and applications.
|
|
4
|
+
*/
|
|
5
|
+
import { formatBps, formatBytes, formatMs, formatPercent, formatSeconds } from "./formatters.js";
|
|
6
|
+
// The lookup table the catalog-index builder consults to resolve a string-named renderer to the function that implements it. Plugin-side formatting logic that
|
|
7
|
+
// would otherwise be duplicated across every plugin (each one reaching into util.ts to wrap formatBps for their bandwidth option, etc.) lives here once - any
|
|
8
|
+
// improvement to a built-in formatter's behavior propagates to every plugin that opts into the name. Adding a new formatter is a two-line change: extend the type
|
|
9
|
+
// union above and add a row here.
|
|
10
|
+
//
|
|
11
|
+
// The registry is module-scope and not exported. Per-plugin customization belongs in the function escape hatch on `render`; there is deliberately no API to mutate
|
|
12
|
+
// the shared registry, which would create initialization-order bugs and let test fixtures leak across files.
|
|
13
|
+
const BUILT_IN_FORMATTERS = {
|
|
14
|
+
// Bitrate stored as bits per second. Delegates to formatBps which selects the right human-readable magnitude (bps / kbps / Mbps).
|
|
15
|
+
bps: (value) => formatBps(Number.parseFloat(value)),
|
|
16
|
+
// Byte count rendered as bytes / KB / MB / GB via the 1024-based convention every operating system uses for file and buffer sizes.
|
|
17
|
+
bytes: (value) => formatBytes(Number.parseFloat(value)),
|
|
18
|
+
// Bitrate stored as kilobits per second. We scale into bits and reuse formatBps so the magnitude selection stays identical to the bps formatter - the only
|
|
19
|
+
// difference between bps and kbps is the storage convention the plugin chose, never the displayed form.
|
|
20
|
+
kbps: (value) => formatBps(Number.parseFloat(value) * 1_000),
|
|
21
|
+
// Duration stored as milliseconds. formatMs promotes through ms / s / min / hr based on magnitude.
|
|
22
|
+
ms: (value) => formatMs(Number.parseFloat(value)),
|
|
23
|
+
// Percentage rendered through the shared formatPercent helper so the precision policy stays uniform across every formatter in the registry - whole numbers carry
|
|
24
|
+
// no decimal, fractional values get one decimal place.
|
|
25
|
+
percent: (value) => formatPercent(Number.parseFloat(value)),
|
|
26
|
+
// Duration stored as seconds. formatSeconds promotes through s / min / hr based on magnitude.
|
|
27
|
+
seconds: (value) => formatSeconds(Number.parseFloat(value))
|
|
28
|
+
};
|
|
29
|
+
// Resolve a built-in formatter by name. Accepts an arbitrary string (not just a `FeatureOptionFormatter` literal) so the lookup naturally returns `undefined` when a
|
|
30
|
+
// caller bypasses the type system - via JS, an `as` cast, or an out-of-date type definition - and passes an unrecognized name. Encapsulating the type-widening cast
|
|
31
|
+
// here keeps the call site clean and names the intent: "look up a registered formatter by name; the result may not exist." Without the widening at this single seam,
|
|
32
|
+
// TypeScript narrows the literal-keyed indexing tightly enough that the runtime safety check at the call site looks dead, and the runtime guard would either be
|
|
33
|
+
// suppressed (silently weakening defense against JS callers) or removed (allowing the silent-fallback failure mode the design exists to prevent).
|
|
34
|
+
function resolveBuiltInFormatter(name) {
|
|
35
|
+
return BUILT_IN_FORMATTERS[name];
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Compose a fully formed feature option string from a category and an option. Accepts either raw strings or the catalog entry objects, mirroring how the catalog
|
|
39
|
+
* is iterated at build time. The result is the canonical key shape every other helper consumes - lowercase the result to derive lookup-index keys, preserve the
|
|
40
|
+
* caller's casing to compose entry strings.
|
|
41
|
+
*
|
|
42
|
+
* @param category - Feature option category entry or category name string.
|
|
43
|
+
* @param option - Feature option entry or option name string.
|
|
44
|
+
*
|
|
45
|
+
* @returns The fully formed feature option in the form of `category.option`, or `category` alone when the option name is empty, or the empty string when the
|
|
46
|
+
* category name is empty.
|
|
47
|
+
*/
|
|
48
|
+
export function expandOption(category, option) {
|
|
49
|
+
const categoryName = (typeof category === "string") ? category : category.name;
|
|
50
|
+
const optionName = (typeof option === "string") ? option : option.name;
|
|
51
|
+
if (!categoryName.length) {
|
|
52
|
+
return "";
|
|
53
|
+
}
|
|
54
|
+
return (!optionName.length) ? categoryName : categoryName + "." + optionName;
|
|
55
|
+
}
|
|
56
|
+
// Compose the canonical lookup-index target key for a (option, id) pair. This is the form a setOption({ option, id, ... }) call would resolve to on the index, and
|
|
57
|
+
// is the comparison key the matcher and writers share.
|
|
58
|
+
function targetKey(option, id) {
|
|
59
|
+
return id?.length ? option.toLowerCase() + "." + id.toLowerCase() : option.toLowerCase();
|
|
60
|
+
}
|
|
61
|
+
// Parse a single configured-options entry into the lookup keys it would register on the index. Returns null for non-canonical entries (no action prefix, or an
|
|
62
|
+
// unknown action). Otherwise returns the primary (raw-tail) key, optionally accompanied by a derived value key with the extracted value for value-centric Enable
|
|
63
|
+
// entries. This is the SSOT for entry decoding - both buildConfigIndex (which uses it to populate the index) and entryAddressesScope (which uses it to decide
|
|
64
|
+
// whether a mutation should replace this entry) consume the same result, so the writer and the reader of the storage format cannot disagree about what any given
|
|
65
|
+
// entry "means."
|
|
66
|
+
//
|
|
67
|
+
// Greedy longest-prefix matching against the value-option registry handles the case where a shorter value-centric option name is a prefix of a longer option in
|
|
68
|
+
// the catalog - the longer match wins, so an entry like `Enable.Audio.Volume.50` (when both `Audio` and `Audio.Volume` are value-centric) is unambiguously parsed
|
|
69
|
+
// as `Audio.Volume` with value `50`, not `Audio` with value `Volume.50`.
|
|
70
|
+
function parseEntry(catalog, rawEntry) {
|
|
71
|
+
const dotIndex = rawEntry.indexOf(".");
|
|
72
|
+
if (dotIndex === -1) {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
const action = rawEntry.slice(0, dotIndex).toLowerCase();
|
|
76
|
+
if ((action !== "enable") && (action !== "disable")) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
const enabled = action === "enable";
|
|
80
|
+
const tailOriginal = rawEntry.slice(dotIndex + 1);
|
|
81
|
+
const tail = tailOriginal.toLowerCase();
|
|
82
|
+
const parsed = { enabled, primaryKey: tail };
|
|
83
|
+
// Value extraction is only meaningful for Enable entries - a disabled option carries no value regardless of trailing segments.
|
|
84
|
+
if (!enabled) {
|
|
85
|
+
return parsed;
|
|
86
|
+
}
|
|
87
|
+
// Iterate the precomputed longest-first value-option-names cache. Sorting here would re-allocate and re-traverse valueOptions on every Enable-entry parse; the
|
|
88
|
+
// cache lives on catalog.sortedValueOptionNames and is rebuilt only when the catalog changes (see buildCatalogIndex).
|
|
89
|
+
for (const optName of catalog.sortedValueOptionNames) {
|
|
90
|
+
if (!tail.startsWith(optName)) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const remainder = tail.slice(optName.length);
|
|
94
|
+
// Exact match on the option name with no trailing segments - there is no value to extract, just a bare Enable on the value option.
|
|
95
|
+
if (!remainder.length) {
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
// The next character must be a dot separator. Otherwise this option name is merely a prefix of a longer unrelated token, and we should continue trying shorter
|
|
99
|
+
// candidates.
|
|
100
|
+
if (!remainder.startsWith(".")) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const extra = remainder.slice(1);
|
|
104
|
+
const extraOriginal = tailOriginal.slice(optName.length + 1);
|
|
105
|
+
const separatorIndex = extra.indexOf(".");
|
|
106
|
+
if (separatorIndex === -1) {
|
|
107
|
+
// Single trailing segment: global value form.
|
|
108
|
+
parsed.valueKey = optName;
|
|
109
|
+
parsed.value = extraOriginal;
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
const idLower = extra.slice(0, separatorIndex);
|
|
113
|
+
const valueOriginal = extraOriginal.slice(separatorIndex + 1);
|
|
114
|
+
// Only register if the value portion is a single segment (no additional dots) - otherwise the tail is unstructured and we leave it for the primary key.
|
|
115
|
+
if (!valueOriginal.includes(".")) {
|
|
116
|
+
parsed.valueKey = optName + "." + idLower;
|
|
117
|
+
parsed.value = valueOriginal;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
return parsed;
|
|
123
|
+
}
|
|
124
|
+
// Decide whether a configured-options entry addresses a target lookup key. Used by applySetOption/applyClearOption to find replaceable entries without exposing
|
|
125
|
+
// the entry format to callers. Goes through the shared parser so the matcher is consistent with the indexer by construction.
|
|
126
|
+
function entryAddressesScope({ catalog, rawEntry, target }) {
|
|
127
|
+
const parsed = parseEntry(catalog, rawEntry);
|
|
128
|
+
if (!parsed) {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
return (parsed.primaryKey === target) || (parsed.valueKey === target);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Build the catalog-derived index from raw categories + options. The result carries the raw inputs alongside every derivation needed for O(1) catalog queries -
|
|
135
|
+
* defaults, value-options registry, groups (both directions), renderers, and the longest-first cache the entry parser consumes. Throws when a built-in formatter
|
|
136
|
+
* name on a `render` declaration does not resolve, surfacing the misconfiguration at load time rather than silently degrading the log-emission path.
|
|
137
|
+
*
|
|
138
|
+
* The index is the catalog-side input to every other pure helper in this module. Build it once per catalog; reuse it across every configured-options mutation
|
|
139
|
+
* because the catalog is invariant across those mutations. Categories without an entry in the options map are skipped silently (a plugin defines a category for
|
|
140
|
+
* future expansion before any option has migrated into it).
|
|
141
|
+
*
|
|
142
|
+
* @param categories - The raw category list.
|
|
143
|
+
* @param options - The raw options map keyed by category name.
|
|
144
|
+
*
|
|
145
|
+
* @returns The immutable catalog index.
|
|
146
|
+
*/
|
|
147
|
+
export function buildCatalogIndex(categories, options) {
|
|
148
|
+
const defaults = {};
|
|
149
|
+
const groupParents = {};
|
|
150
|
+
const groups = {};
|
|
151
|
+
const renderers = {};
|
|
152
|
+
const valueOptions = {};
|
|
153
|
+
for (const category of categories) {
|
|
154
|
+
const categoryOptions = options[category.name];
|
|
155
|
+
if (!categoryOptions) {
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
for (const option of categoryOptions) {
|
|
159
|
+
const entry = expandOption(category, option);
|
|
160
|
+
defaults[entry.toLowerCase()] = option.default;
|
|
161
|
+
// Track value-centric options separately so the lookup index built later knows which entries can carry a trailing value segment.
|
|
162
|
+
if ("defaultValue" in option) {
|
|
163
|
+
valueOptions[entry.toLowerCase()] = option.defaultValue;
|
|
164
|
+
}
|
|
165
|
+
// Register the catalog-declared renderer when present so logFeature can consult it in O(1) without walking the options map at log time. Boolean options may
|
|
166
|
+
// declare a renderer too - it just goes unused by the logging path - so we register unconditionally rather than gating on isValue here. A string-typed
|
|
167
|
+
// declaration names a built-in formatter from BUILT_IN_FORMATTERS; an unknown name is a misconfiguration, surfaced loudly at catalog-build time rather than
|
|
168
|
+
// silently degraded to the raw-value fallback at log time.
|
|
169
|
+
if (option.render !== undefined) {
|
|
170
|
+
if (typeof option.render === "string") {
|
|
171
|
+
const formatter = resolveBuiltInFormatter(option.render);
|
|
172
|
+
if (formatter === undefined) {
|
|
173
|
+
throw new Error("FeatureOptions: unknown built-in formatter \"" + option.render + "\" declared on option \"" + entry + "\".");
|
|
174
|
+
}
|
|
175
|
+
renderers[entry.toLowerCase()] = formatter;
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
renderers[entry.toLowerCase()] = option.render;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (option.group !== undefined) {
|
|
182
|
+
const expandedGroup = category.name + (option.group.length ? ("." + option.group) : "");
|
|
183
|
+
// Build both directions of the parent/child relation so callers can walk it either way in O(1) - forward for a parent's children, reverse for an option's
|
|
184
|
+
// parent group.
|
|
185
|
+
(groups[expandedGroup] ??= []).push(entry);
|
|
186
|
+
groupParents[entry] = expandedGroup;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
// Cache the value-option names sorted longest-first. parseEntry consumes this directly on every Enable-entry parse; precomputing here means the sort runs once
|
|
191
|
+
// per catalog change rather than once per parse. The list mirrors valueOptions's key set, so any future mutation that touches valueOptions must rebuild through
|
|
192
|
+
// this method to keep the two views consistent.
|
|
193
|
+
const sortedValueOptionNames = Object.keys(valueOptions).sort((a, b) => b.length - a.length);
|
|
194
|
+
return { categories, defaults, groupParents, groups, options, renderers, sortedValueOptionNames, valueOptions };
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Build the configured-options lookup index from a catalog index + the configured-options array. Each entry contributes one or two lookup keys via the shared
|
|
198
|
+
* `parseEntry`: the raw tail (always) and an extracted value key (for value-centric Enable entries). First-write-wins on collision so the earliest entry in
|
|
199
|
+
* the array takes precedence over later duplicates - users hand-editing config and accidentally listing an option twice get the natural "first one is canonical"
|
|
200
|
+
* semantic.
|
|
201
|
+
*
|
|
202
|
+
* Rebuild whenever the configured-options array changes; reuse across reads.
|
|
203
|
+
*
|
|
204
|
+
* @param catalog - The catalog index that defines what counts as a value-centric option.
|
|
205
|
+
* @param configuredOptions - The array of configured option strings.
|
|
206
|
+
*
|
|
207
|
+
* @returns The immutable lookup index.
|
|
208
|
+
*/
|
|
209
|
+
export function buildConfigIndex(catalog, configuredOptions) {
|
|
210
|
+
const lookup = new Map();
|
|
211
|
+
for (const rawEntry of configuredOptions) {
|
|
212
|
+
const parsed = parseEntry(catalog, rawEntry);
|
|
213
|
+
if (!parsed) {
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (!lookup.has(parsed.primaryKey)) {
|
|
217
|
+
lookup.set(parsed.primaryKey, { enabled: parsed.enabled });
|
|
218
|
+
}
|
|
219
|
+
if (parsed.valueKey && !lookup.has(parsed.valueKey)) {
|
|
220
|
+
lookup.set(parsed.valueKey, { enabled: true, value: parsed.value });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return lookup;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Compute the new configured-options array after setting an option's enabled state (and optionally its value) at a given scope. Drops any prior entry addressing
|
|
227
|
+
* the same option-at-scope so the new entry is the sole survivor, then appends the freshly composed entry string. Pure: does not mutate the input array; the
|
|
228
|
+
* returned array is a fresh allocation.
|
|
229
|
+
*
|
|
230
|
+
* The composed entry's action segment is canonical "Enable" / "Disable"; the option and id segments preserve the caller's casing for readability since the
|
|
231
|
+
* lookup-index keys are case-insensitive anyway. Value tails are emitted only when meaningful - disabled or non-value options never carry one - so a subsequent
|
|
232
|
+
* {@link applyClearOption} or {@link applySetOption} addressing the same scope cleanly replaces whatever was there.
|
|
233
|
+
*
|
|
234
|
+
* @param options
|
|
235
|
+
* @param options.args - The mutation intent: option key, optional scope id, enabled state, optional value. See {@link SetOptionArgs}.
|
|
236
|
+
* @param options.catalog - The catalog index that defines what counts as a value-centric option (which determines whether to emit a value segment).
|
|
237
|
+
* @param options.configuredOptions - The current configured-options array.
|
|
238
|
+
*
|
|
239
|
+
* @returns The new configured-options array. A fresh allocation, never a shared reference with the input.
|
|
240
|
+
*/
|
|
241
|
+
export function applySetOption({ args, catalog, configuredOptions }) {
|
|
242
|
+
const target = targetKey(args.option, args.id);
|
|
243
|
+
const filtered = configuredOptions.filter((entry) => !entryAddressesScope({ catalog, rawEntry: entry, target }));
|
|
244
|
+
// Compose the new entry. The action segment is canonical "Enable"/"Disable"; the rest preserves the caller's casing for readability since the index is
|
|
245
|
+
// case-insensitive anyway. Value tails are emitted only when meaningful - disabled or non-value options never carry one.
|
|
246
|
+
const segments = [args.enabled ? "Enable" : "Disable", args.option];
|
|
247
|
+
if (args.id?.length) {
|
|
248
|
+
segments.push(args.id);
|
|
249
|
+
}
|
|
250
|
+
if (args.enabled && isValueOption(catalog, args.option) && (args.value !== undefined)) {
|
|
251
|
+
segments.push(args.value.toString());
|
|
252
|
+
}
|
|
253
|
+
filtered.push(segments.join("."));
|
|
254
|
+
return filtered;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Compute the new configured-options array after clearing every entry addressing an option at a given scope. The match is value-aware: for value-centric options
|
|
258
|
+
* it covers both the bare scoped entry and any entry carrying a single trailing value segment, so a subsequent {@link applySetOption} cleanly replaces whatever
|
|
259
|
+
* was there.
|
|
260
|
+
*
|
|
261
|
+
* Pure: does not mutate the input array. When no entry matched the target, returns the input array reference unchanged so reference-equality consumers can detect
|
|
262
|
+
* a no-op without a contents comparison.
|
|
263
|
+
*
|
|
264
|
+
* @param options
|
|
265
|
+
* @param options.args - The addressing intent: option key, optional scope id. See {@link ClearOptionArgs}.
|
|
266
|
+
* @param options.catalog - The catalog index that defines what counts as a value-centric option (which the matcher consults via the shared parser).
|
|
267
|
+
* @param options.configuredOptions - The current configured-options array.
|
|
268
|
+
*
|
|
269
|
+
* @returns The new configured-options array, or the input array reference itself when nothing matched.
|
|
270
|
+
*/
|
|
271
|
+
export function applyClearOption({ args, catalog, configuredOptions }) {
|
|
272
|
+
const target = targetKey(args.option, args.id);
|
|
273
|
+
const filtered = configuredOptions.filter((entry) => !entryAddressesScope({ catalog, rawEntry: entry, target }));
|
|
274
|
+
// Reference-stable no-op: nothing matched, so callers comparing references see no change without inspecting contents.
|
|
275
|
+
return (filtered.length === configuredOptions.length) ? configuredOptions : filtered;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Resolve a feature option through the scope hierarchy in a single traversal. Returns the scope where the option was found, its enabled state, and the raw value
|
|
279
|
+
* for value-centric options. This is the core resolution primitive that every higher-level query builds on - {@link FeatureOptions.test}, {@link FeatureOptions.scope},
|
|
280
|
+
* {@link FeatureOptions.value}, and {@link FeatureOptions.logFeature} all consume the same `ResolvedOptionEntry` shape from one walk.
|
|
281
|
+
*
|
|
282
|
+
* Resolution precedence: device beats controller beats global beats default. An explicit entry at a higher-precedence scope short-circuits the lookup, so the
|
|
283
|
+
* cost is O(1) in the configured-options array size.
|
|
284
|
+
*
|
|
285
|
+
* @param args
|
|
286
|
+
* @param args.catalog - The catalog index (consulted for the default when no scope matched).
|
|
287
|
+
* @param args.configIndex - The configured-options lookup index.
|
|
288
|
+
* @param args.controller - Optional controller scope identifier.
|
|
289
|
+
* @param args.defaultReturnValue - Fallback for options that don't appear in the catalog's defaults. Defaults to false.
|
|
290
|
+
* @param args.device - Optional device scope identifier.
|
|
291
|
+
* @param args.option - The option key to resolve (case-insensitive).
|
|
292
|
+
*
|
|
293
|
+
* @returns The resolved view: scope, enabled state, optional raw value.
|
|
294
|
+
*/
|
|
295
|
+
export function resolveScope({ catalog, configIndex, controller, defaultReturnValue = false, device, option }) {
|
|
296
|
+
const normalizedOption = option.toLowerCase();
|
|
297
|
+
// Check to see if we have a device-level option first.
|
|
298
|
+
if (device) {
|
|
299
|
+
const deviceEntry = configIndex.get(normalizedOption + "." + device.toLowerCase());
|
|
300
|
+
if (deviceEntry) {
|
|
301
|
+
return { enabled: deviceEntry.enabled, optionValue: deviceEntry.value, scope: "device" };
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
// Now check to see if we have a controller-level option.
|
|
305
|
+
if (controller) {
|
|
306
|
+
const controllerEntry = configIndex.get(normalizedOption + "." + controller.toLowerCase());
|
|
307
|
+
if (controllerEntry) {
|
|
308
|
+
return { enabled: controllerEntry.enabled, optionValue: controllerEntry.value, scope: "controller" };
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
// Finally, we check for a global-level value.
|
|
312
|
+
const globalEntry = configIndex.get(normalizedOption);
|
|
313
|
+
if (globalEntry) {
|
|
314
|
+
return { enabled: globalEntry.enabled, optionValue: globalEntry.value, scope: "global" };
|
|
315
|
+
}
|
|
316
|
+
// The option hasn't been set at any scope, return the catalog default. The defaultReturnValue parameter covers the case where the option is not in the catalog
|
|
317
|
+
// at all - typically a misspelling or a stale call site referring to an option that was removed from the catalog.
|
|
318
|
+
return { enabled: getDefaultValue({ catalog, defaultReturnValue, option }), scope: "none" };
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Return the catalog-declared default for a feature option, falling back to a caller-supplied default for options that don't appear in the catalog at all.
|
|
322
|
+
*
|
|
323
|
+
* @param args
|
|
324
|
+
* @param args.catalog - The catalog index.
|
|
325
|
+
* @param args.defaultReturnValue - Fallback when the option is not in the catalog's defaults map. Defaults to false.
|
|
326
|
+
* @param args.option - The option key (case-insensitive).
|
|
327
|
+
*
|
|
328
|
+
* @returns The default value: catalog declaration if present, fallback otherwise.
|
|
329
|
+
*/
|
|
330
|
+
export function getDefaultValue({ catalog, defaultReturnValue = false, option }) {
|
|
331
|
+
return catalog.defaults[option.toLowerCase()] ?? defaultReturnValue;
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Return whether a feature option is value-centric (carries a `defaultValue` in its catalog declaration). The presence of the option's lowercased key in the
|
|
335
|
+
* catalog's `valueOptions` map is the SSOT for this predicate.
|
|
336
|
+
*
|
|
337
|
+
* @param catalog - The catalog index.
|
|
338
|
+
* @param option - The option key (case-insensitive). Empty string returns false.
|
|
339
|
+
*
|
|
340
|
+
* @returns True for value-centric options, false otherwise.
|
|
341
|
+
*/
|
|
342
|
+
export function isValueOption(catalog, option) {
|
|
343
|
+
if (!option) {
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
return option.toLowerCase() in catalog.valueOptions;
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Return whether an option has been explicitly configured at the given scope. Distinct from {@link resolveScope}, which walks the hierarchy; this predicate
|
|
350
|
+
* answers only "did the user set this entry at THIS scope?" without consulting any higher or lower scopes.
|
|
351
|
+
*
|
|
352
|
+
* @param args
|
|
353
|
+
* @param args.configIndex - The configured-options lookup index.
|
|
354
|
+
* @param args.id - Optional scope identifier (device or controller). Omit to address the global scope.
|
|
355
|
+
* @param args.option - The option key (case-insensitive).
|
|
356
|
+
*
|
|
357
|
+
* @returns True when an explicit entry addresses this option-at-scope.
|
|
358
|
+
*/
|
|
359
|
+
export function optionExists({ configIndex, id, option }) {
|
|
360
|
+
return configIndex.has(option.toLowerCase() + (id ? "." + id.toLowerCase() : ""));
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Return whether a grouped option's parent is currently enabled at the given scope. For options that aren't grouped (no `group` property in the catalog entry),
|
|
364
|
+
* always returns `true` - there is no dependency to fail. For grouped options, traverses the scope hierarchy via {@link resolveScope} to evaluate the parent's
|
|
365
|
+
* effective state at the requested device + controller view.
|
|
366
|
+
*
|
|
367
|
+
* This is the SSOT for "is this option's row currently usable?" Every caller that wants to know whether to render a grouped option's row, count it as visible,
|
|
368
|
+
* or honor its dependency-hidden state asks this function rather than reconstructing the parent path themselves. The reverse-lookup from option to parent uses
|
|
369
|
+
* the pre-built `catalog.groupParents` index, so the predicate is O(1) regardless of option-key length.
|
|
370
|
+
*
|
|
371
|
+
* @param args
|
|
372
|
+
* @param args.catalog - The catalog index.
|
|
373
|
+
* @param args.configIndex - The configured-options lookup index.
|
|
374
|
+
* @param args.controller - Optional controller scope identifier.
|
|
375
|
+
* @param args.defaultReturnValue - Fallback default for options not in the catalog. Defaults to false.
|
|
376
|
+
* @param args.device - Optional device scope identifier.
|
|
377
|
+
* @param args.option - Fully-qualified feature option string (e.g., `"Motion.Sensitivity"`). Case-insensitive.
|
|
378
|
+
*
|
|
379
|
+
* @returns `true` when the option has no dependency or its parent is currently enabled at the requested scope; `false` when the parent is currently disabled.
|
|
380
|
+
*/
|
|
381
|
+
export function isDependencyMet({ catalog, configIndex, controller, defaultReturnValue = false, device, option }) {
|
|
382
|
+
const parent = catalog.groupParents[option];
|
|
383
|
+
if (!parent) {
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
return resolveScope({ catalog, configIndex, controller, defaultReturnValue, device, option: parent }).enabled;
|
|
387
|
+
}
|
|
388
|
+
// Utility function to parse and return a numeric configuration parameter. Distinguishes exactly two outcomes: null when the option is explicitly disabled, and
|
|
389
|
+
// undefined-or-a-parsed-number for everything else - unset, set but unparseable, or successfully parsed - matching the public getInteger/getFloat contract, which
|
|
390
|
+
// likewise groups "doesn't exist" and "couldn't be parsed" under a single undefined outcome.
|
|
391
|
+
function parseOptionNumeric(option, convert) {
|
|
392
|
+
// If the option is disabled (null) or we don't have it configured (undefined), preserve that distinction in the return value so callers can tell the two apart.
|
|
393
|
+
if (!option) {
|
|
394
|
+
return (option === null) ? null : undefined;
|
|
395
|
+
}
|
|
396
|
+
// Convert it to a number, if needed.
|
|
397
|
+
const convertedValue = convert(option);
|
|
398
|
+
// Let's validate to make sure it's really a number.
|
|
399
|
+
if (Number.isNaN(convertedValue)) {
|
|
400
|
+
return undefined;
|
|
401
|
+
}
|
|
402
|
+
// Return the value.
|
|
403
|
+
return convertedValue;
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* FeatureOptions provides a hierarchical feature option system for plugins and applications.
|
|
407
|
+
*
|
|
408
|
+
* Supports global, controller, and device-level configuration, value-centric feature options, grouping, and category management.
|
|
409
|
+
*
|
|
410
|
+
* This class is the imperative façade over the pure functional core exposed by this module ({@link buildCatalogIndex}, {@link applySetOption},
|
|
411
|
+
* {@link applyClearOption}, {@link resolveScope}, etc.). Reducer-driven consumers that want immutable state should call the pure functions directly; imperative
|
|
412
|
+
* Node-side plugin code uses this class for the same semantics with mutation-friendly ergonomics.
|
|
413
|
+
*
|
|
414
|
+
* @example
|
|
415
|
+
*
|
|
416
|
+
* ```ts
|
|
417
|
+
* // Define categories and options.
|
|
418
|
+
* const categories = [
|
|
419
|
+
*
|
|
420
|
+
* { name: "motion", description: "Motion Options" },
|
|
421
|
+
* { name: "audio", description: "Audio Options" }
|
|
422
|
+
* ];
|
|
423
|
+
*
|
|
424
|
+
* const options = {
|
|
425
|
+
*
|
|
426
|
+
* motion: [
|
|
427
|
+
* { name: "detect", default: true, description: "Enable motion detection." }
|
|
428
|
+
* ],
|
|
429
|
+
*
|
|
430
|
+
* audio: [
|
|
431
|
+
* { name: "volume", default: false, defaultValue: 50, description: "Audio volume." }
|
|
432
|
+
* ]
|
|
433
|
+
* };
|
|
434
|
+
*
|
|
435
|
+
* // Instantiate FeatureOptions.
|
|
436
|
+
* const featureOpts = new FeatureOptions(categories, options, ["Enable.motion.detect"]);
|
|
437
|
+
*
|
|
438
|
+
* // Check if a feature is enabled.
|
|
439
|
+
* const motionEnabled = featureOpts.test("motion.detect");
|
|
440
|
+
*
|
|
441
|
+
* // Get a value-centric feature option.
|
|
442
|
+
* const volume = featureOpts.value("audio.volume");
|
|
443
|
+
* ```
|
|
444
|
+
*
|
|
445
|
+
* @see FeatureOptionEntry
|
|
446
|
+
* @see FeatureCategoryEntry
|
|
447
|
+
* @see OptionScope
|
|
448
|
+
*/
|
|
449
|
+
export class FeatureOptions {
|
|
450
|
+
/**
|
|
451
|
+
* Default return value for unknown options (defaults to false).
|
|
452
|
+
*/
|
|
453
|
+
defaultReturnValue;
|
|
454
|
+
#catalog;
|
|
455
|
+
#configIndex;
|
|
456
|
+
#configuredOptions;
|
|
457
|
+
/**
|
|
458
|
+
* Create a new FeatureOptions instance.
|
|
459
|
+
*
|
|
460
|
+
* @param categories - Array of feature option categories.
|
|
461
|
+
* @param options - Dictionary mapping category names to arrays of feature options.
|
|
462
|
+
* @param configuredOptions - Optional. Array of currently configured option strings.
|
|
463
|
+
*
|
|
464
|
+
* @example
|
|
465
|
+
*
|
|
466
|
+
* ```ts
|
|
467
|
+
* const featureOpts = new FeatureOptions(categories, options, ["Enable.motion.detect"]);
|
|
468
|
+
* ```
|
|
469
|
+
*/
|
|
470
|
+
constructor(categories, options, configuredOptions = []) {
|
|
471
|
+
this.#catalog = buildCatalogIndex(categories, options);
|
|
472
|
+
this.#configuredOptions = configuredOptions;
|
|
473
|
+
this.#configIndex = buildConfigIndex(this.#catalog, configuredOptions);
|
|
474
|
+
this.defaultReturnValue = false;
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* Return the default value for an option.
|
|
478
|
+
*
|
|
479
|
+
* @param option - Feature option to check.
|
|
480
|
+
*
|
|
481
|
+
* @returns Returns true or false, depending on the option default.
|
|
482
|
+
*/
|
|
483
|
+
defaultValue(option) {
|
|
484
|
+
return getDefaultValue({ catalog: this.#catalog, defaultReturnValue: this.defaultReturnValue, option });
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Return whether the option explicitly exists in the list of configured options.
|
|
488
|
+
*
|
|
489
|
+
* @param option - Feature option to check.
|
|
490
|
+
* @param id - Optional device or controller scope identifier to check.
|
|
491
|
+
*
|
|
492
|
+
* @returns Returns true if the option has been explicitly configured, false otherwise.
|
|
493
|
+
*/
|
|
494
|
+
exists(option, id) {
|
|
495
|
+
return optionExists({ configIndex: this.#configIndex, id, option });
|
|
496
|
+
}
|
|
497
|
+
/**
|
|
498
|
+
* Return whether a grouped option's parent is currently enabled at the given scope. For options that aren't grouped (no `group` property in the catalog entry),
|
|
499
|
+
* always returns `true` - there is no dependency to fail. For grouped options, traverses the scope hierarchy via {@link resolveScope} to evaluate the parent's
|
|
500
|
+
* effective state at the requested device + controller view.
|
|
501
|
+
*
|
|
502
|
+
* This is the SSOT for "is this option's row currently usable?" Every caller that wants to know whether to render a grouped option's row, count it as visible,
|
|
503
|
+
* or honor its dependency-hidden state asks the model rather than reconstructing the parent path themselves. The reverse-lookup from option to parent uses the
|
|
504
|
+
* pre-built {@link CatalogIndex.groupParents} index, so the predicate is O(1) regardless of option-key length.
|
|
505
|
+
*
|
|
506
|
+
* @param option - Fully-qualified feature option string (e.g., `"Motion.Sensitivity"`). Case-insensitive.
|
|
507
|
+
* @param device - Optional device scope identifier, forwarded to {@link resolveScope}.
|
|
508
|
+
* @param controller - Optional controller scope identifier, forwarded to {@link resolveScope}.
|
|
509
|
+
*
|
|
510
|
+
* @returns `true` when the option has no dependency or its parent is currently enabled at the requested scope; `false` when the parent is currently disabled.
|
|
511
|
+
*/
|
|
512
|
+
isDependencyMet(option, device, controller) {
|
|
513
|
+
return isDependencyMet({ catalog: this.#catalog, configIndex: this.#configIndex, controller, defaultReturnValue: this.defaultReturnValue, device, option });
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Return a fully formed feature option string.
|
|
517
|
+
*
|
|
518
|
+
* @param category - Feature option category entry or category name string.
|
|
519
|
+
* @param option - Feature option entry of option name string.
|
|
520
|
+
*
|
|
521
|
+
* @returns Returns a fully formed feature option in the form of `category.option`.
|
|
522
|
+
*/
|
|
523
|
+
expandOption(category, option) {
|
|
524
|
+
return expandOption(category, option);
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Parse a floating point feature option value.
|
|
528
|
+
*
|
|
529
|
+
* @param option - Feature option to check.
|
|
530
|
+
* @param device - Optional device scope identifier.
|
|
531
|
+
* @param controller - Optional controller scope identifier.
|
|
532
|
+
*
|
|
533
|
+
* @returns Returns the value of a value-centric option as a floating point number, `undefined` if it doesn't exist or couldn't be parsed, and `null` if disabled.
|
|
534
|
+
*/
|
|
535
|
+
getFloat(option, device, controller) {
|
|
536
|
+
return parseOptionNumeric(this.value(option, device, controller), Number.parseFloat);
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Parse an integer feature option value.
|
|
540
|
+
*
|
|
541
|
+
* @param option - Feature option to check.
|
|
542
|
+
* @param device - Optional device scope identifier.
|
|
543
|
+
* @param controller - Optional controller scope identifier.
|
|
544
|
+
*
|
|
545
|
+
* @returns Returns the value of a value-centric option as an integer, `undefined` if it doesn't exist or couldn't be parsed, and `null` if disabled.
|
|
546
|
+
*/
|
|
547
|
+
getInteger(option, device, controller) {
|
|
548
|
+
return parseOptionNumeric(this.value(option, device, controller), Number.parseInt);
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* Return whether an option has been set in either the device or controller scope context.
|
|
552
|
+
*
|
|
553
|
+
* @param option - Feature option to check.
|
|
554
|
+
*
|
|
555
|
+
* @returns Returns true if the option is set at the device or controller level and false otherwise.
|
|
556
|
+
*/
|
|
557
|
+
isScopeDevice(option, device) {
|
|
558
|
+
return this.exists(option, device);
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Return whether an option has been set in the global scope context.
|
|
562
|
+
*
|
|
563
|
+
* @param option - Feature option to check.
|
|
564
|
+
*
|
|
565
|
+
* @returns Returns true if the option is set globally and false otherwise.
|
|
566
|
+
*/
|
|
567
|
+
isScopeGlobal(option) {
|
|
568
|
+
return this.exists(option);
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* Return whether an option is value-centric or not.
|
|
572
|
+
*
|
|
573
|
+
* @param option - Feature option entry or string to check.
|
|
574
|
+
*
|
|
575
|
+
* @returns Returns true if it is a value-centric option and false otherwise.
|
|
576
|
+
*/
|
|
577
|
+
isValue(option) {
|
|
578
|
+
return isValueOption(this.#catalog, option);
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Emit an INFO-level log line for a feature option, but only when the user's effective configuration deviates from the declared default.
|
|
582
|
+
*
|
|
583
|
+
* This is the executable form of the project-wide startup-log convention: restating a default is log noise, and deviations should be reported in both directions - a
|
|
584
|
+
* default-off feature the user turned on, a default-on feature the user turned off, and a value the user customized away from the registered default. Callers pass
|
|
585
|
+
* the option key and a human-readable label; this method handles the direction detection and the message synthesis so every plugin emits the same shape from one
|
|
586
|
+
* place. If the convention ever evolves, every call site picks up the change without any source modification.
|
|
587
|
+
*
|
|
588
|
+
* Polymorphic over option type, mirroring how {@link FeatureOptions.test} and {@link FeatureOptions.value} already dispatch on whether the option is value-centric.
|
|
589
|
+
* The distinct emitted-line shapes, across these state combinations:
|
|
590
|
+
*
|
|
591
|
+
* | Option type | User state vs. default | Emitted line |
|
|
592
|
+
* |------------------|---------------------------------------------------------|------------------------------------|
|
|
593
|
+
* | Boolean | matches default | (silent) |
|
|
594
|
+
* | Boolean | default off, enabled | `<label> enabled.` |
|
|
595
|
+
* | Boolean | default on, disabled | `<label> disabled.` |
|
|
596
|
+
* | Value-centric | both axes match | (silent) |
|
|
597
|
+
* | Value-centric | default on, disabled | `<label> disabled.` |
|
|
598
|
+
* | Value-centric | default off, enabled (value matches or differs) | `<label> enabled at <value>.` |
|
|
599
|
+
* | Value-centric | default on, enabled, value differs from declared default | `<label> set to <value>.` |
|
|
600
|
+
*
|
|
601
|
+
* A value-centric option enabled with no resolvable value anywhere - no catalog-declared default and no explicit value at any scope - collapses to the plain
|
|
602
|
+
* `<label> enabled.` line above rather than the `enabled at <value>` form, since there is nothing meaningful to render after "at" (see the defensive fallback in
|
|
603
|
+
* the implementation below).
|
|
604
|
+
*
|
|
605
|
+
* Value rendering consults the catalog-declared {@link FeatureOptionEntry.render} when present; otherwise the raw string returned by {@link FeatureOptions.value}
|
|
606
|
+
* is used. The renderer may be either a {@link FeatureOptionFormatter} string naming a built-in formatter from the shared registry (preferred when the format exists
|
|
607
|
+
* there, since this keeps the catalog JSON-serializable and lets every plugin share one implementation) or an inline function for bespoke cases. Declaring the
|
|
608
|
+
* renderer at the option's catalog entry keeps display formatting a single source of truth shared by `logFeature` and any future surface that displays the value.
|
|
609
|
+
*
|
|
610
|
+
* Scope precedence matches {@link FeatureOptions.test}: device wins over controller wins over global wins over default. Pass the scope arguments that describe the
|
|
611
|
+
* vantage point you want to log from - typically a device identifier for accessory-level configuration, optionally with a controller identifier when the plugin's
|
|
612
|
+
* controller scope is meaningful (e.g., UniFi Protect controllers).
|
|
613
|
+
*
|
|
614
|
+
* @param option - Feature option to check (same key shape as {@link FeatureOptions.test}; case-insensitive).
|
|
615
|
+
* @param label - Human-readable label that prefixes the emitted message. Used verbatim as the first `%s` argument; should be a noun phrase like "Motion sensor"
|
|
616
|
+
* or "Read-only mode" so the rendered line reads naturally ("Motion sensor enabled." / "Read-only mode disabled.").
|
|
617
|
+
* @param log - The plugin's logger. The emitted message is INFO-level; debug-level enumeration of the full feature surface is a separate concern handled by
|
|
618
|
+
* the caller.
|
|
619
|
+
* @param device - Optional device scope identifier, forwarded to {@link FeatureOptions.test}.
|
|
620
|
+
* @param controller - Optional controller scope identifier, forwarded to {@link FeatureOptions.test}.
|
|
621
|
+
*
|
|
622
|
+
* @example
|
|
623
|
+
*
|
|
624
|
+
* ```ts
|
|
625
|
+
* // Boolean option, inside a plugin's `configureMotion()` for a specific device:
|
|
626
|
+
* featureOptions.logFeature("Motion", "Motion sensor", log, device.mac);
|
|
627
|
+
*
|
|
628
|
+
* // Value-centric option - the message shape adapts to which axis (boolean, value, or both) deviated:
|
|
629
|
+
* featureOptions.logFeature("Stream.Bandwidth", "Bandwidth", log, device.mac);
|
|
630
|
+
*
|
|
631
|
+
* // With a controller scope:
|
|
632
|
+
* featureOptions.logFeature("HKSV.Record", "HKSV recording", log, device.mac, controller.id);
|
|
633
|
+
* ```
|
|
634
|
+
*/
|
|
635
|
+
logFeature(option, label, log, device, controller) {
|
|
636
|
+
const effective = this.test(option, device, controller);
|
|
637
|
+
const defaultEnabled = this.defaultValue(option);
|
|
638
|
+
const booleanDeviates = effective !== defaultEnabled;
|
|
639
|
+
// Disabled: emit only when the user turned off something whose default is on. Value-centric options collapse to the same shape here - the value is irrelevant
|
|
640
|
+
// when the option is off, and the operator only needs to know it was disabled.
|
|
641
|
+
if (!effective) {
|
|
642
|
+
if (booleanDeviates) {
|
|
643
|
+
log.info("%s disabled.", label);
|
|
644
|
+
}
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
// Enabled. For plain boolean options that is the whole story.
|
|
648
|
+
if (!this.isValue(option)) {
|
|
649
|
+
if (booleanDeviates) {
|
|
650
|
+
log.info("%s enabled.", label);
|
|
651
|
+
}
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
// Enabled, value-centric option. Resolve the effective value (an explicit user value when set, otherwise the registered catalog default) and compare against the
|
|
655
|
+
// declared default to detect value-axis deviation. We compare normalized strings because the registry stores stringified user input while the catalog's defaultValue
|
|
656
|
+
// is typed as `number | string`; coercing both to string is the one normalization that makes the comparison total.
|
|
657
|
+
const declaredDefault = this.#catalog.valueOptions[option.toLowerCase()]?.toString();
|
|
658
|
+
const effectiveValue = this.value(option, device, controller) ?? declaredDefault;
|
|
659
|
+
const valueDeviates = (effectiveValue !== undefined) && (effectiveValue !== declaredDefault);
|
|
660
|
+
if (!booleanDeviates && !valueDeviates) {
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
// Defensive fallback for the degenerate case of a value-centric catalog entry with no concrete value anywhere - no registered default and no explicit value at any
|
|
664
|
+
// scope. The option is enabled per the user's choice but there is nothing meaningful to render after "at"; emitting "<label> enabled at ." would mislead a reader,
|
|
665
|
+
// so we collapse to the boolean-axis message shape and let the catalog declaration's malformed-ness surface elsewhere.
|
|
666
|
+
if (effectiveValue === undefined) {
|
|
667
|
+
log.info("%s enabled.", label);
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
const renderedValue = this.#catalog.renderers[option.toLowerCase()]?.(effectiveValue) ?? effectiveValue;
|
|
671
|
+
// Message shape splits on which axis deviated: "enabled at" when the user turned the feature on (boolean axis crossed), "set to" when only the value moved away
|
|
672
|
+
// from the registered default. Both forms always carry the effective value, since for value-centric options the value is what the operator most needs to see.
|
|
673
|
+
if (booleanDeviates) {
|
|
674
|
+
log.info("%s enabled at %s.", label, renderedValue);
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
log.info("%s set to %s.", label, renderedValue);
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Remove every configured-options entry addressing the given option at the given scope.
|
|
681
|
+
*
|
|
682
|
+
* Callers express intent ("forget any configuration for option X at scope Y") and the model owns the entry-format end-to-end. The match is value-aware: for
|
|
683
|
+
* value-centric options it covers both the bare scoped entry and any entry carrying a single trailing value segment, so a subsequent {@link setOption} cleanly
|
|
684
|
+
* replaces whatever was there. No-op when no entry addresses the target scope, so callers can treat this as an idempotent reset.
|
|
685
|
+
*
|
|
686
|
+
* @param args - The addressing intent: option key and optional scope id. See {@link ClearOptionArgs}.
|
|
687
|
+
*
|
|
688
|
+
* @example
|
|
689
|
+
*
|
|
690
|
+
* ```ts
|
|
691
|
+
* // Remove any configured value for "Audio.Volume" on device ABC123 (drops both `Enable.Audio.Volume.ABC123` and `Enable.Audio.Volume.ABC123.50`).
|
|
692
|
+
* featureOpts.clearOption({ option: "Audio.Volume", id: "ABC123" });
|
|
693
|
+
* ```
|
|
694
|
+
*/
|
|
695
|
+
clearOption(args) {
|
|
696
|
+
const next = applyClearOption({ args, catalog: this.#catalog, configuredOptions: this.#configuredOptions });
|
|
697
|
+
// Reference-stable no-op: nothing matched, so the array and the index are already coherent. Skip the rebuild and preserve the array reference so callers
|
|
698
|
+
// holding a snapshot see a stable identity for unchanged state.
|
|
699
|
+
if (next === this.#configuredOptions) {
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
this.#configuredOptions = next;
|
|
703
|
+
// Only the index depends on the configured-options array; the catalog-derived state is invariant across config mutations and need not be touched here.
|
|
704
|
+
this.#configIndex = buildConfigIndex(this.#catalog, this.#configuredOptions);
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Set the enabled state (and optionally the value) for an option at a given scope, replacing any prior entry for the same option-at-scope.
|
|
708
|
+
*
|
|
709
|
+
* This is the single mutation primitive for individual feature options. Callers express intent ("enable option X at scope Y, with value Z") and the model owns
|
|
710
|
+
* both the encoding and the prior-entry replacement - the configured-options array is canonical, the lookup index is rebuilt automatically, and the entry-string
|
|
711
|
+
* format never leaks past this method. Value segments are emitted only when `enabled` is true and the option is value-centric; passing `value` for a non-value or
|
|
712
|
+
* disabled option is silently dropped because the resulting entry would be meaningless under the resolution rules.
|
|
713
|
+
*
|
|
714
|
+
* @param args - The mutation intent: option key, optional scope id, enabled state, and optional value. See {@link SetOptionArgs}.
|
|
715
|
+
*
|
|
716
|
+
* @example
|
|
717
|
+
*
|
|
718
|
+
* ```ts
|
|
719
|
+
* // Disable "Motion.Detect" globally.
|
|
720
|
+
* featureOpts.setOption({ enabled: false, option: "Motion.Detect" });
|
|
721
|
+
*
|
|
722
|
+
* // Enable "Audio.Volume" on device ABC123 with value 50, replacing any prior device-scoped entry for the same option.
|
|
723
|
+
* featureOpts.setOption({ enabled: true, id: "ABC123", option: "Audio.Volume", value: 50 });
|
|
724
|
+
* ```
|
|
725
|
+
*/
|
|
726
|
+
setOption(args) {
|
|
727
|
+
this.#configuredOptions = applySetOption({ args, catalog: this.#catalog, configuredOptions: this.#configuredOptions });
|
|
728
|
+
// Only the index depends on the configured-options array; the catalog-derived state is invariant across config mutations and need not be touched here.
|
|
729
|
+
this.#configIndex = buildConfigIndex(this.#catalog, this.#configuredOptions);
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* Return the scope hierarchy location of an option.
|
|
733
|
+
*
|
|
734
|
+
* @param option - Feature option to check.
|
|
735
|
+
* @param device - Optional device scope identifier.
|
|
736
|
+
* @param controller - Optional controller scope identifier.
|
|
737
|
+
*
|
|
738
|
+
* @returns Returns the location in the scope hierarchy of `option`.
|
|
739
|
+
*/
|
|
740
|
+
scope(option, device, controller) {
|
|
741
|
+
return resolveScope({ catalog: this.#catalog, configIndex: this.#configIndex, controller, defaultReturnValue: this.defaultReturnValue, device, option }).scope;
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* Return the current state of a feature option, traversing the scope hierarchy.
|
|
745
|
+
*
|
|
746
|
+
* @param option - Feature option to check.
|
|
747
|
+
* @param device - Optional device scope identifier.
|
|
748
|
+
* @param controller - Optional controller scope identifier.
|
|
749
|
+
*
|
|
750
|
+
* @returns Returns true if the option is enabled, and false otherwise.
|
|
751
|
+
*/
|
|
752
|
+
test(option, device, controller) {
|
|
753
|
+
return resolveScope({ catalog: this.#catalog, configIndex: this.#configIndex, controller, defaultReturnValue: this.defaultReturnValue, device, option }).enabled;
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* Return the value associated with a value-centric feature option, traversing the scope hierarchy.
|
|
757
|
+
*
|
|
758
|
+
* @param option - Feature option to check.
|
|
759
|
+
* @param device - Optional device scope identifier.
|
|
760
|
+
* @param controller - Optional controller scope identifier.
|
|
761
|
+
*
|
|
762
|
+
* @returns Returns the current value associated with `option` if the feature option is enabled, `null` if disabled (or not a value-centric feature option), or
|
|
763
|
+
* `undefined` if it's not specified.
|
|
764
|
+
*/
|
|
765
|
+
value(option, device, controller) {
|
|
766
|
+
// If this isn't a value-centric feature option, we're done.
|
|
767
|
+
if (!this.isValue(option)) {
|
|
768
|
+
return null;
|
|
769
|
+
}
|
|
770
|
+
// Resolve the option through the scope hierarchy in a single traversal. This gives us the scope, enabled state, and raw value in one pass.
|
|
771
|
+
const resolved = resolveScope({ catalog: this.#catalog, configIndex: this.#configIndex, controller, defaultReturnValue: this.defaultReturnValue, device, option });
|
|
772
|
+
// If the option has been explicitly disabled at any scope, or wasn't configured and its default is disabled, there's no value.
|
|
773
|
+
if (!resolved.enabled) {
|
|
774
|
+
return null;
|
|
775
|
+
}
|
|
776
|
+
// If we found a non-empty explicit value in the index, return it. An empty string is deliberately treated as unspecified - from the user's perspective an empty value
|
|
777
|
+
// is the same as not setting one - so it falls through to the default or "enabled, no value" resolution below rather than being returned verbatim.
|
|
778
|
+
if (resolved.optionValue) {
|
|
779
|
+
return resolved.optionValue;
|
|
780
|
+
}
|
|
781
|
+
// The option is enabled but has no explicit value. If it wasn't configured at any scope (scope is "none"), fall back to the registered default value.
|
|
782
|
+
if (resolved.scope === "none") {
|
|
783
|
+
return this.#catalog.valueOptions[option.toLowerCase()]?.toString() ?? null;
|
|
784
|
+
}
|
|
785
|
+
// The option is enabled at an explicit scope but no value was provided...return undefined to indicate "enabled, no value."
|
|
786
|
+
return undefined;
|
|
787
|
+
}
|
|
788
|
+
/**
|
|
789
|
+
* Return the list of available feature option categories.
|
|
790
|
+
*
|
|
791
|
+
* @returns Returns the current list of available feature option categories.
|
|
792
|
+
*/
|
|
793
|
+
get categories() {
|
|
794
|
+
// The catalog stores the categories as readonly to encode the immutability invariant the pure functional core relies on. The public getter returns the
|
|
795
|
+
// historical mutable type for backward compatibility - the array is the same identity the caller passed at construction (or via the setter), so consumers
|
|
796
|
+
// mutating it would be mutating the catalog regardless of the return type. The readonly annotation is the discipline, not a runtime enforcement.
|
|
797
|
+
return this.#catalog.categories;
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* Set the list of available feature option categories.
|
|
801
|
+
*
|
|
802
|
+
* @param category - Array of available categories.
|
|
803
|
+
*/
|
|
804
|
+
set categories(category) {
|
|
805
|
+
// The catalog derivation depends on categories, and the value-options registry (rebuilt by the catalog pass) feeds the index parser, so both stages must run
|
|
806
|
+
// in order. The class assembles a new catalog from the new categories + current options, then a new config index from the new catalog + current configured
|
|
807
|
+
// options.
|
|
808
|
+
this.#catalog = buildCatalogIndex(category, this.#catalog.options);
|
|
809
|
+
this.#configIndex = buildConfigIndex(this.#catalog, this.#configuredOptions);
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* Return the list of currently configured feature options.
|
|
813
|
+
*
|
|
814
|
+
* @returns Returns the currently configured list of feature options.
|
|
815
|
+
*/
|
|
816
|
+
get configuredOptions() {
|
|
817
|
+
return this.#configuredOptions;
|
|
818
|
+
}
|
|
819
|
+
/**
|
|
820
|
+
* Set the list of currently configured feature options.
|
|
821
|
+
*
|
|
822
|
+
* @param options - Array of configured feature options.
|
|
823
|
+
*/
|
|
824
|
+
set configuredOptions(options) {
|
|
825
|
+
this.#configuredOptions = options ?? [];
|
|
826
|
+
// The catalog-derived state is invariant across config mutations; only the lookup index needs rebuilding.
|
|
827
|
+
this.#configIndex = buildConfigIndex(this.#catalog, this.#configuredOptions);
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* Return a reverse index mapping each child option to its parent group. This provides O(1) child-to-parent lookups, complementing the forward `groups` map that maps
|
|
831
|
+
* parents to their children.
|
|
832
|
+
*
|
|
833
|
+
* @returns Returns a record mapping child option names to their parent group names.
|
|
834
|
+
*/
|
|
835
|
+
get groupParents() {
|
|
836
|
+
return this.#catalog.groupParents;
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Return the list of available feature option groups.
|
|
840
|
+
*
|
|
841
|
+
* @returns Returns the current list of available feature option groups.
|
|
842
|
+
*/
|
|
843
|
+
get groups() {
|
|
844
|
+
// Same readonly-to-mutable cast rationale as the categories getter above: this is the same object identity held internally, so a caller mutating it would be
|
|
845
|
+
// mutating the catalog regardless of the return type. The readonly annotation is the discipline, not a runtime enforcement.
|
|
846
|
+
return this.#catalog.groups;
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* Return the list of available feature options.
|
|
850
|
+
*
|
|
851
|
+
* @returns Returns the current list of available feature options.
|
|
852
|
+
*/
|
|
853
|
+
get options() {
|
|
854
|
+
// Same readonly-to-mutable cast rationale as the categories getter above: this is the same object identity held internally, so a caller mutating it would be
|
|
855
|
+
// mutating the catalog regardless of the return type. The readonly annotation is the discipline, not a runtime enforcement.
|
|
856
|
+
return this.#catalog.options;
|
|
857
|
+
}
|
|
858
|
+
/**
|
|
859
|
+
* Set the list of available feature options.
|
|
860
|
+
*
|
|
861
|
+
* @param options - Array of available feature options.
|
|
862
|
+
*/
|
|
863
|
+
set options(options) {
|
|
864
|
+
// The catalog derivation depends on the option definitions, and the index parser consults the resulting value-options registry, so both stages must run in
|
|
865
|
+
// order. The class assembles a new catalog from the current categories + new options, then a new config index from the new catalog + current configured options.
|
|
866
|
+
this.#catalog = buildCatalogIndex(this.#catalog.categories, options ?? {});
|
|
867
|
+
this.#configIndex = buildConfigIndex(this.#catalog, this.#configuredOptions);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
//# sourceMappingURL=featureOptions.js.map
|