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 = ({ }