mirror of
https://github.com/actualbudget/actual.git
synced 2026-05-05 22:52:20 -05:00
Compare commits
39 Commits
claude/api
...
worktree-c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88a8729071 | ||
|
|
85d601a707 | ||
|
|
f85627dcf6 | ||
|
|
695fd0e7e0 | ||
|
|
9682f6d8c9 | ||
|
|
4b940423ee | ||
|
|
9fb6876f3b | ||
|
|
210422f61a | ||
|
|
6941cc9e01 | ||
|
|
13abe0cb00 | ||
|
|
a4e401bc8b | ||
|
|
ff7f81ac06 | ||
|
|
5bf160463c | ||
|
|
598bf81da1 | ||
|
|
995670476e | ||
|
|
711939f71c | ||
|
|
8486dca33a | ||
|
|
359c1fe9ce | ||
|
|
7a4b43e7a4 | ||
|
|
94ea408303 | ||
|
|
dc8694cc3a | ||
|
|
89b442bc74 | ||
|
|
3ca0bcbc13 | ||
|
|
0e5e641aae | ||
|
|
8c47374b9d | ||
|
|
f8d5d38d0a | ||
|
|
023f34814c | ||
|
|
2aa2e49df9 | ||
|
|
c9f5f6deb2 | ||
|
|
e109d652b4 | ||
|
|
4878c0f333 | ||
|
|
18886fe166 | ||
|
|
081b43cf99 | ||
|
|
29827b9be8 | ||
|
|
b5b422f4c8 | ||
|
|
21408f1e66 | ||
|
|
c77b4cc220 | ||
|
|
eac26fa4ef | ||
|
|
d27db77164 |
@@ -39,6 +39,22 @@ async function getPRDetails() {
|
||||
console.log('- Base Branch:', pr.base.ref);
|
||||
console.log('- Head Branch:', pr.head.ref);
|
||||
|
||||
// Fetch all changed files to detect docs-only PRs
|
||||
const files = await octokit.paginate(octokit.rest.pulls.listFiles, {
|
||||
owner,
|
||||
repo: repoName,
|
||||
pull_number: issueNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const changedFiles = files.map(f => f.filename);
|
||||
const isDocsOnly =
|
||||
changedFiles.length > 0 &&
|
||||
changedFiles.every(file => file.startsWith('packages/docs/'));
|
||||
|
||||
console.log('- Changed Files:', changedFiles.length);
|
||||
console.log('- Is Docs Only:', isDocsOnly);
|
||||
|
||||
const result = {
|
||||
number: pr.number,
|
||||
author: pr.user.login,
|
||||
@@ -47,11 +63,31 @@ async function getPRDetails() {
|
||||
headBranch: pr.head.ref,
|
||||
};
|
||||
|
||||
let eligible = true;
|
||||
if (pr.base.ref !== 'master') {
|
||||
console.log(
|
||||
'PR does not target master branch, skipping release notes generation',
|
||||
);
|
||||
eligible = false;
|
||||
} else if (pr.head.ref.startsWith('release/')) {
|
||||
console.log(
|
||||
'PR head branch is a release branch, skipping release notes generation',
|
||||
);
|
||||
eligible = false;
|
||||
} else if (isDocsOnly) {
|
||||
console.log(
|
||||
'PR only changes documentation, skipping release notes generation',
|
||||
);
|
||||
eligible = false;
|
||||
}
|
||||
|
||||
setOutput('result', JSON.stringify(result));
|
||||
setOutput('eligible', JSON.stringify(eligible));
|
||||
} catch (error) {
|
||||
console.log('Error getting PR details:', error.message);
|
||||
console.log('Stack:', error.stack);
|
||||
setOutput('result', 'null');
|
||||
setOutput('eligible', 'false');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -60,5 +96,6 @@ getPRDetails().catch(error => {
|
||||
console.log('Unhandled error:', error.message);
|
||||
console.log('Stack:', error.stack);
|
||||
setOutput('result', 'null');
|
||||
setOutput('eligible', 'false');
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
1
.github/actions/docs-spelling/excludes.txt
vendored
1
.github/actions/docs-spelling/excludes.txt
vendored
@@ -68,7 +68,6 @@ ignore$
|
||||
^\Qsrc/\E$
|
||||
^\Qstatic/\E$
|
||||
^\Q.github/\E$
|
||||
(?:^|/)package(?:-lock|)\.json$
|
||||
(?:^|/)yarn\.lock$
|
||||
(?:^|/)(?i)docusaurus.config.js
|
||||
(?:^|/)(?i)README.md
|
||||
|
||||
14
.github/workflows/ai-generated-release-notes.yml
vendored
14
.github/workflows/ai-generated-release-notes.yml
vendored
@@ -42,11 +42,7 @@ jobs:
|
||||
GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
|
||||
- name: Check if release notes file already exists
|
||||
if: >-
|
||||
steps.check-first-comment.outputs.result == 'true' &&
|
||||
steps.pr-details.outputs.result != 'null' &&
|
||||
fromJSON(steps.pr-details.outputs.result).baseBranch == 'master' &&
|
||||
!startsWith(fromJSON(steps.pr-details.outputs.result).headBranch, 'release/')
|
||||
if: steps.pr-details.outputs.eligible == 'true'
|
||||
id: check-release-notes-exists
|
||||
run: node .github/actions/ai-generated-release-notes/check-release-notes-exists.js
|
||||
env:
|
||||
@@ -56,7 +52,7 @@ jobs:
|
||||
PR_DETAILS: ${{ steps.pr-details.outputs.result }}
|
||||
|
||||
- name: Generate summary with OpenAI
|
||||
if: steps.check-first-comment.outputs.result == 'true' && steps.check-release-notes-exists.outputs.result == 'false'
|
||||
if: steps.check-release-notes-exists.outputs.result == 'false'
|
||||
id: generate-summary
|
||||
run: node .github/actions/ai-generated-release-notes/generate-summary.js
|
||||
env:
|
||||
@@ -65,7 +61,7 @@ jobs:
|
||||
PR_DETAILS: ${{ steps.pr-details.outputs.result }}
|
||||
|
||||
- name: Determine category with OpenAI
|
||||
if: steps.check-first-comment.outputs.result == 'true' && steps.check-release-notes-exists.outputs.result == 'false' && steps.generate-summary.outputs.result != 'null'
|
||||
if: steps.generate-summary.outputs.result != 'null' && steps.generate-summary.outputs.result != ''
|
||||
id: determine-category
|
||||
run: node .github/actions/ai-generated-release-notes/determine-category.js
|
||||
env:
|
||||
@@ -75,7 +71,7 @@ jobs:
|
||||
SUMMARY_DATA: ${{ steps.generate-summary.outputs.result }}
|
||||
|
||||
- name: Create and commit release notes file via GitHub API
|
||||
if: steps.check-first-comment.outputs.result == 'true' && steps.check-release-notes-exists.outputs.result == 'false' && steps.generate-summary.outputs.result != 'null' && steps.determine-category.outputs.result != 'null' && steps.determine-category.outputs.result != ''
|
||||
if: steps.determine-category.outputs.result != 'null' && steps.determine-category.outputs.result != ''
|
||||
run: node .github/actions/ai-generated-release-notes/create-release-notes-file.js
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.ACTIONS_UPDATE_TOKEN }}
|
||||
@@ -85,7 +81,7 @@ jobs:
|
||||
CATEGORY: ${{ steps.determine-category.outputs.result }}
|
||||
|
||||
- name: Comment on PR
|
||||
if: steps.check-first-comment.outputs.result == 'true' && steps.check-release-notes-exists.outputs.result == 'false' && steps.generate-summary.outputs.result != 'null' && steps.determine-category.outputs.result != 'null' && steps.determine-category.outputs.result != ''
|
||||
if: steps.determine-category.outputs.result != 'null' && steps.determine-category.outputs.result != ''
|
||||
run: node .github/actions/ai-generated-release-notes/comment-on-pr.js
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
23
.github/workflows/build.yml
vendored
23
.github/workflows/build.yml
vendored
@@ -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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: api-build-stats
|
||||
path: api-stats.json
|
||||
@@ -56,11 +56,18 @@ jobs:
|
||||
run: cd packages/crdt && yarn build
|
||||
- name: Create package tgz
|
||||
run: cd packages/crdt && yarn pack && mv package.tgz actual-crdt.tgz
|
||||
- name: Prepare bundle stats artifact
|
||||
run: cp packages/crdt/dist/stats.json crdt-stats.json
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: actual-crdt
|
||||
path: packages/crdt/actual-crdt.tgz
|
||||
- name: Upload CRDT bundle stats
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: crdt-build-stats
|
||||
path: crdt-stats.json
|
||||
|
||||
web:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -71,12 +78,12 @@ jobs:
|
||||
- name: Build Web
|
||||
run: yarn build:browser
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: build-stats
|
||||
path: packages/desktop-client/build-stats
|
||||
@@ -96,12 +103,12 @@ jobs:
|
||||
- name: Prepare bundle stats artifact
|
||||
run: cp packages/cli/dist/stats.json cli-stats.json
|
||||
- name: Upload Build
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: actual-cli
|
||||
path: packages/cli/actual-cli.tgz
|
||||
- name: Upload CLI bundle stats
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: cli-build-stats
|
||||
path: cli-stats.json
|
||||
@@ -117,7 +124,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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: sync-server
|
||||
path: packages/sync-server/build
|
||||
|
||||
9
.github/workflows/check.yml
vendored
9
.github/workflows/check.yml
vendored
@@ -64,6 +64,15 @@ jobs:
|
||||
download-translations: 'false'
|
||||
- name: Test
|
||||
run: yarn test
|
||||
check-gh-actions:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
|
||||
|
||||
migrations:
|
||||
if: github.event_name == 'pull_request'
|
||||
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -25,11 +25,11 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
|
||||
uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
|
||||
with:
|
||||
languages: javascript
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
|
||||
uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
|
||||
with:
|
||||
category: '/language:javascript'
|
||||
|
||||
2
.github/workflows/cut-release-branch.yml
vendored
2
.github/workflows/cut-release-branch.yml
vendored
@@ -87,7 +87,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Create release branch and PR
|
||||
uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
token: ${{ secrets.ACTIONS_UPDATE_TOKEN }}
|
||||
commit-message: '🔖 (${{ steps.bump_package_versions.outputs.version }})'
|
||||
|
||||
8
.github/workflows/docker-edge.yml
vendored
8
.github/workflows/docker-edge.yml
vendored
@@ -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@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.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@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
|
||||
8
.github/workflows/docker-release.yml
vendored
8
.github/workflows/docker-release.yml
vendored
@@ -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@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.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@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.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@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
||||
18
.github/workflows/e2e-test.yml
vendored
18
.github/workflows/e2e-test.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4, 5]
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.58.2-jammy
|
||||
image: mcr.microsoft.com/playwright:v1.59.1-jammy
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up environment
|
||||
@@ -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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: desktop-client-test-results-shard-${{ matrix.shard }}
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
name: Functional Desktop App
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.58.2-jammy
|
||||
image: mcr.microsoft.com/playwright:v1.59.1-jammy
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up environment
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
run: |
|
||||
yarn rebuild-electron
|
||||
xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" -- yarn e2e:desktop
|
||||
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: desktop-app-test-results
|
||||
@@ -87,7 +87,7 @@ jobs:
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4, 5]
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.58.2-jammy
|
||||
image: mcr.microsoft.com/playwright:v1.59.1-jammy
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up environment
|
||||
@@ -96,7 +96,7 @@ jobs:
|
||||
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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: vrt-blob-report-${{ matrix.shard }}
|
||||
@@ -110,7 +110,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ !cancelled() }}
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.58.2-jammy
|
||||
image: mcr.microsoft.com/playwright:v1.59.1-jammy
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up environment
|
||||
@@ -124,7 +124,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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
id: playwright-report-vrt
|
||||
with:
|
||||
name: html-report--attempt-${{ github.run_attempt }}
|
||||
@@ -140,7 +140,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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: vrt-comment-metadata
|
||||
path: vrt-metadata/
|
||||
|
||||
2
.github/workflows/e2e-vrt-comment.yml
vendored
2
.github/workflows/e2e-vrt-comment.yml
vendored
@@ -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@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
||||
with:
|
||||
number: ${{ steps.metadata.outputs.pr_number }}
|
||||
header: vrt-comment
|
||||
|
||||
5
.github/workflows/electron-master.yml
vendored
5
.github/workflows/electron-master.yml
vendored
@@ -22,6 +22,7 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-22.04
|
||||
@@ -74,7 +75,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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: actual-electron-${{ matrix.os }}
|
||||
path: |
|
||||
@@ -85,7 +86,7 @@ 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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: actual-electron-${{ matrix.os }}-appx
|
||||
path: |
|
||||
|
||||
19
.github/workflows/electron-pr.yml
vendored
19
.github/workflows/electron-pr.yml
vendored
@@ -26,6 +26,7 @@ concurrency:
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-22.04
|
||||
@@ -65,56 +66,56 @@ jobs:
|
||||
run: ./bin/package-electron
|
||||
|
||||
- name: Upload Linux x64 AppImage
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: Actual-mac-arm64.dmg
|
||||
if-no-files-found: ignore
|
||||
@@ -122,7 +123,7 @@ jobs:
|
||||
|
||||
- name: Upload Windows Store Build
|
||||
if: ${{ startsWith(matrix.os, 'windows') }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: actual-electron-${{ matrix.os }}-appx
|
||||
path: |
|
||||
|
||||
2
.github/workflows/fork-pr-welcome.yml
vendored
2
.github/workflows/fork-pr-welcome.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
if: github.event.pull_request.head.repo.full_name != github.repository
|
||||
steps:
|
||||
- name: Post welcome comment
|
||||
uses: marocchino/sticky-pull-request-comment@70d2764d1a7d5d9560b100cbea0077fc8f633987 # v3.0.2
|
||||
uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
number: ${{ github.event.pull_request.number }}
|
||||
|
||||
2
.github/workflows/publish-flathub.yml
vendored
2
.github/workflows/publish-flathub.yml
vendored
@@ -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@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1
|
||||
with:
|
||||
token: ${{ secrets.FLATHUB_GITHUB_TOKEN }}
|
||||
commit-message: 'Update Actual flatpak to version ${{ steps.resolve_version.outputs.version }}'
|
||||
|
||||
17
.github/workflows/publish-nightly-electron.yml
vendored
17
.github/workflows/publish-nightly-electron.yml
vendored
@@ -20,6 +20,7 @@ concurrency:
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-22.04
|
||||
@@ -83,49 +84,49 @@ jobs:
|
||||
run: ./bin/package-electron
|
||||
|
||||
- name: Upload Linux x64 AppImage
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: Actual-mac-arm64.dmg
|
||||
if-no-files-found: ignore
|
||||
@@ -133,7 +134,7 @@ jobs:
|
||||
|
||||
- name: Upload Windows Store Build
|
||||
if: ${{ startsWith(matrix.os, 'windows') }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: actual-electron-${{ matrix.os }}-appx
|
||||
path: |
|
||||
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: npm-packages
|
||||
path: |
|
||||
|
||||
2
.github/workflows/publish-npm-packages.yml
vendored
2
.github/workflows/publish-npm-packages.yml
vendored
@@ -43,7 +43,7 @@ jobs:
|
||||
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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: npm-packages
|
||||
path: |
|
||||
|
||||
2
.github/workflows/release-notes.yml
vendored
2
.github/workflows/release-notes.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
steps:
|
||||
- name: Check if triggered by bot
|
||||
id: bot-check
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const { data: commit } = await github.rest.git.getCommit({
|
||||
|
||||
47
.github/workflows/size-compare.yml
vendored
47
.github/workflows/size-compare.yml
vendored
@@ -64,6 +64,13 @@ jobs:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
checkName: cli
|
||||
ref: ${{github.base_ref}}
|
||||
- name: Wait for ${{github.base_ref}} CRDT build to succeed
|
||||
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
|
||||
id: master-crdt-build
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
checkName: crdt
|
||||
ref: ${{github.base_ref}}
|
||||
|
||||
- name: Wait for PR build to succeed
|
||||
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
|
||||
@@ -86,15 +93,22 @@ jobs:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
checkName: cli
|
||||
ref: ${{github.event.pull_request.head.sha}}
|
||||
- name: Wait for CRDT PR build to succeed
|
||||
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
|
||||
id: wait-for-crdt-build
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
checkName: crdt
|
||||
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' || steps.wait-for-cli-build.outputs.conclusion == 'failure' || steps.wait-for-crdt-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@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
|
||||
id: pr-web-build
|
||||
with:
|
||||
branch: ${{github.base_ref}}
|
||||
@@ -103,7 +117,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@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
|
||||
id: pr-api-build
|
||||
with:
|
||||
branch: ${{github.base_ref}}
|
||||
@@ -112,7 +126,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@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
|
||||
with:
|
||||
pr: ${{github.event.pull_request.number}}
|
||||
workflow: build.yml
|
||||
@@ -121,7 +135,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@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
|
||||
with:
|
||||
pr: ${{github.event.pull_request.number}}
|
||||
workflow: build.yml
|
||||
@@ -130,7 +144,7 @@ jobs:
|
||||
path: head
|
||||
allow_forks: true
|
||||
- name: Download CLI build artifact from ${{github.base_ref}}
|
||||
uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18
|
||||
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
|
||||
with:
|
||||
branch: ${{github.base_ref}}
|
||||
workflow: build.yml
|
||||
@@ -138,7 +152,7 @@ jobs:
|
||||
name: cli-build-stats
|
||||
path: base
|
||||
- name: Download CLI stats from PR
|
||||
uses: dawidd6/action-download-artifact@1f8785ff7a5130826f848e7f72725c85d241860f # v18
|
||||
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
|
||||
with:
|
||||
pr: ${{github.event.pull_request.number}}
|
||||
workflow: build.yml
|
||||
@@ -146,6 +160,23 @@ jobs:
|
||||
name: cli-build-stats
|
||||
path: head
|
||||
allow_forks: true
|
||||
- name: Download CRDT build artifact from ${{github.base_ref}}
|
||||
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
|
||||
with:
|
||||
branch: ${{github.base_ref}}
|
||||
workflow: build.yml
|
||||
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
|
||||
name: crdt-build-stats
|
||||
path: base
|
||||
- name: Download CRDT stats from PR
|
||||
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
|
||||
with:
|
||||
pr: ${{github.event.pull_request.number}}
|
||||
workflow: build.yml
|
||||
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
|
||||
name: crdt-build-stats
|
||||
path: head
|
||||
allow_forks: true
|
||||
- name: Strip content hashes from stats files
|
||||
run: |
|
||||
if [ -f ./head/web-stats.json ]; then
|
||||
@@ -168,10 +199,12 @@ jobs:
|
||||
--base loot-core=./base/loot-core-stats.json \
|
||||
--base api=./base/api-stats.json \
|
||||
--base cli=./base/cli-stats.json \
|
||||
--base crdt=./base/crdt-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 \
|
||||
--head crdt=./head/crdt-stats.json \
|
||||
--identifier combined \
|
||||
--format pr-body > bundle-stats-comment.md
|
||||
- name: Post combined bundle stats comment
|
||||
|
||||
2
.github/workflows/vrt-update-apply.yml
vendored
2
.github/workflows/vrt-update-apply.yml
vendored
@@ -133,7 +133,7 @@ jobs:
|
||||
|
||||
- name: Comment on PR - Failure
|
||||
if: failure() && steps.metadata.outputs.pr_number != ''
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
APPLY_ERROR: ${{ steps.apply.outputs.error }}
|
||||
PR_NUMBER: ${{ steps.metadata.outputs.pr_number }}
|
||||
|
||||
10
.github/workflows/vrt-update-generate.yml
vendored
10
.github/workflows/vrt-update-generate.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Add 👀 reaction to comment
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
await github.rest.reactions.createForIssueComment({
|
||||
@@ -44,11 +44,11 @@ jobs:
|
||||
github.event.issue.pull_request &&
|
||||
startsWith(github.event.comment.body, '/update-vrt')
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.58.2-jammy
|
||||
image: mcr.microsoft.com/playwright:v1.59.1-jammy
|
||||
steps:
|
||||
- name: Get PR details
|
||||
id: pr
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
@@ -118,7 +118,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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: vrt-patch-${{ github.event.issue.number }}
|
||||
path: vrt-update.patch
|
||||
@@ -134,7 +134,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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: vrt-metadata-${{ github.event.issue.number }}
|
||||
path: pr-metadata/
|
||||
|
||||
@@ -336,11 +336,6 @@
|
||||
"group": ["**/*.api", "**/*.electron"],
|
||||
"message": "Don't directly reference imports from other platforms"
|
||||
},
|
||||
{
|
||||
"group": ["uuid"],
|
||||
"importNames": ["*"],
|
||||
"message": "Use `import { v4 as uuidv4 } from 'uuid'` instead"
|
||||
},
|
||||
{
|
||||
"group": ["**/style", "**/colors"],
|
||||
"importNames": ["colors"],
|
||||
@@ -421,6 +416,16 @@
|
||||
"rules": {
|
||||
"eslint/no-empty-function": "off"
|
||||
}
|
||||
},
|
||||
// crdt enforces the repo's "TODO: enable this" typescript rules as errors
|
||||
{
|
||||
"files": ["packages/crdt/**/*"],
|
||||
"rules": {
|
||||
"typescript/no-misused-spread": "error",
|
||||
"typescript/no-base-to-string": "error",
|
||||
"typescript/no-unsafe-unary-minus": "error",
|
||||
"typescript/no-unsafe-type-assertion": "error"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
942
.yarn/releases/yarn-4.10.3.cjs
vendored
942
.yarn/releases/yarn-4.10.3.cjs
vendored
File diff suppressed because one or more lines are too long
940
.yarn/releases/yarn-4.13.0.cjs
vendored
Executable file
940
.yarn/releases/yarn-4.13.0.cjs
vendored
Executable file
File diff suppressed because one or more lines are too long
@@ -6,4 +6,4 @@ enableTransparentWorkspaces: false
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.10.3.cjs
|
||||
yarnPath: .yarn/releases/yarn-4.13.0.cjs
|
||||
|
||||
@@ -16,6 +16,7 @@ packages/desktop-client/bin/remove-untranslated-languages
|
||||
|
||||
export NODE_OPTIONS="--max-old-space-size=4096"
|
||||
|
||||
yarn workspace @actual-app/crdt build
|
||||
yarn workspace plugins-service build
|
||||
yarn workspace @actual-app/core build:browser
|
||||
yarn workspace @actual-app/web build:browser
|
||||
|
||||
@@ -51,6 +51,7 @@ fi
|
||||
|
||||
export NODE_OPTIONS="--max-old-space-size=4096"
|
||||
|
||||
yarn workspace @actual-app/crdt build
|
||||
yarn workspace plugins-service build
|
||||
yarn workspace @actual-app/core build:node
|
||||
yarn workspace @actual-app/web build --mode=desktop # electron specific build
|
||||
|
||||
@@ -28,5 +28,5 @@ echo "Running VRT tests with the following parameters:"
|
||||
echo "E2E_START_URL: $E2E_START_URL"
|
||||
echo "VRT_ARGS: $VRT_ARGS"
|
||||
|
||||
MSYS_NO_PATHCONV=1 docker run --rm --network host -v "$(pwd)":/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.58.2-jammy /bin/bash \
|
||||
MSYS_NO_PATHCONV=1 docker run --rm --network host -v "$(pwd)":/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.59.1-jammy /bin/bash \
|
||||
-c "E2E_START_URL=$E2E_START_URL yarn vrt $VRT_ARGS"
|
||||
|
||||
22
package.json
22
package.json
@@ -65,19 +65,17 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@types/node": "^22.19.15",
|
||||
"@types/node": "^22.19.17",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260404.1",
|
||||
"@yarnpkg/types": "^4.0.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9.39.3",
|
||||
"eslint-plugin-perfectionist": "^5.6.0",
|
||||
"eslint": "^10.2.0",
|
||||
"eslint-plugin-perfectionist": "^5.8.0",
|
||||
"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.15.5",
|
||||
"lint-staged": "^16.4.0",
|
||||
"minimatch": "^10.2.5",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"oxfmt": "^0.44.0",
|
||||
"oxlint": "^1.59.0",
|
||||
@@ -85,8 +83,8 @@
|
||||
"p-limit": "^7.3.0",
|
||||
"prompts": "^2.4.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.0"
|
||||
"typescript": "^6.0.2",
|
||||
"vitest": "^4.1.2"
|
||||
},
|
||||
"resolutions": {
|
||||
"adm-zip": "patch:adm-zip@npm%3A0.5.16#~/.yarn/patches/adm-zip-npm-0.5.16-4556fea098.patch",
|
||||
@@ -118,5 +116,5 @@
|
||||
"node": ">=22",
|
||||
"yarn": "^4.9.1"
|
||||
},
|
||||
"packageManager": "yarn@4.10.3"
|
||||
"packageManager": "yarn@4.13.0"
|
||||
}
|
||||
|
||||
@@ -32,17 +32,16 @@
|
||||
"dependencies": {
|
||||
"@actual-app/core": "workspace:*",
|
||||
"@actual-app/crdt": "workspace:*",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"compare-versions": "^6.1.1",
|
||||
"uuid": "^13.0.0"
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"compare-versions": "^6.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
|
||||
"rollup-plugin-visualizer": "^6.0.11",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260404.1",
|
||||
"rollup-plugin-visualizer": "^7.0.1",
|
||||
"typescript-strict-plugin": "^2.4.4",
|
||||
"vite": "^8.0.5",
|
||||
"vite-plugin-peggy-loader": "^2.0.1",
|
||||
"vitest": "^4.1.0"
|
||||
"vitest": "^4.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
||||
@@ -19,5 +19,12 @@
|
||||
},
|
||||
"references": [{ "path": "../crdt" }, { "path": "../loot-core" }],
|
||||
"include": ["."],
|
||||
"exclude": ["**/node_modules/*", "dist", "@types", "*.test.ts", "*.config.ts"]
|
||||
"exclude": [
|
||||
"**/node_modules/*",
|
||||
"dist",
|
||||
"@types",
|
||||
"*.test.ts",
|
||||
"*.config.ts",
|
||||
"*.config.mts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260404.1",
|
||||
"extensionless": "^2.0.6",
|
||||
"gray-matter": "^4.0.3",
|
||||
"listify": "^1.0.3",
|
||||
"vitest": "^4.1.0"
|
||||
"vitest": "^4.1.2"
|
||||
},
|
||||
"extensionless": {
|
||||
"lookFor": [
|
||||
|
||||
@@ -43,13 +43,16 @@ Configuration is resolved in this order (highest priority first):
|
||||
|
||||
### 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 |
|
||||
| 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 |
|
||||
| `ACTUAL_CACHE_TTL` | Cache TTL in seconds (default: 60) |
|
||||
| `ACTUAL_LOCK_TIMEOUT` | Budget-dir lock wait timeout in seconds (default: 10) |
|
||||
| `ACTUAL_NO_LOCK` | Set to `1` to disable budget-dir locking |
|
||||
|
||||
### Config File
|
||||
|
||||
@@ -59,7 +62,10 @@ Create an `.actualrc.json` (or `.actualrc`, `.actualrc.yaml`, `actual.config.js`
|
||||
{
|
||||
"serverUrl": "http://localhost:5006",
|
||||
"password": "your-password",
|
||||
"syncId": "1cfdbb80-6274-49bf-b0c2-737235a4c81f"
|
||||
"syncId": "1cfdbb80-6274-49bf-b0c2-737235a4c81f",
|
||||
"cacheTtl": 60,
|
||||
"lockTimeout": 10,
|
||||
"noLock": false
|
||||
}
|
||||
```
|
||||
|
||||
@@ -74,6 +80,11 @@ Create an `.actualrc.json` (or `.actualrc`, `.actualrc.yaml`, `actual.config.js`
|
||||
| `--session-token <token>` | Session token |
|
||||
| `--sync-id <id>` | Budget Sync ID |
|
||||
| `--data-dir <path>` | Data directory |
|
||||
| `--cache-ttl <seconds>` | Cache TTL; `0` disables caching (default: 60) |
|
||||
| `--refresh` | Force a sync on this call, ignoring the cache |
|
||||
| `--no-cache` | Alias for `--refresh` |
|
||||
| `--lock-timeout <secs>` | Lock wait timeout (default: 10) |
|
||||
| `--no-lock` | Disable budget-dir locking (use with care) |
|
||||
| `--format <format>` | Output format: `json` (default), `table`, `csv` |
|
||||
| `--verbose` | Show informational messages |
|
||||
|
||||
@@ -92,6 +103,7 @@ Create an `.actualrc.json` (or `.actualrc`, `.actualrc.yaml`, `actual.config.js`
|
||||
| `schedules` | Manage scheduled transactions |
|
||||
| `query` | Run an ActualQL query |
|
||||
| `server` | Server utilities and lookups |
|
||||
| `sync` | Refresh or inspect local cache |
|
||||
|
||||
Run `actual <command> --help` for subcommands and options.
|
||||
|
||||
@@ -135,22 +147,32 @@ All monetary amounts are **integer cents** when passed as input (flags, JSON):
|
||||
|
||||
- **Split transactions:** When summing or counting transactions, filter `"is_parent": false` to avoid double-counting. A split parent holds the total amount, and its children hold the individual parts — including both would count the total twice.
|
||||
|
||||
- **Avoid rapid sequential requests:** Each CLI invocation opens a new server connection. Running queries in a tight loop (e.g. one per month) may trigger rate limiting or authentication failures. Instead, fetch all data in a single query with a date range filter and process locally:
|
||||
- **Rapid sequential requests:** The CLI caches the budget locally (see [Caching](#caching)), so read-heavy scripts no longer need a single-query workaround by default. For very chatty scripts, run `actual sync` once and then use a long `--cache-ttl` for reads:
|
||||
|
||||
```bash
|
||||
# Good: single query for the full year
|
||||
actual query run --table transactions \
|
||||
--filter '{"$and":[{"date":{"$gte":"2025-01-01"}},{"date":{"$lte":"2025-12-31"}}]}' \
|
||||
--limit 5000
|
||||
|
||||
# Bad: one query per month in a loop (may fail with auth errors)
|
||||
for month in 01 02 03 ...; do actual query run ...; done
|
||||
actual sync
|
||||
actual --cache-ttl 3600 query run ...
|
||||
actual --cache-ttl 3600 accounts list
|
||||
```
|
||||
|
||||
- **Uncategorized transactions:** `category.name` is `null` for transactions without a category. Account for this when filtering or grouping by category.
|
||||
|
||||
- **No date sub-fields in AQL:** `date.month`, `date.year`, etc. are not supported as query fields. To group by month, fetch raw transactions with a date range filter and aggregate locally in a script.
|
||||
|
||||
## Caching
|
||||
|
||||
The CLI keeps a local copy of your budget so repeated commands don't hit the sync server on every call. Within the TTL (default `60` seconds), read commands (`list`, `balance`, `query run`, …) reuse the cached budget without a network round-trip. Write commands (`add`, `update`, `set-amount`, …) always sync with the server before and after the write.
|
||||
|
||||
- `actual sync` — refresh the cache now.
|
||||
- `actual sync --status` — show how stale the local cache is.
|
||||
- `actual sync --clear` — delete the local cache; the next command re-downloads.
|
||||
- `--refresh` (or `--no-cache`) — force a sync on a single call.
|
||||
- `--cache-ttl <seconds>` — override the TTL for a single call (use `0` to disable caching).
|
||||
|
||||
### Concurrency
|
||||
|
||||
The CLI takes a shared lock for reads and an exclusive lock for writes on the per-budget cache directory. Many parallel reads are safe; writes serialize. If another CLI process is holding the lock, subsequent invocations wait up to `--lock-timeout` seconds (default `10`) before failing with an error. Pass `--no-lock` to opt out in trusted single-process setups.
|
||||
|
||||
## Running Locally (Development)
|
||||
|
||||
If you're working on the CLI within the monorepo:
|
||||
|
||||
@@ -12,10 +12,12 @@
|
||||
],
|
||||
"type": "module",
|
||||
"imports": {
|
||||
"#cache": "./src/cache.ts",
|
||||
"#commands/*": "./src/commands/*.ts",
|
||||
"#config": "./src/config.ts",
|
||||
"#connection": "./src/connection.ts",
|
||||
"#input": "./src/input.ts",
|
||||
"#lock": "./src/lock.ts",
|
||||
"#output": "./src/output.ts",
|
||||
"#utils": "./src/utils.ts"
|
||||
},
|
||||
@@ -27,15 +29,17 @@
|
||||
"dependencies": {
|
||||
"@actual-app/api": "workspace:*",
|
||||
"cli-table3": "^0.6.5",
|
||||
"commander": "^13.0.0",
|
||||
"cosmiconfig": "^9.0.0"
|
||||
"commander": "^14.0.3",
|
||||
"cosmiconfig": "^9.0.1",
|
||||
"proper-lockfile": "^4.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.19.15",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
|
||||
"rollup-plugin-visualizer": "^6.0.11",
|
||||
"@types/node": "^22.19.17",
|
||||
"@types/proper-lockfile": "^4",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260404.1",
|
||||
"rollup-plugin-visualizer": "^7.0.1",
|
||||
"vite": "^8.0.5",
|
||||
"vitest": "^4.1.0"
|
||||
"vitest": "^4.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
|
||||
206
packages/cli/src/cache.test.ts
Normal file
206
packages/cli/src/cache.test.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import {
|
||||
CACHE_FILE_NAME,
|
||||
decideSyncAction,
|
||||
readCacheState,
|
||||
writeCacheState,
|
||||
} from './cache';
|
||||
|
||||
describe('readCacheState', () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'actual-cli-cache-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns null when the file does not exist', () => {
|
||||
expect(readCacheState(dir)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the file is corrupt', () => {
|
||||
writeFileSync(join(dir, CACHE_FILE_NAME), 'not json');
|
||||
expect(readCacheState(dir)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the file has the wrong version', () => {
|
||||
writeFileSync(
|
||||
join(dir, CACHE_FILE_NAME),
|
||||
JSON.stringify({
|
||||
version: 999,
|
||||
syncId: 'a',
|
||||
budgetId: 'b',
|
||||
serverUrl: 'c',
|
||||
lastSyncedAt: 1,
|
||||
lastDownloadedAt: 1,
|
||||
}),
|
||||
);
|
||||
expect(readCacheState(dir)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the parsed state when the file is valid', () => {
|
||||
writeFileSync(
|
||||
join(dir, CACHE_FILE_NAME),
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
syncId: 'a',
|
||||
budgetId: 'b',
|
||||
serverUrl: 'c',
|
||||
lastSyncedAt: 1234,
|
||||
lastDownloadedAt: 5678,
|
||||
}),
|
||||
);
|
||||
expect(readCacheState(dir)).toEqual({
|
||||
version: 1,
|
||||
syncId: 'a',
|
||||
budgetId: 'b',
|
||||
serverUrl: 'c',
|
||||
lastSyncedAt: 1234,
|
||||
lastDownloadedAt: 5678,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeCacheState', () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'actual-cli-cache-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('writes the state to the cache file', () => {
|
||||
writeCacheState(dir, {
|
||||
version: 1,
|
||||
syncId: 'a',
|
||||
budgetId: 'b',
|
||||
serverUrl: 'c',
|
||||
lastSyncedAt: 1,
|
||||
lastDownloadedAt: 1,
|
||||
});
|
||||
const raw = readFileSync(join(dir, CACHE_FILE_NAME), 'utf-8');
|
||||
expect(JSON.parse(raw).syncId).toBe('a');
|
||||
});
|
||||
|
||||
it('is atomic: removes the tmp file after rename', () => {
|
||||
writeCacheState(dir, {
|
||||
version: 1,
|
||||
syncId: 'a',
|
||||
budgetId: 'b',
|
||||
serverUrl: 'c',
|
||||
lastSyncedAt: 1,
|
||||
lastDownloadedAt: 1,
|
||||
});
|
||||
expect(existsSync(join(dir, `${CACHE_FILE_NAME}.tmp`))).toBe(false);
|
||||
});
|
||||
|
||||
it('does not throw when the filesystem refuses the write', () => {
|
||||
// Force ENOTDIR by pointing writeCacheState at a path whose parent is a
|
||||
// regular file — no OS-specific pseudo-filesystem semantics needed.
|
||||
const file = join(dir, 'not-a-dir');
|
||||
writeFileSync(file, '');
|
||||
expect(() =>
|
||||
writeCacheState(join(file, 'nested'), {
|
||||
version: 1,
|
||||
syncId: 'a',
|
||||
budgetId: 'b',
|
||||
serverUrl: 'c',
|
||||
lastSyncedAt: 1,
|
||||
lastDownloadedAt: 1,
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('decideSyncAction', () => {
|
||||
const base = {
|
||||
state: {
|
||||
version: 1 as const,
|
||||
syncId: 'sync-1',
|
||||
budgetId: 'bud-1',
|
||||
serverUrl: 'http://s',
|
||||
lastSyncedAt: 1_000_000,
|
||||
lastDownloadedAt: 1_000_000,
|
||||
},
|
||||
config: { syncId: 'sync-1', serverUrl: 'http://s' },
|
||||
now: 1_000_000,
|
||||
ttlMs: 60_000,
|
||||
mutates: false,
|
||||
refresh: false,
|
||||
encrypted: false,
|
||||
};
|
||||
|
||||
it('returns "download" when state is null', () => {
|
||||
expect(decideSyncAction({ ...base, state: null }).action).toBe('download');
|
||||
});
|
||||
|
||||
it('returns "download" when syncId changed', () => {
|
||||
expect(
|
||||
decideSyncAction({
|
||||
...base,
|
||||
config: { ...base.config, syncId: 'other' },
|
||||
}).action,
|
||||
).toBe('download');
|
||||
});
|
||||
|
||||
it('returns "download" when serverUrl changed', () => {
|
||||
expect(
|
||||
decideSyncAction({
|
||||
...base,
|
||||
config: { ...base.config, serverUrl: 'http://other' },
|
||||
}).action,
|
||||
).toBe('download');
|
||||
});
|
||||
|
||||
it('returns "skip" for a read within the TTL', () => {
|
||||
expect(decideSyncAction({ ...base, now: 1_000_000 + 30_000 }).action).toBe(
|
||||
'skip',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns "sync" for a read past the TTL', () => {
|
||||
expect(decideSyncAction({ ...base, now: 1_000_000 + 61_000 }).action).toBe(
|
||||
'sync',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns "sync" for a write even when fresh', () => {
|
||||
expect(decideSyncAction({ ...base, mutates: true }).action).toBe('sync');
|
||||
});
|
||||
|
||||
it('returns "sync" when refresh is true', () => {
|
||||
expect(decideSyncAction({ ...base, refresh: true }).action).toBe('sync');
|
||||
});
|
||||
|
||||
it('returns "sync" when ttlMs is 0', () => {
|
||||
expect(decideSyncAction({ ...base, ttlMs: 0 }).action).toBe('sync');
|
||||
});
|
||||
|
||||
it('returns "sync" for encrypted budgets within the TTL', () => {
|
||||
expect(decideSyncAction({ ...base, encrypted: true }).action).toBe('sync');
|
||||
});
|
||||
|
||||
it('treats clock skew (negative age) as stale', () => {
|
||||
expect(decideSyncAction({ ...base, now: 999_999 }).action).toBe('sync');
|
||||
});
|
||||
|
||||
it('carries cached state on non-download actions', () => {
|
||||
const decision = decideSyncAction({ ...base, mutates: true });
|
||||
expect(decision).toEqual({ action: 'sync', state: base.state });
|
||||
});
|
||||
});
|
||||
102
packages/cli/src/cache.ts
Normal file
102
packages/cli/src/cache.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { isRecord } from './utils';
|
||||
|
||||
export const CACHE_FILE_NAME = 'state.json';
|
||||
export const CACHE_VERSION = 1;
|
||||
export const META_ROOT_DIR = '.actual-cli';
|
||||
|
||||
export type CacheState = {
|
||||
version: typeof CACHE_VERSION;
|
||||
syncId: string;
|
||||
budgetId: string;
|
||||
serverUrl: string;
|
||||
lastSyncedAt: number;
|
||||
lastDownloadedAt: number;
|
||||
};
|
||||
|
||||
export function getMetaDir(dataDir: string, syncId: string): string {
|
||||
return join(dataDir, META_ROOT_DIR, syncId);
|
||||
}
|
||||
|
||||
function cachePath(metaDir: string): string {
|
||||
return join(metaDir, CACHE_FILE_NAME);
|
||||
}
|
||||
|
||||
function isCacheState(value: unknown): value is CacheState {
|
||||
if (!isRecord(value)) return false;
|
||||
return (
|
||||
value.version === CACHE_VERSION &&
|
||||
typeof value.syncId === 'string' &&
|
||||
typeof value.budgetId === 'string' &&
|
||||
typeof value.serverUrl === 'string' &&
|
||||
typeof value.lastSyncedAt === 'number' &&
|
||||
typeof value.lastDownloadedAt === 'number'
|
||||
);
|
||||
}
|
||||
|
||||
export function readCacheState(metaDir: string): CacheState | null {
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(cachePath(metaDir), 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return isCacheState(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
export function writeCacheState(metaDir: string, state: CacheState): void {
|
||||
try {
|
||||
mkdirSync(metaDir, { recursive: true });
|
||||
const target = cachePath(metaDir);
|
||||
const tmp = `${target}.tmp`;
|
||||
writeFileSync(tmp, JSON.stringify(state));
|
||||
renameSync(tmp, target);
|
||||
} catch {
|
||||
// Cache persistence is best-effort. A read-only or unreachable dir must
|
||||
// not crash the CLI; the next invocation simply won't find a cache.
|
||||
}
|
||||
}
|
||||
|
||||
export type SyncDecision =
|
||||
| { action: 'download' }
|
||||
| { action: 'skip'; state: CacheState }
|
||||
| { action: 'sync'; state: CacheState };
|
||||
|
||||
export type DecideSyncArgs = {
|
||||
state: CacheState | null;
|
||||
config: { syncId: string; serverUrl: string };
|
||||
now: number;
|
||||
ttlMs: number;
|
||||
mutates: boolean;
|
||||
refresh: boolean;
|
||||
encrypted: boolean;
|
||||
};
|
||||
|
||||
export function decideSyncAction({
|
||||
state,
|
||||
config,
|
||||
now,
|
||||
ttlMs,
|
||||
mutates,
|
||||
refresh,
|
||||
encrypted,
|
||||
}: DecideSyncArgs): SyncDecision {
|
||||
if (state === null) return { action: 'download' };
|
||||
if (state.syncId !== config.syncId) return { action: 'download' };
|
||||
if (state.serverUrl !== config.serverUrl) return { action: 'download' };
|
||||
if (mutates || refresh || ttlMs === 0 || encrypted) {
|
||||
return { action: 'sync', state };
|
||||
}
|
||||
const age = now - state.lastSyncedAt;
|
||||
if (age < 0) return { action: 'sync', state };
|
||||
if (age < ttlMs) return { action: 'skip', state };
|
||||
return { action: 'sync', state };
|
||||
}
|
||||
@@ -14,26 +14,30 @@ export function registerAccountsCommand(program: Command) {
|
||||
.option('--include-closed', 'Include closed accounts', false)
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const allAccounts = await api.getAccounts();
|
||||
const accounts = allAccounts.filter(
|
||||
a => cmdOpts.includeClosed || !a.closed,
|
||||
);
|
||||
// Stable sort: on-budget first, off-budget second
|
||||
// (preserves API sort_order within each group)
|
||||
accounts.sort((a, b) => Number(a.offbudget) - Number(b.offbudget));
|
||||
const balances = await Promise.all(
|
||||
accounts.map(a => api.getAccountBalance(a.id)),
|
||||
);
|
||||
const output = accounts.map((a, i) => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
offbudget: a.offbudget,
|
||||
closed: a.closed,
|
||||
balance: balances[i],
|
||||
}));
|
||||
printOutput(output, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const allAccounts = await api.getAccounts();
|
||||
const accounts = allAccounts.filter(
|
||||
a => cmdOpts.includeClosed || !a.closed,
|
||||
);
|
||||
// Stable sort: on-budget first, off-budget second
|
||||
// (preserves API sort_order within each group)
|
||||
accounts.sort((a, b) => Number(a.offbudget) - Number(b.offbudget));
|
||||
const balances = await Promise.all(
|
||||
accounts.map(a => api.getAccountBalance(a.id)),
|
||||
);
|
||||
const output = accounts.map((a, i) => ({
|
||||
id: a.id,
|
||||
name: a.name,
|
||||
offbudget: a.offbudget,
|
||||
closed: a.closed,
|
||||
balance: balances[i],
|
||||
}));
|
||||
printOutput(output, opts.format);
|
||||
},
|
||||
{ mutates: false },
|
||||
);
|
||||
});
|
||||
|
||||
accounts
|
||||
@@ -49,13 +53,17 @@ export function registerAccountsCommand(program: Command) {
|
||||
.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);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const id = await api.createAccount(
|
||||
{ name: cmdOpts.name, offbudget: cmdOpts.offbudget },
|
||||
balance,
|
||||
);
|
||||
printOutput({ id }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
|
||||
accounts
|
||||
@@ -81,10 +89,14 @@ export function registerAccountsCommand(program: Command) {
|
||||
'No update fields provided. Use --name or --offbudget.',
|
||||
);
|
||||
}
|
||||
await withConnection(opts, async () => {
|
||||
await api.updateAccount(id, fields);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
await api.updateAccount(id, fields);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
|
||||
accounts
|
||||
@@ -100,14 +112,18 @@ export function registerAccountsCommand(program: Command) {
|
||||
)
|
||||
.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);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
await api.closeAccount(
|
||||
id,
|
||||
cmdOpts.transferAccount,
|
||||
cmdOpts.transferCategory,
|
||||
);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
|
||||
accounts
|
||||
@@ -115,10 +131,14 @@ export function registerAccountsCommand(program: Command) {
|
||||
.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);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
await api.reopenAccount(id);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
|
||||
accounts
|
||||
@@ -126,10 +146,14 @@ export function registerAccountsCommand(program: Command) {
|
||||
.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);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
await api.deleteAccount(id);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
|
||||
accounts
|
||||
@@ -148,9 +172,13 @@ export function registerAccountsCommand(program: Command) {
|
||||
cutoff = cutoffDate;
|
||||
}
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const balance = await api.getAccountBalance(id, cutoff);
|
||||
printOutput({ id, balance }, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const balance = await api.getAccountBalance(id, cutoff);
|
||||
printOutput({ id, balance }, opts.format);
|
||||
},
|
||||
{ mutates: false },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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';
|
||||
@@ -20,7 +19,7 @@ export function registerBudgetsCommand(program: Command) {
|
||||
const result = await api.getBudgets();
|
||||
printOutput(result, opts.format);
|
||||
},
|
||||
{ loadBudget: false },
|
||||
{ mutates: false, skipBudget: true },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -30,40 +29,33 @@ export function registerBudgetsCommand(program: Command) {
|
||||
.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 () => {
|
||||
async config => {
|
||||
const password =
|
||||
config.encryptionPassword ?? cmdOpts.encryptionPassword;
|
||||
await api.downloadBudget(syncId, {
|
||||
password,
|
||||
});
|
||||
printOutput({ success: true, syncId }, opts.format);
|
||||
},
|
||||
{ loadBudget: false },
|
||||
{ mutates: false, skipBudget: true },
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const result = await api.getBudgetMonths();
|
||||
printOutput(result, opts.format);
|
||||
},
|
||||
{ mutates: false },
|
||||
);
|
||||
});
|
||||
|
||||
budgets
|
||||
@@ -71,10 +63,14 @@ export function registerBudgetsCommand(program: Command) {
|
||||
.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);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const result = await api.getBudgetMonth(month);
|
||||
printOutput(result, opts.format);
|
||||
},
|
||||
{ mutates: false },
|
||||
);
|
||||
});
|
||||
|
||||
budgets
|
||||
@@ -89,10 +85,14 @@ export function registerBudgetsCommand(program: Command) {
|
||||
.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);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
await api.setBudgetAmount(cmdOpts.month, cmdOpts.category, amount);
|
||||
printOutput({ success: true }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
|
||||
budgets
|
||||
@@ -104,10 +104,14 @@ export function registerBudgetsCommand(program: Command) {
|
||||
.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);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
await api.setBudgetCarryover(cmdOpts.month, cmdOpts.category, flag);
|
||||
printOutput({ success: true }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
|
||||
budgets
|
||||
@@ -121,10 +125,14 @@ export function registerBudgetsCommand(program: Command) {
|
||||
.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);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
await api.holdBudgetForNextMonth(cmdOpts.month, parsedAmount);
|
||||
printOutput({ success: true }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
|
||||
budgets
|
||||
@@ -133,9 +141,13 @@ export function registerBudgetsCommand(program: Command) {
|
||||
.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);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
await api.resetBudgetHold(cmdOpts.month);
|
||||
printOutput({ success: true }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,10 +15,14 @@ export function registerCategoriesCommand(program: Command) {
|
||||
.description('List all categories')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const result = await api.getCategories();
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const result = await api.getCategories();
|
||||
printOutput(result, opts.format);
|
||||
},
|
||||
{ mutates: false },
|
||||
);
|
||||
});
|
||||
|
||||
categories
|
||||
@@ -29,15 +33,19 @@ export function registerCategoriesCommand(program: Command) {
|
||||
.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);
|
||||
});
|
||||
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);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
|
||||
categories
|
||||
@@ -55,10 +63,14 @@ export function registerCategoriesCommand(program: Command) {
|
||||
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);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
await api.updateCategory(id, fields);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
|
||||
categories
|
||||
@@ -67,9 +79,13 @@ export function registerCategoriesCommand(program: Command) {
|
||||
.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);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
await api.deleteCategory(id, cmdOpts.transferTo);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,10 +15,14 @@ export function registerCategoryGroupsCommand(program: Command) {
|
||||
.description('List all category groups')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const result = await api.getCategoryGroups();
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const result = await api.getCategoryGroups();
|
||||
printOutput(result, opts.format);
|
||||
},
|
||||
{ mutates: false },
|
||||
);
|
||||
});
|
||||
|
||||
groups
|
||||
@@ -28,14 +32,18 @@ export function registerCategoryGroupsCommand(program: Command) {
|
||||
.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);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const id = await api.createCategoryGroup({
|
||||
name: cmdOpts.name,
|
||||
is_income: cmdOpts.isIncome,
|
||||
hidden: false,
|
||||
});
|
||||
printOutput({ id }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
|
||||
groups
|
||||
@@ -53,10 +61,14 @@ export function registerCategoryGroupsCommand(program: Command) {
|
||||
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);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
await api.updateCategoryGroup(id, fields);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
|
||||
groups
|
||||
@@ -65,9 +77,13 @@ export function registerCategoryGroupsCommand(program: Command) {
|
||||
.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);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
await api.deleteCategoryGroup(id, cmdOpts.transferTo);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,10 +12,14 @@ export function registerPayeesCommand(program: Command) {
|
||||
.description('List all payees')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const result = await api.getPayees();
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const result = await api.getPayees();
|
||||
printOutput(result, opts.format);
|
||||
},
|
||||
{ mutates: false },
|
||||
);
|
||||
});
|
||||
|
||||
payees
|
||||
@@ -23,10 +27,14 @@ export function registerPayeesCommand(program: Command) {
|
||||
.description('List frequently used payees')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const result = await api.getCommonPayees();
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const result = await api.getCommonPayees();
|
||||
printOutput(result, opts.format);
|
||||
},
|
||||
{ mutates: false },
|
||||
);
|
||||
});
|
||||
|
||||
payees
|
||||
@@ -35,10 +43,14 @@ export function registerPayeesCommand(program: Command) {
|
||||
.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);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const id = await api.createPayee({ name: cmdOpts.name });
|
||||
printOutput({ id }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
|
||||
payees
|
||||
@@ -54,10 +66,14 @@ export function registerPayeesCommand(program: Command) {
|
||||
);
|
||||
}
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.updatePayee(id, fields);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
await api.updatePayee(id, fields);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
|
||||
payees
|
||||
@@ -65,10 +81,14 @@ export function registerPayeesCommand(program: Command) {
|
||||
.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);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
await api.deletePayee(id);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
|
||||
payees
|
||||
@@ -87,9 +107,13 @@ export function registerPayeesCommand(program: Command) {
|
||||
);
|
||||
}
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.mergePayees(cmdOpts.target, mergeIds);
|
||||
printOutput({ success: true }, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
await api.mergePayees(cmdOpts.target, mergeIds);
|
||||
printOutput({ success: true }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -301,27 +301,31 @@ export function registerQueryCommand(program: Command) {
|
||||
.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);
|
||||
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);
|
||||
const result = await api.aqlQuery(queryObj);
|
||||
|
||||
if (!isRecord(result) || !('data' in result)) {
|
||||
throw new Error('Query result missing data');
|
||||
}
|
||||
if (!isRecord(result) || !('data' in result)) {
|
||||
throw new Error('Query result missing data');
|
||||
}
|
||||
|
||||
if (cmdOpts.count) {
|
||||
printOutput({ count: result.data }, opts.format);
|
||||
} else {
|
||||
printOutput(result.data, opts.format);
|
||||
}
|
||||
});
|
||||
if (cmdOpts.count) {
|
||||
printOutput({ count: result.data }, opts.format);
|
||||
} else {
|
||||
printOutput(result.data, opts.format);
|
||||
}
|
||||
},
|
||||
{ mutates: false },
|
||||
);
|
||||
});
|
||||
|
||||
query
|
||||
|
||||
@@ -15,10 +15,14 @@ export function registerRulesCommand(program: Command) {
|
||||
.description('List all rules')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const result = await api.getRules();
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const result = await api.getRules();
|
||||
printOutput(result, opts.format);
|
||||
},
|
||||
{ mutates: false },
|
||||
);
|
||||
});
|
||||
|
||||
rules
|
||||
@@ -26,10 +30,14 @@ export function registerRulesCommand(program: Command) {
|
||||
.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);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const result = await api.getPayeeRules(payeeId);
|
||||
printOutput(result, opts.format);
|
||||
},
|
||||
{ mutates: false },
|
||||
);
|
||||
});
|
||||
|
||||
rules
|
||||
@@ -39,13 +47,17 @@ export function registerRulesCommand(program: Command) {
|
||||
.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);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const rule = readJsonInput(cmdOpts) as Parameters<
|
||||
typeof api.createRule
|
||||
>[0];
|
||||
const id = await api.createRule(rule);
|
||||
printOutput({ id }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
|
||||
rules
|
||||
@@ -55,13 +67,17 @@ export function registerRulesCommand(program: Command) {
|
||||
.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);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const rule = readJsonInput(cmdOpts) as Parameters<
|
||||
typeof api.updateRule
|
||||
>[0];
|
||||
await api.updateRule(rule);
|
||||
printOutput({ success: true }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
|
||||
rules
|
||||
@@ -69,9 +85,13 @@ export function registerRulesCommand(program: Command) {
|
||||
.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);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
await api.deleteRule(id);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,10 +15,14 @@ export function registerSchedulesCommand(program: Command) {
|
||||
.description('List all schedules')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const result = await api.getSchedules();
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const result = await api.getSchedules();
|
||||
printOutput(result, opts.format);
|
||||
},
|
||||
{ mutates: false },
|
||||
);
|
||||
});
|
||||
|
||||
schedules
|
||||
@@ -28,13 +32,17 @@ export function registerSchedulesCommand(program: Command) {
|
||||
.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);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const schedule = readJsonInput(cmdOpts) as Parameters<
|
||||
typeof api.createSchedule
|
||||
>[0];
|
||||
const id = await api.createSchedule(schedule);
|
||||
printOutput({ id }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
|
||||
schedules
|
||||
@@ -45,13 +53,17 @@ export function registerSchedulesCommand(program: Command) {
|
||||
.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);
|
||||
});
|
||||
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);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
|
||||
schedules
|
||||
@@ -59,9 +71,13 @@ export function registerSchedulesCommand(program: Command) {
|
||||
.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);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
await api.deleteSchedule(id);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export function registerServerCommand(program: Command) {
|
||||
const version = await api.getServerVersion();
|
||||
printOutput({ version }, opts.format);
|
||||
},
|
||||
{ loadBudget: false },
|
||||
{ mutates: false, skipBudget: true },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -34,13 +34,17 @@ export function registerServerCommand(program: Command) {
|
||||
.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,
|
||||
);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const id = await api.getIDByName(cmdOpts.type, cmdOpts.name);
|
||||
printOutput(
|
||||
{ id, type: cmdOpts.type, name: cmdOpts.name },
|
||||
opts.format,
|
||||
);
|
||||
},
|
||||
{ mutates: false },
|
||||
);
|
||||
});
|
||||
|
||||
server
|
||||
@@ -49,12 +53,16 @@ export function registerServerCommand(program: Command) {
|
||||
.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);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const args = cmdOpts.account
|
||||
? { accountId: cmdOpts.account }
|
||||
: undefined;
|
||||
await api.runBankSync(args);
|
||||
printOutput({ success: true }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
124
packages/cli/src/commands/sync.test.ts
Normal file
124
packages/cli/src/commands/sync.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { Command } from 'commander';
|
||||
|
||||
import { CACHE_FILE_NAME, getMetaDir, writeCacheState } from '#cache';
|
||||
import { resolveConfig } from '#config';
|
||||
|
||||
import { registerSyncCommand } from './sync';
|
||||
|
||||
vi.mock('@actual-app/api', () => ({
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
downloadBudget: vi.fn().mockResolvedValue(undefined),
|
||||
loadBudget: vi.fn().mockResolvedValue(undefined),
|
||||
sync: vi.fn().mockResolvedValue(undefined),
|
||||
shutdown: vi.fn().mockResolvedValue(undefined),
|
||||
getBudgets: vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: 'bud-disk-1', groupId: 'sync-1' }]),
|
||||
}));
|
||||
|
||||
vi.mock('#config', () => ({
|
||||
resolveConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
let dataDir: string;
|
||||
|
||||
function metaDirFor(syncId: string) {
|
||||
return getMetaDir(dataDir, syncId);
|
||||
}
|
||||
|
||||
function program() {
|
||||
const p = new Command();
|
||||
p.exitOverride();
|
||||
p.option('--sync-id <id>');
|
||||
p.option('--data-dir <path>');
|
||||
p.option('--format <fmt>');
|
||||
p.option('--verbose');
|
||||
registerSyncCommand(p);
|
||||
return p;
|
||||
}
|
||||
|
||||
describe('actual sync', () => {
|
||||
let stdoutSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'actual-cli-sync-'));
|
||||
vi.mocked(resolveConfig).mockResolvedValue({
|
||||
serverUrl: 'http://test',
|
||||
password: 'pw',
|
||||
dataDir,
|
||||
syncId: 'sync-1',
|
||||
cacheTtl: 60,
|
||||
lockTimeout: 10,
|
||||
refresh: false,
|
||||
noLock: true,
|
||||
});
|
||||
stdoutSpy = vi
|
||||
.spyOn(process.stdout, 'write')
|
||||
.mockImplementation(() => true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stdoutSpy.mockRestore();
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('runs a sync and prints the syncId', async () => {
|
||||
writeCacheState(metaDirFor('sync-1'), {
|
||||
version: 1,
|
||||
syncId: 'sync-1',
|
||||
budgetId: 'bud-disk-1',
|
||||
serverUrl: 'http://test',
|
||||
lastSyncedAt: 0,
|
||||
lastDownloadedAt: 0,
|
||||
});
|
||||
await program().parseAsync(['node', 'actual', 'sync']);
|
||||
const out = stdoutSpy.mock.calls
|
||||
.map((c: unknown[]) => String(c[0]))
|
||||
.join('');
|
||||
expect(out).toMatch(/"syncId":\s*"sync-1"/);
|
||||
});
|
||||
|
||||
it('--status prints cache info without syncing', async () => {
|
||||
writeCacheState(metaDirFor('sync-1'), {
|
||||
version: 1,
|
||||
syncId: 'sync-1',
|
||||
budgetId: 'bud-disk-1',
|
||||
serverUrl: 'http://test',
|
||||
lastSyncedAt: Date.now() - 5000,
|
||||
lastDownloadedAt: Date.now() - 5000,
|
||||
});
|
||||
await program().parseAsync(['node', 'actual', 'sync', '--status']);
|
||||
const out = stdoutSpy.mock.calls
|
||||
.map((c: unknown[]) => String(c[0]))
|
||||
.join('');
|
||||
expect(out).toMatch(/"stale":\s*(true|false)/);
|
||||
expect(out).toMatch(/"ageSeconds":\s*\d+/);
|
||||
});
|
||||
|
||||
it('--status on no prior sync reports "never synced" and exits 0', async () => {
|
||||
await program().parseAsync(['node', 'actual', 'sync', '--status']);
|
||||
const out = stdoutSpy.mock.calls
|
||||
.map((c: unknown[]) => String(c[0]))
|
||||
.join('');
|
||||
expect(out).toMatch(/"neverSynced":\s*true/);
|
||||
});
|
||||
|
||||
it('--clear removes the cache file', async () => {
|
||||
writeCacheState(metaDirFor('sync-1'), {
|
||||
version: 1,
|
||||
syncId: 'sync-1',
|
||||
budgetId: 'bud-disk-1',
|
||||
serverUrl: 'http://test',
|
||||
lastSyncedAt: Date.now(),
|
||||
lastDownloadedAt: Date.now(),
|
||||
});
|
||||
expect(existsSync(join(metaDirFor('sync-1'), CACHE_FILE_NAME))).toBe(true);
|
||||
await program().parseAsync(['node', 'actual', 'sync', '--clear']);
|
||||
expect(existsSync(join(metaDirFor('sync-1'), CACHE_FILE_NAME))).toBe(false);
|
||||
});
|
||||
});
|
||||
118
packages/cli/src/commands/sync.ts
Normal file
118
packages/cli/src/commands/sync.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { CACHE_FILE_NAME, getMetaDir, readCacheState } from '#cache';
|
||||
import type { CliConfig } from '#config';
|
||||
import { resolveConfig } from '#config';
|
||||
import { withConnection } from '#connection';
|
||||
import { acquireExclusive } from '#lock';
|
||||
import { printOutput } from '#output';
|
||||
|
||||
type SyncCmdOpts = {
|
||||
status?: boolean;
|
||||
clear?: boolean;
|
||||
};
|
||||
|
||||
async function requireSyncIdAndMeta(
|
||||
opts: Record<string, unknown>,
|
||||
flag: string,
|
||||
): Promise<{ config: CliConfig; meta: string }> {
|
||||
const config = await resolveConfig(opts);
|
||||
if (!config.syncId) {
|
||||
throw new Error(
|
||||
`Sync ID is required for sync ${flag}. Set --sync-id or ACTUAL_SYNC_ID.`,
|
||||
);
|
||||
}
|
||||
return { config, meta: getMetaDir(config.dataDir, config.syncId) };
|
||||
}
|
||||
|
||||
export function registerSyncCommand(program: Command) {
|
||||
program
|
||||
.command('sync')
|
||||
.description(
|
||||
'Sync the local cached budget with the server, print cache status, or clear the cache',
|
||||
)
|
||||
.option('--status', 'Print cache status without syncing', false)
|
||||
.option(
|
||||
'--clear',
|
||||
'Delete the local cache; next command re-downloads',
|
||||
false,
|
||||
)
|
||||
.action(async (cmdOpts: SyncCmdOpts) => {
|
||||
const opts = program.opts();
|
||||
|
||||
if (cmdOpts.status) {
|
||||
const { config, meta } = await requireSyncIdAndMeta(opts, '--status');
|
||||
const state = readCacheState(meta);
|
||||
if (state === null) {
|
||||
printOutput(
|
||||
{
|
||||
neverSynced: true,
|
||||
syncId: config.syncId,
|
||||
ttlSeconds: config.cacheTtl,
|
||||
},
|
||||
opts.format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const ageSeconds = Math.max(
|
||||
0,
|
||||
Math.round((Date.now() - state.lastSyncedAt) / 1000),
|
||||
);
|
||||
printOutput(
|
||||
{
|
||||
neverSynced: false,
|
||||
syncId: state.syncId,
|
||||
budgetId: state.budgetId,
|
||||
syncedAt: new Date(state.lastSyncedAt).toISOString(),
|
||||
lastDownloadedAt: new Date(state.lastDownloadedAt).toISOString(),
|
||||
ageSeconds,
|
||||
ttlSeconds: config.cacheTtl,
|
||||
stale: ageSeconds > config.cacheTtl,
|
||||
},
|
||||
opts.format,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmdOpts.clear) {
|
||||
const { config, meta } = await requireSyncIdAndMeta(opts, '--clear');
|
||||
// Serialize with concurrent writers so we don't rm a half-written
|
||||
// state.json that's about to be renamed into place.
|
||||
const release = config.noLock
|
||||
? null
|
||||
: await acquireExclusive(meta, {
|
||||
timeoutMs: config.lockTimeout * 1000,
|
||||
});
|
||||
try {
|
||||
rmSync(join(meta, CACHE_FILE_NAME), { force: true });
|
||||
} finally {
|
||||
await release?.();
|
||||
}
|
||||
printOutput({ cleared: true, syncId: config.syncId }, opts.format);
|
||||
return;
|
||||
}
|
||||
|
||||
await withConnection(
|
||||
opts,
|
||||
async config => {
|
||||
const state = config.syncId
|
||||
? readCacheState(getMetaDir(config.dataDir, config.syncId))
|
||||
: null;
|
||||
printOutput(
|
||||
{
|
||||
syncedAt: new Date(
|
||||
state?.lastSyncedAt ?? Date.now(),
|
||||
).toISOString(),
|
||||
syncId: config.syncId,
|
||||
budgetId: state?.budgetId ?? config.syncId,
|
||||
},
|
||||
opts.format,
|
||||
);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -12,10 +12,14 @@ export function registerTagsCommand(program: Command) {
|
||||
.description('List all tags')
|
||||
.action(async () => {
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
const result = await api.getTags();
|
||||
printOutput(result, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const result = await api.getTags();
|
||||
printOutput(result, opts.format);
|
||||
},
|
||||
{ mutates: false },
|
||||
);
|
||||
});
|
||||
|
||||
tags
|
||||
@@ -26,14 +30,18 @@ export function registerTagsCommand(program: Command) {
|
||||
.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);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const id = await api.createTag({
|
||||
tag: cmdOpts.tag,
|
||||
color: cmdOpts.color,
|
||||
description: cmdOpts.description,
|
||||
});
|
||||
printOutput({ id }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
|
||||
tags
|
||||
@@ -55,10 +63,14 @@ export function registerTagsCommand(program: Command) {
|
||||
);
|
||||
}
|
||||
const opts = program.opts();
|
||||
await withConnection(opts, async () => {
|
||||
await api.updateTag(id, fields);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
await api.updateTag(id, fields);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
|
||||
tags
|
||||
@@ -66,9 +78,13 @@ export function registerTagsCommand(program: Command) {
|
||||
.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);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
await api.deleteTag(id);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,14 +18,18 @@ export function registerTransactionsCommand(program: Command) {
|
||||
.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);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const result = await api.getTransactions(
|
||||
cmdOpts.account,
|
||||
cmdOpts.start,
|
||||
cmdOpts.end,
|
||||
);
|
||||
printOutput(result, opts.format);
|
||||
},
|
||||
{ mutates: false },
|
||||
);
|
||||
});
|
||||
|
||||
transactions
|
||||
@@ -41,20 +45,24 @@ export function registerTransactionsCommand(program: Command) {
|
||||
.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);
|
||||
});
|
||||
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);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
|
||||
transactions
|
||||
@@ -69,20 +77,24 @@ export function registerTransactionsCommand(program: Command) {
|
||||
.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);
|
||||
});
|
||||
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);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
|
||||
transactions
|
||||
@@ -92,13 +104,17 @@ export function registerTransactionsCommand(program: Command) {
|
||||
.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);
|
||||
});
|
||||
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);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
|
||||
transactions
|
||||
@@ -106,9 +122,13 @@ export function registerTransactionsCommand(program: Command) {
|
||||
.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);
|
||||
});
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
await api.deleteTransaction(id);
|
||||
printOutput({ success: true, id }, opts.format);
|
||||
},
|
||||
{ mutates: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -28,6 +28,9 @@ describe('resolveConfig', () => {
|
||||
'ACTUAL_SYNC_ID',
|
||||
'ACTUAL_DATA_DIR',
|
||||
'ACTUAL_ENCRYPTION_PASSWORD',
|
||||
'ACTUAL_CACHE_TTL',
|
||||
'ACTUAL_LOCK_TIMEOUT',
|
||||
'ACTUAL_NO_LOCK',
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -159,6 +162,105 @@ describe('resolveConfig', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('cache options', () => {
|
||||
beforeEach(() => {
|
||||
process.env.ACTUAL_SERVER_URL = 'http://test';
|
||||
process.env.ACTUAL_PASSWORD = 'pw';
|
||||
});
|
||||
|
||||
it('defaults cacheTtl to 60 seconds', async () => {
|
||||
const config = await resolveConfig({});
|
||||
expect(config.cacheTtl).toBe(60);
|
||||
});
|
||||
|
||||
it('reads cacheTtl from env', async () => {
|
||||
process.env.ACTUAL_CACHE_TTL = '300';
|
||||
const config = await resolveConfig({});
|
||||
expect(config.cacheTtl).toBe(300);
|
||||
});
|
||||
|
||||
it('prefers cacheTtl from CLI flag', async () => {
|
||||
process.env.ACTUAL_CACHE_TTL = '300';
|
||||
const config = await resolveConfig({ cacheTtl: 10 });
|
||||
expect(config.cacheTtl).toBe(10);
|
||||
});
|
||||
|
||||
it('rejects negative cacheTtl', async () => {
|
||||
await expect(resolveConfig({ cacheTtl: -1 })).rejects.toThrow(/cacheTtl/);
|
||||
});
|
||||
|
||||
it('rejects non-integer cacheTtl from env', async () => {
|
||||
process.env.ACTUAL_CACHE_TTL = 'banana';
|
||||
await expect(resolveConfig({})).rejects.toThrow(/ACTUAL_CACHE_TTL/);
|
||||
});
|
||||
|
||||
it('defaults lockTimeout to 10 seconds', async () => {
|
||||
const config = await resolveConfig({});
|
||||
expect(config.lockTimeout).toBe(10);
|
||||
});
|
||||
|
||||
it('reads lockTimeout from env', async () => {
|
||||
process.env.ACTUAL_LOCK_TIMEOUT = '30';
|
||||
const config = await resolveConfig({});
|
||||
expect(config.lockTimeout).toBe(30);
|
||||
});
|
||||
|
||||
it('defaults refresh to false', async () => {
|
||||
const config = await resolveConfig({});
|
||||
expect(config.refresh).toBe(false);
|
||||
});
|
||||
|
||||
it('sets refresh when provided on CLI opts', async () => {
|
||||
const config = await resolveConfig({ refresh: true });
|
||||
expect(config.refresh).toBe(true);
|
||||
});
|
||||
|
||||
it('sets refresh when noCache is true', async () => {
|
||||
const config = await resolveConfig({ noCache: true });
|
||||
expect(config.refresh).toBe(true);
|
||||
});
|
||||
|
||||
it('defaults noLock to false', async () => {
|
||||
const config = await resolveConfig({});
|
||||
expect(config.noLock).toBe(false);
|
||||
});
|
||||
|
||||
it('parses ACTUAL_NO_LOCK=1 as true', async () => {
|
||||
process.env.ACTUAL_NO_LOCK = '1';
|
||||
const config = await resolveConfig({});
|
||||
expect(config.noLock).toBe(true);
|
||||
});
|
||||
|
||||
it('parses ACTUAL_NO_LOCK=true as true', async () => {
|
||||
process.env.ACTUAL_NO_LOCK = 'true';
|
||||
const config = await resolveConfig({});
|
||||
expect(config.noLock).toBe(true);
|
||||
});
|
||||
|
||||
it('reads cacheTtl/lockTimeout/noLock from config file', async () => {
|
||||
mockConfigFile({
|
||||
serverUrl: 'http://file',
|
||||
password: 'pw',
|
||||
cacheTtl: 120,
|
||||
lockTimeout: 5,
|
||||
noLock: true,
|
||||
});
|
||||
const config = await resolveConfig({});
|
||||
expect(config.cacheTtl).toBe(120);
|
||||
expect(config.lockTimeout).toBe(5);
|
||||
expect(config.noLock).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects non-number cacheTtl in config file', async () => {
|
||||
mockConfigFile({
|
||||
serverUrl: 'http://file',
|
||||
password: 'pw',
|
||||
cacheTtl: 'soon',
|
||||
});
|
||||
await expect(resolveConfig({})).rejects.toThrow(/cacheTtl/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cosmiconfig handling', () => {
|
||||
it('handles null result (no config file found)', async () => {
|
||||
mockConfigFile(null);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { join } from 'path';
|
||||
|
||||
import { cosmiconfig } from 'cosmiconfig';
|
||||
|
||||
import { isRecord } from './utils';
|
||||
import { isRecord, parseBoolEnv, parseNonNegativeIntFlag } from './utils';
|
||||
|
||||
export type CliConfig = {
|
||||
serverUrl: string;
|
||||
@@ -12,6 +12,10 @@ export type CliConfig = {
|
||||
syncId?: string;
|
||||
dataDir: string;
|
||||
encryptionPassword?: string;
|
||||
cacheTtl: number;
|
||||
lockTimeout: number;
|
||||
refresh: boolean;
|
||||
noLock: boolean;
|
||||
};
|
||||
|
||||
export type CliGlobalOpts = {
|
||||
@@ -21,10 +25,27 @@ export type CliGlobalOpts = {
|
||||
syncId?: string;
|
||||
dataDir?: string;
|
||||
encryptionPassword?: string;
|
||||
cacheTtl?: number;
|
||||
lockTimeout?: number;
|
||||
refresh?: boolean;
|
||||
noCache?: boolean;
|
||||
noLock?: boolean;
|
||||
format?: 'json' | 'table' | 'csv';
|
||||
verbose?: boolean;
|
||||
};
|
||||
|
||||
const stringKeys = [
|
||||
'serverUrl',
|
||||
'password',
|
||||
'sessionToken',
|
||||
'syncId',
|
||||
'dataDir',
|
||||
'encryptionPassword',
|
||||
] as const;
|
||||
|
||||
const numberKeys = ['cacheTtl', 'lockTimeout'] as const;
|
||||
const booleanKeys = ['noLock'] as const;
|
||||
|
||||
type ConfigFileContent = {
|
||||
serverUrl?: string;
|
||||
password?: string;
|
||||
@@ -32,15 +53,15 @@ type ConfigFileContent = {
|
||||
syncId?: string;
|
||||
dataDir?: string;
|
||||
encryptionPassword?: string;
|
||||
cacheTtl?: number;
|
||||
lockTimeout?: number;
|
||||
noLock?: boolean;
|
||||
};
|
||||
|
||||
const configFileKeys: readonly string[] = [
|
||||
'serverUrl',
|
||||
'password',
|
||||
'sessionToken',
|
||||
'syncId',
|
||||
'dataDir',
|
||||
'encryptionPassword',
|
||||
...stringKeys,
|
||||
...numberKeys,
|
||||
...booleanKeys,
|
||||
];
|
||||
|
||||
function validateConfigFileContent(value: unknown): ConfigFileContent {
|
||||
@@ -54,9 +75,30 @@ function validateConfigFileContent(value: unknown): ConfigFileContent {
|
||||
if (!configFileKeys.includes(key)) {
|
||||
throw new Error(`Invalid config file: unknown key "${key}"`);
|
||||
}
|
||||
if (value[key] !== undefined && typeof value[key] !== 'string') {
|
||||
const v = value[key];
|
||||
if (v === undefined) continue;
|
||||
if (
|
||||
(stringKeys as readonly string[]).includes(key) &&
|
||||
typeof v !== 'string'
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid config file: key "${key}" must be a string, got ${typeof value[key]}`,
|
||||
`Invalid config file: key "${key}" must be a string, got ${typeof v}`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
(numberKeys as readonly string[]).includes(key) &&
|
||||
(typeof v !== 'number' || !Number.isInteger(v) || v < 0)
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid config file: key "${key}" must be a non-negative integer`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
(booleanKeys as readonly string[]).includes(key) &&
|
||||
typeof v !== 'boolean'
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid config file: key "${key}" must be a boolean, got ${typeof v}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -83,6 +125,22 @@ async function loadConfigFile(): Promise<ConfigFileContent> {
|
||||
return {};
|
||||
}
|
||||
|
||||
function parseNonNegativeIntEnv(
|
||||
raw: string | undefined,
|
||||
source: string,
|
||||
): number | undefined {
|
||||
return raw === undefined ? undefined : parseNonNegativeIntFlag(raw, source);
|
||||
}
|
||||
|
||||
function validateNonNegativeInt(value: number, name: string): number {
|
||||
if (!Number.isInteger(value) || value < 0) {
|
||||
throw new Error(
|
||||
`Invalid ${name}: expected a non-negative integer, got ${value}`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export async function resolveConfig(
|
||||
cliOpts: CliGlobalOpts,
|
||||
): Promise<CliConfig> {
|
||||
@@ -128,6 +186,36 @@ export async function resolveConfig(
|
||||
);
|
||||
}
|
||||
|
||||
const cacheTtl = validateNonNegativeInt(
|
||||
cliOpts.cacheTtl ??
|
||||
parseNonNegativeIntEnv(
|
||||
process.env.ACTUAL_CACHE_TTL,
|
||||
'ACTUAL_CACHE_TTL',
|
||||
) ??
|
||||
fileConfig.cacheTtl ??
|
||||
60,
|
||||
'cacheTtl',
|
||||
);
|
||||
|
||||
const lockTimeout = validateNonNegativeInt(
|
||||
cliOpts.lockTimeout ??
|
||||
parseNonNegativeIntEnv(
|
||||
process.env.ACTUAL_LOCK_TIMEOUT,
|
||||
'ACTUAL_LOCK_TIMEOUT',
|
||||
) ??
|
||||
fileConfig.lockTimeout ??
|
||||
10,
|
||||
'lockTimeout',
|
||||
);
|
||||
|
||||
const refresh = cliOpts.refresh ?? cliOpts.noCache ?? false;
|
||||
|
||||
const noLock =
|
||||
cliOpts.noLock ??
|
||||
parseBoolEnv(process.env.ACTUAL_NO_LOCK) ??
|
||||
fileConfig.noLock ??
|
||||
false;
|
||||
|
||||
return {
|
||||
serverUrl,
|
||||
password,
|
||||
@@ -135,5 +223,9 @@ export async function resolveConfig(
|
||||
syncId,
|
||||
dataDir,
|
||||
encryptionPassword,
|
||||
cacheTtl,
|
||||
lockTimeout,
|
||||
refresh,
|
||||
noLock,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,24 +1,44 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import * as api from '@actual-app/api';
|
||||
|
||||
import { getMetaDir, writeCacheState } from './cache';
|
||||
import { resolveConfig } from './config';
|
||||
import { withConnection } from './connection';
|
||||
|
||||
vi.mock('@actual-app/api', () => ({
|
||||
init: vi.fn().mockResolvedValue(undefined),
|
||||
downloadBudget: vi.fn().mockResolvedValue(undefined),
|
||||
loadBudget: vi.fn().mockResolvedValue(undefined),
|
||||
sync: vi.fn().mockResolvedValue(undefined),
|
||||
shutdown: vi.fn().mockResolvedValue(undefined),
|
||||
getBudgets: vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: 'bud-disk-1', groupId: 'sync-1' }]),
|
||||
}));
|
||||
|
||||
vi.mock('./config', () => ({
|
||||
resolveConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
let dataDir: string;
|
||||
|
||||
function metaDirFor(syncId: string) {
|
||||
return getMetaDir(dataDir, syncId);
|
||||
}
|
||||
|
||||
function setConfig(overrides: Record<string, unknown> = {}) {
|
||||
vi.mocked(resolveConfig).mockResolvedValue({
|
||||
serverUrl: 'http://test',
|
||||
password: 'pw',
|
||||
dataDir: '/tmp/data',
|
||||
syncId: 'budget-1',
|
||||
dataDir,
|
||||
syncId: 'sync-1',
|
||||
cacheTtl: 60,
|
||||
lockTimeout: 10,
|
||||
refresh: false,
|
||||
noLock: true,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
@@ -31,104 +51,182 @@ describe('withConnection', () => {
|
||||
stderrSpy = vi
|
||||
.spyOn(process.stderr, 'write')
|
||||
.mockImplementation(() => true);
|
||||
dataDir = mkdtempSync(join(tmpdir(), 'actual-cli-conn-'));
|
||||
setConfig();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stderrSpy.mockRestore();
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('calls api.init with password when no sessionToken', async () => {
|
||||
setConfig({ password: 'pw', sessionToken: undefined });
|
||||
|
||||
await withConnection({}, async () => 'ok');
|
||||
|
||||
await withConnection({}, async () => 'ok', { mutates: false });
|
||||
expect(api.init).toHaveBeenCalledWith({
|
||||
serverURL: 'http://test',
|
||||
password: 'pw',
|
||||
dataDir: '/tmp/data',
|
||||
dataDir,
|
||||
verbose: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('calls api.init with sessionToken when present', async () => {
|
||||
setConfig({ sessionToken: 'tok', password: undefined });
|
||||
|
||||
await withConnection({}, async () => 'ok');
|
||||
|
||||
await withConnection({}, async () => 'ok', { mutates: false });
|
||||
expect(api.init).toHaveBeenCalledWith({
|
||||
serverURL: 'http://test',
|
||||
sessionToken: 'tok',
|
||||
dataDir: '/tmp/data',
|
||||
dataDir,
|
||||
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', {
|
||||
it('first run: calls downloadBudget and writes cache state', async () => {
|
||||
await withConnection({}, async () => 'ok', { mutates: false });
|
||||
expect(api.downloadBudget).toHaveBeenCalledWith('sync-1', {
|
||||
password: undefined,
|
||||
});
|
||||
expect(api.sync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
it('skips sync on a read inside the TTL', async () => {
|
||||
writeCacheState(metaDirFor('sync-1'), {
|
||||
version: 1,
|
||||
syncId: 'sync-1',
|
||||
budgetId: 'bud-disk-1',
|
||||
serverUrl: 'http://test',
|
||||
lastSyncedAt: Date.now(),
|
||||
lastDownloadedAt: Date.now(),
|
||||
});
|
||||
await withConnection({}, async () => 'ok', { mutates: false });
|
||||
expect(api.loadBudget).toHaveBeenCalledWith('bud-disk-1');
|
||||
expect(api.sync).not.toHaveBeenCalled();
|
||||
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('syncs on a read past the TTL', async () => {
|
||||
writeCacheState(metaDirFor('sync-1'), {
|
||||
version: 1,
|
||||
syncId: 'sync-1',
|
||||
budgetId: 'bud-disk-1',
|
||||
serverUrl: 'http://test',
|
||||
lastSyncedAt: Date.now() - 10 * 60_000,
|
||||
lastDownloadedAt: Date.now() - 10 * 60_000,
|
||||
});
|
||||
await withConnection({}, async () => 'ok', { mutates: false });
|
||||
expect(api.loadBudget).toHaveBeenCalled();
|
||||
expect(api.sync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns callback result', async () => {
|
||||
const result = await withConnection({}, async () => 42);
|
||||
it('write command syncs before and after the callback, even when fresh', async () => {
|
||||
writeCacheState(metaDirFor('sync-1'), {
|
||||
version: 1,
|
||||
syncId: 'sync-1',
|
||||
budgetId: 'bud-disk-1',
|
||||
serverUrl: 'http://test',
|
||||
lastSyncedAt: Date.now(),
|
||||
lastDownloadedAt: Date.now(),
|
||||
});
|
||||
await withConnection({}, async () => 'ok', { mutates: true });
|
||||
expect(api.loadBudget).toHaveBeenCalled();
|
||||
expect(api.sync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('--refresh forces a sync on a read inside the TTL', async () => {
|
||||
setConfig({ refresh: true });
|
||||
writeCacheState(metaDirFor('sync-1'), {
|
||||
version: 1,
|
||||
syncId: 'sync-1',
|
||||
budgetId: 'bud-disk-1',
|
||||
serverUrl: 'http://test',
|
||||
lastSyncedAt: Date.now(),
|
||||
lastDownloadedAt: Date.now(),
|
||||
});
|
||||
await withConnection({}, async () => 'ok', { mutates: false });
|
||||
expect(api.sync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('encrypted budget forces a sync on a read inside the TTL', async () => {
|
||||
setConfig({ encryptionPassword: 'secret' });
|
||||
writeCacheState(metaDirFor('sync-1'), {
|
||||
version: 1,
|
||||
syncId: 'sync-1',
|
||||
budgetId: 'bud-disk-1',
|
||||
serverUrl: 'http://test',
|
||||
lastSyncedAt: Date.now(),
|
||||
lastDownloadedAt: Date.now(),
|
||||
});
|
||||
await withConnection({}, async () => 'ok', { mutates: false });
|
||||
expect(api.sync).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('invalidates cache when syncId changes', async () => {
|
||||
writeCacheState(metaDirFor('sync-1'), {
|
||||
version: 1,
|
||||
syncId: 'OTHER',
|
||||
budgetId: 'bud-disk-1',
|
||||
serverUrl: 'http://test',
|
||||
lastSyncedAt: Date.now(),
|
||||
lastDownloadedAt: Date.now(),
|
||||
});
|
||||
await withConnection({}, async () => 'ok', { mutates: false });
|
||||
expect(api.downloadBudget).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips budget work when skipBudget is true', async () => {
|
||||
await withConnection({}, async () => 'ok', {
|
||||
mutates: false,
|
||||
skipBudget: true,
|
||||
});
|
||||
expect(api.downloadBudget).not.toHaveBeenCalled();
|
||||
expect(api.loadBudget).not.toHaveBeenCalled();
|
||||
expect(api.sync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when syncId is missing and skipBudget is false', async () => {
|
||||
setConfig({ syncId: undefined });
|
||||
await expect(
|
||||
withConnection({}, async () => 'ok', { mutates: false }),
|
||||
).rejects.toThrow('Sync ID is required');
|
||||
});
|
||||
|
||||
it('returns the callback result', async () => {
|
||||
const result = await withConnection({}, async () => 42, {
|
||||
mutates: false,
|
||||
});
|
||||
expect(result).toBe(42);
|
||||
});
|
||||
|
||||
it('calls api.shutdown in finally block on success', async () => {
|
||||
await withConnection({}, async () => 'ok');
|
||||
it('calls api.shutdown on success', async () => {
|
||||
await withConnection({}, async () => 'ok', { mutates: false });
|
||||
expect(api.shutdown).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls api.shutdown in finally block on error', async () => {
|
||||
it('calls api.shutdown on error', async () => {
|
||||
await expect(
|
||||
withConnection({}, async () => {
|
||||
throw new Error('boom');
|
||||
}),
|
||||
withConnection(
|
||||
{},
|
||||
async () => {
|
||||
throw new Error('boom');
|
||||
},
|
||||
{ mutates: false },
|
||||
),
|
||||
).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'),
|
||||
);
|
||||
it('propagates sync errors on a stale read', async () => {
|
||||
writeCacheState(metaDirFor('sync-1'), {
|
||||
version: 1,
|
||||
syncId: 'sync-1',
|
||||
budgetId: 'bud-disk-1',
|
||||
serverUrl: 'http://test',
|
||||
lastSyncedAt: Date.now() - 10 * 60_000,
|
||||
lastDownloadedAt: Date.now() - 10 * 60_000,
|
||||
});
|
||||
vi.mocked(api.sync).mockRejectedValueOnce(new Error('network'));
|
||||
await expect(
|
||||
withConnection({}, async () => 'ok', { mutates: false }),
|
||||
).rejects.toThrow('network');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,30 +1,52 @@
|
||||
import { mkdirSync } from 'fs';
|
||||
|
||||
import * as api from '@actual-app/api';
|
||||
|
||||
import type { CacheState } from './cache';
|
||||
import {
|
||||
CACHE_VERSION,
|
||||
decideSyncAction,
|
||||
getMetaDir,
|
||||
readCacheState,
|
||||
writeCacheState,
|
||||
} from './cache';
|
||||
import type { CliConfig, CliGlobalOpts } from './config';
|
||||
import { resolveConfig } from './config';
|
||||
import type { CliGlobalOpts } from './config';
|
||||
|
||||
function info(message: string, verbose?: boolean) {
|
||||
if (verbose) {
|
||||
process.stderr.write(message + '\n');
|
||||
}
|
||||
}
|
||||
import { acquireExclusive, acquireShared } from './lock';
|
||||
import type { Release } from './lock';
|
||||
|
||||
type ConnectionOptions = {
|
||||
loadBudget?: boolean;
|
||||
mutates: boolean;
|
||||
skipBudget?: boolean;
|
||||
};
|
||||
|
||||
function info(message: string, verbose?: boolean) {
|
||||
if (verbose) process.stderr.write(message + '\n');
|
||||
}
|
||||
|
||||
async function resolveBudgetIdForSyncId(syncId: string): Promise<string> {
|
||||
const budgets = (await api.getBudgets()) as Array<{
|
||||
id?: string;
|
||||
groupId?: string;
|
||||
cloudFileId?: string;
|
||||
}>;
|
||||
const match = budgets.find(
|
||||
b =>
|
||||
b.id !== undefined && (b.groupId === syncId || b.cloudFileId === syncId),
|
||||
);
|
||||
if (!match?.id) {
|
||||
throw new Error(
|
||||
`Could not resolve on-disk budget id for syncId ${syncId} after download.`,
|
||||
);
|
||||
}
|
||||
return match.id;
|
||||
}
|
||||
|
||||
export async function withConnection<T>(
|
||||
globalOpts: CliGlobalOpts,
|
||||
fn: () => Promise<T>,
|
||||
options: ConnectionOptions = {},
|
||||
fn: (config: CliConfig) => Promise<T>,
|
||||
{ mutates, skipBudget = false }: 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) {
|
||||
@@ -48,17 +70,87 @@ export async function withConnection<T>(
|
||||
}
|
||||
|
||||
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) {
|
||||
if (skipBudget) return await fn(config);
|
||||
if (!config.syncId) {
|
||||
throw new Error(
|
||||
'Sync ID is required for this command. Set --sync-id or ACTUAL_SYNC_ID.',
|
||||
);
|
||||
}
|
||||
return await fn();
|
||||
|
||||
const meta = getMetaDir(config.dataDir, config.syncId);
|
||||
let release: Release | null = null;
|
||||
if (!config.noLock) {
|
||||
release = mutates
|
||||
? await acquireExclusive(meta, {
|
||||
timeoutMs: config.lockTimeout * 1000,
|
||||
})
|
||||
: await acquireShared(meta, {
|
||||
timeoutMs: config.lockTimeout * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const cachedState = readCacheState(meta);
|
||||
const decision = decideSyncAction({
|
||||
state: cachedState,
|
||||
config: { syncId: config.syncId, serverUrl: config.serverUrl },
|
||||
now: Date.now(),
|
||||
ttlMs: config.cacheTtl * 1000,
|
||||
mutates,
|
||||
refresh: config.refresh,
|
||||
encrypted: Boolean(config.encryptionPassword),
|
||||
});
|
||||
|
||||
let state: CacheState;
|
||||
if (decision.action === 'download') {
|
||||
info(
|
||||
cachedState === null
|
||||
? `Downloading budget ${config.syncId} for the first time...`
|
||||
: `Re-downloading budget ${config.syncId} (cache invalidated)...`,
|
||||
globalOpts.verbose,
|
||||
);
|
||||
await api.downloadBudget(config.syncId, {
|
||||
password: config.encryptionPassword,
|
||||
});
|
||||
const budgetId = await resolveBudgetIdForSyncId(config.syncId);
|
||||
const now = Date.now();
|
||||
state = {
|
||||
version: CACHE_VERSION,
|
||||
syncId: config.syncId,
|
||||
budgetId,
|
||||
serverUrl: config.serverUrl,
|
||||
lastSyncedAt: now,
|
||||
lastDownloadedAt: now,
|
||||
};
|
||||
writeCacheState(meta, state);
|
||||
} else if (decision.action === 'skip') {
|
||||
const age = Math.round(
|
||||
(Date.now() - decision.state.lastSyncedAt) / 1000,
|
||||
);
|
||||
info(`Using cached budget (synced ${age}s ago)...`, globalOpts.verbose);
|
||||
await api.loadBudget(decision.state.budgetId);
|
||||
state = decision.state;
|
||||
} else {
|
||||
info(`Syncing budget ${config.syncId}...`, globalOpts.verbose);
|
||||
await api.loadBudget(decision.state.budgetId);
|
||||
await api.sync();
|
||||
state = { ...decision.state, lastSyncedAt: Date.now() };
|
||||
writeCacheState(meta, state);
|
||||
}
|
||||
|
||||
const result = await fn(config);
|
||||
|
||||
if (mutates) {
|
||||
info(`Pushing changes for ${config.syncId}...`, globalOpts.verbose);
|
||||
await api.sync();
|
||||
state = { ...state, lastSyncedAt: Date.now() };
|
||||
writeCacheState(meta, state);
|
||||
}
|
||||
|
||||
return result;
|
||||
} finally {
|
||||
if (release) await release();
|
||||
}
|
||||
} finally {
|
||||
await api.shutdown();
|
||||
}
|
||||
|
||||
@@ -9,8 +9,10 @@ import { registerQueryCommand } from './commands/query';
|
||||
import { registerRulesCommand } from './commands/rules';
|
||||
import { registerSchedulesCommand } from './commands/schedules';
|
||||
import { registerServerCommand } from './commands/server';
|
||||
import { registerSyncCommand } from './commands/sync';
|
||||
import { registerTagsCommand } from './commands/tags';
|
||||
import { registerTransactionsCommand } from './commands/transactions';
|
||||
import { parseNonNegativeIntFlag } from './utils';
|
||||
|
||||
declare const __CLI_VERSION__: string;
|
||||
|
||||
@@ -32,6 +34,23 @@ program
|
||||
'--encryption-password <password>',
|
||||
'E2E encryption password (env: ACTUAL_ENCRYPTION_PASSWORD)',
|
||||
)
|
||||
.option(
|
||||
'--cache-ttl <seconds>',
|
||||
'Cache TTL in seconds (env: ACTUAL_CACHE_TTL; default: 60)',
|
||||
value => parseNonNegativeIntFlag(value, '--cache-ttl'),
|
||||
)
|
||||
.option('--refresh', 'Force a sync on this call, ignoring the cache', false)
|
||||
.option('--no-cache', 'Alias for --refresh', false)
|
||||
.option(
|
||||
'--lock-timeout <seconds>',
|
||||
'How long to wait for another CLI process to release the lock (env: ACTUAL_LOCK_TIMEOUT; default: 10)',
|
||||
value => parseNonNegativeIntFlag(value, '--lock-timeout'),
|
||||
)
|
||||
.option(
|
||||
'--no-lock',
|
||||
'Disable the budget directory lock (use with care, env: ACTUAL_NO_LOCK)',
|
||||
false,
|
||||
)
|
||||
.addOption(
|
||||
new Option('--format <format>', 'Output format: json, table, csv')
|
||||
.choices(['json', 'table', 'csv'] as const)
|
||||
@@ -50,6 +69,7 @@ registerRulesCommand(program);
|
||||
registerSchedulesCommand(program);
|
||||
registerQueryCommand(program);
|
||||
registerServerCommand(program);
|
||||
registerSyncCommand(program);
|
||||
|
||||
function normalizeThrownMessage(err: unknown): string {
|
||||
if (err instanceof Error) return err.message;
|
||||
|
||||
159
packages/cli/src/lock.test.ts
Normal file
159
packages/cli/src/lock.test.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { acquireExclusive, acquireShared } from './lock';
|
||||
|
||||
// In-memory stand-in for proper-lockfile. The real library spins up a
|
||||
// setTimeout loop to refresh lockfile mtimes; on some CI filesystems that
|
||||
// timer keeps Node's event loop alive even after tests complete, wedging the
|
||||
// test run. The mock behaves identically from our wrapper's perspective
|
||||
// (acquire, detect contention with ELOCKED, release) without touching the
|
||||
// filesystem or scheduling timers.
|
||||
const mockHeld = new Set<string>();
|
||||
|
||||
vi.mock('proper-lockfile', () => ({
|
||||
default: {
|
||||
lock: vi.fn(
|
||||
async (
|
||||
file: string,
|
||||
opts?: { lockfilePath?: string },
|
||||
): Promise<() => Promise<void>> => {
|
||||
const key = opts?.lockfilePath ?? file;
|
||||
if (mockHeld.has(key)) {
|
||||
const err = new Error('Lock is already held') as Error & {
|
||||
code?: string;
|
||||
};
|
||||
err.code = 'ELOCKED';
|
||||
throw err;
|
||||
}
|
||||
mockHeld.add(key);
|
||||
return async () => {
|
||||
mockHeld.delete(key);
|
||||
};
|
||||
},
|
||||
),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('acquireExclusive', () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
mockHeld.clear();
|
||||
dir = mkdtempSync(join(tmpdir(), 'actual-cli-lock-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('creates the directory if it does not exist', async () => {
|
||||
const target = join(dir, 'nested', 'budget');
|
||||
const release = await acquireExclusive(target, { timeoutMs: 1000 });
|
||||
expect(existsSync(target)).toBe(true);
|
||||
await release();
|
||||
});
|
||||
|
||||
it('returns a release function that frees the lock', async () => {
|
||||
const release1 = await acquireExclusive(dir, { timeoutMs: 1000 });
|
||||
await release1();
|
||||
const release2 = await acquireExclusive(dir, { timeoutMs: 1000 });
|
||||
await release2();
|
||||
});
|
||||
|
||||
it('rejects with a user-friendly error when another holder has the lock', async () => {
|
||||
const release = await acquireExclusive(dir, { timeoutMs: 1000 });
|
||||
await expect(acquireExclusive(dir, { timeoutMs: 100 })).rejects.toThrow(
|
||||
/holding the budget/,
|
||||
);
|
||||
await release();
|
||||
});
|
||||
});
|
||||
|
||||
describe('acquireShared', () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
mockHeld.clear();
|
||||
dir = mkdtempSync(join(tmpdir(), 'actual-cli-lock-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('allows multiple concurrent shared holders', async () => {
|
||||
const r1 = await acquireShared(dir, { timeoutMs: 1000 });
|
||||
const r2 = await acquireShared(dir, { timeoutMs: 1000 });
|
||||
const readers = readdirSync(join(dir, 'readers'));
|
||||
expect(readers).toHaveLength(2);
|
||||
await r1();
|
||||
await r2();
|
||||
});
|
||||
|
||||
it('removes the reader marker on release', async () => {
|
||||
const release = await acquireShared(dir, { timeoutMs: 1000 });
|
||||
await release();
|
||||
const readers = readdirSync(join(dir, 'readers'));
|
||||
expect(readers).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects when an exclusive lock is held', async () => {
|
||||
const releaseExclusive = await acquireExclusive(dir, { timeoutMs: 1000 });
|
||||
await expect(acquireShared(dir, { timeoutMs: 100 })).rejects.toThrow(
|
||||
/holding the budget/,
|
||||
);
|
||||
await releaseExclusive();
|
||||
});
|
||||
|
||||
it('sweeps stale reader markers whose PIDs no longer exist', async () => {
|
||||
const readersDir = join(dir, 'readers');
|
||||
mkdirSync(readersDir, { recursive: true });
|
||||
writeFileSync(join(readersDir, '-1-abc'), '');
|
||||
|
||||
const release = await acquireExclusive(dir, { timeoutMs: 1000 });
|
||||
expect(readdirSync(readersDir)).toHaveLength(0);
|
||||
await release();
|
||||
});
|
||||
});
|
||||
|
||||
describe('writer-reader interaction', () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
mockHeld.clear();
|
||||
dir = mkdtempSync(join(tmpdir(), 'actual-cli-lock-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('exclusive waits for active shared holders to release', async () => {
|
||||
const readerRelease = await acquireShared(dir, { timeoutMs: 500 });
|
||||
|
||||
let writerAcquired = false;
|
||||
const writerPromise = acquireExclusive(dir, { timeoutMs: 1000 }).then(
|
||||
release => {
|
||||
writerAcquired = true;
|
||||
return release;
|
||||
},
|
||||
);
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
expect(writerAcquired).toBe(false);
|
||||
|
||||
await readerRelease();
|
||||
const writerRelease = await writerPromise;
|
||||
expect(writerAcquired).toBe(true);
|
||||
await writerRelease();
|
||||
});
|
||||
});
|
||||
149
packages/cli/src/lock.ts
Normal file
149
packages/cli/src/lock.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import lockfile from 'proper-lockfile';
|
||||
|
||||
export type Release = () => Promise<void>;
|
||||
|
||||
export type AcquireOptions = {
|
||||
timeoutMs: number;
|
||||
};
|
||||
|
||||
const LOCKFILE_NAME = 'lock';
|
||||
const READERS_DIR_NAME = 'readers';
|
||||
const READER_POLL_INTERVAL_MS = 100;
|
||||
|
||||
function lockfilePath(dir: string): string {
|
||||
return join(dir, LOCKFILE_NAME);
|
||||
}
|
||||
|
||||
function readersDir(dir: string): string {
|
||||
return join(dir, READERS_DIR_NAME);
|
||||
}
|
||||
|
||||
function ensureDir(dir: string) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
function retriesForTimeout(timeoutMs: number) {
|
||||
return {
|
||||
retries: Math.max(1, Math.floor(timeoutMs / 200)),
|
||||
minTimeout: 100,
|
||||
maxTimeout: 500,
|
||||
factor: 1.5,
|
||||
};
|
||||
}
|
||||
|
||||
function errorCode(err: unknown): string | undefined {
|
||||
if (err instanceof Error && 'code' in err) {
|
||||
const { code } = err as { code?: unknown };
|
||||
if (typeof code === 'string') return code;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isLockedError(err: unknown): boolean {
|
||||
return errorCode(err) === 'ELOCKED';
|
||||
}
|
||||
|
||||
function lockedMessage(timeoutMs: number): string {
|
||||
return `Another CLI process is holding the budget (waited ${Math.round(
|
||||
timeoutMs / 1000,
|
||||
)}s). Retry, or use a different --data-dir.`;
|
||||
}
|
||||
|
||||
function pidIsAlive(pid: number): boolean {
|
||||
if (pid <= 0) return false;
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (err) {
|
||||
return errorCode(err) === 'EPERM';
|
||||
}
|
||||
}
|
||||
|
||||
function readReaderNames(readers: string): string[] {
|
||||
try {
|
||||
return readdirSync(readers);
|
||||
} catch (err) {
|
||||
if (errorCode(err) === 'ENOENT') return [];
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function sweepStaleReaders(dir: string) {
|
||||
const readers = readersDir(dir);
|
||||
for (const name of readReaderNames(readers)) {
|
||||
const pid = Number(name.split('-')[0]);
|
||||
if (!Number.isFinite(pid) || !pidIsAlive(pid)) {
|
||||
rmSync(join(readers, name), { force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForReadersEmpty(dir: string, timeoutMs: number) {
|
||||
const readers = readersDir(dir);
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
sweepStaleReaders(dir);
|
||||
if (readReaderNames(readers).length === 0) return;
|
||||
await new Promise(resolve => setTimeout(resolve, READER_POLL_INTERVAL_MS));
|
||||
}
|
||||
throw new Error(lockedMessage(timeoutMs));
|
||||
}
|
||||
|
||||
async function acquireGate(
|
||||
dir: string,
|
||||
timeoutMs: number,
|
||||
): Promise<() => Promise<void>> {
|
||||
ensureDir(dir);
|
||||
try {
|
||||
return await lockfile.lock(dir, {
|
||||
lockfilePath: lockfilePath(dir),
|
||||
retries: retriesForTimeout(timeoutMs),
|
||||
stale: 30_000,
|
||||
});
|
||||
} catch (err) {
|
||||
if (isLockedError(err)) throw new Error(lockedMessage(timeoutMs));
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function acquireExclusive(
|
||||
dir: string,
|
||||
{ timeoutMs }: AcquireOptions,
|
||||
): Promise<Release> {
|
||||
const start = Date.now();
|
||||
const release = await acquireGate(dir, timeoutMs);
|
||||
try {
|
||||
const remaining = Math.max(0, timeoutMs - (Date.now() - start));
|
||||
await waitForReadersEmpty(dir, remaining);
|
||||
} catch (err) {
|
||||
await release();
|
||||
throw err;
|
||||
}
|
||||
return () => release();
|
||||
}
|
||||
|
||||
export async function acquireShared(
|
||||
dir: string,
|
||||
{ timeoutMs }: AcquireOptions,
|
||||
): Promise<Release> {
|
||||
const gate = await acquireGate(dir, timeoutMs);
|
||||
let markerPath: string;
|
||||
try {
|
||||
const readers = readersDir(dir);
|
||||
ensureDir(readers);
|
||||
const markerName = `${process.pid}-${randomBytes(6).toString('hex')}`;
|
||||
markerPath = join(readers, markerName);
|
||||
writeFileSync(markerPath, '');
|
||||
} catch (err) {
|
||||
await gate();
|
||||
throw err;
|
||||
}
|
||||
await gate();
|
||||
return async () => {
|
||||
rmSync(markerPath, { force: true });
|
||||
};
|
||||
}
|
||||
@@ -18,3 +18,23 @@ export function parseIntFlag(value: string, flagName: string): number {
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function parseNonNegativeIntFlag(
|
||||
value: string,
|
||||
flagName: string,
|
||||
): number {
|
||||
const parsed = parseIntFlag(value, flagName);
|
||||
if (parsed < 0) {
|
||||
throw new Error(
|
||||
`Invalid ${flagName}: "${value}". Expected a non-negative integer.`,
|
||||
);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function parseBoolEnv(raw: string | undefined): boolean | undefined {
|
||||
if (raw === undefined) return undefined;
|
||||
if (raw === '1' || raw.toLowerCase() === 'true') return true;
|
||||
if (raw === '0' || raw.toLowerCase() === 'false') return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -32,5 +32,8 @@ export default defineConfig({
|
||||
plugins: [visualizer({ template: 'raw-data', filename: 'dist/stats.json' })],
|
||||
test: {
|
||||
globals: true,
|
||||
include: ['src/**/*.test.ts'],
|
||||
exclude: ['**/node_modules/**', '**/dist/**'],
|
||||
testTimeout: 10_000,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -45,6 +45,27 @@
|
||||
-->
|
||||
|
||||
<style>
|
||||
/* Show logo icon next to brand title text */
|
||||
.sidebar-header a[href='https://actualbudget.org'] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
}
|
||||
.sidebar-header a[href='https://actualbudget.org']::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
min-width: 32px;
|
||||
background-image: url('https://actualbudget.org/img/logo.webp');
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
#storybook-explorer-searchfield {
|
||||
font-weight: 400 !important;
|
||||
font-size: 14px !important;
|
||||
|
||||
@@ -16,7 +16,6 @@ const theme = create({
|
||||
base: 'light',
|
||||
brandTitle: 'Actual Budget',
|
||||
brandUrl: 'https://actualbudget.org',
|
||||
brandImage: 'https://actualbudget.org/img/actual.webp',
|
||||
brandTarget: '_blank',
|
||||
|
||||
// UI colors
|
||||
@@ -32,7 +31,7 @@ const theme = create({
|
||||
|
||||
// Fonts
|
||||
fontBase:
|
||||
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
|
||||
'"Inter Variable", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", Helvetica, Arial, sans-serif',
|
||||
fontCode: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace',
|
||||
|
||||
// Text colors
|
||||
|
||||
@@ -47,25 +47,25 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/css": "^11.13.5",
|
||||
"react-aria-components": "^1.15.1",
|
||||
"react-aria-components": "^1.16.0",
|
||||
"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.1.1",
|
||||
"@storybook/addon-a11y": "^10.3.4",
|
||||
"@storybook/addon-docs": "^10.3.4",
|
||||
"@storybook/react-vite": "^10.3.4",
|
||||
"@svgr/babel-plugin-add-jsx-attribute": "^8.0.0",
|
||||
"@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",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260404.1",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint-plugin-storybook": "^10.3.4",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"storybook": "^10.2.16",
|
||||
"storybook": "^10.3.4",
|
||||
"vite": "^8.0.5",
|
||||
"vitest": "^4.1.0"
|
||||
"vitest": "^4.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=19.2",
|
||||
|
||||
@@ -19,7 +19,7 @@ protoc --plugin="protoc-gen-ts=../../node_modules/.bin/protoc-gen-ts" \
|
||||
../../node_modules/.bin/oxfmt src/proto/*.d.ts
|
||||
|
||||
for file in src/proto/*.d.ts; do
|
||||
{ echo "/* eslint-disable @typescript-eslint/no-namespace */"; sed 's/export class/export declare class/g' "$file"; } > "${file%.d.ts}.ts"
|
||||
{ echo "/* oxlint-disable typescript/no-namespace */"; sed 's/export class/export declare class/g' "$file"; } > "${file%.d.ts}.ts"
|
||||
rm "$file"
|
||||
done
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from './src';
|
||||
@@ -9,7 +9,11 @@
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"development": "./src/index.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
"exports": {
|
||||
@@ -20,22 +24,23 @@
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build:node": "tsgo",
|
||||
"build:node": "vite build",
|
||||
"proto:generate": "./bin/generate-proto",
|
||||
"build": "rm -rf dist && yarn run build:node",
|
||||
"build": "yarn run build:node && tsgo -p tsconfig.build.json --emitDeclarationOnly",
|
||||
"test": "vitest --run",
|
||||
"typecheck": "tsgo -b"
|
||||
},
|
||||
"dependencies": {
|
||||
"google-protobuf": "^3.21.4",
|
||||
"murmurhash": "^2.0.1",
|
||||
"uuid": "^13.0.0"
|
||||
"murmurhash": "^2.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/google-protobuf": "3.15.12",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260404.1",
|
||||
"protoc-gen-js": "3.21.4-4",
|
||||
"rollup-plugin-visualizer": "^7.0.1",
|
||||
"ts-protoc-gen": "0.15.0",
|
||||
"vitest": "^4.1.0"
|
||||
"vite": "^8.0.5",
|
||||
"vitest": "^4.1.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,8 @@ describe('Timestamp', function () {
|
||||
'9999-12-31T23:59:59.999Z-FFFF-10000000000000000',
|
||||
];
|
||||
for (const invalidInput of invalidInputs) {
|
||||
expect(Timestamp.parse(invalidInput as string)).toBe(null);
|
||||
// @ts-expect-error we intentionally pass invalid inputs
|
||||
expect(Timestamp.parse(invalidInput)).toBe(null);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import murmurhash from 'murmurhash';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import type { TrieNode } from './merkle';
|
||||
|
||||
@@ -77,7 +76,7 @@ export function deserializeClock(clock: string): Clock {
|
||||
}
|
||||
|
||||
export function makeClientId() {
|
||||
return uuidv4().replace(/-/g, '').slice(-16);
|
||||
return crypto.randomUUID().replace(/-/g, '').slice(-16);
|
||||
}
|
||||
|
||||
const config = {
|
||||
@@ -313,9 +312,7 @@ export class Timestamp {
|
||||
|
||||
static ClockDriftError = class ClockDriftError extends Error {
|
||||
constructor(...args: unknown[]) {
|
||||
super(
|
||||
['maximum clock drift exceeded'].concat(args as string[]).join(' '),
|
||||
);
|
||||
super(['maximum clock drift exceeded', ...args.map(String)].join(' '));
|
||||
this.name = 'ClockDriftError';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* oxlint-disable typescript/no-explicit-any */
|
||||
import './proto/sync_pb.js'; // Import for side effects
|
||||
import type * as SyncPb from './proto/sync_pb';
|
||||
|
||||
export {
|
||||
merkle,
|
||||
@@ -13,11 +13,16 @@ export {
|
||||
Timestamp,
|
||||
} from './crdt';
|
||||
|
||||
// Access global proto namespace
|
||||
export const SyncRequest = (globalThis as any).proto.SyncRequest;
|
||||
export const SyncResponse = (globalThis as any).proto.SyncResponse;
|
||||
export const Message = (globalThis as any).proto.Message;
|
||||
export const MessageEnvelope = (globalThis as any).proto.MessageEnvelope;
|
||||
export const EncryptedData = (globalThis as any).proto.EncryptedData;
|
||||
declare global {
|
||||
var proto: typeof SyncPb;
|
||||
}
|
||||
|
||||
export const SyncProtoBuf = (globalThis as any).proto;
|
||||
const { proto } = globalThis;
|
||||
|
||||
export const SyncRequest = proto.SyncRequest;
|
||||
export const SyncResponse = proto.SyncResponse;
|
||||
export const Message = proto.Message;
|
||||
export const MessageEnvelope = proto.MessageEnvelope;
|
||||
export const EncryptedData = proto.EncryptedData;
|
||||
|
||||
export const SyncProtoBuf = proto;
|
||||
|
||||
8
packages/crdt/tsconfig.build.json
Normal file
8
packages/crdt/tsconfig.build.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": false,
|
||||
"emitDeclarationOnly": false
|
||||
},
|
||||
"exclude": ["**/*.test.ts", "**/*.spec.ts"]
|
||||
}
|
||||
@@ -1,18 +1,19 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"composite": true,
|
||||
// Using ES2021 because that's the newest version where
|
||||
// the latest Node 16.x release supports all of the features
|
||||
"target": "ES2021",
|
||||
"module": "CommonJS",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"noEmit": false,
|
||||
"emitDeclarationOnly": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"strict": true,
|
||||
"outDir": "dist",
|
||||
"tsBuildInfoFile": "dist/.tsbuildinfo"
|
||||
"tsBuildInfoFile": "dist/.tsbuildinfo",
|
||||
"types": ["vitest/globals"]
|
||||
},
|
||||
"include": ["."],
|
||||
"exclude": ["dist", "**/*.test.ts", "**/*.spec.ts"]
|
||||
"include": ["./src/"]
|
||||
}
|
||||
|
||||
24
packages/crdt/vite.config.mts
Normal file
24
packages/crdt/vite.config.mts
Normal file
@@ -0,0 +1,24 @@
|
||||
import path from 'path';
|
||||
|
||||
import { visualizer } from 'rollup-plugin-visualizer';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
ssr: {
|
||||
noExternal: true,
|
||||
external: ['google-protobuf', 'murmurhash'],
|
||||
},
|
||||
build: {
|
||||
ssr: true,
|
||||
target: 'node22',
|
||||
outDir: path.resolve(__dirname, 'dist'),
|
||||
emptyOutDir: true,
|
||||
sourcemap: true,
|
||||
lib: {
|
||||
entry: path.resolve(__dirname, 'src/index.ts'),
|
||||
formats: ['cjs'],
|
||||
fileName: () => 'index.js',
|
||||
},
|
||||
},
|
||||
plugins: [visualizer({ template: 'raw-data', filename: 'dist/stats.json' })],
|
||||
});
|
||||
@@ -65,10 +65,10 @@ Run manually:
|
||||
|
||||
```sh
|
||||
# Run docker container
|
||||
docker run --rm --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.58.2-jammy /bin/bash
|
||||
docker run --rm --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.59.1-jammy /bin/bash
|
||||
|
||||
# If you receive an error such as "docker: invalid reference format", please instead use the following command:
|
||||
docker run --rm --network host -v ${pwd}:/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.58.2-jammy /bin/bash
|
||||
docker run --rm --network host -v ${pwd}:/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.59.1-jammy /bin/bash
|
||||
|
||||
# Once inside the docker container, run the VRT tests: important - they MUST be ran against a HTTPS server.
|
||||
# Use the ip and port noted earlier
|
||||
|
||||
@@ -7,10 +7,9 @@ echo "Building the browser..."
|
||||
|
||||
rm -fr build
|
||||
|
||||
export IS_GENERIC_BROWSER=1
|
||||
export REACT_APP_BACKEND_WORKER_HASH=`ls "$ROOT"/../public/kcab/kcab.worker.*.js | sed 's/.*kcab\.worker\.\(.*\)\.js/\1/'`
|
||||
|
||||
yarn build
|
||||
yarn build --mode=browser
|
||||
|
||||
rm -fr build-stats
|
||||
mkdir build-stats
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
ROOT=`dirname $0`
|
||||
cd "$ROOT/.."
|
||||
|
||||
export IS_GENERIC_BROWSER=1
|
||||
export PORT=3001
|
||||
export REACT_APP_BACKEND_WORKER_HASH="dev"
|
||||
|
||||
yarn start
|
||||
yarn start --mode=browser
|
||||
|
||||
@@ -111,23 +111,20 @@
|
||||
"@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/state": "^6.5.4",
|
||||
"@codemirror/view": "^6.38.7",
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/view": "^6.41.0",
|
||||
"@emotion/css": "^11.13.5",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource/redacted-script": "^5.2.8",
|
||||
"@juggle/resize-observer": "^3.4.0",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@playwright/test": "1.58.2",
|
||||
"@react-aria/interactions": "^3.27.0",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@react-aria/interactions": "^3.27.1",
|
||||
"@reduxjs/toolkit": "^2.11.2",
|
||||
"@rolldown/plugin-babel": "~0.1.8",
|
||||
"@rollup/plugin-inject": "^5.0.5",
|
||||
"@swc/core": "^1.15.18",
|
||||
"@swc/helpers": "^0.5.19",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tanstack/react-query": "^5.96.2",
|
||||
"@testing-library/dom": "10.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "16.3.2",
|
||||
@@ -137,12 +134,11 @@
|
||||
"@types/promise-retry": "^1.1.6",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-modal": "^3.16.3",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260309.1",
|
||||
"@uiw/react-codemirror": "^4.25.7",
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260404.1",
|
||||
"@uiw/react-codemirror": "^4.25.9",
|
||||
"@use-gesture/react": "^10.3.1",
|
||||
"@vitejs/plugin-basic-ssl": "^2.2.0",
|
||||
"@vitejs/plugin-react": "^6.0.0",
|
||||
"@vitejs/plugin-basic-ssl": "^2.3.0",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"absurd-sql": "0.0.54",
|
||||
"auto-text-size": "^0.2.3",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
@@ -152,47 +148,44 @@
|
||||
"downshift": "9.3.2",
|
||||
"html-to-image": "^1.11.13",
|
||||
"hyperformula": "^3.2.0",
|
||||
"i18next": "^25.8.14",
|
||||
"i18next": "^25.10.10",
|
||||
"i18next-parser": "^9.4.0",
|
||||
"i18next-resources-to-backend": "^1.2.1",
|
||||
"jsdom": "^27.4.0",
|
||||
"lodash": "^4.18.1",
|
||||
"lru-cache": "^11.2.6",
|
||||
"lru-cache": "^11.2.7",
|
||||
"mdast-util-newline-to-break": "^2.0.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
"pikaday": "1.8.2",
|
||||
"promise-retry": "^2.0.1",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react": "19.2.4",
|
||||
"react-aria": "^3.46.0",
|
||||
"react-aria-components": "^1.15.1",
|
||||
"react-aria": "^3.47.0",
|
||||
"react-aria-components": "^1.16.0",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
"react-dom": "19.2.4",
|
||||
"react-error-boundary": "^6.0.3",
|
||||
"react-grid-layout": "^2.2.2",
|
||||
"react-error-boundary": "^6.1.1",
|
||||
"react-grid-layout": "^2.2.3",
|
||||
"react-hotkeys-hook": "^5.2.4",
|
||||
"react-i18next": "^16.5.6",
|
||||
"react-i18next": "^16.6.6",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-modal": "3.16.3",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "7.13.1",
|
||||
"react-simple-pull-to-refresh": "^1.3.4",
|
||||
"react-spring": "^10.0.3",
|
||||
"react-swipeable": "^7.0.2",
|
||||
"react-virtualized-auto-sizer": "^2.0.3",
|
||||
"recharts": "^3.7.0",
|
||||
"recharts": "^3.8.1",
|
||||
"rehype-external-links": "^3.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"rolldown": "^1.0.0-rc.12",
|
||||
"rollup-plugin-visualizer": "^6.0.11",
|
||||
"sass": "^1.97.3",
|
||||
"rolldown": "^1.0.0-rc.13",
|
||||
"rollup-plugin-visualizer": "^7.0.1",
|
||||
"sass": "^1.99.0",
|
||||
"typescript-strict-plugin": "^2.4.4",
|
||||
"usehooks-ts": "^3.1.1",
|
||||
"uuid": "^13.0.0",
|
||||
"vite": "^8.0.5",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vitest": "^4.1.0",
|
||||
"xml2js": "^0.6.2"
|
||||
"vitest": "^4.1.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import type {
|
||||
} from '@actual-app/core/types/models';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import type { QueryClient, QueryKey } from '@tanstack/react-query';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { sync } from '#app/appSlice';
|
||||
import { useAccounts } from '#hooks/useAccounts';
|
||||
@@ -44,7 +43,7 @@ const dispatchErrorNotification = (
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
id: uuidv4(),
|
||||
id: crypto.randomUUID(),
|
||||
type: 'error',
|
||||
message,
|
||||
pre: error ? error.message : undefined,
|
||||
|
||||
@@ -9,7 +9,6 @@ import type {
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import type { QueryClient, QueryKey } from '@tanstack/react-query';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { pushModal } from '#modals/modalsSlice';
|
||||
import { addNotification } from '#notifications/notificationsSlice';
|
||||
@@ -32,7 +31,7 @@ function dispatchErrorNotification(
|
||||
dispatch(
|
||||
addNotification({
|
||||
notification: {
|
||||
id: uuidv4(),
|
||||
id: crypto.randomUUID(),
|
||||
type: 'error',
|
||||
message,
|
||||
pre: error ? error.message : undefined,
|
||||
|
||||
@@ -3,7 +3,7 @@ const global = globalThis || this || self;
|
||||
const process = {
|
||||
env: {
|
||||
...import.meta.env,
|
||||
NODE_ENV: import.meta.env.MODE,
|
||||
NODE_ENV: import.meta.env.DEV ? 'development' : 'production',
|
||||
PUBLIC_URL: import.meta.env.BASE_URL.slice(0, -1),
|
||||
},
|
||||
};
|
||||
|
||||
65
packages/desktop-client/src/components/FatalError.test.tsx
Normal file
65
packages/desktop-client/src/components/FatalError.test.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { LazyLoadFailedError } from '@actual-app/core/shared/errors';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { TestProviders } from '#mocks';
|
||||
|
||||
import { FatalError } from './FatalError';
|
||||
|
||||
describe('FatalError', () => {
|
||||
it('renders the SharedArrayBuffer message for a non-Error AppError payload', () => {
|
||||
// matches what browser-server.js posts and what initAll().catch passes
|
||||
const error = {
|
||||
type: 'app-init-failure',
|
||||
SharedArrayBufferMissing: true,
|
||||
};
|
||||
|
||||
render(<FatalError error={error} />, { wrapper: TestProviders });
|
||||
|
||||
expect(screen.getAllByText(/SharedArrayBuffer/).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('renders the IndexedDB message for a non-Error AppError payload', () => {
|
||||
const error = {
|
||||
type: 'app-init-failure',
|
||||
IDBFailure: true,
|
||||
};
|
||||
|
||||
render(<FatalError error={error} />, { wrapper: TestProviders });
|
||||
|
||||
expect(screen.getByText(/IndexedDB/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the generic simple message for an app-init-failure without a specific cause', () => {
|
||||
const error = {
|
||||
type: 'app-init-failure',
|
||||
BackendInitFailure: true,
|
||||
};
|
||||
|
||||
render(<FatalError error={error} />, { wrapper: TestProviders });
|
||||
|
||||
expect(
|
||||
screen.getByText(/problem loading the app in this browser version/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the UI error message for a generic Error', () => {
|
||||
render(<FatalError error={new Error('boom')} />, {
|
||||
wrapper: TestProviders,
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText(/unrecoverable error in the UI/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the lazy load message for a LazyLoadFailedError', () => {
|
||||
render(<FatalError error={new LazyLoadFailedError('SomeModule', null)} />, {
|
||||
wrapper: TestProviders,
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText(/problem loading one of the chunks/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -24,10 +24,12 @@ type AppError = Error & {
|
||||
};
|
||||
|
||||
type FatalErrorProps = {
|
||||
error: Error | AppError;
|
||||
error: unknown;
|
||||
};
|
||||
|
||||
type RenderSimpleProps = FatalErrorProps;
|
||||
type RenderSimpleProps = {
|
||||
error: Error | AppError;
|
||||
};
|
||||
|
||||
function RenderSimple({ error }: RenderSimpleProps) {
|
||||
let msg: ReactNode;
|
||||
@@ -195,7 +197,7 @@ function SharedArrayBufferOverride() {
|
||||
);
|
||||
}
|
||||
|
||||
export function FatalError({ error }: FatalErrorProps) {
|
||||
export function FatalError({ error: rawError }: FatalErrorProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { modalStack } = useModalState();
|
||||
@@ -203,6 +205,12 @@ export function FatalError({ error }: FatalErrorProps) {
|
||||
|
||||
const [showError, setShowError] = useState(false);
|
||||
|
||||
const error: Error | AppError =
|
||||
rawError instanceof Error
|
||||
? rawError
|
||||
: rawError && typeof rawError === 'object'
|
||||
? Object.assign(new Error(String(rawError)), rawError)
|
||||
: new Error(String(rawError));
|
||||
const showSimpleRender = 'type' in error && error.type === 'app-init-failure';
|
||||
const isLazyLoadError = error instanceof LazyLoadFailedError;
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import type { FallbackProps } from 'react-error-boundary';
|
||||
import { Trans } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
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';
|
||||
|
||||
export function FeatureErrorFallback({
|
||||
error,
|
||||
resetErrorBoundary,
|
||||
}: FallbackProps) {
|
||||
useEffect(() => {
|
||||
console.error(error);
|
||||
}, [error]);
|
||||
|
||||
const message = error instanceof Error ? error.message : undefined;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 20,
|
||||
}}
|
||||
>
|
||||
<Text style={{ ...styles.mediumText, color: theme.errorText }}>
|
||||
<Trans>Something went wrong loading this section.</Trans>
|
||||
</Text>
|
||||
{message && (
|
||||
<Text
|
||||
style={{
|
||||
...styles.smallText,
|
||||
fontFamily: 'monospace',
|
||||
color: theme.errorText,
|
||||
marginTop: 10,
|
||||
maxWidth: 600,
|
||||
textAlign: 'center',
|
||||
userSelect: 'text',
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
</Text>
|
||||
)}
|
||||
<Button onPress={resetErrorBoundary} style={{ marginTop: 15 }}>
|
||||
<Trans>Try again</Trans>
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useEffect, useEffectEvent, useRef } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Navigate, Route, Routes, useHref, useLocation } from 'react-router';
|
||||
|
||||
@@ -23,6 +24,7 @@ import { useDispatch, useSelector } from '#redux';
|
||||
import { UserAccessPage } from './admin/UserAccess/UserAccessPage';
|
||||
import { BankSyncStatus } from './BankSyncStatus';
|
||||
import { CommandBar } from './CommandBar';
|
||||
import { FeatureErrorFallback } from './FeatureErrorFallback';
|
||||
import { GlobalKeys } from './GlobalKeys';
|
||||
import { MobileBankSyncAccountEditPage } from './mobile/banksync/MobileBankSyncAccountEditPage';
|
||||
import { MobileNavTabs } from './mobile/MobileNavTabs';
|
||||
@@ -86,6 +88,7 @@ export function FinancesApp() {
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
useMetaThemeColor(isNarrowWidth ? theme.mobileViewTheme : undefined);
|
||||
|
||||
const location = useLocation();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -286,11 +289,25 @@ export function FinancesApp() {
|
||||
/>
|
||||
<Route
|
||||
path="/rules"
|
||||
element={<NarrowAlternate name="Rules" />}
|
||||
element={
|
||||
<ErrorBoundary
|
||||
FallbackComponent={FeatureErrorFallback}
|
||||
resetKeys={[location.pathname]}
|
||||
>
|
||||
<NarrowAlternate name="Rules" />
|
||||
</ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/rules/:id"
|
||||
element={<NarrowAlternate name="RuleEdit" />}
|
||||
element={
|
||||
<ErrorBoundary
|
||||
FallbackComponent={FeatureErrorFallback}
|
||||
resetKeys={[location.pathname]}
|
||||
>
|
||||
<NarrowAlternate name="RuleEdit" />
|
||||
</ErrorBoundary>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/bank-sync"
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import React from 'react';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { FeatureErrorFallback } from '#components/FeatureErrorFallback';
|
||||
|
||||
import { ManageRules } from './ManageRules';
|
||||
import { Page } from './Page';
|
||||
|
||||
export function ManageRulesPage() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Page header={t('Rules')}>
|
||||
<ManageRules isModal={false} payeeId={null} />
|
||||
</Page>
|
||||
<ErrorBoundary FallbackComponent={FeatureErrorFallback}>
|
||||
<Page header={t('Rules')}>
|
||||
<ManageRules isModal={false} payeeId={null} />
|
||||
</Page>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ export function Notes({
|
||||
placeholder={t('Notes (markdown supported)')}
|
||||
/>
|
||||
) : (
|
||||
<Text className={css([markdownStyles, getStyle?.(editable ?? false)])}>
|
||||
<Text className={css([markdownStyles, getStyle?.(false)])}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={[
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { createRef, PureComponent, useEffect, useMemo } from 'react';
|
||||
import type { ReactElement, RefObject } from 'react';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { Trans } from 'react-i18next';
|
||||
import { Navigate, useLocation, useParams } from 'react-router';
|
||||
|
||||
@@ -35,7 +36,6 @@ import type {
|
||||
import { t } from 'i18next';
|
||||
import debounce from 'lodash/debounce';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import {
|
||||
useReopenAccountMutation,
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
useUpdateAccountMutation,
|
||||
} from '#accounts';
|
||||
import { markAccountRead } from '#accounts/accountsSlice';
|
||||
import { FeatureErrorFallback } from '#components/FeatureErrorFallback';
|
||||
import type { SavedFilter } from '#components/filters/SavedFilterMenuButton';
|
||||
import { TransactionList } from '#components/transactions/TransactionList';
|
||||
import { validateAccountName } from '#components/util/accountValidation';
|
||||
@@ -495,16 +496,18 @@ class AccountInternal extends PureComponent<
|
||||
}
|
||||
}
|
||||
|
||||
const balances = this.state.showBalances
|
||||
? await this.calculateBalances()
|
||||
: null;
|
||||
const filteredAmount = await this.getFilteredAmount();
|
||||
this.setState(
|
||||
{
|
||||
transactions: data,
|
||||
transactionsFiltered: isFiltered,
|
||||
loading: false,
|
||||
workingHard: false,
|
||||
balances: this.state.showBalances
|
||||
? await this.calculateBalances()
|
||||
: null,
|
||||
filteredAmount: await this.getFilteredAmount(),
|
||||
balances,
|
||||
filteredAmount,
|
||||
},
|
||||
() => {
|
||||
if (firstLoad) {
|
||||
@@ -1025,10 +1028,10 @@ class AccountInternal extends PureComponent<
|
||||
const lastReconciled = new Date().getTime().toString();
|
||||
this.props.onUpdateAccount({ ...account, last_reconciled: lastReconciled });
|
||||
|
||||
this.setState({
|
||||
this.setState(state => ({
|
||||
reconcileAmount: null,
|
||||
showCleared: this.state.prevShowCleared,
|
||||
});
|
||||
showCleared: state.prevShowCleared,
|
||||
}));
|
||||
};
|
||||
|
||||
onCreateReconciliationTransaction = async (diff: number) => {
|
||||
@@ -1046,9 +1049,9 @@ class AccountInternal extends PureComponent<
|
||||
]);
|
||||
|
||||
// Optimistic UI: update the transaction list before sending the data to the database
|
||||
this.setState({
|
||||
transactions: [...reconciliationTransactions, ...this.state.transactions],
|
||||
});
|
||||
this.setState(state => ({
|
||||
transactions: [...reconciliationTransactions, ...state.transactions],
|
||||
}));
|
||||
|
||||
// run rules on the reconciliation transaction
|
||||
const ruledTransactions = await Promise.all(
|
||||
@@ -1115,7 +1118,7 @@ class AccountInternal extends PureComponent<
|
||||
|
||||
const [firstTransaction] = transactions;
|
||||
const parentTransaction = {
|
||||
id: uuidv4(),
|
||||
id: crypto.randomUUID(),
|
||||
is_parent: true,
|
||||
cleared: transactions.every(t => !!t.cleared),
|
||||
date: firstTransaction.date,
|
||||
@@ -1359,10 +1362,10 @@ class AccountInternal extends PureComponent<
|
||||
};
|
||||
|
||||
onConditionsOpChange = (value: 'and' | 'or') => {
|
||||
this.setState({ filterConditionsOp: value });
|
||||
this.setState({
|
||||
filterId: { ...this.state.filterId, status: 'changed' } as SavedFilter,
|
||||
});
|
||||
this.setState(state => ({
|
||||
filterConditionsOp: value,
|
||||
filterId: { ...state.filterId, status: 'changed' } as SavedFilter,
|
||||
}));
|
||||
void this.applyFilters([...this.state.filterConditions]);
|
||||
if (this.state.search !== '') {
|
||||
this.onSearch(this.state.search);
|
||||
@@ -1384,7 +1387,9 @@ class AccountInternal extends PureComponent<
|
||||
void this.applyFilters([...(savedFilter.conditions ?? [])]);
|
||||
}
|
||||
}
|
||||
this.setState({ filterId: { ...this.state.filterId, ...savedFilter } });
|
||||
this.setState(state => ({
|
||||
filterId: { ...state.filterId, ...savedFilter },
|
||||
}));
|
||||
};
|
||||
|
||||
onClearFilters = () => {
|
||||
@@ -1405,12 +1410,12 @@ class AccountInternal extends PureComponent<
|
||||
c === oldCondition ? updatedCondition : c,
|
||||
),
|
||||
);
|
||||
this.setState({
|
||||
this.setState(state => ({
|
||||
filterId: {
|
||||
...this.state.filterId,
|
||||
status: this.state.filterId && 'changed',
|
||||
...state.filterId,
|
||||
status: state.filterId && 'changed',
|
||||
} as SavedFilter,
|
||||
});
|
||||
}));
|
||||
if (this.state.search !== '') {
|
||||
this.onSearch(this.state.search);
|
||||
}
|
||||
@@ -1421,15 +1426,14 @@ class AccountInternal extends PureComponent<
|
||||
this.state.filterConditions.filter(c => c !== condition),
|
||||
);
|
||||
if (this.state.filterConditions.length === 1) {
|
||||
this.setState({ filterId: undefined });
|
||||
this.setState({ filterConditionsOp: 'and' });
|
||||
this.setState({ filterId: undefined, filterConditionsOp: 'and' });
|
||||
} else {
|
||||
this.setState({
|
||||
this.setState(state => ({
|
||||
filterId: {
|
||||
...this.state.filterId,
|
||||
status: this.state.filterId && 'changed',
|
||||
...state.filterId,
|
||||
status: state.filterId && 'changed',
|
||||
} as SavedFilter,
|
||||
});
|
||||
}));
|
||||
}
|
||||
if (this.state.search !== '') {
|
||||
this.onSearch(this.state.search);
|
||||
@@ -1467,12 +1471,12 @@ class AccountInternal extends PureComponent<
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
this.setState(state => ({
|
||||
filterId: {
|
||||
...this.state.filterId,
|
||||
status: this.state.filterId && 'changed',
|
||||
...state.filterId,
|
||||
status: state.filterId && 'changed',
|
||||
} as SavedFilter,
|
||||
});
|
||||
}));
|
||||
void this.applyFilters([...filterConditions, condition]);
|
||||
}
|
||||
|
||||
@@ -1672,25 +1676,26 @@ class AccountInternal extends PureComponent<
|
||||
if (headerClicked === this.state.sort?.field) {
|
||||
prevField = this.state.sort.prevField;
|
||||
prevAscDesc = this.state.sort.prevAscDesc;
|
||||
this.setState({
|
||||
this.setState(state => ({
|
||||
sort: {
|
||||
...this.state.sort,
|
||||
...state.sort,
|
||||
field: headerClicked,
|
||||
ascDesc,
|
||||
},
|
||||
});
|
||||
}));
|
||||
} else {
|
||||
//if switching to new column then capture state
|
||||
//of current sort column as prev
|
||||
prevField = this.state.sort?.field;
|
||||
prevAscDesc = this.state.sort?.ascDesc;
|
||||
this.setState({
|
||||
this.setState(state => ({
|
||||
sort: {
|
||||
field: headerClicked,
|
||||
ascDesc,
|
||||
prevField: this.state.sort?.field,
|
||||
prevAscDesc: this.state.sort?.ascDesc,
|
||||
prevField: state.sort?.field,
|
||||
prevAscDesc: state.sort?.ascDesc,
|
||||
},
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
this.applySort(headerClicked, ascDesc, prevField, prevAscDesc);
|
||||
@@ -2028,48 +2033,50 @@ export function Account() {
|
||||
createPayee.mutateAsync({ name });
|
||||
|
||||
return (
|
||||
<SchedulesProvider query={schedulesQuery}>
|
||||
<SplitsExpandedProvider
|
||||
initialMode={expandSplits ? 'collapse' : 'expand'}
|
||||
>
|
||||
<AccountHack
|
||||
newTransactions={newTransactions}
|
||||
matchedTransactions={matchedTransactions}
|
||||
accounts={accounts}
|
||||
failedAccounts={failedAccounts}
|
||||
dateFormat={dateFormat}
|
||||
hideFraction={String(hideFraction) === 'true'}
|
||||
expandSplits={expandSplits}
|
||||
showBalances={String(showBalances) === 'true'}
|
||||
setShowBalances={showBalances =>
|
||||
setShowBalances(String(showBalances))
|
||||
}
|
||||
showNetWorthChart={String(showNetWorthChart) === 'true'}
|
||||
setShowNetWorthChart={val => setShowNetWorthChart(String(val))}
|
||||
showCleared={String(hideCleared) !== 'true'}
|
||||
setShowCleared={val => setHideCleared(String(!val))}
|
||||
showReconciled={String(hideReconciled) !== 'true'}
|
||||
setShowReconciled={val => setHideReconciled(String(!val))}
|
||||
showExtraBalances={String(showExtraBalances) === 'true'}
|
||||
setShowExtraBalances={extraBalances =>
|
||||
setShowExtraBalances(String(extraBalances))
|
||||
}
|
||||
payees={payees}
|
||||
modalShowing={modalShowing}
|
||||
accountsSyncing={accountsSyncing}
|
||||
filterConditions={filterConditions}
|
||||
categoryGroups={categoryGroups}
|
||||
accountId={params.id}
|
||||
categoryId={location?.state?.categoryId}
|
||||
location={location}
|
||||
savedFilters={savedFiters}
|
||||
onReopenAccount={onReopenAccount}
|
||||
onUpdateAccount={onUpdateAccount}
|
||||
onUnlinkAccount={onUnlinkAccount}
|
||||
onSyncAndDownload={onSyncAndDownload}
|
||||
onCreatePayee={onCreatePayee}
|
||||
/>
|
||||
</SplitsExpandedProvider>
|
||||
</SchedulesProvider>
|
||||
<ErrorBoundary FallbackComponent={FeatureErrorFallback}>
|
||||
<SchedulesProvider query={schedulesQuery}>
|
||||
<SplitsExpandedProvider
|
||||
initialMode={expandSplits ? 'collapse' : 'expand'}
|
||||
>
|
||||
<AccountHack
|
||||
newTransactions={newTransactions}
|
||||
matchedTransactions={matchedTransactions}
|
||||
accounts={accounts}
|
||||
failedAccounts={failedAccounts}
|
||||
dateFormat={dateFormat}
|
||||
hideFraction={String(hideFraction) === 'true'}
|
||||
expandSplits={expandSplits}
|
||||
showBalances={String(showBalances) === 'true'}
|
||||
setShowBalances={showBalances =>
|
||||
setShowBalances(String(showBalances))
|
||||
}
|
||||
showNetWorthChart={String(showNetWorthChart) === 'true'}
|
||||
setShowNetWorthChart={val => setShowNetWorthChart(String(val))}
|
||||
showCleared={String(hideCleared) !== 'true'}
|
||||
setShowCleared={val => setHideCleared(String(!val))}
|
||||
showReconciled={String(hideReconciled) !== 'true'}
|
||||
setShowReconciled={val => setHideReconciled(String(!val))}
|
||||
showExtraBalances={String(showExtraBalances) === 'true'}
|
||||
setShowExtraBalances={extraBalances =>
|
||||
setShowExtraBalances(String(extraBalances))
|
||||
}
|
||||
payees={payees}
|
||||
modalShowing={modalShowing}
|
||||
accountsSyncing={accountsSyncing}
|
||||
filterConditions={filterConditions}
|
||||
categoryGroups={categoryGroups}
|
||||
accountId={params.id}
|
||||
categoryId={location?.state?.categoryId}
|
||||
location={location}
|
||||
savedFilters={savedFiters}
|
||||
onReopenAccount={onReopenAccount}
|
||||
onUpdateAccount={onUpdateAccount}
|
||||
onUnlinkAccount={onUnlinkAccount}
|
||||
onSyncAndDownload={onSyncAndDownload}
|
||||
onCreatePayee={onCreatePayee}
|
||||
/>
|
||||
</SplitsExpandedProvider>
|
||||
</SchedulesProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
// @ts-strict-ignore
|
||||
import React, { useEffect } from 'react';
|
||||
import type { ComponentProps } from 'react';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { useHotkeys } from 'react-hotkeys-hook';
|
||||
import { AutoSizer } from 'react-virtualized-auto-sizer';
|
||||
|
||||
import { View } from '@actual-app/components/view';
|
||||
import * as monthUtils from '@actual-app/core/shared/months';
|
||||
|
||||
import { FeatureErrorFallback } from '#components/FeatureErrorFallback';
|
||||
import { useGlobalPref } from '#hooks/useGlobalPref';
|
||||
|
||||
import { useBudgetMonthCount } from './BudgetMonthCountContext';
|
||||
@@ -131,20 +133,22 @@ const DynamicBudgetTable = ({
|
||||
}}
|
||||
>
|
||||
<View style={{ width: '100%', maxWidth }}>
|
||||
<BudgetPageHeader
|
||||
startMonth={prewarmStartMonth}
|
||||
numMonths={numMonths}
|
||||
monthBounds={monthBounds}
|
||||
onMonthSelect={_onMonthSelect}
|
||||
/>
|
||||
<BudgetTable
|
||||
type={type}
|
||||
prewarmStartMonth={prewarmStartMonth}
|
||||
startMonth={startMonth}
|
||||
numMonths={numMonths}
|
||||
monthBounds={monthBounds}
|
||||
{...props}
|
||||
/>
|
||||
<ErrorBoundary FallbackComponent={FeatureErrorFallback}>
|
||||
<BudgetPageHeader
|
||||
startMonth={prewarmStartMonth}
|
||||
numMonths={numMonths}
|
||||
monthBounds={monthBounds}
|
||||
onMonthSelect={_onMonthSelect}
|
||||
/>
|
||||
<BudgetTable
|
||||
type={type}
|
||||
prewarmStartMonth={prewarmStartMonth}
|
||||
startMonth={startMonth}
|
||||
numMonths={numMonths}
|
||||
monthBounds={monthBounds}
|
||||
{...props}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -190,11 +190,7 @@ export function SidebarGroup({
|
||||
</Button>
|
||||
</Tooltip>
|
||||
|
||||
<NotesButton
|
||||
id={group.id}
|
||||
style={dragPreview && { color: 'currentColor' }}
|
||||
defaultColor={theme.pageTextLight}
|
||||
/>
|
||||
<NotesButton id={group.id} defaultColor={theme.pageTextLight} />
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -42,7 +42,7 @@ export function removeCategoriesFromGroups(
|
||||
categoryGroups: CategoryGroupEntity[],
|
||||
...categoryIds: CategoryEntity['id'][]
|
||||
) {
|
||||
if (!categoryIds || categoryIds.length === 0) return categoryGroups;
|
||||
if (categoryIds.length === 0) return categoryGroups;
|
||||
|
||||
const categoryIdsSet = new Set(categoryIds);
|
||||
|
||||
|
||||
@@ -35,8 +35,11 @@ import {
|
||||
} from 'date-fns';
|
||||
|
||||
import { GenericInput } from '#components/util/GenericInput';
|
||||
import { useAccounts } from '#hooks/useAccounts';
|
||||
import { useCategories } from '#hooks/useCategories';
|
||||
import { useDateFormat } from '#hooks/useDateFormat';
|
||||
import { useFormat } from '#hooks/useFormat';
|
||||
import { usePayees } from '#hooks/usePayees';
|
||||
import { useTransactionFilters } from '#hooks/useTransactionFilters';
|
||||
|
||||
import { CompactFiltersButton } from './CompactFiltersButton';
|
||||
@@ -92,6 +95,9 @@ function ConfigureField<T extends RuleConditionEntity>({
|
||||
const { t } = useTranslation();
|
||||
const format = useFormat();
|
||||
const dateFormat = useDateFormat() || 'MM/dd/yyyy';
|
||||
const accounts = useAccounts();
|
||||
const categories = useCategories();
|
||||
const payees = usePayees();
|
||||
const field = initialField === 'category_group' ? 'category' : initialField;
|
||||
const [subfield, setSubfield] = useState(initialSubfield);
|
||||
const inputRef = useRef<AmountInputRef>(null);
|
||||
@@ -136,9 +142,183 @@ function ConfigureField<T extends RuleConditionEntity>({
|
||||
return value;
|
||||
}, [value, field, subfield, dateFormat]);
|
||||
|
||||
// For ops that filter based on payeeId, those use PayeeFilter, otherwise we use GenericInput
|
||||
const isPayeeIdOp = (op: T['op']) =>
|
||||
['is', 'is not', 'one of', 'not one of'].includes(op);
|
||||
// For ops that filter based on IDs
|
||||
const isIdOp = (op: T['op']) =>
|
||||
['is', 'isNot', 'oneOf', 'notOneOf'].includes(op);
|
||||
// For ops that use exact matching with a single stored ID value
|
||||
const isSingleIdOp = (op: T['op']) => ['is', 'isNot'].includes(op);
|
||||
// For ops that use exact matching with multiple stored ID values
|
||||
const isMultiIdOp = (op: T['op']) => ['oneOf', 'notOneOf'].includes(op);
|
||||
// For ops that use text matching and expect a string input
|
||||
const isTextOp = (op: T['op']) =>
|
||||
['contains', 'matches', 'doesNotContain'].includes(op);
|
||||
// For account ops that do not use an input value but should preserve the current value in state
|
||||
const isNoValueAccountOp = (op: T['op']) =>
|
||||
['onBudget', 'offBudget'].includes(op);
|
||||
|
||||
// Convert stored ID value into text
|
||||
const resolveIdToText = (field: string, subfield: string, value: unknown) => {
|
||||
if (typeof value !== 'string') {
|
||||
return '';
|
||||
}
|
||||
if (field === 'account') {
|
||||
const account = accounts.data?.find(account => account.id === value);
|
||||
return account?.name ?? '';
|
||||
}
|
||||
if (field === 'payee') {
|
||||
const payee = payees.data?.find(payee => payee.id === value);
|
||||
return payee?.name ?? '';
|
||||
}
|
||||
if (field === 'category' && subfield === 'category_group') {
|
||||
const group = categories.data?.grouped.find(group => group.id === value);
|
||||
return group?.name ?? '';
|
||||
}
|
||||
if (field === 'category' && subfield === 'category') {
|
||||
for (const group of categories.data?.grouped || []) {
|
||||
const category = group.categories?.find(
|
||||
category => category.id === value,
|
||||
);
|
||||
if (category) {
|
||||
return category.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
// Convert text into stored ID value
|
||||
const resolveTextToId = (field: string, subfield: string, value: unknown) => {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
if (field === 'account') {
|
||||
const matches =
|
||||
accounts.data?.filter(account => account.name === value) ?? [];
|
||||
return matches.length === 1 ? matches[0].id : null;
|
||||
}
|
||||
if (field === 'payee') {
|
||||
const matches = payees.data?.filter(payee => payee.name === value) ?? [];
|
||||
return matches.length === 1 ? matches[0].id : null;
|
||||
}
|
||||
if (field === 'category' && subfield === 'category_group') {
|
||||
const matches =
|
||||
categories.data?.grouped.filter(group => group.name === value) ?? [];
|
||||
return matches.length === 1 ? matches[0].id : null;
|
||||
}
|
||||
if (field === 'category' && subfield === 'category') {
|
||||
const matches = [];
|
||||
for (const group of categories.data?.grouped || []) {
|
||||
for (const category of group.categories || []) {
|
||||
if (category.name === value) {
|
||||
matches.push(category);
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches.length === 1 ? matches[0].id : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const isIdField =
|
||||
field === 'account' || field === 'payee' || field === 'category';
|
||||
|
||||
// Converting values when switching between ops is a bit tricky, so we have some specific rules:
|
||||
const setOp = (nextOp: T['op']) => {
|
||||
// Single ID -> Text: Convert stored ID to text with one to one mapping
|
||||
if (isIdField && isSingleIdOp(op) && isTextOp(nextOp)) {
|
||||
dispatch({
|
||||
type: 'set-value',
|
||||
value: resolveIdToText(field, subfield, value),
|
||||
});
|
||||
}
|
||||
// Text -> Single ID: Only convert if there is a single exact match
|
||||
if (isIdField && isTextOp(op) && isSingleIdOp(nextOp)) {
|
||||
const resolvedValue = resolveTextToId(field, subfield, value);
|
||||
if (resolvedValue) {
|
||||
dispatch({
|
||||
type: 'set-value',
|
||||
value: resolvedValue,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Multi ID -> Text: If there is exactly one selected ID, convert it to text; otherwise clear the value
|
||||
if (isIdField && isMultiIdOp(op) && isTextOp(nextOp)) {
|
||||
if (Array.isArray(value) && value.length === 1) {
|
||||
dispatch({
|
||||
type: 'set-value',
|
||||
value: resolveIdToText(field, subfield, value[0]) || '',
|
||||
});
|
||||
} else {
|
||||
dispatch({
|
||||
type: 'set-value',
|
||||
value: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
// Text -> Multi ID: Only convert if there is a single exact match and wrap in array
|
||||
if (isIdField && isTextOp(op) && isMultiIdOp(nextOp)) {
|
||||
const resolvedValue = resolveTextToId(field, subfield, value);
|
||||
if (resolvedValue) {
|
||||
dispatch({
|
||||
type: 'set-value',
|
||||
value: resolvedValue ? [resolvedValue] : [],
|
||||
});
|
||||
}
|
||||
}
|
||||
// No-value Account -> Text: Preserve the old value while the no-value op is selected,
|
||||
// then convert when switching back to text
|
||||
if (field === 'account' && isNoValueAccountOp(op) && isTextOp(nextOp)) {
|
||||
if (Array.isArray(value)) {
|
||||
dispatch({
|
||||
type: 'set-value',
|
||||
value:
|
||||
value.length === 1
|
||||
? resolveIdToText(field, subfield, value[0]) || ''
|
||||
: '',
|
||||
});
|
||||
} else {
|
||||
dispatch({
|
||||
type: 'set-value',
|
||||
value: resolveIdToText(field, subfield, value) || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
// No-value Account -> Single-ID: If preserved value is text, resolve to an ID;
|
||||
// If it is already a single ID string, keep as-is
|
||||
if (field === 'account' && isNoValueAccountOp(op) && isSingleIdOp(nextOp)) {
|
||||
if (typeof value === 'string') {
|
||||
const resolvedValue = resolveTextToId(field, subfield, value);
|
||||
dispatch({
|
||||
type: 'set-value',
|
||||
value: resolvedValue || value,
|
||||
});
|
||||
} else {
|
||||
dispatch({
|
||||
type: 'set-value',
|
||||
value: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
// No-value Account -> Multi-ID: If the preserved value is text, resolve to a single ID and wrap;
|
||||
// if the preserved value is already a single ID string, wrap it directly;
|
||||
// otherwise clear
|
||||
if (field === 'account' && isNoValueAccountOp(op) && isMultiIdOp(nextOp)) {
|
||||
if (typeof value === 'string') {
|
||||
const resolvedValue = resolveTextToId(field, subfield, value);
|
||||
|
||||
dispatch({
|
||||
type: 'set-value',
|
||||
value: resolvedValue ? [resolvedValue] : [value],
|
||||
});
|
||||
} else {
|
||||
dispatch({
|
||||
type: 'set-value',
|
||||
value: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
dispatch({ type: 'set-op', op: nextOp });
|
||||
};
|
||||
|
||||
const subfieldSelectOptions = (
|
||||
field: 'amount' | 'date' | 'category',
|
||||
@@ -241,7 +421,7 @@ function ConfigureField<T extends RuleConditionEntity>({
|
||||
key={currOp}
|
||||
op={currOp}
|
||||
isSelected={currOp === op}
|
||||
onPress={() => dispatch({ type: 'set-op', op: currOp })}
|
||||
onPress={() => setOp(currOp)}
|
||||
/>
|
||||
))}
|
||||
{ops.slice(3, ops.length).map(currOp => (
|
||||
@@ -249,7 +429,7 @@ function ConfigureField<T extends RuleConditionEntity>({
|
||||
key={currOp}
|
||||
op={currOp}
|
||||
isSelected={currOp === op}
|
||||
onPress={() => dispatch({ type: 'set-op', op: currOp })}
|
||||
onPress={() => setOp(currOp)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
@@ -296,39 +476,44 @@ function ConfigureField<T extends RuleConditionEntity>({
|
||||
});
|
||||
}}
|
||||
>
|
||||
{type !== 'boolean' && (field !== 'payee' || !isPayeeIdOp(op)) && (
|
||||
<GenericInput
|
||||
ref={inputRef}
|
||||
// @ts-expect-error - fix me
|
||||
field={field === 'date' || field === 'category' ? subfield : field}
|
||||
// @ts-expect-error - fix me
|
||||
type={
|
||||
type === 'id' &&
|
||||
(op === 'contains' ||
|
||||
op === 'matches' ||
|
||||
op === 'doesNotContain' ||
|
||||
op === 'hasTags')
|
||||
? 'string'
|
||||
: type
|
||||
}
|
||||
numberFormatType="currency"
|
||||
// @ts-expect-error - fix me
|
||||
value={
|
||||
formattedValue ?? (op === 'oneOf' || op === 'notOneOf' ? [] : '')
|
||||
}
|
||||
// @ts-expect-error - fix me
|
||||
multi={op === 'oneOf' || op === 'notOneOf'}
|
||||
op={op}
|
||||
options={subfieldToOptions(field, subfield)}
|
||||
style={{ marginTop: 10 }}
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
onChange={(v: any) => {
|
||||
dispatch({ type: 'set-value', value: v });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{type !== 'boolean' &&
|
||||
(field !== 'payee' || !isIdOp(op)) &&
|
||||
(field !== 'account' || !isNoValueAccountOp(op)) && (
|
||||
<GenericInput
|
||||
ref={inputRef}
|
||||
// @ts-expect-error - fix me
|
||||
field={
|
||||
field === 'date' || field === 'category' ? subfield : field
|
||||
}
|
||||
// @ts-expect-error - fix me
|
||||
type={
|
||||
type === 'id' &&
|
||||
(op === 'contains' ||
|
||||
op === 'matches' ||
|
||||
op === 'doesNotContain' ||
|
||||
op === 'hasTags')
|
||||
? 'string'
|
||||
: type
|
||||
}
|
||||
numberFormatType="currency"
|
||||
// @ts-expect-error - fix me
|
||||
value={
|
||||
formattedValue ??
|
||||
(op === 'oneOf' || op === 'notOneOf' ? [] : '')
|
||||
}
|
||||
// @ts-expect-error - fix me
|
||||
multi={op === 'oneOf' || op === 'notOneOf'}
|
||||
op={op}
|
||||
options={subfieldToOptions(field, subfield)}
|
||||
style={{ marginTop: 10 }}
|
||||
// oxlint-disable-next-line typescript/no-explicit-any
|
||||
onChange={(v: any) => {
|
||||
dispatch({ type: 'set-value', value: v });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{field === 'payee' && isPayeeIdOp(op) && (
|
||||
{field === 'payee' && isIdOp(op) && (
|
||||
<PayeeFilter
|
||||
// @ts-expect-error - fix me
|
||||
value={formattedValue}
|
||||
|
||||
@@ -62,6 +62,7 @@ export function UncategorizedTransactions() {
|
||||
isLoadingMore={isLoadingMoreTransactions}
|
||||
onLoadMore={fetchMoreTransactions}
|
||||
onOpenTransaction={onOpenTransaction}
|
||||
showMakeTransfer
|
||||
/>
|
||||
</SchedulesProvider>
|
||||
);
|
||||
|
||||
@@ -99,7 +99,7 @@ export function EditFieldModal({
|
||||
});
|
||||
|
||||
switch (name) {
|
||||
case 'date':
|
||||
case 'date': {
|
||||
const today = currentDay();
|
||||
label = t('Date');
|
||||
minWidth = 350;
|
||||
@@ -115,6 +115,7 @@ export function EditFieldModal({
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case 'notes':
|
||||
label = t('Notes');
|
||||
@@ -172,6 +173,7 @@ export function EditFieldModal({
|
||||
<LabeledCheckbox
|
||||
id="noteRegex"
|
||||
checked={noteFindReplace.useRegex}
|
||||
style={{ color: theme.menuAutoCompleteText }}
|
||||
onChange={({ currentTarget: { checked } }) =>
|
||||
setNoteFindReplace(current => ({
|
||||
...current,
|
||||
@@ -252,6 +254,7 @@ export function EditFieldModal({
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unhandled edit field name: ${String(name)}`);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -128,7 +128,16 @@ export function SelectLinkedAccountsModal({
|
||||
const localAccounts = allAccounts.filter(a => a.closed === 0);
|
||||
const [draftLinkAccounts, setDraftLinkAccounts] = useState<
|
||||
Map<string, 'linking' | 'unlinking'>
|
||||
>(new Map());
|
||||
>(() => {
|
||||
const externalAccountIds = new Set(externalAccounts.map(a => a.account_id));
|
||||
const initial = new Map<string, 'linking' | 'unlinking'>();
|
||||
for (const acc of localAccounts) {
|
||||
if (acc.account_id && externalAccountIds.has(acc.account_id)) {
|
||||
initial.set(acc.account_id, 'linking');
|
||||
}
|
||||
}
|
||||
return initial;
|
||||
});
|
||||
const [chosenAccounts, setChosenAccounts] = useState<Record<string, string>>(
|
||||
() => {
|
||||
return Object.fromEntries(
|
||||
|
||||
@@ -126,6 +126,16 @@ const dateRangeOptions: dateRangeProps[] = [
|
||||
Monthly: true,
|
||||
Yearly: false,
|
||||
},
|
||||
{
|
||||
description: t('Last 30 days'),
|
||||
key: 'Last 30 days',
|
||||
name: 'last30Days',
|
||||
type: 'Day',
|
||||
Daily: true,
|
||||
Weekly: true,
|
||||
Monthly: true,
|
||||
Yearly: false,
|
||||
},
|
||||
{
|
||||
description: t('Last 3 months'),
|
||||
key: 'Last 3 months',
|
||||
|
||||
@@ -33,6 +33,10 @@ const currentIntervalOptions = [
|
||||
description: t('This month'),
|
||||
disableInclude: true,
|
||||
},
|
||||
{
|
||||
description: t('Last 30 days'),
|
||||
disableInclude: true,
|
||||
},
|
||||
{
|
||||
description: t('Year to date'),
|
||||
disableInclude: true,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { getLiveRange } from './getLiveRange';
|
||||
|
||||
// In test mode, monthUtils.currentDay() returns '2017-01-01'
|
||||
const EARLIEST = '2015-01-01';
|
||||
const LATEST = '2017-01-01';
|
||||
|
||||
describe('getLiveRange', () => {
|
||||
describe('Last 30 days', () => {
|
||||
it('returns the last 30 days ending today', () => {
|
||||
const [start, end] = getLiveRange(
|
||||
'Last 30 days',
|
||||
EARLIEST,
|
||||
LATEST,
|
||||
false,
|
||||
);
|
||||
// currentDay() = '2017-01-01', so 29 days before = '2016-12-03'
|
||||
expect(start).toBe('2016-12-03');
|
||||
expect(end).toBe('2017-01-01');
|
||||
});
|
||||
|
||||
it('is not affected by the includeCurrentInterval flag', () => {
|
||||
const [startExclude, endExclude] = getLiveRange(
|
||||
'Last 30 days',
|
||||
EARLIEST,
|
||||
LATEST,
|
||||
false,
|
||||
);
|
||||
const [startInclude, endInclude] = getLiveRange(
|
||||
'Last 30 days',
|
||||
EARLIEST,
|
||||
LATEST,
|
||||
true,
|
||||
);
|
||||
expect(startExclude).toBe(startInclude);
|
||||
expect(endExclude).toBe(endInclude);
|
||||
});
|
||||
|
||||
it('clamps start date to earliestTransaction when data is scarce', () => {
|
||||
const [start, end] = getLiveRange(
|
||||
'Last 30 days',
|
||||
'2016-12-20',
|
||||
LATEST,
|
||||
false,
|
||||
);
|
||||
expect(start).toBe('2016-12-20');
|
||||
expect(end).toBe('2017-01-01');
|
||||
});
|
||||
|
||||
it('clamps end date to latestTransaction when it precedes today', () => {
|
||||
const [start, end] = getLiveRange(
|
||||
'Last 30 days',
|
||||
EARLIEST,
|
||||
'2016-12-25',
|
||||
false,
|
||||
);
|
||||
expect(start).toBe('2016-12-03');
|
||||
expect(end).toBe('2016-12-25');
|
||||
});
|
||||
|
||||
it('returns sliding-window mode', () => {
|
||||
const [, , mode] = getLiveRange('Last 30 days', EARLIEST, LATEST, false);
|
||||
expect(mode).toBe('sliding-window');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -58,6 +58,15 @@ export function getLiveRange(
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'last30Days': {
|
||||
[dateStart, dateEnd] = validateRange(
|
||||
earliestTransaction,
|
||||
latestTransaction,
|
||||
monthUtils.subDays(monthUtils.currentDay(), 29),
|
||||
monthUtils.currentDay(),
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'allTime': {
|
||||
dateStart = earliestTransaction;
|
||||
dateEnd = latestTransaction;
|
||||
|
||||
@@ -8,6 +8,8 @@ import { theme } from '@actual-app/components/theme';
|
||||
import type {
|
||||
balanceTypeOpType,
|
||||
DataEntity,
|
||||
GroupedEntity,
|
||||
IntervalEntity,
|
||||
RuleConditionEntity,
|
||||
} from '@actual-app/core/types/models';
|
||||
import { css } from '@emotion/css';
|
||||
@@ -250,7 +252,7 @@ export function BarGraph({
|
||||
data[splitData] && (
|
||||
<div>
|
||||
{!compact && <div style={{ marginTop: '15px' }} />}
|
||||
<BarChart
|
||||
<BarChart<GroupedEntity | IntervalEntity>
|
||||
responsive
|
||||
width={width}
|
||||
height={height}
|
||||
|
||||
@@ -12,7 +12,7 @@ import type {
|
||||
RuleConditionEntity,
|
||||
} from '@actual-app/core/types/models';
|
||||
import { Pie, PieChart, Sector } from 'recharts';
|
||||
import type { PieSectorShapeProps } from 'recharts';
|
||||
import type { PieSectorDataItem, PieSectorShapeProps } from 'recharts';
|
||||
|
||||
import { FinancialText } from '#components/FinancialText';
|
||||
import { PrivacyFilter } from '#components/PrivacyFilter';
|
||||
@@ -31,6 +31,8 @@ const RADIAN = Math.PI / 180;
|
||||
|
||||
const canDeviceHover = () => window.matchMedia('(hover: hover)').matches;
|
||||
|
||||
type ClickablePieItem = PieSectorDataItem & { id?: string };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dimension helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -487,7 +489,7 @@ export function DonutGraph({
|
||||
setActiveRing('group');
|
||||
}
|
||||
}}
|
||||
onClick={(item, index) => {
|
||||
onClick={(item: ClickablePieItem, index) => {
|
||||
if (!canDeviceHover()) {
|
||||
setActiveGroupIndex(index);
|
||||
setActiveRing('group');
|
||||
@@ -574,7 +576,7 @@ export function DonutGraph({
|
||||
setPointer('pointer');
|
||||
}
|
||||
}}
|
||||
onClick={(item, index) => {
|
||||
onClick={(item: ClickablePieItem, index) => {
|
||||
if (!canDeviceHover()) {
|
||||
setActiveCategoryIndex(index);
|
||||
setActiveRing('category');
|
||||
@@ -667,7 +669,7 @@ export function DonutGraph({
|
||||
}
|
||||
}
|
||||
}}
|
||||
onClick={(item, index) => {
|
||||
onClick={(item: ClickablePieItem, index) => {
|
||||
if (!canDeviceHover()) {
|
||||
setActiveIndex(index);
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ type NetWorthDataPoint = {
|
||||
date: string;
|
||||
} & Record<string, string | number>;
|
||||
|
||||
type TrendTooltipProps = TooltipContentProps<number, string> & {
|
||||
type TrendTooltipProps = TooltipContentProps & {
|
||||
style?: CSSProperties;
|
||||
};
|
||||
|
||||
@@ -97,7 +97,7 @@ function TrendTooltip({ active, payload, style }: TrendTooltipProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
type StackedTooltipProps = TooltipContentProps<number, string> & {
|
||||
type StackedTooltipProps = TooltipContentProps & {
|
||||
sortedAccounts: Array<{ id: string; name: string }>;
|
||||
accounts: Array<{ id: string; name: string }>;
|
||||
hoveredAccountId: string | null;
|
||||
@@ -394,14 +394,14 @@ export function NetWorthGraph({
|
||||
tickLine={{ stroke: theme.pageText }}
|
||||
/>
|
||||
{effectiveShowTooltip && mode === 'trend' && (
|
||||
<Tooltip<number, string>
|
||||
<Tooltip
|
||||
content={props => <TrendTooltip {...props} style={style} />}
|
||||
formatter={numberFormatterTooltip}
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
)}
|
||||
{effectiveShowTooltip && mode === 'stacked' && (
|
||||
<Tooltip<number, string>
|
||||
<Tooltip
|
||||
content={props => (
|
||||
<StackedTooltip
|
||||
{...props}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user