Compare commits
6 Commits
claude/cus
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0793eb5927 | ||
|
|
a43b6f5c47 | ||
|
|
1f821d2849 | ||
|
|
beee16bc8c | ||
|
|
4cdb26f9a7 | ||
|
|
15358b6b54 |
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
|
||||
|
||||
8
.github/actions/setup/action.yml
vendored
@@ -15,7 +15,7 @@ runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Install node
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Install yarn
|
||||
@@ -27,7 +27,7 @@ runs:
|
||||
run: echo "version=$(node -v)" >> "$GITHUB_OUTPUT"
|
||||
shell: bash
|
||||
- name: Cache
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
id: cache
|
||||
with:
|
||||
path: ${{ format('{0}/**/node_modules', inputs.working-directory) }}
|
||||
@@ -36,7 +36,7 @@ runs:
|
||||
run: mkdir -p ${{ format('{0}/.lage', inputs.working-directory) }}
|
||||
shell: bash
|
||||
- name: Cache Lage
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
with:
|
||||
path: ${{ format('{0}/.lage', inputs.working-directory) }}
|
||||
key: lage-${{ runner.os }}-${{ github.sha }}
|
||||
@@ -48,7 +48,7 @@ runs:
|
||||
shell: bash
|
||||
if: steps.cache.outputs.cache-hit != 'true'
|
||||
- name: Download translations
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
repository: actualbudget/translations
|
||||
path: ${{ inputs.working-directory }}/packages/desktop-client/locale
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
|
||||
4
.github/workflows/autofix.yml
vendored
@@ -15,11 +15,11 @@ jobs:
|
||||
autofix:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
download-translations: 'false'
|
||||
- name: Format code
|
||||
run: yarn lint:fix
|
||||
- uses: autofix-ci/action@635ffb0c9798bd160680f18fd73371e355b85f27
|
||||
- uses: autofix-ci/action@7a166d7532b277f34e16238930461bf77f9d7ed8 # v1.3.3
|
||||
|
||||
47
.github/workflows/build.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
api:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
@@ -34,12 +34,12 @@ jobs:
|
||||
- name: Prepare bundle stats artifact
|
||||
run: cp packages/api/app/stats.json api-stats.json
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: actual-api
|
||||
path: packages/api/actual-api.tgz
|
||||
- name: Upload API bundle stats
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: api-build-stats
|
||||
path: api-stats.json
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
crdt:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
- name: Create package tgz
|
||||
run: cd packages/crdt && yarn pack && mv package.tgz actual-crdt.tgz
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: actual-crdt
|
||||
path: packages/crdt/actual-crdt.tgz
|
||||
@@ -65,23 +65,23 @@ jobs:
|
||||
web:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Build Web
|
||||
run: yarn build:browser
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: actual-web
|
||||
path: packages/desktop-client/build
|
||||
- name: Upload Build Stats
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: build-stats
|
||||
path: packages/desktop-client/build-stats
|
||||
|
||||
server:
|
||||
cli:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
@@ -89,10 +89,35 @@ jobs:
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
download-translations: 'false'
|
||||
- name: Build Server
|
||||
run: yarn workspace @actual-app/sync-server build
|
||||
- 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:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
download-translations: 'false'
|
||||
- name: Build Server
|
||||
run: yarn workspace @actual-app/sync-server build
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: sync-server
|
||||
path: packages/sync-server/build
|
||||
|
||||
12
.github/workflows/check.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
constraints:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
typecheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
validate-cli:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
|
||||
6
.github/workflows/codeql.yml
vendored
@@ -22,14 +22,14 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
uses: github/codeql-action/init@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
|
||||
with:
|
||||
languages: javascript
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
uses: github/codeql-action/analyze@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
|
||||
with:
|
||||
category: '/language:javascript'
|
||||
|
||||
2
.github/workflows/count-points.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
|
||||
16
.github/workflows/docker-edge.yml
vendored
@@ -36,17 +36,17 @@ jobs:
|
||||
matrix:
|
||||
os: [ubuntu, alpine]
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
# Push to both Docker Hub and Github Container Registry
|
||||
images: ${{ env.IMAGES }}
|
||||
@@ -54,14 +54,14 @@ jobs:
|
||||
tags: ${{ env.TAGS }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
if: github.event_name != 'pull_request' && !github.event.repository.fork
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
registry: ghcr.io
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
run: yarn build:server
|
||||
|
||||
- name: Build image for testing
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
# This will use the cache from the earlier build step and not rebuild the image
|
||||
# https://docs.docker.com/build/ci/github-actions/test-before-push/
|
||||
- name: Build and push images
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
18
.github/workflows/docker-release.yml
vendored
@@ -28,17 +28,17 @@ jobs:
|
||||
name: Build Docker image
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
# Push to both Docker Hub and Github Container Registry
|
||||
images: ${{ env.IMAGES }}
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
|
||||
- name: Docker meta for Alpine image
|
||||
id: alpine-meta
|
||||
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: ${{ env.IMAGES }}
|
||||
# Automatically update :latest
|
||||
@@ -58,13 +58,13 @@ jobs:
|
||||
tags: ${{ env.TAGS }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
run: yarn build:server
|
||||
|
||||
- name: Build and push ubuntu image
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
|
||||
- name: Build and push alpine image
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
||||
20
.github/workflows/e2e-test.yml
vendored
@@ -32,7 +32,7 @@ jobs:
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.58.2-jammy
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
- name: Run E2E Tests
|
||||
run: yarn e2e --shard=${{ matrix.shard }}/5
|
||||
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
if: always()
|
||||
with:
|
||||
name: desktop-client-test-results-shard-${{ matrix.shard }}
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.58.2-jammy
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
- name: Run Desktop app E2E Tests
|
||||
run: |
|
||||
xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" -- yarn e2e:desktop
|
||||
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
if: always()
|
||||
with:
|
||||
name: desktop-app-test-results
|
||||
@@ -83,14 +83,14 @@ jobs:
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.58.2-jammy
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
download-translations: 'false'
|
||||
- name: Run VRT Tests
|
||||
run: yarn vrt --shard=${{ matrix.shard }}/5
|
||||
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
if: always()
|
||||
with:
|
||||
name: vrt-blob-report-${{ matrix.shard }}
|
||||
@@ -106,11 +106,11 @@ jobs:
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.58.2-jammy
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Download all blob reports
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
path: packages/desktop-client/all-blob-reports
|
||||
pattern: vrt-blob-report-*
|
||||
@@ -118,7 +118,7 @@ jobs:
|
||||
- name: Merge reports
|
||||
id: merge-reports
|
||||
run: yarn workspace @actual-app/web run playwright merge-reports --reporter html ./all-blob-reports
|
||||
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
id: playwright-report-vrt
|
||||
with:
|
||||
name: html-report--attempt-${{ github.run_attempt }}
|
||||
@@ -134,7 +134,7 @@ jobs:
|
||||
echo "${{ steps.playwright-report-vrt.outputs.artifact-url }}" > vrt-metadata/artifact-url.txt
|
||||
- name: Upload VRT metadata
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: vrt-comment-metadata
|
||||
path: vrt-metadata/
|
||||
|
||||
4
.github/workflows/e2e-vrt-comment.yml
vendored
@@ -18,7 +18,7 @@ jobs:
|
||||
if: github.event.workflow_run.event == 'pull_request'
|
||||
steps:
|
||||
- name: Download VRT metadata
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
|
||||
- name: Comment on PR with VRT report link
|
||||
if: steps.metadata.outputs.should_comment == 'true'
|
||||
uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
|
||||
uses: marocchino/sticky-pull-request-comment@70d2764d1a7d5d9560b100cbea0077fc8f633987 # v3.0.2
|
||||
with:
|
||||
number: ${{ steps.metadata.outputs.pr_number }}
|
||||
header: vrt-comment
|
||||
|
||||
10
.github/workflows/electron-master.yml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
- macos-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- if: ${{ startsWith(matrix.os, 'windows') }}
|
||||
run: pip.exe install setuptools
|
||||
- if: ${{ ! startsWith(matrix.os, 'windows') }}
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
if: ${{ ! startsWith(matrix.os, 'macos') }}
|
||||
run: ./bin/package-electron
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: actual-electron-${{ matrix.os }}
|
||||
path: |
|
||||
@@ -85,13 +85,13 @@ jobs:
|
||||
packages/desktop-electron/dist/*.flatpak
|
||||
- name: Upload Windows Store Build
|
||||
if: ${{ startsWith(matrix.os, 'windows') }}
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: actual-electron-${{ matrix.os }}-appx
|
||||
path: |
|
||||
packages/desktop-electron/dist/*.appx
|
||||
- name: Add to new release
|
||||
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
|
||||
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1
|
||||
with:
|
||||
draft: true
|
||||
body: |
|
||||
@@ -126,7 +126,7 @@ jobs:
|
||||
Install-Module -Name StoreBroker -AcceptLicense -Force -Scope CurrentUser -Verbose
|
||||
|
||||
- name: Download Microsoft Store artifacts
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: actual-electron-windows-latest-appx
|
||||
|
||||
|
||||
20
.github/workflows/electron-pr.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
- macos-latest
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- if: ${{ startsWith(matrix.os, 'windows') }}
|
||||
run: pip.exe install setuptools
|
||||
- if: ${{ ! startsWith(matrix.os, 'windows') }}
|
||||
@@ -65,56 +65,56 @@ jobs:
|
||||
run: ./bin/package-electron
|
||||
|
||||
- name: Upload Linux x64 AppImage
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: Actual-linux-x86_64.AppImage
|
||||
if-no-files-found: ignore
|
||||
path: packages/desktop-electron/dist/Actual-linux-x86_64.AppImage
|
||||
|
||||
- name: Upload Linux arm64 AppImage
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: Actual-linux-arm64.AppImage
|
||||
if-no-files-found: ignore
|
||||
path: packages/desktop-electron/dist/Actual-linux-arm64.AppImage
|
||||
|
||||
- name: Upload Linux x64 flatpak
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: Actual-linux-x86_64.flatpak
|
||||
if-no-files-found: ignore
|
||||
path: packages/desktop-electron/dist/Actual-linux-x86_64.flatpak
|
||||
|
||||
- name: Upload Windows x32 exe
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: Actual-windows-ia32.exe
|
||||
if-no-files-found: ignore
|
||||
path: packages/desktop-electron/dist/Actual-windows-ia32.exe
|
||||
|
||||
- name: Upload Windows x64 exe
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: Actual-windows-x64.exe
|
||||
if-no-files-found: ignore
|
||||
path: packages/desktop-electron/dist/Actual-windows-x64.exe
|
||||
|
||||
- name: Upload Windows arm64 exe
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: Actual-windows-arm64.exe
|
||||
if-no-files-found: ignore
|
||||
path: packages/desktop-electron/dist/Actual-windows-arm64.exe
|
||||
|
||||
- name: Upload Mac x64 dmg
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: Actual-mac-x64.dmg
|
||||
if-no-files-found: ignore
|
||||
path: packages/desktop-electron/dist/Actual-mac-x64.dmg
|
||||
|
||||
- name: Upload Mac arm64 dmg
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: Actual-mac-arm64.dmg
|
||||
if-no-files-found: ignore
|
||||
@@ -122,7 +122,7 @@ jobs:
|
||||
|
||||
- name: Upload Windows Store Build
|
||||
if: ${{ startsWith(matrix.os, 'windows') }}
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: actual-electron-${{ matrix.os }}-appx
|
||||
path: |
|
||||
|
||||
2
.github/workflows/fork-pr-welcome.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
if: github.event.pull_request.head.repo.full_name != github.repository
|
||||
steps:
|
||||
- name: Post welcome comment
|
||||
uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
|
||||
uses: marocchino/sticky-pull-request-comment@70d2764d1a7d5d9560b100cbea0077fc8f633987 # v3.0.2
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
number: ${{ github.event.pull_request.number }}
|
||||
|
||||
4
.github/workflows/generate-release-pr.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.inputs.ref }}
|
||||
- name: Set up environment
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
|
||||
echo "version=$NEW_WEB_VERSION" >> "$GITHUB_OUTPUT"
|
||||
- name: Create PR
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
token: ${{ secrets.ACTIONS_UPDATE_TOKEN }}
|
||||
commit-message: '🔖 (${{ steps.bump_package_versions.outputs.version }})'
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
if: github.repository == 'actualbudget/actual'
|
||||
steps:
|
||||
- name: Check out main repository
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
path: actual
|
||||
- name: Set up environment
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
push \
|
||||
actualbudget/actual
|
||||
- name: Check out updated translations
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ssh-key: ${{ secrets.STRING_IMPORT_DEPLOY_KEY }}
|
||||
repository: actualbudget/translations
|
||||
|
||||
@@ -24,8 +24,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# This is not a security concern because we have approved & merged the PR
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 22
|
||||
- name: Handle feature requests
|
||||
|
||||
2
.github/workflows/netlify-release.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Repository Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
|
||||
4
.github/workflows/publish-flathub.yml
vendored
@@ -92,7 +92,7 @@ jobs:
|
||||
echo "APPIMAGE_ARM64_SHA256=$APPIMAGE_ARM64_SHA256" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Checkout Flathub repo
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
repository: flathub/com.actualbudget.actual
|
||||
token: ${{ secrets.FLATHUB_GITHUB_TOKEN }}
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
cat com.actualbudget.actual.yml
|
||||
|
||||
- name: Create PR in Flathub repo
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
with:
|
||||
token: ${{ secrets.FLATHUB_GITHUB_TOKEN }}
|
||||
commit-message: 'Update Actual flatpak to version ${{ steps.resolve_version.outputs.version }}'
|
||||
|
||||
18
.github/workflows/publish-nightly-electron.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
if: github.event.repository.fork == false
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- if: ${{ startsWith(matrix.os, 'windows') }}
|
||||
run: pip.exe install setuptools
|
||||
|
||||
@@ -83,49 +83,49 @@ jobs:
|
||||
run: ./bin/package-electron
|
||||
|
||||
- name: Upload Linux x64 AppImage
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: Actual-linux-x86_64.AppImage
|
||||
if-no-files-found: ignore
|
||||
path: packages/desktop-electron/dist/Actual-linux-x86_64.AppImage
|
||||
|
||||
- name: Upload Linux arm64 AppImage
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: Actual-linux-arm64.AppImage
|
||||
if-no-files-found: ignore
|
||||
path: packages/desktop-electron/dist/Actual-linux-arm64.AppImage
|
||||
|
||||
- name: Upload Windows x32 exe
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: Actual-windows-ia32.exe
|
||||
if-no-files-found: ignore
|
||||
path: packages/desktop-electron/dist/Actual-windows-ia32.exe
|
||||
|
||||
- name: Upload Windows x64 exe
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: Actual-windows-x64.exe
|
||||
if-no-files-found: ignore
|
||||
path: packages/desktop-electron/dist/Actual-windows-x64.exe
|
||||
|
||||
- name: Upload Windows arm64 exe
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: Actual-windows-arm64.exe
|
||||
if-no-files-found: ignore
|
||||
path: packages/desktop-electron/dist/Actual-windows-arm64.exe
|
||||
|
||||
- name: Upload Mac x64 dmg
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: Actual-mac-x64.dmg
|
||||
if-no-files-found: ignore
|
||||
path: packages/desktop-electron/dist/Actual-mac-x64.dmg
|
||||
|
||||
- name: Upload Mac arm64 dmg
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: Actual-mac-arm64.dmg
|
||||
if-no-files-found: ignore
|
||||
@@ -133,7 +133,7 @@ jobs:
|
||||
|
||||
- name: Upload Windows Store Build
|
||||
if: ${{ startsWith(matrix.os, 'windows') }}
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: actual-electron-${{ matrix.os }}-appx
|
||||
path: |
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
name: Build and pack npm packages
|
||||
if: github.event.repository.fork == false
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
@@ -24,12 +24,14 @@ jobs:
|
||||
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,8 +56,15 @@ 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
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: npm-packages
|
||||
path: |
|
||||
@@ -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
|
||||
@@ -73,12 +83,12 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Download the artifacts
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: npm-packages
|
||||
|
||||
- name: Setup node and npm registry
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 22
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
@@ -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 }}
|
||||
|
||||
22
.github/workflows/publish-npm-packages.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
name: Build and pack npm packages
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
@@ -35,8 +35,15 @@ 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
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: npm-packages
|
||||
path: |
|
||||
@@ -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
|
||||
@@ -54,12 +62,12 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Download the artifacts
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: npm-packages
|
||||
|
||||
- name: Setup node and npm registry
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 22
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
@@ -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 }}
|
||||
|
||||
2
.github/workflows/release-notes.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Get changed files
|
||||
|
||||
45
.github/workflows/size-compare.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout base branch
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.base_ref }}
|
||||
- name: Set up environment
|
||||
@@ -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,15 +79,22 @@ 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
|
||||
|
||||
- name: Download web build artifact from ${{github.base_ref}}
|
||||
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
|
||||
uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18
|
||||
id: pr-web-build
|
||||
with:
|
||||
branch: ${{github.base_ref}}
|
||||
@@ -89,7 +103,7 @@ jobs:
|
||||
name: build-stats
|
||||
path: base
|
||||
- name: Download API build artifact from ${{github.base_ref}}
|
||||
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
|
||||
uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18
|
||||
id: pr-api-build
|
||||
with:
|
||||
branch: ${{github.base_ref}}
|
||||
@@ -98,7 +112,7 @@ jobs:
|
||||
name: api-build-stats
|
||||
path: base
|
||||
- name: Download build stats from PR
|
||||
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
|
||||
uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18
|
||||
with:
|
||||
pr: ${{github.event.pull_request.number}}
|
||||
workflow: build.yml
|
||||
@@ -107,7 +121,7 @@ jobs:
|
||||
path: head
|
||||
allow_forks: true
|
||||
- name: Download API stats from PR
|
||||
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
|
||||
uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18
|
||||
with:
|
||||
pr: ${{github.event.pull_request.number}}
|
||||
workflow: build.yml
|
||||
@@ -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
|
||||
|
||||
6
.github/workflows/stale.yml
vendored
@@ -8,7 +8,7 @@ jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
with:
|
||||
stale-pr-message: 'This PR is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days.'
|
||||
close-pr-message: 'This PR was closed because it has been stalled for 5 days with no activity.'
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
stale-wip:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
with:
|
||||
stale-pr-message: ':wave: Hi! It looks like this PR has not had any changes for a week now. Would you like someone to review this PR? If so - please remove the "[WIP]" prefix from the PR title. That will let the community know that this PR is open for a review.'
|
||||
days-before-stale: 7
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
stale-needs-info:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
with:
|
||||
stale-issue-label: 'needs info'
|
||||
days-before-stale: -1
|
||||
|
||||
6
.github/workflows/vrt-update-apply.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
steps:
|
||||
- name: Download patch artifact
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
path: /tmp/artifacts
|
||||
|
||||
- name: Download metadata artifact
|
||||
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
|
||||
- name: Checkout fork branch
|
||||
if: steps.metadata.outputs.pr_number != ''
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
repository: ${{ steps.metadata.outputs.head_repo }}
|
||||
ref: ${{ steps.metadata.outputs.head_ref }}
|
||||
|
||||
6
.github/workflows/vrt-update-generate.yml
vendored
@@ -60,7 +60,7 @@ jobs:
|
||||
core.setOutput('head_ref', pr.head.ref);
|
||||
core.setOutput('head_repo', pr.head.repo.full_name);
|
||||
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ steps.pr.outputs.head_sha }}
|
||||
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
|
||||
- name: Upload patch artifact
|
||||
if: steps.create-patch.outputs.has_changes == 'true'
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: vrt-patch-${{ github.event.issue.number }}
|
||||
path: vrt-update.patch
|
||||
@@ -129,7 +129,7 @@ jobs:
|
||||
|
||||
- name: Upload PR metadata
|
||||
if: steps.create-patch.outputs.has_changes == 'true'
|
||||
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: vrt-metadata-${{ github.event.issue.number }}
|
||||
path: pr-metadata/
|
||||
|
||||
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
|
||||
|
||||
@@ -17,6 +17,7 @@ module.exports = {
|
||||
},
|
||||
build: {
|
||||
type: 'npmScript',
|
||||
dependsOn: ['^build'],
|
||||
cache: true,
|
||||
options: {
|
||||
outputGlob: ['lib-dist/**', 'dist/**', 'build/**'],
|
||||
|
||||
18
package.json
@@ -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",
|
||||
@@ -64,24 +66,24 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@types/node": "^22.19.10",
|
||||
"@types/node": "^22.19.15",
|
||||
"@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",
|
||||
"baseline-browser-mapping": "^2.10.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-perfectionist": "^4.15.1",
|
||||
"eslint": "^9.39.3",
|
||||
"eslint-plugin-perfectionist": "^5.6.0",
|
||||
"eslint-plugin-typescript-paths": "^0.0.33",
|
||||
"html-to-image": "^1.11.13",
|
||||
"husky": "^9.1.7",
|
||||
"lage": "^2.14.17",
|
||||
"lint-staged": "^16.2.7",
|
||||
"minimatch": "^10.1.2",
|
||||
"lage": "^2.14.19",
|
||||
"lint-staged": "^16.3.2",
|
||||
"minimatch": "^10.2.4",
|
||||
"node-jq": "^6.3.1",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"oxfmt": "^0.32.0",
|
||||
"oxlint": "^1.47.0",
|
||||
"oxlint": "^1.51.0",
|
||||
"oxlint-tsgolint": "^0.13.0",
|
||||
"p-limit": "^7.3.0",
|
||||
"prompts": "^2.4.2",
|
||||
|
||||
@@ -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",
|
||||
@@ -24,7 +38,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
|
||||
"rollup-plugin-visualizer": "^6.0.5",
|
||||
"rollup-plugin-visualizer": "^6.0.11",
|
||||
"typescript-strict-plugin": "^2.4.4",
|
||||
"vite": "^8.0.0",
|
||||
"vite-plugin-dts": "^4.5.4",
|
||||
|
||||
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
@@ -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
@@ -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.15",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
|
||||
"rollup-plugin-visualizer": "^6.0.11",
|
||||
"vite": "^8.0.0",
|
||||
"vitest": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
}
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -5,7 +5,6 @@ import type { Preview } from '@storybook/react-vite';
|
||||
// Not ideal to import from desktop-client, but we need a source of truth for theme variables
|
||||
// TODO: this needs refactoring
|
||||
import * as darkTheme from '../../desktop-client/src/style/themes/dark';
|
||||
import * as developmentTheme from '../../desktop-client/src/style/themes/development';
|
||||
import * as lightTheme from '../../desktop-client/src/style/themes/light';
|
||||
import * as midnightTheme from '../../desktop-client/src/style/themes/midnight';
|
||||
|
||||
@@ -13,7 +12,6 @@ const THEMES = {
|
||||
light: lightTheme,
|
||||
dark: darkTheme,
|
||||
midnight: midnightTheme,
|
||||
development: developmentTheme,
|
||||
} as const;
|
||||
|
||||
type ThemeName = keyof typeof THEMES;
|
||||
@@ -64,7 +62,6 @@ const preview: Preview = {
|
||||
{ value: 'light', title: 'Light' },
|
||||
{ value: 'dark', title: 'Dark' },
|
||||
{ value: 'midnight', title: 'Midnight' },
|
||||
{ value: 'development', title: 'Development' },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -48,18 +48,18 @@
|
||||
"usehooks-ts": "^3.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "^5.0.0",
|
||||
"@storybook/addon-a11y": "^10.2.7",
|
||||
"@storybook/addon-docs": "^10.2.7",
|
||||
"@storybook/react-vite": "^10.2.7",
|
||||
"@chromatic-com/storybook": "^5.0.1",
|
||||
"@storybook/addon-a11y": "^10.2.16",
|
||||
"@storybook/addon-docs": "^10.2.16",
|
||||
"@storybook/react-vite": "^10.2.16",
|
||||
"@svgr/cli": "^8.1.0",
|
||||
"@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",
|
||||
"eslint-plugin-storybook": "^10.2.16",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"storybook": "^10.2.7",
|
||||
"storybook": "^10.2.16",
|
||||
"vite": "^8.0.0",
|
||||
"vitest": "^4.1.0"
|
||||
},
|
||||
|
||||
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
@@ -5,6 +5,7 @@ export class BudgetMenuModal {
|
||||
readonly locator: Locator;
|
||||
readonly heading: Locator;
|
||||
readonly budgetAmountInput: Locator;
|
||||
readonly actionsButton: Locator;
|
||||
readonly copyLastMonthBudgetButton: Locator;
|
||||
readonly setTo3MonthAverageButton: Locator;
|
||||
readonly setTo6MonthAverageButton: Locator;
|
||||
@@ -17,6 +18,9 @@ export class BudgetMenuModal {
|
||||
|
||||
this.heading = locator.getByRole('heading');
|
||||
this.budgetAmountInput = locator.getByTestId('amount-input');
|
||||
this.actionsButton = locator.getByRole('button', {
|
||||
name: 'Actions',
|
||||
});
|
||||
this.copyLastMonthBudgetButton = locator.getByRole('button', {
|
||||
name: "Copy last month's budget",
|
||||
});
|
||||
@@ -38,6 +42,10 @@ export class BudgetMenuModal {
|
||||
await this.heading.getByRole('button', { name: 'Close' }).click();
|
||||
}
|
||||
|
||||
async showActions() {
|
||||
await this.actionsButton.click();
|
||||
}
|
||||
|
||||
async setBudgetAmount(newAmount: string) {
|
||||
await this.budgetAmountInput.fill(newAmount);
|
||||
await this.budgetAmountInput.blur();
|
||||
@@ -45,22 +53,27 @@ export class BudgetMenuModal {
|
||||
}
|
||||
|
||||
async copyLastMonthBudget() {
|
||||
await this.showActions();
|
||||
await this.copyLastMonthBudgetButton.click();
|
||||
}
|
||||
|
||||
async setTo3MonthAverage() {
|
||||
await this.showActions();
|
||||
await this.setTo3MonthAverageButton.click();
|
||||
}
|
||||
|
||||
async setTo6MonthAverage() {
|
||||
await this.showActions();
|
||||
await this.setTo6MonthAverageButton.click();
|
||||
}
|
||||
|
||||
async setToYearlyAverage() {
|
||||
await this.showActions();
|
||||
await this.setToYearlyAverageButton.click();
|
||||
}
|
||||
|
||||
async applyBudgetTemplate() {
|
||||
await this.showActions();
|
||||
await this.applyBudgetTemplateButton.click();
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 120 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 121 KiB After Width: | Height: | Size: 121 KiB |
@@ -22,9 +22,9 @@
|
||||
"@actual-app/components": "workspace:*",
|
||||
"@actual-app/core": "workspace:*",
|
||||
"@babel/core": "^7.29.0",
|
||||
"@codemirror/autocomplete": "^6.20.0",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/language": "^6.12.1",
|
||||
"@codemirror/autocomplete": "^6.20.1",
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@codemirror/language": "^6.12.2",
|
||||
"@codemirror/state": "^6.5.4",
|
||||
"@codemirror/view": "^6.38.7",
|
||||
"@emotion/css": "^11.13.5",
|
||||
@@ -33,11 +33,11 @@
|
||||
"@juggle/resize-observer": "^3.4.0",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@playwright/test": "1.58.2",
|
||||
"@rolldown/plugin-babel": "~0.1.7",
|
||||
"@rolldown/plugin-babel": "~0.1.8",
|
||||
"@rollup/plugin-inject": "^5.0.5",
|
||||
"@swc/core": "^1.15.11",
|
||||
"@swc/helpers": "^0.5.18",
|
||||
"@tanstack/react-query": "^5.90.20",
|
||||
"@swc/core": "^1.15.18",
|
||||
"@swc/helpers": "^0.5.19",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@testing-library/dom": "10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "16.3.2",
|
||||
@@ -49,7 +49,7 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-modal": "^3.16.3",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
|
||||
"@uiw/react-codemirror": "^4.25.4",
|
||||
"@uiw/react-codemirror": "^4.25.7",
|
||||
"@use-gesture/react": "^10.3.1",
|
||||
"@vitejs/plugin-basic-ssl": "^2.2.0",
|
||||
"@vitejs/plugin-react": "^6.0.0",
|
||||
@@ -58,10 +58,10 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"downshift": "9.0.10",
|
||||
"hyperformula": "^3.1.1",
|
||||
"i18next": "^25.8.4",
|
||||
"i18next-parser": "^9.3.0",
|
||||
"downshift": "9.3.2",
|
||||
"hyperformula": "^3.2.0",
|
||||
"i18next": "^25.8.14",
|
||||
"i18next-parser": "^9.4.0",
|
||||
"i18next-resources-to-backend": "^1.2.1",
|
||||
"jsdom": "^27.4.0",
|
||||
"lodash": "^4.17.23",
|
||||
@@ -80,19 +80,19 @@
|
||||
"react-error-boundary": "^6.0.3",
|
||||
"react-grid-layout": "^2.2.2",
|
||||
"react-hotkeys-hook": "^5.2.4",
|
||||
"react-i18next": "^16.5.4",
|
||||
"react-i18next": "^16.5.6",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-modal": "3.16.3",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "7.13.0",
|
||||
"react-router": "7.13.1",
|
||||
"react-simple-pull-to-refresh": "^1.3.4",
|
||||
"react-spring": "^10.0.3",
|
||||
"react-swipeable": "^7.0.2",
|
||||
"react-virtualized-auto-sizer": "^2.0.2",
|
||||
"react-virtualized-auto-sizer": "^2.0.3",
|
||||
"recharts": "^3.7.0",
|
||||
"rehype-external-links": "^3.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"rollup-plugin-visualizer": "^6.0.5",
|
||||
"rollup-plugin-visualizer": "^6.0.11",
|
||||
"sass": "^1.97.3",
|
||||
"typescript-strict-plugin": "^2.4.4",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
|
||||
@@ -33,7 +33,6 @@ export function ThemeSelector({ style }: ThemeSelectorProps) {
|
||||
dark: SvgMoonStars,
|
||||
auto: SvgSystem,
|
||||
midnight: SvgMoonStars,
|
||||
development: SvgMoonStars,
|
||||
} as const;
|
||||
|
||||
type ThemeIconKey = keyof typeof themeIcons;
|
||||
|
||||
@@ -25,6 +25,7 @@ import { IncomeMenu } from './IncomeMenu';
|
||||
|
||||
import { BalanceWithCarryover } from '@desktop-client/components/budget/BalanceWithCarryover';
|
||||
import { makeAmountGrey } from '@desktop-client/components/budget/util';
|
||||
import { NotesButton } from '@desktop-client/components/NotesButton';
|
||||
import {
|
||||
CellValue,
|
||||
CellValueText,
|
||||
@@ -281,85 +282,100 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({
|
||||
}}
|
||||
>
|
||||
{!editing && (
|
||||
<View
|
||||
className={`hover-expand ${budgetMenuOpen ? 'force-visible' : ''}`}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
flexShrink: 1,
|
||||
paddingLeft: 3,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderTopWidth: 1,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: theme.tableBorder,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="bare"
|
||||
onPress={() => {
|
||||
resetBudgetPosition(2, -4);
|
||||
setBudgetMenuOpen(true);
|
||||
}}
|
||||
<>
|
||||
<View
|
||||
style={{
|
||||
color: theme.budgetNumberNeutral, //make sure button is visible when hovered
|
||||
padding: 3,
|
||||
paddingLeft: 3,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderTopWidth: 1,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: theme.tableBorder,
|
||||
}}
|
||||
>
|
||||
<SvgCheveronDown
|
||||
width={14}
|
||||
height={14}
|
||||
className="hover-visible"
|
||||
<NotesButton
|
||||
id={`${category.id}-${month}`}
|
||||
defaultColor={theme.pageTextLight}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Popover
|
||||
triggerRef={budgetMenuTriggerRef}
|
||||
placement="bottom left"
|
||||
isOpen={budgetMenuOpen}
|
||||
onOpenChange={() => setBudgetMenuOpen(false)}
|
||||
style={{ width: 200 }}
|
||||
isNonModal
|
||||
{...budgetPosition}
|
||||
</View>
|
||||
<View
|
||||
className={`hover-expand ${budgetMenuOpen ? 'force-visible' : ''}`}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
flexShrink: 1,
|
||||
paddingLeft: 3,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderTopWidth: 1,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: theme.tableBorder,
|
||||
}}
|
||||
>
|
||||
<BudgetMenu
|
||||
onCopyLastMonthAverage={() => {
|
||||
onMenuAction(month, 'copy-single-last', {
|
||||
category: category.id,
|
||||
});
|
||||
showUndoNotification({
|
||||
message: t(`Budget set to last month's budget.`),
|
||||
});
|
||||
<Button
|
||||
variant="bare"
|
||||
onPress={() => {
|
||||
resetBudgetPosition(2, -4);
|
||||
setBudgetMenuOpen(true);
|
||||
}}
|
||||
onSetMonthsAverage={numberOfMonths => {
|
||||
if (
|
||||
numberOfMonths !== 3 &&
|
||||
numberOfMonths !== 6 &&
|
||||
numberOfMonths !== 12
|
||||
) {
|
||||
return;
|
||||
}
|
||||
style={{
|
||||
padding: 3,
|
||||
}}
|
||||
>
|
||||
<SvgCheveronDown
|
||||
width={14}
|
||||
height={14}
|
||||
className="hover-visible"
|
||||
/>
|
||||
</Button>
|
||||
<Popover
|
||||
triggerRef={budgetMenuTriggerRef}
|
||||
placement="bottom left"
|
||||
isOpen={budgetMenuOpen}
|
||||
onOpenChange={() => setBudgetMenuOpen(false)}
|
||||
style={{ width: 200 }}
|
||||
isNonModal
|
||||
{...budgetPosition}
|
||||
>
|
||||
<BudgetMenu
|
||||
onCopyLastMonthAverage={() => {
|
||||
onMenuAction(month, 'copy-single-last', {
|
||||
category: category.id,
|
||||
});
|
||||
showUndoNotification({
|
||||
message: t(`Budget set to last month's budget.`),
|
||||
});
|
||||
}}
|
||||
onSetMonthsAverage={numberOfMonths => {
|
||||
if (
|
||||
numberOfMonths !== 3 &&
|
||||
numberOfMonths !== 6 &&
|
||||
numberOfMonths !== 12
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
onMenuAction(month, `set-single-${numberOfMonths}-avg`, {
|
||||
category: category.id,
|
||||
});
|
||||
showUndoNotification({
|
||||
message: t(
|
||||
'Budget set to {{numberOfMonths}}-month average.',
|
||||
{ numberOfMonths },
|
||||
),
|
||||
});
|
||||
}}
|
||||
onApplyBudgetTemplate={() => {
|
||||
onMenuAction(month, 'apply-single-category-template', {
|
||||
category: category.id,
|
||||
});
|
||||
showUndoNotification({
|
||||
message: t(`Budget template applied.`),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
</View>
|
||||
onMenuAction(month, `set-single-${numberOfMonths}-avg`, {
|
||||
category: category.id,
|
||||
});
|
||||
showUndoNotification({
|
||||
message: t(
|
||||
'Budget set to {{numberOfMonths}}-month average.',
|
||||
{ numberOfMonths },
|
||||
),
|
||||
});
|
||||
}}
|
||||
onApplyBudgetTemplate={() => {
|
||||
onMenuAction(month, 'apply-single-category-template', {
|
||||
category: category.id,
|
||||
});
|
||||
showUndoNotification({
|
||||
message: t(`Budget template applied.`),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
<EnvelopeSheetCell
|
||||
name="budget"
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Text } from '@actual-app/components/text';
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { View } from '@actual-app/components/view';
|
||||
import { css } from '@emotion/css';
|
||||
import { t } from 'i18next';
|
||||
|
||||
import * as monthUtils from 'loot-core/shared/months';
|
||||
|
||||
@@ -25,6 +26,7 @@ import { BudgetMenu } from './BudgetMenu';
|
||||
|
||||
import { BalanceWithCarryover } from '@desktop-client/components/budget/BalanceWithCarryover';
|
||||
import { makeAmountGrey } from '@desktop-client/components/budget/util';
|
||||
import { NotesButton } from '@desktop-client/components/NotesButton';
|
||||
import {
|
||||
CellValue,
|
||||
CellValueText,
|
||||
@@ -261,77 +263,96 @@ export const CategoryMonth = memo(function CategoryMonth({
|
||||
}}
|
||||
>
|
||||
{!editing && (
|
||||
<View
|
||||
className={`hover-expand ${menuOpen ? 'force-visible' : ''}`}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
flexShrink: 0,
|
||||
paddingLeft: 3,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderTopWidth: 1,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: theme.tableBorder,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
variant="bare"
|
||||
onPress={() => setMenuOpen(true)}
|
||||
<>
|
||||
<View
|
||||
style={{
|
||||
color: theme.budgetNumberNeutral, //make sure button is visible when hovered
|
||||
padding: 3,
|
||||
paddingLeft: 3,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderTopWidth: 1,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: theme.tableBorder,
|
||||
}}
|
||||
>
|
||||
<SvgCheveronDown
|
||||
width={14}
|
||||
height={14}
|
||||
className="hover-visible"
|
||||
<NotesButton
|
||||
id={`${category.id}-${month}`}
|
||||
defaultColor={theme.pageTextLight}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Popover
|
||||
triggerRef={triggerRef}
|
||||
isOpen={menuOpen}
|
||||
onOpenChange={() => setMenuOpen(false)}
|
||||
placement="bottom start"
|
||||
</View>
|
||||
<View
|
||||
className={`hover-expand ${menuOpen ? 'force-visible' : ''}`}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
flexShrink: 0,
|
||||
paddingLeft: 3,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderTopWidth: 1,
|
||||
borderBottomWidth: 1,
|
||||
borderColor: theme.tableBorder,
|
||||
}}
|
||||
>
|
||||
<BudgetMenu
|
||||
onCopyLastMonthAverage={() => {
|
||||
onMenuAction(month, 'copy-single-last', {
|
||||
category: category.id,
|
||||
});
|
||||
showUndoNotification({
|
||||
message: `Budget set to last month's budget.`,
|
||||
});
|
||||
<Button
|
||||
ref={triggerRef}
|
||||
variant="bare"
|
||||
onPress={() => setMenuOpen(true)}
|
||||
style={{
|
||||
padding: 3,
|
||||
}}
|
||||
onSetMonthsAverage={numberOfMonths => {
|
||||
if (
|
||||
numberOfMonths !== 3 &&
|
||||
numberOfMonths !== 6 &&
|
||||
numberOfMonths !== 12
|
||||
) {
|
||||
return;
|
||||
}
|
||||
>
|
||||
<SvgCheveronDown
|
||||
width={14}
|
||||
height={14}
|
||||
className="hover-visible"
|
||||
/>
|
||||
</Button>
|
||||
|
||||
onMenuAction(month, `set-single-${numberOfMonths}-avg`, {
|
||||
category: category.id,
|
||||
});
|
||||
showUndoNotification({
|
||||
message: `Budget set to ${numberOfMonths}-month average.`,
|
||||
});
|
||||
}}
|
||||
onApplyBudgetTemplate={() => {
|
||||
onMenuAction(month, 'apply-single-category-template', {
|
||||
category: category.id,
|
||||
});
|
||||
showUndoNotification({
|
||||
message: `Budget template applied.`,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
</View>
|
||||
<Popover
|
||||
triggerRef={triggerRef}
|
||||
isOpen={menuOpen}
|
||||
onOpenChange={() => setMenuOpen(false)}
|
||||
placement="bottom start"
|
||||
>
|
||||
<BudgetMenu
|
||||
onCopyLastMonthAverage={() => {
|
||||
onMenuAction(month, 'copy-single-last', {
|
||||
category: category.id,
|
||||
});
|
||||
showUndoNotification({
|
||||
message: t(`Budget set to last month's budget.`),
|
||||
});
|
||||
}}
|
||||
onSetMonthsAverage={numberOfMonths => {
|
||||
if (
|
||||
numberOfMonths !== 3 &&
|
||||
numberOfMonths !== 6 &&
|
||||
numberOfMonths !== 12
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
onMenuAction(month, `set-single-${numberOfMonths}-avg`, {
|
||||
category: category.id,
|
||||
});
|
||||
showUndoNotification({
|
||||
message: t(
|
||||
'Budget set to {{numberOfMonths}}-month average.',
|
||||
{ numberOfMonths },
|
||||
),
|
||||
});
|
||||
}}
|
||||
onApplyBudgetTemplate={() => {
|
||||
onMenuAction(month, 'apply-single-category-template', {
|
||||
category: category.id,
|
||||
});
|
||||
showUndoNotification({
|
||||
message: t(`Budget template applied.`),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
<TrackingSheetCell
|
||||
name="budget"
|
||||
|
||||
@@ -7,6 +7,8 @@ import { styles } from '@actual-app/components/styles';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
import { AutoTextSize } from 'auto-text-size';
|
||||
|
||||
import { send } from 'loot-core/platform/client/connection';
|
||||
import * as monthUtils from 'loot-core/shared/months';
|
||||
import type { CategoryEntity } from 'loot-core/types/models';
|
||||
|
||||
import { getColumnWidth, PILL_STYLE } from './BudgetTable';
|
||||
@@ -15,6 +17,7 @@ import { makeAmountGrey } from '@desktop-client/components/budget/util';
|
||||
import { PrivacyFilter } from '@desktop-client/components/PrivacyFilter';
|
||||
import { CellValue } from '@desktop-client/components/spreadsheet/CellValue';
|
||||
import { useFormat } from '@desktop-client/hooks/useFormat';
|
||||
import { useLocale } from '@desktop-client/hooks/useLocale';
|
||||
import { useNotes } from '@desktop-client/hooks/useNotes';
|
||||
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
|
||||
import { useUndo } from '@desktop-client/hooks/useUndo';
|
||||
@@ -43,6 +46,7 @@ export function BudgetCell<
|
||||
...props
|
||||
}: BudgetCellProps<SheetFieldName>) {
|
||||
const { t } = useTranslation();
|
||||
const locale = useLocale();
|
||||
const columnWidth = getColumnWidth();
|
||||
const dispatch = useDispatch();
|
||||
const format = useFormat();
|
||||
@@ -50,6 +54,31 @@ export function BudgetCell<
|
||||
const [budgetType = 'envelope'] = useSyncedPref('budgetType');
|
||||
const categoryNotes = useNotes(category.id);
|
||||
|
||||
const onSaveNotes = useCallback(async (id: string, notes: string) => {
|
||||
await send('notes-save', { id, note: notes });
|
||||
}, []);
|
||||
|
||||
const onEditNotes = useCallback(
|
||||
(id: string, month: string) => {
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'notes',
|
||||
options: {
|
||||
id,
|
||||
name:
|
||||
category.name +
|
||||
' - ' +
|
||||
monthUtils.format(month, "MMMM ''yy", locale),
|
||||
onSave: onSaveNotes,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
[category.name, locale, dispatch, onSaveNotes],
|
||||
);
|
||||
|
||||
const onOpenCategoryBudgetMenu = useCallback(() => {
|
||||
const modalBudgetType = budgetType === 'envelope' ? 'envelope' : 'tracking';
|
||||
const categoryBudgetMenuModal = `${modalBudgetType}-budget-menu` as const;
|
||||
@@ -60,6 +89,7 @@ export function BudgetCell<
|
||||
options: {
|
||||
categoryId: category.id,
|
||||
month,
|
||||
onEditNotes,
|
||||
onUpdateBudget: amount => {
|
||||
onBudgetAction(month, 'budget-amount', {
|
||||
category: category.id,
|
||||
@@ -114,6 +144,7 @@ export function BudgetCell<
|
||||
month,
|
||||
onBudgetAction,
|
||||
showUndoNotification,
|
||||
onEditNotes,
|
||||
format,
|
||||
]);
|
||||
|
||||
|
||||
@@ -2,10 +2,17 @@ import React, { useEffect, useState } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { Trans } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import {
|
||||
SvgCheveronDown,
|
||||
SvgCheveronUp,
|
||||
} from '@actual-app/components/icons/v1';
|
||||
import { SvgNotesPaper } from '@actual-app/components/icons/v2';
|
||||
import { styles } 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';
|
||||
import { t } from 'i18next';
|
||||
|
||||
import * as Platform from 'loot-core/shared/platform';
|
||||
import { amountToInteger, integerToAmount } from 'loot-core/shared/util';
|
||||
@@ -19,14 +26,16 @@ import {
|
||||
ModalTitle,
|
||||
} from '@desktop-client/components/common/Modal';
|
||||
import { FocusableAmountInput } from '@desktop-client/components/mobile/transactions/FocusableAmountInput';
|
||||
import { Notes } from '@desktop-client/components/Notes';
|
||||
import { useCategory } from '@desktop-client/hooks/useCategory';
|
||||
import { useNotes } from '@desktop-client/hooks/useNotes';
|
||||
import type { Modal as ModalType } from '@desktop-client/modals/modalsSlice';
|
||||
import { envelopeBudget } from '@desktop-client/spreadsheet/bindings';
|
||||
|
||||
type EnvelopeBudgetMenuModalProps = Omit<
|
||||
Extract<ModalType, { name: 'envelope-budget-menu' }>['options'],
|
||||
'month'
|
||||
>;
|
||||
type EnvelopeBudgetMenuModalProps = Extract<
|
||||
ModalType,
|
||||
{ name: 'envelope-budget-menu' }
|
||||
>['options'];
|
||||
|
||||
export function EnvelopeBudgetMenuModal({
|
||||
categoryId,
|
||||
@@ -34,7 +43,17 @@ export function EnvelopeBudgetMenuModal({
|
||||
onCopyLastMonthAverage,
|
||||
onSetMonthsAverage,
|
||||
onApplyBudgetTemplate,
|
||||
onEditNotes,
|
||||
month,
|
||||
}: EnvelopeBudgetMenuModalProps) {
|
||||
const buttonStyle: CSSProperties = {
|
||||
...styles.mediumText,
|
||||
height: styles.mobileMinHeight,
|
||||
color: theme.formLabelText,
|
||||
// Adjust based on desired number of buttons per row.
|
||||
flexBasis: '100%',
|
||||
};
|
||||
|
||||
const defaultMenuItemStyle: CSSProperties = {
|
||||
...styles.mobileMenuItem,
|
||||
color: theme.menuItemText,
|
||||
@@ -48,10 +67,24 @@ export function EnvelopeBudgetMenuModal({
|
||||
const { data: category } = useCategory(categoryId);
|
||||
const [amountFocused, setAmountFocused] = useState(false);
|
||||
|
||||
const notesId = category ? `${category.id}-${month}` : '';
|
||||
const originalNotes = useNotes(notesId) ?? '';
|
||||
const _onUpdateBudget = (amount: number) => {
|
||||
onUpdateBudget?.(amountToInteger(amount));
|
||||
};
|
||||
|
||||
const [showMore, setShowMore] = useState(false);
|
||||
|
||||
const onShowMore = () => {
|
||||
setShowMore(!showMore);
|
||||
};
|
||||
|
||||
const _onEditNotes = () => {
|
||||
if (category && month) {
|
||||
onEditNotes?.(`${category.id}-${month}`, month);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// iOS does not support automatically opening up the keyboard for the
|
||||
// total amount field. Hence we should not focus on it on page render.
|
||||
@@ -76,7 +109,6 @@ export function EnvelopeBudgetMenuModal({
|
||||
style={{
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 20,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
@@ -106,12 +138,71 @@ export function EnvelopeBudgetMenuModal({
|
||||
data-testid="budget-amount"
|
||||
/>
|
||||
</View>
|
||||
<BudgetMenu
|
||||
getItemStyle={() => defaultMenuItemStyle}
|
||||
onCopyLastMonthAverage={onCopyLastMonthAverage}
|
||||
onSetMonthsAverage={onSetMonthsAverage}
|
||||
onApplyBudgetTemplate={onApplyBudgetTemplate}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
display: showMore ? 'none' : undefined,
|
||||
overflowY: 'auto',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<Notes
|
||||
notes={originalNotes.length > 0 ? originalNotes : t('No notes')}
|
||||
editable={false}
|
||||
focused={false}
|
||||
getStyle={() => ({
|
||||
borderRadius: 6,
|
||||
...(originalNotes.length === 0 && {
|
||||
justifySelf: 'center',
|
||||
alignSelf: 'center',
|
||||
color: theme.pageTextSubdued,
|
||||
}),
|
||||
})}
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
display: showMore ? 'none' : undefined,
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'space-between',
|
||||
alignContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Button style={buttonStyle} onPress={_onEditNotes}>
|
||||
<SvgNotesPaper
|
||||
width={20}
|
||||
height={20}
|
||||
style={{ paddingRight: 5 }}
|
||||
/>
|
||||
<Trans>Edit notes</Trans>
|
||||
</Button>
|
||||
</View>
|
||||
<View>
|
||||
<Button variant="bare" style={buttonStyle} onPress={onShowMore}>
|
||||
{!showMore ? (
|
||||
<SvgCheveronUp
|
||||
width={30}
|
||||
height={30}
|
||||
style={{ paddingRight: 5 }}
|
||||
/>
|
||||
) : (
|
||||
<SvgCheveronDown
|
||||
width={30}
|
||||
height={30}
|
||||
style={{ paddingRight: 5 }}
|
||||
/>
|
||||
)}
|
||||
<Trans>Actions</Trans>
|
||||
</Button>
|
||||
</View>
|
||||
{showMore && (
|
||||
<BudgetMenu
|
||||
getItemStyle={() => defaultMenuItemStyle}
|
||||
onCopyLastMonthAverage={onCopyLastMonthAverage}
|
||||
onSetMonthsAverage={onSetMonthsAverage}
|
||||
onApplyBudgetTemplate={onApplyBudgetTemplate}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
@@ -2,10 +2,17 @@ import React, { useEffect, useState } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { Trans } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import {
|
||||
SvgCheveronDown,
|
||||
SvgCheveronUp,
|
||||
} from '@actual-app/components/icons/v1';
|
||||
import { SvgNotesPaper } from '@actual-app/components/icons/v2';
|
||||
import { styles } 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';
|
||||
import { t } from 'i18next';
|
||||
|
||||
import * as Platform from 'loot-core/shared/platform';
|
||||
import { amountToInteger, integerToAmount } from 'loot-core/shared/util';
|
||||
@@ -19,14 +26,16 @@ import {
|
||||
ModalTitle,
|
||||
} from '@desktop-client/components/common/Modal';
|
||||
import { FocusableAmountInput } from '@desktop-client/components/mobile/transactions/FocusableAmountInput';
|
||||
import { Notes } from '@desktop-client/components/Notes';
|
||||
import { useCategory } from '@desktop-client/hooks/useCategory';
|
||||
import { useNotes } from '@desktop-client/hooks/useNotes';
|
||||
import type { Modal as ModalType } from '@desktop-client/modals/modalsSlice';
|
||||
import { trackingBudget } from '@desktop-client/spreadsheet/bindings';
|
||||
|
||||
type TrackingBudgetMenuModalProps = Omit<
|
||||
Extract<ModalType, { name: 'tracking-budget-menu' }>['options'],
|
||||
'month'
|
||||
>;
|
||||
type TrackingBudgetMenuModalProps = Extract<
|
||||
ModalType,
|
||||
{ name: 'tracking-budget-menu' }
|
||||
>['options'];
|
||||
|
||||
export function TrackingBudgetMenuModal({
|
||||
categoryId,
|
||||
@@ -34,6 +43,8 @@ export function TrackingBudgetMenuModal({
|
||||
onCopyLastMonthAverage,
|
||||
onSetMonthsAverage,
|
||||
onApplyBudgetTemplate,
|
||||
onEditNotes,
|
||||
month,
|
||||
}: TrackingBudgetMenuModalProps) {
|
||||
const defaultMenuItemStyle: CSSProperties = {
|
||||
...styles.mobileMenuItem,
|
||||
@@ -42,16 +53,38 @@ export function TrackingBudgetMenuModal({
|
||||
borderTop: `1px solid ${theme.pillBorder}`,
|
||||
};
|
||||
|
||||
const buttonStyle: CSSProperties = {
|
||||
...styles.mediumText,
|
||||
height: styles.mobileMinHeight,
|
||||
color: theme.formLabelText,
|
||||
// Adjust based on desired number of buttons per row.
|
||||
flexBasis: '100%',
|
||||
};
|
||||
const budgeted = useTrackingSheetValue(
|
||||
trackingBudget.catBudgeted(categoryId),
|
||||
);
|
||||
const { data: category } = useCategory(categoryId);
|
||||
const notesId = category ? `${category.id}-${month}` : '';
|
||||
const originalNotes = useNotes(notesId) ?? '';
|
||||
|
||||
const [amountFocused, setAmountFocused] = useState(false);
|
||||
|
||||
const _onUpdateBudget = (amount: number) => {
|
||||
onUpdateBudget?.(amountToInteger(amount));
|
||||
};
|
||||
|
||||
const _onEditNotes = () => {
|
||||
if (category && month) {
|
||||
onEditNotes?.(`${category.id}-${month}`, month);
|
||||
}
|
||||
};
|
||||
|
||||
const [showMore, setShowMore] = useState(false);
|
||||
|
||||
const onShowMore = () => {
|
||||
setShowMore(!showMore);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// iOS does not support automatically opening up the keyboard for the
|
||||
// total amount field. Hence we should not focus on it on page render.
|
||||
@@ -76,7 +109,6 @@ export function TrackingBudgetMenuModal({
|
||||
style={{
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 20,
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
@@ -106,12 +138,71 @@ export function TrackingBudgetMenuModal({
|
||||
data-testid="budget-amount"
|
||||
/>
|
||||
</View>
|
||||
<BudgetMenu
|
||||
getItemStyle={() => defaultMenuItemStyle}
|
||||
onCopyLastMonthAverage={onCopyLastMonthAverage}
|
||||
onSetMonthsAverage={onSetMonthsAverage}
|
||||
onApplyBudgetTemplate={onApplyBudgetTemplate}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
display: showMore ? 'none' : undefined,
|
||||
overflowY: 'auto',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<Notes
|
||||
notes={originalNotes.length > 0 ? originalNotes : t('No notes')}
|
||||
editable={false}
|
||||
focused={false}
|
||||
getStyle={() => ({
|
||||
borderRadius: 6,
|
||||
...(originalNotes.length === 0 && {
|
||||
justifySelf: 'center',
|
||||
alignSelf: 'center',
|
||||
color: theme.pageTextSubdued,
|
||||
}),
|
||||
})}
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
display: showMore ? 'none' : undefined,
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'space-between',
|
||||
alignContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Button style={buttonStyle} onPress={_onEditNotes}>
|
||||
<SvgNotesPaper
|
||||
width={20}
|
||||
height={20}
|
||||
style={{ paddingRight: 5 }}
|
||||
/>
|
||||
<Trans>Edit notes</Trans>
|
||||
</Button>
|
||||
</View>
|
||||
<View>
|
||||
<Button variant="bare" style={buttonStyle} onPress={onShowMore}>
|
||||
{!showMore ? (
|
||||
<SvgCheveronUp
|
||||
width={30}
|
||||
height={30}
|
||||
style={{ paddingRight: 5 }}
|
||||
/>
|
||||
) : (
|
||||
<SvgCheveronDown
|
||||
width={30}
|
||||
height={30}
|
||||
style={{ paddingRight: 5 }}
|
||||
/>
|
||||
)}
|
||||
<Trans>Actions</Trans>
|
||||
</Button>
|
||||
</View>
|
||||
{showMore && (
|
||||
<BudgetMenu
|
||||
getItemStyle={() => defaultMenuItemStyle}
|
||||
onCopyLastMonthAverage={onCopyLastMonthAverage}
|
||||
onSetMonthsAverage={onSetMonthsAverage}
|
||||
onApplyBudgetTemplate={onApplyBudgetTemplate}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
@@ -53,6 +53,7 @@ const balanceTypeOptions = [
|
||||
const groupByOptions = [
|
||||
{ description: t('Category'), key: 'Category' },
|
||||
{ description: t('Group'), key: 'Group' },
|
||||
{ description: t('Category+Group'), key: 'CategoryGroup' }, // new: two-ring donut support
|
||||
{ description: t('Payee'), key: 'Payee' },
|
||||
{ description: t('Account'), key: 'Account' },
|
||||
{ description: t('Interval'), key: 'Interval' },
|
||||
@@ -220,12 +221,10 @@ const intervalOptions: intervalOptionsProps[] = [
|
||||
format: 'yy-MM-dd',
|
||||
range: 'weekRangeInclusive',
|
||||
},
|
||||
//{ value: 3, description: 'Fortnightly', name: 3},
|
||||
{
|
||||
description: t('Monthly'),
|
||||
key: 'Monthly',
|
||||
name: 'Month',
|
||||
|
||||
format: "MMM ''yy",
|
||||
range: 'rangeInclusive',
|
||||
},
|
||||
@@ -328,17 +327,11 @@ export const categoryLists = (categories: {
|
||||
const categoriesToSort = [...categories.list];
|
||||
const categoryList: UncategorizedEntity[] = [
|
||||
...categoriesToSort.sort((a, b) => {
|
||||
//The point of this sorting is to make the graphs match the "budget" page
|
||||
const catGroupA = categories.grouped.find(f => f.id === a.group);
|
||||
const catGroupB = categories.grouped.find(f => f.id === b.group);
|
||||
//initial check that both a and b have a sort_order and category group
|
||||
return a.sort_order && b.sort_order && catGroupA && catGroupB
|
||||
? /*sorting by "is_income" because sort_order for this group is
|
||||
separate from other groups*/
|
||||
Number(catGroupA.is_income) - Number(catGroupB.is_income) ||
|
||||
//Next, sorting by group sort_order
|
||||
? Number(catGroupA.is_income) - Number(catGroupB.is_income) ||
|
||||
(catGroupA.sort_order ?? 0) - (catGroupB.sort_order ?? 0) ||
|
||||
//Finally, sorting by category within each group
|
||||
a.sort_order - b.sort_order
|
||||
: 0;
|
||||
}),
|
||||
@@ -382,6 +375,20 @@ export const groupBySelections = (
|
||||
});
|
||||
groupByLabel = 'categoryGroup';
|
||||
break;
|
||||
// CategoryGroup uses category-level data from createCustomSpreadsheet.
|
||||
// The group-level data comes from groupedData (createGroupedSpreadsheet).
|
||||
// This case just prevents the default throw so the spreadsheet doesn't error.
|
||||
case 'CategoryGroup':
|
||||
groupByList = categoryGroup.map(group => {
|
||||
return {
|
||||
...group,
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
hidden: group.hidden,
|
||||
};
|
||||
});
|
||||
groupByLabel = 'categoryGroup';
|
||||
break;
|
||||
case 'Payee':
|
||||
groupByList = payees.map(payee => {
|
||||
return { id: payee.id, name: payee.name, hidden: false };
|
||||
|
||||
@@ -63,10 +63,12 @@ type graphOptions = {
|
||||
disableLabel?: boolean;
|
||||
disableSort?: boolean;
|
||||
};
|
||||
|
||||
const totalGraphOptions: graphOptions[] = [
|
||||
{
|
||||
description: 'TableGraph',
|
||||
disabledSplit: [],
|
||||
// CategoryGroup is only valid for DonutGraph
|
||||
disabledSplit: ['CategoryGroup'],
|
||||
defaultSplit: 'Category',
|
||||
disabledType: [],
|
||||
defaultType: 'Payment',
|
||||
@@ -76,7 +78,8 @@ const totalGraphOptions: graphOptions[] = [
|
||||
},
|
||||
{
|
||||
description: 'BarGraph',
|
||||
disabledSplit: [],
|
||||
// CategoryGroup is only valid for DonutGraph
|
||||
disabledSplit: ['CategoryGroup'],
|
||||
defaultSplit: 'Category',
|
||||
disabledType: [],
|
||||
defaultType: 'Payment',
|
||||
@@ -84,7 +87,8 @@ const totalGraphOptions: graphOptions[] = [
|
||||
},
|
||||
{
|
||||
description: 'AreaGraph',
|
||||
disabledSplit: ['Category', 'Group', 'Payee', 'Account'],
|
||||
// CategoryGroup is only valid for DonutGraph
|
||||
disabledSplit: ['Category', 'Group', 'CategoryGroup', 'Payee', 'Account'],
|
||||
defaultSplit: 'Interval',
|
||||
disabledType: [],
|
||||
defaultType: 'Payment',
|
||||
@@ -94,6 +98,7 @@ const totalGraphOptions: graphOptions[] = [
|
||||
},
|
||||
{
|
||||
description: 'DonutGraph',
|
||||
// CategoryGroup is allowed here — it enables the two-ring concentric donut
|
||||
disabledSplit: [],
|
||||
defaultSplit: 'Category',
|
||||
disabledType: ['Net'],
|
||||
@@ -105,7 +110,8 @@ const totalGraphOptions: graphOptions[] = [
|
||||
const timeGraphOptions: graphOptions[] = [
|
||||
{
|
||||
description: 'TableGraph',
|
||||
disabledSplit: ['Interval'],
|
||||
// CategoryGroup disabled in time mode (DonutGraph not available in time mode)
|
||||
disabledSplit: ['Interval', 'CategoryGroup'],
|
||||
defaultSplit: 'Category',
|
||||
disabledType: ['Net Payment', 'Net Deposit'],
|
||||
defaultType: 'Payment',
|
||||
@@ -116,7 +122,8 @@ const timeGraphOptions: graphOptions[] = [
|
||||
},
|
||||
{
|
||||
description: 'StackedBarGraph',
|
||||
disabledSplit: ['Interval'],
|
||||
// CategoryGroup disabled in time mode
|
||||
disabledSplit: ['Interval', 'CategoryGroup'],
|
||||
defaultSplit: 'Category',
|
||||
disabledType: [],
|
||||
defaultType: 'Payment',
|
||||
@@ -125,7 +132,8 @@ const timeGraphOptions: graphOptions[] = [
|
||||
},
|
||||
{
|
||||
description: 'LineGraph',
|
||||
disabledSplit: ['Interval'],
|
||||
// CategoryGroup disabled in time mode
|
||||
disabledSplit: ['Interval', 'CategoryGroup'],
|
||||
defaultSplit: 'Category',
|
||||
disabledType: [],
|
||||
defaultType: 'Payment',
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// @ts-strict-ignore
|
||||
import React, { useState } from 'react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
import { theme } from '@actual-app/components/theme';
|
||||
import { Pie, PieChart, Sector, Tooltip } from 'recharts';
|
||||
import { Pie, PieChart, Sector } from 'recharts';
|
||||
import type { PieSectorShapeProps } from 'recharts';
|
||||
|
||||
import type {
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
DataEntity,
|
||||
GroupedEntity,
|
||||
IntervalEntity,
|
||||
LegendEntity,
|
||||
RuleConditionEntity,
|
||||
} from 'loot-core/types/models';
|
||||
|
||||
@@ -31,52 +32,162 @@ const RADIAN = Math.PI / 180;
|
||||
|
||||
const canDeviceHover = () => window.matchMedia('(hover: hover)').matches;
|
||||
|
||||
const ActiveShapeMobile = props => {
|
||||
const {
|
||||
cx,
|
||||
cy,
|
||||
innerRadius,
|
||||
outerRadius,
|
||||
startAngle,
|
||||
endAngle,
|
||||
fill,
|
||||
payload,
|
||||
percent,
|
||||
value,
|
||||
format,
|
||||
} = props;
|
||||
const yAxis = payload.name ?? payload.date;
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dimension helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const sin = Math.sin(-RADIAN * 240);
|
||||
const my = cy + outerRadius * sin;
|
||||
const ey = my - 5;
|
||||
type DonutDimensions = {
|
||||
chartInnerRadius: number;
|
||||
chartMidRadius: number;
|
||||
chartOuterRadius: number;
|
||||
compact: boolean;
|
||||
};
|
||||
|
||||
const getDonutDimensions = (
|
||||
width: number,
|
||||
height: number,
|
||||
twoRings: boolean,
|
||||
): DonutDimensions => {
|
||||
const minDim = Math.min(width, height);
|
||||
const compact = height <= 300 || width <= 300;
|
||||
return {
|
||||
chartInnerRadius: minDim * (twoRings && compact ? 0.16 : 0.2),
|
||||
chartMidRadius: minDim * (compact ? 0.27 : 0.31),
|
||||
chartOuterRadius: minDim * (compact ? 0.36 : 0.42),
|
||||
compact,
|
||||
};
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Color helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const resolveCSSVariable = (color: string): string => {
|
||||
if (!color.startsWith('var(')) return color;
|
||||
const inner = color.slice(4, -1).trim();
|
||||
const varName = inner.split(',')[0].trim();
|
||||
return getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(varName)
|
||||
.trim();
|
||||
};
|
||||
|
||||
const hexToRgb = (hex: string) => {
|
||||
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
|
||||
return result
|
||||
? {
|
||||
r: parseInt(result[1], 16),
|
||||
g: parseInt(result[2], 16),
|
||||
b: parseInt(result[3], 16),
|
||||
}
|
||||
: { r: 0, g: 0, b: 0 };
|
||||
};
|
||||
|
||||
const shadeColor = (resolvedHex: string, percent: number): string => {
|
||||
const { r, g, b } = hexToRgb(resolvedHex);
|
||||
const adjust = (c: number) =>
|
||||
Math.min(255, Math.max(0, Math.round(c + (255 - c) * percent)));
|
||||
return `rgb(${adjust(r)}, ${adjust(g)}, ${adjust(b)})`;
|
||||
};
|
||||
|
||||
const buildColorMap = (
|
||||
groupedData: GroupedEntity[],
|
||||
legend: LegendEntity[],
|
||||
): Map<string, string> => {
|
||||
const legendById = new Map(
|
||||
legend
|
||||
.filter(l => l.id != null)
|
||||
.map(l => [l.id, resolveCSSVariable(l.color)]),
|
||||
);
|
||||
|
||||
return groupedData.reduce((acc, group) => {
|
||||
if (!group.id) return acc;
|
||||
|
||||
const groupColor = legendById.get(group.id);
|
||||
if (!groupColor) return acc;
|
||||
|
||||
acc.set(group.id, groupColor);
|
||||
|
||||
// Fix 1: capture cats once to avoid group.categories.length on undefined
|
||||
const cats = group.categories ?? [];
|
||||
cats.forEach((cat, i) => {
|
||||
if (!cat.id) return;
|
||||
const shade = 0.15 + (i / Math.max(cats.length, 1)) * 0.5;
|
||||
acc.set(cat.id, shadeColor(groupColor, shade));
|
||||
});
|
||||
|
||||
return acc;
|
||||
}, new Map<string, string>());
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Active shapes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type ActiveShapeProps = {
|
||||
cx: number;
|
||||
cy: number;
|
||||
midAngle: number;
|
||||
innerRadius: number;
|
||||
outerRadius: number;
|
||||
startAngle: number;
|
||||
endAngle: number;
|
||||
fill: string;
|
||||
payload: { name?: string; date?: string };
|
||||
percent: number;
|
||||
value: number;
|
||||
expandInward: boolean;
|
||||
chartInnerRadius: number;
|
||||
chartMidRadius: number;
|
||||
chartOuterRadius: number;
|
||||
};
|
||||
|
||||
const ActiveShapeMobile = ({
|
||||
cx,
|
||||
cy,
|
||||
innerRadius,
|
||||
outerRadius,
|
||||
startAngle,
|
||||
endAngle,
|
||||
fill,
|
||||
payload,
|
||||
percent,
|
||||
value,
|
||||
expandInward,
|
||||
chartInnerRadius,
|
||||
chartMidRadius,
|
||||
chartOuterRadius,
|
||||
}: ActiveShapeProps) => {
|
||||
const format = useFormat();
|
||||
// Fix 2: guard against undefined payload.name and payload.date
|
||||
const yAxis = payload.name ?? payload.date ?? '';
|
||||
|
||||
const expansionInner = expandInward ? chartInnerRadius - 4 : outerRadius + 2;
|
||||
const expansionOuter = expandInward ? chartInnerRadius - 2 : outerRadius + 4;
|
||||
const ey = cy + chartOuterRadius * Math.sin(-RADIAN * 240) - 5;
|
||||
|
||||
return (
|
||||
<g>
|
||||
<text
|
||||
x={cx}
|
||||
y={cy + outerRadius * Math.sin(-RADIAN * 270) + 15}
|
||||
dy={0}
|
||||
y={cy + chartOuterRadius * Math.sin(-RADIAN * 270) + 17}
|
||||
textAnchor="middle"
|
||||
fill={fill}
|
||||
>
|
||||
{`${yAxis}`}
|
||||
{yAxis}
|
||||
</text>
|
||||
<PrivacyFilter>
|
||||
<FinancialText
|
||||
as="text"
|
||||
x={cx + outerRadius * Math.cos(-RADIAN * 240) - 30}
|
||||
x={cx + chartOuterRadius * Math.cos(-RADIAN * 240) - 30}
|
||||
y={ey}
|
||||
dy={0}
|
||||
textAnchor="end"
|
||||
fill={fill}
|
||||
>
|
||||
{`${format(value, 'financial')}`}
|
||||
{format(value, 'financial')}
|
||||
</FinancialText>
|
||||
<text
|
||||
x={cx + outerRadius * Math.cos(-RADIAN * 330) + 10}
|
||||
x={cx + chartOuterRadius * Math.cos(-RADIAN * 330) + 10}
|
||||
y={ey}
|
||||
dy={0}
|
||||
textAnchor="start"
|
||||
fill="#999"
|
||||
>
|
||||
@@ -97,43 +208,52 @@ const ActiveShapeMobile = props => {
|
||||
cy={cy}
|
||||
startAngle={startAngle}
|
||||
endAngle={endAngle}
|
||||
innerRadius={innerRadius - 8}
|
||||
outerRadius={innerRadius - 6}
|
||||
innerRadius={expansionInner}
|
||||
outerRadius={expansionOuter}
|
||||
fill={fill}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
const ActiveShapeMobileWithFormat = props => (
|
||||
<ActiveShapeMobile {...props} format={props.format} />
|
||||
);
|
||||
|
||||
const ActiveShape = props => {
|
||||
const {
|
||||
cx,
|
||||
cy,
|
||||
midAngle,
|
||||
innerRadius,
|
||||
outerRadius,
|
||||
startAngle,
|
||||
endAngle,
|
||||
fill,
|
||||
payload,
|
||||
percent,
|
||||
value,
|
||||
format,
|
||||
} = props;
|
||||
const yAxis = payload.name ?? payload.date;
|
||||
const ActiveShapeDesktop = ({
|
||||
cx,
|
||||
cy,
|
||||
midAngle,
|
||||
innerRadius,
|
||||
outerRadius,
|
||||
startAngle,
|
||||
endAngle,
|
||||
fill,
|
||||
payload,
|
||||
percent,
|
||||
value,
|
||||
expandInward,
|
||||
chartInnerRadius,
|
||||
chartMidRadius,
|
||||
chartOuterRadius,
|
||||
}: ActiveShapeProps) => {
|
||||
const format = useFormat();
|
||||
// Fix 2: guard against undefined payload.name and payload.date
|
||||
const yAxis = payload.name ?? payload.date ?? '';
|
||||
const sin = Math.sin(-RADIAN * midAngle);
|
||||
const cos = Math.cos(-RADIAN * midAngle);
|
||||
const sx = cx + (innerRadius - 10) * cos;
|
||||
const sy = cy + (innerRadius - 10) * sin;
|
||||
const mx = cx + (innerRadius - 30) * cos;
|
||||
const my = cy + (innerRadius - 30) * sin;
|
||||
|
||||
const expansionInner = expandInward ? chartInnerRadius - 10 : outerRadius + 6;
|
||||
const expansionOuter = expandInward ? chartInnerRadius - 6 : outerRadius + 10;
|
||||
|
||||
const lineStart = expandInward
|
||||
? chartInnerRadius - 20
|
||||
: chartInnerRadius - 10;
|
||||
const lineMid = chartInnerRadius * 0.7;
|
||||
const sx = cx + lineStart * cos;
|
||||
const sy = cy + lineStart * sin;
|
||||
const mx = cx + lineMid * cos;
|
||||
const my = cy + lineMid * sin;
|
||||
const ex = cx + (cos >= 0 ? 1 : -1) * yAxis.length * 4;
|
||||
const ey = cy + 8;
|
||||
const textAnchor = cos <= 0 ? 'start' : 'end';
|
||||
const labelX = ex + (cos <= 0 ? 1 : -1) * 16;
|
||||
|
||||
return (
|
||||
<g>
|
||||
@@ -151,8 +271,8 @@ const ActiveShape = props => {
|
||||
cy={cy}
|
||||
startAngle={startAngle}
|
||||
endAngle={endAngle}
|
||||
innerRadius={outerRadius + 6}
|
||||
outerRadius={outerRadius + 10}
|
||||
innerRadius={expansionInner}
|
||||
outerRadius={expansionOuter}
|
||||
fill={fill}
|
||||
/>
|
||||
<path
|
||||
@@ -161,30 +281,21 @@ const ActiveShape = props => {
|
||||
fill="none"
|
||||
/>
|
||||
<circle cx={ex} cy={ey} r={3} fill={fill} stroke="none" />
|
||||
<text
|
||||
x={ex + (cos <= 0 ? 1 : -1) * 16}
|
||||
y={ey}
|
||||
textAnchor={textAnchor}
|
||||
fill={fill}
|
||||
>{`${yAxis}`}</text>
|
||||
<text x={labelX} y={ey} textAnchor={textAnchor} fill={fill}>
|
||||
{yAxis}
|
||||
</text>
|
||||
<PrivacyFilter>
|
||||
<FinancialText
|
||||
as="text"
|
||||
x={ex + (cos <= 0 ? 1 : -1) * 16}
|
||||
x={labelX}
|
||||
y={ey}
|
||||
dy={18}
|
||||
textAnchor={textAnchor}
|
||||
fill={fill}
|
||||
>
|
||||
{`${format(value, 'financial')}`}
|
||||
{format(value, 'financial')}
|
||||
</FinancialText>
|
||||
<text
|
||||
x={ex + (cos <= 0 ? 1 : -1) * 16}
|
||||
y={ey}
|
||||
dy={36}
|
||||
textAnchor={textAnchor}
|
||||
fill="#999"
|
||||
>
|
||||
<text x={labelX} y={ey} dy={36} textAnchor={textAnchor} fill="#999">
|
||||
{`(${(percent * 100).toFixed(2)}%)`}
|
||||
</text>
|
||||
</PrivacyFilter>
|
||||
@@ -192,15 +303,14 @@ const ActiveShape = props => {
|
||||
);
|
||||
};
|
||||
|
||||
const ActiveShapeWithFormat = props => (
|
||||
<ActiveShape {...props} format={props.format} />
|
||||
);
|
||||
// ---------------------------------------------------------------------------
|
||||
// Custom label
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const customLabel = props => {
|
||||
const radius =
|
||||
props.innerRadius + (props.outerRadius - props.innerRadius) * 0.5;
|
||||
const size = props.cx > props.cy ? props.cy : props.cx;
|
||||
|
||||
const calcX = props.cx + radius * Math.cos(-props.midAngle * RADIAN);
|
||||
const calcY = props.cy + radius * Math.sin(-props.midAngle * RADIAN);
|
||||
const textAnchor = calcX > props.cx ? 'start' : 'end';
|
||||
@@ -209,7 +319,6 @@ const customLabel = props => {
|
||||
const showLabel = props.percent;
|
||||
const showLabelThreshold = 0.05;
|
||||
const fill = theme.reportsInnerLabel;
|
||||
|
||||
return renderCustomLabel(
|
||||
calcX,
|
||||
calcY,
|
||||
@@ -222,6 +331,10 @@ const customLabel = props => {
|
||||
);
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type DonutGraphProps = {
|
||||
style?: CSSProperties;
|
||||
data: DataEntity;
|
||||
@@ -245,7 +358,6 @@ export function DonutGraph({
|
||||
showOffBudget,
|
||||
showTooltip = true,
|
||||
}: DonutGraphProps) {
|
||||
const format = useFormat();
|
||||
const animationProps = useRechartsAnimation({ isAnimationActive: false });
|
||||
|
||||
const yAxis = groupBy === 'Interval' ? 'date' : 'name';
|
||||
@@ -259,18 +371,248 @@ export function DonutGraph({
|
||||
const getVal = (obj: GroupedEntity | IntervalEntity) => {
|
||||
if (['totalDebts', 'netDebts'].includes(balanceTypeOp)) {
|
||||
return -1 * obj[balanceTypeOp];
|
||||
} else {
|
||||
return obj[balanceTypeOp];
|
||||
}
|
||||
return obj[balanceTypeOp];
|
||||
};
|
||||
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [activeGroupIndex, setActiveGroupIndex] = useState(0);
|
||||
const [activeCategoryIndex, setActiveCategoryIndex] = useState(0);
|
||||
const [activeRing, setActiveRing] = useState<'group' | 'category'>(
|
||||
'category',
|
||||
);
|
||||
|
||||
const isCategoryGroup =
|
||||
groupBy === 'CategoryGroup' && !!data.groupedData?.length;
|
||||
|
||||
const { adjustedGroupData, flatCategories } = useMemo(() => {
|
||||
if (!isCategoryGroup || !data.groupedData) {
|
||||
return { adjustedGroupData: [], flatCategories: [] };
|
||||
}
|
||||
|
||||
const adjustedGroups = data.groupedData
|
||||
.map(group => {
|
||||
const visibleCats = group.categories ?? [];
|
||||
return {
|
||||
...group,
|
||||
totalAssets: visibleCats.reduce((sum, c) => sum + c.totalAssets, 0),
|
||||
totalDebts: visibleCats.reduce((sum, c) => sum + c.totalDebts, 0),
|
||||
totalTotals: visibleCats.reduce((sum, c) => sum + c.totalTotals, 0),
|
||||
netAssets: visibleCats.reduce((sum, c) => sum + c.netAssets, 0),
|
||||
netDebts: visibleCats.reduce((sum, c) => sum + c.netDebts, 0),
|
||||
};
|
||||
})
|
||||
.filter(group =>
|
||||
['totalDebts', 'netDebts'].includes(balanceTypeOp)
|
||||
? -1 * group[balanceTypeOp] !== 0
|
||||
: group[balanceTypeOp] !== 0,
|
||||
);
|
||||
|
||||
return {
|
||||
adjustedGroupData: adjustedGroups,
|
||||
flatCategories: data.groupedData.flatMap(g => g.categories ?? []),
|
||||
};
|
||||
}, [isCategoryGroup, data.groupedData, balanceTypeOp]);
|
||||
|
||||
const colorMap = useMemo(
|
||||
() =>
|
||||
isCategoryGroup
|
||||
? buildColorMap(data.groupedData ?? [], data.legend ?? [])
|
||||
: new Map<string, string>(),
|
||||
[isCategoryGroup, data.groupedData, data.legend],
|
||||
);
|
||||
|
||||
return (
|
||||
<Container style={style}>
|
||||
{(width, height) => {
|
||||
const compact = height <= 300 || width <= 300;
|
||||
const { chartInnerRadius, chartMidRadius, chartOuterRadius, compact } =
|
||||
getDonutDimensions(width, height, isCategoryGroup);
|
||||
|
||||
const showActiveShape = width >= 220 && height >= 130;
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Two-ring concentric donut (CategoryGroup mode)
|
||||
// ---------------------------------------------------------------
|
||||
if (isCategoryGroup) {
|
||||
return (
|
||||
data.groupedData && (
|
||||
<div>
|
||||
{!compact && <div style={{ marginTop: '15px' }} />}
|
||||
<PieChart
|
||||
responsive
|
||||
width={width}
|
||||
height={height}
|
||||
style={{ cursor: pointer }}
|
||||
>
|
||||
{/* Inner ring — Category Groups */}
|
||||
<Pie
|
||||
dataKey={val => getVal(val)}
|
||||
nameKey="name"
|
||||
{...animationProps}
|
||||
data={adjustedGroupData}
|
||||
innerRadius={chartInnerRadius}
|
||||
outerRadius={chartMidRadius}
|
||||
startAngle={90}
|
||||
endAngle={-270}
|
||||
shape={(props: PieSectorShapeProps, index: number) => {
|
||||
const item = adjustedGroupData[index];
|
||||
const fill =
|
||||
colorMap.get(item?.id ?? item?.name ?? '') ??
|
||||
props.fill;
|
||||
const isActive =
|
||||
activeRing === 'group' && index === activeGroupIndex;
|
||||
if (isActive && showActiveShape) {
|
||||
return compact ? (
|
||||
<ActiveShapeMobile
|
||||
{...(props as unknown as ActiveShapeProps)}
|
||||
fill={fill}
|
||||
expandInward
|
||||
chartInnerRadius={chartInnerRadius}
|
||||
chartMidRadius={chartMidRadius}
|
||||
chartOuterRadius={chartOuterRadius}
|
||||
/>
|
||||
) : (
|
||||
<ActiveShapeDesktop
|
||||
{...(props as unknown as ActiveShapeProps)}
|
||||
fill={fill}
|
||||
expandInward
|
||||
chartInnerRadius={chartInnerRadius}
|
||||
chartMidRadius={chartMidRadius}
|
||||
chartOuterRadius={chartOuterRadius}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <Sector {...props} fill={fill} />;
|
||||
}}
|
||||
onMouseLeave={() => setPointer('')}
|
||||
onMouseEnter={(_, index) => {
|
||||
if (canDeviceHover()) {
|
||||
setActiveGroupIndex(index);
|
||||
setActiveRing('group');
|
||||
}
|
||||
}}
|
||||
onClick={(item, index) => {
|
||||
if (!canDeviceHover()) {
|
||||
setActiveGroupIndex(index);
|
||||
setActiveRing('group');
|
||||
}
|
||||
if (
|
||||
(canDeviceHover() || activeGroupIndex === index) &&
|
||||
((compact && showTooltip) || !compact)
|
||||
) {
|
||||
const groupCategoryIds = (
|
||||
data.groupedData?.find(g => g.id === item.id)
|
||||
?.categories ?? []
|
||||
)
|
||||
.map(c => c.id)
|
||||
.filter((c): c is string => c != null);
|
||||
|
||||
showActivity({
|
||||
navigate,
|
||||
categories,
|
||||
accounts,
|
||||
balanceTypeOp,
|
||||
filters,
|
||||
showHiddenCategories,
|
||||
showOffBudget,
|
||||
type: 'totals',
|
||||
startDate: data.startDate,
|
||||
endDate: data.endDate,
|
||||
field: 'category',
|
||||
id: groupCategoryIds,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Outer ring — Categories */}
|
||||
<Pie
|
||||
dataKey={val => getVal(val)}
|
||||
nameKey="name"
|
||||
{...animationProps}
|
||||
data={flatCategories}
|
||||
innerRadius={chartMidRadius}
|
||||
outerRadius={chartOuterRadius}
|
||||
startAngle={90}
|
||||
endAngle={-270}
|
||||
labelLine={false}
|
||||
label={e =>
|
||||
viewLabels && !compact ? customLabel(e) : null
|
||||
}
|
||||
shape={(props: PieSectorShapeProps, index: number) => {
|
||||
const item = flatCategories[index];
|
||||
const fill =
|
||||
colorMap.get(item?.id ?? item?.name ?? '') ??
|
||||
props.fill;
|
||||
const isActive =
|
||||
activeRing === 'category' &&
|
||||
index === activeCategoryIndex;
|
||||
if (isActive && showActiveShape) {
|
||||
return compact ? (
|
||||
<ActiveShapeMobile
|
||||
{...(props as unknown as ActiveShapeProps)}
|
||||
fill={fill}
|
||||
expandInward={false}
|
||||
chartInnerRadius={chartInnerRadius}
|
||||
chartMidRadius={chartMidRadius}
|
||||
chartOuterRadius={chartOuterRadius}
|
||||
/>
|
||||
) : (
|
||||
<ActiveShapeDesktop
|
||||
{...(props as unknown as ActiveShapeProps)}
|
||||
fill={fill}
|
||||
expandInward={false}
|
||||
chartInnerRadius={chartInnerRadius}
|
||||
chartMidRadius={chartMidRadius}
|
||||
chartOuterRadius={chartOuterRadius}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <Sector {...props} fill={fill} />;
|
||||
}}
|
||||
onMouseLeave={() => setPointer('')}
|
||||
onMouseEnter={(_, index) => {
|
||||
if (canDeviceHover()) {
|
||||
setActiveCategoryIndex(index);
|
||||
setActiveRing('category');
|
||||
setPointer('pointer');
|
||||
}
|
||||
}}
|
||||
onClick={(item, index) => {
|
||||
if (!canDeviceHover()) {
|
||||
setActiveCategoryIndex(index);
|
||||
setActiveRing('category');
|
||||
}
|
||||
if (
|
||||
(canDeviceHover() || activeCategoryIndex === index) &&
|
||||
((compact && showTooltip) || !compact)
|
||||
) {
|
||||
showActivity({
|
||||
navigate,
|
||||
categories,
|
||||
accounts,
|
||||
balanceTypeOp,
|
||||
filters,
|
||||
showHiddenCategories,
|
||||
showOffBudget,
|
||||
type: 'totals',
|
||||
startDate: data.startDate,
|
||||
endDate: data.endDate,
|
||||
field: 'category',
|
||||
id: item.id,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Single-ring donut (all other groupBy modes)
|
||||
// ---------------------------------------------------------------
|
||||
return (
|
||||
data[splitData] && (
|
||||
<div>
|
||||
@@ -285,13 +627,8 @@ export function DonutGraph({
|
||||
dataKey={val => getVal(val)}
|
||||
nameKey={yAxis}
|
||||
{...animationProps}
|
||||
data={
|
||||
data[splitData]?.map(item => ({
|
||||
...item,
|
||||
})) ?? []
|
||||
}
|
||||
innerRadius={Math.min(width, height) * 0.2}
|
||||
fill="#8884d8"
|
||||
data={data[splitData]?.map(item => ({ ...item })) ?? []}
|
||||
innerRadius={chartInnerRadius}
|
||||
labelLine={false}
|
||||
label={e =>
|
||||
viewLabels && !compact ? customLabel(e) : <div />
|
||||
@@ -299,15 +636,28 @@ export function DonutGraph({
|
||||
startAngle={90}
|
||||
endAngle={-270}
|
||||
shape={(props: PieSectorShapeProps, index: number) => {
|
||||
const fill = data.legend[index]?.color ?? props.fill;
|
||||
const showActiveShape = width >= 220 && height >= 130;
|
||||
const isActive = props.isActive || index === activeIndex;
|
||||
// Fix 3: optional chain data.legend to guard against undefined
|
||||
const fill = data.legend?.[index]?.color ?? props.fill;
|
||||
const isActive = index === activeIndex;
|
||||
if (isActive && showActiveShape) {
|
||||
const shapeProps = { ...props, fill, format };
|
||||
return compact ? (
|
||||
<ActiveShapeMobileWithFormat {...shapeProps} />
|
||||
<ActiveShapeMobile
|
||||
{...(props as unknown as ActiveShapeProps)}
|
||||
fill={fill}
|
||||
expandInward
|
||||
chartInnerRadius={chartInnerRadius}
|
||||
chartMidRadius={chartMidRadius}
|
||||
chartOuterRadius={chartOuterRadius}
|
||||
/>
|
||||
) : (
|
||||
<ActiveShapeWithFormat {...shapeProps} />
|
||||
<ActiveShapeDesktop
|
||||
{...(props as unknown as ActiveShapeProps)}
|
||||
fill={fill}
|
||||
expandInward={false}
|
||||
chartInnerRadius={chartInnerRadius}
|
||||
chartMidRadius={chartMidRadius}
|
||||
chartOuterRadius={chartOuterRadius}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <Sector {...props} fill={fill} />;
|
||||
@@ -325,12 +675,21 @@ export function DonutGraph({
|
||||
if (!canDeviceHover()) {
|
||||
setActiveIndex(index);
|
||||
}
|
||||
|
||||
if (
|
||||
!['Group', 'Interval'].includes(groupBy) &&
|
||||
!['Interval'].includes(groupBy) &&
|
||||
(canDeviceHover() || activeIndex === index) &&
|
||||
((compact && showTooltip) || !compact)
|
||||
) {
|
||||
const groupCategoryIds =
|
||||
groupBy === 'Group'
|
||||
? (
|
||||
categories.grouped.find(g => g.id === item.id)
|
||||
?.categories ?? []
|
||||
)
|
||||
.map(c => c.id)
|
||||
.filter((c): c is string => c != null)
|
||||
: undefined;
|
||||
|
||||
showActivity({
|
||||
navigate,
|
||||
categories,
|
||||
@@ -342,17 +701,15 @@ export function DonutGraph({
|
||||
type: 'totals',
|
||||
startDate: data.startDate,
|
||||
endDate: data.endDate,
|
||||
field: groupBy.toLowerCase(),
|
||||
id: item.id,
|
||||
field:
|
||||
groupBy === 'Group'
|
||||
? 'category'
|
||||
: groupBy.toLowerCase(),
|
||||
id: groupBy === 'Group' ? groupCategoryIds : item.id,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Tooltip
|
||||
content={() => null}
|
||||
defaultIndex={activeIndex}
|
||||
active
|
||||
/>
|
||||
</PieChart>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -23,7 +23,7 @@ type showActivityProps = {
|
||||
startDate: string;
|
||||
endDate?: string;
|
||||
field?: string;
|
||||
id?: string;
|
||||
id?: string | string[]; // changed: supports array for oneOf
|
||||
interval?: string;
|
||||
};
|
||||
|
||||
@@ -55,7 +55,13 @@ export function showActivity({
|
||||
|
||||
const filterConditions = [
|
||||
...filters,
|
||||
id && { field, op: 'is', value: id, type: 'id' },
|
||||
id && {
|
||||
// changed: use oneOf when id is an array, is when it's a string
|
||||
field,
|
||||
op: Array.isArray(id) ? 'oneOf' : 'is',
|
||||
value: id,
|
||||
type: 'id',
|
||||
},
|
||||
{
|
||||
field: 'date',
|
||||
op: isDateOp ? 'gte' : 'is',
|
||||
@@ -97,6 +103,7 @@ export function showActivity({
|
||||
type: 'id',
|
||||
},
|
||||
].filter(f => f);
|
||||
|
||||
void navigate('/accounts', {
|
||||
state: {
|
||||
goBack: true,
|
||||
|
||||
@@ -334,6 +334,7 @@ export type Modal =
|
||||
onCopyLastMonthAverage: () => void;
|
||||
onSetMonthsAverage: (numberOfMonths: number) => void;
|
||||
onApplyBudgetTemplate: () => void;
|
||||
onEditNotes: (id: NoteEntity['id'], month: string) => void;
|
||||
};
|
||||
}
|
||||
| {
|
||||
@@ -345,6 +346,7 @@ export type Modal =
|
||||
onCopyLastMonthAverage: () => void;
|
||||
onSetMonthsAverage: (numberOfMonths: number) => void;
|
||||
onApplyBudgetTemplate: () => void;
|
||||
onEditNotes: (id: NoteEntity['id'], month: string) => void;
|
||||
};
|
||||
}
|
||||
| {
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { isNonProductionEnvironment } from 'loot-core/shared/environment';
|
||||
import type { DarkTheme, Theme } from 'loot-core/types/prefs';
|
||||
|
||||
import { parseInstalledTheme, validateThemeCss } from './customThemes';
|
||||
import * as darkTheme from './themes/dark';
|
||||
import * as developmentTheme from './themes/development';
|
||||
import * as lightTheme from './themes/light';
|
||||
import * as midnightTheme from './themes/midnight';
|
||||
|
||||
@@ -17,9 +15,6 @@ const themes = {
|
||||
dark: { name: 'Dark', colors: darkTheme },
|
||||
midnight: { name: 'Midnight', colors: midnightTheme },
|
||||
auto: { name: 'System default', colors: darkTheme },
|
||||
...(isNonProductionEnvironment() && {
|
||||
development: { name: 'Development', colors: developmentTheme },
|
||||
}),
|
||||
} as const;
|
||||
|
||||
type ThemeKey = keyof typeof themes;
|
||||
@@ -48,11 +43,7 @@ export function ThemeStyle() {
|
||||
const [activeTheme] = useTheme();
|
||||
const [darkThemePreference] = usePreferredDarkTheme();
|
||||
const [themeColors, setThemeColors] = useState<
|
||||
| typeof lightTheme
|
||||
| typeof darkTheme
|
||||
| typeof midnightTheme
|
||||
| typeof developmentTheme
|
||||
| undefined
|
||||
typeof lightTheme | typeof darkTheme | typeof midnightTheme | undefined
|
||||
>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
import * as colorPalette from '@desktop-client/style/palette';
|
||||
|
||||
export const pageBackground = colorPalette.navy100;
|
||||
export const pageBackgroundModalActive = colorPalette.navy200;
|
||||
export const pageBackgroundTopLeft = colorPalette.navy100;
|
||||
export const pageBackgroundBottomRight = colorPalette.blue150;
|
||||
export const pageBackgroundLineTop = colorPalette.white;
|
||||
export const pageBackgroundLineMid = colorPalette.navy100;
|
||||
export const pageBackgroundLineBottom = colorPalette.blue150;
|
||||
export const pageText = '#272630';
|
||||
export const pageTextLight = colorPalette.navy500;
|
||||
export const pageTextSubdued = colorPalette.navy300;
|
||||
export const pageTextDark = colorPalette.navy800;
|
||||
export const pageTextPositive = colorPalette.purple600;
|
||||
export const pageTextLink = colorPalette.blue600;
|
||||
export const pageTextLinkLight = colorPalette.blue300;
|
||||
|
||||
export const cardBackground = colorPalette.white;
|
||||
export const cardBorder = colorPalette.purple700;
|
||||
export const cardShadow = colorPalette.navy700;
|
||||
|
||||
export const tableBackground = colorPalette.white;
|
||||
export const tableRowBackgroundHover = colorPalette.navy50;
|
||||
export const tableText = pageText;
|
||||
export const tableTextLight = colorPalette.navy400;
|
||||
export const tableTextSubdued = colorPalette.navy100;
|
||||
export const tableTextSelected = colorPalette.navy700;
|
||||
export const tableTextHover = colorPalette.navy900;
|
||||
export const tableTextInactive = colorPalette.navy500;
|
||||
export const tableHeaderText = colorPalette.navy600;
|
||||
export const tableHeaderBackground = colorPalette.white;
|
||||
export const tableBorder = colorPalette.navy100;
|
||||
export const tableBorderSelected = colorPalette.purple500;
|
||||
export const tableBorderHover = colorPalette.purple400;
|
||||
export const tableBorderSeparator = colorPalette.navy400;
|
||||
export const tableRowBackgroundHighlight = colorPalette.blue150;
|
||||
export const tableRowBackgroundHighlightText = colorPalette.navy700;
|
||||
export const tableRowHeaderBackground = colorPalette.navy50;
|
||||
export const tableRowHeaderText = colorPalette.navy800;
|
||||
|
||||
export const numberPositive = colorPalette.green700;
|
||||
export const numberNegative = colorPalette.red500;
|
||||
export const numberNeutral = colorPalette.navy100;
|
||||
export const budgetNumberNegative = numberNegative;
|
||||
export const budgetNumberZero = tableTextSubdued;
|
||||
export const budgetNumberNeutral = tableText;
|
||||
export const budgetNumberPositive = budgetNumberNeutral;
|
||||
export const templateNumberFunded = numberPositive;
|
||||
export const templateNumberUnderFunded = colorPalette.orange700;
|
||||
export const toBudgetPositive = numberPositive;
|
||||
export const toBudgetZero = numberPositive;
|
||||
export const toBudgetNegative = budgetNumberNegative;
|
||||
|
||||
export const sidebarBackground = colorPalette.navy900;
|
||||
export const sidebarItemBackgroundPending = colorPalette.orange200;
|
||||
export const sidebarItemBackgroundPositive = colorPalette.green500;
|
||||
export const sidebarItemBackgroundFailed = colorPalette.red300;
|
||||
export const sidebarItemBackgroundHover = colorPalette.navy800;
|
||||
export const sidebarItemAccentSelected = colorPalette.purple200;
|
||||
export const sidebarItemText = colorPalette.navy150;
|
||||
export const sidebarItemTextSelected = colorPalette.purple200;
|
||||
export const sidebarBudgetName = colorPalette.navy150;
|
||||
|
||||
export const menuBackground = colorPalette.white;
|
||||
export const menuItemBackground = colorPalette.navy50;
|
||||
export const menuItemBackgroundHover = colorPalette.navy100;
|
||||
export const menuItemText = colorPalette.navy900;
|
||||
export const menuItemTextHover = menuItemText;
|
||||
export const menuItemTextSelected = colorPalette.purple300;
|
||||
export const menuItemTextHeader = colorPalette.navy400;
|
||||
export const menuBorder = colorPalette.navy100;
|
||||
export const menuBorderHover = colorPalette.purple100;
|
||||
export const menuKeybindingText = colorPalette.navy400;
|
||||
export const menuAutoCompleteBackground = colorPalette.navy900;
|
||||
export const menuAutoCompleteBackgroundHover = colorPalette.navy600;
|
||||
export const menuAutoCompleteText = colorPalette.white;
|
||||
export const menuAutoCompleteTextHeader = colorPalette.orange150;
|
||||
export const menuAutoCompleteItemText = menuAutoCompleteText;
|
||||
|
||||
export const modalBackground = colorPalette.white;
|
||||
export const modalBorder = colorPalette.white;
|
||||
export const mobileHeaderBackground = colorPalette.purple400;
|
||||
export const mobileHeaderText = colorPalette.navy50;
|
||||
export const mobileHeaderTextSubdued = colorPalette.gray200;
|
||||
export const mobileHeaderTextHover = 'rgba(200, 200, 200, .15)';
|
||||
export const mobilePageBackground = colorPalette.navy50;
|
||||
export const mobileNavBackground = colorPalette.white;
|
||||
export const mobileNavItem = colorPalette.gray300;
|
||||
export const mobileNavItemSelected = colorPalette.purple500;
|
||||
export const mobileAccountShadow = colorPalette.navy300;
|
||||
export const mobileAccountText = colorPalette.blue800;
|
||||
export const mobileTransactionSelected = colorPalette.purple500;
|
||||
|
||||
// Mobile view themes (for the top bar)
|
||||
export const mobileViewTheme = mobileHeaderBackground;
|
||||
export const mobileConfigServerViewTheme = colorPalette.purple500;
|
||||
|
||||
export const markdownNormal = colorPalette.purple150;
|
||||
export const markdownDark = colorPalette.purple400;
|
||||
export const markdownLight = colorPalette.purple100;
|
||||
|
||||
// Button
|
||||
export const buttonMenuText = colorPalette.navy100;
|
||||
export const buttonMenuTextHover = colorPalette.navy50;
|
||||
export const buttonMenuBackground = 'transparent';
|
||||
export const buttonMenuBackgroundHover = 'rgba(200, 200, 200, .25)';
|
||||
export const buttonMenuBorder = colorPalette.navy500;
|
||||
export const buttonMenuSelectedText = colorPalette.green800;
|
||||
export const buttonMenuSelectedTextHover = colorPalette.orange800;
|
||||
export const buttonMenuSelectedBackground = colorPalette.orange200;
|
||||
export const buttonMenuSelectedBackgroundHover = colorPalette.orange300;
|
||||
export const buttonMenuSelectedBorder = buttonMenuSelectedBackground;
|
||||
|
||||
export const buttonPrimaryText = colorPalette.white;
|
||||
export const buttonPrimaryTextHover = buttonPrimaryText;
|
||||
export const buttonPrimaryBackground = colorPalette.purple500;
|
||||
export const buttonPrimaryBackgroundHover = buttonPrimaryBackground;
|
||||
export const buttonPrimaryBorder = buttonPrimaryBackground;
|
||||
export const buttonPrimaryShadow = 'rgba(0, 0, 0, 0.3)';
|
||||
export const buttonPrimaryDisabledText = colorPalette.white;
|
||||
export const buttonPrimaryDisabledBackground = colorPalette.navy300;
|
||||
export const buttonPrimaryDisabledBorder = buttonPrimaryDisabledBackground;
|
||||
|
||||
export const buttonNormalText = colorPalette.navy900;
|
||||
export const buttonNormalTextHover = buttonNormalText;
|
||||
export const buttonNormalBackground = colorPalette.white;
|
||||
export const buttonNormalBackgroundHover = buttonNormalBackground;
|
||||
export const buttonNormalBorder = colorPalette.navy150;
|
||||
export const buttonNormalShadow = 'rgba(0, 0, 0, 0.2)';
|
||||
export const buttonNormalSelectedText = colorPalette.white;
|
||||
export const buttonNormalSelectedBackground = colorPalette.blue600;
|
||||
export const buttonNormalDisabledText = colorPalette.navy300;
|
||||
export const buttonNormalDisabledBackground = buttonNormalBackground;
|
||||
export const buttonNormalDisabledBorder = buttonNormalBorder;
|
||||
|
||||
export const calendarText = colorPalette.navy50;
|
||||
export const calendarBackground = colorPalette.navy900;
|
||||
export const calendarItemText = colorPalette.navy150;
|
||||
export const calendarItemBackground = colorPalette.navy800;
|
||||
export const calendarSelectedBackground = colorPalette.navy500;
|
||||
|
||||
export const buttonBareText = buttonNormalText;
|
||||
export const buttonBareTextHover = buttonNormalText;
|
||||
export const buttonBareBackground = 'transparent';
|
||||
export const buttonBareBackgroundHover = 'rgba(100, 100, 100, .15)';
|
||||
export const buttonBareBackgroundActive = 'rgba(100, 100, 100, .25)';
|
||||
export const buttonBareDisabledText = buttonNormalDisabledText;
|
||||
export const buttonBareDisabledBackground = buttonBareBackground;
|
||||
|
||||
export const noticeBackground = colorPalette.green150;
|
||||
export const noticeBackgroundLight = colorPalette.green100;
|
||||
export const noticeBackgroundDark = colorPalette.green500;
|
||||
export const noticeText = colorPalette.green700;
|
||||
export const noticeTextLight = colorPalette.green500;
|
||||
export const noticeTextDark = colorPalette.green900;
|
||||
export const noticeTextMenu = colorPalette.green200;
|
||||
export const noticeBorder = colorPalette.green500;
|
||||
export const warningBackground = colorPalette.orange200;
|
||||
export const warningText = colorPalette.orange700;
|
||||
export const warningTextLight = colorPalette.orange500;
|
||||
export const warningTextDark = colorPalette.orange900;
|
||||
export const warningBorder = colorPalette.orange500;
|
||||
export const errorBackground = colorPalette.red100;
|
||||
export const errorText = colorPalette.red500;
|
||||
export const errorTextDark = colorPalette.red700;
|
||||
export const errorTextDarker = colorPalette.red900;
|
||||
export const errorTextMenu = colorPalette.red200;
|
||||
export const errorBorder = colorPalette.red500;
|
||||
export const upcomingBackground = colorPalette.purple100;
|
||||
export const upcomingText = colorPalette.purple700;
|
||||
export const upcomingBorder = colorPalette.purple500;
|
||||
|
||||
export const formLabelText = colorPalette.blue600;
|
||||
export const formLabelBackground = colorPalette.blue200;
|
||||
export const formInputBackground = colorPalette.navy50;
|
||||
export const formInputBackgroundSelected = colorPalette.white;
|
||||
export const formInputBackgroundSelection = colorPalette.purple500;
|
||||
export const formInputBorder = colorPalette.navy150;
|
||||
export const formInputTextReadOnlySelection = colorPalette.navy50;
|
||||
export const formInputBorderSelected = colorPalette.purple500;
|
||||
export const formInputText = colorPalette.navy900;
|
||||
export const formInputTextSelected = colorPalette.navy50;
|
||||
export const formInputTextPlaceholder = colorPalette.navy300;
|
||||
export const formInputTextPlaceholderSelected = colorPalette.navy200;
|
||||
export const formInputTextSelection = colorPalette.navy100;
|
||||
export const formInputShadowSelected = colorPalette.purple300;
|
||||
export const formInputTextHighlight = colorPalette.purple200;
|
||||
export const checkboxText = tableBackground;
|
||||
export const checkboxBackgroundSelected = colorPalette.blue500;
|
||||
export const checkboxBorderSelected = colorPalette.blue500;
|
||||
export const checkboxShadowSelected = colorPalette.blue300;
|
||||
export const checkboxToggleBackground = colorPalette.gray400;
|
||||
export const checkboxToggleBackgroundSelected = colorPalette.purple600;
|
||||
export const checkboxToggleDisabled = colorPalette.gray200;
|
||||
|
||||
export const pillBackground = colorPalette.navy150;
|
||||
export const pillBackgroundLight = colorPalette.navy50;
|
||||
export const pillText = colorPalette.navy800;
|
||||
export const pillTextHighlighted = colorPalette.purple600;
|
||||
export const pillBorder = colorPalette.navy150;
|
||||
export const pillBorderDark = colorPalette.navy300;
|
||||
export const pillBackgroundSelected = colorPalette.blue150;
|
||||
export const pillTextSelected = colorPalette.blue900;
|
||||
export const pillBorderSelected = colorPalette.purple500;
|
||||
export const pillTextSubdued = colorPalette.navy200;
|
||||
|
||||
export const reportsRed = colorPalette.red300;
|
||||
export const reportsBlue = colorPalette.blue400;
|
||||
export const reportsGreen = colorPalette.green400;
|
||||
export const reportsGray = colorPalette.gray400;
|
||||
export const reportsLabel = colorPalette.navy900;
|
||||
export const reportsInnerLabel = colorPalette.navy800;
|
||||
export const reportsNumberPositive = numberPositive;
|
||||
export const reportsNumberNegative = numberNegative;
|
||||
export const reportsNumberNeutral = numberNeutral;
|
||||
export const reportsChartFill = reportsNumberPositive;
|
||||
|
||||
export const noteTagBackground = colorPalette.purple125;
|
||||
export const noteTagBackgroundHover = colorPalette.purple150;
|
||||
export const noteTagDefault = colorPalette.purple125;
|
||||
export const noteTagText = colorPalette.black;
|
||||
|
||||
export const budgetCurrentMonth = tableBackground;
|
||||
export const budgetOtherMonth = colorPalette.gray50;
|
||||
export const budgetHeaderCurrentMonth = budgetOtherMonth;
|
||||
export const budgetHeaderOtherMonth = colorPalette.gray80;
|
||||
|
||||
export const floatingActionBarBackground = colorPalette.purple400;
|
||||
export const floatingActionBarBorder = floatingActionBarBackground;
|
||||
export const floatingActionBarText = colorPalette.navy50;
|
||||
|
||||
export const tooltipText = colorPalette.navy900;
|
||||
export const tooltipBackground = colorPalette.navy50;
|
||||
export const tooltipBorder = colorPalette.navy150;
|
||||
|
||||
export const calendarCellBackground = colorPalette.navy100;
|
||||
|
||||
export const overlayBackground = 'rgba(0, 0, 0, 0.3)';
|
||||
|
||||
// Chart colors - Qualitative scale (9 colors)
|
||||
export const chartQual1 = colorPalette.chartQual1;
|
||||
export const chartQual2 = colorPalette.chartQual2;
|
||||
export const chartQual3 = colorPalette.chartQual3;
|
||||
export const chartQual4 = colorPalette.chartQual4;
|
||||
export const chartQual5 = colorPalette.chartQual5;
|
||||
export const chartQual6 = colorPalette.chartQual6;
|
||||
export const chartQual7 = colorPalette.chartQual7;
|
||||
export const chartQual8 = colorPalette.chartQual8;
|
||||
export const chartQual9 = colorPalette.chartQual9;
|
||||
@@ -17,7 +17,7 @@
|
||||
"dependencies": {
|
||||
"@actual-app/sync-server": "workspace:*",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"fs-extra": "^11.3.3",
|
||||
"fs-extra": "^11.3.4",
|
||||
"promise-retry": "^2.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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
@@ -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!)
|
||||
|
||||
@@ -20,9 +20,9 @@
|
||||
"@docusaurus/preset-classic": "^3.9.2",
|
||||
"@docusaurus/theme-common": "^3.9.2",
|
||||
"@docusaurus/theme-mermaid": "^3.9.2",
|
||||
"@easyops-cn/docusaurus-search-local": "^0.52.3",
|
||||
"@easyops-cn/docusaurus-search-local": "^0.55.1",
|
||||
"@mdx-js/react": "^3.1.1",
|
||||
"@r74tech/docusaurus-plugin-panzoom": "^2.4.0",
|
||||
"@r74tech/docusaurus-plugin-panzoom": "^2.4.2",
|
||||
"clsx": "^2.1.1",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"react": "19.2.4",
|
||||
|
||||
@@ -78,7 +78,7 @@ Actual has two parts, the client and a sync server. The primary task of the sync
|
||||
|
||||
- [PikaPods](/docs/install/pikapods)
|
||||
- [Fly.io](/docs/install/fly)
|
||||
- [CLI Tool](/docs/install/cli-tool)
|
||||
- [Server CLI](/docs/install/cli-tool)
|
||||
- [Docker Install](/docs/install/docker)
|
||||
- [Build from source](/docs/install/build-from-source)
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
"csv-stringify": "^6.6.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"handlebars": "^4.7.8",
|
||||
"lru-cache": "^11.2.5",
|
||||
"lru-cache": "^11.2.6",
|
||||
"md5": "^2.3.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
"mitt": "^3.0.1",
|
||||
@@ -101,12 +101,12 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@actual-app/crdt": "workspace:*",
|
||||
"@swc/core": "^1.15.11",
|
||||
"@swc/core": "^1.15.18",
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/emscripten": "^1.41.5",
|
||||
"@types/jlongster__sql.js": "npm:@types/sql.js@latest",
|
||||
"@types/node": "^22.19.10",
|
||||
"@types/node": "^22.19.15",
|
||||
"@types/pegjs": "^0.10.6",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
|
||||
"assert": "^2.1.0",
|
||||
@@ -116,14 +116,14 @@
|
||||
"crypto-browserify": "^3.12.1",
|
||||
"fake-indexeddb": "^6.2.5",
|
||||
"fast-check": "4.5.3",
|
||||
"i18next": "^25.8.4",
|
||||
"i18next": "^25.8.14",
|
||||
"jest-diff": "^30.2.0",
|
||||
"jsverify": "^0.8.4",
|
||||
"mockdate": "^3.0.5",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"path-browserify": "^1.0.1",
|
||||
"peggy": "5.0.6",
|
||||
"rollup-plugin-visualizer": "^6.0.5",
|
||||
"peggy": "5.1.0",
|
||||
"rollup-plugin-visualizer": "^6.0.11",
|
||||
"stream-browserify": "^3.0.0",
|
||||
"timers-browserify": "^2.0.12",
|
||||
"ts-node": "^10.9.2",
|
||||
|
||||