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.