Compare commits

..

11 Commits

Author SHA1 Message Date
Joel Jeremy Marquez
58b57aefe1 arial-labels 2024-07-15 12:56:47 -07:00
Joel Jeremy Marquez
e25683f130 Fix typecheck error 2024-07-15 12:56:47 -07:00
Joel Jeremy Marquez
496c76c7f9 vrt 2024-07-15 12:56:47 -07:00
Joel Jeremy Marquez
b7d4964539 Add pillBackgroundHover 2024-07-15 12:56:47 -07:00
Joel Jeremy Marquez
7479df359a Fix OpButton hover 2024-07-15 12:56:47 -07:00
Joel Jeremy Marquez
b1b14d0813 VRT 2024-07-15 12:56:47 -07:00
Joel Jeremy Marquez
b710b9675e Remove title 2024-07-15 12:56:47 -07:00
Joel Jeremy Marquez
f8fb4a9ba7 Release notes 2024-07-15 12:56:47 -07:00
Joel Jeremy Marquez
9f738956d7 React Aria Button on autocomplete and filters 2024-07-15 12:56:47 -07:00
Joel Jeremy Marquez
dc9ce974a5 VRT 2024-07-15 12:56:47 -07:00
Joel Jeremy Marquez
27974c63fd VRT 2024-07-15 12:56:46 -07:00
2728 changed files with 58308 additions and 131938 deletions

View File

@@ -1,12 +0,0 @@
---
alwaysApply: true
---
When running yarn commands - always run them in the root directory. Do not run them in child workspaces.
The following commands can be useful:
- `yarn typecheck` to run typechecker
- `yarn lint` to run the code linter and formatter
- `yarn lint:fix` to fix some of the code lint issues (running this is preferred over `yarn lint`)
- `yarn test` to run all the tests

View File

@@ -1,37 +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.
- When creating a new component, place it in its own file rather than grouping multiple components in a single file.
Naming Conventions
- Favor named exports for components and utilities.
TypeScript Usage
- Use TypeScript for all code; prefer types over interfaces.
- 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.
Change validation
- Run `yarn typecheck` in the root directory to validate that the generated TypeScript code is correct

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"
}

27
.eslintignore Normal file
View File

@@ -0,0 +1,27 @@
packages/api/app/bundle.api.js
packages/api/dist
packages/api/@types
packages/api/migrations
packages/crdt/dist
packages/desktop-client/bundle.browser.js
packages/desktop-client/build/
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/src/icons/**/*
packages/desktop-client/test-results/
packages/desktop-electron/client-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/*

343
.eslintrc.js Normal file
View File

@@ -0,0 +1,343 @@
/* eslint-disable rulesdir/typography */
const path = require('path');
const rulesDirPlugin = require('eslint-plugin-rulesdir');
rulesDirPlugin.RULES_DIR = path.join(
__dirname,
'packages',
'eslint-plugin-actual',
'lib',
'rules',
);
const ruleFCMsg =
'Type the props argument and let TS infer or use ComponentType for a component prop';
const restrictedImportPatterns = [
{
group: ['*.api', '*.web', '*.electron'],
message: 'Dont directly reference imports from other platforms',
},
{
group: ['uuid'],
importNames: ['*'],
message: "Use `import { v4 as uuidv4 } from 'uuid'` instead",
},
];
const restrictedImportColors = [
{
group: ['**/style', '**/colors'],
importNames: ['colors'],
message: 'Please use themes instead of colors',
},
];
module.exports = {
plugins: ['prettier', 'import', 'rulesdir', '@typescript-eslint'],
extends: [
'react-app',
'plugin:react/recommended',
'plugin:react/jsx-runtime',
'plugin:prettier/recommended',
'plugin:@typescript-eslint/recommended',
'plugin:import/typescript',
],
parser: '@typescript-eslint/parser',
parserOptions: { project: [path.join(__dirname, './tsconfig.json')] },
reportUnusedDisableDirectives: true,
globals: {
globalThis: false,
vi: true,
},
rules: {
'prettier/prettier': 'warn',
// Note: base rule explicitly disabled in favor of the TS one
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{
varsIgnorePattern: '^(_|React)',
ignoreRestSiblings: true,
},
],
curly: ['warn', 'multi-line', 'consistent'],
'no-restricted-globals': ['warn'].concat(
require('confusing-browser-globals').filter(g => g !== 'self'),
),
'react/jsx-filename-extension': [
'warn',
{ extensions: ['.jsx', '.tsx'], allow: 'as-needed' },
],
'react/jsx-no-useless-fragment': 'warn',
'react/self-closing-comp': 'warn',
'react/no-unstable-nested-components': [
'warn',
{ allowAsProps: true, customValidators: ['formatter'] },
],
'rulesdir/typography': 'warn',
'rulesdir/prefer-if-statement': 'warn',
// https://github.com/eslint/eslint/issues/16954
// https://github.com/eslint/eslint/issues/16953
'no-loop-func': 'off',
// Do don't need this as we're using TypeScript
'react/prop-types': 'off',
// TODO: re-enable these rules
'react/react-in-jsx-scope': 'off',
'no-var': 'warn',
'react/jsx-curly-brace-presence': 'warn',
'object-shorthand': ['warn', 'properties'],
'import/extensions': [
'warn',
'never',
{
json: 'always',
},
],
'import/no-useless-path-segments': 'warn',
'import/no-duplicates': ['warn', { 'prefer-inline': true }],
'import/no-unused-modules': ['warn', { unusedExports: true }],
'import/order': [
'warn',
{
alphabetize: {
caseInsensitive: true,
order: 'asc',
},
groups: [
'builtin', // Built-in types are first
'external',
'parent',
'sibling',
'index', // Then the index file
],
'newlines-between': 'always',
pathGroups: [
// Enforce that React (and react-related packages) is the first import
{ group: 'builtin', pattern: 'react?(-*)', position: 'before' },
// Separate imports from Actual from "real" external imports
{
group: 'external',
pattern: 'loot-{core,design}/**/*',
position: 'after',
},
],
pathGroupsExcludedImportTypes: ['react'],
},
],
'no-restricted-syntax': [
'warn',
{
// forbid React.* as they are legacy https://twitter.com/dan_abramov/status/1308739731551858689
selector:
":matches(MemberExpression[object.name='React'], TSQualifiedName[left.name='React'])",
message:
'Using default React import is discouraged, please use named exports directly instead.',
},
{
// forbid <a> in favor of <Link>
selector: 'JSXOpeningElement[name.name="a"]',
message: 'Using <a> is discouraged, please use <Link> instead.',
},
],
'no-restricted-imports': [
'warn',
{ patterns: [...restrictedImportPatterns, ...restrictedImportColors] },
],
'@typescript-eslint/ban-ts-comment': [
'error',
{ 'ts-ignore': 'allow-with-description' },
],
// Rules disable during TS migration
'@typescript-eslint/no-var-requires': 'off',
'prefer-const': 'warn',
'prefer-spread': 'off',
'@typescript-eslint/no-empty-function': 'off',
'import/no-default-export': 'warn',
},
overrides: [
{
files: ['.eslintrc.js', './**/.eslintrc.js'],
parserOptions: { project: null },
rules: {
'@typescript-eslint/consistent-type-exports': 'off',
},
},
{
files: [
'./packages/desktop-client/**/*.{ts,tsx}',
'./packages/loot-core/src/client/**/*.{ts,tsx}',
],
rules: {
// enforce type over interface
'@typescript-eslint/consistent-type-definitions': ['warn', 'type'],
// enforce import type
'@typescript-eslint/consistent-type-imports': [
'warn',
{ prefer: 'type-imports', fixStyle: 'inline-type-imports' },
],
'@typescript-eslint/ban-types': [
'warn',
{
types: {
// forbid FC as superflous
FunctionComponent: { message: ruleFCMsg },
FC: { message: ruleFCMsg },
},
extendDefaults: true,
},
],
},
},
{
files: ['./packages/desktop-client/**/*'],
excludedFiles: [
'./packages/desktop-client/src/hooks/useNavigate.{ts,tsx}',
],
rules: {
'no-restricted-imports': [
'warn',
{
patterns: [
{
group: ['react-router-dom'],
importNames: ['useNavigate'],
message: 'Please use Actuals useNavigate() hook instead.',
},
],
},
],
},
},
{
files: ['./packages/loot-core/src/**/*'],
rules: {
'no-restricted-imports': [
'warn',
{
patterns: [
...restrictedImportPatterns,
{
group: ['loot-core/**'],
message:
'Please use relative imports in loot-core instead of importing from `loot-core/*`',
},
],
},
],
},
},
{
files: [
'packages/loot-core/src/types/**/*',
'packages/loot-core/src/client/state-types/**/*',
'**/icons/**/*',
'**/{mocks,__mocks__}/**/*',
// can't correctly resolve usages
'**/*.{testing,electron,browser,web,api}.ts',
],
rules: { 'import/no-unused-modules': 'off' },
},
{
files: [
'./packages/desktop-client/src/style/index.*',
'./packages/desktop-client/src/style/palette.*',
],
rules: {
'no-restricted-imports': ['off', { patterns: restrictedImportColors }],
},
},
{
files: [
'./packages/api/migrations/*',
'./packages/loot-core/migrations/*',
],
rules: {
'import/no-default-export': 'off',
},
},
{
// TODO: fix the issues in these files
files: [
'./packages/desktop-client/src/components/accounts/Account.jsx',
'./packages/desktop-client/src/components/accounts/MobileAccount.jsx',
'./packages/desktop-client/src/components/accounts/MobileAccounts.jsx',
'./packages/desktop-client/src/components/App.tsx',
'./packages/desktop-client/src/components/budget/BudgetCategories.jsx',
'./packages/desktop-client/src/components/budget/BudgetSummaries.tsx',
'./packages/desktop-client/src/components/budget/DynamicBudgetTable.tsx',
'./packages/desktop-client/src/components/budget/index.tsx',
'./packages/desktop-client/src/components/budget/MobileBudget.tsx',
'./packages/desktop-client/src/components/budget/rollover/HoldMenu.tsx',
'./packages/desktop-client/src/components/budget/rollover/TransferMenu.tsx',
'./packages/desktop-client/src/components/common/Menu.tsx',
'./packages/desktop-client/src/components/FinancesApp.tsx',
'./packages/desktop-client/src/components/GlobalKeys.ts',
'./packages/desktop-client/src/components/LoggedInUser.tsx',
'./packages/desktop-client/src/components/manager/ManagementApp.jsx',
'./packages/desktop-client/src/components/manager/subscribe/common.tsx',
'./packages/desktop-client/src/components/ManageRules.tsx',
'./packages/desktop-client/src/components/mobile/MobileAmountInput.jsx',
'./packages/desktop-client/src/components/mobile/MobileNavTabs.tsx',
'./packages/desktop-client/src/components/Modals.tsx',
'./packages/desktop-client/src/components/modals/EditRule.jsx',
'./packages/desktop-client/src/components/modals/ImportTransactions.jsx',
'./packages/desktop-client/src/components/modals/MergeUnusedPayees.jsx',
'./packages/desktop-client/src/components/Notifications.tsx',
'./packages/desktop-client/src/components/payees/ManagePayees.jsx',
'./packages/desktop-client/src/components/payees/ManagePayeesWithData.jsx',
'./packages/desktop-client/src/components/payees/PayeeTable.tsx',
'./packages/desktop-client/src/components/reports/graphs/tableGraph/ReportTable.tsx',
'./packages/desktop-client/src/components/reports/graphs/tableGraph/ReportTableTotals.tsx',
'./packages/desktop-client/src/components/reports/reports/CashFlowCard.jsx',
'./packages/desktop-client/src/components/reports/reports/CustomReport.jsx',
'./packages/desktop-client/src/components/reports/reports/NetWorthCard.jsx',
'./packages/desktop-client/src/components/reports/SaveReportName.tsx',
'./packages/desktop-client/src/components/reports/useReport.ts',
'./packages/desktop-client/src/components/schedules/ScheduleDetails.jsx',
'./packages/desktop-client/src/components/schedules/SchedulesTable.tsx',
'./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',
'./packages/desktop-client/src/components/table.tsx',
'./packages/desktop-client/src/components/Titlebar.tsx',
'./packages/desktop-client/src/components/transactions/MobileTransaction.jsx',
'./packages/desktop-client/src/components/transactions/SelectedTransactions.jsx',
'./packages/desktop-client/src/components/transactions/SimpleTransactionsTable.jsx',
'./packages/desktop-client/src/components/transactions/TransactionList.jsx',
'./packages/desktop-client/src/components/transactions/TransactionsTable.jsx',
'./packages/desktop-client/src/components/transactions/TransactionsTable.test.jsx',
'./packages/desktop-client/src/hooks/useAccounts.ts',
'./packages/desktop-client/src/hooks/useCategories.ts',
'./packages/desktop-client/src/hooks/usePayees.ts',
'./packages/desktop-client/src/hooks/useProperFocus.tsx',
'./packages/desktop-client/src/hooks/useSelected.tsx',
'./packages/loot-core/src/client/query-hooks.tsx',
],
rules: {
'react-hooks/exhaustive-deps': 'off',
},
},
],
settings: {
'import/resolver': {
typescript: {
alwaysTryTypes: true,
},
},
},
};

View File

@@ -1,17 +1,18 @@
name: Bug Report
description: File a bug report also known as an issue or problem.
title: '[Bug]: '
labels: ['needs triage', 'bug']
type: 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.
**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.
**Bank-sync problems (SimpleFin / GoCardless)?** Reach out via the [community Discord](https://discord.gg/pRYNYr4W5A) first and open an issue only if the community deems the issue to be a legitimate bug in Actual.
- type: checkboxes
@@ -22,6 +23,10 @@ body:
options:
- label: 'I have searched and found no existing issue'
required: true
- label: 'I will be providing steps how to reproduce the bug (in most cases this will also mean uploading a demo budget file)'
required: true
validations:
required: true
- type: textarea
id: what-happened
attributes:
@@ -31,15 +36,8 @@ body:
value: 'A bug happened!'
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: How can we reproduce the issue?
description: Please give step-by-step instructions on how to reproduce the issue. In most cases this might also require uploading a sample budget/import file.
value: 'How can we reproduce the issue?'
validations:
required: true
- type: markdown
id: env-info
attributes:
value: '## Environment Details'
- type: dropdown

View File

@@ -6,6 +6,3 @@ contact_links:
- name: Support
url: https://discord.gg/pRYNYr4W5A
about: Need help with something? Having troubles setting up? Or perhaps issues using the API? Reach out to the community on Discord.
- name: Translations
url: https://hosted.weblate.org/projects/actualbudget/actual/
about: Found a string that needs a better translation? Add your suggestion or upvote an existing one in Weblate.

View File

@@ -2,9 +2,9 @@ name: Feature request
description: Request a missing feature
title: '[Feature] '
labels: ['feature']
type: 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.
@@ -16,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,77 +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.',
"If you're happy with this release note, you can add it to your pull request. If not, you'll need to add your own before a maintainer can review your change.",
].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

@@ -1,15 +1,4 @@
name: Setup
description: Setup the environment for the project
inputs:
working-directory:
description: 'Working directory to run in, default .'
required: false
default: '.'
download-translations:
description: 'Whether to download translations as part of setup, default true'
required: false
default: 'true'
runs:
using: composite
@@ -17,33 +6,14 @@ runs:
- name: Install node
uses: actions/setup-node@v4
with:
node-version: 20
- 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
node-version: 18.16.0
- 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)) }}
path: '**/node_modules'
key: yarn-v1-${{ runner.os }}-${{ hashFiles('.nvmrc') }}-${{ hashFiles('**/yarn.lock') }}
- name: Install
working-directory: ${{ inputs.working-directory }}
run: yarn --immutable
shell: bash
if: steps.cache.outputs.cache-hit != 'true'
- name: Download translations
uses: actions/checkout@v4
with:
repository: actualbudget/translations
path: ${{ inputs.working-directory }}/packages/desktop-client/locale
if: ${{ inputs.download-translations == 'true' }}
- name: Remove untranslated languages
run: packages/desktop-client/bin/remove-untranslated-languages
shell: bash
if: ${{ inputs.download-translations == 'true' }}

View File

@@ -1,363 +0,0 @@
import { Octokit } from '@octokit/rest';
import { minimatch } from 'minimatch';
import pLimit from 'p-limit';
const limit = pLimit(50);
/** 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: 500, points: 8 },
{ minChanges: 100, points: 6 },
{ minChanges: 10, points: 2 },
{ minChanges: 0, points: 1 },
],
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: 2000, points: 6 },
{ minChanges: 200, 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();
// Always use UTC for calculations
const firstDayOfLastMonth = new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1, 0, 0, 0, 0),
);
const since = process.env.START_DATE
? new Date(Date.parse(process.env.START_DATE))
: firstDayOfLastMonth;
// Calculate the end of the month for the since date in UTC
const until = new Date(
Date.UTC(
since.getUTCFullYear(),
since.getUTCMonth() + 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(
'GET /search/issues',
{
q: searchQuery,
per_page: 100,
advanced_search: true,
},
response => response.data.filter(pr => pr.number),
);
// Get reviews and PR details for each PR
await Promise.all(
recentPRs.map(pr =>
limit(async () => {
const [reviews, modifiedFiles] = await Promise.all([
octokit.pulls.listReviews({ owner, repo, pull_number: pr.number }),
octokit.paginate(
octokit.pulls.listFiles,
{
owner,
repo,
pull_number: pr.number,
per_page: 100,
},
res => res.data,
),
]);
const totalChanges = modifiedFiles
.filter(
file =>
!config.EXCLUDED_FILES.some(pattern =>
minimatch(file.filename, pattern),
),
)
.reduce((sum, file) => sum + file.additions + file.deletions, 0);
const isReleasePR = pr.title.match(/🔖.*\d+\.\d+\.\d+/);
const prPoints =
config.PR_REVIEW_POINT_TIERS.find(t => totalChanges >= t.minChanges)
?.points ?? 0;
if (isReleasePR) {
if (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;
}
} else {
const uniqueReviewers = new Set();
reviews.data
.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;
});
}
}),
),
);
// 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(),
});
// Get label events for each issue
await Promise.all(
issues.map(issue =>
limit(async () => {
const { data: events } = await octokit.issues.listEventsForTimeline({
owner,
repo,
issue_number: issue.number,
});
events
.filter(event => {
const createdAt = new Date(event.created_at);
return (
createdAt.getTime() > since.getTime() &&
createdAt.getTime() <= until.getTime() &&
stats.has(event.actor?.login)
);
})
.forEach(event => {
if (
event.event === 'unlabeled' &&
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' &&
event.state_reason === 'not_planned'
) {
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:
@@ -68,17 +68,3 @@ jobs:
with:
name: build-stats
path: packages/desktop-client/build-stats
server:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up environment
uses: ./.github/actions/setup
- name: Build Server
run: yarn workspace @actual-app/sync-server build
- name: Upload Build
uses: actions/upload-artifact@v4
with:
name: sync-server
path: packages/sync-server/build

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

@@ -1,109 +0,0 @@
name: Build Edge Docker Image
# Edge Docker images are built for every commit, and daily
on:
push:
branches:
- master
paths:
- 'packages/sync-server/**'
pull_request:
branches:
- master
paths:
- 'packages/sync-server/**'
schedule:
- cron: '0 0 * * *'
workflow_dispatch:
permissions:
contents: read
packages: write
env:
IMAGES: |
${{ !github.event.repository.fork && 'actualbudget/actual-server' || '' }}
ghcr.io/${{ github.repository_owner }}/actual-server
ghcr.io/${{ github.repository_owner }}/actual
# Creates the following tags:
# - actual-server:edge
TAGS: |
type=edge,value=edge
type=sha
jobs:
build:
if: github.event_name == 'workflow_dispatch' || !github.event.repository.fork
name: Build Docker image
runs-on: ubuntu-latest
strategy:
matrix:
os: [ubuntu, alpine]
steps:
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
# Push to both Docker Hub and Github Container Registry
images: ${{ env.IMAGES }}
flavor: ${{ matrix.os != 'ubuntu' && format('suffix=-{0}', matrix.os) || '' }}
tags: ${{ env.TAGS }}
- name: Login to Docker Hub
uses: docker/login-action@v3
if: github.event_name != 'pull_request' && !github.event.repository.fork
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
if: github.event_name != 'pull_request'
with:
registry: ghcr.io
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 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
uses: docker/build-push-action@v5
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
file: packages/sync-server/docker/${{ matrix.os }}.Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7${{ matrix.os == 'alpine' && ',linux/arm/v6' || '' }}
tags: ${{ steps.meta.outputs.tags }}
build-args: |
GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,96 +0,0 @@
name: Build Stable Docker Image
# Stable Docker images are built for every new tag
on:
push:
tags:
- 'v*.*.*'
paths-ignore:
- README.md
- LICENSE.txt
env:
IMAGES: |
actualbudget/actual-server
ghcr.io/actualbudget/actual-server
ghcr.io/actualbudget/actual
# Creates the following tags:
# - actual-server:latest (see docker/metadata-action flavor inputs, below)
# - actual-server:1.3
# - actual-server:1.3.7
# - actual-server:sha-90dd603
TAGS: |
type=semver,pattern={{version}}
jobs:
build:
name: Build Docker image
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
# Push to both Docker Hub and Github Container Registry
images: ${{ env.IMAGES }}
# Automatically update :latest
flavor: latest=true
tags: ${{ env.TAGS }}
- name: Docker meta for Alpine image
id: alpine-meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGES }}
# Automatically update :latest
flavor: |
latest=true
suffix=-alpine,onlatest=true
tags: ${{ env.TAGS }}
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
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
platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: ${{ steps.meta.outputs.tags }}
- name: Build and push alpine image
uses: docker/build-push-action@v5
with:
context: .
push: true
file: packages/sync-server/docker/alpine.Dockerfile
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6
tags: ${{ steps.alpine-meta.outputs.tags }}

View File

@@ -1,7 +1,6 @@
name: E2E Tests
on:
pull_request:
on: [pull_request]
env:
GITHUB_PR_NUMBER: ${{github.event.pull_request.number}}
@@ -32,7 +31,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 +47,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

@@ -24,7 +24,7 @@ jobs:
strategy:
matrix:
os:
- ubuntu-22.04
- ubuntu-latest
- windows-latest
- macos-latest
runs-on: ${{ matrix.os }}
@@ -40,7 +40,6 @@ jobs:
python3 -m pip install setuptools
- if: ${{ startsWith(matrix.os, 'ubuntu') }}
run: |
sudo apt-get update
sudo apt-get install flatpak -y
sudo apt-get install flatpak-builder -y
sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
@@ -49,17 +48,13 @@ jobs:
sudo flatpak install org.electronjs.Electron2.BaseApp/x86_64/23.08 -y
- name: Set up environment
uses: ./.github/actions/setup
- name: Build Electron for Mac
if: ${{ startsWith(matrix.os, 'macos') }}
- name: Build Electron
run: ./bin/package-electron
env:
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
CSC_LINK: ${{ secrets.CSC_LINK }}
# CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
# CSC_LINK: ${{ secrets.CSC_LINK }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
- name: Build Electron
if: ${{ ! startsWith(matrix.os, 'macos') }}
run: ./bin/package-electron
- name: Upload Build
uses: actions/upload-artifact@v4
with:
@@ -67,78 +62,13 @@ jobs:
path: |
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
- name: Upload Windows Store Build
if: ${{ startsWith(matrix.os, 'windows') }}
uses: actions/upload-artifact@v4
with:
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

@@ -19,7 +19,7 @@ jobs:
strategy:
matrix:
os:
- ubuntu-22.04
- ubuntu-latest
- windows-latest
- macos-latest
runs-on: ${{ matrix.os }}
@@ -35,7 +35,6 @@ jobs:
python3 -m pip install setuptools
- if: ${{ startsWith(matrix.os, 'ubuntu') }}
run: |
sudo apt-get update
sudo apt-get install flatpak -y
sudo apt-get install flatpak-builder -y
sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
@@ -53,13 +52,5 @@ jobs:
path: |
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
- name: Upload Windows Store Build
if: ${{ startsWith(matrix.os, 'windows') }}
uses: actions/upload-artifact@v4
with:
name: actual-electron-${{ matrix.os }}-appx
path: |
packages/desktop-electron/dist/*.appx

View File

@@ -1,56 +0,0 @@
name: Generate release PR
on:
workflow_dispatch:
inputs:
ref:
description: 'Commit or branch to release'
required: true
default: 'master'
version:
description: 'Version number for the release (optional)'
required: false
default: ''
jobs:
generate-release-pr:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.ref }}
- name: Bump package versions
id: bump_package_versions
shell: bash
run: |
declare -A packages=(
[web]="desktop-client"
[electron]="desktop-electron"
[sync]="sync-server"
[api]="api"
)
for key in "${!packages[@]}"; do
pkg="${packages[$key]}"
if [[ -n "${{ github.event.inputs.version }}" ]]; then
version="${{ github.event.inputs.version }}"
else
version=$(node ./packages/ci-actions/bin/get-next-package-version.js \
--package-json "./packages/$pkg/package.json" \
--type auto \
--update)
fi
eval "NEW_${key^^}_VERSION=\"$version\""
done
echo "version=$NEW_WEB_VERSION" >> "$GITHUB_OUTPUT"
- name: Create PR
uses: peter-evans/create-pull-request@v7
with:
commit-message: '🔖 (${{ steps.bump_package_versions.outputs.version }})'
title: '🔖 (${{ steps.bump_package_versions.outputs.version }})'
body: 'Generated by [generate-release-pr.yml](../tree/master/.github/workflows/generate-release-pr.yml)'
branch: 'release/v${{ steps.bump_package_versions.outputs.version }}'

View File

@@ -1,87 +0,0 @@
name: Extract and upload i18n strings
on:
schedule:
# 4am UTC
- cron: '0 4 * * *'
workflow_dispatch:
jobs:
extract-and-upload-i18n-strings:
runs-on: ubuntu-latest
if: github.repository == 'actualbudget/actual'
steps:
- name: Check out main repository
uses: actions/checkout@v4
with:
path: actual
- name: Set up environment
uses: ./actual/.github/actions/setup
with:
working-directory: actual
download-translations: false # As we'll manually clone instead
- name: Configure Git config
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
- name: Configure i18n client
run: |
pip install wlc
- name: Lock translations
run: |
wlc \
--url https://hosted.weblate.org/api/ \
--key "${{ secrets.WEBLATE_API_KEY_CI_STRINGS }}" \
lock \
actualbudget/actual
- name: Update VCS with latest translations
run: |
wlc \
--url https://hosted.weblate.org/api/ \
--key "${{ secrets.WEBLATE_API_KEY_CI_STRINGS }}" \
push \
actualbudget/actual
- name: Check out updated translations
uses: actions/checkout@v4
with:
ssh-key: ${{ secrets.STRING_IMPORT_DEPLOY_KEY }}
repository: actualbudget/translations
path: translations
- name: Generate i18n strings
working-directory: actual
run: |
mkdir -p packages/desktop-client/locale/
cp ../translations/en.json packages/desktop-client/locale/
yarn generate:i18n
if [[ ! -f packages/desktop-client/locale/en.json ]]; then
echo "File packages/desktop-client/locale/en.json not found. Ensure the file was generated correctly."
exit 1
fi
- name: Check in new i18n strings
working-directory: translations
run: |
cp ../actual/packages/desktop-client/locale/en.json .
git add .
if git commit -m "Update source strings"; then
git push
else
echo "No changes to commit"
fi
- name: Update Weblate with latest translations
run: |
wlc \
--url https://hosted.weblate.org/api/ \
--key "${{ secrets.WEBLATE_API_KEY_CI_STRINGS }}" \
pull \
actualbudget/actual
- name: Unlock translations
if: always() # Clean up even on failure
run: |
wlc \
--url https://hosted.weblate.org/api/ \
--key "${{ secrets.WEBLATE_API_KEY_CI_STRINGS }}" \
unlock \
actualbudget/actual

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 ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/desktop-client/package.json --type nightly)
NEW_SYNC_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/sync-server/package.json --type nightly)
NEW_API_VERSION=$(node ./packages/ci-actions/bin/get-next-package-version.js --package-json ./packages/api/package.json --type nightly)
# Set package versions
npm version $NEW_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

@@ -13,9 +13,6 @@ name: Compare Sizes
on:
pull_request_target:
paths:
- 'packages/**'
- '!packages/sync-server/**'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
@@ -28,7 +25,7 @@ jobs:
pull-requests: write
steps:
- name: Wait for ${{github.base_ref}} build to succeed
uses: fountainhead/action-wait-for-check@v1.2.0
uses: fountainhead/action-wait-for-check@v1.1.0
id: master-build
with:
token: ${{ secrets.GITHUB_TOKEN }}
@@ -36,7 +33,7 @@ jobs:
ref: ${{github.base_ref}}
- name: Wait for PR build to succeed
uses: fountainhead/action-wait-for-check@v1.2.0
uses: fountainhead/action-wait-for-check@v1.1.0
id: wait-for-build
with:
token: ${{ secrets.GITHUB_TOKEN }}
@@ -49,7 +46,7 @@ jobs:
echo "Build failed on PR branch or ${{github.base_ref}}"
exit 1
- name: Download build artifact from ${{github.base_ref}}
uses: dawidd6/action-download-artifact@v6
uses: dawidd6/action-download-artifact@v3
id: pr-build
with:
branch: ${{github.base_ref}}
@@ -58,13 +55,12 @@ jobs:
path: base
- name: Download build artifact from PR
uses: dawidd6/action-download-artifact@v6
uses: dawidd6/action-download-artifact@v3
with:
pr: ${{github.event.pull_request.number}}
workflow: build.yml
name: build-stats
path: head
allow_forks: true
- name: Strip content hashes from stats files
run: |
@@ -74,14 +70,14 @@ jobs:
sed -i -E 's/index\.[0-9a-zA-Z_-]{8,}\./index./g' ./base/web-stats.json
sed -i -E 's/\.[0-9a-zA-Z_-]{8,}\.chunk\././g' ./base/web-stats.json
sed -i -E 's/\.[0-9a-f]{8,}\././g' ./base/*.json
- uses: twk3/rollup-size-compare-action@v1.1.1
- uses: twk3/rollup-size-compare-action@v1.0.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
current-stats-json-path: ./head/web-stats.json
base-stats-json-path: ./base/web-stats.json
title: desktop-client
- uses: twk3/rollup-size-compare-action@v1.1.1
- uses: github/webpack-bundlesize-compare-action@v1.8.2
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
current-stats-json-path: ./head/loot-core-stats.json

View File

@@ -2,41 +2,15 @@ name: 'Close stale PRs'
on:
schedule:
- cron: '30 1 * * *'
workflow_dispatch: # Allow manual triggering
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9
- uses: actions/stale@v8
with:
stale-pr-message: 'This PR is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days.'
close-pr-message: 'This PR was closed because it has been stalled for 5 days with no activity.'
days-before-stale: 30
days-before-close: 5
days-before-issue-stale: -1
stale-wip:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9
with:
stale-pr-message: ':wave: Hi! It looks like this PR has not had any changes for a week now. Would you like someone to review this PR? If so - please remove the "[WIP]" prefix from the PR title. That will let the community know that this PR is open for a review.'
days-before-stale: 7
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,119 +0,0 @@
name: /update-vrt
on:
issue_comment:
types: [created]
permissions:
pull-requests: read
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.issue.number }}-${{ contains(github.event.comment.body, '/update-vrt') }}
cancel-in-progress: true
jobs:
update-vrt:
name: Update VRT
runs-on: ubuntu-latest
if: |
github.event.issue.pull_request &&
contains(github.event.comment.body, '/update-vrt')
container:
image: mcr.microsoft.com/playwright:v1.52.0-jammy
steps:
- name: Get PR branch
# 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
with:
repository: ${{ steps.comment-branch.outputs.head_owner }}/${{ steps.comment-branch.outputs.head_repo }}
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:
COMMIT_SHA: ${{ steps.comment-branch.outputs.head_sha }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: ./.github/actions/netlify-wait-for-build
- name: Run VRT Tests on Netlify URL
run: yarn vrt --update-snapshots
env:
E2E_START_URL: ${{ steps.netlify.outputs.url }}
- name: Create patch
run: |
git config --system --add safe.directory "*"
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git reset
git add "**/*.png"
if git diff --staged --quiet; then
echo "No changes to commit"
exit 0
fi
git commit -m "Update VRT"
git format-patch -1 HEAD --stdout > Update-VRT.patch
- uses: actions/upload-artifact@v4
with:
name: patch
path: Update-VRT.patch
push-patch:
runs-on: ubuntu-latest
needs: update-vrt
permissions:
contents: write
pull-requests: write
steps:
- name: Get PR branch
# 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
with:
repository: ${{ steps.comment-branch.outputs.head_owner }}/${{ steps.comment-branch.outputs.head_repo }}
ref: ${{ steps.comment-branch.outputs.head_ref }}
- uses: actions/download-artifact@v4
continue-on-error: true
with:
name: patch
- name: Apply patch and push
env:
BRANCH_NAME: ${{ steps.comment-branch.outputs.head_ref }}
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git apply Update-VRT.patch
git add "**/*.png"
if git diff --staged --quiet; then
echo "No changes to commit"
exit 0
fi
git commit -m "Update VRT"
git push origin HEAD:${BRANCH_NAME}
- name: Add finished reaction
uses: dkershner6/reaction-action@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
commentId: ${{ github.event.comment.id }}
reaction: 'rocket'
add-starting-reaction:
runs-on: ubuntu-latest
if: |
github.event.issue.pull_request &&
contains(github.event.comment.body, '/update-vrt')
permissions:
pull-requests: write
steps:
- name: React to comment
uses: dkershner6/reaction-action@v2
with:
token: ${{ secrets.GITHUB_TOKEN }}
commentId: ${{ github.event.comment.id }}
reaction: '+1'

13
.gitignore vendored
View File

@@ -3,7 +3,6 @@
!data/.gitkeep
/data2
Actual-*
!actual-server.js
**/xcuserdata/*
export-2020-01-10.csv
@@ -51,15 +50,3 @@ bundle.mobile.js.map
# Local Netlify folder
.netlify
# build output
package.tgz
# Fly.io configuration
fly.toml
# TypeScript cache
build/
# .d.ts files aren't type-checked with skipLibCheck set to true
*.d.ts

View File

@@ -1 +0,0 @@
yarn lint-staged

2
.nvmrc
View File

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

View File

@@ -1,30 +1 @@
sync_pb.*
packages/api/app/bundle.api.js
packages/api/app/stats.json
packages/api/dist
packages/api/@types
packages/api/migrations
packages/crdt/dist
packages/component-library/src/icons/**/*
packages/desktop-client/bundle.browser.js
packages/desktop-client/stats.json
packages/desktop-client/.swc/
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/*
packages/sync-server/coverage/
.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");

893
.yarn/releases/yarn-4.0.2.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

@@ -2,8 +2,6 @@ compressionLevel: mixed
enableGlobalCache: false
enableTransparentWorkspaces: false
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-4.9.1.cjs
yarnPath: .yarn/releases/yarn-4.0.2.cjs

View File

@@ -1,10 +0,0 @@
# CODEOWNERS file for Actual Budget
# Please add your name to code-paths that you feel especially
# passionate about. You will be notified for any PRs there.
/packages/api/ @MatissJanis
/packages/component-library/ @MatissJanis
/packages/desktop-client/src/components/mobile @joel-jeremy
/packages/desktop-electron/ @MikesGlitch
/packages/loot-core/src/server/budget @youngcw
/packages/sync-server/ @matt-fidd

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

@@ -14,40 +14,22 @@ Want to say thanks? Click the ⭐ at the top of the page.
- Actual [discord](https://discord.gg/pRYNYr4W5A) community.
- Actual [Community Documentation](https://actualbudget.org/docs)
- [Frequently asked questions](https://actualbudget.org/docs/faq)
## Installation
There are four ways to deploy Actual:
If you are only interested in running the latest version and not contributing to the source code, you don't need to clone this repo. You can get the latest version through npm.
1. One-click deployment [via PikaPods](https://www.pikapods.com/pods?run=actual) (~1.40 $/month) - recommended for non-technical users
1. Managed hosting [via Fly.io](https://actualbudget.org/docs/install/fly) (~1.50 $/month)
1. Self-hosted by using [a Docker image](https://actualbudget.org/docs/install/docker)
1. Local-only apps - [downloadable Windows, Mac and Linux apps](https://actualbudget.org/download/) you can run on your device
### The easy way: using a server (recommended)
Learn more in the [installation instructions docs](https://actualbudget.org/docs/install/).
The easiest way to get Actual running is to use the [actual-server](https://github.com/actualbudget/actual-server) project. That is the server for syncing changes across devices, and it comes with the latest version of Actual. The server will provide both the web project and a server for syncing.
## Ready to Start Budgeting?
Read about [Envelope budgeting](https://actualbudget.org/docs/getting-started/envelope-budgeting) to know more about the idea behind Actual Budget.
### Are you new to budgeting or want to start fresh?
Check out the community's [Starting Fresh](https://actualbudget.org/docs/getting-started/starting-fresh) guide so you can quickly get up and running!
### Are you migrating from other budgeting apps?
Check out the community's [Migration](https://actualbudget.org/docs/migration/) guide to start jumping on the Actual Budget train!
You can get up and running quickly and easily by following our [Running Actual Locally Guide](https://actualbudget.org/docs/install/local)
## Documentation
We have a wide range of documentation on how to use Actual, this is all available in our [Community Documentation](https://actualbudget.org/docs), this includes topics on Budgeting, Account Management, Tips & Tricks and some documentation for developers.
## Contributing
Actual is a community driven product. Learn more about [contributing to Actual](https://actualbudget.org/docs/contributing/).
### Code structure
## Code structure
The Actual app is split up into a few packages:
@@ -57,27 +39,15 @@ The Actual app is split up into a few packages:
More information on the project structure is available in our [community documentation](https://actualbudget.org/docs/contributing/project-details).
### Feature Requests
## Feature Requests
Current feature requests can be seen [here](https://github.com/actualbudget/actual/issues?q=is%3Aissue+label%3A%22needs+votes%22+sort%3Areactions-%2B1-desc).
Vote for your favorite requests by reacting :+1: to the top comment of the request.
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>
## Repo Activity
![Alt](https://repobeats.axiom.co/api/embed/e20537dd8b74956f86736726ccfbc6f0565bec22.svg 'Repobeats analytics image')
## Sponsors
Thanks to our wonderful sponsors who make Actual Budget possible!
Thanks to our wonderful sponsors who make Actual budget possible!
<a href="https://www.netlify.com"> <img src="https://www.netlify.com/v3/img/components/netlify-color-accent.svg" alt="Deploys by Netlify" /> </a>

View File

@@ -4,18 +4,6 @@ ROOT=`dirname $0`
cd "$ROOT/.."
echo "Updating translations..."
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
export NODE_OPTIONS="--max-old-space-size=4096"
yarn workspace loot-core build:browser
yarn workspace @actual-app/web build:browser

View File

@@ -6,8 +6,8 @@ RELEASE=""
CI=${CI:-false}
cd "$ROOT/.."
POSITIONAL=()
SKIP_EXE_BUILD=false
while [[ $# -gt 0 ]]; do
key="$1"
@@ -16,38 +16,29 @@ while [[ $# -gt 0 ]]; do
RELEASE="production"
shift
;;
--skip-exe-build)
SKIP_EXE_BUILD=true
shift
;;
*)
POSITIONAL+=("$1")
shift
;;
esac
done
set -- "${POSITIONAL[@]}"
# Get translations
echo "Updating translations..."
if ! [ -d packages/desktop-client/locale ]; then
git clone https://github.com/actualbudget/translations packages/desktop-client/locale
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
pushd packages/desktop-client/locale > /dev/null
git pull
popd > /dev/null
packages/desktop-client/bin/remove-untranslated-languages
export NODE_OPTIONS="--max-old-space-size=4096"
yarn rebuild-electron
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 @actual-app/web build --mode=desktop
yarn workspace desktop-electron update-client
@@ -55,20 +46,14 @@ 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 --publish never --arm64 --x64
echo "Created release"
else
SKIP_NOTARIZATION=true yarn build
fi
echo "\nCreated release"
else
SKIP_NOTARIZATION=true yarn build --publish never --x64
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 actual/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 \
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

@@ -1,808 +0,0 @@
import globals from 'globals';
import pluginImport from 'eslint-plugin-import';
import pluginJSXA11y from 'eslint-plugin-jsx-a11y';
import pluginReact from 'eslint-plugin-react';
import pluginReactHooks from 'eslint-plugin-react-hooks';
import pluginTypescript from 'typescript-eslint';
import pluginTypescriptPaths from 'eslint-plugin-typescript-paths';
import pluginActual from './packages/eslint-plugin-actual/lib/index.js';
import tsParser from '@typescript-eslint/parser';
const confusingBrowserGlobals = [
// https://github.com/facebook/create-react-app/tree/main/packages/confusing-browser-globals
'addEventListener',
'blur',
'close',
'closed',
'confirm',
'defaultStatus',
'defaultstatus',
'event',
'external',
'find',
'focus',
'frameElement',
'frames',
'history',
'innerHeight',
'innerWidth',
'length',
'location',
'locationbar',
'menubar',
'moveBy',
'moveTo',
'name',
'onblur',
'onerror',
'onfocus',
'onload',
'onresize',
'onunload',
'open',
'opener',
'opera',
'outerHeight',
'outerWidth',
'pageXOffset',
'pageYOffset',
'parent',
'print',
'removeEventListener',
'resizeBy',
'resizeTo',
'screen',
'screenLeft',
'screenTop',
'screenX',
'screenY',
'scroll',
'scrollbars',
'scrollBy',
'scrollTo',
'scrollX',
'scrollY',
'status',
'statusbar',
'stop',
'toolbar',
'top',
];
export default pluginTypescript.config(
{
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/',
'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/*',
'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,
},
languageOptions: {
globals: {
...globals.browser,
...globals.commonjs,
...globals.node,
globalThis: false,
vi: true,
},
},
settings: {
react: {
version: 'detect',
},
'import/resolver': {
typescript: {
alwaysTryTypes: true,
},
},
},
},
pluginReact.configs.flat.recommended,
pluginReact.configs.flat['jsx-runtime'],
pluginTypescript.configs.recommended,
pluginImport.flatConfigs.recommended,
{
plugins: {
actual: pluginActual,
},
rules: {
'actual/no-untranslated-strings': 'error',
'actual/prefer-trans-over-t': 'error',
},
},
{
files: ['**/*.{js,ts,jsx,tsx}'],
plugins: {
'jsx-a11y': pluginJSXA11y,
'react-hooks': pluginReactHooks,
},
rules: {
// http://eslint.org/docs/rules/
'array-callback-return': 'warn',
'default-case': [
'warn',
{
commentPattern: '^no default$',
},
],
curly: ['warn', 'multi-line', 'consistent'],
'dot-location': ['warn', 'property'],
eqeqeq: ['warn', 'smart'],
'new-parens': 'warn',
'no-array-constructor': 'warn',
'no-caller': 'warn',
'no-cond-assign': ['warn', 'except-parens'],
'no-const-assign': 'warn',
'no-control-regex': 'warn',
'no-delete-var': 'warn',
'no-dupe-args': 'warn',
'no-dupe-class-members': 'warn',
'no-dupe-keys': 'warn',
'no-duplicate-case': 'warn',
'no-empty-character-class': 'warn',
'no-empty-pattern': 'warn',
'no-eval': 'warn',
'no-ex-assign': 'warn',
'no-extend-native': 'warn',
'no-extra-bind': 'warn',
'no-extra-label': 'warn',
'no-fallthrough': 'warn',
'no-func-assign': 'warn',
'no-implied-eval': 'warn',
'no-invalid-regexp': 'warn',
'no-iterator': 'warn',
'no-label-var': 'warn',
'no-labels': [
'warn',
{
allowLoop: true,
allowSwitch: false,
},
],
'no-lone-blocks': 'warn',
'no-mixed-operators': [
'warn',
{
groups: [
['&', '|', '^', '~', '<<', '>>', '>>>'],
['==', '!=', '===', '!==', '>', '>=', '<', '<='],
['&&', '||'],
['in', 'instanceof'],
],
allowSamePrecedence: false,
},
],
'no-multi-str': 'warn',
'no-global-assign': 'warn',
'no-unsafe-negation': 'warn',
'no-new-func': 'warn',
'no-new-object': 'warn',
'no-new-symbol': 'warn',
'no-new-wrappers': 'warn',
'no-obj-calls': 'warn',
'no-octal': 'warn',
'no-octal-escape': 'warn',
'no-redeclare': 'warn',
'no-regex-spaces': 'warn',
'no-script-url': 'warn',
'no-self-assign': 'warn',
'no-self-compare': 'warn',
'no-sequences': 'warn',
'no-shadow-restricted-names': 'warn',
'no-sparse-arrays': 'warn',
'no-template-curly-in-string': 'warn',
'no-this-before-super': 'warn',
'no-throw-literal': 'warn',
'no-undef': 'error',
'no-unreachable': 'warn',
'no-unused-expressions': [
'error',
{
allowShortCircuit: true,
allowTernary: true,
allowTaggedTemplates: true,
},
],
'no-unused-labels': 'warn',
'no-use-before-define': [
'warn',
{
functions: false,
classes: false,
variables: false,
},
],
'no-useless-computed-key': 'warn',
'no-useless-concat': 'warn',
'no-useless-constructor': 'warn',
'no-useless-escape': 'warn',
'no-useless-rename': [
'warn',
{
ignoreDestructuring: false,
ignoreImport: false,
ignoreExport: false,
},
],
'no-with': 'warn',
'no-whitespace-before-property': 'warn',
'require-yield': 'warn',
'rest-spread-spacing': ['warn', 'never'],
strict: ['warn', 'never'],
'unicode-bom': ['warn', 'never'],
'use-isnan': 'warn',
'valid-typeof': 'warn',
'no-restricted-properties': [
'error',
{
object: 'require',
property: 'ensure',
message:
'Please use import() instead. More info: https://facebook.github.io/create-react-app/docs/code-splitting',
},
{
object: 'System',
property: 'import',
message:
'Please use import() instead. More info: https://facebook.github.io/create-react-app/docs/code-splitting',
},
],
'getter-return': 'warn',
// https://github.com/benmosher/eslint-plugin-import/tree/master/docs/rules
'import/first': 'error',
'import/no-amd': 'error',
'import/no-anonymous-default-export': 'warn',
'import/no-webpack-loader-syntax': 'error',
'import/extensions': [
'warn',
'never',
{
json: 'always',
},
],
'import/no-useless-path-segments': 'warn',
'import/no-duplicates': [
'warn',
{
'prefer-inline': true,
},
],
'import/order': [
'warn',
{
alphabetize: {
caseInsensitive: true,
order: 'asc',
},
groups: ['builtin', 'external', 'parent', 'sibling', 'index'],
'newlines-between': 'always',
pathGroups: [
{
// Enforce that React (and react-related packages) is the first import
group: 'builtin',
pattern: 'react?(-*)',
position: 'before',
},
{
// Separate imports from Actual from "real" external imports
group: 'external',
pattern: 'loot-{core,design}/**/*',
position: 'after',
},
],
pathGroupsExcludedImportTypes: ['react'],
},
],
// https://github.com/yannickcr/eslint-plugin-react/tree/master/docs/rules
'react/forbid-foreign-prop-types': [
'warn',
{
allowInPropTypes: true,
},
],
'react/jsx-no-comment-textnodes': 'warn',
'react/jsx-no-duplicate-props': 'warn',
'react/jsx-no-target-blank': 'warn',
'react/jsx-no-undef': 'error',
'react/jsx-pascal-case': [
'warn',
{
allowAllCaps: true,
ignore: [],
},
],
'react/no-danger-with-children': 'warn',
// Disabled because of undesirable warnings
// See https://github.com/facebook/create-react-app/issues/5204 for
// blockers until its re-enabled
// 'react/no-deprecated': 'warn',
'react/no-direct-mutation-state': 'warn',
'react/no-is-mounted': 'warn',
'react/no-typos': 'error',
'react/require-render-return': 'error',
'react/style-prop-object': 'warn',
'react/jsx-no-useless-fragment': 'warn',
'react/self-closing-comp': 'warn',
'react/jsx-filename-extension': [
'warn',
{
extensions: ['.jsx', '.tsx'],
allow: 'as-needed',
},
],
'react/no-unstable-nested-components': [
'warn',
{
allowAsProps: true,
customValidators: ['formatter'],
},
],
// Don't need this as we're using TypeScript
'react/prop-types': 'off',
// https://github.com/evcohen/eslint-plugin-jsx-a11y/tree/master/docs/rules
'jsx-a11y/alt-text': 'warn',
'jsx-a11y/anchor-has-content': 'warn',
'jsx-a11y/anchor-is-valid': [
'warn',
{
aspects: ['noHref', 'invalidHref'],
},
],
'jsx-a11y/aria-activedescendant-has-tabindex': 'warn',
'jsx-a11y/aria-props': 'warn',
'jsx-a11y/aria-proptypes': 'warn',
'jsx-a11y/aria-role': [
'warn',
{
ignoreNonDOM: true,
},
],
'jsx-a11y/aria-unsupported-elements': 'warn',
'jsx-a11y/heading-has-content': 'warn',
'jsx-a11y/iframe-has-title': 'warn',
'jsx-a11y/img-redundant-alt': 'warn',
'jsx-a11y/no-access-key': 'warn',
'jsx-a11y/no-distracting-elements': 'warn',
'jsx-a11y/no-redundant-roles': 'warn',
'jsx-a11y/role-has-required-aria-props': 'warn',
'jsx-a11y/role-supports-aria-props': 'warn',
'jsx-a11y/scope': 'warn',
// https://github.com/facebook/react/tree/main/packages/eslint-plugin-react-hooks
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': [
'warn',
{
additionalHooks: '(useQuery)',
},
],
'actual/typography': 'warn',
'actual/prefer-if-statement': 'warn',
'actual/prefer-logger-over-console': 'error',
// Note: base rule explicitly disabled in favor of the TS one
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{
varsIgnorePattern: '^(_|React)',
argsIgnorePattern: '^(_|React)',
ignoreRestSiblings: true,
caughtErrors: 'none',
},
],
'no-restricted-globals': ['warn', ...confusingBrowserGlobals],
// https://github.com/eslint/eslint/issues/16954
// https://github.com/eslint/eslint/issues/16953
'no-loop-func': 'off',
// TODO: re-enable these rules
'react/react-in-jsx-scope': 'off',
'no-var': 'warn',
'react/jsx-curly-brace-presence': 'warn',
'object-shorthand': ['warn', 'properties'],
'no-restricted-syntax': [
'warn',
{
// forbid React.* as they are legacy https://twitter.com/dan_abramov/status/1308739731551858689
selector:
":matches(MemberExpression[object.name='React'], TSQualifiedName[left.name='React'])",
message:
'Using default React import is discouraged, please use named exports directly instead.',
},
{
// forbid <a> in favor of <Link>
selector: 'JSXOpeningElement[name.name="a"]',
message: 'Using <a> is discouraged, please use <Link> instead.',
},
],
'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'],
message: "Don't directly reference imports from other platforms",
},
{
group: ['uuid'],
importNames: ['*'],
message: "Use `import { v4 as uuidv4 } from 'uuid'` instead",
},
{
group: ['**/style', '**/colors'],
importNames: ['colors'],
message: 'Please use themes instead of colors',
},
{
group: ['@actual-app/web/*'],
message: 'Please do not import `@actual-app/web` in `loot-core`',
},
],
},
],
'@typescript-eslint/ban-ts-comment': [
'error',
{
'ts-ignore': 'allow-with-description',
},
],
// Rules disabled during TS migration
'@typescript-eslint/no-var-requires': 'off',
'prefer-const': 'warn',
'prefer-spread': 'off',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-require-imports': 'off',
'import/no-default-export': 'warn',
},
},
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
parser: tsParser,
ecmaVersion: 2018,
sourceType: 'module',
parserOptions: {
projectService: true,
ecmaFeatures: {
jsx: true,
},
// typescript-eslint specific options
warnOnUnsupportedTypeScriptVersion: true,
},
},
// If adding a typescript-eslint version of an existing ESLint rule,
// make sure to disable the ESLint rule here.
rules: {
// TypeScript's `noFallthroughCasesInSwitch` option is more robust (#6906)
'default-case': 'off',
// 'tsc' already handles this (https://github.com/typescript-eslint/typescript-eslint/issues/291)
'no-dupe-class-members': 'off',
// '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',
'@typescript-eslint/no-array-constructor': 'warn',
'no-redeclare': 'off',
'@typescript-eslint/no-redeclare': 'warn',
'no-use-before-define': 'off',
'@typescript-eslint/no-use-before-define': [
'warn',
{
functions: false,
classes: false,
variables: false,
typedefs: false,
},
],
'no-unused-expressions': 'off',
'@typescript-eslint/no-unused-expressions': [
'error',
{
allowShortCircuit: true,
allowTernary: true,
allowTaggedTemplates: true,
},
],
'no-useless-constructor': 'off',
'@typescript-eslint/no-useless-constructor': 'warn',
},
},
{
files: ['packages/desktop-client/**/*.{js,ts,jsx,tsx}'],
plugins: {
'typescript-paths': pluginTypescriptPaths,
},
rules: {
'typescript-paths/absolute-parent-import': [
'error',
{ preferPathOverBaseUrl: true },
],
'typescript-paths/absolute-import': ['error', { enableAlias: false }],
},
},
{
files: [
'packages/desktop-client/**/*.{ts,tsx}',
'packages/loot-core/src/client/**/*.{ts,tsx}',
],
rules: {
// enforce import type
'@typescript-eslint/consistent-type-imports': [
'warn',
{
prefer: 'type-imports',
fixStyle: 'inline-type-imports',
},
],
'@typescript-eslint/no-restricted-types': [
'warn',
{
types: {
// forbid FC as superflous
FunctionComponent: {
message:
'Type the props argument and let TS infer or use ComponentType for a component prop',
},
FC: {
message:
'Type the props argument and let TS infer or use ComponentType for a component prop',
},
},
},
],
},
},
{
files: [
'packages/loot-core/src/types/**/*',
'packages/loot-core/src/client/state-types/**/*',
'**/icons/**/*',
'**/{mocks,__mocks__}/**/*',
// can't correctly resolve usages
'**/*.{testing,electron,browser,web,api}.ts',
],
rules: {
'import/no-unused-modules': 'off',
},
},
{
files: ['packages/api/migrations/*', 'packages/loot-core/migrations/*'],
rules: {
'import/no-default-export': 'off',
},
},
{
files: ['packages/api/index.ts'],
rules: {
'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
files: [
'packages/desktop-client/src/components/accounts/Account.jsx',
'packages/desktop-client/src/components/accounts/MobileAccount.jsx',
'packages/desktop-client/src/components/accounts/MobileAccounts.jsx',
'packages/desktop-client/src/components/budget/BudgetCategories.jsx',
'packages/desktop-client/src/components/budget/BudgetSummaries.tsx',
'packages/desktop-client/src/components/budget/DynamicBudgetTable.tsx',
'packages/desktop-client/src/components/budget/index.tsx',
'packages/desktop-client/src/components/budget/MobileBudget.tsx',
'packages/desktop-client/src/components/budget/envelope/HoldMenu.tsx',
'packages/desktop-client/src/components/budget/envelope/TransferMenu.tsx',
'packages/component-library/src/Menu.tsx',
'packages/desktop-client/src/components/FinancesApp.tsx',
'packages/desktop-client/src/components/GlobalKeys.ts',
'packages/desktop-client/src/components/LoggedInUser.tsx',
'packages/desktop-client/src/components/manager/ManagementApp.jsx',
'packages/desktop-client/src/components/manager/subscribe/common.tsx',
'packages/desktop-client/src/components/ManageRules.tsx',
'packages/desktop-client/src/components/mobile/MobileAmountInput.jsx',
'packages/desktop-client/src/components/mobile/MobileNavTabs.tsx',
'packages/desktop-client/src/components/Modals.tsx',
'packages/desktop-client/src/components/modals/EditRule.jsx',
'packages/desktop-client/src/components/modals/ImportTransactions.jsx',
'packages/desktop-client/src/components/modals/MergeUnusedPayees.jsx',
'packages/desktop-client/src/components/Notifications.tsx',
'packages/desktop-client/src/components/payees/ManagePayees.jsx',
'packages/desktop-client/src/components/payees/ManagePayeesWithData.jsx',
'packages/desktop-client/src/components/payees/PayeeTable.tsx',
'packages/desktop-client/src/components/reports/graphs/tableGraph/ReportTable.tsx',
'packages/desktop-client/src/components/reports/graphs/tableGraph/ReportTableTotals.tsx',
'packages/desktop-client/src/components/reports/reports/CashFlowCard.jsx',
'packages/desktop-client/src/components/reports/reports/CustomReport.jsx',
'packages/desktop-client/src/components/reports/reports/NetWorthCard.jsx',
'packages/desktop-client/src/components/reports/SaveReportName.tsx',
'packages/desktop-client/src/components/reports/useReport.ts',
'packages/desktop-client/src/components/schedules/ScheduleDetails.jsx',
'packages/desktop-client/src/components/schedules/SchedulesTable.tsx',
'packages/desktop-client/src/components/select/DateSelect.tsx',
'packages/desktop-client/src/components/sidebar/Tools.tsx',
'packages/desktop-client/src/components/sort.tsx',
],
rules: {
'react-hooks/exhaustive-deps': 'off',
},
},
{
files: [
'eslint.config.mjs',
'**/*.test.js',
'**/*.test.ts',
'**/*.test.jsx',
'**/*.test.tsx',
'**/*.spec.js',
],
rules: {
'actual/typography': 'off',
'actual/no-untranslated-strings': 'off',
'actual/prefer-logger-over-console': 'off',
},
},
{
files: [
'packages/desktop-client/**/*.{ts,tsx}',
'packages/loot-core/src/client/**/*.{ts,tsx}',
],
ignores: ['**/**/globals.d.ts'],
rules: {
// enforce type over interface
'@typescript-eslint/consistent-type-definitions': ['warn', 'type'],
},
},
{
files: ['packages/sync-server/**/*'],
// TODO: fix the issues in these files
rules: {
'import/extensions': 'off',
'actual/typography': 'off',
},
},
{
files: ['packages/sync-server/src/app-gocardless/banks/*.js'],
rules: {
'import/no-anonymous-default-export': 'off',
'import/no-default-export': 'off',
},
},
);

View File

@@ -19,87 +19,55 @@
},
"scripts": {
"start": "yarn start:browser",
"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",
"playwright": "yarn workspace @actual-app/web run playwright",
"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",
"install:server": "yarn workspaces focus @actual-app/sync-server --production",
"typecheck": "yarn tsc --incremental && tsc-strict",
"jq": "./node_modules/node-jq/bin/jq",
"prepare": "husky"
"lint": "eslint . --max-warnings 0 --ext .js,.jsx,.ts,.tsx",
"lint:verbose": "DEBUG=eslint:cli-engine eslint . --max-warnings 0",
"typecheck": "yarn tsc && tsc-strict",
"jq": "./node_modules/node-jq/bin/jq"
},
"devDependencies": {
"@octokit/rest": "^22.0.0",
"@types/node": "^22.17.0",
"@types/prompts": "^2.4.9",
"@typescript-eslint/parser": "^8.42.0",
"cross-env": "^7.0.3",
"eslint": "^9.34.0",
"eslint-config-prettier": "^10.1.5",
"eslint-import-resolver-typescript": "^4.3.5",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^6.0.0-rc.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",
"eslint": "^8.37.0",
"eslint-config-prettier": "^9.1.0",
"eslint-config-react-app": "7.0.1",
"eslint-import-resolver-typescript": "3.5.5",
"eslint-plugin-import": "2.27.5",
"eslint-plugin-prettier": "5.1.3",
"eslint-plugin-react": "7.32.2",
"eslint-plugin-rulesdir": "^0.2.2",
"node-jq": "^4.0.1",
"npm-run-all": "^4.1.5",
"p-limit": "^6.2.0",
"prettier": "^3.5.3",
"prompts": "^2.4.2",
"prettier": "3.2.4",
"source-map-support": "^0.5.21",
"ts-node": "^10.9.2",
"typescript": "^5.9.2",
"typescript-eslint": "^8.42.0",
"typescript-strict-plugin": "^2.4.4"
"typescript": "^5.0.2",
"typescript-strict-plugin": "^2.2.2-beta.2"
},
"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"
]
},
"packageManager": "yarn@4.9.1",
"packageManager": "yarn@4.0.2",
"browserslist": [
"electron >= 35.0",
"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

@@ -1,7 +1,8 @@
import type {
RequestInfo as FetchInfo,
RequestInit as FetchInit,
} from 'node-fetch';
// @ts-ignore: false-positive commonjs module error on build until typescript 5.3
} from 'node-fetch'; // with { 'resolution-mode': 'import' };
// loot-core types
import type { InitConfig } from 'loot-core/server/main';
@@ -15,6 +16,9 @@ import { validateNodeVersion } from './validateNodeVersion';
let actualApp: null | typeof bundle.lib;
export const internal = bundle.lib;
// DEPRECATED: remove the next line in @actual-app/api v7
export * as methods from './methods';
export * from './methods';
export * as utils from './utils';
@@ -42,11 +46,7 @@ export async function init(config: InitConfig = {}) {
export async function shutdown() {
if (actualApp) {
try {
await actualApp.send('sync');
} catch (e) {
// most likely that no budget is loaded, so the sync failed
}
await actualApp.send('sync');
await actualApp.send('close-budget');
actualApp = null;
}

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 });
@@ -80,22 +81,28 @@ describe('API CRUD operations', () => {
expect(groups).toEqual(
expect.arrayContaining([
expect.objectContaining({
hidden: false,
hidden: 0,
id: 'fc3825fd-b982-4b72-b768-5b30844cf832',
is_income: false,
is_income: 0,
name: 'Usual Expenses',
sort_order: 16384,
tombstone: 0,
}),
expect.objectContaining({
hidden: false,
hidden: 0,
id: 'a137772f-cf2f-4089-9432-822d2ddc1466',
is_income: false,
is_income: 0,
name: 'Investments and Savings',
sort_order: 32768,
tombstone: 0,
}),
expect.objectContaining({
hidden: false,
hidden: 0,
id: '2E1F5BDB-209B-43F9-AF2C-3CE28E380C00',
is_income: true,
is_income: 1,
name: 'Income',
sort_order: 32768,
tombstone: 0,
}),
]),
);
@@ -556,10 +563,10 @@ describe('API CRUD operations', () => {
);
// delete rules
await api.deleteRule(rules[1].id);
await api.deleteRule(rules[1]);
expect(await api.getRules()).toHaveLength(1);
await api.deleteRule(rules[0].id);
await api.deleteRule(rules[0]);
expect(await api.getRules()).toHaveLength(0);
});
@@ -568,20 +575,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 +604,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,179 +660,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();
});
});
//apis: createSchedule, getSchedules, updateSchedule, deleteSchedule
test('Schedules: successfully complete schedules operations', async () => {
await api.loadBudget(budgetName);
//test a schedule with a recuring configuration
const ScheduleId1 = await api.createSchedule({
name: 'test-schedule 1',
posts_transaction: true,
// amount: -5000,
amountOp: 'is',
date: {
frequency: 'monthly',
interval: 1,
start: '2025-06-13',
patterns: [],
skipWeekend: false,
weekendSolveMode: 'after',
endMode: 'never',
},
});
//test the creation of non recurring schedule
const ScheduleId2 = await api.createSchedule({
name: 'test-schedule 2',
posts_transaction: false,
amount: 4000,
amountOp: 'is',
date: '2025-06-13',
});
let schedules = await api.getSchedules();
// Schedules successfully created
expect(schedules).toEqual(
expect.arrayContaining([
expect.objectContaining({
name: 'test-schedule 1',
posts_transaction: true,
// amount: -5000,
amountOp: 'is',
date: {
frequency: 'monthly',
interval: 1,
start: '2025-06-13',
patterns: [],
skipWeekend: false,
weekendSolveMode: 'after',
endMode: 'never',
},
}),
expect.objectContaining({
name: 'test-schedule 2',
posts_transaction: false,
amount: 4000,
amountOp: 'is',
date: '2025-06-13',
}),
]),
);
//check getIDByName works on schedules
expect(await api.getIDByName('schedules', 'test-schedule 1')).toEqual(
ScheduleId1,
);
expect(await api.getIDByName('schedules', 'test-schedule 2')).toEqual(
ScheduleId2,
);
//check getIDByName works on accounts
const schedAccountId1 = await api.createAccount(
{ name: 'sched-test-account1', offbudget: true },
1000,
);
expect(await api.getIDByName('accounts', 'sched-test-account1')).toEqual(
schedAccountId1,
);
//check getIDByName works on payees
const schedPayeeId1 = await api.createPayee({ name: 'sched-test-payee1' });
expect(await api.getIDByName('payees', 'sched-test-payee1')).toEqual(
schedPayeeId1,
);
await api.updateSchedule(ScheduleId1, {
amount: -10000,
account: schedAccountId1,
});
await api.deleteSchedule(ScheduleId2);
// schedules successfully updated, and one of them deleted
await api.updateSchedule(ScheduleId1, {
amount: -10000,
account: schedAccountId1,
payee: schedPayeeId1,
});
await api.deleteSchedule(ScheduleId2);
schedules = await api.getSchedules();
expect(schedules).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: ScheduleId1,
posts_transaction: true,
amount: -10000,
account: schedAccountId1,
payee: schedPayeeId1,
amountOp: 'is',
date: {
frequency: 'monthly',
interval: 1,
start: '2025-06-13',
patterns: [],
skipWeekend: false,
weekendSolveMode: 'after',
endMode: 'never',
},
}),
expect.not.objectContaining({ id: ScheduleId2 }),
]),
);
});

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 type { Handlers } from 'loot-core/src/types/handlers';
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');
}
@@ -94,25 +85,8 @@ export function addTransactions(
});
}
export interface ImportTransactionsOpts {
defaultCleared?: boolean;
dryRun?: boolean;
}
export function importTransactions(
accountId: string,
transactions: ImportTransactionEntity[],
opts: ImportTransactionsOpts = {
defaultCleared: true,
dryRun: false,
},
) {
return send('api/transactions-import', {
accountId,
transactions,
isPreview: opts.dryRun,
opts,
});
export function importTransactions(accountId, transactions) {
return send('api/transactions-import', { accountId, transactions });
}
export function getTransactions(accountId, startDate, endDate) {
@@ -191,10 +165,6 @@ export function deleteCategory(id, transferCategoryId?) {
return send('api/category-delete', { id, transferCategoryId });
}
export function getCommonPayees() {
return send('api/common-payees-get');
}
export function getPayees() {
return send('api/payees-get');
}
@@ -231,42 +201,6 @@ export function updateRule(rule) {
return send('api/rule-update', { rule });
}
export function deleteRule(id: string) {
return send('api/rule-delete', id);
}
export function holdBudgetForNextMonth(month, amount) {
return send('api/budget-hold-for-next-month', { month, amount });
}
export function resetBudgetHold(month) {
return send('api/budget-reset-hold', { month });
}
export function createSchedule(schedule) {
return send('api/schedule-create', schedule);
}
export function updateSchedule(id, fields, resetNextDate?: boolean) {
return send('api/schedule-update', {
id,
fields,
resetNextDate,
});
}
export function deleteSchedule(scheduleId) {
return send('api/schedule-delete', scheduleId);
}
export function getSchedules() {
return send('api/schedules-get');
}
export function getIDByName(type, name) {
return send('api/get-id-by-name', { type, name });
}
export function getServerVersion() {
return send('api/get-server-version');
export function deleteRule(id) {
return send('api/rule-delete', { id });
}

View File

@@ -1,10 +1,10 @@
{
"name": "@actual-app/api",
"version": "25.10.0",
"version": "6.8.2",
"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": "^12.2.0",
"compare-versions": "^6.1.1",
"better-sqlite3": "^9.6.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.9.2",
"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.0.2"
}
}

View File

@@ -5,15 +5,15 @@
// the latest Node 16.x release supports all of the features
"target": "ES2021",
"module": "CommonJS",
"moduleResolution": "node10",
"noEmit": false,
"declaration": true,
"outDir": "dist",
"declarationDir": "@types",
"paths": {
"loot-core/*": ["./@types/loot-core/src/*"]
"loot-core/src/*": ["./loot-core/*"],
"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

@@ -1,73 +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.
import fs from 'node:fs';
import { parseArgs } from 'node:util';
// eslint-disable-next-line import/extensions
import { getNextVersion } from '../src/versions/get-next-package-version.js';
const args = process.argv;
const options = {
'package-json': {
type: 'string',
short: 'p',
},
type: {
type: 'string', // nightly, hotfix, monthly, auto
short: 't',
},
update: {
type: 'boolean',
short: 'u',
default: false,
},
};
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'];
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
const currentVersion = packageJson.version;
let newVersion;
try {
newVersion = getNextVersion({
currentVersion,
type: values.type,
currentDate: new Date(),
});
} catch (e) {
console.error(e.message);
process.exit(1);
}
process.stdout.write(newVersion);
if (values.update) {
packageJson.version = newVersion;
fs.writeFileSync(
packageJsonPath,
JSON.stringify(packageJson, null, 2) + '\n',
'utf8',
);
}
} catch (error) {
console.error('Error:', error.message);
process.exit(1);
}

View File

@@ -1,11 +0,0 @@
{
"name": "@actual-app/ci-actions",
"private": true,
"type": "module",
"devDependencies": {
"vitest": "^3.2.4"
},
"scripts": {
"test": "vitest"
}
}

View File

@@ -1,72 +0,0 @@
function parseVersion(version) {
const [y, m, p] = version.split('.');
return {
versionYear: parseInt(y, 10),
versionMonth: parseInt(m, 10),
versionHotfix: parseInt(p, 10),
};
}
function computeNextMonth(versionYear, versionMonth) {
// Create date and add 1 month
const versionDate = new Date(2000 + versionYear, versionMonth - 1, 1); // month is 0-indexed
const nextVersionMonthDate = new Date(
versionDate.getFullYear(),
versionDate.getMonth() + 1,
1,
);
// Format back to YY.M format
const fullYear = nextVersionMonthDate.getFullYear();
const nextVersionYear = fullYear.toString().slice(fullYear < 2100 ? -2 : -3);
const nextVersionMonth = nextVersionMonthDate.getMonth() + 1; // Convert back to 1-indexed
return { nextVersionYear, nextVersionMonth };
}
// Determine logical type from 'auto' based on the current date and version
function resolveType(type, currentDate, versionYear, versionMonth) {
if (type !== 'auto') return type;
const inPatchMonth =
currentDate.getFullYear() === 2000 + versionYear &&
currentDate.getMonth() + 1 === versionMonth;
if (inPatchMonth && currentDate.getDate() <= 25) return 'hotfix';
return 'monthly';
}
export function getNextVersion({
currentVersion,
type,
currentDate = new Date(),
}) {
const { versionYear, versionMonth, versionHotfix } =
parseVersion(currentVersion);
const { nextVersionYear, nextVersionMonth } = computeNextMonth(
versionYear,
versionMonth,
);
const resolvedType = resolveType(
type,
currentDate,
versionYear,
versionMonth,
);
// Format date stamp once for nightly
const currentDateString = currentDate
.toISOString()
.split('T')[0]
.replaceAll('-', '');
switch (resolvedType) {
case 'nightly':
return `${nextVersionYear}.${nextVersionMonth}.0-nightly.${currentDateString}`;
case 'hotfix':
return `${versionYear}.${versionMonth}.${versionHotfix + 1}`;
case 'monthly':
return `${nextVersionYear}.${nextVersionMonth}.0`;
default:
throw new Error(
'Invalid type specified. Use “auto”, “nightly”, “hotfix”, or “monthly”.',
);
}
}

View File

@@ -1,85 +0,0 @@
import { describe, it, expect } from 'vitest';
import { getNextVersion } from './get-next-package-version';
describe('getNextVersion (lib)', () => {
it('hotfix increments patch', () => {
expect(
getNextVersion({
currentVersion: '25.8.1',
type: 'hotfix',
currentDate: new Date('2025-08-10'),
}),
).toBe('25.8.2');
});
it('monthly advances month same year', () => {
expect(
getNextVersion({
currentVersion: '25.8.3',
type: 'monthly',
currentDate: new Date('2025-08-15'),
}),
).toBe('25.9.0');
});
it('monthly wraps year December -> January', () => {
expect(
getNextVersion({
currentVersion: '25.12.3',
type: 'monthly',
currentDate: new Date('2025-12-05'),
}),
).toBe('26.1.0');
});
it('nightly format with date stamp', () => {
expect(
getNextVersion({
currentVersion: '25.8.1',
type: 'nightly',
currentDate: new Date('2025-08-22'),
}),
).toBe('25.9.0-nightly.20250822');
});
it('auto before 25th -> hotfix', () => {
expect(
getNextVersion({
currentVersion: '25.8.4',
type: 'auto',
currentDate: new Date('2025-08-20'),
}),
).toBe('25.8.5');
});
it('auto after 25th (same month) -> monthly', () => {
expect(
getNextVersion({
currentVersion: '25.8.4',
type: 'auto',
currentDate: new Date('2025-08-27'),
}),
).toBe('25.9.0');
});
it('auto after 25th (next month) -> monthly', () => {
expect(
getNextVersion({
currentVersion: '25.8.4',
type: 'auto',
currentDate: new Date('2025-09-02'),
}),
).toBe('25.9.0');
});
it('invalid type throws', () => {
expect(() =>
getNextVersion({
currentVersion: '25.8.4',
type: 'unknown',
currentDate: new Date('2025-08-10'),
}),
).toThrow(/Invalid type/);
});
});

View File

@@ -1,9 +0,0 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
include: ['src/**/*.test.(js|jsx|ts|tsx)'],
environment: 'node',
},
});

View File

@@ -1,59 +0,0 @@
{
"name": "@actual-app/components",
"version": "0.0.1",
"license": "MIT",
"peerDependencies": {
"react": ">=18.2",
"react-dom": ">=18.2"
},
"dependencies": {
"@emotion/css": "^11.13.5",
"react-aria-components": "^1.8.0",
"usehooks-ts": "^3.1.1"
},
"devDependencies": {
"@svgr/cli": "^8.1.0",
"@types/react": "^19.1.12",
"react": "19.1.1",
"react-dom": "19.1.1",
"vitest": "^3.2.4"
},
"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",
"./aligned-text": "./src/AlignedText.tsx",
"./block": "./src/Block.tsx",
"./button": "./src/Button.tsx",
"./card": "./src/Card.tsx",
"./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",
"./text": "./src/Text.tsx",
"./text-one-line": "./src/TextOneLine.tsx",
"./theme": "./src/theme.ts",
"./tokens": "./src/tokens.ts",
"./toggle": "./src/Toggle.tsx",
"./tooltip": "./src/Tooltip.tsx",
"./view": "./src/View.tsx",
"./color-picker": "./src/ColorPicker.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

@@ -1,180 +0,0 @@
import { ChangeEvent, ReactNode } from 'react';
import {
ColorPicker as AriaColorPicker,
ColorPickerProps as AriaColorPickerProps,
Dialog,
DialogTrigger,
ColorSwatch as AriaColorSwatch,
ColorSwatchProps,
ColorSwatchPicker as AriaColorSwatchPicker,
ColorSwatchPickerItem,
ColorField,
parseColor,
} from 'react-aria-components';
import { css } from '@emotion/css';
import { Input } from './Input';
import { Popover } from './Popover';
function ColorSwatch(props: ColorSwatchProps) {
return (
<AriaColorSwatch
{...props}
style={({ color }) => ({
background: color.toString('hex'),
width: '32px',
height: '32px',
borderRadius: '4px',
boxShadow: 'inset 0 0 0 1px rgba(0, 0, 0, 0.1)',
})}
/>
);
}
// colors from https://materialui.co/colors
const DEFAULT_COLOR_SET = [
'#690CB0',
'#D32F2F',
'#C2185B',
'#7B1FA2',
'#512DA8',
'#303F9F',
'#1976D2',
'#0288D1',
'#0097A7',
'#00796B',
'#388E3C',
'#689F38',
'#AFB42B',
'#FBC02D',
'#FFA000',
'#F57C00',
'#E64A19',
'#5D4037',
'#616161',
'#455A64',
];
interface ColorSwatchPickerProps {
columns?: number;
colorset?: string[];
}
function ColorSwatchPicker({
columns = 5,
colorset = DEFAULT_COLOR_SET,
}: ColorSwatchPickerProps) {
const pickers = [];
for (let l = 0; l < colorset.length / columns; l++) {
const pickerItems = [];
for (let c = 0; c < columns; c++) {
const color = colorset[columns * l + c];
if (!color) {
break;
}
pickerItems.push(
<ColorSwatchPickerItem
key={color}
color={color}
className={css({
position: 'relative',
outline: 'none',
borderRadius: '4px',
width: 'fit-content',
forcedColorAdjust: 'none',
cursor: 'pointer',
'&[data-selected]::after': {
// eslint-disable-next-line actual/typography
content: '""',
position: 'absolute',
inset: 0,
border: '2px solid black',
outline: '2px solid white',
outlineOffset: '-4px',
borderRadius: 'inherit',
},
})}
>
<ColorSwatch />
</ColorSwatchPickerItem>,
);
}
pickers.push(
<AriaColorSwatchPicker
key={`colorset-${l}`}
style={{
display: 'flex',
gap: '8px',
flexWrap: 'wrap',
}}
>
{pickerItems}
</AriaColorSwatchPicker>,
);
}
return pickers;
}
const isColor = (value: string) => /^#[0-9a-fA-F]{6}$/.test(value);
interface ColorPickerProps extends AriaColorPickerProps {
children?: ReactNode;
columns?: number;
colorset?: string[];
}
export function ColorPicker({
children,
columns,
colorset,
...props
}: ColorPickerProps) {
const onInput = (value: string) => {
if (!isColor(value)) {
return;
}
const color = parseColor(value);
if (color) {
props.onChange?.(color);
}
};
return (
<AriaColorPicker defaultValue={props.defaultValue ?? '#690CB0'} {...props}>
<DialogTrigger>
{children}
<Popover>
<Dialog
style={{
outline: 'none',
padding: '15px',
display: 'flex',
flexDirection: 'column',
gap: '8px',
minWidth: '192px',
maxHeight: 'inherit',
boxSizing: 'border-box',
overflow: 'auto',
}}
>
<ColorSwatchPicker columns={columns} colorset={colorset} />
<ColorField
onInput={({ target: { value } }: ChangeEvent<HTMLInputElement>) =>
onInput(value)
}
>
<Input placeholder="#RRGGBB" style={{ width: '100px' }} />
</ColorField>
</Dialog>
</Popover>
</DialogTrigger>
</AriaColorPicker>
);
}

View File

@@ -1,59 +0,0 @@
import {
Children,
cloneElement,
isValidElement,
type ReactElement,
Ref,
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);
};
/**
* 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);
useEffect(() => {
if (ref.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);
}
}
}, 0);
}
}, []);
if (typeof children === 'function') {
return children(ref);
}
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.',
);
}

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

@@ -1,57 +0,0 @@
import { type ComponentProps, useCallback, useEffect, useRef } from 'react';
import { Popover as ReactAriaPopover } from 'react-aria-components';
import { css } from '@emotion/css';
import { styles } from './styles';
type PopoverProps = ComponentProps<typeof ReactAriaPopover>;
export const Popover = ({
style = {},
shouldCloseOnInteractOutside,
...props
}: PopoverProps) => {
const ref = useRef<HTMLElement>(null);
const handleFocus = useCallback(
(e: FocusEvent) => {
if (!ref.current?.contains(e.relatedTarget as Node)) {
props.onOpenChange?.(false);
}
},
[props],
);
useEffect(() => {
if (!props.isNonModal) return;
if (props.isOpen) {
ref.current?.addEventListener('focusout', handleFocus);
} else {
ref.current?.removeEventListener('focusout', handleFocus);
}
}, [handleFocus, props.isNonModal, props.isOpen]);
return (
<ReactAriaPopover
ref={ref}
placement="bottom end"
offset={1}
className={css({
...styles.tooltip,
...styles.lightScrollbar,
padding: 0,
userSelect: 'none',
...style,
})}
shouldCloseOnInteractOutside={element => {
if (shouldCloseOnInteractOutside) {
return shouldCloseOnInteractOutside(element);
}
return true;
}}
{...props}
/>
);
};

View File

@@ -1,32 +0,0 @@
import React, { type ReactNode } from 'react';
import { type CSSProperties } from './styles';
import { View } from './View';
type SpaceBetweenProps = {
direction?: 'horizontal' | 'vertical';
gap?: number;
style?: CSSProperties;
children: ReactNode;
};
export const SpaceBetween = ({
direction = 'horizontal',
gap = 15,
style,
children,
}: SpaceBetweenProps) => {
return (
<View
style={{
flexWrap: 'wrap',
flexDirection: direction === 'horizontal' ? 'row' : 'column',
alignItems: 'center',
gap,
...style,
}}
>
{children}
</View>
);
};

View File

@@ -1,88 +0,0 @@
import React, { type CSSProperties } from 'react';
import { css } from '@emotion/css';
import { theme } from './theme';
import { View } from './View';
type ToggleProps = {
id: string;
isOn: boolean;
isDisabled?: boolean;
onToggle?: (isOn: boolean) => void;
className?: string;
style?: CSSProperties;
};
export const Toggle = ({
id,
isOn,
isDisabled = false,
onToggle,
className,
style,
}: ToggleProps) => {
return (
<View style={style} className={className}>
<input
id={id}
checked={isOn}
disabled={isDisabled}
onChange={e => onToggle?.(e.target.checked)}
className={css({
height: 0,
width: 0,
visibility: 'hidden',
})}
type="checkbox"
/>
<label
data-toggle-container
data-on={isOn}
className={String(
css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
cursor: 'pointer',
width: '32px',
height: '16px',
borderRadius: '100px',
position: 'relative',
transition: 'background-color .2s',
backgroundColor: isOn
? theme.checkboxToggleBackgroundSelected
: theme.checkboxToggleBackground,
}),
)}
htmlFor={id}
>
<span
data-toggle
data-on={isOn}
className={css(
{
// eslint-disable-next-line actual/typography
content: '" "',
position: 'absolute',
top: '2px',
left: '2px',
width: '12px',
height: '12px',
borderRadius: '100px',
transition: '0.2s',
boxShadow: '0 0 2px 0 rgba(10, 10, 10, 0.29)',
backgroundColor: isDisabled
? theme.checkboxToggleDisabled
: '#fff',
},
isOn && {
left: 'calc(100% - 2px)',
transform: 'translateX(-100%)',
},
)}
/>
</label>
</View>
);
};

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,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 24.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 17 56.7" style="enable-background:new 0 0 17 56.7;" xml:space="preserve">
<path d="M1.9,2.2L1.9,2.2c1.3,0.2,2.4,0.7,3.4,1.5c0.8,0.8,1.5,1.8,2,2.9c0.9,2.3,3.1,8.4,3.1,21.8S8.2,47.8,7.3,50.1
c-0.5,1.1-1.2,2.1-2,2.9c-1,0.8-2.1,1.3-3.3,1.5H1.9c-0.4,0.1-0.6,0.4-0.6,0.8c0.1,0.3,0.3,0.6,0.6,0.6c1.6,0.2,3.2-0.1,4.7-0.8
c1.4-0.8,2.7-1.9,3.6-3.2c1.7-2.6,2.9-5.4,3.6-8.4l0.1-0.3c2.5-9.7,2.5-19.8,0-29.5l-0.1-0.3c-0.7-3-1.9-5.8-3.6-8.4
C9.3,3.5,8,2.4,6.6,1.6C5.1,0.9,3.5,0.7,1.9,0.9c-0.4,0-0.7,0.4-0.6,0.7C1.3,1.9,1.5,2.2,1.9,2.2L1.9,2.2z"/>
</svg>

Before

Width:  |  Height:  |  Size: 817 B

View File

@@ -1,22 +0,0 @@
import * as React from 'react';
import type { SVGProps } from 'react';
export const SvgCloseParenthesis = (props: SVGProps<SVGSVGElement>) => (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 17 56.7"
preserveAspectRatio="none"
style={{
color: 'inherit',
...props.style,
}}
>
<path
d="M1.9,2.2L1.9,2.2c1.3,0.2,2.4,0.7,3.4,1.5c0.8,0.8,1.5,1.8,2,2.9c0.9,2.3,3.1,8.4,3.1,21.8S8.2,47.8,7.3,50.1
c-0.5,1.1-1.2,2.1-2,2.9c-1,0.8-2.1,1.3-3.3,1.5H1.9c-0.4,0.1-0.6,0.4-0.6,0.8c0.1,0.3,0.3,0.6,0.6,0.6c1.6,0.2,3.2-0.1,4.7-0.8
c1.4-0.8,2.7-1.9,3.6-3.2c1.7-2.6,2.9-5.4,3.6-8.4l0.1-0.3c2.5-9.7,2.5-19.8,0-29.5l-0.1-0.3c-0.7-3-1.9-5.8-3.6-8.4
C9.3,3.5,8,2.4,6.6,1.6C5.1,0.9,3.5,0.7,1.9,0.9c-0.4,0-0.7,0.4-0.6,0.7C1.3,1.9,1.5,2.2,1.9,2.2L1.9,2.2z"
fill="currentColor"
/>
</svg>
);

View File

@@ -1,19 +0,0 @@
import * as React from 'react';
import type { SVGProps } from 'react';
export const SvgHelp = (props: SVGProps<SVGSVGElement>) => (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
style={{
color: 'inherit',
...props.style,
}}
>
<path
fill="currentColor"
d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zM7.92 9.234v.102a.5.5 0 0 0 .5.5h.997a.499.499 0 0 0 .499-.499c0-1.29.998-1.979 2.34-1.979 1.308 0 2.168.689 2.168 1.67 0 .928-.482 1.359-1.686 1.91l-.344.154C11.379 11.54 11 12.21 11 13.381v.119a.5.5 0 0 0 .5.5h.997a.499.499 0 0 0 .499-.499c0-.516.138-.723.55-.912l.345-.155c1.445-.654 2.529-1.514 2.529-3.39v-.103c0-1.978-1.72-3.441-4.164-3.441-2.478 0-4.336 1.428-4.336 3.734zm2.58 7.757c0 .867.659 1.509 1.491 1.509.85 0 1.509-.642 1.509-1.509 0-.867-.659-1.491-1.509-1.491-.832 0-1.491.624-1.491 1.491z"
/>
</svg>
);

View File

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 24.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 17 56.7" style="enable-background:new 0 0 17 56.7;" xml:space="preserve">
<path d="M15.1,54.5L15.1,54.5c-1.3-0.2-2.4-0.7-3.4-1.5c-0.8-0.8-1.5-1.8-2-2.9c-0.9-2.3-3.1-8.4-3.1-21.8S8.8,8.9,9.8,6.6
c0.5-1.1,1.2-2.1,2-2.9c1-0.8,2.1-1.3,3.3-1.5h0.1c0.4-0.1,0.6-0.4,0.6-0.8c-0.1-0.3-0.3-0.6-0.6-0.6c-1.6-0.2-3.2,0.1-4.7,0.8
C9,2.4,7.8,3.5,6.8,4.8c-1.7,2.6-2.9,5.4-3.6,8.4l-0.1,0.3c-2.5,9.7-2.5,19.8,0,29.5l0.1,0.3c0.7,3,1.9,5.8,3.6,8.4
c0.9,1.3,2.2,2.4,3.6,3.2c1.5,0.7,3.1,0.9,4.7,0.8c0.4,0,0.7-0.4,0.6-0.7C15.7,54.8,15.5,54.5,15.1,54.5L15.1,54.5z"/>
</svg>

Before

Width:  |  Height:  |  Size: 829 B

View File

@@ -1,22 +0,0 @@
import * as React from 'react';
import type { SVGProps } from 'react';
export const SvgOpenParenthesis = (props: SVGProps<SVGSVGElement>) => (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 17 56.7"
preserveAspectRatio="none"
style={{
color: 'inherit',
...props.style,
}}
>
<path
d="M15.1,54.5L15.1,54.5c-1.3-0.2-2.4-0.7-3.4-1.5c-0.8-0.8-1.5-1.8-2-2.9c-0.9-2.3-3.1-8.4-3.1-21.8S8.8,8.9,9.8,6.6
c0.5-1.1,1.2-2.1,2-2.9c1-0.8,2.1-1.3,3.3-1.5h0.1c0.4-0.1,0.6-0.4,0.6-0.8c-0.1-0.3-0.3-0.6-0.6-0.6c-1.6-0.2-3.2,0.1-4.7,0.8
C9,2.4,7.8,3.5,6.8,4.8c-1.7,2.6-2.9,5.4-3.6,8.4l-0.1,0.3c-2.5,9.7-2.5,19.8,0,29.5l0.1,0.3c0.7,3,1.9,5.8,3.6,8.4
c0.9,1.3,2.2,2.4,3.6,3.2c1.5,0.7,3.1,0.9,4.7,0.8c0.4,0,0.7-0.4,0.6-0.7C15.7,54.8,15.5,54.5,15.1,54.5L15.1,54.5z"
fill="currentColor"
/>
</svg>
);

View File

@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 24.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" baseProfile="basic" id="Layer_1"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 28.3 28.3"
xml:space="preserve">
<g>
<path d="M23.2,10.1c1.1,0,2-0.9,2-2V2.2c0-1.1-0.9-2-2-2h-18c-1.1,0-2,0.9-2,2c0,0.4,0.1,0.9,0.4,1.2l8.1,10.8L3.6,25
c-0.7,0.9-0.5,2.1,0.4,2.8c0.3,0.3,0.8,0.4,1.2,0.4h18c1.1,0,2-0.9,2-2v-5.8c0-1.1-0.9-2-2-2s-2,0.9-2,2v3.8h-12l6.6-8.8
c0.5-0.7,0.5-1.7,0-2.4L9.2,4.2h12v3.9C21.2,9.2,22.1,10.1,23.2,10.1z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 654 B

View File

@@ -1,20 +0,0 @@
import * as React from 'react';
import type { SVGProps } from 'react';
export const SvgSum = (props: SVGProps<SVGSVGElement>) => (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 28.3 28.3"
style={{
color: 'inherit',
...props.style,
}}
>
<path
d="M23.2,10.1c1.1,0,2-0.9,2-2V2.2c0-1.1-0.9-2-2-2h-18c-1.1,0-2,0.9-2,2c0,0.4,0.1,0.9,0.4,1.2l8.1,10.8L3.6,25
c-0.7,0.9-0.5,2.1,0.4,2.8c0.3,0.3,0.8,0.4,1.2,0.4h18c1.1,0,2-0.9,2-2v-5.8c0-1.1-0.9-2-2-2s-2,0.9-2,2v3.8h-12l6.6-8.8
c0.5-0.7,0.5-1.7,0-2.4L9.2,4.2h12v3.9C21.2,9.2,22.1,10.1,23.2,10.1z"
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,9 +0,0 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="SVGRepo_bgCarrier" stroke-width="0"></g>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g>
<g id="SVGRepo_iconCarrier">
<path
d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10zM7.92 9.234v.102a.5.5 0 0 0 .5.5h.997a.499.499 0 0 0 .499-.499c0-1.29.998-1.979 2.34-1.979 1.308 0 2.168.689 2.168 1.67 0 .928-.482 1.359-1.686 1.91l-.344.154C11.379 11.54 11 12.21 11 13.381v.119a.5.5 0 0 0 .5.5h.997a.499.499 0 0 0 .499-.499c0-.516.138-.723.55-.912l.345-.155c1.445-.654 2.529-1.514 2.529-3.39v-.103c0-1.978-1.72-3.441-4.164-3.441-2.478 0-4.336 1.428-4.336 3.734zm2.58 7.757c0 .867.659 1.509 1.491 1.509.85 0 1.509-.642 1.509-1.509 0-.867-.659-1.491-1.509-1.491-.832 0-1.491.624-1.491 1.491z"
fill="inherit"></path>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 905 B

View File

@@ -1,163 +0,0 @@
import { keyframes } from '@emotion/css';
import { theme } from './theme';
import { tokens } from './tokens';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type CSSProperties = Record<string, any>;
const MOBILE_MIN_HEIGHT = 40;
const shadowLarge = {
boxShadow: '0 15px 30px 0 rgba(0,0,0,0.11), 0 5px 15px 0 rgba(0,0,0,0.08)',
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const styles: Record<string, any> = {
incomeHeaderHeight: 70,
cardShadow: '0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24)',
monthRightPadding: 5,
menuBorderRadius: 4,
mobileMinHeight: MOBILE_MIN_HEIGHT,
mobileMenuItem: {
fontSize: 17,
fontWeight: 400,
paddingTop: 8,
paddingBottom: 8,
height: MOBILE_MIN_HEIGHT,
minHeight: MOBILE_MIN_HEIGHT,
},
mobileEditingPadding: 12,
altMenuMaxHeight: 250,
altMenuText: {
fontSize: 13,
},
altMenuHeaderText: {
fontSize: 13,
fontWeight: 700,
},
veryLargeText: {
fontSize: 30,
fontWeight: 600,
},
largeText: {
fontSize: 20,
fontWeight: 700,
letterSpacing: 0.5,
},
mediumText: {
fontSize: 15,
fontWeight: 500,
},
smallText: {
fontSize: 13,
},
verySmallText: {
fontSize: 12,
},
tinyText: {
fontSize: 10,
},
page: {
flex: 1,
'@media (max-height: 550px)': {
minHeight: 700, // ensure we can scroll on small screens
},
paddingTop: 8, // height of the titlebar
[`@media (min-width: ${tokens.breakpoint_small})`]: {
paddingTop: 36,
},
},
pageContent: {
paddingLeft: 2,
paddingRight: 2,
[`@media (min-width: ${tokens.breakpoint_small})`]: {
paddingLeft: 20,
paddingRight: 20,
},
},
settingsPageContent: {
padding: 20,
[`@media (min-width: ${tokens.breakpoint_small})`]: {
padding: 'inherit',
},
},
staticText: {
cursor: 'default',
userSelect: 'none',
},
shadow: {
boxShadow: '0 2px 4px 0 rgba(0,0,0,0.1)',
},
shadowLarge,
tnum: {
// eslint-disable-next-line actual/typography
fontFeatureSettings: '"tnum"',
},
notFixed: { fontFeatureSettings: '' },
text: {
fontSize: 16,
// lineHeight: 22.4 // TODO: This seems like trouble, but what's the right value?
},
delayedFadeIn: {
animationName: keyframes({
'0%': { opacity: 0 },
'100%': { opacity: 1 },
}),
animationDuration: '1s',
animationFillMode: 'both',
animationDelay: '0.5s',
},
underlinedText: {
borderBottom: `2px solid`,
},
noTapHighlight: {
WebkitTapHighlightColor: 'transparent',
':focus': {
outline: 'none',
},
},
lineClamp: (lines: number) => {
return {
display: '-webkit-box',
WebkitLineClamp: lines,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
textOverflow: 'ellipsis',
wordBreak: 'break-word',
};
},
tooltip: {
padding: 5,
...shadowLarge,
borderWidth: 2,
borderRadius: 4,
borderStyle: 'solid',
borderColor: theme.tooltipBorder,
backgroundColor: theme.tooltipBackground,
color: theme.tooltipText,
overflow: 'auto',
},
popover: {
border: 'none',
backgroundColor: theme.menuBackground,
color: theme.menuItemText,
},
// Dynamically set
horizontalScrollbar: null as CSSProperties | null,
lightScrollbar: null as CSSProperties | null,
darkScrollbar: null as CSSProperties | null,
scrollbarWidth: null as number | null,
editorPill: {
color: theme.pillText,
backgroundColor: theme.pillBackground,
borderRadius: 4,
padding: '3px 5px',
},
mobileListItem: {
borderBottom: `1px solid ${theme.tableBorder}`,
backgroundColor: theme.tableBackground,
padding: 16,
cursor: 'pointer',
},
};

View File

@@ -1,204 +0,0 @@
export const theme = {
pageBackground: 'var(--color-pageBackground)',
pageBackgroundModalActive: 'var(--color-pageBackgroundModalActive)',
pageBackgroundTopLeft: 'var(--color-pageBackgroundTopLeft)',
pageBackgroundBottomRight: 'var(--color-pageBackgroundBottomRight)',
pageBackgroundLineTop: 'var(--color-pageBackgroundLineTop)',
pageBackgroundLineMid: 'var(--color-pageBackgroundLineMid)',
pageBackgroundLineBottom: 'var(--color-pageBackgroundLineBottom)',
pageText: 'var(--color-pageText)',
pageTextLight: 'var(--color-pageTextLight)',
pageTextSubdued: 'var(--color-pageTextSubdued)',
pageTextDark: 'var(--color-pageTextDark)',
pageTextPositive: 'var(--color-pageTextPositive)',
pageTextLink: 'var(--color-pageTextLink)',
pageTextLinkLight: 'var(--color-pageTextLinkLight)',
cardBackground: 'var(--color-cardBackground)',
cardBorder: 'var(--color-cardBorder)',
cardShadow: 'var(--color-cardShadow)',
tableBackground: 'var(--color-tableBackground)',
tableRowBackgroundHover: 'var(--color-tableRowBackgroundHover)',
tableText: 'var(--color-tableText)',
tableTextLight: 'var(--color-tableTextLight)',
tableTextSubdued: 'var(--color-tableTextSubdued)',
tableTextSelected: 'var(--color-tableTextSelected)',
tableTextHover: 'var(--color-tableTextHover)',
tableTextInactive: 'var(--color-tableTextInactive)',
tableHeaderText: 'var(--color-tableHeaderText)',
tableHeaderBackground: 'var(--color-tableHeaderBackground)',
tableBorder: 'var(--color-tableBorder)',
tableBorderSelected: 'var(--color-tableBorderSelected)',
tableBorderHover: 'var(--color-tableBorderHover)',
tableBorderSeparator: 'var(--color-tableBorderSeparator)',
tableRowBackgroundHighlight: 'var(--color-tableRowBackgroundHighlight)',
tableRowBackgroundHighlightText:
'var(--color-tableRowBackgroundHighlightText)',
tableRowHeaderBackground: 'var(--color-tableRowHeaderBackground)',
tableRowHeaderText: 'var(--color-tableRowHeaderText)',
sidebarBackground: 'var(--color-sidebarBackground)',
sidebarItemBackgroundPending: 'var(--color-sidebarItemBackgroundPending)',
sidebarItemBackgroundPositive: 'var(--color-sidebarItemBackgroundPositive)',
sidebarItemBackgroundFailed: 'var(--color-sidebarItemBackgroundFailed)',
sidebarItemAccentSelected: 'var(--color-sidebarItemAccentSelected)',
sidebarItemBackgroundHover: 'var(--color-sidebarItemBackgroundHover)',
sidebarItemText: 'var(--color-sidebarItemText)',
sidebarItemTextSelected: 'var(--color-sidebarItemTextSelected)',
menuBackground: 'var(--color-menuBackground)',
menuItemBackground: 'var(--color-menuItemBackground)',
menuItemBackgroundHover: 'var(--color-menuItemBackgroundHover)',
menuItemText: 'var(--color-menuItemText)',
menuItemTextHover: 'var(--color-menuItemTextHover)',
menuItemTextSelected: 'var(--color-menuItemTextSelected)',
menuItemTextHeader: 'var(--color-menuItemTextHeader)',
menuBorder: 'var(--color-menuBorder)',
menuBorderHover: 'var(--color-menuBorderHover)',
menuKeybindingText: 'var(--color-menuKeybindingText)',
menuAutoCompleteBackground: 'var(--color-menuAutoCompleteBackground)',
menuAutoCompleteBackgroundHover:
'var(--color-menuAutoCompleteBackgroundHover)',
menuAutoCompleteText: 'var(--color-menuAutoCompleteText)',
menuAutoCompleteTextHover: 'var(--color-menuAutoCompleteTextHover)',
menuAutoCompleteTextHeader: 'var(--color-menuAutoCompleteTextHeader)',
menuAutoCompleteItemTextHover: 'var(--color-menuAutoCompleteItemTextHover)',
menuAutoCompleteItemText: 'var(--color-menuAutoCompleteItemText)',
modalBackground: 'var(--color-modalBackground)',
modalBorder: 'var(--color-modalBorder)',
mobileHeaderBackground: 'var(--color-mobileHeaderBackground)',
mobileHeaderText: 'var(--color-mobileHeaderText)',
mobileHeaderTextSubdued: 'var(--color-mobileHeaderTextSubdued)',
mobileHeaderTextHover: 'var(--color-mobileHeaderTextHover)',
mobilePageBackground: 'var(--color-mobilePageBackground)',
mobileNavBackground: 'var(--color-mobileNavBackground)',
mobileNavItem: 'var(--color-mobileNavItem)',
mobileNavItemSelected: 'var(--color-mobileNavItemSelected)',
mobileAccountShadow: 'var(--color-mobileAccountShadow)',
mobileAccountText: 'var(--color-mobileAccountText)',
mobileTransactionSelected: 'var(--color-mobileTransactionSelected)',
mobileViewTheme: 'var(--color-mobileViewTheme)',
mobileConfigServerViewTheme: 'var(--color-mobileConfigServerViewTheme)',
markdownNormal: 'var(--color-markdownNormal)',
markdownDark: 'var(--color-markdownDark)',
markdownLight: 'var(--color-markdownLight)',
buttonMenuText: 'var(--color-buttonMenuText)',
buttonMenuTextHover: 'var(--color-buttonMenuTextHover)',
buttonMenuBackground: 'var(--color-buttonMenuBackground)',
buttonMenuBackgroundHover: 'var(--color-buttonMenuBackgroundHover)',
buttonMenuBorder: 'var(--color-buttonMenuBorder)',
buttonMenuSelectedText: 'var(--color-buttonMenuSelectedText)',
buttonMenuSelectedTextHover: 'var(--color-buttonMenuSelectedTextHover)',
buttonMenuSelectedBackground: 'var(--color-buttonMenuSelectedBackground)',
buttonMenuSelectedBackgroundHover:
'var(--color-buttonMenuSelectedBackgroundHover)',
buttonMenuSelectedBorder: 'var(--color-buttonMenuSelectedBorder)',
buttonPrimaryText: 'var(--color-buttonPrimaryText)',
buttonPrimaryTextHover: 'var(--color-buttonPrimaryTextHover)',
buttonPrimaryBackground: 'var(--color-buttonPrimaryBackground)',
buttonPrimaryBackgroundHover: 'var(--color-buttonPrimaryBackgroundHover)',
buttonPrimaryBorder: 'var(--color-buttonPrimaryBorder)',
buttonPrimaryShadow: 'var(--color-buttonPrimaryShadow)',
buttonPrimaryDisabledText: 'var(--color-buttonPrimaryDisabledText)',
buttonPrimaryDisabledBackground:
'var(--color-buttonPrimaryDisabledBackground)',
buttonPrimaryDisabledBorder: 'var(--color-buttonPrimaryDisabledBorder)',
buttonNormalText: 'var(--color-buttonNormalText)',
buttonNormalTextHover: 'var(--color-buttonNormalTextHover)',
buttonNormalBackground: 'var(--color-buttonNormalBackground)',
buttonNormalBackgroundHover: 'var(--color-buttonNormalBackgroundHover)',
buttonNormalBorder: 'var(--color-buttonNormalBorder)',
buttonNormalShadow: 'var(--color-buttonNormalShadow)',
buttonNormalSelectedText: 'var(--color-buttonNormalSelectedText)',
buttonNormalSelectedBackground: 'var(--color-buttonNormalSelectedBackground)',
buttonNormalDisabledText: 'var(--color-buttonNormalDisabledText)',
buttonNormalDisabledBackground: 'var(--color-buttonNormalDisabledBackground)',
buttonNormalDisabledBorder: 'var(--color-buttonNormalDisabledBorder)',
buttonBareText: 'var(--color-buttonBareText)',
buttonBareTextHover: 'var(--color-buttonBareTextHover)',
buttonBareBackground: 'var(--color-buttonBareBackground)',
buttonBareBackgroundHover: 'var(--color-buttonBareBackgroundHover)',
buttonBareBackgroundActive: 'var(--color-buttonBareBackgroundActive)',
buttonBareDisabledText: 'var(--color-buttonBareDisabledText)',
buttonBareDisabledBackground: 'var(--color-buttonBareDisabledBackground)',
calendarText: 'var(--color-calendarText)',
calendarBackground: 'var(--color-calendarBackground)',
calendarItemText: 'var(--color-calendarItemText)',
calendarItemBackground: 'var(--color-calendarItemBackground)',
calendarSelectedBackground: 'var(--color-calendarSelectedBackground)',
noticeBackground: 'var(--color-noticeBackground)',
noticeBackgroundLight: 'var(--color-noticeBackgroundLight)',
noticeBackgroundDark: 'var(--color-noticeBackgroundDark)',
noticeText: 'var(--color-noticeText)',
noticeTextLight: 'var(--color-noticeTextLight)',
noticeTextDark: 'var(--color-noticeTextDark)',
noticeTextMenu: 'var(--color-noticeTextMenu)',
noticeTextMenuHover: 'var(--color-noticeTextMenuHover)',
noticeBorder: 'var(--color-noticeBorder)',
warningBackground: 'var(--color-warningBackground)',
warningText: 'var(--color-warningText)',
warningTextLight: 'var(--color-warningTextLight)',
warningTextDark: 'var(--color-warningTextDark)',
warningBorder: 'var(--color-warningBorder)',
errorBackground: 'var(--color-errorBackground)',
errorText: 'var(--color-errorText)',
errorTextDark: 'var(--color-errorTextDark)',
errorTextDarker: 'var(--color-errorTextDarker)',
errorTextMenu: 'var(--color-errorTextMenu)',
errorBorder: 'var(--color-errorBorder)',
upcomingBackground: 'var(--color-upcomingBackground)',
upcomingText: 'var(--color-upcomingText)',
upcomingBorder: 'var(--color-upcomingBorder)',
formLabelText: 'var(--color-formLabelText)',
formLabelBackground: 'var(--color-formLabelBackground)',
formInputBackground: 'var(--color-formInputBackground)',
formInputBackgroundSelected: 'var(--color-formInputBackgroundSelected)',
formInputBackgroundSelection: 'var(--color-formInputBackgroundSelection)',
formInputBorder: 'var(--color-formInputBorder)',
formInputTextReadOnlySelection: 'var(--color-formInputTextReadOnlySelection)',
formInputBorderSelected: 'var(--color-formInputBorderSelected)',
formInputText: 'var(--color-formInputText)',
formInputTextSelected: 'var(--color-formInputTextSelected)',
formInputTextPlaceholder: 'var(--color-formInputTextPlaceholder)',
formInputTextPlaceholderSelected:
'var(--color-formInputTextPlaceholderSelected)',
formInputTextSelection: 'var(--color-formInputTextSelection)',
formInputShadowSelected: 'var(--color-formInputShadowSelected)',
formInputTextHighlight: 'var(--color-formInputTextHighlight)',
checkboxText: 'var(--color-checkboxText)',
checkboxBackgroundSelected: 'var(--color-checkboxBackgroundSelected)',
checkboxBorderSelected: 'var(--color-checkboxBorderSelected)',
checkboxShadowSelected: 'var(--color-checkboxShadowSelected)',
checkboxToggleBackground: 'var(--color-checkboxToggleBackground)',
checkboxToggleBackgroundSelected:
'var(--color-checkboxToggleBackgroundSelected)',
checkboxToggleDisabled: 'var(--color-checkboxToggleDisabled)',
pillBackground: 'var(--color-pillBackground)',
pillBackgroundLight: 'var(--color-pillBackgroundLight)',
pillText: 'var(--color-pillText)',
pillTextHighlighted: 'var(--color-pillTextHighlighted)',
pillBorder: 'var(--color-pillBorder)',
pillBorderDark: 'var(--color-pillBorderDark)',
pillBackgroundSelected: 'var(--color-pillBackgroundSelected)',
pillTextSelected: 'var(--color-pillTextSelected)',
pillBorderSelected: 'var(--color-pillBorderSelected)',
pillTextSubdued: 'var(--color-pillTextSubdued)',
reportsRed: 'var(--color-reportsRed)',
reportsBlue: 'var(--color-reportsBlue)',
reportsGreen: 'var(--color-reportsGreen)',
reportsGray: 'var(--color-reportsGray)',
reportsLabel: 'var(--color-reportsLabel)',
reportsInnerLabel: 'var(--color-reportsInnerLabel)',
noteTagBackground: 'var(--color-noteTagBackground)',
noteTagBackgroundHover: 'var(--color-noteTagBackgroundHover)',
noteTagDefault: 'var(--color-noteTagDefault)',
noteTagText: 'var(--color-noteTagText)',
budgetOtherMonth: 'var(--color-budgetOtherMonth)',
budgetCurrentMonth: 'var(--color-budgetCurrentMonth)',
budgetHeaderOtherMonth: 'var(--color-budgetHeaderOtherMonth)',
budgetHeaderCurrentMonth: 'var(--color-budgetHeaderCurrentMonth)',
floatingActionBarBackground: 'var(--color-floatingActionBarBackground)',
floatingActionBarBorder: 'var(--color-floatingActionBarBorder)',
floatingActionBarText: 'var(--color-floatingActionBarText)',
tooltipText: 'var(--color-tooltipText)',
tooltipBackground: 'var(--color-tooltipBackground)',
tooltipBorder: 'var(--color-tooltipBorder)',
calendarCellBackground: 'var(--color-calendarCellBackground)',
};

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

@@ -18,9 +18,4 @@ protoc --plugin="protoc-gen-ts=../../node_modules/.bin/protoc-gen-ts" \
../../node_modules/.bin/prettier --write src/proto/*.d.ts
for file in src/proto/*.d.ts; do
{ echo "/* eslint-disable @typescript-eslint/no-namespace */"; sed 's/export class/export declare class/g' "$file"; } > "${file%.d.ts}.ts"
rm "$file"
done
echo 'One more step! Find the `var global = ...` declaration in src/proto/sync_pb.js and change it to `var global = globalThis;`'

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

@@ -11,19 +11,21 @@
"scripts": {
"build:node": "tsc --p tsconfig.dist.json",
"proto:generate": "./bin/generate-proto",
"build": "rm -rf dist && yarn run build:node",
"test": "vitest --globals"
"build": "rm -rf dist && yarn run build:node && cp src/proto/sync_pb.d.ts dist/src/proto/",
"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": {
"@types/google-protobuf": "^3.15.12",
"protoc-gen-js": "^3.21.4-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",
"ts-protoc-gen": "^0.15.0",
"typescript": "^5.9.2",
"vitest": "^3.2.4"
"typescript": "^5.0.2"
}
}

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

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import * as merkle from './merkle';
import { Timestamp } from './timestamp';

View File

@@ -134,7 +134,6 @@ export function diff(trie1: TrieNode, trie2: TrieNode): number | null {
node2 = node2[diffkey] || emptyTrie();
}
// eslint-disable-next-line no-unreachable
return null;
}

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { Timestamp } from './timestamp';
describe('Timestamp', function () {

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