mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-11 12:43:09 -05:00
Compare commits
10 Commits
react-quer
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b770c233e | ||
|
|
6ca5c84e1f | ||
|
|
e65429497d | ||
|
|
3758d72b65 | ||
|
|
032d10ac42 | ||
|
|
f97a89dc28 | ||
|
|
a4bd301ec6 | ||
|
|
18072e1d8b | ||
|
|
a1e0b3f45d | ||
|
|
8bfa9cbab1 |
@@ -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'
|
||||
|
||||
25
.github/workflows/pr-ai-label-cleanup.yml
vendored
25
.github/workflows/pr-ai-label-cleanup.yml
vendored
@@ -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'
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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
1
packages/api/typings/pegjs.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare module '*.pegjs';
|
||||
@@ -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(
|
||||
|
||||
@@ -219,8 +219,8 @@ global.Actual = {
|
||||
return worker;
|
||||
},
|
||||
|
||||
setTheme: async theme => {
|
||||
await window.__actionsForMenu.setTheme(theme);
|
||||
setTheme: theme => {
|
||||
window.__actionsForMenu.saveGlobalPrefs({ prefs: { theme } });
|
||||
},
|
||||
|
||||
moveBudgetDirectory: () => {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -119,6 +119,8 @@ export function ActionableGridListItem<T extends object>({
|
||||
padding: 16,
|
||||
textAlign: 'left',
|
||||
borderRadius: 0,
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
onClick={handleAction}
|
||||
>
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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"]
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from './queries';
|
||||
export * from './mutations';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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)} })`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
6
upcoming-release-notes/7044.md
Normal file
6
upcoming-release-notes/7044.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [matt-fidd]
|
||||
---
|
||||
|
||||
Bump ajv dependency
|
||||
6
upcoming-release-notes/7048.md
Normal file
6
upcoming-release-notes/7048.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfixes
|
||||
authors: [Juulz]
|
||||
---
|
||||
|
||||
Change menuAutoCompleteTextHover color to green400 in Midnight theme.
|
||||
6
upcoming-release-notes/7058.md
Normal file
6
upcoming-release-notes/7058.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [Juulz]
|
||||
---
|
||||
|
||||
Rename 'Okabe Ito' theme to 'Color-blind (dark)'.
|
||||
6
upcoming-release-notes/7067.md
Normal file
6
upcoming-release-notes/7067.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfixes
|
||||
authors: [jfdoming]
|
||||
---
|
||||
|
||||
Validate file IDs for correctness
|
||||
6
upcoming-release-notes/7076.md
Normal file
6
upcoming-release-notes/7076.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfix
|
||||
authors: [MikesGlitch]
|
||||
---
|
||||
|
||||
Fix server migrations when running on Windows
|
||||
6
upcoming-release-notes/7081.md
Normal file
6
upcoming-release-notes/7081.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfix
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
Mobile: adjust rules list for better alignment and full-width container display.
|
||||
6
upcoming-release-notes/7084.md
Normal file
6
upcoming-release-notes/7084.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Bugfixes
|
||||
authors: [MatissJanis]
|
||||
---
|
||||
|
||||
API: fix module resolution
|
||||
6
upcoming-release-notes/7087.md
Normal file
6
upcoming-release-notes/7087.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [Copilot]
|
||||
---
|
||||
|
||||
Remove 'suspect ai generated' label and delete associated workflow for streamlined labeling system.
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user