@halleyassist/rule-templater 0.0.17 → 0.0.18

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 CHANGED
@@ -121,6 +121,8 @@ const prepared = parsed.prepare({
121
121
  - **round**: Round number to nearest integer
122
122
  - **floor**: Round number down
123
123
  - **ceil**: Round number up
124
+ - **humanise_list**: Join array values into natural language, optionally with a custom joiner like `humanise_list("or")`
125
+ - **humanise_time**: Convert seconds into a single human-readable unit like `1 hour`; optionally round down to a minimum unit such as `humanise_time("minute")` or `humanise_time("min")`
124
126
  - **time_start**: Extract `from` from `time period` / `time period ago` and convert to `time value`
125
127
  - **time_end**: Extract `to` from `time period` / `time period ago` and convert to `time value`
126
128
 
@@ -140,6 +142,14 @@ const prepared = parsed.prepare({
140
142
  // Chaining filters
141
143
  '${text|trim|upper}' with text=' hello ' → HELLO
142
144
 
145
+ // Humanise arrays
146
+ '${names|humanise_list}' with names=['a','b','c'] → a, b and c
147
+ '${names|humanise_list("or")}' with names=['a','b','c'] → a, b or c
148
+
149
+ // Humanise time from seconds
150
+ '${duration|humanise_time}' with duration=3600 → 1 hour
151
+ '${duration|humanise_time("minute")}' with duration=71 → 1 minute
152
+
143
153
  // Time period conversion
144
154
  '${window|time_start}' with window={from:'08:00',to:'12:00'} → 08:00
145
155
  ```
@@ -232,7 +242,7 @@ This is useful for comparing against a hub's list of available functions to ensu
232
242
 
233
243
  ### `ruleTemplate.validate(variables)`
234
244
 
235
- Validates that all required variables are provided and have valid types.
245
+ Validates that all required variables are provided, have valid types, and reference only known template filters.
236
246
 
237
247
  **Parameters:**
238
248
  - `variables` (object): Object mapping variable names to their values and types
@@ -243,6 +253,7 @@ Validates that all required variables are provided and have valid types.
243
253
  **Returns:** Object with:
244
254
  - `valid` (boolean): Whether validation passed
245
255
  - `errors` (array): Array of error messages (empty if valid)
256
+ - `warnings` (array): Array of non-fatal warnings (empty if none)
246
257
 
247
258
  ### `ruleTemplate.prepare(variables)`
248
259
 
@@ -272,6 +283,10 @@ Extracts variables from a general string template.
272
283
 
273
284
  Prepares a general string template by replacing `${...}` placeholders with values and applying filters.
274
285
 
286
+ ### `generalTemplate.validate()`
287
+
288
+ Validates the template itself and reports any unknown filters used in `${...}` chains.
289
+
275
290
  ### `RuleTemplate.validateVariableNode(astNode, variableType)` (Static)
276
291
 
277
292
  Helper method to validate that an AST node matches the expected variable type.
@@ -3888,11 +3888,13 @@ class RuleTemplate {
3888
3888
 
3889
3889
  // Extract filters
3890
3890
  const filters = [];
3891
+ const filterCalls = [];
3891
3892
  for (const child of templateExpr.children || []) {
3892
3893
  if (child.type === 'template_filter_call') {
3893
- const filterName = this._extractFilterName(child);
3894
- if (filterName) {
3895
- filters.push(filterName);
3894
+ const filterCall = this._extractFilterCall(child);
3895
+ if (filterCall) {
3896
+ filters.push(filterCall.name);
3897
+ filterCalls.push(filterCall);
3896
3898
  }
3897
3899
  }
3898
3900
  }
@@ -3901,18 +3903,84 @@ class RuleTemplate {
3901
3903
  const start = node.start;
3902
3904
  const end = node.end;
3903
3905
 
3904
- return { name, filters, start, end };
3906
+ return { name, filters, filterCalls, start, end };
3905
3907
  }
3906
3908
 
3907
3909
  /**
3908
- * Extract filter name from template_filter_call node
3910
+ * Extract filter call from template_filter_call node
3909
3911
  * @private
3910
3912
  */
3911
- _extractFilterName(node) {
3913
+ _extractFilterCall(node) {
3912
3914
  const filterNameNode = node.children?.find(c => c.type === 'template_filter_name');
3913
3915
  if (!filterNameNode || !filterNameNode.text) return null;
3914
-
3915
- return filterNameNode.text.trim();
3916
+
3917
+ const argsNode = node.children?.find(c => c.type === 'template_filter_args');
3918
+
3919
+ return {
3920
+ name: filterNameNode.text.trim(),
3921
+ args: this._extractFilterArgs(argsNode)
3922
+ };
3923
+ }
3924
+
3925
+ _extractFilterArgs(node) {
3926
+ if (!node || !Array.isArray(node.children)) {
3927
+ return [];
3928
+ }
3929
+
3930
+ return node.children
3931
+ .filter(child => child.type === 'template_filter_arg')
3932
+ .map(child => this._extractFilterArgValue(child));
3933
+ }
3934
+
3935
+ _extractFilterArgValue(node) {
3936
+ if (!node || !Array.isArray(node.children) || node.children.length === 0) {
3937
+ return this._normalizeFilterArgText(node?.text?.trim() || '');
3938
+ }
3939
+
3940
+ const child = node.children[0];
3941
+ if (!child) {
3942
+ return this._normalizeFilterArgText(node.text?.trim() || '');
3943
+ }
3944
+
3945
+ if (child.type === 'value' && Array.isArray(child.children) && child.children.length > 0) {
3946
+ return this._extractFilterArgValue(child);
3947
+ }
3948
+
3949
+ if (child.type === 'string') {
3950
+ try {
3951
+ return JSON.parse(child.text);
3952
+ } catch (error) {
3953
+ return this._normalizeFilterArgText(child.text);
3954
+ }
3955
+ }
3956
+
3957
+ if (child.type === 'number') {
3958
+ return Number(child.text);
3959
+ }
3960
+
3961
+ if (child.type === 'true') {
3962
+ return true;
3963
+ }
3964
+
3965
+ if (child.type === 'false') {
3966
+ return false;
3967
+ }
3968
+
3969
+ if (child.type === 'null') {
3970
+ return null;
3971
+ }
3972
+
3973
+ return this._normalizeFilterArgText(child.text?.trim() || node.text?.trim() || '');
3974
+ }
3975
+
3976
+ _normalizeFilterArgText(text) {
3977
+ const normalizedText = String(text).trim();
3978
+
3979
+ if ((normalizedText.startsWith('"') && normalizedText.endsWith('"')) || (normalizedText.startsWith("'") && normalizedText.endsWith("'"))) {
3980
+ return normalizedText.slice(1, -1);
3981
+ }
3982
+
3983
+ return normalizedText;
3916
3984
  }
3917
3985
 
3918
3986
  /**
@@ -3932,10 +4000,31 @@ class RuleTemplate {
3932
4000
 
3933
4001
  const errors = [];
3934
4002
  const warnings = [];
3935
- const extractedVars = this.extractVariables();
4003
+ const extractedVars = this._extractTemplateVariables();
4004
+ const seenVariables = new Set();
4005
+ const seenFilterErrors = new Set();
3936
4006
 
3937
4007
  for (const varInfo of extractedVars) {
3938
4008
  const varName = varInfo.name;
4009
+
4010
+ for (const filter of (varInfo.filterCalls || varInfo.filters || [])) {
4011
+ const filterName = typeof filter === 'string' ? filter : filter?.name;
4012
+ if (filterName && TemplateFilters[filterName]) {
4013
+ continue;
4014
+ }
4015
+
4016
+ const errorMessage = `Unknown filter '${filterName || filter}' for variable '${varName}'`;
4017
+ if (!seenFilterErrors.has(errorMessage)) {
4018
+ errors.push(errorMessage);
4019
+ seenFilterErrors.add(errorMessage);
4020
+ }
4021
+ }
4022
+
4023
+ if (seenVariables.has(varName)) {
4024
+ continue;
4025
+ }
4026
+
4027
+ seenVariables.add(varName);
3939
4028
 
3940
4029
  // Check if variable is provided
3941
4030
  if (!variables.hasOwnProperty(varName)) {
@@ -4008,6 +4097,30 @@ class RuleTemplate {
4008
4097
  return functionCalls;
4009
4098
  }
4010
4099
 
4100
+ _extractTemplateVariables() {
4101
+ const variables = [];
4102
+
4103
+ const traverse = (node) => {
4104
+ if (!node) return;
4105
+
4106
+ if (node.type === 'template_value') {
4107
+ const variableInfo = this._extractVariableFromNode(node);
4108
+ if (variableInfo) {
4109
+ variables.push(variableInfo);
4110
+ }
4111
+ }
4112
+
4113
+ if (node.children) {
4114
+ for (const child of node.children) {
4115
+ traverse(child);
4116
+ }
4117
+ }
4118
+ };
4119
+
4120
+ traverse(this.ast);
4121
+ return variables;
4122
+ }
4123
+
4011
4124
  /**
4012
4125
  * Prepare the template by replacing variables with their values
4013
4126
  * Rebuilds from AST by iterating through children
@@ -4110,12 +4223,15 @@ class RuleTemplate {
4110
4223
 
4111
4224
  // Apply filters if present
4112
4225
  if (templateInfo.filters && templateInfo.filters.length > 0) {
4113
- for (const filterName of templateInfo.filters) {
4114
- if (!TemplateFilters[filterName]) {
4115
- throw new Error(`Unknown filter '${filterName}'`);
4226
+ for (const filter of (templateInfo.filterCalls || templateInfo.filters)) {
4227
+ const filterName = typeof filter === 'string' ? filter : filter?.name;
4228
+ const filterArgs = typeof filter === 'string' ? [] : (Array.isArray(filter?.args) ? filter.args : []);
4229
+
4230
+ if (!filterName || !TemplateFilters[filterName]) {
4231
+ throw new Error(`Unknown filter '${filterName || filter}'`);
4116
4232
  }
4117
-
4118
- TemplateFilters[filterName](varData);
4233
+
4234
+ TemplateFilters[filterName](varData, ...filterArgs);
4119
4235
  }
4120
4236
  }
4121
4237
 
@@ -4193,6 +4309,31 @@ module.exports = RuleTemplate;
4193
4309
  Template filters are functions that transform variable values.
4194
4310
  They are applied in the template syntax as ${variable|filter} or ${variable|filter1|filter2}
4195
4311
  */
4312
+ const HUMANISE_TIME_UNITS = [
4313
+ { name: 'year', seconds: 31536000, aliases: ['year', 'years', 'yr', 'yrs', 'y'] },
4314
+ { name: 'month', seconds: 2592000, aliases: ['month', 'months', 'mo', 'mos'] },
4315
+ { name: 'week', seconds: 604800, aliases: ['week', 'weeks', 'wk', 'wks', 'w'] },
4316
+ { name: 'day', seconds: 86400, aliases: ['day', 'days', 'd'] },
4317
+ { name: 'hour', seconds: 3600, aliases: ['hour', 'hours', 'hr', 'hrs', 'h'] },
4318
+ { name: 'minute', seconds: 60, aliases: ['minute', 'minutes', 'min', 'mins'] },
4319
+ { name: 'second', seconds: 1, aliases: ['second', 'seconds', 'sec', 'secs', 's'] }
4320
+ ];
4321
+
4322
+ const getHumaniseTimeUnit = minUnit => {
4323
+ if (minUnit === null || minUnit === undefined || minUnit === '') {
4324
+ return null;
4325
+ }
4326
+
4327
+ const normalizedMinUnit = String(minUnit).trim().toLowerCase();
4328
+ const unit = HUMANISE_TIME_UNITS.find(candidate => candidate.aliases.includes(normalizedMinUnit));
4329
+
4330
+ if (!unit) {
4331
+ throw new Error(`Unknown humanise_time min_unit \"${minUnit}\"`);
4332
+ }
4333
+
4334
+ return unit;
4335
+ };
4336
+
4196
4337
  const TemplateFilters = {
4197
4338
  // Convert value to JSON string representation
4198
4339
  string: varData => {
@@ -4316,6 +4457,69 @@ const TemplateFilters = {
4316
4457
 
4317
4458
  },
4318
4459
 
4460
+ humanise_list: (varData, joiner = 'and') => {
4461
+ if (typeof varData.value === 'string') {
4462
+ varData.type = 'string';
4463
+ return;
4464
+ }
4465
+
4466
+ if (!Array.isArray(varData.value)) {
4467
+ varData.value = String(varData.value);
4468
+ varData.type = 'string';
4469
+ return;
4470
+ }
4471
+
4472
+ const items = varData.value.map(item => String(item));
4473
+
4474
+ if (items.length === 0) {
4475
+ varData.value = '';
4476
+ } else if (items.length === 1) {
4477
+ [varData.value] = items;
4478
+ } else if (items.length === 2) {
4479
+ varData.value = `${items[0]} ${joiner} ${items[1]}`;
4480
+ } else {
4481
+ varData.value = `${items.slice(0, -1).join(', ')} ${joiner} ${items[items.length - 1]}`;
4482
+ }
4483
+
4484
+ varData.type = 'string';
4485
+
4486
+ },
4487
+
4488
+ humanise_time: (varData, minUnit = null) => {
4489
+ const rawSeconds = Number(varData.value);
4490
+
4491
+ if (isNaN(rawSeconds)) {
4492
+ throw new Error(`Value "${varData.value}" cannot be converted to seconds`);
4493
+ }
4494
+
4495
+ const isNegative = rawSeconds < 0;
4496
+ const absoluteSeconds = Math.abs(rawSeconds);
4497
+ const minimumUnit = getHumaniseTimeUnit(minUnit);
4498
+ const minimumUnitIndex = minimumUnit
4499
+ ? HUMANISE_TIME_UNITS.findIndex(unit => unit.name === minimumUnit.name)
4500
+ : HUMANISE_TIME_UNITS.length - 1;
4501
+ const candidateUnits = HUMANISE_TIME_UNITS.slice(0, minimumUnitIndex + 1);
4502
+ let selectedUnit = candidateUnits.find(unit => absoluteSeconds % unit.seconds === 0);
4503
+ let quantity;
4504
+
4505
+ if (selectedUnit) {
4506
+ quantity = absoluteSeconds / selectedUnit.seconds;
4507
+ } else if (minimumUnit) {
4508
+ selectedUnit = minimumUnit;
4509
+ quantity = Math.floor(absoluteSeconds / selectedUnit.seconds);
4510
+ } else {
4511
+ selectedUnit = HUMANISE_TIME_UNITS[HUMANISE_TIME_UNITS.length - 1];
4512
+ quantity = absoluteSeconds;
4513
+ }
4514
+
4515
+ const signedQuantity = isNegative ? -quantity : quantity;
4516
+ const label = Math.abs(signedQuantity) === 1 ? selectedUnit.name : `${selectedUnit.name}s`;
4517
+
4518
+ varData.value = `${signedQuantity} ${label}`;
4519
+ varData.type = 'string';
4520
+
4521
+ },
4522
+
4319
4523
  // Extract start time from time period/time period ago as time value
4320
4524
  time_start: varData => {
4321
4525
  if (varData.type === 'time period' || varData.type === 'time period ago') {
@@ -4490,12 +4694,15 @@ class VariableValidate {
4490
4694
  throw new Error('Variable data filters must be an array');
4491
4695
  }
4492
4696
 
4493
- for (const filterName of normalizedVarData.filters) {
4494
- if (!TemplateFilters[filterName]) {
4495
- throw new Error(`Unknown filter '${filterName}'`);
4697
+ for (const filter of normalizedVarData.filters) {
4698
+ const filterName = typeof filter === 'string' ? filter : filter?.name;
4699
+ const filterArgs = typeof filter === 'string' ? [] : (Array.isArray(filter?.args) ? filter.args : []);
4700
+
4701
+ if (!filterName || !TemplateFilters[filterName]) {
4702
+ throw new Error(`Unknown filter '${filterName || filter}'`);
4496
4703
  }
4497
4704
 
4498
- TemplateFilters[filterName](normalizedVarData);
4705
+ TemplateFilters[filterName](normalizedVarData, ...filterArgs);
4499
4706
  }
4500
4707
 
4501
4708
  return normalizedVarData;
package/index.d.ts CHANGED
@@ -3,9 +3,14 @@ export interface VariablePosition {
3
3
  end: number;
4
4
  }
5
5
 
6
+ export interface TemplateFilterCall {
7
+ name: string;
8
+ args: any[];
9
+ }
10
+
6
11
  export interface VariableInfo {
7
12
  name: string;
8
- filters: string[];
13
+ filters: Array<string | TemplateFilterCall>;
9
14
  positions: VariablePosition[];
10
15
  }
11
16
 
@@ -134,6 +139,8 @@ export class GeneralTemplate {
134
139
 
135
140
  extractVariables(): VariableInfo[];
136
141
 
142
+ validate(): ValidationResult;
143
+
137
144
  prepare(variables: Variables): string;
138
145
  }
139
146
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@halleyassist/rule-templater",
3
- "version": "0.0.17",
3
+ "version": "0.0.18",
4
4
  "description": "The grammar for HalleyAssist rules",
5
5
  "main": "src/RuleTemplate.production.js",
6
6
  "browser": "./dist/rule-templater.browser.js",
@@ -1,5 +1,7 @@
1
1
  const TemplateFilters = require('./TemplateFilters');
2
2
 
3
+ const FILTER_PATTERN = /([A-Za-z_][A-Za-z0-9_]*)(?:\((.*)\))?$/;
4
+
3
5
  class GeneralTemplate {
4
6
  constructor(templateText) {
5
7
  this.templateText = templateText;
@@ -52,6 +54,33 @@ class GeneralTemplate {
52
54
  return this.getVariables();
53
55
  }
54
56
 
57
+ validate() {
58
+ const errors = [];
59
+ const warnings = [];
60
+ const seenFilterErrors = new Set();
61
+
62
+ for (const variableInfo of this.getVariables()) {
63
+ for (const filter of variableInfo.filters || []) {
64
+ const filterName = typeof filter === 'string' ? filter : filter?.name;
65
+ if (filterName && TemplateFilters[filterName]) {
66
+ continue;
67
+ }
68
+
69
+ const errorMessage = `Unknown filter '${filterName || filter}' for variable '${variableInfo.name}'`;
70
+ if (!seenFilterErrors.has(errorMessage)) {
71
+ errors.push(errorMessage);
72
+ seenFilterErrors.add(errorMessage);
73
+ }
74
+ }
75
+ }
76
+
77
+ return {
78
+ valid: errors.length === 0,
79
+ errors,
80
+ warnings
81
+ };
82
+ }
83
+
55
84
  prepare(variables) {
56
85
  if (!variables || typeof variables !== 'object') {
57
86
  throw new Error('Variables must be provided as an object');
@@ -76,12 +105,15 @@ class GeneralTemplate {
76
105
  varData = Object.assign({}, varData);
77
106
 
78
107
  if (parsedExpression.filters && parsedExpression.filters.length > 0) {
79
- for (const filterName of parsedExpression.filters) {
80
- if (!TemplateFilters[filterName]) {
81
- throw new Error(`Unknown filter '${filterName}'`);
108
+ for (const filter of parsedExpression.filters) {
109
+ const filterName = typeof filter === 'string' ? filter : filter?.name;
110
+ const filterArgs = typeof filter === 'string' ? [] : (Array.isArray(filter?.args) ? filter.args : []);
111
+
112
+ if (!filterName || !TemplateFilters[filterName]) {
113
+ throw new Error(`Unknown filter '${filterName || filter}'`);
82
114
  }
83
115
 
84
- TemplateFilters[filterName](varData);
116
+ TemplateFilters[filterName](varData, ...filterArgs);
85
117
  }
86
118
  }
87
119
 
@@ -101,10 +133,58 @@ class GeneralTemplate {
101
133
 
102
134
  return {
103
135
  name: segments[0],
104
- filters: segments.slice(1)
136
+ filters: segments.slice(1).map(segment => this._parseFilter(segment))
137
+ };
138
+ }
139
+
140
+ _parseFilter(segment) {
141
+ const match = segment.match(FILTER_PATTERN);
142
+ if (!match) {
143
+ return {
144
+ name: segment,
145
+ args: []
146
+ };
147
+ }
148
+
149
+ const [, name, rawArgs] = match;
150
+ return {
151
+ name,
152
+ args: this._parseFilterArgs(rawArgs)
105
153
  };
106
154
  }
107
155
 
156
+ _parseFilterArgs(rawArgs) {
157
+ if (!rawArgs || !rawArgs.trim()) {
158
+ return [];
159
+ }
160
+
161
+ return rawArgs.split(',').map(arg => this._parseFilterArgValue(arg.trim()));
162
+ }
163
+
164
+ _parseFilterArgValue(rawArg) {
165
+ if ((rawArg.startsWith('"') && rawArg.endsWith('"')) || (rawArg.startsWith("'") && rawArg.endsWith("'"))) {
166
+ return rawArg.slice(1, -1);
167
+ }
168
+
169
+ if (rawArg === 'true') {
170
+ return true;
171
+ }
172
+
173
+ if (rawArg === 'false') {
174
+ return false;
175
+ }
176
+
177
+ if (rawArg === 'null') {
178
+ return null;
179
+ }
180
+
181
+ if (rawArg !== '' && !Number.isNaN(Number(rawArg))) {
182
+ return Number(rawArg);
183
+ }
184
+
185
+ return rawArg;
186
+ }
187
+
108
188
  _serializeVariable(varData) {
109
189
  if (varData.value === null || varData.value === undefined) {
110
190
  return '';
@@ -185,11 +185,13 @@ class RuleTemplate {
185
185
 
186
186
  // Extract filters
187
187
  const filters = [];
188
+ const filterCalls = [];
188
189
  for (const child of templateExpr.children || []) {
189
190
  if (child.type === 'template_filter_call') {
190
- const filterName = this._extractFilterName(child);
191
- if (filterName) {
192
- filters.push(filterName);
191
+ const filterCall = this._extractFilterCall(child);
192
+ if (filterCall) {
193
+ filters.push(filterCall.name);
194
+ filterCalls.push(filterCall);
193
195
  }
194
196
  }
195
197
  }
@@ -198,18 +200,84 @@ class RuleTemplate {
198
200
  const start = node.start;
199
201
  const end = node.end;
200
202
 
201
- return { name, filters, start, end };
203
+ return { name, filters, filterCalls, start, end };
202
204
  }
203
205
 
204
206
  /**
205
- * Extract filter name from template_filter_call node
207
+ * Extract filter call from template_filter_call node
206
208
  * @private
207
209
  */
208
- _extractFilterName(node) {
210
+ _extractFilterCall(node) {
209
211
  const filterNameNode = node.children?.find(c => c.type === 'template_filter_name');
210
212
  if (!filterNameNode || !filterNameNode.text) return null;
211
-
212
- return filterNameNode.text.trim();
213
+
214
+ const argsNode = node.children?.find(c => c.type === 'template_filter_args');
215
+
216
+ return {
217
+ name: filterNameNode.text.trim(),
218
+ args: this._extractFilterArgs(argsNode)
219
+ };
220
+ }
221
+
222
+ _extractFilterArgs(node) {
223
+ if (!node || !Array.isArray(node.children)) {
224
+ return [];
225
+ }
226
+
227
+ return node.children
228
+ .filter(child => child.type === 'template_filter_arg')
229
+ .map(child => this._extractFilterArgValue(child));
230
+ }
231
+
232
+ _extractFilterArgValue(node) {
233
+ if (!node || !Array.isArray(node.children) || node.children.length === 0) {
234
+ return this._normalizeFilterArgText(node?.text?.trim() || '');
235
+ }
236
+
237
+ const child = node.children[0];
238
+ if (!child) {
239
+ return this._normalizeFilterArgText(node.text?.trim() || '');
240
+ }
241
+
242
+ if (child.type === 'value' && Array.isArray(child.children) && child.children.length > 0) {
243
+ return this._extractFilterArgValue(child);
244
+ }
245
+
246
+ if (child.type === 'string') {
247
+ try {
248
+ return JSON.parse(child.text);
249
+ } catch (error) {
250
+ return this._normalizeFilterArgText(child.text);
251
+ }
252
+ }
253
+
254
+ if (child.type === 'number') {
255
+ return Number(child.text);
256
+ }
257
+
258
+ if (child.type === 'true') {
259
+ return true;
260
+ }
261
+
262
+ if (child.type === 'false') {
263
+ return false;
264
+ }
265
+
266
+ if (child.type === 'null') {
267
+ return null;
268
+ }
269
+
270
+ return this._normalizeFilterArgText(child.text?.trim() || node.text?.trim() || '');
271
+ }
272
+
273
+ _normalizeFilterArgText(text) {
274
+ const normalizedText = String(text).trim();
275
+
276
+ if ((normalizedText.startsWith('"') && normalizedText.endsWith('"')) || (normalizedText.startsWith("'") && normalizedText.endsWith("'"))) {
277
+ return normalizedText.slice(1, -1);
278
+ }
279
+
280
+ return normalizedText;
213
281
  }
214
282
 
215
283
  /**
@@ -229,10 +297,31 @@ class RuleTemplate {
229
297
 
230
298
  const errors = [];
231
299
  const warnings = [];
232
- const extractedVars = this.extractVariables();
300
+ const extractedVars = this._extractTemplateVariables();
301
+ const seenVariables = new Set();
302
+ const seenFilterErrors = new Set();
233
303
 
234
304
  for (const varInfo of extractedVars) {
235
305
  const varName = varInfo.name;
306
+
307
+ for (const filter of (varInfo.filterCalls || varInfo.filters || [])) {
308
+ const filterName = typeof filter === 'string' ? filter : filter?.name;
309
+ if (filterName && TemplateFilters[filterName]) {
310
+ continue;
311
+ }
312
+
313
+ const errorMessage = `Unknown filter '${filterName || filter}' for variable '${varName}'`;
314
+ if (!seenFilterErrors.has(errorMessage)) {
315
+ errors.push(errorMessage);
316
+ seenFilterErrors.add(errorMessage);
317
+ }
318
+ }
319
+
320
+ if (seenVariables.has(varName)) {
321
+ continue;
322
+ }
323
+
324
+ seenVariables.add(varName);
236
325
 
237
326
  // Check if variable is provided
238
327
  if (!variables.hasOwnProperty(varName)) {
@@ -305,6 +394,30 @@ class RuleTemplate {
305
394
  return functionCalls;
306
395
  }
307
396
 
397
+ _extractTemplateVariables() {
398
+ const variables = [];
399
+
400
+ const traverse = (node) => {
401
+ if (!node) return;
402
+
403
+ if (node.type === 'template_value') {
404
+ const variableInfo = this._extractVariableFromNode(node);
405
+ if (variableInfo) {
406
+ variables.push(variableInfo);
407
+ }
408
+ }
409
+
410
+ if (node.children) {
411
+ for (const child of node.children) {
412
+ traverse(child);
413
+ }
414
+ }
415
+ };
416
+
417
+ traverse(this.ast);
418
+ return variables;
419
+ }
420
+
308
421
  /**
309
422
  * Prepare the template by replacing variables with their values
310
423
  * Rebuilds from AST by iterating through children
@@ -407,12 +520,15 @@ class RuleTemplate {
407
520
 
408
521
  // Apply filters if present
409
522
  if (templateInfo.filters && templateInfo.filters.length > 0) {
410
- for (const filterName of templateInfo.filters) {
411
- if (!TemplateFilters[filterName]) {
412
- throw new Error(`Unknown filter '${filterName}'`);
523
+ for (const filter of (templateInfo.filterCalls || templateInfo.filters)) {
524
+ const filterName = typeof filter === 'string' ? filter : filter?.name;
525
+ const filterArgs = typeof filter === 'string' ? [] : (Array.isArray(filter?.args) ? filter.args : []);
526
+
527
+ if (!filterName || !TemplateFilters[filterName]) {
528
+ throw new Error(`Unknown filter '${filterName || filter}'`);
413
529
  }
414
-
415
- TemplateFilters[filterName](varData);
530
+
531
+ TemplateFilters[filterName](varData, ...filterArgs);
416
532
  }
417
533
  }
418
534
 
@@ -185,11 +185,13 @@ class RuleTemplate {
185
185
 
186
186
  // Extract filters
187
187
  const filters = [];
188
+ const filterCalls = [];
188
189
  for (const child of templateExpr.children || []) {
189
190
  if (child.type === 'template_filter_call') {
190
- const filterName = this._extractFilterName(child);
191
- if (filterName) {
192
- filters.push(filterName);
191
+ const filterCall = this._extractFilterCall(child);
192
+ if (filterCall) {
193
+ filters.push(filterCall.name);
194
+ filterCalls.push(filterCall);
193
195
  }
194
196
  }
195
197
  }
@@ -198,18 +200,84 @@ class RuleTemplate {
198
200
  const start = node.start;
199
201
  const end = node.end;
200
202
 
201
- return { name, filters, start, end };
203
+ return { name, filters, filterCalls, start, end };
202
204
  }
203
205
 
204
206
  /**
205
- * Extract filter name from template_filter_call node
207
+ * Extract filter call from template_filter_call node
206
208
  * @private
207
209
  */
208
- _extractFilterName(node) {
210
+ _extractFilterCall(node) {
209
211
  const filterNameNode = node.children?.find(c => c.type === 'template_filter_name');
210
212
  if (!filterNameNode || !filterNameNode.text) return null;
211
-
212
- return filterNameNode.text.trim();
213
+
214
+ const argsNode = node.children?.find(c => c.type === 'template_filter_args');
215
+
216
+ return {
217
+ name: filterNameNode.text.trim(),
218
+ args: this._extractFilterArgs(argsNode)
219
+ };
220
+ }
221
+
222
+ _extractFilterArgs(node) {
223
+ if (!node || !Array.isArray(node.children)) {
224
+ return [];
225
+ }
226
+
227
+ return node.children
228
+ .filter(child => child.type === 'template_filter_arg')
229
+ .map(child => this._extractFilterArgValue(child));
230
+ }
231
+
232
+ _extractFilterArgValue(node) {
233
+ if (!node || !Array.isArray(node.children) || node.children.length === 0) {
234
+ return this._normalizeFilterArgText(node?.text?.trim() || '');
235
+ }
236
+
237
+ const child = node.children[0];
238
+ if (!child) {
239
+ return this._normalizeFilterArgText(node.text?.trim() || '');
240
+ }
241
+
242
+ if (child.type === 'value' && Array.isArray(child.children) && child.children.length > 0) {
243
+ return this._extractFilterArgValue(child);
244
+ }
245
+
246
+ if (child.type === 'string') {
247
+ try {
248
+ return JSON.parse(child.text);
249
+ } catch (error) {
250
+ return this._normalizeFilterArgText(child.text);
251
+ }
252
+ }
253
+
254
+ if (child.type === 'number') {
255
+ return Number(child.text);
256
+ }
257
+
258
+ if (child.type === 'true') {
259
+ return true;
260
+ }
261
+
262
+ if (child.type === 'false') {
263
+ return false;
264
+ }
265
+
266
+ if (child.type === 'null') {
267
+ return null;
268
+ }
269
+
270
+ return this._normalizeFilterArgText(child.text?.trim() || node.text?.trim() || '');
271
+ }
272
+
273
+ _normalizeFilterArgText(text) {
274
+ const normalizedText = String(text).trim();
275
+
276
+ if ((normalizedText.startsWith('"') && normalizedText.endsWith('"')) || (normalizedText.startsWith("'") && normalizedText.endsWith("'"))) {
277
+ return normalizedText.slice(1, -1);
278
+ }
279
+
280
+ return normalizedText;
213
281
  }
214
282
 
215
283
  /**
@@ -229,10 +297,31 @@ class RuleTemplate {
229
297
 
230
298
  const errors = [];
231
299
  const warnings = [];
232
- const extractedVars = this.extractVariables();
300
+ const extractedVars = this._extractTemplateVariables();
301
+ const seenVariables = new Set();
302
+ const seenFilterErrors = new Set();
233
303
 
234
304
  for (const varInfo of extractedVars) {
235
305
  const varName = varInfo.name;
306
+
307
+ for (const filter of (varInfo.filterCalls || varInfo.filters || [])) {
308
+ const filterName = typeof filter === 'string' ? filter : filter?.name;
309
+ if (filterName && TemplateFilters[filterName]) {
310
+ continue;
311
+ }
312
+
313
+ const errorMessage = `Unknown filter '${filterName || filter}' for variable '${varName}'`;
314
+ if (!seenFilterErrors.has(errorMessage)) {
315
+ errors.push(errorMessage);
316
+ seenFilterErrors.add(errorMessage);
317
+ }
318
+ }
319
+
320
+ if (seenVariables.has(varName)) {
321
+ continue;
322
+ }
323
+
324
+ seenVariables.add(varName);
236
325
 
237
326
  // Check if variable is provided
238
327
  if (!variables.hasOwnProperty(varName)) {
@@ -305,6 +394,30 @@ class RuleTemplate {
305
394
  return functionCalls;
306
395
  }
307
396
 
397
+ _extractTemplateVariables() {
398
+ const variables = [];
399
+
400
+ const traverse = (node) => {
401
+ if (!node) return;
402
+
403
+ if (node.type === 'template_value') {
404
+ const variableInfo = this._extractVariableFromNode(node);
405
+ if (variableInfo) {
406
+ variables.push(variableInfo);
407
+ }
408
+ }
409
+
410
+ if (node.children) {
411
+ for (const child of node.children) {
412
+ traverse(child);
413
+ }
414
+ }
415
+ };
416
+
417
+ traverse(this.ast);
418
+ return variables;
419
+ }
420
+
308
421
  /**
309
422
  * Prepare the template by replacing variables with their values
310
423
  * Rebuilds from AST by iterating through children
@@ -407,12 +520,15 @@ class RuleTemplate {
407
520
 
408
521
  // Apply filters if present
409
522
  if (templateInfo.filters && templateInfo.filters.length > 0) {
410
- for (const filterName of templateInfo.filters) {
411
- if (!TemplateFilters[filterName]) {
412
- throw new Error(`Unknown filter '${filterName}'`);
523
+ for (const filter of (templateInfo.filterCalls || templateInfo.filters)) {
524
+ const filterName = typeof filter === 'string' ? filter : filter?.name;
525
+ const filterArgs = typeof filter === 'string' ? [] : (Array.isArray(filter?.args) ? filter.args : []);
526
+
527
+ if (!filterName || !TemplateFilters[filterName]) {
528
+ throw new Error(`Unknown filter '${filterName || filter}'`);
413
529
  }
414
-
415
- TemplateFilters[filterName](varData);
530
+
531
+ TemplateFilters[filterName](varData, ...filterArgs);
416
532
  }
417
533
  }
418
534
 
@@ -2,6 +2,31 @@
2
2
  Template filters are functions that transform variable values.
3
3
  They are applied in the template syntax as ${variable|filter} or ${variable|filter1|filter2}
4
4
  */
5
+ const HUMANISE_TIME_UNITS = [
6
+ { name: 'year', seconds: 31536000, aliases: ['year', 'years', 'yr', 'yrs', 'y'] },
7
+ { name: 'month', seconds: 2592000, aliases: ['month', 'months', 'mo', 'mos'] },
8
+ { name: 'week', seconds: 604800, aliases: ['week', 'weeks', 'wk', 'wks', 'w'] },
9
+ { name: 'day', seconds: 86400, aliases: ['day', 'days', 'd'] },
10
+ { name: 'hour', seconds: 3600, aliases: ['hour', 'hours', 'hr', 'hrs', 'h'] },
11
+ { name: 'minute', seconds: 60, aliases: ['minute', 'minutes', 'min', 'mins'] },
12
+ { name: 'second', seconds: 1, aliases: ['second', 'seconds', 'sec', 'secs', 's'] }
13
+ ];
14
+
15
+ const getHumaniseTimeUnit = minUnit => {
16
+ if (minUnit === null || minUnit === undefined || minUnit === '') {
17
+ return null;
18
+ }
19
+
20
+ const normalizedMinUnit = String(minUnit).trim().toLowerCase();
21
+ const unit = HUMANISE_TIME_UNITS.find(candidate => candidate.aliases.includes(normalizedMinUnit));
22
+
23
+ if (!unit) {
24
+ throw new Error(`Unknown humanise_time min_unit \"${minUnit}\"`);
25
+ }
26
+
27
+ return unit;
28
+ };
29
+
5
30
  const TemplateFilters = {
6
31
  // Convert value to JSON string representation
7
32
  string: varData => {
@@ -125,6 +150,69 @@ const TemplateFilters = {
125
150
 
126
151
  },
127
152
 
153
+ humanise_list: (varData, joiner = 'and') => {
154
+ if (typeof varData.value === 'string') {
155
+ varData.type = 'string';
156
+ return;
157
+ }
158
+
159
+ if (!Array.isArray(varData.value)) {
160
+ varData.value = String(varData.value);
161
+ varData.type = 'string';
162
+ return;
163
+ }
164
+
165
+ const items = varData.value.map(item => String(item));
166
+
167
+ if (items.length === 0) {
168
+ varData.value = '';
169
+ } else if (items.length === 1) {
170
+ [varData.value] = items;
171
+ } else if (items.length === 2) {
172
+ varData.value = `${items[0]} ${joiner} ${items[1]}`;
173
+ } else {
174
+ varData.value = `${items.slice(0, -1).join(', ')} ${joiner} ${items[items.length - 1]}`;
175
+ }
176
+
177
+ varData.type = 'string';
178
+
179
+ },
180
+
181
+ humanise_time: (varData, minUnit = null) => {
182
+ const rawSeconds = Number(varData.value);
183
+
184
+ if (isNaN(rawSeconds)) {
185
+ throw new Error(`Value "${varData.value}" cannot be converted to seconds`);
186
+ }
187
+
188
+ const isNegative = rawSeconds < 0;
189
+ const absoluteSeconds = Math.abs(rawSeconds);
190
+ const minimumUnit = getHumaniseTimeUnit(minUnit);
191
+ const minimumUnitIndex = minimumUnit
192
+ ? HUMANISE_TIME_UNITS.findIndex(unit => unit.name === minimumUnit.name)
193
+ : HUMANISE_TIME_UNITS.length - 1;
194
+ const candidateUnits = HUMANISE_TIME_UNITS.slice(0, minimumUnitIndex + 1);
195
+ let selectedUnit = candidateUnits.find(unit => absoluteSeconds % unit.seconds === 0);
196
+ let quantity;
197
+
198
+ if (selectedUnit) {
199
+ quantity = absoluteSeconds / selectedUnit.seconds;
200
+ } else if (minimumUnit) {
201
+ selectedUnit = minimumUnit;
202
+ quantity = Math.floor(absoluteSeconds / selectedUnit.seconds);
203
+ } else {
204
+ selectedUnit = HUMANISE_TIME_UNITS[HUMANISE_TIME_UNITS.length - 1];
205
+ quantity = absoluteSeconds;
206
+ }
207
+
208
+ const signedQuantity = isNegative ? -quantity : quantity;
209
+ const label = Math.abs(signedQuantity) === 1 ? selectedUnit.name : `${selectedUnit.name}s`;
210
+
211
+ varData.value = `${signedQuantity} ${label}`;
212
+ varData.type = 'string';
213
+
214
+ },
215
+
128
216
  // Extract start time from time period/time period ago as time value
129
217
  time_start: varData => {
130
218
  if (varData.type === 'time period' || varData.type === 'time period ago') {
@@ -37,7 +37,7 @@ class VariableTemplate {
37
37
  extractVariable() {
38
38
  return {
39
39
  name: this.variable.name,
40
- filters: this.variable.filters.slice()
40
+ filters: this.variable.filters.map(filter => filter.name)
41
41
  };
42
42
  }
43
43
 
@@ -62,12 +62,15 @@ class VariableTemplate {
62
62
 
63
63
  varData = VariableTemplate._cloneVarData(varData);
64
64
 
65
- for (const filterName of this.variable.filters) {
66
- if (!TemplateFilters[filterName]) {
67
- throw new Error(`Unknown filter '${filterName}'`);
65
+ for (const filter of this.variable.filters) {
66
+ const filterName = typeof filter === 'string' ? filter : filter?.name;
67
+ const filterArgs = typeof filter === 'string' ? [] : (Array.isArray(filter?.args) ? filter.args : []);
68
+
69
+ if (!filterName || !TemplateFilters[filterName]) {
70
+ throw new Error(`Unknown filter '${filterName || filter}'`);
68
71
  }
69
72
 
70
- TemplateFilters[filterName](varData);
73
+ TemplateFilters[filterName](varData, ...filterArgs);
71
74
  }
72
75
 
73
76
  return varData;
@@ -98,7 +101,11 @@ class VariableTemplate {
98
101
  const filterNameNode = child.children?.find(c => c.type === 'template_filter_name');
99
102
  const filterName = filterNameNode?.text?.trim();
100
103
  if (filterName) {
101
- filters.push(filterName);
104
+ const argsNode = child.children?.find(c => c.type === 'template_filter_args');
105
+ filters.push({
106
+ name: filterName,
107
+ args: VariableTemplate._extractFilterArgs(argsNode)
108
+ });
102
109
  }
103
110
  }
104
111
  }
@@ -121,6 +128,67 @@ class VariableTemplate {
121
128
 
122
129
  return cloned;
123
130
  }
131
+
132
+ static _extractFilterArgs(node) {
133
+ if (!node || !Array.isArray(node.children)) {
134
+ return [];
135
+ }
136
+
137
+ return node.children
138
+ .filter(child => child.type === 'template_filter_arg')
139
+ .map(child => VariableTemplate._extractFilterArgValue(child));
140
+ }
141
+
142
+ static _extractFilterArgValue(node) {
143
+ if (!node || !Array.isArray(node.children) || node.children.length === 0) {
144
+ return VariableTemplate._normalizeFilterArgText(node?.text?.trim() || '');
145
+ }
146
+
147
+ const child = node.children[0];
148
+ if (!child) {
149
+ return VariableTemplate._normalizeFilterArgText(node.text?.trim() || '');
150
+ }
151
+
152
+ if (child.type === 'value' && Array.isArray(child.children) && child.children.length > 0) {
153
+ return VariableTemplate._extractFilterArgValue(child);
154
+ }
155
+
156
+ if (child.type === 'string') {
157
+ try {
158
+ return JSON.parse(child.text);
159
+ } catch (error) {
160
+ return VariableTemplate._normalizeFilterArgText(child.text);
161
+ }
162
+ }
163
+
164
+ if (child.type === 'number') {
165
+ return Number(child.text);
166
+ }
167
+
168
+ if (child.type === 'true') {
169
+ return true;
170
+ }
171
+
172
+ if (child.type === 'false') {
173
+ return false;
174
+ }
175
+
176
+ if (child.type === 'null') {
177
+ return null;
178
+ }
179
+
180
+ return VariableTemplate._normalizeFilterArgText(child.text?.trim() || node.text?.trim() || '');
181
+ }
182
+
183
+ static _normalizeFilterArgText(text) {
184
+ const normalizedText = String(text).trim();
185
+
186
+ if ((normalizedText.startsWith('"') && normalizedText.endsWith('"')) || (normalizedText.startsWith("'") && normalizedText.endsWith("'"))) {
187
+ return normalizedText.slice(1, -1);
188
+ }
189
+
190
+ return normalizedText;
191
+ }
124
192
  }
125
193
 
126
194
  module.exports = VariableTemplate;
@@ -139,12 +139,15 @@ class VariableValidate {
139
139
  throw new Error('Variable data filters must be an array');
140
140
  }
141
141
 
142
- for (const filterName of normalizedVarData.filters) {
143
- if (!TemplateFilters[filterName]) {
144
- throw new Error(`Unknown filter '${filterName}'`);
142
+ for (const filter of normalizedVarData.filters) {
143
+ const filterName = typeof filter === 'string' ? filter : filter?.name;
144
+ const filterArgs = typeof filter === 'string' ? [] : (Array.isArray(filter?.args) ? filter.args : []);
145
+
146
+ if (!filterName || !TemplateFilters[filterName]) {
147
+ throw new Error(`Unknown filter '${filterName || filter}'`);
145
148
  }
146
149
 
147
- TemplateFilters[filterName](normalizedVarData);
150
+ TemplateFilters[filterName](normalizedVarData, ...filterArgs);
148
151
  }
149
152
 
150
153
  return normalizedVarData;