mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-22 00:13:45 -05:00
Compare commits
4 Commits
claude/imp
...
matiss/the
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2de31993f | ||
|
|
9da16e3137 | ||
|
|
edc406e3c4 | ||
|
|
c857fc9d0f |
@@ -1,7 +0,0 @@
|
||||
#!/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
|
||||
@@ -1,432 +0,0 @@
|
||||
import * as api from '@actual-app/api';
|
||||
import { Command } from 'commander';
|
||||
|
||||
import { printOutput } from '../output';
|
||||
|
||||
import {
|
||||
expandSelectAliases,
|
||||
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"',
|
||||
);
|
||||
});
|
||||
|
||||
it('includes descriptions in field output', async () => {
|
||||
await run(['query', 'fields', 'transactions']);
|
||||
|
||||
const output = vi.mocked(printOutput).mock.calls[0][0] as Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
description?: string;
|
||||
}>;
|
||||
const amountField = output.find(f => f.name === 'amount');
|
||||
expect(amountField?.description).toContain('cents');
|
||||
});
|
||||
});
|
||||
|
||||
describe('describe subcommand', () => {
|
||||
it('outputs schema for all tables', async () => {
|
||||
await run(['query', 'describe']);
|
||||
|
||||
const output = vi.mocked(printOutput).mock.calls[0][0] as Record<
|
||||
string,
|
||||
unknown[]
|
||||
>;
|
||||
expect(output).toHaveProperty('transactions');
|
||||
expect(output).toHaveProperty('accounts');
|
||||
expect(output).toHaveProperty('categories');
|
||||
expect(output).toHaveProperty('payees');
|
||||
expect(output).toHaveProperty('rules');
|
||||
expect(output).toHaveProperty('schedules');
|
||||
});
|
||||
});
|
||||
|
||||
describe('--exclude-transfers flag', () => {
|
||||
it('adds transfer_id null filter for transactions', async () => {
|
||||
await run([
|
||||
'query',
|
||||
'run',
|
||||
'--table',
|
||||
'transactions',
|
||||
'--exclude-transfers',
|
||||
]);
|
||||
|
||||
const qObj = getQueryObj();
|
||||
expect(qObj.filter).toHaveBeenCalledWith({ transfer_id: { $eq: null } });
|
||||
});
|
||||
|
||||
it('errors when used with non-transactions table', async () => {
|
||||
await expect(
|
||||
run(['query', 'run', '--table', 'accounts', '--exclude-transfers']),
|
||||
).rejects.toThrow(
|
||||
'--exclude-transfers can only be used with --table transactions',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandSelectAliases', () => {
|
||||
it('expands transaction aliases', () => {
|
||||
expect(
|
||||
expandSelectAliases('transactions', [
|
||||
'date',
|
||||
'payee',
|
||||
'category',
|
||||
'amount',
|
||||
]),
|
||||
).toEqual(['date', 'payee.name', 'category.name', 'amount']);
|
||||
});
|
||||
|
||||
it('expands account alias', () => {
|
||||
expect(expandSelectAliases('transactions', ['account'])).toEqual([
|
||||
'account.name',
|
||||
]);
|
||||
});
|
||||
|
||||
it('passes through unknown fields unchanged', () => {
|
||||
expect(expandSelectAliases('transactions', ['notes'])).toEqual(['notes']);
|
||||
});
|
||||
|
||||
it('returns fields unchanged for tables without aliases', () => {
|
||||
expect(expandSelectAliases('rules', ['id', 'stage'])).toEqual([
|
||||
'id',
|
||||
'stage',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -4,216 +4,12 @@ import type { Command } from 'commander';
|
||||
import { withConnection } from '../connection';
|
||||
import { readJsonInput } from '../input';
|
||||
import { printOutput } from '../output';
|
||||
import { CliError, parseIntFlag } from '../utils';
|
||||
import { parseIntFlag } from '../utils';
|
||||
|
||||
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
|
||||
type FieldInfo = { type: string; ref?: string; description?: string };
|
||||
|
||||
const TABLE_SCHEMA: Record<string, Record<string, FieldInfo>> = {
|
||||
transactions: {
|
||||
id: { type: 'id', description: 'Unique transaction identifier' },
|
||||
account: { type: 'id', ref: 'accounts', description: 'Account ID' },
|
||||
date: { type: 'date', description: 'Transaction date (YYYY-MM-DD)' },
|
||||
amount: {
|
||||
type: 'integer',
|
||||
description:
|
||||
'Amount in cents (e.g. 1000 = $10.00). Negative = expense, positive = income',
|
||||
},
|
||||
payee: { type: 'id', ref: 'payees', description: 'Payee ID' },
|
||||
category: { type: 'id', ref: 'categories', description: 'Category ID' },
|
||||
notes: { type: 'string', description: 'Transaction notes/memo' },
|
||||
imported_id: {
|
||||
type: 'string',
|
||||
description: 'External ID from bank import',
|
||||
},
|
||||
transfer_id: {
|
||||
type: 'id',
|
||||
description:
|
||||
'Linked transaction ID for transfers. Non-null means this is a transfer between own accounts',
|
||||
},
|
||||
cleared: { type: 'boolean', description: 'Whether transaction is cleared' },
|
||||
reconciled: {
|
||||
type: 'boolean',
|
||||
description: 'Whether transaction is reconciled',
|
||||
},
|
||||
starting_balance_flag: {
|
||||
type: 'boolean',
|
||||
description: 'True for the starting balance transaction',
|
||||
},
|
||||
imported_payee: {
|
||||
type: 'string',
|
||||
description: 'Original payee name from bank import',
|
||||
},
|
||||
is_parent: {
|
||||
type: 'boolean',
|
||||
description: 'True if this is a split parent transaction',
|
||||
},
|
||||
is_child: {
|
||||
type: 'boolean',
|
||||
description: 'True if this is a split child transaction',
|
||||
},
|
||||
parent_id: {
|
||||
type: 'id',
|
||||
description: 'Parent transaction ID for split children',
|
||||
},
|
||||
sort_order: { type: 'float', description: 'Sort order within a day' },
|
||||
schedule: {
|
||||
type: 'id',
|
||||
ref: 'schedules',
|
||||
description: 'Linked schedule ID',
|
||||
},
|
||||
'account.name': {
|
||||
type: 'string',
|
||||
ref: 'accounts',
|
||||
description: 'Resolved account name',
|
||||
},
|
||||
'payee.name': {
|
||||
type: 'string',
|
||||
ref: 'payees',
|
||||
description: 'Resolved payee name',
|
||||
},
|
||||
'category.name': {
|
||||
type: 'string',
|
||||
ref: 'categories',
|
||||
description: 'Resolved category name',
|
||||
},
|
||||
'category.group.name': {
|
||||
type: 'string',
|
||||
ref: 'category_groups',
|
||||
description: 'Resolved category group name',
|
||||
},
|
||||
},
|
||||
accounts: {
|
||||
id: { type: 'id', description: 'Unique account identifier' },
|
||||
name: { type: 'string', description: 'Account name' },
|
||||
offbudget: {
|
||||
type: 'boolean',
|
||||
description: 'True if account is off-budget (tracking)',
|
||||
},
|
||||
closed: { type: 'boolean', description: 'True if account is closed' },
|
||||
sort_order: { type: 'float', description: 'Display sort order' },
|
||||
},
|
||||
categories: {
|
||||
id: { type: 'id', description: 'Unique category identifier' },
|
||||
name: { type: 'string', description: 'Category name' },
|
||||
is_income: { type: 'boolean', description: 'True for income categories' },
|
||||
group_id: {
|
||||
type: 'id',
|
||||
ref: 'category_groups',
|
||||
description: 'Category group ID',
|
||||
},
|
||||
sort_order: { type: 'float', description: 'Display sort order' },
|
||||
hidden: { type: 'boolean', description: 'True if category is hidden' },
|
||||
'group.name': {
|
||||
type: 'string',
|
||||
ref: 'category_groups',
|
||||
description: 'Resolved category group name',
|
||||
},
|
||||
},
|
||||
payees: {
|
||||
id: { type: 'id', description: 'Unique payee identifier' },
|
||||
name: { type: 'string', description: 'Payee name' },
|
||||
transfer_acct: {
|
||||
type: 'id',
|
||||
ref: 'accounts',
|
||||
description:
|
||||
'Linked account ID for transfer payees. Non-null means this payee represents a transfer to/from this account',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
id: { type: 'id', description: 'Unique rule identifier' },
|
||||
stage: { type: 'string', description: 'Rule stage (pre, post, null)' },
|
||||
conditions_op: {
|
||||
type: 'string',
|
||||
description: 'How conditions combine: "and" or "or"',
|
||||
},
|
||||
conditions: { type: 'json', description: 'Rule conditions as JSON array' },
|
||||
actions: { type: 'json', description: 'Rule actions as JSON array' },
|
||||
},
|
||||
schedules: {
|
||||
id: { type: 'id', description: 'Unique schedule identifier' },
|
||||
name: { type: 'string', description: 'Schedule name' },
|
||||
rule: {
|
||||
type: 'id',
|
||||
ref: 'rules',
|
||||
description: 'Associated rule ID',
|
||||
},
|
||||
next_date: {
|
||||
type: 'date',
|
||||
description: 'Next occurrence date (YYYY-MM-DD)',
|
||||
},
|
||||
completed: {
|
||||
type: 'boolean',
|
||||
description: 'True if schedule is completed',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const FIELD_ALIASES: Record<string, Record<string, string>> = {
|
||||
transactions: {
|
||||
payee: 'payee.name',
|
||||
category: 'category.name',
|
||||
account: 'account.name',
|
||||
group: 'category.group.name',
|
||||
},
|
||||
categories: {
|
||||
group: 'group.name',
|
||||
},
|
||||
};
|
||||
|
||||
export function expandSelectAliases(table: string, fields: string[]): string[] {
|
||||
const aliases = FIELD_ALIASES[table];
|
||||
if (!aliases) return fields;
|
||||
return fields.map(f => aliases[f.trim()] ?? f);
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -231,154 +27,34 @@ 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>) {
|
||||
const last = cmdOpts.last ? parseIntFlag(cmdOpts.last, '--last') : undefined;
|
||||
if (!cmdOpts.table) {
|
||||
throw new Error('--table is required (or use --file)');
|
||||
}
|
||||
let queryObj = api.q(cmdOpts.table);
|
||||
|
||||
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');
|
||||
}
|
||||
if (cmdOpts.select) {
|
||||
queryObj = queryObj.select(cmdOpts.select.split(','));
|
||||
}
|
||||
|
||||
const table =
|
||||
cmdOpts.table ?? (last !== undefined ? 'transactions' : undefined);
|
||||
if (!table) {
|
||||
throw new CliError(
|
||||
'--table is required (or use --file or --last)',
|
||||
'Run "actual query tables" to see available tables, or use --last <n> for recent transactions.',
|
||||
);
|
||||
if (cmdOpts.filter) {
|
||||
queryObj = queryObj.filter(JSON.parse(cmdOpts.filter));
|
||||
}
|
||||
|
||||
if (!(table in TABLE_SCHEMA)) {
|
||||
throw new Error(
|
||||
`Unknown table "${table}". Available tables: ${AVAILABLE_TABLES}`,
|
||||
);
|
||||
if (cmdOpts.orderBy) {
|
||||
queryObj = queryObj.orderBy(cmdOpts.orderBy.split(','));
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
if (cmdOpts.excludeTransfers && table !== 'transactions') {
|
||||
throw new Error(
|
||||
'--exclude-transfers can only be used with --table transactions',
|
||||
);
|
||||
}
|
||||
|
||||
let queryObj = api.q(table);
|
||||
|
||||
if (cmdOpts.count) {
|
||||
queryObj = queryObj.calculate({ $count: '*' });
|
||||
} else if (cmdOpts.select) {
|
||||
queryObj = queryObj.select(
|
||||
expandSelectAliases(table, cmdOpts.select.split(',')),
|
||||
);
|
||||
} else if (last !== undefined) {
|
||||
queryObj = queryObj.select(LAST_DEFAULT_SELECT);
|
||||
}
|
||||
|
||||
const filterStr = cmdOpts.filter ?? cmdOpts.where;
|
||||
if (filterStr) {
|
||||
try {
|
||||
queryObj = queryObj.filter(JSON.parse(filterStr));
|
||||
} catch {
|
||||
throw new CliError(
|
||||
'Invalid JSON in --filter.',
|
||||
`Ensure valid JSON. Example: --filter '{"amount":{"$lt":0}}'`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (cmdOpts.excludeTransfers) {
|
||||
queryObj = queryObj.filter({ transfer_id: { $eq: null } });
|
||||
}
|
||||
|
||||
const orderByStr =
|
||||
cmdOpts.orderBy ??
|
||||
(last !== undefined && !cmdOpts.count ? 'date:desc' : undefined);
|
||||
if (orderByStr) {
|
||||
queryObj = queryObj.orderBy(parseOrderBy(orderByStr));
|
||||
}
|
||||
|
||||
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(','));
|
||||
if (cmdOpts.limit) {
|
||||
queryObj = queryObj.limit(parseIntFlag(cmdOpts.limit, '--limit'));
|
||||
}
|
||||
|
||||
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 -
|
||||
|
||||
# Exclude transfers from results
|
||||
actual query run --table transactions --exclude-transfers --last 10
|
||||
|
||||
# Use shorthand aliases (payee = payee.name, category = category.name)
|
||||
actual query run --table transactions --select "date,payee,category,amount" --last 10
|
||||
|
||||
Available tables: ${AVAILABLE_TABLES}
|
||||
Use "actual query tables" and "actual query fields <table>" for schema info.
|
||||
Use "actual query describe" for full schema with all tables, fields, and descriptions.
|
||||
|
||||
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')
|
||||
@@ -389,39 +65,16 @@ export function registerQueryCommand(program: Command) {
|
||||
.description('Execute an AQL query')
|
||||
.option(
|
||||
'--table <table>',
|
||||
'Table to query (use "actual query tables" to list available tables)',
|
||||
'Table to query (transactions, accounts, categories, payees)',
|
||||
)
|
||||
.option('--select <fields>', 'Comma-separated fields to select')
|
||||
.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('--filter <json>', 'Filter expression as JSON')
|
||||
.option('--order-by <fields>', 'Comma-separated fields to order by')
|
||||
.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)',
|
||||
)
|
||||
.option(
|
||||
'--exclude-transfers',
|
||||
'Exclude transfer transactions (only for --table transactions)',
|
||||
false,
|
||||
)
|
||||
.addHelpText('after', RUN_EXAMPLES)
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
@@ -434,60 +87,7 @@ export function registerQueryCommand(program: Command) {
|
||||
: buildQueryFromFlags(cmdOpts);
|
||||
|
||||
const result = await api.aqlQuery(queryObj);
|
||||
|
||||
if (cmdOpts.count) {
|
||||
printOutput({ count: result.data }, opts.format);
|
||||
} else {
|
||||
printOutput(result, opts.format);
|
||||
}
|
||||
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 } : {}),
|
||||
...(info.description ? { description: info.description } : {}),
|
||||
}));
|
||||
printOutput(fields, opts.format);
|
||||
});
|
||||
|
||||
query
|
||||
.command('describe')
|
||||
.description(
|
||||
'Output full schema for all tables (fields, types, relationships, descriptions)',
|
||||
)
|
||||
.action(() => {
|
||||
const opts = program.opts();
|
||||
const schema: Record<string, unknown[]> = {};
|
||||
for (const [table, fields] of Object.entries(TABLE_SCHEMA)) {
|
||||
schema[table] = Object.entries(fields).map(([name, info]) => ({
|
||||
name,
|
||||
type: info.type,
|
||||
...(info.ref ? { ref: info.ref } : {}),
|
||||
...(info.description ? { description: info.description } : {}),
|
||||
}));
|
||||
}
|
||||
printOutput(schema, opts.format);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
import * as api from '@actual-app/api';
|
||||
import { Command } from 'commander';
|
||||
|
||||
import { printOutput } from '../output';
|
||||
|
||||
import { registerTransactionsCommand } from './transactions';
|
||||
|
||||
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: [] }),
|
||||
addTransactions: vi.fn().mockResolvedValue([]),
|
||||
importTransactions: vi.fn().mockResolvedValue({ added: [], updated: [] }),
|
||||
updateTransaction: vi.fn().mockResolvedValue(undefined),
|
||||
deleteTransaction: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
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();
|
||||
registerTransactionsCommand(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('transactions list', () => {
|
||||
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();
|
||||
});
|
||||
|
||||
it('uses AQL query with resolved field names', async () => {
|
||||
await run([
|
||||
'transactions',
|
||||
'list',
|
||||
'--account',
|
||||
'acc-1',
|
||||
'--start',
|
||||
'2025-01-01',
|
||||
'--end',
|
||||
'2025-01-31',
|
||||
]);
|
||||
|
||||
expect(api.q).toHaveBeenCalledWith('transactions');
|
||||
const qObj = getQueryObj();
|
||||
expect(qObj.select).toHaveBeenCalledWith([
|
||||
'*',
|
||||
'account.name',
|
||||
'payee.name',
|
||||
'category.name',
|
||||
]);
|
||||
expect(qObj.filter).toHaveBeenCalledWith({
|
||||
account: 'acc-1',
|
||||
date: { $gte: '2025-01-01', $lte: '2025-01-31' },
|
||||
});
|
||||
expect(qObj.orderBy).toHaveBeenCalledWith([{ date: 'desc' }]);
|
||||
});
|
||||
|
||||
it('defaults --start to 30 days before --end', async () => {
|
||||
await run([
|
||||
'transactions',
|
||||
'list',
|
||||
'--account',
|
||||
'acc-1',
|
||||
'--end',
|
||||
'2025-02-28',
|
||||
]);
|
||||
|
||||
const qObj = getQueryObj();
|
||||
expect(qObj.filter).toHaveBeenCalledWith({
|
||||
account: 'acc-1',
|
||||
date: { $gte: '2025-01-29', $lte: '2025-02-28' },
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults both --start and --end when omitted', async () => {
|
||||
await run(['transactions', 'list', '--account', 'acc-1']);
|
||||
|
||||
const qObj = getQueryObj();
|
||||
const filterCall = qObj.filter.mock.calls[0][0];
|
||||
expect(filterCall.account).toBe('acc-1');
|
||||
expect(filterCall.date.$gte).toBeDefined();
|
||||
expect(filterCall.date.$lte).toBeDefined();
|
||||
});
|
||||
|
||||
it('excludes transfers when --exclude-transfers is set', async () => {
|
||||
await run([
|
||||
'transactions',
|
||||
'list',
|
||||
'--account',
|
||||
'acc-1',
|
||||
'--start',
|
||||
'2025-01-01',
|
||||
'--end',
|
||||
'2025-01-31',
|
||||
'--exclude-transfers',
|
||||
]);
|
||||
|
||||
const qObj = getQueryObj();
|
||||
expect(qObj.filter).toHaveBeenCalledWith({
|
||||
account: 'acc-1',
|
||||
date: { $gte: '2025-01-01', $lte: '2025-01-31' },
|
||||
transfer_id: { $eq: null },
|
||||
});
|
||||
});
|
||||
|
||||
it('outputs result.data from AQL query', async () => {
|
||||
const mockData = [{ id: 't1', amount: -500 }];
|
||||
vi.mocked(api.aqlQuery).mockResolvedValueOnce({ data: mockData });
|
||||
|
||||
await run([
|
||||
'transactions',
|
||||
'list',
|
||||
'--account',
|
||||
'acc-1',
|
||||
'--start',
|
||||
'2025-01-01',
|
||||
'--end',
|
||||
'2025-01-31',
|
||||
]);
|
||||
|
||||
expect(printOutput).toHaveBeenCalledWith(mockData, undefined);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,6 @@ import type { Command } from 'commander';
|
||||
import { withConnection } from '../connection';
|
||||
import { readJsonInput } from '../input';
|
||||
import { printOutput } from '../output';
|
||||
import { defaultDateRange } from '../utils';
|
||||
|
||||
export function registerTransactionsCommand(program: Command) {
|
||||
const transactions = program
|
||||
@@ -15,32 +14,17 @@ export function registerTransactionsCommand(program: Command) {
|
||||
.command('list')
|
||||
.description('List transactions for an account')
|
||||
.requiredOption('--account <id>', 'Account ID')
|
||||
.option(
|
||||
'--start <date>',
|
||||
'Start date (YYYY-MM-DD, defaults to 30 days ago)',
|
||||
)
|
||||
.option('--end <date>', 'End date (YYYY-MM-DD, defaults to today)')
|
||||
.option('--exclude-transfers', 'Exclude transfer transactions', false)
|
||||
.requiredOption('--start <date>', 'Start date (YYYY-MM-DD)')
|
||||
.requiredOption('--end <date>', 'End date (YYYY-MM-DD)')
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const { start, end } = defaultDateRange(cmdOpts.start, cmdOpts.end);
|
||||
|
||||
const filter: Record<string, unknown> = {
|
||||
account: cmdOpts.account,
|
||||
date: { $gte: start, $lte: end },
|
||||
};
|
||||
if (cmdOpts.excludeTransfers) {
|
||||
filter.transfer_id = { $eq: null };
|
||||
}
|
||||
|
||||
const queryObj = api
|
||||
.q('transactions')
|
||||
.select(['*', 'account.name', 'payee.name', 'category.name'])
|
||||
.filter(filter)
|
||||
.orderBy([{ date: 'desc' }]);
|
||||
const result = await api.aqlQuery(queryObj);
|
||||
printOutput(result.data, opts.format);
|
||||
const result = await api.getTransactions(
|
||||
cmdOpts.account,
|
||||
cmdOpts.start,
|
||||
cmdOpts.end,
|
||||
);
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import { registerSchedulesCommand } from './commands/schedules';
|
||||
import { registerServerCommand } from './commands/server';
|
||||
import { registerTagsCommand } from './commands/tags';
|
||||
import { registerTransactionsCommand } from './commands/transactions';
|
||||
import { CliError } from './utils';
|
||||
|
||||
declare const __CLI_VERSION__: string;
|
||||
|
||||
@@ -67,8 +66,5 @@ function normalizeThrownMessage(err: unknown): string {
|
||||
program.parseAsync(process.argv).catch((err: unknown) => {
|
||||
const message = normalizeThrownMessage(err);
|
||||
process.stderr.write(`Error: ${message}\n`);
|
||||
if (err instanceof CliError && err.suggestion) {
|
||||
process.stderr.write(`Suggestion: ${err.suggestion}\n`);
|
||||
}
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
@@ -60,22 +60,6 @@ describe('formatOutput', () => {
|
||||
expect(result).toContain('a');
|
||||
expect(result).toContain('b');
|
||||
});
|
||||
|
||||
it('flattens nested objects instead of showing [object Object]', () => {
|
||||
const data = [{ payee: { id: 'p1', name: 'Grocery' } }];
|
||||
const result = formatOutput(data, 'table');
|
||||
expect(result).toContain('Grocery');
|
||||
expect(result).not.toContain('[object Object]');
|
||||
});
|
||||
|
||||
it('flattens nested objects in key-value table', () => {
|
||||
const result = formatOutput(
|
||||
{ payee: { id: 'p1', name: 'Grocery' } },
|
||||
'table',
|
||||
);
|
||||
expect(result).toContain('Grocery');
|
||||
expect(result).not.toContain('[object Object]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('csv', () => {
|
||||
@@ -128,24 +112,6 @@ describe('formatOutput', () => {
|
||||
const lines = result.split('\n');
|
||||
expect(lines[0]).toBe('a,b');
|
||||
});
|
||||
|
||||
it('flattens nested objects instead of showing [object Object]', () => {
|
||||
const data = [{ payee: { id: 'p1', name: 'Grocery' } }];
|
||||
const result = formatOutput(data, 'csv');
|
||||
const lines = result.split('\n');
|
||||
expect(lines[0]).toBe('payee');
|
||||
expect(lines[1]).toContain('Grocery');
|
||||
expect(lines[1]).not.toContain('[object Object]');
|
||||
});
|
||||
|
||||
it('flattens nested objects in single-object csv', () => {
|
||||
const result = formatOutput(
|
||||
{ payee: { id: 'p1', name: 'Grocery' } },
|
||||
'csv',
|
||||
);
|
||||
expect(result).toContain('Grocery');
|
||||
expect(result).not.toContain('[object Object]');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -2,12 +2,6 @@ import Table from 'cli-table3';
|
||||
|
||||
export type OutputFormat = 'json' | 'table' | 'csv';
|
||||
|
||||
function flattenValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (typeof value === 'object') return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function formatOutput(
|
||||
data: unknown,
|
||||
format: OutputFormat = 'json',
|
||||
@@ -29,7 +23,7 @@ function formatTable(data: unknown): string {
|
||||
if (data && typeof data === 'object') {
|
||||
const table = new Table();
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
table.push({ [key]: flattenValue(value) });
|
||||
table.push({ [key]: String(value) });
|
||||
}
|
||||
return table.toString();
|
||||
}
|
||||
@@ -45,7 +39,7 @@ function formatTable(data: unknown): string {
|
||||
|
||||
for (const row of data) {
|
||||
const r = row as Record<string, unknown>;
|
||||
table.push(keys.map(k => flattenValue(r[k])));
|
||||
table.push(keys.map(k => String(r[k] ?? '')));
|
||||
}
|
||||
|
||||
return table.toString();
|
||||
@@ -56,9 +50,7 @@ function formatCsv(data: unknown): string {
|
||||
if (data && typeof data === 'object') {
|
||||
const entries = Object.entries(data);
|
||||
const header = entries.map(([k]) => escapeCsv(k)).join(',');
|
||||
const values = entries
|
||||
.map(([, v]) => escapeCsv(flattenValue(v)))
|
||||
.join(',');
|
||||
const values = entries.map(([, v]) => escapeCsv(String(v))).join(',');
|
||||
return header + '\n' + values;
|
||||
}
|
||||
return String(data);
|
||||
@@ -72,7 +64,7 @@ function formatCsv(data: unknown): string {
|
||||
const header = keys.map(k => escapeCsv(k)).join(',');
|
||||
const rows = data.map(row => {
|
||||
const r = row as Record<string, unknown>;
|
||||
return keys.map(k => escapeCsv(flattenValue(r[k]))).join(',');
|
||||
return keys.map(k => escapeCsv(String(r[k] ?? ''))).join(',');
|
||||
});
|
||||
|
||||
return [header, ...rows].join('\n');
|
||||
|
||||
@@ -1,56 +1,4 @@
|
||||
import {
|
||||
CliError,
|
||||
defaultDateRange,
|
||||
parseBoolFlag,
|
||||
parseIntFlag,
|
||||
} from './utils';
|
||||
|
||||
describe('CliError', () => {
|
||||
it('stores message and suggestion', () => {
|
||||
const err = new CliError('something failed', 'try this instead');
|
||||
expect(err.message).toBe('something failed');
|
||||
expect(err.suggestion).toBe('try this instead');
|
||||
expect(err).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it('works without suggestion', () => {
|
||||
const err = new CliError('something failed');
|
||||
expect(err.message).toBe('something failed');
|
||||
expect(err.suggestion).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultDateRange', () => {
|
||||
it('returns both dates when both provided', () => {
|
||||
expect(defaultDateRange('2025-01-01', '2025-01-31')).toEqual({
|
||||
start: '2025-01-01',
|
||||
end: '2025-01-31',
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults start to 30 days before end', () => {
|
||||
expect(defaultDateRange(undefined, '2025-02-28')).toEqual({
|
||||
start: '2025-01-29',
|
||||
end: '2025-02-28',
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults end to today when only start provided', () => {
|
||||
const result = defaultDateRange('2025-01-01');
|
||||
expect(result.start).toBe('2025-01-01');
|
||||
expect(result.end).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
});
|
||||
|
||||
it('defaults both to last 30 days when neither provided', () => {
|
||||
const result = defaultDateRange();
|
||||
expect(result.start).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(result.end).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
const startDate = new Date(result.start);
|
||||
const endDate = new Date(result.end);
|
||||
const diffDays = (endDate.getTime() - startDate.getTime()) / 86400000;
|
||||
expect(diffDays).toBe(30);
|
||||
});
|
||||
});
|
||||
import { parseBoolFlag, parseIntFlag } from './utils';
|
||||
|
||||
describe('parseBoolFlag', () => {
|
||||
it('parses "true"', () => {
|
||||
|
||||
@@ -1,22 +1,3 @@
|
||||
export function defaultDateRange(
|
||||
start?: string,
|
||||
end?: string,
|
||||
): { start: string; end: string } {
|
||||
const endDate = end ?? new Date().toLocaleDateString('en-CA');
|
||||
if (start) return { start, end: endDate };
|
||||
const d = new Date(endDate + 'T00:00:00');
|
||||
d.setDate(d.getDate() - 30);
|
||||
return { start: d.toLocaleDateString('en-CA'), end: endDate };
|
||||
}
|
||||
|
||||
export class CliError extends Error {
|
||||
suggestion?: string;
|
||||
constructor(message: string, suggestion?: string) {
|
||||
super(message);
|
||||
this.suggestion = suggestion;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseBoolFlag(value: string, flagName: string): boolean {
|
||||
if (value !== 'true' && value !== 'false') {
|
||||
throw new Error(
|
||||
|
||||
@@ -17,7 +17,8 @@ export function ColorPalette({ colors }: ColorPaletteProps) {
|
||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||
gridTemplateRows: 'repeat(2, 1fr)',
|
||||
width: '100%',
|
||||
height: 60,
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
borderRadius: 4,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AnimatedLoading } from '@actual-app/components/icons/AnimatedLoading';
|
||||
import { baseInputStyle } from '@actual-app/components/input';
|
||||
import { SpaceBetween } from '@actual-app/components/space-between';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { TextOneLine } from '@actual-app/components/text-one-line';
|
||||
import { theme as themeStyle } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
@@ -28,10 +29,10 @@ import type {
|
||||
InstalledTheme,
|
||||
} from '@desktop-client/style/customThemes';
|
||||
|
||||
// Theme item fixed dimensions
|
||||
const THEME_ITEM_HEIGHT = 140;
|
||||
const THEME_ITEM_WIDTH = 140;
|
||||
// Theme item dimensions
|
||||
const ITEMS_PER_ROW = 3;
|
||||
const THEME_ITEM_GAP = 12;
|
||||
const THEME_ITEM_PADDING = 4; // horizontal padding on each side
|
||||
const CATALOG_MAX_HEIGHT = 300;
|
||||
|
||||
type ThemeInstallerProps = {
|
||||
@@ -78,15 +79,11 @@ export function ThemeInstaller({
|
||||
}
|
||||
}, [installedTheme]);
|
||||
|
||||
// Calculate items per row based on container width
|
||||
const getItemsPerRow = useCallback((containerWidth: number) => {
|
||||
const padding = 8; // 4px on each side
|
||||
const availableWidth = containerWidth - padding;
|
||||
return Math.max(
|
||||
1,
|
||||
Math.floor(
|
||||
(availableWidth + THEME_ITEM_GAP) / (THEME_ITEM_WIDTH + THEME_ITEM_GAP),
|
||||
),
|
||||
// Calculate theme item width based on container width (always 3 per row)
|
||||
const getItemWidth = useCallback((containerWidth: number) => {
|
||||
const availableWidth = containerWidth - THEME_ITEM_PADDING * 2;
|
||||
return Math.floor(
|
||||
(availableWidth - (ITEMS_PER_ROW - 1) * THEME_ITEM_GAP) / ITEMS_PER_ROW,
|
||||
);
|
||||
}, []);
|
||||
|
||||
@@ -292,10 +289,10 @@ export function ThemeInstaller({
|
||||
const catalogItems = [...(catalog ?? [])]
|
||||
.filter(catalogTheme => !mode || catalogTheme.mode === mode)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const itemsPerRow = getItemsPerRow(width);
|
||||
const itemWidth = getItemWidth(width);
|
||||
const rows: CatalogTheme[][] = [];
|
||||
for (let i = 0; i < catalogItems.length; i += itemsPerRow) {
|
||||
rows.push(catalogItems.slice(i, i + itemsPerRow));
|
||||
for (let i = 0; i < catalogItems.length; i += ITEMS_PER_ROW) {
|
||||
rows.push(catalogItems.slice(i, i + ITEMS_PER_ROW));
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -303,17 +300,18 @@ export function ThemeInstaller({
|
||||
width={width}
|
||||
height={height}
|
||||
itemCount={rows.length}
|
||||
itemSize={THEME_ITEM_HEIGHT + THEME_ITEM_GAP}
|
||||
itemSize={itemWidth + THEME_ITEM_GAP}
|
||||
itemKey={index => `row-${index}`}
|
||||
renderRow={({ index, style }) => {
|
||||
renderRow={({ index, key, style }) => {
|
||||
const rowThemes = rows[index];
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
style={{
|
||||
...style,
|
||||
display: 'flex',
|
||||
gap: THEME_ITEM_GAP,
|
||||
padding: '0 4px',
|
||||
padding: `0 ${THEME_ITEM_PADDING}px ${THEME_ITEM_GAP}px`,
|
||||
}}
|
||||
>
|
||||
{rowThemes.map((theme, themeIndex) => {
|
||||
@@ -334,9 +332,10 @@ export function ThemeInstaller({
|
||||
aria-label={theme.name}
|
||||
onPress={() => handleCatalogThemeClick(theme)}
|
||||
style={{
|
||||
width: THEME_ITEM_WIDTH,
|
||||
height: THEME_ITEM_HEIGHT,
|
||||
width: itemWidth,
|
||||
height: itemWidth,
|
||||
padding: 8,
|
||||
overflow: 'hidden',
|
||||
borderRadius: 6,
|
||||
border: `2px solid ${
|
||||
hasError
|
||||
@@ -386,23 +385,26 @@ export function ThemeInstaller({
|
||||
/>
|
||||
</View>
|
||||
<ColorPalette colors={theme.colors} />
|
||||
<Text
|
||||
<TextOneLine
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
textAlign: 'center',
|
||||
width: '100%',
|
||||
}}
|
||||
title={theme.name}
|
||||
>
|
||||
{theme.name}
|
||||
</Text>
|
||||
</TextOneLine>
|
||||
|
||||
<SpaceBetween
|
||||
direction="horizontal"
|
||||
align="center"
|
||||
wrap={false}
|
||||
gap={4}
|
||||
style={{ fontSize: 10 }}
|
||||
>
|
||||
<Text
|
||||
<TextOneLine
|
||||
style={{
|
||||
color: themeStyle.pageTextSubdued,
|
||||
}}
|
||||
@@ -411,7 +413,7 @@ export function ThemeInstaller({
|
||||
<Text style={{ fontWeight: 'bold' }}>
|
||||
{extractRepoOwner(theme.repo)}
|
||||
</Text>
|
||||
</Text>
|
||||
</TextOneLine>
|
||||
<Link
|
||||
variant="external"
|
||||
to={normalizeGitHubRepo(theme.repo)}
|
||||
|
||||
@@ -101,30 +101,3 @@ 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,80 +274,16 @@ actual schedules delete <id>
|
||||
|
||||
### Query (ActualQL)
|
||||
|
||||
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
|
||||
Run queries using [ActualQL](./actual-ql/index.md):
|
||||
|
||||
```bash
|
||||
# Show last 5 transactions (convenience shortcut)
|
||||
actual query run --last 5
|
||||
# Run a query (inline)
|
||||
actual query run --table transactions --select "date,amount,payee" --filter '{"amount":{"$lt":0}}' --limit 10
|
||||
|
||||
# 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
|
||||
# Run a query (from 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
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Enhancements
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
cli: improved aql support
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Add post-merge hook to automatically install dependencies when yarn.lock changes after merges.
|
||||
6
upcoming-release-notes/7253.md
Normal file
6
upcoming-release-notes/7253.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfixes
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Custom Themes: improved responsiveness of the theme catalog
|
||||
Reference in New Issue
Block a user