Compare commits

..

12 Commits

Author SHA1 Message Date
Joel Jeremy Marquez
6494d8ef4e Fix lint errors 2026-03-20 16:28:01 -07:00
Joel Jeremy Marquez
1598b6ae81 Merge remote-tracking branch 'origin/master' into js-proxy 2026-03-20 16:13:26 -07:00
Joel Jeremy Marquez
c057a55a2a Fix tests 2026-03-20 16:13:21 -07:00
Matiss Janis Aboltins
23adf06cb0 Add post-merge hook to auto-install dependencies (#7248)
* [AI] Add post-merge hook to auto-install when yarn.lock changes

Mirrors the existing post-checkout hook behavior: after a git pull or
merge, if yarn.lock changed between ORIG_HEAD and HEAD, automatically
runs yarn install to keep dependencies in sync.

https://claude.ai/code/session_01JHoMhGANLTc1q67s1dUHrt

* Add release notes for PR #7248

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-20 22:59:05 +00:00
Joel Jeremy Marquez
573238a2e2 Fix tests and typecheck errors 2026-03-20 13:37:36 -07:00
autofix-ci[bot]
07ace07bcc [autofix.ci] apply automated fixes 2026-03-20 19:55:26 +00:00
Joel Jeremy Marquez
069da61644 Separate api app from mainApp 2026-03-20 12:54:33 -07:00
github-actions[bot]
54039b36c6 Add release notes for PR #7246 2026-03-20 19:29:57 +00:00
Matiss Janis Aboltins
a8a2d23e63 [AI] Improve CLI query command with new flags, subcommands, and docs (#7240)
* [AI] Improve CLI query command with new flags, subcommands, and docs

Add --last, --count, --where, --offset, --group-by flags and field:desc
order-by syntax to make the query command more usable for both humans
and AI agents. Add query tables/fields subcommands for schema discovery.
Expand CLI and ActualQL documentation with comprehensive examples.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [AI] Fix parseOrderBy empty field validation and misleading aggregate docs

Validate that order-by field names are non-empty (e.g. ":desc" now throws
a clear error). Move AVAILABLE_TABLES before first use. Update group-by
examples in CLI help text and docs to use --file with proper aggregate
JSON instead of misleading --select with plain field names.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [autofix.ci] apply automated fixes

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-03-20 19:01:20 +00:00
Joel Jeremy Marquez
ae4e1f9585 Use proxy in server's App framework to be able to call methods more naturally i.e. app.createPayee(...) vs. app.runHandler('createPayee', ...) 2026-03-20 11:56:22 -07:00
Joel Jeremy Marquez
d1f3f3ec10 Refactor app structure and handler invocation. Encapsulate handlers inside app and update all calls to go through the mainApp for consistency. 2026-03-20 10:17:25 -07:00
Joel Jeremy Marquez
269c5a1e48 Use javascript proxy to encapsulate the calls to loot core server via connection package's send 2026-03-19 15:36:36 -07:00
49 changed files with 1353 additions and 1335 deletions

7
.husky/post-merge Executable file
View File

@@ -0,0 +1,7 @@
#!/bin/sh
# Run yarn install after pulling/merging (if yarn.lock changed)
if git diff --name-only ORIG_HEAD HEAD | grep -q "^yarn.lock$"; then
echo "yarn.lock changed — running yarn install..."
yarn install
fi

View File

@@ -0,0 +1,346 @@
import * as api from '@actual-app/api';
import { Command } from 'commander';
import { printOutput } from '../output';
import { parseOrderBy, registerQueryCommand } from './query';
vi.mock('@actual-app/api', () => {
const queryObj = {
select: vi.fn().mockReturnThis(),
filter: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
offset: vi.fn().mockReturnThis(),
groupBy: vi.fn().mockReturnThis(),
calculate: vi.fn().mockReturnThis(),
};
return {
q: vi.fn().mockReturnValue(queryObj),
aqlQuery: vi.fn().mockResolvedValue({ data: [] }),
};
});
vi.mock('../connection', () => ({
withConnection: vi.fn((_opts, fn) => fn()),
}));
vi.mock('../output', () => ({
printOutput: vi.fn(),
}));
function createProgram(): Command {
const program = new Command();
program.option('--format <format>');
program.option('--server-url <url>');
program.option('--password <pw>');
program.option('--session-token <token>');
program.option('--sync-id <id>');
program.option('--data-dir <dir>');
program.option('--verbose');
program.exitOverride();
registerQueryCommand(program);
return program;
}
async function run(args: string[]) {
const program = createProgram();
await program.parseAsync(['node', 'test', ...args]);
}
function getQueryObj() {
return vi.mocked(api.q).mock.results[0]?.value;
}
describe('parseOrderBy', () => {
it('parses plain field names', () => {
expect(parseOrderBy('date')).toEqual(['date']);
});
it('parses field:desc', () => {
expect(parseOrderBy('date:desc')).toEqual([{ date: 'desc' }]);
});
it('parses field:asc', () => {
expect(parseOrderBy('amount:asc')).toEqual([{ amount: 'asc' }]);
});
it('parses multiple mixed fields', () => {
expect(parseOrderBy('date:desc,amount:asc,id')).toEqual([
{ date: 'desc' },
{ amount: 'asc' },
'id',
]);
});
it('throws on invalid direction', () => {
expect(() => parseOrderBy('date:backwards')).toThrow(
'Invalid order direction "backwards"',
);
});
it('throws on empty field', () => {
expect(() => parseOrderBy('date,,amount')).toThrow('empty field');
});
});
describe('query commands', () => {
let stderrSpy: ReturnType<typeof vi.spyOn>;
let stdoutSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
stderrSpy = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
stdoutSpy = vi
.spyOn(process.stdout, 'write')
.mockImplementation(() => true);
});
afterEach(() => {
stderrSpy.mockRestore();
stdoutSpy.mockRestore();
});
describe('run', () => {
it('builds a basic query from flags', async () => {
await run([
'query',
'run',
'--table',
'transactions',
'--select',
'date,amount',
'--limit',
'5',
]);
expect(api.q).toHaveBeenCalledWith('transactions');
const qObj = getQueryObj();
expect(qObj.select).toHaveBeenCalledWith(['date', 'amount']);
expect(qObj.limit).toHaveBeenCalledWith(5);
});
it('rejects unknown table name', async () => {
await expect(
run(['query', 'run', '--table', 'nonexistent']),
).rejects.toThrow('Unknown table "nonexistent"');
});
it('parses order-by with desc direction', async () => {
await run([
'query',
'run',
'--table',
'transactions',
'--order-by',
'date:desc,amount:asc',
]);
const qObj = getQueryObj();
expect(qObj.orderBy).toHaveBeenCalledWith([
{ date: 'desc' },
{ amount: 'asc' },
]);
});
it('passes --filter as JSON', async () => {
await run([
'query',
'run',
'--table',
'transactions',
'--filter',
'{"amount":{"$lt":0}}',
]);
const qObj = getQueryObj();
expect(qObj.filter).toHaveBeenCalledWith({ amount: { $lt: 0 } });
});
});
describe('--last flag', () => {
it('sets default table, select, orderBy, and limit', async () => {
await run(['query', 'run', '--last', '10']);
expect(api.q).toHaveBeenCalledWith('transactions');
const qObj = getQueryObj();
expect(qObj.select).toHaveBeenCalledWith([
'date',
'account.name',
'payee.name',
'category.name',
'amount',
'notes',
]);
expect(qObj.orderBy).toHaveBeenCalledWith([{ date: 'desc' }]);
expect(qObj.limit).toHaveBeenCalledWith(10);
});
it('allows explicit --select override', async () => {
await run(['query', 'run', '--last', '5', '--select', 'date,amount']);
const qObj = getQueryObj();
expect(qObj.select).toHaveBeenCalledWith(['date', 'amount']);
});
it('allows explicit --order-by override', async () => {
await run(['query', 'run', '--last', '5', '--order-by', 'amount:asc']);
const qObj = getQueryObj();
expect(qObj.orderBy).toHaveBeenCalledWith([{ amount: 'asc' }]);
});
it('allows --table transactions explicitly', async () => {
await run(['query', 'run', '--last', '5', '--table', 'transactions']);
expect(api.q).toHaveBeenCalledWith('transactions');
});
it('errors if --table is not transactions', async () => {
await expect(
run(['query', 'run', '--last', '5', '--table', 'accounts']),
).rejects.toThrow('--last implies --table transactions');
});
it('errors if --limit is also set', async () => {
await expect(
run(['query', 'run', '--last', '5', '--limit', '10']),
).rejects.toThrow('--last and --limit are mutually exclusive');
});
});
describe('--count flag', () => {
it('uses calculate with $count', async () => {
vi.mocked(api.aqlQuery).mockResolvedValueOnce({ data: 42 });
await run(['query', 'run', '--table', 'transactions', '--count']);
const qObj = getQueryObj();
expect(qObj.calculate).toHaveBeenCalledWith({ $count: '*' });
expect(printOutput).toHaveBeenCalledWith({ count: 42 }, undefined);
});
it('errors if --select is also set', async () => {
await expect(
run([
'query',
'run',
'--table',
'transactions',
'--count',
'--select',
'date',
]),
).rejects.toThrow('--count and --select are mutually exclusive');
});
});
describe('--where alias', () => {
it('works the same as --filter', async () => {
await run([
'query',
'run',
'--table',
'transactions',
'--where',
'{"amount":{"$gt":0}}',
]);
const qObj = getQueryObj();
expect(qObj.filter).toHaveBeenCalledWith({ amount: { $gt: 0 } });
});
it('errors if both --where and --filter are provided', async () => {
await expect(
run([
'query',
'run',
'--table',
'transactions',
'--where',
'{}',
'--filter',
'{}',
]),
).rejects.toThrow('--where and --filter are mutually exclusive');
});
});
describe('--offset flag', () => {
it('passes offset through to query', async () => {
await run([
'query',
'run',
'--table',
'transactions',
'--offset',
'20',
'--limit',
'10',
]);
const qObj = getQueryObj();
expect(qObj.offset).toHaveBeenCalledWith(20);
expect(qObj.limit).toHaveBeenCalledWith(10);
});
});
describe('--group-by flag', () => {
it('passes group-by through to query', async () => {
await run([
'query',
'run',
'--table',
'transactions',
'--group-by',
'category.name',
'--select',
'category.name,amount',
]);
const qObj = getQueryObj();
expect(qObj.groupBy).toHaveBeenCalledWith(['category.name']);
});
});
describe('tables subcommand', () => {
it('lists available tables', async () => {
await run(['query', 'tables']);
expect(printOutput).toHaveBeenCalledWith(
expect.arrayContaining([
{ name: 'transactions' },
{ name: 'accounts' },
{ name: 'categories' },
{ name: 'payees' },
]),
undefined,
);
});
});
describe('fields subcommand', () => {
it('lists fields for a known table', async () => {
await run(['query', 'fields', 'accounts']);
const output = vi.mocked(printOutput).mock.calls[0][0] as Array<{
name: string;
type: string;
}>;
expect(output).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'id', type: 'id' }),
expect.objectContaining({ name: 'name', type: 'string' }),
]),
);
});
it('errors on unknown table', async () => {
await expect(run(['query', 'fields', 'unknown'])).rejects.toThrow(
'Unknown table "unknown"',
);
});
});
});

View File

@@ -10,6 +10,115 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/**
* Parse order-by strings like "date:desc,amount:asc,id" into
* AQL orderBy format: [{ date: 'desc' }, { amount: 'asc' }, 'id']
*/
export function parseOrderBy(
input: string,
): Array<string | Record<string, string>> {
return input.split(',').map(part => {
const trimmed = part.trim();
if (!trimmed) {
throw new Error('--order-by contains an empty field');
}
const colonIndex = trimmed.indexOf(':');
if (colonIndex === -1) {
return trimmed;
}
const field = trimmed.slice(0, colonIndex).trim();
if (!field) {
throw new Error(
`Invalid order field in "${trimmed}". Field name cannot be empty.`,
);
}
const direction = trimmed.slice(colonIndex + 1);
if (direction !== 'asc' && direction !== 'desc') {
throw new Error(
`Invalid order direction "${direction}" for field "${field}". Expected "asc" or "desc".`,
);
}
return { [field]: direction };
});
}
// TODO: Import schema from API once it exposes table/field metadata
const TABLE_SCHEMA: Record<
string,
Record<string, { type: string; ref?: string }>
> = {
transactions: {
id: { type: 'id' },
account: { type: 'id', ref: 'accounts' },
date: { type: 'date' },
amount: { type: 'integer' },
payee: { type: 'id', ref: 'payees' },
category: { type: 'id', ref: 'categories' },
notes: { type: 'string' },
imported_id: { type: 'string' },
transfer_id: { type: 'id' },
cleared: { type: 'boolean' },
reconciled: { type: 'boolean' },
starting_balance_flag: { type: 'boolean' },
imported_payee: { type: 'string' },
is_parent: { type: 'boolean' },
is_child: { type: 'boolean' },
parent_id: { type: 'id' },
sort_order: { type: 'float' },
schedule: { type: 'id', ref: 'schedules' },
'account.name': { type: 'string', ref: 'accounts' },
'payee.name': { type: 'string', ref: 'payees' },
'category.name': { type: 'string', ref: 'categories' },
'category.group.name': { type: 'string', ref: 'category_groups' },
},
accounts: {
id: { type: 'id' },
name: { type: 'string' },
offbudget: { type: 'boolean' },
closed: { type: 'boolean' },
sort_order: { type: 'float' },
},
categories: {
id: { type: 'id' },
name: { type: 'string' },
is_income: { type: 'boolean' },
group_id: { type: 'id', ref: 'category_groups' },
sort_order: { type: 'float' },
hidden: { type: 'boolean' },
'group.name': { type: 'string', ref: 'category_groups' },
},
payees: {
id: { type: 'id' },
name: { type: 'string' },
transfer_acct: { type: 'id', ref: 'accounts' },
},
rules: {
id: { type: 'id' },
stage: { type: 'string' },
conditions_op: { type: 'string' },
conditions: { type: 'json' },
actions: { type: 'json' },
},
schedules: {
id: { type: 'id' },
name: { type: 'string' },
rule: { type: 'id', ref: 'rules' },
next_date: { type: 'date' },
completed: { type: 'boolean' },
},
};
const AVAILABLE_TABLES = Object.keys(TABLE_SCHEMA).join(', ');
const LAST_DEFAULT_SELECT = [
'date',
'account.name',
'payee.name',
'category.name',
'amount',
'notes',
];
function buildQueryFromFile(
parsed: Record<string, unknown>,
fallbackTable: string | undefined,
@@ -27,34 +136,125 @@ function buildQueryFromFile(
queryObj = queryObj.orderBy(parsed.orderBy);
}
if (typeof parsed.limit === 'number') queryObj = queryObj.limit(parsed.limit);
if (typeof parsed.offset === 'number') {
queryObj = queryObj.offset(parsed.offset);
}
if (Array.isArray(parsed.groupBy)) {
queryObj = queryObj.groupBy(parsed.groupBy);
}
return queryObj;
}
function buildQueryFromFlags(cmdOpts: Record<string, string | undefined>) {
if (!cmdOpts.table) {
throw new Error('--table is required (or use --file)');
}
let queryObj = api.q(cmdOpts.table);
const last = cmdOpts.last ? parseIntFlag(cmdOpts.last, '--last') : undefined;
if (cmdOpts.select) {
if (last !== undefined) {
if (cmdOpts.table && cmdOpts.table !== 'transactions') {
throw new Error(
'--last implies --table transactions. Cannot use with --table ' +
cmdOpts.table,
);
}
if (cmdOpts.limit) {
throw new Error('--last and --limit are mutually exclusive');
}
}
const table =
cmdOpts.table ?? (last !== undefined ? 'transactions' : undefined);
if (!table) {
throw new Error('--table is required (or use --file or --last)');
}
if (!(table in TABLE_SCHEMA)) {
throw new Error(
`Unknown table "${table}". Available tables: ${AVAILABLE_TABLES}`,
);
}
if (cmdOpts.where && cmdOpts.filter) {
throw new Error('--where and --filter are mutually exclusive');
}
if (cmdOpts.count && cmdOpts.select) {
throw new Error('--count and --select are mutually exclusive');
}
let queryObj = api.q(table);
if (cmdOpts.count) {
queryObj = queryObj.calculate({ $count: '*' });
} else if (cmdOpts.select) {
queryObj = queryObj.select(cmdOpts.select.split(','));
} else if (last !== undefined) {
queryObj = queryObj.select(LAST_DEFAULT_SELECT);
}
if (cmdOpts.filter) {
queryObj = queryObj.filter(JSON.parse(cmdOpts.filter));
const filterStr = cmdOpts.filter ?? cmdOpts.where;
if (filterStr) {
queryObj = queryObj.filter(JSON.parse(filterStr));
}
if (cmdOpts.orderBy) {
queryObj = queryObj.orderBy(cmdOpts.orderBy.split(','));
const orderByStr =
cmdOpts.orderBy ??
(last !== undefined && !cmdOpts.count ? 'date:desc' : undefined);
if (orderByStr) {
queryObj = queryObj.orderBy(parseOrderBy(orderByStr));
}
if (cmdOpts.limit) {
queryObj = queryObj.limit(parseIntFlag(cmdOpts.limit, '--limit'));
const limitVal =
last ??
(cmdOpts.limit ? parseIntFlag(cmdOpts.limit, '--limit') : undefined);
if (limitVal !== undefined) {
queryObj = queryObj.limit(limitVal);
}
if (cmdOpts.offset) {
queryObj = queryObj.offset(parseIntFlag(cmdOpts.offset, '--offset'));
}
if (cmdOpts.groupBy) {
queryObj = queryObj.groupBy(cmdOpts.groupBy.split(','));
}
return queryObj;
}
const RUN_EXAMPLES = `
Examples:
# Show last 5 transactions (shortcut)
actual query run --last 5
# Transactions ordered by date descending
actual query run --table transactions --select "date,amount,payee.name" --order-by "date:desc" --limit 10
# Filter with JSON (negative amounts = expenses)
actual query run --table transactions --filter '{"amount":{"$lt":0}}' --limit 5
# Count transactions
actual query run --table transactions --count
# Group by category (use --file for aggregate expressions)
echo '{"table":"transactions","groupBy":["category.name"],"select":["category.name",{"amount":{"$sum":"$amount"}}]}' | actual query run --file -
# Pagination
actual query run --table transactions --order-by "date:desc" --limit 10 --offset 20
# Use --where (alias for --filter)
actual query run --table transactions --where '{"payee.name":"Grocery Store"}' --limit 5
# Read query from a JSON file
actual query run --file query.json
# Pipe query from stdin
echo '{"table":"transactions","limit":5}' | actual query run --file -
Available tables: ${AVAILABLE_TABLES}
Use "actual query tables" and "actual query fields <table>" for schema info.
Common filter operators: $eq, $ne, $lt, $lte, $gt, $gte, $like, $and, $or
See ActualQL docs for full reference: https://actualbudget.org/docs/api/actual-ql/`;
export function registerQueryCommand(program: Command) {
const query = program
.command('query')
@@ -65,16 +265,34 @@ export function registerQueryCommand(program: Command) {
.description('Execute an AQL query')
.option(
'--table <table>',
'Table to query (transactions, accounts, categories, payees)',
'Table to query (use "actual query tables" to list available tables)',
)
.option('--select <fields>', 'Comma-separated fields to select')
.option('--filter <json>', 'Filter expression as JSON')
.option('--order-by <fields>', 'Comma-separated fields to order by')
.option('--filter <json>', 'Filter as JSON (e.g. \'{"amount":{"$lt":0}}\')')
.option(
'--where <json>',
'Alias for --filter (cannot be used together with --filter)',
)
.option(
'--order-by <fields>',
'Fields with optional direction: field1:desc,field2 (default: asc)',
)
.option('--limit <n>', 'Limit number of results')
.option('--offset <n>', 'Skip first N results (for pagination)')
.option(
'--last <n>',
'Show last N transactions (implies --table transactions, --order-by date:desc)',
)
.option('--count', 'Count matching rows instead of returning them')
.option(
'--group-by <fields>',
'Comma-separated fields to group by (use with aggregate selects)',
)
.option(
'--file <path>',
'Read full query object from JSON file (use - for stdin)',
)
.addHelpText('after', RUN_EXAMPLES)
.action(async cmdOpts => {
const opts = program.opts();
await withConnection(opts, async () => {
@@ -87,7 +305,40 @@ export function registerQueryCommand(program: Command) {
: buildQueryFromFlags(cmdOpts);
const result = await api.aqlQuery(queryObj);
printOutput(result, opts.format);
if (cmdOpts.count) {
printOutput({ count: result.data }, opts.format);
} else {
printOutput(result, opts.format);
}
});
});
query
.command('tables')
.description('List available tables for querying')
.action(() => {
const opts = program.opts();
const tables = Object.keys(TABLE_SCHEMA).map(name => ({ name }));
printOutput(tables, opts.format);
});
query
.command('fields <table>')
.description('List fields for a given table')
.action((table: string) => {
const opts = program.opts();
const schema = TABLE_SCHEMA[table];
if (!schema) {
throw new Error(
`Unknown table "${table}". Available tables: ${Object.keys(TABLE_SCHEMA).join(', ')}`,
);
}
const fields = Object.entries(schema).map(([name, info]) => ({
name,
type: info.type,
...(info.ref ? { ref: info.ref } : {}),
}));
printOutput(fields, opts.format);
});
}

View File

@@ -31,8 +31,7 @@
body,
button,
input {
font-family: var(
--font-family,
font-family:
'Inter Variable',
-apple-system,
BlinkMacSystemFont,
@@ -46,8 +45,7 @@
'Helvetica Neue',
'Helvetica',
'Arial',
sans-serif
);
sans-serif;
}
a {
@@ -69,8 +67,7 @@
input,
textarea {
font-size: 1em;
font-family: var(
--font-family,
font-family:
'Inter Variable',
-apple-system,
BlinkMacSystemFont,
@@ -84,8 +81,7 @@
'Helvetica Neue',
'Helvetica',
'Arial',
sans-serif
);
sans-serif;
font-feature-settings: 'ss01', 'ss04';
}

View File

@@ -1,10 +1,10 @@
/*
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Content-Security-Policy: default-src 'self' blob:; img-src 'self' blob: data:; script-src 'self' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src http: https:;
Content-Security-Policy: default-src 'self' blob:; img-src 'self' blob: data:; script-src 'self' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; connect-src http: https:;
/kcab/*
Content-Security-Policy: default-src 'self' blob:; img-src 'self' blob: data:; script-src 'self' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src http: https:;
Content-Security-Policy: default-src 'self' blob:; img-src 'self' blob: data:; script-src 'self' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; connect-src http: https:;
/*.wasm
Content-Type: application/wasm

View File

@@ -10,7 +10,7 @@ import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { send } from 'loot-core/platform/client/connection';
import { send, server } from 'loot-core/platform/client/connection';
import * as undo from 'loot-core/platform/client/undo';
import { getNormalisedString } from 'loot-core/shared/normalisation';
import { q } from 'loot-core/shared/query';
@@ -176,7 +176,7 @@ export function ManageRules({
let loadedRules = null;
if (payeeId) {
loadedRules = await send('payees-get-rules', {
loadedRules = await server.getPayeeRules({
id: payeeId,
});
} else {

View File

@@ -8,7 +8,7 @@ import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { send } from 'loot-core/platform/client/connection';
import { server } from 'loot-core/platform/client/connection';
import type { PayeeEntity } from 'loot-core/types/models';
import { MobileBackButton } from '@desktop-client/components/mobile/MobileBackButton';
@@ -58,7 +58,7 @@ export function MobilePayeeEditPage() {
}
try {
await send('payees-batch-change', {
await server.batchChangePayees({
updated: [{ id: payee.id, name: editedPayeeName.trim() }],
});
showUndoNotification({

View File

@@ -5,7 +5,7 @@ import { styles } from '@actual-app/components/styles';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { send } from 'loot-core/platform/client/connection';
import { server } from 'loot-core/platform/client/connection';
import { getNormalisedString } from 'loot-core/shared/normalisation';
import type { PayeeEntity, RuleEntity } from 'loot-core/types/models';
@@ -52,7 +52,7 @@ export function MobilePayeesPage() {
// View associated rules for the payee
if ((ruleCounts.get(payee.id) ?? 0) > 0) {
try {
const associatedRules: RuleEntity[] = await send('payees-get-rules', {
const associatedRules: RuleEntity[] = await server.getPayeeRules({
id: payee.id,
});
const ruleIds = associatedRules.map(rule => rule.id).join(',');
@@ -88,7 +88,7 @@ export function MobilePayeesPage() {
const handlePayeeDelete = useCallback(
async (payee: PayeeEntity) => {
try {
await send('payees-batch-change', { deleted: [{ id: payee.id }] });
await server.batchChangePayees({ deleted: [{ id: payee.id }] });
showUndoNotification({
message: t('Payee "{{name}}" deleted successfully', {
name: payee.name,

View File

@@ -7,7 +7,7 @@ import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { send } from 'loot-core/platform/client/connection';
import { send, server } from 'loot-core/platform/client/connection';
import type { PayeeEntity } from 'loot-core/types/models';
import type { TransObjectLiteral } from 'loot-core/types/util';
@@ -59,7 +59,7 @@ export function MergeUnusedPayeesModal({
const onMerge = useCallback(
async (targetPayee: PayeeEntity) => {
await send('payees-merge', {
await server.mergePayees({
targetId: targetPayee.id,
mergeIds: payees.map(payee => payee.id),
});

View File

@@ -2,7 +2,7 @@ import React, { useEffect } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { listen, send } from 'loot-core/platform/client/connection';
import { listen, server } from 'loot-core/platform/client/connection';
import * as undo from 'loot-core/platform/client/undo';
import type { UndoState } from 'loot-core/server/undo';
import { applyChanges } from 'loot-core/shared/util';
@@ -112,14 +112,14 @@ export function ManagePayeesWithData({
orphanedPayees={orphanedPayees}
initialSelectedIds={initialSelectedIds}
onBatchChange={async (changes: Diff<PayeeEntity>) => {
await send('payees-batch-change', changes);
await server.batchChangePayees(changes);
queryClient.setQueryData(
payeeQueries.listOrphaned().queryKey,
existing => applyChanges(changes, existing ?? []),
);
}}
onMerge={async ([targetId, ...mergeIds]) => {
await send('payees-merge', { targetId, mergeIds });
await server.mergePayees({ targetId, mergeIds });
const targetIdIsOrphan = orphanedPayees
.map(o => o.id)

View File

@@ -17,7 +17,6 @@ import { Link } from '@desktop-client/components/common/Link';
import { FixedSizeList } from '@desktop-client/components/FixedSizeList';
import { useThemeCatalog } from '@desktop-client/hooks/useThemeCatalog';
import {
embedThemeFonts,
extractRepoOwner,
fetchThemeCss,
generateThemeId,
@@ -167,12 +166,8 @@ export function ThemeInstaller({
setSelectedCatalogTheme(theme);
const normalizedRepo = normalizeGitHubRepo(theme.repo);
// Fetch CSS and embed any referenced font files as data: URIs
const cssWithFonts = fetchThemeCss(theme.repo).then(css =>
embedThemeFonts(css, theme.repo),
);
await installTheme({
css: cssWithFonts,
css: fetchThemeCss(theme.repo),
name: theme.name,
repo: normalizedRepo,
id: generateThemeId(normalizedRepo),

View File

@@ -1,4 +1,4 @@
import { send } from 'loot-core/platform/client/connection';
import { server } from 'loot-core/platform/client/connection';
import type { LocationCoordinates } from 'loot-core/shared/location-utils';
import type {
NearbyPayeeEntity,
@@ -68,7 +68,7 @@ export class SendApiLocationClient implements LocationApiClient {
payeeId: string,
coordinates: LocationCoordinates,
): Promise<string> {
return await send('payee-location-create', {
return await server.createPayeeLocation({
payeeId,
latitude: coordinates.latitude,
longitude: coordinates.longitude,
@@ -76,18 +76,18 @@ export class SendApiLocationClient implements LocationApiClient {
}
async getLocations(payeeId: string): Promise<PayeeLocationEntity[]> {
return await send('payee-locations-get', { payeeId });
return await server.getPayeeLocations({ payeeId });
}
async deleteLocation(locationId: string): Promise<void> {
await send('payee-location-delete', { id: locationId });
await server.deletePayeeLocation({ id: locationId });
}
async getNearbyPayees(
coordinates: LocationCoordinates,
maxDistance: number,
): Promise<NearbyPayeeEntity[]> {
const result = await send('payees-get-nearby', {
const result = await server.getNearbyPayees({
latitude: coordinates.latitude,
longitude: coordinates.longitude,
maxDistance,

View File

@@ -4,7 +4,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { QueryClient, QueryKey } from '@tanstack/react-query';
import { v4 as uuidv4 } from 'uuid';
import { send } from 'loot-core/platform/client/connection';
import { server } from 'loot-core/platform/client/connection';
import type { PayeeEntity } from 'loot-core/types/models';
import { locationService } from './location';
@@ -99,7 +99,7 @@ export function useCreatePayeeMutation() {
return useMutation({
mutationFn: async ({ name }: CreatePayeePayload) => {
const id: PayeeEntity['id'] = await send('payee-create', {
const id: PayeeEntity['id'] = await server.createPayee({
name: name.trim(),
});
return id;

View File

@@ -2,7 +2,7 @@ import { queryOptions } from '@tanstack/react-query';
import { t } from 'i18next';
import memoizeOne from 'memoize-one';
import { send } from 'loot-core/platform/client/connection';
import { server } from 'loot-core/platform/client/connection';
import { groupById } from 'loot-core/shared/util';
import type {
AccountEntity,
@@ -21,7 +21,7 @@ export const payeeQueries = {
queryOptions<PayeeEntity[]>({
queryKey: [...payeeQueries.lists()],
queryFn: async () => {
const payees: PayeeEntity[] = (await send('payees-get')) ?? [];
const payees: PayeeEntity[] = (await server.getPayees()) ?? [];
return translatePayees(payees);
},
placeholderData: [],
@@ -32,7 +32,7 @@ export const payeeQueries = {
queryOptions<PayeeEntity[]>({
queryKey: [...payeeQueries.lists(), 'common'],
queryFn: async () => {
const payees: PayeeEntity[] = (await send('common-payees-get')) ?? [];
const payees: PayeeEntity[] = (await server.getCommonPayees()) ?? [];
return translatePayees(payees);
},
placeholderData: [],
@@ -44,7 +44,7 @@ export const payeeQueries = {
queryKey: [...payeeQueries.lists(), 'orphaned'],
queryFn: async () => {
const payees: Pick<PayeeEntity, 'id'>[] =
(await send('payees-get-orphaned')) ?? [];
(await server.getOrphanedPayees()) ?? [];
return payees;
},
placeholderData: [],
@@ -55,7 +55,7 @@ export const payeeQueries = {
queryOptions<Map<PayeeEntity['id'], number>>({
queryKey: [...payeeQueries.lists(), 'ruleCounts'],
queryFn: async () => {
const counts = await send('payees-get-rule-counts');
const counts = await server.getPayeeRuleCounts();
return new Map(Object.entries(counts ?? {}));
},
placeholderData: new Map(),

View File

@@ -1,23 +1,9 @@
// oxlint-disable eslint/no-script-url
import { afterEach, describe, expect, it, vi } from 'vitest';
import { describe, expect, it } from 'vitest';
import {
embedThemeFonts,
MAX_FONT_FILE_SIZE,
parseInstalledTheme,
validateThemeCss,
} from './customThemes';
import { parseInstalledTheme, validateThemeCss } from './customThemes';
import type { InstalledTheme } from './customThemes';
// Small valid woff2 data URI for testing (actual content doesn't matter for validation)
const TINY_WOFF2_BASE64 = 'AAAAAAAAAA==';
const TINY_WOFF2_DATA_URI = `data:font/woff2;base64,${TINY_WOFF2_BASE64}`;
const FONT_FACE_BLOCK = `@font-face {
font-family: 'Test Font';
src: url('${TINY_WOFF2_DATA_URI}') format('woff2');
font-display: swap;
}`;
describe('validateThemeCss', () => {
describe('valid CSS', () => {
it('should accept valid :root with CSS variables', () => {
@@ -88,7 +74,7 @@ describe('validateThemeCss', () => {
},
])('should reject $description', ({ css }) => {
expect(() => validateThemeCss(css)).toThrow(
'Theme CSS must contain :root { ... } with CSS variable definitions. No other selectors or content allowed.',
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
);
});
});
@@ -104,7 +90,7 @@ describe('validateThemeCss', () => {
color: red;
}`,
expectedError:
'Theme CSS must contain :root { ... } with CSS variable definitions. No other selectors or content allowed.',
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
},
{
description: 'multiple selectors',
@@ -115,7 +101,7 @@ describe('validateThemeCss', () => {
--color-primary: #ffffff;
}`,
expectedError:
'Theme CSS must contain :root { ... } with CSS variable definitions. No other selectors or content allowed.',
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
},
{
description: 'media queries',
@@ -128,7 +114,7 @@ describe('validateThemeCss', () => {
}
}`,
expectedError:
'Theme CSS must contain :root { ... } with CSS variable definitions. No other selectors or content allowed.',
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
},
{
description: 'custom selector before :root',
@@ -139,7 +125,7 @@ describe('validateThemeCss', () => {
--color-primary: #007bff;
}`,
expectedError:
'Theme CSS must contain :root { ... } with CSS variable definitions. No other selectors or content allowed.',
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
},
])('should reject CSS with $description', ({ css, expectedError }) => {
expect(() => validateThemeCss(css)).toThrow(expectedError);
@@ -285,7 +271,7 @@ describe('validateThemeCss', () => {
},
])('should reject $description', ({ css }) => {
expect(() => validateThemeCss(css)).toThrow(
'Theme CSS must contain :root { ... } with CSS variable definitions. No other selectors or content allowed.',
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
);
});
});
@@ -793,6 +779,12 @@ describe('validateThemeCss', () => {
--spacing: 10px 20px;
}`,
},
{
description: 'value with comma-separated values',
css: `:root {
--font-family: Arial, sans-serif;
}`,
},
{
description: 'property name with invalid characters',
css: `:root {
@@ -876,337 +868,6 @@ describe('validateThemeCss', () => {
});
});
describe('validateThemeCss - font properties (--font-*)', () => {
describe('valid font-family values', () => {
it.each([
{
description: 'single generic family',
css: `:root { --font-family: sans-serif; }`,
},
{
description: 'single generic family (serif)',
css: `:root { --font-family: serif; }`,
},
{
description: 'single generic family (monospace)',
css: `:root { --font-family: monospace; }`,
},
{
description: 'system-ui keyword',
css: `:root { --font-family: system-ui; }`,
},
{
description: 'bundled font (Inter Variable)',
css: `:root { --font-family: Inter Variable; }`,
},
{
description: 'quoted bundled font',
css: `:root { --font-family: 'Inter Variable'; }`,
},
{
description: 'double-quoted bundled font',
css: `:root { --font-family: "Inter Variable"; }`,
},
{
description: 'web-safe font (Georgia)',
css: `:root { --font-family: Georgia; }`,
},
{
description: 'web-safe font (Times New Roman) quoted',
css: `:root { --font-family: 'Times New Roman'; }`,
},
{
description: 'comma-separated font stack',
css: `:root { --font-family: Georgia, serif; }`,
},
{
description: 'full font stack with multiple fonts',
css: `:root { --font-family: 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; }`,
},
{
description: 'monospace font stack',
css: `:root { --font-mono: 'Fira Code', Consolas, Monaco, monospace; }`,
},
{
description: 'case-insensitive matching (arial)',
css: `:root { --font-family: arial; }`,
},
{
description: 'case-insensitive matching (GEORGIA)',
css: `:root { --font-family: GEORGIA; }`,
},
{
description: 'macOS system font',
css: `:root { --font-family: 'SF Pro', -apple-system, sans-serif; }`,
},
{
description: 'mixed with color variables',
css: `:root {
--color-primary: #007bff;
--font-family: Georgia, serif;
--color-secondary: #6c757d;
}`,
},
{
description: '--font-mono property',
css: `:root { --font-mono: 'JetBrains Mono', 'Fira Code', monospace; }`,
},
{
description: '--font-heading property',
css: `:root { --font-heading: Palatino, 'Book Antiqua', serif; }`,
},
])('should accept CSS with $description', ({ css }) => {
expect(() => validateThemeCss(css)).not.toThrow();
});
});
describe('invalid font-family values - security', () => {
it.each([
{
description: 'empty value',
css: `:root { --font-family: ; }`,
expectedPattern: /value must not be empty/,
},
{
description: 'url() function in font value',
css: `:root { --font-family: url('https://evil.com/font.woff2'); }`,
expectedPattern: /function calls are not allowed/,
},
{
description: 'url() with data: URI',
css: `:root { --font-family: url(data:font/woff2;base64,abc123); }`,
expectedPattern: /function calls are not allowed/,
},
{
description: 'expression() in font value',
css: `:root { --font-family: expression(alert(1)); }`,
expectedPattern: /function calls are not allowed/,
},
{
description: 'empty font name between commas',
css: `:root { --font-family: Arial, , sans-serif; }`,
expectedPattern: /empty font name/,
},
{
description: 'Google Fonts URL attempt',
css: `:root { --font-family: url(https://fonts.googleapis.com/css2?family=Roboto); }`,
expectedPattern: /function calls are not allowed/,
},
{
description: 'local() function',
css: `:root { --font-family: local(Arial); }`,
expectedPattern: /function calls are not allowed/,
},
{
description: 'format() function',
css: `:root { --font-family: format('woff2'); }`,
expectedPattern: /function calls are not allowed/,
},
{
description: 'rgb() function in font property',
css: `:root { --font-family: rgb(0, 0, 0); }`,
expectedPattern: /function calls are not allowed/,
},
])('should reject CSS with $description', ({ css, expectedPattern }) => {
expect(() => validateThemeCss(css)).toThrow(expectedPattern);
});
});
describe('any font name is valid (no allowlist)', () => {
it.each([
{
description: 'Comic Sans MS',
css: `:root { --font-family: 'Comic Sans MS'; }`,
},
{
description: 'custom font name',
css: `:root { --font-family: 'My Custom Font', sans-serif; }`,
},
{
description: 'arbitrary string',
css: `:root { --font-family: something-random; }`,
},
{ description: 'Papyrus', css: `:root { --font-family: Papyrus; }` },
])('should accept $description as a font name', ({ css }) => {
expect(() => validateThemeCss(css)).not.toThrow();
});
});
});
describe('validateThemeCss - @font-face blocks', () => {
describe('valid @font-face with data: URIs', () => {
it('should accept @font-face with data: URI and :root', () => {
const css = `${FONT_FACE_BLOCK}
:root { --font-family: 'Test Font', sans-serif; }`;
expect(() => validateThemeCss(css)).not.toThrow();
});
it('should accept multiple @font-face blocks', () => {
const css = `@font-face {
font-family: 'Test Font';
src: url('${TINY_WOFF2_DATA_URI}') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Test Font';
src: url('${TINY_WOFF2_DATA_URI}') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
:root { --font-family: 'Test Font', sans-serif; }`;
expect(() => validateThemeCss(css)).not.toThrow();
});
it('should accept @font-face with font/woff MIME type', () => {
const css = `@font-face {
font-family: 'Woff Font';
src: url('data:font/woff;base64,${TINY_WOFF2_BASE64}') format('woff');
}
:root { --font-family: 'Woff Font', sans-serif; }`;
expect(() => validateThemeCss(css)).not.toThrow();
});
it('should accept @font-face with font/ttf MIME type', () => {
const css = `@font-face {
font-family: 'TTF Font';
src: url('data:font/ttf;base64,${TINY_WOFF2_BASE64}') format('truetype');
}
:root { --font-family: 'TTF Font', sans-serif; }`;
expect(() => validateThemeCss(css)).not.toThrow();
});
it('should accept @font-face with application/font-woff2 MIME type', () => {
const css = `@font-face {
font-family: 'App Font';
src: url('data:application/font-woff2;base64,${TINY_WOFF2_BASE64}') format('woff2');
}
:root { --font-family: 'App Font', sans-serif; }`;
expect(() => validateThemeCss(css)).not.toThrow();
});
it('should accept @font-face with font-stretch', () => {
const css = `@font-face {
font-family: 'Stretch Font';
src: url('${TINY_WOFF2_DATA_URI}') format('woff2');
font-stretch: condensed;
}
:root { --font-family: 'Stretch Font', sans-serif; }`;
expect(() => validateThemeCss(css)).not.toThrow();
});
it('should accept @font-face with unicode-range', () => {
const css = `@font-face {
font-family: 'Unicode Font';
src: url('${TINY_WOFF2_DATA_URI}') format('woff2');
unicode-range: U+0000-00FF;
}
:root { --font-family: 'Unicode Font', sans-serif; }`;
expect(() => validateThemeCss(css)).not.toThrow();
});
it('should allow custom font name in --font-family after @font-face declaration', () => {
const css = `@font-face {
font-family: 'My Custom Font';
src: url('${TINY_WOFF2_DATA_URI}') format('woff2');
}
:root { --font-family: 'My Custom Font', Georgia, serif; }`;
expect(() => validateThemeCss(css)).not.toThrow();
});
it('should accept @font-face alongside color variables', () => {
const css = `${FONT_FACE_BLOCK}
:root {
--color-primary: #007bff;
--font-family: 'Test Font', sans-serif;
--color-secondary: #6c757d;
}`;
expect(() => validateThemeCss(css)).not.toThrow();
});
});
describe('invalid @font-face - security', () => {
it('should reject @font-face with remote HTTP URL', () => {
const css = `@font-face {
font-family: 'Bad Font';
src: url('https://evil.com/font.woff2') format('woff2');
}
:root { --font-family: 'Bad Font', sans-serif; }`;
expect(() => validateThemeCss(css)).toThrow(/data: URIs/);
});
it('should reject @font-face with remote HTTPS URL', () => {
const css = `@font-face {
font-family: 'Bad Font';
src: url('https://fonts.example.com/custom.woff2') format('woff2');
}
:root { --font-family: 'Bad Font', sans-serif; }`;
expect(() => validateThemeCss(css)).toThrow(/data: URIs/);
});
it('should reject @font-face with relative URL (not embedded)', () => {
const css = `@font-face {
font-family: 'Bad Font';
src: url('./fonts/custom.woff2') format('woff2');
}
:root { --font-family: 'Bad Font', sans-serif; }`;
expect(() => validateThemeCss(css)).toThrow(/data: URIs/);
});
it('should reject @font-face with javascript: protocol', () => {
const css = `@font-face {
font-family: 'Bad Font';
src: url('javascript:alert(1)');
}
:root { --font-family: 'Bad Font', sans-serif; }`;
expect(() => validateThemeCss(css)).toThrow(/data: URIs/);
});
it('should accept any font name in --font-family (no allowlist)', () => {
const css = `@font-face {
font-family: 'Declared Font';
src: url('${TINY_WOFF2_DATA_URI}') format('woff2');
}
:root { --font-family: 'Undeclared Font', sans-serif; }`;
expect(() => validateThemeCss(css)).not.toThrow();
});
it('should reject oversized font data', () => {
// Create a base64 string that would decode to > MAX_FONT_FILE_SIZE
const oversizedBase64 = 'A'.repeat(
Math.ceil((MAX_FONT_FILE_SIZE * 4) / 3) + 100,
);
const css = `@font-face {
font-family: 'Big Font';
src: url('data:font/woff2;base64,${oversizedBase64}') format('woff2');
}
:root { --font-family: 'Big Font', sans-serif; }`;
expect(() => validateThemeCss(css)).toThrow(/maximum size/);
});
});
describe('CSS without @font-face still works', () => {
it('should accept plain :root without @font-face', () => {
const css = `:root { --color-primary: #007bff; }`;
expect(() => validateThemeCss(css)).not.toThrow();
});
it('should reject other at-rules (not @font-face)', () => {
const css = `@import url('other.css');
:root { --color-primary: #007bff; }`;
expect(() => validateThemeCss(css)).toThrow();
});
it('should reject @media outside :root', () => {
const css = `@media (max-width: 600px) { :root { --color-primary: #ff0000; } }
:root { --color-primary: #007bff; }`;
expect(() => validateThemeCss(css)).toThrow();
});
});
});
describe('parseInstalledTheme', () => {
describe('valid theme JSON', () => {
it('should parse valid theme with all required fields', () => {
@@ -1472,102 +1133,3 @@ describe('parseInstalledTheme', () => {
});
});
});
describe('embedThemeFonts', () => {
const mockFetch = (
responseBody: ArrayBuffer,
ok = true,
status = 200,
): typeof globalThis.fetch =>
vi.fn().mockResolvedValue({
ok,
status,
statusText: ok ? 'OK' : 'Not Found',
arrayBuffer: () => Promise.resolve(responseBody),
} as Partial<Response>);
const tinyBuffer = new Uint8Array([0, 0, 0, 0, 0, 0, 0, 0]).buffer;
afterEach(() => {
vi.restoreAllMocks();
});
it('should rewrite url() references to data URIs', async () => {
vi.stubGlobal('fetch', mockFetch(tinyBuffer));
const css = `@font-face {
font-family: 'Test';
src: url('fonts/test.woff2') format('woff2');
}
:root { --color-primary: #007bff; }`;
const result = await embedThemeFonts(css, 'owner/repo');
expect(result).toContain('data:font/woff2;base64,');
expect(result).not.toContain('fonts/test.woff2');
expect(result).toContain(':root');
});
it('should handle quoted filenames with spaces', async () => {
vi.stubGlobal('fetch', mockFetch(tinyBuffer));
const css = `@font-face {
font-family: 'Inter';
src: url("Inter Variable.woff2") format('woff2');
}
:root { --color-primary: #007bff; }`;
const result = await embedThemeFonts(css, 'owner/repo');
expect(result).toContain('data:font/woff2;base64,');
expect(result).not.toContain('Inter Variable.woff2');
});
it('should reject path traversal with ".."', async () => {
const css = `@font-face {
font-family: 'Evil';
src: url('../escape/font.woff2') format('woff2');
}
:root { --color-primary: #007bff; }`;
await expect(embedThemeFonts(css, 'owner/repo')).rejects.toThrow(
'is not allowed',
);
});
it('should reject root-anchored paths', async () => {
const css = `@font-face {
font-family: 'Evil';
src: url('/etc/passwd') format('woff2');
}
:root { --color-primary: #007bff; }`;
await expect(embedThemeFonts(css, 'owner/repo')).rejects.toThrow(
'is not allowed',
);
});
it('should reject oversized font files', async () => {
const oversized = new ArrayBuffer(MAX_FONT_FILE_SIZE + 1);
vi.stubGlobal('fetch', mockFetch(oversized));
const css = `@font-face {
font-family: 'Big';
src: url('big.woff2') format('woff2');
}
:root { --color-primary: #007bff; }`;
await expect(embedThemeFonts(css, 'owner/repo')).rejects.toThrow(
'exceeds maximum size',
);
});
it('should return CSS unchanged when no url() refs exist', async () => {
const css = `@font-face {
font-family: 'Test';
src: url('${TINY_WOFF2_DATA_URI}') format('woff2');
}
:root { --color-primary: #007bff; }`;
const result = await embedThemeFonts(css, 'owner/repo');
expect(result).toBe(css);
});
});

View File

@@ -79,63 +79,6 @@ export async function fetchDirectCss(url: string): Promise<string> {
return response.text();
}
/** Strip surrounding single or double quotes from a string. */
function stripQuotes(s: string): string {
const t = s.trim();
if (
(t.startsWith("'") && t.endsWith("'")) ||
(t.startsWith('"') && t.endsWith('"'))
) {
return t.slice(1, -1).trim();
}
return t;
}
/**
* Validate a font-family value for a --font-* CSS variable.
*
* Any font name is allowed — referencing a font the user doesn't have
* installed simply triggers the browser's normal fallback behaviour
* (no network requests). The only things we block are function calls
* (url(), expression(), etc.) because those could load external resources
* or execute expressions.
*
* Quoted or unquoted font names are both accepted.
*
* Examples of accepted values:
* Georgia, serif
* 'Fira Code', monospace
* "My Theme Font", sans-serif
*/
function validateFontFamilyValue(value: string, property: string): void {
const trimmed = value.trim();
if (!trimmed) {
throw new Error(
`Invalid font-family value for "${property}": value must not be empty.`,
);
}
// Split on commas, then validate each font name
const families = trimmed.split(',');
for (const raw of families) {
const name = stripQuotes(raw);
if (!name) {
throw new Error(
`Invalid font-family value for "${property}": empty font name in comma-separated list.`,
);
}
// Reject anything that looks like a function call (url(), expression(), etc.)
if (/\(/.test(name)) {
throw new Error(
`Invalid font-family value for "${property}": function calls are not allowed. Only font names are permitted.`,
);
}
}
}
/** Only var(--custom-property-name) is allowed; no fallbacks. Variable name: -- then [a-zA-Z0-9_-]+ (no trailing dash). */
const VAR_ONLY_PATTERN = /^var\s*\(\s*(--[a-zA-Z0-9_-]+)\s*\)$/i;
@@ -149,15 +92,8 @@ function isValidSimpleVarValue(value: string): boolean {
/**
* Validate that a CSS property value only contains allowed content (allowlist approach).
* Allows: colors (hex, rgb/rgba, hsl/hsla), lengths, numbers, keywords, and var(--name) only (no fallbacks).
* Font properties (--font-*) are validated against a safe font family allowlist instead.
*/
function validatePropertyValue(value: string, property: string): void {
// Font properties use a dedicated validator that accepts any font name
// but rejects function calls (url(), expression(), etc.).
if (/^--font-/i.test(property)) {
validateFontFamilyValue(value, property);
return;
}
if (!value || value.length === 0) {
return; // Empty values are allowed
}
@@ -209,152 +145,79 @@ function validatePropertyValue(value: string, property: string): void {
);
}
// ─── @font-face validation ──────────────────────────────────────────────────
/** Maximum size of a single base64-encoded font (bytes of decoded data). 2 MB. */
export const MAX_FONT_FILE_SIZE = 2 * 1024 * 1024;
/** Maximum total size of all embedded font data across all @font-face blocks. 10 MB. */
export const MAX_TOTAL_FONT_SIZE = 10 * 1024 * 1024;
/**
* Extract @font-face blocks from CSS. Returns the blocks and the remaining CSS.
* Only matches top-level @font-face blocks (not nested inside other rules).
* Validate that CSS contains only :root { ... } with CSS custom property (variable) declarations.
* Must contain exactly :root { ... } and nothing else.
* Returns the validated CSS or throws an error.
*/
function extractFontFaceBlocks(css: string): {
fontFaceBlocks: string[];
remaining: string;
} {
const fontFaceBlocks: string[] = [];
let remaining = css;
export function validateThemeCss(css: string): string {
// Strip multi-line comments before validation
// Note: Single-line comments (//) are not stripped to avoid corrupting CSS values like URLs
const cleaned = css.replace(/\/\*[\s\S]*?\*\//g, '').trim();
// Extract @font-face { ... } blocks one at a time using indexOf-based
// parsing. Each iteration removes the matched block from `remaining`.
for (;;) {
const atIdx = remaining.indexOf('@font-face');
if (atIdx === -1) break;
const openBrace = remaining.indexOf('{', atIdx);
if (openBrace === -1) break;
const closeBrace = remaining.indexOf('}', openBrace + 1);
if (closeBrace === -1) break;
fontFaceBlocks.push(remaining.substring(openBrace + 1, closeBrace).trim());
remaining =
remaining.substring(0, atIdx) + remaining.substring(closeBrace + 1);
}
return { fontFaceBlocks, remaining: remaining.trim() };
}
/**
* Validate @font-face blocks: only data: URIs allowed (no remote URLs).
* Enforces size limits to prevent DoS.
*/
function validateFontFaceBlocks(fontFaceBlocks: string[]): void {
let totalSize = 0;
// Match url() with quoted or unquoted content. Quoted URLs use a non-greedy
// match up to the closing quote; unquoted URLs match non-whitespace/non-paren.
const urlRegex = /url\(\s*(?:'([^']*)'|"([^"]*)"|([^'")\s]+))\s*\)/g;
for (const block of fontFaceBlocks) {
urlRegex.lastIndex = 0;
let match;
while ((match = urlRegex.exec(block)) !== null) {
const uri = (match[1] ?? match[2] ?? match[3]).trim();
if (!uri.startsWith('data:')) {
throw new Error(
'Invalid font src: only data: URIs are allowed in @font-face. ' +
'Remote URLs (http/https) are not permitted to protect user privacy. ' +
'Font files are automatically embedded when installing from GitHub.',
);
}
// Estimate decoded size from base64 content
const base64Match = uri.match(/;base64,(.+)$/);
if (base64Match) {
const size = Math.ceil((base64Match[1].length * 3) / 4);
if (size > MAX_FONT_FILE_SIZE) {
throw new Error(
`Font file exceeds maximum size of ${MAX_FONT_FILE_SIZE / 1024 / 1024}MB.`,
);
}
totalSize += size;
}
}
}
if (totalSize > MAX_TOTAL_FONT_SIZE) {
// Must contain exactly :root { ... } and nothing else
// Find :root { ... } and extract content, then check there's nothing after
const rootMatch = cleaned.match(/^:root\s*\{/);
if (!rootMatch) {
throw new Error(
`Total embedded font data exceeds maximum of ${MAX_TOTAL_FONT_SIZE / 1024 / 1024}MB.`,
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
);
}
}
/**
* Split CSS declarations by semicolons, but respect quoted strings and url() contents.
* This is needed because data: URIs contain semicolons (e.g., "data:font/woff2;base64,...").
*/
function splitDeclarations(content: string): string[] {
const declarations: string[] = [];
let start = 0;
let inSingleQuote = false;
let inDoubleQuote = false;
let parenDepth = 0;
// Find the opening brace after :root
const rootStart = cleaned.indexOf(':root');
const openBrace = cleaned.indexOf('{', rootStart);
for (let i = 0; i < content.length; i++) {
const ch = content[i];
if (ch === "'" && !inDoubleQuote && parenDepth === 0) {
inSingleQuote = !inSingleQuote;
} else if (ch === '"' && !inSingleQuote && parenDepth === 0) {
inDoubleQuote = !inDoubleQuote;
} else if (ch === '(' && !inSingleQuote && !inDoubleQuote) {
parenDepth++;
} else if (
ch === ')' &&
!inSingleQuote &&
!inDoubleQuote &&
parenDepth > 0
) {
parenDepth--;
}
if (ch === ';' && !inSingleQuote && !inDoubleQuote && parenDepth === 0) {
const trimmed = content.substring(start, i).trim();
if (trimmed) declarations.push(trimmed);
start = i + 1;
}
if (openBrace === -1) {
throw new Error(
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
);
}
const trimmed = content.substring(start).trim();
if (trimmed) declarations.push(trimmed);
// Find the first closing brace (nested blocks will be caught by the check below)
const closeBrace = cleaned.indexOf('}', openBrace + 1);
return declarations;
}
if (closeBrace === -1) {
throw new Error(
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
);
}
// ─── :root block validation ─────────────────────────────────────────────────
// Extract content inside :root { ... }
const rootContent = cleaned.substring(openBrace + 1, closeBrace).trim();
/**
* Validate the content inside a :root { ... } block.
* Only CSS custom properties (--*) with safe values are allowed.
*/
function validateRootContent(rootContent: string): void {
// Check for forbidden at-rules inside :root
// Check for forbidden at-rules first (before nested block check, since at-rules with braces would trigger that)
// Comprehensive list of CSS at-rules that should not be allowed
// This includes @import, @media, @keyframes, @font-face, @supports, @charset,
// @namespace, @page, @layer, @container, @scope, and any other at-rules
if (/@[a-z-]+/i.test(rootContent)) {
throw new Error(
'Theme CSS contains forbidden at-rules (@import, @media, @keyframes, etc.). Only CSS variable declarations are allowed inside :root { ... }.',
);
}
// Check for nested blocks
// Check for nested blocks (additional selectors) - should not have any { after extracting :root content
if (/\{/.test(rootContent)) {
throw new Error(
'Theme CSS contains nested blocks or additional selectors. Only CSS variable declarations are allowed inside :root { ... }.',
);
}
for (const decl of splitDeclarations(rootContent)) {
// Check that there's nothing after the closing brace
const afterRoot = cleaned.substring(closeBrace + 1).trim();
if (afterRoot.length > 0) {
throw new Error(
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
);
}
// Parse declarations and validate each one
const declarations = rootContent
.split(';')
.map(d => d.trim())
.filter(d => d.length > 0);
for (const decl of declarations) {
const colonIndex = decl.indexOf(':');
if (colonIndex === -1) {
throw new Error(`Invalid CSS declaration: "${decl}"`);
@@ -408,214 +271,9 @@ function validateRootContent(rootContent: string): void {
const value = decl.substring(colonIndex + 1).trim();
validatePropertyValue(value, property);
}
}
// ─── Main validation entry point ────────────────────────────────────────────
/**
* Validate theme CSS. Accepts:
* 1. Optional @font-face blocks (with data: URI fonts only)
* 2. Exactly one :root { ... } block with CSS variable declarations
*
* @font-face blocks must appear before :root.
* Returns the validated CSS or throws an error.
*/
export function validateThemeCss(css: string): string {
// Strip multi-line comments before validation
const cleaned = css.replace(/\/\*[\s\S]*?\*\//g, '').trim();
// Extract @font-face blocks (if any) from the CSS
const { fontFaceBlocks, remaining } = extractFontFaceBlocks(cleaned);
// Validate @font-face blocks (reject remote URLs, enforce size limits)
validateFontFaceBlocks(fontFaceBlocks);
// Now validate the remaining CSS (should be exactly :root { ... })
const rootMatch = remaining.match(/^:root\s*\{/);
if (!rootMatch) {
// If there are @font-face blocks but no :root, that's an error
// If there's nothing at all, that's also an error
throw new Error(
'Theme CSS must contain :root { ... } with CSS variable definitions. No other selectors or content allowed.',
);
}
const rootStart = remaining.indexOf(':root');
const openBrace = remaining.indexOf('{', rootStart);
if (openBrace === -1) {
throw new Error(
'Theme CSS must contain :root { ... } with CSS variable definitions. No other selectors or content allowed.',
);
}
const closeBrace = remaining.indexOf('}', openBrace + 1);
if (closeBrace === -1) {
throw new Error(
'Theme CSS must contain :root { ... } with CSS variable definitions. No other selectors or content allowed.',
);
}
const rootContent = remaining.substring(openBrace + 1, closeBrace).trim();
// Validate :root content
validateRootContent(rootContent);
// Check nothing after :root
const afterRoot = remaining.substring(closeBrace + 1).trim();
if (afterRoot.length > 0) {
throw new Error(
'Theme CSS must contain :root { ... } with CSS variable definitions. No other selectors or content allowed.',
);
}
// Return the comment-stripped CSS — this is what was actually validated,
// so we inject exactly what we checked (defense-in-depth).
return cleaned;
}
// ─── Font embedding (install-time) ─────────────────────────────────────────
/** Map of file extensions to font MIME types for data: URI construction. */
const FONT_EXTENSION_MIME: Record<string, string> = {
'.woff2': 'font/woff2',
'.woff': 'font/woff',
'.ttf': 'font/ttf',
'.otf': 'font/opentype',
};
/** Convert an ArrayBuffer to a base64 string using chunked processing. */
function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
const chunks: string[] = [];
// Process in 8 KB chunks to avoid excessive string concatenation
for (let i = 0; i < bytes.length; i += 8192) {
const chunk = bytes.subarray(i, Math.min(i + 8192, bytes.length));
chunks.push(String.fromCharCode(...chunk));
}
return btoa(chunks.join(''));
}
/**
* Embed fonts referenced in @font-face blocks by fetching them from a GitHub
* repo and converting to data: URIs.
*
* This runs at install time only. Relative URL references like
* `url('./fonts/MyFont.woff2')` are resolved relative to the repo's root
* directory and fetched from GitHub's raw content API.
*
* The returned CSS has all font URLs replaced with self-contained data: URIs,
* so no network requests are needed at runtime.
*
* @param css - The raw theme CSS (may contain relative url() references)
* @param repo - GitHub repo in "owner/repo" format
* @returns CSS with all font URLs replaced by data: URIs
*/
export async function embedThemeFonts(
css: string,
repo: string,
): Promise<string> {
const baseUrl = `https://raw.githubusercontent.com/${repo}/refs/heads/main/`;
// Collect all url() references that need fetching across all @font-face blocks
const urlRegex = /url\(\s*(?:(['"])([^'"]*?)\1|([^'")\s]+))\s*\)/g;
type FontRef = {
fullMatch: string;
quote: string;
path: string;
cleanPath: string;
mime: string;
};
const fontRefs: FontRef[] = [];
// Use extractFontFaceBlocks-style indexOf parsing to find @font-face blocks
// and their url() references, without duplicating the regex approach
let searchCss = css;
let offset = 0;
for (;;) {
const atIdx = searchCss.indexOf('@font-face', 0);
if (atIdx === -1) break;
const openBrace = searchCss.indexOf('{', atIdx);
if (openBrace === -1) break;
const closeBrace = searchCss.indexOf('}', openBrace + 1);
if (closeBrace === -1) break;
const blockContent = searchCss.substring(openBrace + 1, closeBrace);
// Find url() references within this block
let urlMatch;
urlRegex.lastIndex = 0;
while ((urlMatch = urlRegex.exec(blockContent)) !== null) {
const fullMatch = urlMatch[0];
const quote = urlMatch[1] || '';
const path = urlMatch[2] ?? urlMatch[3];
// Skip data: URIs — already embedded
if (path.startsWith('data:')) continue;
if (/^https?:\/\//i.test(path)) {
throw new Error(
`Remote font URL "${path}" is not allowed. Only relative paths to fonts in the same GitHub repo are supported.`,
);
}
const cleanPath = path.replace(/^\.\//, '');
if (cleanPath.startsWith('/') || cleanPath.includes('..')) {
throw new Error(
`Font path "${path}" is not allowed. Only relative paths within the repo are supported (no "/" prefix or ".." segments).`,
);
}
const ext = cleanPath.substring(cleanPath.lastIndexOf('.')).toLowerCase();
const mime = FONT_EXTENSION_MIME[ext];
if (!mime) {
throw new Error(
`Unsupported font file extension "${ext}". Supported: ${Object.keys(FONT_EXTENSION_MIME).join(', ')}.`,
);
}
fontRefs.push({ fullMatch, quote, path, cleanPath, mime });
}
offset = closeBrace + 1;
searchCss = searchCss.substring(offset);
}
if (fontRefs.length === 0) return css;
// Fetch all fonts in parallel
const fetched = await Promise.all(
fontRefs.map(async ref => {
const fontUrl = baseUrl + ref.cleanPath;
const response = await fetch(fontUrl);
if (!response.ok) {
throw new Error(
`Failed to fetch font file "${ref.cleanPath}" from ${fontUrl}: ${response.status} ${response.statusText}`,
);
}
const buffer = await response.arrayBuffer();
if (buffer.byteLength > MAX_FONT_FILE_SIZE) {
throw new Error(
`Font file "${ref.cleanPath}" exceeds maximum size of ${MAX_FONT_FILE_SIZE / 1024 / 1024}MB.`,
);
}
const base64 = arrayBufferToBase64(buffer);
return { ref, dataUri: `data:${ref.mime};base64,${base64}` };
}),
);
// Replace each url() reference with its data: URI
let result = css;
for (const { ref, dataUri } of fetched) {
const q = ref.quote || "'";
result = result.replace(ref.fullMatch, `url(${q}${dataUri}${q})`);
}
return result;
// Return the original CSS (with :root wrapper) so it can be injected properly
return css.trim();
}
/**

View File

@@ -101,3 +101,30 @@ or
);
});
```
## CLI Usage
The examples above are shown in JavaScript. If you're using the [CLI tool](../cli.md), you can express many of the same queries with command-line flags. Here's how the JS patterns translate:
```bash
# Select specific fields (JS: .select(['date', 'amount', 'payee.name']))
actual query run --table transactions --select "date,amount,payee.name"
# Filter by condition (JS: .filter({ amount: { $lt: 0 } }))
actual query run --table transactions --filter '{"amount":{"$lt":0}}'
# Order by field descending (JS: .orderBy([{ date: 'desc' }]))
actual query run --table transactions --order-by "date:desc"
# Search by month (JS: .filter({ date: { $transform: '$month', $eq: '2021-01' } }))
actual query run --table transactions --filter '{"date":{"$transform":"$month","$eq":"2021-01"}}'
# Group by payee with sum — use --file for aggregate queries
echo '{"table":"transactions","groupBy":["payee.name"],"select":["payee.name",{"amount":{"$sum":"$amount"}}]}' | actual query run --file -
# Count transactions (JS: .calculate({ $count: '*' }))
actual query run --table transactions --count
# Quick shortcut: last 10 transactions
actual query run --last 10
```

View File

@@ -274,16 +274,80 @@ actual schedules delete <id>
### Query (ActualQL)
Run queries using [ActualQL](./actual-ql/index.md):
Run queries using [ActualQL](./actual-ql/index.md).
#### Subcommands
| Subcommand | Description |
| ---------------------- | --------------------------------- |
| `query run` | Execute an AQL query |
| `query tables` | List available tables |
| `query fields <table>` | List fields and types for a table |
#### `query run` Options
| Option | Description |
| --------------------- | ------------------------------------------------------------------------------------------- |
| `--table <table>` | Table to query (use `actual query tables` to list) |
| `--select <fields>` | Comma-separated fields to select |
| `--filter <json>` | Filter as JSON (e.g. `'{"amount":{"$lt":0}}'`) |
| `--where <json>` | Alias for `--filter` (cannot be used together) |
| `--order-by <fields>` | Fields with optional direction: `field1:desc,field2` (default: asc) |
| `--limit <n>` | Limit number of results |
| `--offset <n>` | Skip first N results (for pagination) |
| `--last <n>` | Show last N transactions (shortcut: implies `--table transactions`, `--order-by date:desc`) |
| `--count` | Count matching rows instead of returning them |
| `--group-by <fields>` | Comma-separated fields to group by |
| `--file <path>` | Read query from JSON file (use `-` for stdin) |
#### Examples
```bash
# Run a query (inline)
actual query run --table transactions --select "date,amount,payee" --filter '{"amount":{"$lt":0}}' --limit 10
# Show last 5 transactions (convenience shortcut)
actual query run --last 5
# Run a query (from file)
# Override default columns with --last
actual query run --last 10 --select "date,amount,notes"
# Transactions ordered by date descending with limit
actual query run --table transactions --select "date,amount,payee.name" --order-by "date:desc" --limit 10
# Filter with JSON — negative amounts (expenses)
actual query run --table transactions --filter '{"amount":{"$lt":0}}' --limit 5
# Use --where (alias for --filter, more intuitive for SQL users)
actual query run --table transactions --where '{"payee.name":"Grocery Store"}' --limit 5
# Count all transactions
actual query run --table transactions --count
# Count with a filter
actual query run --table transactions --filter '{"category.name":"Groceries"}' --count
# Group by category with aggregate (use --file for aggregate expressions)
echo '{"table":"transactions","groupBy":["category.name"],"select":["category.name",{"amount":{"$sum":"$amount"}}]}' | actual query run --file -
# Pagination: skip first 20, show next 10
actual query run --table transactions --order-by "date:desc" --limit 10 --offset 20
# Multi-field ordering
actual query run --table transactions --order-by "date:desc,amount:asc" --limit 10
# Run a query from a JSON file
actual query run --file query.json
# Pipe query from stdin
echo '{"table":"transactions","select":["date","amount"],"limit":5}' | actual query run --file -
# List available tables
actual query tables
# List fields for a table
actual query fields transactions
```
See [ActualQL](./actual-ql/index.md) for full filter/function reference including `$transform`, `$month`, `$year`, and aggregate functions.
### Server
```bash

View File

@@ -2,11 +2,12 @@
import { v4 as uuidv4 } from 'uuid';
import { addTransactions } from '../server/accounts/sync';
import type { App } from '../server/app';
import { aqlQuery } from '../server/aql';
import * as budgetActions from '../server/budget/actions';
import * as budget from '../server/budget/base';
import * as db from '../server/db';
import { runHandler, runMutator } from '../server/mutators';
import { runMutator } from '../server/mutators';
import * as sheet from '../server/sheet';
import { batchMessages, setSyncingMode } from '../server/sync';
import * as monthUtils from '../shared/months';
@@ -84,7 +85,6 @@ function extractCommonThings(
}
async function fillPrimaryChecking(
handlers,
account,
payees: MockPayeeEntity[],
groups: CategoryGroupEntity[],
@@ -255,7 +255,7 @@ async function fillPrimaryChecking(
return addTransactions(account.id, transactions);
}
async function fillChecking(handlers, account, payees, groups) {
async function fillChecking(app, account, payees, groups) {
const { incomePayee, expensePayees, incomeGroup, expenseCategories } =
extractCommonThings(payees, groups);
const numTransactions = integer(20, 40);
@@ -297,13 +297,13 @@ async function fillChecking(handlers, account, payees, groups) {
starting_balance_flag: true,
});
await handlers['transactions-batch-update']({
await app.runHandler('transactions-batch-update', {
added: transactions,
fastMode: true,
});
}
async function fillInvestment(handlers, account, payees, groups) {
async function fillInvestment(app, account, payees, groups) {
const { incomePayee, incomeGroup } = extractCommonThings(payees, groups);
const numTransactions = integer(10, 30);
@@ -333,13 +333,13 @@ async function fillInvestment(handlers, account, payees, groups) {
starting_balance_flag: true,
});
await handlers['transactions-batch-update']({
await app.runHandler('transactions-batch-update', {
added: transactions,
fastMode: true,
});
}
async function fillSavings(handlers, account, payees, groups) {
async function fillSavings(app, account, payees, groups) {
const { incomePayee, expensePayees, incomeGroup, expenseCategories } =
extractCommonThings(payees, groups);
@@ -378,13 +378,13 @@ async function fillSavings(handlers, account, payees, groups) {
starting_balance_flag: true,
});
await handlers['transactions-batch-update']({
await app.runHandler('transactions-batch-update', {
added: transactions,
fastMode: true,
});
}
async function fillMortgage(handlers, account, payees, groups) {
async function fillMortgage(app, account, payees, groups) {
const { incomePayee, incomeGroup } = extractCommonThings(payees, groups);
const numTransactions = integer(7, 10);
@@ -415,13 +415,13 @@ async function fillMortgage(handlers, account, payees, groups) {
});
}
await handlers['transactions-batch-update']({
await app.runHandler('transactions-batch-update', {
added: transactions,
fastMode: true,
});
}
async function fillOther(handlers, account, payees, groups) {
async function fillOther(app, account, payees, groups) {
const { incomePayee, incomeGroup } = extractCommonThings(payees, groups);
const numTransactions = integer(3, 6);
@@ -453,7 +453,7 @@ async function fillOther(handlers, account, payees, groups) {
});
}
await handlers['transactions-batch-update']({
await app.runHandler('transactions-batch-update', {
added: transactions,
fastMode: true,
});
@@ -594,7 +594,7 @@ async function createBudget(accounts, payees, groups) {
await sheet.waitOnSpreadsheet();
}
export async function createTestBudget(handlers: Handlers) {
export async function createTestBudget(app: App<Handlers>) {
setSyncingMode('import');
db.execQuery('PRAGMA journal_mode = OFF');
@@ -618,7 +618,7 @@ export async function createTestBudget(handlers: Handlers) {
await runMutator(async () => {
for (const account of accounts) {
account.id = await handlers['account-create'](account);
account.id = await app['account-create'](account);
}
});
@@ -642,7 +642,7 @@ export async function createTestBudget(handlers: Handlers) {
await runMutator(() =>
batchMessages(async () => {
for (const newPayee of newPayees) {
const id = await handlers['payee-create']({ name: newPayee.name });
const id = await app['createPayee']({ name: newPayee.name });
payees.push({
id,
name: newPayee.name,
@@ -690,7 +690,7 @@ export async function createTestBudget(handlers: Handlers) {
await runMutator(async () => {
for (const group of newCategoryGroups) {
const groupId = await handlers['category-group-create']({
const groupId = await app['category-group-create']({
name: group.name,
isIncome: group.is_income,
});
@@ -702,7 +702,7 @@ export async function createTestBudget(handlers: Handlers) {
});
for (const category of group.categories) {
const categoryId = await handlers['category-create']({
const categoryId = await app['category-create']({
...category,
isIncome: category.is_income,
groupId,
@@ -717,7 +717,7 @@ export async function createTestBudget(handlers: Handlers) {
}
});
const allGroups = (await runHandler(handlers['get-categories'])).grouped;
const allGroups = (await app['get-categories']()).grouped;
setSyncingMode('import');
@@ -725,26 +725,26 @@ export async function createTestBudget(handlers: Handlers) {
batchMessages(async () => {
for (const account of accounts) {
if (account.name === 'Bank of America') {
await fillPrimaryChecking(handlers, account, payees, allGroups);
await fillPrimaryChecking(account, payees, allGroups);
} else if (
account.name === 'Capital One Checking' ||
account.name === 'HSBC'
) {
await fillChecking(handlers, account, payees, allGroups);
await fillChecking(app, account, payees, allGroups);
} else if (account.name === 'Ally Savings') {
await fillSavings(handlers, account, payees, allGroups);
await fillSavings(app, account, payees, allGroups);
} else if (
account.name === 'Vanguard 401k' ||
account.name === 'Roth IRA'
) {
await fillInvestment(handlers, account, payees, allGroups);
await fillInvestment(app, account, payees, allGroups);
} else if (account.name === 'Mortgage') {
await fillMortgage(handlers, account, payees, allGroups);
await fillMortgage(app, account, payees, allGroups);
} else if (account.name === 'House Asset') {
await fillOther(handlers, account, payees, allGroups);
await fillOther(app, account, payees, allGroups);
} else {
console.error('Unknown account name for test budget: ', account.name);
await fillChecking(handlers, account, payees, allGroups);
await fillChecking(app, account, payees, allGroups);
}
}
}),
@@ -773,7 +773,7 @@ export async function createTestBudget(handlers: Handlers) {
);
const lastDeposit = results[0];
await runHandler(handlers['transaction-update'], {
await app['transaction-update']({
...lastDeposit,
amount: lastDeposit.amount + -primaryBalance + integer(10000, 20000),
});
@@ -791,7 +791,7 @@ export async function createTestBudget(handlers: Handlers) {
batchMessages(async () => {
const account = accounts.find(acc => acc.name === 'Bank of America');
await runHandler(handlers['schedule/create'], {
await app['schedule/create']({
schedule: {
name: 'Phone bills',
posts_transaction: false,
@@ -822,7 +822,7 @@ export async function createTestBudget(handlers: Handlers) {
],
});
await runHandler(handlers['schedule/create'], {
await app['schedule/create']({
schedule: {
name: 'Internet bill',
posts_transaction: false,
@@ -847,7 +847,7 @@ export async function createTestBudget(handlers: Handlers) {
],
});
await runHandler(handlers['schedule/create'], {
await app['schedule/create']({
schedule: {
name: 'Wedding',
posts_transaction: false,
@@ -868,7 +868,7 @@ export async function createTestBudget(handlers: Handlers) {
],
});
await runHandler(handlers['schedule/create'], {
await app['schedule/create']({
schedule: {
name: 'Utilities',
posts_transaction: false,

View File

@@ -1,7 +1,18 @@
import type { Handlers } from '../../../types/handlers';
import type { ServerEvents } from '../../../types/server-events';
export declare function init(): Promise<unknown>;
/**
* Loot core server proxy.
*/
export type ServerProxy = {
[K in keyof Handlers]: (
args?: Parameters<Handlers[K]>[0],
) => ReturnType<Handlers[K]>;
};
export declare const server: ServerProxy;
export declare function init(): Promise<ServerProxy>;
export type Init = typeof init;
/**

View File

@@ -12,6 +12,7 @@ const listeners = new Map();
let messageQueue = [];
let globalWorker = null;
let initPromise: Promise<T.ServerProxy> | null = null;
class ReconstructedError extends Error {
url: string;
@@ -83,6 +84,28 @@ function handleMessage(msg) {
}
}
export const server: T.ServerProxy = new Proxy({} as T.ServerProxy, {
get(_target, prop: string | symbol) {
if (typeof prop === 'symbol') {
return undefined;
}
// Returning undefined for 'then' prevents the proxy from being
// treated as a thenable when awaited, which would cause Promise
// machinery to call server.then(resolve, reject) with native functions.
if (prop === 'then') {
return undefined;
}
if (!initPromise) {
throw new Error(`Cannot use server proxy before init() has been called`);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (args?: any) => send(prop as any, args);
},
});
// Note that this does not support retry. If the worker
// dies, it will permanently be disconnected. That should be OK since
// I don't think a worker should ever die due to a system error.
@@ -107,7 +130,7 @@ function connectWorker(worker, onOpen, onError) {
globalWorker.postMessage({
name: 'client-connected-to-backend',
});
onOpen();
onOpen(server);
} else if (msg.type === 'app-init-failure') {
globalWorker.postMessage({
name: '__app-init-failure-acknowledged',
@@ -149,11 +172,16 @@ function connectWorker(worker, onOpen, onError) {
}
}
export const init: T.Init = async function () {
const worker = await global.Actual.getServerSocket();
return new Promise((resolve, reject) =>
connectWorker(worker, resolve, reject),
);
export const init: T.Init = function () {
if (!initPromise) {
initPromise = global.Actual.getServerSocket().then(
worker =>
new Promise((resolve, reject) =>
connectWorker(worker, resolve, reject),
),
);
}
return initPromise;
};
export const send: T.Send = function (

View File

@@ -9,6 +9,29 @@ const replyHandlers = new Map();
const listeners = new Map();
let messageQueue = [];
let socketClient = null;
let initPromise: Promise<T.ServerProxy> | null = null;
export const server: T.ServerProxy = new Proxy({} as T.ServerProxy, {
get(_target, prop: string | symbol) {
if (typeof prop === 'symbol') {
return undefined;
}
// Returning undefined for 'then' prevents the proxy from being
// treated as a thenable when awaited, which would cause Promise
// machinery to call server.then(resolve, reject) with native functions.
if (prop === 'then') {
return undefined;
}
if (!initPromise) {
throw new Error(`Cannot use server proxy before init() has been called`);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (args?: any) => send(prop as any, args);
},
});
function connectSocket(onOpen) {
global.Actual.ipcConnect(function (client) {
@@ -72,12 +95,15 @@ function connectSocket(onOpen) {
messageQueue = [];
}
onOpen();
onOpen(server);
});
}
export const init: T.Init = async function () {
return new Promise(connectSocket);
export const init: T.Init = function () {
if (!initPromise) {
initPromise = new Promise(connectSocket);
}
return initPromise;
};
export const send: T.Send = function (

View File

@@ -1,9 +1,10 @@
import type { App } from '../../../types/app';
import type { Handlers } from '../../../types/handlers';
import type { ServerEvents } from '../../../types/server-events';
export declare function init(
channel: Window | number, // in electron the port number, in web the worker
handlers: Handlers,
app: App<Handlers>,
): void;
export type Init = typeof init;

View File

@@ -1,6 +1,6 @@
// @ts-strict-ignore
import { APIError } from '../../../server/errors';
import { isMutating, runHandler } from '../../../server/mutators';
import { isMutating } from '../../../server/mutators';
import { captureException } from '../../exceptions';
import { logger } from '../log';
@@ -14,12 +14,12 @@ function coerceError(error) {
return { type: 'ServerError', message: error.message, cause: error };
}
export const init: T.Init = function (_socketName, handlers) {
export const init: T.Init = function (_socketName, app) {
process.parentPort.on('message', ({ data }) => {
const { id, name, args, undoTag, catchErrors } = data;
if (handlers[name]) {
runHandler(handlers[name], args, { undoTag, name }).then(
if (app.hasHandler(name)) {
app.runHandler(name, args, { undoTag, name }).then(
result => {
if (catchErrors) {
result = { data: result, error: null };
@@ -30,7 +30,9 @@ export const init: T.Init = function (_socketName, handlers) {
id,
result,
mutated:
isMutating(handlers[name]) && name !== 'undo' && name !== 'redo',
isMutating(app.getHandler(name)) &&
name !== 'undo' &&
name !== 'redo',
undoTag,
});
},

View File

@@ -1,6 +1,6 @@
// @ts-strict-ignore
import { APIError } from '../../../server/errors';
import { isMutating, runHandler } from '../../../server/mutators';
import { isMutating } from '../../../server/mutators';
import { captureException } from '../../exceptions';
import { logger } from '../log';
@@ -31,7 +31,7 @@ function coerceError(error) {
return { type: 'ServerError', message: error.message, cause: error };
}
export const init: T.Init = function (serverChn, handlers) {
export const init: T.Init = function (serverChn, app) {
const serverChannel = serverChn as Window;
getGlobalObject().__globalServerChannel = serverChannel;
@@ -54,14 +54,14 @@ export const init: T.Init = function (serverChn, handlers) {
const { id, name, args, undoTag, catchErrors } = msg;
if (handlers[name]) {
runHandler(handlers[name], args, { undoTag, name }).then(
if (app.hasHandler(name)) {
app.runHandler(name, args, { undoTag, name }).then(
result => {
serverChannel.postMessage({
type: 'reply',
id,
result: catchErrors ? { data: result, error: null } : result,
mutated: isMutating(handlers[name]),
mutated: isMutating(app.getHandler(name)),
undoTag,
});
},

View File

@@ -10,7 +10,7 @@ vi.mock('./sync', async () => ({
syncAccount: vi.fn(),
}));
const simpleFinBatchSyncHandler = app.handlers['simplefin-batch-sync'];
const simpleFinBatchSyncHandler = app['simplefin-batch-sync'];
function insertBank(bank: { id: string; bank_id: string; name: string }) {
db.runQuery(

View File

@@ -28,7 +28,7 @@ import {
PostError,
TransactionError,
} from '../errors';
import { app as mainApp } from '../main-app';
import { mainApp } from '../main';
import { mutator } from '../mutators';
import { get, post } from '../post';
import { getServer } from '../server-config';
@@ -495,7 +495,7 @@ async function closeAccount({
);
}
await mainApp.handlers['transaction-add']({
await mainApp['transaction-add']({
id: uuidv4(),
payee: transferPayee.id,
amount: -balance,

View File

@@ -1,36 +1,43 @@
import { getBankSyncError } from '../shared/errors';
import type { ServerHandlers } from '../types/server-handlers';
import { installAPI } from './api';
vi.mock('../shared/errors', () => ({
getBankSyncError: vi.fn(error => `Bank sync error: ${error}`),
}));
describe('API handlers', () => {
const handlers = installAPI({} as unknown as ServerHandlers);
// @ts-strict-ignore
import { app as apiApp } from './api';
import { mainApp } from './main';
describe('API app', () => {
describe('api/bank-sync', () => {
it('should sync a single account when accountId is provided', async () => {
handlers['accounts-bank-sync'] = vi
.fn()
.mockResolvedValue({ errors: [] });
await handlers['api/bank-sync']({ accountId: 'account1' });
expect(handlers['accounts-bank-sync']).toHaveBeenCalledWith({
ids: ['account1'],
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should handle errors in non batch sync', async () => {
handlers['accounts-bank-sync'] = vi.fn().mockResolvedValue({
errors: ['connection-failed'],
});
it('should sync a single account when accountId is provided', async () => {
vi.spyOn(mainApp, 'runHandler').mockImplementation(
async (name: string) => {
if (name === 'accounts-bank-sync') return { errors: [] };
throw new Error(`Unexpected handler: ${name}`);
},
);
await apiApp['api/bank-sync']({ accountId: 'account1' });
expect(mainApp.runHandler.bind(mainApp)).toHaveBeenCalledWith(
'accounts-bank-sync',
{
ids: ['account1'],
},
);
});
it('should throw an error when bank sync fails', async () => {
vi.spyOn(mainApp, 'runHandler').mockImplementation(
async (name: string) => {
if (name === 'accounts-bank-sync') {
return { errors: [{ message: 'connection-failed' }] };
}
throw new Error(`Unexpected handler: ${name}`);
},
);
await expect(
handlers['api/bank-sync']({ accountId: 'account2' }),
).rejects.toThrow('Bank sync error: connection-failed');
expect(getBankSyncError).toHaveBeenCalledWith('connection-failed');
apiApp['api/bank-sync']({ accountId: 'account2' }),
).rejects.toThrow('connection-failed');
});
});
});

View File

@@ -17,13 +17,12 @@ import {
updateTransaction,
} from '../shared/transactions';
import { integerToAmount } from '../shared/util';
import type { Handlers } from '../types/handlers';
import type { ApiHandlers } from '../types/api-handlers';
import type {
AccountEntity,
CategoryGroupEntity,
ScheduleEntity,
} from '../types/models';
import type { ServerHandlers } from '../types/server-handlers';
import { addTransactions } from './accounts/sync';
import {
@@ -37,11 +36,13 @@ import {
tagModel,
} from './api-models';
import type { AmountOPType, APIScheduleEntity } from './api-models';
import { createApp } from './app';
import { aqlQuery } from './aql';
import * as cloudStorage from './cloud-storage';
import type { RemoteFile } from './cloud-storage';
import * as db from './db';
import { APIError } from './errors';
import { mainApp } from './main';
import { runMutator } from './mutators';
import * as prefs from './prefs';
import * as sheet from './sheet';
@@ -82,7 +83,7 @@ function withMutation<Params extends Array<unknown>, ReturnType>(
};
}
let handlers = {} as unknown as Handlers;
const handlers = {} as ApiHandlers;
async function validateMonth(month) {
if (!month.match(/^\d{4}-\d{2}$/)) {
@@ -90,7 +91,7 @@ async function validateMonth(month) {
}
if (!IMPORT_MODE) {
const { start, end } = await handlers['get-budget-bounds']();
const { start, end } = await mainApp['get-budget-bounds']();
const range = monthUtils.range(start, end);
if (!range.includes(month)) {
throw APIError('No budget exists for month: ' + month);
@@ -162,7 +163,7 @@ handlers['api/load-budget'] = async function ({ id }) {
if (currentId !== id) {
connection.send('start-load');
const { error } = await handlers['load-budget']({ id });
const { error } = await mainApp['load-budget']({ id });
if (!error) {
connection.send('finish-load');
@@ -177,16 +178,16 @@ handlers['api/load-budget'] = async function ({ id }) {
handlers['api/download-budget'] = async function ({ syncId, password }) {
const { id: currentId } = prefs.getPrefs() || {};
if (currentId) {
await handlers['close-budget']();
await mainApp['close-budget']();
}
const budgets = await handlers['get-budgets']();
const budgets = await mainApp['get-budgets']();
const localBudget = budgets.find(b => b.groupId === syncId);
let remoteBudget: RemoteFile;
// Load a remote file if we could not find the file locally
if (!localBudget) {
const files = await handlers['get-remote-files']();
const files = await mainApp['get-remote-files']();
if (!files) {
throw new Error('Could not get remote files');
}
@@ -210,7 +211,7 @@ handlers['api/download-budget'] = async function ({ syncId, password }) {
);
}
const result = await handlers['key-test']({
const result = await mainApp['key-test']({
cloudFileId: remoteBudget ? remoteBudget.fileId : localBudget.cloudFileId,
password,
});
@@ -221,8 +222,8 @@ handlers['api/download-budget'] = async function ({ syncId, password }) {
// Sync the local budget file
if (localBudget) {
await handlers['load-budget']({ id: localBudget.id });
const result = await handlers['sync-budget']();
await mainApp['load-budget']({ id: localBudget.id });
const result = await mainApp['sync-budget']();
if (result.error) {
throw new Error(getSyncError(result.error.reason, localBudget.id));
}
@@ -230,19 +231,19 @@ handlers['api/download-budget'] = async function ({ syncId, password }) {
}
// Download the remote file (no need to perform a sync as the file will already be up-to-date)
const result = await handlers['download-budget']({
const result = await mainApp['download-budget']({
cloudFileId: remoteBudget.fileId,
});
if (result.error) {
logger.log('Full error details', result.error);
throw new Error(getDownloadError(result.error));
}
await handlers['load-budget']({ id: result.id });
await mainApp['load-budget']({ id: result.id });
};
handlers['api/get-budgets'] = async function () {
const budgets = await handlers['get-budgets']();
const files = (await handlers['get-remote-files']()) || [];
const budgets = await mainApp['get-budgets']();
const files = (await mainApp['get-remote-files']()) || [];
return [
...budgets.map(file => budgetModel.toExternal(file)),
...files.map(file => remoteFileModel.toExternal(file)).filter(file => file),
@@ -251,7 +252,7 @@ handlers['api/get-budgets'] = async function () {
handlers['api/sync'] = async function () {
const { id } = prefs.getPrefs();
const result = await handlers['sync-budget']();
const result = await mainApp['sync-budget']();
if (result.error) {
throw new Error(getSyncError(result.error.reason, id));
}
@@ -262,13 +263,13 @@ handlers['api/bank-sync'] = async function (args) {
const allErrors = [];
if (!batchSync) {
const { errors } = await handlers['accounts-bank-sync']({
const { errors } = await mainApp['accounts-bank-sync']({
ids: [args.accountId],
});
allErrors.push(...errors);
} else {
const accountsData = await handlers['accounts-get']();
const accountsData = await mainApp['accounts-get']();
const accountIdsToSync = accountsData.map(a => a.id);
const simpleFinAccounts = accountsData.filter(
a => a.account_sync_source === 'simpleFin',
@@ -276,14 +277,14 @@ handlers['api/bank-sync'] = async function (args) {
const simpleFinAccountIds = simpleFinAccounts.map(a => a.id);
if (simpleFinAccounts.length > 1) {
const res = await handlers['simplefin-batch-sync']({
const res = await mainApp['simplefin-batch-sync']({
ids: simpleFinAccountIds,
});
res.forEach(a => allErrors.push(...a.res.errors));
}
const { errors } = await handlers['accounts-bank-sync']({
const { errors } = await mainApp['accounts-bank-sync']({
ids: accountIdsToSync.filter(a => !simpleFinAccountIds.includes(a)),
});
@@ -298,10 +299,10 @@ handlers['api/bank-sync'] = async function (args) {
handlers['api/start-import'] = async function ({ budgetName }) {
// Notify UI to close budget
await handlers['close-budget']();
await mainApp['close-budget']();
// Create the budget
await handlers['create-budget']({ budgetName, avoidUpload: true });
await mainApp['create-budget']({ budgetName, avoidUpload: true });
// Clear out the default expense categories
db.runQuery('DELETE FROM categories WHERE is_income = 0');
@@ -323,10 +324,10 @@ handlers['api/finish-import'] = async function () {
// the spreadsheet, but we can't just recreate the spreadsheet
// either; there is other internal state that isn't created
const { id } = prefs.getPrefs();
await handlers['close-budget']();
await handlers['load-budget']({ id });
await mainApp['close-budget']();
await mainApp['load-budget']({ id });
await handlers['get-budget-bounds']();
await mainApp['get-budget-bounds']();
await sheet.waitOnSpreadsheet();
await cloudStorage.upload().catch(() => {
@@ -343,8 +344,8 @@ handlers['api/abort-import'] = async function () {
const { id } = prefs.getPrefs();
await handlers['close-budget']();
await handlers['delete-budget']({ id });
await mainApp['close-budget']();
await mainApp['delete-budget']({ id });
connection.send('show-budgets');
}
@@ -358,7 +359,7 @@ handlers['api/query'] = async function ({ query }) {
handlers['api/budget-months'] = async function () {
checkFileOpen();
const { start, end } = await handlers['get-budget-bounds']();
const { start, end } = await mainApp['get-budget-bounds']();
return monthUtils.range(start, end);
};
@@ -428,7 +429,7 @@ handlers['api/budget-set-amount'] = withMutation(async function ({
amount,
}) {
checkFileOpen();
return handlers['budget/budget-amount']({
return mainApp['budget/budget-amount']({
month,
category: categoryId,
amount,
@@ -443,7 +444,7 @@ handlers['api/budget-set-carryover'] = withMutation(async function ({
checkFileOpen();
await validateMonth(month);
await validateExpenseCategory('budget-set-carryover', categoryId);
return handlers['budget/set-carryover']({
return mainApp['budget/set-carryover']({
startMonth: month,
category: categoryId,
flag,
@@ -459,7 +460,7 @@ handlers['api/budget-hold-for-next-month'] = withMutation(async function ({
if (amount <= 0) {
throw APIError('Amount to hold needs to be greater than 0');
}
return handlers['budget/hold-for-next-month']({
return mainApp['budget/hold-for-next-month']({
month,
amount,
});
@@ -468,7 +469,7 @@ handlers['api/budget-hold-for-next-month'] = withMutation(async function ({
handlers['api/budget-reset-hold'] = withMutation(async function ({ month }) {
checkFileOpen();
await validateMonth(month);
return handlers['budget/reset-hold']({ month });
return mainApp['budget/reset-hold']({ month });
});
handlers['api/transactions-export'] = async function ({
@@ -478,7 +479,7 @@ handlers['api/transactions-export'] = async function ({
accounts,
}) {
checkFileOpen();
return handlers['transactions-export']({
return mainApp['transactions-export']({
transactions,
categoryGroups,
payees,
@@ -493,7 +494,7 @@ handlers['api/transactions-import'] = withMutation(async function ({
opts,
}) {
checkFileOpen();
return handlers['transactions-import']({
return mainApp['transactions-import']({
accountId,
transactions,
isPreview,
@@ -551,7 +552,7 @@ handlers['api/transaction-update'] = withMutation(async function ({
}
const { diff } = updateTransaction(transactions, { id, ...fields });
return handlers['transactions-batch-update'](diff)['updated'];
return mainApp['transactions-batch-update'](diff)['updated'];
});
handlers['api/transaction-delete'] = withMutation(async function ({ id }) {
@@ -566,12 +567,12 @@ handlers['api/transaction-delete'] = withMutation(async function ({ id }) {
}
const { diff } = deleteTransaction(transactions, id);
return handlers['transactions-batch-update'](diff)['deleted'];
return mainApp['transactions-batch-update'](diff)['deleted'];
});
handlers['api/accounts-get'] = async function () {
checkFileOpen();
const accounts: AccountEntity[] = await handlers['accounts-get']();
const accounts: AccountEntity[] = await mainApp['accounts-get']();
return accounts.map(account => accountModel.toExternal(account));
};
@@ -580,7 +581,7 @@ handlers['api/account-create'] = withMutation(async function ({
initialBalance = null,
}) {
checkFileOpen();
return handlers['account-create']({
return mainApp['account-create']({
name: account.name,
offBudget: account.offbudget,
closed: account.closed,
@@ -592,7 +593,7 @@ handlers['api/account-create'] = withMutation(async function ({
handlers['api/account-update'] = withMutation(async function ({ id, fields }) {
checkFileOpen();
return db.updateAccount({ id, ...accountModel.fromExternal(fields) });
await mainApp['account-update']({ id, ...accountModel.fromExternal(fields) });
});
handlers['api/account-close'] = withMutation(async function ({
@@ -601,7 +602,7 @@ handlers['api/account-close'] = withMutation(async function ({
transferCategoryId,
}) {
checkFileOpen();
return handlers['account-close']({
return mainApp['account-close']({
id,
transferAccountId,
categoryId: transferCategoryId,
@@ -610,12 +611,12 @@ handlers['api/account-close'] = withMutation(async function ({
handlers['api/account-reopen'] = withMutation(async function ({ id }) {
checkFileOpen();
return handlers['account-reopen']({ id });
return mainApp['account-reopen']({ id });
});
handlers['api/account-delete'] = withMutation(async function ({ id }) {
checkFileOpen();
return handlers['account-close']({ id, forced: true });
return mainApp['account-close']({ id, forced: true });
});
handlers['api/account-balance'] = withMutation(async function ({
@@ -623,14 +624,14 @@ handlers['api/account-balance'] = withMutation(async function ({
cutoff = new Date(),
}) {
checkFileOpen();
return handlers['account-balance']({ id, cutoff });
return mainApp['account-balance']({ id, cutoff });
});
handlers['api/categories-get'] = async function ({
grouped,
}: { grouped? } = {}) {
checkFileOpen();
const result = await handlers['get-categories']();
const result = await mainApp['get-categories']();
return grouped
? result.grouped.map(group => categoryGroupModel.toExternal(group))
: result.list.map(category => categoryModel.toExternal(category));
@@ -638,7 +639,7 @@ handlers['api/categories-get'] = async function ({
handlers['api/category-groups-get'] = async function () {
checkFileOpen();
const groups = await handlers['get-category-groups']();
const groups = await mainApp['get-category-groups']();
return groups.map(group => categoryGroupModel.toExternal(group));
};
@@ -646,7 +647,7 @@ handlers['api/category-group-create'] = withMutation(async function ({
group,
}) {
checkFileOpen();
return handlers['category-group-create']({
return mainApp['category-group-create']({
name: group.name,
hidden: group.hidden,
});
@@ -657,7 +658,7 @@ handlers['api/category-group-update'] = withMutation(async function ({
fields,
}) {
checkFileOpen();
return handlers['category-group-update']({
return mainApp['category-group-update']({
id,
...categoryGroupModel.fromExternal(fields),
});
@@ -668,7 +669,7 @@ handlers['api/category-group-delete'] = withMutation(async function ({
transferCategoryId,
}) {
checkFileOpen();
return handlers['category-group-delete']({
return mainApp['category-group-delete']({
id,
transferId: transferCategoryId,
});
@@ -676,7 +677,7 @@ handlers['api/category-group-delete'] = withMutation(async function ({
handlers['api/category-create'] = withMutation(async function ({ category }) {
checkFileOpen();
return handlers['category-create']({
return mainApp['category-create']({
name: category.name,
groupId: category.group_id,
isIncome: category.is_income,
@@ -686,7 +687,7 @@ handlers['api/category-create'] = withMutation(async function ({ category }) {
handlers['api/category-update'] = withMutation(async function ({ id, fields }) {
checkFileOpen();
return handlers['category-update']({
return mainApp['category-update']({
id,
...categoryModel.fromExternal(fields),
});
@@ -697,7 +698,7 @@ handlers['api/category-delete'] = withMutation(async function ({
transferCategoryId,
}) {
checkFileOpen();
return handlers['category-delete']({
return mainApp['category-delete']({
id,
transferId: transferCategoryId,
});
@@ -705,31 +706,31 @@ handlers['api/category-delete'] = withMutation(async function ({
handlers['api/common-payees-get'] = async function () {
checkFileOpen();
const payees = await handlers['common-payees-get']();
const payees = await mainApp.getCommonPayees();
return payees.map(payee => payeeModel.toExternal(payee));
};
handlers['api/payees-get'] = async function () {
checkFileOpen();
const payees = await handlers['payees-get']();
const payees = await mainApp.getPayees();
return payees.map(payee => payeeModel.toExternal(payee));
};
handlers['api/payee-create'] = withMutation(async function ({ payee }) {
checkFileOpen();
return handlers['payee-create']({ name: payee.name });
return mainApp.createPayee({ name: payee.name });
});
handlers['api/payee-update'] = withMutation(async function ({ id, fields }) {
checkFileOpen();
return handlers['payees-batch-change']({
return mainApp.batchChangePayees({
updated: [{ id, ...payeeModel.fromExternal(fields) }],
});
});
handlers['api/payee-delete'] = withMutation(async function ({ id }) {
checkFileOpen();
return handlers['payees-batch-change']({ deleted: [{ id }] });
return mainApp.batchChangePayees({ deleted: [{ id }] });
});
handlers['api/payees-merge'] = withMutation(async function ({
@@ -737,18 +738,18 @@ handlers['api/payees-merge'] = withMutation(async function ({
mergeIds,
}) {
checkFileOpen();
return handlers['payees-merge']({ targetId, mergeIds });
return mainApp.mergePayees({ targetId, mergeIds });
});
handlers['api/tags-get'] = async function () {
checkFileOpen();
const tags = await handlers['tags-get']();
const tags = await mainApp['tags-get']();
return tags.map(tag => tagModel.toExternal(tag));
};
handlers['api/tag-create'] = withMutation(async function ({ tag }) {
checkFileOpen();
const result = await handlers['tags-create']({
const result = await mainApp['tags-create']({
tag: tag.tag,
color: tag.color,
description: tag.description,
@@ -758,12 +759,12 @@ handlers['api/tag-create'] = withMutation(async function ({ tag }) {
handlers['api/tag-update'] = withMutation(async function ({ id, fields }) {
checkFileOpen();
await handlers['tags-update']({ id, ...tagModel.fromExternal(fields) });
await mainApp['tags-update']({ id, ...tagModel.fromExternal(fields) });
});
handlers['api/tag-delete'] = withMutation(async function ({ id }) {
checkFileOpen();
await handlers['tags-delete']({ id });
await mainApp['tags-delete']({ id });
});
handlers['api/payee-location-create'] = withMutation(async function ({
@@ -772,17 +773,17 @@ handlers['api/payee-location-create'] = withMutation(async function ({
longitude,
}) {
checkFileOpen();
return handlers['payee-location-create']({ payeeId, latitude, longitude });
return mainApp.createPayeeLocation({ payeeId, latitude, longitude });
});
handlers['api/payee-locations-get'] = async function ({ payeeId }) {
checkFileOpen();
return handlers['payee-locations-get']({ payeeId });
return mainApp.getPayeeLocations({ payeeId });
};
handlers['api/payee-location-delete'] = withMutation(async function ({ id }) {
checkFileOpen();
return handlers['payee-location-delete']({ id });
return mainApp.deletePayeeLocation({ id });
});
handlers['api/payees-get-nearby'] = async function ({
@@ -791,22 +792,22 @@ handlers['api/payees-get-nearby'] = async function ({
maxDistance,
}) {
checkFileOpen();
return handlers['payees-get-nearby']({ latitude, longitude, maxDistance });
return mainApp.getNearbyPayees({ latitude, longitude, maxDistance });
};
handlers['api/rules-get'] = async function () {
checkFileOpen();
return handlers['rules-get']();
return mainApp['rules-get']();
};
handlers['api/payee-rules-get'] = async function ({ id }) {
checkFileOpen();
return handlers['payees-get-rules']({ id });
return mainApp.getPayeeRules({ id });
};
handlers['api/rule-create'] = withMutation(async function ({ rule }) {
checkFileOpen();
const addedRule = await handlers['rule-add'](rule);
const addedRule = await mainApp['rule-add'](rule);
if ('error' in addedRule) {
throw APIError('Failed creating a new rule', addedRule.error);
@@ -817,7 +818,7 @@ handlers['api/rule-create'] = withMutation(async function ({ rule }) {
handlers['api/rule-update'] = withMutation(async function ({ rule }) {
checkFileOpen();
const updatedRule = await handlers['rule-update'](rule);
const updatedRule = await mainApp['rule-update'](rule);
if ('error' in updatedRule) {
throw APIError('Failed updating the rule', updatedRule.error);
@@ -828,7 +829,7 @@ handlers['api/rule-update'] = withMutation(async function ({ rule }) {
handlers['api/rule-delete'] = withMutation(async function (id) {
checkFileOpen();
return handlers['rule-delete'](id);
return mainApp['rule-delete'](id);
});
handlers['api/schedules-get'] = async function () {
@@ -847,7 +848,7 @@ handlers['api/schedule-create'] = withMutation(async function (
name: internalSchedule.name,
posts_transaction: internalSchedule.posts_transaction,
};
return handlers['schedule/create']({
return mainApp['schedule/create']({
schedule: partialSchedule,
conditions: internalSchedule._conditions,
});
@@ -981,7 +982,7 @@ handlers['api/schedule-update'] = withMutation(async function ({
}
if (conditionsUpdated) {
return handlers['schedule/update']({
return mainApp['schedule/update']({
schedule: {
id: sched.id,
posts_transaction: sched.posts_transaction,
@@ -997,7 +998,7 @@ handlers['api/schedule-update'] = withMutation(async function ({
handlers['api/schedule-delete'] = withMutation(async function (id: string) {
checkFileOpen();
return handlers['schedule/delete']({ id });
return mainApp['schedule/delete']({ id });
});
handlers['api/get-id-by-name'] = async function ({ type, name }) {
@@ -1020,11 +1021,7 @@ handlers['api/get-id-by-name'] = async function ({ type, name }) {
handlers['api/get-server-version'] = async function () {
checkFileOpen();
return handlers['get-server-version']();
return mainApp['get-server-version']();
};
export function installAPI(serverHandlers: ServerHandlers) {
const merged = Object.assign({}, serverHandlers, handlers);
handlers = merged as Handlers;
return merged;
}
export const app = createApp(handlers);

View File

@@ -5,6 +5,8 @@ import type { Emitter } from 'mitt';
import { captureException } from '../platform/exceptions';
import type { ServerEvents } from '../types/server-events';
import { runHandler as mutatorRunHandler } from './mutators';
// This is a simple helper abstraction for defining methods exposed to
// the client. It doesn't do much, but checks for naming conflicts and
// makes it cleaner to combine methods. We call a group of related
@@ -18,22 +20,32 @@ type Events = {
type UnlistenService = () => void;
type Service = () => UnlistenService;
class App<Handlers> {
events: Emitter<Events>;
handlers: Handlers;
services: Service[];
unlistenServices: UnlistenService[];
export class App<THandlers> {
private handlers: THandlers;
private services: Service[];
private unlistenServices: UnlistenService[];
constructor() {
this.handlers = {} as Handlers;
readonly events: Emitter<Events>;
constructor(handlers?: THandlers) {
this.handlers = {} as THandlers;
this.services = [];
this.events = mitt<Events>();
this.unlistenServices = [];
if (handlers) {
for (const [name, func] of Object.entries(handlers)) {
this.method(
name as string & keyof THandlers,
func as THandlers[string & keyof THandlers],
);
}
}
}
method<Name extends string & keyof Handlers>(
method<Name extends string & keyof THandlers>(
name: Name,
func: Handlers[Name],
func: THandlers[Name],
) {
if (this.handlers[name] != null) {
throw new Error(
@@ -50,7 +62,7 @@ class App<Handlers> {
combine(...apps) {
for (const app of apps) {
Object.keys(app.handlers).forEach(name => {
this.method(name as string & keyof Handlers, app.handlers[name]);
this.method(name as string & keyof THandlers, app.handlers[name]);
});
app.services.forEach(service => {
@@ -84,8 +96,50 @@ class App<Handlers> {
});
this.unlistenServices = [];
}
getHandler<T extends keyof THandlers>(name: T): THandlers[T] {
return this.handlers[name];
}
hasHandler<T extends keyof THandlers>(name: T): boolean {
return this.getHandler(name) != null;
}
async runHandler<T extends keyof THandlers>(
name: T,
args?: THandlers[T] extends (...a: infer A) => unknown ? A[0] : never,
options?: Parameters<typeof mutatorRunHandler>[2],
): Promise<
THandlers[T] extends (...a: infer _A) => Promise<infer R> ? R : never
> {
const handler = this.handlers[name];
if (!handler) {
throw new Error(`No handler for method: ${String(name)}`);
}
return mutatorRunHandler(
handler as Parameters<typeof mutatorRunHandler>[0],
args,
options,
) as Promise<
THandlers[T] extends (...a: infer _A) => Promise<infer R> ? R : never
>;
}
}
export function createApp<T>() {
return new App<T>();
export function createApp<THandlers>(
handlers?: THandlers,
): App<THandlers> & THandlers {
const app = new App<THandlers>(handlers);
return new Proxy(app, {
get(target, prop, receiver) {
if (prop in target) {
return Reflect.get(target, prop, receiver);
}
return new Proxy(target.runHandler.bind(target), {
apply(boundFn, _thisArg, [args]) {
return boundFn(prop as keyof THandlers, args);
},
});
},
}) as App<THandlers> & THandlers;
}

View File

@@ -16,7 +16,7 @@ import * as db from '../db';
import * as mappings from '../db/mappings';
import { handleBudgetImport } from '../importers';
import type { ImportableBudgetType } from '../importers';
import { app as mainApp } from '../main-app';
import { mainApp } from '../main';
import { mutator } from '../mutators';
import * as prefs from '../prefs';
import { getServer } from '../server-config';
@@ -453,7 +453,7 @@ async function createBudget({
}
if (testMode) {
await createTestBudget(mainApp.handlers);
await createTestBudget(mainApp);
}
return {};

View File

@@ -2,14 +2,14 @@
import * as fs from '../../platform/server/fs';
import * as sqlite from '../../platform/server/sqlite';
import * as cloudStorage from '../cloud-storage';
import { handlers } from '../main';
import { mainApp } from '../main';
import { waitOnSpreadsheet } from '../sheet';
export async function importActual(_filepath: string, buffer: Buffer) {
// Importing Actual files is a special case because we can directly
// write down the files, but because it doesn't go through the API
// layer we need to duplicate some of the workflow
await handlers['close-budget']();
await mainApp['close-budget']();
let id;
try {
@@ -40,8 +40,8 @@ export async function importActual(_filepath: string, buffer: Buffer) {
// Load the budget, force everything to be computed, and try
// to upload it as a cloud file
await handlers['load-budget']({ id });
await handlers['get-budget-bounds']();
await mainApp['load-budget']({ id });
await mainApp['get-budget-bounds']();
await waitOnSpreadsheet();
await cloudStorage.upload().catch(() => {
// Ignore errors

View File

@@ -1,6 +1,6 @@
// @ts-strict-ignore
import { logger } from '../../platform/server/log';
import { handlers } from '../main';
import { mainApp } from '../main';
import { importActual } from './actual';
import * as YNAB4 from './ynab4';
@@ -42,17 +42,17 @@ export async function handleBudgetImport(
}
try {
await handlers['api/start-import']({ budgetName });
await mainApp['api/start-import']({ budgetName });
} catch (e) {
logger.error('failed to start import', e);
return { error: 'unknown' };
}
await importer.doImport(data);
} catch (e) {
await handlers['api/abort-import']();
await mainApp['api/abort-import']();
logger.error('failed to run import', e);
return { error: 'unknown' };
}
await handlers['api/finish-import']();
await mainApp['api/finish-import']();
}

View File

@@ -6,7 +6,7 @@ import { v4 as uuidv4 } from 'uuid';
import { logger } from '../../platform/server/log';
import * as monthUtils from '../../shared/months';
import { amountToInteger, groupBy, sortByKey } from '../../shared/util';
import { send } from '../main-app';
import { mainApp } from '../main';
import type * as YNAB4 from './ynab4-types';
@@ -21,7 +21,7 @@ async function importAccounts(
return Promise.all(
accounts.map(async account => {
if (!account.isTombstone) {
const id = await send('api/account-create', {
const id = await mainApp['api/account-create']({
account: {
name: account.accountName,
offbudget: account.onBudget ? false : true,
@@ -48,7 +48,7 @@ async function importCategories(
masterCategory.subCategories &&
masterCategory.subCategories.some(cat => !cat.isTombstone)
) {
const id = await send('api/category-group-create', {
const id = await mainApp['api/category-group-create']({
group: {
name: masterCategory.name,
is_income: false,
@@ -56,7 +56,7 @@ async function importCategories(
});
entityIdMap.set(masterCategory.entityId, id);
if (masterCategory.note) {
void send('notes-save', {
void mainApp['notes-save']({
id,
note: masterCategory.note,
});
@@ -89,7 +89,7 @@ async function importCategories(
categoryName = categoryNameParts.join('/').trim();
}
const id = await send('api/category-create', {
const id = await mainApp['api/category-create']({
category: {
name: categoryName,
group_id: entityIdMap.get(category.masterCategoryId),
@@ -97,7 +97,7 @@ async function importCategories(
});
entityIdMap.set(category.entityId, id);
if (category.note) {
void send('notes-save', {
void mainApp['notes-save']({
id,
note: category.note,
});
@@ -116,7 +116,7 @@ async function importPayees(
) {
for (const payee of data.payees) {
if (!payee.isTombstone) {
const id = await send('api/payee-create', {
const id = await mainApp['api/payee-create']({
payee: {
name: payee.name,
transfer_acct: entityIdMap.get(payee.targetAccountId) || null,
@@ -134,14 +134,14 @@ async function importTransactions(
data: YNAB4.YFull,
entityIdMap: Map<string, string>,
) {
const categories = await send('api/categories-get', {
const categories = await mainApp['api/categories-get']({
grouped: false,
});
const incomeCategoryId: string = categories.find(
cat => cat.name === 'Income',
).id;
const accounts = await send('api/accounts-get');
const payees = await send('api/payees-get');
const accounts = await mainApp['api/accounts-get']();
const payees = await mainApp['api/payees-get']();
function getCategory(id: string) {
if (id == null || id === 'Category/__Split__') {
@@ -245,7 +245,7 @@ async function importTransactions(
})
.filter(x => x);
await send('api/transactions-add', {
await mainApp['api/transactions-add']({
accountId: entityIdMap.get(accountId),
transactions: toImport,
learnCategories: true,
@@ -291,7 +291,7 @@ async function importBudgets(
) {
const budgets = sortByKey(data.monthlyBudgets, 'month');
await send('api/batch-budget-start');
await mainApp['api/batch-budget-start']();
try {
for (const budget of budgets) {
const filled = fillInBudgets(
@@ -308,20 +308,20 @@ async function importBudgets(
return;
}
await send('api/budget-set-amount', {
await mainApp['api/budget-set-amount']({
month,
categoryId: catId,
amount,
});
if (catBudget.overspendingHandling === 'AffectsBuffer') {
await send('api/budget-set-carryover', {
await mainApp['api/budget-set-carryover']({
month,
categoryId: catId,
flag: false,
});
} else if (catBudget.overspendingHandling === 'Confined') {
await send('api/budget-set-carryover', {
await mainApp['api/budget-set-carryover']({
month,
categoryId: catId,
flag: true,
@@ -331,7 +331,7 @@ async function importBudgets(
);
}
} finally {
await send('api/batch-budget-end');
await mainApp['api/batch-budget-end']();
}
}

View File

@@ -6,7 +6,8 @@ import * as monthUtils from '../../shared/months';
import { q } from '../../shared/query';
import { groupBy, sortByKey } from '../../shared/util';
import type { RecurConfig, RecurPattern, RuleEntity } from '../../types/models';
import { send } from '../main-app';
import { aqlQuery } from '../aql';
import { mainApp } from '../main';
import { ruleModel } from '../transactions/transaction-rules';
import type {
@@ -271,7 +272,7 @@ function importAccounts(data: Budget, entityIdMap: Map<string, string>) {
return Promise.all(
data.accounts.map(async account => {
if (!account.deleted) {
const id = await send('api/account-create', {
const id = await mainApp['api/account-create']({
account: {
name: account.name,
offbudget: account.on_budget ? false : true,
@@ -291,7 +292,7 @@ async function importCategories(
// Hidden categories are put in its own group by YNAB,
// so it's already handled.
const categories = await send('api/categories-get', {
const categories = await mainApp['api/categories-get']({
grouped: false,
});
const incomeCatId = findIdByName(categories, 'Income');
@@ -336,7 +337,7 @@ async function importCategories(
while (true) {
const name = count === 0 ? baseName : `${baseName} (${count})`;
try {
const id = await send('api/category-group-create', {
const id = await mainApp['api/category-group-create']({
group: { ...params, name },
});
return { id, name };
@@ -361,7 +362,7 @@ async function importCategories(
while (true) {
const name = count === 0 ? baseName : `${baseName} (${count})`;
try {
const id = await send('api/category-create', {
const id = await mainApp['api/category-create']({
category: { ...params, name },
});
return { id, name };
@@ -393,7 +394,7 @@ async function importCategories(
groupId = createdGroup.id;
entityIdMap.set(group.id, groupId);
if (group.note) {
void send('notes-save', {
void mainApp['notes-save']({
id: groupId,
note: group.note,
});
@@ -434,7 +435,7 @@ async function importCategories(
});
entityIdMap.set(cat.id, createdCategory.id);
if (cat.note) {
void send('notes-save', {
void mainApp['notes-save']({
id: createdCategory.id,
note: cat.note,
});
@@ -451,7 +452,7 @@ function importPayees(data: Budget, entityIdMap: Map<string, string>) {
return Promise.all(
data.payees.map(async payee => {
if (!payee.deleted) {
const id = await send('api/payee-create', {
const id = await mainApp['api/payee-create']({
payee: { name: payee.name },
});
entityIdMap.set(payee.id, id);
@@ -498,7 +499,7 @@ async function importPayeeLocations(
try {
// Create the payee location in Actual
await send('payee-location-create', {
await mainApp.createPayeeLocation({
payeeId: actualPayeeId,
latitude,
longitude,
@@ -557,7 +558,7 @@ async function importFlagsAsTags(
await Promise.all(
[...tagsToCreate.entries()].map(async ([tag, color]) => {
await send('tags-create', {
await mainApp['tags-create']({
tag,
color,
description: 'Imported from YNAB',
@@ -571,8 +572,8 @@ async function importTransactions(
entityIdMap: Map<string, string>,
flagNameConflicts: Set<string>,
) {
const payees = await send('api/payees-get');
const categories = await send('api/categories-get', {
const payees = await mainApp['api/payees-get']();
const categories = await mainApp['api/categories-get']({
grouped: false,
});
const incomeCatId = findIdByName(categories, 'Income');
@@ -837,7 +838,7 @@ async function importTransactions(
})
.filter(x => x);
await send('api/transactions-add', {
await mainApp['api/transactions-add']({
accountId: entityIdMap.get(accountId),
transactions: toImport,
learnCategories: true,
@@ -861,7 +862,7 @@ async function importScheduledTransactions(
return;
}
const payees = await send('api/payees-get');
const payees = await mainApp['api/payees-get']();
const payeesByTransferAcct = payees
.filter(payee => payee?.transfer_acct)
.map(payee => [payee.transfer_acct, payee] as [string, Payee]);
@@ -884,7 +885,7 @@ async function importScheduledTransactions(
while (true) {
try {
return await send('api/schedule-create', {
return await mainApp['api/schedule-create']({
...params,
name: params.name,
});
@@ -902,19 +903,16 @@ async function importScheduledTransactions(
async function getRuleForSchedule(
scheduleId: string,
): Promise<RuleEntity | null> {
const { data: ruleId } = (await send('api/query', {
query: q('schedules')
.filter({ id: scheduleId })
.calculate('rule')
.serialize(),
})) as { data: string | null };
const { data: ruleId } = (await aqlQuery(
q('schedules').filter({ id: scheduleId }).calculate('rule').serialize(),
)) as { data: string | null };
if (!ruleId) {
return null;
}
const { data: ruleData } = (await send('api/query', {
query: q('rules').filter({ id: ruleId }).select('*').serialize(),
})) as { data: Array<Record<string, unknown>> };
const { data: ruleData } = (await aqlQuery(
q('rules').filter({ id: ruleId }).select('*').serialize(),
)) as { data: Array<Record<string, unknown>> };
const ruleRow = ruleData?.[0];
if (!ruleRow) {
return null;
@@ -973,7 +971,7 @@ async function importScheduledTransactions(
value: scheduleNotes,
});
await send('api/rule-update', {
await mainApp['api/rule-update']({
rule: buildRuleUpdate(rule, actions),
});
}
@@ -1008,7 +1006,7 @@ async function importScheduledTransactions(
value: categoryId,
});
await send('api/rule-update', {
await mainApp['api/rule-update']({
rule: buildRuleUpdate(rule, actions),
});
}
@@ -1087,7 +1085,7 @@ async function importScheduledTransactions(
}
});
await send('api/rule-update', {
await mainApp['api/rule-update']({
rule: buildRuleUpdate(rule, actions),
});
}
@@ -1114,7 +1112,7 @@ async function importBudgets(data: Budget, entityIdMap: Map<string, string>) {
'Credit Card Payments',
);
await send('api/batch-budget-start');
await mainApp['api/batch-budget-start']();
try {
for (const budget of budgets) {
const month = monthUtils.monthFromDate(budget.month);
@@ -1132,7 +1130,7 @@ async function importBudgets(data: Budget, entityIdMap: Map<string, string>) {
return;
}
await send('api/budget-set-amount', {
await mainApp['api/budget-set-amount']({
month,
categoryId: catId,
amount,
@@ -1141,7 +1139,7 @@ async function importBudgets(data: Budget, entityIdMap: Map<string, string>) {
);
}
} finally {
await send('api/batch-budget-end');
await mainApp['api/batch-budget-end']();
}
}

View File

@@ -1,25 +0,0 @@
import * as connection from '../platform/server/connection';
import type { Handlers } from '../types/handlers';
import { createApp } from './app';
import { runHandler } from './mutators';
// Main app
export const app = createApp<Handlers>();
app.events.on('sync', event => {
connection.send('sync-event', event);
});
/**
* Run a handler by name (server-side). Same API shape as the client connection's send.
* Used by server code that needs to invoke handlers directly, e.g. importers.
*/
export async function send<K extends keyof Handlers>(
name: K,
args?: Parameters<Handlers[K]>[0],
): Promise<Awaited<ReturnType<Handlers[K]>>> {
return runHandler(app.handlers[name], args, { name }) as Promise<
Awaited<ReturnType<Handlers[K]>>
>;
}

View File

@@ -10,7 +10,7 @@ import * as monthUtils from '../shared/months';
import * as budgetActions from './budget/actions';
import * as budget from './budget/base';
import * as db from './db';
import { handlers } from './main';
import { mainApp } from './main';
import {
disableGlobalMutations,
enableGlobalMutations,
@@ -28,7 +28,7 @@ beforeEach(async () => {
});
afterEach(async () => {
await runHandler(handlers['close-budget']);
await mainApp['close-budget']();
connection.resetEvents();
enableGlobalMutations();
global.currentMonth = null;
@@ -72,7 +72,7 @@ describe('Budgets', () => {
'SELECT * FROM messages_clock',
);
const { error } = await runHandler(handlers['load-budget'], {
const { error } = await mainApp['load-budget']({
id: 'test-budget',
});
expect(error).toBe(undefined);
@@ -92,7 +92,7 @@ describe('Budgets', () => {
const spy = vi.spyOn(console, 'warn').mockImplementation(() => null);
const { error } = await runHandler(handlers['load-budget'], {
const { error } = await mainApp['load-budget']({
id: 'test-budget',
});
// There should be an error and the budget should be unloaded
@@ -128,7 +128,7 @@ describe('Accounts', () => {
});
const id = 'test-transfer';
await runHandler(handlers['transaction-add'], {
await mainApp['transaction-add']({
id,
account: 'one',
amount: 5000,
@@ -140,7 +140,7 @@ describe('Accounts', () => {
);
let transaction = await db.getTransaction(id);
await runHandler(handlers['transaction-update'], {
await mainApp['transaction-update']({
...(await db.getTransaction(id)),
payee: 'transfer-three',
date: '2017-01-03',
@@ -150,7 +150,7 @@ describe('Accounts', () => {
);
transaction = await db.getTransaction(id);
await runHandler(handlers['transaction-delete'], transaction);
await mainApp['transaction-delete'](transaction);
differ.expectToMatchDiff(
await db.all<db.DbTransaction>('SELECT * FROM transactions'),
);
@@ -172,7 +172,7 @@ describe('Budget', () => {
await db.insertCategory({ name: 'bar', cat_group: 'group1' });
});
let bounds = await runHandler(handlers['get-budget-bounds']);
let bounds = await mainApp['get-budget-bounds']();
expect(bounds.start).toBe('2016-10');
expect(bounds.end).toBe('2018-01');
expect(spreadsheet.meta().createdMonths).toMatchSnapshot();
@@ -181,7 +181,7 @@ describe('Budget', () => {
// current earliest budget to test if it creates the necessary
// budgets for the earlier months
db.runQuery("INSERT INTO accounts (id, name) VALUES ('one', 'boa')");
await runHandler(handlers['transaction-add'], {
await mainApp['transaction-add']({
id: uuidv4(),
date: '2016-05-06',
amount: 50,
@@ -192,7 +192,7 @@ describe('Budget', () => {
// budgets for the months in the future
global.currentMonth = '2017-02';
bounds = await runHandler(handlers['get-budget-bounds']);
bounds = await mainApp['get-budget-bounds']();
expect(bounds.start).toBe('2016-02');
expect(bounds.end).toBe('2018-02');
expect(spreadsheet.meta().createdMonths).toMatchSnapshot();
@@ -230,19 +230,19 @@ describe('Budget', () => {
db.insertCategoryGroup({ id: 'group1', name: 'group1' }),
);
categories = [
await runHandler(handlers['category-create'], {
await mainApp['category-create']({
name: 'foo',
groupId: 'group1',
}),
await runHandler(handlers['category-create'], {
await mainApp['category-create']({
name: 'bar',
groupId: 'group1',
}),
await runHandler(handlers['category-create'], {
await mainApp['category-create']({
name: 'baz',
groupId: 'group1',
}),
await runHandler(handlers['category-create'], {
await mainApp['category-create']({
name: 'biz',
groupId: 'group1',
}),
@@ -259,14 +259,14 @@ describe('Budget', () => {
};
// Test insertions
let changed = await captureChangedCells(() =>
runHandler(handlers['transaction-add'], trans),
mainApp['transaction-add'](trans),
);
expect(
changed.sort((a, b) => (a > b ? 1 : a < b ? -1 : 0)),
).toMatchSnapshot();
// Test updates
changed = await captureChangedCells(async () => {
await runHandler(handlers['transaction-update'], {
await mainApp['transaction-update']({
...(await db.getTransaction(trans.id)),
amount: 7000,
});
@@ -276,7 +276,7 @@ describe('Budget', () => {
).toMatchSnapshot();
// Test deletions
changed = await captureChangedCells(async () => {
await runHandler(handlers['transaction-delete'], { id: trans.id });
await mainApp['transaction-delete']({ id: trans.id });
});
expect(
changed.sort((a, b) => (a > b ? 1 : a < b ? -1 : 0)),
@@ -298,7 +298,7 @@ describe('Categories', () => {
expect(categories.length).toBe(2);
expect(categories.find(cat => cat.name === 'foo')).not.toBeNull();
expect(categories.find(cat => cat.name === 'bar')).not.toBeNull();
await runHandler(handlers['category-delete'], { id: 'foo' });
await mainApp['category-delete']({ id: 'foo' });
categories = await db.getCategories();
expect(categories.length).toBe(1);
@@ -355,7 +355,7 @@ describe('Categories', () => {
let trans = await db.getTransaction(transId);
expect(trans.category).toBe('foo');
await runHandler(handlers['category-delete'], {
await mainApp['category-delete']({
id: 'foo',
transferId: 'bar',
});
@@ -371,7 +371,7 @@ describe('Categories', () => {
// Transfering an income category to an expense just doesn't make
// sense. Make sure this doesn't do anything.
await expect(
runHandler(handlers['category-delete'], {
mainApp['category-delete']({
id: 'income1',
transferId: 'bar',
}),
@@ -381,7 +381,7 @@ describe('Categories', () => {
expect(categories.find(cat => cat.id === 'income1')).toBeDefined();
// Make sure you can delete income categories
await runHandler(handlers['category-delete'], {
await mainApp['category-delete']({
id: 'income1',
transferId: 'income2',
});

View File

@@ -6,12 +6,15 @@ import * as fs from '../platform/server/fs';
import { logger, setVerboseMode } from '../platform/server/log';
import * as sqlite from '../platform/server/sqlite';
import { q } from '../shared/query';
import type { QueryState } from '../shared/query';
import { amountToInteger, integerToAmount } from '../shared/util';
import type { Handlers } from '../types/handlers';
import type { ApiHandlers } from '../types/api-handlers';
import type { Handlers, ServerHandlers } from '../types/handlers';
import { app as accountsApp } from './accounts/app';
import { app as adminApp } from './admin/app';
import { installAPI } from './api';
import { app as apiApp } from './api';
import { createApp } from './app';
import { aqlQuery } from './aql';
import { app as authApp } from './auth/app';
import { app as budgetApp } from './budget/app';
@@ -21,8 +24,7 @@ import * as db from './db';
import * as encryption from './encryption';
import { app as encryptionApp } from './encryption/app';
import { app as filtersApp } from './filters/app';
import { app } from './main-app';
import { mutator, runHandler } from './mutators';
import { mutator } from './mutators';
import { app as notesApp } from './notes/app';
import { app as payeesApp } from './payees/app';
import { get } from './post';
@@ -41,38 +43,24 @@ import { app as transactionsApp } from './transactions/app';
import * as rules from './transactions/transaction-rules';
import { redo, undo } from './undo';
// handlers
// need to work around the type system here because the object
// is /currently/ empty but we promise to fill it in later
export let handlers = {} as unknown as Handlers;
handlers['undo'] = mutator(async function () {
return undo();
});
handlers['redo'] = mutator(function () {
return redo();
});
handlers['make-filters-from-conditions'] = async function ({
async function makeFiltersFromConditions({
conditions,
applySpecialCases,
applySpecialCases = undefined,
}) {
return rules.conditionsToAQL(conditions, { applySpecialCases });
};
}
handlers['query'] = async function (query) {
async function query(query) {
if (query['table'] == null) {
throw new Error('query has no table, did you forgot to call `.serialize`?');
}
return aqlQuery(query);
};
}
handlers['get-server-version'] = async function () {
async function getServerVersion() {
if (!getServer()) {
return { error: 'no-server' };
return { error: 'no-server' as const };
}
let version;
@@ -80,19 +68,19 @@ handlers['get-server-version'] = async function () {
const res = await get(getServer().BASE_SERVER + '/info');
const info = JSON.parse(res);
version = info.build.version;
version = info.build.version as string;
} catch {
return { error: 'network-failure' };
return { error: 'network-failure' as const };
}
return { version };
};
}
handlers['get-server-url'] = async function () {
async function getServerUrl() {
return getServer() && getServer().BASE_SERVER;
};
}
handlers['set-server-url'] = async function ({ url, validate = true }) {
async function setServerUrl({ url, validate = true }) {
if (url == null) {
await asyncStorage.removeItem('user-token');
} else {
@@ -100,7 +88,7 @@ handlers['set-server-url'] = async function ({ url, validate = true }) {
if (validate) {
// Validate the server is running
const result = await runHandler(handlers['subscribe-needs-bootstrap'], {
const result = await mainApp['subscribe-needs-bootstrap']({
url,
});
if ('error' in result) {
@@ -113,20 +101,60 @@ handlers['set-server-url'] = async function ({ url, validate = true }) {
await asyncStorage.setItem('did-bootstrap', true);
setServer(url);
return {};
};
}
handlers['app-focused'] = async function () {
async function appFocused() {
if (prefs.getPrefs() && prefs.getPrefs().id) {
// First we sync
void fullSync();
}
}
export type MiscHandlers = {
undo: () => Promise<void>;
redo: () => Promise<void>;
'make-filters-from-conditions': (arg: {
conditions: unknown;
applySpecialCases?: boolean;
}) => Promise<{ filters: unknown[] }>;
// oxlint-disable-next-line typescript/no-explicit-any
query: (query: QueryState) => Promise<{ data: any; dependencies: string[] }>;
'get-server-version': () => Promise<
{ error: 'no-server' } | { error: 'network-failure' } | { version: string }
>;
'get-server-url': () => Promise<string | null>;
'set-server-url': (arg: {
url: string;
validate?: boolean;
}) => Promise<{ error?: string }>;
'app-focused': () => Promise<void>;
};
handlers = installAPI(handlers) as Handlers;
const miscApp = createApp<MiscHandlers>({
undo: mutator(undo),
redo: mutator(redo),
'make-filters-from-conditions': makeFiltersFromConditions,
query,
'get-server-version': getServerVersion,
'get-server-url': getServerUrl,
'set-server-url': setServerUrl,
'app-focused': appFocused,
});
// A hack for now until we clean up everything
app.handlers = handlers;
app.combine(
const serverApp = createApp<ServerHandlers>();
serverApp.events.on('sync', event => {
connection.send('sync-event', event);
});
serverApp.combine(
miscApp,
authApp,
schedulesApp,
budgetApp,
@@ -148,6 +176,9 @@ app.combine(
tagsApp,
);
export const mainApp = createApp<Handlers>();
mainApp.combine(apiApp, serverApp);
export function getDefaultDocumentDir() {
return fs.join(process.env.ACTUAL_DOCUMENT_DIR, 'Actual');
}
@@ -209,17 +240,7 @@ export async function initApp(isDev, socketName) {
}
setServer(url);
connection.init(socketName, app.handlers);
// Allow running DB queries locally
global.$query = aqlQuery;
global.$q = q;
if (isDev) {
global.$send = (name, args) => runHandler(app.handlers[name], args);
global.$db = db;
global.$setSyncingMode = setSyncingMode;
}
connection.init(socketName, mainApp);
}
type BaseInitConfig = {
@@ -278,25 +299,25 @@ export async function init(config: InitConfig) {
if ('sessionToken' in config && config.sessionToken) {
// Session token authentication
await runHandler(handlers['subscribe-set-token'], {
await mainApp['subscribe-set-token']({
token: config.sessionToken,
});
// Validate the token
const user = await runHandler(handlers['subscribe-get-user'], undefined);
const user = await mainApp['subscribe-get-user']();
if (!user || user.tokenExpired === true) {
// Clear invalid token
await runHandler(handlers['subscribe-set-token'], { token: '' });
await mainApp['subscribe-set-token']({ token: '' });
throw new Error(
'Authentication failed: invalid or expired session token',
);
}
if (user.offline === true) {
// Clear token since we can't validate
await runHandler(handlers['subscribe-set-token'], { token: '' });
await mainApp['subscribe-set-token']({ token: '' });
throw new Error('Authentication failed: server offline or unreachable');
}
} else if ('password' in config && config.password) {
const result = await runHandler(handlers['subscribe-sign-in'], {
const result = await mainApp['subscribe-sign-in']({
password: config.password,
});
if (result?.error) {
@@ -308,7 +329,7 @@ export async function init(config: InitConfig) {
// access to the server, we are doing things locally
setServer(null);
app.events.on('load-budget', () => {
mainApp.events.on('load-budget', () => {
setSyncingMode('offline');
});
}
@@ -321,14 +342,14 @@ export async function init(config: InitConfig) {
export const lib = {
getDataDir: fs.getDataDir,
sendMessage: (msg, args) => connection.send(msg, args),
send: async <K extends keyof Handlers, T extends Handlers[K]>(
send: async <K extends keyof Handlers>(
name: K,
args?: Parameters<T>[0],
): Promise<Awaited<ReturnType<T>>> => {
const res = await runHandler(app.handlers[name], args);
return res;
args?: Parameters<Handlers[K]>[0],
): Promise<Awaited<ReturnType<Handlers[K]>>> => {
const res = await mainApp.runHandler(name, args);
return res as Awaited<ReturnType<Handlers[K]>>;
},
on: (name, func) => app.events.on(name, func),
on: (name, func) => mainApp.events.on(name, func),
q,
db,
amountToInteger,

View File

@@ -1,7 +1,10 @@
// @ts-strict-ignore
import { captureBreadcrumb, captureException } from '../platform/exceptions';
import { sequential } from '../shared/async';
import type { HandlerFunctions, Handlers } from '../types/handlers';
type Handler<TArgs extends unknown[] = unknown[], TReturn = unknown> = (
...args: TArgs
) => TReturn;
const runningMethods = new Set();
@@ -11,7 +14,9 @@ let globalMutationsEnabled = false;
let _latestHandlerNames = [];
export function mutator<T extends HandlerFunctions>(handler: T): T {
export function mutator<TArgs extends unknown[], TReturn>(
handler: Handler<TArgs, TReturn>,
): Handler<TArgs, TReturn> {
mutatingMethods.set(handler, true);
return handler;
}
@@ -38,11 +43,11 @@ function wait(time) {
return new Promise(resolve => setTimeout(resolve, time));
}
export async function runHandler<T extends Handlers[keyof Handlers]>(
handler: T,
args?: Parameters<T>[0],
export async function runHandler<TArgs extends unknown[], TReturn>(
handler: Handler<TArgs, Promise<TReturn>>,
args?: TArgs[0],
{ undoTag, name }: { undoTag?; name? } = {},
): Promise<ReturnType<T>> {
): Promise<TReturn> {
// For debug reasons, track the latest handlers that have been
// called
_latestHandlerNames.push(name);
@@ -50,10 +55,16 @@ export async function runHandler<T extends Handlers[keyof Handlers]>(
_latestHandlerNames = _latestHandlerNames.slice(-5);
}
const invokeHandler = () =>
handler(...((args !== undefined ? [args] : []) as TArgs));
if (mutatingMethods.has(handler)) {
return runMutator(() => handler(args), { undoTag }) as Promise<
ReturnType<T>
>;
// If already inside a mutator, call directly to avoid deadlocking the
// sequential queue.
if (currentContext !== null) {
return invokeHandler();
}
return runMutator(invokeHandler, { undoTag });
}
// When closing a file, it clears out all global state for the file. That
@@ -64,12 +75,12 @@ export async function runHandler<T extends Handlers[keyof Handlers]>(
await flushRunningMethods();
}
const promise = handler(args);
const promise = invokeHandler();
runningMethods.add(promise);
void promise.then(() => {
runningMethods.delete(promise);
});
return promise as Promise<ReturnType<T>>;
return promise;
}
// These are useful for tests. Only use them in tests.

View File

@@ -14,44 +14,42 @@ import { batchMessages } from '../sync';
import * as rules from '../transactions/transaction-rules';
import { undoable } from '../undo';
export type PayeesHandlers = {
'payee-create': typeof createPayee;
'common-payees-get': typeof getCommonPayees;
'payees-get': typeof getPayees;
'payees-get-orphaned': typeof getOrphanedPayees;
'payees-get-rule-counts': typeof getPayeeRuleCounts;
'payees-merge': typeof mergePayees;
'payees-batch-change': typeof batchChangePayees;
'payees-check-orphaned': typeof checkOrphanedPayees;
'payees-get-rules': typeof getPayeeRules;
'payee-location-create': typeof createPayeeLocation;
'payee-locations-get': typeof getPayeeLocations;
'payee-location-delete': typeof deletePayeeLocation;
'payees-get-nearby': typeof getNearbyPayees;
export type PayeeHandlers = {
createPayee: typeof createPayee;
getCommonPayees: typeof getCommonPayees;
getPayees: typeof getPayees;
getOrphanedPayees: typeof getOrphanedPayees;
getPayeeRuleCounts: typeof getPayeeRuleCounts;
mergePayees: typeof mergePayees;
batchChangePayees: typeof batchChangePayees;
checkOrphanedPayees: typeof checkOrphanedPayees;
getPayeeRules: typeof getPayeeRules;
createPayeeLocation: typeof createPayeeLocation;
getPayeeLocations: typeof getPayeeLocations;
deletePayeeLocation: typeof deletePayeeLocation;
getNearbyPayees: typeof getNearbyPayees;
};
export const app = createApp<PayeesHandlers>();
app.method('payee-create', mutator(undoable(createPayee)));
app.method('common-payees-get', getCommonPayees);
app.method('payees-get', getPayees);
app.method('payees-get-orphaned', getOrphanedPayees);
app.method('payees-get-rule-counts', getPayeeRuleCounts);
app.method(
'payees-merge',
mutator(
export const app = createApp<PayeeHandlers>({
createPayee: mutator(undoable(createPayee)),
batchChangePayees: mutator(undoable(batchChangePayees)),
createPayeeLocation: mutator(createPayeeLocation),
deletePayeeLocation: mutator(deletePayeeLocation),
mergePayees: mutator(
undoable(mergePayees, args => ({
mergeIds: args.mergeIds,
targetId: args.targetId,
})),
),
);
app.method('payees-batch-change', mutator(undoable(batchChangePayees)));
app.method('payees-check-orphaned', checkOrphanedPayees);
app.method('payees-get-rules', getPayeeRules);
app.method('payee-location-create', mutator(createPayeeLocation));
app.method('payee-locations-get', getPayeeLocations);
app.method('payee-location-delete', mutator(deletePayeeLocation));
app.method('payees-get-nearby', getNearbyPayees);
getCommonPayees,
getPayees,
getOrphanedPayees,
getPayeeRuleCounts,
checkOrphanedPayees,
getPayeeRules,
getPayeeLocations,
getNearbyPayees,
});
async function createPayee({ name }: { name: PayeeEntity['name'] }) {
return db.insertPayee({ name });

View File

@@ -17,7 +17,7 @@ import type { MetadataPrefs } from '../../types/prefs';
import { setType as setBudgetType, triggerBudgetChanges } from '../budget/base';
import * as db from '../db';
import { PostError, SyncError } from '../errors';
import { app } from '../main-app';
import { mainApp } from '../main';
import { runMutator } from '../mutators';
import { postBinary } from '../post';
import * as prefs from '../prefs';
@@ -410,7 +410,7 @@ export const applyMessages = sequential(async (messages: Message[]) => {
_syncListeners.forEach(func => func(oldData, newData));
const tables = getTablesFromMessages(messages.filter(msg => !msg.old));
app.events.emit('sync', {
mainApp.events.emit('sync', {
type: 'applied',
tables,
data: newData,
@@ -444,16 +444,16 @@ async function errorHandler(e: Error) {
// couldn't apply, which doesn't make any sense. Must be a bug
// in the code. Send a specific error type for it for a custom
// message.
app.events.emit('sync', {
mainApp.events.emit('sync', {
type: 'error',
subtype: 'apply-failure',
meta: e.meta,
});
} else {
app.events.emit('sync', { type: 'error', meta: e.meta });
mainApp.events.emit('sync', { type: 'error', meta: e.meta });
}
} else if (e instanceof Timestamp.ClockDriftError) {
app.events.emit('sync', {
mainApp.events.emit('sync', {
type: 'error',
subtype: 'clock-drift',
meta: { message: e.message },
@@ -574,7 +574,7 @@ export const fullSync = once(async function (): Promise<
| { messages: Message[] }
| { error: { message: string; reason: string; meta: unknown } }
> {
app.events.emit('sync', { type: 'start' });
mainApp.events.emit('sync', { type: 'start' });
let messages;
try {
@@ -586,13 +586,13 @@ export const fullSync = once(async function (): Promise<
if (e.reason === 'out-of-sync') {
captureException(e);
app.events.emit('sync', {
mainApp.events.emit('sync', {
type: 'error',
subtype: 'out-of-sync',
meta: e.meta,
});
} else if (e.reason === 'invalid-schema') {
app.events.emit('sync', {
mainApp.events.emit('sync', {
type: 'error',
subtype: 'invalid-schema',
meta: e.meta,
@@ -601,36 +601,36 @@ export const fullSync = once(async function (): Promise<
e.reason === 'decrypt-failure' ||
e.reason === 'encrypt-failure'
) {
app.events.emit('sync', {
mainApp.events.emit('sync', {
type: 'error',
subtype: e.reason,
meta: e.meta,
});
} else if (e.reason === 'clock-drift') {
app.events.emit('sync', {
mainApp.events.emit('sync', {
type: 'error',
subtype: 'clock-drift',
meta: e.meta,
});
} else {
app.events.emit('sync', { type: 'error', meta: e.meta });
mainApp.events.emit('sync', { type: 'error', meta: e.meta });
}
} else if (e instanceof PostError) {
logger.log(e);
if (e.reason === 'unauthorized') {
app.events.emit('sync', { type: 'unauthorized' });
mainApp.events.emit('sync', { type: 'unauthorized' });
// Set the user into read-only mode
void asyncStorage.setItem('readOnly', 'true');
} else if (e.reason === 'network-failure') {
app.events.emit('sync', { type: 'error', subtype: 'network' });
mainApp.events.emit('sync', { type: 'error', subtype: 'network' });
} else {
app.events.emit('sync', { type: 'error', subtype: e.reason });
mainApp.events.emit('sync', { type: 'error', subtype: e.reason });
}
} else {
captureException(e);
// TODO: Send the message to the client and allow them to expand & view it
app.events.emit('sync', { type: 'error' });
mainApp.events.emit('sync', { type: 'error' });
}
return { error: { message: e.message, reason: e.reason, meta: e.meta } };
@@ -638,7 +638,7 @@ export const fullSync = once(async function (): Promise<
const tables = getTablesFromMessages(messages);
app.events.emit('sync', {
mainApp.events.emit('sync', {
type: 'success',
tables,
syncDisabled: checkSyncingMode('disabled'),

View File

@@ -1,12 +1,12 @@
import { v4 as uuidv4 } from 'uuid';
import * as fs from '../../platform/server/fs';
import { handlers } from '../main';
import { mainApp } from '../main';
export async function uniqueBudgetName(
initialName: string = 'My Finances',
): Promise<string> {
const budgets = await handlers['get-budgets']();
const budgets = await mainApp['get-budgets']();
let idx = 1;
// If there is a conflict, keep appending an index until there is no

View File

@@ -0,0 +1 @@
export { App } from '../server/app';

View File

@@ -6,8 +6,9 @@ import type { BudgetFileHandlers } from '../server/budgetfiles/app';
import type { DashboardHandlers } from '../server/dashboard/app';
import type { EncryptionHandlers } from '../server/encryption/app';
import type { FiltersHandlers } from '../server/filters/app';
import type { MiscHandlers } from '../server/main';
import type { NotesHandlers } from '../server/notes/app';
import type { PayeesHandlers } from '../server/payees/app';
import type { PayeeHandlers } from '../server/payees/app';
import type { PreferencesHandlers } from '../server/preferences/app';
import type { ReportsHandlers } from '../server/reports/app';
import type { RulesHandlers } from '../server/rules/app';
@@ -19,10 +20,8 @@ import type { ToolsHandlers } from '../server/tools/app';
import type { TransactionHandlers } from '../server/transactions/app';
import type { ApiHandlers } from './api-handlers';
import type { ServerHandlers } from './server-handlers';
export type Handlers = {} & ServerHandlers &
ApiHandlers &
export type ServerHandlers = MiscHandlers &
BudgetHandlers &
DashboardHandlers &
FiltersHandlers &
@@ -35,7 +34,7 @@ export type Handlers = {} & ServerHandlers &
AdminHandlers &
ToolsHandlers &
AccountHandlers &
PayeesHandlers &
PayeeHandlers &
SpreadsheetHandlers &
SyncHandlers &
BudgetFileHandlers &
@@ -43,4 +42,6 @@ export type Handlers = {} & ServerHandlers &
TagsHandlers &
AuthHandlers;
export type Handlers = {} & ServerHandlers & ApiHandlers;
export type HandlerFunctions = Handlers[keyof Handlers];

View File

@@ -1,27 +0,0 @@
import type { QueryState } from '../shared/query';
export type ServerHandlers = {
undo: () => Promise<void>;
redo: () => Promise<void>;
'make-filters-from-conditions': (arg: {
conditions: unknown;
applySpecialCases?: boolean;
}) => Promise<{ filters: unknown[] }>;
// oxlint-disable-next-line typescript/no-explicit-any
query: (query: QueryState) => Promise<{ data: any; dependencies: string[] }>;
'get-server-version': () => Promise<
{ error: 'no-server' } | { error: 'network-failure' } | { version: string }
>;
'get-server-url': () => Promise<string | null>;
'set-server-url': (arg: {
url: string;
validate?: boolean;
}) => Promise<{ error?: string }>;
'app-focused': () => Promise<void>;
};

View File

@@ -128,10 +128,6 @@ app.get('/metrics', (_req, res) => {
app.use((req, res, next) => {
res.set('Cross-Origin-Opener-Policy', 'same-origin');
res.set('Cross-Origin-Embedder-Policy', 'require-corp');
res.set(
'Content-Security-Policy',
"default-src 'self' blob:; img-src 'self' blob: data:; script-src 'self' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src http: https:;",
);
next();
});
if (process.env.NODE_ENV === 'development') {

View File

@@ -3,4 +3,4 @@ category: Enhancements
authors: [MatissJanis]
---
Custom Themes: allow using a custom font
cli: improved aql support

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [joel-jeremy]
---
Refactor client-server communication to use typed server proxy methods for improved clarity and safety.

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [MatissJanis]
---
Add post-merge hook to automatically install dependencies when yarn.lock changes after merges.