Migration: add a tool to migrate to etesync 2.0.
parent
5a5c777619
commit
f579f3fcb0
@ -0,0 +1,380 @@
|
|||||||
|
// SPDX-FileCopyrightText: © 2017 EteSync Authors
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as EteSync from "etesync";
|
||||||
|
import * as Etebase from "etebase";
|
||||||
|
import Button from "@material-ui/core/Button";
|
||||||
|
import TextField from "@material-ui/core/TextField";
|
||||||
|
import Switch from "@material-ui/core/Switch";
|
||||||
|
import { Map as ImmutableMap } from "immutable";
|
||||||
|
|
||||||
|
import ContactsIcon from "@material-ui/icons/Contacts";
|
||||||
|
import CalendarTodayIcon from "@material-ui/icons/CalendarToday";
|
||||||
|
import FormatListBulletedIcon from "@material-ui/icons/FormatListBulleted";
|
||||||
|
|
||||||
|
import Container from "./widgets/Container";
|
||||||
|
import { useSelector } from "react-redux";
|
||||||
|
import { StoreState, CredentialsData, UserInfoData } from "./store";
|
||||||
|
import AppBarOverride from "./widgets/AppBarOverride";
|
||||||
|
import ColorBox from "./widgets/ColorBox";
|
||||||
|
import { List, ListItem } from "./widgets/List";
|
||||||
|
import { colorIntToHtml } from "./journal-processors";
|
||||||
|
import { Checkbox, FormGroup, FormControlLabel, CircularProgress } from "@material-ui/core";
|
||||||
|
import Alert from "@material-ui/lab/Alert";
|
||||||
|
import { arrayToChunkIterator } from "./helpers";
|
||||||
|
import { ContactType, EventType, TaskType, PimType } from "./pim-types";
|
||||||
|
|
||||||
|
interface PropsType {
|
||||||
|
etesync: CredentialsData;
|
||||||
|
userInfo: UserInfoData;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FormErrors {
|
||||||
|
errorEmail?: string;
|
||||||
|
errorPassword?: string;
|
||||||
|
errorServer?: string;
|
||||||
|
|
||||||
|
errorGeneral?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MigrateV2(props: PropsType) {
|
||||||
|
const [wantedJournals, setWantedJournals] = React.useState<ImmutableMap<string, EteSync.Journal>>(ImmutableMap({}));
|
||||||
|
const [username, setUsername] = React.useState("");
|
||||||
|
const [password, setPassword] = React.useState("");
|
||||||
|
const [server, setServer] = React.useState("");
|
||||||
|
const [showAdvanced, setShowAdvanced] = React.useState(false);
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
const [progress, setProgress] = React.useState("");
|
||||||
|
const [errors, setErrors] = React.useState<FormErrors>({});
|
||||||
|
const journals = useSelector((state: StoreState) => state.cache.journals!);
|
||||||
|
const journalEntries = useSelector((state: StoreState) => state.cache.entries);
|
||||||
|
const derived = props.etesync.encryptionKey;
|
||||||
|
|
||||||
|
const decryptedJournals = React.useMemo(() => {
|
||||||
|
return journals.map((journal) => {
|
||||||
|
const userInfo = props.userInfo;
|
||||||
|
const keyPair = userInfo.getKeyPair(userInfo.getCryptoManager(derived));
|
||||||
|
const cryptoManager = journal.getCryptoManager(derived, keyPair);
|
||||||
|
const info = journal.getInfo(cryptoManager);
|
||||||
|
return { journal, info };
|
||||||
|
});
|
||||||
|
}, [props.userInfo, journals, derived]);
|
||||||
|
|
||||||
|
const journalClicked = React.useCallback((journal: EteSync.Journal) => {
|
||||||
|
if (wantedJournals.has(journal.uid)) {
|
||||||
|
setWantedJournals(wantedJournals.remove(journal.uid));
|
||||||
|
} else {
|
||||||
|
setWantedJournals(wantedJournals.set(journal.uid, journal));
|
||||||
|
}
|
||||||
|
}, [wantedJournals]);
|
||||||
|
|
||||||
|
const journalMap = React.useMemo(() => {
|
||||||
|
return decryptedJournals.reduce(
|
||||||
|
(ret, { journal, info }) => {
|
||||||
|
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)}>
|
||||||
|
<Checkbox
|
||||||
|
checked={wantedJournals.has(journal.uid)}
|
||||||
|
/>
|
||||||
|
{info.displayName}
|
||||||
|
</ListItem>
|
||||||
|
);
|
||||||
|
|
||||||
|
return ret;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
CALENDAR: [],
|
||||||
|
ADDRESS_BOOK: [],
|
||||||
|
TASKS: [],
|
||||||
|
});
|
||||||
|
}, [decryptedJournals, journalClicked]);
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
form: {
|
||||||
|
},
|
||||||
|
forgotPassword: {
|
||||||
|
paddingTop: 20,
|
||||||
|
},
|
||||||
|
textField: {
|
||||||
|
marginTop: 20,
|
||||||
|
},
|
||||||
|
submit: {
|
||||||
|
marginTop: 40,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleInputChange(func: (value: string) => void) {
|
||||||
|
return (event: React.ChangeEvent<any>) => {
|
||||||
|
func(event.target.value);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
setLoading(true);
|
||||||
|
setProgress("");
|
||||||
|
try {
|
||||||
|
const errors: FormErrors = {};
|
||||||
|
const fieldRequired = "This field is required!";
|
||||||
|
if (!username) {
|
||||||
|
errors.errorEmail = fieldRequired;
|
||||||
|
}
|
||||||
|
if (!password) {
|
||||||
|
errors.errorPassword = fieldRequired;
|
||||||
|
}
|
||||||
|
if (showAdvanced && !server.startsWith("http")) {
|
||||||
|
errors.errorServer = "Server URI must start with http/https://";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(errors).length) {
|
||||||
|
setErrors(errors);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
setErrors({});
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverUrl = (showAdvanced) ? server : undefined;
|
||||||
|
let malformed = 0;
|
||||||
|
|
||||||
|
setProgress("Logging into EteSync 2.0 account");
|
||||||
|
const etebase = await Etebase.Account.login(username, password, serverUrl);
|
||||||
|
const colMgr = etebase.getCollectionManager();
|
||||||
|
|
||||||
|
const { etesync, userInfo } = props;
|
||||||
|
const derived = etesync.encryptionKey;
|
||||||
|
const userInfoCryptoManager = userInfo.getCryptoManager(etesync.encryptionKey);
|
||||||
|
const keyPair = userInfo.getKeyPair(userInfoCryptoManager);
|
||||||
|
|
||||||
|
const now = (new Date()).getTime();
|
||||||
|
|
||||||
|
let i = 1;
|
||||||
|
for (const journal of wantedJournals.values()) {
|
||||||
|
setProgress(`Migrating collection ${i}/${wantedJournals.size}`);
|
||||||
|
console.log("Migrating ", journal.uid);
|
||||||
|
|
||||||
|
const cryptoManager = journal.getCryptoManager(derived, keyPair);
|
||||||
|
const info = journal.getInfo(cryptoManager);
|
||||||
|
const entries = journalEntries.get(journal.uid)!;
|
||||||
|
|
||||||
|
let colType;
|
||||||
|
let parseFunc: (content: string) => PimType;
|
||||||
|
switch (info.type) {
|
||||||
|
case "ADDRESS_BOOK": {
|
||||||
|
colType = "etebase.vcard";
|
||||||
|
parseFunc = ContactType.parse;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "CALENDAR": {
|
||||||
|
colType = "etebase.vevent";
|
||||||
|
parseFunc = EventType.parse;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "TASKS": {
|
||||||
|
colType = "etebase.vtodo";
|
||||||
|
parseFunc = TaskType.parse;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const meta: Etebase.CollectionMetadata = {
|
||||||
|
type: colType,
|
||||||
|
name: info.displayName,
|
||||||
|
description: info.description,
|
||||||
|
color: (info.color !== undefined) ? colorIntToHtml(info.color) : undefined,
|
||||||
|
};
|
||||||
|
const collection = await colMgr.create(meta, "");
|
||||||
|
await colMgr.upload(collection);
|
||||||
|
|
||||||
|
const itemMgr = colMgr.getItemManager(collection);
|
||||||
|
|
||||||
|
const CHUNK_SIZE = 20;
|
||||||
|
const items = new Map<string, Etebase.Item>();
|
||||||
|
let done = 0;
|
||||||
|
let prevUid: string | null = null;
|
||||||
|
for (const chunk of arrayToChunkIterator(entries.toArray(), CHUNK_SIZE)) {
|
||||||
|
setProgress(`Migrating collection ${i}/${wantedJournals.size}\nMigrated entries: ${done}/${entries.size}`);
|
||||||
|
const chunkItems = [];
|
||||||
|
for (const entry of chunk) {
|
||||||
|
console.log("Migrating entry ", entry.uid);
|
||||||
|
done++;
|
||||||
|
const syncEntry = entry.getSyncEntry(cryptoManager, prevUid);
|
||||||
|
prevUid = entry.uid;
|
||||||
|
const pimItem = parseFunc(syncEntry.content);
|
||||||
|
const uid = pimItem.uid;
|
||||||
|
// When we can't set mtime, set to the item's position in the change log so we at least maintain EteSync 1.0 ordering.
|
||||||
|
const mtime = (pimItem.lastModified?.toJSDate())?.getTime() ?? now + done;
|
||||||
|
|
||||||
|
if (!uid) {
|
||||||
|
malformed++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let item = items.get(uid);
|
||||||
|
if (item) {
|
||||||
|
// Existing item
|
||||||
|
item = item._clone(); // We are cloning so we can push multiple revisions at once
|
||||||
|
await item.setContent(syncEntry.content);
|
||||||
|
const meta = await item.getMeta();
|
||||||
|
meta.mtime = mtime;
|
||||||
|
await item.setMeta(meta);
|
||||||
|
} else {
|
||||||
|
// New
|
||||||
|
const meta: Etebase.ItemMetadata = {
|
||||||
|
mtime,
|
||||||
|
name: uid,
|
||||||
|
};
|
||||||
|
item = await itemMgr.create(meta, syncEntry.content);
|
||||||
|
items.set(uid, item);
|
||||||
|
}
|
||||||
|
if (syncEntry.action === EteSync.SyncEntryAction.Delete) {
|
||||||
|
item.delete(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
chunkItems.push(item);
|
||||||
|
}
|
||||||
|
await itemMgr.batch(chunkItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
await etebase.logout();
|
||||||
|
if (malformed > 0) {
|
||||||
|
setProgress(`Done\nIgnored ${malformed} entries (probably safe to ignore)`);
|
||||||
|
} else {
|
||||||
|
setProgress("Done");
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Etebase.UnauthorizedError) {
|
||||||
|
errors.errorGeneral = "Wrong username or password";
|
||||||
|
} else {
|
||||||
|
errors.errorGeneral = e.toString();
|
||||||
|
}
|
||||||
|
setErrors(errors);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let advancedSettings = null;
|
||||||
|
if (showAdvanced) {
|
||||||
|
advancedSettings = (
|
||||||
|
<React.Fragment>
|
||||||
|
<TextField
|
||||||
|
type="url"
|
||||||
|
style={styles.textField}
|
||||||
|
error={!!errors.errorServer}
|
||||||
|
helperText={errors.errorServer}
|
||||||
|
label="Server"
|
||||||
|
name="server"
|
||||||
|
value={server}
|
||||||
|
onChange={handleInputChange(setServer)}
|
||||||
|
/>
|
||||||
|
<br />
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<AppBarOverride title="Migrate to EteSync 2.0" />
|
||||||
|
<p>
|
||||||
|
This tool will help you migrate your data to EteSync 2.0.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
The migration doesn't delete any data. It only copies your data over to the new EteSync 2.0 server. This means that there is no risk of data-loss in the migration.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Please select the collections you would like to migrate, and then enter your EteSync 2.0 credentials and click migrate.
|
||||||
|
</p>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<h3>EteSync 2.0 credentials</h3>
|
||||||
|
<TextField
|
||||||
|
type="text"
|
||||||
|
style={styles.textField}
|
||||||
|
error={!!errors.errorEmail}
|
||||||
|
helperText={errors.errorEmail}
|
||||||
|
label="Username"
|
||||||
|
name="username"
|
||||||
|
value={username}
|
||||||
|
onChange={handleInputChange(setUsername)}
|
||||||
|
/>
|
||||||
|
<br />
|
||||||
|
<TextField
|
||||||
|
type="password"
|
||||||
|
style={styles.textField}
|
||||||
|
error={!!errors.errorPassword}
|
||||||
|
helperText={errors.errorPassword}
|
||||||
|
label="Password"
|
||||||
|
name="password"
|
||||||
|
value={password}
|
||||||
|
onChange={handleInputChange(setPassword)}
|
||||||
|
/>
|
||||||
|
<FormGroup>
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
color="primary"
|
||||||
|
checked={showAdvanced}
|
||||||
|
onChange={() => setShowAdvanced(!showAdvanced)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label="Advanced settings"
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
{advancedSettings}
|
||||||
|
{errors.errorGeneral && (
|
||||||
|
<Alert severity="error" style={styles.textField}>{errors.errorGeneral}</Alert>
|
||||||
|
)}
|
||||||
|
{progress && (
|
||||||
|
<Alert severity="info" style={styles.textField}>{progress}</Alert>
|
||||||
|
)}
|
||||||
|
<div style={styles.submit}>
|
||||||
|
<Button
|
||||||
|
variant="contained"
|
||||||
|
color="secondary"
|
||||||
|
onClick={onSubmit}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<CircularProgress />
|
||||||
|
) : "Migrate"
|
||||||
|
}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
Loading…
Reference in New Issue