diff --git a/src/Collections/Collection.tsx b/src/Collections/Collection.tsx index 5418ff4..4c6eaca 100644 --- a/src/Collections/Collection.tsx +++ b/src/Collections/Collection.tsx @@ -13,8 +13,8 @@ import Container from "../widgets/Container"; /* FIXME: import CollectionEntries from "../components/CollectionEntries"; -import ImportDialog from "./ImportDialog"; */ +import ImportDialog from "./ImportDialog"; import { Link } from "react-router-dom"; @@ -81,15 +81,14 @@ class Collection extends React.Component { + */} - */} ); } diff --git a/src/Collections/ImportDialog.tsx b/src/Collections/ImportDialog.tsx new file mode 100644 index 0000000..7b45f43 --- /dev/null +++ b/src/Collections/ImportDialog.tsx @@ -0,0 +1,212 @@ +// SPDX-FileCopyrightText: © 2017 EteSync Authors +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from "react"; + +import Button from "@material-ui/core/Button"; +import Dialog from "@material-ui/core/Dialog"; +import DialogActions from "@material-ui/core/DialogActions"; +import DialogContent from "@material-ui/core/DialogContent"; +import DialogContentText from "@material-ui/core/DialogContentText"; +import DialogTitle from "@material-ui/core/DialogTitle"; + +import Dropzone from "react-dropzone"; + +import LoadingIndicator from "../widgets/LoadingIndicator"; + +import { arrayToChunkIterator } from "../helpers"; + +import * as uuid from "uuid"; +import * as ICAL from "ical.js"; +import { ContactType, EventType, TaskType, PimType } from "../pim-types"; +import { useCredentials } from "../credentials"; +import { CachedCollection } from "../Pim/helpers"; +import { getCollectionManager } from "../etebase-helpers"; + +const CHUNK_SIZE = 40; + +interface PropsType { + collection: CachedCollection; + open: boolean; + onClose?: () => void; +} + +export default function ImportDialog(props: PropsType) { + const etebase = useCredentials()!; + const [loading, setLoading] = React.useState(false); + const [itemsProcessed, setItemsProccessed] = React.useState(); + + function onFileDropCommon(itemsCreator: (fileText: string) => PimType[], acceptedFiles: File[], rejectedFiles: File[]) { + // XXX: implement handling of rejectedFiles + const reader = new FileReader(); + + reader.onabort = () => { + setLoading(false); + console.error("Import Aborted"); + alert("file reading was aborted"); + }; + reader.onerror = (e) => { + setLoading(false); + console.error(e); + alert("file reading has failed"); + }; + reader.onload = async () => { + try { + const fileText = reader.result as string; + const items = itemsCreator(fileText); + + const { collection } = props.collection; + const colMgr = getCollectionManager(etebase); + const itemMgr = colMgr.getItemManager(collection); + + const eteItems = []; + for (const item of items) { + const mtime = (new Date()).getUTCMilliseconds(); + const meta = { + mtime, + name: item.uid, + }; + const content = item.toIcal(); + + const eteItem = await itemMgr.create(meta, content); + eteItems.push(eteItem); + } + + const chunks = arrayToChunkIterator(eteItems, CHUNK_SIZE); + for (const chunk of chunks) { + await itemMgr.batch(chunk); + } + + setItemsProccessed(items.length); + } catch (e) { + console.error(e); + alert("An error has occurred, please contact developers."); + throw e; + } finally { + if (props.onClose) { + setLoading(false); + } + } + }; + + if (acceptedFiles.length > 0) { + setLoading(true); + acceptedFiles.forEach((file) => { + reader.readAsText(file); + }); + } else { + alert("Failed importing file. Is the file type supported?"); + console.log("Failed importing files. Rejected:", rejectedFiles); + } + } + + function onFileDropContact(acceptedFiles: File[], rejectedFiles: File[]) { + const itemsCreator = (fileText: string) => { + const mainComp = ICAL.parse(fileText); + return mainComp.map((comp) => { + const ret = new ContactType(new ICAL.Component(comp)); + if (!ret.uid) { + ret.uid = uuid.v4(); + } + return ret; + }); + }; + + onFileDropCommon(itemsCreator, acceptedFiles, rejectedFiles); + } + + function onFileDropEvent(acceptedFiles: File[], rejectedFiles: File[]) { + const itemsCreator = (fileText: string) => { + const calendarComp = new ICAL.Component(ICAL.parse(fileText)); + return calendarComp.getAllSubcomponents("vevent").map((comp) => { + const ret = new EventType(comp); + if (!ret.uid) { + ret.uid = uuid.v4(); + } + return ret; + }); + }; + + onFileDropCommon(itemsCreator, acceptedFiles, rejectedFiles); + } + + function onFileDropTask(acceptedFiles: File[], rejectedFiles: File[]) { + const itemsCreator = (fileText: string) => { + const calendarComp = new ICAL.Component(ICAL.parse(fileText)); + return calendarComp.getAllSubcomponents("vtodo").map((comp) => { + const ret = new TaskType(comp); + if (!ret.uid) { + ret.uid = uuid.v4(); + } + return ret; + }); + }; + + onFileDropCommon(itemsCreator, acceptedFiles, rejectedFiles); + } + + function onClose() { + if (loading) { + return; + } + + if (props.onClose) { + props.onClose(); + } + } + + const { metadata } = props.collection; + let acceptTypes; + let dropFunction; + + if (metadata.type === "etebase.vcard") { + acceptTypes = ["text/vcard", "text/directory", "text/x-vcard", ".vcf"]; + dropFunction = onFileDropContact; + } else if (metadata.type === "etebase.vevent") { + acceptTypes = ["text/calendar", ".ics", ".ical"]; + dropFunction = onFileDropEvent; + } else if (metadata.type === "etebase.vtodo") { + acceptTypes = ["text/calendar", ".ics", ".ical"]; + dropFunction = onFileDropTask; + } + + return ( + + + Import entries from file? + + {(itemsProcessed !== undefined) ? ( +

Imported {itemsProcessed} items.

+ ) : (loading ? + + : + + {({ getRootProps, getInputProps }) => ( +
+
+ + + To import entries from a file, drag 'n' drop it here, or click to open the file selector. + +
+
+ )} +
+ )} +
+ + + +
+
+ ); +} diff --git a/src/helpers.tsx b/src/helpers.tsx index 024154c..e8f5e3d 100644 --- a/src/helpers.tsx +++ b/src/helpers.tsx @@ -121,6 +121,12 @@ export function mapPriority(priority: number): TaskPriorityType { } } +export function* arrayToChunkIterator(arr: T[], size: number) { + for (let i = 0 ; i < arr.length ; i += size) { + yield arr.slice(i, i + size); + } +} + export function usePromiseMemo(promise: Promise | undefined | null, deps: React.DependencyList, initial: T | undefined = undefined): T | undefined { const [val, setVal] = React.useState((promise as any)._returnedValue ?? initial); React.useEffect(() => {