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..12defd2e4dd 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,19 @@ 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 a cloud file - only local files are supported. Cloud file:',
+ file,
+ );
+ }
+ }}
/>
{!quickSwitchMode && (
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/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/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/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/main.ts b/packages/loot-core/src/server/main.ts
index aadde848273..24fb626f487 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';
@@ -1710,6 +1714,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 +1891,7 @@ handlers['close-budget'] = async function () {
}
prefs.unloadPrefs();
- stopBackupService();
+ await stopBackupService();
return 'ok';
};
@@ -1892,13 +1904,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 +2022,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 +2091,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 +2169,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/types/server-handlers.d.ts b/packages/loot-core/src/types/server-handlers.d.ts
index 657244b3cc5..92b872e54f5 100644
--- a/packages/loot-core/src/types/server-handlers.d.ts
+++ b/packages/loot-core/src/types/server-handlers.d.ts
@@ -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/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.