mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-17 23:40:32 -05:00
Compare commits
27 Commits
react-quer
...
claude/act
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bea2bd070 | ||
|
|
45a7c029ba | ||
|
|
c4ee71409e | ||
|
|
dfd6e468a6 | ||
|
|
5b227f5fa1 | ||
|
|
e1606b31ab | ||
|
|
1c21476ad3 | ||
|
|
b03a507d33 | ||
|
|
b8a255abc8 | ||
|
|
4f7c3c51a5 | ||
|
|
0e1fc07bf3 | ||
|
|
53cdc6fa48 | ||
|
|
1d0281025d | ||
|
|
231bd96092 | ||
|
|
a3eb298f09 | ||
|
|
f73c5e9210 | ||
|
|
722ff6eb85 | ||
|
|
6f1bac2977 | ||
|
|
c64b7ce754 | ||
|
|
0d7e3d2007 | ||
|
|
3ddb403bfe | ||
|
|
843274e00e | ||
|
|
fd916a925d | ||
|
|
8e2a996671 | ||
|
|
70b802da39 | ||
|
|
b16e5d685c | ||
|
|
c4de834f98 |
3
.github/actions/docs-spelling/expect.txt
vendored
3
.github/actions/docs-spelling/expect.txt
vendored
@@ -2,6 +2,7 @@ Abanca
|
||||
ABNAMRO
|
||||
ABNANL
|
||||
Activo
|
||||
actualrc
|
||||
AESUDEF
|
||||
ALZEY
|
||||
Anglais
|
||||
@@ -110,8 +111,8 @@ KBCBE
|
||||
Keycloak
|
||||
Khurozov
|
||||
KORT
|
||||
KRW
|
||||
Kreditbank
|
||||
KRW
|
||||
lage
|
||||
LHV
|
||||
LHVBEE
|
||||
|
||||
25
.github/workflows/build.yml
vendored
25
.github/workflows/build.yml
vendored
@@ -81,6 +81,31 @@ jobs:
|
||||
name: build-stats
|
||||
path: packages/desktop-client/build-stats
|
||||
|
||||
cli:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
download-translations: 'false'
|
||||
- name: Build CLI
|
||||
run: yarn build:cli
|
||||
- name: Create package tgz
|
||||
run: cd packages/cli && yarn pack && mv package.tgz actual-cli.tgz
|
||||
- name: Prepare bundle stats artifact
|
||||
run: cp packages/cli/dist/stats.json cli-stats.json
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: actual-cli
|
||||
path: packages/cli/actual-cli.tgz
|
||||
- name: Upload CLI bundle stats
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
name: cli-build-stats
|
||||
path: cli-stats.json
|
||||
|
||||
server:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
10
.github/workflows/check.yml
vendored
10
.github/workflows/check.yml
vendored
@@ -12,6 +12,16 @@ concurrency:
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
|
||||
|
||||
jobs:
|
||||
constraints:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
download-translations: 'false'
|
||||
- name: Check dependency version consistency
|
||||
run: yarn constraints
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
6
.github/workflows/electron-pr.yml
vendored
6
.github/workflows/electron-pr.yml
vendored
@@ -42,6 +42,8 @@ jobs:
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
python3 -m pip install setuptools
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- if: ${{ startsWith(matrix.os, 'ubuntu') }}
|
||||
name: Setup Flatpak dependencies
|
||||
run: |
|
||||
@@ -56,11 +58,9 @@ jobs:
|
||||
|
||||
METAINFO_FILE="packages/desktop-electron/extra-resources/linux/com.actualbudget.actual.metainfo.xml"
|
||||
TODAY=$(date +%Y-%m-%d)
|
||||
VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/desktop-electron/package.json --type nightly)
|
||||
VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/desktop-electron/package.json --type nightly)
|
||||
sed -i "s/%RELEASE_VERSION%/$VERSION/g; s/%RELEASE_DATE%/$TODAY/g" "$METAINFO_FILE"
|
||||
flatpak run --command=flatpak-builder-lint org.flatpak.Builder appstream "$METAINFO_FILE"
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Build Electron
|
||||
run: ./bin/package-electron
|
||||
|
||||
|
||||
8
.github/workflows/generate-release-pr.yml
vendored
8
.github/workflows/generate-release-pr.yml
vendored
@@ -20,6 +20,10 @@ jobs:
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
with:
|
||||
ref: ${{ github.event.inputs.ref }}
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
download-translations: 'false'
|
||||
- name: Bump package versions
|
||||
id: bump_package_versions
|
||||
shell: bash
|
||||
@@ -35,12 +39,12 @@ jobs:
|
||||
pkg="${packages[$key]}"
|
||||
|
||||
if [[ -n "${{ github.event.inputs.version }}" ]]; then
|
||||
version=$(node ./packages/ci-actions/bin/get-next-package-version.js \
|
||||
version=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts \
|
||||
--package-json "./packages/$pkg/package.json" \
|
||||
--version "${{ github.event.inputs.version }}" \
|
||||
--update)
|
||||
else
|
||||
version=$(node ./packages/ci-actions/bin/get-next-package-version.js \
|
||||
version=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts \
|
||||
--package-json "./packages/$pkg/package.json" \
|
||||
--type auto \
|
||||
--update)
|
||||
|
||||
@@ -39,6 +39,9 @@ jobs:
|
||||
source .venv/bin/activate
|
||||
python3 -m pip install setuptools
|
||||
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- if: ${{ startsWith(matrix.os, 'ubuntu') }}
|
||||
name: Setup Flatpak dependencies
|
||||
run: |
|
||||
@@ -53,16 +56,14 @@ jobs:
|
||||
|
||||
METAINFO_FILE="packages/desktop-electron/extra-resources/linux/com.actualbudget.actual.metainfo.xml"
|
||||
TODAY=$(date +%Y-%m-%d)
|
||||
VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/desktop-electron/package.json --type nightly)
|
||||
VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/desktop-electron/package.json --type nightly)
|
||||
sed -i "s/%RELEASE_VERSION%/$VERSION/g; s/%RELEASE_DATE%/$TODAY/g" "$METAINFO_FILE"
|
||||
flatpak run --command=flatpak-builder-lint org.flatpak.Builder appstream "$METAINFO_FILE"
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
- name: Update package versions
|
||||
run: |
|
||||
# Get new nightly version
|
||||
NEW_DESKTOP_APP_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/desktop-electron/package.json --type nightly)
|
||||
NEW_DESKTOP_APP_VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/desktop-electron/package.json --type nightly)
|
||||
|
||||
# Set package version
|
||||
npm version $NEW_DESKTOP_APP_VERSION --no-git-tag-version --workspace=desktop-electron --no-workspaces-update
|
||||
|
||||
@@ -20,16 +20,18 @@ jobs:
|
||||
- name: Update package versions
|
||||
run: |
|
||||
# Get new nightly versions
|
||||
NEW_CORE_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/loot-core/package.json --type nightly)
|
||||
NEW_WEB_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/desktop-client/package.json --type nightly)
|
||||
NEW_SYNC_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/sync-server/package.json --type nightly)
|
||||
NEW_API_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/api/package.json --type nightly)
|
||||
NEW_CORE_VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/loot-core/package.json --type nightly)
|
||||
NEW_WEB_VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/desktop-client/package.json --type nightly)
|
||||
NEW_SYNC_VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/sync-server/package.json --type nightly)
|
||||
NEW_API_VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/api/package.json --type nightly)
|
||||
NEW_CLI_VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/cli/package.json --type nightly)
|
||||
|
||||
# Set package versions
|
||||
npm version $NEW_CORE_VERSION --no-git-tag-version --workspace=@actual-app/core --no-workspaces-update
|
||||
npm version $NEW_WEB_VERSION --no-git-tag-version --workspace=@actual-app/web --no-workspaces-update
|
||||
npm version $NEW_SYNC_VERSION --no-git-tag-version --workspace=@actual-app/sync-server --no-workspaces-update
|
||||
npm version $NEW_API_VERSION --no-git-tag-version --workspace=@actual-app/api --no-workspaces-update
|
||||
npm version $NEW_CLI_VERSION --no-git-tag-version --workspace=@actual-app/cli --no-workspaces-update
|
||||
|
||||
- name: Yarn install
|
||||
run: |
|
||||
@@ -54,6 +56,13 @@ jobs:
|
||||
run: |
|
||||
yarn workspace @actual-app/api pack --filename @actual-app/api.tgz
|
||||
|
||||
- name: Build CLI
|
||||
run: yarn workspace @actual-app/cli build
|
||||
|
||||
- name: Pack the cli package
|
||||
run: |
|
||||
yarn workspace @actual-app/cli pack --filename @actual-app/cli.tgz
|
||||
|
||||
- name: Upload package artifacts
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
@@ -63,6 +72,7 @@ jobs:
|
||||
packages/desktop-client/@actual-app/web.tgz
|
||||
packages/sync-server/@actual-app/sync-server.tgz
|
||||
packages/api/@actual-app/api.tgz
|
||||
packages/cli/@actual-app/cli.tgz
|
||||
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -106,3 +116,9 @@ jobs:
|
||||
npm publish api/@actual-app/api.tgz --access public --tag nightly
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Publish CLI
|
||||
run: |
|
||||
npm publish cli/@actual-app/cli.tgz --access public --tag nightly
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
14
.github/workflows/publish-npm-packages.yml
vendored
14
.github/workflows/publish-npm-packages.yml
vendored
@@ -35,6 +35,13 @@ jobs:
|
||||
run: |
|
||||
yarn workspace @actual-app/api pack --filename @actual-app/api.tgz
|
||||
|
||||
- name: Build CLI
|
||||
run: yarn workspace @actual-app/cli build
|
||||
|
||||
- name: Pack the cli package
|
||||
run: |
|
||||
yarn workspace @actual-app/cli pack --filename @actual-app/cli.tgz
|
||||
|
||||
- name: Upload package artifacts
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
with:
|
||||
@@ -44,6 +51,7 @@ jobs:
|
||||
packages/desktop-client/@actual-app/web.tgz
|
||||
packages/sync-server/@actual-app/sync-server.tgz
|
||||
packages/api/@actual-app/api.tgz
|
||||
packages/cli/@actual-app/cli.tgz
|
||||
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -87,3 +95,9 @@ jobs:
|
||||
npm publish api/@actual-app/api.tgz --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Publish CLI
|
||||
run: |
|
||||
npm publish cli/@actual-app/cli.tgz --access public
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
35
.github/workflows/size-compare.yml
vendored
35
.github/workflows/size-compare.yml
vendored
@@ -57,6 +57,13 @@ jobs:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
checkName: api
|
||||
ref: ${{github.base_ref}}
|
||||
- name: Wait for ${{github.base_ref}} CLI build to succeed
|
||||
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
|
||||
id: master-cli-build
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
checkName: cli
|
||||
ref: ${{github.base_ref}}
|
||||
|
||||
- name: Wait for PR build to succeed
|
||||
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
|
||||
@@ -72,9 +79,16 @@ jobs:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
checkName: api
|
||||
ref: ${{github.event.pull_request.head.sha}}
|
||||
- name: Wait for CLI PR build to succeed
|
||||
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
|
||||
id: wait-for-cli-build
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
checkName: cli
|
||||
ref: ${{github.event.pull_request.head.sha}}
|
||||
|
||||
- name: Report build failure
|
||||
if: steps.wait-for-web-build.outputs.conclusion == 'failure' || steps.wait-for-api-build.outputs.conclusion == 'failure'
|
||||
if: steps.wait-for-web-build.outputs.conclusion == 'failure' || steps.wait-for-api-build.outputs.conclusion == 'failure' || steps.wait-for-cli-build.outputs.conclusion == 'failure'
|
||||
run: |
|
||||
echo "Build failed on PR branch or ${{github.base_ref}}"
|
||||
exit 1
|
||||
@@ -115,6 +129,23 @@ jobs:
|
||||
name: api-build-stats
|
||||
path: head
|
||||
allow_forks: true
|
||||
- name: Download CLI build artifact from ${{github.base_ref}}
|
||||
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
|
||||
with:
|
||||
branch: ${{github.base_ref}}
|
||||
workflow: build.yml
|
||||
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
|
||||
name: cli-build-stats
|
||||
path: base
|
||||
- name: Download CLI stats from PR
|
||||
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
|
||||
with:
|
||||
pr: ${{github.event.pull_request.number}}
|
||||
workflow: build.yml
|
||||
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
|
||||
name: cli-build-stats
|
||||
path: head
|
||||
allow_forks: true
|
||||
- name: Strip content hashes from stats files
|
||||
run: |
|
||||
if [ -f ./head/web-stats.json ]; then
|
||||
@@ -136,9 +167,11 @@ jobs:
|
||||
--base desktop-client=./base/web-stats.json \
|
||||
--base loot-core=./base/loot-core-stats.json \
|
||||
--base api=./base/api-stats.json \
|
||||
--base cli=./base/cli-stats.json \
|
||||
--head desktop-client=./head/web-stats.json \
|
||||
--head loot-core=./head/loot-core-stats.json \
|
||||
--head api=./head/api-stats.json \
|
||||
--head cli=./head/cli-stats.json \
|
||||
--identifier combined \
|
||||
--format pr-body > bundle-stats-comment.md
|
||||
- name: Post combined bundle stats comment
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -81,3 +81,10 @@ build/
|
||||
|
||||
*storybook.log
|
||||
storybook-static
|
||||
|
||||
# cli config when testing locally
|
||||
.actualrc.json
|
||||
.actualrc
|
||||
.actualrc.yaml
|
||||
.actualrc.yml
|
||||
actual.config.js
|
||||
|
||||
13
.husky/post-checkout
Executable file
13
.husky/post-checkout
Executable file
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
# Run yarn install when switching branches (if yarn.lock changed)
|
||||
|
||||
# $3 is 1 for branch checkout, 0 for file checkout
|
||||
if [ "$3" != "1" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Check if yarn.lock changed between the old and new HEAD
|
||||
if git diff --name-only "$1" "$2" | grep -q "^yarn.lock$"; then
|
||||
echo "yarn.lock changed — running yarn install..."
|
||||
yarn install
|
||||
fi
|
||||
@@ -17,6 +17,7 @@ module.exports = {
|
||||
},
|
||||
build: {
|
||||
type: 'npmScript',
|
||||
dependsOn: ['^build'],
|
||||
cache: true,
|
||||
options: {
|
||||
outputGlob: ['lib-dist/**', 'dist/**', 'build/**'],
|
||||
|
||||
@@ -34,12 +34,14 @@
|
||||
"start:browser-backend": "yarn workspace @actual-app/core watch:browser",
|
||||
"start:browser-frontend": "yarn workspace @actual-app/web start:browser",
|
||||
"start:storybook": "yarn workspace @actual-app/components start:storybook",
|
||||
"build": "lage build",
|
||||
"build:browser-backend": "yarn workspace @actual-app/core build:browser",
|
||||
"build:server": "yarn build:browser && yarn workspace @actual-app/sync-server build",
|
||||
"build:browser": "./bin/package-browser",
|
||||
"build:desktop": "./bin/package-electron",
|
||||
"build:plugins-service": "yarn workspace plugins-service build",
|
||||
"build:api": "yarn workspace @actual-app/api build",
|
||||
"build:cli": "yarn build --scope=@actual-app/cli",
|
||||
"build:docs": "yarn workspace docs build",
|
||||
"build:storybook": "yarn workspace @actual-app/components build:storybook",
|
||||
"deploy:docs": "yarn workspace docs deploy",
|
||||
@@ -57,6 +59,7 @@
|
||||
"lint": "oxfmt --check . && oxlint --type-aware --quiet",
|
||||
"lint:fix": "oxfmt . && oxlint --fix --type-aware --quiet",
|
||||
"install:server": "yarn workspaces focus @actual-app/sync-server --production",
|
||||
"constraints": "yarn constraints",
|
||||
"typecheck": "tsgo -p tsconfig.root.json --noEmit && lage typecheck",
|
||||
"jq": "./node_modules/node-jq/bin/jq",
|
||||
"prepare": "husky"
|
||||
@@ -66,6 +69,7 @@
|
||||
"@types/node": "^22.19.10",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
|
||||
"@yarnpkg/types": "^4.0.1",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9.39.2",
|
||||
@@ -88,6 +92,7 @@
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"resolutions": {
|
||||
"adm-zip": "patch:adm-zip@npm%3A0.5.16#~/.yarn/patches/adm-zip-npm-0.5.16-4556fea098.patch",
|
||||
"rollup": "4.40.1",
|
||||
"socks": ">=2.8.3"
|
||||
},
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
class Query {
|
||||
/** @type {import('loot-core/shared/query').QueryState} */
|
||||
state;
|
||||
|
||||
constructor(state) {
|
||||
this.state = {
|
||||
filterExpressions: state.filterExpressions || [],
|
||||
|
||||
@@ -9,6 +9,20 @@
|
||||
],
|
||||
"main": "dist/index.js",
|
||||
"types": "@types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"development": "./index.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./@types/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest --run",
|
||||
|
||||
44
packages/ci-actions/bin/get-next-package-version.js → packages/ci-actions/bin/get-next-package-version.ts
Executable file → Normal file
44
packages/ci-actions/bin/get-next-package-version.js → packages/ci-actions/bin/get-next-package-version.ts
Executable file → Normal file
@@ -2,13 +2,13 @@
|
||||
|
||||
// This script is used in GitHub Actions to get the next version based on the current package.json version.
|
||||
// It supports three types of versioning: nightly, hotfix, and monthly.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import { parseArgs } from 'node:util';
|
||||
|
||||
import { getNextVersion } from '../src/versions/get-next-package-version.js';
|
||||
|
||||
const args = process.argv;
|
||||
import {
|
||||
getNextVersion,
|
||||
isValidVersionType,
|
||||
} from '../src/versions/get-next-package-version';
|
||||
|
||||
const options = {
|
||||
'package-json': {
|
||||
@@ -28,40 +28,53 @@ const options = {
|
||||
short: 'u',
|
||||
default: false,
|
||||
},
|
||||
};
|
||||
} as const;
|
||||
|
||||
function fail(message: string): never {
|
||||
console.error(message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const { values } = parseArgs({
|
||||
args,
|
||||
options,
|
||||
allowPositionals: true,
|
||||
});
|
||||
|
||||
if (!values['package-json']) {
|
||||
console.error(
|
||||
const packageJsonPath = values['package-json'];
|
||||
if (!packageJsonPath) {
|
||||
fail(
|
||||
'Please specify the path to package.json using --package-json or -p option.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const packageJsonPath = values['package-json'];
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
|
||||
if (!('version' in packageJson) || typeof packageJson.version !== 'string') {
|
||||
fail('The specified package.json does not contain a valid version field.');
|
||||
}
|
||||
|
||||
const currentVersion = packageJson.version;
|
||||
|
||||
const explicitVersion = values.version;
|
||||
let newVersion;
|
||||
|
||||
if (explicitVersion) {
|
||||
newVersion = explicitVersion;
|
||||
} else {
|
||||
const type = values.type;
|
||||
if (!type || !isValidVersionType(type)) {
|
||||
fail('Please specify the release type using --type or -t.');
|
||||
}
|
||||
|
||||
try {
|
||||
newVersion = getNextVersion({
|
||||
currentVersion,
|
||||
type: values.type,
|
||||
type,
|
||||
currentDate: new Date(),
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e.message);
|
||||
process.exit(1);
|
||||
} catch (error) {
|
||||
fail(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +89,5 @@ try {
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error.message);
|
||||
process.exit(1);
|
||||
fail(`Error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
8
packages/ci-actions/bin/tsx
Executable file
8
packages/ci-actions/bin/tsx
Executable file
@@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
cd ../../
|
||||
|
||||
script="$1"
|
||||
shift
|
||||
exec node --import=extensionless/register --experimental-strip-types packages/ci-actions/"$script" "$@"
|
||||
@@ -3,7 +3,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"tsx": "node --import=extensionless/register --experimental-strip-types",
|
||||
"tsx": "bin/tsx",
|
||||
"test": "vitest --run",
|
||||
"typecheck": "tsgo -b"
|
||||
},
|
||||
|
||||
@@ -77,7 +77,7 @@ describe('getNextVersion (lib)', () => {
|
||||
expect(() =>
|
||||
getNextVersion({
|
||||
currentVersion: '25.8.4',
|
||||
type: 'unknown',
|
||||
type: 'unknown' as never,
|
||||
currentDate: new Date('2025-08-10'),
|
||||
}),
|
||||
).toThrow(/Invalid type/);
|
||||
@@ -1,35 +1,69 @@
|
||||
function parseVersion(version) {
|
||||
export const versionTypeArray = [
|
||||
'auto',
|
||||
'hotfix',
|
||||
'monthly',
|
||||
'nightly',
|
||||
] as const;
|
||||
export type VersionType = (typeof versionTypeArray)[number];
|
||||
|
||||
type ParsedVersion = {
|
||||
versionYear: number;
|
||||
versionMonth: number;
|
||||
versionHotfix: number;
|
||||
};
|
||||
|
||||
type GetNextVersionOptions = {
|
||||
currentVersion: string;
|
||||
type: VersionType;
|
||||
currentDate?: Date;
|
||||
};
|
||||
|
||||
function parseVersion(version: string): ParsedVersion {
|
||||
const [y, m, p] = version.split('.');
|
||||
return {
|
||||
versionYear: parseInt(y, 10),
|
||||
versionMonth: parseInt(m, 10),
|
||||
versionHotfix: parseInt(p, 10),
|
||||
versionYear: Number.parseInt(y, 10),
|
||||
versionMonth: Number.parseInt(m, 10),
|
||||
versionHotfix: Number.parseInt(p, 10),
|
||||
};
|
||||
}
|
||||
|
||||
function computeNextMonth(versionYear, versionMonth) {
|
||||
// Create date and add 1 month
|
||||
const versionDate = new Date(2000 + versionYear, versionMonth - 1, 1); // month is 0-indexed
|
||||
function computeNextMonth(versionYear: number, versionMonth: number) {
|
||||
const versionDate = new Date(2000 + versionYear, versionMonth - 1, 1);
|
||||
const nextVersionMonthDate = new Date(
|
||||
versionDate.getFullYear(),
|
||||
versionDate.getMonth() + 1,
|
||||
1,
|
||||
);
|
||||
|
||||
// Format back to YY.M format
|
||||
const fullYear = nextVersionMonthDate.getFullYear();
|
||||
const nextVersionYear = fullYear.toString().slice(fullYear < 2100 ? -2 : -3);
|
||||
const nextVersionMonth = nextVersionMonthDate.getMonth() + 1; // Convert back to 1-indexed
|
||||
const nextVersionMonth = nextVersionMonthDate.getMonth() + 1;
|
||||
|
||||
return { nextVersionYear, nextVersionMonth };
|
||||
}
|
||||
|
||||
// Determine logical type from 'auto' based on the current date and version
|
||||
function resolveType(type, currentDate, versionYear, versionMonth) {
|
||||
if (type !== 'auto') return type;
|
||||
export function isValidVersionType(value: string): value is VersionType {
|
||||
return versionTypeArray.includes(value as VersionType);
|
||||
}
|
||||
|
||||
function resolveType(
|
||||
type: VersionType,
|
||||
currentDate: Date,
|
||||
versionYear: number,
|
||||
versionMonth: number,
|
||||
) {
|
||||
if (type !== 'auto') {
|
||||
return type;
|
||||
}
|
||||
|
||||
const inPatchMonth =
|
||||
currentDate.getFullYear() === 2000 + versionYear &&
|
||||
currentDate.getMonth() + 1 === versionMonth;
|
||||
if (inPatchMonth && currentDate.getDate() <= 25) return 'hotfix';
|
||||
|
||||
if (inPatchMonth && currentDate.getDate() <= 25) {
|
||||
return 'hotfix';
|
||||
}
|
||||
|
||||
return 'monthly';
|
||||
}
|
||||
|
||||
@@ -37,7 +71,7 @@ export function getNextVersion({
|
||||
currentVersion,
|
||||
type,
|
||||
currentDate = new Date(),
|
||||
}) {
|
||||
}: GetNextVersionOptions) {
|
||||
const { versionYear, versionMonth, versionHotfix } =
|
||||
parseVersion(currentVersion);
|
||||
const { nextVersionYear, nextVersionMonth } = computeNextMonth(
|
||||
@@ -51,11 +85,10 @@ export function getNextVersion({
|
||||
versionMonth,
|
||||
);
|
||||
|
||||
// Format date stamp once for nightly
|
||||
const currentDateString = currentDate
|
||||
.toISOString()
|
||||
.split('T')[0]
|
||||
.replaceAll('-', '');
|
||||
.replace(/-/g, '');
|
||||
|
||||
switch (resolvedType) {
|
||||
case 'nightly':
|
||||
@@ -66,7 +99,7 @@ export function getNextVersion({
|
||||
return `${nextVersionYear}.${nextVersionMonth}.0`;
|
||||
default:
|
||||
throw new Error(
|
||||
'Invalid type specified. Use "auto", "nightly", "hotfix", or "monthly".',
|
||||
`Invalid type ${String(resolvedType satisfies never)} specified. Use "auto", "nightly", "hotfix", or "monthly".`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": [],
|
||||
"module": "nodenext",
|
||||
"moduleResolution": "nodenext",
|
||||
"module": "es2022",
|
||||
"moduleResolution": "bundler",
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"types": ["node"],
|
||||
|
||||
7
packages/cli/.gitignore
vendored
Normal file
7
packages/cli/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
dist
|
||||
coverage
|
||||
.actualrc.json
|
||||
.actualrc
|
||||
.actualrc.yaml
|
||||
.actualrc.yml
|
||||
actual.config.js
|
||||
155
packages/cli/README.md
Normal file
155
packages/cli/README.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# @actual-app/cli
|
||||
|
||||
> **WARNING:** This CLI is experimental.
|
||||
|
||||
Command-line interface for [Actual Budget](https://actualbudget.org). Query and modify your budget data from the terminal — accounts, transactions, categories, payees, rules, schedules, and more.
|
||||
|
||||
> **Note:** This CLI connects to a running [Actual sync server](https://actualbudget.org/docs/install/). It does not operate on local budget files directly.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install -g @actual-app/cli
|
||||
```
|
||||
|
||||
Requires Node.js >= 22.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Set connection details
|
||||
export ACTUAL_SERVER_URL=http://localhost:5006
|
||||
export ACTUAL_PASSWORD=your-password
|
||||
export ACTUAL_SYNC_ID=your-sync-id # Found in Settings → Advanced → Sync ID
|
||||
|
||||
# List your accounts
|
||||
actual accounts list
|
||||
|
||||
# Check a balance
|
||||
actual accounts balance <account-id>
|
||||
|
||||
# View this month's budget
|
||||
actual budgets month 2026-03
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Configuration is resolved in this order (highest priority first):
|
||||
|
||||
1. **CLI flags** (`--server-url`, `--password`, etc.)
|
||||
2. **Environment variables**
|
||||
3. **Config file** (via [cosmiconfig](https://github.com/cosmiconfig/cosmiconfig))
|
||||
4. **Defaults** (`dataDir` defaults to `~/.actual-cli/data`)
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
| ---------------------- | --------------------------------------------- |
|
||||
| `ACTUAL_SERVER_URL` | URL of the Actual sync server (required) |
|
||||
| `ACTUAL_PASSWORD` | Server password (required unless using token) |
|
||||
| `ACTUAL_SESSION_TOKEN` | Session token (alternative to password) |
|
||||
| `ACTUAL_SYNC_ID` | Budget Sync ID (required for most commands) |
|
||||
| `ACTUAL_DATA_DIR` | Local directory for cached budget data |
|
||||
|
||||
### Config File
|
||||
|
||||
Create an `.actualrc.json` (or `.actualrc`, `.actualrc.yaml`, `actual.config.js`):
|
||||
|
||||
```json
|
||||
{
|
||||
"serverUrl": "http://localhost:5006",
|
||||
"password": "your-password",
|
||||
"syncId": "1cfdbb80-6274-49bf-b0c2-737235a4c81f"
|
||||
}
|
||||
```
|
||||
|
||||
**Security:** Do not store plaintext passwords in config files (e.g. `.actualrc.json`, `.actualrc`, `.actualrc.yaml`, `actual.config.js`). Add these files to `.gitignore` if they contain secrets. Prefer the `ACTUAL_SESSION_TOKEN` environment variable instead of the `password` field. See [Environment Variables](#environment-variables) for using a session token.
|
||||
|
||||
### Global Flags
|
||||
|
||||
| Flag | Description |
|
||||
| ------------------------- | ----------------------------------------------- |
|
||||
| `--server-url <url>` | Server URL |
|
||||
| `--password <pw>` | Server password |
|
||||
| `--session-token <token>` | Session token |
|
||||
| `--sync-id <id>` | Budget Sync ID |
|
||||
| `--data-dir <path>` | Data directory |
|
||||
| `--format <format>` | Output format: `json` (default), `table`, `csv` |
|
||||
| `--verbose` | Show informational messages |
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
| ----------------- | ------------------------------ |
|
||||
| `accounts` | Manage accounts |
|
||||
| `budgets` | Manage budgets and allocations |
|
||||
| `categories` | Manage categories |
|
||||
| `category-groups` | Manage category groups |
|
||||
| `transactions` | Manage transactions |
|
||||
| `payees` | Manage payees |
|
||||
| `tags` | Manage tags |
|
||||
| `rules` | Manage transaction rules |
|
||||
| `schedules` | Manage scheduled transactions |
|
||||
| `query` | Run an ActualQL query |
|
||||
| `server` | Server utilities and lookups |
|
||||
|
||||
Run `actual <command> --help` for subcommands and options.
|
||||
|
||||
### Examples
|
||||
|
||||
```bash
|
||||
# List all accounts (as a table)
|
||||
actual accounts list --format table
|
||||
|
||||
# Find an entity ID by name
|
||||
actual server get-id --type accounts --name "Checking"
|
||||
|
||||
# Add a transaction (amount in integer cents: -2500 = -$25.00)
|
||||
actual transactions add --account <id> \
|
||||
--data '[{"date":"2026-03-14","amount":-2500,"payee_name":"Coffee Shop"}]'
|
||||
|
||||
# Export transactions to CSV
|
||||
actual transactions list --account <id> \
|
||||
--start 2026-01-01 --end 2026-12-31 --format csv > transactions.csv
|
||||
|
||||
# Set budget amount ($500 = 50000 cents)
|
||||
actual budgets set-amount --month 2026-03 --category <id> --amount 50000
|
||||
|
||||
# Run an ActualQL query
|
||||
actual query run --table transactions \
|
||||
--select "date,amount,payee" --filter '{"amount":{"$lt":0}}' --limit 10
|
||||
```
|
||||
|
||||
### Amount Convention
|
||||
|
||||
All monetary amounts are **integer cents**:
|
||||
|
||||
| CLI Value | Dollar Amount |
|
||||
| --------- | ------------- |
|
||||
| `5000` | $50.00 |
|
||||
| `-12350` | -$123.50 |
|
||||
|
||||
## Running Locally (Development)
|
||||
|
||||
If you're working on the CLI within the monorepo:
|
||||
|
||||
```bash
|
||||
# 1. Build the CLI
|
||||
yarn build:cli
|
||||
|
||||
# 2. Start a local sync server (in a separate terminal)
|
||||
yarn start:server-dev
|
||||
|
||||
# 3. Open http://localhost:5006 in your browser, create a budget,
|
||||
# then find the Sync ID in Settings → Advanced → Sync ID
|
||||
|
||||
# 4. Run the CLI directly from the build output
|
||||
ACTUAL_SERVER_URL=http://localhost:5006 \
|
||||
ACTUAL_PASSWORD=your-password \
|
||||
ACTUAL_SYNC_ID=your-sync-id \
|
||||
node packages/cli/dist/cli.js accounts list
|
||||
|
||||
# Or use a shorthand alias for convenience
|
||||
alias actual-dev="node $(pwd)/packages/cli/dist/cli.js"
|
||||
actual-dev budgets list
|
||||
```
|
||||
35
packages/cli/package.json
Normal file
35
packages/cli/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@actual-app/cli",
|
||||
"version": "26.3.0",
|
||||
"description": "CLI for Actual Budget",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"actual": "./dist/cli.js",
|
||||
"actual-cli": "./dist/cli.js"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest --run",
|
||||
"typecheck": "tsgo -b"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actual-app/api": "workspace:*",
|
||||
"cli-table3": "^0.6.5",
|
||||
"commander": "^13.0.0",
|
||||
"cosmiconfig": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.19.10",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
|
||||
"rollup-plugin-visualizer": "^6.0.5",
|
||||
"vite": "^8.0.0",
|
||||
"vitest": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
}
|
||||
259
packages/cli/src/commands/accounts.test.ts
Normal file
259
packages/cli/src/commands/accounts.test.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
import * as api from '@actual-app/api';
|
||||
import { Command } from 'commander';
|
||||
|
||||
import { printOutput } from '../output';
|
||||
|
||||
import { registerAccountsCommand } from './accounts';
|
||||
|
||||
vi.mock('@actual-app/api', () => ({
|
||||
getAccounts: vi.fn().mockResolvedValue([]),
|
||||
createAccount: vi.fn().mockResolvedValue('new-id'),
|
||||
updateAccount: vi.fn().mockResolvedValue(undefined),
|
||||
closeAccount: vi.fn().mockResolvedValue(undefined),
|
||||
reopenAccount: vi.fn().mockResolvedValue(undefined),
|
||||
deleteAccount: vi.fn().mockResolvedValue(undefined),
|
||||
getAccountBalance: vi.fn().mockResolvedValue(10000),
|
||||
}));
|
||||
|
||||
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();
|
||||
registerAccountsCommand(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
async function run(args: string[]) {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', ...args]);
|
||||
}
|
||||
|
||||
describe('accounts 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('list', () => {
|
||||
it('calls api.getAccounts and prints result', async () => {
|
||||
const accounts = [{ id: '1', name: 'Checking' }];
|
||||
vi.mocked(api.getAccounts).mockResolvedValue(accounts);
|
||||
|
||||
await run(['accounts', 'list']);
|
||||
|
||||
expect(api.getAccounts).toHaveBeenCalled();
|
||||
expect(printOutput).toHaveBeenCalledWith(accounts, undefined);
|
||||
});
|
||||
|
||||
it('passes format option to printOutput', async () => {
|
||||
vi.mocked(api.getAccounts).mockResolvedValue([]);
|
||||
|
||||
await run(['--format', 'csv', 'accounts', 'list']);
|
||||
|
||||
expect(printOutput).toHaveBeenCalledWith([], 'csv');
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('passes name and defaults to api.createAccount', async () => {
|
||||
await run(['accounts', 'create', '--name', 'Savings']);
|
||||
|
||||
expect(api.createAccount).toHaveBeenCalledWith(
|
||||
{ name: 'Savings', offbudget: false },
|
||||
0,
|
||||
);
|
||||
expect(printOutput).toHaveBeenCalledWith({ id: 'new-id' }, undefined);
|
||||
});
|
||||
|
||||
it('passes offbudget and balance options', async () => {
|
||||
await run([
|
||||
'accounts',
|
||||
'create',
|
||||
'--name',
|
||||
'Investments',
|
||||
'--offbudget',
|
||||
'--balance',
|
||||
'50000',
|
||||
]);
|
||||
|
||||
expect(api.createAccount).toHaveBeenCalledWith(
|
||||
{ name: 'Investments', offbudget: true },
|
||||
50000,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('passes fields to api.updateAccount', async () => {
|
||||
await run(['accounts', 'update', 'acct-1', '--name', 'NewName']);
|
||||
|
||||
expect(api.updateAccount).toHaveBeenCalledWith('acct-1', {
|
||||
name: 'NewName',
|
||||
});
|
||||
expect(printOutput).toHaveBeenCalledWith(
|
||||
{ success: true, id: 'acct-1' },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('passes offbudget true', async () => {
|
||||
await run([
|
||||
'accounts',
|
||||
'update',
|
||||
'acct-1',
|
||||
'--name',
|
||||
'X',
|
||||
'--offbudget',
|
||||
'true',
|
||||
]);
|
||||
|
||||
expect(api.updateAccount).toHaveBeenCalledWith('acct-1', {
|
||||
name: 'X',
|
||||
offbudget: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('passes offbudget false', async () => {
|
||||
await run([
|
||||
'accounts',
|
||||
'update',
|
||||
'acct-1',
|
||||
'--name',
|
||||
'X',
|
||||
'--offbudget',
|
||||
'false',
|
||||
]);
|
||||
|
||||
expect(api.updateAccount).toHaveBeenCalledWith('acct-1', {
|
||||
name: 'X',
|
||||
offbudget: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects invalid offbudget value', async () => {
|
||||
await expect(
|
||||
run(['accounts', 'update', 'acct-1', '--offbudget', 'yes']),
|
||||
).rejects.toThrow(
|
||||
'Invalid --offbudget: "yes". Expected "true" or "false".',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects empty name', async () => {
|
||||
await expect(
|
||||
run(['accounts', 'update', 'acct-1', '--name', ' ']),
|
||||
).rejects.toThrow('Invalid --name: must be a non-empty string.');
|
||||
});
|
||||
|
||||
it('rejects update with no fields', async () => {
|
||||
await expect(run(['accounts', 'update', 'acct-1'])).rejects.toThrow(
|
||||
'No update fields provided. Use --name or --offbudget.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('close', () => {
|
||||
it('passes transfer options to api.closeAccount', async () => {
|
||||
await run([
|
||||
'accounts',
|
||||
'close',
|
||||
'acct-1',
|
||||
'--transfer-account',
|
||||
'acct-2',
|
||||
]);
|
||||
|
||||
expect(api.closeAccount).toHaveBeenCalledWith(
|
||||
'acct-1',
|
||||
'acct-2',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('passes transfer category', async () => {
|
||||
await run([
|
||||
'accounts',
|
||||
'close',
|
||||
'acct-1',
|
||||
'--transfer-category',
|
||||
'cat-1',
|
||||
]);
|
||||
|
||||
expect(api.closeAccount).toHaveBeenCalledWith(
|
||||
'acct-1',
|
||||
undefined,
|
||||
'cat-1',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reopen', () => {
|
||||
it('calls api.reopenAccount', async () => {
|
||||
await run(['accounts', 'reopen', 'acct-1']);
|
||||
|
||||
expect(api.reopenAccount).toHaveBeenCalledWith('acct-1');
|
||||
expect(printOutput).toHaveBeenCalledWith(
|
||||
{ success: true, id: 'acct-1' },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('calls api.deleteAccount', async () => {
|
||||
await run(['accounts', 'delete', 'acct-1']);
|
||||
|
||||
expect(api.deleteAccount).toHaveBeenCalledWith('acct-1');
|
||||
expect(printOutput).toHaveBeenCalledWith(
|
||||
{ success: true, id: 'acct-1' },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('balance', () => {
|
||||
it('calls api.getAccountBalance without cutoff', async () => {
|
||||
await run(['accounts', 'balance', 'acct-1']);
|
||||
|
||||
expect(api.getAccountBalance).toHaveBeenCalledWith('acct-1', undefined);
|
||||
expect(printOutput).toHaveBeenCalledWith(
|
||||
{ id: 'acct-1', balance: 10000 },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('calls api.getAccountBalance with cutoff date', async () => {
|
||||
await run(['accounts', 'balance', 'acct-1', '--cutoff', '2025-01-15']);
|
||||
|
||||
expect(api.getAccountBalance).toHaveBeenCalledWith(
|
||||
'acct-1',
|
||||
new Date('2025-01-15'),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
135
packages/cli/src/commands/accounts.ts
Normal file
135
packages/cli/src/commands/accounts.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import * as api from '@actual-app/api';
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { withConnection } from '../connection';
|
||||
import { printOutput } from '../output';
|
||||
import { parseBoolFlag, parseIntFlag } from '../utils';
|
||||
|
||||
export function registerAccountsCommand(program: Command) {
|
||||
const accounts = program.command('accounts').description('Manage accounts');
|
||||
|
||||
accounts
|
||||
.command('list')
|
||||
.description('List all accounts')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const result = await api.getAccounts();
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
accounts
|
||||
.command('create')
|
||||
.description('Create a new account')
|
||||
.requiredOption('--name <name>', 'Account name')
|
||||
.option('--offbudget', 'Create as off-budget account', false)
|
||||
.option('--balance <amount>', 'Initial balance in cents', '0')
|
||||
.action(async cmdOpts => {
|
||||
const balance = parseIntFlag(cmdOpts.balance, '--balance');
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const id = await api.createAccount(
|
||||
{ name: cmdOpts.name, offbudget: cmdOpts.offbudget },
|
||||
balance,
|
||||
);
|
||||
printOutput({ id }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
accounts
|
||||
.command('update <id>')
|
||||
.description('Update an account')
|
||||
.option('--name <name>', 'New account name')
|
||||
.option('--offbudget <bool>', 'Set off-budget status')
|
||||
.action(async (id: string, cmdOpts) => {
|
||||
const opts = program.opts();
|
||||
const fields: Record<string, unknown> = {};
|
||||
if (cmdOpts.name !== undefined) {
|
||||
const trimmed = cmdOpts.name.trim();
|
||||
if (trimmed === '') {
|
||||
throw new Error('Invalid --name: must be a non-empty string.');
|
||||
}
|
||||
fields.name = trimmed;
|
||||
}
|
||||
if (cmdOpts.offbudget !== undefined) {
|
||||
fields.offbudget = parseBoolFlag(cmdOpts.offbudget, '--offbudget');
|
||||
}
|
||||
if (Object.keys(fields).length === 0) {
|
||||
throw new Error(
|
||||
'No update fields provided. Use --name or --offbudget.',
|
||||
);
|
||||
}
|
||||
await withConnection(opts, async () => {
|
||||
await api.updateAccount(id, fields);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
accounts
|
||||
.command('close <id>')
|
||||
.description('Close an account')
|
||||
.option(
|
||||
'--transfer-account <id>',
|
||||
'Transfer remaining balance to this account',
|
||||
)
|
||||
.option(
|
||||
'--transfer-category <id>',
|
||||
'Transfer remaining balance to this category',
|
||||
)
|
||||
.action(async (id: string, cmdOpts) => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.closeAccount(
|
||||
id,
|
||||
cmdOpts.transferAccount,
|
||||
cmdOpts.transferCategory,
|
||||
);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
accounts
|
||||
.command('reopen <id>')
|
||||
.description('Reopen a closed account')
|
||||
.action(async (id: string) => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.reopenAccount(id);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
accounts
|
||||
.command('delete <id>')
|
||||
.description('Delete an account')
|
||||
.action(async (id: string) => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.deleteAccount(id);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
accounts
|
||||
.command('balance <id>')
|
||||
.description('Get account balance')
|
||||
.option('--cutoff <date>', 'Cutoff date (YYYY-MM-DD)')
|
||||
.action(async (id: string, cmdOpts) => {
|
||||
let cutoff: Date | undefined;
|
||||
if (cmdOpts.cutoff) {
|
||||
const cutoffDate = new Date(cmdOpts.cutoff);
|
||||
if (Number.isNaN(cutoffDate.getTime())) {
|
||||
throw new Error(
|
||||
'Invalid cutoff date: expected a valid date (e.g. YYYY-MM-DD).',
|
||||
);
|
||||
}
|
||||
cutoff = cutoffDate;
|
||||
}
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const balance = await api.getAccountBalance(id, cutoff);
|
||||
printOutput({ id, balance }, opts.format);
|
||||
});
|
||||
});
|
||||
}
|
||||
135
packages/cli/src/commands/budgets.ts
Normal file
135
packages/cli/src/commands/budgets.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import * as api from '@actual-app/api';
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { resolveConfig } from '../config';
|
||||
import { withConnection } from '../connection';
|
||||
import { printOutput } from '../output';
|
||||
import { parseBoolFlag, parseIntFlag } from '../utils';
|
||||
|
||||
export function registerBudgetsCommand(program: Command) {
|
||||
const budgets = program.command('budgets').description('Manage budgets');
|
||||
|
||||
budgets
|
||||
.command('list')
|
||||
.description('List all available budgets')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const result = await api.getBudgets();
|
||||
printOutput(result, opts.format);
|
||||
},
|
||||
{ loadBudget: false },
|
||||
);
|
||||
});
|
||||
|
||||
budgets
|
||||
.command('download <syncId>')
|
||||
.description('Download a budget by sync ID')
|
||||
.option('--encryption-password <password>', 'Encryption password')
|
||||
.action(async (syncId: string, cmdOpts) => {
|
||||
const opts = program.opts();
|
||||
const config = await resolveConfig(opts);
|
||||
const password = config.encryptionPassword ?? cmdOpts.encryptionPassword;
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
await api.downloadBudget(syncId, {
|
||||
password,
|
||||
});
|
||||
printOutput({ success: true, syncId }, opts.format);
|
||||
},
|
||||
{ loadBudget: false },
|
||||
);
|
||||
});
|
||||
|
||||
budgets
|
||||
.command('sync')
|
||||
.description('Sync the current budget')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.sync();
|
||||
printOutput({ success: true }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
budgets
|
||||
.command('months')
|
||||
.description('List available budget months')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const result = await api.getBudgetMonths();
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
budgets
|
||||
.command('month <month>')
|
||||
.description('Get budget data for a specific month (YYYY-MM)')
|
||||
.action(async (month: string) => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const result = await api.getBudgetMonth(month);
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
budgets
|
||||
.command('set-amount')
|
||||
.description('Set budget amount for a category in a month')
|
||||
.requiredOption('--month <month>', 'Budget month (YYYY-MM)')
|
||||
.requiredOption('--category <id>', 'Category ID')
|
||||
.requiredOption('--amount <amount>', 'Amount in cents')
|
||||
.action(async cmdOpts => {
|
||||
const amount = parseIntFlag(cmdOpts.amount, '--amount');
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.setBudgetAmount(cmdOpts.month, cmdOpts.category, amount);
|
||||
printOutput({ success: true }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
budgets
|
||||
.command('set-carryover')
|
||||
.description('Enable/disable carryover for a category')
|
||||
.requiredOption('--month <month>', 'Budget month (YYYY-MM)')
|
||||
.requiredOption('--category <id>', 'Category ID')
|
||||
.requiredOption('--flag <bool>', 'Enable (true) or disable (false)')
|
||||
.action(async cmdOpts => {
|
||||
const flag = parseBoolFlag(cmdOpts.flag, '--flag');
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.setBudgetCarryover(cmdOpts.month, cmdOpts.category, flag);
|
||||
printOutput({ success: true }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
budgets
|
||||
.command('hold-next-month')
|
||||
.description('Hold budget amount for next month')
|
||||
.requiredOption('--month <month>', 'Budget month (YYYY-MM)')
|
||||
.requiredOption('--amount <amount>', 'Amount in cents')
|
||||
.action(async cmdOpts => {
|
||||
const parsedAmount = parseIntFlag(cmdOpts.amount, '--amount');
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.holdBudgetForNextMonth(cmdOpts.month, parsedAmount);
|
||||
printOutput({ success: true }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
budgets
|
||||
.command('reset-hold')
|
||||
.description('Reset budget hold for a month')
|
||||
.requiredOption('--month <month>', 'Budget month (YYYY-MM)')
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.resetBudgetHold(cmdOpts.month);
|
||||
printOutput({ success: true }, opts.format);
|
||||
});
|
||||
});
|
||||
}
|
||||
75
packages/cli/src/commands/categories.ts
Normal file
75
packages/cli/src/commands/categories.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import * as api from '@actual-app/api';
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { withConnection } from '../connection';
|
||||
import { printOutput } from '../output';
|
||||
import { parseBoolFlag } from '../utils';
|
||||
|
||||
export function registerCategoriesCommand(program: Command) {
|
||||
const categories = program
|
||||
.command('categories')
|
||||
.description('Manage categories');
|
||||
|
||||
categories
|
||||
.command('list')
|
||||
.description('List all categories')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const result = await api.getCategories();
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
categories
|
||||
.command('create')
|
||||
.description('Create a new category')
|
||||
.requiredOption('--name <name>', 'Category name')
|
||||
.requiredOption('--group-id <id>', 'Category group ID')
|
||||
.option('--is-income', 'Mark as income category', false)
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const id = await api.createCategory({
|
||||
name: cmdOpts.name,
|
||||
group_id: cmdOpts.groupId,
|
||||
is_income: cmdOpts.isIncome,
|
||||
hidden: false,
|
||||
});
|
||||
printOutput({ id }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
categories
|
||||
.command('update <id>')
|
||||
.description('Update a category')
|
||||
.option('--name <name>', 'New category name')
|
||||
.option('--hidden <bool>', 'Set hidden status')
|
||||
.action(async (id: string, cmdOpts) => {
|
||||
const fields: Record<string, unknown> = {};
|
||||
if (cmdOpts.name !== undefined) fields.name = cmdOpts.name;
|
||||
if (cmdOpts.hidden !== undefined) {
|
||||
fields.hidden = parseBoolFlag(cmdOpts.hidden, '--hidden');
|
||||
}
|
||||
if (Object.keys(fields).length === 0) {
|
||||
throw new Error('No update fields provided. Use --name or --hidden.');
|
||||
}
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.updateCategory(id, fields);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
categories
|
||||
.command('delete <id>')
|
||||
.description('Delete a category')
|
||||
.option('--transfer-to <id>', 'Transfer transactions to this category')
|
||||
.action(async (id: string, cmdOpts) => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.deleteCategory(id, cmdOpts.transferTo);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
});
|
||||
}
|
||||
73
packages/cli/src/commands/category-groups.ts
Normal file
73
packages/cli/src/commands/category-groups.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import * as api from '@actual-app/api';
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { withConnection } from '../connection';
|
||||
import { printOutput } from '../output';
|
||||
import { parseBoolFlag } from '../utils';
|
||||
|
||||
export function registerCategoryGroupsCommand(program: Command) {
|
||||
const groups = program
|
||||
.command('category-groups')
|
||||
.description('Manage category groups');
|
||||
|
||||
groups
|
||||
.command('list')
|
||||
.description('List all category groups')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const result = await api.getCategoryGroups();
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
groups
|
||||
.command('create')
|
||||
.description('Create a new category group')
|
||||
.requiredOption('--name <name>', 'Group name')
|
||||
.option('--is-income', 'Mark as income group', false)
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const id = await api.createCategoryGroup({
|
||||
name: cmdOpts.name,
|
||||
is_income: cmdOpts.isIncome,
|
||||
hidden: false,
|
||||
});
|
||||
printOutput({ id }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
groups
|
||||
.command('update <id>')
|
||||
.description('Update a category group')
|
||||
.option('--name <name>', 'New group name')
|
||||
.option('--hidden <bool>', 'Set hidden status')
|
||||
.action(async (id: string, cmdOpts) => {
|
||||
const fields: Record<string, unknown> = {};
|
||||
if (cmdOpts.name !== undefined) fields.name = cmdOpts.name;
|
||||
if (cmdOpts.hidden !== undefined) {
|
||||
fields.hidden = parseBoolFlag(cmdOpts.hidden, '--hidden');
|
||||
}
|
||||
if (Object.keys(fields).length === 0) {
|
||||
throw new Error('No update fields provided. Use --name or --hidden.');
|
||||
}
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.updateCategoryGroup(id, fields);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
groups
|
||||
.command('delete <id>')
|
||||
.description('Delete a category group')
|
||||
.option('--transfer-to <id>', 'Transfer transactions to this category ID')
|
||||
.action(async (id: string, cmdOpts) => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.deleteCategoryGroup(id, cmdOpts.transferTo);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
});
|
||||
}
|
||||
95
packages/cli/src/commands/payees.ts
Normal file
95
packages/cli/src/commands/payees.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import * as api from '@actual-app/api';
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { withConnection } from '../connection';
|
||||
import { printOutput } from '../output';
|
||||
|
||||
export function registerPayeesCommand(program: Command) {
|
||||
const payees = program.command('payees').description('Manage payees');
|
||||
|
||||
payees
|
||||
.command('list')
|
||||
.description('List all payees')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const result = await api.getPayees();
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
payees
|
||||
.command('common')
|
||||
.description('List frequently used payees')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const result = await api.getCommonPayees();
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
payees
|
||||
.command('create')
|
||||
.description('Create a new payee')
|
||||
.requiredOption('--name <name>', 'Payee name')
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const id = await api.createPayee({ name: cmdOpts.name });
|
||||
printOutput({ id }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
payees
|
||||
.command('update <id>')
|
||||
.description('Update a payee')
|
||||
.option('--name <name>', 'New payee name')
|
||||
.action(async (id: string, cmdOpts) => {
|
||||
const fields: Record<string, unknown> = {};
|
||||
if (cmdOpts.name) fields.name = cmdOpts.name;
|
||||
if (Object.keys(fields).length === 0) {
|
||||
throw new Error(
|
||||
'No fields to update. Use --name to specify a new name.',
|
||||
);
|
||||
}
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.updatePayee(id, fields);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
payees
|
||||
.command('delete <id>')
|
||||
.description('Delete a payee')
|
||||
.action(async (id: string) => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.deletePayee(id);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
payees
|
||||
.command('merge')
|
||||
.description('Merge payees into a target payee')
|
||||
.requiredOption('--target <id>', 'Target payee ID')
|
||||
.requiredOption('--ids <ids>', 'Comma-separated payee IDs to merge')
|
||||
.action(async (cmdOpts: { target: string; ids: string }) => {
|
||||
const mergeIds = cmdOpts.ids
|
||||
.split(',')
|
||||
.map(id => id.trim())
|
||||
.filter(id => id.length > 0);
|
||||
if (mergeIds.length === 0) {
|
||||
throw new Error(
|
||||
'No valid payee IDs provided in --ids. Provide comma-separated IDs.',
|
||||
);
|
||||
}
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.mergePayees(cmdOpts.target, mergeIds);
|
||||
printOutput({ success: true }, opts.format);
|
||||
});
|
||||
});
|
||||
}
|
||||
93
packages/cli/src/commands/query.ts
Normal file
93
packages/cli/src/commands/query.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import * as api from '@actual-app/api';
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { withConnection } from '../connection';
|
||||
import { readJsonInput } from '../input';
|
||||
import { printOutput } from '../output';
|
||||
import { parseIntFlag } from '../utils';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function buildQueryFromFile(
|
||||
parsed: Record<string, unknown>,
|
||||
fallbackTable: string | undefined,
|
||||
) {
|
||||
const table = typeof parsed.table === 'string' ? parsed.table : fallbackTable;
|
||||
if (!table) {
|
||||
throw new Error(
|
||||
'--table is required when the input file lacks a "table" field',
|
||||
);
|
||||
}
|
||||
let queryObj = api.q(table);
|
||||
if (Array.isArray(parsed.select)) queryObj = queryObj.select(parsed.select);
|
||||
if (isRecord(parsed.filter)) queryObj = queryObj.filter(parsed.filter);
|
||||
if (Array.isArray(parsed.orderBy)) {
|
||||
queryObj = queryObj.orderBy(parsed.orderBy);
|
||||
}
|
||||
if (typeof parsed.limit === 'number') queryObj = queryObj.limit(parsed.limit);
|
||||
return queryObj;
|
||||
}
|
||||
|
||||
function buildQueryFromFlags(cmdOpts: Record<string, string | undefined>) {
|
||||
if (!cmdOpts.table) {
|
||||
throw new Error('--table is required (or use --file)');
|
||||
}
|
||||
let queryObj = api.q(cmdOpts.table);
|
||||
|
||||
if (cmdOpts.select) {
|
||||
queryObj = queryObj.select(cmdOpts.select.split(','));
|
||||
}
|
||||
|
||||
if (cmdOpts.filter) {
|
||||
queryObj = queryObj.filter(JSON.parse(cmdOpts.filter));
|
||||
}
|
||||
|
||||
if (cmdOpts.orderBy) {
|
||||
queryObj = queryObj.orderBy(cmdOpts.orderBy.split(','));
|
||||
}
|
||||
|
||||
if (cmdOpts.limit) {
|
||||
queryObj = queryObj.limit(parseIntFlag(cmdOpts.limit, '--limit'));
|
||||
}
|
||||
|
||||
return queryObj;
|
||||
}
|
||||
|
||||
export function registerQueryCommand(program: Command) {
|
||||
const query = program
|
||||
.command('query')
|
||||
.description('Run AQL (Actual Query Language) queries');
|
||||
|
||||
query
|
||||
.command('run')
|
||||
.description('Execute an AQL query')
|
||||
.option(
|
||||
'--table <table>',
|
||||
'Table to query (transactions, accounts, categories, payees)',
|
||||
)
|
||||
.option('--select <fields>', 'Comma-separated fields to select')
|
||||
.option('--filter <json>', 'Filter expression as JSON')
|
||||
.option('--order-by <fields>', 'Comma-separated fields to order by')
|
||||
.option('--limit <n>', 'Limit number of results')
|
||||
.option(
|
||||
'--file <path>',
|
||||
'Read full query object from JSON file (use - for stdin)',
|
||||
)
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const parsed = cmdOpts.file ? readJsonInput(cmdOpts) : undefined;
|
||||
if (parsed !== undefined && !isRecord(parsed)) {
|
||||
throw new Error('Query file must contain a JSON object');
|
||||
}
|
||||
const queryObj = parsed
|
||||
? buildQueryFromFile(parsed, cmdOpts.table)
|
||||
: buildQueryFromFlags(cmdOpts);
|
||||
|
||||
const result = await api.aqlQuery(queryObj);
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
});
|
||||
}
|
||||
77
packages/cli/src/commands/rules.ts
Normal file
77
packages/cli/src/commands/rules.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import * as api from '@actual-app/api';
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { withConnection } from '../connection';
|
||||
import { readJsonInput } from '../input';
|
||||
import { printOutput } from '../output';
|
||||
|
||||
export function registerRulesCommand(program: Command) {
|
||||
const rules = program
|
||||
.command('rules')
|
||||
.description('Manage transaction rules');
|
||||
|
||||
rules
|
||||
.command('list')
|
||||
.description('List all rules')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const result = await api.getRules();
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
rules
|
||||
.command('payee-rules <payeeId>')
|
||||
.description('List rules for a specific payee')
|
||||
.action(async (payeeId: string) => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const result = await api.getPayeeRules(payeeId);
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
rules
|
||||
.command('create')
|
||||
.description('Create a new rule')
|
||||
.option('--data <json>', 'Rule definition as JSON')
|
||||
.option('--file <path>', 'Read rule from JSON file (use - for stdin)')
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const rule = readJsonInput(cmdOpts) as Parameters<
|
||||
typeof api.createRule
|
||||
>[0];
|
||||
const id = await api.createRule(rule);
|
||||
printOutput({ id }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
rules
|
||||
.command('update')
|
||||
.description('Update a rule')
|
||||
.option('--data <json>', 'Rule data as JSON (must include id)')
|
||||
.option('--file <path>', 'Read rule from JSON file (use - for stdin)')
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const rule = readJsonInput(cmdOpts) as Parameters<
|
||||
typeof api.updateRule
|
||||
>[0];
|
||||
await api.updateRule(rule);
|
||||
printOutput({ success: true }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
rules
|
||||
.command('delete <id>')
|
||||
.description('Delete a rule')
|
||||
.action(async (id: string) => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.deleteRule(id);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
});
|
||||
}
|
||||
67
packages/cli/src/commands/schedules.ts
Normal file
67
packages/cli/src/commands/schedules.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import * as api from '@actual-app/api';
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { withConnection } from '../connection';
|
||||
import { readJsonInput } from '../input';
|
||||
import { printOutput } from '../output';
|
||||
|
||||
export function registerSchedulesCommand(program: Command) {
|
||||
const schedules = program
|
||||
.command('schedules')
|
||||
.description('Manage scheduled transactions');
|
||||
|
||||
schedules
|
||||
.command('list')
|
||||
.description('List all schedules')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const result = await api.getSchedules();
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
schedules
|
||||
.command('create')
|
||||
.description('Create a new schedule')
|
||||
.option('--data <json>', 'Schedule definition as JSON')
|
||||
.option('--file <path>', 'Read schedule from JSON file (use - for stdin)')
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const schedule = readJsonInput(cmdOpts) as Parameters<
|
||||
typeof api.createSchedule
|
||||
>[0];
|
||||
const id = await api.createSchedule(schedule);
|
||||
printOutput({ id }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
schedules
|
||||
.command('update <id>')
|
||||
.description('Update a schedule')
|
||||
.option('--data <json>', 'Fields to update as JSON')
|
||||
.option('--file <path>', 'Read fields from JSON file (use - for stdin)')
|
||||
.option('--reset-next-date', 'Reset next occurrence date', false)
|
||||
.action(async (id: string, cmdOpts) => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const fields = readJsonInput(cmdOpts) as Parameters<
|
||||
typeof api.updateSchedule
|
||||
>[1];
|
||||
await api.updateSchedule(id, fields, cmdOpts.resetNextDate);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
schedules
|
||||
.command('delete <id>')
|
||||
.description('Delete a schedule')
|
||||
.action(async (id: string) => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.deleteSchedule(id);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
});
|
||||
}
|
||||
60
packages/cli/src/commands/server.ts
Normal file
60
packages/cli/src/commands/server.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as api from '@actual-app/api';
|
||||
import { Option } from 'commander';
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { withConnection } from '../connection';
|
||||
import { printOutput } from '../output';
|
||||
|
||||
export function registerServerCommand(program: Command) {
|
||||
const server = program.command('server').description('Server utilities');
|
||||
|
||||
server
|
||||
.command('version')
|
||||
.description('Get server version')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const version = await api.getServerVersion();
|
||||
printOutput({ version }, opts.format);
|
||||
},
|
||||
{ loadBudget: false },
|
||||
);
|
||||
});
|
||||
|
||||
server
|
||||
.command('get-id')
|
||||
.description('Get entity ID by name')
|
||||
.addOption(
|
||||
new Option('--type <type>', 'Entity type')
|
||||
.choices(['accounts', 'categories', 'payees', 'schedules'])
|
||||
.makeOptionMandatory(),
|
||||
)
|
||||
.requiredOption('--name <name>', 'Entity name')
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const id = await api.getIDByName(cmdOpts.type, cmdOpts.name);
|
||||
printOutput(
|
||||
{ id, type: cmdOpts.type, name: cmdOpts.name },
|
||||
opts.format,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
server
|
||||
.command('bank-sync')
|
||||
.description('Run bank synchronization')
|
||||
.option('--account <id>', 'Specific account ID to sync')
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const args = cmdOpts.account
|
||||
? { accountId: cmdOpts.account }
|
||||
: undefined;
|
||||
await api.runBankSync(args);
|
||||
printOutput({ success: true }, opts.format);
|
||||
});
|
||||
});
|
||||
}
|
||||
74
packages/cli/src/commands/tags.ts
Normal file
74
packages/cli/src/commands/tags.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import * as api from '@actual-app/api';
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { withConnection } from '../connection';
|
||||
import { printOutput } from '../output';
|
||||
|
||||
export function registerTagsCommand(program: Command) {
|
||||
const tags = program.command('tags').description('Manage tags');
|
||||
|
||||
tags
|
||||
.command('list')
|
||||
.description('List all tags')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const result = await api.getTags();
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
tags
|
||||
.command('create')
|
||||
.description('Create a new tag')
|
||||
.requiredOption('--tag <tag>', 'Tag name')
|
||||
.option('--color <color>', 'Tag color')
|
||||
.option('--description <description>', 'Tag description')
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const id = await api.createTag({
|
||||
tag: cmdOpts.tag,
|
||||
color: cmdOpts.color,
|
||||
description: cmdOpts.description,
|
||||
});
|
||||
printOutput({ id }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
tags
|
||||
.command('update <id>')
|
||||
.description('Update a tag')
|
||||
.option('--tag <tag>', 'New tag name')
|
||||
.option('--color <color>', 'New tag color')
|
||||
.option('--description <description>', 'New tag description')
|
||||
.action(async (id: string, cmdOpts) => {
|
||||
const fields: Record<string, unknown> = {};
|
||||
if (cmdOpts.tag !== undefined) fields.tag = cmdOpts.tag;
|
||||
if (cmdOpts.color !== undefined) fields.color = cmdOpts.color;
|
||||
if (cmdOpts.description !== undefined) {
|
||||
fields.description = cmdOpts.description;
|
||||
}
|
||||
if (Object.keys(fields).length === 0) {
|
||||
throw new Error(
|
||||
'At least one of --tag, --color, or --description is required',
|
||||
);
|
||||
}
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.updateTag(id, fields);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
tags
|
||||
.command('delete <id>')
|
||||
.description('Delete a tag')
|
||||
.action(async (id: string) => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.deleteTag(id);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
});
|
||||
}
|
||||
114
packages/cli/src/commands/transactions.ts
Normal file
114
packages/cli/src/commands/transactions.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import * as api from '@actual-app/api';
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { withConnection } from '../connection';
|
||||
import { readJsonInput } from '../input';
|
||||
import { printOutput } from '../output';
|
||||
|
||||
export function registerTransactionsCommand(program: Command) {
|
||||
const transactions = program
|
||||
.command('transactions')
|
||||
.description('Manage transactions');
|
||||
|
||||
transactions
|
||||
.command('list')
|
||||
.description('List transactions for an account')
|
||||
.requiredOption('--account <id>', 'Account ID')
|
||||
.requiredOption('--start <date>', 'Start date (YYYY-MM-DD)')
|
||||
.requiredOption('--end <date>', 'End date (YYYY-MM-DD)')
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const result = await api.getTransactions(
|
||||
cmdOpts.account,
|
||||
cmdOpts.start,
|
||||
cmdOpts.end,
|
||||
);
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
transactions
|
||||
.command('add')
|
||||
.description('Add transactions to an account')
|
||||
.requiredOption('--account <id>', 'Account ID')
|
||||
.option('--data <json>', 'Transaction data as JSON array')
|
||||
.option(
|
||||
'--file <path>',
|
||||
'Read transaction data from JSON file (use - for stdin)',
|
||||
)
|
||||
.option('--learn-categories', 'Learn category assignments', false)
|
||||
.option('--run-transfers', 'Process transfers', false)
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const transactions = readJsonInput(cmdOpts) as Parameters<
|
||||
typeof api.addTransactions
|
||||
>[1];
|
||||
const result = await api.addTransactions(
|
||||
cmdOpts.account,
|
||||
transactions,
|
||||
{
|
||||
learnCategories: cmdOpts.learnCategories,
|
||||
runTransfers: cmdOpts.runTransfers,
|
||||
},
|
||||
);
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
transactions
|
||||
.command('import')
|
||||
.description('Import transactions to an account')
|
||||
.requiredOption('--account <id>', 'Account ID')
|
||||
.option('--data <json>', 'Transaction data as JSON array')
|
||||
.option(
|
||||
'--file <path>',
|
||||
'Read transaction data from JSON file (use - for stdin)',
|
||||
)
|
||||
.option('--dry-run', 'Preview without importing', false)
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const transactions = readJsonInput(cmdOpts) as Parameters<
|
||||
typeof api.importTransactions
|
||||
>[1];
|
||||
const result = await api.importTransactions(
|
||||
cmdOpts.account,
|
||||
transactions,
|
||||
{
|
||||
defaultCleared: true,
|
||||
dryRun: cmdOpts.dryRun,
|
||||
},
|
||||
);
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
transactions
|
||||
.command('update <id>')
|
||||
.description('Update a transaction')
|
||||
.option('--data <json>', 'Fields to update as JSON')
|
||||
.option('--file <path>', 'Read fields from JSON file (use - for stdin)')
|
||||
.action(async (id: string, cmdOpts) => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const fields = readJsonInput(cmdOpts) as Parameters<
|
||||
typeof api.updateTransaction
|
||||
>[1];
|
||||
await api.updateTransaction(id, fields);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
});
|
||||
|
||||
transactions
|
||||
.command('delete <id>')
|
||||
.description('Delete a transaction')
|
||||
.action(async (id: string) => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.deleteTransaction(id);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
});
|
||||
}
|
||||
185
packages/cli/src/config.test.ts
Normal file
185
packages/cli/src/config.test.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { homedir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
import { resolveConfig } from './config';
|
||||
|
||||
const mockSearch = vi.fn().mockResolvedValue(null);
|
||||
|
||||
vi.mock('cosmiconfig', () => ({
|
||||
cosmiconfig: () => ({
|
||||
search: (...args: unknown[]) => mockSearch(...args),
|
||||
}),
|
||||
}));
|
||||
|
||||
function mockConfigFile(config: Record<string, unknown> | null) {
|
||||
if (config) {
|
||||
mockSearch.mockResolvedValue({ config, isEmpty: false });
|
||||
} else {
|
||||
mockSearch.mockResolvedValue(null);
|
||||
}
|
||||
}
|
||||
|
||||
describe('resolveConfig', () => {
|
||||
const savedEnv: Record<string, string | undefined> = {};
|
||||
const envKeys = [
|
||||
'ACTUAL_SERVER_URL',
|
||||
'ACTUAL_PASSWORD',
|
||||
'ACTUAL_SESSION_TOKEN',
|
||||
'ACTUAL_SYNC_ID',
|
||||
'ACTUAL_DATA_DIR',
|
||||
'ACTUAL_ENCRYPTION_PASSWORD',
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
for (const key of envKeys) {
|
||||
savedEnv[key] = process.env[key];
|
||||
delete process.env[key];
|
||||
}
|
||||
mockConfigFile(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of envKeys) {
|
||||
if (savedEnv[key] !== undefined) {
|
||||
process.env[key] = savedEnv[key];
|
||||
} else {
|
||||
delete process.env[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe('priority chain', () => {
|
||||
it('CLI opts take highest priority', async () => {
|
||||
process.env.ACTUAL_SERVER_URL = 'http://env';
|
||||
process.env.ACTUAL_PASSWORD = 'envpw';
|
||||
process.env.ACTUAL_ENCRYPTION_PASSWORD = 'env-enc';
|
||||
mockConfigFile({
|
||||
serverUrl: 'http://file',
|
||||
password: 'filepw',
|
||||
encryptionPassword: 'file-enc',
|
||||
});
|
||||
|
||||
const config = await resolveConfig({
|
||||
serverUrl: 'http://cli',
|
||||
password: 'clipw',
|
||||
encryptionPassword: 'cli-enc',
|
||||
});
|
||||
|
||||
expect(config.serverUrl).toBe('http://cli');
|
||||
expect(config.password).toBe('clipw');
|
||||
expect(config.encryptionPassword).toBe('cli-enc');
|
||||
});
|
||||
|
||||
it('env vars override file config', async () => {
|
||||
process.env.ACTUAL_SERVER_URL = 'http://env';
|
||||
process.env.ACTUAL_PASSWORD = 'envpw';
|
||||
process.env.ACTUAL_ENCRYPTION_PASSWORD = 'env-enc';
|
||||
mockConfigFile({
|
||||
serverUrl: 'http://file',
|
||||
password: 'filepw',
|
||||
encryptionPassword: 'file-enc',
|
||||
});
|
||||
|
||||
const config = await resolveConfig({});
|
||||
|
||||
expect(config.serverUrl).toBe('http://env');
|
||||
expect(config.password).toBe('envpw');
|
||||
expect(config.encryptionPassword).toBe('env-enc');
|
||||
});
|
||||
|
||||
it('file config is used when no CLI opts or env vars', async () => {
|
||||
mockConfigFile({
|
||||
serverUrl: 'http://file',
|
||||
password: 'filepw',
|
||||
syncId: 'budget-1',
|
||||
encryptionPassword: 'file-enc',
|
||||
});
|
||||
|
||||
const config = await resolveConfig({});
|
||||
|
||||
expect(config.serverUrl).toBe('http://file');
|
||||
expect(config.password).toBe('filepw');
|
||||
expect(config.syncId).toBe('budget-1');
|
||||
expect(config.encryptionPassword).toBe('file-enc');
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaults', () => {
|
||||
it('dataDir defaults to ~/.actual-cli/data', async () => {
|
||||
const config = await resolveConfig({
|
||||
serverUrl: 'http://test',
|
||||
password: 'pw',
|
||||
});
|
||||
|
||||
expect(config.dataDir).toBe(join(homedir(), '.actual-cli', 'data'));
|
||||
});
|
||||
|
||||
it('CLI opt overrides default dataDir', async () => {
|
||||
const config = await resolveConfig({
|
||||
serverUrl: 'http://test',
|
||||
password: 'pw',
|
||||
dataDir: '/custom/dir',
|
||||
});
|
||||
|
||||
expect(config.dataDir).toBe('/custom/dir');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validation', () => {
|
||||
it('throws when serverUrl is missing', async () => {
|
||||
await expect(resolveConfig({ password: 'pw' })).rejects.toThrow(
|
||||
'Server URL is required',
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when neither password nor sessionToken provided', async () => {
|
||||
await expect(resolveConfig({ serverUrl: 'http://test' })).rejects.toThrow(
|
||||
'Authentication required',
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts sessionToken without password', async () => {
|
||||
const config = await resolveConfig({
|
||||
serverUrl: 'http://test',
|
||||
sessionToken: 'tok',
|
||||
});
|
||||
|
||||
expect(config.sessionToken).toBe('tok');
|
||||
expect(config.password).toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts password without sessionToken', async () => {
|
||||
const config = await resolveConfig({
|
||||
serverUrl: 'http://test',
|
||||
password: 'pw',
|
||||
});
|
||||
|
||||
expect(config.password).toBe('pw');
|
||||
expect(config.sessionToken).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cosmiconfig handling', () => {
|
||||
it('handles null result (no config file found)', async () => {
|
||||
mockConfigFile(null);
|
||||
|
||||
const config = await resolveConfig({
|
||||
serverUrl: 'http://test',
|
||||
password: 'pw',
|
||||
});
|
||||
|
||||
expect(config.serverUrl).toBe('http://test');
|
||||
});
|
||||
|
||||
it('handles isEmpty result', async () => {
|
||||
mockSearch.mockResolvedValue({ config: {}, isEmpty: true });
|
||||
|
||||
const config = await resolveConfig({
|
||||
serverUrl: 'http://test',
|
||||
password: 'pw',
|
||||
});
|
||||
|
||||
expect(config.serverUrl).toBe('http://test');
|
||||
});
|
||||
});
|
||||
});
|
||||
141
packages/cli/src/config.ts
Normal file
141
packages/cli/src/config.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { homedir } from 'os';
|
||||
import { join } from 'path';
|
||||
|
||||
import { cosmiconfig } from 'cosmiconfig';
|
||||
|
||||
export type CliConfig = {
|
||||
serverUrl: string;
|
||||
password?: string;
|
||||
sessionToken?: string;
|
||||
syncId?: string;
|
||||
dataDir: string;
|
||||
encryptionPassword?: string;
|
||||
};
|
||||
|
||||
export type CliGlobalOpts = {
|
||||
serverUrl?: string;
|
||||
password?: string;
|
||||
sessionToken?: string;
|
||||
syncId?: string;
|
||||
dataDir?: string;
|
||||
encryptionPassword?: string;
|
||||
format?: 'json' | 'table' | 'csv';
|
||||
verbose?: boolean;
|
||||
};
|
||||
|
||||
type ConfigFileContent = {
|
||||
serverUrl?: string;
|
||||
password?: string;
|
||||
sessionToken?: string;
|
||||
syncId?: string;
|
||||
dataDir?: string;
|
||||
encryptionPassword?: string;
|
||||
};
|
||||
|
||||
const configFileKeys: readonly string[] = [
|
||||
'serverUrl',
|
||||
'password',
|
||||
'sessionToken',
|
||||
'syncId',
|
||||
'dataDir',
|
||||
'encryptionPassword',
|
||||
];
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function validateConfigFileContent(value: unknown): ConfigFileContent {
|
||||
if (!isRecord(value)) {
|
||||
throw new Error(
|
||||
'Invalid config file: expected an object with keys: ' +
|
||||
configFileKeys.join(', '),
|
||||
);
|
||||
}
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!configFileKeys.includes(key)) {
|
||||
throw new Error(`Invalid config file: unknown key "${key}"`);
|
||||
}
|
||||
if (value[key] !== undefined && typeof value[key] !== 'string') {
|
||||
throw new Error(
|
||||
`Invalid config file: key "${key}" must be a string, got ${typeof value[key]}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return value as ConfigFileContent;
|
||||
}
|
||||
|
||||
async function loadConfigFile(): Promise<ConfigFileContent> {
|
||||
const explorer = cosmiconfig('actual', {
|
||||
searchPlaces: [
|
||||
'package.json',
|
||||
'.actualrc',
|
||||
'.actualrc.json',
|
||||
'.actualrc.yaml',
|
||||
'.actualrc.yml',
|
||||
'actual.config.json',
|
||||
'actual.config.yaml',
|
||||
'actual.config.yml',
|
||||
],
|
||||
});
|
||||
const result = await explorer.search();
|
||||
if (result && !result.isEmpty) {
|
||||
return validateConfigFileContent(result.config);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
export async function resolveConfig(
|
||||
cliOpts: CliGlobalOpts,
|
||||
): Promise<CliConfig> {
|
||||
const fileConfig = await loadConfigFile();
|
||||
|
||||
const serverUrl =
|
||||
cliOpts.serverUrl ??
|
||||
process.env.ACTUAL_SERVER_URL ??
|
||||
fileConfig.serverUrl ??
|
||||
'';
|
||||
|
||||
const password =
|
||||
cliOpts.password ?? process.env.ACTUAL_PASSWORD ?? fileConfig.password;
|
||||
|
||||
const sessionToken =
|
||||
cliOpts.sessionToken ??
|
||||
process.env.ACTUAL_SESSION_TOKEN ??
|
||||
fileConfig.sessionToken;
|
||||
|
||||
const syncId =
|
||||
cliOpts.syncId ?? process.env.ACTUAL_SYNC_ID ?? fileConfig.syncId;
|
||||
|
||||
const dataDir =
|
||||
cliOpts.dataDir ??
|
||||
process.env.ACTUAL_DATA_DIR ??
|
||||
fileConfig.dataDir ??
|
||||
join(homedir(), '.actual-cli', 'data');
|
||||
|
||||
const encryptionPassword =
|
||||
cliOpts.encryptionPassword ??
|
||||
process.env.ACTUAL_ENCRYPTION_PASSWORD ??
|
||||
fileConfig.encryptionPassword;
|
||||
|
||||
if (!serverUrl) {
|
||||
throw new Error(
|
||||
'Server URL is required. Set --server-url, ACTUAL_SERVER_URL env var, or serverUrl in config file.',
|
||||
);
|
||||
}
|
||||
|
||||
if (!password && !sessionToken) {
|
||||
throw new Error(
|
||||
'Authentication required. Set --password/--session-token, ACTUAL_PASSWORD/ACTUAL_SESSION_TOKEN env var, or password/sessionToken in config file.',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
serverUrl,
|
||||
password,
|
||||
sessionToken,
|
||||
syncId,
|
||||
dataDir,
|
||||
encryptionPassword,
|
||||
};
|
||||
}
|
||||
134
packages/cli/src/connection.test.ts
Normal file
134
packages/cli/src/connection.test.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import * as api from '@actual-app/api';
|
||||
|
||||
import { resolveConfig } from './config';
|
||||
import { withConnection } from './connection';
|
||||
|
||||
vi.mock('@actual-app/api', () => ({
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
downloadBudget: vi.fn().mockResolvedValue(undefined),
|
||||
shutdown: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('./config', () => ({
|
||||
resolveConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
function setConfig(overrides: Record<string, unknown> = {}) {
|
||||
vi.mocked(resolveConfig).mockResolvedValue({
|
||||
serverUrl: 'http://test',
|
||||
password: 'pw',
|
||||
dataDir: '/tmp/data',
|
||||
syncId: 'budget-1',
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
describe('withConnection', () => {
|
||||
let stderrSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
stderrSpy = vi
|
||||
.spyOn(process.stderr, 'write')
|
||||
.mockImplementation(() => true);
|
||||
setConfig();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stderrSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('calls api.init with password when no sessionToken', async () => {
|
||||
setConfig({ password: 'pw', sessionToken: undefined });
|
||||
|
||||
await withConnection({}, async () => 'ok');
|
||||
|
||||
expect(api.init).toHaveBeenCalledWith({
|
||||
serverURL: 'http://test',
|
||||
password: 'pw',
|
||||
dataDir: '/tmp/data',
|
||||
verbose: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('calls api.init with sessionToken when present', async () => {
|
||||
setConfig({ sessionToken: 'tok', password: undefined });
|
||||
|
||||
await withConnection({}, async () => 'ok');
|
||||
|
||||
expect(api.init).toHaveBeenCalledWith({
|
||||
serverURL: 'http://test',
|
||||
sessionToken: 'tok',
|
||||
dataDir: '/tmp/data',
|
||||
verbose: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('calls api.downloadBudget when syncId is set', async () => {
|
||||
setConfig({ syncId: 'budget-1' });
|
||||
|
||||
await withConnection({}, async () => 'ok');
|
||||
|
||||
expect(api.downloadBudget).toHaveBeenCalledWith('budget-1', {
|
||||
password: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('throws when loadBudget is true but syncId is not set', async () => {
|
||||
setConfig({ syncId: undefined });
|
||||
|
||||
await expect(withConnection({}, async () => 'ok')).rejects.toThrow(
|
||||
'Sync ID is required',
|
||||
);
|
||||
});
|
||||
|
||||
it('skips budget download when loadBudget is false and syncId is not set', async () => {
|
||||
setConfig({ syncId: undefined });
|
||||
|
||||
await withConnection({}, async () => 'ok', { loadBudget: false });
|
||||
|
||||
expect(api.downloadBudget).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not call api.downloadBudget when loadBudget is false', async () => {
|
||||
setConfig({ syncId: 'budget-1' });
|
||||
|
||||
await withConnection({}, async () => 'ok', { loadBudget: false });
|
||||
|
||||
expect(api.downloadBudget).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns callback result', async () => {
|
||||
const result = await withConnection({}, async () => 42);
|
||||
expect(result).toBe(42);
|
||||
});
|
||||
|
||||
it('calls api.shutdown in finally block on success', async () => {
|
||||
await withConnection({}, async () => 'ok');
|
||||
expect(api.shutdown).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls api.shutdown in finally block on error', async () => {
|
||||
await expect(
|
||||
withConnection({}, async () => {
|
||||
throw new Error('boom');
|
||||
}),
|
||||
).rejects.toThrow('boom');
|
||||
|
||||
expect(api.shutdown).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not write to stderr by default', async () => {
|
||||
await withConnection({}, async () => 'ok');
|
||||
|
||||
expect(stderrSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('writes info to stderr when verbose', async () => {
|
||||
await withConnection({ verbose: true }, async () => 'ok');
|
||||
|
||||
expect(stderrSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Connecting to'),
|
||||
);
|
||||
});
|
||||
});
|
||||
65
packages/cli/src/connection.ts
Normal file
65
packages/cli/src/connection.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { mkdirSync } from 'fs';
|
||||
|
||||
import * as api from '@actual-app/api';
|
||||
|
||||
import { resolveConfig } from './config';
|
||||
import type { CliGlobalOpts } from './config';
|
||||
|
||||
function info(message: string, verbose?: boolean) {
|
||||
if (verbose) {
|
||||
process.stderr.write(message + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
type ConnectionOptions = {
|
||||
loadBudget?: boolean;
|
||||
};
|
||||
|
||||
export async function withConnection<T>(
|
||||
globalOpts: CliGlobalOpts,
|
||||
fn: () => Promise<T>,
|
||||
options: ConnectionOptions = {},
|
||||
): Promise<T> {
|
||||
const { loadBudget = true } = options;
|
||||
const config = await resolveConfig(globalOpts);
|
||||
|
||||
mkdirSync(config.dataDir, { recursive: true });
|
||||
|
||||
info(`Connecting to ${config.serverUrl}...`, globalOpts.verbose);
|
||||
|
||||
if (config.sessionToken) {
|
||||
await api.init({
|
||||
serverURL: config.serverUrl,
|
||||
dataDir: config.dataDir,
|
||||
sessionToken: config.sessionToken,
|
||||
verbose: globalOpts.verbose,
|
||||
});
|
||||
} else if (config.password) {
|
||||
await api.init({
|
||||
serverURL: config.serverUrl,
|
||||
dataDir: config.dataDir,
|
||||
password: config.password,
|
||||
verbose: globalOpts.verbose,
|
||||
});
|
||||
} else {
|
||||
throw new Error(
|
||||
'Authentication required. Provide --password or --session-token, or set ACTUAL_PASSWORD / ACTUAL_SESSION_TOKEN.',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
if (loadBudget && config.syncId) {
|
||||
info(`Downloading budget ${config.syncId}...`, globalOpts.verbose);
|
||||
await api.downloadBudget(config.syncId, {
|
||||
password: config.encryptionPassword,
|
||||
});
|
||||
} else if (loadBudget && !config.syncId) {
|
||||
throw new Error(
|
||||
'Sync ID is required for this command. Set --sync-id or ACTUAL_SYNC_ID.',
|
||||
);
|
||||
}
|
||||
return await fn();
|
||||
} finally {
|
||||
await api.shutdown();
|
||||
}
|
||||
}
|
||||
70
packages/cli/src/index.ts
Normal file
70
packages/cli/src/index.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { Command, Option } from 'commander';
|
||||
|
||||
import { registerAccountsCommand } from './commands/accounts';
|
||||
import { registerBudgetsCommand } from './commands/budgets';
|
||||
import { registerCategoriesCommand } from './commands/categories';
|
||||
import { registerCategoryGroupsCommand } from './commands/category-groups';
|
||||
import { registerPayeesCommand } from './commands/payees';
|
||||
import { registerQueryCommand } from './commands/query';
|
||||
import { registerRulesCommand } from './commands/rules';
|
||||
import { registerSchedulesCommand } from './commands/schedules';
|
||||
import { registerServerCommand } from './commands/server';
|
||||
import { registerTagsCommand } from './commands/tags';
|
||||
import { registerTransactionsCommand } from './commands/transactions';
|
||||
|
||||
declare const __CLI_VERSION__: string;
|
||||
|
||||
const program = new Command();
|
||||
|
||||
program
|
||||
.name('actual')
|
||||
.description('CLI for Actual Budget')
|
||||
.version(__CLI_VERSION__)
|
||||
.option('--server-url <url>', 'Actual server URL (env: ACTUAL_SERVER_URL)')
|
||||
.option('--password <password>', 'Server password (env: ACTUAL_PASSWORD)')
|
||||
.option(
|
||||
'--session-token <token>',
|
||||
'Session token (env: ACTUAL_SESSION_TOKEN)',
|
||||
)
|
||||
.option('--sync-id <id>', 'Budget sync ID (env: ACTUAL_SYNC_ID)')
|
||||
.option('--data-dir <path>', 'Data directory (env: ACTUAL_DATA_DIR)')
|
||||
.option(
|
||||
'--encryption-password <password>',
|
||||
'E2E encryption password (env: ACTUAL_ENCRYPTION_PASSWORD)',
|
||||
)
|
||||
.addOption(
|
||||
new Option('--format <format>', 'Output format: json, table, csv')
|
||||
.choices(['json', 'table', 'csv'] as const)
|
||||
.default('json'),
|
||||
)
|
||||
.option('--verbose', 'Show informational messages', false);
|
||||
|
||||
registerAccountsCommand(program);
|
||||
registerBudgetsCommand(program);
|
||||
registerCategoriesCommand(program);
|
||||
registerCategoryGroupsCommand(program);
|
||||
registerTransactionsCommand(program);
|
||||
registerPayeesCommand(program);
|
||||
registerTagsCommand(program);
|
||||
registerRulesCommand(program);
|
||||
registerSchedulesCommand(program);
|
||||
registerQueryCommand(program);
|
||||
registerServerCommand(program);
|
||||
|
||||
function normalizeThrownMessage(err: unknown): string {
|
||||
if (err instanceof Error) return err.message;
|
||||
if (typeof err === 'object' && err !== null) {
|
||||
try {
|
||||
return JSON.stringify(err);
|
||||
} catch {
|
||||
return '<non-serializable error>';
|
||||
}
|
||||
}
|
||||
return String(err);
|
||||
}
|
||||
|
||||
program.parseAsync(process.argv).catch((err: unknown) => {
|
||||
const message = normalizeThrownMessage(err);
|
||||
process.stderr.write(`Error: ${message}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
21
packages/cli/src/input.ts
Normal file
21
packages/cli/src/input.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { readFileSync } from 'fs';
|
||||
|
||||
export function readJsonInput(cmdOpts: {
|
||||
data?: string;
|
||||
file?: string;
|
||||
}): unknown {
|
||||
if (cmdOpts.data && cmdOpts.file) {
|
||||
throw new Error('Cannot use both --data and --file');
|
||||
}
|
||||
if (cmdOpts.data) {
|
||||
return JSON.parse(cmdOpts.data);
|
||||
}
|
||||
if (cmdOpts.file) {
|
||||
const content =
|
||||
cmdOpts.file === '-'
|
||||
? readFileSync(0, 'utf-8')
|
||||
: readFileSync(cmdOpts.file, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
}
|
||||
throw new Error('Either --data or --file is required');
|
||||
}
|
||||
152
packages/cli/src/output.test.ts
Normal file
152
packages/cli/src/output.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { formatOutput, printOutput } from './output';
|
||||
|
||||
describe('formatOutput', () => {
|
||||
describe('json (default)', () => {
|
||||
it('pretty-prints with 2-space indent', () => {
|
||||
const data = { a: 1, b: 'two' };
|
||||
expect(formatOutput(data)).toBe(JSON.stringify(data, null, 2));
|
||||
});
|
||||
|
||||
it('is the default format', () => {
|
||||
expect(formatOutput({ x: 1 })).toBe(formatOutput({ x: 1 }, 'json'));
|
||||
});
|
||||
|
||||
it('handles arrays', () => {
|
||||
const data = [1, 2, 3];
|
||||
expect(formatOutput(data, 'json')).toBe('[\n 1,\n 2,\n 3\n]');
|
||||
});
|
||||
|
||||
it('handles null', () => {
|
||||
expect(formatOutput(null, 'json')).toBe('null');
|
||||
});
|
||||
});
|
||||
|
||||
describe('table', () => {
|
||||
it('renders an object as key-value table', () => {
|
||||
const result = formatOutput({ name: 'Alice', age: 30 }, 'table');
|
||||
expect(result).toContain('name');
|
||||
expect(result).toContain('Alice');
|
||||
expect(result).toContain('age');
|
||||
expect(result).toContain('30');
|
||||
});
|
||||
|
||||
it('renders an array of objects as columnar table', () => {
|
||||
const data = [
|
||||
{ id: 1, name: 'a' },
|
||||
{ id: 2, name: 'b' },
|
||||
];
|
||||
const result = formatOutput(data, 'table');
|
||||
expect(result).toContain('id');
|
||||
expect(result).toContain('name');
|
||||
expect(result).toContain('1');
|
||||
expect(result).toContain('a');
|
||||
expect(result).toContain('2');
|
||||
expect(result).toContain('b');
|
||||
});
|
||||
|
||||
it('returns "(no results)" for empty array', () => {
|
||||
expect(formatOutput([], 'table')).toBe('(no results)');
|
||||
});
|
||||
|
||||
it('returns String(data) for scalar values', () => {
|
||||
expect(formatOutput(42, 'table')).toBe('42');
|
||||
expect(formatOutput('hello', 'table')).toBe('hello');
|
||||
expect(formatOutput(true, 'table')).toBe('true');
|
||||
});
|
||||
|
||||
it('handles null/undefined values in objects', () => {
|
||||
const data = [{ a: null, b: undefined }];
|
||||
const result = formatOutput(data, 'table');
|
||||
expect(result).toContain('a');
|
||||
expect(result).toContain('b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('csv', () => {
|
||||
it('renders array of objects as header + data rows', () => {
|
||||
const data = [
|
||||
{ id: 1, name: 'Alice' },
|
||||
{ id: 2, name: 'Bob' },
|
||||
];
|
||||
const result = formatOutput(data, 'csv');
|
||||
const lines = result.split('\n');
|
||||
expect(lines[0]).toBe('id,name');
|
||||
expect(lines[1]).toBe('1,Alice');
|
||||
expect(lines[2]).toBe('2,Bob');
|
||||
});
|
||||
|
||||
it('renders single object as header + single row', () => {
|
||||
const result = formatOutput({ x: 10, y: 20 }, 'csv');
|
||||
const lines = result.split('\n');
|
||||
expect(lines[0]).toBe('x,y');
|
||||
expect(lines[1]).toBe('10,20');
|
||||
});
|
||||
|
||||
it('returns empty string for empty array', () => {
|
||||
expect(formatOutput([], 'csv')).toBe('');
|
||||
});
|
||||
|
||||
it('returns String(data) for scalar values', () => {
|
||||
expect(formatOutput(42, 'csv')).toBe('42');
|
||||
expect(formatOutput('hello', 'csv')).toBe('hello');
|
||||
});
|
||||
|
||||
it('escapes commas by quoting', () => {
|
||||
const data = [{ val: 'a,b' }];
|
||||
expect(formatOutput(data, 'csv')).toBe('val\n"a,b"');
|
||||
});
|
||||
|
||||
it('escapes double quotes by doubling them', () => {
|
||||
const data = [{ val: 'say "hi"' }];
|
||||
expect(formatOutput(data, 'csv')).toBe('val\n"say ""hi"""');
|
||||
});
|
||||
|
||||
it('escapes newlines by quoting', () => {
|
||||
const data = [{ val: 'line1\nline2' }];
|
||||
expect(formatOutput(data, 'csv')).toBe('val\n"line1\nline2"');
|
||||
});
|
||||
|
||||
it('handles null/undefined values', () => {
|
||||
const data = [{ a: null, b: undefined }];
|
||||
const result = formatOutput(data, 'csv');
|
||||
const lines = result.split('\n');
|
||||
expect(lines[0]).toBe('a,b');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('printOutput', () => {
|
||||
let writeSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
writeSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('writes formatted output followed by newline', () => {
|
||||
printOutput({ a: 1 }, 'json');
|
||||
expect(writeSpy).toHaveBeenCalledWith(
|
||||
JSON.stringify({ a: 1 }, null, 2) + '\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults to json format', () => {
|
||||
printOutput([1, 2]);
|
||||
expect(writeSpy).toHaveBeenCalledWith(
|
||||
JSON.stringify([1, 2], null, 2) + '\n',
|
||||
);
|
||||
});
|
||||
|
||||
it('supports table format', () => {
|
||||
printOutput([], 'table');
|
||||
expect(writeSpy).toHaveBeenCalledWith('(no results)\n');
|
||||
});
|
||||
|
||||
it('supports csv format', () => {
|
||||
printOutput([], 'csv');
|
||||
expect(writeSpy).toHaveBeenCalledWith('\n');
|
||||
});
|
||||
});
|
||||
82
packages/cli/src/output.ts
Normal file
82
packages/cli/src/output.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import Table from 'cli-table3';
|
||||
|
||||
export type OutputFormat = 'json' | 'table' | 'csv';
|
||||
|
||||
export function formatOutput(
|
||||
data: unknown,
|
||||
format: OutputFormat = 'json',
|
||||
): string {
|
||||
switch (format) {
|
||||
case 'json':
|
||||
return JSON.stringify(data, null, 2);
|
||||
case 'table':
|
||||
return formatTable(data);
|
||||
case 'csv':
|
||||
return formatCsv(data);
|
||||
default:
|
||||
return JSON.stringify(data, null, 2);
|
||||
}
|
||||
}
|
||||
|
||||
function formatTable(data: unknown): string {
|
||||
if (!Array.isArray(data)) {
|
||||
if (data && typeof data === 'object') {
|
||||
const table = new Table();
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
table.push({ [key]: String(value) });
|
||||
}
|
||||
return table.toString();
|
||||
}
|
||||
return String(data);
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return '(no results)';
|
||||
}
|
||||
|
||||
const keys = Object.keys(data[0] as Record<string, unknown>);
|
||||
const table = new Table({ head: keys });
|
||||
|
||||
for (const row of data) {
|
||||
const r = row as Record<string, unknown>;
|
||||
table.push(keys.map(k => String(r[k] ?? '')));
|
||||
}
|
||||
|
||||
return table.toString();
|
||||
}
|
||||
|
||||
function formatCsv(data: unknown): string {
|
||||
if (!Array.isArray(data)) {
|
||||
if (data && typeof data === 'object') {
|
||||
const entries = Object.entries(data);
|
||||
const header = entries.map(([k]) => escapeCsv(k)).join(',');
|
||||
const values = entries.map(([, v]) => escapeCsv(String(v))).join(',');
|
||||
return header + '\n' + values;
|
||||
}
|
||||
return String(data);
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const keys = Object.keys(data[0] as Record<string, unknown>);
|
||||
const header = keys.map(k => escapeCsv(k)).join(',');
|
||||
const rows = data.map(row => {
|
||||
const r = row as Record<string, unknown>;
|
||||
return keys.map(k => escapeCsv(String(r[k] ?? ''))).join(',');
|
||||
});
|
||||
|
||||
return [header, ...rows].join('\n');
|
||||
}
|
||||
|
||||
function escapeCsv(value: string): string {
|
||||
if (value.includes(',') || value.includes('"') || value.includes('\n')) {
|
||||
return '"' + value.replace(/"/g, '""') + '"';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function printOutput(data: unknown, format: OutputFormat = 'json') {
|
||||
process.stdout.write(formatOutput(data, format) + '\n');
|
||||
}
|
||||
65
packages/cli/src/utils.test.ts
Normal file
65
packages/cli/src/utils.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { parseBoolFlag, parseIntFlag } from './utils';
|
||||
|
||||
describe('parseBoolFlag', () => {
|
||||
it('parses "true"', () => {
|
||||
expect(parseBoolFlag('true', '--flag')).toBe(true);
|
||||
});
|
||||
|
||||
it('parses "false"', () => {
|
||||
expect(parseBoolFlag('false', '--flag')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects other strings', () => {
|
||||
expect(() => parseBoolFlag('yes', '--flag')).toThrow(
|
||||
'Invalid --flag: "yes". Expected "true" or "false".',
|
||||
);
|
||||
});
|
||||
|
||||
it('includes the flag name in the error message', () => {
|
||||
expect(() => parseBoolFlag('1', '--offbudget')).toThrow(
|
||||
'Invalid --offbudget',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseIntFlag', () => {
|
||||
it('parses a valid integer string', () => {
|
||||
expect(parseIntFlag('42', '--balance')).toBe(42);
|
||||
});
|
||||
|
||||
it('parses zero', () => {
|
||||
expect(parseIntFlag('0', '--balance')).toBe(0);
|
||||
});
|
||||
|
||||
it('parses negative integers', () => {
|
||||
expect(parseIntFlag('-10', '--balance')).toBe(-10);
|
||||
});
|
||||
|
||||
it('rejects decimal values', () => {
|
||||
expect(() => parseIntFlag('3.5', '--balance')).toThrow(
|
||||
'Invalid --balance: "3.5". Expected an integer.',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects non-numeric strings', () => {
|
||||
expect(() => parseIntFlag('abc', '--balance')).toThrow(
|
||||
'Invalid --balance: "abc". Expected an integer.',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects partially numeric strings', () => {
|
||||
expect(() => parseIntFlag('3abc', '--balance')).toThrow(
|
||||
'Invalid --balance: "3abc". Expected an integer.',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects empty string', () => {
|
||||
expect(() => parseIntFlag('', '--balance')).toThrow(
|
||||
'Invalid --balance: "". Expected an integer.',
|
||||
);
|
||||
});
|
||||
|
||||
it('includes the flag name in the error message', () => {
|
||||
expect(() => parseIntFlag('x', '--amount')).toThrow('Invalid --amount');
|
||||
});
|
||||
});
|
||||
16
packages/cli/src/utils.ts
Normal file
16
packages/cli/src/utils.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export function parseBoolFlag(value: string, flagName: string): boolean {
|
||||
if (value !== 'true' && value !== 'false') {
|
||||
throw new Error(
|
||||
`Invalid ${flagName}: "${value}". Expected "true" or "false".`,
|
||||
);
|
||||
}
|
||||
return value === 'true';
|
||||
}
|
||||
|
||||
export function parseIntFlag(value: string, flagName: string): number {
|
||||
const parsed = value.trim() === '' ? NaN : Number(value);
|
||||
if (!Number.isInteger(parsed)) {
|
||||
throw new Error(`Invalid ${flagName}: "${value}". Expected an integer.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
15
packages/cli/tsconfig.json
Normal file
15
packages/cli/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"lib": ["ES2021"],
|
||||
"types": ["vitest/globals", "node"],
|
||||
"noEmit": false,
|
||||
"strict": true,
|
||||
"outDir": "dist",
|
||||
"tsBuildInfoFile": "dist/.tsbuildinfo"
|
||||
},
|
||||
"references": [{ "path": "../api" }],
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "coverage"]
|
||||
}
|
||||
36
packages/cli/vite.config.ts
Normal file
36
packages/cli/vite.config.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { visualizer } from 'rollup-plugin-visualizer';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
const pkg = JSON.parse(
|
||||
fs.readFileSync(path.resolve(__dirname, 'package.json'), 'utf-8'),
|
||||
);
|
||||
|
||||
export default defineConfig({
|
||||
define: {
|
||||
__CLI_VERSION__: JSON.stringify(pkg.version),
|
||||
},
|
||||
ssr: { noExternal: true, external: ['@actual-app/api'] },
|
||||
build: {
|
||||
ssr: true,
|
||||
target: 'node22',
|
||||
outDir: path.resolve(__dirname, 'dist'),
|
||||
emptyOutDir: true,
|
||||
lib: {
|
||||
entry: path.resolve(__dirname, 'src/index.ts'),
|
||||
formats: ['es'],
|
||||
},
|
||||
rollupOptions: {
|
||||
output: {
|
||||
entryFileNames: 'cli.js',
|
||||
banner: chunk => (chunk.isEntry ? '#!/usr/bin/env node' : ''),
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [visualizer({ template: 'raw-data', filename: 'dist/stats.json' })],
|
||||
test: {
|
||||
globals: true,
|
||||
},
|
||||
});
|
||||
@@ -53,7 +53,7 @@
|
||||
"@storybook/addon-docs": "^10.2.7",
|
||||
"@storybook/react-vite": "^10.2.7",
|
||||
"@svgr/cli": "^8.1.0",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react": "^19.2.14",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
|
||||
"@vitejs/plugin-react": "^6.0.0",
|
||||
"eslint-plugin-storybook": "^10.2.7",
|
||||
|
||||
@@ -4,7 +4,7 @@ import { css, cx } from '@emotion/css';
|
||||
|
||||
import type { CSSProperties } from './styles';
|
||||
|
||||
type BlockProps = HTMLProps<HTMLDivElement> & {
|
||||
type BlockProps = Omit<HTMLProps<HTMLDivElement>, 'style'> & {
|
||||
innerRef?: Ref<HTMLDivElement>;
|
||||
style?: CSSProperties;
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import { css } from '@emotion/css';
|
||||
|
||||
import type { CSSProperties } from './styles';
|
||||
|
||||
type ParagraphProps = HTMLProps<HTMLDivElement> & {
|
||||
type ParagraphProps = Omit<HTMLProps<HTMLDivElement>, 'style'> & {
|
||||
style?: CSSProperties;
|
||||
isLast?: boolean;
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ import { css, cx } from '@emotion/css';
|
||||
|
||||
import type { CSSProperties } from './styles';
|
||||
|
||||
type TextProps = HTMLProps<HTMLSpanElement> & {
|
||||
type TextProps = Omit<HTMLProps<HTMLSpanElement>, 'style'> & {
|
||||
innerRef?: Ref<HTMLSpanElement>;
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
|
||||
@@ -5,7 +5,7 @@ import { css, cx } from '@emotion/css';
|
||||
|
||||
import type { CSSProperties } from './styles';
|
||||
|
||||
type ViewProps = HTMLProps<HTMLDivElement> & {
|
||||
type ViewProps = Omit<HTMLProps<HTMLDivElement>, 'style'> & {
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
nativeStyle?: CSSProperties;
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"@types/lodash": "^4",
|
||||
"@types/pikaday": "^1.7.10",
|
||||
"@types/promise-retry": "^1.1.6",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-modal": "^3.16.3",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
|
||||
@@ -86,7 +86,7 @@
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "7.13.0",
|
||||
"react-simple-pull-to-refresh": "^1.3.4",
|
||||
"react-spring": "10.0.0",
|
||||
"react-spring": "^10.0.3",
|
||||
"react-swipeable": "^7.0.2",
|
||||
"react-virtualized-auto-sizer": "^2.0.2",
|
||||
"recharts": "^3.7.0",
|
||||
|
||||
@@ -6,6 +6,8 @@ import * as Platform from 'loot-core/shared/platform';
|
||||
// oxlint-disable-next-line typescript-paths/absolute-parent-import
|
||||
import packageJson from '../package.json';
|
||||
|
||||
import SharedBrowserServerWorker from './shared-browser-server.ts?sharedworker';
|
||||
|
||||
const backendWorkerUrl = new URL('./browser-server.js', import.meta.url);
|
||||
|
||||
// This file installs global variables that the app expects.
|
||||
@@ -21,9 +23,235 @@ const ACTUAL_VERSION = Platform.isPlaywright
|
||||
: packageJson.version;
|
||||
|
||||
// *** Start the backend ***
|
||||
|
||||
let worker = null;
|
||||
// The regular Worker running the backend, created only on the leader tab
|
||||
let localBackendWorker = null;
|
||||
|
||||
/**
|
||||
* WorkerBridge wraps a SharedWorker port and presents a Worker-like interface
|
||||
* (onmessage, postMessage, addEventListener, start) to the connection layer.
|
||||
*
|
||||
* The SharedWorker coordinator assigns each tab a role per budget:
|
||||
* - LEADER: this tab runs the backend in a dedicated Worker
|
||||
* - FOLLOWER: this tab routes messages through the SharedWorker to the leader
|
||||
*
|
||||
* Multiple budgets can be open simultaneously — each has its own leader.
|
||||
*/
|
||||
class WorkerBridge {
|
||||
constructor(sharedPort) {
|
||||
this._sharedPort = sharedPort;
|
||||
this._onmessage = null;
|
||||
this._listeners = [];
|
||||
this._started = false;
|
||||
|
||||
// Listen for all messages from the SharedWorker port
|
||||
sharedPort.addEventListener('message', e => this._onSharedMessage(e));
|
||||
}
|
||||
|
||||
set onmessage(handler) {
|
||||
this._onmessage = handler;
|
||||
// Setting onmessage on a real MessagePort implicitly starts it.
|
||||
// We need to do this explicitly on the underlying port.
|
||||
if (!this._started) {
|
||||
this._started = true;
|
||||
this._sharedPort.start();
|
||||
}
|
||||
}
|
||||
|
||||
get onmessage() {
|
||||
return this._onmessage;
|
||||
}
|
||||
|
||||
postMessage(msg) {
|
||||
// All messages go through the SharedWorker for coordination.
|
||||
// The SharedWorker forwards to the leader's Worker via __to-worker.
|
||||
this._sharedPort.postMessage(msg);
|
||||
}
|
||||
|
||||
addEventListener(type, handler) {
|
||||
this._listeners.push({ type, handler });
|
||||
}
|
||||
|
||||
start() {
|
||||
if (!this._started) {
|
||||
this._started = true;
|
||||
this._sharedPort.start();
|
||||
}
|
||||
}
|
||||
|
||||
_dispatch(event) {
|
||||
if (this._onmessage) this._onmessage(event);
|
||||
for (const { type, handler } of this._listeners) {
|
||||
if (type === 'message') handler(event);
|
||||
}
|
||||
}
|
||||
|
||||
_onSharedMessage(event) {
|
||||
const msg = event.data;
|
||||
|
||||
// Elected as leader: create the real backend Worker on this tab
|
||||
if (msg && msg.type === '__become-leader') {
|
||||
this._createLocalWorker(msg.initMsg, msg.budgetToRestore, msg.pendingMsg);
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward requests from SharedWorker to our local Worker
|
||||
if (msg && msg.type === '__to-worker') {
|
||||
if (localBackendWorker) {
|
||||
localBackendWorker.postMessage(msg.msg);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Leadership transfer: this tab is closing the budget but other tabs
|
||||
// still need it. Terminate our Worker (don't actually close-budget on
|
||||
// the backend) and dispatch a synthetic reply so the UI navigates to
|
||||
// show-budgets normally.
|
||||
if (msg && msg.type === '__close-and-transfer') {
|
||||
console.log('[WorkerBridge] Leadership transferred — terminating Worker');
|
||||
if (localBackendWorker) {
|
||||
localBackendWorker.terminate();
|
||||
localBackendWorker = null;
|
||||
}
|
||||
// Only dispatch a synthetic reply if there's an actual close-budget
|
||||
// request to complete. When requestId is null the eviction was
|
||||
// triggered externally (e.g. another tab deleted this budget).
|
||||
if (msg.requestId) {
|
||||
this._dispatch({
|
||||
data: { type: 'reply', id: msg.requestId, data: {} },
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Role change notification
|
||||
if (msg && msg.type === '__role-change') {
|
||||
console.log(
|
||||
`[WorkerBridge] Role: ${msg.role}${msg.budgetId ? ` (budget: ${msg.budgetId})` : ''}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Surface SharedWorker console output in this tab's DevTools
|
||||
if (msg && msg.type === '__shared-worker-console') {
|
||||
const method = console[msg.level] || console.log;
|
||||
method(...msg.args);
|
||||
return;
|
||||
}
|
||||
|
||||
// Respond to heartbeat pings
|
||||
if (msg && msg.type === '__heartbeat-ping') {
|
||||
this._sharedPort.postMessage({ type: '__heartbeat-pong' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Everything else goes to the connection layer
|
||||
this._dispatch(event);
|
||||
}
|
||||
|
||||
_createLocalWorker(initMsg, budgetToRestore, pendingMsg) {
|
||||
if (localBackendWorker) {
|
||||
localBackendWorker.terminate();
|
||||
}
|
||||
localBackendWorker = new Worker(backendWorkerUrl);
|
||||
initSQLBackend(localBackendWorker);
|
||||
|
||||
const sharedPort = this._sharedPort;
|
||||
localBackendWorker.onmessage = workerEvent => {
|
||||
const workerMsg = workerEvent.data;
|
||||
// absurd-sql internal messages are handled by initSQLBackend
|
||||
if (
|
||||
workerMsg &&
|
||||
workerMsg.type &&
|
||||
workerMsg.type.startsWith('__absurd:')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// After the backend connects, automatically reload the budget that was
|
||||
// open before the leader left (e.g. page refresh). This lets other tabs
|
||||
// continue working without being sent to the budget list.
|
||||
if (workerMsg.type === 'connect') {
|
||||
if (budgetToRestore) {
|
||||
console.log(
|
||||
`[WorkerBridge] Backend connected, restoring budget "${budgetToRestore}"`,
|
||||
);
|
||||
const id = budgetToRestore;
|
||||
budgetToRestore = null;
|
||||
localBackendWorker.postMessage({
|
||||
id: '__restore-budget',
|
||||
name: 'load-budget',
|
||||
args: { id },
|
||||
catchErrors: true,
|
||||
});
|
||||
// Tell SharedWorker to track the restore request so
|
||||
// currentBudgetId gets updated when the reply arrives.
|
||||
sharedPort.postMessage({
|
||||
type: '__track-restore',
|
||||
requestId: '__restore-budget',
|
||||
budgetId: id,
|
||||
});
|
||||
} else if (pendingMsg) {
|
||||
const toSend = pendingMsg;
|
||||
pendingMsg = null;
|
||||
localBackendWorker.postMessage(toSend);
|
||||
}
|
||||
}
|
||||
sharedPort.postMessage({ type: '__from-worker', msg: workerMsg });
|
||||
};
|
||||
|
||||
localBackendWorker.postMessage(initMsg);
|
||||
}
|
||||
}
|
||||
|
||||
function createBackendWorker() {
|
||||
// Use SharedWorker as a coordinator for multi-tab, multi-budget support.
|
||||
// Each budget gets its own leader tab running a dedicated Worker. All other
|
||||
// tabs on the same budget are followers — their messages are routed through
|
||||
// the SharedWorker to the leader's Worker.
|
||||
// The SharedWorker never touches SharedArrayBuffer, so this works on all
|
||||
// platforms including iOS/Safari.
|
||||
if (typeof SharedWorker !== 'undefined' && !Platform.isPlaywright) {
|
||||
try {
|
||||
const sharedWorker = new SharedBrowserServerWorker({
|
||||
name: 'actual-backend',
|
||||
});
|
||||
|
||||
const sharedPort = sharedWorker.port;
|
||||
worker = new WorkerBridge(sharedPort);
|
||||
console.log('[WorkerBridge] Connected to SharedWorker coordinator');
|
||||
|
||||
// Don't call start() here. The port must remain un-started so that
|
||||
// messages (especially 'connect') are queued until connectWorker()
|
||||
// sets onmessage, which implicitly starts the port via the bridge.
|
||||
|
||||
if (window.SharedArrayBuffer) {
|
||||
localStorage.removeItem('SharedArrayBufferOverride');
|
||||
}
|
||||
|
||||
sharedPort.postMessage({
|
||||
type: 'init',
|
||||
version: ACTUAL_VERSION,
|
||||
isDev: IS_DEV,
|
||||
publicUrl: process.env.PUBLIC_URL,
|
||||
hash: process.env.REACT_APP_BACKEND_WORKER_HASH,
|
||||
isSharedArrayBufferOverrideEnabled: localStorage.getItem(
|
||||
'SharedArrayBufferOverride',
|
||||
),
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
sharedPort.postMessage({ type: 'tab-closing' });
|
||||
});
|
||||
|
||||
return;
|
||||
} catch (e) {
|
||||
console.log('SharedWorker failed, falling back to Worker:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: regular Worker (Playwright, no SharedWorker support, or failure)
|
||||
console.log('[WorkerBridge] No SharedWorker available, using direct Worker');
|
||||
worker = new Worker(backendWorkerUrl);
|
||||
initSQLBackend(worker);
|
||||
|
||||
@@ -37,6 +265,7 @@ function createBackendWorker() {
|
||||
isDev: IS_DEV,
|
||||
publicUrl: process.env.PUBLIC_URL,
|
||||
hash: process.env.REACT_APP_BACKEND_WORKER_HASH,
|
||||
hasSharedArrayBuffer: !!window.SharedArrayBuffer,
|
||||
isSharedArrayBufferOverrideEnabled: localStorage.getItem(
|
||||
'SharedArrayBufferOverride',
|
||||
),
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
// @ts-strict-ignore
|
||||
import React, { useEffect, useEffectEvent, useMemo, useState } from 'react';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { AnimatedLoading } from '@actual-app/components/icons/AnimatedLoading';
|
||||
import { SpaceBetween } from '@actual-app/components/space-between';
|
||||
import { styles } from '@actual-app/components/styles';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { Tooltip } from '@actual-app/components/tooltip';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import { send } from 'loot-core/platform/client/connection';
|
||||
import * as undo from 'loot-core/platform/client/undo';
|
||||
import { getNormalisedString } from 'loot-core/shared/normalisation';
|
||||
import { q } from 'loot-core/shared/query';
|
||||
@@ -30,9 +30,7 @@ import { RulesList } from './rules/RulesList';
|
||||
|
||||
import { useAccounts } from '@desktop-client/hooks/useAccounts';
|
||||
import { useCategories } from '@desktop-client/hooks/useCategories';
|
||||
import { usePayeeRules } from '@desktop-client/hooks/usePayeeRules';
|
||||
import { usePayees } from '@desktop-client/hooks/usePayees';
|
||||
import { useRules } from '@desktop-client/hooks/useRules';
|
||||
import { useSchedules } from '@desktop-client/hooks/useSchedules';
|
||||
import {
|
||||
SelectedProvider,
|
||||
@@ -40,10 +38,6 @@ import {
|
||||
} from '@desktop-client/hooks/useSelected';
|
||||
import { pushModal } from '@desktop-client/modals/modalsSlice';
|
||||
import { useDispatch } from '@desktop-client/redux';
|
||||
import {
|
||||
useBatchDeleteRulesMutation,
|
||||
useDeleteRuleMutation,
|
||||
} from '@desktop-client/rules';
|
||||
|
||||
export type FilterData = {
|
||||
payees?: Array<{ id: string; name: string }>;
|
||||
@@ -121,36 +115,17 @@ export function ruleToString(rule: RuleEntity, data: FilterData) {
|
||||
type ManageRulesProps = {
|
||||
isModal: boolean;
|
||||
payeeId: string | null;
|
||||
setLoading?: Dispatch<SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
export function ManageRules({ isModal, payeeId }: ManageRulesProps) {
|
||||
export function ManageRules({
|
||||
isModal,
|
||||
payeeId,
|
||||
setLoading = () => {},
|
||||
}: ManageRulesProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
data: allRules = [],
|
||||
refetch: refetchAllRules,
|
||||
isLoading: isAllRulesLoading,
|
||||
isRefetching: isAllRulesRefetching,
|
||||
} = useRules({
|
||||
enabled: !payeeId,
|
||||
});
|
||||
const {
|
||||
data: payeeRules = [],
|
||||
refetch: refetchPayeeRules,
|
||||
isLoading: isPayeeRulesLoading,
|
||||
isRefetching: isPayeeRulesRefetching,
|
||||
} = usePayeeRules({
|
||||
payeeId,
|
||||
});
|
||||
|
||||
const rulesToUse = payeeId ? payeeRules : allRules;
|
||||
const refetchRules = payeeId ? refetchPayeeRules : refetchAllRules;
|
||||
const isLoading =
|
||||
isAllRulesLoading ||
|
||||
isAllRulesRefetching ||
|
||||
isPayeeRulesLoading ||
|
||||
isPayeeRulesRefetching;
|
||||
|
||||
const [allRules, setAllRules] = useState<RuleEntity[]>([]);
|
||||
const [page, setPage] = useState(0);
|
||||
const [filter, setFilter] = useState('');
|
||||
const dispatch = useDispatch();
|
||||
@@ -172,7 +147,7 @@ export function ManageRules({ isModal, payeeId }: ManageRulesProps) {
|
||||
);
|
||||
|
||||
const filteredRules = useMemo(() => {
|
||||
const rules = rulesToUse.filter(rule => {
|
||||
const rules = allRules.filter(rule => {
|
||||
const schedule = schedules.find(schedule => schedule.rule === rule.id);
|
||||
return schedule ? schedule.completed === false : true;
|
||||
});
|
||||
@@ -186,7 +161,7 @@ export function ManageRules({ isModal, payeeId }: ManageRulesProps) {
|
||||
),
|
||||
)
|
||||
).slice(0, 100 + page * 50);
|
||||
}, [rulesToUse, filter, filterData, page, schedules]);
|
||||
}, [allRules, filter, filterData, page, schedules]);
|
||||
|
||||
const selectedInst = useSelected('manage-rules', filteredRules, []);
|
||||
const [hoveredRule, setHoveredRule] = useState(null);
|
||||
@@ -196,16 +171,38 @@ export function ManageRules({ isModal, payeeId }: ManageRulesProps) {
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
async function loadRules() {
|
||||
setLoading(true);
|
||||
|
||||
let loadedRules = null;
|
||||
if (payeeId) {
|
||||
loadedRules = await send('payees-get-rules', {
|
||||
id: payeeId,
|
||||
});
|
||||
} else {
|
||||
loadedRules = await send('rules-get');
|
||||
}
|
||||
|
||||
setAllRules(loadedRules);
|
||||
return loadedRules;
|
||||
}
|
||||
|
||||
const init = useEffectEvent(() => {
|
||||
async function loadData() {
|
||||
await loadRules();
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
if (payeeId) {
|
||||
undo.setUndoState('openModal', { name: 'manage-rules', options: {} });
|
||||
}
|
||||
|
||||
void loadData();
|
||||
|
||||
return () => {
|
||||
undo.setUndoState('openModal', null);
|
||||
};
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
return init();
|
||||
}, []);
|
||||
@@ -214,33 +211,29 @@ export function ManageRules({ isModal, payeeId }: ManageRulesProps) {
|
||||
setPage(page => page + 1);
|
||||
}
|
||||
|
||||
const { mutate: batchDeleteRules } = useBatchDeleteRulesMutation();
|
||||
|
||||
const onDeleteSelected = async () => {
|
||||
batchDeleteRules(
|
||||
{
|
||||
ids: [...selectedInst.items],
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
void refetchRules();
|
||||
selectedInst.dispatch({ type: 'select-none' });
|
||||
},
|
||||
},
|
||||
);
|
||||
setLoading(true);
|
||||
|
||||
const { someDeletionsFailed } = await send('rule-delete-all', [
|
||||
...selectedInst.items,
|
||||
]);
|
||||
|
||||
if (someDeletionsFailed) {
|
||||
alert(
|
||||
t('Some rules were not deleted because they are linked to schedules.'),
|
||||
);
|
||||
}
|
||||
|
||||
await loadRules();
|
||||
selectedInst.dispatch({ type: 'select-none' });
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const { mutate: deleteRule } = useDeleteRuleMutation();
|
||||
|
||||
function onDeleteRule(id: string) {
|
||||
deleteRule(
|
||||
{ id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
void refetchRules();
|
||||
},
|
||||
},
|
||||
);
|
||||
async function onDeleteRule(id: string) {
|
||||
setLoading(true);
|
||||
await send('rule-delete', id);
|
||||
await loadRules();
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
const onEditRule = rule => {
|
||||
@@ -251,7 +244,8 @@ export function ManageRules({ isModal, payeeId }: ManageRulesProps) {
|
||||
options: {
|
||||
rule,
|
||||
onSave: async () => {
|
||||
void refetchRules();
|
||||
await loadRules();
|
||||
setLoading(false);
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -288,7 +282,8 @@ export function ManageRules({ isModal, payeeId }: ManageRulesProps) {
|
||||
options: {
|
||||
rule,
|
||||
onSave: async () => {
|
||||
void refetchRules();
|
||||
await loadRules();
|
||||
setLoading(false);
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -300,24 +295,6 @@ export function ManageRules({ isModal, payeeId }: ManageRulesProps) {
|
||||
setHoveredRule(id);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<AnimatedLoading width={25} height={25} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const isNonDeletableRuleSelected = schedules.some(schedule =>
|
||||
selectedInst.items.has(schedule.rule),
|
||||
);
|
||||
|
||||
return (
|
||||
<SelectedProvider instance={selectedInst}>
|
||||
<View>
|
||||
@@ -384,24 +361,11 @@ export function ManageRules({ isModal, payeeId }: ManageRulesProps) {
|
||||
>
|
||||
<SpaceBetween gap={10} style={{ justifyContent: 'flex-end' }}>
|
||||
{selectedInst.items.size > 0 && (
|
||||
<Tooltip
|
||||
isOpen={isNonDeletableRuleSelected}
|
||||
content={
|
||||
<Trans>
|
||||
Some selected rules cannot be deleted because they are
|
||||
linked to schedules.
|
||||
</Trans>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
onPress={onDeleteSelected}
|
||||
isDisabled={isNonDeletableRuleSelected}
|
||||
>
|
||||
<Trans count={selectedInst.items.size}>
|
||||
Delete {{ count: selectedInst.items.size }} rules
|
||||
</Trans>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button onPress={onDeleteSelected}>
|
||||
<Trans count={selectedInst.items.size}>
|
||||
Delete {{ count: selectedInst.items.size }} rules
|
||||
</Trans>
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="primary" onPress={onCreateRule}>
|
||||
<Trans>Create new rule</Trans>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { ComponentProps, CSSProperties } from 'react';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { SvgCustomNotesPaper } from '@actual-app/components/icons/v2';
|
||||
import { Popover } from '@actual-app/components/popover';
|
||||
import type { CSSProperties } from '@actual-app/components/styles';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { Tooltip } from '@actual-app/components/tooltip';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
@@ -153,22 +153,27 @@ function Notification({
|
||||
const yOffset = index * Y_OFFSET_PER_LEVEL;
|
||||
|
||||
const [isSwiped, setIsSwiped] = useState(false);
|
||||
const [spring, api] = useSpring(() => ({
|
||||
x: 0,
|
||||
y: yOffset,
|
||||
opacity: stackOpacity,
|
||||
scale,
|
||||
}));
|
||||
const [spring, api] = useSpring(
|
||||
() => ({
|
||||
from: {
|
||||
x: 0,
|
||||
y: yOffset,
|
||||
opacity: stackOpacity,
|
||||
scale,
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
// Update scale, opacity, and y-position when index changes
|
||||
useEffect(() => {
|
||||
void api.start({ scale, opacity: stackOpacity, y: yOffset });
|
||||
void api.start({ to: { scale, opacity: stackOpacity, y: yOffset } });
|
||||
}, [index, scale, stackOpacity, yOffset, api]);
|
||||
|
||||
const swipeHandlers = useSwipeable({
|
||||
onSwiping: ({ deltaX }) => {
|
||||
if (!isSwiped) {
|
||||
void api.start({ x: deltaX });
|
||||
void api.start({ to: { x: deltaX } });
|
||||
}
|
||||
},
|
||||
onSwiped: ({ velocity, deltaX }) => {
|
||||
@@ -179,14 +184,13 @@ function Notification({
|
||||
if (Math.abs(deltaX) > threshold || velocity > 0.5) {
|
||||
// Animate out & remove item after animation
|
||||
void api.start({
|
||||
x: direction * 1000,
|
||||
opacity: 0,
|
||||
to: { x: direction * 1000, opacity: 0 },
|
||||
onRest: onRemove,
|
||||
});
|
||||
setIsSwiped(true);
|
||||
} else {
|
||||
// Reset position if not swiped far enough
|
||||
void api.start({ x: 0 });
|
||||
void api.start({ to: { x: 0 } });
|
||||
}
|
||||
},
|
||||
trackMouse: true,
|
||||
|
||||
@@ -83,7 +83,6 @@ import { pagedQuery } from '@desktop-client/queries/pagedQuery';
|
||||
import type { PagedQuery } from '@desktop-client/queries/pagedQuery';
|
||||
import { useDispatch, useSelector } from '@desktop-client/redux';
|
||||
import type { AppDispatch } from '@desktop-client/redux/store';
|
||||
import { useRunRulesMutation } from '@desktop-client/rules/mutations';
|
||||
import { updateNewTransactions } from '@desktop-client/transactions/transactionsSlice';
|
||||
|
||||
type ConditionEntity = Partial<RuleConditionEntity> | TransactionFilterEntity;
|
||||
@@ -252,7 +251,6 @@ type AccountInternalProps = {
|
||||
onUnlinkAccount: (id: AccountEntity['id']) => void;
|
||||
onSyncAndDownload: (accountId?: AccountEntity['id']) => void;
|
||||
onCreatePayee: (name: PayeeEntity['name']) => Promise<PayeeEntity['id']>;
|
||||
onRunRules: (transaction: TransactionEntity) => Promise<TransactionEntity>;
|
||||
};
|
||||
|
||||
type AccountInternalState = {
|
||||
@@ -693,8 +691,9 @@ class AccountInternal extends PureComponent<
|
||||
const allErrors: string[] = [];
|
||||
|
||||
for (const transaction of transactions) {
|
||||
const res: TransactionEntity | null =
|
||||
await this.props.onRunRules(transaction);
|
||||
const res: TransactionEntity | null = await send('rules-run', {
|
||||
transaction,
|
||||
});
|
||||
if (res) {
|
||||
changedTransactions.push(...ungroupTransaction(res));
|
||||
|
||||
@@ -1056,9 +1055,10 @@ class AccountInternal extends PureComponent<
|
||||
});
|
||||
|
||||
// run rules on the reconciliation transaction
|
||||
const runRules = this.props.onRunRules;
|
||||
const ruledTransactions = await Promise.all(
|
||||
reconciliationTransactions.map(transaction => runRules(transaction)),
|
||||
reconciliationTransactions.map(transaction =>
|
||||
send('rules-run', { transaction }),
|
||||
),
|
||||
);
|
||||
|
||||
// sync the reconciliation transaction
|
||||
@@ -2028,13 +2028,9 @@ export function Account() {
|
||||
const onSyncAndDownload = (id?: AccountEntity['id']) =>
|
||||
syncAndDownload({ id });
|
||||
|
||||
const { mutateAsync: createPayeeAsync } = useCreatePayeeMutation();
|
||||
const createPayee = useCreatePayeeMutation();
|
||||
const onCreatePayee = (name: PayeeEntity['name']) =>
|
||||
createPayeeAsync({ name });
|
||||
|
||||
const { mutateAsync: runRulesAsync } = useRunRulesMutation();
|
||||
const onRunRules = (transaction: TransactionEntity) =>
|
||||
runRulesAsync({ transaction });
|
||||
createPayee.mutateAsync({ name });
|
||||
|
||||
return (
|
||||
<SchedulesProvider query={schedulesQuery}>
|
||||
@@ -2077,7 +2073,6 @@ export function Account() {
|
||||
onUnlinkAccount={onUnlinkAccount}
|
||||
onSyncAndDownload={onSyncAndDownload}
|
||||
onCreatePayee={onCreatePayee}
|
||||
onRunRules={onRunRules}
|
||||
/>
|
||||
</SplitsExpandedProvider>
|
||||
</SchedulesProvider>
|
||||
|
||||
@@ -24,10 +24,13 @@ export function BudgetSummaries() {
|
||||
const [firstMonth] = months;
|
||||
|
||||
const [widthState, setWidthState] = useState(0);
|
||||
const [styles, spring] = useSpring(() => ({
|
||||
x: 0,
|
||||
config: { mass: 3, tension: 600, friction: 80 },
|
||||
}));
|
||||
const [styles, spring] = useSpring(
|
||||
() => ({
|
||||
from: { x: 0 },
|
||||
config: { mass: 3, tension: 600, friction: 80 },
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const containerRef = useResizeObserver<HTMLDivElement>(
|
||||
useCallback(rect => {
|
||||
@@ -55,7 +58,9 @@ export function BudgetSummaries() {
|
||||
}
|
||||
|
||||
const to = -offsetX;
|
||||
void spring.start({ from: { x: from }, x: to });
|
||||
if (from !== to) {
|
||||
void spring.start({ from: { x: from }, to: { x: to } });
|
||||
}
|
||||
}, [spring, firstMonth, monthWidth, allMonths]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -63,7 +68,7 @@ export function BudgetSummaries() {
|
||||
}, [firstMonth]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
void spring.start({ from: { x: -monthWidth }, to: { x: -monthWidth } });
|
||||
void spring.start({ to: { x: -monthWidth }, immediate: true });
|
||||
}, [spring, monthWidth]);
|
||||
|
||||
const { SummaryComponent } = useBudgetComponents();
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { SvgChartPie } from '@actual-app/components/icons/v1';
|
||||
import type { CSSProperties } from '@actual-app/components/styles';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { css, cx } from '@emotion/css';
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// @ts-strict-ignore
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
import { styles } from '@actual-app/components/styles';
|
||||
import type { CSSProperties } from '@actual-app/components/styles';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { t } from 'i18next';
|
||||
|
||||
|
||||
@@ -17,8 +17,12 @@ type TextLinkProps = {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
type ButtonLinkProps = Omit<ComponentProps<typeof Button>, 'variant'> & {
|
||||
type ButtonLinkProps = Omit<
|
||||
ComponentProps<typeof Button>,
|
||||
'variant' | 'style'
|
||||
> & {
|
||||
buttonVariant?: ComponentProps<typeof Button>['variant'];
|
||||
style?: CSSProperties;
|
||||
to?: string;
|
||||
activeStyle?: CSSProperties;
|
||||
};
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
getFieldError,
|
||||
getValidOps,
|
||||
mapField,
|
||||
unparseConditions,
|
||||
unparse,
|
||||
} from 'loot-core/shared/rules';
|
||||
import { titleFirst } from 'loot-core/shared/util';
|
||||
import type { IntegerAmount } from 'loot-core/shared/util';
|
||||
@@ -296,39 +296,37 @@ function ConfigureField<T extends RuleConditionEntity>({
|
||||
});
|
||||
}}
|
||||
>
|
||||
{type &&
|
||||
type !== 'boolean' &&
|
||||
(field !== 'payee' || !isPayeeIdOp(op)) && (
|
||||
<GenericInput
|
||||
ref={inputRef}
|
||||
field={
|
||||
field === 'date' || field === 'category' ? subfield : field
|
||||
}
|
||||
type={
|
||||
type === 'id' &&
|
||||
(op === 'contains' ||
|
||||
op === 'matches' ||
|
||||
op === 'doesNotContain' ||
|
||||
op === 'hasTags')
|
||||
? 'string'
|
||||
: type
|
||||
}
|
||||
numberFormatType="currency"
|
||||
// @ts-expect-error - fix me
|
||||
value={
|
||||
formattedValue ??
|
||||
(op === 'oneOf' || op === 'notOneOf' ? [] : '')
|
||||
}
|
||||
multi={op === 'oneOf' || op === 'notOneOf'}
|
||||
op={op}
|
||||
options={subfieldToOptions(field, subfield)}
|
||||
style={{ marginTop: 10 }}
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
onChange={(v: any) => {
|
||||
dispatch({ type: 'set-value', value: v });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{type !== 'boolean' && (field !== 'payee' || !isPayeeIdOp(op)) && (
|
||||
<GenericInput
|
||||
ref={inputRef}
|
||||
// @ts-expect-error - fix me
|
||||
field={field === 'date' || field === 'category' ? subfield : field}
|
||||
// @ts-expect-error - fix me
|
||||
type={
|
||||
type === 'id' &&
|
||||
(op === 'contains' ||
|
||||
op === 'matches' ||
|
||||
op === 'doesNotContain' ||
|
||||
op === 'hasTags')
|
||||
? 'string'
|
||||
: type
|
||||
}
|
||||
numberFormatType="currency"
|
||||
// @ts-expect-error - fix me
|
||||
value={
|
||||
formattedValue ?? (op === 'oneOf' || op === 'notOneOf' ? [] : '')
|
||||
}
|
||||
// @ts-expect-error - fix me
|
||||
multi={op === 'oneOf' || op === 'notOneOf'}
|
||||
op={op}
|
||||
options={subfieldToOptions(field, subfield)}
|
||||
style={{ marginTop: 10 }}
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
onChange={(v: any) => {
|
||||
dispatch({ type: 'set-value', value: v });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{field === 'payee' && isPayeeIdOp(op) && (
|
||||
<PayeeFilter
|
||||
@@ -426,7 +424,7 @@ export function FilterButton<T extends RuleConditionEntity>({
|
||||
|
||||
async function onValidateAndApply(cond: T) {
|
||||
// @ts-expect-error - fix me
|
||||
cond = unparseConditions({ ...cond, type: FIELD_TYPES.get(cond.field) });
|
||||
cond = unparse({ ...cond, type: FIELD_TYPES.get(cond.field) });
|
||||
|
||||
if (cond.type === 'date' && cond.options) {
|
||||
if (cond.options.month) {
|
||||
@@ -630,11 +628,7 @@ export function FilterEditor<T extends RuleConditionEntity>({
|
||||
dispatch={dispatch}
|
||||
onApply={cond => {
|
||||
// @ts-expect-error - fix me
|
||||
cond = unparseConditions({
|
||||
...cond,
|
||||
// @ts-expect-error - fix me
|
||||
type: FIELD_TYPES.get(cond.field),
|
||||
});
|
||||
cond = unparse({ ...cond, type: FIELD_TYPES.get(cond.field) });
|
||||
|
||||
if (cond.type === 'date' && cond.options) {
|
||||
if (
|
||||
|
||||
@@ -86,7 +86,7 @@ export const FormField = ({ style, children }: FormFieldProps) => {
|
||||
|
||||
// Custom inputs
|
||||
|
||||
type CheckboxProps = ComponentProps<'input'> & {
|
||||
type CheckboxProps = Omit<ComponentProps<'input'>, 'style'> & {
|
||||
style?: CSSProperties;
|
||||
};
|
||||
|
||||
|
||||
@@ -34,10 +34,13 @@ export function ActionableGridListItem<T extends object>({
|
||||
const hasActions = !!actions;
|
||||
|
||||
// Spring animation for the swipe
|
||||
const [{ x }, api] = useSpring(() => ({
|
||||
x: 0,
|
||||
config: config.stiff,
|
||||
}));
|
||||
const [{ x }, api] = useSpring(
|
||||
() => ({
|
||||
from: { x: 0 },
|
||||
config: config.stiff,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
// Handle drag gestures
|
||||
const bind = useDrag(
|
||||
@@ -48,7 +51,7 @@ export function ActionableGridListItem<T extends object>({
|
||||
if (active) {
|
||||
dragStartedRef.current = true;
|
||||
void api.start({
|
||||
x: Math.max(-actionsWidth, Math.min(0, currentX)),
|
||||
to: { x: Math.max(-actionsWidth, Math.min(0, currentX)) },
|
||||
onRest: () => {
|
||||
dragStartedRef.current = false;
|
||||
},
|
||||
@@ -62,7 +65,7 @@ export function ActionableGridListItem<T extends object>({
|
||||
(vx < -0.5 && currentX < -actionsWidth / 5);
|
||||
|
||||
void api.start({
|
||||
x: shouldReveal ? -actionsWidth : 0,
|
||||
to: { x: shouldReveal ? -actionsWidth : 0 },
|
||||
onRest: () => {
|
||||
dragStartedRef.current = false;
|
||||
setIsRevealed(shouldReveal);
|
||||
@@ -141,7 +144,7 @@ export function ActionableGridListItem<T extends object>({
|
||||
? actions({
|
||||
close: () => {
|
||||
void api.start({
|
||||
x: 0,
|
||||
to: { x: 0 },
|
||||
onRest: () => {
|
||||
setIsRevealed(false);
|
||||
},
|
||||
|
||||
@@ -52,7 +52,7 @@ export function MobileNavTabs() {
|
||||
maxWidth: `${100 / COLUMN_COUNT}%`,
|
||||
};
|
||||
|
||||
const [{ y }, api] = useSpring(() => ({ y: OPEN_DEFAULT_Y }));
|
||||
const [{ y }, api] = useSpring(() => ({ from: { y: OPEN_DEFAULT_Y } }), []);
|
||||
|
||||
const openFull = useCallback(
|
||||
({ canceled }: { canceled?: boolean }) => {
|
||||
@@ -60,7 +60,7 @@ export function MobileNavTabs() {
|
||||
// so we change the spring config to create a nice wobbly effect
|
||||
setNavbarState('open');
|
||||
void api.start({
|
||||
y: OPEN_FULL_Y,
|
||||
to: { y: OPEN_FULL_Y },
|
||||
immediate: isTestEnv,
|
||||
config: canceled ? config.wobbly : config.stiff,
|
||||
});
|
||||
@@ -72,7 +72,7 @@ export function MobileNavTabs() {
|
||||
(velocity = 0) => {
|
||||
setNavbarState('default');
|
||||
void api.start({
|
||||
y: OPEN_DEFAULT_Y,
|
||||
to: { y: OPEN_DEFAULT_Y },
|
||||
immediate: isTestEnv,
|
||||
config: { ...config.stiff, velocity },
|
||||
});
|
||||
@@ -84,7 +84,7 @@ export function MobileNavTabs() {
|
||||
(velocity = 0) => {
|
||||
setNavbarState('hidden');
|
||||
void api.start({
|
||||
y: HIDDEN_Y,
|
||||
to: { y: HIDDEN_Y },
|
||||
immediate: isTestEnv,
|
||||
config: { ...config.stiff, velocity },
|
||||
});
|
||||
@@ -199,7 +199,7 @@ export function MobileNavTabs() {
|
||||
} else {
|
||||
// when the user keeps dragging, we just move the sheet according to
|
||||
// the cursor position
|
||||
void api.start({ y: oy, immediate: true });
|
||||
void api.start({ to: { y: oy }, immediate: true });
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -17,8 +17,8 @@ import { useNavigate } from '@desktop-client/hooks/useNavigate';
|
||||
import { useSchedules } from '@desktop-client/hooks/useSchedules';
|
||||
import { useUndo } from '@desktop-client/hooks/useUndo';
|
||||
import { pushModal } from '@desktop-client/modals/modalsSlice';
|
||||
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
|
||||
import { useDispatch } from '@desktop-client/redux';
|
||||
import { useDeleteRuleMutation } from '@desktop-client/rules/mutations';
|
||||
|
||||
export function MobileRuleEditPage() {
|
||||
const { t } = useTranslation();
|
||||
@@ -107,8 +107,6 @@ export function MobileRuleEditPage() {
|
||||
void navigate(-1);
|
||||
};
|
||||
|
||||
const { mutate: deleteRule } = useDeleteRuleMutation();
|
||||
|
||||
const handleDelete = () => {
|
||||
// Runtime guard to ensure id exists
|
||||
if (!id || id === 'new') {
|
||||
@@ -122,17 +120,23 @@ export function MobileRuleEditPage() {
|
||||
options: {
|
||||
message: t('Are you sure you want to delete this rule?'),
|
||||
onConfirm: async () => {
|
||||
deleteRule(
|
||||
{ id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
showUndoNotification({
|
||||
message: t('Rule deleted successfully'),
|
||||
});
|
||||
void navigate('/rules');
|
||||
},
|
||||
},
|
||||
);
|
||||
try {
|
||||
await send('rule-delete', id);
|
||||
showUndoNotification({
|
||||
message: t('Rule deleted successfully'),
|
||||
});
|
||||
void navigate('/rules');
|
||||
} catch (error) {
|
||||
console.error('Failed to delete rule:', error);
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
type: 'error',
|
||||
message: t('Failed to delete rule. Please try again.'),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@ import { styles } from '@actual-app/components/styles';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
|
||||
import { listen } from 'loot-core/platform/client/connection';
|
||||
import { listen, send } from 'loot-core/platform/client/connection';
|
||||
import * as undo from 'loot-core/platform/client/undo';
|
||||
import { getNormalisedString } from 'loot-core/shared/normalisation';
|
||||
import { q } from 'loot-core/shared/query';
|
||||
@@ -21,24 +21,22 @@ import { useAccounts } from '@desktop-client/hooks/useAccounts';
|
||||
import { useCategories } from '@desktop-client/hooks/useCategories';
|
||||
import { useNavigate } from '@desktop-client/hooks/useNavigate';
|
||||
import { usePayees } from '@desktop-client/hooks/usePayees';
|
||||
import { useRules } from '@desktop-client/hooks/useRules';
|
||||
import { useSchedules } from '@desktop-client/hooks/useSchedules';
|
||||
import { useUndo } from '@desktop-client/hooks/useUndo';
|
||||
import { useUrlParam } from '@desktop-client/hooks/useUrlParam';
|
||||
import { useDeleteRuleMutation } from '@desktop-client/rules';
|
||||
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
|
||||
import { useDispatch } from '@desktop-client/redux';
|
||||
|
||||
export function MobileRulesPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const { showUndoNotification } = useUndo();
|
||||
const [visibleRulesParam] = useUrlParam('visible-rules');
|
||||
const [allRules, setAllRules] = useState<RuleEntity[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
const {
|
||||
data: allRules = [],
|
||||
isLoading: isRulesLoading,
|
||||
refetch: refetchRules,
|
||||
} = useRules();
|
||||
const { schedules = [] } = useSchedules({
|
||||
query: useMemo(() => q('schedules').select('*'), []),
|
||||
});
|
||||
@@ -81,10 +79,28 @@ export function MobileRulesPage() {
|
||||
);
|
||||
}, [visibleRules, filter, filterData, schedules]);
|
||||
|
||||
const loadRules = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const result = await send('rules-get');
|
||||
const rules = result || [];
|
||||
setAllRules(rules);
|
||||
} catch (error) {
|
||||
console.error('Failed to load rules:', error);
|
||||
setAllRules([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRules();
|
||||
}, [loadRules]);
|
||||
|
||||
// Listen for undo events to refresh rules list
|
||||
useEffect(() => {
|
||||
const onUndo = () => {
|
||||
void refetchRules();
|
||||
void loadRules();
|
||||
};
|
||||
|
||||
const lastUndoEvent = undo.getUndoState('undoEvent');
|
||||
@@ -93,7 +109,7 @@ export function MobileRulesPage() {
|
||||
}
|
||||
|
||||
return listen('undo-event', onUndo);
|
||||
}, [refetchRules]);
|
||||
}, [loadRules]);
|
||||
|
||||
const handleRulePress = useCallback(
|
||||
(rule: RuleEntity) => {
|
||||
@@ -109,24 +125,45 @@ export function MobileRulesPage() {
|
||||
[setFilter],
|
||||
);
|
||||
|
||||
const { mutate: deleteRule } = useDeleteRuleMutation();
|
||||
|
||||
const handleRuleDelete = useCallback(
|
||||
(rule: RuleEntity) => {
|
||||
deleteRule(
|
||||
{ id: rule.id },
|
||||
{
|
||||
onSuccess: () => {
|
||||
showUndoNotification({
|
||||
message: t('Rule deleted successfully'),
|
||||
});
|
||||
// Refresh the rules list
|
||||
void refetchRules();
|
||||
},
|
||||
},
|
||||
);
|
||||
async (rule: RuleEntity) => {
|
||||
try {
|
||||
const { someDeletionsFailed } = await send('rule-delete-all', [
|
||||
rule.id,
|
||||
]);
|
||||
|
||||
if (someDeletionsFailed) {
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
type: 'warning',
|
||||
message: t(
|
||||
'This rule could not be deleted because it is linked to a schedule.',
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
showUndoNotification({
|
||||
message: t('Rule deleted successfully'),
|
||||
});
|
||||
}
|
||||
|
||||
// Refresh the rules list
|
||||
await loadRules();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete rule:', error);
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
type: 'error',
|
||||
message: t('Failed to delete rule. Please try again.'),
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
[deleteRule, showUndoNotification, t, refetchRules],
|
||||
[dispatch, showUndoNotification, t, loadRules],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -162,7 +199,7 @@ export function MobileRulesPage() {
|
||||
</View>
|
||||
<RulesList
|
||||
rules={filteredRules}
|
||||
isLoading={isRulesLoading}
|
||||
isLoading={isLoading}
|
||||
onRulePress={handleRulePress}
|
||||
onRuleDelete={handleRuleDelete}
|
||||
/>
|
||||
|
||||
@@ -12,11 +12,7 @@ import { View } from '@actual-app/components/view';
|
||||
import { send, sendCatch } from 'loot-core/platform/client/connection';
|
||||
import * as monthUtils from 'loot-core/shared/months';
|
||||
import { q } from 'loot-core/shared/query';
|
||||
import type {
|
||||
RecurConfig,
|
||||
RuleConditionEntity,
|
||||
ScheduleEntity,
|
||||
} from 'loot-core/types/models';
|
||||
import type { RecurConfig, ScheduleEntity } from 'loot-core/types/models';
|
||||
|
||||
import { MobileBackButton } from '@desktop-client/components/mobile/MobileBackButton';
|
||||
import { MobilePageHeader, Page } from '@desktop-client/components/Page';
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
} from 'react';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import type { CSSProperties as EmotionCSSProperties } from '@actual-app/components/styles';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
@@ -167,7 +168,9 @@ type FocusableAmountInputProps = Omit<AmountInputProps, 'onFocus'> & {
|
||||
focused?: boolean;
|
||||
disabled?: boolean;
|
||||
focusedStyle?: CSSProperties;
|
||||
buttonProps?: ComponentPropsWithRef<typeof Button>;
|
||||
buttonProps?: Omit<ComponentPropsWithRef<typeof Button>, 'style'> & {
|
||||
style?: EmotionCSSProperties;
|
||||
};
|
||||
onFocus?: () => void;
|
||||
};
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ import {
|
||||
} from '@desktop-client/components/mobile/MobileForms';
|
||||
import { getPrettyPayee } from '@desktop-client/components/mobile/utils';
|
||||
import { MobilePageHeader, Page } from '@desktop-client/components/Page';
|
||||
import { createSingleTimeScheduleFromTransaction } from '@desktop-client/components/transactions/TransactionList';
|
||||
import { AmountInput } from '@desktop-client/components/util/AmountInput';
|
||||
import { useAccounts } from '@desktop-client/hooks/useAccounts';
|
||||
import { useCategories } from '@desktop-client/hooks/useCategories';
|
||||
@@ -96,10 +97,6 @@ import { useSavePayeeLocationMutation } from '@desktop-client/payees';
|
||||
import { locationService } from '@desktop-client/payees/location';
|
||||
import { aqlQuery } from '@desktop-client/queries/aqlQuery';
|
||||
import { useDispatch, useSelector } from '@desktop-client/redux';
|
||||
import {
|
||||
useCreateSingleTimeScheduleFromTransaction,
|
||||
useRunRulesMutation,
|
||||
} from '@desktop-client/rules';
|
||||
import { setLastTransaction } from '@desktop-client/transactions/transactionsSlice';
|
||||
|
||||
function getFieldName(transactionId: TransactionEntity['id'], field: string) {
|
||||
@@ -689,9 +686,6 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
|
||||
[categories, isBudgetTransfer, t],
|
||||
);
|
||||
|
||||
const { mutate: createSingleTimeScheduleFromTransaction } =
|
||||
useCreateSingleTimeScheduleFromTransaction();
|
||||
|
||||
const onSaveInner = useCallback(() => {
|
||||
const [unserializedTransaction] = unserializedTransactions;
|
||||
|
||||
@@ -750,24 +744,19 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
|
||||
}
|
||||
: unserializedTransaction;
|
||||
|
||||
createSingleTimeScheduleFromTransaction(
|
||||
{
|
||||
transaction: transactionForSchedule,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
type: 'message',
|
||||
message: t('Schedule created successfully'),
|
||||
},
|
||||
}),
|
||||
);
|
||||
void navigate(-1);
|
||||
},
|
||||
},
|
||||
await createSingleTimeScheduleFromTransaction(
|
||||
transactionForSchedule,
|
||||
);
|
||||
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
type: 'message',
|
||||
message: t('Schedule created successfully'),
|
||||
},
|
||||
}),
|
||||
);
|
||||
void navigate(-1);
|
||||
},
|
||||
onCancel: onConfirmSave,
|
||||
},
|
||||
@@ -804,7 +793,6 @@ const TransactionEditInner = memo<TransactionEditInnerProps>(
|
||||
unserializedTransactions,
|
||||
upcomingLength,
|
||||
t,
|
||||
createSingleTimeScheduleFromTransaction,
|
||||
]);
|
||||
|
||||
const onUpdateInner = useCallback(
|
||||
@@ -1496,8 +1484,6 @@ function TransactionEditUnconnected({
|
||||
searchParams,
|
||||
]);
|
||||
|
||||
const { mutateAsync: runRulesAsync } = useRunRulesMutation();
|
||||
|
||||
const onUpdate = useCallback(
|
||||
async (
|
||||
serializedTransaction: TransactionEntity,
|
||||
@@ -1513,7 +1499,9 @@ function TransactionEditUnconnected({
|
||||
// this on new transactions because that's how desktop works.
|
||||
const newTransaction = { ...transaction };
|
||||
if (isTemporary(newTransaction)) {
|
||||
const afterRules = await runRulesAsync({ transaction: newTransaction });
|
||||
const afterRules = await send('rules-run', {
|
||||
transaction: newTransaction,
|
||||
});
|
||||
const diff = getChangedValues(newTransaction, afterRules);
|
||||
|
||||
if (diff) {
|
||||
@@ -1582,7 +1570,7 @@ function TransactionEditUnconnected({
|
||||
}
|
||||
}
|
||||
},
|
||||
[dateFormat, transactions, locationAccess, runRulesAsync],
|
||||
[dateFormat, transactions, locationAccess],
|
||||
);
|
||||
|
||||
const onSave = useCallback(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// @ts-strict-ignore
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
@@ -16,16 +17,17 @@ type ManageRulesModalProps = Extract<
|
||||
|
||||
export function ManageRulesModal({ payeeId }: ManageRulesModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
return (
|
||||
<Modal name="manage-rules">
|
||||
<Modal name="manage-rules" isLoading={loading}>
|
||||
{({ state }) => (
|
||||
<>
|
||||
<ModalHeader
|
||||
title={t('Rules')}
|
||||
rightContent={<ModalCloseButton onPress={() => state.close()} />}
|
||||
/>
|
||||
<ManageRules isModal payeeId={payeeId} />
|
||||
<ManageRules isModal payeeId={payeeId} setLoading={setLoading} />
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
@@ -17,7 +17,6 @@ import { usePayees } from '@desktop-client/hooks/usePayees';
|
||||
import { replaceModal } from '@desktop-client/modals/modalsSlice';
|
||||
import type { Modal as ModalType } from '@desktop-client/modals/modalsSlice';
|
||||
import { useDispatch, useSelector } from '@desktop-client/redux';
|
||||
import { useAddPayeeRenameRuleMutation } from '@desktop-client/rules';
|
||||
|
||||
const highlightStyle = { color: theme.pageTextPositive };
|
||||
|
||||
@@ -58,9 +57,6 @@ export function MergeUnusedPayeesModal({
|
||||
allPayees.filter(p => payeeIds.includes(p.id)),
|
||||
);
|
||||
|
||||
const { mutateAsync: addPayeeRenameRuleAsync } =
|
||||
useAddPayeeRenameRuleMutation();
|
||||
|
||||
const onMerge = useCallback(
|
||||
async (targetPayee: PayeeEntity) => {
|
||||
await send('payees-merge', {
|
||||
@@ -70,7 +66,7 @@ export function MergeUnusedPayeesModal({
|
||||
|
||||
let ruleId;
|
||||
if (shouldCreateRule && !isEditingRule) {
|
||||
const id = await addPayeeRenameRuleAsync({
|
||||
const id = await send('rule-add-payee-rename', {
|
||||
fromNames: payees.map(payee => payee.name),
|
||||
to: targetPayee.id,
|
||||
});
|
||||
@@ -79,7 +75,7 @@ export function MergeUnusedPayeesModal({
|
||||
|
||||
return ruleId;
|
||||
},
|
||||
[shouldCreateRule, isEditingRule, payees, addPayeeRenameRuleAsync],
|
||||
[shouldCreateRule, isEditingRule, payees],
|
||||
);
|
||||
|
||||
const onMergeAndCreateRule = useCallback(
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import type { CSSProperties } from '@actual-app/components/styles';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
|
||||
@@ -447,15 +447,18 @@ function CalendarInner({ widget, parameters }: CalendarInnerProps) {
|
||||
const openY = 0;
|
||||
const [mobileTransactionsOpen, setMobileTransactionsOpen] = useState(false);
|
||||
|
||||
const [{ y }, api] = useSpring(() => ({
|
||||
y: closeY.current,
|
||||
immediate: false,
|
||||
}));
|
||||
const [{ y }, api] = useSpring(
|
||||
() => ({
|
||||
from: { y: closeY.current },
|
||||
immediate: false,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
closeY.current = totalHeight;
|
||||
void api.start({
|
||||
y: mobileTransactionsOpen ? openY : closeY.current,
|
||||
to: { y: mobileTransactionsOpen ? openY : closeY.current },
|
||||
immediate: false,
|
||||
});
|
||||
}, [totalHeight, mobileTransactionsOpen, api]);
|
||||
@@ -463,7 +466,7 @@ function CalendarInner({ widget, parameters }: CalendarInnerProps) {
|
||||
const open = useCallback(
|
||||
({ canceled }: { canceled: boolean }) => {
|
||||
void api.start({
|
||||
y: openY,
|
||||
to: { y: openY },
|
||||
immediate: false,
|
||||
config: canceled ? config.wobbly : config.stiff,
|
||||
});
|
||||
@@ -475,7 +478,7 @@ function CalendarInner({ widget, parameters }: CalendarInnerProps) {
|
||||
const close = useCallback(
|
||||
(velocity = 0) => {
|
||||
void api.start({
|
||||
y: closeY.current,
|
||||
to: { y: closeY.current },
|
||||
config: { ...config.stiff, velocity },
|
||||
});
|
||||
setMobileTransactionsOpen(false);
|
||||
@@ -487,7 +490,7 @@ function CalendarInner({ widget, parameters }: CalendarInnerProps) {
|
||||
({ offset: [, oy], cancel }) => {
|
||||
if (oy < 0) {
|
||||
cancel();
|
||||
void api.start({ y: 0, immediate: true });
|
||||
void api.start({ to: { y: 0 }, immediate: true });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -501,7 +504,7 @@ function CalendarInner({ widget, parameters }: CalendarInnerProps) {
|
||||
open({ canceled: true });
|
||||
setMobileTransactionsOpen(true);
|
||||
} else {
|
||||
void api.start({ y: oy, immediate: true });
|
||||
void api.start({ to: { y: oy }, immediate: true });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -37,10 +37,8 @@ import {
|
||||
isValidOp,
|
||||
makeValue,
|
||||
mapField,
|
||||
parseActions,
|
||||
parseConditions,
|
||||
unparseActions,
|
||||
unparseConditions,
|
||||
parse,
|
||||
unparse,
|
||||
} from 'loot-core/shared/rules';
|
||||
import type { ScheduleStatusType } from 'loot-core/shared/schedules';
|
||||
import type {
|
||||
@@ -48,7 +46,6 @@ import type {
|
||||
RuleActionEntity,
|
||||
RuleEntity,
|
||||
} from 'loot-core/types/models';
|
||||
import type { WithOptional } from 'loot-core/types/util';
|
||||
|
||||
import { FormulaActionEditor } from './FormulaActionEditor';
|
||||
|
||||
@@ -66,12 +63,9 @@ import {
|
||||
SelectedProvider,
|
||||
useSelected,
|
||||
} from '@desktop-client/hooks/useSelected';
|
||||
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
|
||||
import { aqlQuery } from '@desktop-client/queries/aqlQuery';
|
||||
import { useDispatch } from '@desktop-client/redux';
|
||||
import {
|
||||
useApplyRuleActionsMutation,
|
||||
useSaveRuleMutation,
|
||||
} from '@desktop-client/rules';
|
||||
import { disableUndo, enableUndo } from '@desktop-client/undo';
|
||||
|
||||
function updateValue(array, value, update) {
|
||||
@@ -964,7 +958,7 @@ function ConditionsList({
|
||||
}
|
||||
|
||||
const getActions = splits => splits.flatMap(s => s.actions);
|
||||
const getUnparsedActions = splits => getActions(splits).map(unparseActions);
|
||||
const getUnparsedActions = splits => getActions(splits).map(unparse);
|
||||
|
||||
// TODO:
|
||||
// * Dont touch child transactions?
|
||||
@@ -1002,27 +996,19 @@ export function RuleEditor({
|
||||
}: RuleEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [conditions, setConditions] = useState(
|
||||
defaultRule.conditions
|
||||
.map(parseConditions)
|
||||
.map(c => ({ ...c, inputKey: uuid() })),
|
||||
defaultRule.conditions.map(parse).map(c => ({ ...c, inputKey: uuid() })),
|
||||
);
|
||||
const [actionSplits, setActionSplits] = useState<
|
||||
Array<{
|
||||
id: string;
|
||||
actions: Array<RuleActionEntity & { inputKey: string }>;
|
||||
}>
|
||||
>(() => {
|
||||
const parsedActions = defaultRule.actions.map(parseActions);
|
||||
const [actionSplits, setActionSplits] = useState(() => {
|
||||
const parsedActions = defaultRule.actions.map(parse);
|
||||
return parsedActions.reduce(
|
||||
(acc, action) => {
|
||||
const splitIndex =
|
||||
'options' in action ? (action.options?.splitIndex ?? 0) : 0;
|
||||
const splitIndex = action.options?.splitIndex ?? 0;
|
||||
acc[splitIndex] = acc[splitIndex] ?? { id: uuid(), actions: [] };
|
||||
acc[splitIndex].actions.push({ ...action, inputKey: uuid() });
|
||||
return acc;
|
||||
},
|
||||
// The pre-split group is always there
|
||||
[{ id: uuid(), actions: [] } as (typeof actionSplits)[0]],
|
||||
[{ id: uuid(), actions: [] }],
|
||||
);
|
||||
});
|
||||
const [stage, setStage] = useState(defaultRule.stage);
|
||||
@@ -1053,7 +1039,7 @@ export function RuleEditor({
|
||||
// Run it here
|
||||
async function run() {
|
||||
const { filters } = await send('make-filters-from-conditions', {
|
||||
conditions: conditions.map(unparseConditions),
|
||||
conditions: conditions.map(unparse),
|
||||
});
|
||||
|
||||
if (filters.length > 0) {
|
||||
@@ -1225,67 +1211,74 @@ export function RuleEditor({
|
||||
});
|
||||
}
|
||||
|
||||
const { mutate: applyRuleActions } = useApplyRuleActionsMutation();
|
||||
|
||||
function onApply() {
|
||||
const selectedTransactions = transactions.filter(({ id }) =>
|
||||
selectedInst.items.has(id),
|
||||
);
|
||||
applyRuleActions(
|
||||
{
|
||||
transactions: selectedTransactions,
|
||||
ruleActions: getUnparsedActions(actionSplits),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setActionSplits([...actionSplits]);
|
||||
},
|
||||
},
|
||||
);
|
||||
void send('rule-apply-actions', {
|
||||
transactions: selectedTransactions,
|
||||
actions: getUnparsedActions(actionSplits),
|
||||
}).then(content => {
|
||||
// This makes it refetch the transactions
|
||||
content.errors.forEach(error => {
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
type: 'error',
|
||||
message: error,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
setActionSplits([...actionSplits]);
|
||||
});
|
||||
}
|
||||
|
||||
const { mutate: saveRule } = useSaveRuleMutation();
|
||||
|
||||
async function onSave() {
|
||||
const rule: WithOptional<RuleEntity, 'id'> = {
|
||||
const rule = {
|
||||
...defaultRule,
|
||||
stage,
|
||||
conditionsOp,
|
||||
conditions: conditions.map(unparseConditions),
|
||||
conditions: conditions.map(unparse),
|
||||
actions: getUnparsedActions(actionSplits),
|
||||
};
|
||||
|
||||
saveRule(
|
||||
{
|
||||
rule,
|
||||
},
|
||||
{
|
||||
onSuccess: ({ id }) => {
|
||||
originalOnSave?.({
|
||||
id,
|
||||
...rule,
|
||||
});
|
||||
},
|
||||
onError: error => {
|
||||
if ('conditionErrors' in error && error.conditionErrors) {
|
||||
setConditions(applyErrors(conditions, error.conditionErrors));
|
||||
}
|
||||
// @ts-expect-error fix this
|
||||
const method = rule.id ? 'rule-update' : 'rule-add';
|
||||
// @ts-expect-error fix this
|
||||
const { error, id: newId } = await send(method, rule);
|
||||
|
||||
if ('actionErrors' in error && error.actionErrors) {
|
||||
let usedErrorIdx = 0;
|
||||
setActionSplits(
|
||||
actionSplits.map(item => ({
|
||||
...item,
|
||||
actions: item.actions.map(action => ({
|
||||
...action,
|
||||
error: error.actionErrors[usedErrorIdx++] ?? null,
|
||||
})),
|
||||
})),
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
if (error) {
|
||||
// @ts-expect-error fix this
|
||||
if (error.conditionErrors) {
|
||||
// @ts-expect-error fix this
|
||||
setConditions(applyErrors(conditions, error.conditionErrors));
|
||||
}
|
||||
|
||||
// @ts-expect-error fix this
|
||||
if (error.actionErrors) {
|
||||
let usedErrorIdx = 0;
|
||||
setActionSplits(
|
||||
actionSplits.map(item => ({
|
||||
...item,
|
||||
actions: item.actions.map(action => ({
|
||||
...action,
|
||||
// @ts-expect-error fix this
|
||||
error: error.actionErrors[usedErrorIdx++] ?? null,
|
||||
})),
|
||||
})),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// If adding a rule, we got back an id
|
||||
if (newId) {
|
||||
// @ts-expect-error fix this
|
||||
rule.id = newId;
|
||||
}
|
||||
|
||||
// @ts-expect-error fix this
|
||||
originalOnSave?.(rule);
|
||||
}
|
||||
}
|
||||
|
||||
// Enable editing existing split rules even if the feature has since been disabled.
|
||||
|
||||
@@ -14,7 +14,7 @@ import { usePayeesById } from '@desktop-client/hooks/usePayees';
|
||||
import { useSchedules } from '@desktop-client/hooks/useSchedules';
|
||||
|
||||
type ScheduleValueProps = {
|
||||
value: ScheduleEntity['id'];
|
||||
value: ScheduleEntity;
|
||||
};
|
||||
|
||||
export function ScheduleValue({ value }: ScheduleValueProps) {
|
||||
@@ -35,13 +35,12 @@ export function ScheduleValue({ value }: ScheduleValueProps) {
|
||||
<Value
|
||||
value={value}
|
||||
field="rule"
|
||||
describe={val => {
|
||||
const schedule = schedules.find(s => s.id === val);
|
||||
if (!schedule) {
|
||||
return t('(deleted)');
|
||||
}
|
||||
return describeSchedule(schedule, byId[schedule._payee]);
|
||||
}}
|
||||
data={schedules}
|
||||
// TODO: this manual type coercion does not make much sense -
|
||||
// should we instead do `schedule._payee.id`?
|
||||
describe={schedule =>
|
||||
describeSchedule(schedule, byId[schedule._payee as unknown as string])
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ type ValueProps<T> = {
|
||||
field: unknown;
|
||||
valueIsRaw?: boolean;
|
||||
inline?: boolean;
|
||||
data?: unknown;
|
||||
describe?: (item: T) => string;
|
||||
style?: CSSProperties;
|
||||
};
|
||||
@@ -33,7 +34,9 @@ export function Value<T>({
|
||||
field,
|
||||
valueIsRaw,
|
||||
inline = false,
|
||||
describe,
|
||||
data: dataProp,
|
||||
// @ts-expect-error fix this later
|
||||
describe = x => x.name,
|
||||
style,
|
||||
}: ValueProps<T>) {
|
||||
const { t } = useTranslation();
|
||||
@@ -53,6 +56,32 @@ export function Value<T>({
|
||||
};
|
||||
const ValueText = field === 'amount' ? FinancialText : Text;
|
||||
const locale = useLocale();
|
||||
|
||||
function getData() {
|
||||
if (dataProp) {
|
||||
return dataProp;
|
||||
}
|
||||
|
||||
switch (field) {
|
||||
case 'payee':
|
||||
return payees;
|
||||
|
||||
case 'category':
|
||||
return categories;
|
||||
|
||||
case 'category_group':
|
||||
return categoryGroups;
|
||||
|
||||
case 'account':
|
||||
return accounts;
|
||||
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const data = getData();
|
||||
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
function onExpand(e) {
|
||||
@@ -90,39 +119,23 @@ export function Value<T>({
|
||||
case 'payee_name':
|
||||
return value;
|
||||
case 'payee':
|
||||
if (valueIsRaw) {
|
||||
return value;
|
||||
}
|
||||
const payee = payees.find(p => p.id === value);
|
||||
return payee ? (describe?.(value) ?? payee.name) : t('(deleted)');
|
||||
case 'category':
|
||||
if (valueIsRaw) {
|
||||
return value;
|
||||
}
|
||||
const category = categories.find(c => c.id === value);
|
||||
return category
|
||||
? (describe?.(value) ?? category.name)
|
||||
: t('(deleted)');
|
||||
case 'category_group':
|
||||
if (valueIsRaw) {
|
||||
return value;
|
||||
}
|
||||
const categoryGroup = categoryGroups.find(g => g.id === value);
|
||||
return categoryGroup
|
||||
? (describe?.(value) ?? categoryGroup.name)
|
||||
: t('(deleted)');
|
||||
case 'account':
|
||||
if (valueIsRaw) {
|
||||
return value;
|
||||
}
|
||||
const account = accounts.find(a => a.id === value);
|
||||
return account ? (describe?.(value) ?? account.name) : t('(deleted)');
|
||||
case 'rule':
|
||||
if (valueIsRaw) {
|
||||
return value;
|
||||
}
|
||||
if (data && Array.isArray(data)) {
|
||||
const item = data.find(item => item.id === value);
|
||||
if (item) {
|
||||
return describe(item);
|
||||
} else {
|
||||
return t('(deleted)');
|
||||
}
|
||||
}
|
||||
|
||||
return describe?.(value) ?? value;
|
||||
return '…';
|
||||
default:
|
||||
throw new Error(`Unknown field ${String(field)}`);
|
||||
}
|
||||
|
||||
@@ -179,6 +179,7 @@ export function DiscoverSchedules() {
|
||||
for (const schedule of selected) {
|
||||
const scheduleId = await send('schedule/create', {
|
||||
conditions: schedule._conditions,
|
||||
schedule: {},
|
||||
});
|
||||
|
||||
// Now query for matching transactions and link them automatically
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import { t } from 'i18next';
|
||||
|
||||
import { extractScheduleConds } from 'loot-core/shared/schedules';
|
||||
import type {
|
||||
RuleConditionEntity,
|
||||
RuleConditionOp,
|
||||
ScheduleEntity,
|
||||
} from 'loot-core/types/models';
|
||||
import type { RuleConditionOp, ScheduleEntity } from 'loot-core/types/models';
|
||||
|
||||
import type { ScheduleFormFields } from './ScheduleEditForm';
|
||||
|
||||
export function updateScheduleConditions(
|
||||
schedule: Partial<ScheduleEntity>,
|
||||
fields: ScheduleFormFields,
|
||||
): { error?: string; conditions?: RuleConditionEntity[] } {
|
||||
): { error?: string; conditions?: unknown[] } {
|
||||
const conds = extractScheduleConds(schedule._conditions);
|
||||
|
||||
const updateCond = (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { ChangeEvent, KeyboardEvent } from 'react';
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
@@ -124,8 +124,7 @@ export const TagCreationRow = ({ onClose, tags }: TagCreationRowProps) => {
|
||||
}
|
||||
inputProps={{
|
||||
value: tag || '',
|
||||
onInput: ({ target: { value } }: ChangeEvent<HTMLInputElement>) =>
|
||||
setTag(value.replace(/\s/g, '')),
|
||||
onChange: e => setTag(e.target.value.replace(/\s/g, '')),
|
||||
placeholder: t('New tag'),
|
||||
ref: tagInput,
|
||||
}}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { theme } from '@actual-app/components/theme';
|
||||
|
||||
import { send } from 'loot-core/platform/client/connection';
|
||||
import * as monthUtils from 'loot-core/shared/months';
|
||||
import { q } from 'loot-core/shared/query';
|
||||
import { getUpcomingDays } from 'loot-core/shared/schedules';
|
||||
import {
|
||||
addSplitTransaction,
|
||||
@@ -22,6 +23,7 @@ import type {
|
||||
AccountEntity,
|
||||
CategoryEntity,
|
||||
PayeeEntity,
|
||||
RuleActionEntity,
|
||||
RuleConditionEntity,
|
||||
ScheduleEntity,
|
||||
TransactionEntity,
|
||||
@@ -39,11 +41,6 @@ import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
|
||||
import { pushModal } from '@desktop-client/modals/modalsSlice';
|
||||
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
|
||||
import { useDispatch } from '@desktop-client/redux';
|
||||
import {
|
||||
useCreateSingleTimeScheduleFromTransaction,
|
||||
useRunRulesMutation,
|
||||
} from '@desktop-client/rules';
|
||||
|
||||
// When data changes, there are two ways to update the UI:
|
||||
//
|
||||
// * Optimistic updates: we apply the needed updates to local data
|
||||
@@ -87,6 +84,133 @@ async function saveDiffAndApply(diff, changes, onChange, learnCategories) {
|
||||
);
|
||||
}
|
||||
|
||||
export async function createSingleTimeScheduleFromTransaction(
|
||||
transaction: TransactionEntity,
|
||||
): Promise<ScheduleEntity['id']> {
|
||||
const conditions: RuleConditionEntity[] = [
|
||||
{ op: 'is', field: 'date', value: transaction.date },
|
||||
];
|
||||
|
||||
const actions: RuleActionEntity[] = [];
|
||||
|
||||
const conditionFields = ['amount', 'payee', 'account'];
|
||||
|
||||
conditionFields.forEach(field => {
|
||||
const value = transaction[field];
|
||||
if (value != null && value !== '') {
|
||||
conditions.push({
|
||||
op: 'is',
|
||||
field,
|
||||
value,
|
||||
} as RuleConditionEntity);
|
||||
}
|
||||
});
|
||||
|
||||
if (transaction.is_parent && transaction.subtransactions) {
|
||||
if (transaction.notes) {
|
||||
actions.push({
|
||||
op: 'set',
|
||||
field: 'notes',
|
||||
value: transaction.notes,
|
||||
options: {
|
||||
splitIndex: 0,
|
||||
},
|
||||
} as RuleActionEntity);
|
||||
}
|
||||
|
||||
transaction.subtransactions.forEach((split, index) => {
|
||||
const splitIndex = index + 1;
|
||||
|
||||
if (split.amount != null) {
|
||||
actions.push({
|
||||
op: 'set-split-amount',
|
||||
value: split.amount,
|
||||
options: {
|
||||
splitIndex,
|
||||
method: 'fixed-amount',
|
||||
},
|
||||
} as RuleActionEntity);
|
||||
}
|
||||
|
||||
if (split.category) {
|
||||
actions.push({
|
||||
op: 'set',
|
||||
field: 'category',
|
||||
value: split.category,
|
||||
options: {
|
||||
splitIndex,
|
||||
},
|
||||
} as RuleActionEntity);
|
||||
}
|
||||
|
||||
if (split.notes) {
|
||||
actions.push({
|
||||
op: 'set',
|
||||
field: 'notes',
|
||||
value: split.notes,
|
||||
options: {
|
||||
splitIndex,
|
||||
},
|
||||
} as RuleActionEntity);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (transaction.category) {
|
||||
actions.push({
|
||||
op: 'set',
|
||||
field: 'category',
|
||||
value: transaction.category,
|
||||
} as RuleActionEntity);
|
||||
}
|
||||
|
||||
if (transaction.notes) {
|
||||
actions.push({
|
||||
op: 'set',
|
||||
field: 'notes',
|
||||
value: transaction.notes,
|
||||
} as RuleActionEntity);
|
||||
}
|
||||
}
|
||||
|
||||
const formattedDate = monthUtils.format(transaction.date, 'MMM dd, yyyy');
|
||||
const timestamp = Date.now();
|
||||
const scheduleName = `Auto-created future transaction (${formattedDate}) - ${timestamp}`;
|
||||
|
||||
const scheduleId = await send('schedule/create', {
|
||||
conditions,
|
||||
schedule: {
|
||||
posts_transaction: true,
|
||||
name: scheduleName,
|
||||
},
|
||||
});
|
||||
|
||||
if (actions.length > 0) {
|
||||
const schedules = await send(
|
||||
'query',
|
||||
q('schedules').filter({ id: scheduleId }).select('rule').serialize(),
|
||||
);
|
||||
|
||||
const ruleId = schedules?.data?.[0]?.rule;
|
||||
|
||||
if (ruleId) {
|
||||
const rule = await send('rule-get', { id: ruleId });
|
||||
|
||||
if (rule) {
|
||||
const linkScheduleActions = rule.actions.filter(
|
||||
a => a.op === 'link-schedule',
|
||||
);
|
||||
|
||||
await send('rule-update', {
|
||||
...rule,
|
||||
actions: [...linkScheduleActions, ...actions],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return scheduleId;
|
||||
}
|
||||
|
||||
function isFutureTransaction(transaction: TransactionEntity): boolean {
|
||||
const today = monthUtils.currentDay();
|
||||
return transaction.date > today;
|
||||
@@ -257,9 +381,6 @@ export function TransactionList({
|
||||
[dispatch, onRefetch, upcomingLength, t],
|
||||
);
|
||||
|
||||
const { mutateAsync: createSingleTimeScheduleFromTransactionAsync } =
|
||||
useCreateSingleTimeScheduleFromTransaction();
|
||||
|
||||
const onAdd = useCallback(
|
||||
async (newTransactions: TransactionEntity[]) => {
|
||||
newTransactions = realizeTempTransactions(newTransactions);
|
||||
@@ -282,9 +403,9 @@ export function TransactionList({
|
||||
promptToConvertToSchedule(
|
||||
transactionWithSubtransactions,
|
||||
async () => {
|
||||
await createSingleTimeScheduleFromTransactionAsync({
|
||||
transaction: transactionWithSubtransactions,
|
||||
});
|
||||
await createSingleTimeScheduleFromTransaction(
|
||||
transactionWithSubtransactions,
|
||||
);
|
||||
},
|
||||
async () => {
|
||||
await saveDiff(
|
||||
@@ -299,12 +420,7 @@ export function TransactionList({
|
||||
await saveDiff({ added: newTransactions }, isLearnCategoriesEnabled);
|
||||
onRefetch();
|
||||
},
|
||||
[
|
||||
isLearnCategoriesEnabled,
|
||||
onRefetch,
|
||||
promptToConvertToSchedule,
|
||||
createSingleTimeScheduleFromTransactionAsync,
|
||||
],
|
||||
[isLearnCategoriesEnabled, onRefetch, promptToConvertToSchedule],
|
||||
);
|
||||
|
||||
const onSave = useCallback(
|
||||
@@ -350,9 +466,7 @@ export function TransactionList({
|
||||
await send('transaction-delete', { id: transaction.id });
|
||||
}
|
||||
|
||||
await createSingleTimeScheduleFromTransactionAsync({
|
||||
transaction,
|
||||
});
|
||||
await createSingleTimeScheduleFromTransaction(transaction);
|
||||
},
|
||||
saveTransaction,
|
||||
);
|
||||
@@ -362,13 +476,7 @@ export function TransactionList({
|
||||
|
||||
await saveTransaction();
|
||||
},
|
||||
[
|
||||
isLearnCategoriesEnabled,
|
||||
onChange,
|
||||
onRefetch,
|
||||
promptToConvertToSchedule,
|
||||
createSingleTimeScheduleFromTransactionAsync,
|
||||
],
|
||||
[isLearnCategoriesEnabled, onChange, onRefetch, promptToConvertToSchedule],
|
||||
);
|
||||
|
||||
const onAddSplit = useCallback(
|
||||
@@ -401,14 +509,12 @@ export function TransactionList({
|
||||
[isLearnCategoriesEnabled, onChange],
|
||||
);
|
||||
|
||||
const { mutateAsync: runRulesAsync } = useRunRulesMutation();
|
||||
|
||||
const onApplyRules = useCallback(
|
||||
async (
|
||||
transaction: TransactionEntity,
|
||||
updatedFieldName: string | null = null,
|
||||
) => {
|
||||
const afterRules = await runRulesAsync({ transaction });
|
||||
const afterRules = await send('rules-run', { transaction });
|
||||
|
||||
// Show formula errors if any
|
||||
if (afterRules._ruleErrors && afterRules._ruleErrors.length > 0) {
|
||||
@@ -456,7 +562,7 @@ export function TransactionList({
|
||||
}
|
||||
return newTransaction;
|
||||
},
|
||||
[dispatch, runRulesAsync],
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const onManagePayees = useCallback(
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import type { PayeeEntity } from 'loot-core/types/models';
|
||||
|
||||
import { ruleQueries } from '@desktop-client/rules/queries';
|
||||
|
||||
export function usePayeeRules({
|
||||
payeeId,
|
||||
}: {
|
||||
payeeId?: PayeeEntity['id'] | null;
|
||||
}) {
|
||||
return useQuery(ruleQueries.listPayee({ payeeId }));
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { send } from 'loot-core/platform/client/connection';
|
||||
import { computeSchedulePreviewTransactions } from 'loot-core/shared/schedules';
|
||||
import { ungroupTransactions } from 'loot-core/shared/transactions';
|
||||
import type { IntegerAmount } from 'loot-core/shared/util';
|
||||
@@ -9,8 +10,6 @@ import { useCachedSchedules } from './useCachedSchedules';
|
||||
import { useSyncedPref } from './useSyncedPref';
|
||||
import { calculateRunningBalancesBottomUp } from './useTransactions';
|
||||
|
||||
import { useRunRulesMutation } from '@desktop-client/rules/mutations';
|
||||
|
||||
type UsePreviewTransactionsProps = {
|
||||
filter?: (schedule: ScheduleEntity) => boolean;
|
||||
options?: {
|
||||
@@ -64,8 +63,6 @@ export function usePreviewTransactions({
|
||||
);
|
||||
}, [filter, isSchedulesLoading, schedules, statuses, upcomingLength]);
|
||||
|
||||
const { mutateAsync: runRulesAsync } = useRunRulesMutation();
|
||||
|
||||
useEffect(() => {
|
||||
let isUnmounted = false;
|
||||
|
||||
@@ -82,7 +79,7 @@ export function usePreviewTransactions({
|
||||
Promise.all(
|
||||
scheduleTransactions.map(transaction =>
|
||||
// Kick off an async rules application
|
||||
runRulesAsync({ transaction }),
|
||||
send('rules-run', { transaction }),
|
||||
),
|
||||
)
|
||||
.then(newTrans => {
|
||||
@@ -116,13 +113,7 @@ export function usePreviewTransactions({
|
||||
return () => {
|
||||
isUnmounted = true;
|
||||
};
|
||||
}, [
|
||||
scheduleTransactions,
|
||||
schedules,
|
||||
statuses,
|
||||
upcomingLength,
|
||||
runRulesAsync,
|
||||
]);
|
||||
}, [scheduleTransactions, schedules, statuses, upcomingLength]);
|
||||
|
||||
const runningBalances = useMemo(() => {
|
||||
if (!options?.calculateRunningBalances) {
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { UseQueryOptions } from '@tanstack/react-query';
|
||||
|
||||
import type { RuleEntity } from 'loot-core/types/models';
|
||||
|
||||
import { ruleQueries } from '@desktop-client/rules/queries';
|
||||
|
||||
type UseRulesOptions = Pick<UseQueryOptions<RuleEntity[]>, 'enabled'>;
|
||||
|
||||
export function useRules(options?: UseRulesOptions) {
|
||||
return useQuery({
|
||||
...ruleQueries.list(),
|
||||
...(options ?? {}),
|
||||
});
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './queries';
|
||||
export * from './mutations';
|
||||
@@ -1,413 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import type { QueryClient, QueryKey } from '@tanstack/react-query';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { send } from 'loot-core/platform/client/connection';
|
||||
import * as monthUtils from 'loot-core/shared/months';
|
||||
import { q } from 'loot-core/shared/query';
|
||||
import type {
|
||||
NewRuleEntity,
|
||||
PayeeEntity,
|
||||
RuleActionEntity,
|
||||
RuleConditionEntity,
|
||||
RuleEntity,
|
||||
ScheduleEntity,
|
||||
TransactionEntity,
|
||||
} from 'loot-core/types/models';
|
||||
|
||||
import { ruleQueries } from './queries';
|
||||
|
||||
import { useRules } from '@desktop-client/hooks/useRules';
|
||||
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
|
||||
import { aqlQuery } from '@desktop-client/queries/aqlQuery';
|
||||
import { useDispatch } from '@desktop-client/redux';
|
||||
import type { AppDispatch } from '@desktop-client/redux/store';
|
||||
|
||||
function invalidateQueries(queryClient: QueryClient, queryKey?: QueryKey) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: queryKey ?? ruleQueries.lists(),
|
||||
});
|
||||
}
|
||||
|
||||
function dispatchErrorNotification(
|
||||
dispatch: AppDispatch,
|
||||
message: string,
|
||||
error?: Error,
|
||||
) {
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
id: uuidv4(),
|
||||
type: 'error',
|
||||
message,
|
||||
pre: error?.cause ? JSON.stringify(error.cause) : error?.message,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
type AddRulePayload = {
|
||||
rule: Omit<RuleEntity, 'id'>;
|
||||
};
|
||||
|
||||
export function useAddRuleMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ rule }: AddRulePayload) => {
|
||||
return await send('rule-add', rule);
|
||||
},
|
||||
onSuccess: () => invalidateQueries(queryClient),
|
||||
onError: error => {
|
||||
console.error('Error creating rule:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t('There was an error creating the rule. Please try again.'),
|
||||
error,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type UpdateRulePayload = {
|
||||
rule: RuleEntity;
|
||||
};
|
||||
|
||||
export function useUpdateRuleMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ rule }: UpdateRulePayload) => {
|
||||
return await send('rule-update', rule);
|
||||
},
|
||||
onSuccess: () => invalidateQueries(queryClient),
|
||||
onError: error => {
|
||||
console.error('Error updating rule:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t('There was an error updating the rule. Please try again.'),
|
||||
error,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type SaveRulePayload = {
|
||||
rule: RuleEntity | NewRuleEntity;
|
||||
};
|
||||
|
||||
export function useSaveRuleMutation() {
|
||||
const { mutateAsync: updateRuleAsync } = useUpdateRuleMutation();
|
||||
const { mutateAsync: addRuleAsync } = useAddRuleMutation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ rule }: SaveRulePayload) => {
|
||||
if ('id' in rule && rule.id) {
|
||||
return await updateRuleAsync({ rule });
|
||||
} else {
|
||||
return await addRuleAsync({ rule });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type DeleteRulePayload = {
|
||||
id: RuleEntity['id'];
|
||||
};
|
||||
|
||||
export function useDeleteRuleMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id }: DeleteRulePayload) => {
|
||||
return await send('rule-delete', id);
|
||||
},
|
||||
onSuccess: () => invalidateQueries(queryClient),
|
||||
onError: error => {
|
||||
console.error('Error deleting rule:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t('There was an error deleting the rule. Please try again.'),
|
||||
error,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type DeleteAllRulesPayload = {
|
||||
ids: Array<RuleEntity['id']>;
|
||||
};
|
||||
|
||||
export function useBatchDeleteRulesMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ ids }: DeleteAllRulesPayload) => {
|
||||
return await send('rule-delete-all', ids);
|
||||
},
|
||||
onSuccess: () => invalidateQueries(queryClient),
|
||||
onError: error => {
|
||||
console.error('Error deleting rules:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t('There was an error deleting rules. Please try again.'),
|
||||
error,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type ApplyRuleActionsPayload = {
|
||||
transactions: TransactionEntity[];
|
||||
ruleActions: RuleActionEntity[];
|
||||
};
|
||||
|
||||
export function useApplyRuleActionsMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
transactions,
|
||||
ruleActions,
|
||||
}: ApplyRuleActionsPayload) => {
|
||||
const result = await send('rule-apply-actions', {
|
||||
transactions,
|
||||
actions: ruleActions,
|
||||
});
|
||||
if (result && result.errors && result.errors.length > 0) {
|
||||
throw new Error('Error applying rule actions.', {
|
||||
cause: result.errors,
|
||||
});
|
||||
}
|
||||
},
|
||||
onSuccess: () => invalidateQueries(queryClient),
|
||||
onError: error => {
|
||||
console.error('Error applying rule actions:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t('There was an error applying the rule actions. Please try again.'),
|
||||
error,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type AddPayeeRenameRulePayload = {
|
||||
fromNames: Array<PayeeEntity['name']>;
|
||||
to: PayeeEntity['id'];
|
||||
};
|
||||
|
||||
export function useAddPayeeRenameRuleMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ fromNames, to }: AddPayeeRenameRulePayload) => {
|
||||
return await send('rule-add-payee-rename', {
|
||||
fromNames,
|
||||
to,
|
||||
});
|
||||
},
|
||||
onSuccess: () => invalidateQueries(queryClient),
|
||||
onError: error => {
|
||||
console.error('Error adding payee rename rule:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t('There was an error adding the payee rename rule. Please try again.'),
|
||||
error,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type RunRulesPayload = {
|
||||
transaction: TransactionEntity;
|
||||
};
|
||||
|
||||
export function useRunRulesMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ transaction }: RunRulesPayload) => {
|
||||
return await send('rules-run', { transaction });
|
||||
},
|
||||
onSuccess: () => invalidateQueries(queryClient),
|
||||
onError: error => {
|
||||
console.error('Error running rules for transaction:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t(
|
||||
'There was an error running the rules for transaction. Please try again.',
|
||||
),
|
||||
error,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Move to schedules mutations file once we have schedule-related mutations
|
||||
export function useCreateSingleTimeScheduleFromTransaction() {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
const { data: allRules = [] } = useRules();
|
||||
const { mutateAsync: updateRuleAsync } = useUpdateRuleMutation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
transaction,
|
||||
}: {
|
||||
transaction: TransactionEntity;
|
||||
}): Promise<ScheduleEntity['id']> => {
|
||||
const conditions: RuleConditionEntity[] = [
|
||||
{ op: 'is', field: 'date', value: transaction.date },
|
||||
];
|
||||
|
||||
const actions: RuleActionEntity[] = [];
|
||||
|
||||
const conditionFields = ['amount', 'payee', 'account'] as const;
|
||||
|
||||
conditionFields.forEach(field => {
|
||||
const value = transaction[field];
|
||||
if (value != null && value !== '') {
|
||||
conditions.push({
|
||||
op: 'is',
|
||||
field,
|
||||
value,
|
||||
} as RuleConditionEntity);
|
||||
}
|
||||
});
|
||||
|
||||
if (transaction.is_parent && transaction.subtransactions) {
|
||||
if (transaction.notes) {
|
||||
actions.push({
|
||||
op: 'set',
|
||||
field: 'notes',
|
||||
value: transaction.notes,
|
||||
options: {
|
||||
splitIndex: 0,
|
||||
},
|
||||
} as RuleActionEntity);
|
||||
}
|
||||
|
||||
transaction.subtransactions.forEach((split, index) => {
|
||||
const splitIndex = index + 1;
|
||||
|
||||
if (split.amount != null) {
|
||||
actions.push({
|
||||
op: 'set-split-amount',
|
||||
value: split.amount,
|
||||
options: {
|
||||
splitIndex,
|
||||
method: 'fixed-amount',
|
||||
},
|
||||
} as RuleActionEntity);
|
||||
}
|
||||
|
||||
if (split.category) {
|
||||
actions.push({
|
||||
op: 'set',
|
||||
field: 'category',
|
||||
value: split.category,
|
||||
options: {
|
||||
splitIndex,
|
||||
},
|
||||
} as RuleActionEntity);
|
||||
}
|
||||
|
||||
if (split.notes) {
|
||||
actions.push({
|
||||
op: 'set',
|
||||
field: 'notes',
|
||||
value: split.notes,
|
||||
options: {
|
||||
splitIndex,
|
||||
},
|
||||
} as RuleActionEntity);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
if (transaction.category) {
|
||||
actions.push({
|
||||
op: 'set',
|
||||
field: 'category',
|
||||
value: transaction.category,
|
||||
} as RuleActionEntity);
|
||||
}
|
||||
|
||||
if (transaction.notes) {
|
||||
actions.push({
|
||||
op: 'set',
|
||||
field: 'notes',
|
||||
value: transaction.notes,
|
||||
} as RuleActionEntity);
|
||||
}
|
||||
}
|
||||
|
||||
const formattedDate = monthUtils.format(transaction.date, 'MMM dd, yyyy');
|
||||
const timestamp = Date.now();
|
||||
const scheduleName = `Auto-created future transaction (${formattedDate}) - ${timestamp}`;
|
||||
|
||||
const scheduleId = await send('schedule/create', {
|
||||
conditions,
|
||||
schedule: {
|
||||
posts_transaction: true,
|
||||
name: scheduleName,
|
||||
},
|
||||
});
|
||||
|
||||
if (actions.length > 0) {
|
||||
const schedules = await aqlQuery(
|
||||
q('schedules').filter({ id: scheduleId }).select('rule'),
|
||||
);
|
||||
|
||||
const ruleId = schedules?.data?.[0]?.rule;
|
||||
|
||||
if (ruleId) {
|
||||
const rule = allRules.find(r => r.id === ruleId);
|
||||
|
||||
if (rule) {
|
||||
const linkScheduleActions = rule.actions.filter(
|
||||
a => a.op === 'link-schedule',
|
||||
);
|
||||
|
||||
await updateRuleAsync({
|
||||
rule: {
|
||||
...rule,
|
||||
actions: [...linkScheduleActions, ...actions],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return scheduleId;
|
||||
},
|
||||
onSuccess: () => invalidateQueries(queryClient),
|
||||
onError: error => {
|
||||
console.error('Error creating schedule from transaction:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t(
|
||||
'There was an error creating the schedule from the transaction. Please try again.',
|
||||
),
|
||||
error,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { queryOptions } from '@tanstack/react-query';
|
||||
|
||||
import { send } from 'loot-core/platform/client/connection';
|
||||
import type { PayeeEntity, RuleEntity } from 'loot-core/types/models';
|
||||
|
||||
export const ruleQueries = {
|
||||
all: () => ['rules'] as const,
|
||||
lists: () => [...ruleQueries.all(), 'lists'] as const,
|
||||
list: () =>
|
||||
queryOptions<RuleEntity[]>({
|
||||
queryKey: [...ruleQueries.lists()],
|
||||
queryFn: async () => {
|
||||
return await send('rules-get');
|
||||
},
|
||||
staleTime: Infinity,
|
||||
}),
|
||||
listPayee: ({ payeeId }: { payeeId?: PayeeEntity['id'] | null }) =>
|
||||
queryOptions<RuleEntity[]>({
|
||||
queryKey: [...ruleQueries.lists(), { payeeId }] as const,
|
||||
queryFn: async () => {
|
||||
if (!payeeId) {
|
||||
// Should never happen since the query is disabled when payeeId is not provided,
|
||||
// but is needed to satisfy TypeScript.
|
||||
throw new Error('payeeId is required.');
|
||||
}
|
||||
return await send('payees-get-rules', { id: payeeId });
|
||||
},
|
||||
staleTime: Infinity,
|
||||
enabled: !!payeeId,
|
||||
}),
|
||||
};
|
||||
897
packages/desktop-client/src/shared-browser-server-core.test.ts
Normal file
897
packages/desktop-client/src/shared-browser-server-core.test.ts
Normal file
@@ -0,0 +1,897 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Mock } from 'vitest';
|
||||
|
||||
import { createCoordinator } from './shared-browser-server-core';
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────
|
||||
|
||||
type MockPort = {
|
||||
postMessage: Mock;
|
||||
start: Mock;
|
||||
onmessage: ((event: { data: unknown }) => void) | null;
|
||||
};
|
||||
|
||||
type Coordinator = ReturnType<typeof createCoordinator>;
|
||||
|
||||
// ── Test helpers ────────────────────────────────────────────────────────
|
||||
|
||||
function createMockPort(): MockPort {
|
||||
return { postMessage: vi.fn(), start: vi.fn(), onmessage: null };
|
||||
}
|
||||
|
||||
function setup(): Coordinator {
|
||||
return createCoordinator();
|
||||
}
|
||||
|
||||
/** Simulate a new tab connecting to the SharedWorker. */
|
||||
function connectTab(coordinator: Coordinator): MockPort {
|
||||
const port = createMockPort();
|
||||
coordinator.onconnect({ ports: [port] });
|
||||
return port;
|
||||
}
|
||||
|
||||
/** Send a message on behalf of a port (as if the tab sent it). */
|
||||
function sendMsg(port: MockPort, msg: Record<string, unknown>): void {
|
||||
port.onmessage!({ data: msg });
|
||||
}
|
||||
|
||||
/** Send the standard init message from a tab. */
|
||||
function sendInit(port: MockPort): void {
|
||||
sendMsg(port, { type: 'init', version: '1.0', isDev: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulate the leader's Worker reporting that the backend is connected.
|
||||
* In the real flow the Worker sends a 'connect' message → the bridge
|
||||
* wraps it in __from-worker → the SharedWorker broadcasts it.
|
||||
*/
|
||||
function simulateWorkerConnect(leaderPort: MockPort): void {
|
||||
sendMsg(leaderPort, {
|
||||
type: '__from-worker',
|
||||
msg: { type: 'connect' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a fully running budget group with one leader tab.
|
||||
* Returns the leader port.
|
||||
*/
|
||||
function setupBudgetGroup(
|
||||
coordinator: Coordinator,
|
||||
budgetId: string,
|
||||
): MockPort {
|
||||
const leader = connectTab(coordinator);
|
||||
sendInit(leader);
|
||||
leader.postMessage.mockClear();
|
||||
|
||||
// Lobby leader → load budget → migrates lobby to real budget
|
||||
sendMsg(leader, {
|
||||
id: 'lb-1',
|
||||
name: 'load-budget',
|
||||
args: { id: budgetId },
|
||||
});
|
||||
|
||||
// Simulate the Worker reporting connect
|
||||
simulateWorkerConnect(leader);
|
||||
|
||||
// Simulate successful load-budget reply
|
||||
sendMsg(leader, {
|
||||
type: '__from-worker',
|
||||
msg: { type: 'reply', id: 'lb-1', result: {} },
|
||||
});
|
||||
|
||||
leader.postMessage.mockClear();
|
||||
return leader;
|
||||
}
|
||||
|
||||
// ── Tests ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('SharedWorker coordinator', () => {
|
||||
let coordinator: Coordinator;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
coordinator = setup();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
coordinator.destroy();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ── Initialization ──────────────────────────────────────────────────
|
||||
|
||||
describe('initialization', () => {
|
||||
it('first tab is elected as lobby leader', () => {
|
||||
const port = connectTab(coordinator);
|
||||
sendInit(port);
|
||||
|
||||
expect(port.postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: '__role-change',
|
||||
role: 'LEADER',
|
||||
budgetId: '__lobby',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('second tab with no connected backend gets UNASSIGNED role', () => {
|
||||
const port1 = connectTab(coordinator);
|
||||
sendInit(port1);
|
||||
|
||||
const port2 = connectTab(coordinator);
|
||||
sendInit(port2);
|
||||
|
||||
// Second tab should be UNASSIGNED (backend is booting, not connected)
|
||||
expect(port2.postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: '__role-change',
|
||||
role: 'UNASSIGNED',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('second tab gets connect message when backend is already running', () => {
|
||||
setupBudgetGroup(coordinator, 'budget-1');
|
||||
|
||||
const port2 = connectTab(coordinator);
|
||||
sendInit(port2);
|
||||
|
||||
expect(port2.postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: '__role-change', role: 'UNASSIGNED' }),
|
||||
);
|
||||
expect(port2.postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'connect' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('caches init message for new leaders', () => {
|
||||
const port1 = connectTab(coordinator);
|
||||
const initMsg = { type: 'init', version: '2.0', isDev: true };
|
||||
sendMsg(port1, initMsg);
|
||||
|
||||
expect(coordinator.getState().cachedInitMsg).toEqual(initMsg);
|
||||
});
|
||||
|
||||
it('sends cached init failure to late-joining tabs', () => {
|
||||
// Set up a leader whose Worker reports init failure
|
||||
const leader = connectTab(coordinator);
|
||||
sendInit(leader);
|
||||
simulateWorkerConnect(leader);
|
||||
|
||||
sendMsg(leader, {
|
||||
type: '__from-worker',
|
||||
msg: { type: 'app-init-failure', error: 'boom' },
|
||||
});
|
||||
|
||||
const port2 = connectTab(coordinator);
|
||||
sendInit(port2);
|
||||
|
||||
expect(port2.postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'app-init-failure', error: 'boom' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Load budget ─────────────────────────────────────────────────────
|
||||
|
||||
describe('load-budget', () => {
|
||||
it('lobby leader migrates to real budget group on load-budget', () => {
|
||||
const leader = connectTab(coordinator);
|
||||
sendInit(leader);
|
||||
leader.postMessage.mockClear();
|
||||
|
||||
sendMsg(leader, {
|
||||
id: 'lb-1',
|
||||
name: 'load-budget',
|
||||
args: { id: 'my-budget' },
|
||||
});
|
||||
|
||||
const state = coordinator.getState();
|
||||
expect(state.budgetGroups.has('__lobby')).toBe(false);
|
||||
expect(state.budgetGroups.has('my-budget')).toBe(true);
|
||||
expect(state.portToBudget.get(leader)).toBe('my-budget');
|
||||
|
||||
// Should have forwarded load-budget to the Worker
|
||||
expect(leader.postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: '__to-worker',
|
||||
msg: expect.objectContaining({ name: 'load-budget' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('second tab joins existing budget as follower', () => {
|
||||
const leader = setupBudgetGroup(coordinator, 'budget-1');
|
||||
|
||||
const follower = connectTab(coordinator);
|
||||
sendInit(follower);
|
||||
follower.postMessage.mockClear();
|
||||
|
||||
sendMsg(follower, {
|
||||
id: 'lb-2',
|
||||
name: 'load-budget',
|
||||
args: { id: 'budget-1' },
|
||||
});
|
||||
|
||||
expect(follower.postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: '__role-change',
|
||||
role: 'FOLLOWER',
|
||||
budgetId: 'budget-1',
|
||||
}),
|
||||
);
|
||||
|
||||
// Should also get connect since backend is already running
|
||||
expect(follower.postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'connect' }),
|
||||
);
|
||||
|
||||
const group = coordinator.getState().budgetGroups.get('budget-1');
|
||||
expect(group.followers.has(follower)).toBe(true);
|
||||
});
|
||||
|
||||
it('new tab on unloaded budget becomes leader for that budget', () => {
|
||||
// Set up budget-1 so the lobby is consumed
|
||||
setupBudgetGroup(coordinator, 'budget-1');
|
||||
|
||||
const tab2 = connectTab(coordinator);
|
||||
sendInit(tab2);
|
||||
tab2.postMessage.mockClear();
|
||||
|
||||
sendMsg(tab2, {
|
||||
id: 'lb-3',
|
||||
name: 'load-budget',
|
||||
args: { id: 'budget-2' },
|
||||
});
|
||||
|
||||
expect(tab2.postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: '__role-change',
|
||||
role: 'LEADER',
|
||||
budgetId: 'budget-2',
|
||||
}),
|
||||
);
|
||||
|
||||
const state = coordinator.getState();
|
||||
expect(state.budgetGroups.has('budget-2')).toBe(true);
|
||||
expect(state.budgetGroups.get('budget-2').leaderPort).toBe(tab2);
|
||||
});
|
||||
|
||||
it('leader switching budgets pushes followers off old budget', () => {
|
||||
const leader = setupBudgetGroup(coordinator, 'budget-1');
|
||||
|
||||
// Add a follower
|
||||
const follower = connectTab(coordinator);
|
||||
sendInit(follower);
|
||||
sendMsg(follower, {
|
||||
id: 'lb-f',
|
||||
name: 'load-budget',
|
||||
args: { id: 'budget-1' },
|
||||
});
|
||||
follower.postMessage.mockClear();
|
||||
|
||||
// Leader loads a different budget
|
||||
sendMsg(leader, {
|
||||
id: 'lb-switch',
|
||||
name: 'load-budget',
|
||||
args: { id: 'budget-2' },
|
||||
});
|
||||
|
||||
// Follower should be pushed to show-budgets
|
||||
expect(follower.postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'push', name: 'show-budgets' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Close budget ────────────────────────────────────────────────────
|
||||
|
||||
describe('close-budget', () => {
|
||||
it('follower gets synthetic reply and leaves group', () => {
|
||||
setupBudgetGroup(coordinator, 'budget-1');
|
||||
|
||||
const follower = connectTab(coordinator);
|
||||
sendInit(follower);
|
||||
sendMsg(follower, {
|
||||
id: 'lb-f',
|
||||
name: 'load-budget',
|
||||
args: { id: 'budget-1' },
|
||||
});
|
||||
follower.postMessage.mockClear();
|
||||
|
||||
sendMsg(follower, { id: 'cb-1', name: 'close-budget' });
|
||||
|
||||
expect(follower.postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'reply', id: 'cb-1', data: {} }),
|
||||
);
|
||||
|
||||
const group = coordinator.getState().budgetGroups.get('budget-1');
|
||||
expect(group.followers.has(follower)).toBe(false);
|
||||
expect(coordinator.getState().unassignedPorts.has(follower)).toBe(true);
|
||||
});
|
||||
|
||||
it('leader with followers transfers leadership', () => {
|
||||
const leader = setupBudgetGroup(coordinator, 'budget-1');
|
||||
|
||||
const follower = connectTab(coordinator);
|
||||
sendInit(follower);
|
||||
sendMsg(follower, {
|
||||
id: 'lb-f',
|
||||
name: 'load-budget',
|
||||
args: { id: 'budget-1' },
|
||||
});
|
||||
follower.postMessage.mockClear();
|
||||
leader.postMessage.mockClear();
|
||||
|
||||
sendMsg(leader, { id: 'cb-leader', name: 'close-budget' });
|
||||
|
||||
// Leader should get __close-and-transfer
|
||||
expect(leader.postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: '__close-and-transfer',
|
||||
requestId: 'cb-leader',
|
||||
}),
|
||||
);
|
||||
|
||||
// Follower should be promoted to leader
|
||||
expect(follower.postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: '__role-change',
|
||||
role: 'LEADER',
|
||||
budgetId: 'budget-1',
|
||||
}),
|
||||
);
|
||||
|
||||
const group = coordinator.getState().budgetGroups.get('budget-1');
|
||||
expect(group.leaderPort).toBe(follower);
|
||||
});
|
||||
|
||||
it('leader with no followers forwards close to Worker', () => {
|
||||
const leader = setupBudgetGroup(coordinator, 'budget-1');
|
||||
leader.postMessage.mockClear();
|
||||
|
||||
sendMsg(leader, { id: 'cb-solo', name: 'close-budget' });
|
||||
|
||||
expect(leader.postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: '__to-worker',
|
||||
msg: expect.objectContaining({ name: 'close-budget' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('close-budget reply from Worker cleans up group', () => {
|
||||
const leader = setupBudgetGroup(coordinator, 'budget-1');
|
||||
|
||||
sendMsg(leader, { id: 'cb-solo', name: 'close-budget' });
|
||||
|
||||
// Simulate Worker reply
|
||||
sendMsg(leader, {
|
||||
type: '__from-worker',
|
||||
msg: { type: 'reply', id: 'cb-solo', result: {} },
|
||||
});
|
||||
|
||||
expect(coordinator.getState().budgetGroups.has('budget-1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Tab disconnection & failover ────────────────────────────────────
|
||||
|
||||
describe('tab disconnection', () => {
|
||||
it('leader disconnect promotes follower', () => {
|
||||
setupBudgetGroup(coordinator, 'budget-1');
|
||||
|
||||
const follower = connectTab(coordinator);
|
||||
sendInit(follower);
|
||||
sendMsg(follower, {
|
||||
id: 'lb-f',
|
||||
name: 'load-budget',
|
||||
args: { id: 'budget-1' },
|
||||
});
|
||||
follower.postMessage.mockClear();
|
||||
|
||||
// Find current leader to disconnect it
|
||||
const group = coordinator.getState().budgetGroups.get('budget-1');
|
||||
const leader = group.leaderPort as MockPort;
|
||||
|
||||
// Leader closes tab
|
||||
sendMsg(leader, { type: 'tab-closing' });
|
||||
|
||||
// Follower should be promoted
|
||||
expect(follower.postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: '__role-change',
|
||||
role: 'LEADER',
|
||||
budgetId: 'budget-1',
|
||||
}),
|
||||
);
|
||||
|
||||
const updatedGroup = coordinator.getState().budgetGroups.get('budget-1');
|
||||
expect(updatedGroup.leaderPort).toBe(follower);
|
||||
});
|
||||
|
||||
it('last tab leaving removes budget group', () => {
|
||||
const leader = setupBudgetGroup(coordinator, 'budget-1');
|
||||
|
||||
sendMsg(leader, { type: 'tab-closing' });
|
||||
|
||||
expect(coordinator.getState().budgetGroups.has('budget-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('follower disconnect cleans up group membership', () => {
|
||||
setupBudgetGroup(coordinator, 'budget-1');
|
||||
|
||||
const follower = connectTab(coordinator);
|
||||
sendInit(follower);
|
||||
sendMsg(follower, {
|
||||
id: 'lb-f',
|
||||
name: 'load-budget',
|
||||
args: { id: 'budget-1' },
|
||||
});
|
||||
|
||||
const group = coordinator.getState().budgetGroups.get('budget-1');
|
||||
expect(group.followers.has(follower)).toBe(true);
|
||||
|
||||
sendMsg(follower, { type: 'tab-closing' });
|
||||
|
||||
expect(group.followers.has(follower)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Heartbeat ───────────────────────────────────────────────────────
|
||||
|
||||
describe('heartbeat', () => {
|
||||
it('sends heartbeat pings to all connected ports', () => {
|
||||
const port1 = connectTab(coordinator);
|
||||
sendInit(port1);
|
||||
port1.postMessage.mockClear();
|
||||
|
||||
vi.advanceTimersByTime(10_000);
|
||||
|
||||
expect(port1.postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: '__heartbeat-ping' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('removes ports that do not respond to heartbeat', () => {
|
||||
const leader = setupBudgetGroup(coordinator, 'budget-1');
|
||||
|
||||
// First heartbeat — marks port as pending
|
||||
vi.advanceTimersByTime(10_000);
|
||||
// Second heartbeat — port didn't respond, gets removed
|
||||
vi.advanceTimersByTime(10_000);
|
||||
|
||||
expect(coordinator.getState().connectedPorts.includes(leader)).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps ports that respond with pong', () => {
|
||||
const leader = setupBudgetGroup(coordinator, 'budget-1');
|
||||
|
||||
// First heartbeat
|
||||
vi.advanceTimersByTime(10_000);
|
||||
// Respond with pong
|
||||
sendMsg(leader, { type: '__heartbeat-pong' });
|
||||
// Second heartbeat — should NOT remove the port
|
||||
vi.advanceTimersByTime(10_000);
|
||||
|
||||
expect(coordinator.getState().connectedPorts.includes(leader)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Worker message routing ──────────────────────────────────────────
|
||||
|
||||
describe('Worker message routing', () => {
|
||||
it('routes reply to the port that sent the request', () => {
|
||||
const leader = setupBudgetGroup(coordinator, 'budget-1');
|
||||
|
||||
const follower = connectTab(coordinator);
|
||||
sendInit(follower);
|
||||
sendMsg(follower, {
|
||||
id: 'lb-f',
|
||||
name: 'load-budget',
|
||||
args: { id: 'budget-1' },
|
||||
});
|
||||
|
||||
// Follower sends a request
|
||||
follower.postMessage.mockClear();
|
||||
sendMsg(follower, { id: 'req-1', name: 'get-budgets' });
|
||||
|
||||
// Worker replies
|
||||
sendMsg(leader, {
|
||||
type: '__from-worker',
|
||||
msg: { type: 'reply', id: 'req-1', result: ['b1'] },
|
||||
});
|
||||
|
||||
expect(follower.postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'reply',
|
||||
id: 'req-1',
|
||||
result: ['b1'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('broadcasts connect to entire group and unassigned ports', () => {
|
||||
const leader = connectTab(coordinator);
|
||||
sendInit(leader);
|
||||
|
||||
// Load budget (still in lobby migration)
|
||||
sendMsg(leader, {
|
||||
id: 'lb-1',
|
||||
name: 'load-budget',
|
||||
args: { id: 'budget-1' },
|
||||
});
|
||||
|
||||
const unassigned = connectTab(coordinator);
|
||||
sendInit(unassigned);
|
||||
unassigned.postMessage.mockClear();
|
||||
|
||||
// Worker reports connected
|
||||
simulateWorkerConnect(leader);
|
||||
|
||||
expect(unassigned.postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'connect' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards fire-and-forget messages (no id) to Worker', () => {
|
||||
const leader = setupBudgetGroup(coordinator, 'budget-1');
|
||||
|
||||
const follower = connectTab(coordinator);
|
||||
sendInit(follower);
|
||||
sendMsg(follower, {
|
||||
id: 'lb-f',
|
||||
name: 'load-budget',
|
||||
args: { id: 'budget-1' },
|
||||
});
|
||||
leader.postMessage.mockClear();
|
||||
|
||||
// Fire-and-forget message (no id)
|
||||
sendMsg(follower, { type: 'client-connected-to-backend' });
|
||||
|
||||
expect(leader.postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: '__to-worker',
|
||||
msg: expect.objectContaining({
|
||||
type: 'client-connected-to-backend',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('unassigned ports route to any connected group', () => {
|
||||
const leader = setupBudgetGroup(coordinator, 'budget-1');
|
||||
|
||||
const unassigned = connectTab(coordinator);
|
||||
sendInit(unassigned);
|
||||
leader.postMessage.mockClear();
|
||||
|
||||
sendMsg(unassigned, { id: 'req-u', name: 'get-budgets' });
|
||||
|
||||
expect(leader.postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: '__to-worker',
|
||||
msg: expect.objectContaining({ name: 'get-budgets' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Budget-replacing operations ─────────────────────────────────────
|
||||
|
||||
describe('budget-replacing operations', () => {
|
||||
it.each(['create-budget', 'import-budget', 'duplicate-budget'])(
|
||||
'%s from follower gets own temporary Worker',
|
||||
(opName: string) => {
|
||||
setupBudgetGroup(coordinator, 'budget-1');
|
||||
|
||||
const follower = connectTab(coordinator);
|
||||
sendInit(follower);
|
||||
sendMsg(follower, {
|
||||
id: 'lb-f',
|
||||
name: 'load-budget',
|
||||
args: { id: 'budget-1' },
|
||||
});
|
||||
follower.postMessage.mockClear();
|
||||
|
||||
sendMsg(follower, { id: 'op-1', name: opName });
|
||||
|
||||
// Follower should be elected as leader for a temp group
|
||||
expect(follower.postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: '__role-change',
|
||||
role: 'LEADER',
|
||||
}),
|
||||
);
|
||||
|
||||
// The temp group should exist
|
||||
const state = coordinator.getState();
|
||||
const tempBudget = state.portToBudget.get(follower);
|
||||
expect(tempBudget).toMatch(/^__creating-/);
|
||||
},
|
||||
);
|
||||
|
||||
it('create-budget from leader pushes followers off', () => {
|
||||
const leader = setupBudgetGroup(coordinator, 'budget-1');
|
||||
|
||||
const follower = connectTab(coordinator);
|
||||
sendInit(follower);
|
||||
sendMsg(follower, {
|
||||
id: 'lb-f',
|
||||
name: 'load-budget',
|
||||
args: { id: 'budget-1' },
|
||||
});
|
||||
follower.postMessage.mockClear();
|
||||
|
||||
sendMsg(leader, { id: 'cb-1', name: 'create-budget' });
|
||||
|
||||
expect(follower.postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'push', name: 'show-budgets' }),
|
||||
);
|
||||
|
||||
const group = coordinator.getState().budgetGroups.get('budget-1');
|
||||
expect(group.followers.size).toBe(0);
|
||||
});
|
||||
|
||||
it('create-demo-budget evicts existing _demo-budget group', () => {
|
||||
const demoLeader = setupBudgetGroup(coordinator, '_demo-budget');
|
||||
|
||||
const tab2 = connectTab(coordinator);
|
||||
sendInit(tab2);
|
||||
tab2.postMessage.mockClear();
|
||||
|
||||
sendMsg(tab2, { id: 'cdb-1', name: 'create-demo-budget' });
|
||||
|
||||
// Old demo leader should have been evicted
|
||||
expect(demoLeader.postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: '__close-and-transfer',
|
||||
requestId: null,
|
||||
}),
|
||||
);
|
||||
expect(demoLeader.postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'push', name: 'show-budgets' }),
|
||||
);
|
||||
|
||||
expect(coordinator.getState().budgetGroups.has('_demo-budget')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('create-budget with testMode evicts existing _test-budget group', () => {
|
||||
const testLeader = setupBudgetGroup(coordinator, '_test-budget');
|
||||
|
||||
const tab2 = connectTab(coordinator);
|
||||
sendInit(tab2);
|
||||
tab2.postMessage.mockClear();
|
||||
|
||||
sendMsg(tab2, {
|
||||
id: 'ctb-1',
|
||||
name: 'create-budget',
|
||||
args: { testMode: true },
|
||||
});
|
||||
|
||||
expect(testLeader.postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: '__close-and-transfer',
|
||||
requestId: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('load-prefs reply renames __creating- temp group to real budget ID', () => {
|
||||
setupBudgetGroup(coordinator, 'budget-1');
|
||||
|
||||
const creator = connectTab(coordinator);
|
||||
sendInit(creator);
|
||||
sendMsg(creator, {
|
||||
id: 'lb-f',
|
||||
name: 'load-budget',
|
||||
args: { id: 'budget-1' },
|
||||
});
|
||||
|
||||
// Creator sends create-budget → gets temp Worker
|
||||
sendMsg(creator, { id: 'cb-1', name: 'create-budget' });
|
||||
|
||||
const tempId = coordinator.getState().portToBudget.get(creator);
|
||||
expect(tempId).toMatch(/^__creating-/);
|
||||
|
||||
// Simulate backend connect for the temp group
|
||||
simulateWorkerConnect(creator);
|
||||
|
||||
// Track a load-prefs request
|
||||
sendMsg(creator, { id: 'lp-1', name: 'load-prefs' });
|
||||
|
||||
// Worker replies with load-prefs containing the new budget ID
|
||||
sendMsg(creator, {
|
||||
type: '__from-worker',
|
||||
msg: { type: 'reply', id: 'lp-1', result: { id: 'new-budget-123' } },
|
||||
});
|
||||
|
||||
const state = coordinator.getState();
|
||||
expect(state.budgetGroups.has(tempId)).toBe(false);
|
||||
expect(state.budgetGroups.has('new-budget-123')).toBe(true);
|
||||
expect(state.portToBudget.get(creator)).toBe('new-budget-123');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Delete budget ───────────────────────────────────────────────────
|
||||
|
||||
describe('delete-budget', () => {
|
||||
it('evicts the group running the deleted budget', () => {
|
||||
const leader1 = setupBudgetGroup(coordinator, 'budget-1');
|
||||
const leader2 = setupBudgetGroup(coordinator, 'budget-2');
|
||||
|
||||
leader1.postMessage.mockClear();
|
||||
|
||||
// Tab on budget-2 deletes budget-1
|
||||
sendMsg(leader2, {
|
||||
id: 'db-1',
|
||||
name: 'delete-budget',
|
||||
args: { id: 'budget-1' },
|
||||
});
|
||||
|
||||
// budget-1 leader should be evicted
|
||||
expect(leader1.postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: '__close-and-transfer',
|
||||
requestId: null,
|
||||
}),
|
||||
);
|
||||
expect(coordinator.getState().budgetGroups.has('budget-1')).toBe(false);
|
||||
});
|
||||
|
||||
it('spins up temp Worker when no connected group remains after eviction', () => {
|
||||
setupBudgetGroup(coordinator, 'budget-1');
|
||||
|
||||
// A new unassigned tab tries to delete budget-1
|
||||
const deleter = connectTab(coordinator);
|
||||
sendInit(deleter);
|
||||
deleter.postMessage.mockClear();
|
||||
|
||||
sendMsg(deleter, {
|
||||
id: 'db-1',
|
||||
name: 'delete-budget',
|
||||
args: { id: 'budget-1' },
|
||||
});
|
||||
|
||||
// After evicting budget-1, no connected group remains
|
||||
// → deleter should get a temp Worker
|
||||
const tempId = coordinator.getState().portToBudget.get(deleter);
|
||||
expect(tempId).toMatch(/^__deleting-/);
|
||||
|
||||
expect(deleter.postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: '__role-change',
|
||||
role: 'LEADER',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Track restore ───────────────────────────────────────────────────
|
||||
|
||||
describe('__track-restore', () => {
|
||||
it('registers a budget restore for reply routing', () => {
|
||||
const leader = setupBudgetGroup(coordinator, 'budget-1');
|
||||
|
||||
sendMsg(leader, {
|
||||
type: '__track-restore',
|
||||
requestId: 'restore-1',
|
||||
budgetId: 'budget-1',
|
||||
});
|
||||
|
||||
const group = coordinator.getState().budgetGroups.get('budget-1');
|
||||
expect(group.requestToPort.get('restore-1')).toBe(leader);
|
||||
expect(group.requestNames.get('restore-1')).toBe('load-budget');
|
||||
expect(group.requestBudgetIds.get('restore-1')).toBe('budget-1');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Multiple budgets ────────────────────────────────────────────────
|
||||
|
||||
describe('multiple budgets', () => {
|
||||
it('supports multiple independent budget groups', () => {
|
||||
const leader1 = setupBudgetGroup(coordinator, 'budget-1');
|
||||
const leader2 = setupBudgetGroup(coordinator, 'budget-2');
|
||||
|
||||
const state = coordinator.getState();
|
||||
expect(state.budgetGroups.size).toBe(2);
|
||||
expect(state.budgetGroups.get('budget-1').leaderPort).toBe(leader1);
|
||||
expect(state.budgetGroups.get('budget-2').leaderPort).toBe(leader2);
|
||||
});
|
||||
|
||||
it('requests from one group do not leak into another', () => {
|
||||
const leader1 = setupBudgetGroup(coordinator, 'budget-1');
|
||||
const leader2 = setupBudgetGroup(coordinator, 'budget-2');
|
||||
|
||||
// Follower joins budget-1
|
||||
const follower = connectTab(coordinator);
|
||||
sendInit(follower);
|
||||
sendMsg(follower, {
|
||||
id: 'lb-f',
|
||||
name: 'load-budget',
|
||||
args: { id: 'budget-1' },
|
||||
});
|
||||
|
||||
follower.postMessage.mockClear();
|
||||
leader2.postMessage.mockClear();
|
||||
|
||||
// Follower sends request — should go to budget-1's leader only
|
||||
sendMsg(follower, { id: 'req-1', name: 'some-action' });
|
||||
|
||||
expect(leader1.postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: '__to-worker' }),
|
||||
);
|
||||
// leader2 should NOT have received this
|
||||
expect(leader2.postMessage).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: '__to-worker' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Eviction ────────────────────────────────────────────────────────
|
||||
|
||||
describe('evictGroup', () => {
|
||||
it('evicts followers and leader, sending them to show-budgets', () => {
|
||||
const leader = setupBudgetGroup(coordinator, 'budget-1');
|
||||
|
||||
const f1 = connectTab(coordinator);
|
||||
sendInit(f1);
|
||||
sendMsg(f1, {
|
||||
id: 'lb-f1',
|
||||
name: 'load-budget',
|
||||
args: { id: 'budget-1' },
|
||||
});
|
||||
|
||||
const f2 = connectTab(coordinator);
|
||||
sendInit(f2);
|
||||
sendMsg(f2, {
|
||||
id: 'lb-f2',
|
||||
name: 'load-budget',
|
||||
args: { id: 'budget-1' },
|
||||
});
|
||||
|
||||
f1.postMessage.mockClear();
|
||||
f2.postMessage.mockClear();
|
||||
leader.postMessage.mockClear();
|
||||
|
||||
// Another tab triggers deletion of budget-1
|
||||
const deleter = connectTab(coordinator);
|
||||
sendInit(deleter);
|
||||
// Set up a second budget so the deleter has a Worker
|
||||
sendMsg(deleter, {
|
||||
id: 'lb-del',
|
||||
name: 'load-budget',
|
||||
args: { id: 'budget-other' },
|
||||
});
|
||||
simulateWorkerConnect(deleter);
|
||||
sendMsg(deleter, {
|
||||
type: '__from-worker',
|
||||
msg: { type: 'reply', id: 'lb-del', result: {} },
|
||||
});
|
||||
|
||||
sendMsg(deleter, {
|
||||
id: 'db-1',
|
||||
name: 'delete-budget',
|
||||
args: { id: 'budget-1' },
|
||||
});
|
||||
|
||||
// All budget-1 tabs should be pushed to show-budgets
|
||||
expect(f1.postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'push', name: 'show-budgets' }),
|
||||
);
|
||||
expect(f2.postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'push', name: 'show-budgets' }),
|
||||
);
|
||||
expect(leader.postMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: 'push', name: 'show-budgets' }),
|
||||
);
|
||||
|
||||
expect(coordinator.getState().budgetGroups.has('budget-1')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
738
packages/desktop-client/src/shared-browser-server-core.ts
Normal file
738
packages/desktop-client/src/shared-browser-server-core.ts
Normal file
@@ -0,0 +1,738 @@
|
||||
// Core coordinator logic for multi-tab, multi-budget SharedWorker support.
|
||||
//
|
||||
// This module exports a factory so it can be tested independently.
|
||||
// The SharedWorker entry point (shared-browser-server.js) calls
|
||||
// createCoordinator() and wires the result to self.onconnect.
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────
|
||||
|
||||
type ConsoleLevel = 'log' | 'warn' | 'error' | 'info';
|
||||
|
||||
/** Minimal port interface (subset of MessagePort used by the coordinator). */
|
||||
export type CoordinatorPort = {
|
||||
postMessage(msg: unknown): void;
|
||||
start(): void;
|
||||
onmessage: ((event: { data: Record<string, unknown> }) => void) | null;
|
||||
};
|
||||
|
||||
type BudgetGroup = {
|
||||
leaderPort: CoordinatorPort;
|
||||
followers: Set<CoordinatorPort>;
|
||||
backendConnected: boolean;
|
||||
requestToPort: Map<string, CoordinatorPort>;
|
||||
requestNames: Map<string, string>;
|
||||
requestBudgetIds: Map<string, string>;
|
||||
};
|
||||
|
||||
type CoordinatorOptions = {
|
||||
enableConsoleForwarding?: boolean;
|
||||
};
|
||||
|
||||
// ── Factory ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function createCoordinator({
|
||||
enableConsoleForwarding = false,
|
||||
}: CoordinatorOptions = {}) {
|
||||
// ── State ──────────────────────────────────────────────────────────────
|
||||
|
||||
const connectedPorts: CoordinatorPort[] = [];
|
||||
let cachedInitMsg: Record<string, unknown> | null = null;
|
||||
let lastAppInitFailure: Record<string, unknown> | null = null;
|
||||
const pendingPongs = new Set<CoordinatorPort>();
|
||||
|
||||
const budgetGroups = new Map<string, BudgetGroup>();
|
||||
const portToBudget = new Map<CoordinatorPort, string>();
|
||||
const unassignedPorts = new Set<CoordinatorPort>();
|
||||
|
||||
// ── Console forwarding ─────────────────────────────────────────────────
|
||||
|
||||
if (enableConsoleForwarding) {
|
||||
const _originalConsole = {
|
||||
log: console.log.bind(console),
|
||||
warn: console.warn.bind(console),
|
||||
error: console.error.bind(console),
|
||||
info: console.info.bind(console),
|
||||
};
|
||||
|
||||
function forwardConsole(level: ConsoleLevel, args: unknown[]) {
|
||||
_originalConsole[level](...args);
|
||||
const serialized = args.map(a => {
|
||||
if (a instanceof Error) return a.stack || a.message;
|
||||
if (typeof a === 'object') {
|
||||
try {
|
||||
return JSON.stringify(a);
|
||||
} catch {
|
||||
return String(a);
|
||||
}
|
||||
}
|
||||
return String(a);
|
||||
});
|
||||
for (const port of connectedPorts) {
|
||||
port.postMessage({
|
||||
type: '__shared-worker-console',
|
||||
level,
|
||||
args: serialized,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log = (...args: unknown[]) => forwardConsole('log', args);
|
||||
console.warn = (...args: unknown[]) => forwardConsole('warn', args);
|
||||
console.error = (...args: unknown[]) => forwardConsole('error', args);
|
||||
console.info = (...args: unknown[]) => forwardConsole('info', args);
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
function createBudgetGroup(leaderPort: CoordinatorPort): BudgetGroup {
|
||||
return {
|
||||
leaderPort,
|
||||
followers: new Set(),
|
||||
backendConnected: false,
|
||||
requestToPort: new Map(),
|
||||
requestNames: new Map(),
|
||||
requestBudgetIds: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
function logState(action: string) {
|
||||
const groups: string[] = [];
|
||||
for (const [bid, g] of budgetGroups) {
|
||||
groups.push(`"${bid}": leader + ${g.followers.size} follower(s)`);
|
||||
}
|
||||
console.log(
|
||||
`[SharedWorker] ${action} — ${connectedPorts.length} tab(s), ${unassignedPorts.size} unassigned, groups: [${groups.join(', ') || 'none'}]`,
|
||||
);
|
||||
}
|
||||
|
||||
function broadcastToGroup(
|
||||
budgetId: string,
|
||||
msg: unknown,
|
||||
excludePort: CoordinatorPort | null,
|
||||
) {
|
||||
const group = budgetGroups.get(budgetId);
|
||||
if (!group) return;
|
||||
if (group.leaderPort !== excludePort) {
|
||||
group.leaderPort.postMessage(msg);
|
||||
}
|
||||
for (const p of group.followers) {
|
||||
if (p !== excludePort) {
|
||||
p.postMessage(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function broadcastToAllInGroup(budgetId: string, msg: unknown) {
|
||||
broadcastToGroup(budgetId, msg, null);
|
||||
}
|
||||
|
||||
// ── Heartbeat ──────────────────────────────────────────────────────────
|
||||
|
||||
const heartbeatId = setInterval(() => {
|
||||
for (const port of [...pendingPongs]) {
|
||||
pendingPongs.delete(port);
|
||||
removePort(port);
|
||||
}
|
||||
for (const port of connectedPorts) {
|
||||
pendingPongs.add(port);
|
||||
port.postMessage({ type: '__heartbeat-ping' });
|
||||
}
|
||||
}, 10_000);
|
||||
|
||||
// ── Port removal & leader failover ────────────────────────────────────
|
||||
|
||||
function removePort(port: CoordinatorPort) {
|
||||
const idx = connectedPorts.indexOf(port);
|
||||
if (idx !== -1) connectedPorts.splice(idx, 1);
|
||||
unassignedPorts.delete(port);
|
||||
|
||||
const budgetId = portToBudget.get(port);
|
||||
portToBudget.delete(port);
|
||||
if (!budgetId) return;
|
||||
|
||||
const group = budgetGroups.get(budgetId);
|
||||
if (!group) return;
|
||||
|
||||
if (port === group.leaderPort) {
|
||||
if (group.followers.size > 0) {
|
||||
const candidate = group.followers.values().next()
|
||||
.value as CoordinatorPort;
|
||||
group.followers.delete(candidate);
|
||||
console.log(
|
||||
`[SharedWorker] Leader left budget "${budgetId}" — promoting follower`,
|
||||
);
|
||||
electLeader(budgetId, candidate, budgetId);
|
||||
} else {
|
||||
console.log(
|
||||
`[SharedWorker] Last tab left budget "${budgetId}" — removing group`,
|
||||
);
|
||||
budgetGroups.delete(budgetId);
|
||||
}
|
||||
} else {
|
||||
group.followers.delete(port);
|
||||
for (const [id, p] of group.requestToPort) {
|
||||
if (p === port) {
|
||||
group.requestToPort.delete(id);
|
||||
group.requestNames.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Leader election ───────────────────────────────────────────────────
|
||||
|
||||
function electLeader(
|
||||
budgetId: string,
|
||||
port: CoordinatorPort,
|
||||
budgetToRestore?: string | null,
|
||||
pendingMsg?: Record<string, unknown> | null,
|
||||
) {
|
||||
let group = budgetGroups.get(budgetId);
|
||||
if (!group) {
|
||||
group = createBudgetGroup(port);
|
||||
budgetGroups.set(budgetId, group);
|
||||
} else {
|
||||
group.leaderPort = port;
|
||||
group.backendConnected = false;
|
||||
group.requestToPort.clear();
|
||||
group.requestNames.clear();
|
||||
group.requestBudgetIds.clear();
|
||||
}
|
||||
const prevBudget = portToBudget.get(port);
|
||||
if (prevBudget && prevBudget !== budgetId) {
|
||||
removePortFromGroup(port, prevBudget);
|
||||
}
|
||||
portToBudget.set(port, budgetId);
|
||||
unassignedPorts.delete(port);
|
||||
|
||||
console.log(
|
||||
`[SharedWorker] Elected leader for "${budgetId}" (${group.followers.size} follower(s))`,
|
||||
);
|
||||
port.postMessage({
|
||||
type: '__role-change',
|
||||
role: 'LEADER',
|
||||
budgetId,
|
||||
});
|
||||
if (cachedInitMsg) {
|
||||
port.postMessage({
|
||||
type: '__become-leader',
|
||||
initMsg: cachedInitMsg,
|
||||
budgetToRestore: budgetToRestore || null,
|
||||
pendingMsg: pendingMsg || null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function addFollower(budgetId: string, port: CoordinatorPort) {
|
||||
const group = budgetGroups.get(budgetId);
|
||||
if (!group) return;
|
||||
|
||||
const prevBudget = portToBudget.get(port);
|
||||
if (prevBudget && prevBudget !== budgetId) {
|
||||
removePortFromGroup(port, prevBudget);
|
||||
}
|
||||
|
||||
group.followers.add(port);
|
||||
portToBudget.set(port, budgetId);
|
||||
unassignedPorts.delete(port);
|
||||
|
||||
port.postMessage({
|
||||
type: '__role-change',
|
||||
role: 'FOLLOWER',
|
||||
budgetId,
|
||||
});
|
||||
if (group.backendConnected) {
|
||||
port.postMessage({ type: 'connect' });
|
||||
}
|
||||
}
|
||||
|
||||
function removePortFromGroup(port: CoordinatorPort, budgetId: string) {
|
||||
const group = budgetGroups.get(budgetId);
|
||||
if (!group) return;
|
||||
group.followers.delete(port);
|
||||
for (const [id, p] of group.requestToPort) {
|
||||
if (p === port) {
|
||||
group.requestToPort.delete(id);
|
||||
group.requestNames.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function evictGroup(budgetId: string, excludePort: CoordinatorPort) {
|
||||
const group = budgetGroups.get(budgetId);
|
||||
if (!group) return;
|
||||
|
||||
const evicted: CoordinatorPort[] = [];
|
||||
for (const p of group.followers) {
|
||||
if (p !== excludePort) {
|
||||
p.postMessage({ type: 'push', name: 'show-budgets' });
|
||||
portToBudget.delete(p);
|
||||
unassignedPorts.add(p);
|
||||
evicted.push(p);
|
||||
}
|
||||
}
|
||||
group.followers.clear();
|
||||
|
||||
if (group.leaderPort && group.leaderPort !== excludePort) {
|
||||
group.leaderPort.postMessage({
|
||||
type: '__close-and-transfer',
|
||||
requestId: null,
|
||||
});
|
||||
group.leaderPort.postMessage({ type: 'push', name: 'show-budgets' });
|
||||
portToBudget.delete(group.leaderPort);
|
||||
unassignedPorts.add(group.leaderPort);
|
||||
evicted.push(group.leaderPort);
|
||||
}
|
||||
|
||||
budgetGroups.delete(budgetId);
|
||||
if (evicted.length > 0) {
|
||||
console.log(
|
||||
`[SharedWorker] Evicted ${evicted.length} tab(s) from budget "${budgetId}"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Budget lifecycle helpers ──────────────────────────────────────────
|
||||
|
||||
function handleBudgetLoaded(
|
||||
leaderPort: CoordinatorPort,
|
||||
oldGroupId: string,
|
||||
newBudgetId: string,
|
||||
) {
|
||||
const oldGroup = budgetGroups.get(oldGroupId);
|
||||
if (!oldGroup) return;
|
||||
|
||||
if (oldGroupId !== newBudgetId) {
|
||||
const existingTarget = budgetGroups.get(newBudgetId);
|
||||
if (existingTarget && existingTarget !== oldGroup) {
|
||||
console.warn(
|
||||
`[SharedWorker] handleBudgetLoaded: conflict — group "${newBudgetId}" already exists`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
budgetGroups.delete(oldGroupId);
|
||||
budgetGroups.set(newBudgetId, oldGroup);
|
||||
portToBudget.set(leaderPort, newBudgetId);
|
||||
|
||||
for (const p of oldGroup.followers) {
|
||||
portToBudget.set(p, newBudgetId);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[SharedWorker] Budget loaded: "${newBudgetId}" (leader + ${oldGroup.followers.size} follower(s))`,
|
||||
);
|
||||
}
|
||||
|
||||
logState(`Budget "${newBudgetId}" ready`);
|
||||
}
|
||||
|
||||
function handleBudgetClosed(closingPort: CoordinatorPort, budgetId: string) {
|
||||
const group = budgetGroups.get(budgetId);
|
||||
if (!group) return;
|
||||
|
||||
if (closingPort === group.leaderPort && group.followers.size === 0) {
|
||||
budgetGroups.delete(budgetId);
|
||||
portToBudget.delete(closingPort);
|
||||
unassignedPorts.add(closingPort);
|
||||
logState(`Budget "${budgetId}" closed (no tabs remain)`);
|
||||
}
|
||||
}
|
||||
|
||||
function migrateLobbyLeader(
|
||||
port: CoordinatorPort,
|
||||
budgetId: string,
|
||||
pendingMsg: Record<string, unknown>,
|
||||
) {
|
||||
const lobbyGroup = budgetGroups.get('__lobby');
|
||||
if (lobbyGroup && port === lobbyGroup.leaderPort) {
|
||||
budgetGroups.delete('__lobby');
|
||||
budgetGroups.set(budgetId, lobbyGroup);
|
||||
portToBudget.set(port, budgetId);
|
||||
lobbyGroup.requestToPort.set(pendingMsg.id as string, port);
|
||||
lobbyGroup.requestNames.set(
|
||||
pendingMsg.id as string,
|
||||
pendingMsg.name as string,
|
||||
);
|
||||
lobbyGroup.requestBudgetIds.set(pendingMsg.id as string, budgetId);
|
||||
lobbyGroup.leaderPort.postMessage({
|
||||
type: '__to-worker',
|
||||
msg: pendingMsg,
|
||||
});
|
||||
port.postMessage({
|
||||
type: '__role-change',
|
||||
role: 'LEADER',
|
||||
budgetId,
|
||||
});
|
||||
logState(`Lobby leader now on budget "${budgetId}"`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Connection handler ────────────────────────────────────────────────
|
||||
|
||||
function onconnect(e: { ports: CoordinatorPort[] }) {
|
||||
const port = e.ports[0];
|
||||
connectedPorts.push(port);
|
||||
unassignedPorts.add(port);
|
||||
logState('Tab connected');
|
||||
|
||||
port.onmessage = function (event: { data: Record<string, unknown> }) {
|
||||
try {
|
||||
const msg = event.data;
|
||||
const portBudget = portToBudget.get(port);
|
||||
const group = portBudget ? budgetGroups.get(portBudget) : null;
|
||||
|
||||
// ── Tab lifecycle ──────────────────────────────────────────
|
||||
|
||||
if (msg.type === 'tab-closing') {
|
||||
pendingPongs.delete(port);
|
||||
removePort(port);
|
||||
logState('Tab closed');
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === '__heartbeat-pong') {
|
||||
pendingPongs.delete(port);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Initialization ─────────────────────────────────────────
|
||||
|
||||
if (msg.type === 'init') {
|
||||
cachedInitMsg = msg;
|
||||
if (lastAppInitFailure) {
|
||||
port.postMessage(lastAppInitFailure);
|
||||
} else {
|
||||
let anyConnected = false;
|
||||
for (const [, g] of budgetGroups) {
|
||||
if (g.backendConnected) {
|
||||
anyConnected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (anyConnected) {
|
||||
port.postMessage({ type: '__role-change', role: 'UNASSIGNED' });
|
||||
port.postMessage({ type: 'connect' });
|
||||
} else if (budgetGroups.size > 0) {
|
||||
port.postMessage({ type: '__role-change', role: 'UNASSIGNED' });
|
||||
} else {
|
||||
electLeader('__lobby', port);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Leader tab forwarding Worker messages back ─────────────
|
||||
|
||||
if (msg.type === '__from-worker') {
|
||||
if (!group || port !== group.leaderPort) return;
|
||||
const workerMsg = msg.msg as Record<string, unknown>;
|
||||
|
||||
if (workerMsg.type === 'reply' || workerMsg.type === 'error') {
|
||||
const targetPort = group.requestToPort.get(workerMsg.id as string);
|
||||
if (targetPort) {
|
||||
targetPort.postMessage(workerMsg);
|
||||
|
||||
const name = group.requestNames.get(workerMsg.id as string);
|
||||
if (workerMsg.type === 'reply' && name === 'load-budget') {
|
||||
const budgetId = group.requestBudgetIds.get(
|
||||
workerMsg.id as string,
|
||||
);
|
||||
if (budgetId) {
|
||||
group.requestBudgetIds.delete(workerMsg.id as string);
|
||||
handleBudgetLoaded(port, portBudget!, budgetId);
|
||||
}
|
||||
}
|
||||
if (workerMsg.type === 'reply' && name === 'close-budget') {
|
||||
handleBudgetClosed(targetPort, portBudget!);
|
||||
}
|
||||
if (
|
||||
workerMsg.type === 'reply' &&
|
||||
name === 'load-prefs' &&
|
||||
portBudget &&
|
||||
portBudget.startsWith('__creating-') &&
|
||||
workerMsg.result &&
|
||||
(workerMsg.result as Record<string, unknown>).id
|
||||
) {
|
||||
handleBudgetLoaded(
|
||||
port,
|
||||
portBudget,
|
||||
(workerMsg.result as Record<string, unknown>).id as string,
|
||||
);
|
||||
}
|
||||
|
||||
group.requestToPort.delete(workerMsg.id as string);
|
||||
group.requestNames.delete(workerMsg.id as string);
|
||||
}
|
||||
} else if (workerMsg.type === 'connect') {
|
||||
group.backendConnected = true;
|
||||
broadcastToAllInGroup(portBudget!, workerMsg);
|
||||
for (const p of unassignedPorts) {
|
||||
p.postMessage(workerMsg);
|
||||
}
|
||||
} else if (workerMsg.type === 'app-init-failure') {
|
||||
lastAppInitFailure = workerMsg;
|
||||
broadcastToAllInGroup(portBudget!, workerMsg);
|
||||
} else {
|
||||
broadcastToAllInGroup(portBudget!, workerMsg);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Leader tab registering a budget restore ────────────────
|
||||
|
||||
if (msg.type === '__track-restore') {
|
||||
if (group) {
|
||||
group.requestToPort.set(msg.requestId as string, port);
|
||||
group.requestNames.set(msg.requestId as string, 'load-budget');
|
||||
group.requestBudgetIds.set(
|
||||
msg.requestId as string,
|
||||
msg.budgetId as string,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Request interception & routing ─────────────────────────
|
||||
|
||||
if (
|
||||
msg.name === 'load-budget' &&
|
||||
msg.args &&
|
||||
(msg.args as Record<string, unknown>).id
|
||||
) {
|
||||
const budgetId = (msg.args as Record<string, unknown>).id as string;
|
||||
const existingGroup = budgetGroups.get(budgetId);
|
||||
|
||||
if (existingGroup && existingGroup.backendConnected) {
|
||||
addFollower(budgetId, port);
|
||||
existingGroup.requestToPort.set(msg.id as string, port);
|
||||
existingGroup.requestNames.set(
|
||||
msg.id as string,
|
||||
msg.name as string,
|
||||
);
|
||||
existingGroup.requestBudgetIds.set(msg.id as string, budgetId);
|
||||
existingGroup.leaderPort.postMessage({
|
||||
type: '__to-worker',
|
||||
msg,
|
||||
});
|
||||
logState(`Tab joined budget "${budgetId}" as follower`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (existingGroup && !existingGroup.backendConnected) {
|
||||
addFollower(budgetId, port);
|
||||
existingGroup.requestToPort.set(msg.id as string, port);
|
||||
existingGroup.requestNames.set(
|
||||
msg.id as string,
|
||||
msg.name as string,
|
||||
);
|
||||
existingGroup.requestBudgetIds.set(msg.id as string, budgetId);
|
||||
existingGroup.leaderPort.postMessage({
|
||||
type: '__to-worker',
|
||||
msg,
|
||||
});
|
||||
logState(
|
||||
`Tab joined budget "${budgetId}" as follower (backend booting)`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (portBudget === '__lobby') {
|
||||
migrateLobbyLeader(port, budgetId, msg);
|
||||
} else if (group && port === group.leaderPort) {
|
||||
for (const p of group.followers) {
|
||||
p.postMessage({ type: 'push', name: 'show-budgets' });
|
||||
portToBudget.delete(p);
|
||||
unassignedPorts.add(p);
|
||||
}
|
||||
if (group.followers.size > 0) {
|
||||
console.log(
|
||||
`[SharedWorker] Leader switching budgets — pushed ${group.followers.size} follower(s) off "${portBudget}"`,
|
||||
);
|
||||
group.followers.clear();
|
||||
}
|
||||
group.requestToPort.set(msg.id as string, port);
|
||||
group.requestNames.set(msg.id as string, msg.name as string);
|
||||
group.requestBudgetIds.set(msg.id as string, budgetId);
|
||||
group.leaderPort.postMessage({ type: '__to-worker', msg });
|
||||
} else {
|
||||
electLeader(budgetId, port, null, msg);
|
||||
const newGroup = budgetGroups.get(budgetId);
|
||||
if (newGroup) {
|
||||
newGroup.requestToPort.set(msg.id as string, port);
|
||||
newGroup.requestNames.set(msg.id as string, msg.name as string);
|
||||
newGroup.requestBudgetIds.set(msg.id as string, budgetId);
|
||||
}
|
||||
logState(`Tab became leader for new budget "${budgetId}"`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// close-budget: handle leader vs follower
|
||||
if (msg.name === 'close-budget' && group) {
|
||||
if (port === group.leaderPort) {
|
||||
if (group.followers.size > 0) {
|
||||
const newLeader = group.followers.values().next()
|
||||
.value as CoordinatorPort;
|
||||
group.followers.delete(newLeader);
|
||||
console.log(
|
||||
`[SharedWorker] Leader closing budget "${portBudget}" but ${group.followers.size + 1} tab(s) remain — transferring`,
|
||||
);
|
||||
port.postMessage({
|
||||
type: '__close-and-transfer',
|
||||
requestId: msg.id,
|
||||
});
|
||||
electLeader(portBudget!, newLeader, portBudget);
|
||||
portToBudget.delete(port);
|
||||
unassignedPorts.add(port);
|
||||
logState(`Leadership transferred for "${portBudget}"`);
|
||||
return;
|
||||
}
|
||||
group.requestToPort.set(msg.id as string, port);
|
||||
group.requestNames.set(msg.id as string, msg.name as string);
|
||||
group.leaderPort.postMessage({ type: '__to-worker', msg });
|
||||
return;
|
||||
} else {
|
||||
group.followers.delete(port);
|
||||
portToBudget.delete(port);
|
||||
unassignedPorts.add(port);
|
||||
port.postMessage({ type: 'reply', id: msg.id, data: {} });
|
||||
logState(`Follower left budget "${portBudget}"`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// delete-budget: if another group is running this budget, evict it
|
||||
if (msg.name === 'delete-budget' && msg.args) {
|
||||
const targetId = (msg.args as Record<string, unknown>).id as string;
|
||||
if (targetId && budgetGroups.has(targetId)) {
|
||||
evictGroup(targetId, port);
|
||||
logState(`Evicted group for deleted budget "${targetId}"`);
|
||||
}
|
||||
let hasConnected = false;
|
||||
for (const [, g] of budgetGroups) {
|
||||
if (g.backendConnected) {
|
||||
hasConnected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!hasConnected) {
|
||||
const tempId = '__deleting-' + Date.now();
|
||||
electLeader(tempId, port, null, msg);
|
||||
const newGroup = budgetGroups.get(tempId);
|
||||
if (newGroup && msg.id) {
|
||||
newGroup.requestToPort.set(msg.id as string, port);
|
||||
newGroup.requestNames.set(msg.id as string, msg.name as string);
|
||||
}
|
||||
logState(`Tab became leader for budget deletion ("${tempId}")`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Budget-replacing operations
|
||||
if (
|
||||
msg.name === 'create-budget' ||
|
||||
msg.name === 'create-demo-budget' ||
|
||||
msg.name === 'import-budget' ||
|
||||
msg.name === 'duplicate-budget' ||
|
||||
msg.name === 'delete-budget'
|
||||
) {
|
||||
if (msg.name === 'create-demo-budget') {
|
||||
evictGroup('_demo-budget', port);
|
||||
} else if (
|
||||
msg.name === 'create-budget' &&
|
||||
msg.args &&
|
||||
(msg.args as Record<string, unknown>).testMode
|
||||
) {
|
||||
evictGroup('_test-budget', port);
|
||||
}
|
||||
if (group && port === group.leaderPort) {
|
||||
for (const p of group.followers) {
|
||||
p.postMessage({ type: 'push', name: 'show-budgets' });
|
||||
portToBudget.delete(p);
|
||||
unassignedPorts.add(p);
|
||||
}
|
||||
if (group.followers.size > 0) {
|
||||
console.log(
|
||||
`[SharedWorker] Budget-replacing "${msg.name}" — pushed ${group.followers.size} tab(s) off "${portBudget}"`,
|
||||
);
|
||||
group.followers.clear();
|
||||
}
|
||||
} else {
|
||||
if (group) {
|
||||
group.followers.delete(port);
|
||||
portToBudget.delete(port);
|
||||
unassignedPorts.add(port);
|
||||
}
|
||||
const tempId = '__creating-' + Date.now();
|
||||
electLeader(tempId, port, null, msg);
|
||||
const newGroup = budgetGroups.get(tempId);
|
||||
if (newGroup && msg.id) {
|
||||
newGroup.requestToPort.set(msg.id as string, port);
|
||||
newGroup.requestNames.set(msg.id as string, msg.name as string);
|
||||
}
|
||||
logState(`Tab became leader for budget creation ("${tempId}")`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Default: track and forward to leader ───────────────────
|
||||
|
||||
let targetGroup = group;
|
||||
if (!targetGroup) {
|
||||
for (const [, g] of budgetGroups) {
|
||||
if (g.backendConnected) {
|
||||
targetGroup = g;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (targetGroup) {
|
||||
if (msg.id) {
|
||||
targetGroup.requestToPort.set(msg.id as string, port);
|
||||
if (msg.name) {
|
||||
targetGroup.requestNames.set(
|
||||
msg.id as string,
|
||||
msg.name as string,
|
||||
);
|
||||
}
|
||||
if (
|
||||
msg.name === 'load-budget' &&
|
||||
msg.args &&
|
||||
(msg.args as Record<string, unknown>).id
|
||||
) {
|
||||
targetGroup.requestBudgetIds.set(
|
||||
msg.id as string,
|
||||
(msg.args as Record<string, unknown>).id as string,
|
||||
);
|
||||
}
|
||||
}
|
||||
targetGroup.leaderPort.postMessage({ type: '__to-worker', msg });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SharedWorker] Error in message handler:', error);
|
||||
}
|
||||
};
|
||||
|
||||
port.start();
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────
|
||||
|
||||
function destroy() {
|
||||
clearInterval(heartbeatId);
|
||||
}
|
||||
|
||||
function getState() {
|
||||
return {
|
||||
connectedPorts,
|
||||
cachedInitMsg,
|
||||
lastAppInitFailure,
|
||||
budgetGroups,
|
||||
portToBudget,
|
||||
unassignedPorts,
|
||||
};
|
||||
}
|
||||
|
||||
return { onconnect, destroy, getState };
|
||||
}
|
||||
11
packages/desktop-client/src/shared-browser-server.ts
Normal file
11
packages/desktop-client/src/shared-browser-server.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// SharedWorker entry point for multi-tab, multi-budget support.
|
||||
//
|
||||
// All coordinator logic lives in shared-browser-server-core.ts
|
||||
// This file simply creates a coordinator with console forwarding
|
||||
// enabled and wires it to the SharedWorkerGlobalScope.
|
||||
|
||||
import { createCoordinator } from './shared-browser-server-core';
|
||||
|
||||
const coordinator = createCoordinator({ enableConsoleForwarding: true });
|
||||
(self as unknown as { onconnect: typeof coordinator.onconnect }).onconnect =
|
||||
coordinator.onconnect;
|
||||
@@ -230,6 +230,7 @@ const sidebars = {
|
||||
link: { type: 'doc', id: 'api/index' },
|
||||
items: [
|
||||
'api/reference',
|
||||
'api/cli',
|
||||
{
|
||||
type: 'category',
|
||||
label: 'ActualQL',
|
||||
|
||||
356
packages/docs/docs/api/cli.md
Normal file
356
packages/docs/docs/api/cli.md
Normal file
@@ -0,0 +1,356 @@
|
||||
---
|
||||
title: 'CLI'
|
||||
---
|
||||
|
||||
# CLI Tool
|
||||
|
||||
:::danger Experimental — API may change
|
||||
The CLI is **experimental** and its commands, options, and behavior are **likely to change** in future releases. Use it for scripting and automation with the understanding that updates may require changes to your workflows.
|
||||
:::
|
||||
|
||||
The `@actual-app/cli` package provides a command-line interface for interacting with your Actual Budget data. It connects to your sync server and lets you query and modify budgets, accounts, transactions, categories, payees, rules, schedules, and more — all from the terminal.
|
||||
|
||||
:::note
|
||||
This is different from the [Server CLI](../install/cli-tool.md) (`@actual-app/sync-server`), which is used to host and manage the Actual server itself.
|
||||
:::
|
||||
|
||||
## Installation
|
||||
|
||||
Node.js v22 or higher is required.
|
||||
|
||||
```bash
|
||||
npm install --save @actual-app/cli
|
||||
```
|
||||
|
||||
Or install globally:
|
||||
|
||||
```bash
|
||||
npm install --location=global @actual-app/cli
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The CLI requires a connection to a running Actual sync server. Configuration can be provided via environment variables, CLI flags, or a config file.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
| ---------------------- | --------------------------------------------------- |
|
||||
| `ACTUAL_SERVER_URL` | URL of the Actual sync server (required) |
|
||||
| `ACTUAL_SYNC_ID` | Budget Sync ID (required for most commands) |
|
||||
| `ACTUAL_PASSWORD` | Server password (one of password or token required) |
|
||||
| `ACTUAL_SESSION_TOKEN` | Session token (alternative to password) |
|
||||
|
||||
### CLI Flags
|
||||
|
||||
Global flags override environment variables:
|
||||
|
||||
| Flag | Description |
|
||||
| ------------------------- | ----------------------------------------------- |
|
||||
| `--server-url <url>` | Server URL |
|
||||
| `--password <pw>` | Server password |
|
||||
| `--session-token <token>` | Session token |
|
||||
| `--sync-id <id>` | Budget Sync ID |
|
||||
| `--data-dir <path>` | Local data directory for cached budget data |
|
||||
| `--format <format>` | Output format: `json` (default), `table`, `csv` |
|
||||
| `--verbose` | Show informational messages on stderr |
|
||||
|
||||
### Config File
|
||||
|
||||
The CLI uses [cosmiconfig](https://github.com/cosmiconfig/cosmiconfig) for configuration. You can create a config file in any of these formats:
|
||||
|
||||
- `.actualrc` (JSON or YAML)
|
||||
- `.actualrc.json`, `.actualrc.yaml`, `.actualrc.yml`
|
||||
- `actual.config.json`, `actual.config.yaml`, `actual.config.yml`
|
||||
- An `"actual"` key in your `package.json`
|
||||
|
||||
Example `.actualrc.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"serverUrl": "http://localhost:5006",
|
||||
"password": "your-password",
|
||||
"syncId": "1cfdbb80-6274-49bf-b0c2-737235a4c81f"
|
||||
}
|
||||
```
|
||||
|
||||
:::caution Security
|
||||
Avoid storing plaintext passwords in config files (including the `password` key above). Prefer environment variables such as `ACTUAL_PASSWORD` or `ACTUAL_SESSION_TOKEN`, or use a session token in config instead of a password.
|
||||
:::
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
actual <command> <subcommand> [options]
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### Accounts
|
||||
|
||||
```bash
|
||||
# List all accounts
|
||||
actual accounts list
|
||||
|
||||
# Create an account
|
||||
actual accounts create --name "Checking" [--offbudget] [--balance 50000]
|
||||
|
||||
# Update an account
|
||||
actual accounts update <id> [--name "New Name"] [--offbudget true]
|
||||
|
||||
# Close an account (with optional transfer)
|
||||
actual accounts close <id> [--transfer-account <id>] [--transfer-category <id>]
|
||||
|
||||
# Reopen a closed account
|
||||
actual accounts reopen <id>
|
||||
|
||||
# Delete an account
|
||||
actual accounts delete <id>
|
||||
|
||||
# Get account balance
|
||||
actual accounts balance <id> [--cutoff 2026-01-31]
|
||||
```
|
||||
|
||||
### Budgets
|
||||
|
||||
```bash
|
||||
# List available budgets on the server
|
||||
actual budgets list
|
||||
|
||||
# Download a budget by sync ID
|
||||
actual budgets download <syncId> [--encryption-password <pw>]
|
||||
|
||||
# Sync the current budget
|
||||
actual budgets sync
|
||||
|
||||
# List budget months
|
||||
actual budgets months
|
||||
|
||||
# View a specific month
|
||||
actual budgets month 2026-03
|
||||
|
||||
# Set a budget amount (in integer cents)
|
||||
actual budgets set-amount --month 2026-03 --category <id> --amount 50000
|
||||
|
||||
# Set carryover flag
|
||||
actual budgets set-carryover --month 2026-03 --category <id> --flag true
|
||||
|
||||
# Hold funds for next month
|
||||
actual budgets hold-next-month --month 2026-03 --amount 10000
|
||||
|
||||
# Reset held funds
|
||||
actual budgets reset-hold --month 2026-03
|
||||
```
|
||||
|
||||
### Categories
|
||||
|
||||
```bash
|
||||
# List all categories
|
||||
actual categories list
|
||||
|
||||
# Create a category
|
||||
actual categories create --name "Groceries" --group-id <id> [--is-income]
|
||||
|
||||
# Update a category
|
||||
actual categories update <id> [--name "Food"] [--hidden true]
|
||||
|
||||
# Delete a category (with optional transfer)
|
||||
actual categories delete <id> [--transfer-to <id>]
|
||||
```
|
||||
|
||||
### Category Groups
|
||||
|
||||
```bash
|
||||
# List all category groups
|
||||
actual category-groups list
|
||||
|
||||
# Create a category group
|
||||
actual category-groups create --name "Essentials" [--is-income]
|
||||
|
||||
# Update a category group
|
||||
actual category-groups update <id> [--name "New Name"] [--hidden true]
|
||||
|
||||
# Delete a category group (with optional transfer)
|
||||
actual category-groups delete <id> [--transfer-to <id>]
|
||||
```
|
||||
|
||||
### Transactions
|
||||
|
||||
```bash
|
||||
# List transactions for an account within a date range
|
||||
actual transactions list --account <id> --start 2026-01-01 --end 2026-03-31
|
||||
|
||||
# Add transactions (inline JSON)
|
||||
actual transactions add --account <id> --data '[{"date":"2026-03-13","amount":-5000,"payee_name":"Store"}]'
|
||||
|
||||
# Add transactions (from file)
|
||||
actual transactions add --account <id> --file transactions.json
|
||||
|
||||
# Import transactions with reconciliation (deduplication)
|
||||
actual transactions import --account <id> --data '[...]' [--dry-run]
|
||||
|
||||
# Update a transaction
|
||||
actual transactions update <id> --data '{"notes":"Updated note"}'
|
||||
|
||||
# Delete a transaction
|
||||
actual transactions delete <id>
|
||||
```
|
||||
|
||||
### Payees
|
||||
|
||||
```bash
|
||||
# List all payees
|
||||
actual payees list
|
||||
|
||||
# List common payees
|
||||
actual payees common
|
||||
|
||||
# Create a payee
|
||||
actual payees create --name "Grocery Store"
|
||||
|
||||
# Update a payee
|
||||
actual payees update <id> --name "New Name"
|
||||
|
||||
# Delete a payee
|
||||
actual payees delete <id>
|
||||
|
||||
# Merge multiple payees into one
|
||||
actual payees merge --target <id> --ids id1,id2,id3
|
||||
```
|
||||
|
||||
### Tags
|
||||
|
||||
```bash
|
||||
# List all tags
|
||||
actual tags list
|
||||
|
||||
# Create a tag
|
||||
actual tags create --tag "vacation" [--color "#ff0000"] [--description "Vacation expenses"]
|
||||
|
||||
# Update a tag
|
||||
actual tags update <id> [--tag "trip"] [--color "#00ff00"]
|
||||
|
||||
# Delete a tag
|
||||
actual tags delete <id>
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
```bash
|
||||
# List all rules
|
||||
actual rules list
|
||||
|
||||
# List rules for a specific payee
|
||||
actual rules payee-rules <payeeId>
|
||||
|
||||
# Create a rule (inline JSON)
|
||||
actual rules create --data '{"stage":"pre","conditionsOp":"and","conditions":[...],"actions":[...]}'
|
||||
|
||||
# Create a rule (from file)
|
||||
actual rules create --file rule.json
|
||||
|
||||
# Update a rule
|
||||
actual rules update --data '{"id":"...","stage":"pre",...}'
|
||||
|
||||
# Delete a rule
|
||||
actual rules delete <id>
|
||||
```
|
||||
|
||||
### Schedules
|
||||
|
||||
```bash
|
||||
# List all schedules
|
||||
actual schedules list
|
||||
|
||||
# Create a schedule
|
||||
actual schedules create --data '{"name":"Rent","date":"1st","amount":-150000,"amountOp":"is","account":"...","payee":"..."}'
|
||||
|
||||
# Update a schedule
|
||||
actual schedules update <id> --data '{"name":"Updated Rent"}' [--reset-next-date]
|
||||
|
||||
# Delete a schedule
|
||||
actual schedules delete <id>
|
||||
```
|
||||
|
||||
### Query (ActualQL)
|
||||
|
||||
Run queries using [ActualQL](./actual-ql/index.md):
|
||||
|
||||
```bash
|
||||
# Run a query (inline)
|
||||
actual query run --table transactions --select "date,amount,payee" --filter '{"amount":{"$lt":0}}' --limit 10
|
||||
|
||||
# Run a query (from file)
|
||||
actual query run --file query.json
|
||||
```
|
||||
|
||||
### Server
|
||||
|
||||
```bash
|
||||
# Get the server version
|
||||
actual server version
|
||||
|
||||
# Look up an entity ID by name
|
||||
actual server get-id --type accounts --name "Checking"
|
||||
actual server get-id --type categories --name "Groceries"
|
||||
|
||||
# Trigger bank sync
|
||||
actual server bank-sync [--account <id>]
|
||||
```
|
||||
|
||||
## Amount Convention
|
||||
|
||||
All monetary amounts are represented as **integer cents**:
|
||||
|
||||
| CLI Value | Dollar Amount |
|
||||
| --------- | ------------- |
|
||||
| `5000` | $50.00 |
|
||||
| `-12350` | -$123.50 |
|
||||
| `100` | $1.00 |
|
||||
|
||||
When providing amounts, always use integer cents. For example, to budget $50, pass `5000`.
|
||||
|
||||
## Output Formats
|
||||
|
||||
The `--format` flag controls how results are displayed:
|
||||
|
||||
- **`json`** (default) — Machine-readable JSON output, ideal for scripting
|
||||
- **`table`** — Human-readable table format
|
||||
- **`csv`** — Comma-separated values for spreadsheet import
|
||||
|
||||
Use `--verbose` to enable informational messages on stderr for debugging or visibility into what the CLI is doing.
|
||||
|
||||
## Common Workflows
|
||||
|
||||
**View your budget for the current month:**
|
||||
|
||||
```bash
|
||||
actual budgets month 2026-03 --format table
|
||||
```
|
||||
|
||||
**Check an account balance:**
|
||||
|
||||
```bash
|
||||
# Find the account ID
|
||||
actual server get-id --type accounts --name "Checking"
|
||||
# Get the balance
|
||||
actual accounts balance <id>
|
||||
```
|
||||
|
||||
**Export transactions to CSV:**
|
||||
|
||||
```bash
|
||||
actual transactions list --account <id> --start 2026-01-01 --end 2026-12-31 --format csv > transactions.csv
|
||||
```
|
||||
|
||||
**Add a transaction:**
|
||||
|
||||
```bash
|
||||
actual transactions add --account <id> --data '[{"date":"2026-03-14","amount":-2500,"payee_name":"Coffee Shop"}]'
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Non-zero exit codes indicate an error
|
||||
- Errors are written as plain text to stderr (e.g., `Error: message`)
|
||||
- Use `--verbose` to enable informational stderr messages for debugging
|
||||
@@ -99,6 +99,9 @@ yarn build:desktop
|
||||
# Build API package
|
||||
yarn build:api
|
||||
|
||||
# Build CLI package
|
||||
yarn build:cli
|
||||
|
||||
# Build sync server
|
||||
yarn build:server
|
||||
```
|
||||
@@ -160,6 +163,9 @@ yarn build:desktop
|
||||
# API build
|
||||
yarn build:api
|
||||
|
||||
# CLI build
|
||||
yarn build:cli
|
||||
|
||||
# Sync server build
|
||||
yarn build:server
|
||||
```
|
||||
|
||||
@@ -4,7 +4,7 @@ In the open-source version of Actual, there are 3 NPM packages:
|
||||
|
||||
- [@actual-app/api](https://www.npmjs.com/package/@actual-app/api): The API for the underlying functionality. This includes the entire backend of Actual, meant to be used with Node.
|
||||
- [@actual-app/web](https://www.npmjs.com/package/@actual-app/web): A web build that will serve the app with a web frontend. This includes both the frontend and backend of Actual. It includes the backend as well because it's built to be used as a Web Worker.
|
||||
- [@actual-app/sync-server](https://www.npmjs.com/package/@actual-app/sync-server): The entire sync-server and underlying web client in one package. This includes a CLI tool, meant to be used with Node.
|
||||
- [@actual-app/sync-server](https://www.npmjs.com/package/@actual-app/sync-server): The entire sync-server and underlying web client in one package. This includes the Server CLI, meant to be used with Node.
|
||||
|
||||
All packages and the main Actual release are versioned together. That makes it clear which version of the package should be used with the version of Actual.
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ For most cases, we suggest opting for one of the simpler alternatives:
|
||||
|
||||
- [Pikapods](/docs/install/pikapods)
|
||||
- [Desktop Client](/download)
|
||||
- [CLI tool](/docs/install/cli-tool)
|
||||
- [Server CLI](/docs/install/cli-tool)
|
||||
- [Docker](/docs/install/docker)
|
||||
|
||||
:::
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
---
|
||||
title: 'CLI tool'
|
||||
title: 'Server CLI'
|
||||
---
|
||||
|
||||
## Hosting Actual with the CLI tool
|
||||
## Hosting Actual with the Server CLI
|
||||
|
||||
The Actual sync-server is available as an NPM package. The package is designed to make running the sync-server as easy as possible and is published to the official NPM registry under [@@actual-app/sync-server](https://www.npmjs.com/package/@actual-app/sync-server).
|
||||
|
||||
### Installing the CLI tool
|
||||
### Installing the Server CLI
|
||||
|
||||
Node.js v22 or higher is required for the `@actual-app/sync-server` npm package
|
||||
|
||||
@@ -22,7 +22,7 @@ Once installed, you can execute commands directly from your terminal using `actu
|
||||
|
||||
> Before running the tool, navigate to the directory that you wish your files to be located.
|
||||
|
||||
Run the CLI tool with the following syntax:
|
||||
Run the Server CLI with the following syntax:
|
||||
|
||||
```bash
|
||||
actual-server [options]
|
||||
@@ -67,7 +67,7 @@ Reset your password
|
||||
actual-server --reset-password
|
||||
```
|
||||
|
||||
### Updating the CLI tool
|
||||
### Updating the Server CLI
|
||||
|
||||
The sync server can be updated with a simple command.
|
||||
|
||||
@@ -75,7 +75,7 @@ The sync server can be updated with a simple command.
|
||||
npm update -g @actual-app/sync-server
|
||||
```
|
||||
|
||||
### Uninstalling the CLI tool
|
||||
### Uninstalling the Server CLI
|
||||
|
||||
The sync server can be uninstalled with a simple command.
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ While running a server can be a complicated endeavor, we've tried to make it fai
|
||||
|
||||
- If you're not comfortable with the command line and are willing to pay a small amount of money to have your version of Actual hosted on the cloud for you, we recommend [PikaPods](pikapods.md).[^2]
|
||||
- If you're willing to run a few commands in the terminal:
|
||||
- You can run the server with a simple command using the [CLI tool](cli-tool.md)
|
||||
- You can run the server with a simple command using the [Server CLI](cli-tool.md)
|
||||
- [Fly.io](fly.md) also offers cloud hosting for a similar amount of money.
|
||||
- If you want to use Docker, we have instructions for [using our provided Docker containers](docker.md).
|
||||
- You could [build Actual from source](build-from-source.md) on macOS, Windows, or Linux if you don't want to use a tool like Docker. (This method is the best option if you want to contribute to Actual's development!)
|
||||
|
||||
@@ -25,12 +25,12 @@
|
||||
"@r74tech/docusaurus-plugin-panzoom": "^2.4.0",
|
||||
"clsx": "^2.1.1",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "^3.9.2",
|
||||
"@types/react": "^19.2.5"
|
||||
"@types/react": "^19.2.14"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user