feat: Added "Import" option in the account menu that allows you to import from plaintext/markdown files, Evernote exports, Simplenote exports, Google Keep exports and also convert Aegis exports to TokenVault notes
This commit is contained in:
@@ -14,8 +14,6 @@ import { ApplicationGroup } from '@/Application/ApplicationGroup'
|
||||
import { formatLastSyncDate } from '@/Utils/DateUtils'
|
||||
import Spinner from '@/Components/Spinner/Spinner'
|
||||
import { MenuItemIconSize } from '@/Constants/TailwindClassNames'
|
||||
import ImportMenuOption from './ImportMenuOption'
|
||||
import { featureTrunkEnabled, FeatureTrunkName } from '@/FeatureTrunk'
|
||||
|
||||
type Props = {
|
||||
viewControllerManager: ViewControllerManager
|
||||
@@ -189,7 +187,10 @@ const GeneralAccountMenu: FunctionComponent<Props> = ({
|
||||
<Icon type="open-in" className={iconClassName} />
|
||||
Open FileSend
|
||||
</MenuItem>
|
||||
{featureTrunkEnabled(FeatureTrunkName.ImportTools) && <ImportMenuOption />}
|
||||
<MenuItem onClick={() => viewControllerManager.isImportModalVisible.set(true)}>
|
||||
<Icon type="archive" className={iconClassName} />
|
||||
Import
|
||||
</MenuItem>
|
||||
{user ? (
|
||||
<>
|
||||
<MenuItemSeparator />
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
import { classNames, FeatureIdentifier, FeatureStatus } from '@standardnotes/snjs'
|
||||
import Icon from '../Icon/Icon'
|
||||
import MenuItem from '../Menu/MenuItem'
|
||||
import { MenuItemIconSize } from '@/Constants/TailwindClassNames'
|
||||
import { useRef, useState } from 'react'
|
||||
import Popover from '../Popover/Popover'
|
||||
import Menu from '../Menu/Menu'
|
||||
import { ClassicFileReader } from '@standardnotes/filepicker'
|
||||
import {
|
||||
AegisToAuthenticatorConverter,
|
||||
EvernoteConverter,
|
||||
GoogleKeepConverter,
|
||||
SimplenoteConverter,
|
||||
} from '@standardnotes/ui-services'
|
||||
import { useApplication } from '../ApplicationProvider'
|
||||
|
||||
const iconClassName = classNames('mr-2 text-neutral', MenuItemIconSize)
|
||||
|
||||
const ImportMenuOption = () => {
|
||||
const application = useApplication()
|
||||
const anchorRef = useRef<HTMLButtonElement>(null)
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false)
|
||||
|
||||
const togglePopover = () => {
|
||||
setIsMenuOpen((isOpen) => !isOpen)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuItem ref={anchorRef} onClick={togglePopover}>
|
||||
<Icon type="upload" className={iconClassName} />
|
||||
Import
|
||||
<Icon type="chevron-right" className={`ml-auto text-neutral ${MenuItemIconSize}`} />
|
||||
</MenuItem>
|
||||
<Popover
|
||||
anchorElement={anchorRef.current}
|
||||
className="py-2"
|
||||
open={isMenuOpen}
|
||||
side="right"
|
||||
align="end"
|
||||
togglePopover={togglePopover}
|
||||
>
|
||||
<Menu a11yLabel="Import options menu" isOpen={isMenuOpen}>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
setIsMenuOpen((isOpen) => !isOpen)
|
||||
}}
|
||||
>
|
||||
<Icon type="plain-text" className={iconClassName} />
|
||||
Plaintext
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={async () => {
|
||||
const files = await ClassicFileReader.selectFiles()
|
||||
files.forEach(async (file) => {
|
||||
const converter = new GoogleKeepConverter(application)
|
||||
const noteTransferPayload = await converter.convertGoogleKeepBackupFileToNote(file, false)
|
||||
void converter.importFromTransferPayloads([noteTransferPayload])
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Icon type="plain-text" className={iconClassName} />
|
||||
Google Keep
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={async () => {
|
||||
const files = await ClassicFileReader.selectFiles()
|
||||
files.forEach(async (file) => {
|
||||
const converter = new EvernoteConverter(application)
|
||||
const noteAndTagPayloads = await converter.convertENEXFileToNotesAndTags(file, true)
|
||||
void converter.importFromTransferPayloads(noteAndTagPayloads)
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Icon type="rich-text" className={iconClassName} />
|
||||
Evernote
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={async () => {
|
||||
const files = await ClassicFileReader.selectFiles()
|
||||
files.forEach(async (file) => {
|
||||
const converter = new SimplenoteConverter(application)
|
||||
const noteTransferPayloads = await converter.convertSimplenoteBackupFileToNotes(file)
|
||||
void converter.importFromTransferPayloads(noteTransferPayloads)
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Icon type="rich-text" className={iconClassName} />
|
||||
Simplenote
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onClick={async () => {
|
||||
const files = await ClassicFileReader.selectFiles()
|
||||
const isEntitledToAuthenticator =
|
||||
application.features.getFeatureStatus(FeatureIdentifier.TokenVaultEditor) === FeatureStatus.Entitled
|
||||
files.forEach(async (file) => {
|
||||
const converter = new AegisToAuthenticatorConverter(application)
|
||||
const noteTransferPayload = await converter.convertAegisBackupFileToNote(
|
||||
file,
|
||||
isEntitledToAuthenticator,
|
||||
)
|
||||
void converter.importFromTransferPayloads([noteTransferPayload])
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Icon type="lock-filled" className={iconClassName} />
|
||||
Aegis
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
</Popover>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ImportMenuOption
|
||||
@@ -28,6 +28,7 @@ import CommandProvider from '../CommandProvider'
|
||||
import PanesSystemComponent from '../Panes/PanesSystemComponent'
|
||||
import DotOrgNotice from './DotOrgNotice'
|
||||
import LinkingControllerProvider from '@/Controllers/LinkingControllerProvider'
|
||||
import ImportModal from '../ImportModal/ImportModal'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
@@ -229,6 +230,7 @@ const ApplicationView: FunctionComponent<Props> = ({ application, mainApplicatio
|
||||
application={application}
|
||||
viewControllerManager={viewControllerManager}
|
||||
/>
|
||||
<ImportModal viewControllerManager={viewControllerManager} />
|
||||
</>
|
||||
{application.routeService.isDotOrg && <DotOrgNotice />}
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { StreamingFileReader } from '@standardnotes/filepicker'
|
||||
import { FileItem } from '@standardnotes/snjs'
|
||||
import { useMemo, useState, createContext, ReactNode, useRef, useCallback, useEffect, useContext, memo } from 'react'
|
||||
import Portal from './Portal/Portal'
|
||||
import { ElementIds } from '@/Constants/ElementIDs'
|
||||
|
||||
type FileDragTargetData = {
|
||||
tooltipText: string
|
||||
@@ -221,18 +222,24 @@ const FileDragNDropProvider = ({ application, children, featuresController, file
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('dragstart', handleDragStart)
|
||||
window.addEventListener('dragenter', handleDragIn)
|
||||
window.addEventListener('dragleave', handleDragOut)
|
||||
window.addEventListener('dragover', handleDrag)
|
||||
window.addEventListener('drop', handleDrop)
|
||||
const appGroupRoot = document.getElementById(ElementIds.RootId)
|
||||
|
||||
if (!appGroupRoot) {
|
||||
return
|
||||
}
|
||||
|
||||
appGroupRoot.addEventListener('dragstart', handleDragStart)
|
||||
appGroupRoot.addEventListener('dragenter', handleDragIn)
|
||||
appGroupRoot.addEventListener('dragleave', handleDragOut)
|
||||
appGroupRoot.addEventListener('dragover', handleDrag)
|
||||
appGroupRoot.addEventListener('drop', handleDrop)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('dragstart', handleDragStart)
|
||||
window.removeEventListener('dragenter', handleDragIn)
|
||||
window.removeEventListener('dragleave', handleDragOut)
|
||||
window.removeEventListener('dragover', handleDrag)
|
||||
window.removeEventListener('drop', handleDrop)
|
||||
appGroupRoot.removeEventListener('dragstart', handleDragStart)
|
||||
appGroupRoot.removeEventListener('dragenter', handleDragIn)
|
||||
appGroupRoot.removeEventListener('dragleave', handleDragOut)
|
||||
appGroupRoot.removeEventListener('dragover', handleDrag)
|
||||
appGroupRoot.removeEventListener('drop', handleDrop)
|
||||
}
|
||||
}, [handleDragIn, handleDrop, handleDrag, handleDragOut, handleDragStart])
|
||||
|
||||
|
||||
@@ -116,4 +116,8 @@ export const IconNameToSvgMapping = {
|
||||
view: icons.ViewIcon,
|
||||
warning: icons.WarningIcon,
|
||||
window: icons.WindowIcon,
|
||||
evernote: icons.EvernoteIcon,
|
||||
gkeep: icons.GoogleKeepIcon,
|
||||
simplenote: icons.SimplenoteIcon,
|
||||
aegis: icons.AegisIcon,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { ViewControllerManager } from '@/Controllers/ViewControllerManager'
|
||||
import { ContentType, DecryptedTransferPayload, pluralize, SNTag, TagContent, UuidGenerator } from '@standardnotes/snjs'
|
||||
import { Importer } from '@standardnotes/ui-services'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { useCallback, useReducer, useState } from 'react'
|
||||
import { useApplication } from '../ApplicationProvider'
|
||||
import Button from '../Button/Button'
|
||||
import { useStateRef } from '../Panes/useStateRef'
|
||||
import ModalDialog from '../Shared/ModalDialog'
|
||||
import ModalDialogButtons from '../Shared/ModalDialogButtons'
|
||||
import ModalDialogDescription from '../Shared/ModalDialogDescription'
|
||||
import ModalDialogLabel from '../Shared/ModalDialogLabel'
|
||||
import { ImportModalFileItem } from './ImportModalFileItem'
|
||||
import ImportModalInitialPage from './InitialPage'
|
||||
import { ImportModalAction, ImportModalFile, ImportModalState } from './Types'
|
||||
|
||||
const reducer = (state: ImportModalState, action: ImportModalAction): ImportModalState => {
|
||||
switch (action.type) {
|
||||
case 'setFiles':
|
||||
return {
|
||||
...state,
|
||||
files: action.files.map((file) => ({
|
||||
id: UuidGenerator.GenerateUuid(),
|
||||
file,
|
||||
status: action.service ? 'ready' : 'pending',
|
||||
service: action.service,
|
||||
})),
|
||||
}
|
||||
case 'updateFile':
|
||||
return {
|
||||
...state,
|
||||
files: state.files.map((file) => {
|
||||
if (file.file.name === action.file.file.name) {
|
||||
return action.file
|
||||
}
|
||||
return file
|
||||
}),
|
||||
}
|
||||
case 'removeFile':
|
||||
return {
|
||||
...state,
|
||||
files: state.files.filter((file) => file.id !== action.id),
|
||||
}
|
||||
case 'setImportTag':
|
||||
return {
|
||||
...state,
|
||||
importTag: action.tag,
|
||||
}
|
||||
case 'clearState':
|
||||
return {
|
||||
files: [],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const initialState: ImportModalState = {
|
||||
files: [],
|
||||
}
|
||||
|
||||
const ImportModal = ({ viewControllerManager }: { viewControllerManager: ViewControllerManager }) => {
|
||||
const application = useApplication()
|
||||
const [importer] = useState(() => new Importer(application))
|
||||
const [state, dispatch] = useReducer(reducer, initialState)
|
||||
const { files } = state
|
||||
const filesRef = useStateRef(files)
|
||||
|
||||
const importFromPayloads = useCallback(
|
||||
async (file: ImportModalFile, payloads: DecryptedTransferPayload[]) => {
|
||||
dispatch({
|
||||
type: 'updateFile',
|
||||
file: {
|
||||
...file,
|
||||
status: 'importing',
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await importer.importFromTransferPayloads(payloads)
|
||||
|
||||
const notesImported = payloads.filter((payload) => payload.content_type === ContentType.Note)
|
||||
const tagsImported = payloads.filter((payload) => payload.content_type === ContentType.Tag)
|
||||
|
||||
const successMessage =
|
||||
`Successfully imported ${notesImported.length} ` +
|
||||
pluralize(notesImported.length, 'note', 'notes') +
|
||||
(tagsImported.length > 0
|
||||
? ` and ${tagsImported.length} ${pluralize(tagsImported.length, 'tag', 'tags')}`
|
||||
: '')
|
||||
|
||||
dispatch({
|
||||
type: 'updateFile',
|
||||
file: {
|
||||
...file,
|
||||
status: 'success',
|
||||
successMessage,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
dispatch({
|
||||
type: 'updateFile',
|
||||
file: {
|
||||
...file,
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error : new Error('Could not import file'),
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
[importer],
|
||||
)
|
||||
|
||||
const parseAndImport = useCallback(async () => {
|
||||
const files = filesRef.current
|
||||
if (files.length === 0) {
|
||||
return
|
||||
}
|
||||
const importedPayloads: DecryptedTransferPayload[] = []
|
||||
for (const file of files) {
|
||||
if (!file.service) {
|
||||
return
|
||||
}
|
||||
|
||||
if (file.status === 'ready' && file.payloads) {
|
||||
await importFromPayloads(file, file.payloads)
|
||||
importedPayloads.push(...file.payloads)
|
||||
continue
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: 'updateFile',
|
||||
file: {
|
||||
...file,
|
||||
status: 'parsing',
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const payloads = await importer.getPayloadsFromFile(file.file, file.service)
|
||||
await importFromPayloads(file, payloads)
|
||||
importedPayloads.push(...payloads)
|
||||
} catch (error) {
|
||||
dispatch({
|
||||
type: 'updateFile',
|
||||
file: {
|
||||
...file,
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error : new Error('Could not import file'),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
const currentDate = new Date()
|
||||
const importTagPayload: DecryptedTransferPayload<TagContent> = {
|
||||
uuid: UuidGenerator.GenerateUuid(),
|
||||
created_at: currentDate,
|
||||
created_at_timestamp: currentDate.getTime(),
|
||||
updated_at: currentDate,
|
||||
updated_at_timestamp: currentDate.getTime(),
|
||||
content_type: ContentType.Tag,
|
||||
content: {
|
||||
title: `Imported on ${currentDate.toLocaleString()}`,
|
||||
expanded: false,
|
||||
iconString: '',
|
||||
references: importedPayloads
|
||||
.filter((payload) => payload.content_type === ContentType.Note)
|
||||
.map((payload) => ({
|
||||
content_type: ContentType.Note,
|
||||
uuid: payload.uuid,
|
||||
})),
|
||||
},
|
||||
}
|
||||
const [importTag] = await importer.importFromTransferPayloads([importTagPayload])
|
||||
if (importTag) {
|
||||
dispatch({
|
||||
type: 'setImportTag',
|
||||
tag: importTag as SNTag,
|
||||
})
|
||||
}
|
||||
}, [filesRef, importFromPayloads, importer])
|
||||
|
||||
const closeDialog = useCallback(() => {
|
||||
viewControllerManager.isImportModalVisible.set(false)
|
||||
if (state.importTag) {
|
||||
void viewControllerManager.navigationController.setSelectedTag(state.importTag, 'all', {
|
||||
userTriggered: true,
|
||||
})
|
||||
}
|
||||
dispatch({
|
||||
type: 'clearState',
|
||||
})
|
||||
}, [state.importTag, viewControllerManager.isImportModalVisible, viewControllerManager.navigationController])
|
||||
|
||||
if (!viewControllerManager.isImportModalVisible.get()) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<ModalDialog>
|
||||
<ModalDialogLabel closeDialog={closeDialog}>Import</ModalDialogLabel>
|
||||
<ModalDialogDescription>
|
||||
{!files.length && <ImportModalInitialPage dispatch={dispatch} />}
|
||||
{files.length > 0 && (
|
||||
<div className="divide-y divide-border">
|
||||
{files.map((file) => (
|
||||
<ImportModalFileItem file={file} key={file.id} dispatch={dispatch} importer={importer} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ModalDialogDescription>
|
||||
<ModalDialogButtons>
|
||||
{files.length > 0 && files.every((file) => file.status === 'ready') && (
|
||||
<Button primary onClick={parseAndImport}>
|
||||
Import
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={closeDialog}>
|
||||
{files.length > 0 && files.every((file) => file.status === 'success' || file.status === 'error')
|
||||
? 'Close'
|
||||
: 'Cancel'}
|
||||
</Button>
|
||||
</ModalDialogButtons>
|
||||
</ModalDialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default observer(ImportModal)
|
||||
@@ -0,0 +1,146 @@
|
||||
import { classNames, ContentType, DecryptedTransferPayload, pluralize } from '@standardnotes/snjs'
|
||||
import { Importer, NoteImportType } from '@standardnotes/ui-services'
|
||||
import { Dispatch, useCallback, useEffect } from 'react'
|
||||
import Icon from '../Icon/Icon'
|
||||
import { ImportModalAction, ImportModalFile } from './Types'
|
||||
|
||||
const NoteImportTypeColors: Record<NoteImportType, string> = {
|
||||
evernote: 'bg-[#14cc45] text-[#000]',
|
||||
simplenote: 'bg-[#3360cc] text-default',
|
||||
'google-keep': 'bg-[#fbbd00] text-[#000]',
|
||||
aegis: 'bg-[#0d47a1] text-default',
|
||||
plaintext: 'bg-default border border-border',
|
||||
}
|
||||
|
||||
const NoteImportTypeIcons: Record<NoteImportType, string> = {
|
||||
evernote: 'evernote',
|
||||
simplenote: 'simplenote',
|
||||
'google-keep': 'gkeep',
|
||||
aegis: 'aegis',
|
||||
plaintext: 'plain-text',
|
||||
}
|
||||
|
||||
export const ImportModalFileItem = ({
|
||||
file,
|
||||
dispatch,
|
||||
importer,
|
||||
}: {
|
||||
file: ImportModalFile
|
||||
dispatch: Dispatch<ImportModalAction>
|
||||
importer: Importer
|
||||
}) => {
|
||||
const setFileService = useCallback(
|
||||
async (service: NoteImportType | null) => {
|
||||
let payloads: DecryptedTransferPayload[] | undefined
|
||||
try {
|
||||
payloads = service ? await importer.getPayloadsFromFile(file.file, service) : undefined
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: 'updateFile',
|
||||
file: {
|
||||
...file,
|
||||
service,
|
||||
status: service ? 'ready' : 'pending',
|
||||
payloads,
|
||||
},
|
||||
})
|
||||
},
|
||||
[dispatch, file, importer],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const detect = async () => {
|
||||
const detectedService = await Importer.detectService(file.file)
|
||||
void setFileService(detectedService)
|
||||
}
|
||||
if (file.service === undefined) {
|
||||
void detect()
|
||||
}
|
||||
}, [dispatch, file, setFileService])
|
||||
|
||||
const notePayloads =
|
||||
file.status === 'ready' && file.payloads
|
||||
? file.payloads.filter((payload) => payload.content_type === ContentType.Note)
|
||||
: []
|
||||
const tagPayloads =
|
||||
file.status === 'ready' && file.payloads
|
||||
? file.payloads.filter((payload) => payload.content_type === ContentType.Tag)
|
||||
: []
|
||||
|
||||
const payloadsImportMessage =
|
||||
`Ready to import ${notePayloads.length} ` +
|
||||
pluralize(notePayloads.length, 'note', 'notes') +
|
||||
(tagPayloads.length > 0 ? ` and ${tagPayloads.length} ${pluralize(tagPayloads.length, 'tag', 'tags')}` : '')
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'flex gap-2 py-2 px-2',
|
||||
file.service == null ? 'flex-col items-start md:flex-row md:items-center' : 'items-center',
|
||||
)}
|
||||
>
|
||||
<div className="mr-auto flex items-center">
|
||||
{file.service && (
|
||||
<div className={classNames('mr-4 rounded p-2', NoteImportTypeColors[file.service])}>
|
||||
<Icon type={NoteImportTypeIcons[file.service]} size="medium" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<div>{file.file.name}</div>
|
||||
<div className="text-xs opacity-75">
|
||||
{file.status === 'ready'
|
||||
? notePayloads.length > 1 || tagPayloads.length
|
||||
? payloadsImportMessage
|
||||
: 'Ready to import'
|
||||
: null}
|
||||
{file.status === 'pending' && 'Could not auto-detect service. Please select manually.'}
|
||||
{file.status === 'parsing' && 'Parsing...'}
|
||||
{file.status === 'importing' && 'Importing...'}
|
||||
{file.status === 'error' && `${file.error}`}
|
||||
{file.status === 'success' && file.successMessage}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{file.service == null && (
|
||||
<div className="flex items-center">
|
||||
<form
|
||||
className="flex items-center"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
const form = event.target as HTMLFormElement
|
||||
const service = form.elements[0] as HTMLSelectElement
|
||||
void setFileService(service.value as NoteImportType)
|
||||
}}
|
||||
>
|
||||
<select className="mr-2 rounded border border-border bg-default px-2 py-1 text-sm">
|
||||
<option value="evernote">Evernote</option>
|
||||
<option value="simplenote">Simplenote</option>
|
||||
<option value="google-keep">Google Keep</option>
|
||||
<option value="aegis">Aegis</option>
|
||||
<option value="plaintext">Plaintext</option>
|
||||
</select>
|
||||
<button type="submit" className="rounded border border-border bg-default p-1.5 hover:bg-contrast">
|
||||
<Icon type="check" size="medium" />
|
||||
</button>
|
||||
</form>
|
||||
<button
|
||||
className="ml-2 rounded border border-border bg-default p-1.5 hover:bg-contrast"
|
||||
onClick={() => {
|
||||
dispatch({
|
||||
type: 'removeFile',
|
||||
id: file.id,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Icon type="close" size="medium" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{file.status === 'success' && <Icon type="check-circle-filled" className="text-success" />}
|
||||
{file.status === 'error' && <Icon type="warning" className="text-danger" />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { ClassicFileReader } from '@standardnotes/filepicker'
|
||||
import { NoteImportType } from '@standardnotes/ui-services'
|
||||
import { useCallback } from 'react'
|
||||
import Button from '../Button/Button'
|
||||
import Icon from '../Icon/Icon'
|
||||
import { ImportModalAction } from './Types'
|
||||
|
||||
type Props = {
|
||||
dispatch: React.Dispatch<ImportModalAction>
|
||||
}
|
||||
|
||||
const ImportModalInitialPage = ({ dispatch }: Props) => {
|
||||
const selectFiles = useCallback(
|
||||
async (service?: NoteImportType) => {
|
||||
const files = await ClassicFileReader.selectFiles()
|
||||
|
||||
dispatch({
|
||||
type: 'setFiles',
|
||||
files,
|
||||
service,
|
||||
})
|
||||
},
|
||||
[dispatch],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => selectFiles()}
|
||||
className="flex min-h-[30vh] w-full flex-col items-center justify-center gap-2 rounded border-2 border-dashed border-info p-2 hover:border-4"
|
||||
onDragStart={(e) => e.preventDefault()}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault()
|
||||
const files = Array.from(e.dataTransfer.files)
|
||||
dispatch({
|
||||
type: 'setFiles',
|
||||
files,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<div className="text-lg font-semibold">Drag and drop files to auto-detect and import</div>
|
||||
<div className="text-sm">Or click to open file picker</div>
|
||||
</button>
|
||||
<div className="relative mt-6 mb-6 w-full">
|
||||
<hr className="w-full border-border" />
|
||||
<div className="absolute left-1/2 top-1/2 -translate-y-1/2 -translate-x-1/2 bg-default p-1">
|
||||
or import from:
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-center gap-4">
|
||||
<Button
|
||||
className="flex items-center bg-[#14cc45] !py-2 text-[#000]"
|
||||
primary
|
||||
onClick={() => selectFiles('evernote')}
|
||||
>
|
||||
<Icon type="evernote" className="mr-2" />
|
||||
Evernote
|
||||
</Button>
|
||||
<Button
|
||||
className="flex items-center bg-[#fbbd00] !py-2 text-[#000]"
|
||||
primary
|
||||
onClick={() => selectFiles('google-keep')}
|
||||
>
|
||||
<Icon type="gkeep" className="mr-2" />
|
||||
Google Keep
|
||||
</Button>
|
||||
<Button className="flex items-center bg-[#3360cc] !py-2" primary onClick={() => selectFiles('simplenote')}>
|
||||
<Icon type="simplenote" className="mr-2" />
|
||||
Simplenote
|
||||
</Button>
|
||||
<Button className="flex items-center bg-[#0d47a1] !py-2" primary onClick={() => selectFiles('aegis')}>
|
||||
<Icon type="aegis" className="mr-2" />
|
||||
Aegis Authenticator
|
||||
</Button>
|
||||
<Button className="flex items-center bg-info !py-2" onClick={() => selectFiles('plaintext')} primary>
|
||||
<Icon type="plain-text" className="mr-2" />
|
||||
Plaintext
|
||||
</Button>
|
||||
<Button
|
||||
className="flex items-center bg-accessory-tint-4 !py-2"
|
||||
primary
|
||||
onClick={() => selectFiles('plaintext')}
|
||||
>
|
||||
<Icon type="markdown" className="mr-2" />
|
||||
Markdown
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ImportModalInitialPage
|
||||
30
packages/web/src/javascripts/Components/ImportModal/Types.ts
Normal file
30
packages/web/src/javascripts/Components/ImportModal/Types.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { DecryptedTransferPayload, SNTag } from '@standardnotes/models'
|
||||
import { NoteImportType } from '@standardnotes/ui-services'
|
||||
|
||||
type ImportModalFileCommon = {
|
||||
id: string
|
||||
file: File
|
||||
service: NoteImportType | null | undefined
|
||||
}
|
||||
|
||||
export type ImportModalFile = (
|
||||
| { status: 'pending' }
|
||||
| { status: 'ready'; payloads?: DecryptedTransferPayload[] }
|
||||
| { status: 'parsing' }
|
||||
| { status: 'importing' }
|
||||
| { status: 'success'; successMessage: string }
|
||||
| { status: 'error'; error: Error }
|
||||
) &
|
||||
ImportModalFileCommon
|
||||
|
||||
export type ImportModalState = {
|
||||
files: ImportModalFile[]
|
||||
importTag?: SNTag
|
||||
}
|
||||
|
||||
export type ImportModalAction =
|
||||
| { type: 'setFiles'; files: File[]; service?: NoteImportType }
|
||||
| { type: 'updateFile'; file: ImportModalFile }
|
||||
| { type: 'removeFile'; id: ImportModalFile['id'] }
|
||||
| { type: 'setImportTag'; tag: SNTag }
|
||||
| { type: 'clearState' }
|
||||
@@ -1,20 +1,10 @@
|
||||
import { useAndroidBackHandler } from '@/NativeMobileWeb/useAndroidBackHandler'
|
||||
import {
|
||||
useEffect,
|
||||
ReactNode,
|
||||
useMemo,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
memo,
|
||||
useRef,
|
||||
useLayoutEffect,
|
||||
MutableRefObject,
|
||||
} from 'react'
|
||||
import { useEffect, ReactNode, useMemo, createContext, useCallback, useContext, memo } from 'react'
|
||||
import { AppPaneId } from './AppPaneMetadata'
|
||||
import { PaneController } from '../../Controllers/PaneController/PaneController'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { PaneLayout } from '@/Controllers/PaneController/PaneLayout'
|
||||
import { useStateRef } from './useStateRef'
|
||||
|
||||
type ResponsivePaneData = {
|
||||
selectedPane: AppPaneId
|
||||
@@ -54,16 +44,6 @@ type ProviderProps = {
|
||||
paneController: PaneController
|
||||
} & ChildrenProps
|
||||
|
||||
function useStateRef<State>(state: State): MutableRefObject<State> {
|
||||
const ref = useRef<State>(state)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
ref.current = state
|
||||
}, [state])
|
||||
|
||||
return ref
|
||||
}
|
||||
|
||||
const MemoizedChildren = memo(({ children }: ChildrenProps) => <>{children}</>)
|
||||
|
||||
const ResponsivePaneProvider = ({ paneController, children }: ProviderProps) => {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useRef, useLayoutEffect, MutableRefObject } from 'react'
|
||||
|
||||
export function useStateRef<State>(state: State): MutableRefObject<State> {
|
||||
const ref = useRef<State>(state)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
ref.current = state
|
||||
}, [state])
|
||||
|
||||
return ref
|
||||
}
|
||||
@@ -69,6 +69,7 @@ export class ViewControllerManager implements InternalEventHandlerInterface {
|
||||
readonly paneController: PaneController
|
||||
|
||||
public isSessionsModalVisible = false
|
||||
public isImportModalVisible = observable.box(false)
|
||||
|
||||
private appEventObserverRemovers: (() => void)[] = []
|
||||
private eventBus: InternalEventBus
|
||||
|
||||
Reference in New Issue
Block a user