From 8440a8de42b0e10ef7a99c02ce32efba8aab2dc7 Mon Sep 17 00:00:00 2001 From: Mitchell McCaffrey Date: Sat, 13 Feb 2021 21:51:31 +1100 Subject: [PATCH] Added partial import and export functionality --- package.json | 2 +- src/database.js | 4 +- src/modals/DataSelectorModal.js | 210 ++++++++++++++++++++++++++++++++ src/modals/ImportExportModal.js | 143 +++++++++++++++++++--- src/modals/SettingsModal.js | 2 +- src/workers/DatabaseWorker.js | 64 +++++++++- yarn.lock | 8 +- 7 files changed, 404 insertions(+), 29 deletions(-) create mode 100644 src/modals/DataSelectorModal.js diff --git a/package.json b/package.json index c71c97a..e700bdd 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "dependencies": { "@babylonjs/core": "^4.2.0", "@babylonjs/loaders": "^4.2.0", - "@mitchemmc/dexie-export-import": "^1.0.0-rc.2", + "@mitchemmc/dexie-export-import": "^1.0.0", "@msgpack/msgpack": "^2.3.0", "@sentry/react": "^5.27.1", "@stripe/stripe-js": "^1.3.2", diff --git a/src/database.js b/src/database.js index 6eef6ad..51983f0 100644 --- a/src/database.js +++ b/src/database.js @@ -385,8 +385,8 @@ function loadVersions(db) { } // Get the dexie database used in DatabaseContext -export function getDatabase(options) { - let db = new Dexie("OwlbearRodeoDB", options); +export function getDatabase(options, name="OwlbearRodeoDB") { + let db = new Dexie(name, options); loadVersions(db); return db; } diff --git a/src/modals/DataSelectorModal.js b/src/modals/DataSelectorModal.js new file mode 100644 index 0000000..d82a488 --- /dev/null +++ b/src/modals/DataSelectorModal.js @@ -0,0 +1,210 @@ +import React, { useEffect, useState } from "react"; +import { Box, Label, Flex, Button, Text, Checkbox, Divider } from "theme-ui"; +import SimpleBar from "simplebar-react"; + +import Modal from "../components/Modal"; +import LoadingOverlay from "../components/LoadingOverlay"; + +import { getDatabase } from "../database"; + +function DataSelectorModal({ + isOpen, + onRequestClose, + onConfirm, + confirmText, + label, + databaseName, + filter, +}) { + const [maps, setMaps] = useState({}); + const [tokens, setTokens] = useState({}); + + const [isLoading, setIsLoading] = useState(false); + const hasMaps = Object.values(maps).length > 0; + const hasTokens = Object.values(tokens).length > 0; + + useEffect(() => { + async function loadData() { + if (isOpen && databaseName) { + setIsLoading(true); + const db = getDatabase({}, databaseName); + let loadedMaps = {}; + let loadedTokens = {}; + await db + .table("maps") + .filter((map) => filter("maps", map, map.id)) + .each((map) => { + loadedMaps[map.id] = { name: map.name, id: map.id, checked: true }; + }); + await db + .table("tokens") + .filter((token) => filter("tokens", token, token.id)) + .each((token) => { + loadedTokens[token.id] = { + name: token.name, + id: token.id, + checked: true, + }; + }); + db.close(); + setMaps(loadedMaps); + setTokens(loadedTokens); + setIsLoading(false); + } else { + setMaps({}); + setTokens({}); + } + } + loadData(); + }, [isOpen, databaseName, filter]); + + function handleConfirm() { + let checkedMaps = Object.values(maps).filter((map) => map.checked); + let checkedTokens = Object.values(tokens).filter((token) => token.checked); + onConfirm(checkedMaps, checkedTokens); + } + + return ( + + + + {!hasMaps && !hasTokens && ( + + No custom maps or tokens found. + + )} + + + {hasMaps && ( + <> + + + + {Object.values(maps).map((map) => ( + + ))} + + )} + {hasMaps && hasTokens && } + {hasTokens && ( + <> + + {Object.values(tokens).map((token) => ( + + ))} + + )} + + + + + + + {isLoading && } + + + ); +} + +DataSelectorModal.defaultProps = { + label: "Select data", + confirmText: "Yes", + filter: () => true, + databaseName: "OwlbearRodeoDB", +}; + +export default DataSelectorModal; diff --git a/src/modals/ImportExportModal.js b/src/modals/ImportExportModal.js index b917779..ad87311 100644 --- a/src/modals/ImportExportModal.js +++ b/src/modals/ImportExportModal.js @@ -2,23 +2,36 @@ import React, { useRef, useState, useEffect } from "react"; import { Box, Label, Text, Button, Flex } from "theme-ui"; import { saveAs } from "file-saver"; import * as Comlink from "comlink"; +import shortid from "shortid"; import Modal from "../components/Modal"; import LoadingOverlay from "../components/LoadingOverlay"; import LoadingBar from "../components/LoadingBar"; import Banner from "../components/Banner"; +import { useAuth } from "../contexts/AuthContext"; + +import DataSelectorModal from "./DataSelectorModal"; + +import { getDatabase } from "../database"; + import DatabaseWorker from "worker-loader!../workers/DatabaseWorker"; // eslint-disable-line import/no-webpack-loader-syntax const worker = Comlink.wrap(new DatabaseWorker()); +const importDBName = "OwlbearRodeoImportDB"; + function ImportExportModal({ isOpen, onRequestClose }) { + const { userId } = useAuth(); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(); const backgroundTaskRunningRef = useRef(false); const fileInputRef = useRef(); + const [showImportSelector, setShowImportSelector] = useState(false); + const [showExportSelector, setShowExportSelector] = useState(false); + function openFileDialog() { if (fileInputRef.current) { fileInputRef.current.click(); @@ -35,10 +48,14 @@ function ImportExportModal({ isOpen, onRequestClose }) { setIsLoading(true); backgroundTaskRunningRef.current = true; try { - await worker.importData(file, Comlink.proxy(handleDBProgress)); + await worker.importData( + file, + importDBName, + Comlink.proxy(handleDBProgress) + ); setIsLoading(false); + setShowImportSelector(true); backgroundTaskRunningRef.current = false; - window.location.reload(); } catch (e) { setIsLoading(false); backgroundTaskRunningRef.current = false; @@ -52,19 +69,14 @@ function ImportExportModal({ isOpen, onRequestClose }) { setError(e); } } + // Set file input to null to allow adding the same data 2 times in a row + if (fileInputRef.current) { + fileInputRef.current.value = null; + } } - async function handleExportDatabase() { - setIsLoading(true); - backgroundTaskRunningRef.current = true; - try { - const blob = await worker.exportData(Comlink.proxy(handleDBProgress)); - saveAs(blob, `${new Date().toISOString()}.owlbear`); - } catch (e) { - setError(e); - } - setIsLoading(false); - backgroundTaskRunningRef.current = false; + function handleExportDatabase() { + setShowExportSelector(true); } useEffect(() => { @@ -88,6 +100,91 @@ function ImportExportModal({ isOpen, onRequestClose }) { onRequestClose(); } + async function handleImportSelectorClose() { + const importDB = getDatabase({}, importDBName); + await importDB.delete(); + setShowImportSelector(false); + } + + async function handleImportSelectorConfirm(checkedMaps, checkedTokens) { + setIsLoading(true); + backgroundTaskRunningRef.current = true; + setShowImportSelector(false); + loadingProgressRef.current = 0; + + const importDB = getDatabase({}, importDBName); + const db = getDatabase({}); + + if (checkedMaps.length > 0) { + const mapIds = checkedMaps.map((map) => map.id); + const mapsToAdd = await importDB.table("maps").bulkGet(mapIds); + let newMaps = []; + let newStates = []; + for (let map of mapsToAdd) { + const state = await importDB.table("states").get(map.id); + const newId = shortid.generate(); + // Generate new id and change owner + newMaps.push({ ...map, id: newId, owner: userId }); + newStates.push({ ...state, mapId: newId }); + } + await db.table("maps").bulkAdd(newMaps); + await db.table("states").bulkAdd(newStates); + } + if (checkedTokens.length > 0) { + const tokenIds = checkedTokens.map((token) => token.id); + const tokensToAdd = await importDB.table("tokens").bulkGet(tokenIds); + console.log(tokensToAdd); + let newTokens = []; + for (let token of tokensToAdd) { + const newId = shortid.generate(); + // Generate new id and change owner + newTokens.push({ ...token, id: newId, owner: userId }); + } + await db.table("tokens").bulkAdd(newTokens); + } + + await importDB.delete(); + setIsLoading(false); + backgroundTaskRunningRef.current = false; + window.location.reload(); + } + + function exportSelectorFilter(table, value) { + // Only show owned maps and tokens + if (table === "maps" || table === "tokens") { + if (value.owner === userId) { + return true; + } + } + return false; + } + + async function handleExportSelectorClose() { + setShowExportSelector(false); + } + + async function handleExportSelectorConfirm(checkedMaps, checkedTokens) { + setShowExportSelector(false); + setIsLoading(true); + backgroundTaskRunningRef.current = true; + + const mapIds = checkedMaps.map((map) => map.id); + const tokenIds = checkedTokens.map((token) => token.id); + + try { + const blob = await worker.exportData( + Comlink.proxy(handleDBProgress), + mapIds, + tokenIds + ); + saveAs(blob, `${new Date().toISOString()}.owlbear`); + } catch (e) { + setError(e); + } + setIsLoading(false); + backgroundTaskRunningRef.current = false; + } + return ( - + - Importing a database will overwrite your current data. + Select import or export then select the data you wish to use handleImportDatabase(event.target.files[0])} @@ -144,6 +241,22 @@ function ImportExportModal({ isOpen, onRequestClose }) { + + ); diff --git a/src/modals/SettingsModal.js b/src/modals/SettingsModal.js index 5c9467e..45dadf7 100644 --- a/src/modals/SettingsModal.js +++ b/src/modals/SettingsModal.js @@ -151,7 +151,7 @@ function SettingsModal({ isOpen, onRequestClose }) { onClick={() => setIsImportExportModalOpen(true)} disabled={databaseStatus !== "loaded"} > - Import / Export Database + Import / Export Data {storageEstimate && ( diff --git a/src/workers/DatabaseWorker.js b/src/workers/DatabaseWorker.js index 1c6882e..935a928 100644 --- a/src/workers/DatabaseWorker.js +++ b/src/workers/DatabaseWorker.js @@ -1,5 +1,9 @@ import * as Comlink from "comlink"; -import { importInto, exportDB } from "@mitchemmc/dexie-export-import"; +import { + importInto, + exportDB, + peakImportFile, +} from "@mitchemmc/dexie-export-import"; import { encode } from "@msgpack/msgpack"; import { getDatabase } from "../database"; @@ -17,7 +21,9 @@ let service = { let db = getDatabase({}); if (key) { // Load specific item - return await db.table(table).get(key); + const data = await db.table(table).get(key); + db.close(); + return data; } else { // Load entire table let items = []; @@ -31,6 +37,8 @@ let service = { } }); + db.close(); + // Pack data with msgpack so we can use transfer to avoid memory issues const packed = encode(items); return Comlink.transfer(packed, [packed.buffer]); @@ -41,23 +49,67 @@ let service = { /** * Export current database * @param {function} progressCallback + * @param {string[]} maps An array of map ids to export + * @param {string[]} tokens An array of token ids to export */ - async exportData(progressCallback) { + async exportData(progressCallback, maps, tokens) { let db = getDatabase({}); - return await exportDB(db, { + + const filter = (table, value) => { + if (table === "maps") { + return maps.includes(value.id); + } + if (table === "states") { + return maps.includes(value.mapId); + } + if (table === "tokens") { + return tokens.includes(value.id); + } + return false; + }; + + const data = await exportDB(db, { progressCallback, + filter, numRowsPerChunk: 1, }); + db.close(); + return data; }, /** * Import into current database * @param {Blob} data + * @param {string} databaseName The name of the database to import into * @param {function} progressCallback */ - async importData(data, progressCallback) { + async importData(data, databaseName, progressCallback) { + const importMeta = await peakImportFile(data); let db = getDatabase({}); - await importInto(db, data, { progressCallback, overwriteValues: true }); + + if (importMeta.data.databaseName !== db.name) { + throw new Error("Unable to import database, name mismatch"); + } + + let importDB = getDatabase({}, databaseName); + await importInto(importDB, data, { + progressCallback, + acceptNameDiff: true, + overwriteValues: true, + filter: (table, value) => { + // Ensure values are of the correct form + if (table === "maps" || table === "tokens") { + console.log("id" in value && "owner" in value); + return "id" in value && "owner" in value; + } + if (table === "states") { + return "mapId" in value; + } + return true; + }, + }); + db.close(); + importDB.close(); }, }; diff --git a/yarn.lock b/yarn.lock index 8bc129b..d39a9ac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1443,10 +1443,10 @@ resolved "https://registry.yarnpkg.com/@mdx-js/react/-/react-1.6.16.tgz#538eb14473194d0b3c54020cb230e426174315cd" integrity sha512-+FhuSVOPo7+4fZaRwWuCSRUcZkJOkZu0rfAbBKvoCg1LWb1Td8Vzi0DTLORdSvgWNbU6+EL40HIgwTOs00x2Jw== -"@mitchemmc/dexie-export-import@^1.0.0-rc.2": - version "1.0.0-rc.2" - resolved "https://registry.yarnpkg.com/@mitchemmc/dexie-export-import/-/dexie-export-import-1.0.0-rc.2.tgz#80c3e9b3331c9ad50cfe3c9378aedf7640a467ae" - integrity sha512-iCkiUrGTYlIy6sWfp1DeeUMv2NlilG70e+3FV54Av5nsGsFuUCrTh7sCR6pcQChmq1fdYXu6Jg//SkocP5rcNg== +"@mitchemmc/dexie-export-import@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@mitchemmc/dexie-export-import/-/dexie-export-import-1.0.0.tgz#035ed78cd39940662e66bcccd8152e552d0b5a45" + integrity sha512-mDiOD24gYXylrToc0t6nzeNfgnhCDqiLC8AdQ698/Hu229qZgj7ECFDzj+HmIXLlriPniWhIZxv02j7EN7zjEQ== dependencies: dexie "^3.0.0-alpha.5 || ^2.0.4" rollup-plugin-sourcemaps "^0.6.3"