diff --git a/src/fileStorage/actions.ts b/src/fileStorage/actions.ts index 66c7c186..bb03a0fa 100644 --- a/src/fileStorage/actions.ts +++ b/src/fileStorage/actions.ts @@ -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, +})); diff --git a/src/fileStorage/sagas.test.ts b/src/fileStorage/sagas.test.ts index f539a0bb..aa469e2d 100644 --- a/src/fileStorage/sagas.test.ts +++ b/src/fileStorage/sagas.test.ts @@ -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 { + 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(); + const originalShowSaveFilePicker = window.showSaveFilePicker; + window.showSaveFilePicker = jest.fn().mockResolvedValue( + mock({ + 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({ + 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(); + }); +}); diff --git a/src/fileStorage/sagas.ts b/src/fileStorage/sagas.ts index 416a8d92..06e1cfe7 100644 --- a/src/fileStorage/sagas.ts +++ b/src/fileStorage/sagas.ts @@ -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((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()); diff --git a/src/notifications/sagas.test.ts b/src/notifications/sagas.test.ts index 0c64fe12..00ec21bf 100644 --- a/src/notifications/sagas.test.ts +++ b/src/notifications/sagas.test.ts @@ -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(); diff --git a/src/notifications/sagas.ts b/src/notifications/sagas.ts index 74d458f7..4c74564e 100644 --- a/src/notifications/sagas.ts +++ b/src/notifications/sagas.ts @@ -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, +): 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); }