mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-12 09:36:27 +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,
|
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 { mock } from 'jest-mock-extended';
|
||||||
import { AsyncSaga } from '../../test';
|
import { AsyncSaga } from '../../test';
|
||||||
import {
|
import {
|
||||||
|
fileStorageArchiveAllFiles,
|
||||||
|
fileStorageDidArchiveAllFiles,
|
||||||
fileStorageDidChangeItem,
|
fileStorageDidChangeItem,
|
||||||
fileStorageDidExportFile,
|
fileStorageDidExportFile,
|
||||||
|
fileStorageDidFailToArchiveAllFiles,
|
||||||
fileStorageDidFailToExportFile,
|
fileStorageDidFailToExportFile,
|
||||||
fileStorageDidFailToReadFile,
|
fileStorageDidFailToReadFile,
|
||||||
fileStorageDidInitialize,
|
fileStorageDidInitialize,
|
||||||
@@ -231,3 +234,120 @@ describe('export', () => {
|
|||||||
mockFileSaverSaveAs.mockRestore();
|
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
|
// Copyright (c) 2022 The Pybricks Authors
|
||||||
|
|
||||||
import FileSaver from 'file-saver';
|
import FileSaver from 'file-saver';
|
||||||
|
import JSZip from 'jszip';
|
||||||
import localForage from 'localforage';
|
import localForage from 'localforage';
|
||||||
import { extendPrototype } from 'localforage-observable';
|
import { extendPrototype } from 'localforage-observable';
|
||||||
import { eventChannel } from 'redux-saga';
|
import { eventChannel } from 'redux-saga';
|
||||||
import { call, fork, put, takeEvery } from 'typed-redux-saga/macro';
|
import { call, fork, put, takeEvery } from 'typed-redux-saga/macro';
|
||||||
import Observable from 'zen-observable';
|
import Observable from 'zen-observable';
|
||||||
import { ensureError } from '../utils';
|
import { ensureError, timestamp } from '../utils';
|
||||||
import {
|
import {
|
||||||
|
fileStorageArchiveAllFiles,
|
||||||
|
fileStorageDidArchiveAllFiles,
|
||||||
fileStorageDidChangeItem,
|
fileStorageDidChangeItem,
|
||||||
fileStorageDidExportFile,
|
fileStorageDidExportFile,
|
||||||
|
fileStorageDidFailToArchiveAllFiles,
|
||||||
fileStorageDidFailToExportFile,
|
fileStorageDidFailToExportFile,
|
||||||
fileStorageDidFailToInitialize,
|
fileStorageDidFailToInitialize,
|
||||||
fileStorageDidFailToReadFile,
|
fileStorageDidFailToReadFile,
|
||||||
@@ -141,6 +145,50 @@ function* handleExportFile(
|
|||||||
yield* put(fileStorageDidExportFile(action.fileName));
|
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.
|
* Initializes the storage backend.
|
||||||
*/
|
*/
|
||||||
@@ -194,6 +242,7 @@ function* initialize(): Generator {
|
|||||||
yield* takeEvery(fileStorageReadFile, handleReadFile, files);
|
yield* takeEvery(fileStorageReadFile, handleReadFile, files);
|
||||||
yield* takeEvery(fileStorageWriteFile, handleWriteFile, files);
|
yield* takeEvery(fileStorageWriteFile, handleWriteFile, files);
|
||||||
yield* takeEvery(fileStorageExportFile, handleExportFile, files);
|
yield* takeEvery(fileStorageExportFile, handleExportFile, files);
|
||||||
|
yield* takeEvery(fileStorageArchiveAllFiles, handleArchiveAllFiles, files);
|
||||||
|
|
||||||
const fileNames = yield* call(() => files.keys());
|
const fileNames = yield* call(() => files.keys());
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
} from '../ble/actions';
|
} from '../ble/actions';
|
||||||
import { didFailToSaveAs } from '../editor/actions';
|
import { didFailToSaveAs } from '../editor/actions';
|
||||||
import {
|
import {
|
||||||
|
fileStorageDidFailToArchiveAllFiles,
|
||||||
fileStorageDidFailToExportFile,
|
fileStorageDidFailToExportFile,
|
||||||
fileStorageDidFailToInitialize,
|
fileStorageDidFailToInitialize,
|
||||||
fileStorageDidFailToReadFile,
|
fileStorageDidFailToReadFile,
|
||||||
@@ -92,6 +93,7 @@ test.each([
|
|||||||
fileStorageDidFailToReadFile('test.file', new Error('test error')),
|
fileStorageDidFailToReadFile('test.file', new Error('test error')),
|
||||||
fileStorageDidFailToWriteFile('test.file', new Error('test error')),
|
fileStorageDidFailToWriteFile('test.file', new Error('test error')),
|
||||||
fileStorageDidFailToExportFile('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) => {
|
])('actions that should show notification: %o', async (action: AnyAction) => {
|
||||||
const getToasts = jest.fn().mockReturnValue([]);
|
const getToasts = jest.fn().mockReturnValue([]);
|
||||||
const show = jest.fn();
|
const show = jest.fn();
|
||||||
@@ -128,6 +130,7 @@ test.each([
|
|||||||
'test.file',
|
'test.file',
|
||||||
new DOMException('test message', 'AbortError'),
|
new DOMException('test message', 'AbortError'),
|
||||||
),
|
),
|
||||||
|
fileStorageDidFailToArchiveAllFiles(new DOMException('test message', 'AbortError')),
|
||||||
])('actions that should not show a notification: %o', async (action: AnyAction) => {
|
])('actions that should not show a notification: %o', async (action: AnyAction) => {
|
||||||
const getToasts = jest.fn().mockReturnValue([]);
|
const getToasts = jest.fn().mockReturnValue([]);
|
||||||
const show = jest.fn();
|
const show = jest.fn();
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
} from '../ble/actions';
|
} from '../ble/actions';
|
||||||
import { didFailToSaveAs } from '../editor/actions';
|
import { didFailToSaveAs } from '../editor/actions';
|
||||||
import {
|
import {
|
||||||
|
fileStorageDidFailToArchiveAllFiles,
|
||||||
fileStorageDidFailToExportFile,
|
fileStorageDidFailToExportFile,
|
||||||
fileStorageDidFailToInitialize,
|
fileStorageDidFailToInitialize,
|
||||||
fileStorageDidFailToReadFile,
|
fileStorageDidFailToReadFile,
|
||||||
@@ -414,6 +415,17 @@ function* showFileStorageFailToExport(
|
|||||||
yield* showUnexpectedError(MessageId.FileStorageFailedToExport, action.error);
|
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 {
|
export default function* (): Generator {
|
||||||
yield* takeEvery(bleDeviceDidFailToConnect, showBleDeviceDidFailToConnectError);
|
yield* takeEvery(bleDeviceDidFailToConnect, showBleDeviceDidFailToConnectError);
|
||||||
yield* takeEvery(bootloaderDidFailToConnect, showBootloaderDidFailToConnectError);
|
yield* takeEvery(bootloaderDidFailToConnect, showBootloaderDidFailToConnectError);
|
||||||
@@ -429,4 +441,5 @@ export default function* (): Generator {
|
|||||||
yield* takeEvery(fileStorageDidFailToReadFile, showFileStorageFailToRead);
|
yield* takeEvery(fileStorageDidFailToReadFile, showFileStorageFailToRead);
|
||||||
yield* takeEvery(fileStorageDidFailToWriteFile, showFileStorageFailToWrite);
|
yield* takeEvery(fileStorageDidFailToWriteFile, showFileStorageFailToWrite);
|
||||||
yield* takeEvery(fileStorageDidFailToExportFile, showFileStorageFailToExport);
|
yield* takeEvery(fileStorageDidFailToExportFile, showFileStorageFailToExport);
|
||||||
|
yield* takeEvery(fileStorageDidFailToArchiveAllFiles, showFileStorageFailToArchive);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user