Compare commits

..

8 Commits

Author SHA1 Message Date
Matiss Janis Aboltins
f79560c57c Refactor no-cross-package-imports rule to remove hard-banned imports and streamline relative path resolution. Update tests to reflect changes in error messaging for imports not declared in dependencies. 2026-03-17 21:33:54 +00:00
Matiss Janis Aboltins
e30225ed42 Enhance no-cross-package-imports rule to support hard-banned imports and relative path resolution. Introduce checks for exports from undeclared dependencies and add tests for new behaviors, including hard bans on specific package imports. 2026-03-17 21:13:02 +00:00
github-actions[bot]
8876299f71 Add release notes for PR #7233 2026-03-17 20:31:14 +00:00
Claude
554da806ce [AI] Revert verbose no-restricted-imports overrides, restore custom rule
Revert the per-package no-restricted-imports overrides approach (~485
lines of duplicated config) in favor of the custom no-cross-package-imports
rule in eslint-plugin-actual (~150 lines). The custom rule auto-reads
each package's package.json to determine allowed dependencies, requiring
zero config duplication and zero maintenance when dependencies change.

https://claude.ai/code/session_01XjmtRs1P9Rg7FNJAYVcaZJ
2026-03-17 19:06:16 +00:00
Claude
fc622c200b [AI] Replace custom ESLint rule with no-restricted-imports overrides
Remove the custom no-cross-package-imports rule and instead use oxlint's
built-in eslint/no-restricted-imports with per-package overrides to enforce
cross-package dependency boundaries. Each package gets an override that
restricts @actual-app/* imports to only its declared dependencies.

This also catches relative cross-package imports (e.g. ../../desktop-client/)
via glob patterns in the overrides.

https://claude.ai/code/session_01XjmtRs1P9Rg7FNJAYVcaZJ
2026-03-17 17:56:20 +00:00
Claude
d059d57cc7 [AI] Detect relative imports that cross package boundaries
Extend no-cross-package-imports rule to also catch relative imports like
`../../desktop-client/src/...` that traverse into a different package
directory. Resolves the import path against the file's directory and
checks if the target package is declared as a dependency.

Add oxlint-disable for the known intentional violation in
component-library/.storybook/preview.tsx.

https://claude.ai/code/session_01XjmtRs1P9Rg7FNJAYVcaZJ
2026-03-17 16:30:08 +00:00
Claude
8eb096957e [AI] Restore @actual-app/web no-restricted-imports rule and fix stacked disable comments
Keep the existing no-restricted-imports rule for @actual-app/web in loot-core
as-is alongside the new no-cross-package-imports rule. Fix the stacked
oxlint-disable-next-line comments in desktop-electron/e2e/onboarding.test.ts
by combining them into a single line so both suppressions apply correctly.

https://claude.ai/code/session_01XjmtRs1P9Rg7FNJAYVcaZJ
2026-03-17 12:01:14 +00:00
Claude
9d9f76d7df [AI] Add no-cross-package-imports ESLint rule to enforce package dependency boundaries
Add a custom ESLint rule that validates @actual-app/* imports are declared
as dependencies in the importing package's package.json. This replaces the
manual no-restricted-imports pattern for @actual-app/web in loot-core with
an automatic, self-maintaining rule that covers all packages.

https://claude.ai/code/session_01XjmtRs1P9Rg7FNJAYVcaZJ
2026-03-17 11:26:04 +00:00
166 changed files with 1937 additions and 6920 deletions

View File

@@ -2,7 +2,6 @@ Abanca
ABNAMRO
ABNANL
Activo
actualrc
AESUDEF
ALZEY
Anglais
@@ -111,8 +110,8 @@ KBCBE
Keycloak
Khurozov
KORT
Kreditbank
KRW
Kreditbank
lage
LHV
LHVBEE

View File

@@ -15,7 +15,7 @@ runs:
using: composite
steps:
- name: Install node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.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@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
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@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
repository: actualbudget/translations
path: ${{ inputs.working-directory }}/packages/desktop-client/locale

View File

@@ -17,7 +17,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up environment
uses: ./.github/actions/setup
with:

View File

@@ -15,11 +15,11 @@ jobs:
autofix:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Format code
run: yarn lint:fix
- uses: autofix-ci/action@7a166d7532b277f34e16238930461bf77f9d7ed8 # v1.3.3
- uses: autofix-ci/action@635ffb0c9798bd160680f18fd73371e355b85f27

View File

@@ -22,7 +22,7 @@ jobs:
api:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- 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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: actual-api
path: packages/api/actual-api.tgz
- name: Upload API bundle stats
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- 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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: actual-crdt
path: packages/crdt/actual-crdt.tgz
@@ -65,51 +65,26 @@ jobs:
web:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up environment
uses: ./.github/actions/setup
- name: Build Web
run: yarn build:browser
- name: Upload Build
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: actual-web
path: packages/desktop-client/build
- name: Upload Build Stats
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: build-stats
path: packages/desktop-client/build-stats
cli:
server:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Build CLI
run: yarn build:cli
- name: Create package tgz
run: cd packages/cli && yarn pack && mv package.tgz actual-cli.tgz
- name: Prepare bundle stats artifact
run: cp packages/cli/dist/stats.json cli-stats.json
- name: Upload Build
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: actual-cli
path: packages/cli/actual-cli.tgz
- name: Upload CLI bundle stats
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: cli-build-stats
path: cli-stats.json
server:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up environment
uses: ./.github/actions/setup
with:
@@ -117,7 +92,7 @@ jobs:
- name: Build Server
run: yarn workspace @actual-app/sync-server build
- name: Upload Build
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: sync-server
path: packages/sync-server/build

View File

@@ -12,20 +12,10 @@ concurrency:
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
jobs:
constraints:
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: Check dependency version consistency
run: yarn constraints
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up environment
uses: ./.github/actions/setup
with:
@@ -35,7 +25,7 @@ jobs:
typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up environment
uses: ./.github/actions/setup
with:
@@ -45,7 +35,7 @@ jobs:
validate-cli:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up environment
uses: ./.github/actions/setup
with:
@@ -57,7 +47,7 @@ jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up environment
uses: ./.github/actions/setup
with:
@@ -69,7 +59,7 @@ jobs:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up environment
uses: ./.github/actions/setup
with:

View File

@@ -22,14 +22,14 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Initialize CodeQL
uses: github/codeql-action/init@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
uses: github/codeql-action/init@v3
with:
languages: javascript
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
uses: github/codeql-action/analyze@v3
with:
category: '/language:javascript'

View File

@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up environment
uses: ./.github/actions/setup
with:

View File

@@ -36,17 +36,17 @@ jobs:
matrix:
os: [ubuntu, alpine]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Docker meta
id: meta
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.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@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.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@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.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@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.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@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
push: ${{ github.event_name != 'pull_request' }}

View File

@@ -28,17 +28,17 @@ jobs:
name: Build Docker image
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
- name: Docker meta
id: meta
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.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@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.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@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.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@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.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@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
push: true

View File

@@ -32,7 +32,7 @@ jobs:
container:
image: mcr.microsoft.com/playwright:v1.58.2-jammy
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- 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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- 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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- 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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up environment
uses: ./.github/actions/setup
- name: Download all blob reports
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
- uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: vrt-comment-metadata
path: vrt-metadata/

View File

@@ -18,7 +18,7 @@ jobs:
if: github.event.workflow_run.event == 'pull_request'
steps:
- name: Download VRT metadata
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
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@70d2764d1a7d5d9560b100cbea0077fc8f633987 # v3.0.2
uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
with:
number: ${{ steps.metadata.outputs.pr_number }}
header: vrt-comment

View File

@@ -29,7 +29,7 @@ jobs:
- macos-latest
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- 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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.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@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1
uses: softprops/action-gh-release@5be0e66d93ac7ed76da52eca8bb058f665c3a5fe # v2.4.2
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@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: actual-electron-windows-latest-appx

View File

@@ -33,7 +33,7 @@ jobs:
- macos-latest
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- if: ${{ startsWith(matrix.os, 'windows') }}
run: pip.exe install setuptools
- if: ${{ ! startsWith(matrix.os, 'windows') }}
@@ -42,8 +42,6 @@ jobs:
python3 -m venv .venv
source .venv/bin/activate
python3 -m pip install setuptools
- name: Set up environment
uses: ./.github/actions/setup
- if: ${{ startsWith(matrix.os, 'ubuntu') }}
name: Setup Flatpak dependencies
run: |
@@ -58,63 +56,65 @@ jobs:
METAINFO_FILE="packages/desktop-electron/extra-resources/linux/com.actualbudget.actual.metainfo.xml"
TODAY=$(date +%Y-%m-%d)
VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/desktop-electron/package.json --type nightly)
VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/desktop-electron/package.json --type nightly)
sed -i "s/%RELEASE_VERSION%/$VERSION/g; s/%RELEASE_DATE%/$TODAY/g" "$METAINFO_FILE"
flatpak run --command=flatpak-builder-lint org.flatpak.Builder appstream "$METAINFO_FILE"
- name: Set up environment
uses: ./.github/actions/setup
- name: Build Electron
run: ./bin/package-electron
- name: Upload Linux x64 AppImage
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: actual-electron-${{ matrix.os }}-appx
path: |

View File

@@ -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@70d2764d1a7d5d9560b100cbea0077fc8f633987 # v3.0.2
uses: marocchino/sticky-pull-request-comment@773744901bac0e8cbb5a0dc842800d45e9b2b405 # v2.9.4
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
number: ${{ github.event.pull_request.number }}

View File

@@ -17,13 +17,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: ${{ github.event.inputs.ref }}
- name: Set up environment
uses: ./.github/actions/setup
with:
download-translations: 'false'
- name: Bump package versions
id: bump_package_versions
shell: bash
@@ -39,12 +35,12 @@ jobs:
pkg="${packages[$key]}"
if [[ -n "${{ github.event.inputs.version }}" ]]; then
version=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts \
version=$(node ./packages/ci-actions/bin/get-next-package-version.js \
--package-json "./packages/$pkg/package.json" \
--version "${{ github.event.inputs.version }}" \
--update)
else
version=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts \
version=$(node ./packages/ci-actions/bin/get-next-package-version.js \
--package-json "./packages/$pkg/package.json" \
--type auto \
--update)
@@ -55,7 +51,7 @@ jobs:
echo "version=$NEW_WEB_VERSION" >> "$GITHUB_OUTPUT"
- name: Create PR
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
with:
token: ${{ secrets.ACTIONS_UPDATE_TOKEN }}
commit-message: '🔖 (${{ steps.bump_package_versions.outputs.version }})'

View File

@@ -12,7 +12,7 @@ jobs:
if: github.repository == 'actualbudget/actual'
steps:
- name: Check out main repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
path: actual
- name: Set up environment
@@ -44,7 +44,7 @@ jobs:
push \
actualbudget/actual
- name: Check out updated translations
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ssh-key: ${{ secrets.STRING_IMPORT_DEPLOY_KEY }}
repository: actualbudget/translations

View File

@@ -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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version: 22
- name: Handle feature requests

View File

@@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Repository Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up environment
uses: ./.github/actions/setup

View File

@@ -92,7 +92,7 @@ jobs:
echo "APPIMAGE_ARM64_SHA256=$APPIMAGE_ARM64_SHA256" >> "$GITHUB_ENV"
- name: Checkout Flathub repo
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@v6
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@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.FLATHUB_GITHUB_TOKEN }}
commit-message: 'Update Actual flatpak to version ${{ steps.resolve_version.outputs.version }}'

View File

@@ -28,7 +28,7 @@ jobs:
runs-on: ${{ matrix.os }}
if: github.event.repository.fork == false
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- if: ${{ startsWith(matrix.os, 'windows') }}
run: pip.exe install setuptools
@@ -39,9 +39,6 @@ jobs:
source .venv/bin/activate
python3 -m pip install setuptools
- name: Set up environment
uses: ./.github/actions/setup
- if: ${{ startsWith(matrix.os, 'ubuntu') }}
name: Setup Flatpak dependencies
run: |
@@ -56,14 +53,16 @@ jobs:
METAINFO_FILE="packages/desktop-electron/extra-resources/linux/com.actualbudget.actual.metainfo.xml"
TODAY=$(date +%Y-%m-%d)
VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/desktop-electron/package.json --type nightly)
VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/desktop-electron/package.json --type nightly)
sed -i "s/%RELEASE_VERSION%/$VERSION/g; s/%RELEASE_DATE%/$TODAY/g" "$METAINFO_FILE"
flatpak run --command=flatpak-builder-lint org.flatpak.Builder appstream "$METAINFO_FILE"
- name: Set up environment
uses: ./.github/actions/setup
- name: Update package versions
run: |
# Get new nightly version
NEW_DESKTOP_APP_VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/desktop-electron/package.json --type nightly)
NEW_DESKTOP_APP_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/desktop-electron/package.json --type nightly)
# Set package version
npm version $NEW_DESKTOP_APP_VERSION --no-git-tag-version --workspace=desktop-electron --no-workspaces-update
@@ -83,49 +82,49 @@ jobs:
run: ./bin/package-electron
- name: Upload Linux x64 AppImage
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: Actual-mac-arm64.dmg
if-no-files-found: ignore
@@ -133,7 +132,7 @@ jobs:
- name: Upload Windows Store Build
if: ${{ startsWith(matrix.os, 'windows') }}
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: actual-electron-${{ matrix.os }}-appx
path: |

View File

@@ -12,7 +12,7 @@ jobs:
name: Build and pack npm packages
if: github.event.repository.fork == false
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up environment
uses: ./.github/actions/setup
@@ -20,18 +20,16 @@ jobs:
- name: Update package versions
run: |
# Get new nightly versions
NEW_CORE_VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/loot-core/package.json --type nightly)
NEW_WEB_VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/desktop-client/package.json --type nightly)
NEW_SYNC_VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/sync-server/package.json --type nightly)
NEW_API_VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/api/package.json --type nightly)
NEW_CLI_VERSION=$(yarn workspace @actual-app/ci-actions tsx bin/get-next-package-version.ts --package-json ./packages/cli/package.json --type nightly)
NEW_CORE_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/loot-core/package.json --type nightly)
NEW_WEB_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/desktop-client/package.json --type nightly)
NEW_SYNC_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/sync-server/package.json --type nightly)
NEW_API_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/api/package.json --type nightly)
# 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: |
@@ -56,15 +54,8 @@ 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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: npm-packages
path: |
@@ -72,7 +63,6 @@ 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
@@ -83,12 +73,12 @@ jobs:
packages: write
steps:
- name: Download the artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: npm-packages
- name: Setup node and npm registry
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version: 22
registry-url: 'https://registry.npmjs.org'
@@ -116,9 +106,3 @@ 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 }}

View File

@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
name: Build and pack npm packages
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Set up environment
uses: ./.github/actions/setup
@@ -35,15 +35,8 @@ 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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: npm-packages
path: |
@@ -51,7 +44,6 @@ 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
@@ -62,12 +54,12 @@ jobs:
packages: write
steps:
- name: Download the artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: npm-packages
- name: Setup node and npm registry
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0
with:
node-version: 22
registry-url: 'https://registry.npmjs.org'
@@ -95,9 +87,3 @@ 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 }}

View File

@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
fetch-depth: 0
- name: Get changed files

View File

@@ -35,7 +35,7 @@ jobs:
contents: read
steps:
- name: Checkout base branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
ref: ${{ github.base_ref }}
- name: Set up environment
@@ -57,13 +57,6 @@ 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
@@ -79,22 +72,15 @@ 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' || steps.wait-for-cli-build.outputs.conclusion == 'failure'
if: steps.wait-for-web-build.outputs.conclusion == 'failure' || steps.wait-for-api-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@1f8785ff7a5130826f848e7f72725c85d241860f # v18
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
id: pr-web-build
with:
branch: ${{github.base_ref}}
@@ -103,7 +89,7 @@ jobs:
name: build-stats
path: base
- name: Download API build artifact from ${{github.base_ref}}
uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
id: pr-api-build
with:
branch: ${{github.base_ref}}
@@ -112,7 +98,7 @@ jobs:
name: api-build-stats
path: base
- name: Download build stats from PR
uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
with:
pr: ${{github.event.pull_request.number}}
workflow: build.yml
@@ -121,7 +107,7 @@ jobs:
path: head
allow_forks: true
- name: Download API stats from PR
uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18
uses: dawidd6/action-download-artifact@ac66b43f0e6a346234dd65d4d0c8fbb31cb316e5 # v11
with:
pr: ${{github.event.pull_request.number}}
workflow: build.yml
@@ -129,23 +115,6 @@ 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
@@ -167,11 +136,9 @@ 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

View File

@@ -8,7 +8,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.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@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.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@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
with:
stale-issue-label: 'needs info'
days-before-stale: -1

View File

@@ -19,7 +19,7 @@ jobs:
if: ${{ github.event.workflow_run.conclusion == 'success' }}
steps:
- name: Download patch artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
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@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
with:
repository: ${{ steps.metadata.outputs.head_repo }}
ref: ${{ steps.metadata.outputs.head_ref }}

View File

@@ -60,7 +60,7 @@ jobs:
core.setOutput('head_ref', pr.head.ref);
core.setOutput('head_repo', pr.head.repo.full_name);
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.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@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: vrt-metadata-${{ github.event.issue.number }}
path: pr-metadata/

7
.gitignore vendored
View File

@@ -81,10 +81,3 @@ build/
*storybook.log
storybook-static
# cli config when testing locally
.actualrc.json
.actualrc
.actualrc.yaml
.actualrc.yml
actual.config.js

View File

@@ -1,13 +0,0 @@
#!/bin/sh
# Run yarn install when switching branches (if yarn.lock changed)
# $3 is 1 for branch checkout, 0 for file checkout
if [ "$3" != "1" ]; then
exit 0
fi
# Check if yarn.lock changed between the old and new HEAD
if git diff --name-only "$1" "$2" | grep -q "^yarn.lock$"; then
echo "yarn.lock changed — running yarn install..."
yarn install
fi

View File

@@ -1,7 +0,0 @@
#!/bin/sh
# Run yarn install after pulling/merging (if yarn.lock changed)
if git diff --name-only ORIG_HEAD HEAD | grep -q "^yarn.lock$"; then
echo "yarn.lock changed — running yarn install..."
yarn install
fi

View File

@@ -17,7 +17,6 @@ module.exports = {
},
build: {
type: 'npmScript',
dependsOn: ['^build'],
cache: true,
options: {
outputGlob: ['lib-dist/**', 'dist/**', 'build/**'],

View File

@@ -34,14 +34,12 @@
"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",
@@ -59,31 +57,29 @@
"lint": "oxfmt --check . && oxlint --type-aware --quiet",
"lint:fix": "oxfmt . && oxlint --fix --type-aware --quiet",
"install:server": "yarn workspaces focus @actual-app/sync-server --production",
"constraints": "yarn constraints",
"typecheck": "tsgo -p tsconfig.root.json --noEmit && lage typecheck",
"jq": "./node_modules/node-jq/bin/jq",
"prepare": "husky"
},
"devDependencies": {
"@octokit/rest": "^22.0.1",
"@types/node": "^22.19.15",
"@types/node": "^22.19.10",
"@types/prompts": "^2.4.9",
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
"@yarnpkg/types": "^4.0.1",
"baseline-browser-mapping": "^2.10.0",
"baseline-browser-mapping": "^2.9.19",
"cross-env": "^10.1.0",
"eslint": "^9.39.3",
"eslint-plugin-perfectionist": "^5.6.0",
"eslint": "^9.39.2",
"eslint-plugin-perfectionist": "^4.15.1",
"eslint-plugin-typescript-paths": "^0.0.33",
"html-to-image": "^1.11.13",
"husky": "^9.1.7",
"lage": "^2.14.19",
"lint-staged": "^16.3.2",
"minimatch": "^10.2.4",
"lage": "^2.14.17",
"lint-staged": "^16.2.7",
"minimatch": "^10.1.2",
"node-jq": "^6.3.1",
"npm-run-all": "^4.1.5",
"oxfmt": "^0.32.0",
"oxlint": "^1.51.0",
"oxlint": "^1.47.0",
"oxlint-tsgolint": "^0.13.0",
"p-limit": "^7.3.0",
"prompts": "^2.4.2",

View File

@@ -1,7 +1,4 @@
class Query {
/** @type {import('loot-core/shared/query').QueryState} */
state;
constructor(state) {
this.state = {
filterExpressions: state.filterExpressions || [],

View File

@@ -896,73 +896,6 @@ describe('API CRUD operations', () => {
);
expect(transactions[0].notes).toBeNull();
});
test('Transactions: reimportDeleted=false prevents reimporting deleted transactions', async () => {
const accountId = await api.createAccount({ name: 'test-account' }, 0);
// Import a transaction
const result1 = await api.importTransactions(accountId, [
{
date: '2023-11-03',
imported_id: 'reimport-test-1',
amount: 100,
account: accountId,
},
]);
expect(result1.added).toHaveLength(1);
// Delete the transaction
await api.deleteTransaction(result1.added[0]);
// Reimport the same transaction with reimportDeleted=false
const result2 = await api.importTransactions(
accountId,
[
{
date: '2023-11-03',
imported_id: 'reimport-test-1',
amount: 100,
account: accountId,
},
],
{ reimportDeleted: false },
);
// Should match the deleted transaction and not create a new one
expect(result2.added).toHaveLength(0);
expect(result2.updated).toHaveLength(0);
});
test('Transactions: reimportDeleted=true reimports deleted transactions', async () => {
const accountId = await api.createAccount({ name: 'test-account' }, 0);
// Import a transaction
const result1 = await api.importTransactions(accountId, [
{
date: '2023-11-03',
imported_id: 'reimport-test-2',
amount: 200,
account: accountId,
},
]);
expect(result1.added).toHaveLength(1);
// Delete the transaction
await api.deleteTransaction(result1.added[0]);
// Reimport the same transaction relying on reimportDeleted=true default
const result2 = await api.importTransactions(accountId, [
{
date: '2023-11-03',
imported_id: 'reimport-test-2',
amount: 200,
account: accountId,
},
]);
// Should create a new transaction since deleted ones are ignored
expect(result2.added).toHaveLength(1);
});
});
//apis: createSchedule, getSchedules, updateSchedule, deleteSchedule

View File

@@ -9,20 +9,6 @@
],
"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",
@@ -38,7 +24,7 @@
},
"devDependencies": {
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
"rollup-plugin-visualizer": "^6.0.11",
"rollup-plugin-visualizer": "^6.0.5",
"typescript-strict-plugin": "^2.4.4",
"vite": "^8.0.0",
"vite-plugin-dts": "^4.5.4",

View File

@@ -2,13 +2,13 @@
// This script is used in GitHub Actions to get the next version based on the current package.json version.
// It supports three types of versioning: nightly, hotfix, and monthly.
import fs from 'node:fs';
import { parseArgs } from 'node:util';
import {
getNextVersion,
isValidVersionType,
} from '../src/versions/get-next-package-version';
import { getNextVersion } from '../src/versions/get-next-package-version.js';
const args = process.argv;
const options = {
'package-json': {
@@ -28,53 +28,40 @@ const options = {
short: 'u',
default: false,
},
} as const;
function fail(message: string): never {
console.error(message);
process.exit(1);
}
};
const { values } = parseArgs({
args,
options,
allowPositionals: true,
});
const packageJsonPath = values['package-json'];
if (!packageJsonPath) {
fail(
if (!values['package-json']) {
console.error(
'Please specify the path to package.json using --package-json or -p option.',
);
process.exit(1);
}
try {
const packageJsonPath = values['package-json'];
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
if (!('version' in packageJson) || typeof packageJson.version !== 'string') {
fail('The specified package.json does not contain a valid version field.');
}
const currentVersion = packageJson.version;
const explicitVersion = values.version;
let newVersion;
if (explicitVersion) {
newVersion = explicitVersion;
} else {
const type = values.type;
if (!type || !isValidVersionType(type)) {
fail('Please specify the release type using --type or -t.');
}
try {
newVersion = getNextVersion({
currentVersion,
type,
type: values.type,
currentDate: new Date(),
});
} catch (error) {
fail(error instanceof Error ? error.message : String(error));
} catch (e) {
console.error(e.message);
process.exit(1);
}
}
@@ -89,5 +76,6 @@ try {
);
}
} catch (error) {
fail(`Error: ${error instanceof Error ? error.message : String(error)}`);
console.error('Error:', error.message);
process.exit(1);
}

View File

@@ -1,8 +0,0 @@
#!/bin/bash
set -euo pipefail
cd ../../
script="$1"
shift
exec node --import=extensionless/register --experimental-strip-types packages/ci-actions/"$script" "$@"

View File

@@ -3,7 +3,7 @@
"private": true,
"type": "module",
"scripts": {
"tsx": "bin/tsx",
"tsx": "node --import=extensionless/register --experimental-strip-types",
"test": "vitest --run",
"typecheck": "tsgo -b"
},

View File

@@ -1,69 +1,35 @@
export const versionTypeArray = [
'auto',
'hotfix',
'monthly',
'nightly',
] as const;
export type VersionType = (typeof versionTypeArray)[number];
type ParsedVersion = {
versionYear: number;
versionMonth: number;
versionHotfix: number;
};
type GetNextVersionOptions = {
currentVersion: string;
type: VersionType;
currentDate?: Date;
};
function parseVersion(version: string): ParsedVersion {
function parseVersion(version) {
const [y, m, p] = version.split('.');
return {
versionYear: Number.parseInt(y, 10),
versionMonth: Number.parseInt(m, 10),
versionHotfix: Number.parseInt(p, 10),
versionYear: parseInt(y, 10),
versionMonth: parseInt(m, 10),
versionHotfix: parseInt(p, 10),
};
}
function computeNextMonth(versionYear: number, versionMonth: number) {
const versionDate = new Date(2000 + versionYear, versionMonth - 1, 1);
function computeNextMonth(versionYear, versionMonth) {
// Create date and add 1 month
const versionDate = new Date(2000 + versionYear, versionMonth - 1, 1); // month is 0-indexed
const nextVersionMonthDate = new Date(
versionDate.getFullYear(),
versionDate.getMonth() + 1,
1,
);
// Format back to YY.M format
const fullYear = nextVersionMonthDate.getFullYear();
const nextVersionYear = fullYear.toString().slice(fullYear < 2100 ? -2 : -3);
const nextVersionMonth = nextVersionMonthDate.getMonth() + 1;
const nextVersionMonth = nextVersionMonthDate.getMonth() + 1; // Convert back to 1-indexed
return { nextVersionYear, nextVersionMonth };
}
export function isValidVersionType(value: string): value is VersionType {
return versionTypeArray.includes(value as VersionType);
}
function resolveType(
type: VersionType,
currentDate: Date,
versionYear: number,
versionMonth: number,
) {
if (type !== 'auto') {
return type;
}
// Determine logical type from 'auto' based on the current date and version
function resolveType(type, currentDate, versionYear, versionMonth) {
if (type !== 'auto') return type;
const inPatchMonth =
currentDate.getFullYear() === 2000 + versionYear &&
currentDate.getMonth() + 1 === versionMonth;
if (inPatchMonth && currentDate.getDate() <= 25) {
return 'hotfix';
}
if (inPatchMonth && currentDate.getDate() <= 25) return 'hotfix';
return 'monthly';
}
@@ -71,7 +37,7 @@ export function getNextVersion({
currentVersion,
type,
currentDate = new Date(),
}: GetNextVersionOptions) {
}) {
const { versionYear, versionMonth, versionHotfix } =
parseVersion(currentVersion);
const { nextVersionYear, nextVersionMonth } = computeNextMonth(
@@ -85,10 +51,11 @@ export function getNextVersion({
versionMonth,
);
// Format date stamp once for nightly
const currentDateString = currentDate
.toISOString()
.split('T')[0]
.replace(/-/g, '');
.replaceAll('-', '');
switch (resolvedType) {
case 'nightly':
@@ -99,7 +66,7 @@ export function getNextVersion({
return `${nextVersionYear}.${nextVersionMonth}.0`;
default:
throw new Error(
`Invalid type ${String(resolvedType satisfies never)} specified. Use "auto", "nightly", "hotfix", or "monthly".`,
'Invalid type specified. Use "auto", "nightly", "hotfix", or "monthly".',
);
}
}

View File

@@ -77,7 +77,7 @@ describe('getNextVersion (lib)', () => {
expect(() =>
getNextVersion({
currentVersion: '25.8.4',
type: 'unknown' as never,
type: 'unknown',
currentDate: new Date('2025-08-10'),
}),
).toThrow(/Invalid type/);

View File

@@ -2,8 +2,8 @@
"compilerOptions": {
"target": "ES2022",
"lib": [],
"module": "es2022",
"moduleResolution": "bundler",
"module": "nodenext",
"moduleResolution": "nodenext",
"skipLibCheck": true,
"strict": true,
"types": ["node"],

View File

@@ -1,7 +0,0 @@
dist
coverage
.actualrc.json
.actualrc
.actualrc.yaml
.actualrc.yml
actual.config.js

View File

@@ -1,155 +0,0 @@
# @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
```

View File

@@ -1,35 +0,0 @@
{
"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"
}
}

View File

@@ -1,259 +0,0 @@
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'),
);
});
});
});

View File

@@ -1,135 +0,0 @@
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);
});
});
}

View File

@@ -1,135 +0,0 @@
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);
});
});
}

View File

@@ -1,75 +0,0 @@
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);
});
});
}

View File

@@ -1,73 +0,0 @@
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);
});
});
}

View File

@@ -1,95 +0,0 @@
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);
});
});
}

View File

@@ -1,346 +0,0 @@
import * as api from '@actual-app/api';
import { Command } from 'commander';
import { printOutput } from '../output';
import { parseOrderBy, registerQueryCommand } from './query';
vi.mock('@actual-app/api', () => {
const queryObj = {
select: vi.fn().mockReturnThis(),
filter: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
offset: vi.fn().mockReturnThis(),
groupBy: vi.fn().mockReturnThis(),
calculate: vi.fn().mockReturnThis(),
};
return {
q: vi.fn().mockReturnValue(queryObj),
aqlQuery: vi.fn().mockResolvedValue({ data: [] }),
};
});
vi.mock('../connection', () => ({
withConnection: vi.fn((_opts, fn) => fn()),
}));
vi.mock('../output', () => ({
printOutput: vi.fn(),
}));
function createProgram(): Command {
const program = new Command();
program.option('--format <format>');
program.option('--server-url <url>');
program.option('--password <pw>');
program.option('--session-token <token>');
program.option('--sync-id <id>');
program.option('--data-dir <dir>');
program.option('--verbose');
program.exitOverride();
registerQueryCommand(program);
return program;
}
async function run(args: string[]) {
const program = createProgram();
await program.parseAsync(['node', 'test', ...args]);
}
function getQueryObj() {
return vi.mocked(api.q).mock.results[0]?.value;
}
describe('parseOrderBy', () => {
it('parses plain field names', () => {
expect(parseOrderBy('date')).toEqual(['date']);
});
it('parses field:desc', () => {
expect(parseOrderBy('date:desc')).toEqual([{ date: 'desc' }]);
});
it('parses field:asc', () => {
expect(parseOrderBy('amount:asc')).toEqual([{ amount: 'asc' }]);
});
it('parses multiple mixed fields', () => {
expect(parseOrderBy('date:desc,amount:asc,id')).toEqual([
{ date: 'desc' },
{ amount: 'asc' },
'id',
]);
});
it('throws on invalid direction', () => {
expect(() => parseOrderBy('date:backwards')).toThrow(
'Invalid order direction "backwards"',
);
});
it('throws on empty field', () => {
expect(() => parseOrderBy('date,,amount')).toThrow('empty field');
});
});
describe('query commands', () => {
let stderrSpy: ReturnType<typeof vi.spyOn>;
let stdoutSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.clearAllMocks();
stderrSpy = vi
.spyOn(process.stderr, 'write')
.mockImplementation(() => true);
stdoutSpy = vi
.spyOn(process.stdout, 'write')
.mockImplementation(() => true);
});
afterEach(() => {
stderrSpy.mockRestore();
stdoutSpy.mockRestore();
});
describe('run', () => {
it('builds a basic query from flags', async () => {
await run([
'query',
'run',
'--table',
'transactions',
'--select',
'date,amount',
'--limit',
'5',
]);
expect(api.q).toHaveBeenCalledWith('transactions');
const qObj = getQueryObj();
expect(qObj.select).toHaveBeenCalledWith(['date', 'amount']);
expect(qObj.limit).toHaveBeenCalledWith(5);
});
it('rejects unknown table name', async () => {
await expect(
run(['query', 'run', '--table', 'nonexistent']),
).rejects.toThrow('Unknown table "nonexistent"');
});
it('parses order-by with desc direction', async () => {
await run([
'query',
'run',
'--table',
'transactions',
'--order-by',
'date:desc,amount:asc',
]);
const qObj = getQueryObj();
expect(qObj.orderBy).toHaveBeenCalledWith([
{ date: 'desc' },
{ amount: 'asc' },
]);
});
it('passes --filter as JSON', async () => {
await run([
'query',
'run',
'--table',
'transactions',
'--filter',
'{"amount":{"$lt":0}}',
]);
const qObj = getQueryObj();
expect(qObj.filter).toHaveBeenCalledWith({ amount: { $lt: 0 } });
});
});
describe('--last flag', () => {
it('sets default table, select, orderBy, and limit', async () => {
await run(['query', 'run', '--last', '10']);
expect(api.q).toHaveBeenCalledWith('transactions');
const qObj = getQueryObj();
expect(qObj.select).toHaveBeenCalledWith([
'date',
'account.name',
'payee.name',
'category.name',
'amount',
'notes',
]);
expect(qObj.orderBy).toHaveBeenCalledWith([{ date: 'desc' }]);
expect(qObj.limit).toHaveBeenCalledWith(10);
});
it('allows explicit --select override', async () => {
await run(['query', 'run', '--last', '5', '--select', 'date,amount']);
const qObj = getQueryObj();
expect(qObj.select).toHaveBeenCalledWith(['date', 'amount']);
});
it('allows explicit --order-by override', async () => {
await run(['query', 'run', '--last', '5', '--order-by', 'amount:asc']);
const qObj = getQueryObj();
expect(qObj.orderBy).toHaveBeenCalledWith([{ amount: 'asc' }]);
});
it('allows --table transactions explicitly', async () => {
await run(['query', 'run', '--last', '5', '--table', 'transactions']);
expect(api.q).toHaveBeenCalledWith('transactions');
});
it('errors if --table is not transactions', async () => {
await expect(
run(['query', 'run', '--last', '5', '--table', 'accounts']),
).rejects.toThrow('--last implies --table transactions');
});
it('errors if --limit is also set', async () => {
await expect(
run(['query', 'run', '--last', '5', '--limit', '10']),
).rejects.toThrow('--last and --limit are mutually exclusive');
});
});
describe('--count flag', () => {
it('uses calculate with $count', async () => {
vi.mocked(api.aqlQuery).mockResolvedValueOnce({ data: 42 });
await run(['query', 'run', '--table', 'transactions', '--count']);
const qObj = getQueryObj();
expect(qObj.calculate).toHaveBeenCalledWith({ $count: '*' });
expect(printOutput).toHaveBeenCalledWith({ count: 42 }, undefined);
});
it('errors if --select is also set', async () => {
await expect(
run([
'query',
'run',
'--table',
'transactions',
'--count',
'--select',
'date',
]),
).rejects.toThrow('--count and --select are mutually exclusive');
});
});
describe('--where alias', () => {
it('works the same as --filter', async () => {
await run([
'query',
'run',
'--table',
'transactions',
'--where',
'{"amount":{"$gt":0}}',
]);
const qObj = getQueryObj();
expect(qObj.filter).toHaveBeenCalledWith({ amount: { $gt: 0 } });
});
it('errors if both --where and --filter are provided', async () => {
await expect(
run([
'query',
'run',
'--table',
'transactions',
'--where',
'{}',
'--filter',
'{}',
]),
).rejects.toThrow('--where and --filter are mutually exclusive');
});
});
describe('--offset flag', () => {
it('passes offset through to query', async () => {
await run([
'query',
'run',
'--table',
'transactions',
'--offset',
'20',
'--limit',
'10',
]);
const qObj = getQueryObj();
expect(qObj.offset).toHaveBeenCalledWith(20);
expect(qObj.limit).toHaveBeenCalledWith(10);
});
});
describe('--group-by flag', () => {
it('passes group-by through to query', async () => {
await run([
'query',
'run',
'--table',
'transactions',
'--group-by',
'category.name',
'--select',
'category.name,amount',
]);
const qObj = getQueryObj();
expect(qObj.groupBy).toHaveBeenCalledWith(['category.name']);
});
});
describe('tables subcommand', () => {
it('lists available tables', async () => {
await run(['query', 'tables']);
expect(printOutput).toHaveBeenCalledWith(
expect.arrayContaining([
{ name: 'transactions' },
{ name: 'accounts' },
{ name: 'categories' },
{ name: 'payees' },
]),
undefined,
);
});
});
describe('fields subcommand', () => {
it('lists fields for a known table', async () => {
await run(['query', 'fields', 'accounts']);
const output = vi.mocked(printOutput).mock.calls[0][0] as Array<{
name: string;
type: string;
}>;
expect(output).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'id', type: 'id' }),
expect.objectContaining({ name: 'name', type: 'string' }),
]),
);
});
it('errors on unknown table', async () => {
await expect(run(['query', 'fields', 'unknown'])).rejects.toThrow(
'Unknown table "unknown"',
);
});
});
});

View File

@@ -1,344 +0,0 @@
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);
}
/**
* Parse order-by strings like "date:desc,amount:asc,id" into
* AQL orderBy format: [{ date: 'desc' }, { amount: 'asc' }, 'id']
*/
export function parseOrderBy(
input: string,
): Array<string | Record<string, string>> {
return input.split(',').map(part => {
const trimmed = part.trim();
if (!trimmed) {
throw new Error('--order-by contains an empty field');
}
const colonIndex = trimmed.indexOf(':');
if (colonIndex === -1) {
return trimmed;
}
const field = trimmed.slice(0, colonIndex).trim();
if (!field) {
throw new Error(
`Invalid order field in "${trimmed}". Field name cannot be empty.`,
);
}
const direction = trimmed.slice(colonIndex + 1);
if (direction !== 'asc' && direction !== 'desc') {
throw new Error(
`Invalid order direction "${direction}" for field "${field}". Expected "asc" or "desc".`,
);
}
return { [field]: direction };
});
}
// TODO: Import schema from API once it exposes table/field metadata
const TABLE_SCHEMA: Record<
string,
Record<string, { type: string; ref?: string }>
> = {
transactions: {
id: { type: 'id' },
account: { type: 'id', ref: 'accounts' },
date: { type: 'date' },
amount: { type: 'integer' },
payee: { type: 'id', ref: 'payees' },
category: { type: 'id', ref: 'categories' },
notes: { type: 'string' },
imported_id: { type: 'string' },
transfer_id: { type: 'id' },
cleared: { type: 'boolean' },
reconciled: { type: 'boolean' },
starting_balance_flag: { type: 'boolean' },
imported_payee: { type: 'string' },
is_parent: { type: 'boolean' },
is_child: { type: 'boolean' },
parent_id: { type: 'id' },
sort_order: { type: 'float' },
schedule: { type: 'id', ref: 'schedules' },
'account.name': { type: 'string', ref: 'accounts' },
'payee.name': { type: 'string', ref: 'payees' },
'category.name': { type: 'string', ref: 'categories' },
'category.group.name': { type: 'string', ref: 'category_groups' },
},
accounts: {
id: { type: 'id' },
name: { type: 'string' },
offbudget: { type: 'boolean' },
closed: { type: 'boolean' },
sort_order: { type: 'float' },
},
categories: {
id: { type: 'id' },
name: { type: 'string' },
is_income: { type: 'boolean' },
group_id: { type: 'id', ref: 'category_groups' },
sort_order: { type: 'float' },
hidden: { type: 'boolean' },
'group.name': { type: 'string', ref: 'category_groups' },
},
payees: {
id: { type: 'id' },
name: { type: 'string' },
transfer_acct: { type: 'id', ref: 'accounts' },
},
rules: {
id: { type: 'id' },
stage: { type: 'string' },
conditions_op: { type: 'string' },
conditions: { type: 'json' },
actions: { type: 'json' },
},
schedules: {
id: { type: 'id' },
name: { type: 'string' },
rule: { type: 'id', ref: 'rules' },
next_date: { type: 'date' },
completed: { type: 'boolean' },
},
};
const AVAILABLE_TABLES = Object.keys(TABLE_SCHEMA).join(', ');
const LAST_DEFAULT_SELECT = [
'date',
'account.name',
'payee.name',
'category.name',
'amount',
'notes',
];
function buildQueryFromFile(
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);
if (typeof parsed.offset === 'number') {
queryObj = queryObj.offset(parsed.offset);
}
if (Array.isArray(parsed.groupBy)) {
queryObj = queryObj.groupBy(parsed.groupBy);
}
return queryObj;
}
function buildQueryFromFlags(cmdOpts: Record<string, string | undefined>) {
const last = cmdOpts.last ? parseIntFlag(cmdOpts.last, '--last') : undefined;
if (last !== undefined) {
if (cmdOpts.table && cmdOpts.table !== 'transactions') {
throw new Error(
'--last implies --table transactions. Cannot use with --table ' +
cmdOpts.table,
);
}
if (cmdOpts.limit) {
throw new Error('--last and --limit are mutually exclusive');
}
}
const table =
cmdOpts.table ?? (last !== undefined ? 'transactions' : undefined);
if (!table) {
throw new Error('--table is required (or use --file or --last)');
}
if (!(table in TABLE_SCHEMA)) {
throw new Error(
`Unknown table "${table}". Available tables: ${AVAILABLE_TABLES}`,
);
}
if (cmdOpts.where && cmdOpts.filter) {
throw new Error('--where and --filter are mutually exclusive');
}
if (cmdOpts.count && cmdOpts.select) {
throw new Error('--count and --select are mutually exclusive');
}
let queryObj = api.q(table);
if (cmdOpts.count) {
queryObj = queryObj.calculate({ $count: '*' });
} else if (cmdOpts.select) {
queryObj = queryObj.select(cmdOpts.select.split(','));
} else if (last !== undefined) {
queryObj = queryObj.select(LAST_DEFAULT_SELECT);
}
const filterStr = cmdOpts.filter ?? cmdOpts.where;
if (filterStr) {
queryObj = queryObj.filter(JSON.parse(filterStr));
}
const orderByStr =
cmdOpts.orderBy ??
(last !== undefined && !cmdOpts.count ? 'date:desc' : undefined);
if (orderByStr) {
queryObj = queryObj.orderBy(parseOrderBy(orderByStr));
}
const limitVal =
last ??
(cmdOpts.limit ? parseIntFlag(cmdOpts.limit, '--limit') : undefined);
if (limitVal !== undefined) {
queryObj = queryObj.limit(limitVal);
}
if (cmdOpts.offset) {
queryObj = queryObj.offset(parseIntFlag(cmdOpts.offset, '--offset'));
}
if (cmdOpts.groupBy) {
queryObj = queryObj.groupBy(cmdOpts.groupBy.split(','));
}
return queryObj;
}
const RUN_EXAMPLES = `
Examples:
# Show last 5 transactions (shortcut)
actual query run --last 5
# Transactions ordered by date descending
actual query run --table transactions --select "date,amount,payee.name" --order-by "date:desc" --limit 10
# Filter with JSON (negative amounts = expenses)
actual query run --table transactions --filter '{"amount":{"$lt":0}}' --limit 5
# Count transactions
actual query run --table transactions --count
# Group by category (use --file for aggregate expressions)
echo '{"table":"transactions","groupBy":["category.name"],"select":["category.name",{"amount":{"$sum":"$amount"}}]}' | actual query run --file -
# Pagination
actual query run --table transactions --order-by "date:desc" --limit 10 --offset 20
# Use --where (alias for --filter)
actual query run --table transactions --where '{"payee.name":"Grocery Store"}' --limit 5
# Read query from a JSON file
actual query run --file query.json
# Pipe query from stdin
echo '{"table":"transactions","limit":5}' | actual query run --file -
Available tables: ${AVAILABLE_TABLES}
Use "actual query tables" and "actual query fields <table>" for schema info.
Common filter operators: $eq, $ne, $lt, $lte, $gt, $gte, $like, $and, $or
See ActualQL docs for full reference: https://actualbudget.org/docs/api/actual-ql/`;
export function registerQueryCommand(program: Command) {
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 (use "actual query tables" to list available tables)',
)
.option('--select <fields>', 'Comma-separated fields to select')
.option('--filter <json>', 'Filter as JSON (e.g. \'{"amount":{"$lt":0}}\')')
.option(
'--where <json>',
'Alias for --filter (cannot be used together with --filter)',
)
.option(
'--order-by <fields>',
'Fields with optional direction: field1:desc,field2 (default: asc)',
)
.option('--limit <n>', 'Limit number of results')
.option('--offset <n>', 'Skip first N results (for pagination)')
.option(
'--last <n>',
'Show last N transactions (implies --table transactions, --order-by date:desc)',
)
.option('--count', 'Count matching rows instead of returning them')
.option(
'--group-by <fields>',
'Comma-separated fields to group by (use with aggregate selects)',
)
.option(
'--file <path>',
'Read full query object from JSON file (use - for stdin)',
)
.addHelpText('after', RUN_EXAMPLES)
.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);
if (cmdOpts.count) {
printOutput({ count: result.data }, opts.format);
} else {
printOutput(result, opts.format);
}
});
});
query
.command('tables')
.description('List available tables for querying')
.action(() => {
const opts = program.opts();
const tables = Object.keys(TABLE_SCHEMA).map(name => ({ name }));
printOutput(tables, opts.format);
});
query
.command('fields <table>')
.description('List fields for a given table')
.action((table: string) => {
const opts = program.opts();
const schema = TABLE_SCHEMA[table];
if (!schema) {
throw new Error(
`Unknown table "${table}". Available tables: ${Object.keys(TABLE_SCHEMA).join(', ')}`,
);
}
const fields = Object.entries(schema).map(([name, info]) => ({
name,
type: info.type,
...(info.ref ? { ref: info.ref } : {}),
}));
printOutput(fields, opts.format);
});
}

View File

@@ -1,77 +0,0 @@
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);
});
});
}

View File

@@ -1,67 +0,0 @@
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);
});
});
}

View File

@@ -1,60 +0,0 @@
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);
});
});
}

View File

@@ -1,74 +0,0 @@
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);
});
});
}

View File

@@ -1,114 +0,0 @@
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);
});
});
}

View File

@@ -1,185 +0,0 @@
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');
});
});
});

View File

@@ -1,141 +0,0 @@
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,
};
}

View File

@@ -1,134 +0,0 @@
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'),
);
});
});

View File

@@ -1,65 +0,0 @@
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();
}
}

View File

@@ -1,70 +0,0 @@
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;
});

View File

@@ -1,21 +0,0 @@
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');
}

View File

@@ -1,152 +0,0 @@
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');
});
});

View File

@@ -1,82 +0,0 @@
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');
}

View File

@@ -1,65 +0,0 @@
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');
});
});

View File

@@ -1,16 +0,0 @@
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;
}

View File

@@ -1,15 +0,0 @@
{
"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"]
}

View File

@@ -1,36 +0,0 @@
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,
},
});

View File

@@ -4,14 +4,18 @@ 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
// oxlint-disable actual/no-cross-package-imports -- intentional cross-package import, 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';
// oxlint-enable actual/no-cross-package-imports
const THEMES = {
light: lightTheme,
dark: darkTheme,
midnight: midnightTheme,
development: developmentTheme,
} as const;
type ThemeName = keyof typeof THEMES;
@@ -62,6 +66,7 @@ const preview: Preview = {
{ value: 'light', title: 'Light' },
{ value: 'dark', title: 'Dark' },
{ value: 'midnight', title: 'Midnight' },
{ value: 'development', title: 'Development' },
],
},
},

View File

@@ -48,18 +48,18 @@
"usehooks-ts": "^3.1.1"
},
"devDependencies": {
"@chromatic-com/storybook": "^5.0.1",
"@storybook/addon-a11y": "^10.2.16",
"@storybook/addon-docs": "^10.2.16",
"@storybook/react-vite": "^10.2.16",
"@chromatic-com/storybook": "^5.0.0",
"@storybook/addon-a11y": "^10.2.7",
"@storybook/addon-docs": "^10.2.7",
"@storybook/react-vite": "^10.2.7",
"@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.16",
"eslint-plugin-storybook": "^10.2.7",
"react": "19.2.4",
"react-dom": "19.2.4",
"storybook": "^10.2.16",
"storybook": "^10.2.7",
"vite": "^8.0.0",
"vitest": "^4.1.0"
},

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View File

@@ -5,7 +5,6 @@ export class BudgetMenuModal {
readonly locator: Locator;
readonly heading: Locator;
readonly budgetAmountInput: Locator;
readonly actionsButton: Locator;
readonly copyLastMonthBudgetButton: Locator;
readonly setTo3MonthAverageButton: Locator;
readonly setTo6MonthAverageButton: Locator;
@@ -18,9 +17,6 @@ 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",
});
@@ -42,10 +38,6 @@ 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();
@@ -53,27 +45,22 @@ 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();
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

After

Width:  |  Height:  |  Size: 121 KiB

View File

@@ -22,9 +22,9 @@
"@actual-app/components": "workspace:*",
"@actual-app/core": "workspace:*",
"@babel/core": "^7.29.0",
"@codemirror/autocomplete": "^6.20.1",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/language": "^6.12.2",
"@codemirror/autocomplete": "^6.20.0",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/language": "^6.12.1",
"@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.8",
"@rolldown/plugin-babel": "~0.1.7",
"@rollup/plugin-inject": "^5.0.5",
"@swc/core": "^1.15.18",
"@swc/helpers": "^0.5.19",
"@tanstack/react-query": "^5.90.21",
"@swc/core": "^1.15.11",
"@swc/helpers": "^0.5.18",
"@tanstack/react-query": "^5.90.20",
"@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.7",
"@uiw/react-codemirror": "^4.25.4",
"@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.3.2",
"hyperformula": "^3.2.0",
"i18next": "^25.8.14",
"i18next-parser": "^9.4.0",
"downshift": "9.0.10",
"hyperformula": "^3.1.1",
"i18next": "^25.8.4",
"i18next-parser": "^9.3.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.6",
"react-i18next": "^16.5.4",
"react-markdown": "^10.1.0",
"react-modal": "3.16.3",
"react-redux": "^9.2.0",
"react-router": "7.13.1",
"react-router": "7.13.0",
"react-simple-pull-to-refresh": "^1.3.4",
"react-spring": "^10.0.3",
"react-spring": "10.0.0",
"react-swipeable": "^7.0.2",
"react-virtualized-auto-sizer": "^2.0.3",
"react-virtualized-auto-sizer": "^2.0.2",
"recharts": "^3.7.0",
"rehype-external-links": "^3.0.0",
"remark-gfm": "^4.0.1",
"rollup-plugin-visualizer": "^6.0.11",
"rollup-plugin-visualizer": "^6.0.5",
"sass": "^1.97.3",
"typescript-strict-plugin": "^2.4.4",
"usehooks-ts": "^3.1.1",

View File

@@ -207,7 +207,6 @@ export function useMoveAccountMutation() {
type ImportPreviewTransactionsPayload = {
accountId: string;
transactions: TransactionEntity[];
reimportDeleted?: boolean;
};
export function useImportPreviewTransactionsMutation() {
@@ -219,7 +218,6 @@ export function useImportPreviewTransactionsMutation() {
mutationFn: async ({
accountId,
transactions,
reimportDeleted,
}: ImportPreviewTransactionsPayload) => {
const { errors = [], updatedPreview } = await send(
'transactions-import',
@@ -227,7 +225,6 @@ export function useImportPreviewTransactionsMutation() {
accountId,
transactions,
isPreview: true,
opts: reimportDeleted !== undefined ? { reimportDeleted } : undefined,
},
);
@@ -262,7 +259,6 @@ type ImportTransactionsPayload = {
accountId: string;
transactions: TransactionEntity[];
reconcile: boolean;
reimportDeleted?: boolean;
};
export function useImportTransactionsMutation() {
@@ -275,7 +271,6 @@ export function useImportTransactionsMutation() {
accountId,
transactions,
reconcile,
reimportDeleted,
}: ImportTransactionsPayload) => {
if (!reconcile) {
await send('api/transactions-add', {
@@ -294,7 +289,6 @@ export function useImportTransactionsMutation() {
accountId,
transactions,
isPreview: false,
opts: reimportDeleted !== undefined ? { reimportDeleted } : undefined,
});
errors.forEach(error => {

View File

@@ -153,27 +153,22 @@ function Notification({
const yOffset = index * Y_OFFSET_PER_LEVEL;
const [isSwiped, setIsSwiped] = useState(false);
const [spring, api] = useSpring(
() => ({
from: {
x: 0,
y: yOffset,
opacity: stackOpacity,
scale,
},
}),
[],
);
const [spring, api] = useSpring(() => ({
x: 0,
y: yOffset,
opacity: stackOpacity,
scale,
}));
// Update scale, opacity, and y-position when index changes
useEffect(() => {
void api.start({ to: { scale, opacity: stackOpacity, y: yOffset } });
void api.start({ scale, opacity: stackOpacity, y: yOffset });
}, [index, scale, stackOpacity, yOffset, api]);
const swipeHandlers = useSwipeable({
onSwiping: ({ deltaX }) => {
if (!isSwiped) {
void api.start({ to: { x: deltaX } });
void api.start({ x: deltaX });
}
},
onSwiped: ({ velocity, deltaX }) => {
@@ -184,13 +179,14 @@ function Notification({
if (Math.abs(deltaX) > threshold || velocity > 0.5) {
// Animate out & remove item after animation
void api.start({
to: { x: direction * 1000, opacity: 0 },
x: direction * 1000,
opacity: 0,
onRest: onRemove,
});
setIsSwiped(true);
} else {
// Reset position if not swiped far enough
void api.start({ to: { x: 0 } });
void api.start({ x: 0 });
}
},
trackMouse: true,

View File

@@ -33,6 +33,7 @@ export function ThemeSelector({ style }: ThemeSelectorProps) {
dark: SvgMoonStars,
auto: SvgSystem,
midnight: SvgMoonStars,
development: SvgMoonStars,
} as const;
type ThemeIconKey = keyof typeof themeIcons;

View File

@@ -24,13 +24,10 @@ export function BudgetSummaries() {
const [firstMonth] = months;
const [widthState, setWidthState] = useState(0);
const [styles, spring] = useSpring(
() => ({
from: { x: 0 },
config: { mass: 3, tension: 600, friction: 80 },
}),
[],
);
const [styles, spring] = useSpring(() => ({
x: 0,
config: { mass: 3, tension: 600, friction: 80 },
}));
const containerRef = useResizeObserver<HTMLDivElement>(
useCallback(rect => {
@@ -58,9 +55,7 @@ export function BudgetSummaries() {
}
const to = -offsetX;
if (from !== to) {
void spring.start({ from: { x: from }, to: { x: to } });
}
void spring.start({ from: { x: from }, x: to });
}, [spring, firstMonth, monthWidth, allMonths]);
useLayoutEffect(() => {
@@ -68,7 +63,7 @@ export function BudgetSummaries() {
}, [firstMonth]);
useLayoutEffect(() => {
void spring.start({ to: { x: -monthWidth }, immediate: true });
void spring.start({ from: { x: -monthWidth }, to: { x: -monthWidth } });
}, [spring, monthWidth]);
const { SummaryComponent } = useBudgetComponents();

View File

@@ -25,7 +25,6 @@ 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,
@@ -282,100 +281,85 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({
}}
>
{!editing && (
<>
<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,
}}
>
<Button
variant="bare"
onPress={() => {
resetBudgetPosition(2, -4);
setBudgetMenuOpen(true);
}}
style={{
paddingLeft: 3,
alignItems: 'center',
justifyContent: 'center',
borderTopWidth: 1,
borderBottomWidth: 1,
borderColor: theme.tableBorder,
color: theme.budgetNumberNeutral, //make sure button is visible when hovered
padding: 3,
}}
>
<NotesButton
id={`${category.id}-${month}`}
defaultColor={theme.pageTextLight}
<SvgCheveronDown
width={14}
height={14}
className="hover-visible"
/>
</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,
}}
>
<Button
variant="bare"
onPress={() => {
resetBudgetPosition(2, -4);
setBudgetMenuOpen(true);
}}
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;
}
</Button>
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>
</>
<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>
)}
<EnvelopeSheetCell
name="budget"

View File

@@ -15,7 +15,6 @@ 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';
@@ -26,7 +25,6 @@ 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,
@@ -263,96 +261,77 @@ export const CategoryMonth = memo(function CategoryMonth({
}}
>
{!editing && (
<>
<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,
}}
>
<Button
ref={triggerRef}
variant="bare"
onPress={() => setMenuOpen(true)}
style={{
paddingLeft: 3,
alignItems: 'center',
justifyContent: 'center',
borderTopWidth: 1,
borderBottomWidth: 1,
borderColor: theme.tableBorder,
color: theme.budgetNumberNeutral, //make sure button is visible when hovered
padding: 3,
}}
>
<NotesButton
id={`${category.id}-${month}`}
defaultColor={theme.pageTextLight}
<SvgCheveronDown
width={14}
height={14}
className="hover-visible"
/>
</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,
}}
</Button>
<Popover
triggerRef={triggerRef}
isOpen={menuOpen}
onOpenChange={() => setMenuOpen(false)}
placement="bottom start"
>
<Button
ref={triggerRef}
variant="bare"
onPress={() => setMenuOpen(true)}
style={{
padding: 3,
<BudgetMenu
onCopyLastMonthAverage={() => {
onMenuAction(month, 'copy-single-last', {
category: category.id,
});
showUndoNotification({
message: `Budget set to last month's budget.`,
});
}}
>
<SvgCheveronDown
width={14}
height={14}
className="hover-visible"
/>
</Button>
onSetMonthsAverage={numberOfMonths => {
if (
numberOfMonths !== 3 &&
numberOfMonths !== 6 &&
numberOfMonths !== 12
) {
return;
}
<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>
</>
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>
)}
<TrackingSheetCell
name="budget"

View File

@@ -188,10 +188,6 @@ const DATE_FUNCTIONS = new Set([
const QUERY_FUNCTIONS = new Set([
'QUERY',
'QUERY_COUNT',
'BUDGET_QUERY',
'QUERY_EXTRACT_CATEGORIES',
'QUERY_EXTRACT_TIMEFRAME_START',
'QUERY_EXTRACT_TIMEFRAME_END',
'LOOKUP',
'VLOOKUP',
'HLOOKUP',
@@ -407,15 +403,6 @@ const transactionFields: Completion[] = [
'Account balance as of the date of the transaction, excluding the transaction amount. Use for calculations and comparisons.\n\nExample: `=IF(balance < 0, "Negative Balance", "Positive Balance")`',
),
},
{
label: 'parent_amount',
type: 'variable',
section: '💰 Transaction Fields',
boost: 5,
info: t(
'The amount of the parent transaction in cents in split transactions.\n\nExample: `=(parent_amount / 100) * .05`',
),
},
];
// Convert function definitions to completions with grouping
@@ -498,50 +485,6 @@ export function excelFormulaAutocomplete(
apply: `QUERY_COUNT("${queryName}")`,
boost: 14,
},
{
label: `BUDGET_QUERY("budgeted", QUERY_EXTRACT_CATEGORIES("${queryName}"), QUERY_EXTRACT_TIMEFRAME_START("${queryName}"), QUERY_EXTRACT_TIMEFRAME_END("${queryName}"))`,
type: 'function',
section: '🔍 Query Functions',
info: t(
'Sum of budgeted amounts with extracted parameters from {{queryName}}.',
{ queryName },
),
apply: `BUDGET_QUERY("budgeted", QUERY_EXTRACT_CATEGORIES("${queryName}"), QUERY_EXTRACT_TIMEFRAME_START("${queryName}"), QUERY_EXTRACT_TIMEFRAME_END("${queryName}"))`,
boost: 13,
},
{
label: `BUDGET_QUERY("spent", QUERY_EXTRACT_CATEGORIES("${queryName}"), QUERY_EXTRACT_TIMEFRAME_START("${queryName}"), QUERY_EXTRACT_TIMEFRAME_END("${queryName}"))`,
type: 'function',
section: '🔍 Query Functions',
info: t(
'Sum of spending with extracted parameters from {{queryName}}.',
{ queryName },
),
apply: `BUDGET_QUERY("spent", QUERY_EXTRACT_CATEGORIES("${queryName}"), QUERY_EXTRACT_TIMEFRAME_START("${queryName}"), QUERY_EXTRACT_TIMEFRAME_END("${queryName}"))`,
boost: 13,
},
{
label: `BUDGET_QUERY("balance_start", QUERY_EXTRACT_CATEGORIES("${queryName}"), QUERY_EXTRACT_TIMEFRAME_START("${queryName}"), QUERY_EXTRACT_TIMEFRAME_END("${queryName}"))`,
type: 'function',
section: '🔍 Query Functions',
info: t(
'Opening balance with extracted parameters from {{queryName}}.',
{ queryName },
),
apply: `BUDGET_QUERY("balance_start", QUERY_EXTRACT_CATEGORIES("${queryName}"), QUERY_EXTRACT_TIMEFRAME_START("${queryName}"), QUERY_EXTRACT_TIMEFRAME_END("${queryName}"))`,
boost: 13,
},
{
label: `BUDGET_QUERY("balance_end", QUERY_EXTRACT_CATEGORIES("${queryName}"), QUERY_EXTRACT_TIMEFRAME_START("${queryName}"), QUERY_EXTRACT_TIMEFRAME_END("${queryName}"))`,
type: 'function',
section: '🔍 Query Functions',
info: t(
'Closing balance with extracted parameters from {{queryName}}.',
{ queryName },
),
apply: `BUDGET_QUERY("balance_end", QUERY_EXTRACT_CATEGORIES("${queryName}"), QUERY_EXTRACT_TIMEFRAME_START("${queryName}"), QUERY_EXTRACT_TIMEFRAME_END("${queryName}"))`,
boost: 13,
},
])
: [];

View File

@@ -116,54 +116,6 @@ export const queryModeFunctions: Record<string, FunctionDef> = {
{ name: 'decimals', description: 'Decimals' },
],
},
BUDGET_QUERY: {
name: 'BUDGET_QUERY',
description: t(
'Evaluate a budget query using extracted parameters. Supply dimension, categories, and timeframe explicitly.',
),
parameters: [
{
name: 'dimension',
description:
'One of: budgeted, spent, balance_start, balance_end, goal (string)',
},
{
name: 'categories',
description:
'Categories result from QUERY_EXTRACT_CATEGORIES() (array)',
},
{
name: 'timeframe_start',
description:
'Start month from QUERY_EXTRACT_TIMEFRAME_START() (string)',
},
{
name: 'timeframe_end',
description: 'End month from QUERY_EXTRACT_TIMEFRAME_END() (string)',
},
],
},
QUERY_EXTRACT_CATEGORIES: {
name: 'QUERY_EXTRACT_CATEGORIES',
description: t('Extract category IDs from a named query.'),
parameters: [
{ name: 'queryName', description: 'Name of the saved query (string)' },
],
},
QUERY_EXTRACT_TIMEFRAME_START: {
name: 'QUERY_EXTRACT_TIMEFRAME_START',
description: t('Extract the start month from a named query timeframe.'),
parameters: [
{ name: 'queryName', description: 'Name of the saved query (string)' },
],
},
QUERY_EXTRACT_TIMEFRAME_END: {
name: 'QUERY_EXTRACT_TIMEFRAME_END',
description: t('Extract the end month from a named query timeframe.'),
parameters: [
{ name: 'queryName', description: 'Name of the saved query (string)' },
],
},
FLOOR: {
name: 'FLOOR',
description: t('Rounds down to nearest multiple of significance.'),

View File

@@ -34,13 +34,10 @@ export function ActionableGridListItem<T extends object>({
const hasActions = !!actions;
// Spring animation for the swipe
const [{ x }, api] = useSpring(
() => ({
from: { x: 0 },
config: config.stiff,
}),
[],
);
const [{ x }, api] = useSpring(() => ({
x: 0,
config: config.stiff,
}));
// Handle drag gestures
const bind = useDrag(
@@ -51,7 +48,7 @@ export function ActionableGridListItem<T extends object>({
if (active) {
dragStartedRef.current = true;
void api.start({
to: { x: Math.max(-actionsWidth, Math.min(0, currentX)) },
x: Math.max(-actionsWidth, Math.min(0, currentX)),
onRest: () => {
dragStartedRef.current = false;
},
@@ -65,7 +62,7 @@ export function ActionableGridListItem<T extends object>({
(vx < -0.5 && currentX < -actionsWidth / 5);
void api.start({
to: { x: shouldReveal ? -actionsWidth : 0 },
x: shouldReveal ? -actionsWidth : 0,
onRest: () => {
dragStartedRef.current = false;
setIsRevealed(shouldReveal);
@@ -144,7 +141,7 @@ export function ActionableGridListItem<T extends object>({
? actions({
close: () => {
void api.start({
to: { x: 0 },
x: 0,
onRest: () => {
setIsRevealed(false);
},

View File

@@ -52,7 +52,7 @@ export function MobileNavTabs() {
maxWidth: `${100 / COLUMN_COUNT}%`,
};
const [{ y }, api] = useSpring(() => ({ from: { y: OPEN_DEFAULT_Y } }), []);
const [{ y }, api] = useSpring(() => ({ y: OPEN_DEFAULT_Y }));
const openFull = useCallback(
({ canceled }: { canceled?: boolean }) => {
@@ -60,7 +60,7 @@ export function MobileNavTabs() {
// so we change the spring config to create a nice wobbly effect
setNavbarState('open');
void api.start({
to: { y: OPEN_FULL_Y },
y: OPEN_FULL_Y,
immediate: isTestEnv,
config: canceled ? config.wobbly : config.stiff,
});
@@ -72,7 +72,7 @@ export function MobileNavTabs() {
(velocity = 0) => {
setNavbarState('default');
void api.start({
to: { y: OPEN_DEFAULT_Y },
y: OPEN_DEFAULT_Y,
immediate: isTestEnv,
config: { ...config.stiff, velocity },
});
@@ -84,7 +84,7 @@ export function MobileNavTabs() {
(velocity = 0) => {
setNavbarState('hidden');
void api.start({
to: { y: HIDDEN_Y },
y: HIDDEN_Y,
immediate: isTestEnv,
config: { ...config.stiff, velocity },
});
@@ -199,7 +199,7 @@ export function MobileNavTabs() {
} else {
// when the user keeps dragging, we just move the sheet according to
// the cursor position
void api.start({ to: { y: oy }, immediate: true });
void api.start({ y: oy, immediate: true });
}
},
{

View File

@@ -7,8 +7,6 @@ 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';
@@ -17,7 +15,6 @@ 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';
@@ -46,7 +43,6 @@ export function BudgetCell<
...props
}: BudgetCellProps<SheetFieldName>) {
const { t } = useTranslation();
const locale = useLocale();
const columnWidth = getColumnWidth();
const dispatch = useDispatch();
const format = useFormat();
@@ -54,31 +50,6 @@ 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;
@@ -89,7 +60,6 @@ export function BudgetCell<
options: {
categoryId: category.id,
month,
onEditNotes,
onUpdateBudget: amount => {
onBudgetAction(month, 'budget-amount', {
category: category.id,
@@ -144,7 +114,6 @@ export function BudgetCell<
month,
onBudgetAction,
showUndoNotification,
onEditNotes,
format,
]);

View File

@@ -2,17 +2,10 @@ 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';
@@ -26,16 +19,14 @@ 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 = Extract<
ModalType,
{ name: 'envelope-budget-menu' }
>['options'];
type EnvelopeBudgetMenuModalProps = Omit<
Extract<ModalType, { name: 'envelope-budget-menu' }>['options'],
'month'
>;
export function EnvelopeBudgetMenuModal({
categoryId,
@@ -43,17 +34,7 @@ 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,
@@ -67,24 +48,10 @@ 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.
@@ -109,6 +76,7 @@ export function EnvelopeBudgetMenuModal({
style={{
justifyContent: 'center',
alignItems: 'center',
marginBottom: 20,
}}
>
<Text
@@ -138,71 +106,12 @@ export function EnvelopeBudgetMenuModal({
data-testid="budget-amount"
/>
</View>
<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}
/>
)}
<BudgetMenu
getItemStyle={() => defaultMenuItemStyle}
onCopyLastMonthAverage={onCopyLastMonthAverage}
onSetMonthsAverage={onSetMonthsAverage}
onApplyBudgetTemplate={onApplyBudgetTemplate}
/>
</>
)}
</Modal>

View File

@@ -211,7 +211,6 @@ export function ImportTransactionsModal({
const [flipAmount, setFlipAmount] = useState(false);
const [multiplierEnabled, setMultiplierEnabled] = useState(false);
const [reconcile, setReconcile] = useState(true);
const [reimportDeleted, setReimportDeleted] = useState(false);
const [importNotes, setImportNotes] = useState(true);
// This cannot be set after parsing the file, because changing it
@@ -719,7 +718,6 @@ export function ImportTransactionsModal({
accountId,
transactions: finalTransactions,
reconcile,
reimportDeleted,
},
{
onSuccess: async didChange => {
@@ -768,7 +766,6 @@ export function ImportTransactionsModal({
{
accountId,
transactions: previewTransactionsToImport,
reimportDeleted,
},
{
onSuccess: previewTrx => {
@@ -835,7 +832,6 @@ export function ImportTransactionsModal({
startDate,
fieldMappings,
parseDateFormat,
reimportDeleted,
]);
const headers: ComponentProps<typeof TableHeader>['headers'] = [
@@ -1075,16 +1071,6 @@ export function ImportTransactionsModal({
</CheckboxToggle>
)}
{(isOfxFile(filetype) || isCamtFile(filetype)) && reconcile && (
<CheckboxToggle
id="form_reimport_deleted"
checked={reimportDeleted}
onChange={setReimportDeleted}
>
<Trans>Reimport deleted transactions</Trans>
</CheckboxToggle>
)}
{/*Import Options */}
{(filetype === 'qif' || filetype === 'csv') && (
<View style={{ marginTop: 10 }}>
@@ -1201,15 +1187,6 @@ export function ImportTransactionsModal({
>
<Trans>Merge with existing transactions</Trans>
</CheckboxToggle>
{reconcile && (
<CheckboxToggle
id="form_reimport_deleted_csv"
checked={reimportDeleted}
onChange={setReimportDeleted}
>
<Trans>Reimport deleted transactions</Trans>
</CheckboxToggle>
)}
</View>
)}

View File

@@ -2,17 +2,10 @@ 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';
@@ -26,16 +19,14 @@ 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 = Extract<
ModalType,
{ name: 'tracking-budget-menu' }
>['options'];
type TrackingBudgetMenuModalProps = Omit<
Extract<ModalType, { name: 'tracking-budget-menu' }>['options'],
'month'
>;
export function TrackingBudgetMenuModal({
categoryId,
@@ -43,8 +34,6 @@ export function TrackingBudgetMenuModal({
onCopyLastMonthAverage,
onSetMonthsAverage,
onApplyBudgetTemplate,
onEditNotes,
month,
}: TrackingBudgetMenuModalProps) {
const defaultMenuItemStyle: CSSProperties = {
...styles.mobileMenuItem,
@@ -53,38 +42,16 @@ 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.
@@ -109,6 +76,7 @@ export function TrackingBudgetMenuModal({
style={{
justifyContent: 'center',
alignItems: 'center',
marginBottom: 20,
}}
>
<Text
@@ -138,71 +106,12 @@ export function TrackingBudgetMenuModal({
data-testid="budget-amount"
/>
</View>
<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}
/>
)}
<BudgetMenu
getItemStyle={() => defaultMenuItemStyle}
onCopyLastMonthAverage={onCopyLastMonthAverage}
onSetMonthsAverage={onSetMonthsAverage}
onApplyBudgetTemplate={onApplyBudgetTemplate}
/>
</>
)}
</Modal>

Some files were not shown because too many files have changed in this diff Show More