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 { export interface SuperConverterServiceInterface {
isValidSuperString(superString: string): boolean 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 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.ClipperDefaultTagUuid]: undefined,
[PrefKey.DefaultEditorIdentifier]: NativeFeatureIdentifier.TYPES.PlainEditor, [PrefKey.DefaultEditorIdentifier]: NativeFeatureIdentifier.TYPES.PlainEditor,
[PrefKey.SuperNoteExportFormat]: 'json', [PrefKey.SuperNoteExportFormat]: 'json',
[PrefKey.SuperNoteExportEmbedBehavior]: 'reference',
[PrefKey.SuperNoteExportUseMDFrontmatter]: true,
[PrefKey.SystemViewPreferences]: {}, [PrefKey.SystemViewPreferences]: {},
[PrefKey.AuthenticatorNames]: '', [PrefKey.AuthenticatorNames]: '',
[PrefKey.ComponentPreferences]: {}, [PrefKey.ComponentPreferences]: {},

View File

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

View File

@@ -462,7 +462,7 @@ export class FilesBackupService
const tagNames = tags.map((tag) => this.items.getTagLongTitle(tag)) const tagNames = tags.map((tag) => this.items.getTagLongTitle(tag))
const text = const text =
note.noteType === NoteType.Super note.noteType === NoteType.Super
? this.markdownConverter.convertSuperStringToOtherFormat(note.text, 'md') ? await this.markdownConverter.convertSuperStringToOtherFormat(note.text, 'md')
: note.text : note.text
await this.device.savePlaintextNoteBackup(location, note.uuid, note.title, tagNames, 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 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 = { type ZippableData = {
name: string name: string
content: Blob content: Blob

View File

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

View File

@@ -16,7 +16,8 @@ describe('GoogleKeepConverter', () => {
const superConverterService: SuperConverterServiceInterface = { const superConverterService: SuperConverterServiceInterface = {
isValidSuperString: () => true, isValidSuperString: () => true,
convertOtherFormatToSuperString: (data: string) => data, convertOtherFormatToSuperString: (data: string) => data,
convertSuperStringToOtherFormat: (data: string) => data, convertSuperStringToOtherFormat: async (data: string) => data,
getEmbeddedFileIDsFromSuperString: () => [],
} }
const generateUuid = new GenerateUuid(crypto) 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_JSON = createKeyboardCommand('SUPER_EXPORT_JSON')
export const SUPER_EXPORT_MARKDOWN = createKeyboardCommand('SUPER_EXPORT_MARKDOWN') 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 OPEN_PREFERENCES_COMMAND = createKeyboardCommand('OPEN_PREFERENCES_COMMAND')
export const CHANGE_EDITOR_WIDTH_COMMAND = createKeyboardCommand('CHANGE_EDITOR_WIDTH_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[]>([]) const [textDiff, setTextDiff] = useState<fastdiff.Diff[]>([])
useEffect(() => { useEffect(() => {
const firstNote = selectedNotes[0] const setDiffs = async () => {
const firstTitle = firstNote.title const firstNote = selectedNotes[0]
const firstText = const firstTitle = firstNote.title
firstNote.noteType === NoteType.Super && convertSuperToMarkdown const firstText =
? new HeadlessSuperConverter().convertSuperStringToOtherFormat(firstNote.text, 'md') firstNote.noteType === NoteType.Super && convertSuperToMarkdown
: firstNote.text ? await new HeadlessSuperConverter().convertSuperStringToOtherFormat(firstNote.text, 'md')
: firstNote.text
const secondNote = selectedNotes[1] const secondNote = selectedNotes[1]
const secondTitle = secondNote.title const secondTitle = secondNote.title
const secondText = const secondText =
secondNote.noteType === NoteType.Super && convertSuperToMarkdown secondNote.noteType === NoteType.Super && convertSuperToMarkdown
? new HeadlessSuperConverter().convertSuperStringToOtherFormat(secondNote.text, 'md') ? await new HeadlessSuperConverter().convertSuperStringToOtherFormat(secondNote.text, 'md')
: secondNote.text : secondNote.text
const titleDiff = fastdiff(firstTitle, secondTitle, undefined, true) const titleDiff = fastdiff(firstTitle, secondTitle, undefined, true)
const textDiff = fastdiff(firstText, secondText, undefined, true) const textDiff = fastdiff(firstText, secondText, undefined, true)
setTitleDiff(titleDiff) setTitleDiff(titleDiff)
setTextDiff(textDiff) setTextDiff(textDiff)
}
setDiffs().catch(console.error)
}, [convertSuperToMarkdown, selectedNotes]) }, [convertSuperToMarkdown, selectedNotes])
const [preElement, setPreElement] = useState<HTMLPreElement | null>(null) const [preElement, setPreElement] = useState<HTMLPreElement | null>(null)

View File

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

View File

@@ -1,8 +1,10 @@
import { PrefKey, PrefValue } from '@standardnotes/snjs' import { PrefKey, PrefValue } from '@standardnotes/snjs'
import { useApplication } from '../ApplicationProvider' import { useApplication } from '../ApplicationProvider'
import Dropdown from '../Dropdown/Dropdown'
import Modal from '../Modal/Modal' import Modal from '../Modal/Modal'
import usePreference from '@/Hooks/usePreference' import usePreference from '@/Hooks/usePreference'
import RadioButtonGroup from '../RadioButtonGroup/RadioButtonGroup'
import { useEffect } from 'react'
import Switch from '../Switch/Switch'
type Props = { type Props = {
exportNotes: () => void exportNotes: () => void
@@ -12,6 +14,17 @@ type Props = {
const SuperExportModal = ({ exportNotes, close }: Props) => { const SuperExportModal = ({ exportNotes, close }: Props) => {
const application = useApplication() const application = useApplication()
const superNoteExportFormat = usePreference(PrefKey.SuperNoteExportFormat) 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 ( return (
<Modal <Modal
@@ -28,20 +41,21 @@ const SuperExportModal = ({ exportNotes, close }: Props) => {
{ {
label: 'Export', label: 'Export',
type: 'primary', type: 'primary',
onClick: exportNotes, onClick: () => {
close()
exportNotes()
},
mobileSlot: 'right', mobileSlot: 'right',
}, },
]} ]}
> >
<div className="mb-4"> <div className="mb-2">
<div className="mb-1 text-base"> <div className="mb-3 text-base">We detected your selection includes Super notes.</div>
We detected your selection includes Super notes. How do you want to export them? <div className="mb-1">What format do you want to export them in?</div>
</div> <RadioButtonGroup
<Dropdown
label="Super notes export format"
items={[ items={[
{ label: 'Keep as Super', value: 'json' }, { label: 'Super (.json)', value: 'json' },
{ label: 'Markdown', value: 'md' }, { label: 'Markdown (.md)', value: 'md' },
{ label: 'HTML', value: 'html' }, { label: 'HTML', value: 'html' },
]} ]}
value={superNoteExportFormat} 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>
<div className="text-passive-0"> {superNoteExportFormat === 'md' && (
Note that if you convert Super notes to Markdown then import them back into Standard Notes in the future, you <div className="mt-4">
will lose some formatting that the Markdown format is incapable of expressing, such as collapsible blocks and <Switch
embeds. checked={superNoteExportUseMDFrontmatter}
</div> 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> </Modal>
) )
} }

View File

@@ -18,6 +18,7 @@ import { RemoteImageNode } from '../../Plugins/RemoteImagePlugin/RemoteImageNode
import { InlineFileNode } from '../../Plugins/InlineFilePlugin/InlineFileNode' import { InlineFileNode } from '../../Plugins/InlineFilePlugin/InlineFileNode'
import { CreateEditorArgs } from 'lexical' import { CreateEditorArgs } from 'lexical'
import { ListHTMLExportNode } from '../../Plugins/List/ListHTMLExportNode' import { ListHTMLExportNode } from '../../Plugins/List/ListHTMLExportNode'
import { FileExportNode } from './FileExportNode'
const CommonNodes = [ const CommonNodes = [
AutoLinkNode, AutoLinkNode,
@@ -46,8 +47,10 @@ const CommonNodes = [
] ]
export const BlockEditorNodes = [...CommonNodes, ListNode] export const BlockEditorNodes = [...CommonNodes, ListNode]
export const HTMLExportNodes: CreateEditorArgs['nodes'] = [
export const SuperExportNodes: CreateEditorArgs['nodes'] = [
...CommonNodes, ...CommonNodes,
FileExportNode,
ListHTMLExportNode, ListHTMLExportNode,
{ {
replace: ListNode, 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 } return { element: object }
} }
getTextContent(): string {
return `${this.__mimeType.startsWith('image/') ? '!' : ''}[${this.__fileName}](${this.__src})`
}
decorate(_editor: LexicalEditor, config: EditorConfig): JSX.Element { decorate(_editor: LexicalEditor, config: EditorConfig): JSX.Element {
const embedBlockTheme = config.theme.embedBlock || {} const embedBlockTheme = config.theme.embedBlock || {}
const className = { const className = {

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
import { createHeadlessEditor } from '@lexical/headless' import { createHeadlessEditor } from '@lexical/headless'
import { $convertToMarkdownString, $convertFromMarkdownString } from '@lexical/markdown' import { $convertToMarkdownString, $convertFromMarkdownString } from '@lexical/markdown'
import { SuperConverterServiceInterface } from '@standardnotes/snjs' import { FileItem, PrefKey, PrefValue, SuperConverterServiceInterface } from '@standardnotes/snjs'
import { import {
$createParagraphNode, $createParagraphNode,
$getRoot, $getRoot,
@@ -11,13 +11,14 @@ import {
ParagraphNode, ParagraphNode,
} from 'lexical' } from 'lexical'
import BlocksEditorTheme from '../Lexical/Theme/Theme' import BlocksEditorTheme from '../Lexical/Theme/Theme'
import { BlockEditorNodes, HTMLExportNodes } from '../Lexical/Nodes/AllNodes' import { SuperExportNodes } from '../Lexical/Nodes/AllNodes'
import { MarkdownTransformers } from '../MarkdownTransformers' import { MarkdownTransformers } from '../MarkdownTransformers'
import { $generateHtmlFromNodes, $generateNodesFromDOM } from '@lexical/html' 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 { export class HeadlessSuperConverter implements SuperConverterServiceInterface {
private editor: LexicalEditor private editor: LexicalEditor
private htmlExportEditor: LexicalEditor
constructor() { constructor() {
this.editor = createHeadlessEditor({ this.editor = createHeadlessEditor({
@@ -25,14 +26,7 @@ export class HeadlessSuperConverter implements SuperConverterServiceInterface {
theme: BlocksEditorTheme, theme: BlocksEditorTheme,
editable: false, editable: false,
onError: (error: Error) => console.error(error), onError: (error: Error) => console.error(error),
nodes: [...BlockEditorNodes], nodes: SuperExportNodes,
})
this.htmlExportEditor = createHeadlessEditor({
namespace: 'BlocksEditor',
theme: BlocksEditorTheme,
editable: false,
onError: (error: Error) => console.error(error),
nodes: HTMLExportNodes,
}) })
} }
@@ -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) { if (superString.length === 0) {
return superString return superString
} }
if (toFormat === 'html') { const { embedBehavior, getFileItem, getFileBase64 } = config ?? { embedBehavior: 'reference' }
this.htmlExportEditor.setEditorState(this.htmlExportEditor.parseEditorState(superString))
let content: string | undefined if (embedBehavior === 'separate' && !getFileItem) {
throw new Error('getFileItem must be provided when embedBehavior is "separate"')
this.htmlExportEditor.update( }
() => { if (embedBehavior === 'inline' && !getFileItem && !getFileBase64) {
content = $generateHtmlFromNodes(this.htmlExportEditor) throw new Error('getFileItem and getFileBase64 must be provided when embedBehavior is "inline"')
},
{ discrete: true },
)
if (typeof content !== 'string') {
throw new Error('Could not export note')
}
return content
} }
this.editor.setEditorState(this.editor.parseEditorState(superString)) this.editor.setEditorState(this.editor.parseEditorState(superString))
let content: string | undefined 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( this.editor.update(
() => { () => {
switch (toFormat) { switch (toFormat) {
@@ -87,6 +127,9 @@ export class HeadlessSuperConverter implements SuperConverterServiceInterface {
content = $convertToMarkdownString(MarkdownTransformers) content = $convertToMarkdownString(MarkdownTransformers)
break break
} }
case 'html':
content = $generateHtmlFromNodes(this.editor)
break
case 'json': case 'json':
default: default:
content = superString content = superString
@@ -183,4 +226,23 @@ export class HeadlessSuperConverter implements SuperConverterServiceInterface {
return JSON.stringify(this.editor.getEditorState()) 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, disabled = false,
tabIndex, tabIndex,
forceDesktopStyle, forceDesktopStyle,
children,
}: { }: {
checked: boolean checked: boolean
onChange: (checked: boolean) => void onChange: (checked: boolean) => void
@@ -15,20 +16,12 @@ const Switch = ({
disabled?: boolean disabled?: boolean
tabIndex?: number tabIndex?: number
forceDesktopStyle?: boolean forceDesktopStyle?: boolean
children?: React.ReactNode
}) => { }) => {
const isActive = checked && !disabled const isActive = checked && !disabled
return ( return (
<label <label className={classNames(disabled ? 'opacity-50' : '', className)}>
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,
)}
>
<VisuallyHidden> <VisuallyHidden>
<Checkbox <Checkbox
checked={checked} checked={checked}
@@ -40,15 +33,25 @@ const Switch = ({
</VisuallyHidden> </VisuallyHidden>
<div <div
className={classNames( className={classNames(
'absolute top-1/2 block -translate-y-1/2 rounded-full bg-default transition-transform duration-150 ease-out', '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',
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', 'ring-2 ring-transparent focus-within:border-default focus-within:shadow-none focus-within:outline-none focus-within:ring-info',
checked isActive ? 'bg-info' : 'bg-neutral',
? forceDesktopStyle forceDesktopStyle ? 'h-4.5 w-8' : 'h-7 w-12 md:h-4.5 md:w-8',
? 'translate-x-[calc(2rem-1.125rem)]'
: 'translate-x-[calc(3.25rem-1.5rem-0.5rem)] md:translate-x-[calc(2rem-1.125rem)]'
: '',
)} )}
/> >
<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> </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> { private async downloadFile(file: FileItem): Promise<void> {
let downloadingToastId = '' 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 { HeadlessSuperConverter } from '@/Components/SuperEditor/Tools/HeadlessSuperConverter'
import { NoteType, PrefKey, SNNote, PrefDefaults } from '@standardnotes/snjs' import { NoteType, PrefKey, SNNote, PrefDefaults, FileItem, PrefValue, pluralize } from '@standardnotes/snjs'
import { WebApplicationInterface } from '@standardnotes/ui-services' import { WebApplicationInterface, parseAndCreateZippableFileName, sanitizeFileName } from '@standardnotes/ui-services'
import { ZipDirectoryEntry } from '@zip.js/zip.js'
export const getNoteFormat = (application: WebApplicationInterface, note: SNNote) => { export const getNoteFormat = (application: WebApplicationInterface, note: SNNote) => {
if (note.noteType === NoteType.Super) { if (note.noteType === NoteType.Super) {
@@ -21,7 +23,49 @@ export const getNoteFileName = (application: WebApplicationInterface, note: SNNo
return `${note.title}.${format}` 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) const format = getNoteFormat(application, note)
let type: string let type: string
switch (format) { switch (format) {
@@ -38,12 +82,162 @@ export const getNoteBlob = (application: WebApplicationInterface, note: SNNote)
type = 'text/plain' type = 'text/plain'
break break
} }
const content = if (note.noteType === NoteType.Super) {
note.noteType === NoteType.Super const content = await headlessSuperConverter.convertSuperStringToOtherFormat(note.text, format, {
? new HeadlessSuperConverter().convertSuperStringToOtherFormat(note.text, format) embedBehavior: superEmbedBehavior,
: note.text getFileItem: (id) => application.items.findItem<FileItem>(id),
const blob = new Blob([content], { 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, type,
}) })
return blob 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`,
}
}