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
This commit is contained in:
David Lechner
2023-03-07 14:31:12 -06:00
committed by David Lechner
parent d9e8a794d0
commit 6a9405e653
15 changed files with 793 additions and 62 deletions
+3 -1
View File
@@ -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 = () => {
<NewFileWizard />
<RenameFileDialog />
<RenameImportDialog />
<ReplaceImportDialog />
<DuplicateFileDialog />
<DeleteFileAlert />
</div>
+27
View File
@@ -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: <code>{pythonFileExtension}</code>,
zip: 'ZIP',
})}
</>
);
};
export const noPyFiles: CreateToast = (onAction) => ({
message: <NoPyFiles />,
icon: 'info-sign',
intent: Intent.PRIMARY,
onDismiss: () => onAction('dismiss'),
});
+3 -2
View File
@@ -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 };
+3
View File
@@ -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."
}
}
+3 -1
View File
@@ -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,
});
@@ -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(<RenameImportDialog />, {
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(<RenameImportDialog />, {
explorer: { replaceImportDialog: { isOpen: true } },
});
const button = dialog.getByRole('button', { name: 'Close' });
await waitFor(() => expect(button).toBeVisible());
await user.click(button);
expect(dispatch).toHaveBeenCalledWith(replaceImportDialogDidCancel());
});
});
@@ -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<React.FormEventHandler>(
(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 (
<Dialog
className="pb-explorer-replaceImportDialog"
title={i18n.translate('title')}
isOpen={isOpen}
onOpening={() => setRemember(false)}
onClose={handleClose}
>
<form onSubmit={handleSubmit} method="dialog">
<div className={Classes.DIALOG_BODY}>
<p>{i18n.translate('message', { fileName })}</p>
</div>
<div className={Classes.DIALOG_FOOTER}>
<Checkbox
checked={remember}
onChange={(e) =>
setRemember((e.target as HTMLInputElement).checked)
}
>
{i18n.translate('option.remember')}
</Checkbox>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Button
intent="none"
type="submit"
value={ReplaceImportDialogAction.Skip}
>
{i18n.translate('action.skip')}
</Button>
<Button
intent={Intent.DANGER}
type="submit"
value={ReplaceImportDialogAction.Replace}
>
{i18n.translate('action.replace')}
</Button>
<Button
intent={Intent.PRIMARY}
type="submit"
value={ReplaceImportDialogAction.Rename}
>
{i18n.translate('action.rename')}
</Button>
</div>
</div>
</form>
</Dialog>
);
};
export default RenameImportDialog;
@@ -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',
}));
+12
View File
@@ -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<typeof translations> {
// istanbul ignore next: babel-loader rewrites this line
const [i18n] = useShopifyI18n();
return i18n;
}
@@ -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<boolean> = (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<string> = (state = '', action) => {
if (replaceImportDialogShow.matches(action)) {
return action.fileName;
}
return state;
};
export default combineReducers({ isOpen, fileName });
@@ -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;
}
}
@@ -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"
}
}
+336 -13
View File
@@ -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<FileStorageDb>({
metadata: { toArray: () => Dexie.Promise.resolve(files) },
});
};
const mockPythonFile = (name: string, contents: string) => {
return mock<FileWithHandle>({
name,
type: '',
text: () => Promise.resolve(contents),
});
};
const mockZipFile = (name: string, contents: ArrayBuffer) => {
return mock<FileWithHandle>({
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<FileWithHandle>({
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<FileWithHandle>({
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', () => {
+153 -45
View File
@@ -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<FileStorageDb>('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}`,
);
}
}