Compare commits
60 Commits
feature/en
...
7710-bundl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9dae6ba3d | ||
|
|
329a7e81e7 | ||
|
|
e8d95fdf6b | ||
|
|
2e0342574f | ||
|
|
36e5cb17f5 | ||
|
|
2c9e0af3e4 | ||
|
|
e04924810d | ||
|
|
872a40f829 | ||
|
|
fd01bd855c | ||
|
|
7e0f024c97 | ||
|
|
2b7bc80f4e | ||
|
|
6c1699f0b0 | ||
|
|
4b1b68a353 | ||
|
|
dbf5d7c079 | ||
|
|
6bfc299d28 | ||
|
|
a7e22b023c | ||
|
|
d3e7c1ee87 | ||
|
|
a7e100276e | ||
|
|
ee82a16026 | ||
|
|
b61732e20e | ||
|
|
83073b3ee0 | ||
|
|
78234102fa | ||
|
|
2c7f3c7a3d | ||
|
|
a4cb6dac37 | ||
|
|
d1f9f8aecf | ||
|
|
3b927e754c | ||
|
|
f2f3a5aa6d | ||
|
|
8fbad7d64f | ||
|
|
44a3013772 | ||
|
|
70716a59da | ||
|
|
e83567216f | ||
|
|
92d4f82b66 | ||
|
|
50feba1afb | ||
|
|
8263e58eb2 | ||
|
|
d015858e4a | ||
|
|
f3a9c1a02c | ||
|
|
daa698e7d2 | ||
|
|
e0536b593d | ||
|
|
d500057494 | ||
|
|
0086f805f8 | ||
|
|
911d8371cc | ||
|
|
a95c0ad9b0 | ||
|
|
d9308c1474 | ||
|
|
425db2d94d | ||
|
|
5d270340a5 | ||
|
|
070144f182 | ||
|
|
e479c84898 | ||
|
|
1306da27c5 | ||
|
|
0fd510a1d4 | ||
|
|
82673ecd50 | ||
|
|
18c704b3ba | ||
|
|
b05c207123 | ||
|
|
b9ab3e7bc6 | ||
|
|
4f40defe9e | ||
|
|
3799b587ec | ||
|
|
8e1f27f316 | ||
|
|
fb95d4c92d | ||
|
|
2782d464ab | ||
|
|
b63f5dd303 | ||
|
|
35a01b0fa6 |
@@ -1,7 +1,7 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-docker-compose
|
||||
{
|
||||
"name": "Actual development",
|
||||
"name": "Actual Devcontainer",
|
||||
"dockerComposeFile": ["../docker-compose.yml", "docker-compose.yml"],
|
||||
// Alternatively:
|
||||
// "image": "mcr.microsoft.com/devcontainers/typescript-node:0-16",
|
||||
|
||||
1
.github/actions/docs-spelling/expect.txt
vendored
@@ -44,6 +44,7 @@ CLP
|
||||
CMCIFRPAXXX
|
||||
COBADEFF
|
||||
CODEOWNERS
|
||||
Codespaces
|
||||
COEP
|
||||
commerzbank
|
||||
Copiar
|
||||
|
||||
7
.github/actions/setup/action.yml
vendored
@@ -10,6 +10,10 @@ inputs:
|
||||
description: 'Whether to download translations as part of setup, default true'
|
||||
required: false
|
||||
default: 'true'
|
||||
cache:
|
||||
description: 'Whether to restore and save dependency and Lage caches, default true'
|
||||
required: false
|
||||
default: 'true'
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
@@ -18,6 +22,7 @@ runs:
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 22
|
||||
package-manager-cache: ${{ inputs.cache }}
|
||||
- name: Install yarn
|
||||
run: npm install -g yarn
|
||||
shell: bash
|
||||
@@ -28,6 +33,7 @@ runs:
|
||||
shell: bash
|
||||
- name: Cache
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
if: ${{ inputs.cache == 'true' }}
|
||||
id: cache
|
||||
with:
|
||||
path: ${{ format('{0}/**/node_modules', inputs.working-directory) }}
|
||||
@@ -37,6 +43,7 @@ runs:
|
||||
shell: bash
|
||||
- name: Cache Lage
|
||||
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3
|
||||
if: ${{ inputs.cache == 'true' }}
|
||||
with:
|
||||
path: ${{ format('{0}/.lage', inputs.working-directory) }}
|
||||
key: lage-${{ runner.os }}-${{ github.sha }}
|
||||
|
||||
27
.github/workflows/ai-generated-label.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
name: Add 'AI generated' label to '[AI]' PRs
|
||||
|
||||
##########################################################################################
|
||||
# This workflow uses the 'pull_request_target' event so it has a token that can add a #
|
||||
# label to PRs from forks. It does NOT check out or execute any code from the PR, so it #
|
||||
# is not vulnerable to the usual 'pull_request_target' code-injection concerns. Keep it #
|
||||
# that way - do not add a checkout step or run any PR-provided scripts here. #
|
||||
##########################################################################################
|
||||
|
||||
on:
|
||||
# This workflow never checks out or runs PR code; it only reads the PR title and adds a label.
|
||||
pull_request_target: # zizmor: ignore[dangerous-triggers]
|
||||
types: [opened, reopened, edited]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
add-ai-generated-label:
|
||||
name: Add 'AI generated' label
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.event.pull_request.title, '[AI]')
|
||||
steps:
|
||||
- uses: actions-ecosystem/action-add-labels@bd52874380e3909a1ac983768df6976535ece7f8 # v1.1.0
|
||||
with:
|
||||
labels: AI generated
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
3
.github/workflows/build.yml
vendored
@@ -14,6 +14,9 @@ on:
|
||||
pull_request:
|
||||
merge_group:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
|
||||
|
||||
3
.github/workflows/check.yml
vendored
@@ -7,6 +7,9 @@ on:
|
||||
pull_request:
|
||||
merge_group:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.ref != 'refs/heads/master' }}
|
||||
|
||||
5
.github/workflows/count-points.yml
vendored
@@ -11,6 +11,11 @@ on:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
count-points:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
48
.github/workflows/crdt-version-check.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
name: CRDT version bump check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'packages/crdt/**'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check-version-bump:
|
||||
runs-on: ubuntu-latest
|
||||
name: Ensure @actual-app/crdt version is bumped
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Verify version bump
|
||||
env:
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if ! git cat-file -e "origin/${BASE_REF}:packages/crdt/package.json" 2>/dev/null; then
|
||||
echo "packages/crdt/package.json does not exist on the base branch; skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
BASE_VERSION=$(git show "origin/${BASE_REF}:packages/crdt/package.json" | jq -r .version)
|
||||
HEAD_VERSION=$(jq -r .version packages/crdt/package.json)
|
||||
echo "Base version: $BASE_VERSION"
|
||||
echo "Head version: $HEAD_VERSION"
|
||||
|
||||
if [ "$BASE_VERSION" = "$HEAD_VERSION" ]; then
|
||||
echo "::error file=packages/crdt/package.json::Files in packages/crdt/ were modified but the @actual-app/crdt version was not bumped. Please update the \"version\" field in packages/crdt/package.json."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
HIGHEST=$(printf '%s\n%s\n' "$BASE_VERSION" "$HEAD_VERSION" | sort -V | tail -n1)
|
||||
if [ "$HIGHEST" != "$HEAD_VERSION" ]; then
|
||||
echo "::error file=packages/crdt/package.json::The @actual-app/crdt version ($HEAD_VERSION) must be greater than the base version ($BASE_VERSION)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Version bumped from $BASE_VERSION to $HEAD_VERSION."
|
||||
2
.github/workflows/cut-release-branch.yml
vendored
@@ -37,6 +37,8 @@ jobs:
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
# Avoid restoring potentially poisoned caches in release jobs.
|
||||
cache: 'false'
|
||||
download-translations: 'false'
|
||||
|
||||
- name: Bump package versions
|
||||
|
||||
14
.github/workflows/docker-edge.yml
vendored
@@ -75,6 +75,9 @@ jobs:
|
||||
# This is faster and avoids yarn memory issues
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
# Avoid restoring potentially poisoned caches in release jobs.
|
||||
cache: 'false'
|
||||
- name: Build Web
|
||||
run: yarn build:server
|
||||
|
||||
@@ -88,10 +91,15 @@ jobs:
|
||||
tags: actualbudget/actual-server-testing
|
||||
|
||||
- name: Test that the docker image boots
|
||||
timeout-minutes: 1
|
||||
run: |
|
||||
docker run --detach --network=host actualbudget/actual-server-testing
|
||||
sleep 10
|
||||
curl --fail -sS -LI -w '%{http_code}\n' --retry 20 --retry-delay 1 --retry-connrefused localhost:5006
|
||||
docker run --detach --network=host --name actual-server actualbudget/actual-server-testing
|
||||
HEALTHCMD=$(yq -r '.services.actual_server.healthcheck.test[1]' packages/sync-server/docker-compose.yml)
|
||||
until docker exec actual-server sh -c "$HEALTHCMD"; do sleep 1; done
|
||||
|
||||
- name: Dump container logs on failure
|
||||
if: failure()
|
||||
run: docker logs actual-server || true
|
||||
|
||||
# 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/
|
||||
|
||||
45
.github/workflows/docker-release.yml
vendored
@@ -23,6 +23,10 @@ env:
|
||||
TAGS: |
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Docker image
|
||||
@@ -77,9 +81,29 @@ jobs:
|
||||
# This is faster and avoids yarn memory issues
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
# Avoid restoring potentially poisoned caches in release jobs.
|
||||
cache: 'false'
|
||||
- name: Build Web
|
||||
run: yarn build:server
|
||||
|
||||
- name: Build ubuntu image for testing
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
load: true
|
||||
file: packages/sync-server/docker/ubuntu.Dockerfile
|
||||
tags: actualbudget/actual-server-testing
|
||||
|
||||
- name: Test that the ubuntu image boots
|
||||
timeout-minutes: 1
|
||||
run: |
|
||||
docker rm -f actual-server 2>/dev/null || true
|
||||
docker run --detach --network=host --name actual-server actualbudget/actual-server-testing
|
||||
HEALTHCMD=$(yq -r '.services.actual_server.healthcheck.test[1]' packages/sync-server/docker-compose.yml)
|
||||
until docker exec actual-server sh -c "$HEALTHCMD"; do sleep 1; done
|
||||
|
||||
- name: Build and push ubuntu image
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
@@ -89,6 +113,23 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
|
||||
- name: Build alpine image for testing
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
load: true
|
||||
file: packages/sync-server/docker/alpine.Dockerfile
|
||||
tags: actualbudget/actual-server-testing
|
||||
|
||||
- name: Test that the alpine image boots
|
||||
timeout-minutes: 1
|
||||
run: |
|
||||
docker rm -f actual-server 2>/dev/null || true
|
||||
docker run --detach --network=host --name actual-server actualbudget/actual-server-testing
|
||||
HEALTHCMD=$(yq -r '.services.actual_server.healthcheck.test[1]' packages/sync-server/docker-compose.yml)
|
||||
until docker exec actual-server sh -c "$HEALTHCMD"; do sleep 1; done
|
||||
|
||||
- name: Build and push alpine image
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
@@ -97,3 +138,7 @@ jobs:
|
||||
file: packages/sync-server/docker/alpine.Dockerfile
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6
|
||||
tags: ${{ steps.alpine-meta.outputs.tags }}
|
||||
|
||||
- name: Dump container logs on failure
|
||||
if: failure()
|
||||
run: docker logs actual-server || true
|
||||
|
||||
6
.github/workflows/e2e-test.yml
vendored
@@ -199,11 +199,13 @@ jobs:
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
mkdir -p vrt-metadata
|
||||
echo "${{ github.event.pull_request.number }}" > vrt-metadata/pr-number.txt
|
||||
echo "${{ needs.vrt.result }}" > vrt-metadata/vrt-result.txt
|
||||
echo "${PR_NUMBER}" > vrt-metadata/pr-number.txt
|
||||
echo "${VRT_RESULT}" > vrt-metadata/vrt-result.txt
|
||||
echo "${STEPS_PLAYWRIGHT_REPORT_VRT_OUTPUTS_ARTIFACT_URL}" > vrt-metadata/artifact-url.txt
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
STEPS_PLAYWRIGHT_REPORT_VRT_OUTPUTS_ARTIFACT_URL: ${{ steps.playwright-report-vrt.outputs.artifact-url }}
|
||||
VRT_RESULT: ${{ needs.vrt.result }}
|
||||
- name: Upload VRT metadata
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
|
||||
47
.github/workflows/electron-master.yml
vendored
@@ -67,6 +67,9 @@ jobs:
|
||||
STEPS_PROCESS_VERSION_OUTPUTS_VERSION: ${{ steps.process_version.outputs.version }}
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
# Avoid restoring potentially poisoned caches in release jobs.
|
||||
cache: 'false'
|
||||
- name: Build Electron for Mac
|
||||
if: ${{ startsWith(matrix.os, 'macos') }}
|
||||
run: ./bin/package-electron
|
||||
@@ -117,49 +120,7 @@ jobs:
|
||||
!packages/desktop-electron/dist/Actual-windows.exe
|
||||
packages/desktop-electron/dist/*.AppImage
|
||||
packages/desktop-electron/dist/*.flatpak
|
||||
packages/desktop-electron/dist/*.appx
|
||||
|
||||
outputs:
|
||||
version: ${{ steps.process_version.outputs.version }}
|
||||
|
||||
publish-microsoft-store:
|
||||
needs: build
|
||||
runs-on: windows-latest
|
||||
environment: release
|
||||
if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') }}
|
||||
steps:
|
||||
- name: Install StoreBroker
|
||||
shell: powershell
|
||||
run: |
|
||||
Install-Module -Name StoreBroker -AcceptLicense -Force -Scope CurrentUser -Verbose
|
||||
|
||||
- name: Download Microsoft Store artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: actual-electron-windows-latest-appx
|
||||
|
||||
- name: Submit to Microsoft Store
|
||||
shell: powershell
|
||||
run: |
|
||||
# Disable telemetry
|
||||
$global:SBDisableTelemetry = $true
|
||||
|
||||
# Authenticate against the store
|
||||
$pass = ConvertTo-SecureString -String '${{ secrets.MICROSOFT_STORE_CLIENT_SECRET }}' -AsPlainText -Force
|
||||
$cred = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList ${{ secrets.MICROSOFT_STORE_CLIENT_ID }},$pass
|
||||
Set-StoreBrokerAuthentication -TenantId '${{ secrets.MICROSOFT_STORE_TENANT_ID }}' -Credential $cred
|
||||
|
||||
# Zip and create metadata files
|
||||
$artifacts = Get-ChildItem -Path . -Filter *.appx | Select-Object -ExpandProperty FullName
|
||||
New-StoreBrokerConfigFile -Path "$PWD/config.json" -AppId ${{ secrets.MICROSOFT_STORE_PRODUCT_ID }}
|
||||
New-SubmissionPackage -ConfigPath "$PWD/config.json" -DisableAutoPackageNameFormatting -AppxPath $artifacts -OutPath "$PWD" -OutName submission
|
||||
|
||||
# Submit the app
|
||||
# See https://github.com/microsoft/StoreBroker/blob/master/Documentation/USAGE.md#the-easy-way
|
||||
Update-ApplicationSubmission `
|
||||
-AppId ${{ secrets.MICROSOFT_STORE_PRODUCT_ID }} `
|
||||
-SubmissionDataPath "submission.json" `
|
||||
-PackagePath "submission.zip" `
|
||||
-ReplacePackages `
|
||||
-NoStatus `
|
||||
-AutoCommit `
|
||||
-Force
|
||||
|
||||
3
.github/workflows/electron-pr.yml
vendored
@@ -19,6 +19,9 @@ on:
|
||||
- '!packages/docs/**' # Docs changes don't affect Electron
|
||||
- '!packages/eslint-plugin-actual/**' # Eslint plugin changes don't affect Electron
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
@@ -6,6 +6,9 @@ on:
|
||||
- cron: '0 4 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
extract-and-upload-i18n-strings:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -4,6 +4,9 @@ on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
needs-votes:
|
||||
if: ${{ github.event.label.name == 'feature' }}
|
||||
|
||||
@@ -4,6 +4,9 @@ on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
tech-support:
|
||||
if: ${{ github.event.label.name == 'tech-support' }}
|
||||
|
||||
@@ -4,6 +4,9 @@ on:
|
||||
issues:
|
||||
types: [closed]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
remove-help-wanted:
|
||||
if: ${{ !contains(github.event.issue.labels.*.name, 'feature') && contains(github.event.issue.labels.*.name, 'help wanted') }}
|
||||
|
||||
37
.github/workflows/merge-freeze-unfreeze.yml
vendored
@@ -1,37 +0,0 @@
|
||||
# When the "unfreeze" label is added to a PR, add that PR to Merge Freeze's unblocked list
|
||||
# so it can be merged during a freeze. Uses pull_request_target so the workflow runs in
|
||||
# the base repo and has access to MERGEFREEZE_ACCESS_TOKEN for fork PRs; it does not
|
||||
# checkout or run any PR code. Requires MERGEFREEZE_ACCESS_TOKEN repo secret
|
||||
# (project-specific token from Merge Freeze Web API panel for actualbudget/actual / master).
|
||||
# See: https://docs.mergefreeze.com/web-api#post-freeze-status
|
||||
|
||||
name: Merge Freeze – add PR to unblocked list
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [labeled]
|
||||
|
||||
jobs:
|
||||
unfreeze:
|
||||
if: ${{ github.event.label.name == 'unfreeze' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions: {}
|
||||
concurrency:
|
||||
group: merge-freeze-unfreeze-${{ github.ref }}-labels
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: POST to Merge Freeze – add PR to unblocked list
|
||||
env:
|
||||
MERGEFREEZE_ACCESS_TOKEN: ${{ secrets.MERGEFREEZE_ACCESS_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
USER_NAME: ${{ github.actor }}
|
||||
run: |
|
||||
set -e
|
||||
if [ -z "$MERGEFREEZE_ACCESS_TOKEN" ]; then
|
||||
echo "::error::MERGEFREEZE_ACCESS_TOKEN secret is not set"
|
||||
exit 1
|
||||
fi
|
||||
url="https://www.mergefreeze.com/api/branches/actualbudget/actual/master/?access_token=${MERGEFREEZE_ACCESS_TOKEN}"
|
||||
payload=$(jq -n --arg user_name "$USER_NAME" --argjson pr "$PR_NUMBER" '{frozen: true, user_name: $user_name, unblocked_prs: [$pr]}')
|
||||
curl -sf -X POST "$url" -H "Content-Type: application/json" -d "$payload"
|
||||
echo "Merge Freeze updated: PR #$PR_NUMBER added to unblocked list."
|
||||
6
.github/workflows/netlify-release.yml
vendored
@@ -12,6 +12,9 @@ on:
|
||||
tags:
|
||||
- v**
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
@@ -28,6 +31,9 @@ jobs:
|
||||
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
# Avoid restoring potentially poisoned caches in release jobs.
|
||||
cache: 'false'
|
||||
|
||||
- name: Install Netlify
|
||||
run: npm install netlify-cli@17.10.1 -g
|
||||
|
||||
86
.github/workflows/publish-crdt.yml
vendored
Normal file
@@ -0,0 +1,86 @@
|
||||
name: Publish @actual-app/crdt
|
||||
|
||||
# Automatically publishes @actual-app/crdt when its package.json version
|
||||
# changes on master (typically via a merged PR that bumped the version).
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths:
|
||||
- 'packages/crdt/package.json'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: publish-crdt
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
check-version:
|
||||
runs-on: ubuntu-latest
|
||||
name: Check if publish is needed
|
||||
outputs:
|
||||
should-publish: ${{ steps.check.outputs.should-publish }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Compare local version against npm registry
|
||||
id: check
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
LOCAL_VERSION=$(jq -r .version packages/crdt/package.json)
|
||||
echo "Local version: $LOCAL_VERSION"
|
||||
|
||||
PUBLISHED_VERSION=$(npm view @actual-app/crdt version 2>/dev/null || echo "")
|
||||
echo "Published version: ${PUBLISHED_VERSION:-<none>}"
|
||||
|
||||
if [ "$LOCAL_VERSION" = "$PUBLISHED_VERSION" ]; then
|
||||
echo "Versions match - nothing to publish."
|
||||
echo "should-publish=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "Version changed - publish required."
|
||||
echo "should-publish=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
publish:
|
||||
needs: check-version
|
||||
if: needs.check-version.outputs.should-publish == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
name: Publish @actual-app/crdt to npm
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # Required for npm OIDC provenance
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
# Avoid restoring potentially poisoned caches in release jobs.
|
||||
cache: 'false'
|
||||
download-translations: 'false'
|
||||
|
||||
- name: Build @actual-app/crdt
|
||||
run: yarn workspace @actual-app/crdt build
|
||||
|
||||
- name: Pack @actual-app/crdt
|
||||
run: yarn workspace @actual-app/crdt pack --filename @actual-app/crdt.tgz
|
||||
|
||||
- name: Setup node and npm registry
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
||||
with:
|
||||
node-version: 24
|
||||
check-latest: true
|
||||
# Avoid restoring potentially poisoned caches in release jobs.
|
||||
package-manager-cache: false
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Publish to npm
|
||||
run: npm publish packages/crdt/@actual-app/crdt.tgz --access public --provenance
|
||||
3
.github/workflows/publish-flathub.yml
vendored
@@ -18,6 +18,9 @@ concurrency:
|
||||
group: publish-flathub
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
publish-flathub:
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
116
.github/workflows/publish-microsoft-store.yml
vendored
Normal file
@@ -0,0 +1,116 @@
|
||||
name: Publish Microsoft Store
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag (e.g. v25.3.0)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: publish-microsoft-store
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
publish-microsoft-store:
|
||||
runs-on: windows-latest
|
||||
environment: release
|
||||
steps:
|
||||
- name: Resolve version
|
||||
id: resolve_version
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name }}
|
||||
INPUT_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
if [[ "$EVENT_NAME" == "release" ]]; then
|
||||
TAG="$RELEASE_TAG"
|
||||
else
|
||||
TAG="$INPUT_TAG"
|
||||
fi
|
||||
|
||||
if [[ -z "$TAG" ]]; then
|
||||
echo "::error::No tag provided"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate tag format (v-prefixed semver, e.g. v25.3.0 or v1.2.3-beta.1)
|
||||
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?$ ]]; then
|
||||
echo "::error::Invalid tag format: $TAG (expected v-prefixed semver, e.g. v25.3.0)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION="${TAG#v}"
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "Resolved tag=$TAG version=$VERSION"
|
||||
|
||||
- name: Verify release assets exist
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
STEPS_RESOLVE_VERSION_OUTPUTS_TAG: ${{ steps.resolve_version.outputs.tag }}
|
||||
run: |
|
||||
TAG="${STEPS_RESOLVE_VERSION_OUTPUTS_TAG}"
|
||||
|
||||
echo "Checking release assets for tag $TAG..."
|
||||
ASSETS=$(gh api "repos/${{ github.repository }}/releases/tags/$TAG" --jq '.assets[].name')
|
||||
|
||||
echo "Found assets:"
|
||||
echo "$ASSETS"
|
||||
|
||||
if ! echo "$ASSETS" | grep -q "\.appx$"; then
|
||||
echo "::error::No .appx assets found in release $TAG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Required .appx assets found."
|
||||
|
||||
- name: Download Microsoft Store artifacts
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
STEPS_RESOLVE_VERSION_OUTPUTS_TAG: ${{ steps.resolve_version.outputs.tag }}
|
||||
run: |
|
||||
TAG="${STEPS_RESOLVE_VERSION_OUTPUTS_TAG}"
|
||||
gh release download "$TAG" --repo "${{ github.repository }}" --pattern "*.appx"
|
||||
|
||||
- name: Install StoreBroker
|
||||
shell: powershell
|
||||
run: |
|
||||
Install-Module -Name StoreBroker -AcceptLicense -Force -Scope CurrentUser -Verbose
|
||||
|
||||
- name: Submit to Microsoft Store
|
||||
shell: powershell
|
||||
run: |
|
||||
# Disable telemetry
|
||||
$global:SBDisableTelemetry = $true
|
||||
|
||||
# Authenticate against the store
|
||||
$pass = ConvertTo-SecureString -String '${{ secrets.MICROSOFT_STORE_CLIENT_SECRET }}' -AsPlainText -Force
|
||||
$cred = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList ${{ secrets.MICROSOFT_STORE_CLIENT_ID }},$pass
|
||||
Set-StoreBrokerAuthentication -TenantId '${{ secrets.MICROSOFT_STORE_TENANT_ID }}' -Credential $cred
|
||||
|
||||
# Zip and create metadata files
|
||||
$artifacts = Get-ChildItem -Path . -Filter *.appx | Select-Object -ExpandProperty FullName
|
||||
New-StoreBrokerConfigFile -Path "$PWD/config.json" -AppId ${{ secrets.MICROSOFT_STORE_PRODUCT_ID }}
|
||||
New-SubmissionPackage -ConfigPath "$PWD/config.json" -DisableAutoPackageNameFormatting -AppxPath $artifacts -OutPath "$PWD" -OutName submission
|
||||
|
||||
# Submit the app
|
||||
# See https://github.com/microsoft/StoreBroker/blob/master/Documentation/USAGE.md#the-easy-way
|
||||
Update-ApplicationSubmission `
|
||||
-AppId ${{ secrets.MICROSOFT_STORE_PRODUCT_ID }} `
|
||||
-SubmissionDataPath "submission.json" `
|
||||
-PackagePath "submission.zip" `
|
||||
-ReplacePackages `
|
||||
-NoStatus `
|
||||
-AutoCommit `
|
||||
-Force
|
||||
@@ -13,6 +13,9 @@ defaults:
|
||||
env:
|
||||
CI: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
@@ -45,6 +48,9 @@ jobs:
|
||||
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
# Avoid restoring potentially poisoned caches in release jobs.
|
||||
cache: 'false'
|
||||
|
||||
- if: ${{ startsWith(matrix.os, 'ubuntu') }}
|
||||
name: Setup Flatpak dependencies
|
||||
|
||||
8
.github/workflows/publish-npm-packages.yml
vendored
@@ -9,6 +9,9 @@ on:
|
||||
- cron: '0 0 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-and-pack:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -21,6 +24,9 @@ jobs:
|
||||
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
# Avoid restoring potentially poisoned caches in release jobs.
|
||||
cache: 'false'
|
||||
|
||||
- name: Update package versions
|
||||
if: github.event_name != 'push'
|
||||
@@ -105,6 +111,8 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
check-latest: true
|
||||
# Avoid restoring potentially poisoned caches in release jobs.
|
||||
package-manager-cache: false
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Publish Core
|
||||
|
||||
145
.github/workflows/size-compare.yml
vendored
@@ -33,6 +33,7 @@ jobs:
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
actions: read
|
||||
steps:
|
||||
- name: Checkout base branch
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -44,140 +45,120 @@ jobs:
|
||||
with:
|
||||
download-translations: 'false'
|
||||
|
||||
- name: Wait for ${{github.base_ref}} web build to succeed
|
||||
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
|
||||
id: master-web-build
|
||||
# Resolve one successful `build.yml` run for each side (master and PR
|
||||
# head) up front, then pin every download below to its `run_id`. This
|
||||
# ensures artifact downloads are consistent and prevents race conditions.
|
||||
- name: Resolve build runs
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
id: build-runs
|
||||
env:
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
checkName: web
|
||||
ref: ${{github.base_ref}}
|
||||
- name: Wait for ${{github.base_ref}} API build to succeed
|
||||
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
|
||||
id: master-api-build
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
checkName: api
|
||||
ref: ${{github.base_ref}}
|
||||
- name: Wait for ${{github.base_ref}} CLI build to succeed
|
||||
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
|
||||
id: master-cli-build
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
checkName: cli
|
||||
ref: ${{github.base_ref}}
|
||||
- name: Wait for ${{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}}
|
||||
script: |
|
||||
const TIMEOUT_MS = 30 * 60 * 1000;
|
||||
const SLEEP_MS = 15000;
|
||||
|
||||
- name: Wait for PR build to succeed
|
||||
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
|
||||
id: wait-for-web-build
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
checkName: web
|
||||
ref: ${{github.event.pull_request.head.sha}}
|
||||
- name: Wait for API PR build to succeed
|
||||
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
|
||||
id: wait-for-api-build
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
checkName: api
|
||||
ref: ${{github.event.pull_request.head.sha}}
|
||||
- name: Wait for CLI PR build to succeed
|
||||
uses: fountainhead/action-wait-for-check@5a908a24814494009c4bb27c242ea38c93c593be # v1.2.0
|
||||
id: wait-for-cli-build
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
checkName: cli
|
||||
ref: ${{github.event.pull_request.head.sha}}
|
||||
- name: 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}}
|
||||
async function resolveRun({ label, filter, notFoundHint }) {
|
||||
const deadline = Date.now() + TIMEOUT_MS;
|
||||
while (true) {
|
||||
const { data } = await github.rest.actions.listWorkflowRuns({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: 'build.yml',
|
||||
...filter,
|
||||
status: 'success',
|
||||
per_page: 1,
|
||||
});
|
||||
if (data.workflow_runs.length > 0) {
|
||||
const run = data.workflow_runs[0];
|
||||
core.info(`Found ${label} build run ${run.id} (${run.html_url})`);
|
||||
return run.id;
|
||||
}
|
||||
if (Date.now() > deadline) {
|
||||
throw new Error(
|
||||
`No successful build.yml run found for ${label} within 30 min — ${notFoundHint}.`,
|
||||
);
|
||||
}
|
||||
core.info(`No successful ${label} build run yet — sleeping 15s.`);
|
||||
await new Promise(r => setTimeout(r, SLEEP_MS));
|
||||
}
|
||||
}
|
||||
|
||||
- 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' || steps.wait-for-crdt-build.outputs.conclusion == 'failure'
|
||||
run: |
|
||||
echo "Build failed on PR branch or ${GITHUB_BASE_REF}"
|
||||
exit 1
|
||||
const baseRef = process.env.BASE_REF;
|
||||
const headSha = process.env.HEAD_SHA;
|
||||
const [masterRunId, headRunId] = await Promise.all([
|
||||
resolveRun({
|
||||
label: baseRef,
|
||||
filter: { branch: baseRef },
|
||||
notFoundHint: `${baseRef} may be broken`,
|
||||
}),
|
||||
resolveRun({
|
||||
label: `PR head ${headSha}`,
|
||||
filter: { head_sha: headSha },
|
||||
notFoundHint:
|
||||
'build may still be running, have failed, or the branch may have been force-pushed',
|
||||
}),
|
||||
]);
|
||||
core.setOutput('master_run_id', masterRunId);
|
||||
core.setOutput('head_run_id', headRunId);
|
||||
|
||||
- name: Download web build artifact from ${{github.base_ref}}
|
||||
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
|
||||
id: pr-web-build
|
||||
with:
|
||||
branch: ${{github.base_ref}}
|
||||
run_id: ${{ steps.build-runs.outputs.master_run_id }}
|
||||
workflow: build.yml
|
||||
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
|
||||
name: build-stats
|
||||
path: base
|
||||
- name: Download API build artifact from ${{github.base_ref}}
|
||||
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
|
||||
id: pr-api-build
|
||||
with:
|
||||
branch: ${{github.base_ref}}
|
||||
run_id: ${{ steps.build-runs.outputs.master_run_id }}
|
||||
workflow: build.yml
|
||||
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
|
||||
name: api-build-stats
|
||||
path: base
|
||||
- name: Download build stats from PR
|
||||
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
|
||||
with:
|
||||
pr: ${{github.event.pull_request.number}}
|
||||
run_id: ${{ steps.build-runs.outputs.head_run_id }}
|
||||
workflow: build.yml
|
||||
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
|
||||
name: build-stats
|
||||
path: head
|
||||
allow_forks: true
|
||||
- name: Download API stats from PR
|
||||
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
|
||||
with:
|
||||
pr: ${{github.event.pull_request.number}}
|
||||
run_id: ${{ steps.build-runs.outputs.head_run_id }}
|
||||
workflow: build.yml
|
||||
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
|
||||
name: api-build-stats
|
||||
path: head
|
||||
allow_forks: true
|
||||
- name: Download CLI build artifact from ${{github.base_ref}}
|
||||
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
|
||||
with:
|
||||
branch: ${{github.base_ref}}
|
||||
run_id: ${{ steps.build-runs.outputs.master_run_id }}
|
||||
workflow: build.yml
|
||||
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
|
||||
name: cli-build-stats
|
||||
path: base
|
||||
- name: Download CLI stats from PR
|
||||
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
|
||||
with:
|
||||
pr: ${{github.event.pull_request.number}}
|
||||
run_id: ${{ steps.build-runs.outputs.head_run_id }}
|
||||
workflow: build.yml
|
||||
workflow_conclusion: '' # ignore the conclusion of the workflow, since we already checked it
|
||||
name: cli-build-stats
|
||||
path: head
|
||||
allow_forks: true
|
||||
- name: Download CRDT build artifact from ${{github.base_ref}}
|
||||
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
|
||||
with:
|
||||
branch: ${{github.base_ref}}
|
||||
run_id: ${{ steps.build-runs.outputs.master_run_id }}
|
||||
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}}
|
||||
run_id: ${{ steps.build-runs.outputs.head_run_id }}
|
||||
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
|
||||
|
||||
25
.github/workflows/vrt-update-generate.yml
vendored
@@ -82,16 +82,17 @@ jobs:
|
||||
with:
|
||||
download-translations: 'false'
|
||||
- name: Build browser bundle
|
||||
# REACT_APP_NETLIFY=true keeps the "Create test file" button in the
|
||||
# production bundle — every VRT test's beforeEach relies on it via
|
||||
# ConfigurationPage.createTestFile().
|
||||
# REACT_APP_NETLIFY=true flips isNonProductionEnvironment() on in the
|
||||
# bundle so the "Create test file" button (used by every e2e beforeEach
|
||||
# via ConfigurationPage.createTestFile()) is still rendered in a
|
||||
# production build. Without it, e2e tests would time out waiting for
|
||||
# a button that was tree-shaken out.
|
||||
# --skip-translations keeps VRT screenshots deterministic by rendering
|
||||
# source-code English instead of upstream Weblate en.json (which can
|
||||
# drift between snapshot capture and test runs).
|
||||
env:
|
||||
REACT_APP_NETLIFY: 'true'
|
||||
run: |
|
||||
yarn workspace plugins-service build
|
||||
yarn workspace @actual-app/crdt build
|
||||
yarn workspace @actual-app/core build:browser
|
||||
yarn workspace @actual-app/web build:browser
|
||||
run: yarn build:browser --skip-translations
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
@@ -257,13 +258,17 @@ jobs:
|
||||
|
||||
- name: Merge shard patches
|
||||
id: create-patch
|
||||
shell: bash
|
||||
run: |
|
||||
git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
shopt -s nullglob
|
||||
patches=(/tmp/shard-patches/*/vrt-shard.patch)
|
||||
# actions/download-artifact puts a lone matched artifact directly in
|
||||
# `path` but gives each of several its own `path/<name>/` subdir, so
|
||||
# recurse instead of globbing `*/vrt-shard.patch` (which would miss
|
||||
# the common single-shard case).
|
||||
mapfile -t patches < <(find /tmp/shard-patches -type f -name 'vrt-shard.patch' | sort)
|
||||
|
||||
if [ ${#patches[@]} -eq 0 ]; then
|
||||
echo "has_changes=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"vi": "readonly",
|
||||
"backend": "readonly",
|
||||
"importScripts": "readonly",
|
||||
"FS": "readonly"
|
||||
"FS": "readonly",
|
||||
"__APP_VERSION__": "readonly"
|
||||
},
|
||||
"rules": {
|
||||
// Import sorting
|
||||
|
||||
@@ -7,3 +7,7 @@ enableTransparentWorkspaces: false
|
||||
nodeLinker: node-modules
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.13.0.cjs
|
||||
|
||||
# Secure default: don't run postinstall scripts.
|
||||
# If a new package requires them, add it to dependenciesMeta in package.json.
|
||||
enableScripts: false
|
||||
|
||||
19
package.json
@@ -52,7 +52,7 @@
|
||||
"playwright": "yarn workspace @actual-app/web run playwright",
|
||||
"vrt": "yarn workspace @actual-app/web run vrt",
|
||||
"vrt:docker": "./bin/run-vrt",
|
||||
"rebuild-electron": "./node_modules/.bin/electron-rebuild -f -m ./packages/desktop-electron -o better-sqlite3,bcrypt",
|
||||
"rebuild-electron": "./node_modules/.bin/electron-rebuild -m ./packages/desktop-electron -o better-sqlite3,bcrypt --build-from-source -f",
|
||||
"rebuild-node": "yarn workspace @actual-app/core rebuild",
|
||||
"lint": "oxfmt --check . && oxlint --type-aware --quiet",
|
||||
"lint:fix": "oxfmt . && oxlint --fix --type-aware --quiet",
|
||||
@@ -87,6 +87,23 @@
|
||||
"typescript": "^6.0.2",
|
||||
"vitest": "^4.1.2"
|
||||
},
|
||||
"dependenciesMeta": {
|
||||
"bcrypt": {
|
||||
"built": true
|
||||
},
|
||||
"better-sqlite3": {
|
||||
"built": true
|
||||
},
|
||||
"electron": {
|
||||
"built": true
|
||||
},
|
||||
"esbuild": {
|
||||
"built": true
|
||||
},
|
||||
"sharp": {
|
||||
"built": true
|
||||
}
|
||||
},
|
||||
"resolutions": {
|
||||
"adm-zip": "patch:adm-zip@npm%3A0.5.16#~/.yarn/patches/adm-zip-npm-0.5.16-4556fea098.patch",
|
||||
"minimatch@10.2.1": "10.2.5",
|
||||
|
||||
@@ -516,6 +516,29 @@ describe('API CRUD operations', () => {
|
||||
);
|
||||
});
|
||||
|
||||
// apis: getNote, updateNote
|
||||
test('Notes: successfully get and update note', async () => {
|
||||
const categories = await api.getCategories();
|
||||
const categoryId = categories[0].id;
|
||||
|
||||
// No note exists initially
|
||||
const initial = await api.getNote(categoryId);
|
||||
expect(initial).toBeNull();
|
||||
|
||||
// Set a note
|
||||
await api.updateNote(categoryId, 'Test note content');
|
||||
const afterSet = await api.getNote(categoryId);
|
||||
expect(afterSet).toEqual({ id: categoryId, note: 'Test note content' });
|
||||
|
||||
// Update the note
|
||||
await api.updateNote(categoryId, 'Updated note content');
|
||||
const afterUpdate = await api.getNote(categoryId);
|
||||
expect(afterUpdate).toEqual({
|
||||
id: categoryId,
|
||||
note: 'Updated note content',
|
||||
});
|
||||
});
|
||||
|
||||
// apis: getRules, getPayeeRules, createRule, updateRule, deleteRule
|
||||
test('Rules: successfully update rules', async () => {
|
||||
await api.createPayee({ name: 'test-payee' });
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { ImportTransactionsOpts } from '@actual-app/core/types/api-handlers
|
||||
import type { Handlers } from '@actual-app/core/types/handlers';
|
||||
import type {
|
||||
ImportTransactionEntity,
|
||||
NoteEntity,
|
||||
RuleEntity,
|
||||
TransactionEntity,
|
||||
} from '@actual-app/core/types/models';
|
||||
@@ -203,8 +204,8 @@ export function getAccountBalance(id: APIAccountEntity['id'], cutoff?: Date) {
|
||||
return send('api/account-balance', { id, cutoff });
|
||||
}
|
||||
|
||||
export function getCategoryGroups() {
|
||||
return send('api/category-groups-get');
|
||||
export function getCategoryGroups(options: { hidden?: boolean } = {}) {
|
||||
return send('api/category-groups-get', options);
|
||||
}
|
||||
|
||||
export function createCategoryGroup(group: Omit<APICategoryGroupEntity, 'id'>) {
|
||||
@@ -225,8 +226,8 @@ export function deleteCategoryGroup(
|
||||
return send('api/category-group-delete', { id, transferCategoryId });
|
||||
}
|
||||
|
||||
export function getCategories() {
|
||||
return send('api/categories-get', { grouped: false });
|
||||
export function getCategories(options: { hidden?: boolean } = {}) {
|
||||
return send('api/categories-get', { grouped: false, ...options });
|
||||
}
|
||||
|
||||
export function createCategory(category: Omit<APICategoryEntity, 'id'>) {
|
||||
@@ -247,6 +248,14 @@ export function deleteCategory(
|
||||
return send('api/category-delete', { id, transferCategoryId });
|
||||
}
|
||||
|
||||
export function getNote(id: NoteEntity['id']) {
|
||||
return send('api/note-get', { id });
|
||||
}
|
||||
|
||||
export function updateNote(id: NoteEntity['id'], note: NoteEntity['note']) {
|
||||
return send('api/note-update', { id, note });
|
||||
}
|
||||
|
||||
export function getCommonPayees() {
|
||||
return send('api/common-payees-get');
|
||||
}
|
||||
|
||||
@@ -85,6 +85,12 @@ export default defineConfig({
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
// Each test loads a budget file and runs all DB migrations, which can be
|
||||
// slow on busy CI runners; the default 5s timeout is too tight and causes
|
||||
// flaky timeouts (and a cascade of unhandled rejections from in-flight work
|
||||
// continuing after teardown).
|
||||
testTimeout: 20_000,
|
||||
hookTimeout: 20_000,
|
||||
onConsoleLog(log: string, type: 'stdout' | 'stderr'): boolean | void {
|
||||
// print only console.error
|
||||
return type === 'stderr';
|
||||
|
||||
131
packages/cli/src/commands/categories.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import * as api from '@actual-app/api';
|
||||
import { Command } from 'commander';
|
||||
|
||||
import { printOutput } from '#output';
|
||||
|
||||
import { registerCategoriesCommand } from './categories';
|
||||
import { registerCategoryGroupsCommand } from './category-groups';
|
||||
|
||||
vi.mock('@actual-app/api', () => ({
|
||||
getCategories: vi.fn().mockResolvedValue([]),
|
||||
createCategory: vi.fn().mockResolvedValue('new-id'),
|
||||
updateCategory: vi.fn().mockResolvedValue(undefined),
|
||||
deleteCategory: vi.fn().mockResolvedValue(undefined),
|
||||
getCategoryGroups: vi.fn().mockResolvedValue([]),
|
||||
createCategoryGroup: vi.fn().mockResolvedValue('new-group-id'),
|
||||
updateCategoryGroup: vi.fn().mockResolvedValue(undefined),
|
||||
deleteCategoryGroup: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('#connection', () => ({
|
||||
withConnection: vi.fn((_opts, fn) => fn()),
|
||||
}));
|
||||
|
||||
vi.mock('#output', () => ({
|
||||
printOutput: vi.fn(),
|
||||
}));
|
||||
|
||||
function createProgram(): Command {
|
||||
const program = new Command();
|
||||
program.option('--format <format>');
|
||||
program.option('--server-url <url>');
|
||||
program.option('--password <pw>');
|
||||
program.option('--session-token <token>');
|
||||
program.option('--sync-id <id>');
|
||||
program.option('--data-dir <dir>');
|
||||
program.option('--verbose');
|
||||
program.exitOverride();
|
||||
registerCategoriesCommand(program);
|
||||
registerCategoryGroupsCommand(program);
|
||||
return program;
|
||||
}
|
||||
|
||||
async function run(args: string[]) {
|
||||
const program = createProgram();
|
||||
await program.parseAsync(['node', 'test', ...args]);
|
||||
}
|
||||
|
||||
describe('categories commands', () => {
|
||||
let stderrSpy: ReturnType<typeof vi.spyOn>;
|
||||
let stdoutSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
stderrSpy = vi
|
||||
.spyOn(process.stderr, 'write')
|
||||
.mockImplementation(() => true);
|
||||
stdoutSpy = vi
|
||||
.spyOn(process.stdout, 'write')
|
||||
.mockImplementation(() => true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
stderrSpy.mockRestore();
|
||||
stdoutSpy.mockRestore();
|
||||
});
|
||||
|
||||
describe('categories list', () => {
|
||||
it('asks the API to exclude hidden categories by default', async () => {
|
||||
await run(['categories', 'list']);
|
||||
|
||||
expect(api.getCategories).toHaveBeenCalledWith({ hidden: false });
|
||||
});
|
||||
|
||||
it('asks the API for all categories when --include-hidden is passed', async () => {
|
||||
await run(['categories', 'list', '--include-hidden']);
|
||||
|
||||
expect(api.getCategories).toHaveBeenCalledWith({});
|
||||
});
|
||||
|
||||
it('prints whatever the API returns', async () => {
|
||||
const visible = {
|
||||
id: '1',
|
||||
name: 'Visible',
|
||||
group_id: 'g1',
|
||||
hidden: false,
|
||||
};
|
||||
vi.mocked(api.getCategories).mockResolvedValue([visible]);
|
||||
|
||||
await run(['categories', 'list']);
|
||||
|
||||
expect(printOutput).toHaveBeenCalledWith([visible], undefined);
|
||||
});
|
||||
|
||||
it('passes format option to printOutput', async () => {
|
||||
vi.mocked(api.getCategories).mockResolvedValue([]);
|
||||
|
||||
await run(['--format', 'csv', 'categories', 'list']);
|
||||
|
||||
expect(printOutput).toHaveBeenCalledWith([], 'csv');
|
||||
});
|
||||
});
|
||||
|
||||
describe('category-groups list', () => {
|
||||
it('asks the API to exclude hidden groups by default', async () => {
|
||||
await run(['category-groups', 'list']);
|
||||
|
||||
expect(api.getCategoryGroups).toHaveBeenCalledWith({ hidden: false });
|
||||
});
|
||||
|
||||
it('asks the API for all groups when --include-hidden is passed', async () => {
|
||||
await run(['category-groups', 'list', '--include-hidden']);
|
||||
|
||||
expect(api.getCategoryGroups).toHaveBeenCalledWith({});
|
||||
});
|
||||
|
||||
it('prints whatever the API returns', async () => {
|
||||
const group = {
|
||||
id: 'g1',
|
||||
name: 'Group',
|
||||
is_income: false,
|
||||
hidden: false,
|
||||
categories: [{ id: 'c1', name: 'Cat', group_id: 'g1', hidden: false }],
|
||||
};
|
||||
vi.mocked(api.getCategoryGroups).mockResolvedValue([group]);
|
||||
|
||||
await run(['category-groups', 'list']);
|
||||
|
||||
expect(printOutput).toHaveBeenCalledWith([group], undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,13 +12,16 @@ export function registerCategoriesCommand(program: Command) {
|
||||
|
||||
categories
|
||||
.command('list')
|
||||
.description('List all categories')
|
||||
.action(async () => {
|
||||
.description('List categories (excludes hidden by default)')
|
||||
.option('--include-hidden', 'Include hidden categories', false)
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const result = await api.getCategories();
|
||||
const result = await api.getCategories(
|
||||
cmdOpts.includeHidden ? {} : { hidden: false },
|
||||
);
|
||||
printOutput(result, opts.format);
|
||||
},
|
||||
{ mutates: false },
|
||||
|
||||
@@ -12,13 +12,16 @@ export function registerCategoryGroupsCommand(program: Command) {
|
||||
|
||||
groups
|
||||
.command('list')
|
||||
.description('List all category groups')
|
||||
.action(async () => {
|
||||
.description('List category groups (excludes hidden by default)')
|
||||
.option('--include-hidden', 'Include hidden groups and categories', false)
|
||||
.action(async cmdOpts => {
|
||||
const opts = program.opts();
|
||||
await withConnection(
|
||||
opts,
|
||||
async () => {
|
||||
const result = await api.getCategoryGroups();
|
||||
const result = await api.getCategoryGroups(
|
||||
cmdOpts.includeHidden ? {} : { hidden: false },
|
||||
);
|
||||
printOutput(result, opts.format);
|
||||
},
|
||||
{ mutates: false },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$(dirname "$0")")"
|
||||
|
||||
@@ -7,20 +8,10 @@ if ! [ -x "$(command -v protoc)" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export PATH="$PWD/bin:$PATH"
|
||||
|
||||
protoc --plugin="protoc-gen-ts=../../node_modules/.bin/protoc-gen-ts" \
|
||||
--ts_opt=esModuleInterop=true \
|
||||
--ts_out="src/proto" \
|
||||
--js_out=import_style=commonjs,binary:src/proto \
|
||||
protoc --plugin="protoc-gen-es=../../node_modules/.bin/protoc-gen-es" \
|
||||
--es_opt=target=ts \
|
||||
--es_out="src/proto" \
|
||||
--proto_path=src/proto \
|
||||
sync.proto
|
||||
|
||||
../../node_modules/.bin/oxfmt src/proto/*.d.ts
|
||||
|
||||
for file in src/proto/*.d.ts; do
|
||||
{ echo "/* oxlint-disable typescript/no-namespace */"; sed 's/export class/export declare class/g' "$file"; } > "${file%.d.ts}.ts"
|
||||
rm "$file"
|
||||
done
|
||||
|
||||
echo 'One more step! Find the `var global = ...` declaration in src/proto/sync_pb.js and change it to `var global = globalThis;`'
|
||||
../../node_modules/.bin/oxfmt src/proto/*.ts
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
{
|
||||
"name": "@actual-app/crdt",
|
||||
"version": "2.1.0",
|
||||
"version": "3.0.0",
|
||||
"description": "CRDT layer of Actual",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/actualbudget/actual.git",
|
||||
"directory": "packages/crdt"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"!dist/**/*.test.d.ts",
|
||||
@@ -10,14 +15,11 @@
|
||||
"!dist/**/*.spec.d.ts",
|
||||
"!dist/**/*.spec.d.ts.map"
|
||||
],
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"development": "./src/index.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"publishConfig": {
|
||||
"exports": {
|
||||
@@ -25,7 +27,9 @@
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build:node": "vite build",
|
||||
@@ -35,16 +39,14 @@
|
||||
"typecheck": "tsgo -b"
|
||||
},
|
||||
"dependencies": {
|
||||
"google-protobuf": "^3.21.4",
|
||||
"@bufbuild/protobuf": "^2.11.0",
|
||||
"murmurhash": "^2.0.1",
|
||||
"uuid": "^14.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/google-protobuf": "3.15.12",
|
||||
"@bufbuild/protoc-gen-es": "^2.11.0",
|
||||
"@typescript/native-preview": "beta",
|
||||
"protoc-gen-js": "3.21.4-4",
|
||||
"rollup-plugin-visualizer": "^7.0.1",
|
||||
"ts-protoc-gen": "0.15.0",
|
||||
"vite": "^8.0.5",
|
||||
"vitest": "^4.1.2"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import './proto/sync_pb.js'; // Import for side effects
|
||||
import type * as SyncPb from './proto/sync_pb';
|
||||
|
||||
export {
|
||||
merkle,
|
||||
getClock,
|
||||
@@ -13,16 +10,17 @@ export {
|
||||
Timestamp,
|
||||
} from './crdt';
|
||||
|
||||
declare global {
|
||||
var proto: typeof SyncPb;
|
||||
}
|
||||
export {
|
||||
type EncryptedData,
|
||||
type Message,
|
||||
type MessageEnvelope,
|
||||
type SyncRequest,
|
||||
type SyncResponse,
|
||||
EncryptedDataSchema,
|
||||
MessageSchema,
|
||||
MessageEnvelopeSchema,
|
||||
SyncRequestSchema,
|
||||
SyncResponseSchema,
|
||||
} from './proto/sync_pb';
|
||||
|
||||
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;
|
||||
export { create, fromBinary, toBinary } from '@bufbuild/protobuf';
|
||||
|
||||
@@ -21,6 +21,7 @@ message MessageEnvelope {
|
||||
}
|
||||
|
||||
message SyncRequest {
|
||||
reserved 4;
|
||||
repeated MessageEnvelope messages = 1;
|
||||
string fileId = 2;
|
||||
string groupId = 3;
|
||||
|
||||
@@ -1,217 +1,161 @@
|
||||
/* oxlint-disable typescript/no-namespace */
|
||||
// package:
|
||||
// file: sync.proto
|
||||
// @generated by protoc-gen-es v2.11.0 with parameter "target=ts"
|
||||
// @generated from file sync.proto (syntax proto3)
|
||||
/* eslint-disable */
|
||||
|
||||
import * as jspb from 'google-protobuf';
|
||||
import type { Message as Message$1 } from '@bufbuild/protobuf';
|
||||
import type { GenFile, GenMessage } from '@bufbuild/protobuf/codegenv2';
|
||||
import { fileDesc, messageDesc } from '@bufbuild/protobuf/codegenv2';
|
||||
|
||||
export declare class EncryptedData extends jspb.Message {
|
||||
getIv(): Uint8Array | string;
|
||||
getIv_asU8(): Uint8Array;
|
||||
getIv_asB64(): string;
|
||||
setIv(value: Uint8Array | string): void;
|
||||
/**
|
||||
* Describes the file sync.proto.
|
||||
*/
|
||||
export const file_sync: GenFile /*@__PURE__*/ = fileDesc(
|
||||
'CgpzeW5jLnByb3RvIjoKDUVuY3J5cHRlZERhdGESCgoCaXYYASABKAwSDwoHYXV0aFRhZxgCIAEoDBIMCgRkYXRhGAMgASgMIkYKB01lc3NhZ2USDwoHZGF0YXNldBgBIAEoCRILCgNyb3cYAiABKAkSDgoGY29sdW1uGAMgASgJEg0KBXZhbHVlGAQgASgJIkoKD01lc3NhZ2VFbnZlbG9wZRIRCgl0aW1lc3RhbXAYASABKAkSEwoLaXNFbmNyeXB0ZWQYAiABKAgSDwoHY29udGVudBgDIAEoDCJ2CgtTeW5jUmVxdWVzdBIiCghtZXNzYWdlcxgBIAMoCzIQLk1lc3NhZ2VFbnZlbG9wZRIOCgZmaWxlSWQYAiABKAkSDwoHZ3JvdXBJZBgDIAEoCRINCgVrZXlJZBgFIAEoCRINCgVzaW5jZRgGIAEoCUoECAQQBSJCCgxTeW5jUmVzcG9uc2USIgoIbWVzc2FnZXMYASADKAsyEC5NZXNzYWdlRW52ZWxvcGUSDgoGbWVya2xlGAIgASgJYgZwcm90bzM',
|
||||
);
|
||||
|
||||
getAuthtag(): Uint8Array | string;
|
||||
getAuthtag_asU8(): Uint8Array;
|
||||
getAuthtag_asB64(): string;
|
||||
setAuthtag(value: Uint8Array | string): void;
|
||||
/**
|
||||
* @generated from message EncryptedData
|
||||
*/
|
||||
export type EncryptedData = Message$1<'EncryptedData'> & {
|
||||
/**
|
||||
* @generated from field: bytes iv = 1;
|
||||
*/
|
||||
iv: Uint8Array;
|
||||
|
||||
getData(): Uint8Array | string;
|
||||
getData_asU8(): Uint8Array;
|
||||
getData_asB64(): string;
|
||||
setData(value: Uint8Array | string): void;
|
||||
/**
|
||||
* @generated from field: bytes authTag = 2;
|
||||
*/
|
||||
authTag: Uint8Array;
|
||||
|
||||
serializeBinary(): Uint8Array;
|
||||
toObject(includeInstance?: boolean): EncryptedData.AsObject;
|
||||
static toObject(
|
||||
includeInstance: boolean,
|
||||
msg: EncryptedData,
|
||||
): EncryptedData.AsObject;
|
||||
static extensions: { [key: number]: jspb.ExtensionFieldInfo<jspb.Message> };
|
||||
static extensionsBinary: {
|
||||
[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>;
|
||||
};
|
||||
static serializeBinaryToWriter(
|
||||
message: EncryptedData,
|
||||
writer: jspb.BinaryWriter,
|
||||
): void;
|
||||
static deserializeBinary(bytes: Uint8Array): EncryptedData;
|
||||
static deserializeBinaryFromReader(
|
||||
message: EncryptedData,
|
||||
reader: jspb.BinaryReader,
|
||||
): EncryptedData;
|
||||
}
|
||||
/**
|
||||
* @generated from field: bytes data = 3;
|
||||
*/
|
||||
data: Uint8Array;
|
||||
};
|
||||
|
||||
export namespace EncryptedData {
|
||||
export type AsObject = {
|
||||
iv: Uint8Array | string;
|
||||
authtag: Uint8Array | string;
|
||||
data: Uint8Array | string;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Describes the message EncryptedData.
|
||||
* Use `create(EncryptedDataSchema)` to create a new message.
|
||||
*/
|
||||
export const EncryptedDataSchema: GenMessage<EncryptedData> /*@__PURE__*/ =
|
||||
messageDesc(file_sync, 0);
|
||||
|
||||
export declare class Message extends jspb.Message {
|
||||
getDataset(): string;
|
||||
setDataset(value: string): void;
|
||||
/**
|
||||
* @generated from message Message
|
||||
*/
|
||||
export type Message = Message$1<'Message'> & {
|
||||
/**
|
||||
* @generated from field: string dataset = 1;
|
||||
*/
|
||||
dataset: string;
|
||||
|
||||
getRow(): string;
|
||||
setRow(value: string): void;
|
||||
/**
|
||||
* @generated from field: string row = 2;
|
||||
*/
|
||||
row: string;
|
||||
|
||||
getColumn(): string;
|
||||
setColumn(value: string): void;
|
||||
/**
|
||||
* @generated from field: string column = 3;
|
||||
*/
|
||||
column: string;
|
||||
|
||||
getValue(): string;
|
||||
setValue(value: string): void;
|
||||
/**
|
||||
* @generated from field: string value = 4;
|
||||
*/
|
||||
value: string;
|
||||
};
|
||||
|
||||
serializeBinary(): Uint8Array;
|
||||
toObject(includeInstance?: boolean): Message.AsObject;
|
||||
static toObject(includeInstance: boolean, msg: Message): Message.AsObject;
|
||||
static extensions: { [key: number]: jspb.ExtensionFieldInfo<jspb.Message> };
|
||||
static extensionsBinary: {
|
||||
[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>;
|
||||
};
|
||||
static serializeBinaryToWriter(
|
||||
message: Message,
|
||||
writer: jspb.BinaryWriter,
|
||||
): void;
|
||||
static deserializeBinary(bytes: Uint8Array): Message;
|
||||
static deserializeBinaryFromReader(
|
||||
message: Message,
|
||||
reader: jspb.BinaryReader,
|
||||
): Message;
|
||||
}
|
||||
/**
|
||||
* Describes the message Message.
|
||||
* Use `create(MessageSchema)` to create a new message.
|
||||
*/
|
||||
export const MessageSchema: GenMessage<Message> /*@__PURE__*/ = messageDesc(
|
||||
file_sync,
|
||||
1,
|
||||
);
|
||||
|
||||
export namespace Message {
|
||||
export type AsObject = {
|
||||
dataset: string;
|
||||
row: string;
|
||||
column: string;
|
||||
value: string;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* @generated from message MessageEnvelope
|
||||
*/
|
||||
export type MessageEnvelope = Message$1<'MessageEnvelope'> & {
|
||||
/**
|
||||
* @generated from field: string timestamp = 1;
|
||||
*/
|
||||
timestamp: string;
|
||||
|
||||
export declare class MessageEnvelope extends jspb.Message {
|
||||
getTimestamp(): string;
|
||||
setTimestamp(value: string): void;
|
||||
/**
|
||||
* @generated from field: bool isEncrypted = 2;
|
||||
*/
|
||||
isEncrypted: boolean;
|
||||
|
||||
getIsencrypted(): boolean;
|
||||
setIsencrypted(value: boolean): void;
|
||||
/**
|
||||
* @generated from field: bytes content = 3;
|
||||
*/
|
||||
content: Uint8Array;
|
||||
};
|
||||
|
||||
getContent(): Uint8Array | string;
|
||||
getContent_asU8(): Uint8Array;
|
||||
getContent_asB64(): string;
|
||||
setContent(value: Uint8Array | string): void;
|
||||
/**
|
||||
* Describes the message MessageEnvelope.
|
||||
* Use `create(MessageEnvelopeSchema)` to create a new message.
|
||||
*/
|
||||
export const MessageEnvelopeSchema: GenMessage<MessageEnvelope> /*@__PURE__*/ =
|
||||
messageDesc(file_sync, 2);
|
||||
|
||||
serializeBinary(): Uint8Array;
|
||||
toObject(includeInstance?: boolean): MessageEnvelope.AsObject;
|
||||
static toObject(
|
||||
includeInstance: boolean,
|
||||
msg: MessageEnvelope,
|
||||
): MessageEnvelope.AsObject;
|
||||
static extensions: { [key: number]: jspb.ExtensionFieldInfo<jspb.Message> };
|
||||
static extensionsBinary: {
|
||||
[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>;
|
||||
};
|
||||
static serializeBinaryToWriter(
|
||||
message: MessageEnvelope,
|
||||
writer: jspb.BinaryWriter,
|
||||
): void;
|
||||
static deserializeBinary(bytes: Uint8Array): MessageEnvelope;
|
||||
static deserializeBinaryFromReader(
|
||||
message: MessageEnvelope,
|
||||
reader: jspb.BinaryReader,
|
||||
): MessageEnvelope;
|
||||
}
|
||||
/**
|
||||
* @generated from message SyncRequest
|
||||
*/
|
||||
export type SyncRequest = Message$1<'SyncRequest'> & {
|
||||
/**
|
||||
* @generated from field: repeated MessageEnvelope messages = 1;
|
||||
*/
|
||||
messages: MessageEnvelope[];
|
||||
|
||||
export namespace MessageEnvelope {
|
||||
export type AsObject = {
|
||||
timestamp: string;
|
||||
isencrypted: boolean;
|
||||
content: Uint8Array | string;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* @generated from field: string fileId = 2;
|
||||
*/
|
||||
fileId: string;
|
||||
|
||||
export declare class SyncRequest extends jspb.Message {
|
||||
clearMessagesList(): void;
|
||||
getMessagesList(): Array<MessageEnvelope>;
|
||||
setMessagesList(value: Array<MessageEnvelope>): void;
|
||||
addMessages(value?: MessageEnvelope, index?: number): MessageEnvelope;
|
||||
/**
|
||||
* @generated from field: string groupId = 3;
|
||||
*/
|
||||
groupId: string;
|
||||
|
||||
getFileid(): string;
|
||||
setFileid(value: string): void;
|
||||
/**
|
||||
* @generated from field: string keyId = 5;
|
||||
*/
|
||||
keyId: string;
|
||||
|
||||
getGroupid(): string;
|
||||
setGroupid(value: string): void;
|
||||
/**
|
||||
* @generated from field: string since = 6;
|
||||
*/
|
||||
since: string;
|
||||
};
|
||||
|
||||
getKeyid(): string;
|
||||
setKeyid(value: string): void;
|
||||
/**
|
||||
* Describes the message SyncRequest.
|
||||
* Use `create(SyncRequestSchema)` to create a new message.
|
||||
*/
|
||||
export const SyncRequestSchema: GenMessage<SyncRequest> /*@__PURE__*/ =
|
||||
messageDesc(file_sync, 3);
|
||||
|
||||
getSince(): string;
|
||||
setSince(value: string): void;
|
||||
/**
|
||||
* @generated from message SyncResponse
|
||||
*/
|
||||
export type SyncResponse = Message$1<'SyncResponse'> & {
|
||||
/**
|
||||
* @generated from field: repeated MessageEnvelope messages = 1;
|
||||
*/
|
||||
messages: MessageEnvelope[];
|
||||
|
||||
serializeBinary(): Uint8Array;
|
||||
toObject(includeInstance?: boolean): SyncRequest.AsObject;
|
||||
static toObject(
|
||||
includeInstance: boolean,
|
||||
msg: SyncRequest,
|
||||
): SyncRequest.AsObject;
|
||||
static extensions: { [key: number]: jspb.ExtensionFieldInfo<jspb.Message> };
|
||||
static extensionsBinary: {
|
||||
[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>;
|
||||
};
|
||||
static serializeBinaryToWriter(
|
||||
message: SyncRequest,
|
||||
writer: jspb.BinaryWriter,
|
||||
): void;
|
||||
static deserializeBinary(bytes: Uint8Array): SyncRequest;
|
||||
static deserializeBinaryFromReader(
|
||||
message: SyncRequest,
|
||||
reader: jspb.BinaryReader,
|
||||
): SyncRequest;
|
||||
}
|
||||
/**
|
||||
* @generated from field: string merkle = 2;
|
||||
*/
|
||||
merkle: string;
|
||||
};
|
||||
|
||||
export namespace SyncRequest {
|
||||
export type AsObject = {
|
||||
messagesList: Array<MessageEnvelope.AsObject>;
|
||||
fileid: string;
|
||||
groupid: string;
|
||||
keyid: string;
|
||||
since: string;
|
||||
};
|
||||
}
|
||||
|
||||
export declare class SyncResponse extends jspb.Message {
|
||||
clearMessagesList(): void;
|
||||
getMessagesList(): Array<MessageEnvelope>;
|
||||
setMessagesList(value: Array<MessageEnvelope>): void;
|
||||
addMessages(value?: MessageEnvelope, index?: number): MessageEnvelope;
|
||||
|
||||
getMerkle(): string;
|
||||
setMerkle(value: string): void;
|
||||
|
||||
serializeBinary(): Uint8Array;
|
||||
toObject(includeInstance?: boolean): SyncResponse.AsObject;
|
||||
static toObject(
|
||||
includeInstance: boolean,
|
||||
msg: SyncResponse,
|
||||
): SyncResponse.AsObject;
|
||||
static extensions: { [key: number]: jspb.ExtensionFieldInfo<jspb.Message> };
|
||||
static extensionsBinary: {
|
||||
[key: number]: jspb.ExtensionFieldBinaryInfo<jspb.Message>;
|
||||
};
|
||||
static serializeBinaryToWriter(
|
||||
message: SyncResponse,
|
||||
writer: jspb.BinaryWriter,
|
||||
): void;
|
||||
static deserializeBinary(bytes: Uint8Array): SyncResponse;
|
||||
static deserializeBinaryFromReader(
|
||||
message: SyncResponse,
|
||||
reader: jspb.BinaryReader,
|
||||
): SyncResponse;
|
||||
}
|
||||
|
||||
export namespace SyncResponse {
|
||||
export type AsObject = {
|
||||
messagesList: Array<MessageEnvelope.AsObject>;
|
||||
merkle: string;
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Describes the message SyncResponse.
|
||||
* Use `create(SyncResponseSchema)` to create a new message.
|
||||
*/
|
||||
export const SyncResponseSchema: GenMessage<SyncResponse> /*@__PURE__*/ =
|
||||
messageDesc(file_sync, 4);
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
"rootDir": "./src",
|
||||
"composite": true,
|
||||
"target": "ES2021",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"noEmit": false,
|
||||
"emitDeclarationOnly": true,
|
||||
"declaration": true,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { defineConfig } from 'vite';
|
||||
export default defineConfig({
|
||||
ssr: {
|
||||
noExternal: true,
|
||||
external: ['google-protobuf', 'murmurhash'],
|
||||
external: ['@bufbuild/protobuf', 'murmurhash'],
|
||||
},
|
||||
build: {
|
||||
ssr: true,
|
||||
@@ -16,7 +16,7 @@ export default defineConfig({
|
||||
sourcemap: true,
|
||||
lib: {
|
||||
entry: path.resolve(__dirname, 'src/index.ts'),
|
||||
formats: ['cjs'],
|
||||
formats: ['es'],
|
||||
fileName: () => 'index.js',
|
||||
},
|
||||
},
|
||||
|
||||
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 133 KiB |
@@ -25,6 +25,75 @@ export class ReportsPage {
|
||||
return new ReportsPage(this.page);
|
||||
}
|
||||
|
||||
async goToBalanceForecastPage() {
|
||||
const gridItems = this.pageContent.locator('.react-grid-item');
|
||||
const count = await gridItems.count();
|
||||
|
||||
let targetItem: Locator | null = null;
|
||||
for (let i = count - 1; i >= 0; i--) {
|
||||
const item = gridItems.nth(i);
|
||||
await item.scrollIntoViewIfNeeded();
|
||||
const heading = item.getByRole('heading', { name: /^Balance Forecast/i });
|
||||
if (await heading.isVisible()) {
|
||||
targetItem = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetItem) {
|
||||
await this.page.evaluate(() => {
|
||||
window.scrollTo(0, document.documentElement.scrollHeight);
|
||||
});
|
||||
const refreshedCount = await gridItems.count();
|
||||
for (let i = refreshedCount - 1; i >= 0; i--) {
|
||||
const item = gridItems.nth(i);
|
||||
await item.scrollIntoViewIfNeeded();
|
||||
const heading = item.getByRole('heading', {
|
||||
name: /^Balance Forecast/i,
|
||||
});
|
||||
if (await heading.isVisible()) {
|
||||
targetItem = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetItem) {
|
||||
throw new Error('No Balance Forecast dashboard card found in the grid');
|
||||
}
|
||||
|
||||
const cardNavigateButton = targetItem.getByRole('button', {
|
||||
name: /^Balance Forecast/i,
|
||||
});
|
||||
await Promise.all([
|
||||
this.page.waitForURL(/\/reports\/forecast\//),
|
||||
cardNavigateButton.click(),
|
||||
]);
|
||||
|
||||
await this.pageContent
|
||||
.getByRole('button', { name: 'Monthly' })
|
||||
.waitFor({ state: 'visible' });
|
||||
|
||||
return new ReportsPage(this.page);
|
||||
}
|
||||
|
||||
async selectForecastGranularity(granularity: string) {
|
||||
await this.pageContent.getByRole('button', { name: 'Monthly' }).click();
|
||||
const option = this.page.getByRole('button', { name: granularity });
|
||||
await option.waitFor({ state: 'visible' });
|
||||
await option.click();
|
||||
await this.pageContent
|
||||
.getByRole('button', { name: granularity })
|
||||
.waitFor({ state: 'visible' });
|
||||
}
|
||||
|
||||
async addWidget(widgetName: string) {
|
||||
await this.pageContent
|
||||
.getByRole('button', { name: 'Add new widget' })
|
||||
.click();
|
||||
await this.page.getByRole('button', { name: widgetName }).click();
|
||||
}
|
||||
|
||||
async goToCustomReportPage() {
|
||||
await this.pageContent
|
||||
.getByRole('button', { name: 'Add new widget' })
|
||||
|
||||
@@ -42,17 +42,22 @@ export class SettingsPage {
|
||||
}
|
||||
|
||||
async enableExperimentalFeature(featureName: string) {
|
||||
if (await this.advancedSettingsButton.isVisible()) {
|
||||
await this.advancedSettingsButton.click();
|
||||
}
|
||||
await this.advancedSettingsButton.waitFor({
|
||||
state: 'visible',
|
||||
timeout: 2000,
|
||||
});
|
||||
await this.advancedSettingsButton.click();
|
||||
|
||||
if (await this.experimentalSettingsButton.isVisible()) {
|
||||
await this.experimentalSettingsButton.click();
|
||||
}
|
||||
await this.experimentalSettingsButton.waitFor({
|
||||
state: 'visible',
|
||||
timeout: 2000,
|
||||
});
|
||||
await this.experimentalSettingsButton.click();
|
||||
|
||||
const featureCheckbox = this.page.getByRole('checkbox', {
|
||||
name: featureName,
|
||||
});
|
||||
await featureCheckbox.waitFor({ state: 'visible' });
|
||||
if (!(await featureCheckbox.isChecked())) {
|
||||
await featureCheckbox.click();
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
@@ -55,6 +55,28 @@ test.describe.parallel('Reports', () => {
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
});
|
||||
|
||||
test.describe('balance forecast', () => {
|
||||
test.beforeEach(async () => {
|
||||
const settingsPage = await navigation.goToSettingsPage();
|
||||
await settingsPage.enableExperimentalFeature('Balance Forecast Report');
|
||||
|
||||
reportsPage = await navigation.goToReportsPage();
|
||||
await reportsPage.waitToLoad();
|
||||
await reportsPage.addWidget('Balance forecast');
|
||||
await reportsPage.goToBalanceForecastPage();
|
||||
});
|
||||
|
||||
test('loads balance forecast report with monthly granularity', async () => {
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
});
|
||||
|
||||
test('switches to daily granularity', async () => {
|
||||
await reportsPage.selectForecastGranularity('Daily');
|
||||
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe.parallel('custom reports', () => {
|
||||
let customReportPage: CustomReportPage;
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 80 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 81 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 110 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 3.6 KiB |
@@ -27,6 +27,7 @@
|
||||
"#transactions": "./src/transactions/index.ts",
|
||||
"#undo": "./src/undo/index.ts",
|
||||
"#global-events": "./src/global-events.ts",
|
||||
"#enablebanking": "./src/enablebanking.ts",
|
||||
"#gocardless": "./src/gocardless.ts",
|
||||
"#i18n": "./src/i18n.ts",
|
||||
"#mocks": "./src/mocks.tsx",
|
||||
@@ -41,6 +42,7 @@
|
||||
"#components/budget": "./src/components/budget/index.tsx",
|
||||
"#components/budget/goals/actions": "./src/components/budget/goals/actions.ts",
|
||||
"#components/budget/goals/automationExamples": "./src/components/budget/goals/automationExamples.ts",
|
||||
"#components/budget/goals/cleanupModel": "./src/components/budget/goals/cleanupModel.ts",
|
||||
"#components/budget/goals/constants": "./src/components/budget/goals/constants.ts",
|
||||
"#components/budget/goals/displayTemplateMeta": "./src/components/budget/goals/displayTemplateMeta.ts",
|
||||
"#components/budget/goals/formatMonthLabel": "./src/components/budget/goals/formatMonthLabel.ts",
|
||||
@@ -160,6 +162,7 @@
|
||||
"cross-env": "^10.1.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"downshift": "9.3.2",
|
||||
"fzf": "^0.5.2",
|
||||
"html-to-image": "^1.11.13",
|
||||
"hyperformula": "^3.2.0",
|
||||
"i18next": "^25.10.10",
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { SyncResponseWithErrors } from '@actual-app/core/server/accounts/ap
|
||||
import type {
|
||||
AccountEntity,
|
||||
CategoryEntity,
|
||||
SyncServerEnableBankingAccount,
|
||||
SyncServerGoCardlessAccount,
|
||||
SyncServerPluggyAiAccount,
|
||||
SyncServerSimpleFinAccount,
|
||||
@@ -499,6 +500,48 @@ export function useLinkAccountPluggyAiMutation() {
|
||||
});
|
||||
}
|
||||
|
||||
type LinkAccountEnableBankingPayload = LinkAccountBasePayload & {
|
||||
externalAccount: SyncServerEnableBankingAccount;
|
||||
};
|
||||
|
||||
export function useLinkAccountEnableBankingMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
externalAccount,
|
||||
upgradingId,
|
||||
offBudget,
|
||||
startingDate,
|
||||
startingBalance,
|
||||
}: LinkAccountEnableBankingPayload) => {
|
||||
await send('enablebanking-accounts-link', {
|
||||
externalAccount,
|
||||
upgradingId,
|
||||
offBudget,
|
||||
startingDate,
|
||||
startingBalance,
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
invalidateQueries(queryClient);
|
||||
invalidateQueries(queryClient, payeeQueries.lists());
|
||||
},
|
||||
onError: error => {
|
||||
console.error('Error linking account to Enable Banking:', error);
|
||||
dispatchErrorNotification(
|
||||
dispatch,
|
||||
t(
|
||||
'There was an error linking the account to Enable Banking. Please try again.',
|
||||
),
|
||||
error,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
type SyncAccountsPayload = {
|
||||
id?: AccountEntity['id'] | undefined;
|
||||
};
|
||||
@@ -590,6 +633,8 @@ export function useSyncAccountsMutation() {
|
||||
accountIdsToSync = accountIdsToSync.filter(
|
||||
id => !simpleFinAccounts.find(sfa => sfa.id === id),
|
||||
);
|
||||
|
||||
dispatch(setAccountsSyncing({ ids: accountIdsToSync }));
|
||||
}
|
||||
|
||||
// Loop through the accounts and perform sync operation.. one by one
|
||||
|
||||
@@ -335,10 +335,17 @@ const isUpdateReadyForDownloadPromise = new Promise(resolve => {
|
||||
resolve(true);
|
||||
};
|
||||
});
|
||||
const updateSW = registerSW({
|
||||
immediate: true,
|
||||
onNeedRefresh: markUpdateReadyForDownload,
|
||||
});
|
||||
// Skip SW registration in dev so stale cached assets don't override edits
|
||||
// between page loads. Plugin code that needs a SW can register one itself.
|
||||
// In dev there is no SW to install, so applyAppUpdate() can't rely on the
|
||||
// SW lifecycle to swap the page — fall back to a plain reload so callers
|
||||
// don't hang on the never-resolving promise inside applyAppUpdate.
|
||||
const updateSW = IS_DEV
|
||||
? () => window.location.reload()
|
||||
: registerSW({
|
||||
immediate: true,
|
||||
onNeedRefresh: markUpdateReadyForDownload,
|
||||
});
|
||||
|
||||
global.Actual = {
|
||||
IS_DEV,
|
||||
|
||||
@@ -25,14 +25,15 @@ const importScriptsWithRetry = async (script, { maxRetries = 5 } = {}) => {
|
||||
}
|
||||
|
||||
// Attempt to retry after a small delay
|
||||
await new Promise(resolve =>
|
||||
setTimeout(async () => {
|
||||
await importScriptsWithRetry(script, {
|
||||
await new Promise((resolve, reject) => {
|
||||
setTimeout(() => {
|
||||
importScriptsWithRetry(script, {
|
||||
maxRetries: maxRetries - 1,
|
||||
});
|
||||
resolve();
|
||||
}, 5000),
|
||||
);
|
||||
})
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
}, 5000);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -76,9 +77,11 @@ self.addEventListener('message', async event => {
|
||||
return;
|
||||
}
|
||||
|
||||
// A single failed importScripts bricks the SharedWorker until
|
||||
// it's evicted, so retry in production too.
|
||||
await importScriptsWithRetry(
|
||||
`${msg.publicUrl}/kcab/kcab.worker.${hash}.js`,
|
||||
{ maxRetries: isDev ? 5 : 0 },
|
||||
{ maxRetries: isDev ? 5 : 3 },
|
||||
);
|
||||
|
||||
backend.initApp(isDev, self).catch(err => {
|
||||
|
||||
118
packages/desktop-client/src/components/EnableBankingCallback.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { Paragraph } from '@actual-app/components/paragraph';
|
||||
import { View } from '@actual-app/components/view';
|
||||
import { send } from '@actual-app/core/platform/client/connection';
|
||||
|
||||
import { Error as ErrorAlert } from '#components/alerts';
|
||||
import { useUrlParam } from '#hooks/useUrlParam';
|
||||
|
||||
export function EnableBankingCallback() {
|
||||
const { t } = useTranslation();
|
||||
const [code] = useUrlParam('code');
|
||||
const [stateParam] = useUrlParam('state');
|
||||
const [errorParam] = useUrlParam('error');
|
||||
const storedState = localStorage.getItem('enablebanking_auth_state');
|
||||
const stateValid =
|
||||
typeof stateParam === 'string' &&
|
||||
typeof storedState === 'string' &&
|
||||
stateParam === storedState;
|
||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>(
|
||||
'loading',
|
||||
);
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const calledRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (calledRef.current) return;
|
||||
calledRef.current = true;
|
||||
|
||||
async function handleCallback() {
|
||||
if (errorParam) {
|
||||
setStatus('error');
|
||||
setErrorMessage(
|
||||
t('Authorization was denied or failed: {{error}}', {
|
||||
error: errorParam,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
setStatus('error');
|
||||
setErrorMessage(t('Missing authorization parameters.'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stateValid) {
|
||||
localStorage.removeItem('enablebanking_auth_state');
|
||||
setStatus('error');
|
||||
setErrorMessage(t('Authorization state mismatch. Please try again.'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await send('enablebanking-complete-auth', {
|
||||
code,
|
||||
state: stateParam,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
setStatus('error');
|
||||
setErrorMessage(
|
||||
result.error.message || t('Failed to complete authorization.'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus('success');
|
||||
localStorage.removeItem('enablebanking_auth_state');
|
||||
|
||||
// Auto-close after a short delay
|
||||
setTimeout(() => {
|
||||
window.close();
|
||||
}, 1500);
|
||||
} catch {
|
||||
setStatus('error');
|
||||
setErrorMessage(t('An unexpected error occurred.'));
|
||||
}
|
||||
}
|
||||
|
||||
void handleCallback();
|
||||
}, [code, stateParam, stateValid, errorParam, t]);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
padding: 20,
|
||||
maxWidth: 500,
|
||||
margin: '40px auto',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{status === 'loading' && (
|
||||
<Paragraph>
|
||||
<Trans>Completing authorization...</Trans>
|
||||
</Paragraph>
|
||||
)}
|
||||
|
||||
{status === 'success' && (
|
||||
<Paragraph>
|
||||
<Trans>
|
||||
Authorization successful! This window will close automatically.
|
||||
</Trans>
|
||||
</Paragraph>
|
||||
)}
|
||||
|
||||
{status === 'error' && (
|
||||
<>
|
||||
<ErrorAlert>{errorMessage}</ErrorAlert>
|
||||
<Paragraph style={{ marginTop: 10 }}>
|
||||
<Trans>You can close this window and try again.</Trans>
|
||||
</Paragraph>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -30,7 +30,7 @@ describe('FatalError', () => {
|
||||
expect(screen.getByText(/IndexedDB/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the generic simple message for an app-init-failure without a specific cause', () => {
|
||||
it('renders a backend-worker message for a BackendInitFailure', () => {
|
||||
const error = {
|
||||
type: 'app-init-failure',
|
||||
BackendInitFailure: true,
|
||||
@@ -38,6 +38,16 @@ describe('FatalError', () => {
|
||||
|
||||
render(<FatalError error={error} />, { wrapper: TestProviders });
|
||||
|
||||
expect(
|
||||
screen.getByText(/couldn't load a critical backend worker/i),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the generic simple message for an app-init-failure without a specific cause', () => {
|
||||
const error = { type: 'app-init-failure' };
|
||||
|
||||
render(<FatalError error={error} />, { wrapper: TestProviders });
|
||||
|
||||
expect(
|
||||
screen.getByText(/problem loading the app in this browser version/i),
|
||||
).toBeInTheDocument();
|
||||
|
||||
@@ -69,10 +69,17 @@ function RenderSimple({ error }: RenderSimpleProps) {
|
||||
</Trans>
|
||||
</Text>
|
||||
);
|
||||
} else if ('BackendInitFailure' in error && error.BackendInitFailure) {
|
||||
msg = (
|
||||
<Text>
|
||||
<Trans>
|
||||
Actual couldn't load a critical backend worker. Reload the page to try
|
||||
again; if the problem persists, do a hard refresh to clear any stale
|
||||
cached assets.
|
||||
</Trans>
|
||||
</Text>
|
||||
);
|
||||
} else {
|
||||
// This indicates the backend failed to initialize. Show the
|
||||
// user something at least so they aren't looking at a blank
|
||||
// screen
|
||||
msg = (
|
||||
<Text>
|
||||
<Trans>
|
||||
@@ -92,19 +99,6 @@ function RenderSimple({ error }: RenderSimpleProps) {
|
||||
}}
|
||||
>
|
||||
<Text>{msg}</Text>
|
||||
<Text>
|
||||
<Trans>
|
||||
Please get{' '}
|
||||
<Link
|
||||
variant="external"
|
||||
linkColor="muted"
|
||||
to="https://actualbudget.org/contact"
|
||||
>
|
||||
in touch
|
||||
</Link>{' '}
|
||||
for support
|
||||
</Trans>
|
||||
</Text>
|
||||
</SpaceBetween>
|
||||
);
|
||||
}
|
||||
|
||||