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
master
Tom Hacohen 6 years ago
parent 8a58fb01fc
commit d1293b08c3

@ -14,6 +14,7 @@
"react-big-calendar": "^0.20.3", "react-big-calendar": "^0.20.3",
"react-datetime": "^2.16.3", "react-datetime": "^2.16.3",
"react-dom": "^16.4.0", "react-dom": "^16.4.0",
"react-dropzone": "^10.0.4",
"react-redux": "^5.0.6", "react-redux": "^5.0.6",
"react-router": "^4.3.1", "react-router": "^4.3.1",
"react-router-dom": "^4.3.1", "react-router-dom": "^4.3.1",

@ -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<PropsType> {
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 (
<React.Fragment>
<Dialog
open={this.props.open}
onClose={this.onClose}
>
<DialogTitle>Import entries from file?</DialogTitle>
<DialogContent>
{ loading ?
<LoadingIndicator style={{display: 'block', margin: 'auto'}} />
:
<Dropzone
onDrop={dropFunction}
multiple={false}
accept={acceptTypes}
>
{({getRootProps, getInputProps}) => (
<section>
<div {...getRootProps()}>
<input {...getInputProps()} />
<DialogContentText id="alert-dialog-description">
To import entries from a file, drag 'n' drop it here, or click to open the file selector.
</DialogContentText>
</div>
</section>
)}
</Dropzone>
}
</DialogContent>
<DialogActions>
<Button disabled={loading} onClick={this.onClose} color="primary">
Cancel
</Button>
</DialogActions>
</Dialog>
</React.Fragment>
);
}
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<any>(
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;

@ -5,6 +5,7 @@ import Tabs from '@material-ui/core/Tabs';
import { Theme, withTheme } from '@material-ui/core/styles'; import { Theme, withTheme } from '@material-ui/core/styles';
import IconEdit from '@material-ui/icons/Edit'; import IconEdit from '@material-ui/icons/Edit';
import IconMembers from '@material-ui/icons/People'; import IconMembers from '@material-ui/icons/People';
import IconImport from '@material-ui/icons/ImportExport';
import SearchableAddressBook from '../components/SearchableAddressBook'; import SearchableAddressBook from '../components/SearchableAddressBook';
import Contact from '../components/Contact'; import Contact from '../components/Contact';
@ -18,6 +19,7 @@ import Container from '../widgets/Container';
import JournalEntries from '../components/JournalEntries'; import JournalEntries from '../components/JournalEntries';
import journalView from './journalView'; import journalView from './journalView';
import ImportDialog from './ImportDialog';
import { syncEntriesToItemMap, syncEntriesToEventItemMap, syncEntriesToTaskItemMap } from '../journal-processors'; import { syncEntriesToItemMap, syncEntriesToEventItemMap, syncEntriesToTaskItemMap } from '../journal-processors';
@ -28,7 +30,11 @@ import { Link } from 'react-router-dom';
import { routeResolver } from '../App'; import { routeResolver } from '../App';
import { historyPersistor } from '../persist-state-history'; import { historyPersistor } from '../persist-state-history';
import { CredentialsData, UserInfoData } from '../store';
interface PropsType { interface PropsType {
etesync: CredentialsData;
userInfo: UserInfoData;
syncInfo: SyncInfo; syncInfo: SyncInfo;
syncJournal: SyncInfoJournal; syncJournal: SyncInfoJournal;
isOwner: boolean; isOwner: boolean;
@ -46,13 +52,16 @@ const JournalTaskList = journalView(TaskList, Task);
class Journal extends React.Component<PropsTypeInner> { class Journal extends React.Component<PropsTypeInner> {
public state: { public state: {
tab: number, tab: number,
importDialogOpen: boolean,
}; };
constructor(props: PropsTypeInner) { constructor(props: PropsTypeInner) {
super(props); super(props);
this.importDialogToggle = this.importDialogToggle.bind(this);
this.state = { this.state = {
tab: 0, tab: 0,
importDialogOpen: false,
}; };
} }
@ -114,6 +123,12 @@ class Journal extends React.Component<PropsTypeInner> {
</IconButton> </IconButton>
</> </>
} }
<IconButton
title="Import"
onClick={this.importDialogToggle}
>
<IconImport />
</IconButton>
</AppBarOverride> </AppBarOverride>
<Tabs <Tabs
variant="fullWidth" variant="fullWidth"
@ -134,9 +149,21 @@ class Journal extends React.Component<PropsTypeInner> {
<JournalEntries journal={journal} entries={syncEntries} /> <JournalEntries journal={journal} entries={syncEntries} />
</Container> </Container>
} }
<ImportDialog
etesync={this.props.etesync}
userInfo={this.props.userInfo}
syncJournal={this.props.syncJournal}
open={this.state.importDialogOpen}
onClose={this.importDialogToggle}
/>
</React.Fragment> </React.Fragment>
); );
} }
private importDialogToggle() {
this.setState((state: any) => ({ importDialogOpen: !state.importDialogOpen }));
}
} }
export default withTheme()(Journal); export default withTheme()(Journal);

@ -100,6 +100,8 @@ class Journals extends React.PureComponent {
path={routeResolver.getRoute('journals._id')} path={routeResolver.getRoute('journals._id')}
render={() => ( render={() => (
<Journal <Journal
etesync={this.props.etesync}
userInfo={this.props.userInfo}
syncInfo={this.props.syncInfo} syncInfo={this.props.syncInfo}
syncJournal={syncJournal} syncJournal={syncJournal}
isOwner={isOwner} isOwner={isOwner}

@ -16,6 +16,7 @@ export class EventType extends ICAL.Event implements PimType {
} }
public color: string; public color: string;
public timezoneComp: ICAL.Component | null;
get timezone() { get timezone() {
if (this.startDate) { if (this.startDate) {
@ -53,6 +54,9 @@ export class EventType extends ICAL.Event implements PimType {
comp.updatePropertyWithValue('version', '4.0'); comp.updatePropertyWithValue('version', '4.0');
comp.addSubcomponent(this.component); comp.addSubcomponent(this.component);
if (this.timezoneComp) {
comp.addSubcomponent(this.timezoneComp);
}
return comp.toString(); return comp.toString();
} }

@ -1612,6 +1612,13 @@ atob@^2.1.1:
resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9" resolved "https://registry.yarnpkg.com/atob/-/atob-2.1.2.tgz#6d9517eb9e030d2436666651e86bd9f6f13533c9"
integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg== integrity sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==
attr-accept@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/attr-accept/-/attr-accept-1.1.3.tgz#48230c79f93790ef2775fcec4f0db0f5db41ca52"
integrity sha512-iT40nudw8zmCweivz6j58g+RT33I4KbaIvRUhjNmDwO2WmsQUxFEZZYZ5w3vXe5x5MX9D7mfvA/XaLOZYFR9EQ==
dependencies:
core-js "^2.5.0"
autoprefixer@^9.4.2: autoprefixer@^9.4.2:
version "9.4.7" version "9.4.7"
resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-9.4.7.tgz#f997994f9a810eae47b38fa6d8a119772051c4ff" resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-9.4.7.tgz#f997994f9a810eae47b38fa6d8a119772051c4ff"
@ -4030,6 +4037,13 @@ file-loader@2.0.0:
loader-utils "^1.0.2" loader-utils "^1.0.2"
schema-utils "^1.0.0" schema-utils "^1.0.0"
file-selector@^0.1.11:
version "0.1.11"
resolved "https://registry.yarnpkg.com/file-selector/-/file-selector-0.1.11.tgz#4648d1303fc594afe8638d0f35caed38697d32cf"
integrity sha512-NopCegJ7QuoqVzUdSLcZb0M9IFO69CSFZzuZhZBasfQxepNwa1ehL6L9UKe3EyBof3EUeraccfJocLCRAvtxdg==
dependencies:
tslib "^1.9.0"
filename-regex@^2.0.0: filename-regex@^2.0.0:
version "2.0.1" version "2.0.1"
resolved "https://registry.yarnpkg.com/filename-regex/-/filename-regex-2.0.1.tgz#c1c4b9bee3e09725ddb106b75c1e301fe2f18b26" resolved "https://registry.yarnpkg.com/filename-regex/-/filename-regex-2.0.1.tgz#c1c4b9bee3e09725ddb106b75c1e301fe2f18b26"
@ -8117,7 +8131,7 @@ prop-types-extra@^1.0.1, prop-types-extra@^1.1.0:
react-is "^16.3.2" react-is "^16.3.2"
warning "^3.0.0" warning "^3.0.0"
prop-types@^15.5.10, prop-types@^15.5.7, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2: prop-types@^15.5.10, prop-types@^15.5.7, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2:
version "15.7.2" version "15.7.2"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.7.2.tgz#52c41e75b8c87e72b9d9360e0206b99dcbffa6c5"
integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ== integrity sha512-8QQikdH7//R2vurIJSutZ1smHYTcLpRWEOlHnzcWHmBYrOGUysKwSsrC89BCiFj3CbrfJ/nXFdJepOVrY1GCHQ==
@ -8368,6 +8382,15 @@ react-dom@^16.4.0:
prop-types "^15.6.2" prop-types "^15.6.2"
scheduler "^0.13.1" scheduler "^0.13.1"
react-dropzone@^10.0.4:
version "10.0.4"
resolved "https://registry.yarnpkg.com/react-dropzone/-/react-dropzone-10.0.4.tgz#10669f52a5d3d287a1e9fe65bc2e0d27530bb23a"
integrity sha512-E7ASgo/38zmLurN0uend1MOLk1PssBIXfPlMoJK5K5/WJeBtigWjQPC4rkjb4Zx77AUFtMaPSCabTCuDI6wFbA==
dependencies:
attr-accept "^1.1.3"
file-selector "^0.1.11"
prop-types "^15.7.2"
react-error-overlay@^5.1.3: react-error-overlay@^5.1.3:
version "5.1.3" version "5.1.3"
resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-5.1.3.tgz#16fcbde75ed4dc6161dc6dc959b48e92c6ffa9ad" resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-5.1.3.tgz#16fcbde75ed4dc6161dc6dc959b48e92c6ffa9ad"

Loading…
Cancel
Save