onChange('value', v)}
numberFormatType="currency"
@@ -461,6 +462,7 @@ function ActionEditor({ action, editorStyle, onChange, onDelete, onAdd }) {
{
map[entry.transaction.trx_id] = entry;
@@ -309,7 +309,7 @@ export function ImportTransactionsModal({ options }) {
return next;
}, []);
},
- [accountId, categories.list, clearOnImport, importPreviewTransactions],
+ [accountId, categories.list, clearOnImport, dispatch],
);
const parse = useCallback(
@@ -320,8 +320,9 @@ export function ImportTransactionsModal({ options }) {
setFilename(filename);
setFileType(filetype);
- const { errors, transactions: parsedTransactions = [] } =
- await parseTransactions(filename, options);
+ const { errors, transactions: parsedTransactions = [] } = await dispatch(
+ parseTransactions(filename, options),
+ );
let index = 0;
const transactions = parsedTransactions.map(trans => {
@@ -399,11 +400,11 @@ export function ImportTransactionsModal({ options }) {
},
[
accountId,
+ dispatch,
getImportPreview,
inOutMode,
multiplierAmount,
outValue,
- parseTransactions,
prefs,
],
);
@@ -427,7 +428,6 @@ export function ImportTransactionsModal({ options }) {
parse(options.filename, parseOptions);
}, [
- parseTransactions,
options.filename,
delimiter,
hasHeaderRow,
@@ -653,13 +653,11 @@ export function ImportTransactionsModal({ options }) {
});
}
- const didChange = await importTransactions(
- accountId,
- finalTransactions,
- reconcile,
+ const didChange = await dispatch(
+ importTransactions(accountId, finalTransactions, reconcile),
);
if (didChange) {
- await getPayees();
+ await dispatch(getPayees());
}
if (onImported) {
diff --git a/packages/desktop-client/src/components/modals/SelectLinkedAccountsModal.jsx b/packages/desktop-client/src/components/modals/SelectLinkedAccountsModal.jsx
index 90ec64cd633..bac4aefe5f4 100644
--- a/packages/desktop-client/src/components/modals/SelectLinkedAccountsModal.jsx
+++ b/packages/desktop-client/src/components/modals/SelectLinkedAccountsModal.jsx
@@ -23,7 +23,7 @@ import { TableHeader, Table, Row, Field } from '../table';
const addOnBudgetAccountOption = { id: 'new-on', name: 'Create new account' };
const addOffBudgetAccountOption = {
id: 'new-off',
- name: 'Create new account (off-budget)',
+ name: 'Create new account (off budget)',
};
export function SelectLinkedAccountsModal({
diff --git a/packages/desktop-client/src/components/modals/manager/DuplicateFileModal.tsx b/packages/desktop-client/src/components/modals/manager/DuplicateFileModal.tsx
new file mode 100644
index 00000000000..ed1d705521e
--- /dev/null
+++ b/packages/desktop-client/src/components/modals/manager/DuplicateFileModal.tsx
@@ -0,0 +1,240 @@
+import React, { useEffect, useState } from 'react';
+import { Trans, useTranslation } from 'react-i18next';
+import { useDispatch } from 'react-redux';
+
+import {
+ addNotification,
+ duplicateBudget,
+ uniqueBudgetName,
+ validateBudgetName,
+} from 'loot-core/client/actions';
+import { type File } from 'loot-core/src/types/file';
+
+import { theme } from '../../../style';
+import { Button, ButtonWithLoading } from '../../common/Button2';
+import { FormError } from '../../common/FormError';
+import { InitialFocus } from '../../common/InitialFocus';
+import { InlineField } from '../../common/InlineField';
+import { Input } from '../../common/Input';
+import {
+ Modal,
+ ModalButtons,
+ ModalCloseButton,
+ ModalHeader,
+} from '../../common/Modal';
+import { Text } from '../../common/Text';
+import { View } from '../../common/View';
+
+type DuplicateFileProps = {
+ file: File;
+ managePage?: boolean;
+ loadBudget?: 'none' | 'original' | 'copy';
+ onComplete?: (event: {
+ status: 'success' | 'failed' | 'canceled';
+ error?: object;
+ }) => void;
+};
+
+export function DuplicateFileModal({
+ file,
+ managePage,
+ loadBudget = 'none',
+ onComplete,
+}: DuplicateFileProps) {
+ const { t } = useTranslation();
+ const fileEndingTranslation = t(' - copy');
+ const [newName, setNewName] = useState(file.name + fileEndingTranslation);
+ const [nameError, setNameError] = useState(null);
+
+ // If the state is "broken" that means it was created by another user.
+ const isCloudFile = 'cloudFileId' in file && file.state !== 'broken';
+ const isLocalFile = 'id' in file;
+ const dispatch = useDispatch();
+
+ const [loadingState, setLoadingState] = useState<'cloud' | 'local' | null>(
+ null,
+ );
+
+ useEffect(() => {
+ (async () => {
+ setNewName(await uniqueBudgetName(file.name + fileEndingTranslation));
+ })();
+ }, [file.name, fileEndingTranslation]);
+
+ const validateAndSetName = async (name: string) => {
+ const trimmedName = name.trim();
+ const { valid, message } = await validateBudgetName(trimmedName);
+ if (valid) {
+ setNewName(trimmedName);
+ setNameError(null);
+ } else {
+ // The "Unknown error" should never happen, but this satifies type checking
+ setNameError(message ?? t('Unknown error with budget name'));
+ }
+ };
+
+ const handleDuplicate = async (sync: 'localOnly' | 'cloudSync') => {
+ const { valid, message } = await validateBudgetName(newName);
+ if (valid) {
+ setLoadingState(sync === 'cloudSync' ? 'cloud' : 'local');
+
+ try {
+ await dispatch(
+ duplicateBudget({
+ id: 'id' in file ? file.id : undefined,
+ cloudId:
+ sync === 'cloudSync' && 'cloudFileId' in file
+ ? file.cloudFileId
+ : undefined,
+ oldName: file.name,
+ newName,
+ cloudSync: sync === 'cloudSync',
+ managePage,
+ loadBudget,
+ }),
+ );
+ dispatch(
+ addNotification({
+ type: 'message',
+ message: t('Duplicate file “{{newName}}” created.', { newName }),
+ }),
+ );
+ if (onComplete) onComplete({ status: 'success' });
+ } catch (e) {
+ const newError = new Error(t('Failed to duplicate budget'));
+ if (onComplete) onComplete({ status: 'failed', error: newError });
+ else console.error('Failed to duplicate budget:', e);
+ dispatch(
+ addNotification({
+ type: 'error',
+ message: t('Failed to duplicate budget file.'),
+ }),
+ );
+ } finally {
+ setLoadingState(null);
+ }
+ } else {
+ const failError = new Error(
+ message ?? t('Unknown error with budget name'),
+ );
+ if (onComplete) onComplete({ status: 'failed', error: failError });
+ }
+ };
+
+ return (
+
+ {({ state: { close } }) => (
+
+ {
+ close();
+ if (onComplete) onComplete({ status: 'canceled' });
+ }}
+ />
+ }
+ />
+
+
+
+
+ setNewName(event.target.value)}
+ onBlur={event => validateAndSetName(event.target.value)}
+ style={{ flex: 1 }}
+ />
+
+
+ {nameError && (
+
+ {nameError}
+
+ )}
+
+ {isLocalFile ? (
+ isCloudFile && (
+
+
+ Your budget is hosted on a server, making it accessible for
+ download on your devices.
+
+ Would you like to duplicate this budget for all your devices
+ or keep it stored locally on this device?
+
+
+ )
+ ) : (
+
+
+ Unable to duplicate a budget that is not located on your
+ device.
+
+ Please download the budget from the server before duplicating.
+
+
+ )}
+
+
+ {isLocalFile && isCloudFile && (
+ handleDuplicate('cloudSync')}
+ >
+ Duplicate for all devices
+
+ )}
+ {isLocalFile && (
+ handleDuplicate('localOnly')}
+ >
+ Duplicate
+ {isCloudFile && locally}
+
+ )}
+
+
+
+ )}
+
+ );
+}
diff --git a/packages/desktop-client/src/components/payees/PayeeTableRow.tsx b/packages/desktop-client/src/components/payees/PayeeTableRow.tsx
index dce345a3da8..5392c1a435e 100644
--- a/packages/desktop-client/src/components/payees/PayeeTableRow.tsx
+++ b/packages/desktop-client/src/components/payees/PayeeTableRow.tsx
@@ -1,10 +1,10 @@
// @ts-strict-ignore
-import { memo, useRef, useState, type CSSProperties } from 'react';
+import { memo, useRef, type CSSProperties } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { type PayeeEntity } from 'loot-core/src/types/models';
-import { useFeatureFlag } from '../../hooks/useFeatureFlag';
+import { useContextMenu } from '../../hooks/useContextMenu';
import { useSelectedDispatch } from '../../hooks/useSelected';
import { SvgArrowThinRight, SvgBookmark } from '../../icons/v1';
import { theme } from '../../style';
@@ -111,10 +111,8 @@ export const PayeeTableRow = memo(
const { t } = useTranslation();
const triggerRef = useRef(null);
- const [menuOpen, setMenuOpen] = useState(false);
- const [crossOffset, setCrossOffset] = useState(0);
- const [offset, setOffset] = useState(0);
- const contextMenusEnabled = useFeatureFlag('contextMenus');
+ const { setMenuOpen, menuOpen, handleContextMenu, position } =
+ useContextMenu();
return (
onHover && onHover(payee.id)}
- onContextMenu={e => {
- if (!contextMenusEnabled) return;
- e.preventDefault();
- setMenuOpen(true);
- const rect = e.currentTarget.getBoundingClientRect();
- setCrossOffset(e.clientX - rect.left);
- setOffset(e.clientY - rect.bottom);
- }}
+ onContextMenu={handleContextMenu}
>
setMenuOpen(false)}
- crossOffset={crossOffset}
- offset={offset}
+ {...position}
style={{ width: 200, margin: 1 }}
isNonModal
>
diff --git a/packages/desktop-client/src/components/reports/ReportOptions.ts b/packages/desktop-client/src/components/reports/ReportOptions.ts
index d8f2ed7fce6..953771f1f65 100644
--- a/packages/desktop-client/src/components/reports/ReportOptions.ts
+++ b/packages/desktop-client/src/components/reports/ReportOptions.ts
@@ -257,7 +257,7 @@ const transferCategory: UncategorizedEntity = {
};
const offBudgetCategory: UncategorizedEntity = {
id: '',
- name: t('Off Budget'),
+ name: t('Off budget'),
uncategorized_id: 'off_budget',
hidden: false,
};
@@ -271,7 +271,7 @@ type UncategorizedGroupEntity = Pick<
};
const uncategorizedGroup: UncategorizedGroupEntity = {
- name: t('Uncategorized & Off Budget'),
+ name: t('Uncategorized & Off budget'),
id: 'uncategorized',
hidden: false,
uncategorized_id: 'all',
diff --git a/packages/desktop-client/src/components/rules/ConditionExpression.tsx b/packages/desktop-client/src/components/rules/ConditionExpression.tsx
index 229868a338f..cc2d092bcc5 100644
--- a/packages/desktop-client/src/components/rules/ConditionExpression.tsx
+++ b/packages/desktop-client/src/components/rules/ConditionExpression.tsx
@@ -49,7 +49,11 @@ export function ConditionExpression({
{prefix && {prefix} }
{mapField(field, options)}{' '}
{friendlyOp(op)}{' '}
-
+ {!['onbudget', 'offbudget'].includes(
+ (op as string)?.toLocaleLowerCase(),
+ ) && (
+
+ )}
);
}
diff --git a/packages/desktop-client/src/components/rules/RuleRow.tsx b/packages/desktop-client/src/components/rules/RuleRow.tsx
index 2c59dcac267..10b67f30320 100644
--- a/packages/desktop-client/src/components/rules/RuleRow.tsx
+++ b/packages/desktop-client/src/components/rules/RuleRow.tsx
@@ -1,5 +1,5 @@
// @ts-strict-ignore
-import React, { memo, useRef, useState } from 'react';
+import React, { memo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { v4 as uuid } from 'uuid';
@@ -7,7 +7,7 @@ import { v4 as uuid } from 'uuid';
import { friendlyOp } from 'loot-core/src/shared/rules';
import { type RuleEntity } from 'loot-core/src/types/models';
-import { useFeatureFlag } from '../../hooks/useFeatureFlag';
+import { useContextMenu } from '../../hooks/useContextMenu';
import { useSelectedDispatch } from '../../hooks/useSelected';
import { SvgRightArrow2 } from '../../icons/v0';
import { styles, theme } from '../../style';
@@ -60,10 +60,8 @@ export const RuleRow = memo(
const { t } = useTranslation();
const triggerRef = useRef(null);
- const [menuOpen, setMenuOpen] = useState(false);
- const [crossOffset, setCrossOffset] = useState(0);
- const [offset, setOffset] = useState(0);
- const contextMenusEnabled = useFeatureFlag('contextMenus');
+ const { setMenuOpen, menuOpen, handleContextMenu, position } =
+ useContextMenu();
return (
onHover && onHover(rule.id)}
onMouseLeave={() => onHover && onHover(null)}
- onContextMenu={e => {
- if (!contextMenusEnabled) return;
- e.preventDefault();
- setMenuOpen(true);
- const rect = triggerRef.current.getBoundingClientRect();
- setCrossOffset(e.clientX - rect.left);
- setOffset(e.clientY - rect.bottom);
- }}
+ onContextMenu={handleContextMenu}
>
setMenuOpen(false)}
- crossOffset={crossOffset}
- offset={offset}
+ {...position}
style={{ width: 200, margin: 1 }}
isNonModal
>
diff --git a/packages/desktop-client/src/components/schedules/SchedulesTable.tsx b/packages/desktop-client/src/components/schedules/SchedulesTable.tsx
index 2d65f595667..e3a1782db6d 100644
--- a/packages/desktop-client/src/components/schedules/SchedulesTable.tsx
+++ b/packages/desktop-client/src/components/schedules/SchedulesTable.tsx
@@ -13,8 +13,8 @@ import { integerToCurrency } from 'loot-core/src/shared/util';
import { type ScheduleEntity } from 'loot-core/src/types/models';
import { useAccounts } from '../../hooks/useAccounts';
+import { useContextMenu } from '../../hooks/useContextMenu';
import { useDateFormat } from '../../hooks/useDateFormat';
-import { useFeatureFlag } from '../../hooks/useFeatureFlag';
import { usePayees } from '../../hooks/usePayees';
import { SvgDotsHorizontalTriple } from '../../icons/v1';
import { SvgCheck } from '../../icons/v2';
@@ -186,10 +186,14 @@ function ScheduleRow({
const rowRef = useRef(null);
const buttonRef = useRef(null);
- const [open, setOpen] = useState(false);
- const [crossOffset, setCrossOffset] = useState(0);
- const [offset, setOffset] = useState(0);
- const contextMenusEnabled = useFeatureFlag('contextMenus');
+ const {
+ setMenuOpen,
+ menuOpen,
+ handleContextMenu,
+ resetPosition,
+ position,
+ asContextMenu,
+ } = useContextMenu();
return (
{
- if (!contextMenusEnabled) return;
- if (minimal) return;
- e.preventDefault();
- const rect = e.currentTarget.getBoundingClientRect();
- setCrossOffset(e.clientX - rect.left);
- setOffset(e.clientY - rect.bottom);
- setOpen('contextMenu');
- }}
+ onContextMenu={handleContextMenu}
>
{!minimal && (
setOpen(false)}
+ triggerRef={asContextMenu ? rowRef : buttonRef}
+ isOpen={menuOpen}
+ onOpenChange={() => setMenuOpen(false)}
isNonModal
placement="bottom start"
- crossOffset={open === 'contextMenu' ? crossOffset : 0}
- offset={open === 'contextMenu' ? offset : 0}
+ {...position}
style={{ margin: 1 }}
>
{
onAction(action, id);
- setOpen(false);
+ resetPosition();
+ setMenuOpen(false);
}}
/>
@@ -276,7 +272,8 @@ function ScheduleRow({
variant="bare"
aria-label={t('Menu')}
onPress={() => {
- setOpen('button');
+ resetPosition();
+ setMenuOpen(true);
}}
>
{t('Learn more…')}
diff --git a/packages/desktop-client/src/components/settings/Reset.tsx b/packages/desktop-client/src/components/settings/Reset.tsx
index 193bb2565e6..0a9128d918f 100644
--- a/packages/desktop-client/src/components/settings/Reset.tsx
+++ b/packages/desktop-client/src/components/settings/Reset.tsx
@@ -1,10 +1,11 @@
import React, { useState } from 'react';
+import { useDispatch } from 'react-redux';
import { t } from 'i18next';
+import { resetSync } from 'loot-core/client/actions';
import { send } from 'loot-core/src/platform/client/fetch';
-import { useActions } from '../../hooks/useActions';
import { useMetadataPref } from '../../hooks/useMetadataPref';
import { ButtonWithLoading } from '../common/Button2';
import { Text } from '../common/Text';
@@ -41,13 +42,13 @@ export function ResetCache() {
export function ResetSync() {
const [groupId] = useMetadataPref('groupId');
const isEnabled = !!groupId;
- const { resetSync } = useActions();
+ const dispatch = useDispatch();
const [resetting, setResetting] = useState(false);
async function onResetSync() {
setResetting(true);
- await resetSync();
+ await dispatch(resetSync());
setResetting(false);
}
diff --git a/packages/desktop-client/src/components/settings/index.tsx b/packages/desktop-client/src/components/settings/index.tsx
index 4a7852e12b0..6e93d81a702 100644
--- a/packages/desktop-client/src/components/settings/index.tsx
+++ b/packages/desktop-client/src/components/settings/index.tsx
@@ -1,12 +1,13 @@
import React, { type ReactNode, useEffect } from 'react';
+import { useDispatch } from 'react-redux';
import { css } from '@emotion/css';
import { t } from 'i18next';
+import { closeBudget, loadPrefs } from 'loot-core/client/actions';
import { isElectron } from 'loot-core/shared/environment';
import { listen } from 'loot-core/src/platform/client/fetch';
-import { useActions } from '../../hooks/useActions';
import { useGlobalPref } from '../../hooks/useGlobalPref';
import { useIsOutdated, useLatestVersion } from '../../hooks/useLatestVersion';
import { useMetadataPref } from '../../hooks/useMetadataPref';
@@ -126,17 +127,20 @@ function AdvancedAbout() {
export function Settings() {
const [floatingSidebar] = useGlobalPref('floatingSidebar');
const [budgetName] = useMetadataPref('budgetName');
+ const dispatch = useDispatch();
- const { loadPrefs, closeBudget } = useActions();
+ const onCloseBudget = () => {
+ dispatch(closeBudget());
+ };
useEffect(() => {
const unlisten = listen('prefs-updated', () => {
- loadPrefs();
+ dispatch(loadPrefs());
});
- loadPrefs();
+ dispatch(loadPrefs());
return () => unlisten();
- }, [loadPrefs]);
+ }, [dispatch]);
const { isNarrowWidth } = useResponsive();
@@ -169,7 +173,7 @@ export function Settings() {
style={{ color: theme.buttonNormalDisabledText }}
/>
-
+
)}
diff --git a/packages/desktop-client/src/components/sidebar/Account.tsx b/packages/desktop-client/src/components/sidebar/Account.tsx
index 62985660a4f..eebfd7c98c6 100644
--- a/packages/desktop-client/src/components/sidebar/Account.tsx
+++ b/packages/desktop-client/src/components/sidebar/Account.tsx
@@ -153,7 +153,7 @@ export function Account>({
state.account.accountsSyncing,
@@ -100,11 +100,11 @@ export function Accounts() {
style={{ fontWeight, marginTop: 15 }}
/>
- {budgetedAccounts.length > 0 && (
+ {onBudgetAccounts.length > 0 && (
)}
- {budgetedAccounts.map((account, i) => (
+ {onBudgetAccounts.map((account, i) => (
['value'] {
const { sheetName, fullSheetName } = useSheetName(binding);
- const bindingObj =
- typeof binding === 'string'
- ? { name: binding, value: null, query: undefined }
- : binding;
+ const bindingObj = useMemo(
+ () =>
+ typeof binding === 'string'
+ ? { name: binding, value: null, query: undefined }
+ : binding,
+ [binding],
+ );
const spreadsheet = useSpreadsheet();
const [result, setResult] = useState>({
name: fullSheetName,
- value: bindingObj.value === undefined ? null : bindingObj.value,
+ value: bindingObj.value ? bindingObj.value : null,
query: bindingObj.query,
});
const latestOnChange = useRef(onChange);
@@ -48,15 +51,16 @@ export function useSheetValue<
latestValue.current = result.value;
useLayoutEffect(() => {
- if (bindingObj.query) {
- spreadsheet.createQuery(sheetName, bindingObj.name, bindingObj.query);
- }
+ let isMounted = true;
- return spreadsheet.bind(
+ const unbind = spreadsheet.bind(
sheetName,
- binding,
- null,
+ bindingObj,
(newResult: SheetValueResult) => {
+ if (!isMounted) {
+ return;
+ }
+
if (latestOnChange.current) {
latestOnChange.current(newResult);
}
@@ -66,7 +70,17 @@ export function useSheetValue<
}
},
);
- }, [sheetName, bindingObj.name, JSON.stringify(bindingObj.query)]);
+
+ return () => {
+ isMounted = false;
+ unbind();
+ };
+ }, [
+ spreadsheet,
+ sheetName,
+ bindingObj.name,
+ bindingObj.query?.serializeAsString(),
+ ]);
return result.value;
}
diff --git a/packages/desktop-client/src/components/transactions/TransactionsTable.jsx b/packages/desktop-client/src/components/transactions/TransactionsTable.jsx
index 60f581b60c0..655e853ccf4 100644
--- a/packages/desktop-client/src/components/transactions/TransactionsTable.jsx
+++ b/packages/desktop-client/src/components/transactions/TransactionsTable.jsx
@@ -47,7 +47,7 @@ import {
titleFirst,
} from 'loot-core/src/shared/util';
-import { useFeatureFlag } from '../../hooks/useFeatureFlag';
+import { useContextMenu } from '../../hooks/useContextMenu';
import { useMergedRefs } from '../../hooks/useMergedRefs';
import { usePrevious } from '../../hooks/usePrevious';
import { useProperFocus } from '../../hooks/useProperFocus';
@@ -1048,10 +1048,8 @@ const Transaction = memo(function Transaction({
}, 1);
}, [splitError, allTransactions]);
- const [menuOpen, setMenuOpen] = useState(false);
- const [crossOffset, setCrossOffset] = useState(0);
- const [offset, setOffset] = useState(0);
- const contextMenusEnabled = useFeatureFlag('contextMenus');
+ const { setMenuOpen, menuOpen, handleContextMenu, position } =
+ useContextMenu();
return (
{
- if (!contextMenusEnabled) return;
- if (transaction.id === 'temp') return;
- e.preventDefault();
- const rect = e.currentTarget.getBoundingClientRect();
- setCrossOffset(e.clientX - rect.left);
- setOffset(e.clientY - rect.bottom);
- setMenuOpen(true);
- }}
+ onContextMenu={handleContextMenu}
>
setMenuOpen(false)}
- crossOffset={crossOffset}
- offset={offset}
+ {...position}
style={{ width: 200, margin: 1 }}
isNonModal
>
@@ -1420,7 +1409,7 @@ const Transaction = memo(function Transaction({
) : isBudgetTransfer || isOffBudget ? (
- );
+ switch (op) {
+ case 'onBudget':
+ case 'offBudget':
+ content = null;
+ break;
+ default:
+ content = (
+
+ );
+ break;
+ }
break;
case 'category':
diff --git a/packages/desktop-client/src/hooks/useContextMenu.ts b/packages/desktop-client/src/hooks/useContextMenu.ts
new file mode 100644
index 00000000000..69bbd0bd8ce
--- /dev/null
+++ b/packages/desktop-client/src/hooks/useContextMenu.ts
@@ -0,0 +1,40 @@
+import { type MouseEventHandler, useState } from 'react';
+
+import { useFeatureFlag } from './useFeatureFlag';
+
+export function useContextMenu() {
+ const [menuOpen, setMenuOpen] = useState(false);
+ const [asContextMenu, setAsContextMenu] = useState(false);
+ const [position, setPosition] = useState({ crossOffset: 0, offset: 0 });
+ const contextMenusEnabled = useFeatureFlag('contextMenus');
+
+ const handleContextMenu: MouseEventHandler = e => {
+ if (!contextMenusEnabled) return;
+
+ e.preventDefault();
+ setAsContextMenu(true);
+
+ const rect = e.currentTarget.getBoundingClientRect();
+ setPosition({
+ crossOffset: e.clientX - rect.left,
+ offset: e.clientY - rect.bottom,
+ });
+ setMenuOpen(true);
+ };
+
+ const resetPosition = (crossOffset = 0, offset = 0) => {
+ setPosition({ crossOffset, offset });
+ };
+
+ return {
+ menuOpen,
+ setMenuOpen: (open: boolean) => {
+ setMenuOpen(open);
+ setAsContextMenu(false);
+ },
+ position,
+ handleContextMenu,
+ resetPosition,
+ asContextMenu,
+ };
+}
diff --git a/packages/desktop-client/src/hooks/useBudgetedAccounts.ts b/packages/desktop-client/src/hooks/useOnBudgetAccounts.ts
similarity index 86%
rename from packages/desktop-client/src/hooks/useBudgetedAccounts.ts
rename to packages/desktop-client/src/hooks/useOnBudgetAccounts.ts
index dbd8e1f53db..31fd6cf10c0 100644
--- a/packages/desktop-client/src/hooks/useBudgetedAccounts.ts
+++ b/packages/desktop-client/src/hooks/useOnBudgetAccounts.ts
@@ -2,7 +2,7 @@ import { useMemo } from 'react';
import { useAccounts } from './useAccounts';
-export function useBudgetedAccounts() {
+export function useOnBudgetAccounts() {
const accounts = useAccounts();
return useMemo(
() =>
diff --git a/packages/loot-core/src/client/SpreadsheetProvider.tsx b/packages/loot-core/src/client/SpreadsheetProvider.tsx
index 7c53ca515d4..6f8cfafbd78 100644
--- a/packages/loot-core/src/client/SpreadsheetProvider.tsx
+++ b/packages/loot-core/src/client/SpreadsheetProvider.tsx
@@ -62,22 +62,26 @@ function makeSpreadsheet() {
});
}
- bind(sheetName = '__global', binding, fields, cb) {
+ bind(sheetName = '__global', binding, callback) {
binding =
typeof binding === 'string' ? { name: binding, value: null } : binding;
+ if (binding.query) {
+ this.createQuery(sheetName, binding.name, binding.query);
+ }
+
const resolvedName = `${sheetName}!${binding.name}`;
- const cleanup = this.observeCell(resolvedName, cb);
+ const cleanup = this.observeCell(resolvedName, callback);
// Always synchronously call with the existing value if it has one.
// This is a display optimization to avoid flicker. The LRU cache
// will keep a number of recent nodes in memory.
if (LRUValueCache.has(resolvedName)) {
- cb(LRUValueCache.get(resolvedName));
+ callback(LRUValueCache.get(resolvedName));
}
if (cellCache[resolvedName] != null) {
- cellCache[resolvedName].then(cb);
+ cellCache[resolvedName].then(callback);
} else {
const req = this.get(sheetName, binding.name);
cellCache[resolvedName] = req;
@@ -90,7 +94,7 @@ function makeSpreadsheet() {
// with an old value depending on the order of messages)
if (cellCache[resolvedName] === req) {
LRUValueCache.set(resolvedName, result);
- cb(result);
+ callback(result);
}
});
}
diff --git a/packages/loot-core/src/client/actions/budgets.ts b/packages/loot-core/src/client/actions/budgets.ts
index 5ce240a64b8..8e0a5bfebd1 100644
--- a/packages/loot-core/src/client/actions/budgets.ts
+++ b/packages/loot-core/src/client/actions/budgets.ts
@@ -148,6 +148,73 @@ export function createBudget({ testMode = false, demoMode = false } = {}) {
};
}
+export function validateBudgetName(name: string): {
+ valid: boolean;
+ message?: string;
+} {
+ return send('validate-budget-name', { name });
+}
+
+export function uniqueBudgetName(name: string): string {
+ return send('unique-budget-name', { name });
+}
+
+export function duplicateBudget({
+ id,
+ cloudId,
+ oldName,
+ newName,
+ managePage,
+ loadBudget = 'none',
+ cloudSync,
+}: {
+ id?: string;
+ cloudId?: string;
+ oldName: string;
+ newName: string;
+ managePage?: boolean;
+ loadBudget: 'none' | 'original' | 'copy';
+ /**
+ * cloudSync is used to determine if the duplicate budget
+ * should be synced to the server
+ */
+ cloudSync?: boolean;
+}) {
+ return async (dispatch: Dispatch) => {
+ try {
+ dispatch(
+ setAppState({
+ loadingText: t('Duplicating: {{oldName}} -- to: {{newName}}', {
+ oldName,
+ newName,
+ }),
+ }),
+ );
+
+ await send('duplicate-budget', {
+ id,
+ cloudId,
+ newName,
+ cloudSync,
+ open: loadBudget,
+ });
+
+ dispatch(closeModal());
+
+ if (managePage) {
+ await dispatch(loadAllFiles());
+ }
+ } catch (error) {
+ console.error('Error duplicating budget:', error);
+ throw error instanceof Error
+ ? error
+ : new Error('Error duplicating budget: ' + String(error));
+ } finally {
+ dispatch(setAppState({ loadingText: null }));
+ }
+ };
+}
+
export function importBudget(
filepath: string,
type: Parameters[0]['type'],
diff --git a/packages/loot-core/src/client/data-hooks/schedules.tsx b/packages/loot-core/src/client/data-hooks/schedules.tsx
index 6207b6304a8..03b95b42b30 100644
--- a/packages/loot-core/src/client/data-hooks/schedules.tsx
+++ b/packages/loot-core/src/client/data-hooks/schedules.tsx
@@ -161,7 +161,7 @@ export function useCachedSchedules() {
}
export function accountSchedulesQuery(
- accountId?: AccountEntity['id'] | 'budgeted' | 'offbudget' | 'uncategorized',
+ accountId?: AccountEntity['id'] | 'onbudget' | 'offbudget' | 'uncategorized',
) {
const filterByAccount = accountFilter(accountId, '_account');
const filterByPayee = accountFilter(accountId, '_payee.transfer_acct');
diff --git a/packages/loot-core/src/client/data-hooks/transactions.ts b/packages/loot-core/src/client/data-hooks/transactions.ts
index 60ec93e1f2d..096fe2836ea 100644
--- a/packages/loot-core/src/client/data-hooks/transactions.ts
+++ b/packages/loot-core/src/client/data-hooks/transactions.ts
@@ -1,4 +1,4 @@
-import { useEffect, useRef, useState, useMemo } from 'react';
+import { useEffect, useRef, useState, useMemo, useCallback } from 'react';
import debounce from 'lodash/debounce';
@@ -24,10 +24,11 @@ type UseTransactionsProps = {
type UseTransactionsResult = {
transactions: ReadonlyArray;
- isLoading?: boolean;
+ isLoading: boolean;
error?: Error;
- reload?: () => void;
- loadMore?: () => void;
+ reload: () => void;
+ loadMore: () => void;
+ isLoadingMore: boolean;
};
export function useTransactions({
@@ -35,6 +36,7 @@ export function useTransactions({
options = { pageCount: 50 },
}: UseTransactionsProps): UseTransactionsResult {
const [isLoading, setIsLoading] = useState(true);
+ const [isLoadingMore, setIsLoadingMore] = useState(false);
const [error, setError] = useState(undefined);
const [transactions, setTransactions] = useState<
ReadonlyArray
@@ -88,12 +90,32 @@ export function useTransactions({
};
}, [query]);
+ const loadMore = useCallback(async () => {
+ if (!pagedQueryRef.current) {
+ return;
+ }
+
+ setIsLoadingMore(true);
+
+ await pagedQueryRef.current
+ .fetchNext()
+ .catch(setError)
+ .finally(() => {
+ setIsLoadingMore(false);
+ });
+ }, []);
+
+ const reload = useCallback(() => {
+ pagedQueryRef.current?.run();
+ }, []);
+
return {
transactions,
isLoading,
error,
- reload: pagedQueryRef.current?.run,
- loadMore: pagedQueryRef.current?.fetchNext,
+ reload,
+ loadMore,
+ isLoadingMore,
};
}
diff --git a/packages/loot-core/src/client/queries.ts b/packages/loot-core/src/client/queries.ts
index c36659a1378..a8b1ff9b368 100644
--- a/packages/loot-core/src/client/queries.ts
+++ b/packages/loot-core/src/client/queries.ts
@@ -29,11 +29,11 @@ const envelopeParametrizedField = parametrizedField<'envelope-budget'>();
const trackingParametrizedField = parametrizedField<'tracking-budget'>();
export function accountFilter(
- accountId?: AccountEntity['id'] | 'budgeted' | 'offbudget' | 'uncategorized',
+ accountId?: AccountEntity['id'] | 'onbudget' | 'offbudget' | 'uncategorized',
field = 'account',
) {
if (accountId) {
- if (accountId === 'budgeted') {
+ if (accountId === 'onbudget') {
return {
$and: [
{ [`${field}.offbudget`]: false },
@@ -68,7 +68,7 @@ export function accountFilter(
}
export function transactions(
- accountId?: AccountEntity['id'] | 'budgeted' | 'offbudget' | 'uncategorized',
+ accountId?: AccountEntity['id'] | 'onbudget' | 'offbudget' | 'uncategorized',
) {
let query = q('transactions').options({ splits: 'grouped' });
@@ -159,16 +159,16 @@ export function allAccountBalance() {
} satisfies Binding<'account', 'accounts-balance'>;
}
-export function budgetedAccountBalance() {
+export function onBudgetAccountBalance() {
return {
- name: `budgeted-accounts-balance`,
+ name: `onbudget-accounts-balance`,
query: q('transactions')
.filter({ 'account.offbudget': false, 'account.closed': false })
.calculate({ $sum: '$amount' }),
- } satisfies Binding<'account', 'budgeted-accounts-balance'>;
+ } satisfies Binding<'account', 'onbudget-accounts-balance'>;
}
-export function offbudgetAccountBalance() {
+export function offBudgetAccountBalance() {
return {
name: `offbudget-accounts-balance`,
query: q('transactions')
diff --git a/packages/loot-core/src/client/state-types/modals.d.ts b/packages/loot-core/src/client/state-types/modals.d.ts
index 11450cf4775..9a415988fcf 100644
--- a/packages/loot-core/src/client/state-types/modals.d.ts
+++ b/packages/loot-core/src/client/state-types/modals.d.ts
@@ -78,6 +78,37 @@ type FinanceModals = {
'delete-budget': { file: File };
+ 'duplicate-budget': {
+ /** The budget file to be duplicated */
+ file: File;
+ /**
+ * Indicates whether the duplication is initiated from the budget
+ * management page. This may affect the behavior or UI of the
+ * duplication process.
+ */
+ managePage?: boolean;
+ /**
+ * loadBudget indicates whether to open the 'original' budget, the
+ * new duplicated 'copy' budget, or no budget ('none'). If 'none'
+ * duplicate-budget stays on the same page.
+ */
+ loadBudget?: 'none' | 'original' | 'copy';
+ /**
+ * onComplete is called when the DuplicateFileModal is closed.
+ * @param event the event object will pass back the status of the
+ * duplicate process.
+ * 'success' if the budget was duplicated.
+ * 'failed' if the budget could not be duplicated. This will also
+ * pass an error on the event object.
+ * 'canceled' if the DuplicateFileModal was canceled.
+ * @returns
+ */
+ onComplete?: (event: {
+ status: 'success' | 'failed' | 'canceled';
+ error?: Error;
+ }) => void;
+ };
+
import: null;
'import-ynab4': null;
diff --git a/packages/loot-core/src/mocks/spreadsheet.ts b/packages/loot-core/src/mocks/spreadsheet.ts
index cdd02751c8a..87e4456a930 100644
--- a/packages/loot-core/src/mocks/spreadsheet.ts
+++ b/packages/loot-core/src/mocks/spreadsheet.ts
@@ -23,7 +23,7 @@ export function makeSpreadsheet() {
this._getNode(sheetName, name).value = value;
},
- bind(sheetName, binding, fields, cb) {
+ bind(sheetName, binding, cb) {
const { name } = binding;
const resolvedName = `${sheetName}!${name}`;
if (!this.observers[resolvedName]) {
diff --git a/packages/loot-core/src/platform/server/fs/index.web.ts b/packages/loot-core/src/platform/server/fs/index.web.ts
index 06eebb13ade..0d299888528 100644
--- a/packages/loot-core/src/platform/server/fs/index.web.ts
+++ b/packages/loot-core/src/platform/server/fs/index.web.ts
@@ -19,11 +19,11 @@ export { join };
export { getDocumentDir, getBudgetDir, _setDocumentDir } from './shared';
export const getDataDir = () => process.env.ACTUAL_DATA_DIR;
-export const pathToId = function (filepath) {
+export const pathToId = function (filepath: string): string {
return filepath.replace(/^\//, '').replace(/\//g, '-');
};
-function _exists(filepath) {
+function _exists(filepath: string): boolean {
try {
FS.readlink(filepath);
return true;
@@ -47,7 +47,7 @@ function _mkdirRecursively(dir) {
}
}
-function _createFile(filepath) {
+function _createFile(filepath: string) {
// This can create the file. Check if it exists, if not create a
// symlink if it's a sqlite file. Otherwise store in idb
@@ -67,7 +67,7 @@ function _createFile(filepath) {
return filepath;
}
-async function _readFile(filepath, opts?: { encoding?: string }) {
+async function _readFile(filepath: string, opts?: { encoding?: string }) {
// We persist stuff in /documents, but don't need to handle sqlite
// file specifically because those are symlinked to a separate
// filesystem and will be handled in the BlockedFS
@@ -88,7 +88,7 @@ async function _readFile(filepath, opts?: { encoding?: string }) {
throw new Error('File does not exist: ' + filepath);
}
- if (opts.encoding === 'utf8' && ArrayBuffer.isView(item.contents)) {
+ if (opts?.encoding === 'utf8' && ArrayBuffer.isView(item.contents)) {
return String.fromCharCode.apply(
null,
new Uint16Array(item.contents.buffer),
@@ -101,7 +101,7 @@ async function _readFile(filepath, opts?: { encoding?: string }) {
}
}
-function resolveLink(path) {
+function resolveLink(path: string): string {
try {
const { node } = FS.lookupPath(path, { follow: false });
return node.link ? FS.readlink(path) : path;
@@ -110,7 +110,7 @@ function resolveLink(path) {
}
}
-async function _writeFile(filepath, contents) {
+async function _writeFile(filepath: string, contents): Promise {
if (contents instanceof ArrayBuffer) {
contents = new Uint8Array(contents);
} else if (ArrayBuffer.isView(contents)) {
@@ -146,9 +146,53 @@ async function _writeFile(filepath, contents) {
} else {
FS.writeFile(resolveLink(filepath), contents);
}
+ return true;
}
-async function _removeFile(filepath) {
+async function _copySqlFile(
+ frompath: string,
+ topath: string,
+): Promise {
+ _createFile(topath);
+
+ const { store } = await idb.getStore(await idb.getDatabase(), 'files');
+ await idb.set(store, { filepath: topath, contents: '' });
+ const fromitem = await idb.get(store, frompath);
+ const fromDbPath = pathToId(fromitem.filepath);
+ const toDbPath = pathToId(topath);
+
+ const fromfile = BFS.backend.createFile(fromDbPath);
+ const tofile = BFS.backend.createFile(toDbPath);
+
+ try {
+ fromfile.open();
+ tofile.open();
+ const fileSize = fromfile.meta.size;
+ const blockSize = fromfile.meta.blockSize;
+
+ const buffer = new ArrayBuffer(blockSize);
+ const bufferView = new Uint8Array(buffer);
+
+ for (let i = 0; i < fileSize; i += blockSize) {
+ const bytesToRead = Math.min(blockSize, fileSize - i);
+ fromfile.read(bufferView, 0, bytesToRead, i);
+ tofile.write(bufferView, 0, bytesToRead, i);
+ }
+ } catch (error) {
+ tofile.close();
+ fromfile.close();
+ _removeFile(toDbPath);
+ console.error('Failed to copy database file', error);
+ return false;
+ } finally {
+ tofile.close();
+ fromfile.close();
+ }
+
+ return true;
+}
+
+async function _removeFile(filepath: string) {
if (!NO_PERSIST && filepath.startsWith('/documents')) {
const isDb = filepath.endsWith('.sqlite');
@@ -272,22 +316,39 @@ export const size = async function (filepath) {
return attrs.size;
};
-export const copyFile = async function (frompath, topath) {
- // TODO: This reads the whole file into memory, but that's probably
- // not a problem. This could be optimized
- const contents = await _readFile(frompath);
- return _writeFile(topath, contents);
+export const copyFile = async function (
+ frompath: string,
+ topath: string,
+): Promise {
+ let result = false;
+ try {
+ const contents = await _readFile(frompath);
+ result = await _writeFile(topath, contents);
+ } catch (error) {
+ if (frompath.endsWith('.sqlite') || topath.endsWith('.sqlite')) {
+ try {
+ result = await _copySqlFile(frompath, topath);
+ } catch (secondError) {
+ throw new Error(
+ `Failed to copy SQL file from ${frompath} to ${topath}: ${secondError.message}`,
+ );
+ }
+ } else {
+ throw error;
+ }
+ }
+ return result;
};
-export const readFile = async function (filepath, encoding = 'utf8') {
+export const readFile = async function (filepath: string, encoding = 'utf8') {
return _readFile(filepath, { encoding });
};
-export const writeFile = async function (filepath, contents) {
+export const writeFile = async function (filepath: string, contents) {
return _writeFile(filepath, contents);
};
-export const removeFile = async function (filepath) {
+export const removeFile = async function (filepath: string) {
return _removeFile(filepath);
};
diff --git a/packages/loot-core/src/server/accounts/rules.ts b/packages/loot-core/src/server/accounts/rules.ts
index 20f9ea2d219..449590370fb 100644
--- a/packages/loot-core/src/server/accounts/rules.ts
+++ b/packages/loot-core/src/server/accounts/rules.ts
@@ -201,6 +201,8 @@ const CONDITION_TYPES = {
'doesNotContain',
'notOneOf',
'and',
+ 'onBudget',
+ 'offBudget',
],
nullable: true,
parse(op, value, fieldName) {
@@ -518,6 +520,21 @@ export class Condition {
console.log('invalid regexp in matches condition', e);
return false;
}
+
+ case 'onBudget':
+ if (!object._account) {
+ return false;
+ }
+
+ return object._account.offbudget === 0;
+
+ case 'offBudget':
+ if (!object._account) {
+ return false;
+ }
+
+ return object._account.offbudget === 1;
+
default:
}
@@ -948,6 +965,8 @@ const OP_SCORES: Record = {
doesNotContain: 0,
matches: 0,
hasTags: 0,
+ onBudget: 0,
+ offBudget: 0,
};
function computeScore(rule: Rule): number {
diff --git a/packages/loot-core/src/server/accounts/sync.ts b/packages/loot-core/src/server/accounts/sync.ts
index aedf1e4f80f..42ff79b628b 100644
--- a/packages/loot-core/src/server/accounts/sync.ts
+++ b/packages/loot-core/src/server/accounts/sync.ts
@@ -523,6 +523,9 @@ export async function matchTransactions(
);
// The first pass runs the rules, and preps data for fuzzy matching
+ const accounts: AccountEntity[] = await db.getAccounts();
+ const accountsMap = new Map(accounts.map(account => [account.id, account]));
+
const transactionsStep1 = [];
for (const {
payee_name,
@@ -530,7 +533,7 @@ export async function matchTransactions(
subtransactions,
} of normalized) {
// Run the rules
- const trans = await runRules(originalTrans);
+ const trans = await runRules(originalTrans, accountsMap);
let match = null;
let fuzzyDataset = null;
@@ -673,9 +676,12 @@ export async function addTransactions(
{ rawPayeeName: true },
);
+ const accounts: AccountEntity[] = await db.getAccounts();
+ const accountsMap = new Map(accounts.map(account => [account.id, account]));
+
for (const { trans: originalTrans, subtransactions } of normalized) {
// Run the rules
- const trans = await runRules(originalTrans);
+ const trans = await runRules(originalTrans, accountsMap);
const finalTransaction = {
id: uuidv4(),
diff --git a/packages/loot-core/src/server/accounts/transaction-rules.ts b/packages/loot-core/src/server/accounts/transaction-rules.ts
index 2c2569a5fc8..8a8d52c72bd 100644
--- a/packages/loot-core/src/server/accounts/transaction-rules.ts
+++ b/packages/loot-core/src/server/accounts/transaction-rules.ts
@@ -13,10 +13,11 @@ import {
type TransactionEntity,
type RuleActionEntity,
type RuleEntity,
+ AccountEntity,
} from '../../types/models';
import { schemaConfig } from '../aql';
import * as db from '../db';
-import { getPayee, getPayeeByName, insertPayee } from '../db';
+import { getPayee, getPayeeByName, insertPayee, getAccount } from '../db';
import { getMappings } from '../db/mappings';
import { RuleError } from '../errors';
import { requiredFields, toDateRepr } from '../models';
@@ -274,8 +275,20 @@ function onApplySync(oldValues, newValues) {
}
// Runner
-export async function runRules(trans) {
- let finalTrans = await prepareTransactionForRules({ ...trans });
+export async function runRules(
+ trans,
+ accounts: Map | null = null,
+) {
+ let accountsMap = null;
+ if (accounts === null) {
+ accountsMap = new Map(
+ (await db.getAccounts()).map(account => [account.id, account]),
+ );
+ } else {
+ accountsMap = accounts;
+ }
+
+ let finalTrans = await prepareTransactionForRules({ ...trans }, accountsMap);
const rules = rankRules(
fastSetMerge(
@@ -291,7 +304,11 @@ export async function runRules(trans) {
return await finalizeTransactionForRules(finalTrans);
}
-function conditionSpecialCases(cond: Condition): Condition {
+function conditionSpecialCases(cond: Condition | null): Condition | null {
+ if (!cond) {
+ return cond;
+ }
+
//special cases that require multiple conditions
if (cond.op === 'is' && cond.field === 'category' && cond.value === null) {
return new Condition(
@@ -555,6 +572,12 @@ export function conditionsToAQL(conditions, { recurDateBounds = 100 } = {}) {
return {
$and: getValue(value).map(subExpr => mapConditionToActualQL(subExpr)),
};
+
+ case 'onBudget':
+ return { 'account.offbudget': false };
+ case 'offBudget':
+ return { 'account.offbudget': true };
+
default:
throw new Error('Unhandled operator: ' + op);
}
@@ -604,8 +627,14 @@ export async function applyActions(
return null;
}
+ const accounts: AccountEntity[] = await db.getAccounts();
const transactionsForRules = await Promise.all(
- transactions.map(prepareTransactionForRules),
+ transactions.map(transactions =>
+ prepareTransactionForRules(
+ transactions,
+ new Map(accounts.map(account => [account.id, account])),
+ ),
+ ),
);
const updated = transactionsForRules.flatMap(trans => {
@@ -836,10 +865,12 @@ export async function updateCategoryRules(transactions) {
export type TransactionForRules = TransactionEntity & {
payee_name?: string;
+ _account?: AccountEntity;
};
export async function prepareTransactionForRules(
trans: TransactionEntity,
+ accounts: Map | null = null,
): Promise {
const r: TransactionForRules = { ...trans };
if (trans.payee) {
@@ -849,6 +880,14 @@ export async function prepareTransactionForRules(
}
}
+ if (trans.account) {
+ if (accounts !== null && accounts.has(trans.account)) {
+ r._account = accounts.get(trans.account);
+ } else {
+ r._account = await getAccount(trans.account);
+ }
+ }
+
return r;
}
diff --git a/packages/loot-core/src/server/accounts/transfer.ts b/packages/loot-core/src/server/accounts/transfer.ts
index f9c81048378..fa8969667e0 100644
--- a/packages/loot-core/src/server/accounts/transfer.ts
+++ b/packages/loot-core/src/server/accounts/transfer.ts
@@ -26,7 +26,7 @@ async function clearCategory(transaction, transferAcct) {
[transferAcct],
);
- // If the transfer is between two on-budget or two off-budget accounts,
+ // If the transfer is between two on budget or two off budget accounts,
// we should clear the category, because the category is not relevant
if (fromOffBudget === toOffBudget) {
await db.updateTransaction({ id: transaction.id, category: null });
diff --git a/packages/loot-core/src/server/db/index.ts b/packages/loot-core/src/server/db/index.ts
index 0893c2aadc8..dc324fca85f 100644
--- a/packages/loot-core/src/server/db/index.ts
+++ b/packages/loot-core/src/server/db/index.ts
@@ -473,6 +473,10 @@ export async function getPayee(id) {
return first(`SELECT * FROM payees WHERE id = ?`, [id]);
}
+export async function getAccount(id) {
+ return first(`SELECT * FROM accounts WHERE id = ?`, [id]);
+}
+
export async function insertPayee(payee) {
payee = payeeModel.validate(payee);
let id;
diff --git a/packages/loot-core/src/server/main.ts b/packages/loot-core/src/server/main.ts
index aadde848273..89a29484f7a 100644
--- a/packages/loot-core/src/server/main.ts
+++ b/packages/loot-core/src/server/main.ts
@@ -73,7 +73,11 @@ import * as syncMigrations from './sync/migrate';
import { app as toolsApp } from './tools/app';
import { withUndo, clearUndo, undo, redo } from './undo';
import { updateVersion } from './update';
-import { uniqueFileName, idFromFileName } from './util/budget-name';
+import {
+ uniqueBudgetName,
+ idFromBudgetName,
+ validateBudgetName,
+} from './util/budget-name';
const DEMO_BUDGET_ID = '_demo-budget';
const TEST_BUDGET_ID = '_test-budget';
@@ -512,22 +516,8 @@ handlers['make-filters-from-conditions'] = async function ({ conditions }) {
};
handlers['getCell'] = async function ({ sheetName, name }) {
- // Fields is no longer used - hardcode
- const fields = ['name', 'value'];
const node = sheet.get()._getNode(resolveName(sheetName, name));
- if (fields) {
- const res = {};
- fields.forEach(field => {
- if (field === 'run') {
- res[field] = node._run ? node._run.toString() : null;
- } else {
- res[field] = node[field];
- }
- });
- return res;
- } else {
- return node;
- }
+ return { name: node.name, value: node.value };
};
handlers['getCells'] = async function ({ names }) {
@@ -1107,7 +1097,7 @@ handlers['accounts-bank-sync'] = async function ({ ids = [] }) {
const accounts = await db.runQuery(
`
- SELECT a.*, b.bank_id as bankId
+ SELECT a.*, b.bank_id as bankId
FROM accounts a
LEFT JOIN banks b ON a.bank = b.id
WHERE a.tombstone = 0 AND a.closed = 0
@@ -1710,6 +1700,14 @@ handlers['sync'] = async function () {
return fullSync();
};
+handlers['validate-budget-name'] = async function ({ name }) {
+ return validateBudgetName(name);
+};
+
+handlers['unique-budget-name'] = async function ({ name }) {
+ return uniqueBudgetName(name);
+};
+
handlers['get-budgets'] = async function () {
const paths = await fs.listDir(fs.getDocumentDir());
const budgets = (
@@ -1879,7 +1877,7 @@ handlers['close-budget'] = async function () {
}
prefs.unloadPrefs();
- stopBackupService();
+ await stopBackupService();
return 'ok';
};
@@ -1892,13 +1890,102 @@ handlers['delete-budget'] = async function ({ id, cloudFileId }) {
// If a local file exists, you can delete it by passing its local id
if (id) {
- const budgetDir = fs.getBudgetDir(id);
- await fs.removeDirRecursively(budgetDir);
+ // opening and then closing the database is a hack to be able to delete
+ // the budget file if it hasn't been opened yet. This needs a better
+ // way, but works for now.
+ try {
+ await db.openDatabase(id);
+ await db.closeDatabase();
+ const budgetDir = fs.getBudgetDir(id);
+ await fs.removeDirRecursively(budgetDir);
+ } catch (e) {
+ return 'fail';
+ }
}
return 'ok';
};
+handlers['duplicate-budget'] = async function ({
+ id,
+ newName,
+ cloudSync,
+ open,
+}): Promise {
+ if (!id) throw new Error('Unable to duplicate a budget that is not local.');
+
+ const { valid, message } = await validateBudgetName(newName);
+ if (!valid) throw new Error(message);
+
+ const budgetDir = fs.getBudgetDir(id);
+
+ const newId = await idFromBudgetName(newName);
+
+ // copy metadata from current budget
+ // replace id with new budget id and budgetName with new budget name
+ const metadataText = await fs.readFile(fs.join(budgetDir, 'metadata.json'));
+ const metadata = JSON.parse(metadataText);
+ metadata.id = newId;
+ metadata.budgetName = newName;
+ [
+ 'cloudFileId',
+ 'groupId',
+ 'lastUploaded',
+ 'encryptKeyId',
+ 'lastSyncedTimestamp',
+ ].forEach(item => {
+ if (metadata[item]) delete metadata[item];
+ });
+
+ try {
+ const newBudgetDir = fs.getBudgetDir(newId);
+ await fs.mkdir(newBudgetDir);
+
+ // write metadata for new budget
+ await fs.writeFile(
+ fs.join(newBudgetDir, 'metadata.json'),
+ JSON.stringify(metadata),
+ );
+
+ await fs.copyFile(
+ fs.join(budgetDir, 'db.sqlite'),
+ fs.join(newBudgetDir, 'db.sqlite'),
+ );
+ } catch (error) {
+ // Clean up any partially created files
+ try {
+ const newBudgetDir = fs.getBudgetDir(newId);
+ if (await fs.exists(newBudgetDir)) {
+ await fs.removeDirRecursively(newBudgetDir);
+ }
+ } catch {} // Ignore cleanup errors
+ throw new Error(`Failed to duplicate budget: ${error.message}`);
+ }
+
+ // load in and validate
+ const { error } = await loadBudget(newId);
+ if (error) {
+ console.log('Error duplicating budget: ' + error);
+ return error;
+ }
+
+ if (cloudSync) {
+ try {
+ await cloudStorage.upload();
+ } catch (error) {
+ console.warn('Failed to sync duplicated budget to cloud:', error);
+ // Ignore any errors uploading. If they are offline they should
+ // still be able to create files.
+ }
+ }
+
+ handlers['close-budget']();
+ if (open === 'original') await loadBudget(id);
+ if (open === 'copy') await loadBudget(newId);
+
+ return newId;
+};
+
handlers['create-budget'] = async function ({
budgetName,
avoidUpload,
@@ -1921,13 +2008,10 @@ handlers['create-budget'] = async function ({
} else {
// Generate budget name if not given
if (!budgetName) {
- // Unfortunately we need to load all of the existing files first
- // so we can detect conflicting names.
- const files = await handlers['get-budgets']();
- budgetName = await uniqueFileName(files);
+ budgetName = await uniqueBudgetName();
}
- id = await idFromFileName(budgetName);
+ id = await idFromBudgetName(budgetName);
}
const budgetDir = fs.getBudgetDir(id);
@@ -1993,8 +2077,8 @@ handlers['export-budget'] = async function () {
}
};
-async function loadBudget(id) {
- let dir;
+async function loadBudget(id: string) {
+ let dir: string;
try {
dir = fs.getBudgetDir(id);
} catch (e) {
@@ -2071,7 +2155,7 @@ async function loadBudget(id) {
!Platform.isMobile &&
process.env.NODE_ENV !== 'test'
) {
- startBackupService(id);
+ await startBackupService(id);
}
try {
diff --git a/packages/loot-core/src/server/util/budget-name.ts b/packages/loot-core/src/server/util/budget-name.ts
index 3c94888f0da..dfe492e5c51 100644
--- a/packages/loot-core/src/server/util/budget-name.ts
+++ b/packages/loot-core/src/server/util/budget-name.ts
@@ -1,16 +1,18 @@
-// @ts-strict-ignore
import { v4 as uuidv4 } from 'uuid';
import * as fs from '../../platform/server/fs';
+import { handlers } from '../main';
-export async function uniqueFileName(existingFiles) {
- const initialName = 'My Finances';
+export async function uniqueBudgetName(
+ initialName: string = 'My Finances',
+): Promise {
+ const budgets = await handlers['get-budgets']();
let idx = 1;
// If there is a conflict, keep appending an index until there is no
// conflict and we have a unique name
let newName = initialName;
- while (existingFiles.find(file => file.name === newName)) {
+ while (budgets.find(file => file.name === newName)) {
newName = `${initialName} ${idx}`;
idx++;
}
@@ -18,7 +20,25 @@ export async function uniqueFileName(existingFiles) {
return newName;
}
-export async function idFromFileName(name) {
+export async function validateBudgetName(
+ name: string,
+): Promise<{ valid: boolean; message?: string }> {
+ const trimmedName = name.trim();
+ const uniqueName = await uniqueBudgetName(trimmedName);
+ let message: string | null = null;
+
+ if (trimmedName === '') message = 'Budget name cannot be blank';
+ if (trimmedName.length > 100) {
+ message = 'Budget name is too long (max length 100)';
+ }
+ if (uniqueName !== trimmedName) {
+ message = `“${name}” already exists, try “${uniqueName}” instead`;
+ }
+
+ return message ? { valid: false, message } : { valid: true };
+}
+
+export async function idFromBudgetName(name: string): Promise {
let id = name.replace(/( |[^A-Za-z0-9])/g, '-') + '-' + uuidv4().slice(0, 7);
// Make sure the id is unique. There's a chance one could already
diff --git a/packages/loot-core/src/shared/query.ts b/packages/loot-core/src/shared/query.ts
index 4b38e62ac8c..b53ad149867 100644
--- a/packages/loot-core/src/shared/query.ts
+++ b/packages/loot-core/src/shared/query.ts
@@ -5,18 +5,18 @@ type ObjectExpression = {
};
export type QueryState = {
- table: string;
- tableOptions: Record;
- filterExpressions: Array;
- selectExpressions: Array;
- groupExpressions: Array;
- orderExpressions: Array;
- calculation: boolean;
- rawMode: boolean;
- withDead: boolean;
- validateRefs: boolean;
- limit: number | null;
- offset: number | null;
+ get table(): string;
+ get tableOptions(): Readonly>;
+ get filterExpressions(): ReadonlyArray;
+ get selectExpressions(): ReadonlyArray;
+ get groupExpressions(): ReadonlyArray;
+ get orderExpressions(): ReadonlyArray;
+ get calculation(): boolean;
+ get rawMode(): boolean;
+ get withDead(): boolean;
+ get validateRefs(): boolean;
+ get limit(): number | null;
+ get offset(): number | null;
};
export class Query {
@@ -76,15 +76,19 @@ export class Query {
exprs = [exprs];
}
- const query = new Query({ ...this.state, selectExpressions: exprs });
- query.state.calculation = false;
- return query;
+ return new Query({
+ ...this.state,
+ selectExpressions: exprs,
+ calculation: false,
+ });
}
calculate(expr: ObjectExpression | string) {
- const query = this.select({ result: expr });
- query.state.calculation = true;
- return query;
+ return new Query({
+ ...this.state,
+ selectExpressions: [{ result: expr }],
+ calculation: true,
+ });
}
groupBy(exprs: ObjectExpression | string | Array) {
@@ -140,6 +144,10 @@ export class Query {
serialize() {
return this.state;
}
+
+ serializeAsString() {
+ return JSON.stringify(this.serialize());
+ }
}
export function getPrimaryOrderBy(
diff --git a/packages/loot-core/src/shared/rules.ts b/packages/loot-core/src/shared/rules.ts
index cbfe591964f..82c837fe177 100644
--- a/packages/loot-core/src/shared/rules.ts
+++ b/packages/loot-core/src/shared/rules.ts
@@ -21,6 +21,8 @@ const TYPE_INFO = {
'isNot',
'doesNotContain',
'notOneOf',
+ 'onBudget',
+ 'offBudget',
],
nullable: true,
},
@@ -65,12 +67,16 @@ const FIELD_INFO = {
type: 'string',
disallowedOps: new Set(['hasTags']),
},
- payee: { type: 'id' },
+ payee: { type: 'id', disallowedOps: new Set(['onBudget', 'offBudget']) },
payee_name: { type: 'string' },
date: { type: 'date' },
notes: { type: 'string' },
amount: { type: 'number' },
- category: { type: 'id', internalOps: new Set(['and']) },
+ category: {
+ type: 'id',
+ disallowedOps: new Set(['onBudget', 'offBudget']),
+ internalOps: new Set(['and']),
+ },
account: { type: 'id' },
cleared: { type: 'boolean' },
reconciled: { type: 'boolean' },
@@ -199,6 +205,10 @@ export function friendlyOp(op, type?) {
return t('and');
case 'or':
return 'or';
+ case 'onBudget':
+ return 'is on budget';
+ case 'offBudget':
+ return 'is off budget';
default:
return '';
}
diff --git a/packages/loot-core/src/types/models/rule.d.ts b/packages/loot-core/src/types/models/rule.d.ts
index 5cbbe7d3697..8d85f2c6634 100644
--- a/packages/loot-core/src/types/models/rule.d.ts
+++ b/packages/loot-core/src/types/models/rule.d.ts
@@ -27,7 +27,9 @@ export type RuleConditionOp =
| 'doesNotContain'
| 'hasTags'
| 'and'
- | 'matches';
+ | 'matches'
+ | 'onBudget'
+ | 'offBudget';
type FieldValueTypes = {
account: string;
@@ -76,6 +78,8 @@ export type RuleConditionEntity =
| 'contains'
| 'doesNotContain'
| 'matches'
+ | 'onBudget'
+ | 'offBudget'
>
| BaseConditionEntity<
'category',
diff --git a/packages/loot-core/src/types/server-handlers.d.ts b/packages/loot-core/src/types/server-handlers.d.ts
index 4afbc72a3e8..92b872e54f5 100644
--- a/packages/loot-core/src/types/server-handlers.d.ts
+++ b/packages/loot-core/src/types/server-handlers.d.ts
@@ -178,7 +178,7 @@ export interface ServerHandlers {
'account-move': (arg: { id; targetId }) => Promise;
- 'secret-set': (arg: { name: string; value: string }) => Promise;
+ 'secret-set': (arg: { name: string; value: string | null }) => Promise;
'secret-check': (arg: string) => Promise;
'gocardless-poll-web-token': (arg: {
@@ -304,6 +304,12 @@ export interface ServerHandlers {
| { messages: Message[] }
>;
+ 'validate-budget-name': (arg: {
+ name: string;
+ }) => Promise<{ valid: boolean; message?: string }>;
+
+ 'unique-budget-name': (arg: { name: string }) => Promise;
+
'get-budgets': () => Promise;
'get-remote-files': () => Promise;
@@ -327,7 +333,24 @@ export interface ServerHandlers {
'delete-budget': (arg: {
id?: string;
cloudFileId?: string;
- }) => Promise<'ok'>;
+ }) => Promise<'ok' | 'fail'>;
+
+ /**
+ * Duplicates a budget file.
+ * @param {Object} arg - The arguments for duplicating a budget.
+ * @param {string} [arg.id] - The ID of the local budget to duplicate.
+ * @param {string} [arg.cloudId] - The ID of the cloud-synced budget to duplicate.
+ * @param {string} arg.newName - The name for the duplicated budget.
+ * @param {boolean} [arg.cloudSync] - Whether to sync the duplicated budget to the cloud.
+ * @returns {Promise} The ID of the newly created budget.
+ */
+ 'duplicate-budget': (arg: {
+ id?: string;
+ cloudId?: string;
+ newName: string;
+ cloudSync?: boolean;
+ open: 'none' | 'original' | 'copy';
+ }) => Promise;
'create-budget': (arg: {
budgetName?;
diff --git a/upcoming-release-notes/3775.md b/upcoming-release-notes/3775.md
new file mode 100644
index 00000000000..dc711646600
--- /dev/null
+++ b/upcoming-release-notes/3775.md
@@ -0,0 +1,6 @@
+---
+category: Enhancements
+authors: [UnderKoen]
+---
+
+Position context menus on the to budget page to the cursor, and make popovers non selectable.
diff --git a/upcoming-release-notes/3847.md b/upcoming-release-notes/3847.md
new file mode 100644
index 00000000000..785e81f7abe
--- /dev/null
+++ b/upcoming-release-notes/3847.md
@@ -0,0 +1,6 @@
+---
+category: Features
+authors: [tlesicka]
+---
+
+Added ability to duplicate budgets.
diff --git a/upcoming-release-notes/3879.md b/upcoming-release-notes/3879.md
new file mode 100644
index 00000000000..89887d8dedf
--- /dev/null
+++ b/upcoming-release-notes/3879.md
@@ -0,0 +1,6 @@
+---
+category: Maintenance
+authors: [joel-jeremy]
+---
+
+Optimize useSheetValue hook
diff --git a/upcoming-release-notes/3891.md b/upcoming-release-notes/3891.md
new file mode 100644
index 00000000000..034344b1d13
--- /dev/null
+++ b/upcoming-release-notes/3891.md
@@ -0,0 +1,6 @@
+---
+category: Enhancements
+authors: [lelemm]
+---
+
+Filter accounts when on budget or off budget
diff --git a/upcoming-release-notes/3899.md b/upcoming-release-notes/3899.md
new file mode 100644
index 00000000000..f330782869e
--- /dev/null
+++ b/upcoming-release-notes/3899.md
@@ -0,0 +1,6 @@
+---
+category: Maintenance
+authors: [joel-jeremy]
+---
+
+Convert BudgetTable.jsx to TypeScript
diff --git a/upcoming-release-notes/3900.md b/upcoming-release-notes/3900.md
new file mode 100644
index 00000000000..6482218bc0c
--- /dev/null
+++ b/upcoming-release-notes/3900.md
@@ -0,0 +1,6 @@
+---
+category: Enhancements
+authors: [joel-jeremy]
+---
+
+Add loading indicator when loading more transactions in mobile transaction list.
diff --git a/upcoming-release-notes/3903.md b/upcoming-release-notes/3903.md
new file mode 100644
index 00000000000..14f2456f37c
--- /dev/null
+++ b/upcoming-release-notes/3903.md
@@ -0,0 +1,6 @@
+---
+category: Maintenance
+authors: [joel-jeremy]
+---
+
+Use consistent terms for on budget accounts i.e. `For budget`/`Budgeted` --> `On budget`.
diff --git a/upcoming-release-notes/3911.md b/upcoming-release-notes/3911.md
new file mode 100644
index 00000000000..01f7348949c
--- /dev/null
+++ b/upcoming-release-notes/3911.md
@@ -0,0 +1,6 @@
+---
+category: Maintenance
+authors: [joel-jeremy]
+---
+
+Remove usage of useActions hook
diff --git a/upcoming-release-notes/3942.md b/upcoming-release-notes/3942.md
new file mode 100644
index 00000000000..c66d2b9326b
--- /dev/null
+++ b/upcoming-release-notes/3942.md
@@ -0,0 +1,6 @@
+---
+category: Bugfix
+authors: [MatissJanis]
+---
+
+Fix misaligned gocardless credential popover.
diff --git a/upcoming-release-notes/3943.md b/upcoming-release-notes/3943.md
new file mode 100644
index 00000000000..988e0ac840c
--- /dev/null
+++ b/upcoming-release-notes/3943.md
@@ -0,0 +1,6 @@
+---
+category: Bugfix
+authors: [MatissJanis]
+---
+
+Fix rule creation throwing error for "notes contains (nothing)" condition.
diff --git a/upcoming-release-notes/3944.md b/upcoming-release-notes/3944.md
new file mode 100644
index 00000000000..5d73da1eeec
--- /dev/null
+++ b/upcoming-release-notes/3944.md
@@ -0,0 +1,6 @@
+---
+category: Bugfix
+authors: [adamhl8]
+---
+
+Fix tracking budget docs link in settings.