mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-09 03:32:54 -05:00
Extract budget file related server handlers from main.ts to server/budgetfiles/app.ts (#4547)
* Extract budget file related server handlers from main.ts to server/budgetfiles/app.ts * Fix lint error * Release notes * Fix typo * Move backups to budgetfiles folder * Move backup snapshot * Fix lint * Fix lint
This commit is contained in:
committed by
GitHub
parent
0d420ab4d9
commit
490ec22b8a
@@ -10,7 +10,7 @@ import { View } from '@actual-app/components/view';
|
||||
import { loadBackup, makeBackup } from 'loot-core/client/budgets/budgetsSlice';
|
||||
import { type Modal as ModalType } from 'loot-core/client/modals/modalsSlice';
|
||||
import { send, listen } from 'loot-core/platform/client/fetch';
|
||||
import { type Backup } from 'loot-core/server/backups';
|
||||
import { type Backup } from 'loot-core/server/budgetfiles/backups';
|
||||
|
||||
import { useMetadataPref } from '../../hooks/useMetadataPref';
|
||||
import { useDispatch } from '../../redux';
|
||||
|
||||
@@ -75,10 +75,6 @@ export function DuplicateFileModal({
|
||||
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',
|
||||
|
||||
@@ -26,18 +26,20 @@ export function ExportBudget() {
|
||||
|
||||
const response = await send('export-budget');
|
||||
|
||||
if ('error' in response) {
|
||||
if ('error' in response && response.error) {
|
||||
setError(response.error);
|
||||
setIsLoading(false);
|
||||
console.log('Export error code:', response.error);
|
||||
return;
|
||||
}
|
||||
|
||||
window.Actual.saveFile(
|
||||
response.data,
|
||||
`${format(new Date(), 'yyyy-MM-dd')}-${budgetName}.zip`,
|
||||
t('Export budget'),
|
||||
);
|
||||
if (response.data) {
|
||||
window.Actual.saveFile(
|
||||
response.data,
|
||||
`${format(new Date(), 'yyyy-MM-dd')}-${budgetName}.zip`,
|
||||
t('Export budget'),
|
||||
);
|
||||
}
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
||||
|
||||
@@ -189,7 +189,6 @@ export const duplicateBudget = createAppAsyncThunk(
|
||||
async (
|
||||
{
|
||||
id,
|
||||
cloudId,
|
||||
oldName,
|
||||
newName,
|
||||
managePage,
|
||||
@@ -198,6 +197,10 @@ export const duplicateBudget = createAppAsyncThunk(
|
||||
}: DuplicateBudgetPayload,
|
||||
{ dispatch },
|
||||
) => {
|
||||
if (!id) {
|
||||
throw new Error('Unable to duplicate a budget that is not local.');
|
||||
}
|
||||
|
||||
try {
|
||||
await dispatch(
|
||||
setAppState({
|
||||
@@ -210,7 +213,6 @@ export const duplicateBudget = createAppAsyncThunk(
|
||||
|
||||
await send('duplicate-budget', {
|
||||
id,
|
||||
cloudId,
|
||||
newName,
|
||||
cloudSync,
|
||||
open: loadBudget,
|
||||
@@ -309,14 +311,18 @@ export const downloadBudget = createAppAsyncThunk(
|
||||
);
|
||||
|
||||
const { id, error } = await send('download-budget', {
|
||||
fileId: cloudFileId,
|
||||
replace,
|
||||
cloudFileId,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
if (error.reason === 'decrypt-failure') {
|
||||
const opts = {
|
||||
hasExistingKey: error.meta && error.meta.isMissingKey,
|
||||
hasExistingKey: Boolean(
|
||||
error.meta &&
|
||||
typeof error.meta === 'object' &&
|
||||
'isMissingKey' in error.meta &&
|
||||
error.meta.isMissingKey,
|
||||
),
|
||||
cloudFileId,
|
||||
onSuccess: () => {
|
||||
dispatch(downloadBudget({ cloudFileId, replace }));
|
||||
@@ -334,8 +340,16 @@ export const downloadBudget = createAppAsyncThunk(
|
||||
'This file will be replaced. This probably happened because files were manually ' +
|
||||
'moved around outside of Actual.',
|
||||
{
|
||||
id: error.meta.id,
|
||||
name: error.meta.name,
|
||||
id:
|
||||
error.meta &&
|
||||
typeof error.meta === 'object' &&
|
||||
'id' in error.meta &&
|
||||
error.meta.id,
|
||||
name:
|
||||
error.meta &&
|
||||
typeof error.meta === 'object' &&
|
||||
'name' in error.meta &&
|
||||
error.meta.name,
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -349,15 +363,17 @@ export const downloadBudget = createAppAsyncThunk(
|
||||
}
|
||||
return null;
|
||||
} else {
|
||||
if (!id) {
|
||||
throw new Error('No id returned from download.');
|
||||
}
|
||||
await Promise.all([
|
||||
dispatch(loadGlobalPrefs()),
|
||||
dispatch(loadAllFiles()),
|
||||
dispatch(loadBudget({ id })),
|
||||
]);
|
||||
await dispatch(setAppState({ loadingText: null }));
|
||||
return id;
|
||||
}
|
||||
|
||||
return id;
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ export function useSchedules({
|
||||
setError(undefined);
|
||||
|
||||
if (!query) {
|
||||
console.error('No query provided to useSchedules');
|
||||
// This usually means query is not yet set on this render cycle.
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -223,7 +223,7 @@ handlers['api/download-budget'] = async function ({ syncId, password }) {
|
||||
|
||||
// Download the remote file (no need to perform a sync as the file will already be up-to-date)
|
||||
const result = await handlers['download-budget']({
|
||||
fileId: remoteBudget.fileId,
|
||||
cloudFileId: remoteBudget.fileId,
|
||||
});
|
||||
if (result.error) {
|
||||
console.log('Full error details', result.error);
|
||||
|
||||
678
packages/loot-core/src/server/budgetfiles/app.ts
Normal file
678
packages/loot-core/src/server/budgetfiles/app.ts
Normal file
@@ -0,0 +1,678 @@
|
||||
// @ts-strict-ignore
|
||||
import * as CRDT from '@actual-app/crdt';
|
||||
|
||||
import { createTestBudget } from '../../mocks/budget';
|
||||
import { captureException, captureBreadcrumb } from '../../platform/exceptions';
|
||||
import * as asyncStorage from '../../platform/server/asyncStorage';
|
||||
import * as connection from '../../platform/server/connection';
|
||||
import * as fs from '../../platform/server/fs';
|
||||
import { logger } from '../../platform/server/log';
|
||||
import { Budget } from '../../types/budget';
|
||||
import { createApp } from '../app';
|
||||
import * as budget from '../budget/base';
|
||||
import * as cloudStorage from '../cloud-storage';
|
||||
import * as db from '../db';
|
||||
import * as mappings from '../db/mappings';
|
||||
import { handleBudgetImport, ImportableBudgetType } from '../importers';
|
||||
import { app as mainApp } from '../main-app';
|
||||
import { mutator } from '../mutators';
|
||||
import * as Platform from '../platform';
|
||||
import * as prefs from '../prefs';
|
||||
import { getServer } from '../server-config';
|
||||
import * as sheet from '../sheet';
|
||||
import { setSyncingMode, initialFullSync, clearFullSyncTimeout } from '../sync';
|
||||
import * as syncMigrations from '../sync/migrate';
|
||||
import * as rules from '../transactions/transaction-rules';
|
||||
import { clearUndo } from '../undo';
|
||||
import { updateVersion } from '../update';
|
||||
import {
|
||||
idFromBudgetName,
|
||||
uniqueBudgetName,
|
||||
validateBudgetName,
|
||||
} from '../util/budget-name';
|
||||
|
||||
import {
|
||||
getAvailableBackups,
|
||||
makeBackup as _makeBackup,
|
||||
loadBackup as _loadBackup,
|
||||
startBackupService,
|
||||
stopBackupService,
|
||||
} from './backups';
|
||||
|
||||
const DEMO_BUDGET_ID = '_demo-budget';
|
||||
const TEST_BUDGET_ID = '_test-budget';
|
||||
|
||||
export type BudgetFileHandlers = {
|
||||
'validate-budget-name': typeof handleValidateBudgetName;
|
||||
'unique-budget-name': typeof handleUniqueBudgetName;
|
||||
'get-budgets': typeof getBudgets;
|
||||
'get-remote-files': typeof getRemoteFiles;
|
||||
'get-user-file-info': typeof getUserFileInfo;
|
||||
'reset-budget-cache': typeof resetBudgetCache;
|
||||
'upload-budget': typeof uploadBudget;
|
||||
'download-budget': typeof downloadBudget;
|
||||
'sync-budget': typeof syncBudget;
|
||||
'load-budget': typeof loadBudget;
|
||||
'create-demo-budget': typeof createDemoBudget;
|
||||
'close-budget': typeof closeBudget;
|
||||
'delete-budget': typeof deleteBudget;
|
||||
'duplicate-budget': typeof duplicateBudget;
|
||||
'create-budget': typeof createBudget;
|
||||
'import-budget': typeof importBudget;
|
||||
'export-budget': typeof exportBudget;
|
||||
'upload-file-web': typeof uploadFileWeb;
|
||||
'backups-get': typeof getBackups;
|
||||
'backup-load': typeof loadBackup;
|
||||
'backup-make': typeof makeBackup;
|
||||
'get-last-opened-backup': typeof getLastOpenedBackup;
|
||||
};
|
||||
|
||||
export const app = createApp<BudgetFileHandlers>();
|
||||
app.method('validate-budget-name', handleValidateBudgetName);
|
||||
app.method('unique-budget-name', handleUniqueBudgetName);
|
||||
app.method('get-budgets', getBudgets);
|
||||
app.method('get-remote-files', getRemoteFiles);
|
||||
app.method('get-user-file-info', getUserFileInfo);
|
||||
app.method('reset-budget-cache', mutator(resetBudgetCache));
|
||||
app.method('upload-budget', uploadBudget);
|
||||
app.method('download-budget', downloadBudget);
|
||||
app.method('sync-budget', syncBudget);
|
||||
app.method('load-budget', loadBudget);
|
||||
app.method('create-demo-budget', createDemoBudget);
|
||||
app.method('close-budget', closeBudget);
|
||||
app.method('delete-budget', deleteBudget);
|
||||
app.method('duplicate-budget', duplicateBudget);
|
||||
app.method('create-budget', createBudget);
|
||||
app.method('import-budget', importBudget);
|
||||
app.method('export-budget', exportBudget);
|
||||
app.method('upload-file-web', uploadFileWeb);
|
||||
app.method('backups-get', getBackups);
|
||||
app.method('backup-load', loadBackup);
|
||||
app.method('backup-make', makeBackup);
|
||||
app.method('get-last-opened-backup', getLastOpenedBackup);
|
||||
|
||||
async function handleValidateBudgetName({ name }: { name: string }) {
|
||||
return validateBudgetName(name);
|
||||
}
|
||||
|
||||
async function handleUniqueBudgetName({ name }: { name: string }) {
|
||||
return uniqueBudgetName(name);
|
||||
}
|
||||
|
||||
async function getBudgets() {
|
||||
const paths = await fs.listDir(fs.getDocumentDir());
|
||||
const budgets: (Budget | null)[] = await Promise.all(
|
||||
paths.map(async name => {
|
||||
const prefsPath = fs.join(fs.getDocumentDir(), name, 'metadata.json');
|
||||
if (await fs.exists(prefsPath)) {
|
||||
let prefs;
|
||||
try {
|
||||
prefs = JSON.parse(await fs.readFile(prefsPath));
|
||||
} catch (e) {
|
||||
console.log('Error parsing metadata:', e.stack);
|
||||
return null;
|
||||
}
|
||||
|
||||
// We treat the directory name as the canonical id so that if
|
||||
// the user moves it around/renames/etc, nothing breaks. The
|
||||
// id is stored in prefs just for convenience (and the prefs
|
||||
// will always update to the latest given id)
|
||||
if (name !== DEMO_BUDGET_ID) {
|
||||
return {
|
||||
id: name,
|
||||
...(prefs.cloudFileId ? { cloudFileId: prefs.cloudFileId } : {}),
|
||||
...(prefs.encryptKeyId ? { encryptKeyId: prefs.encryptKeyId } : {}),
|
||||
...(prefs.groupId ? { groupId: prefs.groupId } : {}),
|
||||
...(prefs.owner ? { owner: prefs.owner } : {}),
|
||||
name: prefs.budgetName || '(no name)',
|
||||
} satisfies Budget;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}),
|
||||
);
|
||||
|
||||
return budgets.filter(Boolean) as Budget[];
|
||||
}
|
||||
|
||||
async function getRemoteFiles() {
|
||||
return cloudStorage.listRemoteFiles();
|
||||
}
|
||||
|
||||
async function getUserFileInfo(fileId: string) {
|
||||
return cloudStorage.getRemoteFile(fileId);
|
||||
}
|
||||
|
||||
async function resetBudgetCache() {
|
||||
// Recomputing everything will update the cache
|
||||
await sheet.loadUserBudgets(db);
|
||||
sheet.get().recomputeAll();
|
||||
await sheet.waitOnSpreadsheet();
|
||||
}
|
||||
|
||||
async function uploadBudget({ id }: { id?: Budget['id'] } = {}): Promise<{
|
||||
error?: { reason: string };
|
||||
}> {
|
||||
if (id) {
|
||||
if (prefs.getPrefs()) {
|
||||
throw new Error('upload-budget: id given but prefs already loaded');
|
||||
}
|
||||
|
||||
await prefs.loadPrefs(id);
|
||||
}
|
||||
|
||||
try {
|
||||
await cloudStorage.upload();
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
if (e.type === 'FileUploadError') {
|
||||
return { error: e };
|
||||
}
|
||||
captureException(e);
|
||||
return { error: { reason: 'internal' } };
|
||||
} finally {
|
||||
if (id) {
|
||||
prefs.unloadPrefs();
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
async function downloadBudget({
|
||||
cloudFileId,
|
||||
}: {
|
||||
cloudFileId: Budget['cloudFileId'];
|
||||
}): Promise<{ id?: Budget['id']; error?: { reason: string; meta?: unknown } }> {
|
||||
let result;
|
||||
try {
|
||||
result = await cloudStorage.download(cloudFileId);
|
||||
} catch (e) {
|
||||
if (e.type === 'FileDownloadError') {
|
||||
if (e.reason === 'file-exists' && e.meta.id) {
|
||||
await prefs.loadPrefs(e.meta.id);
|
||||
const name = prefs.getPrefs().budgetName;
|
||||
prefs.unloadPrefs();
|
||||
|
||||
e.meta = { ...e.meta, name };
|
||||
}
|
||||
|
||||
return { error: e };
|
||||
} else {
|
||||
captureException(e);
|
||||
return { error: { reason: 'internal' } };
|
||||
}
|
||||
}
|
||||
|
||||
const id = result.id;
|
||||
await loadBudget({ id });
|
||||
result = await syncBudget();
|
||||
|
||||
if (result.error) {
|
||||
return result;
|
||||
}
|
||||
return { id };
|
||||
}
|
||||
|
||||
// open and sync, but don’t close
|
||||
async function syncBudget() {
|
||||
setSyncingMode('enabled');
|
||||
const result = await initialFullSync();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function loadBudget({ id }: { id: Budget['id'] }) {
|
||||
const currentPrefs = prefs.getPrefs();
|
||||
|
||||
if (currentPrefs) {
|
||||
if (currentPrefs.id === id) {
|
||||
// If it's already loaded, do nothing
|
||||
return {};
|
||||
} else {
|
||||
// Otherwise, close the currently loaded budget
|
||||
await closeBudget();
|
||||
}
|
||||
}
|
||||
|
||||
const res = await _loadBudget(id);
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
async function createDemoBudget() {
|
||||
// Make sure the read only flag isn't leftover (normally it's
|
||||
// reset when signing in, but you don't have to sign in for the
|
||||
// demo budget)
|
||||
await asyncStorage.setItem('readOnly', '');
|
||||
|
||||
return createBudget({
|
||||
budgetName: 'Demo Budget',
|
||||
testMode: true,
|
||||
testBudgetId: DEMO_BUDGET_ID,
|
||||
});
|
||||
}
|
||||
|
||||
async function closeBudget() {
|
||||
captureBreadcrumb({ message: 'Closing budget' });
|
||||
|
||||
// The spreadsheet may be running, wait for it to complete
|
||||
await sheet.waitOnSpreadsheet();
|
||||
sheet.unloadSpreadsheet();
|
||||
|
||||
clearFullSyncTimeout();
|
||||
await app.stopServices();
|
||||
|
||||
await db.closeDatabase();
|
||||
|
||||
try {
|
||||
await asyncStorage.setItem('lastBudget', '');
|
||||
} catch (e) {
|
||||
// This might fail if we are shutting down after failing to load a
|
||||
// budget. We want to unload whatever has already been loaded but
|
||||
// be resilient to anything failing
|
||||
}
|
||||
|
||||
prefs.unloadPrefs();
|
||||
await stopBackupService();
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
async function deleteBudget({
|
||||
id,
|
||||
cloudFileId,
|
||||
}: {
|
||||
id?: Budget['id'];
|
||||
cloudFileId?: Budget['cloudFileId'];
|
||||
}) {
|
||||
// If it's a cloud file, you can delete it from the server by
|
||||
// passing its cloud id
|
||||
if (cloudFileId) {
|
||||
await cloudStorage.removeFile(cloudFileId).catch(() => {});
|
||||
}
|
||||
|
||||
// If a local file exists, you can delete it by passing its local id
|
||||
if (id) {
|
||||
// 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';
|
||||
}
|
||||
|
||||
async function duplicateBudget({
|
||||
id,
|
||||
newName,
|
||||
cloudSync,
|
||||
open,
|
||||
}: {
|
||||
id: Budget['id'];
|
||||
newName: Budget['name'];
|
||||
cloudSync: boolean;
|
||||
open: 'none' | 'original' | 'copy';
|
||||
}): Promise<Budget['id']> {
|
||||
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 file: ${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.
|
||||
}
|
||||
}
|
||||
|
||||
await closeBudget();
|
||||
if (open === 'original') await _loadBudget(id);
|
||||
if (open === 'copy') await _loadBudget(newId);
|
||||
|
||||
return newId;
|
||||
}
|
||||
|
||||
async function createBudget({
|
||||
budgetName,
|
||||
avoidUpload,
|
||||
testMode,
|
||||
testBudgetId,
|
||||
}: {
|
||||
budgetName?: Budget['name'];
|
||||
avoidUpload?: boolean;
|
||||
testMode?: boolean;
|
||||
testBudgetId?: Budget['name'];
|
||||
} = {}) {
|
||||
let id;
|
||||
if (testMode) {
|
||||
budgetName = budgetName || 'Test Budget';
|
||||
id = testBudgetId || TEST_BUDGET_ID;
|
||||
|
||||
if (await fs.exists(fs.getBudgetDir(id))) {
|
||||
await fs.removeDirRecursively(fs.getBudgetDir(id));
|
||||
}
|
||||
} else {
|
||||
// Generate budget name if not given
|
||||
if (!budgetName) {
|
||||
budgetName = await uniqueBudgetName();
|
||||
}
|
||||
|
||||
id = await idFromBudgetName(budgetName);
|
||||
}
|
||||
|
||||
const budgetDir = fs.getBudgetDir(id);
|
||||
await fs.mkdir(budgetDir);
|
||||
|
||||
// Create the initial database
|
||||
await fs.copyFile(fs.bundledDatabasePath, fs.join(budgetDir, 'db.sqlite'));
|
||||
|
||||
// Create the initial prefs file
|
||||
await fs.writeFile(
|
||||
fs.join(budgetDir, 'metadata.json'),
|
||||
JSON.stringify(prefs.getDefaultPrefs(id, budgetName)),
|
||||
);
|
||||
|
||||
// Load it in
|
||||
const { error } = await _loadBudget(id);
|
||||
if (error) {
|
||||
console.log('Error creating budget: ' + error);
|
||||
return { error };
|
||||
}
|
||||
|
||||
if (!avoidUpload && !testMode) {
|
||||
try {
|
||||
await cloudStorage.upload();
|
||||
} catch (e) {
|
||||
// Ignore any errors uploading. If they are offline they should
|
||||
// still be able to create files.
|
||||
}
|
||||
}
|
||||
|
||||
if (testMode) {
|
||||
await createTestBudget(mainApp.handlers);
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
async function importBudget({
|
||||
filepath,
|
||||
type,
|
||||
}: {
|
||||
filepath: string;
|
||||
type: ImportableBudgetType;
|
||||
}): Promise<{ error?: string }> {
|
||||
try {
|
||||
if (!(await fs.exists(filepath))) {
|
||||
throw new Error(`File not found at the provided path: ${filepath}`);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await fs.readFile(filepath, 'binary'));
|
||||
const results = await handleBudgetImport(type, filepath, buffer);
|
||||
return results || {};
|
||||
} catch (err) {
|
||||
err.message = 'Error importing budget: ' + err.message;
|
||||
captureException(err);
|
||||
return { error: 'internal-error' };
|
||||
}
|
||||
}
|
||||
|
||||
async function exportBudget() {
|
||||
try {
|
||||
return {
|
||||
data: await cloudStorage.exportBuffer(),
|
||||
};
|
||||
} catch (err) {
|
||||
err.message = 'Error exporting budget: ' + err.message;
|
||||
captureException(err);
|
||||
return { error: 'internal-error' };
|
||||
}
|
||||
}
|
||||
|
||||
function onSheetChange({ names }: { names: string[] }) {
|
||||
const nodes = names.map(name => {
|
||||
const node = sheet.get()._getNode(name);
|
||||
return { name: node.name, value: node.value };
|
||||
});
|
||||
connection.send('cells-changed', nodes);
|
||||
}
|
||||
|
||||
async function _loadBudget(id: Budget['id']): Promise<{
|
||||
error?:
|
||||
| 'budget-not-found'
|
||||
| 'loading-budget'
|
||||
| 'out-of-sync-migrations'
|
||||
| 'out-of-sync-data'
|
||||
| 'opening-budget';
|
||||
}> {
|
||||
let dir: string;
|
||||
try {
|
||||
dir = fs.getBudgetDir(id);
|
||||
} catch (e) {
|
||||
captureException(
|
||||
new Error('`getBudgetDir` failed in `loadBudget`: ' + e.message),
|
||||
);
|
||||
return { error: 'budget-not-found' };
|
||||
}
|
||||
|
||||
captureBreadcrumb({ message: 'Loading budget ' + dir });
|
||||
|
||||
if (!(await fs.exists(dir))) {
|
||||
captureException(new Error('budget directory does not exist'));
|
||||
return { error: 'budget-not-found' };
|
||||
}
|
||||
|
||||
try {
|
||||
await prefs.loadPrefs(id);
|
||||
await db.openDatabase(id);
|
||||
} catch (e) {
|
||||
captureBreadcrumb({ message: 'Error loading budget ' + id });
|
||||
captureException(e);
|
||||
await closeBudget();
|
||||
return { error: 'opening-budget' };
|
||||
}
|
||||
|
||||
// Older versions didn't tag the file with the current user, so do
|
||||
// so now
|
||||
if (!prefs.getPrefs().userId) {
|
||||
const userId = await asyncStorage.getItem('user-token');
|
||||
await prefs.savePrefs({ userId });
|
||||
}
|
||||
|
||||
try {
|
||||
await updateVersion();
|
||||
} catch (e) {
|
||||
console.warn('Error updating', e);
|
||||
let result;
|
||||
if (e.message.includes('out-of-sync-migrations')) {
|
||||
result = { error: 'out-of-sync-migrations' };
|
||||
} else if (e.message.includes('out-of-sync-data')) {
|
||||
result = { error: 'out-of-sync-data' };
|
||||
} else {
|
||||
captureException(e);
|
||||
logger.info('Error updating budget ' + id, e);
|
||||
console.log('Error updating budget', e);
|
||||
result = { error: 'loading-budget' };
|
||||
}
|
||||
|
||||
await closeBudget();
|
||||
return result;
|
||||
}
|
||||
|
||||
await db.loadClock();
|
||||
|
||||
if (prefs.getPrefs().resetClock) {
|
||||
// If we need to generate a fresh clock, we need to generate a new
|
||||
// client id. This happens when the database is transferred to a
|
||||
// new device.
|
||||
//
|
||||
// TODO: The client id should be stored elsewhere. It shouldn't
|
||||
// work this way, but it's fine for now.
|
||||
CRDT.getClock().timestamp.setNode(CRDT.makeClientId());
|
||||
await db.runQuery(
|
||||
'INSERT OR REPLACE INTO messages_clock (id, clock) VALUES (1, ?)',
|
||||
[CRDT.serializeClock(CRDT.getClock())],
|
||||
);
|
||||
|
||||
await prefs.savePrefs({ resetClock: false });
|
||||
}
|
||||
|
||||
if (
|
||||
!Platform.isWeb &&
|
||||
!Platform.isMobile &&
|
||||
process.env.NODE_ENV !== 'test'
|
||||
) {
|
||||
await startBackupService(id);
|
||||
}
|
||||
|
||||
try {
|
||||
await sheet.loadSpreadsheet(db, onSheetChange);
|
||||
} catch (e) {
|
||||
captureException(e);
|
||||
await closeBudget();
|
||||
return { error: 'opening-budget' };
|
||||
}
|
||||
|
||||
// This is a bit leaky, but we need to set the initial budget type
|
||||
const { value: budgetType = 'rollover' } =
|
||||
(await db.first<Pick<db.DbPreference, 'value'>>(
|
||||
'SELECT value from preferences WHERE id = ?',
|
||||
['budgetType'],
|
||||
)) ?? {};
|
||||
sheet.get().meta().budgetType = budgetType as prefs.BudgetType;
|
||||
await budget.createAllBudgets();
|
||||
|
||||
// Load all the in-memory state
|
||||
await mappings.loadMappings();
|
||||
await rules.loadRules();
|
||||
await syncMigrations.listen();
|
||||
await mainApp.startServices();
|
||||
|
||||
clearUndo();
|
||||
|
||||
// Ensure that syncing is enabled
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
if (id === DEMO_BUDGET_ID) {
|
||||
setSyncingMode('disabled');
|
||||
} else {
|
||||
if (getServer()) {
|
||||
setSyncingMode('enabled');
|
||||
} else {
|
||||
setSyncingMode('disabled');
|
||||
}
|
||||
|
||||
await asyncStorage.setItem('lastBudget', id);
|
||||
|
||||
// Only upload periodically on desktop
|
||||
if (!Platform.isMobile) {
|
||||
await cloudStorage.possiblyUpload();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.events.emit('load-budget', { id });
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
async function uploadFileWeb({
|
||||
filename,
|
||||
contents,
|
||||
}: {
|
||||
filename: string;
|
||||
contents: ArrayBuffer;
|
||||
}) {
|
||||
if (!Platform.isWeb) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await fs.writeFile('/uploads/' + filename, contents);
|
||||
return {};
|
||||
}
|
||||
|
||||
async function getBackups({ id }) {
|
||||
return getAvailableBackups(id);
|
||||
}
|
||||
|
||||
async function loadBackup({ id, backupId }) {
|
||||
await _loadBackup(id, backupId);
|
||||
}
|
||||
|
||||
async function makeBackup({ id }) {
|
||||
await _makeBackup(id);
|
||||
}
|
||||
|
||||
async function getLastOpenedBackup() {
|
||||
const id = await asyncStorage.getItem('lastBudget');
|
||||
if (id && id !== '') {
|
||||
const budgetDir = fs.getBudgetDir(id);
|
||||
|
||||
// We never want to give back a budget that does not exist on the
|
||||
// filesystem anymore, so first check that it exists
|
||||
if (await fs.exists(budgetDir)) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -2,13 +2,12 @@
|
||||
import * as dateFns from 'date-fns';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import * as connection from '../platform/server/connection';
|
||||
import * as fs from '../platform/server/fs';
|
||||
import * as sqlite from '../platform/server/sqlite';
|
||||
import * as monthUtils from '../shared/months';
|
||||
|
||||
import * as cloudStorage from './cloud-storage';
|
||||
import * as prefs from './prefs';
|
||||
import * as connection from '../../platform/server/connection';
|
||||
import * as fs from '../../platform/server/fs';
|
||||
import * as sqlite from '../../platform/server/sqlite';
|
||||
import * as monthUtils from '../../shared/months';
|
||||
import * as cloudStorage from '../cloud-storage';
|
||||
import * as prefs from '../prefs';
|
||||
|
||||
// A special backup that represents the latest version of the db that
|
||||
// can be reverted to after loading a backup
|
||||
@@ -368,7 +368,7 @@ export async function removeFile(fileId) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function listRemoteFiles(): Promise<RemoteFile[] | null> {
|
||||
export async function listRemoteFiles(): Promise<RemoteFile[]> {
|
||||
const userToken = await asyncStorage.getItem('user-token');
|
||||
if (!userToken) {
|
||||
return null;
|
||||
@@ -391,10 +391,12 @@ export async function listRemoteFiles(): Promise<RemoteFile[] | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
return res.data.map(file => ({
|
||||
...file,
|
||||
hasKey: encryption.hasKey(file.encryptKeyId),
|
||||
}));
|
||||
return res.data
|
||||
.map(file => ({
|
||||
...file,
|
||||
hasKey: encryption.hasKey(file.encryptKeyId),
|
||||
}))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export async function getRemoteFile(
|
||||
@@ -429,14 +431,14 @@ export async function getRemoteFile(
|
||||
};
|
||||
}
|
||||
|
||||
export async function download(fileId) {
|
||||
export async function download(cloudFileId) {
|
||||
const userToken = await asyncStorage.getItem('user-token');
|
||||
const syncServer = getServer().SYNC_SERVER;
|
||||
|
||||
const userFileFetch = fetch(`${syncServer}/download-user-file`, {
|
||||
headers: {
|
||||
'X-ACTUAL-TOKEN': userToken,
|
||||
'X-ACTUAL-FILE-ID': fileId,
|
||||
'X-ACTUAL-FILE-ID': cloudFileId,
|
||||
},
|
||||
})
|
||||
.then(checkHTTPStatus)
|
||||
@@ -454,11 +456,11 @@ export async function download(fileId) {
|
||||
const userFileInfoFetch = fetchJSON(`${syncServer}/get-user-file-info`, {
|
||||
headers: {
|
||||
'X-ACTUAL-TOKEN': userToken,
|
||||
'X-ACTUAL-FILE-ID': fileId,
|
||||
'X-ACTUAL-FILE-ID': cloudFileId,
|
||||
},
|
||||
}).catch(err => {
|
||||
console.log('Error fetching file info', err);
|
||||
throw FileDownloadError('internal', { fileId });
|
||||
throw FileDownloadError('internal', { fileId: cloudFileId });
|
||||
});
|
||||
|
||||
const [userFileInfoRes, userFileRes] = await Promise.all([
|
||||
@@ -471,7 +473,7 @@ export async function download(fileId) {
|
||||
'Could not download file from the server. Are you sure you have the right file ID?',
|
||||
userFileInfoRes,
|
||||
);
|
||||
throw FileDownloadError('internal', { fileId });
|
||||
throw FileDownloadError('internal', { fileId: cloudFileId });
|
||||
}
|
||||
|
||||
const fileData = userFileInfoRes.data;
|
||||
|
||||
@@ -87,7 +87,12 @@ export function APIError(msg: string, meta?: Record<string, any>) {
|
||||
|
||||
export function FileDownloadError(
|
||||
reason: string,
|
||||
meta?: { fileId?: string; isMissingKey?: boolean },
|
||||
meta?: {
|
||||
fileId?: string;
|
||||
isMissingKey?: boolean;
|
||||
name?: string;
|
||||
id?: string;
|
||||
},
|
||||
) {
|
||||
return { type: 'FileDownloadError', reason, meta };
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { importActual } from './actual';
|
||||
import * as YNAB4 from './ynab4';
|
||||
import * as YNAB5 from './ynab5';
|
||||
|
||||
type ImportableBudgetType = 'ynab4' | 'ynab5' | 'actual';
|
||||
export type ImportableBudgetType = 'ynab4' | 'ynab5' | 'actual';
|
||||
|
||||
type Importer = {
|
||||
parseFile(buffer: Buffer): unknown;
|
||||
|
||||
@@ -2,18 +2,13 @@
|
||||
import './polyfills';
|
||||
|
||||
import * as injectAPI from '@actual-app/api/injected';
|
||||
import * as CRDT from '@actual-app/crdt';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { createTestBudget } from '../mocks/budget';
|
||||
import { captureException, captureBreadcrumb } from '../platform/exceptions';
|
||||
import * as asyncStorage from '../platform/server/asyncStorage';
|
||||
import * as connection from '../platform/server/connection';
|
||||
import * as fs from '../platform/server/fs';
|
||||
import { logger } from '../platform/server/log';
|
||||
import * as sqlite from '../platform/server/sqlite';
|
||||
import { q } from '../shared/query';
|
||||
import { type Budget } from '../types/budget';
|
||||
import { Handlers } from '../types/handlers';
|
||||
import { OpenIdConfig } from '../types/models/openid';
|
||||
|
||||
@@ -21,22 +16,12 @@ import { app as accountsApp } from './accounts/app';
|
||||
import { app as adminApp } from './admin/app';
|
||||
import { installAPI } from './api';
|
||||
import { runQuery as aqlQuery } from './aql';
|
||||
import {
|
||||
getAvailableBackups,
|
||||
loadBackup,
|
||||
makeBackup,
|
||||
startBackupService,
|
||||
stopBackupService,
|
||||
} from './backups';
|
||||
import { app as budgetApp } from './budget/app';
|
||||
import * as budget from './budget/base';
|
||||
import * as cloudStorage from './cloud-storage';
|
||||
import { app as budgetFilesApp } from './budgetfiles/app';
|
||||
import { app as dashboardApp } from './dashboard/app';
|
||||
import * as db from './db';
|
||||
import * as mappings from './db/mappings';
|
||||
import * as encryption from './encryption';
|
||||
import { app as filtersApp } from './filters/app';
|
||||
import { handleBudgetImport } from './importers';
|
||||
import { app } from './main-app';
|
||||
import { mutator, runHandler } from './mutators';
|
||||
import { app as notesApp } from './notes/app';
|
||||
@@ -49,41 +34,13 @@ import { app as reportsApp } from './reports/app';
|
||||
import { app as rulesApp } from './rules/app';
|
||||
import { app as schedulesApp } from './schedules/app';
|
||||
import { getServer, isValidBaseURL, setServer } from './server-config';
|
||||
import * as sheet from './sheet';
|
||||
import { app as spreadsheetApp } from './spreadsheet/app';
|
||||
import {
|
||||
initialFullSync,
|
||||
fullSync,
|
||||
setSyncingMode,
|
||||
makeTestMessage,
|
||||
clearFullSyncTimeout,
|
||||
resetSync,
|
||||
} from './sync';
|
||||
import { fullSync, setSyncingMode, makeTestMessage, resetSync } from './sync';
|
||||
import { app as syncApp } from './sync/app';
|
||||
import * as syncMigrations from './sync/migrate';
|
||||
import { app as toolsApp } from './tools/app';
|
||||
import { app as transactionsApp } from './transactions/app';
|
||||
import * as rules from './transactions/transaction-rules';
|
||||
import { clearUndo, undo, redo } from './undo';
|
||||
import { updateVersion } from './update';
|
||||
import {
|
||||
uniqueBudgetName,
|
||||
idFromBudgetName,
|
||||
validateBudgetName,
|
||||
} from './util/budget-name';
|
||||
|
||||
const DEMO_BUDGET_ID = '_demo-budget';
|
||||
const TEST_BUDGET_ID = '_test-budget';
|
||||
|
||||
// util
|
||||
|
||||
function onSheetChange({ names }) {
|
||||
const nodes = names.map(name => {
|
||||
const node = sheet.get()._getNode(name);
|
||||
return { name: node.name, value: node.value };
|
||||
});
|
||||
connection.send('cells-changed', nodes);
|
||||
}
|
||||
import { undo, redo } from './undo';
|
||||
|
||||
// handlers
|
||||
|
||||
@@ -437,388 +394,6 @@ handlers['set-server-url'] = async function ({ url, validate = true }) {
|
||||
return {};
|
||||
};
|
||||
|
||||
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 = (
|
||||
await Promise.all(
|
||||
paths.map(async name => {
|
||||
const prefsPath = fs.join(fs.getDocumentDir(), name, 'metadata.json');
|
||||
if (await fs.exists(prefsPath)) {
|
||||
let prefs;
|
||||
try {
|
||||
prefs = JSON.parse(await fs.readFile(prefsPath));
|
||||
} catch (e) {
|
||||
console.log('Error parsing metadata:', e.stack);
|
||||
return;
|
||||
}
|
||||
|
||||
// We treat the directory name as the canonical id so that if
|
||||
// the user moves it around/renames/etc, nothing breaks. The
|
||||
// id is stored in prefs just for convenience (and the prefs
|
||||
// will always update to the latest given id)
|
||||
if (name !== DEMO_BUDGET_ID) {
|
||||
return {
|
||||
id: name,
|
||||
...(prefs.cloudFileId ? { cloudFileId: prefs.cloudFileId } : {}),
|
||||
...(prefs.encryptKeyId
|
||||
? { encryptKeyId: prefs.encryptKeyId }
|
||||
: {}),
|
||||
...(prefs.groupId ? { groupId: prefs.groupId } : {}),
|
||||
...(prefs.owner ? { owner: prefs.owner } : {}),
|
||||
name: prefs.budgetName || '(no name)',
|
||||
} satisfies Budget;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}),
|
||||
)
|
||||
).filter(x => x);
|
||||
|
||||
return budgets;
|
||||
};
|
||||
|
||||
handlers['get-remote-files'] = async function () {
|
||||
return cloudStorage.listRemoteFiles();
|
||||
};
|
||||
|
||||
handlers['get-user-file-info'] = async function (fileId: string) {
|
||||
return cloudStorage.getRemoteFile(fileId);
|
||||
};
|
||||
|
||||
handlers['reset-budget-cache'] = mutator(async function () {
|
||||
// Recomputing everything will update the cache
|
||||
await sheet.loadUserBudgets(db);
|
||||
sheet.get().recomputeAll();
|
||||
await sheet.waitOnSpreadsheet();
|
||||
});
|
||||
|
||||
handlers['upload-budget'] = async function ({ id }: { id? } = {}) {
|
||||
if (id) {
|
||||
if (prefs.getPrefs()) {
|
||||
throw new Error('upload-budget: id given but prefs already loaded');
|
||||
}
|
||||
|
||||
await prefs.loadPrefs(id);
|
||||
}
|
||||
|
||||
try {
|
||||
await cloudStorage.upload();
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
if (e.type === 'FileUploadError') {
|
||||
return { error: e };
|
||||
}
|
||||
captureException(e);
|
||||
return { error: { reason: 'internal' } };
|
||||
} finally {
|
||||
if (id) {
|
||||
prefs.unloadPrefs();
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
handlers['download-budget'] = async function ({ fileId }) {
|
||||
let result;
|
||||
try {
|
||||
result = await cloudStorage.download(fileId);
|
||||
} catch (e) {
|
||||
if (e.type === 'FileDownloadError') {
|
||||
if (e.reason === 'file-exists' && e.meta.id) {
|
||||
await prefs.loadPrefs(e.meta.id);
|
||||
const name = prefs.getPrefs().budgetName;
|
||||
prefs.unloadPrefs();
|
||||
|
||||
e.meta = { ...e.meta, name };
|
||||
}
|
||||
|
||||
return { error: e };
|
||||
} else {
|
||||
captureException(e);
|
||||
return { error: { reason: 'internal' } };
|
||||
}
|
||||
}
|
||||
|
||||
const id = result.id;
|
||||
await handlers['load-budget']({ id });
|
||||
result = await handlers['sync-budget']();
|
||||
|
||||
if (result.error) {
|
||||
return result;
|
||||
}
|
||||
return { id };
|
||||
};
|
||||
|
||||
// open and sync, but don’t close
|
||||
handlers['sync-budget'] = async function () {
|
||||
setSyncingMode('enabled');
|
||||
const result = await initialFullSync();
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
handlers['load-budget'] = async function ({ id }) {
|
||||
const currentPrefs = prefs.getPrefs();
|
||||
|
||||
if (currentPrefs) {
|
||||
if (currentPrefs.id === id) {
|
||||
// If it's already loaded, do nothing
|
||||
return {};
|
||||
} else {
|
||||
// Otherwise, close the currently loaded budget
|
||||
await handlers['close-budget']();
|
||||
}
|
||||
}
|
||||
|
||||
const res = await loadBudget(id);
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
handlers['create-demo-budget'] = async function () {
|
||||
// Make sure the read only flag isn't leftover (normally it's
|
||||
// reset when signing in, but you don't have to sign in for the
|
||||
// demo budget)
|
||||
await asyncStorage.setItem('readOnly', '');
|
||||
|
||||
return handlers['create-budget']({
|
||||
budgetName: 'Demo Budget',
|
||||
testMode: true,
|
||||
testBudgetId: DEMO_BUDGET_ID,
|
||||
});
|
||||
};
|
||||
|
||||
handlers['close-budget'] = async function () {
|
||||
captureBreadcrumb({ message: 'Closing budget' });
|
||||
|
||||
// The spreadsheet may be running, wait for it to complete
|
||||
await sheet.waitOnSpreadsheet();
|
||||
sheet.unloadSpreadsheet();
|
||||
|
||||
clearFullSyncTimeout();
|
||||
await app.stopServices();
|
||||
|
||||
await db.closeDatabase();
|
||||
|
||||
try {
|
||||
await asyncStorage.setItem('lastBudget', '');
|
||||
} catch (e) {
|
||||
// This might fail if we are shutting down after failing to load a
|
||||
// budget. We want to unload whatever has already been loaded but
|
||||
// be resilient to anything failing
|
||||
}
|
||||
|
||||
prefs.unloadPrefs();
|
||||
await stopBackupService();
|
||||
return 'ok';
|
||||
};
|
||||
|
||||
handlers['delete-budget'] = async function ({ id, cloudFileId }) {
|
||||
// If it's a cloud file, you can delete it from the server by
|
||||
// passing its cloud id
|
||||
if (cloudFileId) {
|
||||
await cloudStorage.removeFile(cloudFileId).catch(() => {});
|
||||
}
|
||||
|
||||
// If a local file exists, you can delete it by passing its local id
|
||||
if (id) {
|
||||
// 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 file: ${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,
|
||||
testMode,
|
||||
testBudgetId,
|
||||
}: {
|
||||
budgetName?;
|
||||
avoidUpload?;
|
||||
testMode?;
|
||||
testBudgetId?;
|
||||
} = {}) {
|
||||
let id;
|
||||
if (testMode) {
|
||||
budgetName = budgetName || 'Test Budget';
|
||||
id = testBudgetId || TEST_BUDGET_ID;
|
||||
|
||||
if (await fs.exists(fs.getBudgetDir(id))) {
|
||||
await fs.removeDirRecursively(fs.getBudgetDir(id));
|
||||
}
|
||||
} else {
|
||||
// Generate budget name if not given
|
||||
if (!budgetName) {
|
||||
budgetName = await uniqueBudgetName();
|
||||
}
|
||||
|
||||
id = await idFromBudgetName(budgetName);
|
||||
}
|
||||
|
||||
const budgetDir = fs.getBudgetDir(id);
|
||||
await fs.mkdir(budgetDir);
|
||||
|
||||
// Create the initial database
|
||||
await fs.copyFile(fs.bundledDatabasePath, fs.join(budgetDir, 'db.sqlite'));
|
||||
|
||||
// Create the initial prefs file
|
||||
await fs.writeFile(
|
||||
fs.join(budgetDir, 'metadata.json'),
|
||||
JSON.stringify(prefs.getDefaultPrefs(id, budgetName)),
|
||||
);
|
||||
|
||||
// Load it in
|
||||
const { error } = await loadBudget(id);
|
||||
if (error) {
|
||||
console.log('Error creating budget: ' + error);
|
||||
return { error };
|
||||
}
|
||||
|
||||
if (!avoidUpload && !testMode) {
|
||||
try {
|
||||
await cloudStorage.upload();
|
||||
} catch (e) {
|
||||
// Ignore any errors uploading. If they are offline they should
|
||||
// still be able to create files.
|
||||
}
|
||||
}
|
||||
|
||||
if (testMode) {
|
||||
await createTestBudget(handlers);
|
||||
}
|
||||
|
||||
return {};
|
||||
};
|
||||
|
||||
handlers['import-budget'] = async function ({ filepath, type }) {
|
||||
try {
|
||||
if (!(await fs.exists(filepath))) {
|
||||
throw new Error(`File not found at the provided path: ${filepath}`);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await fs.readFile(filepath, 'binary'));
|
||||
const results = await handleBudgetImport(type, filepath, buffer);
|
||||
return results || {};
|
||||
} catch (err) {
|
||||
err.message = 'Error importing budget: ' + err.message;
|
||||
captureException(err);
|
||||
return { error: 'internal-error' };
|
||||
}
|
||||
};
|
||||
|
||||
handlers['export-budget'] = async function () {
|
||||
try {
|
||||
return {
|
||||
data: await cloudStorage.exportBuffer(),
|
||||
};
|
||||
} catch (err) {
|
||||
err.message = 'Error exporting budget: ' + err.message;
|
||||
captureException(err);
|
||||
return { error: 'internal-error' };
|
||||
}
|
||||
};
|
||||
|
||||
handlers['enable-openid'] = async function (loginConfig) {
|
||||
try {
|
||||
const userToken = await asyncStorage.getItem('user-token');
|
||||
@@ -924,172 +499,6 @@ handlers['get-openid-config'] = async function ({ password }) {
|
||||
}
|
||||
};
|
||||
|
||||
async function loadBudget(id: string) {
|
||||
let dir: string;
|
||||
try {
|
||||
dir = fs.getBudgetDir(id);
|
||||
} catch (e) {
|
||||
captureException(
|
||||
new Error('`getBudgetDir` failed in `loadBudget`: ' + e.message),
|
||||
);
|
||||
return { error: 'budget-not-found' };
|
||||
}
|
||||
|
||||
captureBreadcrumb({ message: 'Loading budget ' + dir });
|
||||
|
||||
if (!(await fs.exists(dir))) {
|
||||
captureException(new Error('budget directory does not exist'));
|
||||
return { error: 'budget-not-found' };
|
||||
}
|
||||
|
||||
try {
|
||||
await prefs.loadPrefs(id);
|
||||
await db.openDatabase(id);
|
||||
} catch (e) {
|
||||
captureBreadcrumb({ message: 'Error loading budget ' + id });
|
||||
captureException(e);
|
||||
await handlers['close-budget']();
|
||||
return { error: 'opening-budget' };
|
||||
}
|
||||
|
||||
// Older versions didn't tag the file with the current user, so do
|
||||
// so now
|
||||
if (!prefs.getPrefs().userId) {
|
||||
const userId = await asyncStorage.getItem('user-token');
|
||||
prefs.savePrefs({ userId });
|
||||
}
|
||||
|
||||
try {
|
||||
await updateVersion();
|
||||
} catch (e) {
|
||||
console.warn('Error updating', e);
|
||||
let result;
|
||||
if (e.message.includes('out-of-sync-migrations')) {
|
||||
result = { error: 'out-of-sync-migrations' };
|
||||
} else if (e.message.includes('out-of-sync-data')) {
|
||||
result = { error: 'out-of-sync-data' };
|
||||
} else {
|
||||
captureException(e);
|
||||
logger.info('Error updating budget ' + id, e);
|
||||
console.log('Error updating budget', e);
|
||||
result = { error: 'loading-budget' };
|
||||
}
|
||||
|
||||
await handlers['close-budget']();
|
||||
return result;
|
||||
}
|
||||
|
||||
await db.loadClock();
|
||||
|
||||
if (prefs.getPrefs().resetClock) {
|
||||
// If we need to generate a fresh clock, we need to generate a new
|
||||
// client id. This happens when the database is transferred to a
|
||||
// new device.
|
||||
//
|
||||
// TODO: The client id should be stored elsewhere. It shouldn't
|
||||
// work this way, but it's fine for now.
|
||||
CRDT.getClock().timestamp.setNode(CRDT.makeClientId());
|
||||
await db.runQuery(
|
||||
'INSERT OR REPLACE INTO messages_clock (id, clock) VALUES (1, ?)',
|
||||
[CRDT.serializeClock(CRDT.getClock())],
|
||||
);
|
||||
|
||||
await prefs.savePrefs({ resetClock: false });
|
||||
}
|
||||
|
||||
if (
|
||||
!Platform.isWeb &&
|
||||
!Platform.isMobile &&
|
||||
process.env.NODE_ENV !== 'test'
|
||||
) {
|
||||
await startBackupService(id);
|
||||
}
|
||||
|
||||
try {
|
||||
await sheet.loadSpreadsheet(db, onSheetChange);
|
||||
} catch (e) {
|
||||
captureException(e);
|
||||
await handlers['close-budget']();
|
||||
return { error: 'opening-budget' };
|
||||
}
|
||||
|
||||
// This is a bit leaky, but we need to set the initial budget type
|
||||
const { value: budgetType = 'rollover' } =
|
||||
(await db.first<Pick<db.DbPreference, 'value'>>(
|
||||
'SELECT value from preferences WHERE id = ?',
|
||||
['budgetType'],
|
||||
)) ?? {};
|
||||
sheet.get().meta().budgetType = budgetType;
|
||||
await budget.createAllBudgets();
|
||||
|
||||
// Load all the in-memory state
|
||||
await mappings.loadMappings();
|
||||
await rules.loadRules();
|
||||
await syncMigrations.listen();
|
||||
await app.startServices();
|
||||
|
||||
clearUndo();
|
||||
|
||||
// Ensure that syncing is enabled
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
if (id === DEMO_BUDGET_ID) {
|
||||
setSyncingMode('disabled');
|
||||
} else {
|
||||
if (getServer()) {
|
||||
setSyncingMode('enabled');
|
||||
} else {
|
||||
setSyncingMode('disabled');
|
||||
}
|
||||
|
||||
await asyncStorage.setItem('lastBudget', id);
|
||||
|
||||
// Only upload periodically on desktop
|
||||
if (!Platform.isMobile) {
|
||||
await cloudStorage.possiblyUpload();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.events.emit('load-budget', { id });
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
handlers['upload-file-web'] = async function ({ filename, contents }) {
|
||||
if (!Platform.isWeb) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await fs.writeFile('/uploads/' + filename, contents);
|
||||
return {};
|
||||
};
|
||||
|
||||
handlers['backups-get'] = async function ({ id }) {
|
||||
return getAvailableBackups(id);
|
||||
};
|
||||
|
||||
handlers['backup-load'] = async function ({ id, backupId }) {
|
||||
await loadBackup(id, backupId);
|
||||
};
|
||||
|
||||
handlers['backup-make'] = async function ({ id }) {
|
||||
await makeBackup(id);
|
||||
};
|
||||
|
||||
handlers['get-last-opened-backup'] = async function () {
|
||||
const id = await asyncStorage.getItem('lastBudget');
|
||||
if (id && id !== '') {
|
||||
const budgetDir = fs.getBudgetDir(id);
|
||||
|
||||
// We never want to give back a budget that does not exist on the
|
||||
// filesystem anymore, so first check that it exists
|
||||
if (await fs.exists(budgetDir)) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
handlers['app-focused'] = async function () {
|
||||
if (prefs.getPrefs() && prefs.getPrefs().id) {
|
||||
// First we sync
|
||||
@@ -1119,6 +528,7 @@ app.combine(
|
||||
payeesApp,
|
||||
spreadsheetApp,
|
||||
syncApp,
|
||||
budgetFilesApp,
|
||||
);
|
||||
|
||||
export function getDefaultDocumentDir() {
|
||||
|
||||
@@ -3,6 +3,7 @@ import mitt from 'mitt';
|
||||
|
||||
import { QueryState } from '../../shared/query';
|
||||
import { compileQuery, runCompiledQuery, schema, schemaConfig } from '../aql';
|
||||
import { BudgetType } from '../prefs';
|
||||
|
||||
import { Graph } from './graph-data-structure';
|
||||
import { unresolveName, resolveName } from './util';
|
||||
@@ -20,7 +21,10 @@ export type Node = {
|
||||
};
|
||||
|
||||
export class Spreadsheet {
|
||||
_meta;
|
||||
_meta: {
|
||||
createdMonths: Set<string>;
|
||||
budgetType: BudgetType;
|
||||
};
|
||||
cacheBarrier;
|
||||
computeQueue;
|
||||
dirtyCells;
|
||||
@@ -44,6 +48,7 @@ export class Spreadsheet {
|
||||
this.events = mitt();
|
||||
this._meta = {
|
||||
createdMonths: new Set(),
|
||||
budgetType: 'rollover',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,15 @@ export function getUploadError({
|
||||
}
|
||||
}
|
||||
|
||||
export function getDownloadError({ reason, meta, fileName }) {
|
||||
export function getDownloadError({
|
||||
reason,
|
||||
meta,
|
||||
fileName,
|
||||
}: {
|
||||
reason: string;
|
||||
meta?: unknown;
|
||||
fileName?: string;
|
||||
}) {
|
||||
switch (reason) {
|
||||
case 'network':
|
||||
case 'download-failure':
|
||||
@@ -65,7 +73,10 @@ export function getDownloadError({ reason, meta, fileName }) {
|
||||
);
|
||||
|
||||
default:
|
||||
const info = meta && meta.fileId ? `, fileId: ${meta.fileId}` : '';
|
||||
const info =
|
||||
meta && typeof meta === 'object' && 'fileId' in meta && meta.fileId
|
||||
? `, fileId: ${meta.fileId}`
|
||||
: '';
|
||||
return t(
|
||||
'Something went wrong trying to download that file, sorry! Visit https://actualbudget.org/contact/ for support. reason: {{reason}}{{info}}',
|
||||
{ reason, info },
|
||||
|
||||
4
packages/loot-core/src/types/handlers.d.ts
vendored
4
packages/loot-core/src/types/handlers.d.ts
vendored
@@ -1,6 +1,7 @@
|
||||
import type { AccountHandlers } from '../server/accounts/app';
|
||||
import type { AdminHandlers } from '../server/admin/app';
|
||||
import type { BudgetHandlers } from '../server/budget/app';
|
||||
import type { BudgetFileHandlers } from '../server/budgetfiles/app';
|
||||
import type { DashboardHandlers } from '../server/dashboard/app';
|
||||
import type { FiltersHandlers } from '../server/filters/app';
|
||||
import type { NotesHandlers } from '../server/notes/app';
|
||||
@@ -34,6 +35,7 @@ export interface Handlers
|
||||
AccountHandlers,
|
||||
PayeesHandlers,
|
||||
SpreadsheetHandlers,
|
||||
SyncHandlers {}
|
||||
SyncHandlers,
|
||||
BudgetFileHandlers {}
|
||||
|
||||
export type HandlerFunctions = Handlers[keyof Handlers];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type Backup } from '../server/backups';
|
||||
import { type Backup } from '../server/budgetfiles/backups';
|
||||
import { type UndoState } from '../server/undo';
|
||||
|
||||
type SyncSubtype =
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
import { Backup } from '../server/backups';
|
||||
import { RemoteFile } from '../server/cloud-storage';
|
||||
import { Message } from '../server/sync';
|
||||
import { QueryState } from '../shared/query';
|
||||
|
||||
import { Budget } from './budget';
|
||||
import { OpenIdConfig } from './models/openid';
|
||||
import { EmptyObject } from './util';
|
||||
|
||||
export interface ServerHandlers {
|
||||
undo: () => Promise<void>;
|
||||
redo: () => Promise<void>;
|
||||
|
||||
'get-earliest-transaction': () => Promise<{ date: string }>;
|
||||
|
||||
'make-filters-from-conditions': (arg: {
|
||||
conditions: unknown;
|
||||
applySpecialCases?: boolean;
|
||||
@@ -104,83 +98,6 @@ 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[]>;
|
||||
|
||||
'get-user-file-info': (fileId: string) => Promise<RemoteFile | null>;
|
||||
|
||||
'reset-budget-cache': () => Promise<unknown>;
|
||||
|
||||
'upload-budget': (arg: { id }) => Promise<{ error?: string }>;
|
||||
|
||||
'download-budget': (arg: { fileId; replace? }) => Promise<{ error; id }>;
|
||||
|
||||
'sync-budget': () => Promise<{
|
||||
error?: { message: string; reason: string; meta: unknown };
|
||||
}>;
|
||||
|
||||
'load-budget': (arg: { id: string }) => Promise<{ error }>;
|
||||
|
||||
'create-demo-budget': () => Promise<unknown>;
|
||||
|
||||
'close-budget': () => Promise<'ok'>;
|
||||
|
||||
'delete-budget': (arg: {
|
||||
id?: string | undefined;
|
||||
cloudFileId?: string | undefined;
|
||||
}) => 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 | undefined;
|
||||
cloudId?: string | undefined;
|
||||
newName: string;
|
||||
cloudSync?: boolean;
|
||||
open: 'none' | 'original' | 'copy';
|
||||
}) => Promise<string>;
|
||||
|
||||
'create-budget': (arg: {
|
||||
budgetName?;
|
||||
avoidUpload?;
|
||||
testMode?: boolean;
|
||||
testBudgetId?;
|
||||
}) => Promise<unknown>;
|
||||
|
||||
'import-budget': (arg: {
|
||||
filepath: string;
|
||||
type: 'ynab4' | 'ynab5' | 'actual';
|
||||
}) => Promise<{ error?: string }>;
|
||||
|
||||
'export-budget': () => Promise<{ data: Buffer } | { error: string }>;
|
||||
|
||||
'upload-file-web': (arg: {
|
||||
filename: string;
|
||||
contents: ArrayBuffer;
|
||||
}) => Promise<EmptyObject | null>;
|
||||
|
||||
'backups-get': (arg: { id: string }) => Promise<Backup[]>;
|
||||
|
||||
'backup-load': (arg: { id: string; backupId: string }) => Promise<void>;
|
||||
|
||||
'backup-make': (arg: { id: string }) => Promise<void>;
|
||||
|
||||
'get-last-opened-backup': () => Promise<string | null>;
|
||||
|
||||
'app-focused': () => Promise<void>;
|
||||
|
||||
'enable-openid': (arg: {
|
||||
|
||||
6
upcoming-release-notes/4547.md
Normal file
6
upcoming-release-notes/4547.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [joel-jeremy]
|
||||
---
|
||||
|
||||
Extract budget file related server handlers from main.ts to server/budgetfiles/app.ts
|
||||
Reference in New Issue
Block a user