mirror of
https://github.com/pybricks/pybricks-code.git
synced 2026-09-14 10:35:11 +00:00
When duplicating a file users expect the file to be opened as in other applications. Fixes: https://github.com/pybricks/support/issues/1084
596 lines
18 KiB
TypeScript
596 lines
18 KiB
TypeScript
// SPDX-License-Identifier: MIT
|
|
// Copyright (c) 2022-2023 The Pybricks Authors
|
|
|
|
import { fileOpen, fileSave } from 'browser-fs-access';
|
|
import JSZip from 'jszip';
|
|
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 {
|
|
editorActivateFile,
|
|
editorCloseFile,
|
|
editorDidActivateFile,
|
|
editorDidCloseFile,
|
|
editorDidFailToActivateFile,
|
|
editorReplaceFile,
|
|
} from '../editor/actions';
|
|
import { EditorError } from '../editor/error';
|
|
import { getPybricksMicroPythonFileTemplate } from '../editor/pybricksMicroPython';
|
|
import { FileStorageDb } from '../fileStorage';
|
|
import {
|
|
fileStorageCopyFile,
|
|
fileStorageDeleteFile,
|
|
fileStorageDidCopyFile,
|
|
fileStorageDidDeleteFile,
|
|
fileStorageDidDumpAllFiles,
|
|
fileStorageDidFailToCopyFile,
|
|
fileStorageDidFailToDeleteFile,
|
|
fileStorageDidFailToDumpAllFiles,
|
|
fileStorageDidFailToReadFile,
|
|
fileStorageDidFailToRenameFile,
|
|
fileStorageDidFailToWriteFile,
|
|
fileStorageDidReadFile,
|
|
fileStorageDidRenameFile,
|
|
fileStorageDidWriteFile,
|
|
fileStorageDumpAllFiles,
|
|
fileStorageReadFile,
|
|
fileStorageRenameFile,
|
|
fileStorageWriteFile,
|
|
} from '../fileStorage/actions';
|
|
import {
|
|
FileNameValidationResult,
|
|
pythonFileExtension,
|
|
pythonFileExtensionRegex,
|
|
pythonFileMimeType,
|
|
validateFileName,
|
|
} from '../pybricksMicropython/lib';
|
|
import { RootState } from '../reducers';
|
|
import { defined, ensureError, timestamp } from '../utils';
|
|
import {
|
|
explorerArchiveAllFiles,
|
|
explorerCreateNewFile,
|
|
explorerDeleteFile,
|
|
explorerDidArchiveAllFiles,
|
|
explorerDidCreateNewFile,
|
|
explorerDidDeleteFile,
|
|
explorerDidDuplicateFile,
|
|
explorerDidExportFile,
|
|
explorerDidFailToArchiveAllFiles,
|
|
explorerDidFailToCreateNewFile,
|
|
explorerDidFailToDeleteFile,
|
|
explorerDidFailToDuplicateFile,
|
|
explorerDidFailToExportFile,
|
|
explorerDidFailToImportFiles,
|
|
explorerDidFailToRenameFile,
|
|
explorerDidImportFiles,
|
|
explorerDidRenameFile,
|
|
explorerDuplicateFile,
|
|
explorerExportFile,
|
|
explorerImportFiles,
|
|
explorerRenameFile,
|
|
explorerUserActivateFile,
|
|
explorerUserDidActivateFile,
|
|
} from './actions';
|
|
import {
|
|
deleteFileAlertDidAccept,
|
|
deleteFileAlertDidCancel,
|
|
deleteFileAlertShow,
|
|
} from './deleteFileAlert/actions';
|
|
import {
|
|
duplicateFileDialogDidAccept,
|
|
duplicateFileDialogDidCancel,
|
|
duplicateFileDialogShow,
|
|
} from './duplicateFileDialog/actions';
|
|
import { ExplorerError } from './error';
|
|
import {
|
|
newFileWizardDidAccept,
|
|
newFileWizardDidCancel,
|
|
newFileWizardShow,
|
|
} from './newFileWizard/actions';
|
|
import {
|
|
renameFileDialogDidAccept,
|
|
renameFileDialogDidCancel,
|
|
renameFileDialogShow,
|
|
} from './renameFileDialog/actions';
|
|
import {
|
|
renameImportDialogDidAccept,
|
|
renameImportDialogDidCancel,
|
|
renameImportDialogShow,
|
|
} from './renameImportDialog/actions';
|
|
import {
|
|
ReplaceImportDialogAction,
|
|
replaceImportDialogDidAccept,
|
|
replaceImportDialogDidCancel,
|
|
replaceImportDialogShow,
|
|
} from './replaceImportDialog/actions';
|
|
|
|
function* handleExplorerArchiveAllFiles(): Generator {
|
|
try {
|
|
yield* put(fileStorageDumpAllFiles());
|
|
|
|
const { didDump, didFailToDump } = yield* race({
|
|
didDump: take(fileStorageDidDumpAllFiles),
|
|
didFailToDump: take(fileStorageDidFailToDumpAllFiles),
|
|
});
|
|
|
|
if (didFailToDump) {
|
|
throw didFailToDump.error;
|
|
}
|
|
|
|
defined(didDump);
|
|
|
|
if (didDump.files.length === 0) {
|
|
throw new ExplorerError('NoFiles', 'no files in storage');
|
|
}
|
|
|
|
const zip = new JSZip();
|
|
|
|
for (const f of didDump.files) {
|
|
yield* call(() => zip.file(f.path, f.contents));
|
|
}
|
|
|
|
const zipData = yield* call(() => zip.generateAsync({ type: 'blob' }));
|
|
|
|
const fileName = `pybricks-backup-${timestamp()}${zipFileExtension}`;
|
|
|
|
yield* call(() =>
|
|
fileSave(zipData, {
|
|
id: 'pybricksCodeFileStorageArchive',
|
|
fileName,
|
|
extensions: [zipFileExtension],
|
|
mimeTypes: [zipFileMimeType],
|
|
// TODO: translate description
|
|
description: 'Zip Files',
|
|
}),
|
|
);
|
|
|
|
yield* put(explorerDidArchiveAllFiles());
|
|
} catch (err) {
|
|
const error = ensureError(err);
|
|
|
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
// user canceled, don't show error message
|
|
} else if (error instanceof ExplorerError && error.name === 'NoFiles') {
|
|
yield* put(alertsShowAlert('explorer', 'noFilesToBackup'));
|
|
} else {
|
|
yield* put(
|
|
alertsShowAlert('alerts', 'unexpectedError', {
|
|
error,
|
|
}),
|
|
);
|
|
}
|
|
yield* put(explorerDidFailToArchiveAllFiles(error));
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
const existingFileInfo = existingFiles.find((x) => x.path === fileName);
|
|
const openFileUuids = yield* select((s: RootState) => s.editor.openFileUuids);
|
|
|
|
// If the file is open, modify contents in the editor so preserve undo
|
|
// history, otherwise write directly to storage.
|
|
if (existingFileInfo && openFileUuids.includes(existingFileInfo.uuid)) {
|
|
yield* put(editorReplaceFile(existingFileInfo.uuid, sourceFileContents));
|
|
} else {
|
|
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,
|
|
},
|
|
{
|
|
mimeTypes: [zipFileMimeType],
|
|
extensions: [zipFileExtension],
|
|
// TODO: translate description
|
|
description: 'ZIP Files',
|
|
},
|
|
]),
|
|
);
|
|
|
|
const context: ImportContext = {};
|
|
|
|
for (const file of selectedFiles) {
|
|
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 zipFiles = zip.filter((_, f) =>
|
|
f.name.endsWith(pythonFileExtension),
|
|
);
|
|
|
|
if (zipFiles.length === 0) {
|
|
yield* put(alertsShowAlert('explorer', 'noPyFiles'));
|
|
break;
|
|
}
|
|
|
|
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}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
yield* put(explorerDidImportFiles());
|
|
} catch (err) {
|
|
yield* put(explorerDidFailToImportFiles(ensureError(err)));
|
|
}
|
|
}
|
|
|
|
function* handleExplorerCreateNewFile(): Generator {
|
|
try {
|
|
yield* put(newFileWizardShow());
|
|
|
|
const { didAccept, didCancel } = yield* race({
|
|
didAccept: take(newFileWizardDidAccept),
|
|
didCancel: take(newFileWizardDidCancel),
|
|
});
|
|
|
|
if (didCancel) {
|
|
throw new DOMException('user canceled', 'AbortError');
|
|
}
|
|
|
|
defined(didAccept);
|
|
|
|
const fileName = `${didAccept.fileName}${didAccept.fileExtension}`;
|
|
|
|
yield* put(
|
|
fileStorageWriteFile(
|
|
fileName,
|
|
getPybricksMicroPythonFileTemplate(didAccept.hubType) || '',
|
|
),
|
|
);
|
|
|
|
const { didWrite, didFailToWrite } = yield* race({
|
|
didWrite: take(fileStorageDidWriteFile.when((a) => a.path === fileName)),
|
|
didFailToWrite: take(
|
|
fileStorageDidFailToWriteFile.when((a) => a.path === fileName),
|
|
),
|
|
});
|
|
|
|
if (didFailToWrite) {
|
|
throw didFailToWrite.error;
|
|
}
|
|
|
|
defined(didWrite);
|
|
|
|
yield* put(editorActivateFile(didWrite.uuid));
|
|
|
|
yield* put(explorerDidCreateNewFile());
|
|
} catch (err) {
|
|
yield* put(explorerDidFailToCreateNewFile(ensureError(err)));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Connects user triggered action to editor module.
|
|
* @param action
|
|
*/
|
|
function* handleExplorerActivateFile(
|
|
action: ReturnType<typeof explorerUserActivateFile>,
|
|
): Generator {
|
|
yield* put(editorActivateFile(action.uuid));
|
|
|
|
const { didFailToActivate } = yield* race({
|
|
didActivate: take(editorDidActivateFile.when((a) => a.uuid === action.uuid)),
|
|
didFailToActivate: take(
|
|
editorDidFailToActivateFile.when((a) => a.uuid === action.uuid),
|
|
),
|
|
});
|
|
|
|
if (didFailToActivate) {
|
|
if (
|
|
didFailToActivate.error instanceof EditorError &&
|
|
didFailToActivate.error.name === 'FileInUse'
|
|
) {
|
|
yield* put(
|
|
alertsShowAlert('explorer', 'fileInUse', { fileName: action.fileName }),
|
|
);
|
|
} else {
|
|
yield* put(
|
|
alertsShowAlert('alerts', 'unexpectedError', {
|
|
error: didFailToActivate.error,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
yield* put(explorerUserDidActivateFile(action.fileName));
|
|
}
|
|
|
|
/** Connects user initiate duplicate file actions to the duplicate file dialog. */
|
|
function* handleExplorerDuplicateFile(
|
|
action: ReturnType<typeof explorerDuplicateFile>,
|
|
): Generator {
|
|
try {
|
|
yield* put(duplicateFileDialogShow(action.fileName));
|
|
|
|
const { didAccept, didCancel } = yield* race({
|
|
didAccept: take(duplicateFileDialogDidAccept),
|
|
didCancel: take(duplicateFileDialogDidCancel),
|
|
});
|
|
|
|
if (didCancel) {
|
|
throw new DOMException('user canceled', 'AbortError');
|
|
}
|
|
|
|
defined(didAccept);
|
|
|
|
// REVISIT: if editor is not flushed to storage right away, we would
|
|
// need to check for open editors here
|
|
|
|
yield* put(fileStorageCopyFile(action.fileName, didAccept.newName));
|
|
|
|
const { didCopy, didFailToCopy } = yield* race({
|
|
didCopy: take(
|
|
fileStorageDidCopyFile.when((a) => a.path === action.fileName),
|
|
),
|
|
didFailToCopy: take(
|
|
fileStorageDidFailToCopyFile.when((a) => a.path === action.fileName),
|
|
),
|
|
});
|
|
|
|
if (didFailToCopy) {
|
|
throw didFailToCopy.error;
|
|
}
|
|
|
|
defined(didCopy);
|
|
|
|
yield* put(editorActivateFile(didCopy.newUuid));
|
|
|
|
yield* put(explorerDidDuplicateFile(action.fileName));
|
|
} catch (err) {
|
|
yield* put(explorerDidFailToDuplicateFile(action.fileName, ensureError(err)));
|
|
}
|
|
}
|
|
|
|
/** Connects user initiate rename file actions to the rename file dialog. */
|
|
function* handleExplorerRenameFile(
|
|
action: ReturnType<typeof explorerRenameFile>,
|
|
): Generator {
|
|
yield* put(renameFileDialogShow(action.fileName));
|
|
|
|
const { accepted, canceled } = yield* race({
|
|
accepted: take(renameFileDialogDidAccept),
|
|
canceled: take(renameFileDialogDidCancel),
|
|
});
|
|
|
|
if (canceled) {
|
|
yield* put(explorerDidFailToRenameFile());
|
|
return;
|
|
}
|
|
|
|
defined(accepted);
|
|
|
|
yield* put(fileStorageRenameFile(action.fileName, accepted.newName));
|
|
|
|
const didRename = yield* race({
|
|
succeeded: take(
|
|
fileStorageDidRenameFile.when((a) => a.fileName === action.fileName),
|
|
),
|
|
failed: take(
|
|
fileStorageDidFailToRenameFile.when((a) => a.fileName === action.fileName),
|
|
),
|
|
});
|
|
|
|
if (didRename.failed) {
|
|
yield* put(explorerDidFailToRenameFile());
|
|
return;
|
|
}
|
|
|
|
yield* put(explorerDidRenameFile());
|
|
}
|
|
|
|
function* handleExplorerExportFile(
|
|
action: ReturnType<typeof explorerExportFile>,
|
|
): Generator {
|
|
try {
|
|
yield* put(fileStorageReadFile(action.fileName));
|
|
|
|
const { didRead, didFailToRead } = yield* race({
|
|
didRead: take(
|
|
fileStorageDidReadFile.when((a) => a.path === action.fileName),
|
|
),
|
|
didFailToRead: take(
|
|
fileStorageDidFailToReadFile.when((a) => a.path === action.fileName),
|
|
),
|
|
});
|
|
|
|
if (didFailToRead) {
|
|
throw didFailToRead.error;
|
|
}
|
|
|
|
defined(didRead);
|
|
|
|
const blob = new Blob([didRead.contents], { type: `${pythonFileMimeType}` });
|
|
|
|
yield* call(() =>
|
|
fileSave(blob, {
|
|
id: 'pybricksCodeFileStorageExport',
|
|
fileName: action.fileName,
|
|
extensions: [pythonFileExtension],
|
|
mimeTypes: [pythonFileMimeType],
|
|
// TODO: translate description
|
|
description: 'Python Files',
|
|
}),
|
|
);
|
|
|
|
yield* put(explorerDidExportFile(action.fileName));
|
|
} catch (err) {
|
|
yield* put(explorerDidFailToExportFile(action.fileName, ensureError(err)));
|
|
}
|
|
}
|
|
|
|
function* handleExplorerDeleteFile(action: ReturnType<typeof explorerDeleteFile>) {
|
|
try {
|
|
yield* put(deleteFileAlertShow(action.fileName));
|
|
|
|
const { didCancel } = yield* race({
|
|
didAccept: take(deleteFileAlertDidAccept),
|
|
didCancel: take(deleteFileAlertDidCancel),
|
|
});
|
|
|
|
if (didCancel) {
|
|
throw new DOMException('user canceled', 'AbortError');
|
|
}
|
|
|
|
// at this point we know the user accepted
|
|
|
|
const openUuids = yield* select((s: RootState) => s.editor.openFileUuids);
|
|
|
|
// have to close editor before deleting, otherwise we get "in use" error
|
|
if (openUuids.includes(action.uuid)) {
|
|
yield* put(editorCloseFile(action.uuid));
|
|
yield* take(editorDidCloseFile.when((a) => a.uuid === action.uuid));
|
|
}
|
|
|
|
yield* put(fileStorageDeleteFile(action.fileName));
|
|
|
|
const { didFailToDelete } = yield* race({
|
|
didDelete: take(
|
|
fileStorageDidDeleteFile.when((a) => a.path === action.fileName),
|
|
),
|
|
didFailToDelete: take(
|
|
fileStorageDidFailToDeleteFile.when((a) => a.path === action.fileName),
|
|
),
|
|
});
|
|
|
|
if (didFailToDelete) {
|
|
throw didFailToDelete.error;
|
|
}
|
|
|
|
yield* put(explorerDidDeleteFile(action.fileName));
|
|
} catch (err) {
|
|
yield* put(explorerDidFailToDeleteFile(action.fileName, ensureError(err)));
|
|
}
|
|
}
|
|
|
|
export default function* (): Generator {
|
|
yield* takeEvery(explorerArchiveAllFiles, handleExplorerArchiveAllFiles);
|
|
yield* takeEvery(explorerImportFiles, handleExplorerImportFiles);
|
|
yield* takeEvery(explorerCreateNewFile, handleExplorerCreateNewFile);
|
|
yield* takeEvery(explorerUserActivateFile, handleExplorerActivateFile);
|
|
yield* takeEvery(explorerRenameFile, handleExplorerRenameFile);
|
|
yield* takeEvery(explorerDuplicateFile, handleExplorerDuplicateFile);
|
|
yield* takeEvery(explorerExportFile, handleExplorerExportFile);
|
|
yield* takeEvery(explorerDeleteFile, handleExplorerDeleteFile);
|
|
}
|