mirror of
https://github.com/actualbudget/actual.git
synced 2026-05-29 23:47:20 -05:00
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:
@@ -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':
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?;
|
||||
|
||||
6
upcoming-release-notes/3847.md
Normal file
6
upcoming-release-notes/3847.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Features
|
||||
authors: [tlesicka]
|
||||
---
|
||||
|
||||
Added ability to duplicate budgets.
|
||||
Reference in New Issue
Block a user