From 6a9405e65390a30effbc6504379f98982fa5144b Mon Sep 17 00:00:00 2001 From: David Lechner Date: Tue, 7 Mar 2023 14:17:56 -0600 Subject: [PATCH] explorer: add support for importing ZIP Also add a new dialog to get user input when file names conflict. Previously, we were not supplying a list of existing files, so existing files were silently written over. Fixes: https://github.com/pybricks/support/issues/833 --- CHANGELOG.md | 8 + src/explorer/Explorer.tsx | 4 +- src/explorer/alerts/NoPyFiles.tsx | 27 ++ src/explorer/alerts/index.ts | 5 +- src/explorer/alerts/translations/en.json | 3 + src/explorer/reducers.ts | 4 +- .../ReplaceImportDialog.test.tsx | 59 +++ .../ReplaceImportDialog.tsx | 91 +++++ src/explorer/replaceImportDialog/actions.ts | 37 ++ src/explorer/replaceImportDialog/i18n.ts | 12 + src/explorer/replaceImportDialog/reducers.ts | 36 ++ .../replaceImportDialog.scss | 10 + .../replaceImportDialog/translations/en.json | 12 + src/explorer/sagas.test.ts | 349 +++++++++++++++++- src/explorer/sagas.ts | 198 +++++++--- 15 files changed, 793 insertions(+), 62 deletions(-) create mode 100644 src/explorer/alerts/NoPyFiles.tsx create mode 100644 src/explorer/replaceImportDialog/ReplaceImportDialog.test.tsx create mode 100644 src/explorer/replaceImportDialog/ReplaceImportDialog.tsx create mode 100644 src/explorer/replaceImportDialog/actions.ts create mode 100644 src/explorer/replaceImportDialog/i18n.ts create mode 100644 src/explorer/replaceImportDialog/reducers.ts create mode 100644 src/explorer/replaceImportDialog/replaceImportDialog.scss create mode 100644 src/explorer/replaceImportDialog/translations/en.json diff --git a/CHANGELOG.md b/CHANGELOG.md index ff44c30d..66b2f9c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ ## [Unreleased] +### Added +- Added ability to import ZIP files containing Python files ([support#833]). + +### Fixed +- Fixed importing file with same name overwrites existing without asking user. + +[support#833]: https://github.com/pybricks/support/issues/833 + ## [2.1.1] - 2023-02-17 ### Changed diff --git a/src/explorer/Explorer.tsx b/src/explorer/Explorer.tsx index 781abb7f..b9c96862 100644 --- a/src/explorer/Explorer.tsx +++ b/src/explorer/Explorer.tsx @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2022 The Pybricks Authors +// Copyright (c) 2022-2023 The Pybricks Authors // A file explorer control. @@ -58,6 +58,7 @@ import { useI18n } from './i18n'; import NewFileWizard from './newFileWizard/NewFileWizard'; import RenameFileDialog from './renameFileDialog/RenameFileDialog'; import RenameImportDialog from './renameImportDialog/RenameImportDialog'; +import ReplaceImportDialog from './replaceImportDialog/ReplaceImportDialog'; type ActionButtonProps = { /** The DOM id for this instance. */ @@ -455,6 +456,7 @@ const Explorer: React.VFC = () => { + diff --git a/src/explorer/alerts/NoPyFiles.tsx b/src/explorer/alerts/NoPyFiles.tsx new file mode 100644 index 00000000..02bc0f70 --- /dev/null +++ b/src/explorer/alerts/NoPyFiles.tsx @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2023 The Pybricks Authors + +import { Intent } from '@blueprintjs/core'; +import React from 'react'; +import { pythonFileExtension } from '../../pybricksMicropython/lib'; +import type { CreateToast } from '../../toasterTypes'; +import { useI18n } from './i18n'; + +const NoPyFiles: React.VoidFunctionComponent = () => { + const i18n = useI18n(); + return ( + <> + {i18n.translate('noPyFiles.message', { + py: {pythonFileExtension}, + zip: 'ZIP', + })} + + ); +}; + +export const noPyFiles: CreateToast = (onAction) => ({ + message: , + icon: 'info-sign', + intent: Intent.PRIMARY, + onDismiss: () => onAction('dismiss'), +}); diff --git a/src/explorer/alerts/index.ts b/src/explorer/alerts/index.ts index 535c81e6..61a9d3e1 100644 --- a/src/explorer/alerts/index.ts +++ b/src/explorer/alerts/index.ts @@ -1,8 +1,9 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2022 The Pybricks Authors +// Copyright (c) 2022-2023 The Pybricks Authors import { fileInUse } from './FileInUseAlert'; import { noFilesToBackup } from './NoFilesToBackup'; +import { noPyFiles } from './NoPyFiles'; // gathers all of the alert creation functions for passing up to the top level -export default { fileInUse, noFilesToBackup }; +export default { fileInUse, noFilesToBackup, noPyFiles }; diff --git a/src/explorer/alerts/translations/en.json b/src/explorer/alerts/translations/en.json index 61607e20..a98d2165 100644 --- a/src/explorer/alerts/translations/en.json +++ b/src/explorer/alerts/translations/en.json @@ -4,5 +4,8 @@ }, "noFilesToBackup": { "message": "There are no files to backup. Create a new file first by clicking the {icon} icon." + }, + "noPyFiles": { + "message": "There were no {py} files in the {zip} file." } } diff --git a/src/explorer/reducers.ts b/src/explorer/reducers.ts index b4395b91..ebfea2dc 100644 --- a/src/explorer/reducers.ts +++ b/src/explorer/reducers.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2022 The Pybricks Authors +// Copyright (c) 2022-2023 The Pybricks Authors import { combineReducers } from 'redux'; @@ -8,6 +8,7 @@ import duplicateFileDialog from './duplicateFileDialog/reducers'; import newFileWizard from './newFileWizard/reducers'; import renameFileDialog from './renameFileDialog/reducers'; import renameImportDialog from './renameImportDialog/reducers'; +import replaceImportDialog from './replaceImportDialog/reducers'; export default combineReducers({ duplicateFileDialog, @@ -15,4 +16,5 @@ export default combineReducers({ newFileWizard, renameFileDialog, renameImportDialog, + replaceImportDialog, }); diff --git a/src/explorer/replaceImportDialog/ReplaceImportDialog.test.tsx b/src/explorer/replaceImportDialog/ReplaceImportDialog.test.tsx new file mode 100644 index 00000000..bfadff6a --- /dev/null +++ b/src/explorer/replaceImportDialog/ReplaceImportDialog.test.tsx @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022-2023 The Pybricks Authors + +import { waitFor } from '@testing-library/dom'; +import React from 'react'; +import { testRender } from '../../../test'; +import RenameImportDialog from './ReplaceImportDialog'; +import { + ReplaceImportDialogAction, + replaceImportDialogDidAccept, + replaceImportDialogDidCancel, +} from './actions'; + +describe('replace button', () => { + it.each([ + [/skip/i, ReplaceImportDialogAction.Skip, false], + [/skip/i, ReplaceImportDialogAction.Skip, true], + [/replace/i, ReplaceImportDialogAction.Replace, false], + [/replace/i, ReplaceImportDialogAction.Replace, true], + [/rename/i, ReplaceImportDialogAction.Rename, false], + [/rename/i, ReplaceImportDialogAction.Rename, true], + ])( + 'should accept when %c%s button is clicked and remember checkbox is %s', + async (buttonName, action, remember) => { + const [user, dialog, dispatch] = testRender(, { + explorer: { + replaceImportDialog: { isOpen: true, fileName: 'old.file' }, + }, + }); + + if (remember) { + const rememberCheckBox = dialog.getByRole('checkbox', { + name: /remember/i, + }); + await user.click(rememberCheckBox); + } + + const button = dialog.getByRole('button', { name: buttonName }); + await user.click(button); + + expect(dispatch).toHaveBeenCalledWith( + replaceImportDialogDidAccept(action, remember), + ); + }, + ); + + it('should cancel when close button is clicked', async () => { + const [user, dialog, dispatch] = testRender(, { + explorer: { replaceImportDialog: { isOpen: true } }, + }); + + const button = dialog.getByRole('button', { name: 'Close' }); + + await waitFor(() => expect(button).toBeVisible()); + + await user.click(button); + expect(dispatch).toHaveBeenCalledWith(replaceImportDialogDidCancel()); + }); +}); diff --git a/src/explorer/replaceImportDialog/ReplaceImportDialog.tsx b/src/explorer/replaceImportDialog/ReplaceImportDialog.tsx new file mode 100644 index 00000000..9ce00a0a --- /dev/null +++ b/src/explorer/replaceImportDialog/ReplaceImportDialog.tsx @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022-2023 The Pybricks Authors + +import './replaceImportDialog.scss'; +import { Button, Checkbox, Classes, Dialog, Intent } from '@blueprintjs/core'; +import React, { useCallback, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { useSelector } from '../../reducers'; +import { + ReplaceImportDialogAction, + replaceImportDialogDidAccept, + replaceImportDialogDidCancel, +} from './actions'; +import { useI18n } from './i18n'; + +const RenameImportDialog: React.VFC = () => { + const i18n = useI18n(); + const dispatch = useDispatch(); + const isOpen = useSelector((s) => s.explorer.replaceImportDialog.isOpen); + const fileName = useSelector((s) => s.explorer.replaceImportDialog.fileName); + const [remember, setRemember] = useState(false); + + const handleSubmit = useCallback( + (e) => { + e.preventDefault(); + dispatch( + replaceImportDialogDidAccept( + ((e.nativeEvent as SubmitEvent).submitter as HTMLButtonElement) + .value as ReplaceImportDialogAction, + remember, + ), + ); + }, + [dispatch, remember], + ); + + const handleClose = useCallback(() => { + dispatch(replaceImportDialogDidCancel()); + }, [dispatch]); + + return ( + setRemember(false)} + onClose={handleClose} + > +
+
+

{i18n.translate('message', { fileName })}

+
+
+ + setRemember((e.target as HTMLInputElement).checked) + } + > + {i18n.translate('option.remember')} + +
+ + + +
+
+
+
+ ); +}; + +export default RenameImportDialog; diff --git a/src/explorer/replaceImportDialog/actions.ts b/src/explorer/replaceImportDialog/actions.ts new file mode 100644 index 00000000..e431df68 --- /dev/null +++ b/src/explorer/replaceImportDialog/actions.ts @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2023 The Pybricks Authors + +import { createAction } from '../../actions'; + +/** + * Action that requests to show the replace file dialog. + * @param fileName The file name. + */ +export const replaceImportDialogShow = createAction((fileName: string) => ({ + type: 'explorer.replaceImportDialog.action.show', + fileName, +})); + +export enum ReplaceImportDialogAction { + Skip = 'skip', + Replace = 'replace', + Rename = 'rename', +} + +/** + * Action that indicates the replace file dialog was accepted. + */ +export const replaceImportDialogDidAccept = createAction( + (action: ReplaceImportDialogAction, remember: boolean) => ({ + type: 'explorer.replaceImportDialog.action.didAccept', + action, + remember, + }), +); + +/** + * Action that indicates the replace file dialog was canceled. + */ +export const replaceImportDialogDidCancel = createAction(() => ({ + type: 'explorer.replaceImportDialog.action.didCancel', +})); diff --git a/src/explorer/replaceImportDialog/i18n.ts b/src/explorer/replaceImportDialog/i18n.ts new file mode 100644 index 00000000..eb8dc486 --- /dev/null +++ b/src/explorer/replaceImportDialog/i18n.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022 The Pybricks Authors + +import { useI18n as useShopifyI18n } from '@shopify/react-i18n'; +import type { TypedI18n } from '../../i18n'; +import type translations from './translations/en.json'; + +export function useI18n(): TypedI18n { + // istanbul ignore next: babel-loader rewrites this line + const [i18n] = useShopifyI18n(); + return i18n; +} diff --git a/src/explorer/replaceImportDialog/reducers.ts b/src/explorer/replaceImportDialog/reducers.ts new file mode 100644 index 00000000..39e88aef --- /dev/null +++ b/src/explorer/replaceImportDialog/reducers.ts @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2022-2023 The Pybricks Authors + +import { Reducer, combineReducers } from 'redux'; +import { + replaceImportDialogDidAccept, + replaceImportDialogDidCancel, + replaceImportDialogShow, +} from './actions'; + +/** Controls the replace file dialog isOpen state. */ +const isOpen: Reducer = (state = false, action) => { + if (replaceImportDialogShow.matches(action)) { + return true; + } + + if ( + replaceImportDialogDidAccept.matches(action) || + replaceImportDialogDidCancel.matches(action) + ) { + return false; + } + + return state; +}; + +/** Controls the replace file dialog file name input box text. */ +const fileName: Reducer = (state = '', action) => { + if (replaceImportDialogShow.matches(action)) { + return action.fileName; + } + + return state; +}; + +export default combineReducers({ isOpen, fileName }); diff --git a/src/explorer/replaceImportDialog/replaceImportDialog.scss b/src/explorer/replaceImportDialog/replaceImportDialog.scss new file mode 100644 index 00000000..dabe8fa2 --- /dev/null +++ b/src/explorer/replaceImportDialog/replaceImportDialog.scss @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2023 The Pybricks Authors + +@use '@blueprintjs/core/lib/scss/variables' as bp; + +.#{bp.$ns}-dialog.pb-explorer-replaceImportDialog { + .#{bp.$ns}-dialog-footer-actions { + flex-direction: column; + } +} diff --git a/src/explorer/replaceImportDialog/translations/en.json b/src/explorer/replaceImportDialog/translations/en.json new file mode 100644 index 00000000..2fddb5f7 --- /dev/null +++ b/src/explorer/replaceImportDialog/translations/en.json @@ -0,0 +1,12 @@ +{ + "title": "Replace existing file?", + "message": "A file already exists with the same name as the imported file '{fileName}'.", + "option": { + "remember": "Remember this answer when resolving additional conflicts." + }, + "action": { + "skip": "Keep the existing file and skip importing this file", + "replace": "Replace the existing file with the imported file", + "rename": "Keep the existing file and rename the imported file" + } +} diff --git a/src/explorer/sagas.test.ts b/src/explorer/sagas.test.ts index 0248a36c..f697031b 100644 --- a/src/explorer/sagas.test.ts +++ b/src/explorer/sagas.test.ts @@ -1,9 +1,11 @@ // SPDX-License-Identifier: MIT -// Copyright (c) 2022 The Pybricks Authors +// Copyright (c) 2022-2023 The Pybricks Authors import * as browserFsAccess from 'browser-fs-access'; import { FileWithHandle } from 'browser-fs-access'; +import Dexie from 'dexie'; import { mock } from 'jest-mock-extended'; +import JSZip from 'jszip'; import { AsyncSaga, uuid } from '../../test'; import { alertsShowAlert } from '../alerts/actions'; import { Hub } from '../components/hubPicker'; @@ -15,7 +17,7 @@ import { editorDidFailToActivateFile, } from '../editor/actions'; import { EditorError } from '../editor/error'; -import { UUID } from '../fileStorage'; +import { FileMetadata, FileStorageDb, UUID } from '../fileStorage'; import { fileStorageCopyFile, fileStorageDeleteFile, @@ -86,6 +88,11 @@ import { renameImportDialogDidAccept, renameImportDialogShow, } from './renameImportDialog/actions'; +import { + ReplaceImportDialogAction, + replaceImportDialogDidAccept, + replaceImportDialogShow, +} from './replaceImportDialog/actions'; import explorer from './sagas'; jest.mock('browser-fs-access'); @@ -190,17 +197,38 @@ describe('handleExplorerArchiveAllFiles', () => { }); describe('handleExplorerImportFiles', () => { + const mockFileStorage = (...files: FileMetadata[]) => { + return mock({ + metadata: { toArray: () => Dexie.Promise.resolve(files) }, + }); + }; + + const mockPythonFile = (name: string, contents: string) => { + return mock({ + name, + type: '', + text: () => Promise.resolve(contents), + }); + }; + + const mockZipFile = (name: string, contents: ArrayBuffer) => { + return mock({ + name, + type: 'application/zip', + arrayBuffer: () => Promise.resolve(contents), + }); + }; + it('should write file to storage', async () => { const testFileName = 'test.py'; const testFileContents = '# test'; - const saga = new AsyncSaga(explorer); + const saga = new AsyncSaga(explorer, { + fileStorage: mockFileStorage(), + }); jest.spyOn(browserFsAccess, 'fileOpen').mockResolvedValueOnce([ - mock({ - name: testFileName, - text: () => Promise.resolve(testFileContents), - }), + mockPythonFile(testFileName, testFileContents), ]); saga.put(explorerImportFiles()); @@ -219,7 +247,9 @@ describe('handleExplorerImportFiles', () => { it('should handle user cancellation', async () => { const cancelError = new DOMException('test message', 'AbortError'); - const saga = new AsyncSaga(explorer); + const saga = new AsyncSaga(explorer, { + fileStorage: mockFileStorage(), + }); jest.spyOn(browserFsAccess, 'fileOpen').mockRejectedValueOnce(cancelError); @@ -235,13 +265,12 @@ describe('handleExplorerImportFiles', () => { const testFileName = 'bad#name.py'; const testFileContents = '# test'; - const saga = new AsyncSaga(explorer); + const saga = new AsyncSaga(explorer, { + fileStorage: mockFileStorage(), + }); jest.spyOn(browserFsAccess, 'fileOpen').mockResolvedValueOnce([ - mock({ - name: testFileName, - text: () => Promise.resolve(testFileContents), - }), + mockPythonFile(testFileName, testFileContents), ]); saga.put(explorerImportFiles()); @@ -264,6 +293,300 @@ describe('handleExplorerImportFiles', () => { await saga.end(); }); + + describe('duplicate file name', () => { + it.each([false, true])( + 'should handle user selected replace and remember is %s', + async (remember) => { + const testFileName1 = 'test1.py'; + const testFileContents1 = '# test'; + const testFileUuid1 = uuid(1); + + const testFileName2 = 'test2.py'; + const testFileContents2 = '# test'; + const testFileUuid2 = uuid(2); + + const saga = new AsyncSaga(explorer, { + fileStorage: mockFileStorage( + { + uuid: testFileUuid1, + path: testFileName1, + sha256: '', + viewState: null, + }, + { + uuid: testFileUuid2, + path: testFileName2, + sha256: '', + viewState: null, + }, + ), + }); + + jest.spyOn(browserFsAccess, 'fileOpen').mockResolvedValueOnce([ + mockPythonFile(testFileName1, testFileContents1), + mockPythonFile(testFileName2, testFileContents2), + ]); + + saga.put(explorerImportFiles()); + + await expect(saga.take()).resolves.toEqual( + replaceImportDialogShow(testFileName1), + ); + + saga.put( + replaceImportDialogDidAccept( + ReplaceImportDialogAction.Replace, + remember, + ), + ); + + await expect(saga.take()).resolves.toEqual( + fileStorageWriteFile(testFileName1, testFileContents1), + ); + + saga.put(fileStorageDidWriteFile(testFileName1, testFileUuid1)); + + if (!remember) { + await expect(saga.take()).resolves.toEqual( + replaceImportDialogShow(testFileName2), + ); + + saga.put( + replaceImportDialogDidAccept( + ReplaceImportDialogAction.Replace, + remember, + ), + ); + } + + await expect(saga.take()).resolves.toEqual( + fileStorageWriteFile(testFileName2, testFileContents2), + ); + + saga.put(fileStorageDidWriteFile(testFileName2, testFileUuid2)); + + await expect(saga.take()).resolves.toEqual(explorerDidImportFiles()); + + await saga.end(); + }, + ); + + it.each([false, true])( + 'should handle user selected rename and remember is %s', + async (remember) => { + const testFileName1 = 'test1.py'; + const testFileContents1 = '# test'; + const testFileUuid1 = uuid(1); + + const testFileName2 = 'test2.py'; + const testFileContents2 = '# test'; + const testFileUuid2 = uuid(2); + + const saga = new AsyncSaga(explorer, { + fileStorage: mockFileStorage( + { + uuid: testFileUuid1, + path: testFileName1, + sha256: '', + viewState: null, + }, + { + uuid: testFileUuid2, + path: testFileName2, + sha256: '', + viewState: null, + }, + ), + }); + + jest.spyOn(browserFsAccess, 'fileOpen').mockResolvedValueOnce([ + mockPythonFile(testFileName1, testFileContents1), + mockPythonFile(testFileName2, testFileContents2), + ]); + + saga.put(explorerImportFiles()); + + await expect(saga.take()).resolves.toEqual( + replaceImportDialogShow(testFileName1), + ); + + saga.put( + replaceImportDialogDidAccept( + ReplaceImportDialogAction.Rename, + remember, + ), + ); + + await expect(saga.take()).resolves.toEqual( + renameImportDialogShow(testFileName1), + ); + + const renamedFileName1 = 'good_name1.py'; + const renamedFileUuid1 = uuid(1); + + saga.put(renameImportDialogDidAccept(testFileName1, renamedFileName1)); + + await expect(saga.take()).resolves.toEqual( + fileStorageWriteFile(renamedFileName1, testFileContents1), + ); + + saga.put(fileStorageDidWriteFile(renamedFileName1, renamedFileUuid1)); + + if (!remember) { + await expect(saga.take()).resolves.toEqual( + replaceImportDialogShow(testFileName2), + ); + + saga.put( + replaceImportDialogDidAccept( + ReplaceImportDialogAction.Rename, + remember, + ), + ); + } + + await expect(saga.take()).resolves.toEqual( + renameImportDialogShow(testFileName2), + ); + + const renamedFileName2 = 'good_name2.py'; + const renamedFileUuid2 = uuid(2); + + saga.put(renameImportDialogDidAccept(testFileName2, renamedFileName2)); + + await expect(saga.take()).resolves.toEqual( + fileStorageWriteFile(renamedFileName2, testFileContents2), + ); + + saga.put(fileStorageDidWriteFile(renamedFileName2, renamedFileUuid2)); + + await expect(saga.take()).resolves.toEqual(explorerDidImportFiles()); + + await saga.end(); + }, + ); + + it.each([false, true])( + 'should handle user selected skip and remember is %s', + async (remember) => { + const testFileName1 = 'test1.py'; + const testFileContents1 = '# test'; + const testFileUuid1 = uuid(1); + + const testFileName2 = 'test2.py'; + const testFileContents2 = '# test'; + const testFileUuid2 = uuid(2); + + const saga = new AsyncSaga(explorer, { + fileStorage: mockFileStorage( + { + uuid: testFileUuid1, + path: testFileName1, + sha256: '', + viewState: null, + }, + { + uuid: testFileUuid2, + path: testFileName2, + sha256: '', + viewState: null, + }, + ), + }); + + jest.spyOn(browserFsAccess, 'fileOpen').mockResolvedValueOnce([ + mockPythonFile(testFileName1, testFileContents1), + mockPythonFile(testFileName2, testFileContents2), + ]); + + saga.put(explorerImportFiles()); + + await expect(saga.take()).resolves.toEqual( + replaceImportDialogShow(testFileName1), + ); + + saga.put( + replaceImportDialogDidAccept( + ReplaceImportDialogAction.Skip, + remember, + ), + ); + + if (!remember) { + await expect(saga.take()).resolves.toEqual( + replaceImportDialogShow(testFileName2), + ); + + saga.put( + replaceImportDialogDidAccept( + ReplaceImportDialogAction.Skip, + remember, + ), + ); + } + + await expect(saga.take()).resolves.toEqual(explorerDidImportFiles()); + + await saga.end(); + }, + ); + }); + + it('should handle ZIP files', async () => { + const testFileName = 'test.py'; + const testFileContents = '# test'; + + const zipFile = new JSZip().file(testFileName, testFileContents); + + const saga = new AsyncSaga(explorer, { + fileStorage: mockFileStorage(), + }); + + jest.spyOn(browserFsAccess, 'fileOpen').mockResolvedValueOnce([ + mockZipFile( + 'test.zip', + await zipFile.generateAsync({ type: 'arraybuffer' }), + ), + ]); + + saga.put(explorerImportFiles()); + + await expect(saga.take()).resolves.toEqual( + fileStorageWriteFile(testFileName, testFileContents), + ); + + saga.put(fileStorageDidWriteFile(testFileName, uuid(0))); + + await expect(saga.take()).resolves.toEqual(explorerDidImportFiles()); + + await saga.end(); + }); + + it('should notify user if ZIP file contains no Python files', async () => { + const zipFile = new JSZip(); + + const saga = new AsyncSaga(explorer, { + fileStorage: mockFileStorage(), + }); + + jest.spyOn(browserFsAccess, 'fileOpen').mockResolvedValueOnce([ + mockZipFile( + 'test.zip', + await zipFile.generateAsync({ type: 'arraybuffer' }), + ), + ]); + + saga.put(explorerImportFiles()); + + await expect(saga.take()).resolves.toEqual( + alertsShowAlert('explorer', 'noPyFiles'), + ); + + await expect(saga.take()).resolves.toEqual(explorerDidImportFiles()); + + await saga.end(); + }); }); describe('handleExplorerCreateNewFile', () => { diff --git a/src/explorer/sagas.ts b/src/explorer/sagas.ts index d500c4d9..1da7ed89 100644 --- a/src/explorer/sagas.ts +++ b/src/explorer/sagas.ts @@ -3,7 +3,15 @@ import { fileOpen, fileSave } from 'browser-fs-access'; import JSZip from 'jszip'; -import { call, put, race, select, take, takeEvery } from 'typed-redux-saga/macro'; +import { + call, + getContext, + put, + race, + select, + take, + takeEvery, +} from 'typed-redux-saga/macro'; import { alertsShowAlert } from '../alerts/actions'; import { zipFileExtension, zipFileMimeType } from '../app/constants'; import { @@ -15,6 +23,7 @@ import { } from '../editor/actions'; import { EditorError } from '../editor/error'; import { getPybricksMicroPythonFileTemplate } from '../editor/pybricksMicroPython'; +import { FileStorageDb } from '../fileStorage'; import { fileStorageCopyFile, fileStorageDeleteFile, @@ -95,6 +104,12 @@ import { renameImportDialogDidCancel, renameImportDialogShow, } from './renameImportDialog/actions'; +import { + ReplaceImportDialogAction, + replaceImportDialogDidAccept, + replaceImportDialogDidCancel, + replaceImportDialogShow, +} from './replaceImportDialog/actions'; function* handleExplorerArchiveAllFiles(): Generator { try { @@ -155,59 +170,152 @@ function* handleExplorerArchiveAllFiles(): Generator { } } +type ImportContext = { + rememberedAction?: ReplaceImportDialogAction; +}; + +function* importPythonFile( + sourceFileName: string, + sourceFileContents: string, + context: ImportContext, +): Generator { + const [baseName] = sourceFileName.split(pythonFileExtensionRegex); + let fileName = `${baseName}${pythonFileExtension}`; + + const db = yield* getContext('fileStorage'); + const existingFiles = yield* call(() => db.metadata.toArray()); + + const result = validateFileName( + baseName, + pythonFileExtension, + existingFiles.map((f) => f.path), + ); + + let replace = false; + + if (result === FileNameValidationResult.AlreadyExists) { + let action = context.rememberedAction; + + if (action === undefined) { + yield* put(replaceImportDialogShow(sourceFileName)); + + const { accepted, cancelled } = yield* race({ + accepted: take(replaceImportDialogDidAccept), + cancelled: take(replaceImportDialogDidCancel), + }); + + if (cancelled) { + return; + } + + defined(accepted); + + if (accepted.remember) { + context.rememberedAction = accepted.action; + } + + action = accepted.action; + } + + if (action === ReplaceImportDialogAction.Skip) { + return; + } + + if (action === ReplaceImportDialogAction.Replace) { + replace = true; + } + } + + if (result !== FileNameValidationResult.IsOk && !replace) { + yield* put(renameImportDialogShow(sourceFileName)); + + const { accepted, cancelled } = yield* race({ + accepted: take(renameImportDialogDidAccept), + cancelled: take(renameImportDialogDidCancel), + }); + + if (cancelled) { + return; + } + + defined(accepted); + + fileName = accepted.newName; + } + + yield* put(fileStorageWriteFile(fileName, sourceFileContents)); + + const { didFailToWrite } = yield* race({ + didWrite: take(fileStorageDidWriteFile.when((a) => a.path === fileName)), + didFailToWrite: take( + fileStorageDidFailToWriteFile.when((a) => a.path === fileName), + ), + }); + + if (didFailToWrite) { + throw didFailToWrite.error; + } +} + function* handleExplorerImportFiles(): Generator { try { const selectedFiles = yield* call(() => - fileOpen({ - id: 'pybricks-code-explorer-import', - mimeTypes: [pythonFileMimeType], - extensions: [pythonFileExtension], - // TODO: translate description - description: 'Python Files', - multiple: true, - excludeAcceptAllOption: true, - }), + fileOpen([ + { + id: 'pybricks-code-explorer-import', + mimeTypes: [pythonFileMimeType], + extensions: [pythonFileExtension], + // TODO: translate description + description: 'Python Files', + multiple: true, + excludeAcceptAllOption: true, + }, + { + mimeTypes: [zipFileMimeType], + extensions: [zipFileExtension], + // TODO: translate description + description: 'ZIP Files', + }, + ]), ); + const context: ImportContext = {}; + for (const file of selectedFiles) { - // getting the text now to catch possible error *before* user interaction - const text = yield* call(() => file.text()); + switch (file.type) { + case '': // empty string means "could not be determined" + case pythonFileMimeType: + { + // getting the text now to catch possible error *before* user interaction + const text = yield* call(() => file.text()); + yield* importPythonFile(file.name, text, context); + } + break; + case zipFileMimeType: + { + const zip = yield* call(() => + JSZip.loadAsync(file.arrayBuffer()), + ); - const [baseName] = file.name.split(pythonFileExtensionRegex); - let fileName = `${baseName}${pythonFileExtension}`; + const zipFiles = zip.filter((_, f) => + f.name.endsWith(pythonFileExtension), + ); - const result = validateFileName(baseName, pythonFileExtension, []); + if (zipFiles.length === 0) { + yield* put(alertsShowAlert('explorer', 'noPyFiles')); + break; + } - if (result !== FileNameValidationResult.IsOk) { - yield* put(renameImportDialogShow(file.name)); - - const { accepted, cancelled } = yield* race({ - accepted: take(renameImportDialogDidAccept), - cancelled: take(renameImportDialogDidCancel), - }); - - if (cancelled) { - continue; - } - - defined(accepted); - - fileName = accepted.newName; - } - - yield* put(fileStorageWriteFile(fileName, text)); - - const { didFailToWrite } = yield* race({ - didWrite: take( - fileStorageDidWriteFile.when((a) => a.path === fileName), - ), - didFailToWrite: take( - fileStorageDidFailToWriteFile.when((a) => a.path === fileName), - ), - }); - - if (didFailToWrite) { - throw didFailToWrite.error; + for (const zipFile of zipFiles) { + const text = yield* call(() => zipFile.async('text')); + yield* importPythonFile(zipFile.name, text, context); + } + } + break; + default: + throw new Error( + `'${file.name}' has unsupported file type: ${file.type}`, + ); } }