diff --git a/packages/desktop-client/src/components/Modals.tsx b/packages/desktop-client/src/components/Modals.tsx
index a52f6fef71d..58ba6f051f4 100644
--- a/packages/desktop-client/src/components/Modals.tsx
+++ b/packages/desktop-client/src/components/Modals.tsx
@@ -45,6 +45,7 @@ import { KeyboardShortcutModal } from './modals/KeyboardShortcutModal';
import { LoadBackupModal } from './modals/LoadBackupModal';
import { ConfirmChangeDocumentDirModal } from './modals/manager/ConfirmChangeDocumentDir';
import { DeleteFileModal } from './modals/manager/DeleteFileModal';
+import { DuplicateFileModal } from './modals/manager/DuplicateFileModal';
import { FilesSettingsModal } from './modals/manager/FilesSettingsModal';
import { ImportActualModal } from './modals/manager/ImportActualModal';
import { ImportModal } from './modals/manager/ImportModal';
@@ -586,6 +587,16 @@ export function Modals() {
return ;
case 'delete-budget':
return ;
+ case 'duplicate-budget':
+ return (
+
+ );
case 'import':
return ;
case 'files-settings':
diff --git a/packages/desktop-client/src/components/manager/BudgetList.tsx b/packages/desktop-client/src/components/manager/BudgetList.tsx
index 72a3ebab0c0..3fef3015d61 100644
--- a/packages/desktop-client/src/components/manager/BudgetList.tsx
+++ b/packages/desktop-client/src/components/manager/BudgetList.tsx
@@ -64,9 +64,11 @@ function getFileDescription(file: File, t: (key: string) => string) {
function FileMenu({
onDelete,
onClose,
+ onDuplicate,
}: {
onDelete: () => void;
onClose: () => void;
+ onDuplicate?: () => void;
}) {
function onMenuSelect(type: string) {
onClose();
@@ -75,18 +77,30 @@ function FileMenu({
case 'delete':
onDelete();
break;
+ case 'duplicate':
+ if (onDuplicate) onDuplicate();
+ break;
default:
}
}
const { t } = useTranslation();
- const items = [{ name: 'delete', text: t('Delete') }];
+ const items = [
+ ...(onDuplicate ? [{ name: 'duplicate', text: t('Duplicate') }] : []),
+ { name: 'delete', text: t('Delete') },
+ ];
return
;
}
-function FileMenuButton({ onDelete }: { onDelete: () => void }) {
+function FileMenuButton({
+ onDelete,
+ onDuplicate,
+}: {
+ onDelete: () => void;
+ onDuplicate?: () => void;
+}) {
const triggerRef = useRef(null);
const [menuOpen, setMenuOpen] = useState(false);
@@ -108,7 +122,11 @@ function FileMenuButton({ onDelete }: { onDelete: () => void }) {
isOpen={menuOpen}
onOpenChange={() => setMenuOpen(false)}
>
- setMenuOpen(false)} />
+ setMenuOpen(false)}
+ onDuplicate={onDuplicate}
+ />
);
@@ -169,11 +187,13 @@ function FileItem({
quickSwitchMode,
onSelect,
onDelete,
+ onDuplicate,
}: {
file: File;
quickSwitchMode: boolean;
onSelect: (file: File) => void;
onDelete: (file: File) => void;
+ onDuplicate: (file: File) => void;
}) {
const { t } = useTranslation();
@@ -239,7 +259,10 @@ function FileItem({
)}
{!quickSwitchMode && (
- onDelete(file)} />
+ onDelete(file)}
+ onDuplicate={'id' in file ? () => onDuplicate(file) : undefined}
+ />
)}
@@ -252,11 +275,13 @@ function BudgetFiles({
quickSwitchMode,
onSelect,
onDelete,
+ onDuplicate,
}: {
files: File[];
quickSwitchMode: boolean;
onSelect: (file: File) => void;
onDelete: (file: File) => void;
+ onDuplicate: (file: File) => void;
}) {
function isLocalFile(file: File): file is LocalFile {
return file.state === 'local';
@@ -292,6 +317,7 @@ function BudgetFiles({
quickSwitchMode={quickSwitchMode}
onSelect={onSelect}
onDelete={onDelete}
+ onDuplicate={onDuplicate}
/>
))
)}
@@ -467,7 +493,16 @@ export function BudgetList({ showHeader = true, quickSwitchMode = false }) {
files={files}
quickSwitchMode={quickSwitchMode}
onSelect={onSelect}
- onDelete={file => dispatch(pushModal('delete-budget', { file }))}
+ onDelete={(file: File) =>
+ dispatch(pushModal('delete-budget', { file }))
+ }
+ onDuplicate={(file: File) => {
+ if (file && 'id' in file) {
+ dispatch(pushModal('duplicate-budget', { file, managePage: true }));
+ } else {
+ console.error('Attempted to duplicate an invalid file:', file);
+ }
+ }}
/>
{!quickSwitchMode && (
{backups.map((backup, idx) => (
@@ -32,7 +39,11 @@ function BackupTable({ backups, onSelect }: BackupTableProps) {
>
|
@@ -52,8 +63,10 @@ export function LoadBackupModal({
watchUpdates,
backupDisabled,
}: LoadBackupModalProps) {
+ const { t } = useTranslation();
const dispatch = useDispatch();
const [backups, setBackups] = useState([]);
+ const [loading, setLoading] = useState<'revert' | 'backup' | null>(null);
const [prefsBudgetId] = useMetadataPref('id');
const budgetIdToLoad = budgetId ?? prefsBudgetId;
@@ -74,7 +87,6 @@ export function LoadBackupModal({
const previousBackups = backups.filter(
backup => !('isLatest' in backup ? backup.isLatest : false),
);
- const { t } = useTranslation();
return (
@@ -103,14 +115,39 @@ export function LoadBackupModal({
'You can load a different backup or revert to the original version below.',
)}
-
+
) : (
@@ -124,13 +161,35 @@ export function LoadBackupModal({
)}
-
+
)}
@@ -141,7 +200,18 @@ export function LoadBackupModal({
) : (
dispatch(loadBackup(budgetIdToLoad, id))}
+ onSelect={id => {
+ try {
+ dispatch(loadBackup(budgetIdToLoad, id));
+ } catch (error) {
+ dispatch(
+ addNotification({
+ type: 'error',
+ message: t('Unable to load backup.'),
+ }),
+ );
+ }
+ }}
/>
)}
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..028ff038bd2
--- /dev/null
+++ b/packages/desktop-client/src/components/modals/manager/DuplicateFileModal.tsx
@@ -0,0 +1,253 @@
+import React, { useState } from 'react';
+import { Trans, useTranslation } from 'react-i18next';
+import { useDispatch } from 'react-redux';
+
+import { addNotification, duplicateBudget } 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 [newName, setNewName] = useState(file.name + ' - copy');
+ 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 dispatch = useDispatch();
+
+ const [loadingState, setLoadingState] = useState<'cloud' | 'local' | null>(
+ null,
+ );
+
+ const validateNewName = (name: string): string | null => {
+ const trimmedName = name.trim();
+ if (trimmedName === '') return t('Budget name cannot be blank');
+ if (trimmedName.length > 100) {
+ return t('Budget name is too long (max length 100)');
+ }
+ if (!/^[a-zA-Z0-9 .\-_()]+$/.test(trimmedName)) {
+ return t('Budget name contains invalid characters');
+ }
+ // Additional validation checks can go here
+
+ return null;
+ };
+
+ const validateAndSetName = (name: string) => {
+ const trimmedName = name.trim();
+ const error = validateNewName(trimmedName);
+ if (error) {
+ setNameError(error);
+ } else {
+ setNewName(trimmedName);
+ setNameError(null);
+ }
+ };
+
+ const handleDuplicate = async (sync: 'localOnly' | 'cloudSync') => {
+ const error = validateNewName(newName);
+ if (!error) {
+ 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.'),
+ }),
+ );
+ if (onComplete) onComplete({ status: 'success' });
+ } catch (e) {
+ const newError = new Error('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(error);
+ 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}
+
+ )}
+
+ {isCloudFile && (
+ <>
+
+
+ Current budget is a hosted budget which
+ means it is stored on your server to make it available for
+ download on any device. Would you like to duplicate this
+ budget for all devices?
+
+
+
+ handleDuplicate('cloudSync')}
+ >
+ Duplicate budget for all devices
+
+ >
+ )}
+
+ {'id' in file && (
+ <>
+ {isCloudFile ? (
+
+
+ You can also duplicate to just the local copy. This will
+ leave the original on the server and create a duplicate on
+ only this device.
+
+
+ ) : (
+
+
+ This is a local budget which is not
+ stored on a server. Only a local copy will be duplicated.
+
+
+ )}
+
+
+
+ handleDuplicate('localOnly')}
+ >
+ Duplicate budget
+ {isCloudFile && locally only}
+
+
+ >
+ )}
+
+ >
+ )}
+
+ );
+}
diff --git a/packages/desktop-client/src/components/sidebar/BudgetName.tsx b/packages/desktop-client/src/components/sidebar/BudgetName.tsx
index d7b97e4365b..422fc6d3972 100644
--- a/packages/desktop-client/src/components/sidebar/BudgetName.tsx
+++ b/packages/desktop-client/src/components/sidebar/BudgetName.tsx
@@ -1,9 +1,15 @@
import React, { type ReactNode, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
-import { useDispatch } from 'react-redux';
+import { useDispatch, useSelector } from 'react-redux';
-import { closeBudget } from 'loot-core/src/client/actions';
+import { closeBudget, replaceModal } from 'loot-core/src/client/actions';
import * as Platform from 'loot-core/src/client/platform';
+import {
+ type File,
+ type LocalFile,
+ type SyncableLocalFile,
+ type SyncedLocalFile,
+} from 'loot-core/types/file';
import { useMetadataPref } from '../../hooks/useMetadataPref';
import { useNavigate } from '../../hooks/useNavigate';
@@ -58,6 +64,19 @@ function EditableBudgetName() {
const triggerRef = useRef(null);
const [editing, setEditing] = useState(false);
+ const [id] = useMetadataPref('id');
+ const allFiles = useSelector(state => state.budgets.allFiles || []);
+
+ function isNonRemoteFile(
+ file: File,
+ ): file is LocalFile | SyncableLocalFile | SyncedLocalFile {
+ return file.state !== 'remote';
+ }
+
+ const nonRemoteFiles = allFiles.filter(isNonRemoteFile);
+ const budgetFile = id ? nonRemoteFiles.find(f => f.id === id) : null;
+ const budgetId = budgetFile?.id ? budgetFile.id : undefined;
+
function onMenuSelect(type: string) {
setMenuOpen(false);
@@ -65,6 +84,11 @@ function EditableBudgetName() {
case 'rename':
setEditing(true);
break;
+ case 'backups':
+ if (budgetId) {
+ dispatch(replaceModal('load-backup', { budgetId }));
+ }
+ break;
case 'settings':
navigate('/settings');
break;
@@ -77,6 +101,7 @@ function EditableBudgetName() {
const items = [
{ name: 'rename', text: t('Rename budget') },
+ { name: 'backups', text: t('Backups') },
{ name: 'settings', text: t('Settings') },
{ name: 'close', text: t('Close file') },
];
diff --git a/packages/loot-core/src/client/actions/budgets.ts b/packages/loot-core/src/client/actions/budgets.ts
index 96dc92434a2..1b7ffa172aa 100644
--- a/packages/loot-core/src/client/actions/budgets.ts
+++ b/packages/loot-core/src/client/actions/budgets.ts
@@ -148,6 +148,59 @@ export function createBudget({ testMode = false, demoMode = false } = {}) {
};
}
+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 + t(' -- to: ') + 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);
+ dispatch(setAppState({ loadingText: null }));
+ throw new Error('Error duplicating budget:');
+ } finally {
+ dispatch(setAppState({ loadingText: null }));
+ }
+ };
+}
+
export function importBudget(
filepath: string,
type: Parameters[0]['type'],
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..04fdeacfe18 100644
--- a/packages/loot-core/src/client/state-types/modals.d.ts
+++ b/packages/loot-core/src/client/state-types/modals.d.ts
@@ -49,7 +49,13 @@ type FinanceModals = {
| { group: string }
);
- 'load-backup': EmptyObject;
+ 'load-backup': {
+ /**
+ * The ID of the budget to load a backup for.
+ * If undefined, it may indicate loading a backup for the current budget or a user-selected budget.
+ */
+ budgetId: string | undefined;
+ };
'manage-rules': { payeeId?: string };
'edit-rule': {
@@ -78,6 +84,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/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/backups.d.ts b/packages/loot-core/src/server/backups.d.ts
new file mode 100644
index 00000000000..7eda225f234
--- /dev/null
+++ b/packages/loot-core/src/server/backups.d.ts
@@ -0,0 +1,24 @@
+export type Backup =
+ | { id: string; date: Date; isLatest?: boolean }
+ | LatestBackup;
+export type LatestBackup = { id: string; date: null; isLatest: boolean };
+
+export function getAvailableBackups(id: string): Promise;
+
+export function updateBackups(backups: Backup[]): Promise;
+
+export function makeBackup(id: string): Promise;
+
+/**
+ * Removes all backup files associated with the specified budget ID.
+ * This function is typically used when deleting a budget to ensure all related backups are also removed.
+ * @param {string} id - The ID of the budget whose backups should be removed.
+ * @returns {Promise} A promise that resolves to true if all backups were successfully removed, false otherwise.
+ */
+export function removeAllBackups(id: string): Promise;
+
+export function loadBackup(id: string, backupId: string): Promise;
+
+export function startBackupService(id: string): Promise;
+
+export function stopBackupService(): Promise;
diff --git a/packages/loot-core/src/server/backups.ts b/packages/loot-core/src/server/backups.electron.ts
similarity index 73%
rename from packages/loot-core/src/server/backups.ts
rename to packages/loot-core/src/server/backups.electron.ts
index a3ad3eb10e8..83a9ab59964 100644
--- a/packages/loot-core/src/server/backups.ts
+++ b/packages/loot-core/src/server/backups.electron.ts
@@ -1,4 +1,3 @@
-// @ts-strict-ignore
import * as dateFns from 'date-fns';
import { v4 as uuidv4 } from 'uuid';
@@ -7,23 +6,21 @@ import * as fs from '../platform/server/fs';
import * as sqlite from '../platform/server/sqlite';
import * as monthUtils from '../shared/months';
+import { type Backup, type LatestBackup } from './backups';
import * as cloudStorage from './cloud-storage';
import * as prefs from './prefs';
// A special backup that represents the latest version of the db that
// can be reverted to after loading a backup
const LATEST_BACKUP_FILENAME = 'db.latest.sqlite';
-let serviceInterval = null;
-export type Backup = { id: string; date: string } | LatestBackup;
-type LatestBackup = { id: string; date: null; isLatest: true };
-type BackupWithDate = { id: string; date: Date };
+let serviceInterval: NodeJS.Timeout | null = null;
-async function getBackups(id: string): Promise {
+async function getBackups(id: string): Promise {
const budgetDir = fs.getBudgetDir(id);
const backupDir = fs.join(budgetDir, 'backups');
- let paths = [];
+ let paths = [] as string[];
if (await fs.exists(backupDir)) {
paths = await fs.listDir(backupDir);
paths = paths.filter(file => file.match(/\.sqlite$/));
@@ -71,21 +68,22 @@ export async function getAvailableBackups(id: string): Promise {
backups.unshift(latestBackup);
}
- return backups.map(backup => ({
- ...backup,
- date: backup.date ? dateFns.format(backup.date, 'yyyy-MM-dd H:mm') : null,
- }));
+ return backups;
}
-export async function updateBackups(backups) {
- const byDay = backups.reduce((groups, backup) => {
- const day = dateFns.format(backup.date, 'yyyy-MM-dd');
- groups[day] = groups[day] || [];
- groups[day].push(backup);
+export async function updateBackups(backups: Backup[]): Promise {
+ type GroupByDay = { [index: string]: Backup[] };
+
+ const byDay = backups.reduce((groups: GroupByDay, backup: Backup) => {
+ if (backup.date) {
+ const day = dateFns.format(backup.date, 'yyyy-MM-dd');
+ groups[day] = groups[day] || ([] as Backup[]);
+ groups[day].push(backup);
+ }
return groups;
- }, {});
+ }, {} as GroupByDay);
- const removed = [];
+ const removed = [] as string[];
for (const day of Object.keys(byDay)) {
const dayBackups = byDay[day];
const isToday = day === monthUtils.currentDay();
@@ -135,6 +133,39 @@ export async function makeBackup(id: string) {
connection.send('backups-updated', await getAvailableBackups(id));
}
+/**
+ * Removes all backup files associated with the specified budget ID.
+ * This function is typically used when deleting a budget to ensure all related backups are also removed.
+ * @param {string} id - The ID of the budget whose backups should be removed.
+ * @returns {Promise} A promise that resolves to true if all backups were successfully removed, false otherwise.
+ */
+export async function removeAllBackups(id: string): Promise {
+ const budgetDir = fs.getBudgetDir(id);
+ const backupsDir = fs.join(budgetDir, 'backups');
+
+ if (!(await fs.exists(backupsDir))) {
+ return true; // No backups to remove
+ }
+
+ const toRemove = await getAvailableBackups(id);
+ let success = true;
+
+ for (const item of toRemove) {
+ try {
+ if (item?.isLatest) {
+ await fs.removeFile(fs.join(budgetDir, LATEST_BACKUP_FILENAME));
+ } else {
+ await fs.removeFile(fs.join(backupsDir, item.id));
+ }
+ } catch (error) {
+ console.error(`Failed to remove backup ${item.id}:`, error);
+ success = false;
+ }
+ }
+
+ return success;
+}
+
export async function loadBackup(id: string, backupId: string) {
const budgetDir = fs.getBudgetDir(id);
@@ -153,8 +184,8 @@ export async function loadBackup(id: string, backupId: string) {
// Restart the backup service to make sure the user has the full
// amount of time to figure out which one they want
- stopBackupService();
- startBackupService(id);
+ await stopBackupService();
+ await startBackupService(id);
await prefs.loadPrefs(id);
}
@@ -189,9 +220,9 @@ export async function loadBackup(id: string, backupId: string) {
// will be restored if the user reverts to the original version)
await prefs.loadPrefs(id);
await prefs.savePrefs({
- groupId: null,
- lastSyncedTimestamp: null,
- lastUploaded: null,
+ groupId: undefined,
+ lastSyncedTimestamp: undefined,
+ lastUploaded: undefined,
});
// Re-upload the new file
@@ -208,7 +239,7 @@ export async function loadBackup(id: string, backupId: string) {
}
}
-export function startBackupService(id: string) {
+export async function startBackupService(id: string): Promise {
if (serviceInterval) {
clearInterval(serviceInterval);
}
@@ -223,7 +254,9 @@ export function startBackupService(id: string) {
);
}
-export function stopBackupService() {
- clearInterval(serviceInterval);
- serviceInterval = null;
+export async function stopBackupService(): Promise {
+ if (serviceInterval) {
+ clearInterval(serviceInterval);
+ serviceInterval = null;
+ }
}
diff --git a/packages/loot-core/src/server/backups.web.ts b/packages/loot-core/src/server/backups.web.ts
new file mode 100644
index 00000000000..df1c046b820
--- /dev/null
+++ b/packages/loot-core/src/server/backups.web.ts
@@ -0,0 +1,271 @@
+import { type Database } from '@jlongster/sql.js';
+import * as dateFns from 'date-fns';
+
+import * as connection from '../platform/server/connection';
+import * as fs from '../platform/server/fs';
+import * as sqlite from '../platform/server/sqlite';
+import * as monthUtils from '../shared/months';
+
+import { type Backup, type LatestBackup } from './backups';
+import * as cloudStorage from './cloud-storage';
+import * as prefs from './prefs';
+
+// A special backup that represents the latest version of the db that
+// can be reverted to after loading a backup
+const LATEST_BACKUP_FILENAME = 'db.latest.sqlite';
+
+const FILE_TIMESTAMP_FORMAT = 'yyyyMMddHHmmssSSS';
+
+let serviceInterval: NodeJS.Timeout | null = null;
+
+async function getBackups(id: string): Promise {
+ const budgetDir = fs.getBudgetDir(id);
+
+ let paths = [] as string[];
+ paths = await fs.listDir(budgetDir);
+ paths = paths.filter(file => file.match(/db\.backup\.sqlite$/));
+
+ const backups = await Promise.all(
+ paths.map(async path => {
+ const dateString = path.substring(0, 17); // 'yyyyMMddHHmmssSSS'
+ const date = dateFns.parse(dateString, FILE_TIMESTAMP_FORMAT, new Date());
+
+ if (date.toString() === 'Invalid Date') return null;
+
+ return {
+ id: path,
+ date,
+ };
+ }),
+ );
+
+ const validBackups = backups.filter(backup => backup !== null);
+
+ validBackups.sort((b1, b2) => b2.date.getTime() - b1.date.getTime());
+
+ return validBackups;
+}
+
+async function getLatestBackup(id: string): Promise {
+ const budgetDir = fs.getBudgetDir(id);
+ if (await fs.exists(fs.join(budgetDir, LATEST_BACKUP_FILENAME))) {
+ return {
+ id: LATEST_BACKUP_FILENAME,
+ date: null,
+ isLatest: true,
+ };
+ }
+ return null;
+}
+
+export async function getAvailableBackups(id: string): Promise {
+ const backups = await getBackups(id);
+
+ const latestBackup = await getLatestBackup(id);
+ if (latestBackup) {
+ backups.unshift(latestBackup);
+ }
+
+ return backups;
+}
+
+export async function updateBackups(backups: Backup[]): Promise {
+ type GroupByDay = { [index: string]: Backup[] };
+
+ const byDay = backups.reduce((groups: GroupByDay, backup: Backup) => {
+ if (backup.date) {
+ const day = dateFns.format(backup.date, 'yyyy-MM-dd');
+ groups[day] = groups[day] || ([] as Backup[]);
+ groups[day].push(backup);
+ }
+ return groups;
+ }, {} as GroupByDay);
+
+ const removed = [] as string[];
+ for (const day of Object.keys(byDay)) {
+ const dayBackups = byDay[day];
+ const isToday = day === monthUtils.currentDay();
+ // Allow 3 backups of the current day (so fine-grained edits are
+ // kept around). Otherwise only keep around one backup per day.
+ // And only keep a total of 10 backups.
+ for (const backup of dayBackups.slice(isToday ? 3 : 1)) {
+ removed.push(backup.id);
+ }
+ }
+
+ // Get the list of remaining backups and only keep the latest 10
+ const currentBackups = backups.filter(backup => !removed.includes(backup.id));
+ return removed.concat(currentBackups.slice(10).map(backup => backup.id));
+}
+
+export async function makeBackup(id: string) {
+ const budgetDir = fs.getBudgetDir(id);
+
+ // When making a backup, we no longer consider the user to be
+ // viewing any backups. If there exists a "latest backup" we should
+ // delete it and consider whatever is current as the latest
+ if (await fs.exists(fs.join(budgetDir, LATEST_BACKUP_FILENAME))) {
+ await fs.removeFile(fs.join(budgetDir, LATEST_BACKUP_FILENAME));
+ }
+
+ const currentTime = new Date();
+ const backupId = `${dateFns.format(currentTime, FILE_TIMESTAMP_FORMAT)}-db.backup.sqlite`;
+
+ await fs.copyFile(
+ fs.join(budgetDir, 'db.sqlite'),
+ fs.join(budgetDir, backupId),
+ );
+
+ // Remove all the messages from the backup
+ let db: Database | null = null;
+ try {
+ db = await sqlite.openDatabase(fs.join(budgetDir, backupId));
+ if (db) {
+ await sqlite.runQuery(db, 'DELETE FROM messages_crdt');
+ await sqlite.runQuery(db, 'DELETE FROM messages_clock');
+ }
+ } catch (error) {
+ console.error('Error cleaning up backup messages:', error);
+ } finally {
+ if (db) {
+ sqlite.closeDatabase(db);
+ }
+ }
+
+ const toRemove = await updateBackups(await getBackups(id));
+ for (const id of toRemove) {
+ try {
+ await fs.removeFile(fs.join(budgetDir, id));
+ } catch (error) {
+ console.error(`Failed to remove backup ${id}:`, error);
+ }
+ }
+
+ connection.send('backups-updated', await getAvailableBackups(id));
+}
+
+/**
+ * Removes all backup files associated with the specified budget ID.
+ * This function is typically used when deleting a budget to ensure all related backups are also removed.
+ * @param {string} id - The ID of the budget whose backups should be removed.
+ * @returns {Promise} A promise that resolves to true if all backups were successfully removed, false otherwise.
+ */
+export async function removeAllBackups(id: string): Promise {
+ const budgetDir = fs.getBudgetDir(id);
+ const toRemove = await getAvailableBackups(id);
+ let success = true;
+
+ for (const item of toRemove) {
+ try {
+ await fs.removeFile(fs.join(budgetDir, item.id));
+ } catch (error) {
+ console.error(`Failed to remove backup ${item.id}:`, error);
+ success = false;
+ }
+ }
+
+ return success;
+}
+
+export async function loadBackup(id: string, backupId: string) {
+ const budgetDir = fs.getBudgetDir(id);
+
+ if (!(await fs.exists(fs.join(budgetDir, LATEST_BACKUP_FILENAME)))) {
+ // If this is the first time we're loading a backup, save the
+ // current version so the user can easily revert back to it
+ await fs.copyFile(
+ fs.join(budgetDir, 'db.sqlite'),
+ fs.join(budgetDir, LATEST_BACKUP_FILENAME),
+ );
+
+ await fs.copyFile(
+ fs.join(budgetDir, 'metadata.json'),
+ fs.join(budgetDir, 'metadata.latest.json'),
+ );
+
+ // Restart the backup service to make sure the user has the full
+ // amount of time to figure out which one they want
+ await stopBackupService();
+ await startBackupService(id);
+
+ await prefs.loadPrefs(id);
+ }
+
+ if (backupId === LATEST_BACKUP_FILENAME) {
+ console.log('Reverting backup');
+
+ // If reverting back to the latest, copy and delete the latest
+ // backup
+ await fs.copyFile(
+ fs.join(budgetDir, LATEST_BACKUP_FILENAME),
+ fs.join(budgetDir, 'db.sqlite'),
+ );
+ await fs.copyFile(
+ fs.join(budgetDir, 'metadata.latest.json'),
+ fs.join(budgetDir, 'metadata.json'),
+ );
+ await fs.removeFile(fs.join(budgetDir, LATEST_BACKUP_FILENAME));
+ await fs.removeFile(fs.join(budgetDir, 'metadata.latest.json'));
+
+ // Re-upload the new file
+ try {
+ await cloudStorage.upload();
+ } catch (error) {
+ console.error('Error uploading to cloud storage:', error);
+ }
+ prefs.unloadPrefs();
+ } else {
+ console.log('Loading backup', backupId);
+
+ // This function is only ever called when a budget isn't loaded,
+ // so it's safe to load our prefs in. We need to forget about any
+ // syncing data if we are loading a backup (the current sync data
+ // will be restored if the user reverts to the original version)
+ await prefs.loadPrefs(id);
+ await prefs.savePrefs({
+ groupId: undefined,
+ lastSyncedTimestamp: undefined,
+ lastUploaded: undefined,
+ });
+
+ // Re-upload the new file
+ try {
+ await cloudStorage.upload();
+ } catch (error) {
+ console.error('Error uploading to cloud storage:', error);
+ }
+
+ prefs.unloadPrefs();
+
+ await fs.copyFile(
+ fs.join(budgetDir, backupId),
+ fs.join(budgetDir, 'db.sqlite'),
+ );
+ }
+}
+
+export async function startBackupService(id: string): Promise {
+ if (serviceInterval) {
+ clearInterval(serviceInterval);
+ }
+
+ // Make a backup every 15 minutes
+ serviceInterval = setInterval(
+ async () => {
+ try {
+ console.log('Making backup');
+ await makeBackup(id);
+ } catch (error) {
+ console.error('Error making backup:', error);
+ }
+ },
+ 1000 * 60 * 15,
+ );
+}
+
+export async function stopBackupService(): Promise {
+ if (serviceInterval) {
+ clearInterval(serviceInterval);
+ serviceInterval = null;
+ }
+}
diff --git a/packages/loot-core/src/server/main.ts b/packages/loot-core/src/server/main.ts
index aadde848273..c7add7c2f3a 100644
--- a/packages/loot-core/src/server/main.ts
+++ b/packages/loot-core/src/server/main.ts
@@ -33,6 +33,7 @@ import {
getAvailableBackups,
loadBackup,
makeBackup,
+ removeAllBackups,
startBackupService,
stopBackupService,
} from './backups';
@@ -1879,7 +1880,7 @@ handlers['close-budget'] = async function () {
}
prefs.unloadPrefs();
- stopBackupService();
+ await stopBackupService();
return 'ok';
};
@@ -1892,13 +1893,119 @@ handlers['delete-budget'] = async function ({ id, cloudFileId }) {
// If a local file exists, you can delete it by passing its local id
if (id) {
+ // loading and then closing the budget 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.
+ await loadBudget(id);
+ await handlers['close-budget']();
+
const budgetDir = fs.getBudgetDir(id);
+ if (Platform.isWeb) {
+ await removeAllBackups(id);
+ }
await fs.removeDirRecursively(budgetDir);
}
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.');
+ if (!newName?.trim()) {
+ throw new Error('Budget name is required and cannot be empty');
+ }
+ if (!/^[a-zA-Z0-9 .\-_()]+$/.test(newName)) {
+ throw new Error('Budget name contains invalid characters');
+ }
+
+ const budgetDir = fs.getBudgetDir(id);
+
+ let budgetName = newName;
+ let sameName = false;
+
+ if (budgetName.indexOf(' - copy') !== -1) {
+ sameName = true;
+ budgetName = budgetName.replace(' - copy', '');
+ }
+
+ const newId = await idFromFileName(budgetName);
+
+ const budgets = await handlers['get-budgets']();
+ budgetName = await uniqueFileName(
+ budgets,
+ sameName ? budgetName + ' - copy' : budgetName,
+ );
+
+ // 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 = budgetName;
+ [
+ '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,
@@ -1993,8 +2100,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 +2178,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..87157717a14 100644
--- a/packages/loot-core/src/server/util/budget-name.ts
+++ b/packages/loot-core/src/server/util/budget-name.ts
@@ -2,9 +2,12 @@
import { v4 as uuidv4 } from 'uuid';
import * as fs from '../../platform/server/fs';
+import { type Budget } from '../../types/budget';
-export async function uniqueFileName(existingFiles) {
- const initialName = 'My Finances';
+export async function uniqueFileName(
+ existingFiles: Budget[],
+ initialName: string = 'My Finances',
+): Promise {
let idx = 1;
// If there is a conflict, keep appending an index until there is no
diff --git a/packages/loot-core/src/types/server-handlers.d.ts b/packages/loot-core/src/types/server-handlers.d.ts
index 1ae73ef66fd..3b97143ea12 100644
--- a/packages/loot-core/src/types/server-handlers.d.ts
+++ b/packages/loot-core/src/types/server-handlers.d.ts
@@ -328,6 +328,23 @@ export interface ServerHandlers {
cloudFileId?: string;
}) => Promise<'ok'>;
+ /**
+ * 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?;
avoidUpload?;
diff --git a/upcoming-release-notes/3689.md b/upcoming-release-notes/3689.md
new file mode 100644
index 00000000000..6af6adaebbc
--- /dev/null
+++ b/upcoming-release-notes/3689.md
@@ -0,0 +1,6 @@
+---
+category: Features
+authors: [tlesicka]
+---
+
+Added ability to duplicate budgets and enabled budget snapshots on web-app.