mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-12 01:23:52 +00:00
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.
This commit is contained in:
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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<FileSystemWritableFileStream>();
|
||||
const originalShowSaveFilePicker = window.showSaveFilePicker;
|
||||
window.showSaveFilePicker = jest.fn().mockResolvedValue(
|
||||
mock<FileSystemFileHandle>({
|
||||
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<FileSystemFileHandle>({
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<typeof fileStorageExportFile>,
|
||||
): Generator {
|
||||
const data = yield* call(() => files.getItem<string>(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());
|
||||
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<typeof fileStorageDidFailToExportFile>,
|
||||
): 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user