feat: Allow exporting multiple Super notes and select what format to export them to (#2191)

This commit is contained in:
Aman Harwara
2023-02-01 00:47:28 +05:30
committed by GitHub
parent 5c17ae3c4e
commit 506a1e83f1
12 changed files with 185 additions and 9 deletions

Binary file not shown.

View File

@@ -43,6 +43,7 @@ export enum PrefKey {
DefaultEditorIdentifier = 'defaultEditorIdentifier', DefaultEditorIdentifier = 'defaultEditorIdentifier',
MomentsDefaultTagUuid = 'momentsDefaultTagUuid', MomentsDefaultTagUuid = 'momentsDefaultTagUuid',
SystemViewPreferences = 'systemViewPreferences', SystemViewPreferences = 'systemViewPreferences',
SuperNoteExportFormat = 'superNoteExportFormat',
} }
export enum NewNoteTitleFormat { export enum NewNoteTitleFormat {
@@ -109,4 +110,5 @@ export type PrefValue = {
[PrefKey.DefaultEditorIdentifier]: EditorIdentifier [PrefKey.DefaultEditorIdentifier]: EditorIdentifier
[PrefKey.MomentsDefaultTagUuid]: string | undefined [PrefKey.MomentsDefaultTagUuid]: string | undefined
[PrefKey.SystemViewPreferences]: Partial<Record<SystemViewId, TagPreferences>> [PrefKey.SystemViewPreferences]: Partial<Record<SystemViewId, TagPreferences>>
[PrefKey.SuperNoteExportFormat]: 'json' | 'md' | 'html'
} }

View File

@@ -15,6 +15,7 @@ export type IconType =
| 'arrow-right' | 'arrow-right'
| 'arrow-up' | 'arrow-up'
| 'arrows-horizontal' | 'arrows-horizontal'
| 'arrows-vertical'
| 'arrows-sort-down' | 'arrows-sort-down'
| 'arrows-sort-up' | 'arrows-sort-up'
| 'asterisk' | 'asterisk'

View File

@@ -114,5 +114,8 @@
"lint-staged": { "lint-staged": {
"app/**/*.{js,ts,jsx,tsx}": "eslint --cache --fix", "app/**/*.{js,ts,jsx,tsx}": "eslint --cache --fix",
"app/**/*.{js,ts,jsx,tsx,css,md}": "prettier --write" "app/**/*.{js,ts,jsx,tsx,css,md}": "prettier --write"
},
"dependencies": {
"@lexical/headless": "^0.7.6"
} }
} }

View File

@@ -8,6 +8,7 @@ export const IconNameToSvgMapping = {
'arrow-up': icons.ArrowUpIcon, 'arrow-up': icons.ArrowUpIcon,
'arrows-sort-down': icons.ArrowsSortDownIcon, 'arrows-sort-down': icons.ArrowsSortDownIcon,
'arrows-sort-up': icons.ArrowsSortUpIcon, 'arrows-sort-up': icons.ArrowsSortUpIcon,
'arrows-vertical': icons.ArrowsVerticalIcon,
'attachment-file': icons.AttachmentFileIcon, 'attachment-file': icons.AttachmentFileIcon,
'check-bold': icons.CheckBoldIcon, 'check-bold': icons.CheckBoldIcon,
'check-circle': icons.CheckCircleIcon, 'check-circle': icons.CheckCircleIcon,

View File

@@ -41,6 +41,7 @@ import ReadonlyPlugin from './Plugins/ReadonlyPlugin/ReadonlyPlugin'
import { SuperSearchContextProvider } from './Plugins/SearchPlugin/Context' import { SuperSearchContextProvider } from './Plugins/SearchPlugin/Context'
import { SearchPlugin } from './Plugins/SearchPlugin/SearchPlugin' import { SearchPlugin } from './Plugins/SearchPlugin/SearchPlugin'
import ModalOverlay from '@/Components/Modal/ModalOverlay' import ModalOverlay from '@/Components/Modal/ModalOverlay'
import { SuperEditorNodes } from './SuperEditorNodes'
const NotePreviewCharLimit = 160 const NotePreviewCharLimit = 160
@@ -170,7 +171,7 @@ export const SuperEditor: FunctionComponent<Props> = ({
<BlocksEditorComposer <BlocksEditorComposer
readonly={note.current.locked} readonly={note.current.locked}
initialValue={note.current.text} initialValue={note.current.text}
nodes={[FileNode, BubbleNode]} nodes={SuperEditorNodes}
> >
<BlocksEditor <BlocksEditor
onChange={handleChange} onChange={handleChange}

View File

@@ -0,0 +1,4 @@
import { FileNode } from './Plugins/EncryptedFilePlugin/Nodes/FileNode'
import { BubbleNode } from './Plugins/ItemBubblePlugin/Nodes/BubbleNode'
export const SuperEditorNodes = [FileNode, BubbleNode]

View File

@@ -0,0 +1,46 @@
import { createHeadlessEditor } from '@lexical/headless'
import { $convertToMarkdownString } from '@lexical/markdown'
import { MarkdownTransformers } from '@standardnotes/blocks-editor'
import { $generateHtmlFromNodes } from '@lexical/html'
import { BlockEditorNodes } from '@standardnotes/blocks-editor/src/Lexical/Nodes/AllNodes'
import BlocksEditorTheme from '@standardnotes/blocks-editor/src/Lexical/Theme/Theme'
import { SNNote } from '@standardnotes/models'
import { SuperEditorNodes } from './SuperEditorNodes'
export const exportSuperNote = (note: SNNote, format: 'txt' | 'md' | 'html' | 'json') => {
const headlessEditor = createHeadlessEditor({
namespace: 'BlocksEditor',
theme: BlocksEditorTheme,
editable: false,
onError: (error: Error) => console.error(error),
nodes: [...SuperEditorNodes, ...BlockEditorNodes],
})
headlessEditor.setEditorState(headlessEditor.parseEditorState(note.text))
let content: string | undefined
headlessEditor.update(() => {
switch (format) {
case 'md':
content = $convertToMarkdownString(MarkdownTransformers)
break
case 'html':
content = $generateHtmlFromNodes(headlessEditor)
break
case 'json':
content = JSON.stringify(headlessEditor.toJSON())
break
case 'txt':
default:
content = note.text
break
}
})
if (!content) {
throw new Error('Could not export note')
}
return content
}

View File

@@ -32,6 +32,8 @@ import { iconClass } from './ClassNames'
import SuperNoteOptions from './SuperNoteOptions' import SuperNoteOptions from './SuperNoteOptions'
import MenuSwitchButtonItem from '../Menu/MenuSwitchButtonItem' import MenuSwitchButtonItem from '../Menu/MenuSwitchButtonItem'
import MenuItem from '../Menu/MenuItem' import MenuItem from '../Menu/MenuItem'
import ModalOverlay from '../Modal/ModalOverlay'
import SuperExportModal from './SuperExportModal'
const iconSize = MenuItemIconSize const iconSize = MenuItemIconSize
const iconClassDanger = `text-danger mr-2 ${iconSize}` const iconClassDanger = `text-danger mr-2 ${iconSize}`
@@ -94,6 +96,11 @@ const NotesOptions = ({
} }
}, [application]) }, [application])
const [showExportSuperModal, setShowExportSuperModal] = useState(false)
const closeSuperExportModal = useCallback(() => {
setShowExportSuperModal(false)
}, [])
const downloadSelectedItems = useCallback(async () => { const downloadSelectedItems = useCallback(async () => {
if (notes.length === 1) { if (notes.length === 1) {
const note = notes[0] const note = notes[0]
@@ -165,6 +172,8 @@ const NotesOptions = ({
return null return null
} }
const isOnlySuperNoteSelected = notes.length === 1 && notes[0].noteType === NoteType.Super
return ( return (
<> <>
{notes.length === 1 && ( {notes.length === 1 && (
@@ -251,13 +260,22 @@ const NotesOptions = ({
{pinShortcut && <KeyboardShortcutIndicator className="ml-auto" shortcut={pinShortcut} />} {pinShortcut && <KeyboardShortcutIndicator className="ml-auto" shortcut={pinShortcut} />}
</MenuItem> </MenuItem>
)} )}
{notes[0].noteType !== NoteType.Super && ( {!isOnlySuperNoteSelected && (
<> <>
<MenuItem <MenuItem
onClick={() => { onClick={() => {
application.isNativeMobileWeb() if (application.isNativeMobileWeb()) {
? void shareSelectedNotes(application, notes) void shareSelectedNotes(application, notes)
: void downloadSelectedItems() } else {
const hasSuperNote = notes.some((note) => note.noteType === NoteType.Super)
if (hasSuperNote) {
setShowExportSuperModal(true)
return
}
void downloadSelectedItems()
}
}} }}
> >
<Icon type={application.platform === Platform.Android ? 'share' : 'download'} className={iconClass} /> <Icon type={application.platform === Platform.Android ? 'share' : 'download'} className={iconClass} />
@@ -374,6 +392,10 @@ const NotesOptions = ({
<NoteSizeWarning note={notes[0]} /> <NoteSizeWarning note={notes[0]} />
</> </>
) : null} ) : null}
<ModalOverlay isOpen={showExportSuperModal} onDismiss={closeSuperExportModal}>
<SuperExportModal exportNotes={downloadSelectedItems} close={closeSuperExportModal} />
</ModalOverlay>
</> </>
) )
} }

View File

@@ -0,0 +1,76 @@
import { ApplicationEvent, PrefKey, PrefValue } from '@standardnotes/snjs'
import { useEffect, useState } from 'react'
import { useApplication } from '../ApplicationProvider'
import Dropdown from '../Dropdown/Dropdown'
import Modal from '../Modal/Modal'
type Props = {
exportNotes: () => void
close: () => void
}
const SuperExportModal = ({ exportNotes, close }: Props) => {
const application = useApplication()
const [superNoteExportFormat, setSuperNoteExportFormat] = useState<PrefValue[PrefKey.SuperNoteExportFormat]>(
() => application.getPreference(PrefKey.SuperNoteExportFormat) || 'json',
)
useEffect(() => {
return application.addSingleEventObserver(ApplicationEvent.PreferencesChanged, async () => {
setSuperNoteExportFormat(application.getPreference(PrefKey.SuperNoteExportFormat) || 'json')
})
}, [application, superNoteExportFormat])
return (
<Modal
title="Export notes"
className={{
description: 'p-4',
}}
close={close}
actions={[
{
label: 'Cancel',
type: 'cancel',
onClick: close,
mobileSlot: 'left',
},
{
label: 'Export',
type: 'primary',
onClick: exportNotes,
mobileSlot: 'right',
},
]}
>
<div className="mb-4">
<div className="mb-1 text-base">
We detected your selection includes Super notes. How do you want to export them?
</div>
<Dropdown
id="export-format-dropdown"
label="Super notes export format"
items={[
{ label: 'Keep as Super', value: 'json' },
{ label: 'Markdown', value: 'md' },
{ label: 'HTML', value: 'html' },
]}
value={superNoteExportFormat}
onChange={(value) => {
void application.setPreference(
PrefKey.SuperNoteExportFormat,
value as PrefValue[PrefKey.SuperNoteExportFormat],
)
}}
portal={false}
/>
</div>
<div className="text-passive-0">
Note that if you convert Super notes to Markdown then import them back into Standard Notes in the future, you
will lose some formatting that the Markdown format is incapable of expressing, such as collapsible blocks and
embeds.
</div>
</Modal>
)
}
export default SuperExportModal

View File

@@ -1,10 +1,19 @@
import { WebApplication } from '@/Application/Application' import { WebApplication } from '@/Application/Application'
import { SNNote } from '@standardnotes/snjs' import { exportSuperNote } from '@/Components/NoteView/SuperEditor/SuperNoteExporter'
import { NoteType, PrefKey, SNNote } from '@standardnotes/snjs'
export const getNoteFormat = (application: WebApplication, note: SNNote) => { export const getNoteFormat = (application: WebApplication, note: SNNote) => {
const editor = application.componentManager.editorForNote(note) const editor = application.componentManager.editorForNote(note)
const format = editor?.package_info?.file_type || 'txt'
return format const isSuperNote = note.noteType === NoteType.Super
if (isSuperNote) {
const superNoteExportFormatPref = application.getPreference(PrefKey.SuperNoteExportFormat) || 'json'
return superNoteExportFormatPref
}
return editor?.package_info?.file_type || 'txt'
} }
export const getNoteFileName = (application: WebApplication, note: SNNote): string => { export const getNoteFileName = (application: WebApplication, note: SNNote): string => {
@@ -29,7 +38,8 @@ export const getNoteBlob = (application: WebApplication, note: SNNote) => {
type = 'text/plain' type = 'text/plain'
break break
} }
const blob = new Blob([note.text], { const content = note.noteType === NoteType.Super ? exportSuperNote(note, format) : note.text
const blob = new Blob([content], {
type, type,
}) })
return blob return blob

View File

@@ -3008,6 +3008,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@lexical/headless@npm:^0.7.6":
version: 0.7.6
resolution: "@lexical/headless@npm:0.7.6"
peerDependencies:
lexical: 0.7.6
checksum: 9dd9cacba2a45a2e9b0fce5e8ccda1642f7c7c1f04ecf96b2393c1534004f55c04dcce819d88fd61c47204f78b3864fa67ffb4611c94806548b307c622498352
languageName: node
linkType: hard
"@lexical/history@npm:0.7.6": "@lexical/history@npm:0.7.6":
version: 0.7.6 version: 0.7.6
resolution: "@lexical/history@npm:0.7.6" resolution: "@lexical/history@npm:0.7.6"
@@ -5247,6 +5256,7 @@ __metadata:
"@babel/plugin-transform-react-jsx": ^7.19.0 "@babel/plugin-transform-react-jsx": ^7.19.0
"@babel/preset-env": "*" "@babel/preset-env": "*"
"@babel/preset-typescript": ^7.18.6 "@babel/preset-typescript": ^7.18.6
"@lexical/headless": ^0.7.6
"@lexical/react": 0.7.6 "@lexical/react": 0.7.6
"@pmmmwh/react-refresh-webpack-plugin": ^0.5.10 "@pmmmwh/react-refresh-webpack-plugin": ^0.5.10
"@reach/alert": ^0.18.0 "@reach/alert": ^0.18.0