From a87e2c664c9aa46bf7e9137fcb57bfae4ae8d35e Mon Sep 17 00:00:00 2001 From: David Lechner Date: Tue, 1 Mar 2022 13:28:54 -0600 Subject: [PATCH] fileStorage: add export actions These are largely copied from the editor saveAs actions and allow exporting ("downloading") a file from the browser local storage to the file system. --- src/fileStorage/actions.ts | 31 +++++++ src/fileStorage/sagas.test.ts | 148 ++++++++++++++++++++++++++++++++ src/fileStorage/sagas.ts | 64 ++++++++++++++ src/notifications/i18n.en.json | 3 +- src/notifications/i18n.ts | 1 + src/notifications/sagas.test.ts | 6 ++ src/notifications/sagas.ts | 13 +++ 7 files changed, 265 insertions(+), 1 deletion(-) diff --git a/src/fileStorage/actions.ts b/src/fileStorage/actions.ts index f6cf0c2c..66c7c186 100644 --- a/src/fileStorage/actions.ts +++ b/src/fileStorage/actions.ts @@ -77,3 +77,34 @@ export const fileStorageDidFailToWriteFile = createAction( error, }), ); + +/** + * Request to export (download) a file. + * @param fileName The name of the file. + */ +export const fileStorageExportFile = createAction((fileName: string) => ({ + type: 'fileStorage.action.exportFile', + fileName, +})); + +/** + * Indicates that fileStorageExportFile(fileName) succeeded. + * @param fileName The name of the file. + */ +export const fileStorageDidExportFile = createAction((fileName: string) => ({ + type: 'fileStorage.action.didExportFile', + fileName, +})); + +/** + * Indicates that fileStorageExportFile(fileName) failed. + * @param fileName The name of the file. + * @param error The error that was raised. + */ +export const fileStorageDidFailToExportFile = createAction( + (fileName: string, error: Error) => ({ + type: 'fileStorage.action.didFailToExportFile', + fileName, + error, + }), +); diff --git a/src/fileStorage/sagas.test.ts b/src/fileStorage/sagas.test.ts index 7b05a712..f539a0bb 100644 --- a/src/fileStorage/sagas.test.ts +++ b/src/fileStorage/sagas.test.ts @@ -1,18 +1,25 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2022 The Pybricks Authors +import FileSaver from 'file-saver'; +import { mock } from 'jest-mock-extended'; import { AsyncSaga } from '../../test'; import { fileStorageDidChangeItem, + fileStorageDidExportFile, + fileStorageDidFailToExportFile, fileStorageDidFailToReadFile, fileStorageDidInitialize, fileStorageDidReadFile, fileStorageDidWriteFile, + fileStorageExportFile, fileStorageReadFile, fileStorageWriteFile, } from './actions'; import fileStorage from './sagas'; +jest.mock('file-saver'); + beforeEach(() => { // localForge uses localStorage as backend in test environment, so we need // to start with a clean slate in each test @@ -83,3 +90,144 @@ it('should dispatch fail action if file does not exist', async () => { await saga.end(); }); + +describe('export', () => { + /** + * helper function that writes test file to storage for later use in a test + * @param saga The saga. + * @returns The test file name and test file contents. + */ + async function setUpTestFile(saga: AsyncSaga): Promise<[string, string]> { + 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)); + + return [testFileName, testFileContents]; + } + + it('should fail if file does not exist', async () => { + const testFileName = 'test.file'; + + const saga = new AsyncSaga(fileStorage); + + const action0 = await saga.take(); + expect(action0).toEqual(fileStorageDidInitialize([])); + + saga.put(fileStorageExportFile(testFileName)); + + const action = await saga.take(); + expect(action).toEqual( + fileStorageDidFailToExportFile( + testFileName, + new Error('file does not exist'), + ), + ); + + await saga.end(); + }); + + it('should export 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), + }), + ); + + const [testFileName] = await setUpTestFile(saga); + + saga.put(fileStorageExportFile(testFileName)); + + const action = await saga.take(); + expect(action).toEqual(fileStorageDidExportFile(testFileName)); + 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), + }), + ); + + const [testFileName] = await setUpTestFile(saga); + + saga.put(fileStorageExportFile(testFileName)); + + const action = await saga.take(); + expect(action).toEqual(fileStorageDidFailToExportFile(testFileName, 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'); + + const [testFileName] = await setUpTestFile(saga); + + saga.put(fileStorageExportFile(testFileName)); + + const action = await saga.take(); + expect(action).toEqual(fileStorageDidExportFile(testFileName)); + 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; + }); + + const [testFileName] = await setUpTestFile(saga); + + saga.put(fileStorageExportFile(testFileName)); + + const action = await saga.take(); + expect(action).toEqual(fileStorageDidFailToExportFile(testFileName, testError)); + expect(mockFileSaverSaveAs).toHaveBeenCalled(); + + await saga.end(); + + mockFileSaverSaveAs.mockRestore(); + }); +}); diff --git a/src/fileStorage/sagas.ts b/src/fileStorage/sagas.ts index e12f0bb2..416a8d92 100644 --- a/src/fileStorage/sagas.ts +++ b/src/fileStorage/sagas.ts @@ -1,6 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2022 The Pybricks Authors +import FileSaver from 'file-saver'; import localForage from 'localforage'; import { extendPrototype } from 'localforage-observable'; import { eventChannel } from 'redux-saga'; @@ -9,6 +10,8 @@ import Observable from 'zen-observable'; import { ensureError } from '../utils'; import { fileStorageDidChangeItem, + fileStorageDidExportFile, + fileStorageDidFailToExportFile, fileStorageDidFailToInitialize, fileStorageDidFailToReadFile, fileStorageDidFailToWriteFile, @@ -16,6 +19,7 @@ import { fileStorageDidReadFile, fileStorageDidRemoveItem, fileStorageDidWriteFile, + fileStorageExportFile, fileStorageReadFile, fileStorageWriteFile, } from './actions'; @@ -78,6 +82,65 @@ function* handleWriteFile( } } +function* handleExportFile( + files: LocalForage, + action: ReturnType, +): Generator { + const data = yield* call(() => files.getItem(action.fileName)); + + if (data === null) { + yield* put( + fileStorageDidFailToExportFile( + action.fileName, + new Error('file does not exist'), + ), + ); + return; + } + + const blob = new Blob([data], { type: 'text/x-python;charset=utf-8' }); + + if (window.showSaveFilePicker) { + // This uses https://wicg.github.io/file-system-access which is not + // available in all browsers + try { + const handle = yield* call(() => + window.showSaveFilePicker({ + suggestedName: 'main.py', + types: [ + { + accept: { 'text/x-python': '.py' }, + // TODO: translate description + description: 'Python Files', + }, + ], + }), + ); + + const writeable = yield* call(() => handle.createWritable()); + yield* call(() => writeable.write(blob)); + yield* call(() => writeable.close()); + } catch (err) { + yield* put( + fileStorageDidFailToExportFile(action.fileName, ensureError(err)), + ); + return; + } + } else { + // this is a fallback to use the standard browser download mechanism + try { + FileSaver.saveAs(blob, 'main.py'); + } catch (err) { + yield* put( + fileStorageDidFailToExportFile(action.fileName, ensureError(err)), + ); + return; + } + } + + yield* put(fileStorageDidExportFile(action.fileName)); +} + /** * Initializes the storage backend. */ @@ -130,6 +193,7 @@ function* initialize(): Generator { yield* takeEvery(localForageChannel, handleFileStorageDidChange); yield* takeEvery(fileStorageReadFile, handleReadFile, files); yield* takeEvery(fileStorageWriteFile, handleWriteFile, files); + yield* takeEvery(fileStorageExportFile, handleExportFile, files); const fileNames = yield* call(() => files.keys()); diff --git a/src/notifications/i18n.en.json b/src/notifications/i18n.en.json index 8a9f682c..0b2d1ef3 100644 --- a/src/notifications/i18n.en.json +++ b/src/notifications/i18n.en.json @@ -17,7 +17,8 @@ "fileStorage": { "failedToInitialize": "Failed to initial file storage. Changes will not be automatically saved.", "failedToRead": "Failed to read file.", - "failedToWrite": "Failed to write file." + "failedToWrite": "Failed to write file.", + "failedToExport": "Failed to export file.'" }, "flashFirmware": { "timedOut": "The hub took too long to respond. Restart the hub and try again.", diff --git a/src/notifications/i18n.ts b/src/notifications/i18n.ts index 1b9e90df..7adb817d 100644 --- a/src/notifications/i18n.ts +++ b/src/notifications/i18n.ts @@ -16,6 +16,7 @@ export enum MessageId { FileStorageFailedToInitialize = 'fileStorage.failedToInitialize', FileStorageFailedToRead = 'fileStorage.failedToRead', FileStorageFailedToWrite = 'fileStorage.failedToWrite', + FileStorageFailedToExport = 'fileStorage.failedToExport', FlashFirmwareTimedOut = 'flashFirmware.timedOut', FlashFirmwareBleError = 'flashFirmware.bleError', FlashFirmwareDisconnected = 'flashFirmware.disconnected', diff --git a/src/notifications/sagas.test.ts b/src/notifications/sagas.test.ts index 01c385e1..0c64fe12 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 { + fileStorageDidFailToExportFile, fileStorageDidFailToInitialize, fileStorageDidFailToReadFile, fileStorageDidFailToWriteFile, @@ -90,6 +91,7 @@ test.each([ fileStorageDidFailToInitialize(new Error('test error')), fileStorageDidFailToReadFile('test.file', new Error('test error')), fileStorageDidFailToWriteFile('test.file', new Error('test error')), + fileStorageDidFailToExportFile('test.file', new Error('test error')), ])('actions that should show notification: %o', async (action: AnyAction) => { const getToasts = jest.fn().mockReturnValue([]); const show = jest.fn(); @@ -122,6 +124,10 @@ test.each([ didCheckForUpdate(true), bleDIServiceDidReceiveFirmwareRevision(firmwareVersion), didFailToSaveAs(new DOMException('test message', 'AbortError')), + fileStorageDidFailToExportFile( + 'test.file', + 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 33acfa14..74d458f7 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 { + fileStorageDidFailToExportFile, fileStorageDidFailToInitialize, fileStorageDidFailToReadFile, fileStorageDidFailToWriteFile, @@ -402,6 +403,17 @@ function* showFileStorageFailToWrite( yield* showUnexpectedError(MessageId.FileStorageFailedToWrite, action.error); } +function* showFileStorageFailToExport( + 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); @@ -416,4 +428,5 @@ export default function* (): Generator { yield* takeEvery(fileStorageDidFailToInitialize, showFileStorageFailToInitialize); yield* takeEvery(fileStorageDidFailToReadFile, showFileStorageFailToRead); yield* takeEvery(fileStorageDidFailToWriteFile, showFileStorageFailToWrite); + yield* takeEvery(fileStorageDidFailToExportFile, showFileStorageFailToExport); }