Compare commits

..

1 Commits

Author SHA1 Message Date
Julian Dominguez-Schatz
65c0a5a316 🔖 (25.3.1) (#4497)
* fix negative amount parsing (#4489)

* fix negative amount parsing

* note

* Remove used release notes

* Empty commit to bump ci

* Fix number input on mobile with hidden decimals (#4503)

* Fix number input on mobile with hidden decimals

* Add release notes

* Remove used release notes

* Empty commit to bump ci

* Bump non-server versions

* Bump sync-server

---------

Co-authored-by: Matt Fiddaman <github@m.fiddaman.uk>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-03-02 20:17:29 -05:00
1894 changed files with 26994 additions and 38594 deletions

View File

@@ -1,11 +1,14 @@
// 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",
"dockerComposeFile": ["../docker-compose.yml", "docker-compose.yml"],
// Alternatively:
// "image": "mcr.microsoft.com/devcontainers/typescript-node:0-16",
"service": "actual-development",
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
"postCreateCommand": "yarn install"
"name": "Actual development",
"dockerComposeFile": [
"../docker-compose.yml",
"docker-compose.yml"
],
// Alternatively:
// "image": "mcr.microsoft.com/devcontainers/typescript-node:0-16",
"service": "actual-development",
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
"postCreateCommand": "yarn install"
}

View File

@@ -1,7 +1,7 @@
name: Bug Report
description: File a bug report also known as an issue or problem.
title: '[Bug]: '
labels: ['needs triage', 'bug']
labels: ['bug']
body:
- type: markdown
id: intro-md

View File

@@ -1 +1 @@
<!-- Thank you for submitting a pull request! Make sure to follow the instructions to write release notes for your PR — it should only take a minute or two: https://github.com/actualbudget/docs#writing-good-release-notes. Try running yarn generate:release-notes *before* pushing your PR for an interactive experience. -->
<!-- Thank you for submitting a pull request! Make sure to follow the instructions to write release notes for your PR — it should only take a minute or two: https://github.com/actualbudget/docs#writing-good-release-notes -->

View File

@@ -11,36 +11,13 @@ files_to_bump=(
packages/api/package.json
packages/desktop-client/package.json
packages/desktop-electron/package.json
packages/sync-server/package.json
)
for file in "${files_to_bump[@]}"; do
if [ -z "$version" ]; then
# version format: YY.MM.patch
version="$(jq -r .version "$file" | perl -e '
($y,$m,$p)=split(/\./,<>);
($sec,$min,$hour,$day,$mon,$year)=localtime();
$year -= 100; # Perl year starts at 1900
$mon++; # Adjust 0-indexed month to 1-indexed
if ($y == $year && $m == $mon) {
if ($day <= 25) {
# Patch release for the current month
$p++;
} else {
# Use next month for a new release period
$p = 0;
$m++;
$m > 12 && ($m=1, $y++);
}
} else {
# Use the current date for a new release period
$y = $year;
$m = $mon;
$p = 0;
}
print "$y.$m.$p\n";
')"
# logic: if before the 25th, bump patch, else set minor/major to next month
version="$(jq -r .version "$file" | perl -e '($y,$m,$p)=split/\./,<>;$d=(localtime)[3];$d>25?($p=0,++$m,$m>12&&($m=1,++$y)):$p++;print"$y.$m.$p\n"')"
if [ -z "$version" ]; then
echo "Error: Failed to calculate new version" >&2
exit 1

View File

@@ -16,21 +16,17 @@ runs:
- name: Install node
uses: actions/setup-node@v4
with:
node-version: 20
node-version: 18.16.0
- name: Install yarn
run: npm install -g yarn
shell: bash
if: ${{ env.ACT }}
- name: Get Node version
id: get-node
run: echo "version=$(node -v)" >> "$GITHUB_OUTPUT"
shell: bash
- name: Cache
uses: actions/cache@v4
id: cache
with:
path: ${{ format('{0}/**/node_modules', inputs.working-directory) }}
key: yarn-v1-${{ runner.os }}-${{ steps.get-node.outputs.version }}-${{ hashFiles(format('{0}/**/yarn.lock', inputs.working-directory)) }}
key: yarn-v1-${{ runner.os }}-${{ hashFiles(format('{0}/.nvmrc', inputs.working-directory)) }}-${{ hashFiles(format('{0}/**/yarn.lock', inputs.working-directory)) }}
- name: Install
working-directory: ${{ inputs.working-directory }}
run: yarn --immutable

View File

@@ -57,7 +57,7 @@ jobs:
- name: Set up environment
uses: ./.github/actions/setup
- name: Build Web
run: yarn build:browser
run: ./bin/package-browser
- name: Upload Build
uses: actions/upload-artifact@v4
with:
@@ -76,7 +76,7 @@ jobs:
- name: Set up environment
uses: ./.github/actions/setup
- name: Build Server
run: yarn workspace @actual-app/sync-server build
run: cd packages/sync-server && yarn build
- name: Upload Build
uses: actions/upload-artifact@v4
with:

View File

@@ -43,6 +43,6 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: '19'
- name: Check migrations
run: node ./.github/actions/check-migrations.js

View File

@@ -73,19 +73,17 @@ jobs:
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
# Building outside of the docker image allows us to build once and push to multiple platforms
# This is faster and avoids yarn memory issues
- name: Set up environment
uses: ./.github/actions/setup
- name: Build Web
run: yarn build:server
- name: Download artifacts
run: ./packages/sync-server/docker/download-artifacts.sh
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push image
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
file: packages/sync-server/docker/${{ matrix.os }}.Dockerfile
file: packages/sync-server/docker/edge-${{ matrix.os }}.Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7${{ matrix.os == 'alpine' && ',linux/arm/v6' || '' }}
tags: ${{ steps.meta.outputs.tags }}
build-args: |

View File

@@ -70,19 +70,12 @@ jobs:
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
# Building outside of the docker image allows us to build once and push to multiple platforms
# This is faster and avoids yarn memory issues
- name: Set up environment
uses: ./.github/actions/setup
- name: Build Web
run: yarn build:server
- name: Build and push ubuntu image
uses: docker/build-push-action@v5
with:
context: .
push: true
file: packages/sync-server/docker/ubuntu.Dockerfile
file: packages/sync-server/docker/stable-ubuntu.Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: ${{ steps.meta.outputs.tags }}
@@ -91,6 +84,6 @@ jobs:
with:
context: .
push: true
file: packages/sync-server/docker/alpine.Dockerfile
file: packages/sync-server/docker/stable-alpine.Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6
tags: ${{ steps.alpine-meta.outputs.tags }}

View File

@@ -32,7 +32,7 @@ jobs:
needs: netlify
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.52.0-jammy
image: mcr.microsoft.com/playwright:v1.41.1-jammy
steps:
- uses: actions/checkout@v4
- name: Set up environment
@@ -48,33 +48,12 @@ jobs:
path: packages/desktop-client/test-results/
retention-days: 30
overwrite: true
functional-desktop-app:
name: Functional Desktop App
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.52.0-jammy
steps:
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
- name: Run Desktop app E2E Tests
run: |
xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" -- yarn e2e:desktop
- uses: actions/upload-artifact@v4
if: always()
with:
name: desktop-app-test-results
path: packages/desktop-electron/e2e/test-results/
retention-days: 30
overwrite: true
vrt:
name: Visual regression
needs: netlify
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.52.0-jammy
image: mcr.microsoft.com/playwright:v1.41.1-jammy
steps:
- uses: actions/checkout@v4
- name: Set up environment

View File

@@ -77,68 +77,13 @@ jobs:
name: actual-electron-${{ matrix.os }}-appx
path: |
packages/desktop-electron/dist/*.appx
- name: Process release version
id: process_version
run: |
echo "version=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- name: Add to new release
- name: Add to Release
uses: softprops/action-gh-release@v2
with:
draft: true
body: |
:link: [View release notes](https://actualbudget.org/blog/release-${{ steps.process_version.outputs.version }})
## Desktop releases
Please note: Microsoft store updates can sometimes lag behind the main release by a couple of days while they verify the new version.
<a href="https://apps.microsoft.com/detail/9p2hmlhsdbrm?cid=Github+Releases&mode=direct">
<img src="https://get.microsoft.com/images/en-us%20dark.svg" width="200"/>
</a>
files: |
packages/desktop-electron/dist/*.dmg
packages/desktop-electron/dist/*.exe
!packages/desktop-electron/dist/Actual-windows.exe
packages/desktop-electron/dist/*.AppImage
packages/desktop-electron/dist/*.flatpak
publish-microsoft-store:
needs: build
runs-on: windows-latest
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@v4
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

View File

@@ -27,7 +27,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
node-version: '19'
- name: Handle feature requests
run: node .github/actions/handle-feature-requests.js
env:

View File

@@ -30,7 +30,7 @@ jobs:
run: npm install netlify-cli@17.10.1 -g
- name: Build Actual
run: yarn build:browser
run: ./bin/package-browser
- name: Deploy to Netlify
id: netlify_deploy

View File

@@ -1,78 +0,0 @@
name: Publish npm packages
# # Npm packages are published for every new tag
on:
push:
tags:
- 'v*.*.*'
jobs:
build-and-pack:
runs-on: ubuntu-latest
name: Build and pack npm packages
steps:
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
- name: Build Web
run: yarn build:server
- name: Pack the web and server packages
run: |
yarn workspace @actual-app/web pack --filename @actual-app/web.tgz
yarn workspace @actual-app/sync-server pack --filename @actual-app/sync-server.tgz
- name: Build API
run: yarn build:api
- name: Pack the api package
run: |
yarn workspace @actual-app/api pack --filename @actual-app/api.tgz
- name: Upload package artifacts
uses: actions/upload-artifact@v4
with:
name: npm-packages
path: |
packages/desktop-client/@actual-app/web.tgz
packages/sync-server/@actual-app/sync-server.tgz
packages/api/@actual-app/api.tgz
publish:
runs-on: ubuntu-latest
name: Publish npm packages
needs: build-and-pack
permissions:
contents: read
packages: write
steps:
- name: Download the artifacts
uses: actions/download-artifact@v4
with:
name: npm-packages
- name: Setup node and npm registry
uses: actions/setup-node@v4
with:
node-version: 20
registry-url: 'https://registry.npmjs.org'
- name: Publish Web
run: |
npm publish desktop-client/@actual-app/web.tgz --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish Sync-Server
run: |
npm publish sync-server/@actual-app/sync-server.tgz --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish API
run: |
npm publish api/@actual-app/api.tgz --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -1,7 +1,7 @@
name: /update-vrt
on:
issue_comment:
types: [created]
types: [ created ]
permissions:
pull-requests: read
@@ -19,10 +19,10 @@ jobs:
github.event.issue.pull_request &&
contains(github.event.comment.body, '/update-vrt')
container:
image: mcr.microsoft.com/playwright:v1.52.0-jammy
image: mcr.microsoft.com/playwright:v1.41.1-jammy
steps:
- name: Get PR branch
# Until https://github.com/xt0rted/pull-request-comment-branch/issues/322 is resolved we use the forked version
# Until https://github.com/xt0rted/pull-request-comment-branch/issues/322 is resolved we use the forked version
uses: gotson/pull-request-comment-branch@head-repo-owner-dist
id: comment-branch
- uses: actions/checkout@v4
@@ -31,10 +31,6 @@ jobs:
ref: ${{ steps.comment-branch.outputs.head_ref }}
- name: Set up environment
uses: ./.github/actions/setup
- name: Run VRT Tests on Desktop app
continue-on-error: true
run: |
xvfb-run --auto-servernum --server-args="-screen 0 1920x1080x24" -- yarn e2e:desktop --update-snapshots
- name: Wait for Netlify build to finish
id: netlify
env:
@@ -101,7 +97,7 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
commentId: ${{ github.event.comment.id }}
reaction: 'rocket'
reaction: "rocket"
add-starting-reaction:
runs-on: ubuntu-latest
@@ -116,4 +112,4 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
commentId: ${{ github.event.comment.id }}
reaction: '+1'
reaction: "+1"

7
.gitignore vendored
View File

@@ -3,7 +3,6 @@
!data/.gitkeep
/data2
Actual-*
!actual-server.js
**/xcuserdata/*
export-2020-01-10.csv
@@ -54,9 +53,3 @@ bundle.mobile.js.map
# build output
package.tgz
# Fly.io configuration
fly.toml
# TypeScript cache
build/

2
.nvmrc
View File

@@ -1 +1 @@
v20/*
v18.16.0

View File

@@ -1,30 +1 @@
sync_pb.*
packages/api/app/bundle.api.js
packages/api/app/stats.json
packages/api/dist
packages/api/@types
packages/api/migrations
packages/crdt/dist
packages/component-library/src/icons/**/*
packages/desktop-client/bundle.browser.js
packages/desktop-client/build/
packages/desktop-client/locale/
packages/desktop-client/build-electron/
packages/desktop-client/build-stats/
packages/desktop-client/public/kcab/
packages/desktop-client/public/data/
packages/desktop-client/**/node_modules/*
packages/desktop-client/node_modules/
packages/desktop-client/test-results/
packages/desktop-client/playwright-report/
packages/desktop-electron/client-build/
packages/desktop-electron/build/
packages/desktop-electron/dist/
packages/import-ynab4/**/node_modules/*
packages/import-ynab5/**/node_modules/*
packages/loot-core/**/node_modules/*
packages/loot-core/**/lib-dist/*
packages/loot-core/**/proto/*
.yarn/*
.github/*
upcoming-release-notes/*

View File

@@ -1,10 +0,0 @@
diff --git a/methods/inflater.js b/methods/inflater.js
index 8769e66e82b25541aba80b1ac6429199c9a8179f..1d4402402f0e1aaf64062c1f004c3d6e6fe93e76 100644
--- a/methods/inflater.js
+++ b/methods/inflater.js
@@ -1,4 +1,4 @@
-const version = +(process.versions ? process.versions.node : "").split(".")[0] || 0;
+const version = +(process?.versions?.node ?? "").split(".")[0] || 0;
module.exports = function (/*Buffer*/ inbuf, /*number*/ expectedLength) {
var zlib = require("zlib");

894
.yarn/releases/yarn-4.3.1.cjs vendored Executable file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -6,4 +6,4 @@ enableTransparentWorkspaces: false
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.9.1.cjs
yarnPath: .yarn/releases/yarn-4.3.1.cjs

View File

@@ -5,7 +5,7 @@
# you are doing.
###################################################
FROM node:20-bullseye as dev
FROM node:18-bullseye as dev
RUN apt-get update -y && apt-get upgrade -y && apt-get install -y openssl
WORKDIR /app
CMD ["sh", "./bin/docker-start"]

View File

@@ -66,11 +66,7 @@ To add new feature requests, open a new Issue of the "Feature Request" type.
### Translation
Make Actual Budget accessible to more people by helping with the [Internationalization](https://actualbudget.org/docs/contributing/i18n/) of Actual. We are using a crowd sourcing tool to manage the translations, see our [Weblate Project](https://hosted.weblate.org/projects/actualbudget/). Weblate proudly supports open-source software projects through their [Libre plan](https://weblate.org/en/hosting/#libre).
<a href="https://hosted.weblate.org/engage/actualbudget/">
<img src="https://hosted.weblate.org/widget/actualbudget/actual/287x66-grey.png" alt="Translation status" />
</a>
Make Actual Budget accessible to more people by helping with the [Internationalization](https://actualbudget.org/docs/contributing/i18n/) of Actual. We are using a crowd sourcing tool to manage the translations, see our [Weblate Project](https://hosted.weblate.org/projects/actualbudget/). Weblate proudly supports open-source software projects through their [Libre plan](https://weblate.org/en/hosting/#libre).
## Repo Activity

View File

@@ -9,7 +9,6 @@ if ! [ -d packages/desktop-client/locale ]; then
git clone https://github.com/actualbudget/translations packages/desktop-client/locale
fi
pushd packages/desktop-client/locale > /dev/null
git checkout .
git pull
popd > /dev/null
packages/desktop-client/bin/remove-untranslated-languages

View File

@@ -6,8 +6,8 @@ RELEASE=""
CI=${CI:-false}
cd "$ROOT/.."
POSITIONAL=()
SKIP_EXE_BUILD=false
while [[ $# -gt 0 ]]; do
key="$1"
@@ -16,19 +16,26 @@ while [[ $# -gt 0 ]]; do
RELEASE="production"
shift
;;
--skip-exe-build)
SKIP_EXE_BUILD=true
shift
;;
*)
POSITIONAL+=("$1")
shift
;;
esac
done
set -- "${POSITIONAL[@]}"
if [ "$OSTYPE" == "msys" ]; then
if [ $CI != true ]; then
read -s -p "Windows certificate password: " -r CSC_KEY_PASSWORD
export CSC_KEY_PASSWORD
elif [ -n "$CIRCLE_TAG" ]; then
# We only want to run this on CircleCI as Github doesn't have the CSC_KEY_PASSWORD secret set.
certutil -f -p ${CSC_KEY_PASSWORD} -importPfx ~/windows-shift-reset-llc.p12
fi
fi
yarn workspace loot-core build:node
# Get translations
echo "Updating translations..."
if ! [ -d packages/desktop-client/locale ]; then
@@ -39,34 +46,22 @@ git pull
popd > /dev/null
packages/desktop-client/bin/remove-untranslated-languages
yarn workspace loot-core build:node
yarn workspace @actual-app/web build --mode=desktop # electron specific build
# required for running the sync-server server
yarn workspace loot-core build:browser
yarn workspace @actual-app/web build:browser
yarn workspace @actual-app/sync-server build
yarn workspace desktop-electron update-client
(
cd packages/desktop-electron;
yarn clean;
if [ $SKIP_EXE_BUILD == true ]; then
echo "Building the dist"
yarn build:dist
echo "Skipping exe build"
else
if [ "$RELEASE" == "production" ]; then
if [ -f ../../.secret-tokens ]; then
source ../../.secret-tokens
fi
yarn build
if [ "$RELEASE" == "production" ]; then
if [ -f ../../.secret-tokens ]; then
source ../../.secret-tokens
fi
yarn build
echo "Created release"
else
SKIP_NOTARIZATION=true yarn build
fi
echo "\nCreated release"
else
SKIP_NOTARIZATION=true yarn build
fi
)

View File

@@ -1,182 +0,0 @@
import { exec } from 'node:child_process';
import { existsSync, writeFile } from 'node:fs';
import { exit } from 'node:process';
import prompts from 'prompts';
async function run() {
const username = await execAsync(
// eslint-disable-next-line rulesdir/typography
"gh api user --jq '.login'",
'To avoid having to enter your username, consider installing the official GitHub CLI (https://github.com/cli/cli) and logging in with `gh auth login`.',
);
const activePr = await getActivePr(username);
if (activePr) {
console.log(
`Found potentially matching PR ${activePr.number}: ${activePr.title}`,
);
}
const prNumber = activePr?.number ?? (await getNextPrNumber());
const result = await prompts([
{
name: 'githubUsername',
message: 'Comma-separated GitHub username(s)',
type: 'text',
initial: username,
},
{
name: 'pullRequestNumber',
message: 'PR Number',
type: 'number',
initial: prNumber,
},
{
name: 'releaseNoteType',
message: 'Release Note Type',
type: 'select',
choices: [
{ title: '✨ Features', value: 'Features' },
{ title: '👍 Enhancements', value: 'Enhancements' },
{ title: '🐛 Bugfix', value: 'Bugfix' },
{ title: '⚙️ Maintenance', value: 'Maintenance' },
],
},
{
name: 'oneLineSummary',
message: 'Brief Summary',
type: 'text',
initial: activePr?.title,
},
]);
if (
!result.githubUsername ||
!result.oneLineSummary ||
!result.releaseNoteType
) {
console.log('All questions must be answered. Exiting');
exit(1);
}
const fileContents = getFileContents(
result.releaseNoteType,
result.githubUsername,
result.oneLineSummary,
);
const filepath = `./upcoming-release-notes/${prNumber}.md`;
if (existsSync(filepath)) {
const { confirm } = await prompts({
name: 'confirm',
type: 'confirm',
message: `This will overwrite the existing release note ${filepath} Are you sure?`,
});
if (!confirm) {
console.log('Exiting');
exit(1);
}
}
writeFile(filepath, fileContents, err => {
if (err) {
console.error('Failed to write release note file:', err);
exit(1);
} else {
console.log(
`Release note generated successfully: ./upcoming-release-notes/${prNumber}.md`,
);
}
});
}
// makes an attempt to find an existing open PR from <username>:<branch>
async function getActivePr(
username: string,
): Promise<{ number: number; title: string } | undefined> {
if (!username) {
return undefined;
}
const branchName = await execAsync('git rev-parse --abbrev-ref HEAD');
if (!branchName) {
return undefined;
}
const forkHead = `${username}:${branchName}`;
return getPrNumberFromHead(forkHead);
}
async function getPrNumberFromHead(
head: string,
): Promise<{ number: number; title: string } | undefined> {
try {
// head is a weird query parameter in this API call. If nothing matches, it
// will return as if the head query parameter doesn't exist. To get around
// this, we make the page size 2 and only return the number if the length.
const resp = await fetch(
'https://api.github.com/repos/actualbudget/actual/pulls?state=open&per_page=2&head=' +
head,
);
if (!resp.ok) {
console.warn('error fetching from github pulls api:', resp.status);
return undefined;
}
const ghResponse = await resp.json();
if (ghResponse?.length === 1) {
return ghResponse[0];
} else {
return undefined;
}
} catch (e) {
console.warn('error fetching from github pulls api:', e);
}
}
async function getNextPrNumber(): Promise<number> {
try {
const resp = await fetch(
'https://api.github.com/repos/actualbudget/actual/issues?state=all&per_page=1',
);
if (!resp.ok) {
throw new Error(`API responded with status: ${resp.status}`);
}
const ghResponse = await resp.json();
const latestPrNumber = ghResponse?.[0]?.number;
if (!latestPrNumber) {
console.error(
'Could not find latest issue number in GitHub API response',
ghResponse,
);
exit(1);
}
return latestPrNumber + 1;
} catch (error) {
console.error('Failed to fetch next PR number:', error);
exit(1);
}
}
function getFileContents(type: string, username: string, summary: string) {
return `---
category: ${type}
authors: [${username}]
---
${summary}
`;
}
// simple exec that fails silently and returns an empty string on failure
async function execAsync(cmd: string, errorLog?: string): Promise<string> {
return new Promise<string>(res => {
exec(cmd, (error, stdout) => {
if (error) {
console.log(errorLog);
res('');
} else {
res(stdout.trim());
}
});
});
}
run();

View File

@@ -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.52.0-jammy /bin/bash \
MSYS_NO_PATHCONV=1 docker run --rm --network host -v "$(pwd)":/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.41.1-jammy /bin/bash \
-c "E2E_START_URL=$E2E_START_URL yarn vrt $VRT_ARGS"

View File

@@ -15,3 +15,4 @@ services:
volumes:
- '.:/app'
restart: 'no'

View File

@@ -5,11 +5,11 @@ import globals from 'globals';
import pluginImport from 'eslint-plugin-import';
import pluginJSXA11y from 'eslint-plugin-jsx-a11y';
import pluginPrettier from 'eslint-plugin-prettier/recommended';
import pluginReact from 'eslint-plugin-react';
import pluginReactHooks from 'eslint-plugin-react-hooks';
import pluginRulesDir from 'eslint-plugin-rulesdir';
import pluginTypescript from 'typescript-eslint';
import pluginTypescriptPaths from 'eslint-plugin-typescript-paths';
import tsParser from '@typescript-eslint/parser';
@@ -85,16 +85,15 @@ const confusingBrowserGlobals = [
'top',
];
export default pluginTypescript.config(
/** @type {import('eslint').Linter.Config[]} */
export default [
{
ignores: [
'packages/api/app/bundle.api.js',
'packages/api/app/stats.json',
'packages/api/dist',
'packages/api/@types',
'packages/api/migrations',
'packages/crdt/dist',
'packages/component-library/src/icons/**/*',
'packages/desktop-client/bundle.browser.js',
'packages/desktop-client/build/',
'packages/desktop-client/build-electron/',
@@ -103,6 +102,7 @@ export default pluginTypescript.config(
'packages/desktop-client/public/data/',
'packages/desktop-client/**/node_modules/*',
'packages/desktop-client/node_modules/',
'packages/desktop-client/src/icons/**/*',
'packages/desktop-client/test-results/',
'packages/desktop-client/playwright-report/',
'packages/desktop-electron/client-build/',
@@ -117,26 +117,6 @@ export default pluginTypescript.config(
'.github/*',
],
},
{
// Temporary until the sync-server is migrated to TypeScript
files: [
'packages/sync-server/**/*.spec.{js,jsx}',
'packages/sync-server/**/*.test.{js,jsx}',
],
languageOptions: {
globals: {
vi: true,
describe: true,
expect: true,
it: true,
beforeAll: true,
beforeEach: true,
afterAll: true,
afterEach: true,
test: true,
},
},
},
{
linterOptions: {
reportUnusedDisableDirectives: true,
@@ -145,6 +125,7 @@ export default pluginTypescript.config(
globals: {
...globals.browser,
...globals.commonjs,
...globals.jest,
...globals.node,
globalThis: false,
vi: true,
@@ -164,14 +145,14 @@ export default pluginTypescript.config(
},
pluginReact.configs.flat.recommended,
pluginReact.configs.flat['jsx-runtime'],
pluginTypescript.configs.recommended,
pluginPrettier,
...pluginTypescript.configs.recommended,
pluginImport.flatConfigs.recommended,
{
plugins: {
'react-hooks': pluginReactHooks,
'jsx-a11y': pluginJSXA11y,
rulesdir: pluginRulesDir,
'typescript-paths': pluginTypescriptPaths,
},
},
{
@@ -540,7 +521,7 @@ export default pluginTypescript.config(
},
},
{
files: ['**/*.{ts,tsx}'],
files: ['**/*.ts?(x)'],
languageOptions: {
parser: tsParser,
@@ -548,7 +529,7 @@ export default pluginTypescript.config(
sourceType: 'module',
parserOptions: {
projectService: true,
project: [path.join(__dirname, './tsconfig.json')],
ecmaFeatures: {
jsx: true,
},
@@ -568,13 +549,6 @@ export default pluginTypescript.config(
// 'tsc' already handles this (https://github.com/typescript-eslint/typescript-eslint/issues/477)
'no-undef': 'off',
// TypeScript already handles these (https://typescript-eslint.io/troubleshooting/typed-linting/performance/#eslint-plugin-import)
'import/named': 'off',
'import/namespace': 'off',
'import/default': 'off',
'import/no-named-as-default-member': 'off',
'import/no-unresolved': 'off',
// Add TypeScript specific rules (and turn off ESLint equivalents)
'@typescript-eslint/consistent-type-assertions': 'warn',
'no-array-constructor': 'off',
@@ -608,16 +582,6 @@ export default pluginTypescript.config(
'@typescript-eslint/no-useless-constructor': 'warn',
},
},
{
files: ['packages/desktop-client/**/*.{js,ts,jsx,tsx}'],
rules: {
'typescript-paths/absolute-parent-import': [
'error',
{ preferPathOverBaseUrl: true },
],
'typescript-paths/absolute-import': ['error', { enableAlias: false }],
},
},
{
files: [
'packages/desktop-client/**/*.{ts,tsx}',
@@ -782,16 +746,6 @@ export default pluginTypescript.config(
'import/no-unresolved': 'off',
},
},
// Allow configuring vitest with default exports (recommended as per vitest docs)
{
files: ['**/vitest.config.ts', '**/vitest.web.config.ts'],
rules: {
'import/no-anonymous-default-export': 'off',
'import/no-default-export': 'off',
},
},
{},
{
// TODO: fix the issues in these files
@@ -835,6 +789,7 @@ export default pluginTypescript.config(
'packages/desktop-client/src/components/select/DateSelect.tsx',
'packages/desktop-client/src/components/sidebar/Tools.tsx',
'packages/desktop-client/src/components/sort.tsx',
'packages/desktop-client/src/components/spreadsheet/useSheetValue.ts',
],
rules: {
@@ -883,4 +838,4 @@ export default pluginTypescript.config(
'@typescript-eslint/no-unused-vars': 'off',
},
},
);
];

View File

@@ -22,79 +22,65 @@
"start:server": "yarn workspace @actual-app/sync-server start",
"start:server-monitor": "yarn workspace @actual-app/sync-server start-monitor",
"start:server-dev": "NODE_ENV=development BROWSER_OPEN=localhost:5006 yarn npm-run-all --parallel 'start:server-monitor' 'start'",
"start:desktop": "yarn desktop-dependencies && npm-run-all --parallel 'start:desktop-*'",
"desktop-dependencies": "yarn rebuild-electron && yarn workspace loot-core build:browser",
"start:desktop": "yarn rebuild-electron && npm-run-all --parallel 'start:desktop-*'",
"start:desktop-node": "yarn workspace loot-core watch:node",
"start:desktop-client": "yarn workspace @actual-app/web watch",
"start:desktop-server-client": "yarn workspace @actual-app/web build:browser",
"start:desktop-electron": "yarn workspace desktop-electron watch",
"start:electron": "yarn start:desktop",
"start:browser": "npm-run-all --parallel 'start:browser-*'",
"start:browser-backend": "yarn workspace loot-core watch:browser",
"start:browser-frontend": "yarn workspace @actual-app/web start:browser",
"build:server": "yarn build:browser && yarn workspace @actual-app/sync-server build",
"build:browser": "./bin/package-browser",
"build:desktop": "./bin/package-electron",
"build:api": "yarn workspace @actual-app/api build",
"generate:i18n": "yarn workspace @actual-app/web generate:i18n",
"generate:release-notes": "ts-node ./bin/release-note-generator.ts",
"test": "yarn workspaces foreach --all --parallel --verbose run test",
"test:debug": "yarn workspaces foreach --all --verbose run test",
"e2e": "yarn workspaces foreach --all --exclude desktop-electron --parallel --verbose run e2e",
"e2e:desktop": "yarn build:desktop --skip-exe-build && yarn workspace desktop-electron e2e",
"e2e": "yarn workspaces foreach --all --parallel --verbose run e2e",
"vrt": "yarn workspaces foreach --all --parallel --verbose run vrt",
"vrt:docker": "./bin/run-vrt",
"rebuild-electron": "./node_modules/.bin/electron-rebuild -f -m ./packages/loot-core",
"rebuild-node": "yarn workspace loot-core rebuild",
"lint": "prettier --check . && eslint . --max-warnings 0",
"lint:fix": "prettier --check --write . && eslint . --max-warnings 0 --fix",
"lint": "eslint . --max-warnings 0",
"lint:verbose": "DEBUG=eslint:cli-engine eslint . --max-warnings 0",
"install:server": "yarn workspaces focus @actual-app/sync-server --production",
"typecheck": "yarn tsc --incremental && tsc-strict",
"typecheck": "yarn tsc && tsc-strict",
"jq": "./node_modules/node-jq/bin/jq",
"prepare": "husky"
},
"devDependencies": {
"@types/node": "^22.15.18",
"@types/prompts": "^2.4.9",
"@typescript-eslint/parser": "^8.32.1",
"@typescript-eslint/parser": "^8.18.1",
"cross-env": "^7.0.3",
"eslint": "^9.27.0",
"eslint-config-prettier": "^10.1.5",
"eslint-import-resolver-typescript": "^4.3.5",
"eslint": "^9.17.0",
"eslint-config-prettier": "^9.1.0",
"eslint-import-resolver-typescript": "^3.7.0",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-prettier": "5.2.1",
"eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-rulesdir": "^0.2.2",
"eslint-plugin-typescript-paths": "^0.0.33",
"globals": "^15.15.0",
"html-to-image": "^1.11.13",
"husky": "^9.1.7",
"lint-staged": "^15.5.2",
"node-jq": "^6.0.1",
"globals": "^15.13.0",
"husky": "^9.0.11",
"lint-staged": "^15.2.9",
"node-jq": "^4.0.1",
"npm-run-all": "^4.1.5",
"prettier": "^3.5.3",
"prompts": "^2.4.2",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"ts-node": "^10.9.2",
"typescript": "^5.8.3",
"typescript-eslint": "^8.32.1",
"typescript": "^5.5.4",
"typescript-eslint": "^8.18.1",
"typescript-strict-plugin": "^2.4.4"
},
"resolutions": {
"rollup": "4.40.1",
"socks": ">=2.8.3"
"rollup": "4.9.4"
},
"engines": {
"node": ">=20",
"yarn": "^4.9.1"
"node": ">=18.0.0"
},
"lint-staged": {
"*.{js,jsx,ts,tsx,md,json,yml}": [
"eslint --fix",
"prettier --write"
]
"*.{js,jsx,ts,tsx,md,json}": "prettier --write"
},
"packageManager": "yarn@4.9.1",
"packageManager": "yarn@4.3.1",
"browserslist": [
"electron 24.0",
"defaults"

View File

@@ -1,7 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`API setup and teardown > successfully loads budget 1`] = `
[
exports[`API setup and teardown successfully loads budget 1`] = `
Array [
"2016-10",
"2016-11",
"2016-12",

View File

@@ -0,0 +1,24 @@
module.exports = {
moduleFileExtensions: [
'testing.js',
'testing.ts',
'api.js',
'api.ts',
'api.tsx',
'electron.js',
'electron.ts',
'mjs',
'js',
'ts',
'tsx',
'json',
],
testEnvironment: 'node',
testPathIgnorePatterns: ['/node_modules/'],
watchPathIgnorePatterns: ['<rootDir>/mocks/budgets/'],
setupFilesAfterEnv: ['<rootDir>/../loot-core/src/mocks/setup.ts'],
transformIgnorePatterns: ['/node_modules/'],
transform: {
'^.+\\.(t|j)sx?$': '@swc/jest',
},
};

View File

@@ -6,9 +6,10 @@ import * as api from './index';
const budgetName = 'test-budget';
global.IS_TESTING = true;
beforeEach(async () => {
// we need real datetime if we are going to mix new timestamps with our mock data
global.restoreDateNow();
const budgetPath = path.join(__dirname, '/mocks/budgets/', budgetName);
await fs.rm(budgetPath, { force: true, recursive: true });
@@ -653,60 +654,4 @@ describe('API CRUD operations', () => {
);
expect(transactions).toHaveLength(1);
});
test('Transactions: import notes are preserved when importing', async () => {
const accountId = await api.createAccount({ name: 'test-account' }, 0);
// Test with notes
const transactionsWithNotes = [
{
date: '2023-11-03',
imported_id: '11',
amount: 100,
notes: 'test note',
},
];
const addResultWithNotes = await api.addTransactions(
accountId,
transactionsWithNotes,
{
learnCategories: true,
runTransfers: true,
},
);
expect(addResultWithNotes).toBe('ok');
let transactions = await api.getTransactions(
accountId,
'2023-11-01',
'2023-11-30',
);
expect(transactions[0].notes).toBe('test note');
// Clear transactions
await api.deleteTransaction(transactions[0].id);
// Test without notes
const transactionsWithoutNotes = [
{ date: '2023-11-03', imported_id: '11', amount: 100, notes: null },
];
const addResultWithoutNotes = await api.addTransactions(
accountId,
transactionsWithoutNotes,
{
learnCategories: true,
runTransfers: true,
},
);
expect(addResultWithoutNotes).toBe('ok');
transactions = await api.getTransactions(
accountId,
'2023-11-01',
'2023-11-30',
);
expect(transactions[0].notes).toBeNull();
});
});

View File

@@ -52,18 +52,10 @@ export async function batchBudgetUpdates(func) {
}
}
/**
* @deprecated Please use `aqlQuery` instead.
* This function will be removed in a future release.
*/
export function runQuery(query) {
return send('api/query', { query: query.serialize() });
}
export function aqlQuery(query) {
return send('api/query', { query: query.serialize() });
}
export function getBudgetMonths() {
return send('api/budget-months');
}

View File

@@ -1,10 +1,10 @@
{
"name": "@actual-app/api",
"version": "25.6.1",
"version": "25.3.1",
"license": "MIT",
"description": "An API for Actual",
"engines": {
"node": ">=20"
"node": ">=18.12.0"
},
"main": "dist/index.js",
"types": "@types/index.d.ts",
@@ -14,25 +14,27 @@
],
"scripts": {
"build:app": "yarn workspace loot-core build:api",
"build:crdt": "yarn workspace @actual-app/crdt build",
"build:node": "tsc --p tsconfig.dist.json && tsc-alias -p tsconfig.dist.json",
"build:migrations": "cp migrations/*.sql dist/migrations",
"build:default-db": "cp default-db.sqlite dist/",
"build": "yarn run clean && yarn run build:app && yarn run build:node && yarn run build:migrations && yarn run build:default-db",
"test": "yarn run build:app && yarn run build:crdt && vitest",
"test": "yarn run build:app && jest -c jest.config.js",
"clean": "rm -rf dist @types"
},
"dependencies": {
"@actual-app/crdt": "workspace:^",
"better-sqlite3": "^11.10.0",
"compare-versions": "^6.1.1",
"better-sqlite3": "^11.7.0",
"compare-versions": "^6.1.0",
"node-fetch": "^3.3.2",
"uuid": "^9.0.1"
},
"devDependencies": {
"@types/uuid": "^9.0.8",
"tsc-alias": "^1.8.16",
"typescript": "^5.8.3",
"vitest": "^3.1.3"
"@swc/core": "^1.5.3",
"@swc/jest": "^0.2.36",
"@types/jest": "^27.5.2",
"@types/uuid": "^9.0.2",
"jest": "^27.5.1",
"tsc-alias": "^1.8.8",
"typescript": "^5.5.4"
}
}

View File

@@ -15,5 +15,5 @@
}
},
"include": ["."],
"exclude": ["**/node_modules/*", "dist", "@types", "*.test.ts"]
"exclude": ["**/node_modules/*", "dist", "@types"]
}

View File

@@ -1,9 +0,0 @@
export default {
test: {
globals: true,
onConsoleLog(log: string, type: 'stdout' | 'stderr'): boolean | void {
// print only console.error
return type === 'stderr';
},
},
};

View File

@@ -3,29 +3,18 @@
"version": "0.0.1",
"license": "MIT",
"peerDependencies": {
"react": ">=18.2",
"react-dom": ">=18.2"
"react": ">=18.2"
},
"dependencies": {
"@emotion/css": "^11.13.5",
"react-aria-components": "^1.8.0",
"usehooks-ts": "^3.1.1"
"@emotion/css": "^11.13.4",
"react-aria-components": "^1.4.1"
},
"devDependencies": {
"@svgr/cli": "^8.1.0",
"@types/react": "^19.1.4",
"react": "19.1.0",
"react-dom": "19.1.0",
"vitest": "^3.1.3"
"@types/react": "^18.2.0",
"react": "18.2.0"
},
"exports": {
"./hooks/*": "./src/hooks/*.ts",
"./icons/logo": "./src/icons/logo/index.ts",
"./icons/v0": "./src/icons/v0/index.ts",
"./icons/v1": "./src/icons/v1/index.ts",
"./icons/v2": "./src/icons/v2/index.ts",
"./icons/AnimatedLoading": "./src/icons/AnimatedLoading.tsx",
"./icons/Loading": "./src/icons/Loading.tsx",
"./icons/*": "./src/icons/*.tsx",
"./aligned-text": "./src/AlignedText.tsx",
"./block": "./src/Block.tsx",
"./button": "./src/Button.tsx",
@@ -33,12 +22,10 @@
"./form-error": "./src/FormError.tsx",
"./initial-focus": "./src/InitialFocus.ts",
"./inline-field": "./src/InlineField.tsx",
"./input": "./src/Input.tsx",
"./label": "./src/Label.tsx",
"./menu": "./src/Menu.tsx",
"./paragraph": "./src/Paragraph.tsx",
"./popover": "./src/Popover.tsx",
"./select": "./src/Select.tsx",
"./space-between": "./src/SpaceBetween.tsx",
"./stack": "./src/Stack.tsx",
"./styles": "./src/styles.ts",
@@ -49,10 +36,5 @@
"./toggle": "./src/Toggle.tsx",
"./tooltip": "./src/Tooltip.tsx",
"./view": "./src/View.tsx"
},
"scripts": {
"generate:icons": "rm src/icons/*/*.tsx; cd src/icons && svgr --template template.ts --index-template index-template.ts --typescript --expand-props start -d . .",
"test": "npm-run-all -cp 'test:*'",
"test:web": "ENV=web vitest -c vitest.web.config.ts"
}
}

View File

@@ -7,7 +7,7 @@ import React, {
} from 'react';
import { Button as ReactAriaButton } from 'react-aria-components';
import { css, cx } from '@emotion/css';
import { css } from '@emotion/css';
import { AnimatedLoading } from './icons/AnimatedLoading';
import { styles } from './styles';
@@ -145,24 +145,26 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
const defaultButtonClassName: string = useMemo(
() =>
css({
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
padding: _getPadding(variant),
margin: 0,
overflow: 'hidden',
display: 'flex',
borderRadius: 4,
backgroundColor: backgroundColor[variantWithDisabled],
border: _getBorder(variant, variantWithDisabled),
color: textColor[variantWithDisabled],
transition: 'box-shadow .25s',
WebkitAppRegion: 'no-drag',
...styles.smallText,
'&[data-hovered]': _getHoveredStyles(variant),
'&[data-pressed]': _getActiveStyles(variant, bounce),
}),
String(
css({
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
padding: _getPadding(variant),
margin: 0,
overflow: 'hidden',
display: 'flex',
borderRadius: 4,
backgroundColor: backgroundColor[variantWithDisabled],
border: _getBorder(variant, variantWithDisabled),
color: textColor[variantWithDisabled],
transition: 'box-shadow .25s',
WebkitAppRegion: 'no-drag',
...styles.smallText,
'&[data-hovered]': _getHoveredStyles(variant),
'&[data-pressed]': _getActiveStyles(variant, bounce),
}),
),
[bounce, variant, variantWithDisabled],
);
@@ -174,8 +176,9 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
{...restProps}
className={
typeof className === 'function'
? renderProps => cx(defaultButtonClassName, className(renderProps))
: cx(defaultButtonClassName, className)
? renderProps =>
`${defaultButtonClassName} ${className(renderProps)}`
: `${defaultButtonClassName} ${className || ''}`
}
>
{children}

View File

@@ -1,59 +1,36 @@
import {
Children,
cloneElement,
isValidElement,
type ReactElement,
Ref,
type Ref,
cloneElement,
useEffect,
useRef,
} from 'react';
type InitialFocusProps<T extends HTMLElement> = {
/**
* The child element to focus when the component mounts. This can be either a single React element or a function that returns a React element.
*/
children: ReactElement<{ ref: Ref<T> }> | ((ref: Ref<T>) => ReactElement);
type InitialFocusProps = {
children:
| ReactElement<{ inputRef: Ref<HTMLInputElement> }>
| ((node: Ref<HTMLInputElement>) => ReactElement);
};
/**
* InitialFocus sets focus on its child element
* when it mounts.
* @param {Object} props - The component props.
* @param {ReactElement | function} props.children - A single React element or a function that returns a React element.
*/
export function InitialFocus<T extends HTMLElement = HTMLElement>({
children,
}: InitialFocusProps<T>) {
const ref = useRef<T | null>(null);
export function InitialFocus({ children }: InitialFocusProps) {
const node = useRef<HTMLInputElement>(null);
useEffect(() => {
if (ref.current) {
if (node.current) {
// This is needed to avoid a strange interaction with
// `ScopeTab`, which doesn't allow it to be focused at first for
// some reason. Need to look into it.
setTimeout(() => {
if (ref.current) {
ref.current.focus();
if (
ref.current instanceof HTMLInputElement ||
ref.current instanceof HTMLTextAreaElement
) {
ref.current.setSelectionRange(0, 10000);
}
if (node.current) {
node.current.focus();
node.current.setSelectionRange(0, 10000);
}
}, 0);
}
}, []);
if (typeof children === 'function') {
return children(ref);
return children(node);
}
const child = Children.only(children);
if (isValidElement(child)) {
return cloneElement(child, { ref });
}
throw new Error(
'InitialFocus expects a single valid React element as its child.',
);
return cloneElement(children, { inputRef: node });
}

View File

@@ -1,117 +0,0 @@
import * as React from 'react';
import { forwardRef, Ref } from 'react';
import { render } from '@testing-library/react';
import { InitialFocus } from './InitialFocus';
import { View } from './View';
describe('InitialFocus', () => {
it('should focus a text input', async () => {
const component = render(
<View>
<InitialFocus>
<input type="text" title="focused" />
</InitialFocus>
<input type="text" title="unfocused" />
</View>,
);
// This is needed bc of the `setTimeout` in the `InitialFocus` component.
await new Promise(resolve => setTimeout(resolve, 0));
const input = component.getByTitle('focused') as HTMLInputElement;
const unfocusedInput = component.getByTitle(
'unfocused',
) as HTMLInputElement;
expect(document.activeElement).toBe(input);
expect(document.activeElement).not.toBe(unfocusedInput);
});
it('should focus a textarea', async () => {
const component = render(
<View>
<InitialFocus>
<textarea title="focused" />
</InitialFocus>
<textarea title="unfocused" />
</View>,
);
// This is needed bc of the `setTimeout` in the `InitialFocus` component.
await new Promise(resolve => setTimeout(resolve, 0));
const textarea = component.getByTitle('focused') as HTMLTextAreaElement;
const unfocusedTextarea = component.getByTitle(
'unfocused',
) as HTMLTextAreaElement;
expect(document.activeElement).toBe(textarea);
expect(document.activeElement).not.toBe(unfocusedTextarea);
});
it('should select text in an input', async () => {
const component = render(
<View>
<InitialFocus>
<input type="text" title="focused" defaultValue="Hello World" />
</InitialFocus>
<input type="text" title="unfocused" />
</View>,
);
// This is needed bc of the `setTimeout` in the `InitialFocus` component.
await new Promise(resolve => setTimeout(resolve, 0));
const input = component.getByTitle('focused') as HTMLInputElement;
expect(document.activeElement).toBe(input);
expect(input.selectionStart).toBe(0);
expect(input.selectionEnd).toBe(11); // Length of "Hello World"
});
it('should focus a button', async () => {
const component = render(
<View>
<InitialFocus>
<button title="focused">Click me</button>
</InitialFocus>
<button title="unfocused">Do not click me</button>
</View>,
);
// This is needed bc of the `setTimeout` in the `InitialFocus` component.
await new Promise(resolve => setTimeout(resolve, 0));
const button = component.getByTitle('focused') as HTMLButtonElement;
const unfocusedButton = component.getByTitle(
'unfocused',
) as HTMLButtonElement;
expect(document.activeElement).toBe(button);
expect(document.activeElement).not.toBe(unfocusedButton);
});
it('should focus a custom component with ref forwarding', async () => {
const CustomInput = forwardRef<HTMLInputElement>((props, ref) => (
<input type="text" ref={ref} {...props} title="focused" />
));
CustomInput.displayName = 'CustomInput';
const component = render(
<View>
<InitialFocus>
{node => <CustomInput ref={node as Ref<HTMLInputElement>} />}
</InitialFocus>
<input type="text" title="unfocused" />
</View>,
);
// This is needed bc of the `setTimeout` in the `InitialFocus` component.
await new Promise(resolve => setTimeout(resolve, 0));
const input = component.getByTitle('focused') as HTMLInputElement;
const unfocusedInput = component.getByTitle(
'unfocused',
) as HTMLInputElement;
expect(document.activeElement).toBe(input);
expect(document.activeElement).not.toBe(unfocusedInput);
});
});

View File

@@ -1,118 +0,0 @@
import React, {
ChangeEvent,
ComponentPropsWithRef,
type KeyboardEvent,
type FocusEvent,
} from 'react';
import { Input as ReactAriaInput } from 'react-aria-components';
import { css, cx } from '@emotion/css';
import { useResponsive } from './hooks/useResponsive';
import { styles } from './styles';
import { theme } from './theme';
export const baseInputStyle = {
outline: 0,
backgroundColor: theme.tableBackground,
color: theme.formInputText,
margin: 0,
padding: 5,
borderRadius: 4,
border: '1px solid ' + theme.formInputBorder,
};
const defaultInputClassName = css({
...baseInputStyle,
color: theme.formInputText,
whiteSpace: 'nowrap',
overflow: 'hidden',
flexShrink: 0,
'&[data-focused]': {
border: '1px solid ' + theme.formInputBorderSelected,
boxShadow: '0 1px 1px ' + theme.formInputShadowSelected,
},
'&[data-disabled]': {
color: theme.formInputTextPlaceholder,
},
'::placeholder': { color: theme.formInputTextPlaceholder },
...styles.smallText,
});
export type InputProps = ComponentPropsWithRef<typeof ReactAriaInput> & {
onEnter?: (value: string, event: KeyboardEvent<HTMLInputElement>) => void;
onEscape?: (value: string, event: KeyboardEvent<HTMLInputElement>) => void;
onChangeValue?: (
newValue: string,
event: ChangeEvent<HTMLInputElement>,
) => void;
onUpdate?: (newValue: string, event: FocusEvent<HTMLInputElement>) => void;
};
export function Input({
ref,
onEnter,
onEscape,
onChangeValue,
onUpdate,
className,
...props
}: InputProps) {
return (
<ReactAriaInput
ref={ref}
className={
typeof className === 'function'
? renderProps => cx(defaultInputClassName, className(renderProps))
: cx(defaultInputClassName, className)
}
{...props}
onKeyUp={e => {
props.onKeyUp?.(e);
if (e.key === 'Enter' && onEnter) {
onEnter(e.currentTarget.value, e);
}
if (e.key === 'Escape' && onEscape) {
onEscape(e.currentTarget.value, e);
}
}}
onBlur={e => {
onUpdate?.(e.currentTarget.value, e);
props.onBlur?.(e);
}}
onChange={e => {
onChangeValue?.(e.currentTarget.value, e);
props.onChange?.(e);
}}
/>
);
}
const defaultBigInputClassName = css({
padding: 10,
fontSize: 15,
border: 'none',
...styles.shadow,
'&[data-focused]': { border: 'none', ...styles.shadow },
});
export function BigInput({ className, ...props }: InputProps) {
return (
<Input
{...props}
className={
typeof className === 'function'
? renderProps => cx(defaultBigInputClassName, className(renderProps))
: cx(defaultBigInputClassName, className)
}
/>
);
}
export function ResponsiveInput(props: InputProps) {
const { isNarrowWidth } = useResponsive();
return isNarrowWidth ? <BigInput {...props} /> : <Input {...props} />;
}

View File

@@ -3,13 +3,11 @@ import {
useEffect,
useRef,
useState,
type ComponentProps,
type ComponentType,
type SVGProps,
type CSSProperties,
} from 'react';
import { Button } from './Button';
import { Text } from './Text';
import { theme } from './theme';
import { Toggle } from './Toggle';
@@ -63,7 +61,6 @@ type MenuProps<NameType> = {
style?: CSSProperties;
className?: string;
getItemStyle?: (item: MenuItemObject<NameType>) => CSSProperties;
slot?: ComponentProps<typeof Button>['slot'];
};
export function Menu<const NameType = string>({
@@ -74,7 +71,6 @@ export function Menu<const NameType = string>({
style,
className,
getItemStyle,
slot,
}: MenuProps<NameType>) {
const elRef = useRef<HTMLDivElement>(null);
const items = allItems.filter(x => x);
@@ -165,10 +161,9 @@ export function Menu<const NameType = string>({
const Icon = item.icon;
return (
<Button
<View
role="button"
key={String(item.name)}
variant="bare"
slot={slot}
style={{
cursor: 'default',
padding: 10,
@@ -184,9 +179,11 @@ export function Menu<const NameType = string>({
}),
...(!isLabel(item) && getItemStyle?.(item)),
}}
onHoverStart={() => setHoveredIndex(idx)}
onHoverEnd={() => setHoveredIndex(null)}
onPress={() => {
onPointerEnter={() => setHoveredIndex(idx)}
onPointerLeave={() => setHoveredIndex(null)}
onPointerUp={e => {
e.stopPropagation();
if (
!item.disabled &&
item.toggle === undefined &&
@@ -235,7 +232,7 @@ export function Menu<const NameType = string>({
</View>
)}
{item.key && <Keybinding keyName={item.key} />}
</Button>
</View>
);
})}
{footer}

View File

@@ -21,14 +21,7 @@ function getChildren(key, children) {
'type' in child &&
child.type === Fragment
) {
return list.concat(
getChildren(
child.key,
typeof child.props === 'object' && 'children' in child.props
? child.props.children
: [],
),
);
return list.concat(getChildren(child.key, child.props.children));
}
list.push({ key: key + child['key'], child });
return list;

View File

@@ -26,7 +26,7 @@ export const Tooltip = ({
const triggerRef = useRef(null);
const [isHovered, setIsHover] = useState(false);
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout>>(null);
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
const handlePointerEnter = useCallback(() => {
const timeout = setTimeout(() => {

View File

@@ -1,23 +0,0 @@
import { useWindowSize } from 'usehooks-ts';
import { breakpoints } from '../tokens';
export function useResponsive() {
const { height, width } = useWindowSize({
debounceDelay: 250,
});
// Possible view modes: narrow, small, medium, wide
// To check if we're at least small width, check !isNarrowWidth
return {
// atLeastMediumWidth is provided to avoid checking (isMediumWidth || isWideWidth)
atLeastMediumWidth: width >= breakpoints.medium,
isNarrowWidth: width < breakpoints.small,
isSmallWidth: width >= breakpoints.small && width < breakpoints.medium,
isMediumWidth: width >= breakpoints.medium && width < breakpoints.wide,
// No atLeastWideWidth because that's identical to isWideWidth
isWideWidth: width >= breakpoints.wide,
height,
width,
};
}

View File

@@ -1,18 +0,0 @@
import * as React from 'react';
import type { SVGProps } from 'react';
export const SvgArrowButtonSingleLeft1 = (props: SVGProps<SVGSVGElement>) => (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
style={{
color: 'inherit',
...props.style,
}}
>
<path
d="M.25 12a2.643 2.643 0 0 1 .775-1.875L10.566.584a1.768 1.768 0 0 1 2.5 2.5l-8.739 8.739a.25.25 0 0 0 0 .354l8.739 8.739a1.768 1.768 0 0 1-2.5 2.5l-9.541-9.541A2.643 2.643 0 0 1 .25 12Z"
fill="currentColor"
/>
</svg>
);

View File

@@ -1 +0,0 @@
<svg id="Bold" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><title>arrow-button-single-left-1</title><path d="M.25,12a2.643,2.643,0,0,1,.775-1.875L10.566.584a1.768,1.768,0,0,1,2.5,2.5L4.327,11.823a.25.25,0,0,0,0,.354l8.739,8.739a1.768,1.768,0,0,1-2.5,2.5L1.025,13.875A2.643,2.643,0,0,1,.25,12Z"/></svg>

Before

Width:  |  Height:  |  Size: 312 B

View File

@@ -1,35 +0,0 @@
import path from 'path';
import peggyLoader from 'vite-plugin-peggy-loader';
import { defineConfig } from 'vitest/config';
const resolveExtensions = [
'.testing.ts',
'.web.ts',
'.mjs',
'.js',
'.mts',
'.ts',
'.jsx',
'.tsx',
'.json',
'.wasm',
];
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
include: ['src/**/*.web.test.(js|jsx|ts|tsx)'],
},
resolve: {
alias: [
{
find: /^@actual-app\/crdt(\/.*)?$/,
replacement: path.resolve('../../../crdt/src$1'),
},
],
extensions: resolveExtensions,
},
plugins: [peggyLoader()],
});

View File

@@ -1 +1 @@
export * from './src';
export * from './src/main';

View File

@@ -0,0 +1,6 @@
module.exports = {
testEnvironment: 'node',
transform: {
'^.+\\.(t|j)sx?$': '@swc/jest',
},
};

View File

@@ -12,17 +12,20 @@
"build:node": "tsc --p tsconfig.dist.json",
"proto:generate": "./bin/generate-proto",
"build": "rm -rf dist && yarn run build:node && cp src/proto/sync_pb.d.ts dist/src/proto/",
"test": "vitest --globals"
"test": "jest -c jest.config.js"
},
"dependencies": {
"google-protobuf": "^3.21.4",
"google-protobuf": "^3.12.0-rc.1",
"murmurhash": "^2.0.1",
"uuid": "^9.0.1"
},
"devDependencies": {
"@types/uuid": "^9.0.8",
"@swc/core": "^1.5.3",
"@swc/jest": "^0.2.36",
"@types/jest": "^27.5.2",
"@types/uuid": "^9.0.2",
"jest": "^27.5.1",
"ts-protoc-gen": "^0.15.0",
"typescript": "^5.8.3",
"vitest": "^3.1.3"
"typescript": "^5.5.4"
}
}

View File

@@ -1,23 +1,23 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`merkle trie > adding an item works 1`] = `
{
"1": {
"2": {
"1": {
"0": {
"1": {
"0": {
"0": {
"2": {
"0": {
"1": {
"1": {
"0": {
"2": {
"2": {
"0": {
"0": {
exports[`merkle trie adding an item works 1`] = `
Object {
"1": Object {
"2": Object {
"1": Object {
"0": Object {
"1": Object {
"0": Object {
"0": Object {
"2": Object {
"0": Object {
"1": Object {
"1": Object {
"0": Object {
"2": Object {
"2": Object {
"0": Object {
"0": Object {
"hash": 1983295247,
},
"hash": 1983295247,
@@ -34,14 +34,14 @@ exports[`merkle trie > adding an item works 1`] = `
},
"hash": 1983295247,
},
"1": {
"0": {
"1": {
"0": {
"2": {
"0": {
"0": {
"0": {
"1": Object {
"0": Object {
"1": Object {
"0": Object {
"2": Object {
"0": Object {
"0": Object {
"0": Object {
"hash": 1469038940,
},
"hash": 1469038940,
@@ -78,33 +78,33 @@ exports[`merkle trie > adding an item works 1`] = `
}
`;
exports[`merkle trie > pruning works and keeps correct hashes 1`] = `
{
"1": {
"2": {
"1": {
"0": {
"0": {
"2": {
"2": {
"2": {
"1": {
"2": {
"2": {
"0": {
"0": {
"1": {
"2": {
"0": {
exports[`merkle trie pruning works and keeps correct hashes 1`] = `
Object {
"1": Object {
"2": Object {
"1": Object {
"0": Object {
"0": Object {
"2": Object {
"2": Object {
"2": Object {
"1": Object {
"2": Object {
"2": Object {
"0": Object {
"0": Object {
"1": Object {
"2": Object {
"0": Object {
"hash": 1000,
},
"hash": 1000,
},
"hash": 1000,
},
"2": {
"2": {
"0": {
"2": Object {
"2": Object {
"0": Object {
"hash": 1100,
},
"hash": 1100,
@@ -113,28 +113,28 @@ exports[`merkle trie > pruning works and keeps correct hashes 1`] = `
},
"hash": 1956,
},
"1": {
"0": {
"2": {
"0": {
"1": Object {
"0": Object {
"2": Object {
"0": Object {
"hash": 1200,
},
"hash": 1200,
},
"hash": 1200,
},
"1": {
"2": {
"0": {
"1": Object {
"2": Object {
"0": Object {
"hash": 1300,
},
"hash": 1300,
},
"hash": 1300,
},
"2": {
"2": {
"0": {
"2": Object {
"2": Object {
"0": Object {
"hash": 1400,
},
"hash": 1400,
@@ -143,28 +143,28 @@ exports[`merkle trie > pruning works and keeps correct hashes 1`] = `
},
"hash": 1244,
},
"2": {
"0": {
"2": {
"0": {
"2": Object {
"0": Object {
"2": Object {
"0": Object {
"hash": 1500,
},
"hash": 1500,
},
"hash": 1500,
},
"1": {
"2": {
"0": {
"1": Object {
"2": Object {
"0": Object {
"hash": 1600,
},
"hash": 1600,
},
"hash": 1600,
},
"2": {
"2": {
"0": {
"2": Object {
"2": Object {
"0": Object {
"hash": 1700,
},
"hash": 1700,
@@ -175,29 +175,29 @@ exports[`merkle trie > pruning works and keeps correct hashes 1`] = `
},
"hash": 1600,
},
"1": {
"0": {
"0": {
"1": {
"1": {
"1": Object {
"0": Object {
"0": Object {
"1": Object {
"1": Object {
"hash": 1800,
},
"hash": 1800,
},
"hash": 1800,
},
"1": {
"1": {
"1": {
"1": Object {
"1": Object {
"1": Object {
"hash": 1900,
},
"hash": 1900,
},
"hash": 1900,
},
"2": {
"1": {
"1": {
"2": Object {
"1": Object {
"1": Object {
"hash": 2000,
},
"hash": 2000,
@@ -206,10 +206,10 @@ exports[`merkle trie > pruning works and keeps correct hashes 1`] = `
},
"hash": 1972,
},
"1": {
"0": {
"1": {
"1": {
"1": Object {
"0": Object {
"1": Object {
"1": Object {
"hash": 2100,
},
"hash": 2100,
@@ -246,33 +246,33 @@ exports[`merkle trie > pruning works and keeps correct hashes 1`] = `
}
`;
exports[`merkle trie > pruning works and keeps correct hashes 2`] = `
{
"1": {
"2": {
"1": {
"0": {
"0": {
"2": {
"2": {
"2": {
"1": {
"2": {
"2": {
"0": {
"1": {
"1": {
"2": {
"0": {
exports[`merkle trie pruning works and keeps correct hashes 2`] = `
Object {
"1": Object {
"2": Object {
"1": Object {
"0": Object {
"0": Object {
"2": Object {
"2": Object {
"2": Object {
"1": Object {
"2": Object {
"2": Object {
"0": Object {
"1": Object {
"1": Object {
"2": Object {
"0": Object {
"hash": 1300,
},
"hash": 1300,
},
"hash": 1300,
},
"2": {
"2": {
"0": {
"2": Object {
"2": Object {
"0": Object {
"hash": 1400,
},
"hash": 1400,
@@ -281,19 +281,19 @@ exports[`merkle trie > pruning works and keeps correct hashes 2`] = `
},
"hash": 1244,
},
"2": {
"1": {
"2": {
"0": {
"2": Object {
"1": Object {
"2": Object {
"0": Object {
"hash": 1600,
},
"hash": 1600,
},
"hash": 1600,
},
"2": {
"2": {
"0": {
"2": Object {
"2": Object {
"0": Object {
"hash": 1700,
},
"hash": 1700,
@@ -304,20 +304,20 @@ exports[`merkle trie > pruning works and keeps correct hashes 2`] = `
},
"hash": 1600,
},
"1": {
"0": {
"1": {
"1": {
"1": {
"1": Object {
"0": Object {
"1": Object {
"1": Object {
"1": Object {
"hash": 1900,
},
"hash": 1900,
},
"hash": 1900,
},
"2": {
"1": {
"1": {
"2": Object {
"1": Object {
"1": Object {
"hash": 2000,
},
"hash": 2000,
@@ -326,10 +326,10 @@ exports[`merkle trie > pruning works and keeps correct hashes 2`] = `
},
"hash": 1972,
},
"1": {
"0": {
"1": {
"1": {
"1": Object {
"0": Object {
"1": Object {
"1": Object {
"hash": 2100,
},
"hash": 2100,

View File

@@ -39,7 +39,6 @@ HTTPS=true yarn start
```
or using the dev container:
```
HTTPS=true docker compose up --build
```
@@ -65,10 +64,10 @@ Run manually:
```sh
# Run docker container
docker run --rm --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.52.0-jammy /bin/bash
docker run --rm --network host -v $(pwd):/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.41.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.52.0-jammy /bin/bash
docker run --rm --network host -v ${pwd}:/work/ -w /work/ -it mcr.microsoft.com/playwright:v1.41.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
@@ -83,7 +82,6 @@ E2E_START_URL=https://ip:port yarn vrt
You can also run the tests against a remote server by passing the URL:
Run in standardized docker container:
```sh
E2E_START_URL=https://my-remote-server.com yarn vrt:docker
@@ -92,7 +90,6 @@ E2E_START_URL=https://my-remote-server.com yarn vrt:docker
```
Run locally:
```sh
E2E_START_URL=https://my-remote-server.com yarn vrt
```

View File

@@ -20,7 +20,7 @@ const processTranslations = () => {
console.log(`en.json has ${enKeysCount} keys.`);
files.forEach(file => {
files.forEach((file) => {
if (file === 'en.json' || path.extname(file) !== '.json') return;
if (file.startsWith('en-')) {
@@ -34,9 +34,7 @@ const processTranslations = () => {
// Calculate the percentage of keys present compared to en.json
const percentage = (fileKeysCount / enKeysCount) * 100;
console.log(
`${file} has ${fileKeysCount} keys (${percentage.toFixed(2)}%).`,
);
console.log(`${file} has ${fileKeysCount} keys (${percentage.toFixed(2)}%).`);
if (percentage < 50) {
fs.unlinkSync(filePath);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 33 KiB

View File

@@ -161,28 +161,5 @@ test.describe('Accounts', () => {
await expect(importButton).not.toBeVisible();
});
test('import notes checkbox is not shown for CSV files', async () => {
const fileChooserPromise = page.waitForEvent('filechooser');
await accountPage.page.getByRole('button', { name: 'Import' }).click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(join(__dirname, 'data/test.csv'));
// Verify the import notes checkbox is not visible for CSV files
const importNotesCheckbox = page.getByRole('checkbox', {
name: 'Import notes from file',
});
await expect(importNotesCheckbox).not.toBeVisible();
// Import the transactions
const importButton = page.getByRole('button', {
name: /Import \d+ transactions/,
});
await importButton.click();
// Verify the transactions were imported
await expect(importButton).not.toBeVisible();
});
});
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 32 KiB

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