From 181ff241da5bb2a9118898c2c62659b6367e8c6e Mon Sep 17 00:00:00 2001 From: Tom Hacohen Date: Thu, 6 Aug 2020 12:18:11 +0300 Subject: [PATCH 01/25] Tasks: autocomplete from all of the tags options (not just hardcoded). Partially fixes #152. --- src/components/Tasks/Sidebar.tsx | 6 ++++-- src/pim-types.ts | 6 +++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/components/Tasks/Sidebar.tsx b/src/components/Tasks/Sidebar.tsx index 677355f..63a8731 100644 --- a/src/components/Tasks/Sidebar.tsx +++ b/src/components/Tasks/Sidebar.tsx @@ -10,7 +10,7 @@ import { setSettings } from "../../store/actions"; import { StoreState } from "../../store"; import { List, ListItem, ListSubheader } from "../../widgets/List"; -import { TaskType } from "../../pim-types"; +import { TaskType, setTaskTags } from "../../pim-types"; interface ListItemPropsType { name: string | null; @@ -49,6 +49,8 @@ export default React.memo(function Sidebar(props: { tasks: TaskType[] }) { tasks.forEach((task) => task.tags.forEach((tag) => { tags.set(tag, (tags.get(tag) ?? 0) + 1); })); + // FIXME: ugly hack to support potential tags. Will be fixed very soon. + setTaskTags(Array.from(tags.keys())); const tagsList = [...tags].sort(([a], [b]) => a.localeCompare(b)).map(([tag, amount]) => ( ); -}); \ No newline at end of file +}); diff --git a/src/pim-types.ts b/src/pim-types.ts index 5bcb29e..3a07fe3 100644 --- a/src/pim-types.ts +++ b/src/pim-types.ts @@ -152,7 +152,11 @@ export enum TaskPriorityType { Low = 9 } -export const TaskTags = ["Work", "Home"]; +export let TaskTags = ["Work", "Home"]; + +export function setTaskTags(tags: string[]) { + TaskTags = tags; +} export class TaskType extends EventType { public static fromVCalendar(comp: ICAL.Component) { From c4d239dba370a5df599377bdfb743f4eb35c5f66 Mon Sep 17 00:00:00 2001 From: Tom Hacohen Date: Sun, 9 Aug 2020 10:24:20 +0300 Subject: [PATCH 02/25] Login: add signup link. --- src/components/LoginForm.tsx | 3 +++ src/constants/index.ts | 1 + 2 files changed, 4 insertions(+) diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx index d9db674..88f3a7c 100644 --- a/src/components/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -181,6 +181,9 @@ class LoginForm extends React.PureComponent { +
+ Don't have an account yet? Sign up here! +
); } diff --git a/src/constants/index.ts b/src/constants/index.ts index 82e5829..bccb355 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -8,5 +8,6 @@ export const sourceCode = "https://github.com/etesync/etesync-web"; export const reportIssue = sourceCode + "/issues"; export const forgotPassword = "https://www.etesync.com/accounts/password/reset/"; +export const signUp = "https://www.etesync.com/accounts/signup/"; export const serviceApiBase = process.env.REACT_APP_DEFAULT_API_PATH || "https://api.etesync.com/"; From 562dc48610425462322c60a91329d12baf81ba55 Mon Sep 17 00:00:00 2001 From: Tom Hacohen Date: Wed, 19 Aug 2020 20:40:27 +0300 Subject: [PATCH 03/25] Fix issue with yearly recurrence. Should fix #158. --- src/widgets/RRule.tsx | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/src/widgets/RRule.tsx b/src/widgets/RRule.tsx index 24859ee..e7b8ddd 100644 --- a/src/widgets/RRule.tsx +++ b/src/widgets/RRule.tsx @@ -85,7 +85,7 @@ const menuItemsWeekDays = weekdays.map((day) => { function makeArray(item: T) { if (item === undefined) { - return item; + return []; } else if (Array.isArray(item)) { return item; } else { @@ -95,16 +95,12 @@ function makeArray(item: T) { function sanitizeByDay(item: string | string[] | undefined) { const ret = makeArray(item); - if (Array.isArray(ret)) { - return (ret as string[]).map((value) => { - if (parseInt(value) === 1) { - return value.substr(1); - } - return value; - }); - } else { - return []; - } + return (ret as string[]).map((value) => { + if (parseInt(value) === 1) { + return value.substr(1); + } + return value; + }); } const styles = { @@ -250,7 +246,7 @@ export default function RRule(props: PropsType) { Months {this.props.collections.map((x) => ( From 824172d1d361aff2d18490a2278609a33e8266da Mon Sep 17 00:00:00 2001 From: Tom Hacohen Date: Tue, 25 Aug 2020 13:19:49 +0300 Subject: [PATCH 11/25] Birthday calendar: prepend '19' to double-digit birth years. Without this we were creating malformed dates. --- src/SyncGate.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SyncGate.tsx b/src/SyncGate.tsx index 6b4f925..f4de697 100644 --- a/src/SyncGate.tsx +++ b/src/SyncGate.tsx @@ -102,7 +102,7 @@ const syncInfoSelector = createSelector( if (bdayTime === {} || bdayTime.month === undefined) { return; } - const year = bdayTime.year ?? 1900; + const year = (bdayTime.year ?? 1900).toString().padStart(4, "19"); // XXX The padding is a hack to fix malformed dates const month = (bdayTime.month + 1).toString().padStart(2, "0"); const day = bdayTime.day.toString().padStart(2, "0"); From f7920df2ed292361b96e5e470b6fc9e24fe986de Mon Sep 17 00:00:00 2001 From: Tom Hacohen Date: Fri, 28 Aug 2020 09:27:29 +0300 Subject: [PATCH 12/25] Tasks: always show searchbar. Partial fix for #156. --- src/components/Tasks/Toolbar.tsx | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/src/components/Tasks/Toolbar.tsx b/src/components/Tasks/Toolbar.tsx index 2046176..75ffa07 100644 --- a/src/components/Tasks/Toolbar.tsx +++ b/src/components/Tasks/Toolbar.tsx @@ -8,7 +8,6 @@ import MoreVertIcon from "@material-ui/icons/MoreVert"; import MenuItem from "@material-ui/core/MenuItem"; import SortIcon from "@material-ui/icons/Sort"; import SearchIcon from "@material-ui/icons/Search"; -import CloseIcon from "@material-ui/icons/Close"; import TextField from "@material-ui/core/TextField"; import { makeStyles } from "@material-ui/core/styles"; import { Transition } from "react-transition-group"; @@ -57,23 +56,16 @@ interface PropsType { export default function Toolbar(props: PropsType) { const { showCompleted, setShowCompleted, searchTerm, setSearchTerm, showHidden, setShowHidden } = props; - const [showSearchField, setShowSearchField] = React.useState(false); const [sortAnchorEl, setSortAnchorEl] = React.useState(null); const [optionsAnchorEl, setOptionsAnchorEl] = React.useState(null); + const showSearchField = true; const classes = useStyles(); const dispatch = useDispatch(); const taskSettings = useSelector((state: StoreState) => state.settings.taskSettings); const { sortBy } = taskSettings; - const toggleSearchField = () => { - if (showSearchField) { - setSearchTerm(""); - } - setShowSearchField(!showSearchField); - }; - const handleSortChange = (sort: string) => { dispatch(setSettings({ taskSettings: { ...taskSettings, sortBy: sort } })); setSortAnchorEl(null); @@ -109,12 +101,6 @@ export default function Toolbar(props: PropsType) { )} -
- - {showSearchField ? : } - -
-
); -} \ No newline at end of file +} From 5a5c777619d194d00c55a21964a698ba700d1945 Mon Sep 17 00:00:00 2001 From: Tom Hacohen Date: Wed, 2 Sep 2020 21:11:30 +0300 Subject: [PATCH 13/25] Deploy script: set -e --- deploy.sh | 2 ++ 1 file changed, 2 insertions(+) diff --git a/deploy.sh b/deploy.sh index 183f750..aa82785 100755 --- a/deploy.sh +++ b/deploy.sh @@ -1,3 +1,5 @@ +set -e + SSH_HOST=client.etesync.com SSH_PORT=22 SSH_USER=etesync From f579f3fcb0068751f28e3ef6879a314c45812f53 Mon Sep 17 00:00:00 2001 From: Tom Hacohen Date: Mon, 31 Aug 2020 18:11:54 +0300 Subject: [PATCH 14/25] Migration: add a tool to migrate to etesync 2.0. --- package.json | 1 + src/App.tsx | 4 +- src/MigrateV2.tsx | 380 ++++++++++++++++++++++++++++++++++++++ src/SyncGate.tsx | 11 ++ src/helpers.tsx | 6 + src/journal-processors.ts | 1 + src/pim-types.ts | 5 + yarn.lock | 32 ++++ 8 files changed, 439 insertions(+), 1 deletion(-) create mode 100644 src/MigrateV2.tsx diff --git a/package.json b/package.json index 5439a05..0b6b565 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "@material-ui/lab": "^4.0.0-alpha.47", "@material-ui/pickers": "^3.2.10", "@material-ui/styles": "^4.6.0", + "etebase": "^0.11.0", "etesync": "^0.3.1", "fuse.js": "^5.0.9-beta", "ical.js": "^1.4.0", diff --git a/src/App.tsx b/src/App.tsx index ad3b508..36dd919 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -89,6 +89,8 @@ export const routeResolver = new RouteResolver({ }, debug: { }, + "migrate-v2": { + }, }); interface AppBarPropsType { @@ -143,7 +145,7 @@ function App(props: PropsType) { function autoRefresh() { if (navigator.onLine && props.credentials && - !(window.location.pathname.match(/.*\/(new|edit|duplicate)$/))) { + !(window.location.pathname.match(/.*\/(new|edit|duplicate)$/) || window.location.pathname.startsWith("/migrate-v2"))) { refresh(); } } diff --git a/src/MigrateV2.tsx b/src/MigrateV2.tsx new file mode 100644 index 0000000..8758a22 --- /dev/null +++ b/src/MigrateV2.tsx @@ -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({})); + 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({}); + 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 = ( + + ); + break; + } + ret[info.type] = ret[info.type] || []; + ret[info.type].push( + journalClicked(journal)}> + + {info.displayName} + + ); + + 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) => { + 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(); + 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 = ( + + +
+
+ ); + } + + return ( + + +

+ This tool will help you migrate your data to EteSync 2.0. +

+

+ 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. +

+

+ Please select the collections you would like to migrate, and then enter your EteSync 2.0 credentials and click migrate. +

+ + } + nestedItems={journalMap.ADDRESS_BOOK} + /> + + } + nestedItems={journalMap.CALENDAR} + /> + + } + nestedItems={journalMap.TASKS} + /> + + +

EteSync 2.0 credentials

+ +
+ + + setShowAdvanced(!showAdvanced)} + /> + } + label="Advanced settings" + /> + + {advancedSettings} + {errors.errorGeneral && ( + {errors.errorGeneral} + )} + {progress && ( + {progress} + )} +
+ +
+
+ ); +} diff --git a/src/SyncGate.tsx b/src/SyncGate.tsx index f4de697..16d9987 100644 --- a/src/SyncGate.tsx +++ b/src/SyncGate.tsx @@ -29,6 +29,7 @@ import { addJournal, fetchAll, fetchEntries, fetchUserInfo, createUserInfo } fro import { syncEntriesToItemMap } from "./journal-processors"; import { ContactType } from "./pim-types"; import { parseDate } from "./helpers"; +import MigrateV2 from "./MigrateV2"; export interface SyncInfoJournal { journal: EteSync.Journal; @@ -293,6 +294,16 @@ export default withRouter(function SyncGate(props: RouteComponentProps<{}> & Pro /> )} /> + ( + + )} + /> ); }); diff --git a/src/helpers.tsx b/src/helpers.tsx index 91da9ea..993b84e 100644 --- a/src/helpers.tsx +++ b/src/helpers.tsx @@ -147,3 +147,9 @@ export function parseDate(prop: ICAL.Property) { return {}; } + +export function* arrayToChunkIterator(arr: T[], size: number) { + for (let i = 0 ; i < arr.length ; i += size) { + yield arr.slice(i, i + size); + } +} diff --git a/src/journal-processors.ts b/src/journal-processors.ts index 9e7f314..cfeaef6 100644 --- a/src/journal-processors.ts +++ b/src/journal-processors.ts @@ -52,6 +52,7 @@ export function colorIntToHtml(color?: number) { } // tslint:disable:no-bitwise + color = color >>> 0; const blue = color & 0xFF; const green = (color >> 8) & 0xFF; const red = (color >> 16) & 0xFF; diff --git a/src/pim-types.ts b/src/pim-types.ts index fd40b33..b196bcd 100644 --- a/src/pim-types.ts +++ b/src/pim-types.ts @@ -12,6 +12,7 @@ export interface PimType { uid: string; toIcal(): string; clone(): PimType; + lastModified: ICAL.Time | undefined; } export function timezoneLoadFromName(timezone: string | null) { @@ -372,6 +373,10 @@ export class ContactType implements PimType { return this.comp.getFirstPropertyValue("bday"); } + get lastModified() { + return this.comp.getFirstPropertyValue("rev"); + } + get group() { const kind = this.comp.getFirstPropertyValue("kind"); return kind in ["group", "organization"]; diff --git a/yarn.lock b/yarn.lock index abb28df..99e7854 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1477,6 +1477,11 @@ call-me-maybe "^1.0.1" glob-to-regexp "^0.3.0" +"@msgpack/msgpack@^1.12.2": + version "1.12.2" + resolved "https://registry.yarnpkg.com/@msgpack/msgpack/-/msgpack-1.12.2.tgz#6a22e99a49b131a8789053d0b0903834552da36f" + integrity sha512-Vwhc3ObxmDZmA5hY8mfsau2rJ4vGPvzbj20QSZ2/E1GDPF61QVyjLfNHak9xmel6pW4heRt3v1fHa6np9Ehfeg== + "@nodelib/fs.stat@^1.1.2": version "1.1.3" resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-1.1.3.tgz#2b5a3ab3f918cca48a8c754c08168e3f03eba61b" @@ -4636,6 +4641,16 @@ etag@~1.8.1: resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= +etebase@^0.11.0: + version "0.11.0" + resolved "https://registry.yarnpkg.com/etebase/-/etebase-0.11.0.tgz#082e6785167957723419c8eac59372b70953a921" + integrity sha512-VZwYf/i9yy89TEm2vR2dREukVcfrLmnRPS6xo5WmOTZHqBXxxMfjXtIKbH9B4xpqT5khzHmKQW6y9qFCxmWzgg== + dependencies: + "@msgpack/msgpack" "^1.12.2" + libsodium-wrappers "0.7.6" + node-fetch "^2.6.0" + urijs "^1.19.1" + etesync@^0.3.1: version "0.3.1" resolved "https://registry.yarnpkg.com/etesync/-/etesync-0.3.1.tgz#2af257d5617679177d93fb8f6bdaf64462aa15e0" @@ -6965,6 +6980,18 @@ levn@^0.3.0, levn@~0.3.0: prelude-ls "~1.1.2" type-check "~0.3.2" +libsodium-wrappers@0.7.6: + version "0.7.6" + resolved "https://registry.yarnpkg.com/libsodium-wrappers/-/libsodium-wrappers-0.7.6.tgz#baed4c16d4bf9610104875ad8a8e164d259d48fb" + integrity sha512-OUO2CWW5bHdLr6hkKLHIKI4raEkZrf3QHkhXsJ1yCh6MZ3JDA7jFD3kCATNquuGSG6MjjPHQIQms0y0gBDzjQg== + dependencies: + libsodium "0.7.6" + +libsodium@0.7.6: + version "0.7.6" + resolved "https://registry.yarnpkg.com/libsodium/-/libsodium-0.7.6.tgz#018b80c5728054817845fbffa554274441bda277" + integrity sha512-hPb/04sEuLcTRdWDtd+xH3RXBihpmbPCsKW/Jtf4PsvdyKh+D6z2D2gvp/5BfoxseP+0FCOg66kE+0oGUE/loQ== + lie@3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/lie/-/lie-3.1.1.tgz#9a436b2cc7746ca59de7a41fa469b3efb76bd87e" @@ -7534,6 +7561,11 @@ no-case@^3.0.3: lower-case "^2.0.1" tslib "^1.10.0" +node-fetch@^2.6.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.0.tgz#e633456386d4aa55863f676a7ab0daa8fdecb0fd" + integrity sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA== + node-forge@0.9.0: version "0.9.0" resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.9.0.tgz#d624050edbb44874adca12bb9a52ec63cb782579" From da6f625a343515aebbe6acf14e813c7dd8e06f9d Mon Sep 17 00:00:00 2001 From: Tom Hacohen Date: Wed, 9 Sep 2020 13:11:08 +0300 Subject: [PATCH 15/25] Migration: use new password widget. --- src/MigrateV2.tsx | 12 ++++++---- src/widgets/PasswordField.tsx | 44 +++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 src/widgets/PasswordField.tsx diff --git a/src/MigrateV2.tsx b/src/MigrateV2.tsx index 8758a22..97dc4e2 100644 --- a/src/MigrateV2.tsx +++ b/src/MigrateV2.tsx @@ -24,6 +24,7 @@ import { Checkbox, FormGroup, FormControlLabel, CircularProgress } from "@materi import Alert from "@material-ui/lab/Alert"; import { arrayToChunkIterator } from "./helpers"; import { ContactType, EventType, TaskType, PimType } from "./pim-types"; +import PasswordField from "./widgets/PasswordField"; interface PropsType { etesync: CredentialsData; @@ -107,7 +108,11 @@ export default function MigrateV2(props: PropsType) { forgotPassword: { paddingTop: 20, }, + alertInfo: { + marginTop: 20, + }, textField: { + width: "20em", marginTop: 20, }, submit: { @@ -333,8 +338,7 @@ export default function MigrateV2(props: PropsType) { onChange={handleInputChange(setUsername)} />
- {advancedSettings} {errors.errorGeneral && ( - {errors.errorGeneral} + {errors.errorGeneral} )} {progress && ( - {progress} + {progress} )}
From 6d224307f881b52bb6b4e0b4bde45834c2108e63 Mon Sep 17 00:00:00 2001 From: Tom Hacohen Date: Wed, 9 Sep 2020 14:03:26 +0300 Subject: [PATCH 17/25] Migration: change it to a multi-step wizard. --- src/MigrateV2.tsx | 394 +++++++++++++++++++++++++++-------------- src/widgets/Wizard.tsx | 63 +++++++ 2 files changed, 324 insertions(+), 133 deletions(-) create mode 100644 src/widgets/Wizard.tsx diff --git a/src/MigrateV2.tsx b/src/MigrateV2.tsx index ac77f1f..759e14b 100644 --- a/src/MigrateV2.tsx +++ b/src/MigrateV2.tsx @@ -13,7 +13,6 @@ 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"; @@ -25,6 +24,8 @@ import Alert from "@material-ui/lab/Alert"; import { arrayToChunkIterator } from "./helpers"; import { ContactType, EventType, TaskType, PimType } from "./pim-types"; import PasswordField from "./widgets/PasswordField"; +import Wizard, { PagePropsType, WizardNavigationBar } from "./widgets/Wizard"; +import ExternalLink from "./widgets/ExternalLink"; interface PropsType { etesync: CredentialsData; @@ -40,7 +41,55 @@ interface FormErrors { } export default function MigrateV2(props: PropsType) { - const [wantedJournals, setWantedJournals] = React.useState>(ImmutableMap({})); + const [etebase, setEtebase] = React.useState(); + + const wizardPages = [ + (props: PagePropsType) => ( + <> +
+

Etebase 2.0 Migration tool

+

+ This tool will help you migrate your data to EteSync 2.0. + 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. +

+
+ + + ), + (pageProps: PagePropsType) => ( + + ), + (pageProps: PagePropsType) => ( + + ), + (_props: PagePropsType) => ( + <> +
+

Migration finished successfully!

+

+ You should now log into your apps using your EteSync 2.0 credentials, and logout from your EteSync 1.0 accounts. +

+

+ The EteSync 2.0 web client is located at: https://pim.etesync.com +

+
+ + ), + ]; + + return ( + <> + + 1} style={{ display: "flex", flexDirection: "column", flex: 1 }} /> + + ); +} + +interface OurPagePropsType extends PropsType, PagePropsType { + etebase?: Etebase.Account; +} + +export function WizardAccountPage(props: OurPagePropsType & { setEtebase: (etebase: Etebase.Account) => void }) { const [username, setUsername] = React.useState(""); const [password, setPassword] = React.useState(""); const [server, setServer] = React.useState(""); @@ -49,9 +98,191 @@ export default function MigrateV2(props: PropsType) { const [loading, setLoading] = React.useState(false); const [progress, setProgress] = React.useState(""); const [errors, setErrors] = React.useState({}); + const email = props.etesync.credentials.email; + + const styles = { + form: { + }, + forgotPassword: { + paddingTop: 20, + }, + alertInfo: { + marginTop: 20, + maxWidth: "50em", + }, + textField: { + width: "20em", + marginTop: 20, + }, + submit: { + marginTop: 40, + marginBottom: 40, + }, + }; + + function handleInputChange(func: (value: string) => void) { + return (event: React.ChangeEvent) => { + 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 etebase: Etebase.Account; + if (hasAccount) { + setProgress("Logging into EteSync 2.0 account"); + etebase = await Etebase.Account.login(username, password, serverUrl); + } else { + setProgress("Creating an EteSync 2.0 account"); + const user: Etebase.User = { + username, + email, + }; + etebase = await Etebase.Account.signup(user, password, serverUrl); + } + props.setEtebase(etebase); + props.next?.(); + 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 = ( + + +
+
+ ); + } + + if (props.etebase) { + return ( + <> +

EteSync 2.0 credentials

+

Already logged in. Click "Next" to continue.

+ + ); + } + + return ( + <> +

EteSync 2.0 credentials

+ setHasAccount(!hasAccount)} />} + label="I already have an EteSync 2.0 account" + /> +
+ +
+ + {!hasAccount && ( + + Please make sure you remember your password, as it can't be recovered if lost! + + )} + + setShowAdvanced(!showAdvanced)} + /> + } + label="Advanced settings" + /> + + {advancedSettings} + {errors.errorGeneral && ( + {errors.errorGeneral} + )} + {progress && ( + {progress} + )} +
+ +
+ + ); +} + +export function WizardMigrationPage(props: OurPagePropsType) { + const [wantedJournals, setWantedJournals] = React.useState>(ImmutableMap({})); + const [loading, setLoading] = React.useState(false); + const [done, setDone] = React.useState(false); + const [progress, setProgress] = React.useState(""); + const [errors, setErrors] = React.useState({}); const journals = useSelector((state: StoreState) => state.cache.journals!); const journalEntries = useSelector((state: StoreState) => state.cache.entries); - const email = props.etesync.credentials.email; const derived = props.etesync.encryptionKey; const decryptedJournals = React.useMemo(() => { @@ -122,50 +353,12 @@ export default function MigrateV2(props: PropsType) { }, }; - function handleInputChange(func: (value: string) => void) { - return (event: React.ChangeEvent) => { - 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; - - let etebase: Etebase.Account; - if (hasAccount) { - setProgress("Logging into EteSync 2.0 account"); - etebase = await Etebase.Account.login(username, password, serverUrl); - } else { - setProgress("Logging into EteSync 2.0 account"); - const user: Etebase.User = { - username, - email, - }; - etebase = await Etebase.Account.signup(user, password, serverUrl); - } + const etebase = props.etebase!; const colMgr = etebase.getCollectionManager(); const { etesync, userInfo } = props; @@ -274,50 +467,19 @@ export default function MigrateV2(props: PropsType) { } else { setProgress("Done"); } + setDone(true); } catch (e) { - if (e instanceof Etebase.UnauthorizedError) { - errors.errorGeneral = "Wrong username or password"; - } else { - errors.errorGeneral = e.toString(); - } + errors.errorGeneral = e.toString(); setErrors(errors); } finally { setLoading(false); } } - let advancedSettings = null; - if (showAdvanced) { - advancedSettings = ( - - -
-
- ); - } - return ( - - -

- This tool will help you migrate your data to EteSync 2.0. -

-

- 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. -

-

- Please select the collections you would like to migrate, and then enter your EteSync 2.0 credentials and click migrate. -

+ <> +

Choose collections to migrate

-

EteSync 2.0 credentials

- setHasAccount(!hasAccount)} />} - label="I already have an EteSync 2.0 account" - /> -
- -
- - {!hasAccount && ( - - Please make sure you remember your password, as it can't be recovered if lost! - - )} - - setShowAdvanced(!showAdvanced)} - /> - } - label="Advanced settings" - /> - - {advancedSettings} {errors.errorGeneral && ( {errors.errorGeneral} )} @@ -389,18 +507,28 @@ export default function MigrateV2(props: PropsType) { {progress} )}
- + {(done) ? ( + + ) : ( + + )}
-
+ ); } diff --git a/src/widgets/Wizard.tsx b/src/widgets/Wizard.tsx new file mode 100644 index 0000000..39af413 --- /dev/null +++ b/src/widgets/Wizard.tsx @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: © 2019 EteSync Authors +// SPDX-License-Identifier: GPL-3.0-only + +import * as React from "react"; +import Button from "@material-ui/core/Button"; + +import Container from "./Container"; + +export interface PagePropsType { + prev?: () => void; + next?: () => void; + currentPage: number; + totalPages: number; +} + +export function WizardNavigationBar(props: PagePropsType) { + const first = props.currentPage === 0; + const last = props.currentPage === props.totalPages - 1; + + return ( +
+ + +
+ ); +} + +interface PropsType extends React.HTMLProps { + pages: ((props: PagePropsType) => React.ReactNode)[]; + onFinish: () => void; +} + +export default function Wizard(inProps: PropsType) { + const [currentPage, setCurrentPage] = React.useState(0); + const { pages, onFinish, ...props } = inProps; + + const Content = pages[currentPage]; + + const first = currentPage === 0; + const last = currentPage === pages.length - 1; + const prev = !first ? () => setCurrentPage(currentPage - 1) : undefined; + const next = !last ? () => setCurrentPage(currentPage + 1) : onFinish; + + return ( + + {Content({ prev, next, currentPage, totalPages: pages.length })} + + ); +} + From 71c1ebc2133b3f9c55912d31269a5a0fb634b19f Mon Sep 17 00:00:00 2001 From: Tom Hacohen Date: Wed, 9 Sep 2020 18:29:59 +0300 Subject: [PATCH 18/25] Migration: let the server know that we are migrating an account Needed for automatic email confirmation. --- src/MigrateV2.tsx | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/MigrateV2.tsx b/src/MigrateV2.tsx index 759e14b..775b9a5 100644 --- a/src/MigrateV2.tsx +++ b/src/MigrateV2.tsx @@ -4,6 +4,7 @@ import * as React from "react"; import * as EteSync from "etesync"; import * as Etebase from "etebase"; +import URI from "urijs"; import Button from "@material-ui/core/Button"; import TextField from "@material-ui/core/TextField"; import Switch from "@material-ui/core/Switch"; @@ -44,7 +45,7 @@ export default function MigrateV2(props: PropsType) { const [etebase, setEtebase] = React.useState(); const wizardPages = [ - (props: PagePropsType) => ( + (pageProps: PagePropsType) => ( <>

Etebase 2.0 Migration tool

@@ -53,7 +54,7 @@ export default function MigrateV2(props: PropsType) { 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.

- + ), (pageProps: PagePropsType) => ( @@ -161,6 +162,21 @@ export function WizardAccountPage(props: OurPagePropsType & { setEtebase: (eteba username, email, }; + // Let the server know the account is asking for a migration + { + const serviceUrl = URI(props.etesync.serviceApiUrl).normalize(); + serviceUrl.segment("etesync-v2"); + serviceUrl.segment("confirm-migration"); + serviceUrl.segment(""); + const response = await fetch(serviceUrl.normalize().toString(), { + method: "post", + headers: { Authorization: "Token " + props.etesync.credentials.authToken }, + }); + if (!response.ok) { + throw new Error("Failed preparing account for migration"); + } + } + etebase = await Etebase.Account.signup(user, password, serverUrl); } props.setEtebase(etebase); From 094983ad47838b2e2067f872186c0851a43aec44 Mon Sep 17 00:00:00 2001 From: Tom Hacohen Date: Fri, 11 Sep 2020 09:55:55 +0300 Subject: [PATCH 19/25] Show the server error on failure to create account --- src/MigrateV2.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/MigrateV2.tsx b/src/MigrateV2.tsx index 775b9a5..a5dfbc3 100644 --- a/src/MigrateV2.tsx +++ b/src/MigrateV2.tsx @@ -173,7 +173,12 @@ export function WizardAccountPage(props: OurPagePropsType & { setEtebase: (eteba headers: { Authorization: "Token " + props.etesync.credentials.authToken }, }); if (!response.ok) { - throw new Error("Failed preparing account for migration"); + try { + const json = await response.json(); + throw new Error(json.detail ?? JSON.stringify(json)); + } catch (e) { + throw new Error("Failed preparing account for migration"); + } } } From 0e4e5b46ada58bf7a1bc3c576271490f3bfa9821 Mon Sep 17 00:00:00 2001 From: Tom Hacohen Date: Sun, 13 Sep 2020 18:49:45 +0300 Subject: [PATCH 20/25] Migration: properly handle field-specific errors. --- src/MigrateV2.tsx | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/MigrateV2.tsx b/src/MigrateV2.tsx index a5dfbc3..ac55c6c 100644 --- a/src/MigrateV2.tsx +++ b/src/MigrateV2.tsx @@ -188,7 +188,24 @@ export function WizardAccountPage(props: OurPagePropsType & { setEtebase: (eteba props.next?.(); setProgress("Done"); } catch (e) { - if (e instanceof Etebase.UnauthorizedError) { + if ((e instanceof Etebase.HttpError) && (e.content)) { + let found = false; + if (e.content.errors) { + for (const field of e.content.errors) { + if (field.field === "user.username") { + errors.errorEmail = field.detail; + found = true; + } else if (!field.field) { + errors.errorGeneral = field.detail; + found = true; + } + } + } + + if (!found) { + errors.errorGeneral = e.content.detail ?? e.toString(); + } + } else if (e instanceof Etebase.UnauthorizedError) { errors.errorGeneral = "Wrong username or password"; } else { errors.errorGeneral = e.toString(); From ee848679b1185be46362766a0c42cba36ced7615 Mon Sep 17 00:00:00 2001 From: Tom Hacohen Date: Sun, 13 Sep 2020 18:50:35 +0300 Subject: [PATCH 21/25] Migration: add a minimum password length requirement. --- src/MigrateV2.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/MigrateV2.tsx b/src/MigrateV2.tsx index ac55c6c..d0b849f 100644 --- a/src/MigrateV2.tsx +++ b/src/MigrateV2.tsx @@ -86,6 +86,8 @@ export default function MigrateV2(props: PropsType) { ); } +const PASSWORD_MIN_LENGTH = 8; + interface OurPagePropsType extends PropsType, PagePropsType { etebase?: Etebase.Account; } @@ -138,6 +140,8 @@ export function WizardAccountPage(props: OurPagePropsType & { setEtebase: (eteba } if (!password) { errors.errorPassword = fieldRequired; + } else if (password.length < PASSWORD_MIN_LENGTH) { + errors.errorPassword = `Passwourds should be at least ${PASSWORD_MIN_LENGTH} digits long.`; } if (showAdvanced && !server.startsWith("http")) { errors.errorServer = "Server URI must start with http/https://"; From 9e9206e690c78a05c70de4cb563c9211218da4f3 Mon Sep 17 00:00:00 2001 From: Tom Hacohen Date: Wed, 16 Sep 2020 10:21:52 +0300 Subject: [PATCH 22/25] Migration: add a note when migrating shared journals. --- src/MigrateV2.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/MigrateV2.tsx b/src/MigrateV2.tsx index d0b849f..cf95ea9 100644 --- a/src/MigrateV2.tsx +++ b/src/MigrateV2.tsx @@ -327,6 +327,8 @@ export function WizardMigrationPage(props: OurPagePropsType) { const journalEntries = useSelector((state: StoreState) => state.cache.entries); const derived = props.etesync.encryptionKey; + const me = props.etesync.credentials.email; + const decryptedJournals = React.useMemo(() => { return journals.map((journal) => { const userInfo = props.userInfo; @@ -365,6 +367,9 @@ export function WizardMigrationPage(props: OurPagePropsType) { checked={wantedJournals.has(journal.uid)} /> {info.displayName} + {((journal.owner !== me) && wantedJournals.has(journal.uid)) && ( + Migrating a shared collection will only copy its contents, and will not make it shared. Pleaes ask the collection owner to migrate it to EteSync 2.0 and share it with you instead. + )} ); From 6475a8e34369deb42ec2a4cc506f4da7cdc37023 Mon Sep 17 00:00:00 2001 From: Tom Hacohen Date: Wed, 16 Sep 2020 10:32:39 +0300 Subject: [PATCH 23/25] Migration: add a warning about mtime missing when migrating. --- src/MigrateV2.tsx | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/MigrateV2.tsx b/src/MigrateV2.tsx index cf95ea9..df68d97 100644 --- a/src/MigrateV2.tsx +++ b/src/MigrateV2.tsx @@ -405,6 +405,7 @@ export function WizardMigrationPage(props: OurPagePropsType) { setProgress(""); try { let malformed = 0; + let badMtime = 0; const etebase = props.etebase!; const colMgr = etebase.getCollectionManager(); @@ -471,14 +472,19 @@ export function WizardMigrationPage(props: OurPagePropsType) { 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; } + // 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. + let mtime = (pimItem.lastModified?.toJSDate())?.getTime(); + if (!mtime) { + mtime = now + done; + badMtime++; + } + let item = items.get(uid); if (item) { // Existing item @@ -509,10 +515,14 @@ export function WizardMigrationPage(props: OurPagePropsType) { } await etebase.logout(); - if (malformed > 0) { - setProgress(`Done\nIgnored ${malformed} entries (probably safe to ignore)`); - } else { - setProgress("Done"); + { + let out = "Done"; + if (badMtime > 0) { + out += `\nModification time missing for ${badMtime} entries (setting to "now")`; + } else if (malformed > 0) { + out += `\nIgnored ${malformed} entries (probably safe to ignore)`; + } + setProgress(out); } setDone(true); From 7403b9a61a75bbd0ea68a8a4638a8f01f4e10601 Mon Sep 17 00:00:00 2001 From: Tom Hacohen Date: Thu, 1 Oct 2020 13:38:19 +0300 Subject: [PATCH 24/25] Migration: set collection mtime when creating the first collections. --- src/MigrateV2.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/MigrateV2.tsx b/src/MigrateV2.tsx index df68d97..489fcd8 100644 --- a/src/MigrateV2.tsx +++ b/src/MigrateV2.tsx @@ -447,11 +447,13 @@ export function WizardMigrationPage(props: OurPagePropsType) { continue; } } + const mtime = (new Date()).getTime(); const meta: Etebase.CollectionMetadata = { type: colType, name: info.displayName, description: info.description, color: (info.color !== undefined) ? colorIntToHtml(info.color) : undefined, + mtime, }; const collection = await colMgr.create(meta, ""); await colMgr.upload(collection); From 3a4db2fd3c050abb01ad441c7e49d776ddbb0582 Mon Sep 17 00:00:00 2001 From: niconfus <72577889+niconfus@users.noreply.github.com> Date: Thu, 8 Oct 2020 16:07:58 -0400 Subject: [PATCH 25/25] Fix localization for DateTimePicker --- src/widgets/DateTimePicker.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/widgets/DateTimePicker.tsx b/src/widgets/DateTimePicker.tsx index 530c4d4..9047f41 100644 --- a/src/widgets/DateTimePicker.tsx +++ b/src/widgets/DateTimePicker.tsx @@ -23,7 +23,7 @@ class DateTimePicker extends React.PureComponent { public render() { const Picker = (this.props.dateOnly) ? KeyboardDatePicker : KeyboardDateTimePicker; - const dateFormat = (this.props.dateOnly) ? "DD/MM/YYYY" : "DD/MM/YYYY HH:mm"; + const dateFormat = (this.props.dateOnly) ? "L" : "L LT"; return (