import { 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 { v4 as uuid } from "uuid"; import { useToasts } from "react-toast-notifications"; import Modal from "../components/Modal"; import LoadingOverlay from "../components/LoadingOverlay"; import LoadingBar from "../components/LoadingBar"; import ErrorBanner from "../components/banner/ErrorBanner"; import { useUserId } from "../contexts/UserIdContext"; import { useDatabase } from "../contexts/DatabaseContext"; import SelectDataModal from "./SelectDataModal"; import { getDatabase } from "../database"; import { Map, MapState, TokenState } from "../components/map/Map"; import { Token } from "../tokens"; const importDBName = "OwlbearRodeoImportDB"; class MissingAssetError extends Error { constructor(message: string) { super(message); this.name = "MissingAssetError"; } } function ImportExportModal({ isOpen, onRequestClose, }: { isOpen: boolean; onRequestClose: () => void; }) { const { worker } = useDatabase(); const userId = useUserId(); 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); const { addToast } = useToasts(); function addSuccessToast(message: string, maps: Map[], tokens: Token[]) { const mapText = `${maps.length} map${maps.length > 1 ? "s" : ""}`; const tokenText = `${tokens.length} token${tokens.length > 1 ? "s" : ""}`; if (maps.length > 0 && tokens.length > 0) { addToast(`${message} ${mapText} and ${tokenText}`); } else if (maps.length > 0) { addToast(`${message} ${mapText}`); } else if (tokens.length > 0) { addToast(`${message} ${tokenText}`); } } function openFileDialog() { if (fileInputRef.current) { fileInputRef.current.click(); } } const loadingProgressRef = useRef(0); function handleDBProgress({ completedRows, totalRows, }: { completedRows: number; totalRows: number; }) { loadingProgressRef.current = completedRows / totalRows; } async function handleImportDatabase(file: File) { setIsLoading(true); backgroundTaskRunningRef.current = true; try { await worker.importData( file, importDBName, Comlink.proxy(handleDBProgress) ); setIsLoading(false); setShowImportSelector(true); backgroundTaskRunningRef.current = false; } catch (e) { setIsLoading(false); backgroundTaskRunningRef.current = false; if (e.message.startsWith("Max buffer length exceeded")) { setError( new Error( "Max image size exceeded ensure your database doesn't have an image over 100MB" ) ); } else { console.error(e); 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; } } function handleExportDatabase() { setShowExportSelector(true); } useEffect(() => { function handleBeforeUnload(event: any) { if (backgroundTaskRunningRef.current) { event.returnValue = "Database is still processing, are you sure you want to leave?"; } } window.addEventListener("beforeunload", handleBeforeUnload); return () => { window.removeEventListener("beforeunload", handleBeforeUnload); }; }, []); function handleClose() { if (isLoading) { return; } onRequestClose(); } async function handleImportSelectorClose() { const importDB = getDatabase({ addons: [] }, importDBName); await importDB.delete(); importDB.close(); setShowImportSelector(false); } async function handleImportSelectorConfirm( checkedMaps: Map[], checkedTokens: Token[], checkedMapGroups: any[], checkedTokenGroups: any[] ) { setIsLoading(true); backgroundTaskRunningRef.current = true; setShowImportSelector(false); loadingProgressRef.current = 0; const importDB = getDatabase({ addons: [] }, importDBName); const db = getDatabase({}); try { // Keep track of a mapping of old token ids to new ones to apply them to the map states let newTokenIds: Record = {}; // Mapping of old asset ids to new asset ids let newAssetIds: Record = {}; // Mapping of old maps ids to new map ids let newMapIds: Record = {}; let newTokens: Token[] = []; if (checkedTokens.length > 0) { const tokenIds = checkedTokens.map((token) => token.id); const tokensToAdd = await importDB.table("tokens").bulkGet(tokenIds); for (let token of tokensToAdd) { // Generate new ids const newId = uuid(); newTokenIds[token.id] = newId; if (token.type === "default") { newTokens.push({ ...token, id: newId, owner: userId }); } else { const newFileId = uuid(); const newThumbnailId = uuid(); newAssetIds[token.file] = newFileId; newAssetIds[token.thumbnail] = newThumbnailId; // Change ids and owner newTokens.push({ ...token, id: newId, owner: userId, file: newFileId, thumbnail: newThumbnailId, }); } } } let newMaps: Map[] = []; let newStates: MapState[] = []; if (checkedMaps.length > 0) { const mapIds = checkedMaps.map((map: any) => map.id); const mapsToAdd = await importDB.table("maps").bulkGet(mapIds); for (let map of mapsToAdd) { let state: MapState = await importDB.table("states").get(map.id); // Apply new token ids to imported state for (let tokenState of Object.values(state.tokens)) { if (tokenState.tokenId in newTokenIds) { state.tokens[tokenState.id].tokenId = newTokenIds[tokenState.tokenId]; } // Change token state file asset id if (tokenState.type === "file" && tokenState.file in newAssetIds) { state.tokens[tokenState.id].file = newAssetIds[tokenState.file]; } // Change token state owner if owned by the user of the map if (tokenState.owner === map.owner) { state.tokens[tokenState.id].owner = userId; } } // Generate new ids const newId = uuid(); newMapIds[map.id] = newId; if (map.type === "default") { newMaps.push({ ...map, id: newId, owner: userId }); } else { const newFileId = uuid(); const newThumbnailId = uuid(); newAssetIds[map.file] = newFileId; newAssetIds[map.thumbnail] = newThumbnailId; const newResolutionIds: Record = {}; for (let res of Object.keys(map.resolutions)) { newResolutionIds[res] = uuid(); newAssetIds[map.resolutions[res]] = newResolutionIds[res]; } // Change ids and owner newMaps.push({ ...map, id: newId, owner: userId, file: newFileId, thumbnail: newThumbnailId, resolutions: newResolutionIds, }); } newStates.push({ ...state, mapId: newId }); } } // Add assets with new ids const assetsToAdd = await importDB .table("assets") .bulkGet(Object.keys(newAssetIds)); let newAssets: any[] = []; for (let asset of assetsToAdd) { if (asset) { newAssets.push({ ...asset, id: newAssetIds[asset.id], owner: userId, }); } else { throw new MissingAssetError("Import missing assets"); } } // Add map groups with new ids let newMapGroups: any[] = []; if (checkedMapGroups.length > 0) { for (let group of checkedMapGroups) { if (group.type === "item") { newMapGroups.push({ ...group, id: newMapIds[group.id] }); } else { newMapGroups.push({ ...group, id: uuid(), items: group.items.map((item) => ({ ...item, id: newMapIds[item.id], })), }); } } } // Add token groups with new ids let newTokenGroups: any[] = []; if (checkedTokenGroups.length > 0) { for (let group of checkedTokenGroups) { if (group.type === "item") { newTokenGroups.push({ ...group, id: newTokenIds[group.id] }); } else { newTokenGroups.push({ ...group, id: uuid(), items: group.items.map((item: any) => ({ ...item, id: newTokenIds[item.id], })), }); } } } db.transaction( "rw", [ db.table("tokens"), db.table("maps"), db.table("states"), db.table("assets"), db.table("groups"), ], async () => { if (newTokens.length > 0) { await db.table("tokens").bulkAdd(newTokens); } if (newMaps.length > 0) { await db.table("maps").bulkAdd(newMaps); } if (newStates.length > 0) { await db.table("states").bulkAdd(newStates); } if (newAssets.length > 0) { await db.table("assets").bulkAdd(newAssets); } if (newMapGroups.length > 0) { const mapGroup = await db.table("groups").get("maps"); await db .table("groups") .update("maps", { items: [...newMapGroups, ...mapGroup.items] }); } if (newTokenGroups.length > 0) { const tokenGroup = await db.table("groups").get("tokens"); await db.table("groups").update("tokens", { items: [...newTokenGroups, ...tokenGroup.items], }); } } ); addSuccessToast("Imported", checkedMaps, checkedTokens); } catch (e) { console.error(e); if (e instanceof MissingAssetError) { setError(e); } else { setError(new Error("Unable to import data")); } } await importDB.delete(); importDB.close(); db.close(); setIsLoading(false); backgroundTaskRunningRef.current = false; } function exportSelectorFilter(table: string) { return ( table === "maps" || table === "tokens" || table === "states" || table === "groups" ); } async function handleExportSelectorClose() { setShowExportSelector(false); } async function handleExportSelectorConfirm( checkedMaps: Map[], checkedTokens: TokenState[] ) { setShowExportSelector(false); setIsLoading(true); backgroundTaskRunningRef.current = true; const mapIds = checkedMaps.map((map) => map.id); const tokenIds = checkedTokens.map((token) => token.id); try { const buffer = await worker.exportData( Comlink.proxy(handleDBProgress), mapIds, tokenIds ); const blob = new Blob([buffer]); saveAs(blob, `${shortid.generate()}.owlbear`); addSuccessToast("Exported", checkedMaps, checkedTokens); } catch (e) { console.error(e); setError(e); } setIsLoading(false); backgroundTaskRunningRef.current = false; } return ( Select import or export then select the data you wish to use event.target.files && handleImportDatabase(event.target.files[0]) } type="file" accept=".owlbear" style={{ display: "none" }} ref={fileInputRef} /> {isLoading && ( )} setError(undefined)} /> ); } export default ImportExportModal;