Compare commits

..

10 Commits

Author SHA1 Message Date
Matt Fiddaman
6b770c233e note 2026-02-26 23:00:29 +00:00
Matt Fiddaman
6ca5c84e1f Merge branch 'master' into dependabot/npm_and_yarn/ajv-6.14.0 2026-02-26 22:42:44 +00:00
Copilot
e65429497d [AI] Remove 'suspect ai generated' label and associated workflow (#7087)
* Initial plan

* [AI] Remove 'suspect ai generated' label and associated workflow

Co-authored-by: MatissJanis <886567+MatissJanis@users.noreply.github.com>

* [AI] Remove 'suspect ai generated' label from coderabbit config

Co-authored-by: MatissJanis <886567+MatissJanis@users.noreply.github.com>

* Add release notes for PR #7087

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: MatissJanis <886567+MatissJanis@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-02-26 20:51:47 +00:00
Matiss Janis Aboltins
3758d72b65 Mobile rules item alignment (#7081)
* [AI] Fix mobile rules list items to be full width and left-aligned

- Override Button's default justifyContent/alignItems centering in
  ActionableGridListItem to use flex-start alignment
- Add width: 100% to RulesListItem's SpaceBetween to fill the item width

Co-authored-by: Matiss Janis Aboltins <MatissJanis@users.noreply.github.com>

* Add release notes for PR #7081

* Change category from Enhancements to Bugfix

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Matiss Janis Aboltins <MatissJanis@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-02-26 20:22:29 +00:00
Matiss Janis Aboltins
032d10ac42 [AI] Fix API build output path (dist/index.js instead of dist/api/index.js) (#7084)
* [AI] Fix API build output path (dist/index.js instead of dist/api/index.js)

- Set rootDir in packages/api/tsconfig.json so output is under dist/ not dist/api/
- Remove loot-core pegjs.ts from include; add local typings/pegjs.d.ts
- Use mkdir -p in build:migrations for idempotent build
- Exclude **/@types/** so declaration output does not conflict with input

Made-with: Cursor

* Update TypeScript configuration in api package to refine exclude patterns
2026-02-26 20:20:38 +00:00
Michael Clark
f97a89dc28 🐛 Fix file path on windows (#7076)
* fix file path on windows

* file path in migrations

* release notes
2026-02-25 15:00:10 +00:00
Juulz
a4bd301ec6 🐞 Midnight theme: Change menuAutoCompleteTextHover color - Fixes #7029 (#7048)
* Change menuAutoCompleteTextHover color to green400

* Change menuAutoCompleteTextHover color to green400 in Midnight theme.

Change menuAutoCompleteTextHover color to green400 in Midnight theme.

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-02-24 17:29:09 +00:00
Julian Dominguez-Schatz
18072e1d8b Validate file IDs for correctness (#7067)
* Validate file IDs for correctness

* Add release notes
2026-02-24 15:32:50 +00:00
Juulz
a1e0b3f45d Rename theme 'Okabe Ito' to 'Color-blind (dark)' (#7058)
* Rename theme 'Okabe Ito' to 'Color-blind (dark)'

* Rename 'Okabe Ito' theme to 'Color-blind (dark)'

* Fix capitalization in theme name for consistency
2026-02-23 18:49:45 +00:00
dependabot[bot]
8bfa9cbab1 Bump ajv from 6.12.6 to 6.14.0
Bumps [ajv](https://github.com/ajv-validator/ajv) from 6.12.6 to 6.14.0.
- [Release notes](https://github.com/ajv-validator/ajv/releases)
- [Commits](https://github.com/ajv-validator/ajv/compare/v6.12.6...v6.14.0)

---
updated-dependencies:
- dependency-name: ajv
  dependency-version: 6.14.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-21 21:36:18 +00:00
46 changed files with 405 additions and 612 deletions

View File

@@ -13,8 +13,6 @@ reviews:
mode: off
enabled: false
labeling_instructions:
- label: 'suspect ai generated'
instructions: 'This issue or PR is suspected to be generated by AI. Add this only if "AI generated" label is not present. Add it always if the commit or PR title is prefixed with "[AI]".'
- label: 'API'
instructions: 'This issue or PR updates the API in `packages/api`.'
- label: 'documentation'

View File

@@ -1,25 +0,0 @@
name: Remove 'suspect ai generated' label when 'AI generated' is present
on:
pull_request_target:
types: [labeled]
permissions:
pull-requests: write
jobs:
remove-suspect-label:
if: >-
${{ contains(github.event.pull_request.labels.*.name, 'AI generated') &&
contains(github.event.pull_request.labels.*.name, 'suspect ai generated') }}
runs-on: ubuntu-slim
steps:
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with:
script: |
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
name: 'suspect ai generated'
});

View File

@@ -13,7 +13,7 @@
"build:app": "yarn workspace loot-core build:api",
"build:crdt": "yarn workspace @actual-app/crdt build",
"build:node": "tsc && tsc-alias",
"build:migrations": "mkdir dist/migrations && cp migrations/*.sql dist/migrations",
"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 clean && yarn run build:app && yarn run build:crdt && vitest --run",

View File

@@ -9,12 +9,13 @@
"noEmit": false,
"declaration": true,
"outDir": "dist",
"rootDir": ".",
"declarationDir": "@types",
"paths": {
"loot-core/*": ["./@types/loot-core/src/*"]
},
"plugins": [{ "name": "typescript-strict-plugin", "paths": ["."] }]
},
"include": [".", "../../packages/loot-core/typings/pegjs.ts"],
"include": ["."],
"exclude": ["**/node_modules/*", "dist", "@types", "*.test.ts"]
}

1
packages/api/typings/pegjs.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare module '*.pegjs';

View File

@@ -6,7 +6,7 @@ import { getUploadError } from 'loot-core/shared/errors';
import type { AtLeastOne } from 'loot-core/types/util';
import { pushModal } from '@desktop-client/modals/modalsSlice';
import { prefQueries } from '@desktop-client/prefs';
import { loadPrefs } from '@desktop-client/prefs/prefsSlice';
import { createAppAsyncThunk } from '@desktop-client/redux';
import { getIsOutdated, getLatestVersion } from '@desktop-client/util/versions';
@@ -89,8 +89,8 @@ export const resetSync = createAppAsyncThunk(
export const sync = createAppAsyncThunk(
`${sliceName}/sync`,
async (_, { extra: { queryClient } }) => {
const prefs = await queryClient.ensureQueryData(prefQueries.listMetadata());
async (_, { dispatch, getState }) => {
const prefs = getState().prefs.local;
if (prefs && prefs.id) {
const result = await send('sync');
if (result && 'error' in result) {
@@ -98,9 +98,7 @@ export const sync = createAppAsyncThunk(
}
// Update the prefs
queryClient.invalidateQueries({
queryKey: prefQueries.lists(),
});
await dispatch(loadPrefs());
}
return {};
@@ -109,10 +107,8 @@ export const sync = createAppAsyncThunk(
export const getLatestAppVersion = createAppAsyncThunk(
`${sliceName}/getLatestAppVersion`,
async (_, { dispatch, extra: { queryClient } }) => {
const globalPrefs = await queryClient.ensureQueryData(
prefQueries.listGlobal(),
);
async (_, { dispatch, getState }) => {
const globalPrefs = getState().prefs.global;
if (globalPrefs && globalPrefs.notifyWhenUpdateIsAvailable) {
const theLatestVersion = await getLatestVersion();
dispatch(

View File

@@ -219,8 +219,8 @@ global.Actual = {
return worker;
},
setTheme: async theme => {
await window.__actionsForMenu.setTheme(theme);
setTheme: theme => {
window.__actionsForMenu.saveGlobalPrefs({ prefs: { theme } });
},
moveBudgetDirectory: () => {

View File

@@ -1,6 +1,5 @@
import { createSlice } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
import { type QueryClient } from '@tanstack/react-query';
import { t } from 'i18next';
import { send } from 'loot-core/platform/client/connection';
@@ -12,7 +11,7 @@ import type { Handlers } from 'loot-core/types/handlers';
import { resetApp, setAppState } from '@desktop-client/app/appSlice';
import { closeModal, pushModal } from '@desktop-client/modals/modalsSlice';
import { prefQueries } from '@desktop-client/prefs';
import { loadGlobalPrefs, loadPrefs } from '@desktop-client/prefs/prefsSlice';
import { createAppAsyncThunk } from '@desktop-client/redux';
import { signOut } from '@desktop-client/users/usersSlice';
@@ -56,7 +55,7 @@ type LoadBudgetPayload = {
export const loadBudget = createAppAsyncThunk(
`${sliceName}/loadBudget`,
async ({ id, options = {} }: LoadBudgetPayload, { dispatch, extra }) => {
async ({ id, options = {} }: LoadBudgetPayload, { dispatch }) => {
dispatch(setAppState({ loadingText: t('Loading...') }));
// Loading a budget may fail
@@ -90,43 +89,23 @@ export const loadBudget = createAppAsyncThunk(
}
} else {
dispatch(closeModal());
extra.queryClient.invalidateQueries({
queryKey: prefQueries.lists(),
});
await dispatch(loadPrefs());
}
dispatch(setAppState({ loadingText: null }));
},
);
function invalidateClosedBudgetQueries(queryClient: QueryClient) {
// Invalidate all queries but do not cause a refetch.
// This is because we want to clear out all the budget data from the queries,
// but we don't want to trigger a bunch of error states from the queries trying
// to fetch data for a budget that is now closed. The next time a budget is loaded,
// the queries will refetch with the correct budget id.
queryClient.invalidateQueries({
refetchType: 'none',
});
// Invalidate the metadata query since the budget is now closed.
// We want to cause a refetch so that the app can update to show the correct state
// (e.g. show the manager page if no budget is open).
queryClient.invalidateQueries({
queryKey: prefQueries.listMetadata().queryKey,
});
}
export const closeBudget = createAppAsyncThunk(
`${sliceName}/closeBudget`,
async (_, { dispatch, extra: { queryClient } }) => {
const prefs = await queryClient.ensureQueryData(prefQueries.listMetadata());
async (_, { dispatch, getState, extra: { queryClient } }) => {
const prefs = getState().prefs.local;
if (prefs && prefs.id) {
dispatch(resetApp());
queryClient.clear();
dispatch(setAppState({ loadingText: t('Closing...') }));
await send('close-budget');
dispatch(setAppState({ loadingText: null }));
invalidateClosedBudgetQueries(queryClient);
if (localStorage.getItem('SharedArrayBufferOverride')) {
window.location.reload();
}
@@ -136,11 +115,11 @@ export const closeBudget = createAppAsyncThunk(
export const closeBudgetUI = createAppAsyncThunk(
`${sliceName}/closeBudgetUI`,
async (_, { dispatch, extra: { queryClient } }) => {
const prefs = await queryClient.ensureQueryData(prefQueries.listMetadata());
async (_, { dispatch, getState, extra: { queryClient } }) => {
const prefs = getState().prefs.local;
if (prefs && prefs.id) {
dispatch(resetApp());
invalidateClosedBudgetQueries(queryClient);
queryClient.clear();
}
},
);
@@ -167,7 +146,7 @@ export const createBudget = createAppAsyncThunk(
`${sliceName}/createBudget`,
async (
{ testMode = false, demoMode = false }: CreateBudgetPayload,
{ dispatch, extra: { queryClient } },
{ dispatch },
) => {
dispatch(
setAppState({
@@ -185,9 +164,7 @@ export const createBudget = createAppAsyncThunk(
dispatch(closeModal());
await dispatch(loadAllFiles());
queryClient.invalidateQueries({
queryKey: prefQueries.lists(),
});
await dispatch(loadPrefs());
// Set the loadingText to null after we've loaded the budget prefs
// so that the existing manager page doesn't flash
@@ -266,19 +243,14 @@ type ImportBudgetPayload = {
export const importBudget = createAppAsyncThunk(
`${sliceName}/importBudget`,
async (
{ filepath, type }: ImportBudgetPayload,
{ dispatch, extra: { queryClient } },
) => {
async ({ filepath, type }: ImportBudgetPayload, { dispatch }) => {
const { error } = await send('import-budget', { filepath, type });
if (error) {
throw new Error(error);
}
dispatch(closeModal());
queryClient.invalidateQueries({
queryKey: prefQueries.lists(),
});
await dispatch(loadPrefs());
},
);
@@ -332,7 +304,7 @@ export const downloadBudget = createAppAsyncThunk(
`${sliceName}/downloadBudget`,
async (
{ cloudFileId, replace = false }: DownloadBudgetPayload,
{ dispatch, extra: { queryClient } },
{ dispatch },
): Promise<string | null> => {
dispatch(
setAppState({
@@ -396,10 +368,8 @@ export const downloadBudget = createAppAsyncThunk(
if (!id) {
throw new Error('No id returned from download.');
}
queryClient.invalidateQueries({
queryKey: prefQueries.listGlobal().queryKey,
});
await Promise.all([
dispatch(loadGlobalPrefs()),
dispatch(loadAllFiles()),
dispatch(loadBudget({ id })),
]);
@@ -417,11 +387,8 @@ type LoadBackupPayload = {
// Take in the budget id so that backups can be loaded when a budget isn't opened
export const loadBackup = createAppAsyncThunk(
`${sliceName}/loadBackup`,
async (
{ budgetId, backupId }: LoadBackupPayload,
{ dispatch, extra: { queryClient } },
) => {
const prefs = await queryClient.ensureQueryData(prefQueries.listMetadata());
async ({ budgetId, backupId }: LoadBackupPayload, { dispatch, getState }) => {
const prefs = getState().prefs.local;
if (prefs && prefs.id) {
await dispatch(closeBudget());
}
@@ -433,8 +400,8 @@ export const loadBackup = createAppAsyncThunk(
export const makeBackup = createAppAsyncThunk(
`${sliceName}/makeBackup`,
async (_, { extra: { queryClient } }) => {
const prefs = await queryClient.ensureQueryData(prefQueries.listMetadata());
async (_, { getState }) => {
const prefs = getState().prefs.local;
if (prefs && prefs.id) {
await send('backup-make', { id: prefs.id });
}

View File

@@ -38,9 +38,10 @@ import { SpreadsheetProvider } from '@desktop-client/hooks/useSpreadsheet';
import { setI18NextLanguage } from '@desktop-client/i18n';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { installPolyfills } from '@desktop-client/polyfills';
import { prefQueries } from '@desktop-client/prefs';
import { loadGlobalPrefs } from '@desktop-client/prefs/prefsSlice';
import { useDispatch, useSelector, useStore } from '@desktop-client/redux';
import {
CustomThemeStyle,
hasHiddenScrollbars,
ThemeStyle,
useTheme,
@@ -60,8 +61,6 @@ function AppInner() {
setI18NextLanguage(null);
}, []);
const queryClient = useQueryClient();
useEffect(() => {
const maybeUpdate = async <T,>(cb?: () => T): Promise<T | void> => {
if (global.Actual.isUpdateReadyForDownload()) {
@@ -92,7 +91,7 @@ function AppInner() {
loadingText: t('Loading global preferences...'),
}),
);
void queryClient.prefetchQuery(prefQueries.listGlobal());
await dispatch(loadGlobalPrefs());
// Open the last opened budget, if any
dispatch(
@@ -240,6 +239,7 @@ export function App() {
<AppInner />
</ErrorBoundary>
<ThemeStyle />
<CustomThemeStyle />
<ErrorBoundary FallbackComponent={FatalError}>
<Modals />
</ErrorBoundary>

View File

@@ -6,7 +6,7 @@ import { Navigate, Route, Routes, useHref, useLocation } from 'react-router';
import { useResponsive } from '@actual-app/components/hooks/useResponsive';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { usePrefetchQuery } from '@tanstack/react-query';
import { useQuery } from '@tanstack/react-query';
import * as undo from 'loot-core/platform/client/undo';
@@ -28,19 +28,17 @@ import { FloatableSidebar } from './sidebar';
import { ManageTagsPage } from './tags/ManageTagsPage';
import { Titlebar } from './Titlebar';
import { accountQueries } from '@desktop-client/accounts';
import { getLatestAppVersion, sync } from '@desktop-client/app/appSlice';
import { ProtectedRoute } from '@desktop-client/auth/ProtectedRoute';
import { Permissions } from '@desktop-client/auth/types';
import { useAccounts } from '@desktop-client/hooks/useAccounts';
import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref';
import { useLocalPref } from '@desktop-client/hooks/useLocalPref';
import { useMetaThemeColor } from '@desktop-client/hooks/useMetaThemeColor';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { ScrollProvider } from '@desktop-client/hooks/useScrollListener';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { prefQueries } from '@desktop-client/prefs';
import { useDispatch, useSelector } from '@desktop-client/redux';
import { CustomThemeStyle } from '@desktop-client/style';
function NarrowNotSupported({
redirectTo = '/budget',
@@ -93,7 +91,10 @@ export function FinancesApp() {
const dispatch = useDispatch();
const { t } = useTranslation();
const { data: accounts, isFetching: isAccountsFetching } = useAccounts();
// TODO: Replace with `useAccounts` hook once it's updated to return the useQuery results.
const { data: accounts, isFetching: isAccountsFetching } = useQuery(
accountQueries.list(),
);
const versionInfo = useSelector(state => state.app.versionInfo);
const [notifyWhenUpdateIsAvailable] = useGlobalPref(
@@ -197,7 +198,6 @@ export function FinancesApp() {
<RouterBehaviors />
<GlobalKeys />
<CommandBar />
<CustomThemeStyle />
<View
style={{
flexDirection: 'row',

View File

@@ -10,7 +10,6 @@ import { styles } from '@actual-app/components/styles';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { useQuery } from '@tanstack/react-query';
import { listen } from 'loot-core/platform/client/connection';
import type { RemoteFile, SyncedLocalFile } from 'loot-core/types/file';
@@ -56,6 +55,7 @@ export function LoggedInUser({
f => f.state === 'remote' || f.state === 'synced' || f.state === 'detached',
) as (SyncedLocalFile | RemoteFile)[];
const currentFile = remoteFiles.find(f => f.cloudFileId === cloudFileId);
const hasSyncedPrefs = useSelector(state => state.prefs.synced);
const initializeUserData = useCallback(async () => {
try {
@@ -235,7 +235,7 @@ export function LoggedInUser({
multiuserEnabled &&
userData &&
userData?.displayName &&
!budgetId && (
!hasSyncedPrefs && (
<small>
(
<Trans>
@@ -251,7 +251,7 @@ export function LoggedInUser({
multiuserEnabled &&
userData &&
userData?.displayName &&
budgetId && (
hasSyncedPrefs && (
<small>
(
<Trans>

View File

@@ -28,7 +28,6 @@ import {
useSaveCategoryGroupMutation,
useSaveCategoryMutation,
} from '@desktop-client/budget';
import { LoadingIndicator } from '@desktop-client/components/reports/LoadingIndicator';
import { useCategories } from '@desktop-client/hooks/useCategories';
import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref';
import { useLocalPref } from '@desktop-client/hooks/useLocalPref';
@@ -176,7 +175,7 @@ export function Budget() {
};
if (!initialized || !categoryGroups) {
return <LoadingIndicator />;
return null;
}
let table;

View File

@@ -25,7 +25,7 @@ import {
} from '@desktop-client/components/ServerContext';
import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref';
import { useNavigate } from '@desktop-client/hooks/useNavigate';
import { useSaveGlobalPrefsMutation } from '@desktop-client/prefs';
import { saveGlobalPrefs } from '@desktop-client/prefs/prefsSlice';
import { useDispatch } from '@desktop-client/redux';
import { loggedIn, signOut } from '@desktop-client/users/usersSlice';
@@ -40,6 +40,7 @@ export function ElectronServerConfig({
const navigate = useNavigate();
const setServerUrl = useSetServerURL();
const currentUrl = useServerURL();
const dispatch = useDispatch();
const [syncServerConfig, setSyncServerConfig] =
useGlobalPref('syncServerConfig');
@@ -53,7 +54,6 @@ export function ElectronServerConfig({
const hasInternalServerConfig = syncServerConfig?.port;
const [startingSyncServer, setStartingSyncServer] = useState(false);
const saveGlobalPrefs = useSaveGlobalPrefsMutation();
const onConfigureSyncServer = async () => {
if (startingSyncServer) {
@@ -73,13 +73,17 @@ export function ElectronServerConfig({
setConfigError(null);
setStartingSyncServer(true);
// Ensure config is saved before starting the server
await saveGlobalPrefs.mutateAsync({
syncServerConfig: {
...syncServerConfig,
port: electronServerPort,
autoStart: true,
},
});
await dispatch(
saveGlobalPrefs({
prefs: {
syncServerConfig: {
...syncServerConfig,
port: electronServerPort,
autoStart: true,
},
},
}),
).unwrap();
await window.globalThis.Actual.stopSyncServer();
await window.globalThis.Actual.startSyncServer();

View File

@@ -119,6 +119,8 @@ export function ActionableGridListItem<T extends object>({
padding: 16,
textAlign: 'left',
borderRadius: 0,
justifyContent: 'flex-start',
alignItems: 'flex-start',
}}
onClick={handleAction}
>

View File

@@ -52,7 +52,10 @@ export function RulesListItem({
}
{...props}
>
<SpaceBetween gap={12} style={{ alignItems: 'flex-start' }}>
<SpaceBetween
gap={12}
style={{ alignItems: 'flex-start', width: '100%' }}
>
{/* Column 1: PRE/POST pill */}
<View
style={{

View File

@@ -13,7 +13,6 @@ import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { css } from '@emotion/css';
import { useQueryClient } from '@tanstack/react-query';
import { send } from 'loot-core/platform/client/connection';
import { getCreateKeyError } from 'loot-core/shared/errors';
@@ -28,7 +27,7 @@ import {
ModalHeader,
} from '@desktop-client/components/common/Modal';
import type { Modal as ModalType } from '@desktop-client/modals/modalsSlice';
import { prefQueries } from '@desktop-client/prefs';
import { loadGlobalPrefs } from '@desktop-client/prefs/prefsSlice';
import { useDispatch } from '@desktop-client/redux';
type CreateEncryptionKeyModalProps = Extract<
@@ -46,7 +45,6 @@ export function CreateEncryptionKeyModal({
const [showPassword, setShowPassword] = useState(false);
const { isNarrowWidth } = useResponsive();
const dispatch = useDispatch();
const queryClient = useQueryClient();
const isRecreating = recreate;
@@ -62,9 +60,7 @@ export function CreateEncryptionKeyModal({
return;
}
void queryClient.invalidateQueries({
queryKey: prefQueries.listGlobal().queryKey,
});
void dispatch(loadGlobalPrefs());
void dispatch(loadAllFiles());
void dispatch(sync());

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useEffectEvent } from 'react';
import React, { useEffect } from 'react';
import type { ReactNode } from 'react';
import { Trans, useTranslation } from 'react-i18next';
@@ -11,6 +11,7 @@ import { tokens } from '@actual-app/components/tokens';
import { View } from '@actual-app/components/view';
import { css } from '@emotion/css';
import { listen } from 'loot-core/platform/client/connection';
import { isElectron } from 'loot-core/shared/environment';
import { AuthSettings } from './AuthSettings';
@@ -42,6 +43,7 @@ import { useFeatureFlag } from '@desktop-client/hooks/useFeatureFlag';
import { useGlobalPref } from '@desktop-client/hooks/useGlobalPref';
import { useMetadataPref } from '@desktop-client/hooks/useMetadataPref';
import { useSyncedPref } from '@desktop-client/hooks/useSyncedPref';
import { loadPrefs } from '@desktop-client/prefs/prefsSlice';
import { useDispatch, useSelector } from '@desktop-client/redux';
function About() {
@@ -181,17 +183,20 @@ export function Settings() {
void dispatch(closeBudget());
};
const onSetDefaultCurrencyCodePref = useEffectEvent(
(isCurrencyExperimentalEnabled: boolean) => {
if (!isCurrencyExperimentalEnabled) {
setDefaultCurrencyCodePref('');
}
},
);
useEffect(() => {
const unlisten = listen('prefs-updated', () => {
void dispatch(loadPrefs());
});
void dispatch(loadPrefs());
return () => unlisten();
}, [dispatch]);
useEffect(() => {
onSetDefaultCurrencyCodePref(isCurrencyExperimentalEnabled);
}, [isCurrencyExperimentalEnabled]);
if (!isCurrencyExperimentalEnabled) {
setDefaultCurrencyCodePref('');
}
}, [isCurrencyExperimentalEnabled, setDefaultCurrencyCodePref]);
const { isNarrowWidth } = useResponsive();

View File

@@ -30,7 +30,7 @@
"colors": ["#141520", "#242733", "#373B4A", "#E8ECF0", "#FFD700", "#8F7A20"]
},
{
"name": "Okabe Ito",
"name": "Color-blind (Dark)",
"repo": "Juulz/okabe-ito",
"colors": ["#222222", "#141520", "#e69f00", "#56b4e9", "#b88115", "#00304d"]
},

View File

@@ -14,7 +14,7 @@ import {
addNotification,
} from './notifications/notificationsSlice';
import { payeeQueries } from './payees';
import { prefQueries } from './prefs';
import { loadPrefs } from './prefs/prefsSlice';
import type { AppStore } from './redux/store';
import * as syncEvents from './sync-events';
@@ -141,9 +141,7 @@ export function handleGlobalEvents(store: AppStore, queryClient: QueryClient) {
const unlistenFinishLoad = listen('finish-load', () => {
store.dispatch(closeModal());
store.dispatch(setAppState({ loadingText: null }));
void queryClient.invalidateQueries({
queryKey: prefQueries.lists(),
});
void store.dispatch(loadPrefs());
});
const unlistenStartImport = listen('start-import', () => {
@@ -153,9 +151,7 @@ export function handleGlobalEvents(store: AppStore, queryClient: QueryClient) {
const unlistenFinishImport = listen('finish-import', () => {
store.dispatch(closeModal());
store.dispatch(setAppState({ loadingText: null }));
void queryClient.invalidateQueries({
queryKey: prefQueries.lists(),
});
void store.dispatch(loadPrefs());
});
const unlistenShowBudgets = listen('show-budgets', () => {
@@ -167,12 +163,6 @@ export function handleGlobalEvents(store: AppStore, queryClient: QueryClient) {
void window.Actual.reload();
});
const unlistenPrefsUpdated = listen('prefs-updated', () => {
void queryClient.invalidateQueries({
queryKey: prefQueries.lists(),
});
});
return () => {
unlistenServerError();
unlistenOrphanedPayees();
@@ -186,6 +176,5 @@ export function handleGlobalEvents(store: AppStore, queryClient: QueryClient) {
unlistenFinishImport();
unlistenShowBudgets();
unlistenApiFetchRedirected();
unlistenPrefsUpdated();
};
}

View File

@@ -1,8 +1,9 @@
import { useQuery } from '@tanstack/react-query';
import { useCallback } from 'react';
import type { GlobalPrefs } from 'loot-core/types/prefs';
import { prefQueries, useSaveGlobalPrefsMutation } from '@desktop-client/prefs';
import { saveGlobalPrefs } from '@desktop-client/prefs/prefsSlice';
import { useDispatch, useSelector } from '@desktop-client/redux';
type SetGlobalPrefAction<K extends keyof GlobalPrefs> = (
value: GlobalPrefs[K],
@@ -12,24 +13,23 @@ export function useGlobalPref<K extends keyof GlobalPrefs>(
prefName: K,
onSaveGlobalPrefs?: () => void,
): [GlobalPrefs[K], SetGlobalPrefAction<K>] {
const { mutate: saveGlobalPrefs } = useSaveGlobalPrefsMutation();
const saveGlobalPref: SetGlobalPrefAction<K> = value => {
saveGlobalPrefs(
{
[prefName]: value,
},
{
onSuccess: onSaveGlobalPrefs,
},
);
};
const dispatch = useDispatch();
const setGlobalPref = useCallback<SetGlobalPrefAction<K>>(
value => {
void dispatch(
saveGlobalPrefs({
prefs: {
[prefName]: value,
},
onSaveGlobalPrefs,
}),
);
},
[prefName, dispatch, onSaveGlobalPrefs],
);
const globalPref = useSelector(
state => state.prefs.global?.[prefName] as GlobalPrefs[K],
);
const { data: globalPref } = useQuery({
...prefQueries.listGlobal(),
select: prefs => prefs?.[prefName],
enabled: !!prefName,
notifyOnChangeProps: ['data'],
});
return [globalPref as GlobalPrefs[K], saveGlobalPref];
return [globalPref, setGlobalPref];
}

View File

@@ -1,11 +1,9 @@
import { useQuery } from '@tanstack/react-query';
import { useCallback } from 'react';
import type { MetadataPrefs } from 'loot-core/types/prefs';
import {
prefQueries,
useSaveMetadataPrefsMutation,
} from '@desktop-client/prefs';
import { savePrefs } from '@desktop-client/prefs/prefsSlice';
import { useDispatch, useSelector } from '@desktop-client/redux';
type SetMetadataPrefAction<K extends keyof MetadataPrefs> = (
value: MetadataPrefs[K],
@@ -14,17 +12,14 @@ type SetMetadataPrefAction<K extends keyof MetadataPrefs> = (
export function useMetadataPref<K extends keyof MetadataPrefs>(
prefName: K,
): [MetadataPrefs[K], SetMetadataPrefAction<K>] {
const { mutate: saveMetadataPrefs } = useSaveMetadataPrefsMutation();
const saveMetadataPref: SetMetadataPrefAction<K> = value => {
saveMetadataPrefs({ [prefName]: value });
};
const dispatch = useDispatch();
const setLocalPref = useCallback<SetMetadataPrefAction<K>>(
value => {
void dispatch(savePrefs({ prefs: { [prefName]: value } }));
},
[prefName, dispatch],
);
const localPref = useSelector(state => state.prefs.local?.[prefName]);
const { data: metadataPref } = useQuery({
...prefQueries.listMetadata(),
select: prefs => prefs?.[prefName],
enabled: !!prefName,
notifyOnChangeProps: ['data'],
});
return [metadataPref as MetadataPrefs[K], saveMetadataPref];
return [localPref, setLocalPref];
}

View File

@@ -1,8 +1,9 @@
import { useQuery } from '@tanstack/react-query';
import { useCallback } from 'react';
import type { SyncedPrefs } from 'loot-core/types/prefs';
import { prefQueries, useSaveSyncedPrefsMutation } from '@desktop-client/prefs';
import { saveSyncedPrefs } from '@desktop-client/prefs/prefsSlice';
import { useDispatch, useSelector } from '@desktop-client/redux';
type SetSyncedPrefAction<K extends keyof SyncedPrefs> = (
value: SyncedPrefs[K],
@@ -11,17 +12,18 @@ type SetSyncedPrefAction<K extends keyof SyncedPrefs> = (
export function useSyncedPref<K extends keyof SyncedPrefs>(
prefName: K,
): [SyncedPrefs[K], SetSyncedPrefAction<K>] {
const { mutate: saveSyncedPrefs } = useSaveSyncedPrefsMutation();
const saveSyncedPref: SetSyncedPrefAction<K> = value => {
saveSyncedPrefs({ [prefName]: value });
};
const dispatch = useDispatch();
const setPref = useCallback<SetSyncedPrefAction<K>>(
value => {
void dispatch(
saveSyncedPrefs({
prefs: { [prefName]: value },
}),
);
},
[prefName, dispatch],
);
const pref = useSelector(state => state.prefs.synced[prefName]);
const { data: syncedPref } = useQuery({
...prefQueries.listSynced(),
select: prefs => prefs?.[prefName],
enabled: !!prefName,
notifyOnChangeProps: ['data'],
});
return [syncedPref as SyncedPrefs[K], saveSyncedPref];
return [pref, setPref];
}

View File

@@ -1,15 +1,22 @@
import { useQuery } from '@tanstack/react-query';
import { useCallback } from 'react';
import type { SyncedPrefs } from 'loot-core/types/prefs';
import { prefQueries, useSaveSyncedPrefsMutation } from '@desktop-client/prefs';
import { saveSyncedPrefs } from '@desktop-client/prefs/prefsSlice';
import { useDispatch, useSelector } from '@desktop-client/redux';
type SetSyncedPrefsAction = (value: Partial<SyncedPrefs>) => void;
/** @deprecated: please use `useSyncedPref` (singular) */
export function useSyncedPrefs(): [SyncedPrefs, SetSyncedPrefsAction] {
const { mutate: saveSyncedPrefs } = useSaveSyncedPrefsMutation();
const { data: syncedPrefs } = useQuery(prefQueries.listSynced());
const dispatch = useDispatch();
const setPrefs = useCallback<SetSyncedPrefsAction>(
newValue => {
void dispatch(saveSyncedPrefs({ prefs: newValue }));
},
[dispatch],
);
const prefs = useSelector(state => state.prefs.synced);
return [syncedPrefs as SyncedPrefs, saveSyncedPrefs];
return [prefs, setPrefs];
}

View File

@@ -13,7 +13,6 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { send } from 'loot-core/platform/client/connection';
import { q } from 'loot-core/shared/query';
import type { Theme } from 'loot-core/types/prefs';
import * as accountsSlice from './accounts/accountsSlice';
import * as appSlice from './app/appSlice';
@@ -23,7 +22,6 @@ import { App } from './components/App';
import { ServerProvider } from './components/ServerContext';
import * as modalsSlice from './modals/modalsSlice';
import * as notificationsSlice from './notifications/notificationsSlice';
import { prefQueries } from './prefs';
import * as prefsSlice from './prefs/prefsSlice';
import { aqlQuery } from './queries/aqlQuery';
import { configureAppStore } from './redux/store';
@@ -70,16 +68,6 @@ function inputFocused(e: KeyboardEvent) {
);
}
async function setTheme(theme: Theme) {
// TODO: Tests and `toMatchThemeScreenshots` needs to be updated
// to change the theme via UI navigation instead of going through
// this method because this feels a bit hacky.
await send('save-global-prefs', { theme });
void queryClient.invalidateQueries({
queryKey: prefQueries.listGlobal().queryKey,
});
}
// Expose this to the main process to menu items can access it
window.__actionsForMenu = {
...boundActions,
@@ -87,7 +75,6 @@ window.__actionsForMenu = {
redo,
appFocused,
uploadFile,
setTheme,
};
// Expose send for fun!
@@ -121,7 +108,6 @@ declare global {
redo: typeof redo;
appFocused: typeof appFocused;
uploadFile: typeof uploadFile;
setTheme: typeof setTheme;
};
$send: typeof send;

View File

@@ -1,2 +0,0 @@
export * from './queries';
export * from './mutations';

View File

@@ -1,250 +0,0 @@
import { useTranslation } from 'react-i18next';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { v4 as uuidv4 } from 'uuid';
import { send } from 'loot-core/platform/client/connection';
import type {
GlobalPrefs,
MetadataPrefs,
SyncedPrefs,
} from 'loot-core/types/prefs';
import { prefQueries } from './queries';
import { addNotification } from '@desktop-client/notifications/notificationsSlice';
import { useDispatch } from '@desktop-client/redux';
import type { AppDispatch } from '@desktop-client/redux/store';
function dispatchErrorNotification(
dispatch: AppDispatch,
message: string,
error?: Error,
) {
dispatch(
addNotification({
notification: {
id: uuidv4(),
type: 'error',
message,
pre: error ? error.message : undefined,
},
}),
);
}
type SaveMetadataPrefsPayload = MetadataPrefs;
export function useSaveMetadataPrefsMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
return useMutation({
mutationFn: async (metadataPrefs: SaveMetadataPrefsPayload) => {
const existing = await queryClient.ensureQueryData(
prefQueries.listMetadata(),
);
const prefsToSave = diff(metadataPrefs, existing);
if (Object.keys(prefsToSave).length > 0) {
await send('save-prefs', prefsToSave);
}
return prefsToSave;
},
onSuccess: changedPrefs => {
if (changedPrefs && Object.keys(changedPrefs).length > 0) {
queryClient.setQueryData(
prefQueries.listMetadata().queryKey,
oldData => {
return oldData
? {
...oldData,
...changedPrefs,
}
: oldData;
},
);
// Invalidate individual pref caches in case any components are subscribed to those directly
// const queryKeys = Object.keys(changedPrefs).map(
// prefName =>
// prefQueries.detailMetadata(prefName as keyof MetadataPrefs)
// .queryKey,
// );
// queryKeys.forEach(key => invalidateQueries(queryClient, key));
}
},
onError: error => {
console.error('Error saving metadata preferences:', error);
dispatchErrorNotification(
dispatch,
t(
'There was an error saving the metadata preferences. Please try again.',
),
error,
);
throw error;
},
});
}
type SaveGlobalPrefsPayload = GlobalPrefs;
export function useSaveGlobalPrefsMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
return useMutation({
mutationFn: async (globalPrefs: SaveGlobalPrefsPayload) => {
const existing = await queryClient.ensureQueryData(
prefQueries.listGlobal(),
);
const prefsToSave = diff(globalPrefs, existing);
if (Object.keys(prefsToSave).length > 0) {
await send('save-global-prefs', prefsToSave);
}
return prefsToSave;
},
onSuccess: changedPrefs => {
if (changedPrefs && Object.keys(changedPrefs).length > 0) {
queryClient.setQueryData(prefQueries.listGlobal().queryKey, oldData => {
return oldData
? {
...oldData,
...changedPrefs,
}
: oldData;
});
// Invalidate individual pref caches in case any components are subscribed to those directly
// const queryKeys = Object.keys(changedPrefs).map(
// prefName =>
// prefQueries.detailGlobal(prefName as keyof GlobalPrefs).queryKey,
// );
// queryKeys.forEach(key => invalidateQueries(queryClient, key));
}
},
onError: error => {
console.error('Error saving global preferences:', error);
dispatchErrorNotification(
dispatch,
t(
'There was an error saving the global preferences. Please try again.',
),
error,
);
throw error;
},
});
}
type SaveSyncedPrefsPayload = SyncedPrefs;
export function useSaveSyncedPrefsMutation() {
const queryClient = useQueryClient();
const dispatch = useDispatch();
const { t } = useTranslation();
return useMutation({
mutationFn: async (syncedPrefs: SaveSyncedPrefsPayload) => {
const existing = await queryClient.ensureQueryData(
prefQueries.listSynced(),
);
const prefsToSave = diff(syncedPrefs, existing);
if (Object.keys(prefsToSave).length > 0) {
await Promise.all(
Object.entries(prefsToSave).map(([syncedPrefName, value]) =>
send('preferences/save', {
id: syncedPrefName as keyof SyncedPrefs,
value,
}),
),
);
}
return prefsToSave;
},
onSuccess: changedPrefs => {
if (changedPrefs && Object.keys(changedPrefs).length > 0) {
queryClient.setQueryData(prefQueries.listSynced().queryKey, oldData => {
return oldData
? {
...oldData,
...changedPrefs,
}
: oldData;
});
// Invalidate individual pref caches in case any components are subscribed to those directly
// const queryKeys = Object.keys(changedPrefs).map(
// prefName =>
// prefQueries.detailSynced(prefName as keyof SyncedPrefs).queryKey,
// );
// queryKeys.forEach(key => invalidateQueries(queryClient, key));
}
},
onError: error => {
console.error('Error saving synced preferences:', error);
dispatchErrorNotification(
dispatch,
t(
'There was an error saving the synced preferences. Please try again.',
),
error,
);
throw error;
},
});
}
// type SaveServerPrefsPayload = ServerPrefs;
// export function useSaveServerPrefsMutation() {
// const queryClient = useQueryClient();
// const dispatch = useDispatch();
// const { t } = useTranslation();
// return useMutation({
// mutationFn: async (serverPrefs: SaveServerPrefsPayload) => {
// const result = await send('save-server-prefs', {
// prefs: serverPrefs,
// });
// if (result && 'error' in result) {
// return { error: result.error };
// }
// },
// onSuccess: () => invalidateQueries(queryClient, prefQueries.listServer().queryKey),
// onError: error => {
// console.error('Error saving server preferences:', error);
// dispatchErrorNotification(
// dispatch,
// t(
// 'There was an error saving the server preferences. Please try again.',
// ),
// error,
// );
// throw error;
// },
// });
// }
function diff<T extends object>(incoming: T, existing?: T | null): Partial<T> {
const changed: Partial<T> = {};
for (const [key, value] of Object.entries(incoming) as Array<
[keyof T, T[keyof T]]
>) {
if (!existing || existing[key] !== value) {
(changed as Record<keyof T, T[keyof T]>)[key] = value;
}
}
return changed;
}

View File

@@ -2,21 +2,134 @@ import { createSlice } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
import { send } from 'loot-core/platform/client/connection';
import type { ServerPrefs } from 'loot-core/types/prefs';
import { parseNumberFormat, setNumberFormat } from 'loot-core/shared/util';
import type {
GlobalPrefs,
MetadataPrefs,
ServerPrefs,
SyncedPrefs,
} from 'loot-core/types/prefs';
import { resetApp } from '@desktop-client/app/appSlice';
import { setI18NextLanguage } from '@desktop-client/i18n';
import { closeModal } from '@desktop-client/modals/modalsSlice';
import { createAppAsyncThunk } from '@desktop-client/redux';
import { getUserData } from '@desktop-client/users/usersSlice';
const sliceName = 'prefs';
type PrefsState = {
local: MetadataPrefs;
global: GlobalPrefs;
synced: SyncedPrefs;
server: ServerPrefs;
};
const initialState: PrefsState = {
local: {},
global: {},
synced: {},
server: {},
};
export const loadPrefs = createAppAsyncThunk(
`${sliceName}/loadPrefs`,
async (_, { dispatch, getState }) => {
const prefs = await send('load-prefs');
// Remove any modal state if switching between budgets
const currentPrefs = getState().prefs.local;
if (prefs && prefs.id && !currentPrefs) {
dispatch(closeModal());
}
const [globalPrefs, syncedPrefs] = await Promise.all([
send('load-global-prefs'),
send('preferences/get'),
]);
dispatch(
setPrefs({ local: prefs, global: globalPrefs, synced: syncedPrefs }),
);
// Certain loot-core utils depend on state outside of the React tree, update them
setNumberFormat(
parseNumberFormat({
format: syncedPrefs.numberFormat,
hideFraction: syncedPrefs.hideFraction,
}),
);
// We need to load translations before the app renders
setI18NextLanguage(globalPrefs.language ?? '');
return prefs;
},
);
type SavePrefsPayload = {
prefs: MetadataPrefs;
};
export const savePrefs = createAppAsyncThunk(
`${sliceName}/savePrefs`,
async ({ prefs }: SavePrefsPayload, { dispatch }) => {
await send('save-prefs', prefs);
dispatch(mergeLocalPrefs(prefs));
},
);
export const loadGlobalPrefs = createAppAsyncThunk(
`${sliceName}/loadGlobalPrefs`,
async (_, { dispatch, getState }) => {
const globalPrefs = await send('load-global-prefs');
dispatch(
setPrefs({
local: getState().prefs.local,
global: globalPrefs,
synced: getState().prefs.synced,
}),
);
return globalPrefs;
},
);
type SaveGlobalPrefsPayload = {
prefs: GlobalPrefs;
onSaveGlobalPrefs?: () => void;
};
export const saveGlobalPrefs = createAppAsyncThunk(
`${sliceName}/saveGlobalPrefs`,
async (
{ prefs, onSaveGlobalPrefs }: SaveGlobalPrefsPayload,
{ dispatch },
) => {
await send('save-global-prefs', prefs);
dispatch(mergeGlobalPrefs(prefs));
onSaveGlobalPrefs?.();
},
);
type SaveSyncedPrefsPayload = {
prefs: SyncedPrefs;
};
export const saveSyncedPrefs = createAppAsyncThunk(
`${sliceName}/saveSyncedPrefs`,
async ({ prefs }: SaveSyncedPrefsPayload, { dispatch }) => {
await Promise.all(
Object.entries(prefs).map(([prefName, value]) =>
send('preferences/save', {
id: prefName as keyof SyncedPrefs,
value,
}),
),
);
dispatch(mergeSyncedPrefs(prefs));
},
);
type SaveServerPrefsPayload = {
prefs: ServerPrefs;
};
@@ -34,12 +147,35 @@ export const saveServerPrefs = createAppAsyncThunk(
},
);
type SetPrefsPayload = {
local: MetadataPrefs;
global: GlobalPrefs;
synced: SyncedPrefs;
};
type MergeLocalPrefsPayload = MetadataPrefs;
type MergeGlobalPrefsPayload = GlobalPrefs;
type MergeSyncedPrefsPayload = SyncedPrefs;
type MergeServerPrefsPayload = ServerPrefs;
const prefsSlice = createSlice({
name: sliceName,
initialState,
reducers: {
setPrefs(state, action: PayloadAction<SetPrefsPayload>) {
state.local = action.payload.local;
state.global = action.payload.global;
state.synced = action.payload.synced;
},
mergeLocalPrefs(state, action: PayloadAction<MergeLocalPrefsPayload>) {
state.local = { ...state.local, ...action.payload };
},
mergeGlobalPrefs(state, action: PayloadAction<MergeGlobalPrefsPayload>) {
state.global = { ...state.global, ...action.payload };
},
mergeSyncedPrefs(state, action: PayloadAction<MergeSyncedPrefsPayload>) {
state.synced = { ...state.synced, ...action.payload };
},
mergeServerPrefs(state, action: PayloadAction<MergeServerPrefsPayload>) {
state.server = { ...state.server, ...action.payload };
},
@@ -47,6 +183,7 @@ const prefsSlice = createSlice({
extraReducers: builder => {
builder.addCase(resetApp, state => ({
...initialState,
global: state.global || initialState.global,
server: state.server || initialState.server,
}));
builder.addCase(getUserData.fulfilled, (state, action) => {
@@ -74,7 +211,18 @@ export const { name, reducer, getInitialState } = prefsSlice;
export const actions = {
...prefsSlice.actions,
loadPrefs,
savePrefs,
loadGlobalPrefs,
saveGlobalPrefs,
saveSyncedPrefs,
saveServerPrefs,
};
export const { mergeServerPrefs } = actions;
export const {
mergeGlobalPrefs,
mergeLocalPrefs,
mergeServerPrefs,
mergeSyncedPrefs,
setPrefs,
} = actions;

View File

@@ -1,104 +0,0 @@
import { queryOptions } from '@tanstack/react-query';
import { send } from 'loot-core/platform/client/connection';
import { parseNumberFormat, setNumberFormat } from 'loot-core/shared/util';
import type {
GlobalPrefs,
MetadataPrefs,
ServerPrefs,
SyncedPrefs,
} from 'loot-core/types/prefs';
import { setI18NextLanguage } from '@desktop-client/i18n';
export type AllPrefs = {
metadata: MetadataPrefs;
global: GlobalPrefs;
synced: SyncedPrefs;
server: ServerPrefs;
};
export const prefQueries = {
all: () => ['prefs'],
lists: () => [...prefQueries.all(), 'lists'],
list: () =>
queryOptions<AllPrefs>({
queryKey: [...prefQueries.lists(), 'all'],
queryFn: async ({ client }) => {
const [metadataPrefs, globalPrefs, syncedPrefs] = await Promise.all([
client.ensureQueryData(prefQueries.listMetadata()),
client.ensureQueryData(prefQueries.listGlobal()),
client.ensureQueryData(prefQueries.listSynced()),
]);
// Certain loot-core utils depend on state outside of the React tree, update them
setNumberFormat(
parseNumberFormat({
format: syncedPrefs.numberFormat,
hideFraction: syncedPrefs.hideFraction,
}),
);
// We need to load translations before the app renders
setI18NextLanguage(globalPrefs.language ?? '');
return {
metadata: metadataPrefs,
global: globalPrefs,
synced: syncedPrefs,
server: {}, // Server prefs are loaded separately
};
},
placeholderData: {
metadata: {},
global: {},
synced: {},
server: {},
},
// Manually invalidated when preferences change
staleTime: Infinity,
}),
listMetadata: () =>
queryOptions<MetadataPrefs>({
queryKey: [...prefQueries.lists(), 'metadata'],
queryFn: async () => {
return await send('load-prefs');
},
placeholderData: {},
// Manually invalidated when local preferences change
staleTime: Infinity,
}),
listGlobal: () =>
queryOptions<GlobalPrefs>({
queryKey: [...prefQueries.lists(), 'global'],
queryFn: async () => {
return await send('load-global-prefs');
},
placeholderData: {},
// Manually invalidated when global preferences change
staleTime: Infinity,
}),
listSynced: () =>
queryOptions<SyncedPrefs>({
queryKey: [...prefQueries.lists(), 'synced'],
queryFn: async ({ client }) => {
const metadataPrefs = await client.getQueryData(
prefQueries.listMetadata().queryKey,
);
// Synced prefs are budget-specific, so if we don't have
// a budget loaded, just return an empty object.
if (!metadataPrefs?.id) {
return {};
}
return await send('preferences/get');
},
placeholderData: {},
// Manually invalidated when synced preferences change
staleTime: Infinity,
}),
listServer: () =>
queryOptions({
...prefQueries.list(),
select: data => data.server,
}),
};

View File

@@ -74,7 +74,7 @@ export const menuKeybindingText = colorPalette.purple200;
export const menuAutoCompleteBackground = colorPalette.gray600;
export const menuAutoCompleteBackgroundHover = colorPalette.gray500;
export const menuAutoCompleteText = colorPalette.gray100;
export const menuAutoCompleteTextHover = colorPalette.green900;
export const menuAutoCompleteTextHover = colorPalette.green400;
export const menuAutoCompleteTextHeader = colorPalette.purple200;
export const menuAutoCompleteItemTextHover = colorPalette.gray50;
export const menuAutoCompleteItemText = menuItemText;

View File

@@ -14,7 +14,7 @@ import { pushModal } from './modals/modalsSlice';
import { addNotification } from './notifications/notificationsSlice';
import type { Notification } from './notifications/notificationsSlice';
import { payeeQueries } from './payees';
import { prefQueries } from './prefs';
import { loadPrefs } from './prefs/prefsSlice';
import type { AppStore } from './redux/store';
import { signOut } from './users/usersSlice';
@@ -37,8 +37,8 @@ export function listenForSyncEvent(store: AppStore, queryClient: QueryClient) {
let attemptedSyncRepair = false;
const unlistenSuccess = listen('sync-event', async event => {
const prefs = await queryClient.ensureQueryData(prefQueries.listMetadata());
const unlistenSuccess = listen('sync-event', event => {
const prefs = store.getState().prefs.local;
if (!prefs || !prefs.id) {
// Do nothing if no budget is loaded
return;
@@ -62,9 +62,7 @@ export function listenForSyncEvent(store: AppStore, queryClient: QueryClient) {
const tables = event.tables;
if (tables.includes('prefs')) {
void queryClient.invalidateQueries({
queryKey: prefQueries.lists(),
});
void store.dispatch(loadPrefs());
}
if (
@@ -221,10 +219,8 @@ export function listenForSyncEvent(store: AppStore, queryClient: QueryClient) {
title: t('Register'),
action: async () => {
await store.dispatch(uploadBudget({}));
store.dispatch(sync());
void queryClient.invalidateQueries({
queryKey: prefQueries.lists(),
});
void store.dispatch(sync());
void store.dispatch(loadPrefs());
},
},
};
@@ -257,9 +253,7 @@ export function listenForSyncEvent(store: AppStore, queryClient: QueryClient) {
// the server does not match the local one. This can mean a
// few things depending on the state, and we try to show an
// appropriate message and call to action to fix it.
const { cloudFileId } = await queryClient.ensureQueryData(
prefQueries.listMetadata(),
);
const { cloudFileId } = store.getState().prefs.local;
if (!cloudFileId) {
console.error(
'Received file-has-reset or file-has-new-key error but no cloudFileId in prefs',

View File

@@ -9,7 +9,7 @@ import {
closeBudget,
loadAllFiles,
} from '@desktop-client/budgetfiles/budgetfilesSlice';
import { prefQueries } from '@desktop-client/prefs';
import { loadGlobalPrefs } from '@desktop-client/prefs/prefsSlice';
import { createAppAsyncThunk } from '@desktop-client/redux';
const sliceName = 'user';
@@ -48,13 +48,11 @@ export const loggedIn = createAppAsyncThunk(
export const signOut = createAppAsyncThunk(
`${sliceName}/signOut`,
async (_, { dispatch, extra: { queryClient } }) => {
async (_, { dispatch }) => {
await send('subscribe-sign-out');
void dispatch(getUserData());
void queryClient.invalidateQueries({
queryKey: prefQueries.listGlobal().queryKey,
});
void dispatch(loadGlobalPrefs());
void dispatch(closeBudget());
// Handled in budgetSlice
// dispatch({ type: constants.SIGN_OUT });

View File

@@ -25,7 +25,7 @@ import type {
import { copy, exists, mkdir, remove } from 'fs-extra';
import promiseRetry from 'promise-retry';
import type { GlobalPrefsJson, Theme } from '../loot-core/src/types/prefs';
import type { GlobalPrefsJson } from '../loot-core/src/types/prefs';
import { getMenu } from './menu';
import {
@@ -625,10 +625,11 @@ ipcMain.on('message', (_event, msg) => {
serverProcess.postMessage(msg.args);
});
ipcMain.on('set-theme', async (_event, theme: Theme) => {
ipcMain.on('set-theme', (_event, theme: string) => {
const obj = { theme };
if (clientWin) {
await clientWin.webContents.executeJavaScript(
`window.__actionsForMenu && window.__actionsForMenu.setTheme('${theme}')`,
void clientWin.webContents.executeJavaScript(
`window.__actionsForMenu && window.__actionsForMenu.saveGlobalPrefs({ prefs: ${JSON.stringify(obj)} })`,
);
}
});

View File

@@ -1,8 +1,6 @@
import { contextBridge, ipcRenderer } from 'electron';
import type { IpcRenderer } from 'electron';
import type { Theme } from 'loot-core/types/prefs';
import type {
GetBootstrapDataPayload,
OpenFileDialogPayload,
@@ -87,7 +85,7 @@ contextBridge.exposeInMainWorld('Actual', {
return null;
},
setTheme: async (theme: Theme) => {
setTheme: (theme: string) => {
ipcRenderer.send('set-theme', theme);
},

View File

@@ -1,7 +1,5 @@
import type EventEmitter from 'events';
import type { Theme } from 'loot-core/types/prefs';
export type IpcClient = {
on: EventEmitter['on'];
emit: (name: string, data: unknown) => void;
@@ -36,7 +34,7 @@ type Actual = {
applyAppUpdate: () => Promise<void>;
ipcConnect: (callback: (client: IpcClient) => void) => void;
getServerSocket: () => Promise<Worker | null>;
setTheme: (theme: Theme) => Promise<void>;
setTheme: (theme: string) => void;
logToTerminal: (...args: unknown[]) => void;
onEventFromMain: (
event: string,

View File

@@ -366,6 +366,19 @@ describe('/upload-user-file', () => {
expect(res.text).toBe('fileId is required');
});
it('returns 400 for invalid fileId format', async () => {
const res = await request(app)
.post('/upload-user-file')
.set('Content-Type', 'application/encrypted-file')
.set('x-actual-token', 'valid-token')
.set('x-actual-name', 'test-file')
.set('x-actual-file-id', 'budget@2026')
.send(Buffer.from('file content'));
expect(res.statusCode).toEqual(400);
expect(res.text).toBe('invalid fileId');
});
it('uploads a new file successfully', async () => {
const fileId = crypto.randomBytes(16).toString('hex');
const fileName = 'test-file.txt';
@@ -670,6 +683,16 @@ describe('/download-user-file', () => {
expect(res.text).toBe('User or file not found');
});
it('returns 400 for invalid fileId format', async () => {
const res = await request(app)
.get('/download-user-file')
.set('x-actual-token', 'valid-token')
.set('x-actual-file-id', 'budget@2026');
expect(res.statusCode).toEqual(400);
expect(res.text).toBe('invalid fileId');
});
it('returns 500 error if the file does not exist on the filesystem', async () => {
getAccountDb().mutate(
'INSERT INTO files (id, deleted) VALUES (?, FALSE)',

View File

@@ -49,11 +49,16 @@ app.use(express.json({ limit: `${config.get('upload.fileSizeLimitMB')}mb` }));
export { app as handlers };
const OK_RESPONSE = { status: 'ok' };
const FILE_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
function boolToInt(deleted) {
return deleted ? 1 : 0;
}
function isValidFileId(fileId: unknown): fileId is string {
return typeof fileId === 'string' && FILE_ID_PATTERN.test(fileId);
}
const verifyFileExists = (fileId, filesService, res, errorObject) => {
try {
return filesService.get(fileId);
@@ -256,6 +261,10 @@ app.post('/upload-user-file', async (req, res) => {
res.status(400).send('fileId is required');
return;
}
if (!isValidFileId(fileId)) {
res.status(400).send('invalid fileId');
return;
}
let groupId = req.headers['x-actual-group-id'] || null;
const encryptMeta = req.headers['x-actual-encrypt-meta'] || null;
@@ -352,6 +361,10 @@ app.get('/download-user-file', async (req, res) => {
res.status(400).send('Single file ID is required');
return;
}
if (!isValidFileId(fileId)) {
res.status(400).send('invalid fileId');
return;
}
const filesService = new FilesService(getAccountDb());
const file = verifyFileExists(

View File

@@ -1,6 +1,6 @@
import { readdir } from 'node:fs/promises';
import path, { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { load } from 'migrate';
@@ -30,7 +30,9 @@ export async function run(direction: 'up' | 'down' = 'up'): Promise<void> {
for (const f of files
.filter(f => f.endsWith('.js') || f.endsWith('.ts'))
.sort()) {
migrationsModules[f] = await import(path.join(migrationsDir, f));
migrationsModules[f] = await import(
pathToFileURL(path.join(migrationsDir, f)).href
);
}
return new Promise<void>((resolve, reject) => {

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [matt-fidd]
---
Bump ajv dependency

View File

@@ -0,0 +1,6 @@
---
category: Bugfixes
authors: [Juulz]
---
Change menuAutoCompleteTextHover color to green400 in Midnight theme.

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [Juulz]
---
Rename 'Okabe Ito' theme to 'Color-blind (dark)'.

View File

@@ -0,0 +1,6 @@
---
category: Bugfixes
authors: [jfdoming]
---
Validate file IDs for correctness

View File

@@ -0,0 +1,6 @@
---
category: Bugfix
authors: [MikesGlitch]
---
Fix server migrations when running on Windows

View File

@@ -0,0 +1,6 @@
---
category: Bugfix
authors: [MatissJanis]
---
Mobile: adjust rules list for better alignment and full-width container display.

View File

@@ -0,0 +1,6 @@
---
category: Bugfixes
authors: [MatissJanis]
---
API: fix module resolution

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [Copilot]
---
Remove 'suspect ai generated' label and delete associated workflow for streamlined labeling system.

View File

@@ -10892,14 +10892,14 @@ __metadata:
linkType: hard
"ajv@npm:^6.10.0, ajv@npm:^6.12.0, ajv@npm:^6.12.4, ajv@npm:^6.12.5":
version: 6.12.6
resolution: "ajv@npm:6.12.6"
version: 6.14.0
resolution: "ajv@npm:6.14.0"
dependencies:
fast-deep-equal: "npm:^3.1.1"
fast-json-stable-stringify: "npm:^2.0.0"
json-schema-traverse: "npm:^0.4.1"
uri-js: "npm:^4.2.2"
checksum: 10/48d6ad21138d12eb4d16d878d630079a2bda25a04e745c07846a4ad768319533031e28872a9b3c5790fa1ec41aabdf2abed30a56e5a03ebc2cf92184b8ee306c
checksum: 10/c71f14dd2b6f2535d043f74019c8169f7aeb1106bafbb741af96f34fdbf932255c919ddd46344043d03b62ea0ccb319f83667ec5eedf612393f29054fe5ce4a5
languageName: node
linkType: hard