explorer: split archive saga from fileStorage

This splits the fileStorage archive into two sagas to separate concerns.
fileStorage now just gives a dump of the database and explorer deals
with the user interaction and zipping.
This commit is contained in:
David Lechner
2022-04-04 17:48:36 -05:00
parent 7cb0cf3151
commit 05731e50fc
12 changed files with 209 additions and 67 deletions
+15 -11
View File
@@ -326,24 +326,28 @@ export const fileStorageDidFailToRenameFile = createAction(
);
/**
* Request to archive (download) all files in the store.
* Requests file storage to dump all file paths and contents currently in storage.
*/
export const fileStorageArchiveAllFiles = createAction(() => ({
type: 'fileStorage.action.archiveAllFiles',
export const fileStorageDumpAllFiles = createAction(() => ({
type: 'fileStorage.action.dumpAllFiles',
}));
/**
* Indicates that fileStorageArchiveAllFiles() succeeded.
* Indicates that {@link fileStorageDumpAllFiles} succeeded.
* @param files: An array of all file paths and contents.
*/
export const fileStorageDidArchiveAllFiles = createAction(() => ({
type: 'fileStorage.action.didArchiveAllFiles',
}));
export const fileStorageDidDumpAllFiles = createAction(
(files: ReadonlyArray<Readonly<{ path: string; contents: string }>>) => ({
type: 'fileStorage.action.didDumpAllFiles',
files,
}),
);
/**
* Indicates that fileStorageArchiveAllFiles() failed.
* @param error The error that was raised.
* Indicates that {@link fileStorageDumpAllFiles} succeeded.
* @param error The error.
*/
export const fileStorageDidFailToArchiveAllFiles = createAction((error: Error) => ({
type: 'fileStorage.action.didFailToArchiveAllFiles',
export const fileStorageDidFailToDumpAllFiles = createAction((error: Error) => ({
type: 'fileStorage.action.didFailToDumpAllFiles',
error,
}));
+19 -16
View File
@@ -1,7 +1,6 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import * as browserFsAccess from 'browser-fs-access';
import 'fake-indexeddb/auto';
import Dexie from 'dexie';
import 'dexie-observable';
@@ -11,16 +10,15 @@ import {
FD,
FileMetadata,
FileOpenMode,
fileStorageArchiveAllFiles,
fileStorageClose,
fileStorageDeleteFile,
fileStorageDidAddItem,
fileStorageDidArchiveAllFiles,
fileStorageDidChangeItem,
fileStorageDidClose,
fileStorageDidDeleteFile,
fileStorageDidFailToArchiveAllFiles,
fileStorageDidDumpAllFiles,
fileStorageDidFailToDeleteFile,
fileStorageDidFailToDumpAllFiles,
fileStorageDidFailToInitialize,
fileStorageDidFailToOpen,
fileStorageDidFailToRead,
@@ -36,6 +34,7 @@ import {
fileStorageDidRenameFile,
fileStorageDidWrite,
fileStorageDidWriteFile,
fileStorageDumpAllFiles,
fileStorageOpen,
fileStorageRead,
fileStorageReadFile,
@@ -45,8 +44,6 @@ import {
} from './actions';
import fileStorage from './sagas';
jest.mock('browser-fs-access');
/** SHA256 hash of '' */
const emptyFileSha256 =
'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
@@ -700,33 +697,39 @@ describe('renameFile', () => {
});
});
describe('archive', () => {
describe('dump all files', () => {
let saga: AsyncSaga;
let testFile: FileMetadata;
let testFileContents: string;
beforeEach(async () => {
saga = new AsyncSaga(fileStorage);
await expect(saga.take()).resolves.toEqual(fileStorageDidInitialize([]));
await setUpTestFile(saga);
[testFile, testFileContents] = await setUpTestFile(saga);
});
it('should archive file', async () => {
jest.spyOn(browserFsAccess, 'fileSave');
saga.put(fileStorageDumpAllFiles());
saga.put(fileStorageArchiveAllFiles());
await expect(saga.take()).resolves.toEqual(fileStorageDidArchiveAllFiles());
expect(browserFsAccess.fileSave).toHaveBeenCalled();
await expect(saga.take()).resolves.toEqual(
fileStorageDidDumpAllFiles([
{ path: testFile.path, contents: testFileContents },
]),
);
});
it('should catch error', async () => {
const testError = new Error('test error');
jest.spyOn(browserFsAccess, 'fileSave').mockRejectedValue(testError);
saga.put(fileStorageArchiveAllFiles());
jest.spyOn(Dexie.prototype, 'transaction').mockImplementation(() => {
throw testError;
});
saga.put(fileStorageDumpAllFiles());
await expect(saga.take()).resolves.toEqual(
fileStorageDidFailToArchiveAllFiles(testError),
fileStorageDidFailToDumpAllFiles(testError),
);
});
+15 -24
View File
@@ -1,7 +1,6 @@
// SPDX-License-Identifier: MIT
// Copyright (c) 2022 The Pybricks Authors
import { fileSave } from 'browser-fs-access';
import Dexie, { Table } from 'dexie';
import {
ICreateChange,
@@ -10,10 +9,9 @@ import {
IUpdateChange,
} from 'dexie-observable/api';
import 'dexie-observable';
import JSZip from 'jszip';
import { eventChannel } from 'redux-saga';
import { call, fork, put, race, take, takeEvery } from 'typed-redux-saga/macro';
import { defined, ensureError, timestamp } from '../utils';
import { defined, ensureError } from '../utils';
import { sha256Digest } from '../utils/crypto';
import { createCountFunc } from '../utils/iter';
import {
@@ -21,16 +19,15 @@ import {
FileMetadata,
FileOpenMode,
UUID,
fileStorageArchiveAllFiles,
fileStorageClose,
fileStorageDeleteFile,
fileStorageDidAddItem,
fileStorageDidArchiveAllFiles,
fileStorageDidChangeItem,
fileStorageDidClose,
fileStorageDidDeleteFile,
fileStorageDidFailToArchiveAllFiles,
fileStorageDidDumpAllFiles,
fileStorageDidFailToDeleteFile,
fileStorageDidFailToDumpAllFiles,
fileStorageDidFailToInitialize,
fileStorageDidFailToOpen,
fileStorageDidFailToRead,
@@ -46,6 +43,7 @@ import {
fileStorageDidRenameFile,
fileStorageDidWrite,
fileStorageDidWriteFile,
fileStorageDumpAllFiles,
fileStorageOpen,
fileStorageRead,
fileStorageReadFile,
@@ -615,30 +613,23 @@ function* handleRenameFile(
}
}
function* handleArchiveAllFiles(db: FileStorageDb): Generator {
function* handleDumpAllFiles(db: FileStorageDb): Generator {
try {
const zip = new JSZip();
const dump = new Array<{ path: string; contents: string }>();
yield* call(() => db._contents.each((f) => zip.file(f.path, f.contents)));
const zipData = yield* call(() => zip.generateAsync({ type: 'blob' }));
const fileName = `pybricks-backup-${timestamp()}.zip`;
// REVISIT: consider using dexie-export-import addon if we want to do
// a full backup instead of just the file contents
// https://www.npmjs.com/package/dexie-export-import
yield* call(() =>
fileSave(zipData, {
id: 'pybricksCodeFileStorageArchive',
fileName,
extensions: ['.zip'],
mimeTypes: ['application/zip'],
// TODO: translate description
description: 'Zip Files',
}),
db.transaction('r', db._contents, () =>
db._contents.each((f) => dump.push(f)),
),
);
yield* put(fileStorageDidArchiveAllFiles());
yield* put(fileStorageDidDumpAllFiles(dump));
} catch (err) {
yield* put(fileStorageDidFailToArchiveAllFiles(ensureError(err)));
yield* put(fileStorageDidFailToDumpAllFiles(ensureError(err)));
}
}
@@ -701,7 +692,7 @@ function* initialize(): Generator {
yield* takeEvery(fileStorageWriteFile, handleWriteFile);
yield* takeEvery(fileStorageDeleteFile, handleDeleteFile, db);
yield* takeEvery(fileStorageRenameFile, handleRenameFile, db);
yield* takeEvery(fileStorageArchiveAllFiles, handleArchiveAllFiles, db);
yield* takeEvery(fileStorageDumpAllFiles, handleDumpAllFiles, db);
const files = yield* call(() => db.metadata.toArray());