Duplicate Budget (#3847)

* Initial Commit

* Create 3847.md

* Removed un-needed comment

* Changed error log text

* Moved budget name validation from DuplicateFileModal to loot-core/server

* Added translation

* Fixed linting error

* Changed delete file hack

Changed from loading and closing the budget file to just opening and closing the database to be able to delete it.

* Removed hard coded english from loot-core server

* Updated wording and style of Duplicate File Modal

* Simpler wording for Duplication text and buttons
This commit is contained in:
Travis Lesicka
2024-12-11 01:55:38 +10:00
committed by GitHub
parent 2b908e9263
commit 6ea77324ef
10 changed files with 634 additions and 39 deletions

View File

@@ -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 <BudgetListModal key={name} />;
case 'delete-budget':
return <DeleteFileModal key={name} file={options.file} />;
case 'duplicate-budget':
return (
<DuplicateFileModal
key={name}
file={options.file}
managePage={options?.managePage}
loadBudget={options?.loadBudget}
onComplete={options?.onComplete}
/>
);
case 'import':
return <ImportModal key={name} />;
case 'files-settings':

View File

@@ -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 <Menu onMenuSelect={onMenuSelect} items={items} />;
}
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)}
>
<FileMenu onDelete={onDelete} onClose={() => setMenuOpen(false)} />
<FileMenu
onDelete={onDelete}
onClose={() => setMenuOpen(false)}
onDuplicate={onDuplicate}
/>
</Popover>
</View>
);
@@ -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 && (
<FileMenuButton onDelete={() => onDelete(file)} />
<FileMenuButton
onDelete={() => onDelete(file)}
onDuplicate={'id' in file ? () => onDuplicate(file) : undefined}
/>
)}
</View>
</View>
@@ -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 && (
<View

View File

@@ -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<string | null>(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 (
<Modal name="duplicate-budget">
{({ state: { close } }) => (
<View style={{ maxWidth: 700 }}>
<ModalHeader
title={t('Duplicate “{{fileName}}”', { fileName: file.name })}
rightContent={
<ModalCloseButton
onPress={() => {
close();
if (onComplete) onComplete({ status: 'canceled' });
}}
/>
}
/>
<View
style={{
padding: 15,
gap: 15,
paddingTop: 0,
paddingBottom: 25,
lineHeight: '1.5em',
}}
>
<InlineField
label={t('New Budget Name')}
width="100%"
labelWidth={150}
>
<InitialFocus>
<Input
name="name"
value={newName}
aria-label={t('New Budget Name')}
aria-invalid={nameError ? 'true' : 'false'}
onChange={event => setNewName(event.target.value)}
onBlur={event => validateAndSetName(event.target.value)}
style={{ flex: 1 }}
/>
</InitialFocus>
</InlineField>
{nameError && (
<FormError style={{ marginLeft: 150, color: theme.warningText }}>
{nameError}
</FormError>
)}
{isLocalFile ? (
isCloudFile && (
<Text>
<Trans>
Your budget is hosted on a server, making it accessible for
download on your devices.
<br />
Would you like to duplicate this budget for all your devices
or keep it stored locally on this device?
</Trans>
</Text>
)
) : (
<Text>
<Trans>
Unable to duplicate a budget that is not located on your
device.
<br />
Please download the budget from the server before duplicating.
</Trans>
</Text>
)}
<ModalButtons>
<Button
onPress={() => {
close();
if (onComplete) onComplete({ status: 'canceled' });
}}
>
<Trans>Cancel</Trans>
</Button>
{isLocalFile && isCloudFile && (
<ButtonWithLoading
variant={loadingState !== null ? 'bare' : 'primary'}
isLoading={loadingState === 'cloud'}
style={{
marginLeft: 10,
}}
onPress={() => handleDuplicate('cloudSync')}
>
<Trans>Duplicate for all devices</Trans>
</ButtonWithLoading>
)}
{isLocalFile && (
<ButtonWithLoading
variant={
loadingState !== null
? 'bare'
: isCloudFile
? 'normal'
: 'primary'
}
isLoading={loadingState === 'local'}
style={{
marginLeft: 10,
}}
onPress={() => handleDuplicate('localOnly')}
>
<Trans>Duplicate</Trans>
{isCloudFile && <Trans> locally</Trans>}
</ButtonWithLoading>
)}
</ModalButtons>
</View>
</View>
)}
</Modal>
);
}

View File

@@ -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<Handlers['import-budget']>[0]['type'],

View File

@@ -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;

View File

@@ -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<boolean> {
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<boolean> {
_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<boolean> {
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);
};

View File

@@ -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<string> {
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 {

View File

@@ -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<string> {
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<string> {
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

View File

@@ -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<string>;
'get-budgets': () => Promise<Budget[]>;
'get-remote-files': () => Promise<RemoteFile[]>;
@@ -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<string>} The ID of the newly created budget.
*/
'duplicate-budget': (arg: {
id?: string;
cloudId?: string;
newName: string;
cloudSync?: boolean;
open: 'none' | 'original' | 'copy';
}) => Promise<string>;
'create-budget': (arg: {
budgetName?;

View File

@@ -0,0 +1,6 @@
---
category: Features
authors: [tlesicka]
---
Added ability to duplicate budgets.