Add ability to import Actual files; enable export on desktop

This commit is contained in:
James Long 2022-05-25 22:42:10 -04:00
parent 58494fe174
commit d7e71158b9
9 changed files with 264 additions and 137 deletions

View file

@ -334,7 +334,7 @@ function FileSettings({
async function onExport() { async function onExport() {
let data = await send('export-budget'); let data = await send('export-budget');
window.Actual.saveFile(data, 'budget.zip', 'Export budget'); window.Actual.saveFile(data, `${prefs.id}.zip`, 'Export budget');
} }
let dateFormat = prefs.dateFormat || 'MM/dd/yyyy'; let dateFormat = prefs.dateFormat || 'MM/dd/yyyy';
@ -431,12 +431,10 @@ function FileSettings({
</View> </View>
</View> </View>
{Platform.isBrowser && ( <View style={{ marginTop: 30, alignItems: 'flex-start' }}>
<View style={{ marginTop: 30, alignItems: 'flex-start' }}> <Title name="Export" />
<Title name="Export" /> <Button onClick={onExport}>Export data</Button>
<Button onClick={onExport}>Export data</Button> </View>
</View>
)}
<Advanced <Advanced
prefs={prefs} prefs={prefs}

View file

@ -10,6 +10,7 @@ import LoadBackup from 'loot-design/src/components/modals/LoadBackup';
import Import from 'loot-design/src/components/manager/Import'; import Import from 'loot-design/src/components/manager/Import';
import ImportYNAB4 from 'loot-design/src/components/manager/ImportYNAB4'; import ImportYNAB4 from 'loot-design/src/components/manager/ImportYNAB4';
import ImportYNAB5 from 'loot-design/src/components/manager/ImportYNAB5'; import ImportYNAB5 from 'loot-design/src/components/manager/ImportYNAB5';
import ImportActual from 'loot-design/src/components/manager/ImportActual';
import DeleteFile from 'loot-design/src/components/manager/DeleteFile'; import DeleteFile from 'loot-design/src/components/manager/DeleteFile';
import CreateEncryptionKey from '../modals/CreateEncryptionKey'; import CreateEncryptionKey from '../modals/CreateEncryptionKey';
import FixEncryptionKey from '../modals/FixEncryptionKey'; import FixEncryptionKey from '../modals/FixEncryptionKey';
@ -72,6 +73,10 @@ function Modals({
return ( return (
<ImportYNAB5 key={name} modalProps={modalProps} actions={actions} /> <ImportYNAB5 key={name} modalProps={modalProps} actions={actions} />
); );
case 'import-actual':
return (
<ImportActual key={name} modalProps={modalProps} actions={actions} />
);
case 'load-backup': { case 'load-backup': {
return ( return (
<Component <Component

View file

@ -8,7 +8,7 @@ let socketClient = null;
function connectSocket(name, onOpen) { function connectSocket(name, onOpen) {
global.Actual.ipcConnect(name, function(client) { global.Actual.ipcConnect(name, function(client) {
client.on('message', data => { client.on('message', data => {
const msg = JSON.parse(data); const msg = data;
if (msg.type === 'error') { if (msg.type === 'error') {
// An error happened while handling a message so cleanup the // An error happened while handling a message so cleanup the
@ -19,7 +19,15 @@ function connectSocket(name, onOpen) {
const { id } = msg; const { id } = msg;
replyHandlers.delete(id); replyHandlers.delete(id);
} else if (msg.type === 'reply') { } else if (msg.type === 'reply') {
const { id, result, mutated, undoTag } = msg; let { id, result, mutated, undoTag } = msg;
// Check if the result is a serialized buffer, and if so
// convert it to a Uint8Array. This is only needed when working
// with node; the web version connection layer automatically
// supports buffers
if (result && result.type === 'Buffer' && Array.isArray(result.data)) {
result = new Uint8Array(result.data);
}
const handler = replyHandlers.get(id); const handler = replyHandlers.get(id);
if (handler) { if (handler) {
@ -53,9 +61,7 @@ function connectSocket(name, onOpen) {
// Send any messages that were queued while closed // Send any messages that were queued while closed
if (messageQueue.length > 0) { if (messageQueue.length > 0) {
messageQueue.forEach(msg => messageQueue.forEach(msg => client.emit('message', msg));
client.emit('message', JSON.stringify(msg))
);
messageQueue = []; messageQueue = [];
} }
@ -78,16 +84,13 @@ module.exports.send = function send(name, args, { catchErrors = false } = {}) {
replyHandlers.set(id, { resolve, reject }); replyHandlers.set(id, { resolve, reject });
if (socketClient) { if (socketClient) {
socketClient.emit( socketClient.emit('message', {
'message', id,
JSON.stringify({ name,
id, args,
name, undoTag: undo.snapshot(),
args, catchErrors: !!catchErrors
undoTag: undo.snapshot(), });
catchErrors: !!catchErrors
})
);
} else { } else {
messageQueue.push({ messageQueue.push({
id, id,

View file

@ -16,7 +16,7 @@ function init(socketName, handlers) {
ipc.serve(() => { ipc.serve(() => {
ipc.server.on('message', (data, socket) => { ipc.server.on('message', (data, socket) => {
let msg = JSON.parse(data); let msg = data;
let { id, name, args, undoTag, catchErrors } = msg; let { id, name, args, undoTag, catchErrors } = msg;
if (handlers[name]) { if (handlers[name]) {
@ -26,20 +26,16 @@ function init(socketName, handlers) {
result = { data: result, error: null }; result = { data: result, error: null };
} }
ipc.server.emit( ipc.server.emit(socket, 'message', {
socket, type: 'reply',
'message', id,
JSON.stringify({ result,
type: 'reply', mutated:
id, isMutating(handlers[name]) &&
result, name !== 'undo' &&
mutated: name !== 'redo',
isMutating(handlers[name]) && undoTag
name !== 'undo' && });
name !== 'redo',
undoTag
})
);
}, },
nativeError => { nativeError => {
let error = coerceError(nativeError); let error = coerceError(nativeError);
@ -47,27 +43,15 @@ function init(socketName, handlers) {
if (name.startsWith('api/')) { if (name.startsWith('api/')) {
// The API is newer and does automatically forward // The API is newer and does automatically forward
// errors // errors
ipc.server.emit( ipc.server.emit(socket, 'message', { type: 'reply', id, error });
socket,
'message',
JSON.stringify({ type: 'reply', id, error })
);
} else if (catchErrors) { } else if (catchErrors) {
ipc.server.emit( ipc.server.emit(socket, 'message', {
socket, type: 'reply',
'message', id,
JSON.stringify({ result: { error, data: null }
type: 'reply', });
id,
result: { error, data: null }
})
);
} else { } else {
ipc.server.emit( ipc.server.emit(socket, 'message', { type: 'error', id });
socket,
'message',
JSON.stringify({ type: 'error', id })
);
} }
if (error.type === 'InternalError' && name !== 'api/load-budget') { if (error.type === 'InternalError' && name !== 'api/load-budget') {
@ -83,16 +67,12 @@ function init(socketName, handlers) {
} else { } else {
console.warn('Unknown method: ' + name); console.warn('Unknown method: ' + name);
captureException(new Error('Unknown server method: ' + name)); captureException(new Error('Unknown server method: ' + name));
ipc.server.emit( ipc.server.emit(socket, 'message', {
socket, type: 'reply',
'message', id,
JSON.stringify({ result: null,
type: 'reply', error: { type: 'APIError', message: 'Unknown method: ' + name }
id, });
result: null,
error: { type: 'APIError', message: 'Unknown method: ' + name }
})
);
} }
}); });
}); });
@ -106,10 +86,7 @@ function getNumClients() {
function send(name, args) { function send(name, args) {
if (ipc.server) { if (ipc.server) {
ipc.server.broadcast( ipc.server.broadcast('message', { type: 'push', name, args });
'message',
JSON.stringify({ type: 'push', name, args })
);
} }
} }

View file

@ -16,12 +16,16 @@ function pathToId(filepath) {
} }
function _exists(filepath) { function _exists(filepath) {
try {
FS.readlink(filepath);
return true;
} catch (e) {}
try { try {
FS.stat(filepath); FS.stat(filepath);
} catch (e) { return true;
return false; } catch (e) {}
} return false;
return true;
} }
function _mkdirRecursively(dir) { function _mkdirRecursively(dir) {

View file

@ -154,7 +154,60 @@ export async function exportBuffer() {
zipped.addFile('metadata.json', metaContent); zipped.addFile('metadata.json', metaContent);
}); });
return zipped.toBuffer(); return Buffer.from(zipped.toBuffer());
}
export async function importBuffer(fileData, buffer) {
let zipped = new AdmZip(buffer);
let entries = zipped.getEntries();
let dbEntry = entries.find(e => e.entryName.includes('db.sqlite'));
let metaEntry = entries.find(e => e.entryName.includes('metadata.json'));
if (!dbEntry || !metaEntry) {
throw FileDownloadError('invalid-zip-file');
}
let dbContent = zipped.readFile(dbEntry);
let metaContent = zipped.readFile(metaEntry);
let meta;
try {
meta = JSON.parse(metaContent.toString('utf8'));
} catch (err) {
throw FileDownloadError('invalid-meta-file');
}
// Update the metadata. The stored file on the server might be
// out-of-date with a few keys
meta = {
...meta,
cloudFileId: fileData.fileId,
groupId: fileData.groupId,
lastUploaded: monthUtils.currentDay(),
encryptKeyId: fileData.encryptMeta ? fileData.encryptMeta.keyId : null
};
let budgetDir = fs.getBudgetDir(meta.id);
if (await fs.exists(budgetDir)) {
// Don't remove the directory so that backups are retained
let dbFile = fs.join(budgetDir, 'db.sqlite');
let metaFile = fs.join(budgetDir, 'metadata.json');
if (await fs.exists(dbFile)) {
await fs.removeFile(dbFile);
}
if (await fs.exists(metaFile)) {
await fs.removeFile(metaFile);
}
} else {
await fs.mkdir(budgetDir);
}
await fs.writeFile(fs.join(budgetDir, 'db.sqlite'), dbContent);
await fs.writeFile(fs.join(budgetDir, 'metadata.json'), JSON.stringify(meta));
return { id: meta.id };
} }
export async function upload() { export async function upload() {
@ -355,58 +408,5 @@ export async function download(fileId, replace) {
} }
} }
let zipped = new AdmZip(buffer); return importBuffer(fileData, buffer, replace);
let entries = zipped.getEntries();
let dbEntry = entries.find(e => e.entryName.includes('db.sqlite'));
let metaEntry = entries.find(e => e.entryName.includes('metadata.json'));
if (!dbEntry || !metaEntry) {
throw FileDownloadError('invalid-zip-file');
}
let dbContent = zipped.readFile(dbEntry);
let metaContent = zipped.readFile(metaEntry);
let meta;
try {
meta = JSON.parse(metaContent.toString('utf8'));
} catch (err) {
throw FileDownloadError('invalid-meta-file');
}
// Update the metadata. The stored file on the server might be
// out-of-date with a few keys
meta = {
...meta,
cloudFileId: fileData.fileId,
groupId: fileData.groupId,
lastUploaded: monthUtils.currentDay(),
encryptKeyId: fileData.encryptMeta ? fileData.encryptMeta.keyId : null
};
let budgetDir = fs.getBudgetDir(meta.id);
if (await fs.exists(budgetDir)) {
if (replace) {
// Don't remove the directory so that backups are retained
let dbFile = fs.join(budgetDir, 'db.sqlite');
let metaFile = fs.join(budgetDir, 'metadata.json');
if (await fs.exists(dbFile)) {
await fs.removeFile(dbFile);
}
if (await fs.exists(metaFile)) {
await fs.removeFile(metaFile);
}
} else {
throw FileDownloadError('file-exists', { id: meta.id });
}
} else {
await fs.mkdir(budgetDir);
}
await fs.writeFile(fs.join(budgetDir, 'db.sqlite'), dbContent);
await fs.writeFile(fs.join(budgetDir, 'metadata.json'), JSON.stringify(meta));
return { id: meta.id };
} }

View file

@ -1834,6 +1834,41 @@ handlers['import-budget'] = async function({ filepath, type }) {
} catch (e) { } catch (e) {
return { error: 'not-ynab5' }; return { error: 'not-ynab5' };
} }
break;
case 'actual':
// We should pull out import/export into its own app so this
// can be abstracted out better. Importing Actual files is a
// special case because we can directly write down the files,
// but because it doesn't go through the API layer we need to
// duplicate some of the workflow
await handlers['close-budget']();
let { id } = await cloudStorage.importBuffer(
{ cloudFileId: null, groupId: null },
buffer
);
// We never want to load cached data from imported files, so
// delete the cache
let sqliteDb = await sqlite.openDatabase(
fs.join(fs.getBudgetDir(id), 'db.sqlite')
);
sqlite.execQuery(
sqliteDb,
`
DELETE FROM kvcache;
DELETE FROM kvcache_key;
`
);
sqlite.closeDatabase(sqliteDb);
// Load the budget, force everything to be computed, and try
// to upload it as a cloud file
await handlers['load-budget']({ id });
await handlers['get-budget-bounds']();
await sheet.waitOnSpreadsheet();
await cloudStorage.upload().catch(err => {});
break; break;
default: default:
} }

View file

@ -37,6 +37,9 @@ function Import({ modalProps, actions, availableImports }) {
case 'ynab5': case 'ynab5':
actions.pushModal('import-ynab5'); actions.pushModal('import-ynab5');
break; break;
case 'actual':
actions.pushModal('import-actual');
break;
default: default:
} }
} }
@ -83,14 +86,12 @@ function Import({ modalProps, actions, availableImports }) {
<div>The newer web app</div> <div>The newer web app</div>
</View> </View>
</Button> </Button>
{false && ( <Button style={itemStyle} onClick={() => onSelectType('actual')}>
<Button style={itemStyle}> <span style={{ fontWeight: 700 }}>Actual</span>
<span style={{ fontWeight: 700 }}>Actual</span> <View style={{ color: colors.n5 }}>
<View style={{ color: colors.n5 }}> <div>Import a file exported from Actual</div>
<div>Import a backup or external file</div> </View>
</View> </Button>
</Button>
)}
</View> </View>
</View> </View>

View file

@ -0,0 +1,104 @@
import React, { useState } from 'react';
import { useDispatch } from 'react-redux';
import { importBudget } from 'loot-core/src/client/actions/budgets';
import {
View,
Block,
Modal,
ButtonWithLoading,
Button,
Link,
P,
ExternalLink
} from '../common';
import { styles, colors } from '../../style';
function getErrorMessage(error) {
switch (error) {
case 'parse-error':
return 'Unable to parse file. Please select a JSON file exported from nYNAB.';
case 'not-ynab5':
return 'This file is not valid. Please select a JSON file exported from nYNAB.';
default:
return 'An unknown error occurred while importing. Sorry! We have been notified of this issue.';
}
}
function Import({ modalProps, availableImports }) {
const dispatch = useDispatch();
const [error, setError] = useState(false);
const [importing, setImporting] = useState(false);
async function onImport() {
const res = await window.Actual.openFileDialog({
properties: ['openFile'],
filters: [{ name: 'actual', extensions: ['zip'] }]
});
if (res) {
setImporting(true);
setError(false);
try {
await dispatch(importBudget(res[0], 'actual'));
} catch (err) {
setError(err.message);
} finally {
setImporting(false);
}
}
}
return (
<Modal
{...modalProps}
showHeader={false}
showOverlay={false}
noAnimation={true}
style={{ width: 400 }}
>
{() => (
<View style={[styles.smallText, { lineHeight: 1.5, marginTop: 20 }]}>
{error && (
<Block style={{ color: colors.r4, marginBottom: 15 }}>
{getErrorMessage(error)}
</Block>
)}
<View style={{ '& > div': { lineHeight: '1.7em' } }}>
<P>
You can import data from another Actual account or instance. First
export your data from a different account, and it will give you a
compressed file. This file is simple zip file that contains the
"db.sqlite" and "metadata.json" files.
</P>
<P>Select one of these compressed files and import it here.</P>
<View style={{ alignSelf: 'center' }}>
<ButtonWithLoading loading={importing} primary onClick={onImport}>
Select file...
</ButtonWithLoading>
</View>
</View>
<View
style={{
flexDirection: 'row',
marginTop: 20,
alignItems: 'center'
}}
>
<View style={{ flex: 1 }} />
<Button
style={{ marginRight: 10 }}
onClick={() => modalProps.onBack()}
>
Back
</Button>
</View>
</View>
)}
</Modal>
);
}
export default Import;