From 28ef783813a3b185e09cb15f2710e1172b2e59b7 Mon Sep 17 00:00:00 2001 From: UnderKoen Date: Thu, 22 Aug 2024 21:35:11 +0200 Subject: [PATCH 1/9] feat(rules): templating actions --- .../src/components/modals/EditRule.jsx | 42 +++++++++- .../src/components/rules/ActionExpression.tsx | 9 +- packages/loot-core/package.json | 1 + .../loot-core/src/server/accounts/rules.ts | 82 ++++++++++++++++++- packages/loot-core/src/types/models/rule.d.ts | 1 + upcoming-release-notes/3305.md | 6 ++ yarn.lock | 37 ++++++++- 7 files changed, 172 insertions(+), 6 deletions(-) create mode 100644 upcoming-release-notes/3305.md diff --git a/packages/desktop-client/src/components/modals/EditRule.jsx b/packages/desktop-client/src/components/modals/EditRule.jsx index b896846fcfe..0d68d483079 100644 --- a/packages/desktop-client/src/components/modals/EditRule.jsx +++ b/packages/desktop-client/src/components/modals/EditRule.jsx @@ -38,7 +38,7 @@ import { import { useDateFormat } from '../../hooks/useDateFormat'; import { useSelected, SelectedProvider } from '../../hooks/useSelected'; import { SvgDelete, SvgAdd, SvgSubtract } from '../../icons/v0'; -import { SvgInformationOutline } from '../../icons/v1'; +import { SvgAlignLeft, SvgCode, SvgInformationOutline } from '../../icons/v1'; import { styles, theme } from '../../style'; import { Button } from '../common/Button2'; import { Menu } from '../common/Menu'; @@ -360,6 +360,8 @@ function ActionEditor({ action, editorStyle, onChange, onDelete, onAdd }) { options, } = action; + const templated = options?.template !== undefined; + return ( {op === 'set' ? ( @@ -380,13 +382,34 @@ function ActionEditor({ action, editorStyle, onChange, onDelete, onAdd }) { onChange('value', v)} numberFormatType="currency" /> + {/*Due to that these fields have id's as value it is not helpful to have templating here*/} + {['payee', 'category', 'account'].indexOf(field) === -1 && ( + + )} ) : op === 'set-split-amount' ? ( <> @@ -812,18 +835,31 @@ export function EditRule({ defaultRule, onSave: originalOnSave }) { id, actions: updateValue(actions, action, () => { const a = { ...action }; + if (field === 'method') { a.options = { ...a.options, method: value }; + } else if (field === 'template') { + if (value) { + a.options = { ...a.options, template: a.value }; + } else { + a.options = { ...a.options, template: undefined }; + if (a.type !== 'string') a.value = null; + } } else { a[field] = value; + if (a.options?.template !== undefined) { + a.options.template = value; + } if (field === 'field') { a.type = FIELD_TYPES.get(a.field); a.value = null; + a.options = { ...a.options, template: undefined }; return newInput(a); } else if (field === 'op') { a.value = null; a.inputKey = '' + Math.random(); + a.options = { ...a.options, template: undefined }; return newInput(a); } } diff --git a/packages/desktop-client/src/components/rules/ActionExpression.tsx b/packages/desktop-client/src/components/rules/ActionExpression.tsx index 7073d3dba18..07e8c76ee9e 100644 --- a/packages/desktop-client/src/components/rules/ActionExpression.tsx +++ b/packages/desktop-client/src/components/rules/ActionExpression.tsx @@ -71,7 +71,14 @@ function SetActionExpression({ {friendlyOp(op)}{' '} {mapField(field, options)}{' '} to - + {options?.template ? ( + <> + template + {options.template} + + ) : ( + + )} ); } diff --git a/packages/loot-core/package.json b/packages/loot-core/package.json index 803ec1bc0ed..3c3ffe1cc3f 100644 --- a/packages/loot-core/package.json +++ b/packages/loot-core/package.json @@ -28,6 +28,7 @@ "csv-stringify": "^5.6.5", "date-fns": "^2.30.0", "deep-equal": "^2.2.3", + "handlebars": "^4.7.8", "lru-cache": "^5.1.1", "md5": "^2.3.0", "memoize-one": "^6.0.0", diff --git a/packages/loot-core/src/server/accounts/rules.ts b/packages/loot-core/src/server/accounts/rules.ts index 467dc7bc801..e6e37bfbc9e 100644 --- a/packages/loot-core/src/server/accounts/rules.ts +++ b/packages/loot-core/src/server/accounts/rules.ts @@ -1,5 +1,6 @@ // @ts-strict-ignore import * as dateFns from 'date-fns'; +import * as Handlebars from 'handlebars'; import { monthFromDate, @@ -9,6 +10,9 @@ import { addDays, subDays, parseDate, + dayFromDate, + format, + currentDay, } from '../../shared/months'; import { sortNumbers, getApproxNumberThreshold } from '../../shared/rules'; import { recurConfigToRSchedule } from '../../shared/schedules'; @@ -23,6 +27,57 @@ import { RuleConditionEntity } from '../../types/models'; import { RuleError } from '../errors'; import { Schedule as RSchedule } from '../util/rschedule'; +void (function registerHandlebarsHelpers() { + const regexTest = /^\/(.*)\/([gimuy]*)$/; + + function mathHelper(fn: (a: number, b: number) => number) { + return (a: unknown, ...b: unknown[]) => { + // Last argument is the Handlebars options object + b.splice(-1, 1); + return b.map(Number).reduce(fn, Number(a)); + }; + } + + const helpers = { + regex: (value: unknown, regex: unknown, replace: unknown) => { + if (typeof regex !== 'string' || typeof replace !== 'string') { + return ''; + } + + let regexp: RegExp; + const match = regexTest.exec(regex); + // Regex is in format /regex/flags + if (match) { + regexp = new RegExp(match[1], match[2]); + } else { + regexp = new RegExp(regex); + } + + return String(value).replace(regexp, replace); + }, + add: mathHelper((a, b) => a + b), + sub: mathHelper((a, b) => a - b), + div: mathHelper((a, b) => a / b), + mul: mathHelper((a, b) => a * b), + mod: mathHelper((a, b) => a % b), + floor: (a: unknown) => Math.floor(Number(a)), + ceil: (a: unknown) => Math.ceil(Number(a)), + round: (a: unknown) => Math.round(Number(a)), + abs: (a: unknown) => Math.abs(Number(a)), + min: mathHelper(Math.min), + max: mathHelper(Math.max), + fixed: (a: unknown, digits: unknown) => Number(a).toFixed(Number(digits)), + day: (date: string) => dayFromDate(parseDate(date)), + month: (date: string) => monthFromDate(parseDate(date)), + year: (date: string) => yearFromDate(parseDate(date)), + format: (date: string, f: string) => format(date, f), + }; + + for (const [name, fn] of Object.entries(helpers)) { + Handlebars.registerHelper(name, fn); + } +})(); + function assert(test, type, msg) { if (!test) { throw new RuleError(type, msg); @@ -468,6 +523,8 @@ export class Action { type; value; + private handlebarsTemplate?: Handlebars.TemplateDelegate; + constructor(op: ActionOperator, field, value, options, fieldTypes) { assert( ACTION_OPS.includes(op), @@ -480,6 +537,9 @@ export class Action { assert(typeName, 'internal', `Invalid field for action: ${field}`); this.field = field; this.type = typeName; + if (options?.template) { + this.handlebarsTemplate = Handlebars.compile(options.template); + } } else if (op === 'set-split-amount') { this.field = null; this.type = 'number'; @@ -504,7 +564,27 @@ export class Action { exec(object) { switch (this.op) { case 'set': - object[this.field] = this.value; + if (this.handlebarsTemplate) { + object[this.field] = this.handlebarsTemplate({ + ...object, + today: currentDay(), + }); + + // Handlebars always returns a string, so we need to convert + switch (this.type) { + case 'number': + object[this.field] = parseFloat(object[this.field]); + break; + case 'date': + object[this.field] = parseDate(object[this.field]); + break; + case 'boolean': + object[this.field] = object[this.field] === 'true'; + break; + } + } else { + object[this.field] = this.value; + } break; case 'set-split-amount': switch (this.options.method) { diff --git a/packages/loot-core/src/types/models/rule.d.ts b/packages/loot-core/src/types/models/rule.d.ts index 3c61ebf70a3..3ccf18411f7 100644 --- a/packages/loot-core/src/types/models/rule.d.ts +++ b/packages/loot-core/src/types/models/rule.d.ts @@ -133,6 +133,7 @@ export interface SetRuleActionEntity { op: 'set'; value: unknown; options?: { + template?: string; splitIndex?: number; }; type?: string; diff --git a/upcoming-release-notes/3305.md b/upcoming-release-notes/3305.md new file mode 100644 index 00000000000..34a65eac692 --- /dev/null +++ b/upcoming-release-notes/3305.md @@ -0,0 +1,6 @@ +--- +category: Enhancements +authors: [UnderKoen] +--- + +Add rule action templating for set actions using handlebars syntax. diff --git a/yarn.lock b/yarn.lock index 179f6284b9e..53109757d21 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10659,6 +10659,24 @@ __metadata: languageName: node linkType: hard +"handlebars@npm:^4.7.8": + version: 4.7.8 + resolution: "handlebars@npm:4.7.8" + dependencies: + minimist: "npm:^1.2.5" + neo-async: "npm:^2.6.2" + source-map: "npm:^0.6.1" + uglify-js: "npm:^3.1.4" + wordwrap: "npm:^1.0.0" + dependenciesMeta: + uglify-js: + optional: true + bin: + handlebars: bin/handlebars + checksum: 10/bd528f4dd150adf67f3f857118ef0fa43ff79a153b1d943fa0a770f2599e38b25a7a0dbac1a3611a4ec86970fd2325a81310fb788b5c892308c9f8743bd02e11 + languageName: node + linkType: hard + "has-bigints@npm:^1.0.1, has-bigints@npm:^1.0.2": version: 1.0.2 resolution: "has-bigints@npm:1.0.2" @@ -13108,6 +13126,7 @@ __metadata: deep-equal: "npm:^2.2.3" fake-indexeddb: "npm:^3.1.8" fast-check: "npm:3.15.0" + handlebars: "npm:^4.7.8" i18next: "npm:^23.11.5" jest: "npm:^27.5.1" jsverify: "npm:^0.8.4" @@ -14038,7 +14057,7 @@ __metadata: languageName: node linkType: hard -"minimist@npm:^1.2.0, minimist@npm:^1.2.3, minimist@npm:^1.2.6": +"minimist@npm:^1.2.0, minimist@npm:^1.2.3, minimist@npm:^1.2.5, minimist@npm:^1.2.6": version: 1.2.8 resolution: "minimist@npm:1.2.8" checksum: 10/908491b6cc15a6c440ba5b22780a0ba89b9810e1aea684e253e43c4e3b8d56ec1dcdd7ea96dde119c29df59c936cde16062159eae4225c691e19c70b432b6e6f @@ -18563,6 +18582,15 @@ __metadata: languageName: node linkType: hard +"uglify-js@npm:^3.1.4": + version: 3.19.2 + resolution: "uglify-js@npm:3.19.2" + bin: + uglifyjs: bin/uglifyjs + checksum: 10/8b0af1fa5260e7f8bc3e9a1e08ae05023b7c96eeb8965e27f29724597389d4e703d4aa6f66e6cd87a14a84e431df73a358ee58c0afce6b615b40cc95fcbf4ec6 + languageName: node + linkType: hard + "unbox-primitive@npm:^1.0.2": version: 1.0.2 resolution: "unbox-primitive@npm:1.0.2" @@ -19616,6 +19644,13 @@ __metadata: languageName: node linkType: hard +"wordwrap@npm:^1.0.0": + version: 1.0.0 + resolution: "wordwrap@npm:1.0.0" + checksum: 10/497d40beb2bdb08e6d38754faa17ce20b0bf1306327f80cb777927edb23f461ee1f6bc659b3c3c93f26b08e1cf4b46acc5bae8fda1f0be3b5ab9a1a0211034cd + languageName: node + linkType: hard + "workbox-background-sync@npm:7.1.0": version: 7.1.0 resolution: "workbox-background-sync@npm:7.1.0" From 3185e6610e2c952a8ffd265c9524a42956dd6241 Mon Sep 17 00:00:00 2001 From: UnderKoen Date: Thu, 22 Aug 2024 22:22:49 +0200 Subject: [PATCH 2/9] chore: update snapshots --- .../accounts/__snapshots__/transaction-rules.test.ts.snap | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/loot-core/src/server/accounts/__snapshots__/transaction-rules.test.ts.snap b/packages/loot-core/src/server/accounts/__snapshots__/transaction-rules.test.ts.snap index 05ac017ece7..9e5e985417c 100644 --- a/packages/loot-core/src/server/accounts/__snapshots__/transaction-rules.test.ts.snap +++ b/packages/loot-core/src/server/accounts/__snapshots__/transaction-rules.test.ts.snap @@ -6,6 +6,7 @@ Array [ "actions": Array [ Action { "field": "category", + "handlebarsTemplate": undefined, "op": "set", "options": undefined, "rawValue": "food", @@ -32,6 +33,7 @@ Array [ "actions": Array [ Action { "field": "category", + "handlebarsTemplate": undefined, "op": "set", "options": undefined, "rawValue": "food", @@ -58,6 +60,7 @@ Array [ "actions": Array [ Action { "field": "category", + "handlebarsTemplate": undefined, "op": "set", "options": undefined, "rawValue": "beer", @@ -89,6 +92,7 @@ Array [ "actions": Array [ Action { "field": "category", + "handlebarsTemplate": undefined, "op": "set", "options": undefined, "rawValue": "beer", @@ -115,6 +119,7 @@ Array [ "actions": Array [ Action { "field": "category", + "handlebarsTemplate": undefined, "op": "set", "options": undefined, "rawValue": "beer", @@ -141,6 +146,7 @@ Array [ "actions": Array [ Action { "field": "category", + "handlebarsTemplate": undefined, "op": "set", "options": undefined, "rawValue": "beer", From ffeeb58a163366ba5258ff400b68890c42ad2335 Mon Sep 17 00:00:00 2001 From: UnderKoen Date: Fri, 23 Aug 2024 17:28:05 +0200 Subject: [PATCH 3/9] fix: date functions templating --- packages/loot-core/src/server/accounts/rules.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/loot-core/src/server/accounts/rules.ts b/packages/loot-core/src/server/accounts/rules.ts index e6e37bfbc9e..f9a75f28dd2 100644 --- a/packages/loot-core/src/server/accounts/rules.ts +++ b/packages/loot-core/src/server/accounts/rules.ts @@ -10,7 +10,6 @@ import { addDays, subDays, parseDate, - dayFromDate, format, currentDay, } from '../../shared/months'; @@ -67,9 +66,9 @@ void (function registerHandlebarsHelpers() { min: mathHelper(Math.min), max: mathHelper(Math.max), fixed: (a: unknown, digits: unknown) => Number(a).toFixed(Number(digits)), - day: (date: string) => dayFromDate(parseDate(date)), - month: (date: string) => monthFromDate(parseDate(date)), - year: (date: string) => yearFromDate(parseDate(date)), + day: (date: string) => format(date, "d"), + month: (date: string) => format(date, "M"), + year: (date: string) => format(date, "yyyy"), format: (date: string, f: string) => format(date, f), }; @@ -145,7 +144,7 @@ const CONDITION_TYPES = { date: { ops: ['is', 'isapprox', 'gt', 'gte', 'lt', 'lte'], nullable: false, - parse(op, value, fieldName) { + parse(op, value, fieldName){ const parsed = typeof value === 'string' ? parseDateString(value) From ab55538dbea3bdcc453ac109f9a1db44a752201c Mon Sep 17 00:00:00 2001 From: UnderKoen Date: Fri, 23 Aug 2024 17:56:27 +0200 Subject: [PATCH 4/9] chore: lint --- packages/loot-core/src/server/accounts/rules.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/loot-core/src/server/accounts/rules.ts b/packages/loot-core/src/server/accounts/rules.ts index 1e9e0f319d0..4afb98eb00b 100644 --- a/packages/loot-core/src/server/accounts/rules.ts +++ b/packages/loot-core/src/server/accounts/rules.ts @@ -66,9 +66,9 @@ void (function registerHandlebarsHelpers() { min: mathHelper(Math.min), max: mathHelper(Math.max), fixed: (a: unknown, digits: unknown) => Number(a).toFixed(Number(digits)), - day: (date: string) => format(date, "d"), - month: (date: string) => format(date, "M"), - year: (date: string) => format(date, "yyyy"), + day: (date: string) => format(date, 'd'), + month: (date: string) => format(date, 'M'), + year: (date: string) => format(date, 'yyyy'), format: (date: string, f: string) => format(date, f), }; @@ -144,7 +144,7 @@ const CONDITION_TYPES = { date: { ops: ['is', 'isapprox', 'gt', 'gte', 'lt', 'lte'], nullable: false, - parse(op, value, fieldName){ + parse(op, value, fieldName) { const parsed = typeof value === 'string' ? parseDateString(value) From 94953c1d4b8964fe917dcf2d15ed85246f50365b Mon Sep 17 00:00:00 2001 From: UnderKoen Date: Mon, 26 Aug 2024 18:41:34 +0200 Subject: [PATCH 5/9] fix: put action templating behind feature flag --- .../src/components/modals/EditRule.jsx | 47 +++++++++++-------- .../src/components/settings/Experimental.tsx | 3 ++ .../src/hooks/useFeatureFlag.ts | 1 + packages/loot-core/src/types/prefs.d.ts | 3 +- 4 files changed, 33 insertions(+), 21 deletions(-) diff --git a/packages/desktop-client/src/components/modals/EditRule.jsx b/packages/desktop-client/src/components/modals/EditRule.jsx index f8edcb80001..1b53cb5348c 100644 --- a/packages/desktop-client/src/components/modals/EditRule.jsx +++ b/packages/desktop-client/src/components/modals/EditRule.jsx @@ -36,6 +36,7 @@ import { } from 'loot-core/src/shared/util'; import { useDateFormat } from '../../hooks/useDateFormat'; +import { useFeatureFlag } from '../../hooks/useFeatureFlag'; import { useSelected, SelectedProvider } from '../../hooks/useSelected'; import { SvgDelete, SvgAdd, SvgSubtract } from '../../icons/v0'; import { SvgAlignLeft, SvgCode, SvgInformationOutline } from '../../icons/v1'; @@ -362,6 +363,9 @@ function ActionEditor({ action, editorStyle, onChange, onDelete, onAdd }) { const templated = options?.template !== undefined; + // Even if the feature flag is disabled, we still want to be able to turn off templating + const isTemplatingEnabled = useFeatureFlag('actionTemplating') || templated; + return ( {op === 'set' ? ( @@ -390,26 +394,29 @@ function ActionEditor({ action, editorStyle, onChange, onDelete, onAdd }) { /> {/*Due to that these fields have id's as value it is not helpful to have templating here*/} - {['payee', 'category', 'account'].indexOf(field) === -1 && ( - - )} + {isTemplatingEnabled && + ['payee', 'category', 'account'].indexOf(field) === -1 && ( + + )} ) : op === 'set-split-amount' ? ( <> diff --git a/packages/desktop-client/src/components/settings/Experimental.tsx b/packages/desktop-client/src/components/settings/Experimental.tsx index 8165e465f6d..48931c11f90 100644 --- a/packages/desktop-client/src/components/settings/Experimental.tsx +++ b/packages/desktop-client/src/components/settings/Experimental.tsx @@ -116,6 +116,9 @@ export function ExperimentalFeatures() { > Customizable reports page (dashboards) + + Action templating + ) : ( = { spendingReport: false, simpleFinSync: false, dashboards: false, + actionTemplating: false, }; export function useFeatureFlag(name: FeatureFlag): boolean { diff --git a/packages/loot-core/src/types/prefs.d.ts b/packages/loot-core/src/types/prefs.d.ts index c1d7054ea48..0c0a563fbd7 100644 --- a/packages/loot-core/src/types/prefs.d.ts +++ b/packages/loot-core/src/types/prefs.d.ts @@ -7,7 +7,8 @@ export type FeatureFlag = | 'reportBudget' | 'goalTemplatesEnabled' | 'spendingReport' - | 'simpleFinSync'; + | 'simpleFinSync' + | 'actionTemplating'; /** * Cross-device preferences. These sync across devices when they are changed. From f23345f302db668d5849a94912582629651c8ac3 Mon Sep 17 00:00:00 2001 From: UnderKoen Date: Mon, 26 Aug 2024 19:00:01 +0200 Subject: [PATCH 6/9] fix: template syntax checking --- packages/loot-core/src/server/accounts/rules.ts | 5 +++++ packages/loot-core/src/shared/rules.ts | 2 ++ 2 files changed, 7 insertions(+) diff --git a/packages/loot-core/src/server/accounts/rules.ts b/packages/loot-core/src/server/accounts/rules.ts index 4afb98eb00b..7c1b55053ed 100644 --- a/packages/loot-core/src/server/accounts/rules.ts +++ b/packages/loot-core/src/server/accounts/rules.ts @@ -585,6 +585,11 @@ export class Action { this.type = typeName; if (options?.template) { this.handlebarsTemplate = Handlebars.compile(options.template); + try { + this.handlebarsTemplate({}); + } catch (e) { + assert(false, 'invalid-template', `Invalid Handlebars template`); + } } } else if (op === 'set-split-amount') { this.field = null; diff --git a/packages/loot-core/src/shared/rules.ts b/packages/loot-core/src/shared/rules.ts index 76eaa292605..27985017295 100644 --- a/packages/loot-core/src/shared/rules.ts +++ b/packages/loot-core/src/shared/rules.ts @@ -192,6 +192,8 @@ export function getFieldError(type) { return 'Value must be a number'; case 'invalid-field': return 'Please choose a valid field for this type of rule'; + case 'invalid-template': + return 'Invalid handlebars template'; default: return 'Internal error, sorry! Please get in touch https://actualbudget.org/contact/ for support'; } From 5f59aa559c9cb677c84e968560ecd53429aa0b41 Mon Sep 17 00:00:00 2001 From: UnderKoen Date: Fri, 4 Oct 2024 12:03:56 +0200 Subject: [PATCH 7/9] test: handle bar functions --- .../src/server/accounts/rules.test.ts | 82 +++++++++++++++++++ .../loot-core/src/server/accounts/rules.ts | 11 ++- 2 files changed, 89 insertions(+), 4 deletions(-) diff --git a/packages/loot-core/src/server/accounts/rules.test.ts b/packages/loot-core/src/server/accounts/rules.test.ts index 00df2b74bf3..c0d191e42fb 100644 --- a/packages/loot-core/src/server/accounts/rules.test.ts +++ b/packages/loot-core/src/server/accounts/rules.test.ts @@ -316,6 +316,88 @@ describe('Action', () => { new Action('set', 'account', '', null); }).toThrow(/Field cannot be empty/i); }); + + describe('templating', () => { + test('should use available fields', () => { + const action = new Action('set', 'notes', '', { + template: 'Hey {{notes}}! You just payed {{amount}}', + }); + const item = { notes: 'Sarah', amount: 10 }; + action.exec(item); + expect(item.notes).toBe('Hey Sarah! You just payed 10'); + }); + + describe('regex helper', () => { + function testHelper(template: string, expected: unknown) { + test(template, () => { + const action = new Action('set', 'notes', '', { template }); + const item = { notes: 'Sarah Condition' }; + action.exec(item); + expect(item.notes).toBe(expected); + }); + } + + testHelper('{{regex notes "/[aeuio]/g" "a"}}', 'Sarah Candataan'); + testHelper('{{regex notes "/[aeuio]/" ""}}', 'Srah Condition'); + // capture groups + testHelper('{{regex notes "/^.+ (.+)$/" "$1"}}', 'Condition'); + // no match + testHelper('{{regex notes "/Klaas/" "Jantje"}}', 'Sarah Condition'); + // no regex format (/.../flags) + testHelper('{{regex notes "Sarah" "Jantje"}}', 'Jantje Condition'); + }); + + describe('math helpers', () => { + function testHelper( + template: string, + expected: unknown, + field = 'amount', + ) { + test(template, () => { + const action = new Action('set', field, '', { template }); + const item = { [field]: 10 }; + action.exec(item); + expect(item[field]).toBe(expected); + }); + } + + testHelper('{{add amount 5}}', 15); + testHelper('{{add amount 5 10}}', 25); + testHelper('{{sub amount 5}}', 5); + testHelper('{{sub amount 5 10}}', -5); + testHelper('{{mul amount 5}}', 50); + testHelper('{{mul amount 5 10}}', 500); + testHelper('{{div amount 5}}', 2); + testHelper('{{div amount 5 10}}', 0.2); + testHelper('{{mod amount 3}}', 1); + testHelper('{{mod amount 6 5}}', 4); + testHelper('{{floor (div amount 3)}}', 3); + testHelper('{{ceil (div amount 3)}}', 4); + testHelper('{{round (div amount 3)}}', 3); + testHelper('{{round (div amount 4)}}', 3); + testHelper('{{abs -5}}', 5); + testHelper('{{abs 5}}', 5); + testHelper('{{min amount 5 500}}', 5); + testHelper('{{max amount 5 500}}', 500); + testHelper('{{fixed (div 10 4) 2}}', '2.50', 'notes'); + }); + + describe('date helpers', () => { + function testHelper(template: string, expected: unknown) { + test(template, () => { + const action = new Action('set', 'notes', '', { template }); + const item = { notes: '' }; + action.exec(item); + expect(item.notes).toBe(expected); + }); + } + + testHelper('{{day "2002-07-25"}}', '25'); + testHelper('{{month "2002-07-25"}}', '7'); + testHelper('{{year "2002-07-25"}}', '2002'); + testHelper('{{format "2002-07-25" "MM yyyy d"}}', '07 2002 25'); + }); + }); }); describe('Rule', () => { diff --git a/packages/loot-core/src/server/accounts/rules.ts b/packages/loot-core/src/server/accounts/rules.ts index 11f638c0374..0be4b81513a 100644 --- a/packages/loot-core/src/server/accounts/rules.ts +++ b/packages/loot-core/src/server/accounts/rules.ts @@ -31,7 +31,7 @@ import { RuleConditionEntity } from '../../types/models'; import { RuleError } from '../errors'; import { Schedule as RSchedule } from '../util/rschedule'; -void (function registerHandlebarsHelpers() { +function registerHandlebarsHelpers() { const regexTest = /^\/(.*)\/([gimuy]*)$/; function mathHelper(fn: (a: number, b: number) => number) { @@ -68,8 +68,8 @@ void (function registerHandlebarsHelpers() { ceil: (a: unknown) => Math.ceil(Number(a)), round: (a: unknown) => Math.round(Number(a)), abs: (a: unknown) => Math.abs(Number(a)), - min: mathHelper(Math.min), - max: mathHelper(Math.max), + min: mathHelper((a, b) => Math.min(a, b)), + max: mathHelper((a, b) => Math.max(a, b)), fixed: (a: unknown, digits: unknown) => Number(a).toFixed(Number(digits)), day: (date: string) => format(date, 'd'), month: (date: string) => format(date, 'M'), @@ -80,7 +80,9 @@ void (function registerHandlebarsHelpers() { for (const [name, fn] of Object.entries(helpers)) { Handlebars.registerHelper(name, fn); } -})(); +} + +registerHandlebarsHelpers(); function assert(test, type, msg) { if (!test) { @@ -564,6 +566,7 @@ export class Action { try { this.handlebarsTemplate({}); } catch (e) { + console.debug(e); assert(false, 'invalid-template', `Invalid Handlebars template`); } } From 0b24fe00e02a466698094e82fe27d28370390b64 Mon Sep 17 00:00:00 2001 From: UnderKoen Date: Tue, 8 Oct 2024 18:18:27 +0200 Subject: [PATCH 8/9] chore: pr feedback --- .../src/components/settings/Experimental.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/desktop-client/src/components/settings/Experimental.tsx b/packages/desktop-client/src/components/settings/Experimental.tsx index 0814fcf8870..d0888e1a935 100644 --- a/packages/desktop-client/src/components/settings/Experimental.tsx +++ b/packages/desktop-client/src/components/settings/Experimental.tsx @@ -110,8 +110,11 @@ export function ExperimentalFeatures() { > Customizable reports page (dashboards) - - Action templating + + Rule action templating ) : ( From 05db1aa7c025a747ac2a84afa167abb8fb64db2c Mon Sep 17 00:00:00 2001 From: UnderKoen Date: Tue, 8 Oct 2024 18:25:34 +0200 Subject: [PATCH 9/9] feat: add `{{debug x}}` handler --- packages/loot-core/src/server/accounts/rules.test.ts | 11 +++++++++++ packages/loot-core/src/server/accounts/rules.ts | 3 +++ 2 files changed, 14 insertions(+) diff --git a/packages/loot-core/src/server/accounts/rules.test.ts b/packages/loot-core/src/server/accounts/rules.test.ts index c0d191e42fb..859d7ab7e71 100644 --- a/packages/loot-core/src/server/accounts/rules.test.ts +++ b/packages/loot-core/src/server/accounts/rules.test.ts @@ -397,6 +397,17 @@ describe('Action', () => { testHelper('{{year "2002-07-25"}}', '2002'); testHelper('{{format "2002-07-25" "MM yyyy d"}}', '07 2002 25'); }); + + test('{{debug}} should log the item', () => { + const action = new Action('set', 'notes', '', { + template: '{{debug notes}}', + }); + const item = { notes: 'Sarah' }; + const spy = jest.spyOn(console, 'log').mockImplementation(); + action.exec(item); + expect(spy).toHaveBeenCalledWith('Sarah'); + spy.mockRestore(); + }); }); }); diff --git a/packages/loot-core/src/server/accounts/rules.ts b/packages/loot-core/src/server/accounts/rules.ts index 0be4b81513a..ef6c78decab 100644 --- a/packages/loot-core/src/server/accounts/rules.ts +++ b/packages/loot-core/src/server/accounts/rules.ts @@ -75,6 +75,9 @@ function registerHandlebarsHelpers() { month: (date: string) => format(date, 'M'), year: (date: string) => format(date, 'yyyy'), format: (date: string, f: string) => format(date, f), + debug: (value: unknown) => { + console.log(value); + }, }; for (const [name, fn] of Object.entries(helpers)) {