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,5 +1,6 @@
export interface SuperConverterServiceInterface {
isValidSuperString(superString: string): boolean
convertSuperStringToOtherFormat: (superString: string, toFormat: 'txt' | 'md' | 'html' | 'json') => string
convertSuperStringToOtherFormat: (superString: string, toFormat: 'txt' | 'md' | 'html' | 'json') => Promise<string>
convertOtherFormatToSuperString: (otherFormatString: string, fromFormat: 'txt' | 'md' | 'html' | 'json') => string
getEmbeddedFileIDsFromSuperString(superString: string): string[]
}

View File

@@ -40,6 +40,8 @@ export const PrefDefaults = {
[PrefKey.ClipperDefaultTagUuid]: undefined,
[PrefKey.DefaultEditorIdentifier]: NativeFeatureIdentifier.TYPES.PlainEditor,
[PrefKey.SuperNoteExportFormat]: 'json',
[PrefKey.SuperNoteExportEmbedBehavior]: 'reference',
[PrefKey.SuperNoteExportUseMDFrontmatter]: true,
[PrefKey.SystemViewPreferences]: {},
[PrefKey.AuthenticatorNames]: '',
[PrefKey.ComponentPreferences]: {},

View File

@@ -41,6 +41,8 @@ export enum PrefKey {
ClipperDefaultTagUuid = 'clipperDefaultTagUuid',
SystemViewPreferences = 'systemViewPreferences',
SuperNoteExportFormat = 'superNoteExportFormat',
SuperNoteExportEmbedBehavior = 'superNoteExportEmbedBehavior',
SuperNoteExportUseMDFrontmatter = 'superNoteExportUseMDFrontmatter',
AuthenticatorNames = 'authenticatorNames',
PaneGesturesEnabled = 'paneGesturesEnabled',
ComponentPreferences = 'componentPreferences',
@@ -83,6 +85,8 @@ export type PrefValue = {
[PrefKey.ClipperDefaultTagUuid]: string | undefined
[PrefKey.SystemViewPreferences]: Partial<Record<SystemViewId, TagPreferences>>
[PrefKey.SuperNoteExportFormat]: 'json' | 'md' | 'html'
[PrefKey.SuperNoteExportEmbedBehavior]: 'reference' | 'inline' | 'separate'
[PrefKey.SuperNoteExportUseMDFrontmatter]: boolean
[PrefKey.AuthenticatorNames]: string
[PrefKey.PaneGesturesEnabled]: boolean
[PrefKey.ComponentPreferences]: AllComponentPreferences

View File

@@ -462,7 +462,7 @@ export class FilesBackupService
const tagNames = tags.map((tag) => this.items.getTagLongTitle(tag))
const text =
note.noteType === NoteType.Super
? this.markdownConverter.convertSuperStringToOtherFormat(note.text, 'md')
? await this.markdownConverter.convertSuperStringToOtherFormat(note.text, 'md')
: note.text
await this.device.savePlaintextNoteBackup(location, note.uuid, note.title, tagNames, text)
}

View File

@@ -19,6 +19,11 @@ function zippableFileName(name: string, suffix = '', format = 'txt'): string {
return sanitizedName.slice(0, maxFileNameLength - nameEnd.length) + nameEnd
}
export function parseAndCreateZippableFileName(name: string) {
const { name: parsedName, ext } = parseFileName(name)
return zippableFileName(parsedName, '', ext)
}
type ZippableData = {
name: string
content: Blob

View File

@@ -31,7 +31,8 @@ describe('EvernoteConverter', () => {
const superConverterService: SuperConverterServiceInterface = {
isValidSuperString: () => true,
convertOtherFormatToSuperString: (data: string) => data,
convertSuperStringToOtherFormat: (data: string) => data,
convertSuperStringToOtherFormat: async (data: string) => data,
getEmbeddedFileIDsFromSuperString: () => [],
}
const generateUuid = new GenerateUuid(crypto)

View File

@@ -16,7 +16,8 @@ describe('GoogleKeepConverter', () => {
const superConverterService: SuperConverterServiceInterface = {
isValidSuperString: () => true,
convertOtherFormatToSuperString: (data: string) => data,
convertSuperStringToOtherFormat: (data: string) => data,
convertSuperStringToOtherFormat: async (data: string) => data,
getEmbeddedFileIDsFromSuperString: () => [],
}
const generateUuid = new GenerateUuid(crypto)

View File

@@ -36,7 +36,6 @@ export const SUPER_SHOW_MARKDOWN_PREVIEW = createKeyboardCommand('SUPER_SHOW_MAR
export const SUPER_EXPORT_JSON = createKeyboardCommand('SUPER_EXPORT_JSON')
export const SUPER_EXPORT_MARKDOWN = createKeyboardCommand('SUPER_EXPORT_MARKDOWN')
export const SUPER_EXPORT_HTML = createKeyboardCommand('SUPER_EXPORT_HTML')
export const OPEN_PREFERENCES_COMMAND = createKeyboardCommand('OPEN_PREFERENCES_COMMAND')
export const CHANGE_EDITOR_WIDTH_COMMAND = createKeyboardCommand('CHANGE_EDITOR_WIDTH_COMMAND')

View File

@@ -27,25 +27,29 @@ export const DiffView = ({
const [textDiff, setTextDiff] = useState<fastdiff.Diff[]>([])
useEffect(() => {
const firstNote = selectedNotes[0]
const firstTitle = firstNote.title
const firstText =
firstNote.noteType === NoteType.Super && convertSuperToMarkdown
? new HeadlessSuperConverter().convertSuperStringToOtherFormat(firstNote.text, 'md')
: firstNote.text
const setDiffs = async () => {
const firstNote = selectedNotes[0]
const firstTitle = firstNote.title
const firstText =
firstNote.noteType === NoteType.Super && convertSuperToMarkdown
? await new HeadlessSuperConverter().convertSuperStringToOtherFormat(firstNote.text, 'md')
: firstNote.text
const secondNote = selectedNotes[1]
const secondTitle = secondNote.title
const secondText =
secondNote.noteType === NoteType.Super && convertSuperToMarkdown
? new HeadlessSuperConverter().convertSuperStringToOtherFormat(secondNote.text, 'md')
: secondNote.text
const secondNote = selectedNotes[1]
const secondTitle = secondNote.title
const secondText =
secondNote.noteType === NoteType.Super && convertSuperToMarkdown
? await new HeadlessSuperConverter().convertSuperStringToOtherFormat(secondNote.text, 'md')
: secondNote.text
const titleDiff = fastdiff(firstTitle, secondTitle, undefined, true)
const textDiff = fastdiff(firstText, secondText, undefined, true)
const titleDiff = fastdiff(firstTitle, secondTitle, undefined, true)
const textDiff = fastdiff(firstText, secondText, undefined, true)
setTitleDiff(titleDiff)
setTextDiff(textDiff)
setTitleDiff(titleDiff)
setTextDiff(textDiff)
}
setDiffs().catch(console.error)
}, [convertSuperToMarkdown, selectedNotes])
const [preElement, setPreElement] = useState<HTMLPreElement | null>(null)

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>
)
}

View File

@@ -18,6 +18,7 @@ import { RemoteImageNode } from '../../Plugins/RemoteImagePlugin/RemoteImageNode
import { InlineFileNode } from '../../Plugins/InlineFilePlugin/InlineFileNode'
import { CreateEditorArgs } from 'lexical'
import { ListHTMLExportNode } from '../../Plugins/List/ListHTMLExportNode'
import { FileExportNode } from './FileExportNode'
const CommonNodes = [
AutoLinkNode,
@@ -46,8 +47,10 @@ const CommonNodes = [
]
export const BlockEditorNodes = [...CommonNodes, ListNode]
export const HTMLExportNodes: CreateEditorArgs['nodes'] = [
export const SuperExportNodes: CreateEditorArgs['nodes'] = [
...CommonNodes,
FileExportNode,
ListHTMLExportNode,
{
replace: ListNode,

View File

@@ -0,0 +1,93 @@
import { DecoratorBlockNode, SerializedDecoratorBlockNode } from '@lexical/react/LexicalDecoratorBlockNode'
import { parseAndCreateZippableFileName } from '@standardnotes/ui-services'
import { DOMExportOutput, Spread } from 'lexical'
type SerializedFileExportNode = Spread<
{
version: 1
type: 'file-export'
name: string
mimeType: string
},
SerializedDecoratorBlockNode
>
export class FileExportNode extends DecoratorBlockNode {
__name: string
__mimeType: string
static getType(): string {
return 'file-export'
}
constructor(name: string, mimeType: string) {
super()
this.__name = name
this.__mimeType = mimeType
}
static clone(node: FileExportNode): FileExportNode {
return new FileExportNode(node.__name, node.__mimeType)
}
static importJSON(serializedNode: SerializedFileExportNode): FileExportNode {
const node = new FileExportNode(serializedNode.name, serializedNode.mimeType)
return node
}
exportJSON(): SerializedFileExportNode {
return {
...super.exportJSON(),
name: this.__name,
mimeType: this.__mimeType,
version: 1,
type: 'file-export',
}
}
getZippableFileName(): string {
return parseAndCreateZippableFileName(this.__name)
}
getTextContent(): string {
return `${this.__mimeType.startsWith('image/') ? '!' : ''}[${this.__name}](./${this.getZippableFileName()})`
}
exportDOM(): DOMExportOutput {
const src = `./${this.getZippableFileName()}`
if (this.__mimeType.startsWith('image/')) {
const img = document.createElement('img')
img.setAttribute('src', src)
return { element: img }
} else if (this.__mimeType.startsWith('audio')) {
const audio = document.createElement('audio')
audio.setAttribute('controls', '')
const source = document.createElement('source')
source.setAttribute('src', src)
source.setAttribute('type', this.__mimeType)
audio.appendChild(source)
return { element: audio }
} else if (this.__mimeType.startsWith('video')) {
const video = document.createElement('video')
video.setAttribute('controls', '')
const source = document.createElement('source')
source.setAttribute('src', src)
source.setAttribute('type', this.__mimeType)
video.appendChild(source)
return { element: video }
}
const object = document.createElement('object')
object.setAttribute('data', src)
object.setAttribute('type', this.__mimeType)
return { element: object }
}
decorate(): JSX.Element {
// Doesn't need to actually render anything since this is only used for export
return <></>
}
}
export function $createFileExportNode(name: string, mimeType: string): FileExportNode {
return new FileExportNode(name, mimeType)
}

View File

@@ -1,139 +0,0 @@
import { useApplication } from '@/Components/ApplicationProvider'
import { downloadBlobOnAndroid } from '@/NativeMobileWeb/DownloadBlobOnAndroid'
import { shareBlobOnMobile } from '@/NativeMobileWeb/ShareBlobOnMobile'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { Platform } from '@standardnotes/snjs'
import {
sanitizeFileName,
SUPER_EXPORT_HTML,
SUPER_EXPORT_JSON,
SUPER_EXPORT_MARKDOWN,
} from '@standardnotes/ui-services'
import { useCallback, useEffect, useRef } from 'react'
import { useCommandService } from '@/Components/CommandProvider'
import { HeadlessSuperConverter } from '../../Tools/HeadlessSuperConverter'
// @ts-expect-error Using inline loaders to load CSS as string
import superEditorCSS from '!css-loader!sass-loader!../../Lexical/Theme/editor.scss'
// @ts-expect-error Using inline loaders to load CSS as string
import snColorsCSS from '!css-loader!sass-loader!@standardnotes/styles/src/Styles/_colors.scss'
// @ts-expect-error Using inline loaders to load CSS as string
import exportOverridesCSS from '!css-loader!sass-loader!../../Lexical/Theme/export-overrides.scss'
const html = (title: string, content: string) => `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>${title}</title>
<style>
${snColorsCSS.toString()}
${superEditorCSS.toString()}
${exportOverridesCSS.toString()}
</style>
</head>
<body>
${content}
</body>
</html>
`
export const ExportPlugin = () => {
const application = useApplication()
const [editor] = useLexicalComposerContext()
const commandService = useCommandService()
const converter = useRef(new HeadlessSuperConverter())
const downloadData = useCallback(
(data: Blob, fileName: string) => {
if (!application.isNativeMobileWeb()) {
application.archiveService.downloadData(data, fileName)
return
}
if (application.platform === Platform.Android) {
downloadBlobOnAndroid(application.mobileDevice, data, fileName).catch(console.error)
} else {
shareBlobOnMobile(application.mobileDevice, application.isNativeMobileWeb(), data, fileName).catch(
console.error,
)
}
},
[application],
)
const exportJson = useCallback(
(title: string) => {
const content = converter.current.convertSuperStringToOtherFormat(JSON.stringify(editor.getEditorState()), 'json')
const blob = new Blob([content], { type: 'application/json' })
downloadData(blob, `${sanitizeFileName(title)}.json`)
},
[downloadData, editor],
)
const exportMarkdown = useCallback(
(title: string) => {
const content = converter.current.convertSuperStringToOtherFormat(JSON.stringify(editor.getEditorState()), 'md')
const blob = new Blob([content], { type: 'text/markdown' })
downloadData(blob, `${sanitizeFileName(title)}.md`)
},
[downloadData, editor],
)
const exportHtml = useCallback(
(title: string) => {
const content = html(
title,
converter.current.convertSuperStringToOtherFormat(JSON.stringify(editor.getEditorState()), 'html'),
)
const blob = new Blob([content], { type: 'text/html' })
downloadData(blob, `${sanitizeFileName(title)}.html`)
},
[downloadData, editor],
)
useEffect(() => {
return commandService.addCommandHandler({
command: SUPER_EXPORT_JSON,
onKeyDown: (_, data) => {
if (!data) {
throw new Error('No data provided for export command')
}
const title = data as string
exportJson(title)
},
})
}, [commandService, exportJson])
useEffect(() => {
return commandService.addCommandHandler({
command: SUPER_EXPORT_MARKDOWN,
onKeyDown: (_, data) => {
if (!data) {
throw new Error('No data provided for export command')
}
const title = data as string
exportMarkdown(title)
},
})
}, [commandService, exportMarkdown])
useEffect(() => {
return commandService.addCommandHandler({
command: SUPER_EXPORT_HTML,
onKeyDown: (_, data) => {
if (!data) {
throw new Error('No data provided for export command')
}
const title = data as string
exportHtml(title)
},
})
}, [commandService, exportHtml])
return null
}

View File

@@ -148,6 +148,10 @@ export class InlineFileNode extends DecoratorBlockNode {
return { element: object }
}
getTextContent(): string {
return `${this.__mimeType.startsWith('image/') ? '!' : ''}[${this.__fileName}](${this.__src})`
}
decorate(_editor: LexicalEditor, config: EditorConfig): JSX.Element {
const embedBlockTheme = config.theme.embedBlock || {}
const className = {

View File

@@ -37,7 +37,6 @@ import PasswordPlugin from './Plugins/PasswordPlugin/PasswordPlugin'
import { useCommandService } from '@/Components/CommandProvider'
import { SUPER_SHOW_MARKDOWN_PREVIEW } from '@standardnotes/ui-services'
import { SuperNoteMarkdownPreview } from './SuperNoteMarkdownPreview'
import { ExportPlugin } from './Plugins/ExportPlugin/ExportPlugin'
import GetMarkdownPlugin, { GetMarkdownPluginInterface } from './Plugins/GetMarkdownPlugin/GetMarkdownPlugin'
import { useResponsiveEditorFontSize } from '@/Utils/getPlaintextFontSize'
import ReadonlyPlugin from './Plugins/ReadonlyPlugin/ReadonlyPlugin'
@@ -243,7 +242,6 @@ export const SuperEditor: FunctionComponent<Props> = ({
/>
<NodeObserverPlugin nodeType={BubbleNode} onRemove={handleBubbleRemove} />
<NodeObserverPlugin nodeType={FileNode} onRemove={handleBubbleRemove} />
<ExportPlugin />
{readonly === undefined && <ReadonlyPlugin note={note.current} />}
<AutoFocusPlugin isEnabled={controller.isTemplateNote} />
<SuperSearchContextProvider>

View File

@@ -6,7 +6,7 @@ import {
isUIFeatureAnIframeFeature,
spaceSeparatedStrings,
} from '@standardnotes/snjs'
import { useCallback, useEffect, useMemo } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useApplication } from '../ApplicationProvider'
import IframeFeatureView from '../ComponentView/IframeFeatureView'
import Icon from '../Icon/Icon'
@@ -52,18 +52,23 @@ const SuperNoteConverter = ({
return 'json'
}, [uiFeature])
const convertedContent = useMemo(() => {
if (note.text.length === 0) {
const [convertedContent, setConvertedContent] = useState<string>('')
useEffect(() => {
const convertContent = async () => {
if (note.text.length === 0) {
return note.text
}
try {
return new HeadlessSuperConverter().convertSuperStringToOtherFormat(note.text, format)
} catch (error) {
console.error(error)
}
return note.text
}
try {
return new HeadlessSuperConverter().convertSuperStringToOtherFormat(note.text, format)
} catch (error) {
console.error(error)
}
return note.text
convertContent().then(setConvertedContent).catch(console.error)
}, [format, note])
const componentViewer = useMemo(() => {

View File

@@ -1,6 +1,6 @@
import { createHeadlessEditor } from '@lexical/headless'
import { $convertToMarkdownString, $convertFromMarkdownString } from '@lexical/markdown'
import { SuperConverterServiceInterface } from '@standardnotes/snjs'
import { FileItem, PrefKey, PrefValue, SuperConverterServiceInterface } from '@standardnotes/snjs'
import {
$createParagraphNode,
$getRoot,
@@ -11,13 +11,14 @@ import {
ParagraphNode,
} from 'lexical'
import BlocksEditorTheme from '../Lexical/Theme/Theme'
import { BlockEditorNodes, HTMLExportNodes } from '../Lexical/Nodes/AllNodes'
import { SuperExportNodes } from '../Lexical/Nodes/AllNodes'
import { MarkdownTransformers } from '../MarkdownTransformers'
import { $generateHtmlFromNodes, $generateNodesFromDOM } from '@lexical/html'
import { FileNode } from '../Plugins/EncryptedFilePlugin/Nodes/FileNode'
import { $createFileExportNode } from '../Lexical/Nodes/FileExportNode'
import { $createInlineFileNode } from '../Plugins/InlineFilePlugin/InlineFileNode'
export class HeadlessSuperConverter implements SuperConverterServiceInterface {
private editor: LexicalEditor
private htmlExportEditor: LexicalEditor
constructor() {
this.editor = createHeadlessEditor({
@@ -25,14 +26,7 @@ export class HeadlessSuperConverter implements SuperConverterServiceInterface {
theme: BlocksEditorTheme,
editable: false,
onError: (error: Error) => console.error(error),
nodes: [...BlockEditorNodes],
})
this.htmlExportEditor = createHeadlessEditor({
namespace: 'BlocksEditor',
theme: BlocksEditorTheme,
editable: false,
onError: (error: Error) => console.error(error),
nodes: HTMLExportNodes,
nodes: SuperExportNodes,
})
}
@@ -45,34 +39,80 @@ export class HeadlessSuperConverter implements SuperConverterServiceInterface {
}
}
convertSuperStringToOtherFormat(superString: string, toFormat: 'txt' | 'md' | 'html' | 'json'): string {
async convertSuperStringToOtherFormat(
superString: string,
toFormat: 'txt' | 'md' | 'html' | 'json',
config?: {
embedBehavior?: PrefValue[PrefKey.SuperNoteExportEmbedBehavior]
getFileItem?: (id: string) => FileItem | undefined
getFileBase64?: (id: string) => Promise<string | undefined>
},
): Promise<string> {
if (superString.length === 0) {
return superString
}
if (toFormat === 'html') {
this.htmlExportEditor.setEditorState(this.htmlExportEditor.parseEditorState(superString))
const { embedBehavior, getFileItem, getFileBase64 } = config ?? { embedBehavior: 'reference' }
let content: string | undefined
this.htmlExportEditor.update(
() => {
content = $generateHtmlFromNodes(this.htmlExportEditor)
},
{ discrete: true },
)
if (typeof content !== 'string') {
throw new Error('Could not export note')
}
return content
if (embedBehavior === 'separate' && !getFileItem) {
throw new Error('getFileItem must be provided when embedBehavior is "separate"')
}
if (embedBehavior === 'inline' && !getFileItem && !getFileBase64) {
throw new Error('getFileItem and getFileBase64 must be provided when embedBehavior is "inline"')
}
this.editor.setEditorState(this.editor.parseEditorState(superString))
let content: string | undefined
await new Promise<void>((resolve) => {
this.editor.update(
() => {
if (embedBehavior === 'reference') {
resolve()
return
}
if (!getFileItem) {
resolve()
return
}
const fileNodes = $nodesOfType(FileNode)
Promise.all(
fileNodes.map(async (fileNode) => {
const fileItem = getFileItem(fileNode.getId())
if (!fileItem) {
return
}
if (embedBehavior === 'inline' && getFileBase64) {
const fileBase64 = await getFileBase64(fileNode.getId())
if (!fileBase64) {
return
}
this.editor.update(
() => {
const inlineFileNode = $createInlineFileNode(fileBase64, fileItem.mimeType, fileItem.name)
fileNode.replace(inlineFileNode)
},
{ discrete: true },
)
} else {
this.editor.update(
() => {
const fileExportNode = $createFileExportNode(fileItem.name, fileItem.mimeType)
fileNode.replace(fileExportNode)
},
{ discrete: true },
)
}
}),
)
.then(() => resolve())
.catch(console.error)
},
{ discrete: true },
)
})
this.editor.update(
() => {
switch (toFormat) {
@@ -87,6 +127,9 @@ export class HeadlessSuperConverter implements SuperConverterServiceInterface {
content = $convertToMarkdownString(MarkdownTransformers)
break
}
case 'html':
content = $generateHtmlFromNodes(this.editor)
break
case 'json':
default:
content = superString
@@ -183,4 +226,23 @@ export class HeadlessSuperConverter implements SuperConverterServiceInterface {
return JSON.stringify(this.editor.getEditorState())
}
getEmbeddedFileIDsFromSuperString(superString: string): string[] {
if (superString.length === 0) {
return []
}
this.editor.setEditorState(this.editor.parseEditorState(superString))
const ids: string[] = []
this.editor.getEditorState().read(() => {
const fileNodes = $nodesOfType(FileNode)
fileNodes.forEach((fileNode) => {
ids.push(fileNode.getId())
})
})
return ids
}
}

View File

@@ -8,6 +8,7 @@ const Switch = ({
disabled = false,
tabIndex,
forceDesktopStyle,
children,
}: {
checked: boolean
onChange: (checked: boolean) => void
@@ -15,20 +16,12 @@ const Switch = ({
disabled?: boolean
tabIndex?: number
forceDesktopStyle?: boolean
children?: React.ReactNode
}) => {
const isActive = checked && !disabled
return (
<label
className={classNames(
'relative box-content inline-block flex-shrink-0 cursor-pointer rounded-full border-2 border-solid border-transparent bg-clip-padding transition-colors duration-150 ease-out',
'ring-2 ring-transparent focus-within:border-default focus-within:shadow-none focus-within:outline-none focus-within:ring-info',
disabled ? 'opacity-50' : '',
isActive ? 'bg-info' : 'bg-neutral',
forceDesktopStyle ? 'h-4.5 w-8' : 'h-7 w-12 md:h-4.5 md:w-8',
className,
)}
>
<label className={classNames(disabled ? 'opacity-50' : '', className)}>
<VisuallyHidden>
<Checkbox
checked={checked}
@@ -40,15 +33,25 @@ const Switch = ({
</VisuallyHidden>
<div
className={classNames(
'absolute top-1/2 block -translate-y-1/2 rounded-full bg-default transition-transform duration-150 ease-out',
forceDesktopStyle ? 'left-[2px] h-3.5 w-3.5' : 'left-[0.15rem] h-6 w-6 md:left-[2px] md:h-3.5 md:w-3.5',
checked
? forceDesktopStyle
? 'translate-x-[calc(2rem-1.125rem)]'
: 'translate-x-[calc(3.25rem-1.5rem-0.5rem)] md:translate-x-[calc(2rem-1.125rem)]'
: '',
'relative box-content inline-block flex-shrink-0 cursor-pointer rounded-full border-2 border-solid border-transparent bg-clip-padding transition-colors duration-150 ease-out',
'ring-2 ring-transparent focus-within:border-default focus-within:shadow-none focus-within:outline-none focus-within:ring-info',
isActive ? 'bg-info' : 'bg-neutral',
forceDesktopStyle ? 'h-4.5 w-8' : 'h-7 w-12 md:h-4.5 md:w-8',
)}
/>
>
<div
className={classNames(
'absolute top-1/2 block -translate-y-1/2 rounded-full bg-default transition-transform duration-150 ease-out',
forceDesktopStyle ? 'left-[2px] h-3.5 w-3.5' : 'left-[0.15rem] h-6 w-6 md:left-[2px] md:h-3.5 md:w-3.5',
checked
? forceDesktopStyle
? 'translate-x-[calc(2rem-1.125rem)]'
: 'translate-x-[calc(3.25rem-1.5rem-0.5rem)] md:translate-x-[calc(2rem-1.125rem)]'
: '',
)}
/>
</div>
{children}
</label>
)
}

View File

@@ -275,6 +275,20 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
}
}
getFileBlob = async (file: FileItem): Promise<Blob | undefined> => {
const chunks: Uint8Array[] = []
const error = await this.files.downloadFile(file, async (decryptedChunk) => {
chunks.push(decryptedChunk)
})
if (error) {
return
}
const finalDecryptedBytes = concatenateUint8Arrays(chunks)
return new Blob([finalDecryptedBytes], {
type: file.mimeType,
})
}
private async downloadFile(file: FileItem): Promise<void> {
let downloadingToastId = ''

View File

@@ -1,32 +0,0 @@
import { WebApplication } from '@/Application/WebApplication'
import { getNoteBlob, getNoteFileName } from '@/Utils/NoteExportUtils'
import { parseFileName } from '@standardnotes/filepicker'
import { Platform, SNNote } from '@standardnotes/snjs'
import { sanitizeFileName } from '@standardnotes/ui-services'
import { downloadBlobOnAndroid } from './DownloadBlobOnAndroid'
export const downloadSelectedNotesOnAndroid = async (application: WebApplication, notes: SNNote[]) => {
if (!application.isNativeMobileWeb() || application.platform !== Platform.Android) {
throw new Error('Function being used on non-android platform')
}
if (notes.length === 1) {
const note = notes[0]
const blob = getNoteBlob(application, note)
const { name, ext } = parseFileName(getNoteFileName(application, note))
const filename = `${sanitizeFileName(name)}.${ext}`
await downloadBlobOnAndroid(application.mobileDevice, blob, filename)
return
}
if (notes.length > 1) {
const zippedDataBlob = await application.archiveService.zipData(
notes.map((note) => {
return {
name: getNoteFileName(application, note),
content: getNoteBlob(application, note),
}
}),
)
const filename = `Standard Notes Export - ${application.archiveService.formattedDateForExports()}.zip`
await downloadBlobOnAndroid(application.mobileDevice, zippedDataBlob, filename)
}
}

View File

@@ -1,36 +0,0 @@
import { WebApplication } from '@/Application/WebApplication'
import { getNoteBlob, getNoteFileName } from '@/Utils/NoteExportUtils'
import { parseFileName } from '@standardnotes/filepicker'
import { SNNote } from '@standardnotes/snjs'
import { sanitizeFileName } from '@standardnotes/ui-services'
import { shareBlobOnMobile } from './ShareBlobOnMobile'
export const shareSelectedNotes = async (application: WebApplication, notes: SNNote[]) => {
if (!application.isNativeMobileWeb()) {
throw new Error('Share function being used outside mobile webview')
}
if (notes.length === 1) {
const note = notes[0]
const blob = getNoteBlob(application, note)
const { name, ext } = parseFileName(getNoteFileName(application, note))
const filename = `${sanitizeFileName(name)}.${ext}`
void shareBlobOnMobile(application.mobileDevice, application.isNativeMobileWeb(), blob, filename)
return
}
if (notes.length > 1) {
const zippedDataBlob = await application.archiveService.zipData(
notes.map((note) => {
return {
name: getNoteFileName(application, note),
content: getNoteBlob(application, note),
}
}),
)
void shareBlobOnMobile(
application.mobileDevice,
application.isNativeMobileWeb(),
zippedDataBlob,
`Standard Notes Export - ${application.archiveService.formattedDateForExports()}.zip`,
)
}
}

View File

@@ -1,6 +1,8 @@
import { WebApplication } from '@/Application/WebApplication'
import { HeadlessSuperConverter } from '@/Components/SuperEditor/Tools/HeadlessSuperConverter'
import { NoteType, PrefKey, SNNote, PrefDefaults } from '@standardnotes/snjs'
import { WebApplicationInterface } from '@standardnotes/ui-services'
import { NoteType, PrefKey, SNNote, PrefDefaults, FileItem, PrefValue, pluralize } from '@standardnotes/snjs'
import { WebApplicationInterface, parseAndCreateZippableFileName, sanitizeFileName } from '@standardnotes/ui-services'
import { ZipDirectoryEntry } from '@zip.js/zip.js'
export const getNoteFormat = (application: WebApplicationInterface, note: SNNote) => {
if (note.noteType === NoteType.Super) {
@@ -21,7 +23,49 @@ export const getNoteFileName = (application: WebApplicationInterface, note: SNNo
return `${note.title}.${format}`
}
export const getNoteBlob = (application: WebApplicationInterface, note: SNNote) => {
const headlessSuperConverter = new HeadlessSuperConverter()
// @ts-expect-error Using inline loaders to load CSS as string
import superEditorCSS from '!css-loader!sass-loader!../Components/SuperEditor/Lexical/Theme/editor.scss'
// @ts-expect-error Using inline loaders to load CSS as string
import snColorsCSS from '!css-loader!sass-loader!@standardnotes/styles/src/Styles/_colors.scss'
// @ts-expect-error Using inline loaders to load CSS as string
import exportOverridesCSS from '!css-loader!sass-loader!../Components/SuperEditor/Lexical/Theme/export-overrides.scss'
import { getBase64FromBlob } from './Utils'
import { ToastType, addToast, dismissToast } from '@standardnotes/toast'
const superHTML = (note: SNNote, content: string) => `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>${note.title}</title>
<style>
${snColorsCSS.toString()}
${superEditorCSS.toString()}
${exportOverridesCSS.toString()}
</style>
</head>
<body>
${content}
</body>
</html>
`
const superMarkdown = (note: SNNote, content: string) => `---
title: ${note.title}
created_at: ${note.created_at.toISOString()}
updated_at: ${note.serverUpdatedAt.toISOString()}
uuid: ${note.uuid}
---
${content}
`
export const getNoteBlob = async (
application: WebApplication,
note: SNNote,
superEmbedBehavior: PrefValue[PrefKey.SuperNoteExportEmbedBehavior],
) => {
const format = getNoteFormat(application, note)
let type: string
switch (format) {
@@ -38,12 +82,162 @@ export const getNoteBlob = (application: WebApplicationInterface, note: SNNote)
type = 'text/plain'
break
}
const content =
note.noteType === NoteType.Super
? new HeadlessSuperConverter().convertSuperStringToOtherFormat(note.text, format)
: note.text
const blob = new Blob([content], {
if (note.noteType === NoteType.Super) {
const content = await headlessSuperConverter.convertSuperStringToOtherFormat(note.text, format, {
embedBehavior: superEmbedBehavior,
getFileItem: (id) => application.items.findItem<FileItem>(id),
getFileBase64: async (id) => {
const fileItem = application.items.findItem<FileItem>(id)
if (!fileItem) {
return
}
const fileBlob = await application.filesController.getFileBlob(fileItem)
if (!fileBlob) {
return
}
return await getBase64FromBlob(fileBlob)
},
})
const useMDFrontmatter =
format === 'md' &&
application.getPreference(
PrefKey.SuperNoteExportUseMDFrontmatter,
PrefDefaults[PrefKey.SuperNoteExportUseMDFrontmatter],
)
const result =
format === 'html' ? superHTML(note, content) : useMDFrontmatter ? superMarkdown(note, content) : content
const blob = new Blob([result], {
type,
})
return blob
}
const blob = new Blob([note.text], {
type,
})
return blob
}
const isSuperNote = (note: SNNote) => {
return note.noteType === NoteType.Super
}
const noteHasEmbeddedFiles = (note: SNNote) => {
return note.text.includes('"type":"snfile"')
}
const noteRequiresFolder = (
note: SNNote,
superExportFormat: PrefValue[PrefKey.SuperNoteExportFormat],
superEmbedBehavior: PrefValue[PrefKey.SuperNoteExportEmbedBehavior],
) => {
if (!isSuperNote(note)) {
return false
}
if (superExportFormat === 'json') {
return false
}
if (superEmbedBehavior !== 'separate') {
return false
}
return noteHasEmbeddedFiles(note)
}
const addEmbeddedFilesToFolder = async (application: WebApplication, note: SNNote, folder: ZipDirectoryEntry) => {
try {
const embeddedFileIDs = headlessSuperConverter.getEmbeddedFileIDsFromSuperString(note.text)
for (const embeddedFileID of embeddedFileIDs) {
const fileItem = application.items.findItem<FileItem>(embeddedFileID)
if (!fileItem) {
continue
}
const embeddedFileBlob = await application.filesController.getFileBlob(fileItem)
if (!embeddedFileBlob) {
continue
}
folder.addBlob(parseAndCreateZippableFileName(fileItem.title), embeddedFileBlob)
}
} catch (error) {
console.error(error)
}
}
export const createNoteExport = async (
application: WebApplication,
notes: SNNote[],
): Promise<
| {
blob: Blob
fileName: string
}
| undefined
> => {
if (notes.length === 0) {
return
}
const toast = addToast({
type: ToastType.Progress,
message: `Exporting ${notes.length} ${pluralize(notes.length, 'note', 'notes')}...`,
})
const superExportFormatPref = application.getPreference(
PrefKey.SuperNoteExportFormat,
PrefDefaults[PrefKey.SuperNoteExportFormat],
)
const superEmbedBehaviorPref = application.getPreference(
PrefKey.SuperNoteExportEmbedBehavior,
PrefDefaults[PrefKey.SuperNoteExportEmbedBehavior],
)
if (notes.length === 1 && !noteRequiresFolder(notes[0], superExportFormatPref, superEmbedBehaviorPref)) {
const blob = await getNoteBlob(application, notes[0], superEmbedBehaviorPref)
const fileName = getNoteFileName(application, notes[0])
dismissToast(toast)
return {
blob,
fileName,
}
}
const zip = await import('@zip.js/zip.js')
const zipFS = new zip.fs.FS()
const { root } = zipFS
if (notes.length === 1 && noteRequiresFolder(notes[0], superExportFormatPref, superEmbedBehaviorPref)) {
const blob = await getNoteBlob(application, notes[0], superEmbedBehaviorPref)
const fileName = parseAndCreateZippableFileName(getNoteFileName(application, notes[0]))
root.addBlob(fileName, blob)
await addEmbeddedFilesToFolder(application, notes[0], root)
const zippedBlob = await zipFS.exportBlob()
dismissToast(toast)
return {
blob: zippedBlob,
fileName: fileName + '.zip',
}
}
for (const note of notes) {
const blob = await getNoteBlob(application, note, superEmbedBehaviorPref)
const fileName = parseAndCreateZippableFileName(getNoteFileName(application, note))
if (!noteRequiresFolder(note, superExportFormatPref, superEmbedBehaviorPref)) {
root.addBlob(fileName, blob)
continue
}
const folder = root.addDirectory(sanitizeFileName(note.title))
folder.addBlob(fileName, blob)
await addEmbeddedFilesToFolder(application, note, folder)
}
const zippedBlob = await zipFS.exportBlob()
dismissToast(toast)
return {
blob: zippedBlob,
fileName: `Standard Notes Export - ${application.archiveService.formattedDateForExports()}.zip`,
}
}