Skip to content

Commit d73fb4b

Browse files
committed
[AI] Implement BALANCE_OF as HyperFormula custom function via context
1 parent 9f1a987 commit d73fb4b

6 files changed

Lines changed: 58 additions & 50 deletions

File tree

packages/desktop-client/src/hooks/useFormulaExecution.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -327,8 +327,8 @@ export function useFormulaExecution(
327327
// Create HyperFormula instance
328328
hfInstance = HyperFormula.buildEmpty({
329329
licenseKey: 'gpl-v3',
330-
localeLang: typeof locale === 'string' ? locale : 'en-US',
331330
language: 'enUS',
331+
localeLang: typeof locale === 'string' ? locale : 'en-US',
332332
});
333333

334334
// Add a sheet and set the formula in cell A1

packages/desktop-client/src/hooks/useTransactionFormulaExecution.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,21 @@
11
import { useEffect, useState } from 'react';
22

3+
import { HyperFormula } from 'hyperformula';
4+
import enUS from 'hyperformula/i18n/languages/enUS';
5+
6+
import {
7+
CustomFunctionsPlugin,
8+
customFunctionsTranslations,
9+
} from '@actual-app/core/server/rules/customFunctions';
10+
311
import { useLocale } from './useLocale';
412

13+
HyperFormula.registerLanguage('enUS', enUS);
14+
HyperFormula.registerFunctionPlugin(
15+
CustomFunctionsPlugin,
16+
customFunctionsTranslations,
17+
);
18+
519
type TransactionContext = {
620
amount?: number;
721
date?: string;
@@ -26,8 +40,7 @@ export function useTransactionFormulaExecution(
2640
useEffect(() => {
2741
let cancelled = false;
2842

29-
async function executeFormula() {
30-
const { HyperFormula } = await import('hyperformula');
43+
function executeFormula() {
3144
let hfInstance: ReturnType<typeof HyperFormula.buildEmpty> | null = null;
3245

3346
if (!formula || !formula.startsWith('=')) {
@@ -40,7 +53,12 @@ export function useTransactionFormulaExecution(
4053
// Create HyperFormula instance
4154
hfInstance = HyperFormula.buildEmpty({
4255
licenseKey: 'gpl-v3',
56+
language: 'enUS',
4357
localeLang: typeof locale === 'string' ? locale : 'en-US',
58+
context: {
59+
// No server prefetch in preview
60+
balanceOfPrefetch: new Map(),
61+
},
4462
});
4563

4664
// Add a sheet
@@ -59,7 +77,12 @@ export function useTransactionFormulaExecution(
5977
};
6078

6179
for (const [key, value] of Object.entries(fieldValues)) {
62-
if (value !== undefined && value !== null) {
80+
if (
81+
value !== undefined &&
82+
value !== null &&
83+
typeof value !== 'object' &&
84+
!key.startsWith('_')
85+
) {
6386
// Set the value in a cell
6487
hfInstance.setCellContents({ sheet: sheetId, col: 0, row }, [
6588
[value],

packages/loot-core/src/server/rules/action.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import { currentDay, format, parseDate } from '../../shared/months';
1010
import { FIELD_TYPES } from '../../shared/rules';
1111
import type { TransactionForRules } from '../transactions/transaction-rules';
1212

13-
import { substituteBalanceOfLiterals } from './balanceOfFormula';
1413
import {
1514
CustomFunctionsPlugin,
1615
customFunctionsTranslations,
@@ -304,6 +303,10 @@ export class Action {
304303
hfInstance = HyperFormula.buildEmpty({
305304
licenseKey: 'gpl-v3',
306305
language: 'enUS',
306+
context: {
307+
balanceOfPrefetch:
308+
transaction['_balanceOfPrefetched'] ?? new Map(),
309+
},
307310
});
308311

309312
const sheetName = hfInstance.addSheet('Sheet1');
@@ -341,13 +344,8 @@ export class Action {
341344
hfInstance.addNamedExpression(key, cellValue);
342345
}
343346

344-
const evaluatedFormula = substituteBalanceOfLiterals(
345-
formula,
346-
transaction._balanceOfPrefetched,
347-
);
348-
349347
hfInstance.setCellContents({ sheet: sheetId, col: 0, row: 0 }, [
350-
[evaluatedFormula],
348+
[formula],
351349
]);
352350

353351
const cellAddress = { sheet: sheetId, col: 0, row: 0 };

packages/loot-core/src/server/rules/balanceOfFormula.test.ts

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import {
66
decodeBalanceOfQuotedLiteral,
77
extractBalanceOfLiterals,
88
resolveAccountIdForBalanceOf,
9-
substituteBalanceOfLiterals,
109
} from './balanceOfFormula';
1110

1211
describe('balanceOfFormula', () => {
@@ -25,19 +24,6 @@ describe('balanceOfFormula', () => {
2524
expect(decodeBalanceOfQuotedLiteral(String.raw`\"x\"`)).toBe('"x"');
2625
});
2726

28-
it('substituteBalanceOfLiterals replaces calls with cent literals', () => {
29-
const map = new Map([
30-
['Checking', 42],
31-
['id-1', 99],
32-
]);
33-
expect(substituteBalanceOfLiterals('=BALANCE_OF("Checking")+1', map)).toBe(
34-
'=42+1',
35-
);
36-
expect(substituteBalanceOfLiterals('=BALANCE_OF("Missing")', map)).toBe(
37-
'=0',
38-
);
39-
});
40-
4127
it('resolveAccountIdForBalanceOf prefers map key then name', () => {
4228
const id = 'acc-1';
4329
const a1: db.DbAccount = {

packages/loot-core/src/server/rules/balanceOfFormula.ts

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -54,21 +54,3 @@ export function resolveAccountIdForBalanceOf(
5454
}
5555
return null;
5656
}
57-
58-
/**
59-
* Replace each BALANCE_OF("…") with a cent literal so HyperFormula never needs
60-
* runtime DB (prefetch map is keyed by decoded string literals).
61-
*/
62-
export function substituteBalanceOfLiterals(
63-
formula: string,
64-
map: Map<string, number> | null | undefined,
65-
): string {
66-
return formula.replace(
67-
/BALANCE_OF\s*\(\s*"((?:[^"\\]|\\.)*)"\s*\)/gi,
68-
(_match, inner: string) => {
69-
const key = decodeBalanceOfQuotedLiteral(inner);
70-
const cents = map?.get(key) ?? 0;
71-
return String(cents);
72-
},
73-
);
74-
}

packages/loot-core/src/server/rules/customFunctions.ts

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,36 +26,55 @@ export class CustomFunctionsPlugin extends FunctionPlugin {
2626
},
2727
);
2828
}
29+
30+
balanceOf(ast: ProcedureAst, state: InterpreterState) {
31+
return this.runFunction(
32+
ast.args,
33+
state,
34+
this.metadata('BALANCE_OF'),
35+
(accountKey: string) => {
36+
const ctx = this.config.context as
37+
| { balanceOfPrefetch?: Map<string, number> }
38+
| undefined;
39+
return ctx?.balanceOfPrefetch?.get(accountKey) ?? 0;
40+
},
41+
);
42+
}
2943
}
3044

3145
CustomFunctionsPlugin.implementedFunctions = {
32-
INTEGER_TO_AMOUNT: {
33-
method: 'integerToAmount',
46+
BALANCE_OF: {
47+
method: 'balanceOf',
48+
parameters: [{ argumentType: FunctionArgumentType.STRING }],
49+
},
50+
FIXED: {
51+
method: 'fixed',
3452
parameters: [
3553
{ argumentType: FunctionArgumentType.NUMBER },
3654
{
3755
argumentType: FunctionArgumentType.NUMBER,
3856
optionalArg: true,
39-
defaultValue: 2,
57+
defaultValue: 0,
4058
},
4159
],
4260
},
43-
FIXED: {
44-
method: 'fixed',
61+
INTEGER_TO_AMOUNT: {
62+
method: 'integerToAmount',
4563
parameters: [
4664
{ argumentType: FunctionArgumentType.NUMBER },
4765
{
4866
argumentType: FunctionArgumentType.NUMBER,
4967
optionalArg: true,
50-
defaultValue: 0,
68+
defaultValue: 2,
5169
},
5270
],
5371
},
5472
};
5573

5674
export const customFunctionsTranslations = {
5775
enUS: {
58-
INTEGER_TO_AMOUNT: 'INTEGER_TO_AMOUNT',
76+
BALANCE_OF: 'BALANCE_OF',
5977
FIXED: 'FIXED',
78+
INTEGER_TO_AMOUNT: 'INTEGER_TO_AMOUNT',
6079
},
6180
};

0 commit comments

Comments
 (0)