mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-09 11:42:54 -05:00
Compare commits
13 Commits
master
...
feat/plugi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18a9cb5192 | ||
|
|
25034ff639 | ||
|
|
41490e1831 | ||
|
|
cf28d05ba7 | ||
|
|
f212559eec | ||
|
|
c25e8c2df5 | ||
|
|
e8c4489657 | ||
|
|
ac07adfff6 | ||
|
|
434ba5221d | ||
|
|
280965f9a0 | ||
|
|
e659ed0554 | ||
|
|
d6a7a892f0 | ||
|
|
2ee1a61689 |
32
.github/workflows/build.yml
vendored
32
.github/workflows/build.yml
vendored
@@ -62,6 +62,38 @@ jobs:
|
||||
name: actual-crdt
|
||||
path: packages/crdt/actual-crdt.tgz
|
||||
|
||||
plugins-core:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Build Plugins Core
|
||||
run: yarn workspace @actual-app/plugins-core build
|
||||
- name: Create package tgz
|
||||
run: yarn workspace @actual-app/plugins-core pack --filename actual-plugins-core.tgz
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: actual-plugins-core
|
||||
path: packages/plugins-core/actual-plugins-core.tgz
|
||||
|
||||
plugins-service:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Build Plugins Service
|
||||
run: yarn workspace plugins-service build
|
||||
- name: Create package tgz
|
||||
run: yarn workspace plugins-service pack --filename actual-plugins-service.tgz
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: actual-plugins-service
|
||||
path: packages/plugins-service/actual-plugins-service.tgz
|
||||
|
||||
web:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -97,6 +97,8 @@ export default defineConfig(
|
||||
'packages/loot-core/**/proto/*',
|
||||
'packages/sync-server/user-files/',
|
||||
'packages/sync-server/server-files/',
|
||||
'packages/plugins-core/build/',
|
||||
'packages/plugins-core/node_modules/',
|
||||
'.yarn/*',
|
||||
'.github/*',
|
||||
'**/build/',
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
"build:plugins-service": "yarn workspace plugins-service build",
|
||||
"build:api": "yarn workspace @actual-app/api build",
|
||||
"build:docs": "yarn workspace docs build",
|
||||
"build:plugins-core": "yarn workspace @actual-app/plugins-core build",
|
||||
"generate:i18n": "yarn workspace @actual-app/web generate:i18n",
|
||||
"generate:release-notes": "ts-node ./bin/release-note-generator.ts",
|
||||
"test": "lage test --continue",
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"outDir": "dist",
|
||||
"declarationDir": "@types",
|
||||
"paths": {
|
||||
"loot-core/*": ["./@types/loot-core/src/*"]
|
||||
"loot-core/*": ["./@types/loot-core/loot-core/src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["."],
|
||||
|
||||
@@ -48,7 +48,8 @@
|
||||
"./toggle": "./src/Toggle.tsx",
|
||||
"./tooltip": "./src/Tooltip.tsx",
|
||||
"./view": "./src/View.tsx",
|
||||
"./color-picker": "./src/ColorPicker.tsx"
|
||||
"./color-picker": "./src/ColorPicker.tsx",
|
||||
"./modal": "./src/Modal.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"generate:icons": "rm src/icons/*/*.tsx; cd src/icons && svgr --template template.ts --index-template index-template.ts --typescript --expand-props start -d . .",
|
||||
|
||||
11
packages/component-library/src/Modal.ts
Normal file
11
packages/component-library/src/Modal.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { type CSSProperties } from 'react';
|
||||
|
||||
export type BasicModalProps = {
|
||||
isLoading?: boolean;
|
||||
noAnimation?: boolean;
|
||||
style?: CSSProperties;
|
||||
onClose?: () => void;
|
||||
containerProps?: {
|
||||
style?: CSSProperties;
|
||||
};
|
||||
};
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 8.3 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 8.3 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 8.5 KiB |
@@ -1,12 +1,11 @@
|
||||
import { q } from 'loot-core/shared/query';
|
||||
import type { AccountEntity, CategoryEntity } from 'loot-core/types/models';
|
||||
|
||||
import {
|
||||
parametrizedField,
|
||||
type SheetFields,
|
||||
type Binding,
|
||||
type SheetNames,
|
||||
} from '.';
|
||||
} from '@actual-app/shared-types/spreadsheet';
|
||||
|
||||
import { uncategorizedTransactions } from '@desktop-client/queries';
|
||||
|
||||
|
||||
@@ -1,119 +1,8 @@
|
||||
import { type Query } from 'loot-core/shared/query';
|
||||
|
||||
export type Spreadsheets = {
|
||||
account: {
|
||||
// Common fields
|
||||
'uncategorized-amount': number;
|
||||
'uncategorized-balance': number;
|
||||
|
||||
// Account fields
|
||||
balance: number;
|
||||
[key: `balance-${string}-cleared`]: number | null;
|
||||
'accounts-balance': number;
|
||||
'onbudget-accounts-balance': number;
|
||||
'offbudget-accounts-balance': number;
|
||||
'closed-accounts-balance': number;
|
||||
balanceCleared: number;
|
||||
balanceUncleared: number;
|
||||
lastReconciled: string | null;
|
||||
};
|
||||
category: {
|
||||
// Common fields
|
||||
'uncategorized-amount': number;
|
||||
'uncategorized-balance': number;
|
||||
|
||||
balance: number;
|
||||
balanceCleared: number;
|
||||
balanceUncleared: number;
|
||||
};
|
||||
'envelope-budget': {
|
||||
// Common fields
|
||||
'uncategorized-amount': number;
|
||||
'uncategorized-balance': number;
|
||||
|
||||
// Envelope budget fields
|
||||
'available-funds': number;
|
||||
'last-month-overspent': number;
|
||||
buffered: number;
|
||||
'buffered-auto': number;
|
||||
'buffered-selected': number;
|
||||
'to-budget': number | null;
|
||||
'from-last-month': number;
|
||||
'total-budgeted': number;
|
||||
'total-income': number;
|
||||
'total-spent': number;
|
||||
'total-leftover': number;
|
||||
'group-sum-amount': number;
|
||||
'group-budget': number;
|
||||
'group-leftover': number;
|
||||
budget: number;
|
||||
'sum-amount': number;
|
||||
leftover: number;
|
||||
carryover: number;
|
||||
goal: number;
|
||||
'long-goal': number;
|
||||
};
|
||||
'tracking-budget': {
|
||||
// Common fields
|
||||
'uncategorized-amount': number;
|
||||
'uncategorized-balance': number;
|
||||
|
||||
// Tracking budget fields
|
||||
'total-budgeted': number;
|
||||
'total-budget-income': number;
|
||||
'total-saved': number;
|
||||
'total-income': number;
|
||||
'total-spent': number;
|
||||
'real-saved': number;
|
||||
'total-leftover': number;
|
||||
'group-sum-amount': number;
|
||||
'group-budget': number;
|
||||
'group-leftover': number;
|
||||
budget: number;
|
||||
'sum-amount': number;
|
||||
leftover: number;
|
||||
carryover: number;
|
||||
goal: number;
|
||||
'long-goal': number;
|
||||
};
|
||||
[`balance`]: {
|
||||
// Common fields
|
||||
'uncategorized-amount': number;
|
||||
'uncategorized-balance': number;
|
||||
|
||||
// Balance fields
|
||||
[key: `balance-query-${string}`]: number;
|
||||
[key: `selected-transactions-${string}`]: Array<{ id: string }>;
|
||||
[key: `selected-balance-${string}`]: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type SheetNames = keyof Spreadsheets & string;
|
||||
|
||||
export type SheetFields<SheetName extends SheetNames> =
|
||||
keyof Spreadsheets[SheetName] & string;
|
||||
|
||||
export type BindingObject<
|
||||
SheetName extends SheetNames,
|
||||
SheetFieldName extends SheetFields<SheetName>,
|
||||
> = {
|
||||
name: SheetFieldName;
|
||||
value?: Spreadsheets[SheetName][SheetFieldName] | undefined;
|
||||
query?: Query | undefined;
|
||||
};
|
||||
|
||||
export type Binding<
|
||||
SheetName extends SheetNames,
|
||||
SheetFieldName extends SheetFields<SheetName>,
|
||||
> =
|
||||
| SheetFieldName
|
||||
| {
|
||||
name: SheetFieldName;
|
||||
value?: Spreadsheets[SheetName][SheetFieldName] | undefined;
|
||||
query?: Query | undefined;
|
||||
};
|
||||
export const parametrizedField =
|
||||
<SheetName extends SheetNames>() =>
|
||||
<SheetFieldName extends SheetFields<SheetName>>(field: SheetFieldName) =>
|
||||
(id?: string): SheetFieldName =>
|
||||
`${field}-${id}` as SheetFieldName;
|
||||
export type {
|
||||
Spreadsheets,
|
||||
SheetNames,
|
||||
SheetFields,
|
||||
BindingObject,
|
||||
Binding,
|
||||
} from '@actual-app/shared-types/spreadsheet';
|
||||
export { parametrizedField } from '@actual-app/shared-types/spreadsheet';
|
||||
|
||||
@@ -1,176 +1,7 @@
|
||||
import { WithRequired } from '../types/util';
|
||||
|
||||
type ObjectExpression = {
|
||||
[key: string]: ObjectExpression | unknown;
|
||||
};
|
||||
|
||||
export type QueryState = {
|
||||
get table(): string;
|
||||
get tableOptions(): Readonly<Record<string, unknown>>;
|
||||
get filterExpressions(): ReadonlyArray<ObjectExpression>;
|
||||
get selectExpressions(): ReadonlyArray<ObjectExpression | string | '*'>;
|
||||
get groupExpressions(): ReadonlyArray<ObjectExpression | string>;
|
||||
get orderExpressions(): ReadonlyArray<ObjectExpression | string>;
|
||||
get calculation(): boolean;
|
||||
get rawMode(): boolean;
|
||||
get withDead(): boolean;
|
||||
get validateRefs(): boolean;
|
||||
get limit(): number | null;
|
||||
get offset(): number | null;
|
||||
};
|
||||
|
||||
export class Query {
|
||||
state: QueryState;
|
||||
|
||||
constructor(state: WithRequired<Partial<QueryState>, 'table'>) {
|
||||
this.state = {
|
||||
tableOptions: state.tableOptions || {},
|
||||
filterExpressions: state.filterExpressions || [],
|
||||
selectExpressions: state.selectExpressions || [],
|
||||
groupExpressions: state.groupExpressions || [],
|
||||
orderExpressions: state.orderExpressions || [],
|
||||
calculation: false,
|
||||
rawMode: false,
|
||||
withDead: false,
|
||||
validateRefs: true,
|
||||
limit: null,
|
||||
offset: null,
|
||||
...state,
|
||||
};
|
||||
}
|
||||
|
||||
filter(expr: ObjectExpression) {
|
||||
return new Query({
|
||||
...this.state,
|
||||
filterExpressions: [...this.state.filterExpressions, expr],
|
||||
});
|
||||
}
|
||||
|
||||
unfilter(exprs?: Array<keyof ObjectExpression>) {
|
||||
// Remove all filters if no arguments are passed
|
||||
if (!exprs) {
|
||||
return new Query({
|
||||
...this.state,
|
||||
filterExpressions: [],
|
||||
});
|
||||
}
|
||||
|
||||
const exprSet = new Set(exprs);
|
||||
return new Query({
|
||||
...this.state,
|
||||
filterExpressions: this.state.filterExpressions.filter(
|
||||
expr => !exprSet.has(Object.keys(expr)[0]),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
select(
|
||||
exprs:
|
||||
| Array<ObjectExpression | string>
|
||||
| ObjectExpression
|
||||
| string
|
||||
| '*'
|
||||
| ['*'] = [],
|
||||
) {
|
||||
if (!Array.isArray(exprs)) {
|
||||
exprs = [exprs];
|
||||
}
|
||||
|
||||
return new Query({
|
||||
...this.state,
|
||||
selectExpressions: exprs,
|
||||
calculation: false,
|
||||
});
|
||||
}
|
||||
|
||||
calculate(expr: ObjectExpression | string) {
|
||||
return new Query({
|
||||
...this.state,
|
||||
selectExpressions: [{ result: expr }],
|
||||
calculation: true,
|
||||
});
|
||||
}
|
||||
|
||||
groupBy(exprs: ObjectExpression | string | Array<ObjectExpression | string>) {
|
||||
if (!Array.isArray(exprs)) {
|
||||
exprs = [exprs];
|
||||
}
|
||||
|
||||
return new Query({
|
||||
...this.state,
|
||||
groupExpressions: [...this.state.groupExpressions, ...exprs],
|
||||
});
|
||||
}
|
||||
|
||||
orderBy(exprs: ObjectExpression | string | Array<ObjectExpression | string>) {
|
||||
if (!Array.isArray(exprs)) {
|
||||
exprs = [exprs];
|
||||
}
|
||||
|
||||
return new Query({
|
||||
...this.state,
|
||||
orderExpressions: [...this.state.orderExpressions, ...exprs],
|
||||
});
|
||||
}
|
||||
|
||||
limit(num: number) {
|
||||
return new Query({ ...this.state, limit: num });
|
||||
}
|
||||
|
||||
offset(num: number) {
|
||||
return new Query({ ...this.state, offset: num });
|
||||
}
|
||||
|
||||
raw() {
|
||||
return new Query({ ...this.state, rawMode: true });
|
||||
}
|
||||
|
||||
withDead() {
|
||||
return new Query({ ...this.state, withDead: true });
|
||||
}
|
||||
|
||||
withoutValidatedRefs() {
|
||||
return new Query({ ...this.state, validateRefs: false });
|
||||
}
|
||||
|
||||
options(opts: Record<string, unknown>) {
|
||||
return new Query({ ...this.state, tableOptions: opts });
|
||||
}
|
||||
|
||||
reset() {
|
||||
return q(this.state.table);
|
||||
}
|
||||
|
||||
serialize() {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
serializeAsString() {
|
||||
return JSON.stringify(this.serialize());
|
||||
}
|
||||
}
|
||||
|
||||
export function getPrimaryOrderBy(
|
||||
query: Query,
|
||||
defaultOrderBy: ObjectExpression | null,
|
||||
) {
|
||||
const orderExprs = query.serialize().orderExpressions;
|
||||
if (orderExprs.length === 0) {
|
||||
if (defaultOrderBy) {
|
||||
return { order: 'asc', ...defaultOrderBy };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstOrder = orderExprs[0];
|
||||
if (typeof firstOrder === 'string') {
|
||||
return { field: firstOrder, order: 'asc' };
|
||||
}
|
||||
// Handle this form: { field: 'desc' }
|
||||
const [field] = Object.keys(firstOrder);
|
||||
return { field, order: firstOrder[field] };
|
||||
}
|
||||
|
||||
export function q(table: QueryState['table']) {
|
||||
return new Query({ table });
|
||||
}
|
||||
// Re-export query types and functions from @actual-app/query
|
||||
export type {
|
||||
ObjectExpression,
|
||||
QueryState,
|
||||
QueryBuilder,
|
||||
} from '@actual-app/query';
|
||||
export { Query, q, getPrimaryOrderBy } from '@actual-app/query';
|
||||
|
||||
@@ -1,25 +1,5 @@
|
||||
export type AccountEntity = {
|
||||
id: string;
|
||||
name: string;
|
||||
offbudget: 0 | 1;
|
||||
closed: 0 | 1;
|
||||
sort_order: number;
|
||||
last_reconciled: string | null;
|
||||
tombstone: 0 | 1;
|
||||
} & (_SyncFields<true> | _SyncFields<false>);
|
||||
|
||||
export type _SyncFields<T> = {
|
||||
account_id: T extends true ? string : null;
|
||||
bank: T extends true ? string : null;
|
||||
bankName: T extends true ? string : null;
|
||||
bankId: T extends true ? number : null;
|
||||
mask: T extends true ? string : null; // end of bank account number
|
||||
official_name: T extends true ? string : null;
|
||||
balance_current: T extends true ? number : null;
|
||||
balance_available: T extends true ? number : null;
|
||||
balance_limit: T extends true ? number : null;
|
||||
account_sync_source: T extends true ? AccountSyncSource : null;
|
||||
last_sync: T extends true ? string : null;
|
||||
};
|
||||
|
||||
export type AccountSyncSource = 'simpleFin' | 'goCardless' | 'pluggyai';
|
||||
export type {
|
||||
AccountEntity,
|
||||
_SyncFields,
|
||||
AccountSyncSource,
|
||||
} from '@actual-app/shared-types/models/account';
|
||||
|
||||
@@ -1,11 +1 @@
|
||||
import { CategoryEntity } from './category';
|
||||
|
||||
export interface CategoryGroupEntity {
|
||||
id: string;
|
||||
name: string;
|
||||
is_income?: boolean;
|
||||
sort_order?: number;
|
||||
tombstone?: boolean;
|
||||
hidden?: boolean;
|
||||
categories?: CategoryEntity[];
|
||||
}
|
||||
export type { CategoryGroupEntity } from '@actual-app/shared-types/models/category-group';
|
||||
|
||||
@@ -1,13 +1 @@
|
||||
import { CategoryGroupEntity } from './category-group';
|
||||
|
||||
export interface CategoryEntity {
|
||||
id: string;
|
||||
name: string;
|
||||
is_income?: boolean;
|
||||
group: CategoryGroupEntity['id'];
|
||||
goal_def?: string;
|
||||
template_settings?: { source: 'notes' | 'ui' };
|
||||
sort_order?: number;
|
||||
tombstone?: boolean;
|
||||
hidden?: boolean;
|
||||
}
|
||||
export type { CategoryEntity } from '@actual-app/shared-types/models/category';
|
||||
|
||||
@@ -1,10 +1 @@
|
||||
import { AccountEntity } from './account';
|
||||
|
||||
export interface PayeeEntity {
|
||||
id: string;
|
||||
name: string;
|
||||
transfer_acct?: AccountEntity['id'];
|
||||
favorite?: boolean;
|
||||
learn_categories?: boolean;
|
||||
tombstone?: boolean;
|
||||
}
|
||||
export type { PayeeEntity } from '@actual-app/shared-types/models/payee';
|
||||
|
||||
@@ -12,7 +12,24 @@ export interface RuleEntity extends NewRuleEntity {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export type RuleConditionOp = RuleConditionEntity['op'];
|
||||
export type RuleConditionOp =
|
||||
| 'is'
|
||||
| 'isNot'
|
||||
| 'oneOf'
|
||||
| 'notOneOf'
|
||||
| 'isapprox'
|
||||
| 'isbetween'
|
||||
| 'gt'
|
||||
| 'gte'
|
||||
| 'lt'
|
||||
| 'lte'
|
||||
| 'contains'
|
||||
| 'doesNotContain'
|
||||
| 'hasTags'
|
||||
| 'and'
|
||||
| 'matches'
|
||||
| 'onBudget'
|
||||
| 'offBudget';
|
||||
|
||||
export type FieldValueTypes = {
|
||||
account: string;
|
||||
|
||||
@@ -1,49 +1,6 @@
|
||||
import type { AccountEntity } from './account';
|
||||
import type { PayeeEntity } from './payee';
|
||||
import type { RuleConditionEntity, RuleEntity } from './rule';
|
||||
|
||||
export interface RecurPattern {
|
||||
value: number;
|
||||
type: 'SU' | 'MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA' | 'day';
|
||||
}
|
||||
|
||||
export interface RecurConfig {
|
||||
frequency: 'daily' | 'weekly' | 'monthly' | 'yearly';
|
||||
interval?: number;
|
||||
patterns?: RecurPattern[];
|
||||
skipWeekend?: boolean;
|
||||
start: string;
|
||||
endMode?: 'never' | 'after_n_occurrences' | 'on_date';
|
||||
endOccurrences?: number;
|
||||
endDate?: string;
|
||||
weekendSolveMode?: 'before' | 'after';
|
||||
}
|
||||
|
||||
export interface ScheduleEntity {
|
||||
id: string;
|
||||
name?: string;
|
||||
rule: RuleEntity['id'];
|
||||
next_date: string;
|
||||
completed: boolean;
|
||||
posts_transaction: boolean;
|
||||
tombstone: boolean;
|
||||
|
||||
// These are special fields that are actually pulled from the
|
||||
// underlying rule
|
||||
_payee: PayeeEntity['id'];
|
||||
_account: AccountEntity['id'];
|
||||
_amount: number | { num1: number; num2: number };
|
||||
_amountOp: string;
|
||||
_date: RecurConfig | string;
|
||||
_conditions: RuleConditionEntity[];
|
||||
_actions: Array<{ op: unknown }>;
|
||||
}
|
||||
|
||||
export type DiscoverScheduleEntity = {
|
||||
id: ScheduleEntity['id'];
|
||||
account: AccountEntity['id'];
|
||||
payee: PayeeEntity['id'];
|
||||
date: RecurConfig;
|
||||
amount: ScheduleEntity['_amount'];
|
||||
_conditions: ScheduleEntity['_conditions'];
|
||||
};
|
||||
export type {
|
||||
RecurPattern,
|
||||
RecurConfig,
|
||||
ScheduleEntity,
|
||||
DiscoverScheduleEntity,
|
||||
} from '@actual-app/shared-types/models/schedule';
|
||||
|
||||
@@ -8,7 +8,7 @@ export type EverythingButIdOptional<T extends { id: unknown }> = {
|
||||
id: T['id'];
|
||||
} & Partial<Omit<T, 'id'>>;
|
||||
|
||||
export type WithRequired<T, K extends keyof T> = T & Required<Pick<T, K>>;
|
||||
export type { WithRequired } from '@actual-app/shared-types';
|
||||
|
||||
// Allows use of object literals inside child elements of `Trans` tags
|
||||
// see https://github.com/i18next/react-i18next/issues/1483
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"allowJs": false,
|
||||
|
||||
@@ -52,6 +52,14 @@ export default defineConfig(({ mode }) => {
|
||||
find: /^@actual-app\/crdt(\/.*)?$/,
|
||||
replacement: path.resolve(crdtDir, 'src') + '$1',
|
||||
},
|
||||
{
|
||||
find: '@actual-app/query',
|
||||
replacement: path.resolve(__dirname, '../query/src'),
|
||||
},
|
||||
{
|
||||
find: '@actual-app/shared-types',
|
||||
replacement: path.resolve(__dirname, '../shared-types/src'),
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
|
||||
@@ -75,6 +75,14 @@ export default defineConfig(({ mode }) => {
|
||||
find: /^@actual-app\/crdt(\/.*)?$/,
|
||||
replacement: path.resolve(crdtDir, 'src') + '$1',
|
||||
},
|
||||
{
|
||||
find: '@actual-app/query',
|
||||
replacement: path.resolve(__dirname, '../query/src'),
|
||||
},
|
||||
{
|
||||
find: '@actual-app/shared-types',
|
||||
replacement: path.resolve(__dirname, '../shared-types/src'),
|
||||
},
|
||||
],
|
||||
},
|
||||
define: {
|
||||
|
||||
@@ -50,6 +50,14 @@ export default defineConfig(({ mode }) => {
|
||||
find: /^@actual-app\/crdt(\/.*)?$/,
|
||||
replacement: path.resolve(crdtDir, 'src') + '$1',
|
||||
},
|
||||
{
|
||||
find: '@actual-app/query',
|
||||
replacement: path.resolve(__dirname, '../query/src'),
|
||||
},
|
||||
{
|
||||
find: '@actual-app/shared-types',
|
||||
replacement: path.resolve(__dirname, '../shared-types/src'),
|
||||
},
|
||||
],
|
||||
},
|
||||
plugins: [
|
||||
|
||||
67
packages/plugins-core/package.json
Normal file
67
packages/plugins-core/package.json
Normal file
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"name": "@actual-app/plugins-core",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"description": "Core plugin system for Actual Budget",
|
||||
"main": "build/client.js",
|
||||
"types": "src/client.ts",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint src/ tests/ --ext .ts,.tsx",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.1.4",
|
||||
"@types/react-dom": "^19.1.4",
|
||||
"typescript": "^5.5.4",
|
||||
"typescript-eslint": "^8.18.1",
|
||||
"typescript-strict-plugin": "^2.4.4",
|
||||
"vite": "^6.2.0",
|
||||
"vite-plugin-dts": "^4.5.3"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"development": "./src/index.ts",
|
||||
"import": "./build/client.js",
|
||||
"require": "./build/client.cjs"
|
||||
},
|
||||
"./server": {
|
||||
"types": "./src/server.ts",
|
||||
"development": "./src/server.ts",
|
||||
"import": "./build/server.js",
|
||||
"require": "./build/server.cjs"
|
||||
},
|
||||
"./client": {
|
||||
"types": "./src/client.ts",
|
||||
"development": "./src/client.ts",
|
||||
"import": "./build/client.js",
|
||||
"require": "./build/client.cjs"
|
||||
},
|
||||
"./BasicModalComponents": {
|
||||
"types": "./src/BasicModalComponents.tsx",
|
||||
"import": "./build/BasicModalComponents.js",
|
||||
"development": "./src/BasicModalComponents.tsx"
|
||||
},
|
||||
"./types/*": {
|
||||
"types": "./src/types/*.ts",
|
||||
"development": "./src/types/*.ts"
|
||||
},
|
||||
"./query": {
|
||||
"types": "./src/query/index.ts",
|
||||
"development": "./src/query/index.ts"
|
||||
},
|
||||
"./src/*": "./src/*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/css": "^11.13.5",
|
||||
"i18next": "^25.2.1",
|
||||
"react": "19.1.0",
|
||||
"react-aria-components": "^1.7.1",
|
||||
"react-dom": "19.1.0",
|
||||
"react-i18next": "^16.0.0",
|
||||
"usehooks-ts": "^3.1.1"
|
||||
}
|
||||
}
|
||||
230
packages/plugins-core/src/BasicModalComponents.tsx
Normal file
230
packages/plugins-core/src/BasicModalComponents.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
import React, {
|
||||
CSSProperties,
|
||||
ReactNode,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { SvgDelete } from '@actual-app/components/icons/v0';
|
||||
import { View } from '@actual-app/components/view';
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { Input } from '@actual-app/components/input';
|
||||
import { styles } from '@actual-app/components/styles';
|
||||
import { SvgLogo } from '@actual-app/components/icons/logo';
|
||||
type ModalButtonsProps = {
|
||||
style?: CSSProperties;
|
||||
leftContent?: ReactNode;
|
||||
focusButton?: boolean;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export const ModalButtons = ({
|
||||
style,
|
||||
leftContent,
|
||||
focusButton = false,
|
||||
children,
|
||||
}: ModalButtonsProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (focusButton && containerRef.current) {
|
||||
const button = containerRef.current.querySelector<HTMLButtonElement>(
|
||||
'button:not([data-hidden])',
|
||||
);
|
||||
|
||||
if (button) {
|
||||
button.focus();
|
||||
}
|
||||
}
|
||||
}, [focusButton]);
|
||||
|
||||
return (
|
||||
<View
|
||||
innerRef={containerRef}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
marginTop: 30,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{leftContent}
|
||||
<View style={{ flex: 1 }} />
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
type ModalHeaderProps = {
|
||||
leftContent?: ReactNode;
|
||||
showLogo?: boolean;
|
||||
title?: ReactNode;
|
||||
rightContent?: ReactNode;
|
||||
};
|
||||
|
||||
export function ModalHeader({
|
||||
leftContent,
|
||||
showLogo,
|
||||
title,
|
||||
rightContent,
|
||||
}: ModalHeaderProps) {
|
||||
return (
|
||||
<View
|
||||
role="heading"
|
||||
style={{
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
position: 'relative',
|
||||
height: 60,
|
||||
flex: 'none',
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
}}
|
||||
>
|
||||
{leftContent}
|
||||
</View>
|
||||
|
||||
{(title || showLogo) && (
|
||||
<View
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
// We need to force a width for the text-overflow
|
||||
// ellipses to work because we are aligning center.
|
||||
width: 'calc(100% - 60px)',
|
||||
}}
|
||||
>
|
||||
{showLogo && (
|
||||
<SvgLogo
|
||||
width={30}
|
||||
height={30}
|
||||
style={{ justifyContent: 'center', alignSelf: 'center' }}
|
||||
/>
|
||||
)}
|
||||
{title &&
|
||||
(typeof title === 'string' || typeof title === 'number' ? (
|
||||
<ModalTitle title={`${title}`} />
|
||||
) : (
|
||||
title
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{rightContent && (
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
}}
|
||||
>
|
||||
{rightContent}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
type ModalTitleProps = {
|
||||
title: string;
|
||||
isEditable?: boolean;
|
||||
getStyle?: (isEditing: boolean) => CSSProperties;
|
||||
onEdit?: (isEditing: boolean) => void;
|
||||
onTitleUpdate?: (newName: string) => void;
|
||||
};
|
||||
|
||||
export function ModalTitle({
|
||||
title,
|
||||
isEditable,
|
||||
getStyle,
|
||||
onTitleUpdate,
|
||||
}: ModalTitleProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const onTitleClick = () => {
|
||||
if (isEditable) {
|
||||
setIsEditing(true);
|
||||
}
|
||||
};
|
||||
|
||||
const _onTitleUpdate = (newTitle: string) => {
|
||||
if (newTitle !== title) {
|
||||
onTitleUpdate?.(newTitle);
|
||||
}
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.scrollLeft = 0;
|
||||
}
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
const style = getStyle?.(isEditing);
|
||||
|
||||
return isEditing ? (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
style={{
|
||||
fontSize: 25,
|
||||
fontWeight: 700,
|
||||
textAlign: 'center',
|
||||
...style,
|
||||
}}
|
||||
defaultValue={title}
|
||||
onUpdate={_onTitleUpdate}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
_onTitleUpdate?.(e.currentTarget.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
onClick={onTitleClick}
|
||||
style={{
|
||||
fontSize: 25,
|
||||
fontWeight: 700,
|
||||
textAlign: 'center',
|
||||
...(isEditable && styles.underlinedText),
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
type ModalCloseButtonProps = {
|
||||
onPress?: () => void;
|
||||
style?: CSSProperties;
|
||||
};
|
||||
|
||||
export function ModalCloseButton({ onPress, style }: ModalCloseButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Button
|
||||
variant="bare"
|
||||
onPress={onPress}
|
||||
aria-label={t('Close')}
|
||||
style={{ padding: '10px 10px' }}
|
||||
>
|
||||
<SvgDelete width={10} style={style} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
31
packages/plugins-core/src/client.ts
Normal file
31
packages/plugins-core/src/client.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// Modal Components (client-side only)
|
||||
export {
|
||||
ModalTitle,
|
||||
ModalButtons,
|
||||
ModalHeader,
|
||||
ModalCloseButton,
|
||||
} from './BasicModalComponents';
|
||||
|
||||
// Client-side middleware
|
||||
export { initializePlugin } from './middleware';
|
||||
|
||||
// Client-side hooks (React hooks)
|
||||
export { useReport } from './utils';
|
||||
|
||||
// Query System (also needed on client-side for components)
|
||||
export * from '@actual-app/query';
|
||||
|
||||
// Spreadsheet types and utilities (client-side only)
|
||||
export * from '@actual-app/shared-types/spreadsheet';
|
||||
|
||||
// Client-side plugin types
|
||||
export type {
|
||||
ActualPlugin,
|
||||
ActualPluginInitialized,
|
||||
HostContext,
|
||||
} from './types/actualPlugin';
|
||||
|
||||
export type {
|
||||
ActualPluginToolkit,
|
||||
ActualPluginToolkitFunctions,
|
||||
} from './types/toolkit';
|
||||
13
packages/plugins-core/src/index.ts
Normal file
13
packages/plugins-core/src/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Main Entry Point for @actual-app/plugins-core
|
||||
*
|
||||
* Re-exports everything from both server and client exports.
|
||||
* `server` must be used in `loot-core`
|
||||
* `client` must be used in `desktop-client`
|
||||
*/
|
||||
|
||||
// Re-export all server-safe exports
|
||||
export * from './server';
|
||||
|
||||
// Re-export all client-only exports
|
||||
export * from './client';
|
||||
101
packages/plugins-core/src/middleware.ts
Normal file
101
packages/plugins-core/src/middleware.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import React, { ReactElement } from 'react';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
|
||||
import type { BasicModalProps } from '@actual-app/components/modal';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
|
||||
import {
|
||||
ActualPlugin,
|
||||
ActualPluginInitialized,
|
||||
SlotLocations,
|
||||
} from './types/actualPlugin';
|
||||
|
||||
const containerRoots = new WeakMap<HTMLElement, ReactDOM.Root>();
|
||||
|
||||
function getOrCreateRoot(container: HTMLElement) {
|
||||
let root = containerRoots.get(container);
|
||||
if (!root) {
|
||||
root = ReactDOM.createRoot(container);
|
||||
containerRoots.set(container, root);
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
export function initializePlugin(
|
||||
plugin: ActualPlugin,
|
||||
): ActualPluginInitialized {
|
||||
const originalActivate = plugin.activate;
|
||||
|
||||
const newPlugin: ActualPluginInitialized = {
|
||||
...plugin,
|
||||
initialized: true,
|
||||
activate: context => {
|
||||
context.i18nInstance.use(initReactI18next);
|
||||
|
||||
const wrappedContext = {
|
||||
...context,
|
||||
|
||||
// Database provided by host
|
||||
db: context.db,
|
||||
|
||||
// Query builder passed through directly
|
||||
q: context.q,
|
||||
|
||||
registerSlotContent(position: SlotLocations, element: ReactElement) {
|
||||
return context.registerSlotContent(position, container => {
|
||||
const root = getOrCreateRoot(container);
|
||||
root.render(element);
|
||||
return () => root.unmount();
|
||||
});
|
||||
},
|
||||
|
||||
pushModal(element: ReactElement, modalProps?: BasicModalProps) {
|
||||
context.pushModal(container => {
|
||||
const root = getOrCreateRoot(container);
|
||||
root.render(element);
|
||||
return () => root.unmount();
|
||||
}, modalProps);
|
||||
},
|
||||
|
||||
registerRoute(path: string, element: ReactElement) {
|
||||
return context.registerRoute(path, container => {
|
||||
const root = getOrCreateRoot(container);
|
||||
root.render(element);
|
||||
return () => root.unmount();
|
||||
});
|
||||
},
|
||||
|
||||
registerDashboardWidget(
|
||||
widgetType: string,
|
||||
displayName: string,
|
||||
element: ReactElement,
|
||||
options?: {
|
||||
defaultWidth?: number;
|
||||
defaultHeight?: number;
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
},
|
||||
) {
|
||||
return context.registerDashboardWidget(
|
||||
widgetType,
|
||||
displayName,
|
||||
container => {
|
||||
const root = getOrCreateRoot(container);
|
||||
root.render(element);
|
||||
return () => root.unmount();
|
||||
},
|
||||
options,
|
||||
);
|
||||
},
|
||||
|
||||
// Report and spreadsheet utilities - passed through from host context
|
||||
createSpreadsheet: context.createSpreadsheet,
|
||||
makeFilters: context.makeFilters,
|
||||
};
|
||||
|
||||
originalActivate(wrappedContext);
|
||||
},
|
||||
};
|
||||
|
||||
return newPlugin;
|
||||
}
|
||||
62
packages/plugins-core/src/server.ts
Normal file
62
packages/plugins-core/src/server.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Server-Only Exports for @actual-app/plugins-core
|
||||
*
|
||||
* This file contains only types and utilities that can be used in server environments
|
||||
* (Web Workers, Node.js) without any DOM dependencies or React components.
|
||||
*/
|
||||
|
||||
// Database types
|
||||
export type {
|
||||
SqlParameter,
|
||||
DatabaseQueryResult,
|
||||
DatabaseRow,
|
||||
DatabaseSelectResult,
|
||||
DatabaseResult,
|
||||
DatabaseOperation,
|
||||
PluginMetadata,
|
||||
} from '@actual-app/query/database';
|
||||
|
||||
// Plugin file types
|
||||
export type { PluginFile, PluginFileCollection } from './types/plugin-files';
|
||||
|
||||
// AQL query result types
|
||||
export type {
|
||||
AQLQueryResult,
|
||||
AQLQueryOptions,
|
||||
} from '@actual-app/query/aql-result';
|
||||
|
||||
// Model types (server-safe)
|
||||
export type {
|
||||
AccountEntity,
|
||||
CategoryEntity,
|
||||
CategoryGroupEntity,
|
||||
PayeeEntity,
|
||||
ScheduleEntity,
|
||||
} from '@actual-app/shared-types/models/index';
|
||||
|
||||
// Plugin types (server-safe ones)
|
||||
export type {
|
||||
PluginDatabase,
|
||||
PluginSpreadsheet,
|
||||
PluginBinding,
|
||||
PluginCellValue,
|
||||
PluginFilterCondition,
|
||||
PluginFilterResult,
|
||||
PluginConditionValue,
|
||||
PluginMigration,
|
||||
PluginContext,
|
||||
ContextEvent,
|
||||
} from './types/actualPlugin';
|
||||
|
||||
export type { ActualPluginEntry } from './types/actualPluginEntry';
|
||||
export type { ActualPluginManifest } from './types/actualPluginManifest';
|
||||
|
||||
// Query System (server-safe)
|
||||
export {
|
||||
Query,
|
||||
q,
|
||||
getPrimaryOrderBy,
|
||||
type QueryState,
|
||||
type QueryBuilder,
|
||||
type ObjectExpression,
|
||||
} from '@actual-app/query';
|
||||
241
packages/plugins-core/src/types/actualPlugin.ts
Normal file
241
packages/plugins-core/src/types/actualPlugin.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
import type { BasicModalProps } from '@actual-app/components/modal';
|
||||
import type { Query, QueryBuilder } from '@actual-app/query';
|
||||
import type {
|
||||
AccountEntity,
|
||||
CategoryEntity,
|
||||
CategoryGroupEntity,
|
||||
PayeeEntity,
|
||||
ScheduleEntity,
|
||||
} from '@actual-app/shared-types';
|
||||
import type { i18n } from 'i18next';
|
||||
export type SlotLocations =
|
||||
| 'sidebar-main-menu'
|
||||
| 'sidebar-more-menu'
|
||||
| 'sidebar-before-accounts'
|
||||
| 'sidebar-after-accounts'
|
||||
| 'topbar';
|
||||
|
||||
// Define condition value types for filtering
|
||||
export type PluginConditionValue =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null
|
||||
| Array<string | number>
|
||||
| { num1: number; num2: number };
|
||||
|
||||
export type PluginFilterCondition = {
|
||||
field: string;
|
||||
op: string;
|
||||
value: PluginConditionValue;
|
||||
type?: string;
|
||||
customName?: string;
|
||||
};
|
||||
|
||||
export type PluginFilterResult = {
|
||||
filters: Record<string, unknown>;
|
||||
};
|
||||
|
||||
// Simple color mapping type for theme methods
|
||||
export interface PluginDatabase {
|
||||
runQuery<T = unknown>(
|
||||
sql: string,
|
||||
params?: (string | number)[],
|
||||
fetchAll?: boolean,
|
||||
): Promise<T[] | { changes: number; insertId?: number }>;
|
||||
|
||||
execQuery(sql: string): void;
|
||||
|
||||
transaction(fn: () => void): void;
|
||||
|
||||
getMigrationState(): Promise<string[]>;
|
||||
|
||||
setMetadata(key: string, value: string): Promise<void>;
|
||||
|
||||
getMetadata(key: string): Promise<string | null>;
|
||||
|
||||
/**
|
||||
* Execute an AQL (Actual Query Language) query.
|
||||
* This provides a higher-level abstraction over SQL that's consistent with Actual Budget's query system.
|
||||
*
|
||||
* @param query - The AQL query (can be a PluginQuery object or serialized PluginQueryState)
|
||||
* @param options - Optional parameters for the query
|
||||
* @param options.target - Target database: 'plugin' for plugin tables, 'host' for main app tables. Defaults to 'plugin'
|
||||
* @param options.params - Named parameters for the query
|
||||
* @returns Promise that resolves to the query result with data and dependencies
|
||||
*/
|
||||
aql(
|
||||
query: Query,
|
||||
options?: {
|
||||
target?: 'plugin' | 'host';
|
||||
params?: Record<string, unknown>;
|
||||
},
|
||||
): Promise<{ data: unknown; dependencies: string[] }>;
|
||||
}
|
||||
|
||||
export type PluginBinding = string | { name: string; query?: Query };
|
||||
|
||||
export type PluginCellValue = { name: string; value: unknown | null };
|
||||
|
||||
export interface PluginSpreadsheet {
|
||||
/**
|
||||
* Bind to a cell and observe changes
|
||||
* @param sheetName - Name of the sheet (optional, defaults to global)
|
||||
* @param binding - Cell binding (string name or object with name and optional query)
|
||||
* @param callback - Function called when cell value changes
|
||||
* @returns Cleanup function to stop observing
|
||||
*/
|
||||
bind(
|
||||
sheetName: string | undefined,
|
||||
binding: PluginBinding,
|
||||
callback: (node: PluginCellValue) => void,
|
||||
): () => void;
|
||||
|
||||
/**
|
||||
* Get a cell value directly
|
||||
* @param sheetName - Name of the sheet
|
||||
* @param name - Cell name
|
||||
* @returns Promise that resolves to the cell value
|
||||
*/
|
||||
get(sheetName: string, name: string): Promise<PluginCellValue>;
|
||||
|
||||
/**
|
||||
* Get all cell names in a sheet
|
||||
* @param sheetName - Name of the sheet
|
||||
* @returns Promise that resolves to array of cell names
|
||||
*/
|
||||
getCellNames(sheetName: string): Promise<string[]>;
|
||||
|
||||
/**
|
||||
* Create a query in a sheet
|
||||
* @param sheetName - Name of the sheet
|
||||
* @param name - Query name
|
||||
* @param query - The query to create
|
||||
* @returns Promise that resolves when query is created
|
||||
*/
|
||||
createQuery(sheetName: string, name: string, query: Query): Promise<void>;
|
||||
}
|
||||
|
||||
export type PluginMigration = [
|
||||
timestamp: number,
|
||||
name: string,
|
||||
upCommand: string,
|
||||
downCommand: string,
|
||||
];
|
||||
|
||||
// Plugin context type for easier reuse
|
||||
export type PluginContext = Omit<
|
||||
HostContext,
|
||||
| 'registerSlotContent'
|
||||
| 'pushModal'
|
||||
| 'registerRoute'
|
||||
| 'registerDashboardWidget'
|
||||
> & {
|
||||
registerSlotContent: (
|
||||
location: SlotLocations,
|
||||
element: ReactElement,
|
||||
) => () => void;
|
||||
pushModal: (element: ReactElement, modalProps?: BasicModalProps) => void;
|
||||
registerRoute: (path: string, routeElement: ReactElement) => () => void;
|
||||
|
||||
// Dashboard widget registration - wrapped for JSX elements
|
||||
registerDashboardWidget: (
|
||||
widgetType: string,
|
||||
displayName: string,
|
||||
element: ReactElement,
|
||||
options?: {
|
||||
defaultWidth?: number;
|
||||
defaultHeight?: number;
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
},
|
||||
) => () => void;
|
||||
|
||||
db?: PluginDatabase;
|
||||
q: QueryBuilder;
|
||||
|
||||
// Report and spreadsheet utilities
|
||||
createSpreadsheet: () => PluginSpreadsheet;
|
||||
|
||||
makeFilters: (
|
||||
conditions: Array<PluginFilterCondition>,
|
||||
) => Promise<PluginFilterResult>;
|
||||
};
|
||||
|
||||
export interface ActualPlugin {
|
||||
name: string;
|
||||
version: string;
|
||||
install: (
|
||||
oldVersion: string,
|
||||
newVersion: string,
|
||||
context: PluginContext,
|
||||
) => void;
|
||||
uninstall: (context: PluginContext) => void;
|
||||
migrations?: () => PluginMigration[];
|
||||
activate: (context: PluginContext) => void;
|
||||
deactivate: (context: PluginContext) => void;
|
||||
}
|
||||
|
||||
export type ActualPluginInitialized = Omit<ActualPlugin, 'activate'> & {
|
||||
initialized: true;
|
||||
activate: (context: HostContext & { db: PluginDatabase }) => void;
|
||||
};
|
||||
|
||||
export interface ContextEvent {
|
||||
payees: { payees: PayeeEntity[] };
|
||||
categories: { categories: CategoryEntity[]; groups: CategoryGroupEntity[] };
|
||||
accounts: { accounts: AccountEntity[] };
|
||||
schedules: { schedules: ScheduleEntity[] };
|
||||
}
|
||||
|
||||
export interface HostContext {
|
||||
navigate: (routePath: string) => void;
|
||||
|
||||
pushModal: (
|
||||
parameter: (container: HTMLDivElement) => void | (() => void),
|
||||
modalProps?: BasicModalProps,
|
||||
) => void;
|
||||
popModal: () => void;
|
||||
|
||||
registerRoute: (
|
||||
path: string,
|
||||
routeElement: (container: HTMLDivElement) => void | (() => void),
|
||||
) => () => void;
|
||||
|
||||
registerSlotContent: (
|
||||
location: SlotLocations,
|
||||
parameter: (container: HTMLDivElement) => void | (() => void),
|
||||
) => () => void;
|
||||
|
||||
on: <K extends keyof ContextEvent>(
|
||||
eventType: K,
|
||||
callback: (data: ContextEvent[K]) => void,
|
||||
) => void;
|
||||
|
||||
// Dashboard widget methods
|
||||
registerDashboardWidget: (
|
||||
widgetType: string,
|
||||
displayName: string,
|
||||
renderWidget: (container: HTMLDivElement) => void | (() => void),
|
||||
options?: {
|
||||
defaultWidth?: number;
|
||||
defaultHeight?: number;
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
},
|
||||
) => () => void;
|
||||
|
||||
// Query builder provided by host (loot-core's q function)
|
||||
q: QueryBuilder;
|
||||
|
||||
// Report and spreadsheet utilities for dashboard widgets
|
||||
createSpreadsheet: () => PluginSpreadsheet;
|
||||
|
||||
makeFilters: (
|
||||
conditions: Array<PluginFilterCondition>,
|
||||
) => Promise<PluginFilterResult>;
|
||||
|
||||
i18nInstance: i18n;
|
||||
}
|
||||
5
packages/plugins-core/src/types/actualPluginEntry.ts
Normal file
5
packages/plugins-core/src/types/actualPluginEntry.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import React from 'react';
|
||||
|
||||
import { ActualPluginInitialized } from './actualPlugin';
|
||||
|
||||
export type ActualPluginEntry = () => ActualPluginInitialized;
|
||||
11
packages/plugins-core/src/types/actualPluginManifest.ts
Normal file
11
packages/plugins-core/src/types/actualPluginManifest.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export interface ActualPluginManifest {
|
||||
url: string;
|
||||
name: string;
|
||||
version: string;
|
||||
enabled?: boolean;
|
||||
description?: string;
|
||||
pluginType: 'server' | 'client';
|
||||
minimumActualVersion: string;
|
||||
author: string;
|
||||
plugin?: Blob;
|
||||
}
|
||||
18
packages/plugins-core/src/types/plugin-files.ts
Normal file
18
packages/plugins-core/src/types/plugin-files.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Plugin File Types
|
||||
*
|
||||
* Types for plugin file operations and storage.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Represents a single file within a plugin package
|
||||
*/
|
||||
export interface PluginFile {
|
||||
name: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collection of files that make up a plugin
|
||||
*/
|
||||
export type PluginFileCollection = PluginFile[];
|
||||
7
packages/plugins-core/src/types/toolkit.ts
Normal file
7
packages/plugins-core/src/types/toolkit.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export type ActualPluginToolkitFunctions = {
|
||||
pushModal: (modalName: string, options?: unknown) => void;
|
||||
};
|
||||
|
||||
export type ActualPluginToolkit = {
|
||||
functions: ActualPluginToolkitFunctions;
|
||||
};
|
||||
24
packages/plugins-core/src/utils.ts
Normal file
24
packages/plugins-core/src/utils.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
import { PluginSpreadsheet } from './types/actualPlugin';
|
||||
|
||||
/**
|
||||
* useReport hook for plugins to manage spreadsheet data
|
||||
* This is similar to the useReport hook in desktop-client but adapted for plugins
|
||||
*/
|
||||
export function useReport<T>(
|
||||
sheetName: string,
|
||||
getData: (
|
||||
spreadsheet: PluginSpreadsheet,
|
||||
setData: (results: T) => void,
|
||||
) => Promise<void>,
|
||||
spreadsheet: PluginSpreadsheet,
|
||||
): T | null {
|
||||
const [results, setResults] = useState<T | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
getData(spreadsheet, results => setResults(results));
|
||||
}, [getData, spreadsheet]);
|
||||
|
||||
return results;
|
||||
}
|
||||
14
packages/plugins-core/tsconfig.json
Normal file
14
packages/plugins-core/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"outDir": "dist",
|
||||
"jsx": "react-jsx",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"baseUrl": "."
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
50
packages/plugins-core/vite.config.mts
Normal file
50
packages/plugins-core/vite.config.mts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { resolve } from 'path';
|
||||
|
||||
import { defineConfig } from 'vite';
|
||||
import dts from 'vite-plugin-dts';
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {},
|
||||
},
|
||||
build: {
|
||||
outDir: 'build',
|
||||
lib: {
|
||||
entry: {
|
||||
server: 'src/server.ts',
|
||||
client: 'src/client.ts',
|
||||
},
|
||||
name: '@actual-app/plugins-core',
|
||||
fileName: (format, entryName) =>
|
||||
format === 'es' ? `${entryName}.js` : `${entryName}.cjs`,
|
||||
formats: ['es', 'cjs'],
|
||||
},
|
||||
rollupOptions: {
|
||||
external: [
|
||||
'react',
|
||||
'react-dom',
|
||||
'i18next',
|
||||
'react-i18next',
|
||||
'react-aria-components',
|
||||
'@emotion/css',
|
||||
'usehooks-ts',
|
||||
],
|
||||
output: {
|
||||
globals: {
|
||||
react: 'React',
|
||||
'react-dom': 'ReactDOM',
|
||||
i18next: 'i18next',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
dts({
|
||||
insertTypesEntry: true,
|
||||
outDir: 'build',
|
||||
include: ['src/**/*'],
|
||||
exclude: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
|
||||
rollupTypes: false,
|
||||
copyDtsFiles: false,
|
||||
}),
|
||||
],
|
||||
});
|
||||
24
packages/query/package.json
Normal file
24
packages/query/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@actual-app/query",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"description": "Query system and database utilities for Actual Budget",
|
||||
"license": "MIT",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./database": "./src/database.ts",
|
||||
"./aql-result": "./src/aql-result.ts",
|
||||
"./spreadsheet": "./src/spreadsheet.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actual-app/shared-types": "workspace:^"
|
||||
}
|
||||
}
|
||||
27
packages/query/src/aql-result.ts
Normal file
27
packages/query/src/aql-result.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* AQL Query Result Types
|
||||
*
|
||||
* Types for AQL (Advanced Query Language) query results.
|
||||
*/
|
||||
|
||||
import { DatabaseRow } from './database';
|
||||
|
||||
/**
|
||||
* Result of an AQL query operation
|
||||
*/
|
||||
export interface AQLQueryResult {
|
||||
/** The actual data returned by the query */
|
||||
data: DatabaseRow[] | DatabaseRow | null;
|
||||
/** List of table/field dependencies detected during query execution */
|
||||
dependencies: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for AQL query execution
|
||||
*/
|
||||
export interface AQLQueryOptions {
|
||||
/** Target for the query - 'plugin' uses plugin DB, 'host' uses main DB */
|
||||
target?: 'plugin' | 'host';
|
||||
/** Parameters to be passed to the query */
|
||||
params?: Record<string, string | number | boolean | null>;
|
||||
}
|
||||
51
packages/query/src/database.ts
Normal file
51
packages/query/src/database.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Plugin Database Types
|
||||
*
|
||||
* Shared types for database operations between plugins and the host application.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parameters that can be passed to SQL queries
|
||||
*/
|
||||
export type SqlParameter = string | number | boolean | null | Buffer;
|
||||
|
||||
/**
|
||||
* Result of a database query operation (INSERT, UPDATE, DELETE)
|
||||
*/
|
||||
export interface DatabaseQueryResult {
|
||||
changes: number;
|
||||
insertId?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Row returned from a database SELECT query
|
||||
*/
|
||||
export type DatabaseRow = Record<string, SqlParameter>;
|
||||
|
||||
/**
|
||||
* Result of a database SELECT query
|
||||
*/
|
||||
export type DatabaseSelectResult = DatabaseRow[];
|
||||
|
||||
/**
|
||||
* Union type for all possible database query results
|
||||
*/
|
||||
export type DatabaseResult = DatabaseQueryResult | DatabaseSelectResult;
|
||||
|
||||
/**
|
||||
* Database transaction operation
|
||||
*/
|
||||
export interface DatabaseOperation {
|
||||
type: 'exec' | 'query';
|
||||
sql: string;
|
||||
params?: SqlParameter[];
|
||||
fetchAll?: boolean; // Only used for query operations
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin database metadata key-value pair
|
||||
*/
|
||||
export interface PluginMetadata {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
179
packages/query/src/index.ts
Normal file
179
packages/query/src/index.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Query System - Single Source of Truth
|
||||
*
|
||||
* This is the main query implementation used by both loot-core and plugins.
|
||||
* No more conversion functions needed!
|
||||
*/
|
||||
|
||||
import {
|
||||
ObjectExpression,
|
||||
IQuery,
|
||||
QueryState,
|
||||
WithRequired,
|
||||
} from '@actual-app/shared-types';
|
||||
|
||||
export type { ObjectExpression, IQuery, QueryState };
|
||||
|
||||
export class Query implements IQuery {
|
||||
state: QueryState;
|
||||
|
||||
constructor(state: WithRequired<Partial<QueryState>, 'table'>) {
|
||||
this.state = {
|
||||
tableOptions: state.tableOptions || {},
|
||||
filterExpressions: state.filterExpressions || [],
|
||||
selectExpressions: state.selectExpressions || [],
|
||||
groupExpressions: state.groupExpressions || [],
|
||||
orderExpressions: state.orderExpressions || [],
|
||||
calculation: false,
|
||||
rawMode: false,
|
||||
withDead: false,
|
||||
validateRefs: true,
|
||||
limit: null,
|
||||
offset: null,
|
||||
...state,
|
||||
};
|
||||
}
|
||||
|
||||
filter(expr: ObjectExpression) {
|
||||
return new Query({
|
||||
...this.state,
|
||||
filterExpressions: [...this.state.filterExpressions, expr],
|
||||
});
|
||||
}
|
||||
|
||||
unfilter(exprs?: Array<keyof ObjectExpression>) {
|
||||
// Remove all filters if no arguments are passed
|
||||
if (!exprs) {
|
||||
return new Query({
|
||||
...this.state,
|
||||
filterExpressions: [],
|
||||
});
|
||||
}
|
||||
|
||||
const exprSet = new Set(exprs);
|
||||
return new Query({
|
||||
...this.state,
|
||||
filterExpressions: this.state.filterExpressions.filter(
|
||||
expr => !exprSet.has(Object.keys(expr)[0]),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
select(
|
||||
exprs:
|
||||
| Array<ObjectExpression | string>
|
||||
| ObjectExpression
|
||||
| string
|
||||
| '*'
|
||||
| ['*'] = [],
|
||||
) {
|
||||
if (!Array.isArray(exprs)) {
|
||||
exprs = [exprs];
|
||||
}
|
||||
|
||||
return new Query({
|
||||
...this.state,
|
||||
selectExpressions: exprs,
|
||||
calculation: false,
|
||||
});
|
||||
}
|
||||
|
||||
calculate(expr: ObjectExpression | string) {
|
||||
return new Query({
|
||||
...this.state,
|
||||
selectExpressions: [{ result: expr }],
|
||||
calculation: true,
|
||||
});
|
||||
}
|
||||
|
||||
groupBy(exprs: ObjectExpression | string | Array<ObjectExpression | string>) {
|
||||
if (!Array.isArray(exprs)) {
|
||||
exprs = [exprs];
|
||||
}
|
||||
|
||||
return new Query({
|
||||
...this.state,
|
||||
groupExpressions: [...this.state.groupExpressions, ...exprs],
|
||||
});
|
||||
}
|
||||
|
||||
orderBy(exprs: ObjectExpression | string | Array<ObjectExpression | string>) {
|
||||
if (!Array.isArray(exprs)) {
|
||||
exprs = [exprs];
|
||||
}
|
||||
|
||||
return new Query({
|
||||
...this.state,
|
||||
orderExpressions: [...this.state.orderExpressions, ...exprs],
|
||||
});
|
||||
}
|
||||
|
||||
limit(num: number) {
|
||||
return new Query({ ...this.state, limit: num });
|
||||
}
|
||||
|
||||
offset(num: number) {
|
||||
return new Query({ ...this.state, offset: num });
|
||||
}
|
||||
|
||||
raw() {
|
||||
return new Query({ ...this.state, rawMode: true });
|
||||
}
|
||||
|
||||
withDead() {
|
||||
return new Query({ ...this.state, withDead: true });
|
||||
}
|
||||
|
||||
withoutValidatedRefs() {
|
||||
return new Query({ ...this.state, validateRefs: false });
|
||||
}
|
||||
|
||||
options(opts: Record<string, unknown>) {
|
||||
return new Query({ ...this.state, tableOptions: opts });
|
||||
}
|
||||
|
||||
reset() {
|
||||
return q(this.state.table);
|
||||
}
|
||||
|
||||
serialize() {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
serializeAsString() {
|
||||
return JSON.stringify(this.serialize());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Query builder function - creates a new Query for the given table
|
||||
*/
|
||||
export function q(table: QueryState['table']) {
|
||||
return new Query({ table });
|
||||
}
|
||||
|
||||
export type { QueryBuilder } from '@actual-app/shared-types';
|
||||
|
||||
/**
|
||||
* Helper function to get primary order by clause
|
||||
*/
|
||||
export function getPrimaryOrderBy(
|
||||
query: Query,
|
||||
defaultOrderBy: ObjectExpression | null,
|
||||
) {
|
||||
const orderExprs = query.serialize().orderExpressions;
|
||||
if (orderExprs.length === 0) {
|
||||
if (defaultOrderBy) {
|
||||
return { order: 'asc', ...defaultOrderBy };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstOrder = orderExprs[0];
|
||||
if (typeof firstOrder === 'string') {
|
||||
return { field: firstOrder, order: 'asc' };
|
||||
}
|
||||
// Handle this form: { field: 'desc' }
|
||||
const [field] = Object.keys(firstOrder);
|
||||
return { field, order: firstOrder[field] };
|
||||
}
|
||||
12
packages/query/tsconfig.json
Normal file
12
packages/query/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"noEmit": false,
|
||||
"declaration": true,
|
||||
"outDir": "dist",
|
||||
"declarationDir": "@types"
|
||||
},
|
||||
"include": ["."],
|
||||
"exclude": ["**/node_modules/*", "dist", "@types", "*.test.ts"]
|
||||
}
|
||||
21
packages/shared-types/package.json
Normal file
21
packages/shared-types/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "@actual-app/shared-types",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"description": "Shared type definitions for Actual Budget",
|
||||
"license": "MIT",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./models/*": "./src/models/*.ts",
|
||||
"./modalProps": "./src/modalProps.ts",
|
||||
"./spreadsheet": "./src/spreadsheet.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.5.4"
|
||||
}
|
||||
}
|
||||
8
packages/shared-types/src/index.ts
Normal file
8
packages/shared-types/src/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// Re-export all model types
|
||||
export type * from './models';
|
||||
|
||||
// Export query types
|
||||
export type * from './query';
|
||||
|
||||
// Export utility types
|
||||
export type * from './util';
|
||||
25
packages/shared-types/src/models/account.ts
Normal file
25
packages/shared-types/src/models/account.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export type AccountEntity = {
|
||||
id: string;
|
||||
name: string;
|
||||
offbudget: 0 | 1;
|
||||
closed: 0 | 1;
|
||||
sort_order: number;
|
||||
last_reconciled: string | null;
|
||||
tombstone: 0 | 1;
|
||||
} & (_SyncFields<true> | _SyncFields<false>);
|
||||
|
||||
export type _SyncFields<T> = {
|
||||
account_id: T extends true ? string : null;
|
||||
bank: T extends true ? string : null;
|
||||
bankName: T extends true ? string : null;
|
||||
bankId: T extends true ? number : null;
|
||||
mask: T extends true ? string : null; // end of bank account number
|
||||
official_name: T extends true ? string : null;
|
||||
balance_current: T extends true ? number : null;
|
||||
balance_available: T extends true ? number : null;
|
||||
balance_limit: T extends true ? number : null;
|
||||
account_sync_source: T extends true ? AccountSyncSource : null;
|
||||
last_sync: T extends true ? string : null;
|
||||
};
|
||||
|
||||
export type AccountSyncSource = 'simpleFin' | 'goCardless' | 'pluggyai';
|
||||
11
packages/shared-types/src/models/category-group.ts
Normal file
11
packages/shared-types/src/models/category-group.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { CategoryEntity } from './category';
|
||||
|
||||
export interface CategoryGroupEntity {
|
||||
id: string;
|
||||
name: string;
|
||||
is_income?: boolean;
|
||||
sort_order?: number;
|
||||
tombstone?: boolean;
|
||||
hidden?: boolean;
|
||||
categories?: CategoryEntity[];
|
||||
}
|
||||
7
packages/shared-types/src/models/category-views.ts
Normal file
7
packages/shared-types/src/models/category-views.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { CategoryEntity } from './category';
|
||||
import { CategoryGroupEntity } from './category-group';
|
||||
|
||||
export interface CategoryViews {
|
||||
grouped: CategoryGroupEntity[];
|
||||
list: CategoryEntity[];
|
||||
}
|
||||
13
packages/shared-types/src/models/category.ts
Normal file
13
packages/shared-types/src/models/category.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { CategoryGroupEntity } from './category-group';
|
||||
|
||||
export interface CategoryEntity {
|
||||
id: string;
|
||||
name: string;
|
||||
is_income?: boolean;
|
||||
group: CategoryGroupEntity['id'];
|
||||
goal_def?: string;
|
||||
template_settings?: { source: 'notes' | 'ui' };
|
||||
sort_order?: number;
|
||||
tombstone?: boolean;
|
||||
hidden?: boolean;
|
||||
}
|
||||
7
packages/shared-types/src/models/index.ts
Normal file
7
packages/shared-types/src/models/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export type * from './account';
|
||||
export type * from './payee';
|
||||
export type * from './category';
|
||||
export type * from './category-group';
|
||||
export type * from './category-views';
|
||||
export type * from './rule';
|
||||
export type * from './schedule';
|
||||
10
packages/shared-types/src/models/payee.ts
Normal file
10
packages/shared-types/src/models/payee.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { AccountEntity } from './account';
|
||||
|
||||
export interface PayeeEntity {
|
||||
id: string;
|
||||
name: string;
|
||||
transfer_acct?: AccountEntity['id'];
|
||||
favorite?: boolean;
|
||||
learn_categories?: boolean;
|
||||
tombstone?: boolean;
|
||||
}
|
||||
169
packages/shared-types/src/models/rule.ts
Normal file
169
packages/shared-types/src/models/rule.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import type { RecurConfig, ScheduleEntity } from './schedule';
|
||||
|
||||
export interface NewRuleEntity {
|
||||
stage: 'pre' | null | 'post';
|
||||
conditionsOp: 'or' | 'and';
|
||||
conditions: RuleConditionEntity[];
|
||||
actions: RuleActionEntity[];
|
||||
tombstone?: boolean;
|
||||
}
|
||||
|
||||
export interface RuleEntity extends NewRuleEntity {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export type RuleConditionOp = RuleConditionEntity['op'];
|
||||
|
||||
export type FieldValueTypes = {
|
||||
account: string;
|
||||
amount: number;
|
||||
category: string;
|
||||
date: string | RecurConfig;
|
||||
notes: string;
|
||||
payee: string;
|
||||
payee_name: string;
|
||||
imported_payee: string;
|
||||
saved: string;
|
||||
transfer: boolean;
|
||||
parent: boolean;
|
||||
cleared: boolean;
|
||||
reconciled: boolean;
|
||||
};
|
||||
|
||||
type BaseConditionEntity<
|
||||
Field extends keyof FieldValueTypes,
|
||||
Op extends RuleConditionOp,
|
||||
> = {
|
||||
field: Field;
|
||||
op: Op;
|
||||
value: Op extends 'oneOf' | 'notOneOf'
|
||||
? Array<FieldValueTypes[Field]>
|
||||
: Op extends 'isbetween'
|
||||
? { num1: number; num2: number }
|
||||
: FieldValueTypes[Field];
|
||||
options?: {
|
||||
inflow?: boolean;
|
||||
outflow?: boolean;
|
||||
month?: boolean;
|
||||
year?: boolean;
|
||||
};
|
||||
conditionsOp?: string;
|
||||
type?: 'id' | 'boolean' | 'date' | 'number' | 'string';
|
||||
customName?: string;
|
||||
queryFilter?: Record<string, { $oneof: string[] }>;
|
||||
};
|
||||
|
||||
export type RuleConditionEntity =
|
||||
| BaseConditionEntity<
|
||||
'account',
|
||||
| 'is'
|
||||
| 'isNot'
|
||||
| 'oneOf'
|
||||
| 'notOneOf'
|
||||
| 'contains'
|
||||
| 'doesNotContain'
|
||||
| 'matches'
|
||||
| 'onBudget'
|
||||
| 'offBudget'
|
||||
>
|
||||
| BaseConditionEntity<
|
||||
'category',
|
||||
| 'is'
|
||||
| 'isNot'
|
||||
| 'oneOf'
|
||||
| 'notOneOf'
|
||||
| 'contains'
|
||||
| 'doesNotContain'
|
||||
| 'matches'
|
||||
>
|
||||
| BaseConditionEntity<
|
||||
'amount',
|
||||
'is' | 'isapprox' | 'isbetween' | 'gt' | 'gte' | 'lt' | 'lte'
|
||||
>
|
||||
| BaseConditionEntity<
|
||||
'date',
|
||||
'is' | 'isapprox' | 'isbetween' | 'gt' | 'gte' | 'lt' | 'lte'
|
||||
>
|
||||
| BaseConditionEntity<
|
||||
'notes',
|
||||
| 'is'
|
||||
| 'isNot'
|
||||
| 'oneOf'
|
||||
| 'notOneOf'
|
||||
| 'contains'
|
||||
| 'doesNotContain'
|
||||
| 'matches'
|
||||
| 'hasTags'
|
||||
>
|
||||
| BaseConditionEntity<
|
||||
'payee',
|
||||
| 'is'
|
||||
| 'isNot'
|
||||
| 'oneOf'
|
||||
| 'notOneOf'
|
||||
| 'contains'
|
||||
| 'doesNotContain'
|
||||
| 'matches'
|
||||
>
|
||||
| BaseConditionEntity<
|
||||
'imported_payee',
|
||||
| 'is'
|
||||
| 'isNot'
|
||||
| 'oneOf'
|
||||
| 'notOneOf'
|
||||
| 'contains'
|
||||
| 'doesNotContain'
|
||||
| 'matches'
|
||||
>
|
||||
| BaseConditionEntity<'saved', 'is'>
|
||||
| BaseConditionEntity<'cleared', 'is'>
|
||||
| BaseConditionEntity<'reconciled', 'is'>;
|
||||
|
||||
export type RuleActionEntity =
|
||||
| SetRuleActionEntity
|
||||
| SetSplitAmountRuleActionEntity
|
||||
| LinkScheduleRuleActionEntity
|
||||
| PrependNoteRuleActionEntity
|
||||
| AppendNoteRuleActionEntity
|
||||
| DeleteTransactionRuleActionEntity;
|
||||
|
||||
export interface SetRuleActionEntity {
|
||||
field: string;
|
||||
op: 'set';
|
||||
value: unknown;
|
||||
options?: {
|
||||
template?: string;
|
||||
formula?: string;
|
||||
splitIndex?: number;
|
||||
};
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export interface SetSplitAmountRuleActionEntity {
|
||||
op: 'set-split-amount';
|
||||
value: number;
|
||||
options?: {
|
||||
splitIndex?: number;
|
||||
method: 'fixed-amount' | 'fixed-percent' | 'remainder';
|
||||
};
|
||||
}
|
||||
|
||||
export interface LinkScheduleRuleActionEntity {
|
||||
op: 'link-schedule';
|
||||
value: ScheduleEntity;
|
||||
}
|
||||
|
||||
export interface PrependNoteRuleActionEntity {
|
||||
op: 'prepend-notes';
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface AppendNoteRuleActionEntity {
|
||||
op: 'append-notes';
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface DeleteTransactionRuleActionEntity {
|
||||
op: 'delete-transaction';
|
||||
value: string;
|
||||
}
|
||||
49
packages/shared-types/src/models/schedule.ts
Normal file
49
packages/shared-types/src/models/schedule.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { AccountEntity } from './account';
|
||||
import type { PayeeEntity } from './payee';
|
||||
import type { RuleConditionEntity, RuleEntity } from './rule';
|
||||
|
||||
export interface RecurPattern {
|
||||
value: number;
|
||||
type: 'SU' | 'MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA' | 'day';
|
||||
}
|
||||
|
||||
export interface RecurConfig {
|
||||
frequency: 'daily' | 'weekly' | 'monthly' | 'yearly';
|
||||
interval?: number;
|
||||
patterns?: RecurPattern[];
|
||||
skipWeekend?: boolean;
|
||||
start: string;
|
||||
endMode: 'never' | 'after_n_occurrences' | 'on_date';
|
||||
endOccurrences?: number;
|
||||
endDate?: string;
|
||||
weekendSolveMode?: 'before' | 'after';
|
||||
}
|
||||
|
||||
export interface ScheduleEntity {
|
||||
id: string;
|
||||
name?: string;
|
||||
rule: RuleEntity['id'];
|
||||
next_date: string;
|
||||
completed: boolean;
|
||||
posts_transaction: boolean;
|
||||
tombstone: boolean;
|
||||
|
||||
// These are special fields that are actually pulled from the
|
||||
// underlying rule
|
||||
_payee: PayeeEntity['id'];
|
||||
_account: AccountEntity['id'];
|
||||
_amount: number | { num1: number; num2: number };
|
||||
_amountOp: string;
|
||||
_date: RecurConfig | string;
|
||||
_conditions: RuleConditionEntity[];
|
||||
_actions: Array<{ op: unknown }>;
|
||||
}
|
||||
|
||||
export type DiscoverScheduleEntity = {
|
||||
id: ScheduleEntity['id'];
|
||||
account: AccountEntity['id'];
|
||||
payee: PayeeEntity['id'];
|
||||
date: RecurConfig;
|
||||
amount: ScheduleEntity['_amount'];
|
||||
_conditions: ScheduleEntity['_conditions'];
|
||||
};
|
||||
54
packages/shared-types/src/query.ts
Normal file
54
packages/shared-types/src/query.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Query System Types
|
||||
*/
|
||||
|
||||
export type ObjectExpression = {
|
||||
[key: string]: ObjectExpression | unknown;
|
||||
};
|
||||
|
||||
export type QueryState = {
|
||||
get table(): string;
|
||||
get tableOptions(): Readonly<Record<string, unknown>>;
|
||||
get filterExpressions(): ReadonlyArray<ObjectExpression>;
|
||||
get selectExpressions(): ReadonlyArray<ObjectExpression | string | '*'>;
|
||||
get groupExpressions(): ReadonlyArray<ObjectExpression | string>;
|
||||
get orderExpressions(): ReadonlyArray<ObjectExpression | string>;
|
||||
get calculation(): boolean;
|
||||
get rawMode(): boolean;
|
||||
get withDead(): boolean;
|
||||
get validateRefs(): boolean;
|
||||
get limit(): number | null;
|
||||
get offset(): number | null;
|
||||
};
|
||||
|
||||
export type IQuery = {
|
||||
state: QueryState;
|
||||
filter(expr: ObjectExpression): IQuery;
|
||||
unfilter(exprs?: Array<keyof ObjectExpression>): IQuery;
|
||||
select(
|
||||
exprs:
|
||||
| Array<ObjectExpression | string>
|
||||
| ObjectExpression
|
||||
| string
|
||||
| '*'
|
||||
| ['*'],
|
||||
): IQuery;
|
||||
calculate(expr: ObjectExpression | string): IQuery;
|
||||
groupBy(
|
||||
exprs: ObjectExpression | string | Array<ObjectExpression | string>,
|
||||
): IQuery;
|
||||
orderBy(
|
||||
exprs: ObjectExpression | string | Array<ObjectExpression | string>,
|
||||
): IQuery;
|
||||
limit(num: number): IQuery;
|
||||
offset(num: number): IQuery;
|
||||
raw(): IQuery;
|
||||
withDead(): IQuery;
|
||||
withoutValidatedRefs(): IQuery;
|
||||
options(opts: Record<string, unknown>): IQuery;
|
||||
reset(): IQuery;
|
||||
serialize(): QueryState;
|
||||
serializeAsString(): string;
|
||||
};
|
||||
|
||||
export type QueryBuilder = (table: string) => IQuery;
|
||||
127
packages/shared-types/src/spreadsheet.ts
Normal file
127
packages/shared-types/src/spreadsheet.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Spreadsheet Types and Utilities
|
||||
*
|
||||
* Core spreadsheet schema definitions and binding utilities used across
|
||||
* the application for managing financial data in sheet-like structures.
|
||||
*/
|
||||
|
||||
import { IQuery } from './query';
|
||||
|
||||
export type Spreadsheets = {
|
||||
account: {
|
||||
// Common fields
|
||||
'uncategorized-amount': number;
|
||||
'uncategorized-balance': number;
|
||||
|
||||
// Account fields
|
||||
balance: number;
|
||||
[key: `balance-${string}-cleared`]: number | null;
|
||||
'accounts-balance': number;
|
||||
'onbudget-accounts-balance': number;
|
||||
'offbudget-accounts-balance': number;
|
||||
'closed-accounts-balance': number;
|
||||
balanceCleared: number;
|
||||
balanceUncleared: number;
|
||||
lastReconciled: string | null;
|
||||
};
|
||||
category: {
|
||||
// Common fields
|
||||
'uncategorized-amount': number;
|
||||
'uncategorized-balance': number;
|
||||
|
||||
balance: number;
|
||||
balanceCleared: number;
|
||||
balanceUncleared: number;
|
||||
};
|
||||
'envelope-budget': {
|
||||
// Common fields
|
||||
'uncategorized-amount': number;
|
||||
'uncategorized-balance': number;
|
||||
|
||||
// Envelope budget fields
|
||||
'available-funds': number;
|
||||
'last-month-overspent': number;
|
||||
buffered: number;
|
||||
'buffered-auto': number;
|
||||
'buffered-selected': number;
|
||||
'to-budget': number | null;
|
||||
'from-last-month': number;
|
||||
'total-budgeted': number;
|
||||
'total-income': number;
|
||||
'total-spent': number;
|
||||
'total-leftover': number;
|
||||
'group-sum-amount': number;
|
||||
'group-budget': number;
|
||||
'group-leftover': number;
|
||||
budget: number;
|
||||
'sum-amount': number;
|
||||
leftover: number;
|
||||
carryover: number;
|
||||
goal: number;
|
||||
'long-goal': number;
|
||||
};
|
||||
'tracking-budget': {
|
||||
// Common fields
|
||||
'uncategorized-amount': number;
|
||||
'uncategorized-balance': number;
|
||||
|
||||
// Tracking budget fields
|
||||
'total-budgeted': number;
|
||||
'total-budget-income': number;
|
||||
'total-saved': number;
|
||||
'total-income': number;
|
||||
'total-spent': number;
|
||||
'real-saved': number;
|
||||
'total-leftover': number;
|
||||
'group-sum-amount': number;
|
||||
'group-budget': number;
|
||||
'group-leftover': number;
|
||||
budget: number;
|
||||
'sum-amount': number;
|
||||
leftover: number;
|
||||
carryover: number;
|
||||
goal: number;
|
||||
'long-goal': number;
|
||||
};
|
||||
[`balance`]: {
|
||||
// Common fields
|
||||
'uncategorized-amount': number;
|
||||
'uncategorized-balance': number;
|
||||
|
||||
// Balance fields
|
||||
[key: `balance-query-${string}`]: number;
|
||||
[key: `selected-transactions-${string}`]: Array<{ id: string }>;
|
||||
[key: `selected-balance-${string}`]: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type SheetNames = keyof Spreadsheets & string;
|
||||
|
||||
export type SheetFields<SheetName extends SheetNames> =
|
||||
keyof Spreadsheets[SheetName] & string;
|
||||
|
||||
export type BindingObject<
|
||||
SheetName extends SheetNames,
|
||||
SheetFieldName extends SheetFields<SheetName>,
|
||||
> = {
|
||||
name: SheetFieldName;
|
||||
value?: Spreadsheets[SheetName][SheetFieldName] | undefined;
|
||||
query?: IQuery | undefined;
|
||||
};
|
||||
|
||||
export type Binding<
|
||||
SheetName extends SheetNames,
|
||||
SheetFieldName extends SheetFields<SheetName>,
|
||||
> =
|
||||
| SheetFieldName
|
||||
| {
|
||||
name: SheetFieldName;
|
||||
value?: Spreadsheets[SheetName][SheetFieldName] | undefined;
|
||||
query?: IQuery | undefined;
|
||||
};
|
||||
|
||||
export const parametrizedField =
|
||||
<SheetName extends SheetNames>() =>
|
||||
<SheetFieldName extends SheetFields<SheetName>>(field: SheetFieldName) =>
|
||||
(id?: string): SheetFieldName =>
|
||||
`${field}-${id}` as SheetFieldName;
|
||||
5
packages/shared-types/src/util.ts
Normal file
5
packages/shared-types/src/util.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Utility types for plugins-core
|
||||
*/
|
||||
|
||||
export type WithRequired<T, K extends keyof T> = T & Required<Pick<T, K>>;
|
||||
9
packages/shared-types/tsconfig.json
Normal file
9
packages/shared-types/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "build",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -32,6 +32,7 @@
|
||||
"paths": {
|
||||
// TEMPORARY: Until we can fix the "exports" in the loot-core package.json
|
||||
"loot-core/*": ["./packages/loot-core/src/*"],
|
||||
"plugins-core/*": ["./packages/plugins-core/src/*"],
|
||||
"@desktop-client/*": ["./packages/desktop-client/src/*"],
|
||||
"@desktop-client/e2e/*": ["./packages/desktop-client/e2e/*"]
|
||||
},
|
||||
@@ -53,7 +54,8 @@
|
||||
"**/lib-dist/*",
|
||||
"**/test-results/*",
|
||||
"**/playwright-report/*",
|
||||
"**/service-worker/*"
|
||||
"**/service-worker/*",
|
||||
"packages/test-plugin/*"
|
||||
],
|
||||
"ts-node": {
|
||||
"compilerOptions": {
|
||||
|
||||
7
upcoming-release-notes/5786.md
Normal file
7
upcoming-release-notes/5786.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
category: Features
|
||||
authors: [lelemm]
|
||||
---
|
||||
|
||||
Add support for frontend plugins with a new core package for enhanced functionality.
|
||||
|
||||
Reference in New Issue
Block a user