mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-12 01:23:52 +00:00
fileStorage: add archive actions
This allows users to back up all files to their computer.
This commit is contained in:
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user