From 3123f36afb05213e631323491cb3c3eb800e2f43 Mon Sep 17 00:00:00 2001 From: Komediruzecki Date: Sun, 12 Dec 2021 21:39:47 +0100 Subject: [PATCH] Add initial evernote migration Add frontend rendering of notebooks and their note counts Add note metadata and content fetch Add notes importing with rest API Make workspace Id optional in rest create doc Add migration steps Add UI for evernote migration Add handling of evernote failed imports Add note errors handling Add token reset if needed for auth --- src/cloud/api/migrations/EvernoteApi.ts | 117 ++++ src/cloud/api/rest/doc.ts | 6 +- src/cloud/components/Application.tsx | 4 +- src/cloud/components/ImportFlow/index.tsx | 82 ++- .../molecules/EvernoteImportNotebookList.tsx | 234 +++++++ src/cloud/components/Router.tsx | 10 + .../pages/migrations/EvernoteMigrate.tsx | 649 ++++++++++++++++++ src/cloud/pages/migrations/index.tsx | 20 + .../organisms/Sidebar/atoms/SidebarHeader.tsx | 2 +- 9 files changed, 1099 insertions(+), 25 deletions(-) create mode 100644 src/cloud/api/migrations/EvernoteApi.ts create mode 100644 src/cloud/components/ImportFlow/molecules/EvernoteImportNotebookList.tsx create mode 100644 src/cloud/pages/migrations/EvernoteMigrate.tsx create mode 100644 src/cloud/pages/migrations/index.tsx diff --git a/src/cloud/api/migrations/EvernoteApi.ts b/src/cloud/api/migrations/EvernoteApi.ts new file mode 100644 index 0000000000..78cffb2233 --- /dev/null +++ b/src/cloud/api/migrations/EvernoteApi.ts @@ -0,0 +1,117 @@ +import { callApi } from '../../lib/client' +import { NotebookMetadata } from '../../pages/migrations/EvernoteMigrate' +import { SerializedDocWithSupplemental } from '../../interfaces/db/doc' + +export interface EvernoteNotebooks { + notebooks: NotebookMetadata[] +} + +export interface EvernoteNoteData { + noteId: string + notebookId: string + title: string +} + +interface EvernoteNotes { + notes: EvernoteNoteData[] +} + +export interface AccessTokenRequestData { + oauthAccessToken: string + oauthAccessTokenSecret: string + edamShard: string + edamUserId: string + edamExpires: string + edamNoteStoreUrl: string + edamWebApiUrlPrefix: string +} + +interface RequestTokenData { + oauthToken: string + oauthTokenSecret: string + redirectUrl: string +} + +export const evernoteAccessTokenDataKey = 'evernote-migration-access-token-data' +export const evernoteOAuthVerifierKey = 'evernote-migration-oauth_verifier' +export const evernoteOAuthTokenKey = 'evernote-migration-oauth_token' +export const evernoteTempTokenKey = 'evernote-migration-temp-token' +export const evernoteTempTokenSecretKey = 'evernote-migration-temp-token-secret' + +interface CreateEvernoteMigrationAccessTokenRequestBody { + oauthToken: string + oauthVerifier: string + oauthTokenSecret: string + // state: string // (Optional) Use it if you set `state`. + // todo: the state is oauthTokenSecret afaik +} + +interface EvernoteDocImportResponseBody { + doc: SerializedDocWithSupplemental +} + +export async function fetchEvernoteAccessToken( + oauthToken: string, + oauthVerifier: string, + oauthTokenSecret: string +): Promise { + const body: CreateEvernoteMigrationAccessTokenRequestBody = { + oauthToken, + oauthVerifier, + oauthTokenSecret, + } + return callApi( + `/api/migrations/evernote/access-token`, + { method: 'post', json: body } + ) +} + +export async function evernoteAuthorize(): Promise { + return callApi('api/migrations/evernote/authorize') +} + +export async function fetchEvernoteNotebooks( + oauthAccessToken: string +): Promise { + return callApi( + `/api/migrations/evernote/notebooks?oauthAccessToken=${oauthAccessToken}` + ) +} + +export async function fetchEvernoteNotes( + oauthAccessToken: string, + notebookId: string +): Promise { + return callApi( + `/api/migrations/evernote/notes?oauthAccessToken=${oauthAccessToken}¬ebookId=${notebookId}` + ) +} + +export async function importEvernoteNote( + oauthAccessToken: string, + noteId: string, + teamId: string, + parentFolderId: string, + workspaceId: string +): Promise { + return callApi( + `/api/migrations/evernote/${teamId}/${noteId}/import?oauthAccessToken=${oauthAccessToken}&parentFolderId=${parentFolderId}&workspaceId=${workspaceId}` + ) +} + +export function resetAccessToken() { + localStorage.removeItem(evernoteAccessTokenDataKey) + localStorage.removeItem(evernoteOAuthVerifierKey) + localStorage.removeItem(evernoteOAuthTokenKey) + localStorage.removeItem(evernoteTempTokenKey) + localStorage.removeItem(evernoteTempTokenSecretKey) +} + +export function getAccessToken(): string | null { + const accessTokenData = localStorage.getItem(evernoteAccessTokenDataKey) + if (accessTokenData == null) { + return null + } + return (JSON.parse(accessTokenData) as AccessTokenRequestData) + .oauthAccessToken +} diff --git a/src/cloud/api/rest/doc.ts b/src/cloud/api/rest/doc.ts index 0ff9be0c04..14d8c88bc6 100644 --- a/src/cloud/api/rest/doc.ts +++ b/src/cloud/api/rest/doc.ts @@ -1,10 +1,10 @@ -import { SerializedDoc } from '../../interfaces/db/doc' +import { SerializedDocWithSupplemental } from '../../interfaces/db/doc' import { callApi } from '../../lib/client' interface DocCreateRequestBody { content: string title: string - workspaceId: string + workspaceId?: string path: string tags: string[] teamId: string @@ -13,7 +13,7 @@ interface DocCreateRequestBody { } interface DocCreateResponseBody { - doc: SerializedDoc + doc: SerializedDocWithSupplemental } export function createDocREST(body: DocCreateRequestBody) { diff --git a/src/cloud/components/Application.tsx b/src/cloud/components/Application.tsx index fbaba6f71f..4e4d4a34c7 100644 --- a/src/cloud/components/Application.tsx +++ b/src/cloud/components/Application.tsx @@ -133,6 +133,8 @@ const Application = ({ return openSettingsTab('teamUpgrade') case 'members': return openSettingsTab('teamMembers') + case 'evernote-migration': + return openSettingsTab('import') default: return } @@ -359,7 +361,7 @@ const Application = ({ return ( ) diff --git a/src/cloud/components/ImportFlow/index.tsx b/src/cloud/components/ImportFlow/index.tsx index 4e0805aef6..3ff2900a17 100644 --- a/src/cloud/components/ImportFlow/index.tsx +++ b/src/cloud/components/ImportFlow/index.tsx @@ -13,13 +13,22 @@ import { useModal } from '../../../design/lib/stores/modal' import styled from '../../../design/lib/styled' import Spinner from '../../../design/components/atoms/Spinner' import ImportFlowSource from './molecules/ImportFlowSources' +import { + evernoteAccessTokenDataKey, + evernoteAuthorize, + evernoteTempTokenKey, + evernoteTempTokenSecretKey, +} from '../../api/migrations/EvernoteApi' +import { useRouter } from '../../lib/router' +import { useElectron } from '../../lib/stores/electron' -type ImportStep = 'destination' | 'source' | 'import' +type ImportStep = 'destination' | 'source' | 'import' | 'evernote-import' const ImportFlow = () => { const [step, setStep] = useState('source') const [selectedWorkspaceId, setSelectedWorkspaceId] = useState() const [selectedFolderId, setSelectedFolderId] = useState() + const { closeLastModal: closeModal } = useModal() const fileUploaderRef = useRef(null) const [uploadType, setUploadType] = useState< @@ -40,6 +49,7 @@ const ImportFlow = () => { const navigateToTeam = useNavigateToTeam() const navigateToWorkspace = useNavigateToWorkspace() + const { sendToElectron, usingElectron } = useElectron() const onFileUpload = useCallback( async (event: React.ChangeEvent) => { @@ -142,27 +152,59 @@ const ImportFlow = () => { navigateToFolder, ] ) + const { push } = useRouter() + const fetchEvernoteTempToken = useCallback(() => { + evernoteAuthorize() + .then((result) => { + localStorage.setItem(evernoteTempTokenKey, result.oauthToken) + localStorage.setItem( + evernoteTempTokenSecretKey, + result.oauthTokenSecret + ) - const onsourceCallback = useCallback((val: string) => { - switch (val) { - case 'dropbox': - case 'gdocs': - case 'md': - setUploadType('md') - break - case 'evernote': - case 'confluence': - case 'html': - setUploadType('html') - break - case 'quip': - case 'notion': - default: - setUploadType('md|html') - break + if (usingElectron) { + sendToElectron('open-external-url', result.redirectUrl) + } else { + open(result.redirectUrl) + } + }) + .catch((err) => console.log('Err' + err)) + }, [sendToElectron, usingElectron]) + + const evernoteLoginOauth = useCallback(() => { + const accessTokenData = localStorage.getItem(evernoteAccessTokenDataKey) + if (accessTokenData != null) { + push('/migrations/evernote') + return } - setStep('destination') - }, []) + fetchEvernoteTempToken() + }, [fetchEvernoteTempToken, push]) + + const onsourceCallback = useCallback( + (val: string) => { + switch (val) { + case 'dropbox': + case 'gdocs': + case 'md': + setUploadType('md') + setStep('destination') + break + case 'evernote': + case 'confluence': + case 'html': + setUploadType('html') + evernoteLoginOauth() + break + case 'quip': + case 'notion': + default: + setUploadType('md|html') + setStep('destination') + break + } + }, + [evernoteLoginOauth] + ) const content = useMemo(() => { switch (step) { diff --git a/src/cloud/components/ImportFlow/molecules/EvernoteImportNotebookList.tsx b/src/cloud/components/ImportFlow/molecules/EvernoteImportNotebookList.tsx new file mode 100644 index 0000000000..6baeb66d66 --- /dev/null +++ b/src/cloud/components/ImportFlow/molecules/EvernoteImportNotebookList.tsx @@ -0,0 +1,234 @@ +import React, { useCallback, useMemo, useState } from 'react' +import { useI18n } from '../../../lib/hooks/useI18n' +import { lngKeys } from '../../../lib/i18n/types' +import styled from '../../../../design/lib/styled' +import Form from '../../../../design/components/molecules/Form' +import { CheckboxWithLabel } from '../../../../design/components/molecules/Form/atoms/FormCheckbox' +import cc from 'classcat' +import { NotebookMetadata } from '../../../pages/migrations/EvernoteMigrate' +import { SerializedTeamWithPermissions } from '../../../interfaces/db/team' +import { SerializedSubscription } from '../../../interfaces/db/subscription' +import Spinner from '../../../../design/components/atoms/Spinner' +import { useToast } from '../../../../design/lib/stores/toast' + +interface EvernoteImportNotebookListProps { + notebooks: NotebookMetadata[] + onSelect: ( + selectedNotebooks: NotebookMetadata[], + selectedSpace: string + ) => void + teams: (SerializedTeamWithPermissions & { + subscription?: SerializedSubscription + })[] +} + +const EvernoteImportNotebookList = ({ + teams, + notebooks, + onSelect, +}: EvernoteImportNotebookListProps) => { + const { translate } = useI18n() + const [selectedSpace, setSelectedSpace] = useState('') + const [notebookNames] = useState( + notebooks.map((n) => n.notebookName) + ) + const [selectedNotebooks, setSelectedNotebooks] = useState([]) + const { pushMessage } = useToast() + + const toggleNotebookSelection = useCallback((notebookName: string) => { + setSelectedNotebooks((prev) => { + const deselectNotebook = prev.includes(notebookName) + if (deselectNotebook) { + return [...prev.filter((n) => n !== notebookName)] + } else { + return [...prev, notebookName] + } + }) + }, []) + + const allRowsAreSelected = useMemo(() => { + return selectedNotebooks.length == notebooks.length + }, [notebooks.length, selectedNotebooks.length]) + + const selectAllRows = useCallback( + (selectingAllDocs) => { + if (selectingAllDocs) { + setSelectedNotebooks(notebookNames) + } else { + setSelectedNotebooks([]) + } + }, + [notebookNames] + ) + + const selectSpace = useCallback((selectedTeamId) => { + setSelectedSpace(selectedTeamId) + }, []) + + const onImport = useCallback(() => { + const notebooksToImport = notebooks.filter((n) => + selectedNotebooks.includes(n.notebookName) + ) + const numNotes = notebooksToImport.reduce( + (sum, notebook) => notebook.noteCount + sum, + 0 + ) + if (numNotes === 0) { + pushMessage({ + title: 'Migration Error', + description: 'Please select at least one note to import.', + }) + return + } + onSelect(notebooksToImport, selectedSpace) + }, [notebooks, onSelect, pushMessage, selectedNotebooks, selectedSpace]) + + return ( + +
+ selectAllRows(!allRowsAreSelected)} + /> + + ), + }, + ], + }, + { + items: [ + { + type: 'node', + element: ( + + ), + }, + ], + }, + { + title: 'Select a destination space', + description: + 'Notebook(s) will be imported in a separate folder, and each notebook will be sub-folder.', + items: [ + { + type: 'select--string', + props: { + value: selectedSpace, + onChange: selectSpace, + options: teams.map((team) => team.id), + labels: teams.map((team) => team.name), + placeholder: 'Select Space', + }, + }, + ], + }, + { + items: [ + { + type: 'button', + props: { + variant: 'primary', + disabled: + selectedSpace === '' || selectedNotebooks.length === 0, + onClick: () => onImport(), + label: translate(lngKeys.GeneralImport), + }, + }, + ], + }, + ]} + /> + + ) +} + +interface EvernoteNotebookListProps { + notebooks: NotebookMetadata[] + selectedNotebooks: string[] + toggleNotebookSelection: (selectedNotebook: string) => void +} + +const EvernoteNotebookList = ({ + notebooks, + selectedNotebooks, + toggleNotebookSelection, +}: EvernoteNotebookListProps) => { + return ( + + {notebooks.length == 0 && } + {notebooks.map((notebook) => { + const notebookName = notebook.notebookName + return ( +
+ toggleNotebookSelection(notebookName)} + /> +
{notebook.noteCount}
+
+ ) + })} +
+ ) +} + +const NotebookContainer = styled.div` + display: flex; + flex-direction: column; +` + +const Container = styled.div` + width: 100%; + .notebook-select-all-row__checkbox__wrapper { + display: flex; + align-items: center; + margin-bottom: ${({ theme }) => theme.sizes.spaces.sm}px; + } + + .notebook-row__checkbox__wrapper { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: ${({ theme }) => theme.sizes.spaces.xsm}px; + } + + .notebook-row__checkbox { + margin-left: ${({ theme }) => theme.sizes.spaces.sm}px; + margin-right: ${({ theme }) => theme.sizes.spaces.sm}px; + } +` + +export default EvernoteImportNotebookList diff --git a/src/cloud/components/Router.tsx b/src/cloud/components/Router.tsx index 80e3ad76d5..6b2461f49f 100644 --- a/src/cloud/components/Router.tsx +++ b/src/cloud/components/Router.tsx @@ -64,6 +64,7 @@ import { BaseTheme } from '../../design/lib/styled/types' import { PreviewStyleProvider } from '../../lib/preview' import HomePage from '../pages/home' import DashboardPage from '../pages/[teamId]/dashboard' +import EvernoteMigration from '../pages/migrations' const CombinedProvider = combineProviders( PreviewStyleProvider, @@ -347,6 +348,7 @@ function isHomepagePathname(pathname: string) { } switch (pathname) { case '/': + // case '/migrations': case '/features': case '/pricing': case '/integrations': @@ -367,6 +369,9 @@ function isApplicationPagePathname(pathname: string) { if (isHomepagePathname(pathname)) return false const [, ...splittedPathnames] = pathname.split('/') + if (splittedPathnames.length === 2 && splittedPathnames[1] === 'migrations') { + return true + } if ( (splittedPathnames.length >= 2 && @@ -403,6 +408,11 @@ function getPageComponent(pathname: string): PageSpec | null { Component: CooperatePage, getInitialProps: CooperatePage.getInitialProps, } + case 'migrations': + return { + Component: EvernoteMigration, + getInitialProps: EvernoteMigration.getInitialProps, + } case 'settings': return { Component: SettingsPage, diff --git a/src/cloud/pages/migrations/EvernoteMigrate.tsx b/src/cloud/pages/migrations/EvernoteMigrate.tsx new file mode 100644 index 0000000000..c8fbc9e416 --- /dev/null +++ b/src/cloud/pages/migrations/EvernoteMigrate.tsx @@ -0,0 +1,649 @@ +import React, { useCallback, useEffect, useState } from 'react' +import { SerializedUser } from '../../interfaces/db/user' +import { + SerializedTeam, + SerializedTeamWithPermissions, +} from '../../interfaces/db/team' +import { SerializedSubscription } from '../../interfaces/db/subscription' +import { useRouter } from '../../lib/router' +import { + AccessTokenRequestData, + evernoteAccessTokenDataKey, + evernoteAuthorize, + EvernoteNoteData, + evernoteOAuthTokenKey, + evernoteOAuthVerifierKey, + evernoteTempTokenKey, + evernoteTempTokenSecretKey, + fetchEvernoteAccessToken, + fetchEvernoteNotebooks, + fetchEvernoteNotes, + getAccessToken, + importEvernoteNote, + resetAccessToken, +} from '../../api/migrations/EvernoteApi' +import { useToast } from '../../../design/lib/stores/toast' +import EvernoteImportNotebookList from '../../components/ImportFlow/molecules/EvernoteImportNotebookList' +import { rightSidePageLayout } from '../../../design/lib/styled/styleFunctions' +import { rightSideTopBarHeight } from '../../../design/components/organisms/Topbar' +import Spinner from '../../../design/components/atoms/Spinner' +import { useCloudApi } from '../../lib/hooks/useCloudApi' +import { SerializedFolder } from '../../interfaces/db/folder' +import Button from '../../../design/components/atoms/Button' +import { useNavigateToWorkspace } from '../../components/Link/WorkspaceLink' +import { SerializedWorkspace } from '../../interfaces/db/workspace' +import { DialogIconTypes, useDialog } from '../../../design/lib/stores/dialog' +import styled from '../../../design/lib/styled' +import ProgressBar from '../../../components/atoms/ProgressBar' +import { useNav } from '../../lib/stores/nav' +import { getMapFromEntityArray } from '../../../design/lib/utils/array' +import { useElectron } from '../../lib/stores/electron' + +interface EvernoteMigrateProps { + user: SerializedUser + teams: (SerializedTeamWithPermissions & { + subscription?: SerializedSubscription + })[] +} + +export interface NotebookMetadata { + notebookName: string + noteCount: number + notebookId: string +} + +type EvernoteImportStep = 'notebook-list' | 'migration' | 'migration-finished' + +function getProgressPercentage(jobsCompleted: number, jobsCount: number) { + return Math.max(Math.min((jobsCompleted / jobsCount) * 100, 100), 0) +} + +export const EvernoteMigrate = ({ teams = [] }: EvernoteMigrateProps) => { + const [notebookFolders, setNotebookFolders] = useState< + Map + >(new Map()) + const [numberOfNotesToImport, setNumberOfNotesToImport] = useState(0) + const [progressValue, setProgressValue] = useState(0) + const [stopMigration, setStopMigration] = useState(false) + const [selectedTeam, setSelectedTeam] = useState(null) + const [ + selectedWorkspace, + setSelectedWorkspace, + ] = useState(null) + const [notMigratedNotes, setNotMigratedNotes] = useState( + [] + ) + const [loading, setLoading] = useState(false) + const [migrationStep, setMigrationStep] = useState( + 'notebook-list' + ) + const [migrationDescription, setMigrationDescription] = useState('') + const [notebooks, setNotebooks] = useState([]) + + const { query } = useRouter() + const { pushMessage } = useToast() + const { sendToElectron, usingElectron } = useElectron() + + const { createWorkspace: createWorkspaceApi, createFolder } = useCloudApi() + const navigateToWorkspace = useNavigateToWorkspace() + const { messageBox } = useDialog() + const { + updateDocsMap, + updateParentFolderOfDoc, + updateParentWorkspaceOfDoc, + updateTagsMap, + } = useNav() + + const fetchEvernoteTempToken = useCallback(() => { + evernoteAuthorize() + .then((result) => { + localStorage.setItem(evernoteTempTokenKey, result.oauthToken) + localStorage.setItem( + evernoteTempTokenSecretKey, + result.oauthTokenSecret + ) + + if (usingElectron) { + sendToElectron('open-external-url', result.redirectUrl) + } else { + open(result.redirectUrl) + } + }) + .catch((err) => { + setLoading(false) + resetAccessToken() + pushMessage({ + title: 'UnAuthorized', + description: + 'You need to grant access to Boost Note to import Evernote notebooks. Please authorize again on Settings import page.', + }) + console.log('Failed because of', err) + fetchEvernoteTempToken() + }) + }, [pushMessage, sendToElectron, usingElectron]) + + const fetchNotebooks = useCallback( + (oauthAccessToken: string) => { + setMigrationDescription('Retrieving data from Evernote...') + setLoading(true) + fetchEvernoteNotebooks(oauthAccessToken) + .then((res) => { + setLoading(false) + setMigrationDescription('') + setNotebooks(res.notebooks) + }) + .catch((err) => { + setLoading(false) + // reset saved access token - it is invalid + resetAccessToken() + pushMessage({ + title: 'UnAuthorized', + description: + 'You need to grant access to Boost Note to import Evernote notebooks. Please authorize again on Settings import page.', + }) + console.log('Failed because of', err) + fetchEvernoteTempToken() + }) + }, + [fetchEvernoteTempToken, pushMessage] + ) + + const getEvernoteAccessToken = useCallback( + (oauthToken, oauthVerifier, oauthSecretToken) => { + setLoading(true) + fetchEvernoteAccessToken(oauthToken, oauthVerifier, oauthSecretToken) + .then((data) => { + setLoading(false) + localStorage.setItem(evernoteAccessTokenDataKey, JSON.stringify(data)) + fetchNotebooks(data.oauthAccessToken) + }) + .catch((err) => { + resetAccessToken() + setLoading(false) + + pushMessage({ + title: 'UnAuthorized', + description: + 'You need to grant access to Boost Note to import Evernote notebooks. Please refresh the page.', + }) + console.log('Failed because of', err) + fetchEvernoteTempToken() + }) + }, + [fetchNotebooks, pushMessage, fetchEvernoteTempToken] + ) + + const importNote = useCallback( + async ( + selectedTeam: SerializedTeam, + noteData: EvernoteNoteData, + folder: SerializedFolder + ) => { + console.log('Import', selectedTeam) + if (selectedTeam == null) { + pushMessage({ + title: 'Error', + description: 'Please select a team.', + }) + return + } + const oauthAccessToken = getAccessToken() + if (oauthAccessToken == null) { + pushMessage({ + title: 'Unauthorized', + description: 'Please authorize evernote again.', + }) + return + } + try { + const res = await importEvernoteNote( + oauthAccessToken, + noteData.noteId, + selectedTeam.id, + folder.id, + folder.workspaceId + ) + // console.log('Got doc from backend', res.doc) + if (res && res.doc) { + if (res.doc.tags != null && res.doc.tags.length > 0) { + const tagMap = getMapFromEntityArray(res.doc.tags) + updateTagsMap(...tagMap) + } + updateDocsMap([res.doc.id, res.doc]) + if (res.doc.parentFolder != null) { + updateParentFolderOfDoc(res.doc) + } else if (res.doc.workspace != null) { + updateParentWorkspaceOfDoc(res.doc) + } + } + } catch (e) { + console.warn('Hey failed note', noteData, e) + setNotMigratedNotes((prev) => { + return [...prev, noteData] + }) + } + }, + [ + pushMessage, + updateDocsMap, + updateParentFolderOfDoc, + updateParentWorkspaceOfDoc, + updateTagsMap, + ] + ) + + const importNotes = useCallback( + async (selectedTeam, folders, notes) => { + setProgressValue(0) + const totalNumberOfNotes = notes.length + setNumberOfNotesToImport(totalNumberOfNotes) + let currentNoteIdx = 1 + + for (const note of notes) { + const folder = folders.get(note.notebookId) + console.log('Importing the note', note, folder) + if (folder == null) { + setNotMigratedNotes((prev) => { + return [...prev, note] + }) + continue + } + + if (stopMigration) { + setNotMigratedNotes((prev) => { + return [...prev, ...notes] + }) + continue + } + + setMigrationDescription( + `Migrating... (${currentNoteIdx}/${totalNumberOfNotes})` + ) + + await importNote(selectedTeam, note, folder) + currentNoteIdx += 1 + setProgressValue( + getProgressPercentage(currentNoteIdx, totalNumberOfNotes) + ) + } + }, + [importNote, stopMigration] + ) + + const importNotebooks = useCallback( + async ( + selectedNotebooks, + selectedTeam: SerializedTeam, + workspaceId, + oauthAccessToken + ) => { + const createdFolders: Map = new Map() + for (const notebook of selectedNotebooks) { + console.log('Importing notebook', notebook.notebookName) + try { + await createFolder( + selectedTeam, + { + workspaceId: workspaceId, + description: '', + folderName: notebook.notebookName, + }, + { + skipRedirect: true, + afterSuccess: (folder: SerializedFolder) => { + console.log('Created notebook folder', folder) + createdFolders.set(notebook.notebookId, folder) + }, + } + ) + console.log('Folder created...', notebook.notebookName) + } catch (e) { + setLoading(false) + setMigrationDescription('') + setMigrationStep('notebook-list') + pushMessage({ + title: 'Error during migration', + description: e, + }) + } + } + + setLoading(true) + setMigrationStep('migration') + + const notesToImport: EvernoteNoteData[] = [] + setProgressValue(0) + let currentNotebookIdx = 1 + for (const notebook of selectedNotebooks) { + setProgressValue( + getProgressPercentage(currentNotebookIdx, selectedNotebooks.length) + ) + setMigrationDescription( + `Loading notes from evernote notebooks... (${currentNotebookIdx++}/${ + selectedNotebooks.length + })` + ) + try { + const notes = await fetchEvernoteNotes( + oauthAccessToken, + notebook.notebookId + ) + if (createdFolders.has(notebook.notebookId)) { + notesToImport.push(...notes.notes) + } else { + // the folder probably wasn't created, set those notes to failed + setNotMigratedNotes((prev) => { + return [...prev, ...notes.notes] + }) + } + } catch (e) { + // no conversion will happen in this case, we could track this as not loaded notes? But don't know how to show those! + pushMessage({ + title: 'Evernote notes fetch error', + description: + 'Error happened during fetching evernote notes. Not all notes will be imported.', + }) + } + } + + setLoading(false) + setNotebookFolders(createdFolders) + await importNotes(selectedTeam, createdFolders, notesToImport) + + setMigrationStep('migration-finished') + }, + [importNotes, createFolder, pushMessage] + ) + + const importEvernoteNotebooks = useCallback( + async (selectedNotebooks: NotebookMetadata[], selectedTeamId) => { + const selectedTeam = teams.find( + (team: SerializedTeam) => team.id == selectedTeamId + ) + if (selectedTeam == null) { + pushMessage({ + title: 'Error', + description: `The team selected is invalid: ${selectedTeamId}.`, + }) + return + } + + setSelectedTeam(selectedTeam) + + const oauthAccessToken = getAccessToken() + if (oauthAccessToken == null) { + pushMessage({ + title: 'Unauthorized', + description: 'Please authorize evernote again.', + }) + return + } + + // create folder and sub-folders for notes + try { + await createWorkspaceApi( + selectedTeam, + { + name: 'EvernoteNotebooks', + permissions: [], + public: false, + }, + { + skipRedirect: true, + afterSuccess: (wp) => { + setSelectedWorkspace(wp) + importNotebooks( + selectedNotebooks, + selectedTeam, + wp.id, + oauthAccessToken + ) + }, + } + ) + } catch (e) { + pushMessage({ + title: 'Error during migration', + description: e, + }) + setLoading(false) + setMigrationDescription('') + setMigrationStep('notebook-list') + } + }, + [createWorkspaceApi, importNotebooks, pushMessage, teams] + ) + + useEffect(() => { + const accessTokenData = localStorage.getItem(evernoteAccessTokenDataKey) + if (accessTokenData) { + const accessOauthToken = (JSON.parse( + accessTokenData + ) as AccessTokenRequestData).oauthAccessToken + fetchNotebooks(accessOauthToken) + return + } + + const evernoteTempTokenSecret = localStorage.getItem( + evernoteTempTokenSecretKey + ) + if ( + localStorage.getItem(evernoteTempTokenKey) == null || + evernoteTempTokenSecret == null || + query.oauth_token == null + ) { + return + } + + if (!query.oauth_verifier || query.oauth_verifier === '') { + pushMessage({ + title: 'No access given', + description: + 'You need to grant access to Boost Note to import Evernote notebooks' + + query.reason, + }) + return + } + if ( + query.oauth_verifier != null && + typeof query.oauth_verifier === 'string' + ) { + localStorage.setItem(evernoteOAuthVerifierKey, query.oauth_verifier) + } + if (typeof query.oauth_token === 'string') { + localStorage.setItem(evernoteOAuthTokenKey, query.oauth_token) + } + getEvernoteAccessToken( + query.oauth_token, + query.oauth_verifier, + evernoteTempTokenSecret + ) + }, [fetchNotebooks, getEvernoteAccessToken, pushMessage, query]) + + const retryImportingFailedNotes = useCallback(async () => { + setMigrationDescription('') + setMigrationStep('migration') + + const notesToImport = [...notMigratedNotes] + setNotMigratedNotes([]) + await importNotes(selectedTeam, notebookFolders, notesToImport) + + setMigrationStep('migration-finished') + }, [importNotes, notMigratedNotes, notebookFolders, selectedTeam]) + + const navigateToCreatedWorkspace = useCallback(() => { + if (selectedWorkspace != null && selectedTeam != null) { + navigateToWorkspace(selectedWorkspace, selectedTeam, 'index') + } else { + pushMessage({ + title: 'Error in navigation', + description: + 'Something went wrong during migration, please try again later.', + }) + } + }, [navigateToWorkspace, pushMessage, selectedTeam, selectedWorkspace]) + + const abortMigrationProcess = useCallback(() => { + messageBox({ + title: `Stop Evernote migration?`, + message: `Are you sure you want to stop the migration. You can continue migrating remaining notes if you stop the migration now.`, + iconType: DialogIconTypes.Warning, + buttons: [ + { + variant: 'secondary', + label: 'Cancel', + cancelButton: true, + defaultButton: true, + }, + { + variant: 'danger', + label: 'Stop', + onClick: async () => { + setStopMigration(true) + }, + }, + ], + }) + }, [messageBox]) + + useEffect(() => { + if (migrationStep === 'migration-finished') { + if (notMigratedNotes.length > 0) { + setMigrationDescription( + `${ + numberOfNotesToImport - notMigratedNotes.length + } note(s) have been migrated.\n${ + notMigratedNotes.length + } note(s) have not been migrated yet.` + ) + } else { + setMigrationDescription( + `${numberOfNotesToImport} notes have been migrated.` + ) + } + } + }, [migrationStep, notMigratedNotes.length, numberOfNotesToImport]) + + return ( + + {'evernote +

Evernote migration

+ <> + {migrationDescription !== '' &&

{migrationDescription}

} + {loading && } + + + {notebooks.length > 0 && migrationStep === 'notebook-list' && ( + <> + + importEvernoteNotebooks(selectedNotebooks, selectedSpace) + } + /> + + )} + + {migrationStep === 'migration' && ( + + + + + )} + + {notMigratedNotes.length > 0 && ( +
+

Failed

+
+ {notMigratedNotes.map((failedNote) => ( +
+ {failedNote.title} +
+ ))} +
+
+ )} + + {migrationStep === 'migration-finished' && ( + + {notMigratedNotes.length > 0 && ( + + )} + + + )} +
+ ) +} + +const MigrationProgressContainer = styled.div` + min-width: 480px; + height: fit-content; + display: flex; + flex-direction: column; + flex-wrap: nowrap; + justify-content: center; + align-content: center; + align-items: center; + + .evernote__migration__progress__style { + background-color: ${({ theme }) => + theme.colors.background.secondary} !important; + height: 15px; + border-radius: 4px; + margin-top: ${({ theme }) => theme.sizes.spaces.l}px !important; + margin-bottom: ${({ theme }) => theme.sizes.spaces.l}px !important; + } + + .evernote__migration__progress__style:after { + background-color: ${({ theme }) => + theme.colors.background.quaternary} !important; + } +` + +const MigrationFinishedButtons = styled.div` + display: flex; + align-content: center; +` + +const Container = styled.div` + ${rightSidePageLayout}; + margin: 0 auto; + padding-top: calc( + ${rightSideTopBarHeight}px + ${({ theme }) => theme.sizes.spaces.l}px + ); + padding-left: ${({ theme }) => theme.sizes.spaces.l}px; + padding-right: ${({ theme }) => theme.sizes.spaces.l}px; + min-height: calc(100vh - ${rightSideTopBarHeight}px); + height: auto; + display: flex; + flex-direction: column; + align-items: center; + + .not-imported-notes--container { + display: flex; + flex-direction: column; + margin-bottom: 10px; + padding: 6px; + + min-width: 480px; + min-height: 180px; + + background-color: ${({ theme }) => theme.colors.background.quaternary}; + } +` diff --git a/src/cloud/pages/migrations/index.tsx b/src/cloud/pages/migrations/index.tsx new file mode 100644 index 0000000000..5743020f1f --- /dev/null +++ b/src/cloud/pages/migrations/index.tsx @@ -0,0 +1,20 @@ +import React from 'react' +import { useGlobalData } from '../../lib/stores/globalData' +import { EvernoteMigrate } from './EvernoteMigrate' +import HomePageSignInForm from '../home/HomePageSignInForm' + +const EvernoteMigration = () => { + const { + globalData: { currentUser, teams }, + } = useGlobalData() + if (currentUser) { + return + } + return +} + +EvernoteMigration.getInitialProps = async () => { + return {} +} + +export default EvernoteMigration diff --git a/src/design/components/organisms/Sidebar/atoms/SidebarHeader.tsx b/src/design/components/organisms/Sidebar/atoms/SidebarHeader.tsx index 57eee2efd3..0b9d3cfeeb 100644 --- a/src/design/components/organisms/Sidebar/atoms/SidebarHeader.tsx +++ b/src/design/components/organisms/Sidebar/atoms/SidebarHeader.tsx @@ -52,7 +52,7 @@ const SidebarHeader: AppComponent = ({ }