From d1293b08c340491bca5f727172299ea42c81c732 Mon Sep 17 00:00:00 2001 From: Tom Hacohen Date: Wed, 27 Mar 2019 13:25:18 +0000 Subject: [PATCH] Journal: implement importing entries from file It still needs a bit of polished, such as proper error handling, progress indication, chunked pushing and etc, though it does work! Fixes #17 --- package.json | 1 + src/Journals/ImportDialog.tsx | 192 ++++++++++++++++++++++++++++++++++ src/Journals/Journal.tsx | 27 +++++ src/Journals/index.tsx | 2 + src/pim-types.ts | 4 + yarn.lock | 25 ++++- 6 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 src/Journals/ImportDialog.tsx diff --git a/package.json b/package.json index 75f119d..fc46677 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "react-big-calendar": "^0.20.3", "react-datetime": "^2.16.3", "react-dom": "^16.4.0", + "react-dropzone": "^10.0.4", "react-redux": "^5.0.6", "react-router": "^4.3.1", "react-router-dom": "^4.3.1", diff --git a/src/Journals/ImportDialog.tsx b/src/Journals/ImportDialog.tsx new file mode 100644 index 0000000..77c3a5f --- /dev/null +++ b/src/Journals/ImportDialog.tsx @@ -0,0 +1,192 @@ +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 { SyncInfoJournal } from '../SyncGate'; + +import { store, CredentialsData, UserInfoData } from '../store'; +import { addEntries } from '../store/actions'; +import { createJournalEntry } from '../etesync-helpers'; +import * as EteSync from '../api/EteSync'; + +import * as ICAL from 'ical.js'; +import { ContactType, EventType, TaskType, PimType } from '../pim-types'; + +interface PropsType { + etesync: CredentialsData; + userInfo: UserInfoData; + syncJournal: SyncInfoJournal; + open: boolean; + onClose?: () => void; +} + +class ImportDialog extends React.Component { + public state = { + loading: false, + }; + + constructor(props: PropsType) { + super(props); + + this.onFileDropCommon = this.onFileDropCommon.bind(this); + this.onFileDropEvent = this.onFileDropEvent.bind(this); + this.onFileDropTask = this.onFileDropTask.bind(this); + this.onFileDropContact = this.onFileDropContact.bind(this); + this.onClose = this.onClose.bind(this); + } + + public render() { + const { syncJournal } = this.props; + const { loading } = this.state; + const collectionInfo = syncJournal.collection; + let acceptTypes; + let dropFunction; + + if (collectionInfo.type === 'ADDRESS_BOOK') { + acceptTypes = ['text/vcard']; + dropFunction = this.onFileDropContact; + } else if (collectionInfo.type === 'CALENDAR') { + acceptTypes = ['text/calendar']; + dropFunction = this.onFileDropEvent; + } else if (collectionInfo.type === 'TASKS') { + acceptTypes = ['text/calendar']; + dropFunction = this.onFileDropTask; + } + + return ( + + + Import entries from file? + + { loading ? + + : + + {({getRootProps, getInputProps}) => ( +
+
+ + + To import entries from a file, drag 'n' drop it here, or click to open the file selector. + +
+
+ )} +
+ } +
+ + + +
+
+ ); + } + + private onFileDropCommon(itemsCreator: (fileText: string) => PimType[], acceptedFiles: File[], rejectedFiles: File[]) { + const reader = new FileReader(); + + reader.onabort = () => alert('file reading was aborted'); + reader.onerror = () => alert('file reading has failed'); + reader.onload = () => { + const fileText = reader.result as string; + const items = itemsCreator(fileText); + + const { syncJournal } = this.props; + const last = syncJournal.journalEntries.last() as EteSync.Entry; + const lastUid = last ? last.uid : null; + + // XXX implement chunked push most likely... + let prevUid = lastUid; + const journalItems = items.map((item) => { + const ret = createJournalEntry( + this.props.etesync, this.props.userInfo, syncJournal.journal, + prevUid, EteSync.SyncEntryAction.Add, item.toIcal()); + + prevUid = ret.uid; + return ret; + }); + + store.dispatch( + addEntries(this.props.etesync, syncJournal.journal.uid, journalItems, lastUid) + ).then(() => { + if (this.props.onClose) { + this.props.onClose(); + } + }); + }; + + this.setState({ loading: true }); + acceptedFiles.forEach((file) => { + reader.readAsText(file); + }); + } + + private onFileDropContact(acceptedFiles: File[], rejectedFiles: File[]) { + const itemsCreator = (fileText: string) => { + const mainComp = ICAL.parse(fileText); + return mainComp.map((comp) => new ContactType(new ICAL.Component(comp))); + }; + + this.onFileDropCommon(itemsCreator, acceptedFiles, rejectedFiles); + } + + private onFileDropEvent(acceptedFiles: File[], rejectedFiles: File[]) { + const itemsCreator = (fileText: string) => { + const calendarComp = new ICAL.Component(ICAL.parse(fileText)); + const timezoneComp = calendarComp.getFirstSubcomponent('vtimezone'); + return calendarComp.getAllSubcomponents('vevent').map((comp) => { + const ret = new EventType(comp); + ret.timezoneComp = timezoneComp; + return ret; + }); + }; + + this.onFileDropCommon(itemsCreator, acceptedFiles, rejectedFiles); + } + + private onFileDropTask(acceptedFiles: File[], rejectedFiles: File[]) { + const itemsCreator = (fileText: string) => { + const calendarComp = new ICAL.Component(ICAL.parse(fileText)); + const timezoneComp = calendarComp.getFirstSubcomponent('vtimezone'); + return calendarComp.getAllSubcomponents('vtodo').map((comp) => { + const ret = new TaskType(comp); + ret.timezoneComp = timezoneComp; + return ret; + }); + }; + + this.onFileDropCommon(itemsCreator, acceptedFiles, rejectedFiles); + } + + private onClose() { + if (this.state.loading) { + return; + } + + if (this.props.onClose) { + this.props.onClose(); + } + } +} + +export default ImportDialog; + diff --git a/src/Journals/Journal.tsx b/src/Journals/Journal.tsx index 5039892..a3e6f2a 100644 --- a/src/Journals/Journal.tsx +++ b/src/Journals/Journal.tsx @@ -5,6 +5,7 @@ import Tabs from '@material-ui/core/Tabs'; import { Theme, withTheme } from '@material-ui/core/styles'; import IconEdit from '@material-ui/icons/Edit'; import IconMembers from '@material-ui/icons/People'; +import IconImport from '@material-ui/icons/ImportExport'; import SearchableAddressBook from '../components/SearchableAddressBook'; import Contact from '../components/Contact'; @@ -18,6 +19,7 @@ import Container from '../widgets/Container'; import JournalEntries from '../components/JournalEntries'; import journalView from './journalView'; +import ImportDialog from './ImportDialog'; import { syncEntriesToItemMap, syncEntriesToEventItemMap, syncEntriesToTaskItemMap } from '../journal-processors'; @@ -28,7 +30,11 @@ import { Link } from 'react-router-dom'; import { routeResolver } from '../App'; import { historyPersistor } from '../persist-state-history'; +import { CredentialsData, UserInfoData } from '../store'; + interface PropsType { + etesync: CredentialsData; + userInfo: UserInfoData; syncInfo: SyncInfo; syncJournal: SyncInfoJournal; isOwner: boolean; @@ -46,13 +52,16 @@ const JournalTaskList = journalView(TaskList, Task); class Journal extends React.Component { public state: { tab: number, + importDialogOpen: boolean, }; constructor(props: PropsTypeInner) { super(props); + this.importDialogToggle = this.importDialogToggle.bind(this); this.state = { tab: 0, + importDialogOpen: false, }; } @@ -114,6 +123,12 @@ class Journal extends React.Component { } + + + { } + + ); } + + private importDialogToggle() { + this.setState((state: any) => ({ importDialogOpen: !state.importDialogOpen })); + } } export default withTheme()(Journal); diff --git a/src/Journals/index.tsx b/src/Journals/index.tsx index e2a0076..67acaf5 100644 --- a/src/Journals/index.tsx +++ b/src/Journals/index.tsx @@ -100,6 +100,8 @@ class Journals extends React.PureComponent { path={routeResolver.getRoute('journals._id')} render={() => (