diff --git a/packages/web/src/javascripts/Components/AccountMenu/GeneralAccountMenu.tsx b/packages/web/src/javascripts/Components/AccountMenu/GeneralAccountMenu.tsx index b5c172e36..d1581ba63 100644 --- a/packages/web/src/javascripts/Components/AccountMenu/GeneralAccountMenu.tsx +++ b/packages/web/src/javascripts/Components/AccountMenu/GeneralAccountMenu.tsx @@ -165,7 +165,12 @@ const GeneralAccountMenu: FunctionComponent = ({ )} - viewControllerManager.isImportModalVisible.set(true)}> + { + viewControllerManager.importModalController.setIsVisible(true) + viewControllerManager.accountMenuController.closeAccountMenu() + }} + > Import diff --git a/packages/web/src/javascripts/Components/ApplicationView/ApplicationView.tsx b/packages/web/src/javascripts/Components/ApplicationView/ApplicationView.tsx index e703dbaa9..47caa73ab 100644 --- a/packages/web/src/javascripts/Components/ApplicationView/ApplicationView.tsx +++ b/packages/web/src/javascripts/Components/ApplicationView/ApplicationView.tsx @@ -272,7 +272,7 @@ const ApplicationView: FunctionComponent = ({ application, mainApplicatio application={application} viewControllerManager={viewControllerManager} /> - + {application.routeService.isDotOrg && } {isIOS() && } diff --git a/packages/web/src/javascripts/Components/ImportModal/ImportModal.tsx b/packages/web/src/javascripts/Components/ImportModal/ImportModal.tsx index 51a531f85..4f4905ce0 100644 --- a/packages/web/src/javascripts/Components/ImportModal/ImportModal.tsx +++ b/packages/web/src/javascripts/Components/ImportModal/ImportModal.tsx @@ -1,191 +1,13 @@ -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, useMemo, useReducer, useState } from 'react' -import { useApplication } from '../ApplicationProvider' -import { useStateRef } from '@/Hooks/useStateRef' -import { ImportModalFileItem } from './ImportModalFileItem' +import { useMemo } from 'react' +import ImportModalFileItem from './ImportModalFileItem' import ImportModalInitialPage from './InitialPage' -import { ImportModalAction, ImportModalFile, ImportModalState } from './Types' import Modal, { ModalAction } from '../Modal/Modal' import ModalOverlay from '../Modal/ModalOverlay' +import { ImportModalController } from '@/Controllers/ImportModalController' -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]) +const ImportModal = ({ importModalController }: { importModalController: ImportModalController }) => { + const { files, setFiles, updateFile, removeFile, importer, parseAndImport, isVisible, close } = importModalController const isReadyToImport = files.length > 0 && files.every((file) => file.status === 'ready') const importSuccessOrError = @@ -203,22 +25,28 @@ const ImportModal = ({ viewControllerManager }: { viewControllerManager: ViewCon { label: importSuccessOrError ? 'Close' : 'Cancel', type: 'cancel', - onClick: closeDialog, + onClick: close, mobileSlot: 'left', }, ], - [closeDialog, importSuccessOrError, isReadyToImport, parseAndImport], + [close, importSuccessOrError, isReadyToImport, parseAndImport], ) return ( - - + +
- {!files.length && } + {!files.length && } {files.length > 0 && (
{files.map((file) => ( - + ))}
)} diff --git a/packages/web/src/javascripts/Components/ImportModal/ImportModalFileItem.tsx b/packages/web/src/javascripts/Components/ImportModal/ImportModalFileItem.tsx index 31bc3b280..df6147051 100644 --- a/packages/web/src/javascripts/Components/ImportModal/ImportModalFileItem.tsx +++ b/packages/web/src/javascripts/Components/ImportModal/ImportModalFileItem.tsx @@ -1,8 +1,9 @@ +import { ImportModalController, ImportModalFile } from '@/Controllers/ImportModalController' import { classNames, ContentType, DecryptedTransferPayload, pluralize } from '@standardnotes/snjs' import { Importer, NoteImportType } from '@standardnotes/ui-services' -import { Dispatch, useCallback, useEffect } from 'react' +import { observer } from 'mobx-react-lite' +import { useCallback, useEffect } from 'react' import Icon from '../Icon/Icon' -import { ImportModalAction, ImportModalFile } from './Types' const NoteImportTypeColors: Record = { evernote: 'bg-[#14cc45] text-[#000]', @@ -20,13 +21,15 @@ const NoteImportTypeIcons: Record = { plaintext: 'plain-text', } -export const ImportModalFileItem = ({ +const ImportModalFileItem = ({ file, - dispatch, + updateFile, + removeFile, importer, }: { file: ImportModalFile - dispatch: Dispatch + updateFile: ImportModalController['updateFile'] + removeFile: ImportModalController['removeFile'] importer: Importer }) => { const setFileService = useCallback( @@ -34,21 +37,18 @@ export const ImportModalFileItem = ({ let payloads: DecryptedTransferPayload[] | undefined try { payloads = service ? await importer.getPayloadsFromFile(file.file, service) : undefined - } catch { - // + } catch (error) { + console.error(error) } - dispatch({ - type: 'updateFile', - file: { - ...file, - service, - status: service ? 'ready' : 'pending', - payloads, - }, + updateFile({ + ...file, + service, + status: service ? 'ready' : 'pending', + payloads, }) }, - [dispatch, file, importer], + [file, importer, updateFile], ) useEffect(() => { @@ -59,7 +59,7 @@ export const ImportModalFileItem = ({ if (file.service === undefined) { void detect() } - }, [dispatch, file, setFileService]) + }, [file, setFileService]) const notePayloads = file.status === 'ready' && file.payloads @@ -129,10 +129,7 @@ export const ImportModalFileItem = ({
) } + +export default observer(ImportModalFileItem) diff --git a/packages/web/src/javascripts/Components/ImportModal/InitialPage.tsx b/packages/web/src/javascripts/Components/ImportModal/InitialPage.tsx index 101aa2b61..2749aa7ef 100644 --- a/packages/web/src/javascripts/Components/ImportModal/InitialPage.tsx +++ b/packages/web/src/javascripts/Components/ImportModal/InitialPage.tsx @@ -1,26 +1,23 @@ +import { ImportModalController } from '@/Controllers/ImportModalController' import { ClassicFileReader } from '@standardnotes/filepicker' import { NoteImportType } from '@standardnotes/ui-services' +import { observer } from 'mobx-react-lite' import { useCallback } from 'react' import Button from '../Button/Button' import Icon from '../Icon/Icon' -import { ImportModalAction } from './Types' type Props = { - dispatch: React.Dispatch + setFiles: ImportModalController['setFiles'] } -const ImportModalInitialPage = ({ dispatch }: Props) => { +const ImportModalInitialPage = ({ setFiles }: Props) => { const selectFiles = useCallback( async (service?: NoteImportType) => { const files = await ClassicFileReader.selectFiles() - dispatch({ - type: 'setFiles', - files, - service, - }) + setFiles(files, service) }, - [dispatch], + [setFiles], ) return ( @@ -33,10 +30,7 @@ const ImportModalInitialPage = ({ dispatch }: Props) => { onDrop={(e) => { e.preventDefault() const files = Array.from(e.dataTransfer.files) - dispatch({ - type: 'setFiles', - files, - }) + setFiles(files) }} >
Drag and drop files to auto-detect and import
@@ -90,4 +84,4 @@ const ImportModalInitialPage = ({ dispatch }: Props) => { ) } -export default ImportModalInitialPage +export default observer(ImportModalInitialPage) diff --git a/packages/web/src/javascripts/Components/ImportModal/Types.ts b/packages/web/src/javascripts/Components/ImportModal/Types.ts deleted file mode 100644 index a56f439b3..000000000 --- a/packages/web/src/javascripts/Components/ImportModal/Types.ts +++ /dev/null @@ -1,30 +0,0 @@ -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/Controllers/ImportModalController.ts b/packages/web/src/javascripts/Controllers/ImportModalController.ts new file mode 100644 index 000000000..884adad44 --- /dev/null +++ b/packages/web/src/javascripts/Controllers/ImportModalController.ts @@ -0,0 +1,176 @@ +import { WebApplication } from '@/Application/Application' +import { DecryptedTransferPayload, SNTag, TagContent } from '@standardnotes/models' +import { ContentType, pluralize, UuidGenerator } from '@standardnotes/snjs' +import { Importer, NoteImportType } from '@standardnotes/ui-services' +import { action, makeObservable, observable } from 'mobx' +import { NavigationController } from './Navigation/NavigationController' + +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 class ImportModalController { + isVisible = false + importer: Importer + files: ImportModalFile[] = [] + importTag: SNTag | undefined = undefined + + constructor(application: WebApplication, private navigationController: NavigationController) { + makeObservable(this, { + isVisible: observable, + setIsVisible: action, + + files: observable, + setFiles: action, + updateFile: action, + removeFile: action, + + importTag: observable, + setImportTag: action, + }) + + this.importer = new Importer(application) + } + + setIsVisible = (isVisible: boolean) => { + this.isVisible = isVisible + } + + setFiles = (files: File[], service?: NoteImportType) => { + this.files = files.map((file) => ({ + id: UuidGenerator.GenerateUuid(), + file, + service, + status: service ? 'ready' : 'pending', + })) + } + + updateFile = (file: ImportModalFile) => { + this.files = this.files.map((f) => (f.id === file.id ? file : f)) + } + + removeFile = (id: ImportModalFile['id']) => { + this.files = this.files.filter((f) => f.id !== id) + } + + setImportTag = (tag: SNTag | undefined) => { + this.importTag = tag + } + + close = () => { + this.setIsVisible(false) + if (this.importTag) { + this.navigationController + .setSelectedTag(this.importTag, 'all', { + userTriggered: true, + }) + .catch(console.error) + } + this.setFiles([]) + this.setImportTag(undefined) + } + + importFromPayloads = async (file: ImportModalFile, payloads: DecryptedTransferPayload[]) => { + this.updateFile({ + ...file, + status: 'importing', + }) + + try { + await this.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')}` : '') + + this.updateFile({ + ...file, + status: 'success', + successMessage, + }) + } catch (error) { + this.updateFile({ + ...file, + status: 'error', + error: error instanceof Error ? error : new Error('Could not import file'), + }) + console.error(error) + } + } + + parseAndImport = async () => { + if (this.files.length === 0) { + return + } + const importedPayloads: DecryptedTransferPayload[] = [] + for (const file of this.files) { + if (!file.service) { + return + } + + if (file.status === 'ready' && file.payloads) { + await this.importFromPayloads(file, file.payloads) + importedPayloads.push(...file.payloads) + continue + } + + this.updateFile({ + ...file, + status: 'parsing', + }) + + try { + const payloads = await this.importer.getPayloadsFromFile(file.file, file.service) + await this.importFromPayloads(file, payloads) + importedPayloads.push(...payloads) + } catch (error) { + this.updateFile({ + ...file, + status: 'error', + error: error instanceof Error ? error : new Error('Could not import file'), + }) + console.error(error) + } + } + 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 this.importer.importFromTransferPayloads([importTagPayload]) + if (importTag) { + this.setImportTag(importTag as SNTag) + } + } +} diff --git a/packages/web/src/javascripts/Controllers/ViewControllerManager.ts b/packages/web/src/javascripts/Controllers/ViewControllerManager.ts index 14446f3f6..832147323 100644 --- a/packages/web/src/javascripts/Controllers/ViewControllerManager.ts +++ b/packages/web/src/javascripts/Controllers/ViewControllerManager.ts @@ -40,6 +40,7 @@ import { PersistenceService } from './Abstract/PersistenceService' import { CrossControllerEvent } from './CrossControllerEvent' import { EventObserverInterface } from '@/Event/EventObserverInterface' import { ApplicationEventObserver } from '@/Event/ApplicationEventObserver' +import { ImportModalController } from './ImportModalController' export class ViewControllerManager implements InternalEventHandlerInterface { readonly enableUnfinishedFeatures: boolean = window?.enabledUnfinishedFeatures @@ -67,9 +68,9 @@ export class ViewControllerManager implements InternalEventHandlerInterface { readonly historyModalController: HistoryModalController readonly linkingController: LinkingController readonly paneController: PaneController + readonly importModalController: ImportModalController public isSessionsModalVisible = false - public isImportModalVisible = observable.box(false) private appEventObserverRemovers: (() => void)[] = [] private eventBus: InternalEventBus @@ -154,6 +155,8 @@ export class ViewControllerManager implements InternalEventHandlerInterface { this.historyModalController = new HistoryModalController(this.application, this.eventBus, this.notesController) + this.importModalController = new ImportModalController(this.application, this.navigationController) + this.toastService = new ToastService() this.applicationEventObserver = new ApplicationEventObserver(