mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-21 15:36:50 -05:00
Compare commits
17 Commits
claude/fix
...
claude/sec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ab1036322 | ||
|
|
fca27bf843 | ||
|
|
28ddeba0fe | ||
|
|
23adf06cb0 | ||
|
|
41714740c3 | ||
|
|
f44fb7152f | ||
|
|
b57ae405a9 | ||
|
|
ac7222caf2 | ||
|
|
a8a2d23e63 | ||
|
|
8237da8e7b | ||
|
|
71e3b9edc4 | ||
|
|
fc8480bde4 | ||
|
|
61636d74b2 | ||
|
|
2dcbdcfeaf | ||
|
|
789dd1c862 | ||
|
|
4c64b52ea0 | ||
|
|
a16b4107a7 |
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);
|
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(
|
function buildQueryFromFile(
|
||||||
parsed: Record<string, unknown>,
|
parsed: Record<string, unknown>,
|
||||||
fallbackTable: string | undefined,
|
fallbackTable: string | undefined,
|
||||||
@@ -27,34 +136,125 @@ function buildQueryFromFile(
|
|||||||
queryObj = queryObj.orderBy(parsed.orderBy);
|
queryObj = queryObj.orderBy(parsed.orderBy);
|
||||||
}
|
}
|
||||||
if (typeof parsed.limit === 'number') queryObj = queryObj.limit(parsed.limit);
|
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;
|
return queryObj;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildQueryFromFlags(cmdOpts: Record<string, string | undefined>) {
|
function buildQueryFromFlags(cmdOpts: Record<string, string | undefined>) {
|
||||||
if (!cmdOpts.table) {
|
const last = cmdOpts.last ? parseIntFlag(cmdOpts.last, '--last') : undefined;
|
||||||
throw new Error('--table is required (or use --file)');
|
|
||||||
}
|
|
||||||
let queryObj = api.q(cmdOpts.table);
|
|
||||||
|
|
||||||
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(','));
|
queryObj = queryObj.select(cmdOpts.select.split(','));
|
||||||
|
} else if (last !== undefined) {
|
||||||
|
queryObj = queryObj.select(LAST_DEFAULT_SELECT);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cmdOpts.filter) {
|
const filterStr = cmdOpts.filter ?? cmdOpts.where;
|
||||||
queryObj = queryObj.filter(JSON.parse(cmdOpts.filter));
|
if (filterStr) {
|
||||||
|
queryObj = queryObj.filter(JSON.parse(filterStr));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cmdOpts.orderBy) {
|
const orderByStr =
|
||||||
queryObj = queryObj.orderBy(cmdOpts.orderBy.split(','));
|
cmdOpts.orderBy ??
|
||||||
|
(last !== undefined && !cmdOpts.count ? 'date:desc' : undefined);
|
||||||
|
if (orderByStr) {
|
||||||
|
queryObj = queryObj.orderBy(parseOrderBy(orderByStr));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cmdOpts.limit) {
|
const limitVal =
|
||||||
queryObj = queryObj.limit(parseIntFlag(cmdOpts.limit, '--limit'));
|
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;
|
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) {
|
export function registerQueryCommand(program: Command) {
|
||||||
const query = program
|
const query = program
|
||||||
.command('query')
|
.command('query')
|
||||||
@@ -65,16 +265,34 @@ export function registerQueryCommand(program: Command) {
|
|||||||
.description('Execute an AQL query')
|
.description('Execute an AQL query')
|
||||||
.option(
|
.option(
|
||||||
'--table <table>',
|
'--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('--select <fields>', 'Comma-separated fields to select')
|
||||||
.option('--filter <json>', 'Filter expression as JSON')
|
.option('--filter <json>', 'Filter as JSON (e.g. \'{"amount":{"$lt":0}}\')')
|
||||||
.option('--order-by <fields>', 'Comma-separated fields to order by')
|
.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('--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(
|
.option(
|
||||||
'--file <path>',
|
'--file <path>',
|
||||||
'Read full query object from JSON file (use - for stdin)',
|
'Read full query object from JSON file (use - for stdin)',
|
||||||
)
|
)
|
||||||
|
.addHelpText('after', RUN_EXAMPLES)
|
||||||
.action(async cmdOpts => {
|
.action(async cmdOpts => {
|
||||||
const opts = program.opts();
|
const opts = program.opts();
|
||||||
await withConnection(opts, async () => {
|
await withConnection(opts, async () => {
|
||||||
@@ -87,7 +305,40 @@ export function registerQueryCommand(program: Command) {
|
|||||||
: buildQueryFromFlags(cmdOpts);
|
: buildQueryFromFlags(cmdOpts);
|
||||||
|
|
||||||
const result = await api.aqlQuery(queryObj);
|
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,7 +31,8 @@
|
|||||||
body,
|
body,
|
||||||
button,
|
button,
|
||||||
input {
|
input {
|
||||||
font-family:
|
font-family: var(
|
||||||
|
--font-family,
|
||||||
'Inter Variable',
|
'Inter Variable',
|
||||||
-apple-system,
|
-apple-system,
|
||||||
BlinkMacSystemFont,
|
BlinkMacSystemFont,
|
||||||
@@ -45,7 +46,8 @@
|
|||||||
'Helvetica Neue',
|
'Helvetica Neue',
|
||||||
'Helvetica',
|
'Helvetica',
|
||||||
'Arial',
|
'Arial',
|
||||||
sans-serif;
|
sans-serif
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
@@ -67,7 +69,8 @@
|
|||||||
input,
|
input,
|
||||||
textarea {
|
textarea {
|
||||||
font-size: 1em;
|
font-size: 1em;
|
||||||
font-family:
|
font-family: var(
|
||||||
|
--font-family,
|
||||||
'Inter Variable',
|
'Inter Variable',
|
||||||
-apple-system,
|
-apple-system,
|
||||||
BlinkMacSystemFont,
|
BlinkMacSystemFont,
|
||||||
@@ -81,7 +84,8 @@
|
|||||||
'Helvetica Neue',
|
'Helvetica Neue',
|
||||||
'Helvetica',
|
'Helvetica',
|
||||||
'Arial',
|
'Arial',
|
||||||
sans-serif;
|
sans-serif
|
||||||
|
);
|
||||||
font-feature-settings: 'ss01', 'ss04';
|
font-feature-settings: 'ss01', 'ss04';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
/*
|
/*
|
||||||
Cross-Origin-Opener-Policy: same-origin
|
Cross-Origin-Opener-Policy: same-origin
|
||||||
Cross-Origin-Embedder-Policy: require-corp
|
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'; 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'; font-src 'self' data:; connect-src http: https:;
|
||||||
|
|
||||||
/kcab/*
|
/kcab/*
|
||||||
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:;
|
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:;
|
||||||
|
|
||||||
/*.wasm
|
/*.wasm
|
||||||
Content-Type: application/wasm
|
Content-Type: application/wasm
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { Link } from '@desktop-client/components/common/Link';
|
|||||||
import { FixedSizeList } from '@desktop-client/components/FixedSizeList';
|
import { FixedSizeList } from '@desktop-client/components/FixedSizeList';
|
||||||
import { useThemeCatalog } from '@desktop-client/hooks/useThemeCatalog';
|
import { useThemeCatalog } from '@desktop-client/hooks/useThemeCatalog';
|
||||||
import {
|
import {
|
||||||
|
embedThemeFonts,
|
||||||
extractRepoOwner,
|
extractRepoOwner,
|
||||||
fetchThemeCss,
|
fetchThemeCss,
|
||||||
generateThemeId,
|
generateThemeId,
|
||||||
@@ -166,8 +167,12 @@ export function ThemeInstaller({
|
|||||||
setSelectedCatalogTheme(theme);
|
setSelectedCatalogTheme(theme);
|
||||||
|
|
||||||
const normalizedRepo = normalizeGitHubRepo(theme.repo);
|
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({
|
await installTheme({
|
||||||
css: fetchThemeCss(theme.repo),
|
css: cssWithFonts,
|
||||||
name: theme.name,
|
name: theme.name,
|
||||||
repo: normalizedRepo,
|
repo: normalizedRepo,
|
||||||
id: generateThemeId(normalizedRepo),
|
id: generateThemeId(normalizedRepo),
|
||||||
|
|||||||
@@ -1,9 +1,23 @@
|
|||||||
// oxlint-disable eslint/no-script-url
|
// oxlint-disable eslint/no-script-url
|
||||||
import { describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { parseInstalledTheme, validateThemeCss } from './customThemes';
|
import {
|
||||||
|
embedThemeFonts,
|
||||||
|
MAX_FONT_FILE_SIZE,
|
||||||
|
parseInstalledTheme,
|
||||||
|
validateThemeCss,
|
||||||
|
} from './customThemes';
|
||||||
import type { InstalledTheme } 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('validateThemeCss', () => {
|
||||||
describe('valid CSS', () => {
|
describe('valid CSS', () => {
|
||||||
it('should accept valid :root with CSS variables', () => {
|
it('should accept valid :root with CSS variables', () => {
|
||||||
@@ -74,7 +88,7 @@ describe('validateThemeCss', () => {
|
|||||||
},
|
},
|
||||||
])('should reject $description', ({ css }) => {
|
])('should reject $description', ({ css }) => {
|
||||||
expect(() => validateThemeCss(css)).toThrow(
|
expect(() => validateThemeCss(css)).toThrow(
|
||||||
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
|
'Theme CSS must contain :root { ... } with CSS variable definitions. No other selectors or content allowed.',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -90,7 +104,7 @@ describe('validateThemeCss', () => {
|
|||||||
color: red;
|
color: red;
|
||||||
}`,
|
}`,
|
||||||
expectedError:
|
expectedError:
|
||||||
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
|
'Theme CSS must contain :root { ... } with CSS variable definitions. No other selectors or content allowed.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: 'multiple selectors',
|
description: 'multiple selectors',
|
||||||
@@ -101,7 +115,7 @@ describe('validateThemeCss', () => {
|
|||||||
--color-primary: #ffffff;
|
--color-primary: #ffffff;
|
||||||
}`,
|
}`,
|
||||||
expectedError:
|
expectedError:
|
||||||
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
|
'Theme CSS must contain :root { ... } with CSS variable definitions. No other selectors or content allowed.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: 'media queries',
|
description: 'media queries',
|
||||||
@@ -114,7 +128,7 @@ describe('validateThemeCss', () => {
|
|||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
expectedError:
|
expectedError:
|
||||||
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
|
'Theme CSS must contain :root { ... } with CSS variable definitions. No other selectors or content allowed.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: 'custom selector before :root',
|
description: 'custom selector before :root',
|
||||||
@@ -125,7 +139,7 @@ describe('validateThemeCss', () => {
|
|||||||
--color-primary: #007bff;
|
--color-primary: #007bff;
|
||||||
}`,
|
}`,
|
||||||
expectedError:
|
expectedError:
|
||||||
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
|
'Theme CSS must contain :root { ... } with CSS variable definitions. No other selectors or content allowed.',
|
||||||
},
|
},
|
||||||
])('should reject CSS with $description', ({ css, expectedError }) => {
|
])('should reject CSS with $description', ({ css, expectedError }) => {
|
||||||
expect(() => validateThemeCss(css)).toThrow(expectedError);
|
expect(() => validateThemeCss(css)).toThrow(expectedError);
|
||||||
@@ -271,7 +285,7 @@ describe('validateThemeCss', () => {
|
|||||||
},
|
},
|
||||||
])('should reject $description', ({ css }) => {
|
])('should reject $description', ({ css }) => {
|
||||||
expect(() => validateThemeCss(css)).toThrow(
|
expect(() => validateThemeCss(css)).toThrow(
|
||||||
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
|
'Theme CSS must contain :root { ... } with CSS variable definitions. No other selectors or content allowed.',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -779,12 +793,6 @@ describe('validateThemeCss', () => {
|
|||||||
--spacing: 10px 20px;
|
--spacing: 10px 20px;
|
||||||
}`,
|
}`,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
description: 'value with comma-separated values',
|
|
||||||
css: `:root {
|
|
||||||
--font-family: Arial, sans-serif;
|
|
||||||
}`,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
description: 'property name with invalid characters',
|
description: 'property name with invalid characters',
|
||||||
css: `:root {
|
css: `:root {
|
||||||
@@ -868,6 +876,337 @@ 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('parseInstalledTheme', () => {
|
||||||
describe('valid theme JSON', () => {
|
describe('valid theme JSON', () => {
|
||||||
it('should parse valid theme with all required fields', () => {
|
it('should parse valid theme with all required fields', () => {
|
||||||
@@ -1133,3 +1472,123 @@ 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();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
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 reject when total font size exceeds budget', async () => {
|
||||||
|
// Each font is under the per-file limit but together they exceed the total
|
||||||
|
// Use MAX_FONT_FILE_SIZE (2MB) per font, need 6 to exceed 10MB total
|
||||||
|
const bigBuffer = new ArrayBuffer(MAX_FONT_FILE_SIZE);
|
||||||
|
vi.stubGlobal('fetch', mockFetch(bigBuffer));
|
||||||
|
|
||||||
|
const fontBlocks = Array.from(
|
||||||
|
{ length: 6 },
|
||||||
|
(_, i) => `@font-face {
|
||||||
|
font-family: 'Font${i}';
|
||||||
|
src: url('font${i}.woff2') format('woff2');
|
||||||
|
}`,
|
||||||
|
).join('\n');
|
||||||
|
const css = `${fontBlocks}\n:root { --color-primary: #007bff; }`;
|
||||||
|
|
||||||
|
await expect(embedThemeFonts(css, 'owner/repo')).rejects.toThrow(
|
||||||
|
'Total embedded font data exceeds maximum',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
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,6 +79,63 @@ export async function fetchDirectCss(url: string): Promise<string> {
|
|||||||
return response.text();
|
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). */
|
/** 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;
|
const VAR_ONLY_PATTERN = /^var\s*\(\s*(--[a-zA-Z0-9_-]+)\s*\)$/i;
|
||||||
|
|
||||||
@@ -92,8 +149,15 @@ function isValidSimpleVarValue(value: string): boolean {
|
|||||||
/**
|
/**
|
||||||
* Validate that a CSS property value only contains allowed content (allowlist approach).
|
* 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).
|
* 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 {
|
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) {
|
if (!value || value.length === 0) {
|
||||||
return; // Empty values are allowed
|
return; // Empty values are allowed
|
||||||
}
|
}
|
||||||
@@ -145,79 +209,152 @@ 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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate that CSS contains only :root { ... } with CSS custom property (variable) declarations.
|
* Extract @font-face blocks from CSS. Returns the blocks and the remaining CSS.
|
||||||
* Must contain exactly :root { ... } and nothing else.
|
* Only matches top-level @font-face blocks (not nested inside other rules).
|
||||||
* Returns the validated CSS or throws an error.
|
|
||||||
*/
|
*/
|
||||||
export function validateThemeCss(css: string): string {
|
function extractFontFaceBlocks(css: string): {
|
||||||
// Strip multi-line comments before validation
|
fontFaceBlocks: string[];
|
||||||
// Note: Single-line comments (//) are not stripped to avoid corrupting CSS values like URLs
|
remaining: string;
|
||||||
const cleaned = css.replace(/\/\*[\s\S]*?\*\//g, '').trim();
|
} {
|
||||||
|
const fontFaceBlocks: string[] = [];
|
||||||
|
let remaining = css;
|
||||||
|
|
||||||
// Must contain exactly :root { ... } and nothing else
|
// Extract @font-face { ... } blocks one at a time using indexOf-based
|
||||||
// Find :root { ... } and extract content, then check there's nothing after
|
// parsing. Each iteration removes the matched block from `remaining`.
|
||||||
const rootMatch = cleaned.match(/^:root\s*\{/);
|
for (;;) {
|
||||||
if (!rootMatch) {
|
const atIdx = remaining.indexOf('@font-face');
|
||||||
throw new Error(
|
if (atIdx === -1) break;
|
||||||
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
|
|
||||||
);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the opening brace after :root
|
return { fontFaceBlocks, remaining: remaining.trim() };
|
||||||
const rootStart = cleaned.indexOf(':root');
|
}
|
||||||
const openBrace = cleaned.indexOf('{', rootStart);
|
|
||||||
|
|
||||||
if (openBrace === -1) {
|
/**
|
||||||
throw new Error(
|
* Validate @font-face blocks: only data: URIs allowed (no remote URLs).
|
||||||
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the first closing brace (nested blocks will be caught by the check below)
|
if (totalSize > MAX_TOTAL_FONT_SIZE) {
|
||||||
const closeBrace = cleaned.indexOf('}', openBrace + 1);
|
|
||||||
|
|
||||||
if (closeBrace === -1) {
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Theme CSS must contain exactly :root { ... } with CSS variable definitions. No other selectors or content allowed.',
|
`Total embedded font data exceeds maximum of ${MAX_TOTAL_FONT_SIZE / 1024 / 1024}MB.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Extract content inside :root { ... }
|
/**
|
||||||
const rootContent = cleaned.substring(openBrace + 1, closeBrace).trim();
|
* 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;
|
||||||
|
|
||||||
// Check for forbidden at-rules first (before nested block check, since at-rules with braces would trigger that)
|
for (let i = 0; i < content.length; i++) {
|
||||||
// Comprehensive list of CSS at-rules that should not be allowed
|
const ch = content[i];
|
||||||
// This includes @import, @media, @keyframes, @font-face, @supports, @charset,
|
|
||||||
// @namespace, @page, @layer, @container, @scope, and any other at-rules
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = content.substring(start).trim();
|
||||||
|
if (trimmed) declarations.push(trimmed);
|
||||||
|
|
||||||
|
return declarations;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── :root block validation ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
if (/@[a-z-]+/i.test(rootContent)) {
|
if (/@[a-z-]+/i.test(rootContent)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Theme CSS contains forbidden at-rules (@import, @media, @keyframes, etc.). Only CSS variable declarations are allowed inside :root { ... }.',
|
'Theme CSS contains forbidden at-rules (@import, @media, @keyframes, etc.). Only CSS variable declarations are allowed inside :root { ... }.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for nested blocks (additional selectors) - should not have any { after extracting :root content
|
// Check for nested blocks
|
||||||
if (/\{/.test(rootContent)) {
|
if (/\{/.test(rootContent)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Theme CSS contains nested blocks or additional selectors. Only CSS variable declarations are allowed inside :root { ... }.',
|
'Theme CSS contains nested blocks or additional selectors. Only CSS variable declarations are allowed inside :root { ... }.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check that there's nothing after the closing brace
|
for (const decl of splitDeclarations(rootContent)) {
|
||||||
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(':');
|
const colonIndex = decl.indexOf(':');
|
||||||
if (colonIndex === -1) {
|
if (colonIndex === -1) {
|
||||||
throw new Error(`Invalid CSS declaration: "${decl}"`);
|
throw new Error(`Invalid CSS declaration: "${decl}"`);
|
||||||
@@ -271,9 +408,220 @@ export function validateThemeCss(css: string): string {
|
|||||||
const value = decl.substring(colonIndex + 1).trim();
|
const value = decl.substring(colonIndex + 1).trim();
|
||||||
validatePropertyValue(value, property);
|
validatePropertyValue(value, property);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Return the original CSS (with :root wrapper) so it can be injected properly
|
// ─── Main validation entry point ────────────────────────────────────────────
|
||||||
return css.trim();
|
|
||||||
|
/**
|
||||||
|
* 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 fonts sequentially to enforce a running total size budget
|
||||||
|
const fetched: { ref: FontRef; dataUri: string }[] = [];
|
||||||
|
let totalBytes = 0;
|
||||||
|
for (const ref of fontRefs) {
|
||||||
|
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.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
totalBytes += buffer.byteLength;
|
||||||
|
if (totalBytes > MAX_TOTAL_FONT_SIZE) {
|
||||||
|
throw new Error(
|
||||||
|
`Total embedded font data exceeds maximum of ${MAX_TOTAL_FONT_SIZE / 1024 / 1024}MB.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const base64 = arrayBufferToBase64(buffer);
|
||||||
|
fetched.push({ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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)
|
### 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
|
```bash
|
||||||
# Run a query (inline)
|
# Show last 5 transactions (convenience shortcut)
|
||||||
actual query run --table transactions --select "date,amount,payee" --filter '{"amount":{"$lt":0}}' --limit 10
|
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
|
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
|
### Server
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -128,6 +128,10 @@ app.get('/metrics', (_req, res) => {
|
|||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
res.set('Cross-Origin-Opener-Policy', 'same-origin');
|
res.set('Cross-Origin-Opener-Policy', 'same-origin');
|
||||||
res.set('Cross-Origin-Embedder-Policy', 'require-corp');
|
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();
|
next();
|
||||||
});
|
});
|
||||||
if (process.env.NODE_ENV === 'development') {
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
|||||||
6
upcoming-release-notes/7239.md
Normal file
6
upcoming-release-notes/7239.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
category: Enhancements
|
||||||
|
authors: [MatissJanis]
|
||||||
|
---
|
||||||
|
|
||||||
|
Custom Themes: allow using a custom font
|
||||||
6
upcoming-release-notes/7240.md
Normal file
6
upcoming-release-notes/7240.md
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
category: Enhancements
|
||||||
|
authors: [MatissJanis]
|
||||||
|
---
|
||||||
|
|
||||||
|
cli: improved aql support
|
||||||
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