mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-21 06:58:47 -05:00
Compare commits
12 Commits
claude/sec
...
js-proxy
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6494d8ef4e | ||
|
|
1598b6ae81 | ||
|
|
c057a55a2a | ||
|
|
23adf06cb0 | ||
|
|
573238a2e2 | ||
|
|
07ace07bcc | ||
|
|
069da61644 | ||
|
|
54039b36c6 | ||
|
|
a8a2d23e63 | ||
|
|
ae4e1f9585 | ||
|
|
d1f3f3ec10 | ||
|
|
269c5a1e48 |
7
.husky/post-merge
Executable file
7
.husky/post-merge
Executable 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
|
||||
346
packages/cli/src/commands/query.test.ts
Normal file
346
packages/cli/src/commands/query.test.ts
Normal 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"',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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']();
|
||||
}
|
||||
|
||||
@@ -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']();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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']();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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]>>
|
||||
>;
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
1
packages/loot-core/src/types/app.ts
Normal file
1
packages/loot-core/src/types/app.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { App } from '../server/app';
|
||||
@@ -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];
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -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') {
|
||||
|
||||
@@ -3,4 +3,4 @@ category: Enhancements
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Custom Themes: allow using a custom font
|
||||
cli: improved aql support
|
||||
6
upcoming-release-notes/7246.md
Normal file
6
upcoming-release-notes/7246.md
Normal 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.
|
||||
6
upcoming-release-notes/7248.md
Normal file
6
upcoming-release-notes/7248.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Add post-merge hook to automatically install dependencies when yarn.lock changes after merges.
|
||||
Reference in New Issue
Block a user