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
1952 changed files with 29584 additions and 43708 deletions

View File

@@ -1,24 +0,0 @@
---
description:
globs:
alwaysApply: true
---
Before pushing code changes or opening a pull request, follow these steps:
1. Check if your branch already has a changelog file in the "upcoming-release-notes" folder.
2. If there is no changelog file for your branch:
a. Find the number of the most recent (highest-numbered) open issue or pull request on GitHub.
b. Increment that number by 1. Use this as the filename for your new changelog file.
c. Create a new markdown file in the "upcoming-release-notes" folder with the following format:
```
---
category: Features OR Maintenance OR Enhancements OR Bugfix
authors: [$GithubUsername]
---
$Description
```
3. Commit the new changelog file.
4. Proceed with your push or pull request.

View File

@@ -1,32 +0,0 @@
---
description:
globs: *.ts,*.tsx
alwaysApply: false
---
You are an expert in TypeScript and React.
Code Style and Structure
- Write concise, technical TypeScript code.
- Use functional and declarative programming patterns; avoid classes.
- Prefer iteration and modularization over code duplication.
- Use descriptive variable names with auxiliary verbs (e.g., isLoaded, hasError).
- Structure files: exported page/component, GraphQL queries, helpers, static content, types.
Naming Conventions
- Favor named exports for components and utilities.
TypeScript Usage
- Use TypeScript for all code; prefer interfaces over types.
- Avoid enums; use objects or maps instead.
- Avoid using `any` or `unknown` unless absolutely necessary. Look for type definitions in the codebase instead.
- Avoid type assertions with `as` or `!`; prefer using `satisfies`.
Syntax and Formatting
- Use the "function" keyword for pure functions.
- Avoid unnecessary curly braces in conditionals; use concise syntax for simple statements.
- Use declarative JSX, keeping JSX minimal and readable.

View File

@@ -1,14 +0,0 @@
---
description:
globs:
alwaysApply: true
---
Vitest test runner is used for unit tests.
When running unit tests, always include the flag `--watch=false` to prevent watch mode.
To run unit tests for a specific package in the monorepo, use the following command:
`yarn workspace <workspaceNameFromPackageJson> run test <pathToTest>`
Recommendation: Minimize the number of dependencies you mock. The fewer dependencies you mock, the better.

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,13 +1,15 @@
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
attributes:
value: |
Thanks for taking the time to fill out this bug report! Please ensure you provide as much information as possible to better assist in confirming and identifying a fix for the bug.
- type: markdown
id: intro-md
attributes:
value: |
**IMPORTANT:** we use GitHub Issues only for BUG REPORTS and FEATURE REQUESTS. If you are looking for help/support - please reach out to the [community on Discord](https://discord.gg/pRYNYr4W5A). All non-bug and non-feature-request issues will be closed.
@@ -21,6 +23,8 @@ body:
options:
- label: 'I have searched and found no existing issue'
required: true
validations:
required: true
- type: textarea
id: what-happened
attributes:
@@ -39,6 +43,7 @@ body:
validations:
required: true
- type: markdown
id: env-info
attributes:
value: '## Environment Details'
- type: dropdown

View File

@@ -4,6 +4,7 @@ title: '[Feature] '
labels: ['feature']
body:
- type: markdown
id: intro-md
attributes:
value: |
Thanks for taking the time to fill out this feature request! Please ensure you provide as much information as possible so we can better understand what youre proposing so we can come up with the best solution for everyone.
@@ -15,6 +16,8 @@ body:
options:
- label: 'I have searched and found no existing issue'
required: true
validations:
required: true
- type: checkboxes
attributes:
label: '💻'

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

@@ -1,75 +0,0 @@
#!/usr/bin/env node
import { Octokit } from '@octokit/rest';
import fs from 'fs';
const token = process.env.GITHUB_TOKEN;
const repo = process.env.GITHUB_REPOSITORY;
const issueNumber = process.env.GITHUB_EVENT_ISSUE_NUMBER;
const commentId = process.env.GITHUB_EVENT_COMMENT_ID;
if (!token || !repo || !issueNumber || !commentId) {
console.log('Missing required environment variables');
process.exit(1);
}
const [owner, repoName] = repo.split('/');
const octokit = new Octokit({ auth: token });
function setOutput(name, value) {
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`);
}
async function checkFirstComment() {
try {
console.log('Fetching comments with Octokit...');
// Get all comments with automatic pagination
const comments = await octokit.paginate(octokit.rest.issues.listComments, {
owner,
repo: repoName,
issue_number: issueNumber,
});
console.log(`Total comments found: ${comments.length}`);
// Filter for CodeRabbit summary comments (containing the specific marker)
const coderabbitSummaryComments = comments.filter(comment => {
const isCodeRabbit = comment.user.login === 'coderabbitai[bot]';
const hasSummaryMarker = comment.body.includes(
'<!-- This is an auto-generated comment: summarize by coderabbit.ai -->',
);
if (isCodeRabbit) {
console.log(
`CodeRabbit comment found (ID: ${comment.id}), has summary marker: ${hasSummaryMarker}`,
);
}
return isCodeRabbit && hasSummaryMarker;
});
const isFirstSummaryComment =
coderabbitSummaryComments.length === 1 &&
coderabbitSummaryComments[0].id == commentId;
console.log(
`CodeRabbit summary comments found: ${coderabbitSummaryComments.length}`,
);
console.log(`Current comment ID: ${commentId}`);
console.log(`Is first summary comment: ${isFirstSummaryComment}`);
setOutput('result', isFirstSummaryComment);
} catch (error) {
console.log('Error checking CodeRabbit comment:', error.message);
console.log('Stack:', error.stack);
setOutput('result', 'false');
process.exit(1);
}
}
checkFirstComment().catch(error => {
console.log('Unhandled error:', error.message);
console.log('Stack:', error.stack);
setOutput('result', 'false');
process.exit(1);
});

View File

@@ -1,76 +0,0 @@
#!/usr/bin/env node
import { Octokit } from '@octokit/rest';
import fs from 'fs';
const token = process.env.GITHUB_TOKEN;
const repo = process.env.GITHUB_REPOSITORY;
const issueNumber = process.env.GITHUB_EVENT_ISSUE_NUMBER;
const prDetailsJson = process.env.PR_DETAILS;
if (!token || !repo || !issueNumber || !prDetailsJson) {
console.log('Missing required environment variables');
process.exit(1);
}
const [owner, repoName] = repo.split('/');
const octokit = new Octokit({ auth: token });
function setOutput(name, value) {
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`);
}
async function checkReleaseNotesExists() {
try {
const prDetails = JSON.parse(prDetailsJson);
if (!prDetails) {
console.log('No PR details available, skipping file check');
setOutput('result', 'false');
return;
}
const fileName = `upcoming-release-notes/${prDetails.number}.md`;
// Get PR info to get head SHA
const { data: pr } = await octokit.rest.pulls.get({
owner,
repo: repoName,
pull_number: issueNumber,
});
const prHeadSha = pr.head.sha;
console.log(
`Checking for file on PR branch: ${pr.head.ref} (${prHeadSha})`,
);
// Check if file exists
try {
await octokit.rest.repos.getContent({
owner,
repo: repoName,
path: fileName,
ref: prHeadSha,
});
console.log(
`Release notes file already exists on PR branch: ${fileName}`,
);
setOutput('result', 'true');
} catch (error) {
if (error.status === 404) {
console.log(
`No existing release notes file found on PR branch: ${fileName}`,
);
setOutput('result', 'false');
} else {
console.log('Error checking file existence:', error.message);
setOutput('result', 'false');
}
}
} catch (error) {
console.log('Error in file existence check:', error.message);
setOutput('result', 'false');
}
}
checkReleaseNotesExists();

View File

@@ -1,76 +0,0 @@
#!/usr/bin/env node
import { Octokit } from '@octokit/rest';
const token = process.env.GITHUB_TOKEN;
const repo = process.env.GITHUB_REPOSITORY;
const issueNumber = process.env.GITHUB_EVENT_ISSUE_NUMBER;
const summaryDataJson = process.env.SUMMARY_DATA;
const category = process.env.CATEGORY;
if (!token || !repo || !issueNumber || !summaryDataJson || !category) {
console.log('Missing required environment variables');
process.exit(1);
}
const [owner, repoName] = repo.split('/');
const octokit = new Octokit({ auth: token });
async function commentOnPR() {
try {
const summaryData = JSON.parse(summaryDataJson);
if (!summaryData) {
console.log('No summary data available, skipping comment');
return;
}
if (!category || category === 'null') {
console.log('No valid category available, skipping comment');
return;
}
// Clean category for display
const cleanCategory =
typeof category === 'string'
? category.replace(/^["']|["']$/g, '')
: category;
// Get PR info for the file URL
const { data: pr } = await octokit.rest.pulls.get({
owner,
repo: repoName,
pull_number: issueNumber,
});
const prBranch = pr.head.ref;
const headOwner = pr.head.repo.owner.login;
const headRepo = pr.head.repo.name;
const fileUrl = `https://github.com/${headOwner}/${headRepo}/blob/${prBranch}/upcoming-release-notes/${summaryData.prNumber}.md`;
const commentBody = [
'🤖 **Auto-generated Release Notes**',
'',
`Hey @${summaryData.author}! I've automatically created a release notes file based on CodeRabbit's analysis:`,
'',
`**Category:** ${cleanCategory}`,
`**Summary:** ${summaryData.summary}`,
`**File:** [upcoming-release-notes/${summaryData.prNumber}.md](${fileUrl})`,
'',
'The release notes file has been committed to the repository. You can edit it if needed before merging.',
].join('\n');
await octokit.rest.issues.createComment({
owner,
repo: repoName,
issue_number: issueNumber,
body: commentBody,
});
console.log('✅ Successfully commented on PR');
} catch (error) {
console.log('Error commenting on PR:', error.message);
}
}
commentOnPR();

View File

@@ -1,96 +0,0 @@
#!/usr/bin/env node
import { Octokit } from '@octokit/rest';
const token = process.env.GITHUB_TOKEN;
const repo = process.env.GITHUB_REPOSITORY;
const issueNumber = process.env.GITHUB_EVENT_ISSUE_NUMBER;
const summaryDataJson = process.env.SUMMARY_DATA;
const category = process.env.CATEGORY;
if (!token || !repo || !issueNumber || !summaryDataJson || !category) {
console.log('Missing required environment variables');
process.exit(1);
}
const [owner, repoName] = repo.split('/');
const octokit = new Octokit({ auth: token });
async function createReleaseNotesFile() {
try {
const summaryData = JSON.parse(summaryDataJson);
console.log('Debug - Category value:', category);
console.log('Debug - Category type:', typeof category);
console.log('Debug - Category JSON stringified:', JSON.stringify(category));
if (!summaryData) {
console.log('No summary data available, cannot create file');
return;
}
if (!category || category === 'null') {
console.log('No valid category available, cannot create file');
return;
}
// Create file content - ensure category is not quoted
const cleanCategory =
typeof category === 'string'
? category.replace(/^["']|["']$/g, '')
: category;
console.log('Debug - Clean category:', cleanCategory);
const fileContent = `---
category: ${cleanCategory}
authors: [${summaryData.author}]
---
${summaryData.summary}`;
const fileName = `upcoming-release-notes/${summaryData.prNumber}.md`;
console.log(`Creating release notes file: ${fileName}`);
console.log('File content:');
console.log(fileContent);
// Get PR info
const { data: pr } = await octokit.rest.pulls.get({
owner,
repo: repoName,
pull_number: issueNumber,
});
const prBranch = pr.head.ref;
const headOwner = pr.head.repo.owner.login;
const headRepo = pr.head.repo.name;
console.log(
`Committing to PR branch: ${headOwner}/${headRepo}:${prBranch}`,
);
// Create the file via GitHub API on the PR branch
await octokit.rest.repos.createOrUpdateFileContents({
owner: headOwner,
repo: headRepo,
path: fileName,
message: `Add release notes for PR #${summaryData.prNumber}`,
content: Buffer.from(`${fileContent}\n\n`).toString('base64'),
branch: prBranch,
committer: {
name: 'github-actions[bot]',
email: 'github-actions[bot]@users.noreply.github.com',
},
author: {
name: 'github-actions[bot]',
email: 'github-actions[bot]@users.noreply.github.com',
},
});
console.log(`✅ Successfully created release notes file: ${fileName}`);
} catch (error) {
console.log('Error creating release notes file:', error.message);
}
}
createReleaseNotesFile();

View File

@@ -1,118 +0,0 @@
#!/usr/bin/env node
const https = require('https');
const fs = require('fs');
const commentBody = process.env.GITHUB_EVENT_COMMENT_BODY;
const prDetailsJson = process.env.PR_DETAILS;
const summaryDataJson = process.env.SUMMARY_DATA;
const openaiApiKey = process.env.OPENAI_API_KEY;
if (!commentBody || !prDetailsJson || !summaryDataJson || !openaiApiKey) {
console.log('Missing required environment variables');
process.exit(1);
}
function setOutput(name, value) {
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`);
}
try {
const prDetails = JSON.parse(prDetailsJson);
const summaryData = JSON.parse(summaryDataJson);
if (!summaryData || !prDetails) {
console.log('Missing data for categorization');
setOutput('result', 'null');
process.exit(0);
}
const data = JSON.stringify({
model: 'gpt-4o-mini',
messages: [
{
role: 'system',
content:
'You are categorizing pull requests for release notes. You must respond with exactly one of these categories: "Features", "Enhancements", "Bugfix", or "Maintenance". No other text or explanation.',
},
{
role: 'user',
content: `PR Title: ${prDetails.title}\n\nGenerated Summary: ${summaryData.summary}\n\nCodeRabbit Analysis:\n${commentBody}\n\nCategories:\n- Features: New functionality or capabilities\n- Bugfix: Fixes for broken or incorrect behavior\n- Enhancements: Improvements to existing functionality\n- Maintenance: Code cleanup, refactoring, dependencies, etc.\n\nWhat category does this PR belong to?`,
},
],
max_tokens: 10,
temperature: 0.1,
});
const options = {
hostname: 'api.openai.com',
path: '/v1/chat/completions',
method: 'POST',
headers: {
Authorization: `Bearer ${openaiApiKey}`,
'Content-Type': 'application/json',
},
};
const req = https.request(options, res => {
let responseData = '';
res.on('data', chunk => (responseData += chunk));
res.on('end', () => {
if (res.statusCode !== 200) {
console.log('OpenAI API error for categorization');
setOutput('result', 'null');
return;
}
try {
const response = JSON.parse(responseData);
console.log('OpenAI raw response:', JSON.stringify(response, null, 2));
const rawContent = response.choices[0].message.content.trim();
console.log('Raw content from OpenAI:', rawContent);
let category;
try {
category = JSON.parse(rawContent);
console.log('Parsed category:', category);
} catch (parseError) {
console.log(
'JSON parse error, using raw content:',
parseError.message,
);
category = rawContent;
}
// Validate the category response
const validCategories = [
'Features',
'Bugfix',
'Enhancements',
'Maintenance',
];
if (validCategories.includes(category)) {
console.log('OpenAI categorized as:', category);
setOutput('result', category);
} else {
console.log('Invalid category from OpenAI:', category);
console.log('Valid categories are:', validCategories);
setOutput('result', 'null');
}
} catch (error) {
console.log('Error parsing OpenAI response:', error.message);
setOutput('result', 'null');
}
});
});
req.on('error', error => {
console.log('Error in categorization:', error.message);
setOutput('result', 'null');
});
req.write(data);
req.end();
} catch (error) {
console.log('Error in categorization:', error.message);
setOutput('result', 'null');
}

View File

@@ -1,97 +0,0 @@
#!/usr/bin/env node
const https = require('https');
const fs = require('fs');
const commentBody = process.env.GITHUB_EVENT_COMMENT_BODY;
const prDetailsJson = process.env.PR_DETAILS;
const openaiApiKey = process.env.OPENAI_API_KEY;
if (!commentBody || !prDetailsJson || !openaiApiKey) {
console.log('Missing required environment variables');
process.exit(1);
}
function setOutput(name, value) {
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`);
}
try {
const prDetails = JSON.parse(prDetailsJson);
if (!prDetails) {
console.log('No PR details available, cannot generate summary');
setOutput('result', 'null');
process.exit(0);
}
console.log('CodeRabbit comment body:', commentBody);
const data = JSON.stringify({
model: 'gpt-4o-mini',
messages: [
{
role: 'system',
content:
'You are a technical writer helping to create concise release notes. Generate a maximum 15-word summary that describes what this PR does. Focus on the user-facing changes or bug fixes. Do not include "This PR" or similar phrases - just describe the change directly. Start with a base form verb (e.g., "Add" not "Adds", "Fix" not "Fixes", "Introduce" not "Introduces").',
},
{
role: 'user',
content: `PR Title: ${prDetails.title}\n\nCodeRabbit Analysis:\n${commentBody}\n\nPlease provide a concise summary (max 15 words) of what this PR accomplishes.`,
},
],
max_tokens: 50,
temperature: 0.3,
});
const options = {
hostname: 'api.openai.com',
path: '/v1/chat/completions',
method: 'POST',
headers: {
Authorization: `Bearer ${openaiApiKey}`,
'Content-Type': 'application/json',
},
};
const req = https.request(options, res => {
let responseData = '';
res.on('data', chunk => (responseData += chunk));
res.on('end', () => {
if (res.statusCode !== 200) {
console.log(`OpenAI API error: ${res.statusCode} ${res.statusMessage}`);
setOutput('result', 'null');
return;
}
try {
const response = JSON.parse(responseData);
const summary = response.choices[0].message.content.trim();
console.log('Generated summary:', summary);
const result = {
summary: summary,
prNumber: prDetails.number,
author: prDetails.author,
};
setOutput('result', JSON.stringify(result));
} catch (error) {
console.log('Error parsing OpenAI response:', error.message);
setOutput('result', 'null');
}
});
});
req.on('error', error => {
console.log('Error generating summary:', error.message);
setOutput('result', 'null');
});
req.write(data);
req.end();
} catch (error) {
console.log('Error generating summary:', error.message);
setOutput('result', 'null');
}

View File

@@ -1,59 +0,0 @@
#!/usr/bin/env node
import { Octokit } from '@octokit/rest';
import fs from 'fs';
const token = process.env.GITHUB_TOKEN;
const repo = process.env.GITHUB_REPOSITORY;
const issueNumber = process.env.GITHUB_EVENT_ISSUE_NUMBER;
if (!token || !repo || !issueNumber) {
console.log('Missing required environment variables');
process.exit(1);
}
const [owner, repoName] = repo.split('/');
const octokit = new Octokit({ auth: token });
function setOutput(name, value) {
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${name}=${value}\n`);
}
async function getPRDetails() {
try {
console.log(
`Fetching PR details for ${owner}/${repoName}#${issueNumber}...`,
);
const { data: pr } = await octokit.rest.pulls.get({
owner,
repo: repoName,
pull_number: issueNumber,
});
console.log('PR details fetched successfully');
console.log('- PR Number:', pr.number);
console.log('- PR Author:', pr.user.login);
console.log('- PR Title:', pr.title);
const result = {
number: pr.number,
author: pr.user.login,
title: pr.title,
};
setOutput('result', JSON.stringify(result));
} catch (error) {
console.log('Error getting PR details:', error.message);
console.log('Stack:', error.stack);
setOutput('result', 'null');
process.exit(1);
}
}
getPRDetails().catch(error => {
console.log('Unhandled error:', error.message);
console.log('Stack:', error.stack);
setOutput('result', 'null');
process.exit(1);
});

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

@@ -1,94 +0,0 @@
#!/usr/bin/env node
// This script is used in GitHub Actions to get the next version based on the current package.json version.
// It supports three types of versioning: nightly, hotfix, and monthly.
const { parseArgs } = require('node:util');
const fs = require('node:fs');
const args = process.argv;
const options = {
'package-json': {
type: 'string',
short: 'p',
},
type: {
type: 'string', // nightly, hotfix, monthly
short: 't',
},
};
const { values } = parseArgs({
args,
options,
allowPositionals: true,
});
if (!values['package-json']) {
console.error(
'Please specify the path to package.json using --package-json or -p option.',
);
process.exit(1);
}
try {
const packageJsonPath = values['package-json'];
// Read and parse package.json
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
const currentVersion = packageJson.version;
// Parse year and month from version (e.g. 25.5.1 -> year=2025, month=5)
const versionParts = currentVersion.split('.');
const versionYear = parseInt(versionParts[0]);
const versionMonth = parseInt(versionParts[1]);
const versionHotfix = parseInt(versionParts[2]);
// Create date and add 1 month
const versionDate = new Date(2000 + versionYear, versionMonth - 1, 1); // month is 0-indexed
const nextVersionMonthDate = new Date(
versionDate.getFullYear(),
versionDate.getMonth() + 1,
1,
);
// Format back to YY.M format
const nextVersionYear = nextVersionMonthDate
.getFullYear()
.toString()
.slice(-2);
const nextVersionMonth = nextVersionMonthDate.getMonth() + 1; // Convert back to 1-indexed
// Get current date string
const currentDate = new Date()
.toISOString()
.split('T')[0]
.replaceAll('-', '');
switch (values.type) {
case 'nightly': {
const newVersion = `${nextVersionYear}.${nextVersionMonth}.0-nightly.${currentDate}`;
process.stdout.write(newVersion); // return the new version to stdout
process.exit();
}
case 'hotfix': {
const bugfixVersion = `${versionYear}.${versionMonth}.${versionHotfix + 1}`;
process.stdout.write(bugfixVersion); // return the bugfix version to stdout
process.exit();
}
case 'monthly': {
const stableVersion = `${nextVersionYear}.${nextVersionMonth}.0`;
process.stdout.write(stableVersion); // return the stable version to stdout
process.exit();
}
default:
console.error(
'Invalid type specified. Use "nightly", "hotfix", or "monthly".',
);
process.exit(1);
}
} catch (error) {
console.error('Error:', error.message);
process.exit(1);
}

View File

@@ -1,5 +1,4 @@
name: Setup
description: Setup the environment for the project
inputs:
working-directory:
@@ -17,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

@@ -1,350 +0,0 @@
import { Octokit } from '@octokit/rest';
import { minimatch } from 'minimatch';
/** Repository-specific configuration for points calculation */
const REPOSITORY_CONFIG = new Map([
[
'actual',
{
POINTS_PER_ISSUE_TRIAGE_ACTION: 1,
POINTS_PER_ISSUE_CLOSING_ACTION: 1,
POINTS_PER_RELEASE_PR: 0,
PR_REVIEW_POINT_TIERS: [
{ minChanges: 1000, points: 6 },
{ minChanges: 100, points: 4 },
{ minChanges: 0, points: 2 },
],
EXCLUDED_FILES: [
'yarn.lock',
'.yarn/**/*',
'packages/component-library/src/icons/**/*',
'release-notes/**/*',
],
},
],
[
'docs',
{
POINTS_PER_ISSUE_TRIAGE_ACTION: 1,
POINTS_PER_ISSUE_CLOSING_ACTION: 1,
POINTS_PER_RELEASE_PR: 4,
PR_REVIEW_POINT_TIERS: [
{ minChanges: 1000, points: 6 },
{ minChanges: 100, points: 4 },
{ minChanges: 0, points: 2 },
],
EXCLUDED_FILES: ['yarn.lock', '.yarn/**/*'],
},
],
]);
/**
* Get the start and end dates for the last month.
* @returns {Object} An object containing the start and end dates.
*/
function getLastMonthDates() {
// Get data relating to the last month
const now = new Date();
const firstDayOfLastMonth = new Date(
now.getFullYear(),
now.getMonth() - 1,
1,
);
const since = process.env.START_DATE
? new Date(process.env.START_DATE)
: firstDayOfLastMonth;
// Calculate the end of the month for the since date
const until = new Date(
since.getFullYear(),
since.getMonth() + 1,
0,
23,
59,
59,
999,
);
return { since, until };
}
/**
* Used for calculating the monthly points each core contributor has earned.
* These are used for payouts depending.
* @param {string} repo - The repository to analyze ('actual' or 'docs')
* @returns {number} The total points earned for the repository
*/
async function countContributorPoints(repo) {
const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN });
const owner = 'actualbudget';
const config = REPOSITORY_CONFIG.get(repo);
const { since, until } = getLastMonthDates();
// Get organization members
const { data: orgMembers } = await octokit.orgs.listMembers({
org: owner,
});
const orgMemberLogins = new Set(orgMembers.map(member => member.login));
// Initialize stats map with all org members
const stats = new Map(
Array.from(orgMemberLogins).map(login => [
login,
{
reviews: [], // Will store objects with PR number and points
labelRemovals: [],
issueClosings: [],
points: 0,
},
]),
);
// Helper function to print statistics
const printStats = (title, getValue, formatLine) => {
console.log(`\n${title}:`);
console.log('='.repeat(title.length + 1));
const entries = Array.from(stats.entries())
.map(([user, userStats]) => [user, getValue(userStats)])
.filter(([, count]) => count > 0)
.sort((a, b) => b[1] - a[1]);
if (entries.length === 0) {
console.log(`No ${title.toLowerCase()} found in the last month.`);
} else {
entries.forEach(([user, count]) => {
console.log(formatLine(user, count));
});
}
};
// Get all PRs using search
const searchQuery = `repo:${owner}/${repo} is:pr is:merged merged:${since.toISOString()}..${until.toISOString()}`;
const recentPRs = await octokit.paginate(
octokit.search.issuesAndPullRequests,
{
q: searchQuery,
per_page: 100,
advanced_search: true,
},
response => response.data,
);
// Get reviews and PR details for each PR
for (const pr of recentPRs) {
const { data: reviews } = await octokit.pulls.listReviews({
owner,
repo,
pull_number: pr.number,
});
// Get list of modified files
const { data: modifiedFiles } = await octokit.pulls.listFiles({
owner,
repo,
pull_number: pr.number,
});
// Calculate points based on PR size, excluding specified files
const totalChanges = modifiedFiles
.filter(
file =>
!config.EXCLUDED_FILES.some(pattern =>
minimatch(file.filename, pattern),
),
)
.reduce((sum, file) => sum + file.additions + file.deletions, 0);
// Check if this is a release PR
const isReleasePR = pr.title.match(/^🔖 \(\d+\.\d+\.\d+\)/);
// Calculate points for reviewers based on PR size
const prPoints = config.PR_REVIEW_POINT_TIERS.find(
tier => totalChanges > tier.minChanges,
).points;
// Add points to the reviewers
const uniqueReviewers = new Set();
reviews
.filter(
review =>
stats.has(review.user?.login) &&
review.state === 'APPROVED' &&
!uniqueReviewers.has(review.user?.login),
)
.forEach(({ user: { login: reviewer } }) => {
uniqueReviewers.add(reviewer);
const userStats = stats.get(reviewer);
userStats.reviews.push({ pr: pr.number.toString(), points: prPoints });
userStats.points += prPoints;
});
// Award points to the PR creator if it's a release PR
if (isReleasePR && stats.has(pr.user.login)) {
const creatorStats = stats.get(pr.user.login);
creatorStats.reviews.push({
pr: pr.number.toString(),
points: config.POINTS_PER_RELEASE_PR,
isReleaseCreator: true,
});
creatorStats.points += config.POINTS_PER_RELEASE_PR;
}
}
// Get all issues with label events in the last month
const issues = await octokit.paginate(
octokit.issues.listForRepo,
{
owner,
repo,
state: 'all',
sort: 'updated',
direction: 'desc',
per_page: 100,
since: since.toISOString(),
},
(response, done) =>
response.data.filter(issue => new Date(issue.updated_at) <= until),
);
// Get label events for each issue
for (const issue of issues) {
const { data: events } = await octokit.issues.listEventsForTimeline({
owner,
repo,
issue_number: issue.number,
});
// Process events
events
.filter(
event =>
new Date(event.created_at) > since &&
new Date(event.created_at) <= until &&
stats.has(event.actor?.login),
)
.forEach(event => {
if (
event.event === 'unlabeled' &&
event.label &&
event.label.name.toLowerCase() === 'needs triage'
) {
const remover = event.actor.login;
const userStats = stats.get(remover);
userStats.labelRemovals.push(issue.number.toString());
userStats.points += config.POINTS_PER_ISSUE_TRIAGE_ACTION;
}
if (event.event === 'closed') {
const closer = event.actor.login;
const userStats = stats.get(closer);
userStats.issueClosings.push(issue.number.toString());
userStats.points += config.POINTS_PER_ISSUE_CLOSING_ACTION;
}
});
}
// Print all statistics
printStats(
`PR Review Statistics (${repo})`,
stats => stats.reviews.length,
(user, count) =>
`${user}: ${count} (PRs: ${stats
.get(user)
.reviews.map(r => {
if (r.isReleaseCreator) {
return `#${r.pr} (${r.points}pts - Release Creator)`;
}
return `#${r.pr} (${r.points}pts)`;
})
.join(', ')})`,
);
printStats(
`"Needs Triage" Label Removal Statistics (${repo})`,
stats => stats.labelRemovals.length,
(user, count) =>
`${user}: ${count} (Issues: ${stats.get(user).labelRemovals.join(', ')})`,
);
printStats(
`Issue Closing Statistics (${repo})`,
stats => stats.issueClosings.length,
(user, count) =>
`${user}: ${count} (Issues: ${stats.get(user).issueClosings.join(', ')})`,
);
// Print points summary
printStats(
`Points Summary (${repo})`,
stats => stats.points,
(user, userPoints) => `${user}: ${userPoints}`,
);
// Calculate and print total points
const totalPoints = Array.from(stats.values()).reduce(
(sum, userStats) => sum + userStats.points,
0,
);
console.log(`\nTotal points earned for ${repo}: ${totalPoints}`);
// Return the points
return new Map(
Array.from(stats.entries()).map(([login, userStats]) => [
login,
userStats.points,
]),
);
}
/**
* Calculate the points for both repositories and print cumulative results
*/
async function calculateCumulativePoints() {
// Get stats for each repository
const repoPointsResults = await Promise.all(
Array.from(REPOSITORY_CONFIG.keys()).map(countContributorPoints),
);
// Calculate cumulative stats
const cumulativeStats = new Map(repoPointsResults[0]);
// Combine stats from all repositories
for (let i = 1; i < repoPointsResults.length; i++) {
for (const [login, points] of repoPointsResults[i].entries()) {
if (!cumulativeStats.has(login)) {
cumulativeStats.set(login, 0);
}
cumulativeStats.set(login, cumulativeStats.get(login) + points);
}
}
// Print cumulative statistics
console.log('\n\nCUMULATIVE STATISTICS ACROSS ALL REPOSITORIES');
console.log('='.repeat(50));
console.log('\nCumulative Points Summary:');
console.log('='.repeat('Cumulative Points Summary'.length + 1));
const entries = Array.from(cumulativeStats.entries())
.filter(([, count]) => count > 0)
.sort((a, b) => b[1] - a[1]);
if (entries.length === 0) {
console.log('No cumulative points summary found.');
} else {
entries.forEach(([user, points]) => {
console.log(`${user}: ${points}`);
});
}
// Calculate and print total cumulative points
const totalCumulativePoints = Array.from(cumulativeStats.values()).reduce(
(sum, points) => sum + points,
0,
);
console.log('\nTotal cumulative points earned: ' + totalCumulativePoints);
}
// Run the calculations
calculateCumulativePoints().catch(console.error);

View File

@@ -1,89 +0,0 @@
name: Generate Release Notes from CodeRabbit summary
on:
issue_comment:
types: [created]
jobs:
generate-release-notes:
# Only run on PR comments from CodeRabbit bot
if: github.event.issue.pull_request && github.event.comment.user.login == 'coderabbitai[bot]'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
pull-requests: write
issues: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
- name: Check if this is CodeRabbit's first comment
id: check-first-comment
run: node .github/actions/ai-generated-release-notes/check-first-comment.js
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
GITHUB_EVENT_COMMENT_ID: ${{ github.event.comment.id }}
- name: Get PR details
if: steps.check-first-comment.outputs.result == 'true'
id: pr-details
run: node .github/actions/ai-generated-release-notes/pr-details.js
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
- name: Check if release notes file already exists
if: steps.check-first-comment.outputs.result == 'true' && steps.pr-details.outputs.result != 'null'
id: check-release-notes-exists
run: node .github/actions/ai-generated-release-notes/check-release-notes-exists.js
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
PR_DETAILS: ${{ steps.pr-details.outputs.result }}
- name: Generate summary with OpenAI
if: steps.check-first-comment.outputs.result == 'true' && steps.check-release-notes-exists.outputs.result == 'false'
id: generate-summary
run: node .github/actions/ai-generated-release-notes/generate-summary.js
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
GITHUB_EVENT_COMMENT_BODY: ${{ github.event.comment.body }}
PR_DETAILS: ${{ steps.pr-details.outputs.result }}
- name: Determine category with OpenAI
if: steps.check-first-comment.outputs.result == 'true' && steps.check-release-notes-exists.outputs.result == 'false' && steps.generate-summary.outputs.result != 'null'
id: determine-category
run: node .github/actions/ai-generated-release-notes/determine-category.js
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
GITHUB_EVENT_COMMENT_BODY: ${{ github.event.comment.body }}
PR_DETAILS: ${{ steps.pr-details.outputs.result }}
SUMMARY_DATA: ${{ steps.generate-summary.outputs.result }}
- name: Create and commit release notes file via GitHub API
if: steps.check-first-comment.outputs.result == 'true' && steps.check-release-notes-exists.outputs.result == 'false' && steps.generate-summary.outputs.result != 'null' && steps.determine-category.outputs.result != 'null' && steps.determine-category.outputs.result != ''
run: node .github/actions/ai-generated-release-notes/create-release-notes-file.js
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
SUMMARY_DATA: ${{ steps.generate-summary.outputs.result }}
CATEGORY: ${{ steps.determine-category.outputs.result }}
- name: Comment on PR
if: steps.check-first-comment.outputs.result == 'true' && steps.check-release-notes-exists.outputs.result == 'false' && steps.generate-summary.outputs.result != 'null' && steps.determine-category.outputs.result != 'null' && steps.determine-category.outputs.result != ''
run: node .github/actions/ai-generated-release-notes/comment-on-pr.js
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
SUMMARY_DATA: ${{ steps.generate-summary.outputs.result }}
CATEGORY: ${{ steps.determine-category.outputs.result }}

View File

@@ -1,23 +0,0 @@
name: autofix.ci
defaults:
run:
shell: bash
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
permissions:
contents: read
jobs:
autofix:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
- name: Format code
run: yarn lint:fix
- uses: autofix-ci/action@635ffb0c9798bd160680f18fd73371e355b85f27

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

@@ -27,16 +27,6 @@ jobs:
uses: ./.github/actions/setup
- name: Typecheck
run: yarn typecheck
validate-cli:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
- name: Build Web
run: yarn build:server
- name: Check that the built CLI works
run: node packages/sync-server/build/bin/actual-server.js --version
test:
runs-on: ubuntu-latest
steps:
@@ -53,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

@@ -1,26 +0,0 @@
name: Count points
on:
schedule:
# Run at 00:00 on the first day of every month
- cron: '0 0 1 * *'
workflow_dispatch:
inputs:
startDate:
description: 'Start date for point counter (YYYY-MM-DD)'
required: true
type: string
jobs:
count-points:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
- name: Count points
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
START_DATE: ${{ inputs.startDate }}
run: node .github/scripts/count-points.mjs

View File

@@ -73,36 +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 image for testing
uses: docker/build-push-action@v5
with:
context: .
push: false
load: true
file: packages/sync-server/docker/${{ matrix.os }}.Dockerfile
tags: actualbudget/actual-server-testing
- name: Test that the docker image boots
run: |
docker run --detach --network=host actualbudget/actual-server-testing
sleep 5
curl --fail -sS -LI -w '%{http_code}\n' --retry 10 --retry-delay 1 --retry-connrefused localhost:5006
# This will use the cache from the earlier build step and not rebuild the image
# https://docs.docker.com/build/ci/github-actions/test-before-push/
- name: Build and push images
- 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

@@ -3,7 +3,7 @@ name: Extract and upload i18n strings
on:
schedule:
# 4am UTC
- cron: '0 4 * * *'
- cron: "0 4 * * *"
workflow_dispatch:
jobs:
@@ -19,7 +19,7 @@ jobs:
uses: ./actual/.github/actions/setup
with:
working-directory: actual
download-translations: false # As we'll manually clone instead
download-translations: false # As we'll manually clone instead
- name: Configure Git config
run: |
git config --global user.name "github-actions[bot]"
@@ -78,7 +78,7 @@ jobs:
actualbudget/actual
- name: Unlock translations
if: always() # Clean up even on failure
if: always() # Clean up even on failure
run: |
wlc \
--url https://hosted.weblate.org/api/ \

View File

@@ -24,7 +24,7 @@ jobs:
body: |
:sparkles: Thanks for sharing your idea! :sparkles:
This repository uses a voting-based system for feature requests. While enhancement issues are automatically closed, we still welcome feature requests! The voting system helps us gauge community interest in potential features. We also encourage community contributions for any feature requests marked as needing votes (just post a comment first so we can help guide you toward a successful contribution).
This repository uses lodash style issue management for enhancements. That means enhancement issues are automatically closed. This doesnt mean we dont accept feature requests, though! We will consider implementing ones that receive many upvotes, and we welcome contributions for any feature requests marked as needing votes (just post a comment first so we can help you make a successful contribution).
The enhancement backlog can be found here: https://github.com/actualbudget/actual/issues?q=label%3A%22needs+votes%22+sort%3Areactions-%2B1-desc+

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

@@ -22,15 +22,15 @@ jobs:
steps:
- name: Repository Checkout
uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
- name: Install Netlify
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
@@ -40,4 +40,4 @@ jobs:
--site ${{ secrets.NETLIFY_SITE_ID }} \
--auth ${{ secrets.NETLIFY_API_TOKEN }} \
--filter @actual-app/web \
--prod
--prod

View File

@@ -1,95 +0,0 @@
name: Publish nightly npm packages
# Nightly npm packages are built daily
on:
schedule:
- cron: '0 0 * * *'
workflow_dispatch:
jobs:
build-and-pack:
runs-on: ubuntu-latest
name: Build and pack npm packages
if: github.event.repository.fork == false
steps:
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
- name: Update package versions
run: |
# Get new nightly versions
NEW_WEB_VERSION=$(node ./.github/actions/get-next-package-version.js --package-json ./packages/desktop-client/package.json --type nightly)
NEW_SYNC_VERSION=$(node ./.github/actions/get-next-package-version.js --package-json ./packages/sync-server/package.json --type nightly)
NEW_API_VERSION=$(node ./.github/actions/get-next-package-version.js --package-json ./packages/api/package.json --type nightly)
# Set package versions
npm version $NEW_WEB_VERSION --no-git-tag-version --workspace=@actual-app/web --no-workspaces-update
npm version $NEW_SYNC_VERSION --no-git-tag-version --workspace=@actual-app/sync-server --no-workspaces-update
npm version $NEW_API_VERSION --no-git-tag-version --workspace=@actual-app/api --no-workspaces-update
- name: Yarn install
run: |
yarn install
- name: Build Server & 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 Nightly 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 --tag nightly
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish Sync-Server
run: |
npm publish sync-server/@actual-app/sync-server.tgz --access public --tag nightly
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- name: Publish API
run: |
npm publish api/@actual-app/api.tgz --access public --tag nightly
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

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

@@ -2,7 +2,6 @@ name: 'Close stale PRs'
on:
schedule:
- cron: '30 1 * * *'
workflow_dispatch: # Allow manual triggering
jobs:
stale:
@@ -25,18 +24,3 @@ jobs:
any-of-labels: ':construction: WIP'
days-before-close: -1
days-before-issue-stale: -1
stale-needs-info:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9
with:
stale-issue-label: 'needs info'
days-before-stale: -1
days-before-close: 7
close-issue-message: 'This issue has been automatically closed because there have been no comments for 7 days after the "needs info" label was added. If you still need help, please feel free to reopen the issue with the requested information.'
remove-stale-when-updated: false
stale-pr-message: '' # Disable PR processing
close-pr-message: '' # Disable PR processing
days-before-pr-stale: -1 # Disable PR processing
days-before-pr-close: -1 # Disable PR processing

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,27 +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/loot-core/**/node_modules/*
packages/loot-core/**/lib-dist/*
packages/loot-core/**/proto/*
.yarn/*
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 initialPrNumber = 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: initialPrNumber,
},
{
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 ||
!result.pullRequestNumber
) {
console.log('All questions must be answered. Exiting');
exit(1);
}
const fileContents = getFileContents(
result.releaseNoteType,
result.githubUsername,
result.oneLineSummary,
);
const prNumber = result.pullRequestNumber;
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: ${filepath}`);
}
});
}
// 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,39 +102,21 @@ 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/',
'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/*',
'packages/sync-server/build/',
'.yarn/*',
'.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,
@@ -144,6 +125,7 @@ export default pluginTypescript.config(
globals: {
...globals.browser,
...globals.commonjs,
...globals.jest,
...globals.node,
globalThis: false,
vi: true,
@@ -163,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,
},
},
{
@@ -503,32 +485,6 @@ export default pluginTypescript.config(
'no-restricted-imports': [
'warn',
{
paths: [
{
name: 'react-router',
importNames: ['useNavigate'],
message:
"Please import Actual's useNavigate() hook from `src/hooks` instead.",
},
{
name: 'react-redux',
importNames: ['useDispatch'],
message:
"Please import Actual's useDispatch() hook from `src/redux` instead.",
},
{
name: 'react-redux',
importNames: ['useSelector'],
message:
"Please import Actual's useSelector() hook from `src/redux` instead.",
},
{
name: 'react-redux',
importNames: ['useStore'],
message:
"Please import Actual's useStore() hook from `src/redux` instead.",
},
],
patterns: [
{
group: ['*.api', '*.web', '*.electron'],
@@ -544,10 +500,6 @@ export default pluginTypescript.config(
importNames: ['colors'],
message: 'Please use themes instead of colors',
},
{
group: ['@actual-app/web/*'],
message: 'Please do not import `@actual-app/web` in `loot-core`',
},
],
},
],
@@ -569,7 +521,7 @@ export default pluginTypescript.config(
},
},
{
files: ['**/*.{ts,tsx}'],
files: ['**/*.ts?(x)'],
languageOptions: {
parser: tsParser,
@@ -577,7 +529,7 @@ export default pluginTypescript.config(
sourceType: 'module',
parserOptions: {
projectService: true,
project: [path.join(__dirname, './tsconfig.json')],
ecmaFeatures: {
jsx: true,
},
@@ -597,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',
@@ -637,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}',
@@ -681,6 +616,88 @@ export default pluginTypescript.config(
],
},
},
{
files: ['packages/desktop-client/**/*'],
ignores: ['packages/desktop-client/src/hooks/useNavigate.{ts,tsx}'],
rules: {
'no-restricted-imports': [
'warn',
{
paths: [
{
name: 'react-router-dom',
importNames: ['useNavigate'],
message:
"Please import Actual's useNavigate() hook from `src/hooks` instead.",
},
],
},
],
},
},
{
files: ['packages/desktop-client/**/*', 'packages/loot-core/**/*'],
ignores: ['packages/desktop-client/src/redux/index.{ts,tsx}'],
rules: {
'no-restricted-imports': [
'warn',
{
paths: [
{
name: 'react-redux',
importNames: ['useDispatch'],
message:
"Please import Actual's useDispatch() hook from `src/redux` instead.",
},
{
name: 'react-redux',
importNames: ['useSelector'],
message:
"Please import Actual's useSelector() hook from `src/redux` instead.",
},
{
name: 'react-redux',
importNames: ['useStore'],
message:
"Please import Actual's useStore() hook from `src/redux` instead.",
},
],
},
],
},
},
{
files: ['packages/loot-core/src/**/*'],
rules: {
'no-restricted-imports': [
'warn',
{
patterns: [
{
group: ['*.api', '*.web', '*.electron'],
message: "Don't directly reference imports from other platforms",
},
{
group: ['uuid'],
importNames: ['*'],
message: "Use `import { v4 as uuidv4 } from 'uuid'` instead",
},
{
group: ['loot-core/**'],
message:
'Please use relative imports in loot-core instead of importing from `loot-core/*`',
},
{
group: ['@actual-app/web/*'],
message: 'Please do not import `@actual-app/web` in `loot-core`',
},
],
},
],
},
},
{
files: [
'packages/loot-core/src/types/**/*',
@@ -695,6 +712,27 @@ export default pluginTypescript.config(
'import/no-unused-modules': 'off',
},
},
{
files: [
'packages/desktop-client/src/style/index.*',
'packages/desktop-client/src/style/palette.*',
],
rules: {
'no-restricted-imports': [
'off',
{
patterns: [
{
group: ['**/style', '**/colors'],
importNames: ['colors'],
message: 'Please use themes instead of colors',
},
],
},
],
},
},
{
files: ['packages/api/migrations/*', 'packages/loot-core/migrations/*'],
@@ -708,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
@@ -761,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: {
@@ -809,4 +838,4 @@ export default pluginTypescript.config(
'@typescript-eslint/no-unused-vars': 'off',
},
},
);
];

View File

@@ -22,81 +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": {
"@octokit/rest": "^22.0.0",
"@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",
"minimatch": "^10.0.3",
"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,mjs,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 });
@@ -568,20 +569,8 @@ describe('API CRUD operations', () => {
const accountId = await api.createAccount({ name: 'test-account' }, 0);
let newTransaction = [
{
account: accountId,
date: '2023-11-03',
imported_id: '11',
amount: 100,
notes: 'notes',
},
{
account: accountId,
date: '2023-11-03',
imported_id: '12',
amount: 100,
notes: '',
},
{ date: '2023-11-03', imported_id: '11', amount: 100, notes: 'notes' },
{ date: '2023-11-03', imported_id: '12', amount: 100, notes: '' },
];
const addResult = await api.addTransactions(accountId, newTransaction, {
@@ -609,27 +598,9 @@ describe('API CRUD operations', () => {
expect(transactions).toHaveLength(2);
newTransaction = [
{
account: accountId,
date: '2023-12-03',
imported_id: '11',
amount: 100,
notes: 'notes',
},
{
account: accountId,
date: '2023-12-03',
imported_id: '12',
amount: 100,
notes: 'notes',
},
{
account: accountId,
date: '2023-12-03',
imported_id: '22',
amount: 200,
notes: '',
},
{ date: '2023-12-03', imported_id: '11', amount: 100, notes: 'notes' },
{ date: '2023-12-03', imported_id: '12', amount: 100, notes: 'notes' },
{ date: '2023-12-03', imported_id: '22', amount: 200, notes: '' },
];
const reconciled = await api.importTransactions(accountId, newTransaction);
@@ -683,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

@@ -1,6 +1,5 @@
// @ts-strict-ignore
import type { Handlers } from 'loot-core/types/handlers';
import type { ImportTransactionEntity } from 'loot-core/types/models/import-transaction';
import * as injected from './injected';
@@ -53,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');
}
@@ -99,8 +90,8 @@ export interface ImportTransactionsOpts {
}
export function importTransactions(
accountId: string,
transactions: ImportTransactionEntity[],
accountId,
transactions,
opts: ImportTransactionsOpts = {
defaultCleared: true,
},

View File

@@ -1,10 +1,10 @@
{
"name": "@actual-app/api",
"version": "25.7.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,24 +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": "^11.1.0"
"uuid": "^9.0.1"
},
"devDependencies": {
"tsc-alias": "^1.8.16",
"typescript": "^5.8.3",
"vitest": "^3.2.4"
"@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

@@ -11,9 +11,9 @@
"outDir": "dist",
"declarationDir": "@types",
"paths": {
"loot-core/*": ["./@types/loot-core/src/*"]
"loot-core/*": ["./@types/loot-core/*"]
}
},
"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.2.4"
"@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,16 +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": "^11.1.0"
"uuid": "^9.0.1"
},
"devDependencies": {
"@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.2.4"
"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: 106 KiB

After

Width:  |  Height:  |  Size: 116 KiB

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