feat: When exporting a Super note, embedded files can be inlined in the note or exported along the note in a zip file. You can now also choose to include frontmatter when exporting to Markdown format.

(#2610)
This commit is contained in:
Aman Harwara
2023-10-31 01:19:04 +05:30
committed by GitHub
parent 044776d937
commit 991de1ddf5
23 changed files with 605 additions and 416 deletions

View File

@@ -1,6 +1,6 @@
import Icon from '@/Components/Icon/Icon'
import { observer } from 'mobx-react-lite'
import { useState, useEffect, useMemo, useCallback, useRef } from 'react'
import { useState, useEffect, useMemo, useCallback } from 'react'
import { NoteType, Platform, SNNote } from '@standardnotes/snjs'
import {
CHANGE_EDITOR_WIDTH_COMMAND,
@@ -8,9 +8,6 @@ import {
PIN_NOTE_COMMAND,
SHOW_HIDDEN_OPTIONS_KEYBOARD_COMMAND,
STAR_NOTE_COMMAND,
SUPER_EXPORT_HTML,
SUPER_EXPORT_JSON,
SUPER_EXPORT_MARKDOWN,
SUPER_SHOW_MARKDOWN_PREVIEW,
} from '@standardnotes/ui-services'
import ChangeEditorOption from './ChangeEditorOption'
@@ -20,9 +17,7 @@ import { addToast, dismissToast, ToastType } from '@standardnotes/toast'
import { NotesOptionsProps } from './NotesOptionsProps'
import { useResponsiveAppPane } from '../Panes/ResponsivePaneProvider'
import { AppPaneId } from '../Panes/AppPaneMetadata'
import { getNoteBlob, getNoteFileName } from '@/Utils/NoteExportUtils'
import { shareSelectedNotes } from '@/NativeMobileWeb/ShareSelectedNotes'
import { downloadSelectedNotesOnAndroid } from '@/NativeMobileWeb/DownloadSelectedNotesOnAndroid'
import { createNoteExport } from '@/Utils/NoteExportUtils'
import ProtectedUnauthorizedLabel from '../ProtectedItemOverlay/ProtectedUnauthorizedLabel'
import { MenuItemIconSize } from '@/Constants/TailwindClassNames'
import { KeyboardShortcutIndicator } from '../KeyboardShortcutIndicator/KeyboardShortcutIndicator'
@@ -39,9 +34,9 @@ import SuperExportModal from './SuperExportModal'
import { useApplication } from '../ApplicationProvider'
import { MutuallyExclusiveMediaQueryBreakpoints } from '@/Hooks/useMediaQuery'
import AddToVaultMenuOption from '../Vaults/AddToVaultMenuOption'
import Menu from '../Menu/Menu'
import Popover from '../Popover/Popover'
import MenuSection from '../Menu/MenuSection'
import { downloadOrShareBlobBasedOnPlatform } from '@/Utils/DownloadOrShareBasedOnPlatform'
import { shareBlobOnMobile } from '@/NativeMobileWeb/ShareBlobOnMobile'
const iconSize = MenuItemIconSize
const iconClassDanger = `text-danger mr-2 ${iconSize}`
@@ -104,34 +99,40 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
}, [])
const downloadSelectedItems = useCallback(async () => {
if (notes.length === 1) {
const note = notes[0]
const blob = getNoteBlob(application, note)
application.archiveService.downloadData(blob, getNoteFileName(application, note))
return
}
if (notes.length > 1) {
const loadingToastId = addToast({
type: ToastType.Loading,
message: `Exporting ${notes.length} notes...`,
try {
const result = await createNoteExport(application, notes)
if (!result) {
return
}
const { blob, fileName } = result
void downloadOrShareBlobBasedOnPlatform({
archiveService: application.archiveService,
platform: application.platform,
mobileDevice: application.mobileDevice,
blob: blob,
filename: fileName,
isNativeMobileWeb: application.isNativeMobileWeb(),
})
await application.archiveService.downloadDataAsZip(
notes.map((note) => {
return {
name: getNoteFileName(application, note),
content: getNoteBlob(application, note),
}
}),
)
dismissToast(loadingToastId)
} catch (error) {
console.error(error)
addToast({
type: ToastType.Success,
message: `Exported ${notes.length} notes`,
type: ToastType.Error,
message: 'Could not export notes',
})
}
}, [application, notes])
const exportSelectedItems = useCallback(() => {
const hasSuperNote = notes.some((note) => note.noteType === NoteType.Super)
if (hasSuperNote) {
setShowExportSuperModal(true)
return
}
downloadSelectedItems().catch(console.error)
}, [downloadSelectedItems, notes])
const closeMenuAndToggleNotesList = useCallback(() => {
const isMobileScreen = matchMedia(MutuallyExclusiveMediaQueryBreakpoints.sm).matches
if (isMobileScreen) {
@@ -199,9 +200,6 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
[application],
)
const superExportButtonRef = useRef<HTMLButtonElement>(null)
const [isSuperExportMenuOpen, setIsSuperExportMenuOpen] = useState(false)
const unauthorized = notes.some((note) => !application.isAuthorizedToRenderItem(note))
if (unauthorized) {
return <ProtectedUnauthorizedLabel />
@@ -224,8 +222,6 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
return null
}
const isOnlySuperNoteSelected = notes.length === 1 && notes[0].noteType === NoteType.Super
return (
<>
{notes.length === 1 && (
@@ -342,77 +338,35 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
{pinShortcut && <KeyboardShortcutIndicator className="ml-auto" shortcut={pinShortcut} />}
</MenuItem>
)}
{isOnlySuperNoteSelected ? (
<>
<MenuItem
ref={superExportButtonRef}
onClick={() => {
setIsSuperExportMenuOpen((open) => !open)
}}
>
<div className="flex items-center">
<Icon type="download" className={iconClass} />
Export
</div>
<Icon type="chevron-right" className="ml-auto text-neutral" />
</MenuItem>
<Popover
title="Export note"
side="left"
align="start"
open={isSuperExportMenuOpen}
anchorElement={superExportButtonRef.current}
togglePopover={() => {
setIsSuperExportMenuOpen(!isSuperExportMenuOpen)
}}
className="md:py-1"
>
<Menu a11yLabel={'Super note export menu'}>
<MenuSection>
<MenuItem onClick={() => commandService.triggerCommand(SUPER_EXPORT_JSON, notes[0].title)}>
<Icon type="code" className={iconClass} />
Export as JSON
</MenuItem>
<MenuItem onClick={() => commandService.triggerCommand(SUPER_EXPORT_MARKDOWN, notes[0].title)}>
<Icon type="markdown" className={iconClass} />
Export as Markdown
</MenuItem>
<MenuItem onClick={() => commandService.triggerCommand(SUPER_EXPORT_HTML, notes[0].title)}>
<Icon type="rich-text" className={iconClass} />
Export as HTML
</MenuItem>
</MenuSection>
</Menu>
</Popover>
</>
) : (
<>
<MenuItem
onClick={() => {
if (application.isNativeMobileWeb()) {
void shareSelectedNotes(application, notes)
} else {
const hasSuperNote = notes.some((note) => note.noteType === NoteType.Super)
if (hasSuperNote) {
setShowExportSuperModal(true)
<MenuItem
onClick={() => {
if (application.isNativeMobileWeb()) {
createNoteExport(application, notes)
.then((result) => {
if (!result) {
return
}
void downloadSelectedItems()
}
}}
>
<Icon type={application.platform === Platform.Android ? 'share' : 'download'} className={iconClass} />
{application.platform === Platform.Android ? 'Share' : 'Export'}
</MenuItem>
{application.platform === Platform.Android && (
<MenuItem onClick={() => downloadSelectedNotesOnAndroid(application, notes)}>
<Icon type="download" className={iconClass} />
Export
</MenuItem>
)}
</>
const { blob, fileName } = result
shareBlobOnMobile(application.mobileDevice, application.isNativeMobileWeb(), blob, fileName).catch(
console.error,
)
})
.catch(console.error)
} else {
exportSelectedItems()
}
}}
>
<Icon type={application.platform === Platform.Android ? 'share' : 'download'} className={iconClass} />
{application.platform === Platform.Android ? 'Share' : 'Export'}
</MenuItem>
{application.platform === Platform.Android && (
<MenuItem onClick={exportSelectedItems}>
<Icon type="download" className={iconClass} />
Export
</MenuItem>
)}
<MenuItem onClick={duplicateSelectedItems} disabled={areSomeNotesInReadonlySharedVault}>
<Icon type="copy" className={iconClass} />

View File

@@ -1,8 +1,10 @@
import { PrefKey, PrefValue } from '@standardnotes/snjs'
import { useApplication } from '../ApplicationProvider'
import Dropdown from '../Dropdown/Dropdown'
import Modal from '../Modal/Modal'
import usePreference from '@/Hooks/usePreference'
import RadioButtonGroup from '../RadioButtonGroup/RadioButtonGroup'
import { useEffect } from 'react'
import Switch from '../Switch/Switch'
type Props = {
exportNotes: () => void
@@ -12,6 +14,17 @@ type Props = {
const SuperExportModal = ({ exportNotes, close }: Props) => {
const application = useApplication()
const superNoteExportFormat = usePreference(PrefKey.SuperNoteExportFormat)
const superNoteExportEmbedBehavior = usePreference(PrefKey.SuperNoteExportEmbedBehavior)
const superNoteExportUseMDFrontmatter = usePreference(PrefKey.SuperNoteExportUseMDFrontmatter)
useEffect(() => {
if (superNoteExportFormat === 'json' && superNoteExportEmbedBehavior === 'separate') {
void application.setPreference(PrefKey.SuperNoteExportEmbedBehavior, 'reference')
}
if (superNoteExportFormat === 'md' && superNoteExportEmbedBehavior === 'reference') {
void application.setPreference(PrefKey.SuperNoteExportEmbedBehavior, 'separate')
}
}, [application, superNoteExportEmbedBehavior, superNoteExportFormat])
return (
<Modal
@@ -28,20 +41,21 @@ const SuperExportModal = ({ exportNotes, close }: Props) => {
{
label: 'Export',
type: 'primary',
onClick: exportNotes,
onClick: () => {
close()
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
label="Super notes export format"
<div className="mb-2">
<div className="mb-3 text-base">We detected your selection includes Super notes.</div>
<div className="mb-1">What format do you want to export them in?</div>
<RadioButtonGroup
items={[
{ label: 'Keep as Super', value: 'json' },
{ label: 'Markdown', value: 'md' },
{ label: 'Super (.json)', value: 'json' },
{ label: 'Markdown (.md)', value: 'md' },
{ label: 'HTML', value: 'html' },
]}
value={superNoteExportFormat}
@@ -52,12 +66,47 @@ const SuperExportModal = ({ exportNotes, close }: Props) => {
)
}}
/>
{superNoteExportFormat === 'md' && (
<div className="mt-2 text-xs text-passive-0">
Note that conversion to Markdown is not lossless. Some features like collapsible blocks and formatting like
superscript/subscript may not be correctly converted.
</div>
)}
</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>
{superNoteExportFormat === 'md' && (
<div className="mt-4">
<Switch
checked={superNoteExportUseMDFrontmatter}
onChange={(checked) => {
void application.setPreference(
PrefKey.SuperNoteExportUseMDFrontmatter,
checked as PrefValue[PrefKey.SuperNoteExportUseMDFrontmatter],
)
}}
className="!flex items-center"
>
<span className="ml-2">Export with frontmatter</span>
</Switch>
</div>
)}
{superNoteExportFormat !== 'json' && (
<div className="mb-2 mt-4">
<div className="mb-1">How do you want embedded files to be handled?</div>
<RadioButtonGroup
items={[
{ label: 'Inline', value: 'inline' },
{ label: 'Export separately', value: 'separate' },
].concat(superNoteExportFormat !== 'md' ? [{ label: 'Keep as reference', value: 'reference' }] : [])}
value={superNoteExportEmbedBehavior}
onChange={(value) => {
void application.setPreference(
PrefKey.SuperNoteExportEmbedBehavior,
value as PrefValue[PrefKey.SuperNoteExportEmbedBehavior],
)
}}
/>
</div>
)}
</Modal>
)
}