Journals: remove unused journals components.
parent
2f565994b7
commit
c69f5e27ad
@ -1,207 +0,0 @@
|
||||
// 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 { SyncInfoJournal } from "../SyncGate";
|
||||
|
||||
import { store, CredentialsData, UserInfoData } from "../store";
|
||||
import { addEntries } from "../store/actions";
|
||||
import { createJournalEntry } from "../etesync-helpers";
|
||||
import * as EteSync from "etesync";
|
||||
|
||||
import * as uuid from "uuid";
|
||||
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;
|
||||
}
|
||||
|
||||
export default function ImportDialog(props: PropsType) {
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
|
||||
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 { syncJournal } = 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(
|
||||
props.etesync, props.userInfo, syncJournal.journal,
|
||||
prevUid, EteSync.SyncEntryAction.Add, item.toIcal());
|
||||
|
||||
prevUid = ret.uid;
|
||||
return ret;
|
||||
});
|
||||
|
||||
await store.dispatch<any>(
|
||||
addEntries(props.etesync, syncJournal.journal.uid, journalItems, lastUid)
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert("An error has occurred, please contact developers.");
|
||||
throw e;
|
||||
} finally {
|
||||
if (props.onClose) {
|
||||
setLoading(false);
|
||||
props.onClose();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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 { syncJournal } = props;
|
||||
const collectionInfo = syncJournal.collection;
|
||||
let acceptTypes;
|
||||
let dropFunction;
|
||||
|
||||
if (collectionInfo.type === "ADDRESS_BOOK") {
|
||||
acceptTypes = ["text/vcard", "text/directory", "text/x-vcard", ".vcf"];
|
||||
dropFunction = onFileDropContact;
|
||||
} else if (collectionInfo.type === "CALENDAR") {
|
||||
acceptTypes = ["text/calendar", ".ics", ".ical"];
|
||||
dropFunction = onFileDropEvent;
|
||||
} else if (collectionInfo.type === "TASKS") {
|
||||
acceptTypes = ["text/calendar", ".ics", ".ical"];
|
||||
dropFunction = onFileDropTask;
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Dialog
|
||||
open={props.open}
|
||||
onClose={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={onClose} color="primary">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
@ -1,104 +0,0 @@
|
||||
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from "react";
|
||||
import IconButton from "@material-ui/core/IconButton";
|
||||
import IconEdit from "@material-ui/icons/Edit";
|
||||
import IconMembers from "@material-ui/icons/People";
|
||||
import IconImport from "@material-ui/icons/ImportExport";
|
||||
|
||||
|
||||
import AppBarOverride from "../widgets/AppBarOverride";
|
||||
import Container from "../widgets/Container";
|
||||
|
||||
import JournalEntries from "../components/JournalEntries";
|
||||
import ImportDialog from "./ImportDialog";
|
||||
|
||||
import { SyncInfo, SyncInfoJournal } from "../SyncGate";
|
||||
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { routeResolver } from "../App";
|
||||
|
||||
import { CredentialsData, UserInfoData } from "../store";
|
||||
|
||||
interface PropsType {
|
||||
etesync: CredentialsData;
|
||||
userInfo: UserInfoData;
|
||||
syncInfo: SyncInfo;
|
||||
syncJournal: SyncInfoJournal;
|
||||
isOwner: boolean;
|
||||
}
|
||||
|
||||
class Journal extends React.Component<PropsType> {
|
||||
public state: {
|
||||
tab: number;
|
||||
importDialogOpen: boolean;
|
||||
};
|
||||
|
||||
constructor(props: PropsType) {
|
||||
super(props);
|
||||
|
||||
this.importDialogToggle = this.importDialogToggle.bind(this);
|
||||
this.state = {
|
||||
tab: 0,
|
||||
importDialogOpen: false,
|
||||
};
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { isOwner, syncJournal } = this.props;
|
||||
|
||||
const journal = syncJournal.journal;
|
||||
const collectionInfo = syncJournal.collection;
|
||||
const syncEntries = syncJournal.entries;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<AppBarOverride title={collectionInfo.displayName}>
|
||||
{isOwner &&
|
||||
<>
|
||||
<IconButton
|
||||
component={Link}
|
||||
title="Edit"
|
||||
{...{ to: routeResolver.getRoute("journals._id.edit", { journalUid: journal.uid }) }}
|
||||
>
|
||||
<IconEdit />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
component={Link}
|
||||
title="Members"
|
||||
{...{ to: routeResolver.getRoute("journals._id.members", { journalUid: journal.uid }) }}
|
||||
>
|
||||
<IconMembers />
|
||||
</IconButton>
|
||||
</>
|
||||
}
|
||||
<IconButton
|
||||
title="Import"
|
||||
onClick={this.importDialogToggle}
|
||||
>
|
||||
<IconImport />
|
||||
</IconButton>
|
||||
</AppBarOverride>
|
||||
<Container>
|
||||
<JournalEntries journal={journal} entries={syncEntries} />
|
||||
</Container>
|
||||
|
||||
<ImportDialog
|
||||
etesync={this.props.etesync}
|
||||
userInfo={this.props.userInfo}
|
||||
syncJournal={this.props.syncJournal}
|
||||
open={this.state.importDialogOpen}
|
||||
onClose={this.importDialogToggle}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
private importDialogToggle() {
|
||||
this.setState((state: any) => ({ importDialogOpen: !state.importDialogOpen }));
|
||||
}
|
||||
}
|
||||
|
||||
export default Journal;
|
@ -1,214 +0,0 @@
|
||||
// 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 TextField from "@material-ui/core/TextField";
|
||||
import Select from "@material-ui/core/Select";
|
||||
import MenuItem from "@material-ui/core/MenuItem";
|
||||
import FormControl from "@material-ui/core/FormControl";
|
||||
import InputLabel from "@material-ui/core/InputLabel";
|
||||
import IconDelete from "@material-ui/icons/Delete";
|
||||
import IconCancel from "@material-ui/icons/Clear";
|
||||
import IconSave from "@material-ui/icons/Save";
|
||||
import * as colors from "@material-ui/core/colors";
|
||||
|
||||
import AppBarOverride from "../widgets/AppBarOverride";
|
||||
import Container from "../widgets/Container";
|
||||
import ConfirmationDialog from "../widgets/ConfirmationDialog";
|
||||
|
||||
import * as EteSync from "etesync";
|
||||
import { SyncInfo } from "../SyncGate";
|
||||
import ColorPicker from "../widgets/ColorPicker";
|
||||
import { defaultColor, colorHtmlToInt, colorIntToHtml } from "../journal-processors";
|
||||
|
||||
interface PropsType {
|
||||
syncInfo: SyncInfo;
|
||||
item?: EteSync.CollectionInfo;
|
||||
onSave: (info: EteSync.CollectionInfo, originalInfo?: EteSync.CollectionInfo) => void;
|
||||
onDelete: (info: EteSync.CollectionInfo) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
interface FormErrors {
|
||||
displayName?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export default function JournalEdit(props: PropsType) {
|
||||
const [errors, setErrors] = React.useState<FormErrors>({});
|
||||
const [showDeleteDialog, setShowDeleteDialog] = React.useState(false);
|
||||
const [info, setInfo] = React.useState<EteSync.CollectionInfo>();
|
||||
const [selectedColor, setSelectedColor] = React.useState("");
|
||||
|
||||
React.useEffect(() => {
|
||||
if (props.item !== undefined) {
|
||||
setInfo(props.item);
|
||||
if (props.item.color) {
|
||||
setSelectedColor(colorIntToHtml(props.item.color));
|
||||
}
|
||||
} else {
|
||||
setInfo({
|
||||
uid: EteSync.genUid(),
|
||||
type: "ADDRESS_BOOK",
|
||||
displayName: "",
|
||||
description: "",
|
||||
} as EteSync.CollectionInfo);
|
||||
}
|
||||
}, [props.item]);
|
||||
|
||||
if (info === undefined) {
|
||||
return <React.Fragment />;
|
||||
}
|
||||
|
||||
function onSubmit(e: React.FormEvent<any>) {
|
||||
e.preventDefault();
|
||||
const saveErrors: FormErrors = {};
|
||||
const fieldRequired = "This field is required!";
|
||||
|
||||
const { onSave } = props;
|
||||
|
||||
const displayName = info?.displayName;
|
||||
const color = colorHtmlToInt(selectedColor);
|
||||
|
||||
if (!displayName) {
|
||||
saveErrors.displayName = fieldRequired;
|
||||
}
|
||||
|
||||
if (selectedColor && !color) {
|
||||
saveErrors.color = "Must be of the form #RRGGBB or #RRGGBBAA or empty";
|
||||
}
|
||||
|
||||
if (Object.keys(saveErrors).length > 0) {
|
||||
setErrors(saveErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
const item = new EteSync.CollectionInfo({ ...info, color: color });
|
||||
onSave(item, props.item);
|
||||
}
|
||||
|
||||
function onDeleteRequest() {
|
||||
setShowDeleteDialog(true);
|
||||
}
|
||||
|
||||
const { item, onDelete, onCancel } = props;
|
||||
|
||||
const pageTitle = (item !== undefined) ? item.displayName : "New Journal";
|
||||
|
||||
const styles = {
|
||||
fullWidth: {
|
||||
width: "100%",
|
||||
},
|
||||
submit: {
|
||||
marginTop: 40,
|
||||
marginBottom: 20,
|
||||
textAlign: "right" as any,
|
||||
},
|
||||
};
|
||||
|
||||
const journalTypes = {
|
||||
ADDRESS_BOOK: "Address Book",
|
||||
CALENDAR: "Calendar",
|
||||
TASKS: "Task List",
|
||||
};
|
||||
let collectionColorBox: React.ReactNode;
|
||||
switch (info.type) {
|
||||
case "CALENDAR":
|
||||
case "TASKS":
|
||||
collectionColorBox = (
|
||||
<ColorPicker
|
||||
defaultColor={defaultColor}
|
||||
color={selectedColor}
|
||||
onChange={(color: string) => setSelectedColor(color)}
|
||||
error={errors.color}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppBarOverride title={pageTitle} />
|
||||
<Container style={{ maxWidth: "30rem" }}>
|
||||
<form onSubmit={onSubmit}>
|
||||
<FormControl disabled={props.item !== undefined} style={styles.fullWidth}>
|
||||
<InputLabel>
|
||||
Collection type
|
||||
</InputLabel>
|
||||
<Select
|
||||
name="type"
|
||||
required
|
||||
value={info.type}
|
||||
onChange={(event: React.ChangeEvent<{ value: string }>) => setInfo({ ...info, type: event.target.value })}
|
||||
>
|
||||
{Object.keys(journalTypes).map((x) => (
|
||||
<MenuItem key={x} value={x}>{journalTypes[x]}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
name="displayName"
|
||||
required
|
||||
label="Display name of this collection"
|
||||
value={info.displayName}
|
||||
onChange={(event: React.ChangeEvent<{ value: string }>) => setInfo({ ...info, displayName: event.target.value })}
|
||||
style={styles.fullWidth}
|
||||
margin="normal"
|
||||
error={!!errors.displayName}
|
||||
helperText={errors.displayName}
|
||||
/>
|
||||
<TextField
|
||||
name="description"
|
||||
label="Description (optional)"
|
||||
value={info.description}
|
||||
onChange={(event: React.ChangeEvent<{ value: string }>) => setInfo({ ...info, description: event.target.value })}
|
||||
style={styles.fullWidth}
|
||||
margin="normal"
|
||||
/>
|
||||
{collectionColorBox}
|
||||
|
||||
<div style={styles.submit}>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={onCancel}
|
||||
>
|
||||
<IconCancel style={{ marginRight: 8 }} />
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
{props.item &&
|
||||
<Button
|
||||
variant="contained"
|
||||
style={{ marginLeft: 15, backgroundColor: colors.red[500], color: "white" }}
|
||||
onClick={onDeleteRequest}
|
||||
>
|
||||
<IconDelete style={{ marginRight: 8 }} />
|
||||
Delete
|
||||
</Button>
|
||||
}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="secondary"
|
||||
style={{ marginLeft: 15 }}
|
||||
>
|
||||
<IconSave style={{ marginRight: 8 }} />
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Container>
|
||||
<ConfirmationDialog
|
||||
title="Delete Confirmation"
|
||||
labelOk="Delete"
|
||||
open={showDeleteDialog}
|
||||
onOk={() => onDelete(props.item!)}
|
||||
onCancel={() => setShowDeleteDialog(false)}
|
||||
>
|
||||
Are you sure you would like to delete this journal?
|
||||
</ConfirmationDialog>
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,125 +0,0 @@
|
||||
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import TextField from "@material-ui/core/TextField";
|
||||
import Checkbox from "@material-ui/core/Checkbox";
|
||||
import FormControlLabel from "@material-ui/core/FormControlLabel";
|
||||
|
||||
import LoadingIndicator from "../widgets/LoadingIndicator";
|
||||
import ConfirmationDialog from "../widgets/ConfirmationDialog";
|
||||
import PrettyFingerprint from "../widgets/PrettyFingerprint";
|
||||
|
||||
import * as EteSync from "etesync";
|
||||
|
||||
import { CredentialsData } from "../store";
|
||||
|
||||
interface PropsType {
|
||||
etesync: CredentialsData;
|
||||
info: EteSync.CollectionInfo;
|
||||
onOk: (user: string, publicKey: string, readOnly: boolean) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function JournalMemberAddDialog(props: PropsType) {
|
||||
const [addUser, setAddUser] = React.useState("");
|
||||
const [publicKey, setPublicKey] = React.useState("");
|
||||
const [readOnly, setReadOnly] = React.useState(false);
|
||||
const [userChosen, setUserChosen] = React.useState(false);
|
||||
const [error, setError] = React.useState<Error>();
|
||||
|
||||
function onAddRequest(_user: string) {
|
||||
setUserChosen(true);
|
||||
|
||||
const { etesync } = props;
|
||||
|
||||
const creds = etesync.credentials;
|
||||
const apiBase = etesync.serviceApiUrl;
|
||||
const userInfoManager = new EteSync.UserInfoManager(creds, apiBase);
|
||||
userInfoManager.fetch(addUser).then((userInfo) => {
|
||||
setPublicKey(userInfo.publicKey);
|
||||
}).catch((error) => {
|
||||
setError(error);
|
||||
});
|
||||
}
|
||||
|
||||
function onOk() {
|
||||
props.onOk(addUser, publicKey, readOnly);
|
||||
}
|
||||
|
||||
const { onClose } = props;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<ConfirmationDialog
|
||||
title="Error adding member"
|
||||
labelOk="OK"
|
||||
open
|
||||
onOk={onClose}
|
||||
onCancel={onClose}
|
||||
>
|
||||
User ({addUser}) not found. Have they setup their encryption password from one of the apps?
|
||||
</ConfirmationDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (publicKey) {
|
||||
return (
|
||||
<>
|
||||
<ConfirmationDialog
|
||||
title="Verify security fingerprint"
|
||||
labelOk="OK"
|
||||
open
|
||||
onOk={onOk}
|
||||
onCancel={onClose}
|
||||
>
|
||||
<p>
|
||||
Verify {addUser}'s security fingerprint to ensure the encryption is secure.
|
||||
</p>
|
||||
<div style={{ textAlign: "center" }}>
|
||||
<PrettyFingerprint publicKey={publicKey as any} />
|
||||
</div>
|
||||
</ConfirmationDialog>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<ConfirmationDialog
|
||||
title="Add member"
|
||||
labelOk="OK"
|
||||
open={!userChosen}
|
||||
onOk={onAddRequest}
|
||||
onCancel={onClose}
|
||||
>
|
||||
{userChosen ?
|
||||
<LoadingIndicator />
|
||||
:
|
||||
<>
|
||||
<TextField
|
||||
name="addUser"
|
||||
type="email"
|
||||
placeholder="User email"
|
||||
style={{ width: "100%" }}
|
||||
value={addUser}
|
||||
onChange={(ev) => setAddUser(ev.target.value)}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={readOnly}
|
||||
onChange={(event) => setReadOnly(event.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Read only?"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
</ConfirmationDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
@ -1,155 +0,0 @@
|
||||
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from "react";
|
||||
import sjcl from "sjcl";
|
||||
|
||||
import { List, ListItem } from "../widgets/List";
|
||||
|
||||
import IconMemberAdd from "@material-ui/icons/PersonAdd";
|
||||
import VisibilityIcon from "@material-ui/icons/Visibility";
|
||||
|
||||
import AppBarOverride from "../widgets/AppBarOverride";
|
||||
import Container from "../widgets/Container";
|
||||
import LoadingIndicator from "../widgets/LoadingIndicator";
|
||||
import ConfirmationDialog from "../widgets/ConfirmationDialog";
|
||||
|
||||
import JournalMemberAddDialog from "./JournalMemberAddDialog";
|
||||
|
||||
import * as EteSync from "etesync";
|
||||
import { CredentialsData, UserInfoData } from "../store";
|
||||
|
||||
import { SyncInfoJournal } from "../SyncGate";
|
||||
|
||||
interface PropsType {
|
||||
etesync: CredentialsData;
|
||||
syncJournal: SyncInfoJournal;
|
||||
userInfo: UserInfoData;
|
||||
}
|
||||
|
||||
export default function JournalMembers(props: PropsType) {
|
||||
const [members, setMembers] = React.useState<EteSync.JournalMemberJson[] | null>(null);
|
||||
const [revokeUser, setRevokeUser] = React.useState<string | null>(null);
|
||||
const [addMemberOpen, setAddMemberOpen] = React.useState(false);
|
||||
|
||||
function fetchMembers() {
|
||||
const { etesync, syncJournal } = props;
|
||||
const info = syncJournal.collection;
|
||||
|
||||
const creds = etesync.credentials;
|
||||
const apiBase = etesync.serviceApiUrl;
|
||||
const journalMembersManager = new EteSync.JournalMembersManager(creds, apiBase, info.uid);
|
||||
journalMembersManager.list().then((members) => {
|
||||
setMembers(members);
|
||||
});
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchMembers();
|
||||
}, []);
|
||||
|
||||
function onRevokeRequest(user: string) {
|
||||
setRevokeUser(user);
|
||||
}
|
||||
|
||||
function onRevokeDo() {
|
||||
const { etesync, syncJournal } = props;
|
||||
const info = syncJournal.collection;
|
||||
|
||||
const creds = etesync.credentials;
|
||||
const apiBase = etesync.serviceApiUrl;
|
||||
const journalMembersManager = new EteSync.JournalMembersManager(creds, apiBase, info.uid);
|
||||
journalMembersManager.delete({ user: revokeUser!, key: "" }).then(() => {
|
||||
fetchMembers();
|
||||
});
|
||||
setRevokeUser(null);
|
||||
}
|
||||
|
||||
function onMemberAdd(user: string, publicKey: string, readOnly: boolean) {
|
||||
const { etesync, syncJournal, userInfo } = props;
|
||||
const journal = syncJournal.journal;
|
||||
const derived = props.etesync.encryptionKey;
|
||||
|
||||
const keyPair = userInfo.getKeyPair(userInfo.getCryptoManager(derived));
|
||||
const cryptoManager = journal.getCryptoManager(derived, keyPair);
|
||||
|
||||
const pubkeyBytes = sjcl.codec.bytes.fromBits(sjcl.codec.base64.toBits(publicKey));
|
||||
const encryptedKey = sjcl.codec.base64.fromBits(sjcl.codec.bytes.toBits(cryptoManager.getEncryptedKey(keyPair, pubkeyBytes)));
|
||||
|
||||
const creds = etesync.credentials;
|
||||
const apiBase = etesync.serviceApiUrl;
|
||||
const journalMembersManager = new EteSync.JournalMembersManager(creds, apiBase, journal.uid);
|
||||
journalMembersManager.create({ user, key: encryptedKey, readOnly }).then(() => {
|
||||
fetchMembers();
|
||||
});
|
||||
setAddMemberOpen(false);
|
||||
}
|
||||
|
||||
const { syncJournal } = props;
|
||||
|
||||
const info = syncJournal.collection;
|
||||
const sharingAllowed = syncJournal.journal.version > 1;
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppBarOverride title={`${info.displayName} - Members`} />
|
||||
<Container style={{ maxWidth: "30rem" }}>
|
||||
{members ?
|
||||
<List>
|
||||
<ListItem rightIcon={<IconMemberAdd />} onClick={() => setAddMemberOpen(true)}>
|
||||
Add member
|
||||
</ListItem>
|
||||
{(members.length > 0 ?
|
||||
members.map((member) => (
|
||||
<ListItem
|
||||
key={member.user}
|
||||
onClick={() => onRevokeRequest(member.user)}
|
||||
rightIcon={(member.readOnly) ? (<VisibilityIcon />) : undefined}
|
||||
>
|
||||
{member.user}
|
||||
</ListItem>
|
||||
))
|
||||
:
|
||||
<ListItem>
|
||||
No members
|
||||
</ListItem>
|
||||
)}
|
||||
</List>
|
||||
:
|
||||
<LoadingIndicator />
|
||||
}
|
||||
</Container>
|
||||
<ConfirmationDialog
|
||||
title="Remove member"
|
||||
labelOk="OK"
|
||||
open={revokeUser !== null}
|
||||
onOk={onRevokeDo}
|
||||
onCancel={() => setRevokeUser(null)}
|
||||
>
|
||||
Would you like to revoke {revokeUser}'s access?<br />
|
||||
Please be advised that a malicious user would potentially be able to retain access to encryption keys. Please refer to the FAQ for more information.
|
||||
</ConfirmationDialog>
|
||||
|
||||
{addMemberOpen &&
|
||||
(sharingAllowed ?
|
||||
<JournalMemberAddDialog
|
||||
etesync={props.etesync}
|
||||
info={info}
|
||||
onOk={onMemberAdd}
|
||||
onClose={() => setAddMemberOpen(false)}
|
||||
/>
|
||||
:
|
||||
<ConfirmationDialog
|
||||
title="Now Allowed"
|
||||
labelOk="OK"
|
||||
open
|
||||
onOk={() => setAddMemberOpen(false)}
|
||||
onClose={() => setAddMemberOpen(false)}
|
||||
>
|
||||
Sharing of old-style journals is not allowed. In order to share this journal, create a new one, and copy its contents over using the "import" dialog. If you are experiencing any issues, please contact support.
|
||||
</ConfirmationDialog>
|
||||
)
|
||||
}
|
||||
</>
|
||||
);
|
||||
}
|
@ -1,103 +0,0 @@
|
||||
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from "react";
|
||||
import { History } from "history";
|
||||
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import IconButton from "@material-ui/core/IconButton";
|
||||
import IconAdd from "@material-ui/icons/Add";
|
||||
import ContactsIcon from "@material-ui/icons/Contacts";
|
||||
import CalendarTodayIcon from "@material-ui/icons/CalendarToday";
|
||||
import FormatListBulletedIcon from "@material-ui/icons/FormatListBulleted";
|
||||
|
||||
import { List, ListItem } from "../widgets/List";
|
||||
|
||||
import AppBarOverride from "../widgets/AppBarOverride";
|
||||
import Container from "../widgets/Container";
|
||||
|
||||
import { routeResolver } from "../App";
|
||||
|
||||
import { JournalsData, UserInfoData, CredentialsData } from "../store";
|
||||
import ColorBox from "../widgets/ColorBox";
|
||||
import { colorIntToHtml } from "../journal-processors";
|
||||
|
||||
interface PropsType {
|
||||
etesync: CredentialsData;
|
||||
journals: JournalsData;
|
||||
userInfo: UserInfoData;
|
||||
history: History;
|
||||
}
|
||||
|
||||
export default function JournalsList(props: PropsType) {
|
||||
const derived = props.etesync.encryptionKey;
|
||||
|
||||
function journalClicked(journalUid: string) {
|
||||
props.history.push(routeResolver.getRoute("journals._id", { journalUid }));
|
||||
}
|
||||
|
||||
const journalMap = props.journals.reduce(
|
||||
(ret, journal) => {
|
||||
const userInfo = props.userInfo;
|
||||
const keyPair = userInfo.getKeyPair(userInfo.getCryptoManager(derived));
|
||||
const cryptoManager = journal.getCryptoManager(derived, keyPair);
|
||||
const info = journal.getInfo(cryptoManager);
|
||||
let colorBox: React.ReactElement | undefined;
|
||||
switch (info.type) {
|
||||
case "CALENDAR":
|
||||
case "TASKS":
|
||||
colorBox = (
|
||||
<ColorBox size={24} color={colorIntToHtml(info.color)} />
|
||||
);
|
||||
break;
|
||||
}
|
||||
ret[info.type] = ret[info.type] || [];
|
||||
ret[info.type].push(
|
||||
<ListItem key={journal.uid} rightIcon={colorBox} insetChildren
|
||||
onClick={() => journalClicked(journal.uid)}>
|
||||
{info.displayName}
|
||||
</ListItem>
|
||||
);
|
||||
|
||||
return ret;
|
||||
},
|
||||
{
|
||||
CALENDAR: [],
|
||||
ADDRESS_BOOK: [],
|
||||
TASKS: [],
|
||||
});
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<AppBarOverride title="Journals">
|
||||
<IconButton
|
||||
component={Link}
|
||||
title="New"
|
||||
{...{ to: routeResolver.getRoute("journals.new") }}
|
||||
>
|
||||
<IconAdd />
|
||||
</IconButton>
|
||||
</AppBarOverride>
|
||||
<List>
|
||||
<ListItem
|
||||
primaryText="Address Books"
|
||||
leftIcon={<ContactsIcon />}
|
||||
nestedItems={journalMap.ADDRESS_BOOK}
|
||||
/>
|
||||
|
||||
<ListItem
|
||||
primaryText="Calendars"
|
||||
leftIcon={<CalendarTodayIcon />}
|
||||
nestedItems={journalMap.CALENDAR}
|
||||
/>
|
||||
|
||||
<ListItem
|
||||
primaryText="Tasks"
|
||||
leftIcon={<FormatListBulletedIcon />}
|
||||
nestedItems={journalMap.TASKS}
|
||||
/>
|
||||
</List>
|
||||
</Container>
|
||||
);
|
||||
}
|
@ -1,96 +0,0 @@
|
||||
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import ContactsIcon from "@material-ui/icons/Contacts";
|
||||
import CalendarTodayIcon from "@material-ui/icons/CalendarToday";
|
||||
import FormatListBulletedIcon from "@material-ui/icons/FormatListBulleted";
|
||||
|
||||
import { List, ListItem } from "../widgets/List";
|
||||
|
||||
import AppBarOverride from "../widgets/AppBarOverride";
|
||||
import Container from "../widgets/Container";
|
||||
|
||||
import { JournalsData, UserInfoData, CredentialsData } from "../store";
|
||||
import ImportDialog from "./ImportDialog";
|
||||
import { SyncInfo, SyncInfoJournal } from "../SyncGate";
|
||||
import { colorIntToHtml } from "../journal-processors";
|
||||
import ColorBox from "../widgets/ColorBox";
|
||||
|
||||
interface PropsType {
|
||||
etesync: CredentialsData;
|
||||
journals: JournalsData;
|
||||
userInfo: UserInfoData;
|
||||
syncInfo: SyncInfo;
|
||||
}
|
||||
|
||||
export default function JournalsList(props: PropsType) {
|
||||
const [selectedJournal, setSelectedJournal] = React.useState<SyncInfoJournal>();
|
||||
const derived = props.etesync.encryptionKey;
|
||||
|
||||
const journalMap = props.journals.reduce(
|
||||
(ret, journal) => {
|
||||
const userInfo = props.userInfo;
|
||||
const keyPair = userInfo.getKeyPair(userInfo.getCryptoManager(derived));
|
||||
const cryptoManager = journal.getCryptoManager(derived, keyPair);
|
||||
const info = journal.getInfo(cryptoManager);
|
||||
let colorBox: React.ReactElement | undefined;
|
||||
switch (info.type) {
|
||||
case "CALENDAR":
|
||||
case "TASKS":
|
||||
colorBox = (
|
||||
<ColorBox size={24} color={colorIntToHtml(info.color)} />
|
||||
);
|
||||
break;
|
||||
}
|
||||
ret[info.type] = ret[info.type] || [];
|
||||
ret[info.type].push(
|
||||
<ListItem key={journal.uid} rightIcon={colorBox} insetChildren
|
||||
onClick={() => setSelectedJournal(props.syncInfo.get(journal.uid))}>
|
||||
{info.displayName} ({journal.uid.slice(0, 5)})
|
||||
</ListItem>
|
||||
);
|
||||
|
||||
return ret;
|
||||
},
|
||||
{
|
||||
CALENDAR: [],
|
||||
ADDRESS_BOOK: [],
|
||||
TASKS: [],
|
||||
});
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<AppBarOverride title="Journals Import" />
|
||||
<List>
|
||||
<ListItem
|
||||
primaryText="Address Books"
|
||||
leftIcon={<ContactsIcon />}
|
||||
nestedItems={journalMap.ADDRESS_BOOK}
|
||||
/>
|
||||
|
||||
<ListItem
|
||||
primaryText="Calendars"
|
||||
leftIcon={<CalendarTodayIcon />}
|
||||
nestedItems={journalMap.CALENDAR}
|
||||
/>
|
||||
|
||||
<ListItem
|
||||
primaryText="Tasks"
|
||||
leftIcon={<FormatListBulletedIcon />}
|
||||
nestedItems={journalMap.TASKS}
|
||||
/>
|
||||
</List>
|
||||
{selectedJournal && (
|
||||
<ImportDialog
|
||||
etesync={props.etesync}
|
||||
userInfo={props.userInfo}
|
||||
syncJournal={selectedJournal}
|
||||
open={!!selectedJournal}
|
||||
onClose={() => setSelectedJournal(undefined)}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
@ -1,181 +0,0 @@
|
||||
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from "react";
|
||||
import { Location, History } from "history";
|
||||
import { Route, Switch } from "react-router";
|
||||
|
||||
import Journal from "./Journal";
|
||||
import JournalEdit from "./JournalEdit";
|
||||
import JournalMembers from "./JournalMembers";
|
||||
import JournalsList from "./JournalsList";
|
||||
import JournalsListImport from "./JournalsListImport";
|
||||
|
||||
import { routeResolver } from "../App";
|
||||
|
||||
import { store, JournalsData, UserInfoData, CredentialsData } from "../store";
|
||||
import { addJournal, deleteJournal, updateJournal } from "../store/actions";
|
||||
import { SyncInfo } from "../SyncGate";
|
||||
|
||||
import * as EteSync from "etesync";
|
||||
|
||||
class Journals extends React.PureComponent {
|
||||
public props: {
|
||||
etesync: CredentialsData;
|
||||
journals: JournalsData;
|
||||
userInfo: UserInfoData;
|
||||
syncInfo: SyncInfo;
|
||||
history: History;
|
||||
location: Location;
|
||||
};
|
||||
|
||||
constructor(props: any) {
|
||||
super(props);
|
||||
this.onCancel = this.onCancel.bind(this);
|
||||
this.onItemDelete = this.onItemDelete.bind(this);
|
||||
this.onItemSave = this.onItemSave.bind(this);
|
||||
}
|
||||
|
||||
public render() {
|
||||
return (
|
||||
<Switch>
|
||||
<Route
|
||||
path={routeResolver.getRoute("journals")}
|
||||
exact
|
||||
render={({ history }) => (
|
||||
<>
|
||||
<JournalsList
|
||||
userInfo={this.props.userInfo}
|
||||
etesync={this.props.etesync}
|
||||
journals={this.props.journals}
|
||||
history={history}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
path={routeResolver.getRoute("journals.import")}
|
||||
exact
|
||||
render={() => (
|
||||
<>
|
||||
<JournalsListImport
|
||||
userInfo={this.props.userInfo}
|
||||
etesync={this.props.etesync}
|
||||
journals={this.props.journals}
|
||||
syncInfo={this.props.syncInfo}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
path={routeResolver.getRoute("journals.new")}
|
||||
render={() => (
|
||||
<JournalEdit
|
||||
syncInfo={this.props.syncInfo}
|
||||
onSave={this.onItemSave}
|
||||
onDelete={this.onItemDelete}
|
||||
onCancel={this.onCancel}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
path={routeResolver.getRoute("journals._id")}
|
||||
render={({ match }) => {
|
||||
const journalUid = match.params.journalUid;
|
||||
|
||||
const syncJournal = this.props.syncInfo.get(journalUid);
|
||||
if (!syncJournal) {
|
||||
return (<div>Journal not found!</div>);
|
||||
}
|
||||
|
||||
const isOwner = syncJournal.journal.owner === this.props.etesync.credentials.email;
|
||||
|
||||
const collectionInfo = syncJournal.collection;
|
||||
return (
|
||||
<Switch>
|
||||
<Route
|
||||
path={routeResolver.getRoute("journals._id.edit")}
|
||||
render={() => (
|
||||
<JournalEdit
|
||||
syncInfo={this.props.syncInfo}
|
||||
item={collectionInfo}
|
||||
onSave={this.onItemSave}
|
||||
onDelete={this.onItemDelete}
|
||||
onCancel={this.onCancel}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
path={routeResolver.getRoute("journals._id.members")}
|
||||
render={() => (
|
||||
<JournalMembers
|
||||
etesync={this.props.etesync}
|
||||
syncJournal={syncJournal}
|
||||
userInfo={this.props.userInfo}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Route
|
||||
path={routeResolver.getRoute("journals._id")}
|
||||
render={() => (
|
||||
<Journal
|
||||
etesync={this.props.etesync}
|
||||
userInfo={this.props.userInfo}
|
||||
syncInfo={this.props.syncInfo}
|
||||
syncJournal={syncJournal}
|
||||
isOwner={isOwner}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
public onItemSave(info: EteSync.CollectionInfo, originalInfo?: EteSync.CollectionInfo) {
|
||||
const syncJournal = this.props.syncInfo.get(info.uid);
|
||||
|
||||
const derived = this.props.etesync.encryptionKey;
|
||||
const userInfo = this.props.userInfo;
|
||||
const existingJournal = (syncJournal) ? syncJournal.journal.serialize() : { uid: info.uid };
|
||||
const journal = new EteSync.Journal(existingJournal);
|
||||
const keyPair = userInfo.getKeyPair(userInfo.getCryptoManager(derived));
|
||||
const cryptoManager = journal.getCryptoManager(derived, keyPair);
|
||||
journal.setInfo(cryptoManager, info);
|
||||
|
||||
if (originalInfo) {
|
||||
store.dispatch<any>(updateJournal(this.props.etesync, journal)).then(() =>
|
||||
this.props.history.goBack()
|
||||
);
|
||||
} else {
|
||||
store.dispatch<any>(addJournal(this.props.etesync, journal)).then(() =>
|
||||
this.props.history.goBack()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public onItemDelete(info: EteSync.CollectionInfo) {
|
||||
const syncJournal = this.props.syncInfo.get(info.uid);
|
||||
|
||||
const derived = this.props.etesync.encryptionKey;
|
||||
const userInfo = this.props.userInfo;
|
||||
const existingJournal = (syncJournal) ? syncJournal.journal.serialize() : { uid: info.uid };
|
||||
const journal = new EteSync.Journal(existingJournal);
|
||||
const keyPair = userInfo.getKeyPair(userInfo.getCryptoManager(derived));
|
||||
const cryptoManager = journal.getCryptoManager(derived, keyPair);
|
||||
journal.setInfo(cryptoManager, info);
|
||||
|
||||
store.dispatch<any>(deleteJournal(this.props.etesync, journal)).then(() =>
|
||||
this.props.history.push(routeResolver.getRoute("journals"))
|
||||
);
|
||||
}
|
||||
|
||||
public onCancel() {
|
||||
this.props.history.goBack();
|
||||
}
|
||||
}
|
||||
|
||||
export default Journals;
|
@ -1,68 +0,0 @@
|
||||
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from "react";
|
||||
import { Route, Switch, withRouter } from "react-router";
|
||||
|
||||
import * as EteSync from "etesync";
|
||||
|
||||
import { routeResolver } from "../App";
|
||||
|
||||
import { History } from "history";
|
||||
import { SyncInfo } from "../SyncGate";
|
||||
|
||||
function objValues(obj: any) {
|
||||
return Object.keys(obj).map((x) => obj[x]);
|
||||
}
|
||||
|
||||
export function journalView(JournalList: any, JournalItem: any) {
|
||||
return withRouter(class extends React.PureComponent {
|
||||
public props: {
|
||||
journal: EteSync.Journal;
|
||||
entries: {[key: string]: any};
|
||||
syncInfo?: SyncInfo;
|
||||
history?: History;
|
||||
};
|
||||
|
||||
constructor(props: any) {
|
||||
super(props);
|
||||
this.itemClicked = this.itemClicked.bind(this);
|
||||
}
|
||||
|
||||
public itemClicked(contact: any) {
|
||||
const uid = contact.uid;
|
||||
|
||||
this.props.history!.push(
|
||||
routeResolver.getRoute("journals._id.items._id", { journalUid: this.props.journal.uid, itemUid: encodeURIComponent(uid) }));
|
||||
}
|
||||
|
||||
public render() {
|
||||
const items = this.props.entries;
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Route
|
||||
path={routeResolver.getRoute("journals._id")}
|
||||
exact
|
||||
render={() => (
|
||||
<JournalList syncInfo={this.props.syncInfo} entries={objValues(items)} onItemClick={this.itemClicked} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={routeResolver.getRoute("journals._id.items._id")}
|
||||
exact
|
||||
render={({ match }) => {
|
||||
|
||||
return (
|
||||
<JournalItem item={items[`${match.params.journalUid}|${decodeURIComponent(match.params.itemUid)}`]} />
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default journalView;
|
@ -1,261 +0,0 @@
|
||||
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as Immutable from "immutable";
|
||||
|
||||
import { AutoSizer, List as VirtualizedList } from "react-virtualized";
|
||||
|
||||
import * as React from "react";
|
||||
import { List, ListItem } from "../widgets/List";
|
||||
import Dialog from "@material-ui/core/Dialog";
|
||||
import DialogTitle from "@material-ui/core/DialogTitle";
|
||||
import DialogContent from "@material-ui/core/DialogContent";
|
||||
import DialogActions from "@material-ui/core/DialogActions";
|
||||
import Button from "@material-ui/core/Button";
|
||||
import IconAdd from "@material-ui/icons/Add";
|
||||
import IconDelete from "@material-ui/icons/Delete";
|
||||
import IconEdit from "@material-ui/icons/Edit";
|
||||
import IconError from "@material-ui/icons/Error";
|
||||
|
||||
import { TaskType, EventType, ContactType, parseString } from "../pim-types";
|
||||
|
||||
import * as EteSync from "etesync";
|
||||
import LoadingIndicator from "../widgets/LoadingIndicator";
|
||||
import { useCredentials } from "../login";
|
||||
import { createJournalEntry } from "../etesync-helpers";
|
||||
import { useSelector, useDispatch } from "react-redux";
|
||||
import { StoreState } from "../store";
|
||||
import { addEntries } from "../store/actions";
|
||||
|
||||
interface RollbackToHereDialogPropsType {
|
||||
journal: EteSync.Journal;
|
||||
entries: Immutable.List<EteSync.SyncEntry>;
|
||||
entryUid: string;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function RollbackToHereDialog(props: RollbackToHereDialogPropsType) {
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const etesync = useCredentials()!;
|
||||
const dispatch = useDispatch();
|
||||
const userInfo = useSelector((state: StoreState) => state.cache.userInfo);
|
||||
|
||||
async function go() {
|
||||
setLoading(true);
|
||||
|
||||
const changes = new Map<string, EteSync.SyncEntry>();
|
||||
|
||||
for (const entry of props.entries.reverse()) {
|
||||
const comp = parseString(entry.content);
|
||||
const itemComp = comp.getFirstSubcomponent("vevent") ?? comp.getFirstSubcomponent("vtodo") ?? comp;
|
||||
const itemUid = itemComp.getFirstPropertyValue("uid");
|
||||
|
||||
if (itemUid && !changes.has(itemUid)) {
|
||||
changes.set(itemUid, entry);
|
||||
}
|
||||
|
||||
if (entry.uid === props.entryUid) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const last = props.entries.last(null);
|
||||
const lastUid = last?.uid ? last.uid : null;
|
||||
|
||||
// XXX implement chunked push most likely...
|
||||
let prevUid = lastUid;
|
||||
const journalItems = [];
|
||||
for (const syncEntry of changes.values()) {
|
||||
if (syncEntry.action === EteSync.SyncEntryAction.Delete) {
|
||||
const ret = createJournalEntry(etesync, userInfo, props.journal, prevUid, EteSync.SyncEntryAction.Add, syncEntry.content);
|
||||
journalItems.push(ret);
|
||||
|
||||
prevUid = ret.uid;
|
||||
}
|
||||
}
|
||||
|
||||
if (journalItems.length > 0) {
|
||||
await dispatch<any>(
|
||||
addEntries(etesync, props.journal.uid, journalItems, lastUid)
|
||||
);
|
||||
}
|
||||
|
||||
props.onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={props.open}
|
||||
onClose={props.onClose}
|
||||
>
|
||||
<DialogTitle>
|
||||
Recover items
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
{loading ? (
|
||||
<LoadingIndicator style={{ display: "block", margin: "auto" }} />
|
||||
) : (
|
||||
<p>
|
||||
This function restores all of the deleted items that happened after this change entry. It will not modify any items that haven't been changed since the item was deleted.
|
||||
</p>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
color="primary"
|
||||
disabled={loading}
|
||||
onClick={props.onClose}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
disabled={loading}
|
||||
onClick={go}
|
||||
>
|
||||
Go
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface PropsType {
|
||||
journal: EteSync.Journal;
|
||||
entries: Immutable.List<EteSync.SyncEntry>;
|
||||
uid?: string;
|
||||
}
|
||||
|
||||
export default function JournalEntries(props: PropsType) {
|
||||
const [dialog, setDialog] = React.useState<EteSync.SyncEntry>();
|
||||
const [rollbackDialogId, setRollbackDialogId] = React.useState<string>();
|
||||
|
||||
if (props.journal === undefined) {
|
||||
return (<div>Loading</div>);
|
||||
}
|
||||
|
||||
const rowRenderer = (params: { index: number, key: string, style: React.CSSProperties }) => {
|
||||
const { key, index, style } = params;
|
||||
// eslint-disable-next-line react/prop-types
|
||||
const syncEntry = props.entries.get(props.entries.size - index - 1)!;
|
||||
let comp;
|
||||
try {
|
||||
comp = parseString(syncEntry.content);
|
||||
} catch (e) {
|
||||
const icon = (<IconError style={{ color: "red" }} />);
|
||||
return (
|
||||
<ListItem
|
||||
key={key}
|
||||
style={style}
|
||||
leftIcon={icon}
|
||||
primaryText="Failed parsing item"
|
||||
secondaryText="Unknown"
|
||||
onClick={() => setDialog(syncEntry)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let icon;
|
||||
if (syncEntry.action === EteSync.SyncEntryAction.Add) {
|
||||
icon = (<IconAdd style={{ color: "#16B14B" }} />);
|
||||
} else if (syncEntry.action === EteSync.SyncEntryAction.Change) {
|
||||
icon = (<IconEdit style={{ color: "#FEB115" }} />);
|
||||
} else if (syncEntry.action === EteSync.SyncEntryAction.Delete) {
|
||||
icon = (<IconDelete style={{ color: "#F20C0C" }} />);
|
||||
}
|
||||
|
||||
let name;
|
||||
let uid;
|
||||
if (comp.name === "vcalendar") {
|
||||
if (EventType.isEvent(comp)) {
|
||||
const vevent = EventType.fromVCalendar(comp);
|
||||
name = vevent.summary;
|
||||
uid = vevent.uid;
|
||||
} else {
|
||||
const vtodo = TaskType.fromVCalendar(comp);
|
||||
name = vtodo.summary;
|
||||
uid = vtodo.uid;
|
||||
}
|
||||
} else if (comp.name === "vcard") {
|
||||
const vcard = new ContactType(comp);
|
||||
name = vcard.fn;
|
||||
uid = vcard.uid;
|
||||
} else {
|
||||
name = "Error processing entry";
|
||||
uid = "";
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react/prop-types
|
||||
if (props.uid && (props.uid !== uid)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
<ListItem
|
||||
key={key}
|
||||
style={style}
|
||||
leftIcon={icon}
|
||||
primaryText={name}
|
||||
secondaryText={uid}
|
||||
onClick={() => setDialog(syncEntry)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<RollbackToHereDialog
|
||||
journal={props.journal}
|
||||
entries={props.entries}
|
||||
entryUid={rollbackDialogId!}
|
||||
open={!!rollbackDialogId}
|
||||
onClose={() => setRollbackDialogId(undefined)}
|
||||
/>
|
||||
<Dialog
|
||||
open={dialog !== undefined}
|
||||
onClose={() => setDialog(undefined)}
|
||||
>
|
||||
<DialogTitle>
|
||||
Raw Content
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<div>Entry UID: <pre className="d-inline-block">{dialog?.uid}</pre></div>
|
||||
<div>Content:
|
||||
<pre>{dialog?.content}</pre>
|
||||
</div>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
setDialog(undefined);
|
||||
setRollbackDialogId(dialog?.uid);
|
||||
}}
|
||||
>
|
||||
Recover items until here
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={() => setDialog(undefined)}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
<List style={{ height: "calc(100vh - 300px)" }}>
|
||||
<AutoSizer>
|
||||
{({ height, width }) => (
|
||||
<VirtualizedList
|
||||
width={width}
|
||||
height={height}
|
||||
rowCount={props.entries.size}
|
||||
rowHeight={56}
|
||||
rowRenderer={rowRenderer}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</List>
|
||||
</div>
|
||||
);
|
||||
}
|
Loading…
Reference in New Issue