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:
@@ -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[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]: {},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = ''
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user