data-solectrus 0.2.10
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/.github/workflows/npm-publish.yml +32 -0
- package/CHANGELOG.md +138 -0
- package/LICENSE +21 -0
- package/README.md +229 -0
- package/admin/custom/customComponents.js +2691 -0
- package/admin/data-solectrus.png +0 -0
- package/admin/i18n/de/translations.json +94 -0
- package/admin/i18n/en/translations.json +94 -0
- package/admin/jsonConfig.json +59 -0
- package/io-package.json +39 -0
- package/lib/formula.js +249 -0
- package/lib/jsonpath.js +202 -0
- package/lib/services/evaluator.js +266 -0
- package/lib/services/itemIds.js +56 -0
- package/lib/services/itemManager.js +217 -0
- package/lib/services/snapshot.js +48 -0
- package/lib/services/sourceDiscovery.js +67 -0
- package/lib/services/stateRegistry.js +462 -0
- package/lib/services/subscriptions.js +83 -0
- package/lib/services/tickRunner.js +282 -0
- package/main.js +253 -0
- package/package.json +36 -0
|
Binary file
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
{
|
|
2
|
+
"Values": "Werte",
|
|
3
|
+
"Runtime": "Einstellungen",
|
|
4
|
+
"Poll interval (seconds)": "Intervall (Sekunden)",
|
|
5
|
+
"Read inputs on tick (snapshot)": "Inputs pro Tick aktiv lesen (Snapshot)",
|
|
6
|
+
"Snapshot delay (ms)": "Snapshot-Verzögerung (ms)",
|
|
7
|
+
"Enable sync diagnostics (input timestamp gap)": "Sync-Diagnose aktivieren (Input-Timestamp-Gap)",
|
|
8
|
+
"Enable sync diagnostics (input timestamp gap) (help)": "Wenn aktiv, aktualisiert der Adapter pro Tick die info.inputTsGap*-States, um Zeitversatz zwischen Inputs sichtbar zu machen.",
|
|
9
|
+
"Configured values": "Konfigurierte Werte",
|
|
10
|
+
"Add": "Hinzufügen",
|
|
11
|
+
"Duplicate": "Duplizieren",
|
|
12
|
+
"Delete": "Löschen",
|
|
13
|
+
"Up": "Hoch",
|
|
14
|
+
"Down": "Runter",
|
|
15
|
+
"Enabled": "Aktiviert",
|
|
16
|
+
"Name": "Name",
|
|
17
|
+
"Target ID": "Ziel-ID",
|
|
18
|
+
"Mode": "Modus",
|
|
19
|
+
"Formula": "Formel",
|
|
20
|
+
"Source": "Quelle",
|
|
21
|
+
"ioBroker Source State": "ioBroker Quell-State",
|
|
22
|
+
"Select": "Auswählen",
|
|
23
|
+
"Inputs": "Eingänge",
|
|
24
|
+
"Add input": "Eingang hinzufügen",
|
|
25
|
+
"Key": "Variable",
|
|
26
|
+
"Datatype": "Datentyp",
|
|
27
|
+
"Standard": "Standard",
|
|
28
|
+
"Number": "Zahl",
|
|
29
|
+
"Boolean": "Boolean",
|
|
30
|
+
"String": "String",
|
|
31
|
+
"Mixed": "Mixed",
|
|
32
|
+
"Role": "Rolle",
|
|
33
|
+
"Unit": "Einheit",
|
|
34
|
+
"Folder/Group": "Ordner/Gruppe",
|
|
35
|
+
"Clamp result": "Ergebnis begrenzen",
|
|
36
|
+
"Clamp negative to 0": "Ergebnis negativ → 0",
|
|
37
|
+
"Clamp negative to 0 (hint source)": "Nur Output (ändert den Quell-State nicht).",
|
|
38
|
+
"Clamp negative to 0 (hint formula)": "Nur Output. Für einzelne Inputs: Input neg→0.",
|
|
39
|
+
"Clamp negative to 0 (tooltip)": "Begrenzt nur das Ergebnis (Output). Wenn du nur eine Quelle/einen Input begrenzen willst, aktiviere neg→0 direkt am Input.",
|
|
40
|
+
"Clamp input negative to 0": "Input negativ auf 0",
|
|
41
|
+
"Min": "Min",
|
|
42
|
+
"Max": "Max",
|
|
43
|
+
"Formula expression": "Formel-Ausdruck",
|
|
44
|
+
"Builder…": "Builder…",
|
|
45
|
+
"Formula Builder": "Formel-Builder",
|
|
46
|
+
"Insert building blocks on the left. The editor uses current (unsaved) inputs.": "Bausteine links einfügen. Der Editor nutzt die aktuellen (auch ungespeicherten) Inputs.",
|
|
47
|
+
"Variables (Inputs)": "Variablen (Inputs)",
|
|
48
|
+
"No inputs configured yet.": "Noch keine Inputs konfiguriert.",
|
|
49
|
+
"Operators": "Operatoren",
|
|
50
|
+
"Functions": "Funktionen",
|
|
51
|
+
"State functions": "State-Funktionen",
|
|
52
|
+
"Live values": "Live-Werte",
|
|
53
|
+
"Refresh": "Aktualisieren",
|
|
54
|
+
"Loading…": "Lade…",
|
|
55
|
+
"Result": "Ergebnis",
|
|
56
|
+
"Refresh preview": "Vorschau aktualisieren",
|
|
57
|
+
"Preview not available": "Vorschau nicht verfügbar",
|
|
58
|
+
"Preview timeout": "Vorschau-Timeout",
|
|
59
|
+
"Adapter not running": "Adapter läuft nicht",
|
|
60
|
+
"Preview not supported for state functions": "Vorschau für State-Funktionen nicht verfügbar",
|
|
61
|
+
"Unterminated string": "String nicht geschlossen",
|
|
62
|
+
"Invalid number": "Ungültige Zahl",
|
|
63
|
+
"Unexpected character": "Unerwartetes Zeichen",
|
|
64
|
+
"Expected": "Erwartet",
|
|
65
|
+
"Unexpected end": "Unerwartetes Ende",
|
|
66
|
+
"Unknown function": "Unbekannte Funktion",
|
|
67
|
+
"Unsupported operator": "Nicht unterstützter Operator",
|
|
68
|
+
"Unexpected token": "Unerwartetes Token",
|
|
69
|
+
"n/a": "n/v",
|
|
70
|
+
"Pick a state id and insert s(\"id\")": "State-ID wählen und s(\"id\") einfügen",
|
|
71
|
+
"Pick a state id and insert v(\"id\")": "State-ID wählen und v(\"id\") einfügen",
|
|
72
|
+
"Pick a state id and insert jp(\"id\", \"$.value\")": "State-ID wählen und jp(\"id\", \"$.value\") einfügen",
|
|
73
|
+
"Insert s()": "s() einfügen",
|
|
74
|
+
"Insert v()": "v() einfügen",
|
|
75
|
+
"Insert jp()": "jp() einfügen",
|
|
76
|
+
"Tip: You can still edit the formula as plain text anytime.": "Tipp: Du kannst die Formel jederzeit auch direkt als Text bearbeiten.",
|
|
77
|
+
"Cancel": "Abbrechen",
|
|
78
|
+
"Apply": "Übernehmen",
|
|
79
|
+
"Close": "Schließen",
|
|
80
|
+
"min": "min",
|
|
81
|
+
"max": "max",
|
|
82
|
+
"clamp": "clamp",
|
|
83
|
+
"IF": "IF",
|
|
84
|
+
"JSONPath (optional)": "JSONPath (optional)",
|
|
85
|
+
"e.g. $.apower": "z.B. $.apower",
|
|
86
|
+
"e.g. pv1 + pv2 + pv3": "z.B. pv1 + pv2 + pv3",
|
|
87
|
+
"e.g. some.adapter.0.channel.state": "z.B. irgendein.adapter.0.kanal.state",
|
|
88
|
+
"Selection dialog not available": "Auswahldialog nicht verfügbar",
|
|
89
|
+
"Select from existing states": "Aus vorhandenen States wählen",
|
|
90
|
+
"No items configured.": "Keine Werte konfiguriert.",
|
|
91
|
+
"Select an item on the left or add a new one.": "Links einen Eintrag wählen oder einen neuen hinzufügen.",
|
|
92
|
+
"Unnamed": "Unbenannt",
|
|
93
|
+
"Item": "Eintrag"
|
|
94
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
{
|
|
2
|
+
"Values": "Values",
|
|
3
|
+
"Runtime": "Global settings",
|
|
4
|
+
"Poll interval (seconds)": "Poll interval (seconds)",
|
|
5
|
+
"Read inputs on tick (snapshot)": "Read inputs on tick (snapshot)",
|
|
6
|
+
"Snapshot delay (ms)": "Snapshot delay (ms)",
|
|
7
|
+
"Enable sync diagnostics (input timestamp gap)": "Enable sync diagnostics (input timestamp gap)",
|
|
8
|
+
"Enable sync diagnostics (input timestamp gap) (help)": "When enabled, the adapter updates info.inputTsGap* states each tick to show input timestamp skew.",
|
|
9
|
+
"Configured values": "Configured values",
|
|
10
|
+
"Add": "Add",
|
|
11
|
+
"Duplicate": "Duplicate",
|
|
12
|
+
"Delete": "Delete",
|
|
13
|
+
"Up": "Up",
|
|
14
|
+
"Down": "Down",
|
|
15
|
+
"Enabled": "Enabled",
|
|
16
|
+
"Name": "Name",
|
|
17
|
+
"Target ID": "Target ID",
|
|
18
|
+
"Mode": "Mode",
|
|
19
|
+
"Formula": "Formula",
|
|
20
|
+
"Source": "Source",
|
|
21
|
+
"ioBroker Source State": "ioBroker Source State",
|
|
22
|
+
"Select": "Select",
|
|
23
|
+
"Inputs": "Inputs",
|
|
24
|
+
"Add input": "Add input",
|
|
25
|
+
"Key": "Key",
|
|
26
|
+
"Datatype": "Datatype",
|
|
27
|
+
"Standard": "Standard",
|
|
28
|
+
"Number": "Number",
|
|
29
|
+
"Boolean": "Boolean",
|
|
30
|
+
"String": "String",
|
|
31
|
+
"Mixed": "Mixed",
|
|
32
|
+
"Role": "Role",
|
|
33
|
+
"Unit": "Unit",
|
|
34
|
+
"Folder/Group": "Folder/Group",
|
|
35
|
+
"Clamp result": "Clamp result",
|
|
36
|
+
"Clamp negative to 0": "Clamp negative result to 0",
|
|
37
|
+
"Clamp negative to 0 (hint source)": "Output only (does not change the source state).",
|
|
38
|
+
"Clamp negative to 0 (hint formula)": "Output only. For single inputs use input neg→0.",
|
|
39
|
+
"Clamp negative to 0 (tooltip)": "Clamps only the item result (output). If you want to clamp just one source/input, enable neg→0 on that input.",
|
|
40
|
+
"Min": "Min",
|
|
41
|
+
"Max": "Max",
|
|
42
|
+
"Clamp input negative to 0": "Clamp input negative to 0",
|
|
43
|
+
"Formula expression": "Formula expression",
|
|
44
|
+
"Builder…": "Builder…",
|
|
45
|
+
"Formula Builder": "Formula Builder",
|
|
46
|
+
"Insert building blocks on the left. The editor uses current (unsaved) inputs.": "Insert building blocks on the left. The editor uses current (unsaved) inputs.",
|
|
47
|
+
"Variables (Inputs)": "Variables (Inputs)",
|
|
48
|
+
"No inputs configured yet.": "No inputs configured yet.",
|
|
49
|
+
"Operators": "Operators",
|
|
50
|
+
"Functions": "Functions",
|
|
51
|
+
"State functions": "State functions",
|
|
52
|
+
"Live values": "Live values",
|
|
53
|
+
"Refresh": "Refresh",
|
|
54
|
+
"Loading…": "Loading…",
|
|
55
|
+
"Result": "Result",
|
|
56
|
+
"Refresh preview": "Refresh preview",
|
|
57
|
+
"Preview not available": "Preview not available",
|
|
58
|
+
"Preview timeout": "Preview timeout",
|
|
59
|
+
"Adapter not running": "Adapter not running",
|
|
60
|
+
"Preview not supported for state functions": "Preview not supported for state functions",
|
|
61
|
+
"Unterminated string": "Unterminated string",
|
|
62
|
+
"Invalid number": "Invalid number",
|
|
63
|
+
"Unexpected character": "Unexpected character",
|
|
64
|
+
"Expected": "Expected",
|
|
65
|
+
"Unexpected end": "Unexpected end",
|
|
66
|
+
"Unknown function": "Unknown function",
|
|
67
|
+
"Unsupported operator": "Unsupported operator",
|
|
68
|
+
"Unexpected token": "Unexpected token",
|
|
69
|
+
"n/a": "n/a",
|
|
70
|
+
"Pick a state id and insert s(\"id\")": "Pick a state id and insert s(\"id\")",
|
|
71
|
+
"Pick a state id and insert v(\"id\")": "Pick a state id and insert v(\"id\")",
|
|
72
|
+
"Pick a state id and insert jp(\"id\", \"$.value\")": "Pick a state id and insert jp(\"id\", \"$.value\")",
|
|
73
|
+
"Insert s()": "Insert s()",
|
|
74
|
+
"Insert v()": "Insert v()",
|
|
75
|
+
"Insert jp()": "Insert jp()",
|
|
76
|
+
"Tip: You can still edit the formula as plain text anytime.": "Tip: You can still edit the formula as plain text anytime.",
|
|
77
|
+
"Cancel": "Cancel",
|
|
78
|
+
"Apply": "Apply",
|
|
79
|
+
"Close": "Close",
|
|
80
|
+
"min": "min",
|
|
81
|
+
"max": "max",
|
|
82
|
+
"clamp": "clamp",
|
|
83
|
+
"IF": "IF",
|
|
84
|
+
"JSONPath (optional)": "JSONPath (optional)",
|
|
85
|
+
"e.g. $.apower": "e.g. $.apower",
|
|
86
|
+
"e.g. pv1 + pv2 + pv3": "e.g. pv1 + pv2 + pv3",
|
|
87
|
+
"e.g. some.adapter.0.channel.state": "e.g. some.adapter.0.channel.state",
|
|
88
|
+
"Selection dialog not available": "Selection dialog not available",
|
|
89
|
+
"Select from existing states": "Select from existing states",
|
|
90
|
+
"No items configured.": "No items configured.",
|
|
91
|
+
"Select an item on the left or add a new one.": "Select an item on the left or add a new one.",
|
|
92
|
+
"Unnamed": "Unnamed",
|
|
93
|
+
"Item": "Item"
|
|
94
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"i18n": true,
|
|
3
|
+
"type": "tabs",
|
|
4
|
+
"items": {
|
|
5
|
+
"values": {
|
|
6
|
+
"type": "panel",
|
|
7
|
+
"label": "Values",
|
|
8
|
+
"items": {
|
|
9
|
+
"hint": {
|
|
10
|
+
"type": "staticText",
|
|
11
|
+
"text": "Configure values via the Master/Detail editor.",
|
|
12
|
+
"sm": 12
|
|
13
|
+
},
|
|
14
|
+
"items": {
|
|
15
|
+
"type": "custom",
|
|
16
|
+
"url": "custom/customComponents.js",
|
|
17
|
+
"name": "DataSolectrusItems/Components/DataSolectrusItemsEditor",
|
|
18
|
+
"label": "Configured values",
|
|
19
|
+
"sm": 12
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"runtime": {
|
|
24
|
+
"type": "panel",
|
|
25
|
+
"label": "Runtime",
|
|
26
|
+
"items": {
|
|
27
|
+
"pollIntervalSeconds": {
|
|
28
|
+
"type": "number",
|
|
29
|
+
"label": "Poll interval (seconds)",
|
|
30
|
+
"min": 1,
|
|
31
|
+
"max": 3600,
|
|
32
|
+
"default": 5,
|
|
33
|
+
"sm": 6
|
|
34
|
+
},
|
|
35
|
+
"snapshotInputs": {
|
|
36
|
+
"type": "checkbox",
|
|
37
|
+
"label": "Read inputs on tick (snapshot)",
|
|
38
|
+
"default": false,
|
|
39
|
+
"sm": 12
|
|
40
|
+
},
|
|
41
|
+
"snapshotDelayMs": {
|
|
42
|
+
"type": "number",
|
|
43
|
+
"label": "Snapshot delay (ms)",
|
|
44
|
+
"min": 0,
|
|
45
|
+
"max": 5000,
|
|
46
|
+
"default": 0,
|
|
47
|
+
"sm": 6
|
|
48
|
+
},
|
|
49
|
+
"enableInputTsGapDiagnostics": {
|
|
50
|
+
"type": "checkbox",
|
|
51
|
+
"label": "Enable sync diagnostics (input timestamp gap)",
|
|
52
|
+
"default": true,
|
|
53
|
+
"help": "Enable sync diagnostics (input timestamp gap) (help)",
|
|
54
|
+
"sm": 12
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
package/io-package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"common": {
|
|
3
|
+
"name": "data-solectrus",
|
|
4
|
+
"version": "0.2.10",
|
|
5
|
+
"title": "Data-SOLECTRUS",
|
|
6
|
+
"icon": "data-solectrus.png",
|
|
7
|
+
"extIcon": "https://raw.githubusercontent.com/Felliglanz/data-solectrus/main/admin/data-solectrus.png",
|
|
8
|
+
"titleLang": {
|
|
9
|
+
"en": "Data-SOLECTRUS",
|
|
10
|
+
"de": "Data-SOLECTRUS"
|
|
11
|
+
},
|
|
12
|
+
"desc": {
|
|
13
|
+
"en": "Compute and mirror ioBroker states into adapter-owned states using formulas.",
|
|
14
|
+
"de": "Berechnet und spiegelt ioBroker-Datenpunkte als adapter-eigene States mittels Formeln."
|
|
15
|
+
},
|
|
16
|
+
"platform": "Javascript/Node.js",
|
|
17
|
+
"mode": "daemon",
|
|
18
|
+
"compact": true,
|
|
19
|
+
"messagebox": true,
|
|
20
|
+
"enabled": true,
|
|
21
|
+
"adminUI": {
|
|
22
|
+
"config": "json"
|
|
23
|
+
},
|
|
24
|
+
"type": "energy",
|
|
25
|
+
"connectionType": "none",
|
|
26
|
+
"dataSource": "poll",
|
|
27
|
+
"authors": [
|
|
28
|
+
"Sven"
|
|
29
|
+
],
|
|
30
|
+
"license": "MIT"
|
|
31
|
+
},
|
|
32
|
+
"native": {
|
|
33
|
+
"pollIntervalSeconds": 5,
|
|
34
|
+
"enableInputTsGapDiagnostics": true,
|
|
35
|
+
"items": []
|
|
36
|
+
},
|
|
37
|
+
"objects": [],
|
|
38
|
+
"instanceObjects": []
|
|
39
|
+
}
|
package/lib/formula.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const jsep = require('jsep');
|
|
4
|
+
|
|
5
|
+
function parseExpression(expr) {
|
|
6
|
+
return jsep(String(expr));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Normalizes some common non-JS formula syntax into the JS-like operators that `jsep` understands.
|
|
11
|
+
* - AND/OR/NOT (case-insensitive) -> && / || / !
|
|
12
|
+
* - single '=' (outside strings) -> '=='
|
|
13
|
+
*
|
|
14
|
+
* This is intentionally conservative and only runs outside quoted strings.
|
|
15
|
+
*/
|
|
16
|
+
function normalizeFormulaExpression(expr) {
|
|
17
|
+
let s = String(expr);
|
|
18
|
+
if (!s) return s;
|
|
19
|
+
|
|
20
|
+
let out = '';
|
|
21
|
+
let inSingle = false;
|
|
22
|
+
let inDouble = false;
|
|
23
|
+
let escaped = false;
|
|
24
|
+
|
|
25
|
+
const isWordChar = c => /[A-Za-z0-9_]/.test(c);
|
|
26
|
+
const at = i => (i >= 0 && i < s.length ? s[i] : '');
|
|
27
|
+
const matchWordAt = (i, word) => {
|
|
28
|
+
// assumes already outside quotes
|
|
29
|
+
const w = String(word);
|
|
30
|
+
if (s.substr(i, w.length).toUpperCase() !== w.toUpperCase()) return false;
|
|
31
|
+
const prev = at(i - 1);
|
|
32
|
+
const next = at(i + w.length);
|
|
33
|
+
if (prev && isWordChar(prev)) return false;
|
|
34
|
+
if (next && isWordChar(next)) return false;
|
|
35
|
+
return true;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
for (let i = 0; i < s.length; i++) {
|
|
39
|
+
const ch = s[i];
|
|
40
|
+
|
|
41
|
+
if (escaped) {
|
|
42
|
+
out += ch;
|
|
43
|
+
escaped = false;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (ch === '\\') {
|
|
47
|
+
out += ch;
|
|
48
|
+
escaped = true;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!inDouble && ch === "'") {
|
|
53
|
+
inSingle = !inSingle;
|
|
54
|
+
out += ch;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (!inSingle && ch === '"') {
|
|
58
|
+
inDouble = !inDouble;
|
|
59
|
+
out += ch;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (inSingle || inDouble) {
|
|
64
|
+
out += ch;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// AND/OR/NOT keywords
|
|
69
|
+
if (matchWordAt(i, 'AND')) {
|
|
70
|
+
out += '&&';
|
|
71
|
+
i += 2;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (matchWordAt(i, 'OR')) {
|
|
75
|
+
out += '||';
|
|
76
|
+
i += 1;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (matchWordAt(i, 'NOT')) {
|
|
80
|
+
out += '!';
|
|
81
|
+
i += 2;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// single '=' -> '==' (but keep ==, ===, !=, <=, >=)
|
|
86
|
+
if (ch === '=') {
|
|
87
|
+
const prev = at(i - 1);
|
|
88
|
+
const next = at(i + 1);
|
|
89
|
+
const prevIsGuard = prev === '=' || prev === '!' || prev === '<' || prev === '>';
|
|
90
|
+
if (!prevIsGuard && next !== '=') {
|
|
91
|
+
out += '==';
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
out += ch;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function analyzeAst(ast, options) {
|
|
103
|
+
const maxNodes = options && Number.isFinite(options.maxNodes) ? options.maxNodes : 2000;
|
|
104
|
+
const maxDepth = options && Number.isFinite(options.maxDepth) ? options.maxDepth : 60;
|
|
105
|
+
let nodes = 0;
|
|
106
|
+
let depthMax = 0;
|
|
107
|
+
/** @type {{node:any, depth:number}[]} */
|
|
108
|
+
const stack = [{ node: ast, depth: 1 }];
|
|
109
|
+
while (stack.length) {
|
|
110
|
+
const entry = stack.pop();
|
|
111
|
+
const node = entry && entry.node;
|
|
112
|
+
const depth = entry && entry.depth ? entry.depth : 1;
|
|
113
|
+
if (!node || typeof node !== 'object') continue;
|
|
114
|
+
nodes++;
|
|
115
|
+
if (depth > depthMax) depthMax = depth;
|
|
116
|
+
if (nodes > maxNodes) {
|
|
117
|
+
throw new Error(`Expression too complex (>${maxNodes} nodes)`);
|
|
118
|
+
}
|
|
119
|
+
if (depthMax > maxDepth) {
|
|
120
|
+
throw new Error(`Expression too deeply nested (>${maxDepth})`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
switch (node.type) {
|
|
124
|
+
case 'BinaryExpression':
|
|
125
|
+
case 'LogicalExpression':
|
|
126
|
+
stack.push({ node: node.right, depth: depth + 1 });
|
|
127
|
+
stack.push({ node: node.left, depth: depth + 1 });
|
|
128
|
+
break;
|
|
129
|
+
case 'UnaryExpression':
|
|
130
|
+
stack.push({ node: node.argument, depth: depth + 1 });
|
|
131
|
+
break;
|
|
132
|
+
case 'ConditionalExpression':
|
|
133
|
+
stack.push({ node: node.alternate, depth: depth + 1 });
|
|
134
|
+
stack.push({ node: node.consequent, depth: depth + 1 });
|
|
135
|
+
stack.push({ node: node.test, depth: depth + 1 });
|
|
136
|
+
break;
|
|
137
|
+
case 'CallExpression': {
|
|
138
|
+
const args = Array.isArray(node.arguments) ? node.arguments : [];
|
|
139
|
+
for (let i = args.length - 1; i >= 0; i--) {
|
|
140
|
+
stack.push({ node: args[i], depth: depth + 1 });
|
|
141
|
+
}
|
|
142
|
+
// callee is an Identifier in allowed expressions; no need to traverse.
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
default:
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return { nodes, depth: depthMax };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function evalFormulaAst(ast, vars, funcs) {
|
|
153
|
+
const functions = funcs || {};
|
|
154
|
+
const variables = vars || Object.create(null);
|
|
155
|
+
|
|
156
|
+
const evalNode = node => {
|
|
157
|
+
if (!node || typeof node !== 'object') {
|
|
158
|
+
throw new Error('Invalid expression');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
switch (node.type) {
|
|
162
|
+
case 'Literal':
|
|
163
|
+
return node.value;
|
|
164
|
+
case 'Identifier':
|
|
165
|
+
return Object.prototype.hasOwnProperty.call(variables, node.name) ? variables[node.name] : 0;
|
|
166
|
+
case 'UnaryExpression': {
|
|
167
|
+
const arg = evalNode(node.argument);
|
|
168
|
+
switch (node.operator) {
|
|
169
|
+
case '+':
|
|
170
|
+
return Number(arg);
|
|
171
|
+
case '-':
|
|
172
|
+
return -Number(arg);
|
|
173
|
+
case '!':
|
|
174
|
+
return !arg;
|
|
175
|
+
default:
|
|
176
|
+
throw new Error(`Operator not allowed: ${node.operator}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
case 'BinaryExpression':
|
|
180
|
+
case 'LogicalExpression': {
|
|
181
|
+
const left = evalNode(node.left);
|
|
182
|
+
const right = evalNode(node.right);
|
|
183
|
+
switch (node.operator) {
|
|
184
|
+
case '+':
|
|
185
|
+
return Number(left) + Number(right);
|
|
186
|
+
case '-':
|
|
187
|
+
return Number(left) - Number(right);
|
|
188
|
+
case '*':
|
|
189
|
+
return Number(left) * Number(right);
|
|
190
|
+
case '/':
|
|
191
|
+
return Number(left) / Number(right);
|
|
192
|
+
case '%':
|
|
193
|
+
return Number(left) % Number(right);
|
|
194
|
+
case '&&':
|
|
195
|
+
return left && right;
|
|
196
|
+
case '||':
|
|
197
|
+
return left || right;
|
|
198
|
+
case '==':
|
|
199
|
+
// loose equality intentionally supported for compatibility with other formula engines
|
|
200
|
+
return left == right;
|
|
201
|
+
case '!=':
|
|
202
|
+
return left != right;
|
|
203
|
+
case '===':
|
|
204
|
+
return left === right;
|
|
205
|
+
case '!==':
|
|
206
|
+
return left !== right;
|
|
207
|
+
case '<':
|
|
208
|
+
return Number(left) < Number(right);
|
|
209
|
+
case '<=':
|
|
210
|
+
return Number(left) <= Number(right);
|
|
211
|
+
case '>':
|
|
212
|
+
return Number(left) > Number(right);
|
|
213
|
+
case '>=':
|
|
214
|
+
return Number(left) >= Number(right);
|
|
215
|
+
default:
|
|
216
|
+
throw new Error(`Operator not allowed: ${node.operator}`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
case 'ConditionalExpression': {
|
|
220
|
+
const test = evalNode(node.test);
|
|
221
|
+
return test ? evalNode(node.consequent) : evalNode(node.alternate);
|
|
222
|
+
}
|
|
223
|
+
case 'CallExpression': {
|
|
224
|
+
if (!node.callee || node.callee.type !== 'Identifier') {
|
|
225
|
+
throw new Error('Only simple function calls are allowed');
|
|
226
|
+
}
|
|
227
|
+
const fnName = node.callee.name;
|
|
228
|
+
const fn = functions[fnName];
|
|
229
|
+
if (typeof fn !== 'function') {
|
|
230
|
+
throw new Error(`Function not allowed: ${fnName}`);
|
|
231
|
+
}
|
|
232
|
+
const args = Array.isArray(node.arguments) ? node.arguments.map(evalNode) : [];
|
|
233
|
+
return fn.apply(null, args);
|
|
234
|
+
}
|
|
235
|
+
default:
|
|
236
|
+
// Blocks MemberExpression, ThisExpression, NewExpression, etc.
|
|
237
|
+
throw new Error(`Expression type not allowed: ${node.type}`);
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
return evalNode(ast);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
module.exports = {
|
|
245
|
+
parseExpression,
|
|
246
|
+
normalizeFormulaExpression,
|
|
247
|
+
analyzeAst,
|
|
248
|
+
evalFormulaAst,
|
|
249
|
+
};
|