From 6e4bf2417abbd88cbac1fd34ae8306041fa2279f Mon Sep 17 00:00:00 2001 From: Aman Harwara Date: Fri, 30 Dec 2022 01:22:59 +0530 Subject: [PATCH] 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 --- packages/icons/src/Icons/ic-aegis.svg | 4 + packages/icons/src/Icons/ic-evernote.svg | 4 + packages/icons/src/Icons/ic-gkeep.svg | 4 + packages/icons/src/Icons/ic-simplenote.svg | 4 + packages/icons/src/Icons/index.ts | 8 + .../AegisToAuthenticatorConverter.ts | 41 ++-- .../EvernoteConverter/EvernoteConverter.ts | 10 +- .../GoogleKeepConverter.ts | 22 +- packages/ui-services/src/Import/Importer.ts | 93 ++++++- .../PlaintextConverter/PlaintextConverter.ts | 36 +++ .../SimplenoteConverter.ts | 38 +-- packages/ui-services/src/Import/index.ts | 7 + packages/ui-services/src/index.ts | 5 +- .../AccountMenu/GeneralAccountMenu.tsx | 7 +- .../AccountMenu/ImportMenuOption.tsx | 115 --------- .../ApplicationView/ApplicationView.tsx | 2 + .../Components/FileDragNDropProvider.tsx | 27 ++- .../Components/Icon/IconNameToSvgMapping.tsx | 4 + .../Components/ImportModal/ImportModal.tsx | 226 ++++++++++++++++++ .../ImportModal/ImportModalFileItem.tsx | 146 +++++++++++ .../Components/ImportModal/InitialPage.tsx | 93 +++++++ .../Components/ImportModal/Types.ts | 30 +++ .../Panes/ResponsivePaneProvider.tsx | 24 +- .../Components/Panes/useStateRef.tsx | 11 + .../Controllers/ViewControllerManager.ts | 1 + 25 files changed, 755 insertions(+), 207 deletions(-) create mode 100644 packages/icons/src/Icons/ic-aegis.svg create mode 100644 packages/icons/src/Icons/ic-evernote.svg create mode 100644 packages/icons/src/Icons/ic-gkeep.svg create mode 100644 packages/icons/src/Icons/ic-simplenote.svg create mode 100644 packages/ui-services/src/Import/PlaintextConverter/PlaintextConverter.ts create mode 100644 packages/ui-services/src/Import/index.ts delete mode 100644 packages/web/src/javascripts/Components/AccountMenu/ImportMenuOption.tsx create mode 100644 packages/web/src/javascripts/Components/ImportModal/ImportModal.tsx create mode 100644 packages/web/src/javascripts/Components/ImportModal/ImportModalFileItem.tsx create mode 100644 packages/web/src/javascripts/Components/ImportModal/InitialPage.tsx create mode 100644 packages/web/src/javascripts/Components/ImportModal/Types.ts create mode 100644 packages/web/src/javascripts/Components/Panes/useStateRef.tsx diff --git a/packages/icons/src/Icons/ic-aegis.svg b/packages/icons/src/Icons/ic-aegis.svg new file mode 100644 index 000000000..1f6123029 --- /dev/null +++ b/packages/icons/src/Icons/ic-aegis.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/packages/icons/src/Icons/ic-evernote.svg b/packages/icons/src/Icons/ic-evernote.svg new file mode 100644 index 000000000..728c50c44 --- /dev/null +++ b/packages/icons/src/Icons/ic-evernote.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/packages/icons/src/Icons/ic-gkeep.svg b/packages/icons/src/Icons/ic-gkeep.svg new file mode 100644 index 000000000..206b3e5b7 --- /dev/null +++ b/packages/icons/src/Icons/ic-gkeep.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/packages/icons/src/Icons/ic-simplenote.svg b/packages/icons/src/Icons/ic-simplenote.svg new file mode 100644 index 000000000..06e670794 --- /dev/null +++ b/packages/icons/src/Icons/ic-simplenote.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/packages/icons/src/Icons/index.ts b/packages/icons/src/Icons/index.ts index c502e3a2f..9c00bb800 100644 --- a/packages/icons/src/Icons/index.ts +++ b/packages/icons/src/Icons/index.ts @@ -201,6 +201,10 @@ import UserSwitch from './ic-user-switch.svg' import ViewIcon from './ic-view.svg' import WarningIcon from './ic-warning.svg' import WindowIcon from './ic-window.svg' +import EvernoteIcon from './ic-evernote.svg' +import GoogleKeepIcon from './ic-gkeep.svg' +import SimplenoteIcon from './ic-simplenote.svg' +import AegisIcon from './ic-aegis.svg' export { AccessibilityIcon, @@ -406,4 +410,8 @@ export { ViewIcon, WarningIcon, WindowIcon, + EvernoteIcon, + GoogleKeepIcon, + SimplenoteIcon, + AegisIcon, } diff --git a/packages/ui-services/src/Import/AegisConverter/AegisToAuthenticatorConverter.ts b/packages/ui-services/src/Import/AegisConverter/AegisToAuthenticatorConverter.ts index 41ec6017f..45c62f2e8 100644 --- a/packages/ui-services/src/Import/AegisConverter/AegisToAuthenticatorConverter.ts +++ b/packages/ui-services/src/Import/AegisConverter/AegisToAuthenticatorConverter.ts @@ -3,7 +3,6 @@ import { ContentType } from '@standardnotes/common' import { readFileAsText } from '../Utils' import { FeatureIdentifier, NoteType } from '@standardnotes/features' import { WebApplicationInterface } from '@standardnotes/services' -import { Importer } from '../Importer' type AegisData = { db: { @@ -18,6 +17,8 @@ type AegisData = { } } +const AegisEntryTypes = ['hotp', 'totp', 'steam', 'yandex'] as const + type AuthenticatorEntry = { service: string account: string @@ -25,9 +26,26 @@ type AuthenticatorEntry = { notes: string } -export class AegisToAuthenticatorConverter extends Importer { - constructor(protected override application: WebApplicationInterface) { - super(application) +export class AegisToAuthenticatorConverter { + constructor(protected application: WebApplicationInterface) {} + + static isValidAegisJson(json: any): boolean { + return json.db && json.db.entries && json.db.entries.every((entry: any) => AegisEntryTypes.includes(entry.type)) + } + + async convertAegisBackupFileToNote( + file: File, + addEditorInfo: boolean, + ): Promise> { + const content = await readFileAsText(file) + + const entries = this.parseEntries(content) + + if (!entries) { + throw new Error('Could not parse entries') + } + + return this.createNoteFromEntries(entries, file, addEditorInfo) } createNoteFromEntries( @@ -57,21 +75,6 @@ export class AegisToAuthenticatorConverter extends Importer { } } - async convertAegisBackupFileToNote( - file: File, - addEditorInfo: boolean, - ): Promise> { - const content = await readFileAsText(file) - - const entries = this.parseEntries(content) - - if (!entries) { - throw new Error('Could not parse entries') - } - - return this.createNoteFromEntries(entries, file, addEditorInfo) - } - parseEntries(data: string): AuthenticatorEntry[] | null { try { const json = JSON.parse(data) as AegisData diff --git a/packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.ts b/packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.ts index d6e0cc68f..6d5309c91 100644 --- a/packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.ts +++ b/packages/ui-services/src/Import/EvernoteConverter/EvernoteConverter.ts @@ -2,7 +2,6 @@ import { ContentType } from '@standardnotes/common' import { WebApplicationInterface } from '@standardnotes/services' import { DecryptedTransferPayload, NoteContent, TagContent } from '@standardnotes/models' import { readFileAsText } from '../Utils' -import { Importer } from '../Importer' import dayjs from 'dayjs' import customParseFormat from 'dayjs/plugin/customParseFormat' import utc from 'dayjs/plugin/utc' @@ -11,10 +10,8 @@ dayjs.extend(utc) const dateFormat = 'YYYYMMDDTHHmmss' -export class EvernoteConverter extends Importer { - constructor(protected override application: WebApplicationInterface) { - super(application) - } +export class EvernoteConverter { + constructor(protected application: WebApplicationInterface) {} async convertENEXFileToNotesAndTags(file: File, stripHTML: boolean): Promise { const content = await readFileAsText(file) @@ -138,6 +135,9 @@ export class EvernoteConverter extends Importer { } const allItems: DecryptedTransferPayload[] = [...notes, ...tags] + if (allItems.length === 0) { + throw new Error('Could not parse any notes or tags from Evernote file.') + } if (defaultTag) { allItems.push(defaultTag) } diff --git a/packages/ui-services/src/Import/GoogleKeepConverter/GoogleKeepConverter.ts b/packages/ui-services/src/Import/GoogleKeepConverter/GoogleKeepConverter.ts index d3ce3f4c9..909dc0bd5 100644 --- a/packages/ui-services/src/Import/GoogleKeepConverter/GoogleKeepConverter.ts +++ b/packages/ui-services/src/Import/GoogleKeepConverter/GoogleKeepConverter.ts @@ -2,7 +2,6 @@ import { WebApplicationInterface } from '@standardnotes/services' import { ContentType } from '@standardnotes/common' import { DecryptedTransferPayload, NoteContent } from '@standardnotes/models' import { readFileAsText } from '../Utils' -import { Importer } from '../Importer' type GoogleKeepJsonNote = { color: string @@ -14,10 +13,8 @@ type GoogleKeepJsonNote = { userEditedTimestampUsec: number } -export class GoogleKeepConverter extends Importer { - constructor(protected override application: WebApplicationInterface) { - super(application) - } +export class GoogleKeepConverter { + constructor(protected application: WebApplicationInterface) {} async convertGoogleKeepBackupFileToNote( file: File, @@ -99,9 +96,24 @@ export class GoogleKeepConverter extends Importer { return } + static isValidGoogleKeepJson(json: any): boolean { + return ( + json.title && + json.textContent && + json.userEditedTimestampUsec && + typeof json.isArchived === 'boolean' && + typeof json.isTrashed === 'boolean' && + typeof json.isPinned === 'boolean' && + json.color + ) + } + tryParseAsJson(data: string): DecryptedTransferPayload | null { try { const parsed = JSON.parse(data) as GoogleKeepJsonNote + if (!GoogleKeepConverter.isValidGoogleKeepJson(parsed)) { + return null + } const date = new Date(parsed.userEditedTimestampUsec / 1000) return { created_at: date, diff --git a/packages/ui-services/src/Import/Importer.ts b/packages/ui-services/src/Import/Importer.ts index a7999175c..2d940fd0c 100644 --- a/packages/ui-services/src/Import/Importer.ts +++ b/packages/ui-services/src/Import/Importer.ts @@ -1,14 +1,91 @@ -import { WebApplicationInterface } from '@standardnotes/services' -import { DecryptedTransferPayload } from '@standardnotes/snjs' +import { parseFileName } from '@standardnotes/filepicker' +import { FeatureStatus, WebApplicationInterface } from '@standardnotes/services' +import { FeatureIdentifier } from '@standardnotes/features' +import { AegisToAuthenticatorConverter } from './AegisConverter/AegisToAuthenticatorConverter' +import { EvernoteConverter } from './EvernoteConverter/EvernoteConverter' +import { GoogleKeepConverter } from './GoogleKeepConverter/GoogleKeepConverter' +import { PlaintextConverter } from './PlaintextConverter/PlaintextConverter' +import { SimplenoteConverter } from './SimplenoteConverter/SimplenoteConverter' +import { readFileAsText } from './Utils' +import { DecryptedTransferPayload } from '@standardnotes/models' + +export type NoteImportType = 'plaintext' | 'evernote' | 'google-keep' | 'simplenote' | 'aegis' export class Importer { - constructor(protected application: WebApplicationInterface) {} + aegisConverter: AegisToAuthenticatorConverter + googleKeepConverter: GoogleKeepConverter + simplenoteConverter: SimplenoteConverter + plaintextConverter: PlaintextConverter + evernoteConverter: EvernoteConverter - async importFromTransferPayloads(payloads: DecryptedTransferPayload[]): Promise { - for (const payload of payloads) { - const itemPayload = this.application.items.createPayloadFromObject(payload) - const item = this.application.items.createItemFromPayload(itemPayload) - await this.application.mutator.insertItem(item) + constructor(protected application: WebApplicationInterface) { + this.aegisConverter = new AegisToAuthenticatorConverter(application) + this.googleKeepConverter = new GoogleKeepConverter(application) + this.simplenoteConverter = new SimplenoteConverter(application) + this.plaintextConverter = new PlaintextConverter(application) + this.evernoteConverter = new EvernoteConverter(application) + } + + static detectService = async (file: File): Promise => { + const content = await readFileAsText(file) + + const { ext } = parseFileName(file.name) + + if (ext === 'enex') { + return 'evernote' } + + try { + const json = JSON.parse(content) + + if (AegisToAuthenticatorConverter.isValidAegisJson(json)) { + return 'aegis' + } + + if (GoogleKeepConverter.isValidGoogleKeepJson(json)) { + return 'google-keep' + } + + if (SimplenoteConverter.isValidSimplenoteJson(json)) { + return 'simplenote' + } + } catch { + /* empty */ + } + + if (PlaintextConverter.isValidPlaintextFile(file)) { + return 'plaintext' + } + + return null + } + + async getPayloadsFromFile(file: File, type: NoteImportType): Promise { + if (type === 'aegis') { + const isEntitledToAuthenticator = + this.application.features.getFeatureStatus(FeatureIdentifier.TokenVaultEditor) === FeatureStatus.Entitled + return [await this.aegisConverter.convertAegisBackupFileToNote(file, isEntitledToAuthenticator)] + } else if (type === 'google-keep') { + return [await this.googleKeepConverter.convertGoogleKeepBackupFileToNote(file, true)] + } else if (type === 'simplenote') { + return await this.simplenoteConverter.convertSimplenoteBackupFileToNotes(file) + } else if (type === 'evernote') { + return await this.evernoteConverter.convertENEXFileToNotesAndTags(file, false) + } else if (type === 'plaintext') { + return [await this.plaintextConverter.convertPlaintextFileToNote(file)] + } + + return [] + } + + async importFromTransferPayloads(payloads: DecryptedTransferPayload[]) { + const insertedItems = await Promise.all( + payloads.map(async (payload) => { + const itemPayload = this.application.items.createPayloadFromObject(payload) + const item = this.application.items.createItemFromPayload(itemPayload) + return this.application.mutator.insertItem(item) + }), + ) + return insertedItems } } diff --git a/packages/ui-services/src/Import/PlaintextConverter/PlaintextConverter.ts b/packages/ui-services/src/Import/PlaintextConverter/PlaintextConverter.ts new file mode 100644 index 000000000..c132dd23a --- /dev/null +++ b/packages/ui-services/src/Import/PlaintextConverter/PlaintextConverter.ts @@ -0,0 +1,36 @@ +import { ContentType } from '@standardnotes/common' +import { parseFileName } from '@standardnotes/filepicker' +import { DecryptedTransferPayload, NoteContent } from '@standardnotes/models' +import { WebApplicationInterface } from '@standardnotes/services' +import { readFileAsText } from '../Utils' + +export class PlaintextConverter { + constructor(protected application: WebApplicationInterface) {} + + static isValidPlaintextFile(file: File): boolean { + return file.type === 'text/plain' || file.type === 'text/markdown' + } + + async convertPlaintextFileToNote(file: File): Promise> { + const content = await readFileAsText(file) + + const { name } = parseFileName(file.name) + + const createdAtDate = file.lastModified ? new Date(file.lastModified) : new Date() + const updatedAtDate = file.lastModified ? new Date(file.lastModified) : new Date() + + return { + created_at: createdAtDate, + created_at_timestamp: createdAtDate.getTime(), + updated_at: updatedAtDate, + updated_at_timestamp: updatedAtDate.getTime(), + uuid: this.application.generateUUID(), + content_type: ContentType.Note, + content: { + title: name, + text: content, + references: [], + }, + } + } +} diff --git a/packages/ui-services/src/Import/SimplenoteConverter/SimplenoteConverter.ts b/packages/ui-services/src/Import/SimplenoteConverter/SimplenoteConverter.ts index 4eef5b3e7..8b3ffd4b5 100644 --- a/packages/ui-services/src/Import/SimplenoteConverter/SimplenoteConverter.ts +++ b/packages/ui-services/src/Import/SimplenoteConverter/SimplenoteConverter.ts @@ -2,7 +2,6 @@ import { DecryptedTransferPayload, NoteContent } from '@standardnotes/models' import { ContentType } from '@standardnotes/common' import { readFileAsText } from '../Utils' import { WebApplicationInterface } from '@standardnotes/services' -import { Importer } from '../Importer' type SimplenoteItem = { creationDate: string @@ -15,9 +14,28 @@ type SimplenoteData = { trashedNotes: SimplenoteItem[] } -export class SimplenoteConverter extends Importer { - constructor(protected override application: WebApplicationInterface) { - super(application) +const isSimplenoteEntry = (entry: any): boolean => entry.id && entry.content && entry.creationDate && entry.lastModified + +export class SimplenoteConverter { + constructor(protected application: WebApplicationInterface) {} + + static isValidSimplenoteJson(json: any): boolean { + return ( + (json.activeNotes && json.activeNotes.every(isSimplenoteEntry)) || + (json.trashedNotes && json.trashedNotes.every(isSimplenoteEntry)) + ) + } + + async convertSimplenoteBackupFileToNotes(file: File): Promise[]> { + const content = await readFileAsText(file) + + const notes = this.parse(content) + + if (!notes) { + throw new Error('Could not parse notes') + } + + return notes } createNoteFromItem(item: SimplenoteItem, trashed: boolean): DecryptedTransferPayload { @@ -51,18 +69,6 @@ export class SimplenoteConverter extends Importer { } } - async convertSimplenoteBackupFileToNotes(file: File): Promise[]> { - const content = await readFileAsText(file) - - const notes = this.parse(content) - - if (!notes) { - throw new Error('Could not parse notes') - } - - return notes - } - parse(data: string) { try { const parsed = JSON.parse(data) as SimplenoteData diff --git a/packages/ui-services/src/Import/index.ts b/packages/ui-services/src/Import/index.ts new file mode 100644 index 000000000..514616fe6 --- /dev/null +++ b/packages/ui-services/src/Import/index.ts @@ -0,0 +1,7 @@ +export * from './AegisConverter/AegisToAuthenticatorConverter' +export * from './SimplenoteConverter/SimplenoteConverter' +export * from './GoogleKeepConverter/GoogleKeepConverter' +export * from './EvernoteConverter/EvernoteConverter' +export * from './PlaintextConverter/PlaintextConverter' +export * from './Utils' +export * from './Importer' diff --git a/packages/ui-services/src/index.ts b/packages/ui-services/src/index.ts index 9cb7c7bda..9e397f7df 100644 --- a/packages/ui-services/src/index.ts +++ b/packages/ui-services/src/index.ts @@ -32,7 +32,4 @@ export * from './Theme/ThemeManager' export * from './Toast/ToastService' export * from './Toast/ToastServiceInterface' export * from './StatePersistence/StatePersistence' -export * from './Import/AegisConverter/AegisToAuthenticatorConverter' -export * from './Import/SimplenoteConverter/SimplenoteConverter' -export * from './Import/GoogleKeepConverter/GoogleKeepConverter' -export * from './Import/EvernoteConverter/EvernoteConverter' +export * from './Import' diff --git a/packages/web/src/javascripts/Components/AccountMenu/GeneralAccountMenu.tsx b/packages/web/src/javascripts/Components/AccountMenu/GeneralAccountMenu.tsx index ac6a10fdf..1ad15b5aa 100644 --- a/packages/web/src/javascripts/Components/AccountMenu/GeneralAccountMenu.tsx +++ b/packages/web/src/javascripts/Components/AccountMenu/GeneralAccountMenu.tsx @@ -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 = ({ Open FileSend - {featureTrunkEnabled(FeatureTrunkName.ImportTools) && } + viewControllerManager.isImportModalVisible.set(true)}> + + Import + {user ? ( <> diff --git a/packages/web/src/javascripts/Components/AccountMenu/ImportMenuOption.tsx b/packages/web/src/javascripts/Components/AccountMenu/ImportMenuOption.tsx deleted file mode 100644 index e11bada17..000000000 --- a/packages/web/src/javascripts/Components/AccountMenu/ImportMenuOption.tsx +++ /dev/null @@ -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(null) - const [isMenuOpen, setIsMenuOpen] = useState(false) - - const togglePopover = () => { - setIsMenuOpen((isOpen) => !isOpen) - } - - return ( - <> - - - Import - - - - - { - setIsMenuOpen((isOpen) => !isOpen) - }} - > - - Plaintext - - { - 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]) - }) - }} - > - - Google Keep - - { - 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) - }) - }} - > - - Evernote - - { - const files = await ClassicFileReader.selectFiles() - files.forEach(async (file) => { - const converter = new SimplenoteConverter(application) - const noteTransferPayloads = await converter.convertSimplenoteBackupFileToNotes(file) - void converter.importFromTransferPayloads(noteTransferPayloads) - }) - }} - > - - Simplenote - - { - 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]) - }) - }} - > - - Aegis - - - - - ) -} - -export default ImportMenuOption diff --git a/packages/web/src/javascripts/Components/ApplicationView/ApplicationView.tsx b/packages/web/src/javascripts/Components/ApplicationView/ApplicationView.tsx index 4f760c473..78db68a34 100644 --- a/packages/web/src/javascripts/Components/ApplicationView/ApplicationView.tsx +++ b/packages/web/src/javascripts/Components/ApplicationView/ApplicationView.tsx @@ -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 = ({ application, mainApplicatio application={application} viewControllerManager={viewControllerManager} /> + {application.routeService.isDotOrg && } diff --git a/packages/web/src/javascripts/Components/FileDragNDropProvider.tsx b/packages/web/src/javascripts/Components/FileDragNDropProvider.tsx index e98e293b6..323e607f1 100644 --- a/packages/web/src/javascripts/Components/FileDragNDropProvider.tsx +++ b/packages/web/src/javascripts/Components/FileDragNDropProvider.tsx @@ -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]) diff --git a/packages/web/src/javascripts/Components/Icon/IconNameToSvgMapping.tsx b/packages/web/src/javascripts/Components/Icon/IconNameToSvgMapping.tsx index 34ff490d4..5c4d522f1 100644 --- a/packages/web/src/javascripts/Components/Icon/IconNameToSvgMapping.tsx +++ b/packages/web/src/javascripts/Components/Icon/IconNameToSvgMapping.tsx @@ -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, } diff --git a/packages/web/src/javascripts/Components/ImportModal/ImportModal.tsx b/packages/web/src/javascripts/Components/ImportModal/ImportModal.tsx new file mode 100644 index 000000000..abdf0368b --- /dev/null +++ b/packages/web/src/javascripts/Components/ImportModal/ImportModal.tsx @@ -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 = { + 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 ( + + Import + + {!files.length && } + {files.length > 0 && ( +
+ {files.map((file) => ( + + ))} +
+ )} +
+ + {files.length > 0 && files.every((file) => file.status === 'ready') && ( + + )} + + +
+ ) +} + +export default observer(ImportModal) diff --git a/packages/web/src/javascripts/Components/ImportModal/ImportModalFileItem.tsx b/packages/web/src/javascripts/Components/ImportModal/ImportModalFileItem.tsx new file mode 100644 index 000000000..31bc3b280 --- /dev/null +++ b/packages/web/src/javascripts/Components/ImportModal/ImportModalFileItem.tsx @@ -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 = { + 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 = { + evernote: 'evernote', + simplenote: 'simplenote', + 'google-keep': 'gkeep', + aegis: 'aegis', + plaintext: 'plain-text', +} + +export const ImportModalFileItem = ({ + file, + dispatch, + importer, +}: { + file: ImportModalFile + dispatch: Dispatch + 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 ( +
+
+ {file.service && ( +
+ +
+ )} +
+
{file.file.name}
+
+ {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} +
+
+
+ {file.service == null && ( +
+
{ + event.preventDefault() + const form = event.target as HTMLFormElement + const service = form.elements[0] as HTMLSelectElement + void setFileService(service.value as NoteImportType) + }} + > + + +
+ +
+ )} + {file.status === 'success' && } + {file.status === 'error' && } +
+ ) +} diff --git a/packages/web/src/javascripts/Components/ImportModal/InitialPage.tsx b/packages/web/src/javascripts/Components/ImportModal/InitialPage.tsx new file mode 100644 index 000000000..101aa2b61 --- /dev/null +++ b/packages/web/src/javascripts/Components/ImportModal/InitialPage.tsx @@ -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 +} + +const ImportModalInitialPage = ({ dispatch }: Props) => { + const selectFiles = useCallback( + async (service?: NoteImportType) => { + const files = await ClassicFileReader.selectFiles() + + dispatch({ + type: 'setFiles', + files, + service, + }) + }, + [dispatch], + ) + + return ( + <> + +
+
+
+ or import from: +
+
+
+ + + + + + +
+ + ) +} + +export default ImportModalInitialPage diff --git a/packages/web/src/javascripts/Components/ImportModal/Types.ts b/packages/web/src/javascripts/Components/ImportModal/Types.ts new file mode 100644 index 000000000..a56f439b3 --- /dev/null +++ b/packages/web/src/javascripts/Components/ImportModal/Types.ts @@ -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' } diff --git a/packages/web/src/javascripts/Components/Panes/ResponsivePaneProvider.tsx b/packages/web/src/javascripts/Components/Panes/ResponsivePaneProvider.tsx index fb727b77e..41864f6f6 100644 --- a/packages/web/src/javascripts/Components/Panes/ResponsivePaneProvider.tsx +++ b/packages/web/src/javascripts/Components/Panes/ResponsivePaneProvider.tsx @@ -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): MutableRefObject { - const ref = useRef(state) - - useLayoutEffect(() => { - ref.current = state - }, [state]) - - return ref -} - const MemoizedChildren = memo(({ children }: ChildrenProps) => <>{children}) const ResponsivePaneProvider = ({ paneController, children }: ProviderProps) => { diff --git a/packages/web/src/javascripts/Components/Panes/useStateRef.tsx b/packages/web/src/javascripts/Components/Panes/useStateRef.tsx new file mode 100644 index 000000000..b43ed1243 --- /dev/null +++ b/packages/web/src/javascripts/Components/Panes/useStateRef.tsx @@ -0,0 +1,11 @@ +import { useRef, useLayoutEffect, MutableRefObject } from 'react' + +export function useStateRef(state: State): MutableRefObject { + const ref = useRef(state) + + useLayoutEffect(() => { + ref.current = state + }, [state]) + + return ref +} diff --git a/packages/web/src/javascripts/Controllers/ViewControllerManager.ts b/packages/web/src/javascripts/Controllers/ViewControllerManager.ts index 89b7dd432..b91d96b74 100644 --- a/packages/web/src/javascripts/Controllers/ViewControllerManager.ts +++ b/packages/web/src/javascripts/Controllers/ViewControllerManager.ts @@ -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