fileStorage: add archive actions

This allows users to back up all files to their computer.
This commit is contained in:
David Lechner
2022-03-02 10:33:21 -06:00
parent 12f34eb210
commit bc7e9e2a20
5 changed files with 209 additions and 1 deletions
+23
View File
@@ -108,3 +108,26 @@ export const fileStorageDidFailToExportFile = createAction(
error,
}),
);
/**
* Request to archive (download) all files in the store.
*/
export const fileStorageArchiveAllFiles = createAction(() => ({
type: 'fileStorage.action.archiveAllFiles',
}));
/**
* Indicates that fileStorageArchiveAllFiles() succeeded.
*/
export const fileStorageDidArchiveAllFiles = createAction(() => ({
type: 'fileStorage.action.didArchiveAllFiles',
}));
/**
* Indicates that fileStorageArchiveAllFiles() failed.
* @param error The error that was raised.
*/
export const fileStorageDidFailToArchiveAllFiles = createAction((error: Error) => ({
type: 'fileStorage.action.didFailToArchiveAllFiles',
error,
}));
+120
View File
@@ -5,8 +5,11 @@ import FileSaver from 'file-saver';
import { mock } from 'jest-mock-extended';
import { AsyncSaga } from '../../test';
import {
fileStorageArchiveAllFiles,
fileStorageDidArchiveAllFiles,
fileStorageDidChangeItem,
fileStorageDidExportFile,
fileStorageDidFailToArchiveAllFiles,
fileStorageDidFailToExportFile,
fileStorageDidFailToReadFile,
fileStorageDidInitialize,
@@ -231,3 +234,120 @@ describe('export', () => {
mockFileSaverSaveAs.mockRestore();
});
});
describe('archive', () => {
/**
* helper function that writes test file to storage for later use in a test
* @param saga The saga.
*/
async function setUpTestFile(saga: AsyncSaga): Promise<void> {
const testFileName = 'test.file';
const testFileContents = 'test file contents';
const action0 = await saga.take();
expect(action0).toEqual(fileStorageDidInitialize([]));
saga.put(fileStorageWriteFile(testFileName, testFileContents));
const action1 = await saga.take();
expect(action1).toEqual(fileStorageDidWriteFile(testFileName));
const action2 = await saga.take();
expect(action2).toEqual(fileStorageDidChangeItem(testFileName));
}
it('should archive file with web file system api', async () => {
const saga = new AsyncSaga(fileStorage);
// window.showSaveFilePicker is not defined in the test environment
// so we can't use spyOn().
const mockWriteable = mock<FileSystemWritableFileStream>();
const originalShowSaveFilePicker = window.showSaveFilePicker;
window.showSaveFilePicker = jest.fn().mockResolvedValue(
mock<FileSystemFileHandle>({
createWritable: jest.fn().mockResolvedValue(mockWriteable),
}),
);
await setUpTestFile(saga);
saga.put(fileStorageArchiveAllFiles());
const action = await saga.take();
expect(action).toEqual(fileStorageDidArchiveAllFiles());
expect(window.showSaveFilePicker).toHaveBeenCalled();
expect(mockWriteable.write).toHaveBeenCalled();
expect(mockWriteable.close).toHaveBeenCalled();
await saga.end();
window.showSaveFilePicker = originalShowSaveFilePicker;
});
it('should get error from web file system api', async () => {
const saga = new AsyncSaga(fileStorage);
// window.showSaveFilePicker is not defined in the test environment
// so we can't use spyOn().
const testError = new Error('test error');
const originalShowSaveFilePicker = window.showSaveFilePicker;
window.showSaveFilePicker = jest.fn().mockResolvedValue(
mock<FileSystemFileHandle>({
createWritable: jest.fn().mockRejectedValue(testError),
}),
);
await setUpTestFile(saga);
saga.put(fileStorageArchiveAllFiles());
const action = await saga.take();
expect(action).toEqual(fileStorageDidFailToArchiveAllFiles(testError));
expect(window.showSaveFilePicker).toHaveBeenCalled();
await saga.end();
window.showSaveFilePicker = originalShowSaveFilePicker;
});
it('should export file using fallback', async () => {
const saga = new AsyncSaga(fileStorage);
const mockFileSaverSaveAs = jest.spyOn(FileSaver, 'saveAs');
await setUpTestFile(saga);
saga.put(fileStorageArchiveAllFiles());
const action = await saga.take();
expect(action).toEqual(fileStorageDidArchiveAllFiles());
expect(mockFileSaverSaveAs).toHaveBeenCalled();
await saga.end();
mockFileSaverSaveAs.mockRestore();
});
it('should get error from fallback', async () => {
const saga = new AsyncSaga(fileStorage);
const testError = new Error('test error');
const mockFileSaverSaveAs = jest
.spyOn(FileSaver, 'saveAs')
.mockImplementation(() => {
throw testError;
});
await setUpTestFile(saga);
saga.put(fileStorageArchiveAllFiles());
const action = await saga.take();
expect(action).toEqual(fileStorageDidFailToArchiveAllFiles(testError));
expect(mockFileSaverSaveAs).toHaveBeenCalled();
await saga.end();
mockFileSaverSaveAs.mockRestore();
});
});
+50 -1
View File
@@ -2,15 +2,19 @@
// Copyright (c) 2022 The Pybricks Authors
import FileSaver from 'file-saver';
import JSZip from 'jszip';
import localForage from 'localforage';
import { extendPrototype } from 'localforage-observable';
import { eventChannel } from 'redux-saga';
import { call, fork, put, takeEvery } from 'typed-redux-saga/macro';
import Observable from 'zen-observable';
import { ensureError } from '../utils';
import { ensureError, timestamp } from '../utils';
import {
fileStorageArchiveAllFiles,
fileStorageDidArchiveAllFiles,
fileStorageDidChangeItem,
fileStorageDidExportFile,
fileStorageDidFailToArchiveAllFiles,
fileStorageDidFailToExportFile,
fileStorageDidFailToInitialize,
fileStorageDidFailToReadFile,
@@ -141,6 +145,50 @@ function* handleExportFile(
yield* put(fileStorageDidExportFile(action.fileName));
}
function* handleArchiveAllFiles(files: LocalForage): Generator {
try {
const zip = new JSZip();
yield* call(() =>
files.iterate<string, void>((value, key) => {
zip.file(key, value);
}),
);
const zipData = yield* call(() => zip.generateAsync({ type: 'blob' }));
const suggestedName = `pybricks-backup-${timestamp()}.zip`;
if (window.showSaveFilePicker) {
// This uses https://wicg.github.io/file-system-access which is not
// available in all browsers
const handle = yield* call(() =>
window.showSaveFilePicker({
suggestedName,
types: [
{
accept: { 'application/zip': '.zip' },
// TODO: translate description
description: 'Zip Files',
},
],
}),
);
const writeable = yield* call(() => handle.createWritable());
yield* call(() => writeable.write(zipData));
yield* call(() => writeable.close());
} else {
// this is a fallback to use the standard browser download mechanism
FileSaver.saveAs(zipData, suggestedName);
}
yield* put(fileStorageDidArchiveAllFiles());
} catch (err) {
yield* put(fileStorageDidFailToArchiveAllFiles(ensureError(err)));
}
}
/**
* Initializes the storage backend.
*/
@@ -194,6 +242,7 @@ function* initialize(): Generator {
yield* takeEvery(fileStorageReadFile, handleReadFile, files);
yield* takeEvery(fileStorageWriteFile, handleWriteFile, files);
yield* takeEvery(fileStorageExportFile, handleExportFile, files);
yield* takeEvery(fileStorageArchiveAllFiles, handleArchiveAllFiles, files);
const fileNames = yield* call(() => files.keys());
+3
View File
@@ -17,6 +17,7 @@ import {
} from '../ble/actions';
import { didFailToSaveAs } from '../editor/actions';
import {
fileStorageDidFailToArchiveAllFiles,
fileStorageDidFailToExportFile,
fileStorageDidFailToInitialize,
fileStorageDidFailToReadFile,
@@ -92,6 +93,7 @@ test.each([
fileStorageDidFailToReadFile('test.file', new Error('test error')),
fileStorageDidFailToWriteFile('test.file', new Error('test error')),
fileStorageDidFailToExportFile('test.file', new Error('test error')),
fileStorageDidFailToArchiveAllFiles(new Error('test error')),
])('actions that should show notification: %o', async (action: AnyAction) => {
const getToasts = jest.fn().mockReturnValue([]);
const show = jest.fn();
@@ -128,6 +130,7 @@ test.each([
'test.file',
new DOMException('test message', 'AbortError'),
),
fileStorageDidFailToArchiveAllFiles(new DOMException('test message', 'AbortError')),
])('actions that should not show a notification: %o', async (action: AnyAction) => {
const getToasts = jest.fn().mockReturnValue([]);
const show = jest.fn();
+13
View File
@@ -19,6 +19,7 @@ import {
} from '../ble/actions';
import { didFailToSaveAs } from '../editor/actions';
import {
fileStorageDidFailToArchiveAllFiles,
fileStorageDidFailToExportFile,
fileStorageDidFailToInitialize,
fileStorageDidFailToReadFile,
@@ -414,6 +415,17 @@ function* showFileStorageFailToExport(
yield* showUnexpectedError(MessageId.FileStorageFailedToExport, action.error);
}
function* showFileStorageFailToArchive(
action: ReturnType<typeof fileStorageDidFailToArchiveAllFiles>,
): Generator {
if (action.error.name === 'AbortError') {
// user clicked cancel button - not an error
return;
}
yield* showUnexpectedError(MessageId.FileStorageFailedToExport, action.error);
}
export default function* (): Generator {
yield* takeEvery(bleDeviceDidFailToConnect, showBleDeviceDidFailToConnectError);
yield* takeEvery(bootloaderDidFailToConnect, showBootloaderDidFailToConnectError);
@@ -429,4 +441,5 @@ export default function* (): Generator {
yield* takeEvery(fileStorageDidFailToReadFile, showFileStorageFailToRead);
yield* takeEvery(fileStorageDidFailToWriteFile, showFileStorageFailToWrite);
yield* takeEvery(fileStorageDidFailToExportFile, showFileStorageFailToExport);
yield* takeEvery(fileStorageDidFailToArchiveAllFiles, showFileStorageFailToArchive);
}