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 {
|
||||
isValidSuperString(superString: string): boolean
|
||||
convertSuperStringToOtherFormat: (superString: string, toFormat: 'txt' | 'md' | 'html' | 'json') => string
|
||||
convertSuperStringToOtherFormat: (superString: string, toFormat: 'txt' | 'md' | 'html' | 'json') => Promise<string>
|
||||
convertOtherFormatToSuperString: (otherFormatString: string, fromFormat: 'txt' | 'md' | 'html' | 'json') => string
|
||||
getEmbeddedFileIDsFromSuperString(superString: string): string[]
|
||||
}
|
||||
|
||||
@@ -40,6 +40,8 @@ export const PrefDefaults = {
|
||||
[PrefKey.ClipperDefaultTagUuid]: undefined,
|
||||
[PrefKey.DefaultEditorIdentifier]: NativeFeatureIdentifier.TYPES.PlainEditor,
|
||||
[PrefKey.SuperNoteExportFormat]: 'json',
|
||||
[PrefKey.SuperNoteExportEmbedBehavior]: 'reference',
|
||||
[PrefKey.SuperNoteExportUseMDFrontmatter]: true,
|
||||
[PrefKey.SystemViewPreferences]: {},
|
||||
[PrefKey.AuthenticatorNames]: '',
|
||||
[PrefKey.ComponentPreferences]: {},
|
||||
|
||||
@@ -41,6 +41,8 @@ export enum PrefKey {
|
||||
ClipperDefaultTagUuid = 'clipperDefaultTagUuid',
|
||||
SystemViewPreferences = 'systemViewPreferences',
|
||||
SuperNoteExportFormat = 'superNoteExportFormat',
|
||||
SuperNoteExportEmbedBehavior = 'superNoteExportEmbedBehavior',
|
||||
SuperNoteExportUseMDFrontmatter = 'superNoteExportUseMDFrontmatter',
|
||||
AuthenticatorNames = 'authenticatorNames',
|
||||
PaneGesturesEnabled = 'paneGesturesEnabled',
|
||||
ComponentPreferences = 'componentPreferences',
|
||||
@@ -83,6 +85,8 @@ export type PrefValue = {
|
||||
[PrefKey.ClipperDefaultTagUuid]: string | undefined
|
||||
[PrefKey.SystemViewPreferences]: Partial<Record<SystemViewId, TagPreferences>>
|
||||
[PrefKey.SuperNoteExportFormat]: 'json' | 'md' | 'html'
|
||||
[PrefKey.SuperNoteExportEmbedBehavior]: 'reference' | 'inline' | 'separate'
|
||||
[PrefKey.SuperNoteExportUseMDFrontmatter]: boolean
|
||||
[PrefKey.AuthenticatorNames]: string
|
||||
[PrefKey.PaneGesturesEnabled]: boolean
|
||||
[PrefKey.ComponentPreferences]: AllComponentPreferences
|
||||
|
||||
@@ -462,7 +462,7 @@ export class FilesBackupService
|
||||
const tagNames = tags.map((tag) => this.items.getTagLongTitle(tag))
|
||||
const text =
|
||||
note.noteType === NoteType.Super
|
||||
? this.markdownConverter.convertSuperStringToOtherFormat(note.text, 'md')
|
||||
? await this.markdownConverter.convertSuperStringToOtherFormat(note.text, 'md')
|
||||
: note.text
|
||||
await this.device.savePlaintextNoteBackup(location, note.uuid, note.title, tagNames, text)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,11 @@ function zippableFileName(name: string, suffix = '', format = 'txt'): string {
|
||||
return sanitizedName.slice(0, maxFileNameLength - nameEnd.length) + nameEnd
|
||||
}
|
||||
|
||||
export function parseAndCreateZippableFileName(name: string) {
|
||||
const { name: parsedName, ext } = parseFileName(name)
|
||||
return zippableFileName(parsedName, '', ext)
|
||||
}
|
||||
|
||||
type ZippableData = {
|
||||
name: string
|
||||
content: Blob
|
||||
|
||||
@@ -31,7 +31,8 @@ describe('EvernoteConverter', () => {
|
||||
const superConverterService: SuperConverterServiceInterface = {
|
||||
isValidSuperString: () => true,
|
||||
convertOtherFormatToSuperString: (data: string) => data,
|
||||
convertSuperStringToOtherFormat: (data: string) => data,
|
||||
convertSuperStringToOtherFormat: async (data: string) => data,
|
||||
getEmbeddedFileIDsFromSuperString: () => [],
|
||||
}
|
||||
|
||||
const generateUuid = new GenerateUuid(crypto)
|
||||
|
||||
@@ -16,7 +16,8 @@ describe('GoogleKeepConverter', () => {
|
||||
const superConverterService: SuperConverterServiceInterface = {
|
||||
isValidSuperString: () => true,
|
||||
convertOtherFormatToSuperString: (data: string) => data,
|
||||
convertSuperStringToOtherFormat: (data: string) => data,
|
||||
convertSuperStringToOtherFormat: async (data: string) => data,
|
||||
getEmbeddedFileIDsFromSuperString: () => [],
|
||||
}
|
||||
const generateUuid = new GenerateUuid(crypto)
|
||||
|
||||
|
||||
@@ -36,7 +36,6 @@ export const SUPER_SHOW_MARKDOWN_PREVIEW = createKeyboardCommand('SUPER_SHOW_MAR
|
||||
|
||||
export const SUPER_EXPORT_JSON = createKeyboardCommand('SUPER_EXPORT_JSON')
|
||||
export const SUPER_EXPORT_MARKDOWN = createKeyboardCommand('SUPER_EXPORT_MARKDOWN')
|
||||
export const SUPER_EXPORT_HTML = createKeyboardCommand('SUPER_EXPORT_HTML')
|
||||
export const OPEN_PREFERENCES_COMMAND = createKeyboardCommand('OPEN_PREFERENCES_COMMAND')
|
||||
|
||||
export const CHANGE_EDITOR_WIDTH_COMMAND = createKeyboardCommand('CHANGE_EDITOR_WIDTH_COMMAND')
|
||||
|
||||
@@ -27,18 +27,19 @@ export const DiffView = ({
|
||||
const [textDiff, setTextDiff] = useState<fastdiff.Diff[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const setDiffs = async () => {
|
||||
const firstNote = selectedNotes[0]
|
||||
const firstTitle = firstNote.title
|
||||
const firstText =
|
||||
firstNote.noteType === NoteType.Super && convertSuperToMarkdown
|
||||
? new HeadlessSuperConverter().convertSuperStringToOtherFormat(firstNote.text, 'md')
|
||||
? await new HeadlessSuperConverter().convertSuperStringToOtherFormat(firstNote.text, 'md')
|
||||
: firstNote.text
|
||||
|
||||
const secondNote = selectedNotes[1]
|
||||
const secondTitle = secondNote.title
|
||||
const secondText =
|
||||
secondNote.noteType === NoteType.Super && convertSuperToMarkdown
|
||||
? new HeadlessSuperConverter().convertSuperStringToOtherFormat(secondNote.text, 'md')
|
||||
? await new HeadlessSuperConverter().convertSuperStringToOtherFormat(secondNote.text, 'md')
|
||||
: secondNote.text
|
||||
|
||||
const titleDiff = fastdiff(firstTitle, secondTitle, undefined, true)
|
||||
@@ -46,6 +47,9 @@ export const DiffView = ({
|
||||
|
||||
setTitleDiff(titleDiff)
|
||||
setTextDiff(textDiff)
|
||||
}
|
||||
|
||||
setDiffs().catch(console.error)
|
||||
}, [convertSuperToMarkdown, selectedNotes])
|
||||
|
||||
const [preElement, setPreElement] = useState<HTMLPreElement | null>(null)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Icon from '@/Components/Icon/Icon'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import { useState, useEffect, useMemo, useCallback, useRef } from 'react'
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react'
|
||||
import { NoteType, Platform, SNNote } from '@standardnotes/snjs'
|
||||
import {
|
||||
CHANGE_EDITOR_WIDTH_COMMAND,
|
||||
@@ -8,9 +8,6 @@ import {
|
||||
PIN_NOTE_COMMAND,
|
||||
SHOW_HIDDEN_OPTIONS_KEYBOARD_COMMAND,
|
||||
STAR_NOTE_COMMAND,
|
||||
SUPER_EXPORT_HTML,
|
||||
SUPER_EXPORT_JSON,
|
||||
SUPER_EXPORT_MARKDOWN,
|
||||
SUPER_SHOW_MARKDOWN_PREVIEW,
|
||||
} from '@standardnotes/ui-services'
|
||||
import ChangeEditorOption from './ChangeEditorOption'
|
||||
@@ -20,9 +17,7 @@ import { addToast, dismissToast, ToastType } from '@standardnotes/toast'
|
||||
import { NotesOptionsProps } from './NotesOptionsProps'
|
||||
import { useResponsiveAppPane } from '../Panes/ResponsivePaneProvider'
|
||||
import { AppPaneId } from '../Panes/AppPaneMetadata'
|
||||
import { getNoteBlob, getNoteFileName } from '@/Utils/NoteExportUtils'
|
||||
import { shareSelectedNotes } from '@/NativeMobileWeb/ShareSelectedNotes'
|
||||
import { downloadSelectedNotesOnAndroid } from '@/NativeMobileWeb/DownloadSelectedNotesOnAndroid'
|
||||
import { createNoteExport } from '@/Utils/NoteExportUtils'
|
||||
import ProtectedUnauthorizedLabel from '../ProtectedItemOverlay/ProtectedUnauthorizedLabel'
|
||||
import { MenuItemIconSize } from '@/Constants/TailwindClassNames'
|
||||
import { KeyboardShortcutIndicator } from '../KeyboardShortcutIndicator/KeyboardShortcutIndicator'
|
||||
@@ -39,9 +34,9 @@ import SuperExportModal from './SuperExportModal'
|
||||
import { useApplication } from '../ApplicationProvider'
|
||||
import { MutuallyExclusiveMediaQueryBreakpoints } from '@/Hooks/useMediaQuery'
|
||||
import AddToVaultMenuOption from '../Vaults/AddToVaultMenuOption'
|
||||
import Menu from '../Menu/Menu'
|
||||
import Popover from '../Popover/Popover'
|
||||
import MenuSection from '../Menu/MenuSection'
|
||||
import { downloadOrShareBlobBasedOnPlatform } from '@/Utils/DownloadOrShareBasedOnPlatform'
|
||||
import { shareBlobOnMobile } from '@/NativeMobileWeb/ShareBlobOnMobile'
|
||||
|
||||
const iconSize = MenuItemIconSize
|
||||
const iconClassDanger = `text-danger mr-2 ${iconSize}`
|
||||
@@ -104,34 +99,40 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
|
||||
}, [])
|
||||
|
||||
const downloadSelectedItems = useCallback(async () => {
|
||||
if (notes.length === 1) {
|
||||
const note = notes[0]
|
||||
const blob = getNoteBlob(application, note)
|
||||
application.archiveService.downloadData(blob, getNoteFileName(application, note))
|
||||
try {
|
||||
const result = await createNoteExport(application, notes)
|
||||
if (!result) {
|
||||
return
|
||||
}
|
||||
|
||||
if (notes.length > 1) {
|
||||
const loadingToastId = addToast({
|
||||
type: ToastType.Loading,
|
||||
message: `Exporting ${notes.length} notes...`,
|
||||
const { blob, fileName } = result
|
||||
void downloadOrShareBlobBasedOnPlatform({
|
||||
archiveService: application.archiveService,
|
||||
platform: application.platform,
|
||||
mobileDevice: application.mobileDevice,
|
||||
blob: blob,
|
||||
filename: fileName,
|
||||
isNativeMobileWeb: application.isNativeMobileWeb(),
|
||||
})
|
||||
await application.archiveService.downloadDataAsZip(
|
||||
notes.map((note) => {
|
||||
return {
|
||||
name: getNoteFileName(application, note),
|
||||
content: getNoteBlob(application, note),
|
||||
}
|
||||
}),
|
||||
)
|
||||
dismissToast(loadingToastId)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
addToast({
|
||||
type: ToastType.Success,
|
||||
message: `Exported ${notes.length} notes`,
|
||||
type: ToastType.Error,
|
||||
message: 'Could not export notes',
|
||||
})
|
||||
}
|
||||
}, [application, notes])
|
||||
|
||||
const exportSelectedItems = useCallback(() => {
|
||||
const hasSuperNote = notes.some((note) => note.noteType === NoteType.Super)
|
||||
|
||||
if (hasSuperNote) {
|
||||
setShowExportSuperModal(true)
|
||||
return
|
||||
}
|
||||
|
||||
downloadSelectedItems().catch(console.error)
|
||||
}, [downloadSelectedItems, notes])
|
||||
|
||||
const closeMenuAndToggleNotesList = useCallback(() => {
|
||||
const isMobileScreen = matchMedia(MutuallyExclusiveMediaQueryBreakpoints.sm).matches
|
||||
if (isMobileScreen) {
|
||||
@@ -199,9 +200,6 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
|
||||
[application],
|
||||
)
|
||||
|
||||
const superExportButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const [isSuperExportMenuOpen, setIsSuperExportMenuOpen] = useState(false)
|
||||
|
||||
const unauthorized = notes.some((note) => !application.isAuthorizedToRenderItem(note))
|
||||
if (unauthorized) {
|
||||
return <ProtectedUnauthorizedLabel />
|
||||
@@ -224,8 +222,6 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
|
||||
return null
|
||||
}
|
||||
|
||||
const isOnlySuperNoteSelected = notes.length === 1 && notes[0].noteType === NoteType.Super
|
||||
|
||||
return (
|
||||
<>
|
||||
{notes.length === 1 && (
|
||||
@@ -342,64 +338,24 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
|
||||
{pinShortcut && <KeyboardShortcutIndicator className="ml-auto" shortcut={pinShortcut} />}
|
||||
</MenuItem>
|
||||
)}
|
||||
{isOnlySuperNoteSelected ? (
|
||||
<>
|
||||
<MenuItem
|
||||
ref={superExportButtonRef}
|
||||
onClick={() => {
|
||||
setIsSuperExportMenuOpen((open) => !open)
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Icon type="download" className={iconClass} />
|
||||
Export
|
||||
</div>
|
||||
<Icon type="chevron-right" className="ml-auto text-neutral" />
|
||||
</MenuItem>
|
||||
<Popover
|
||||
title="Export note"
|
||||
side="left"
|
||||
align="start"
|
||||
open={isSuperExportMenuOpen}
|
||||
anchorElement={superExportButtonRef.current}
|
||||
togglePopover={() => {
|
||||
setIsSuperExportMenuOpen(!isSuperExportMenuOpen)
|
||||
}}
|
||||
className="md:py-1"
|
||||
>
|
||||
<Menu a11yLabel={'Super note export menu'}>
|
||||
<MenuSection>
|
||||
<MenuItem onClick={() => commandService.triggerCommand(SUPER_EXPORT_JSON, notes[0].title)}>
|
||||
<Icon type="code" className={iconClass} />
|
||||
Export as JSON
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => commandService.triggerCommand(SUPER_EXPORT_MARKDOWN, notes[0].title)}>
|
||||
<Icon type="markdown" className={iconClass} />
|
||||
Export as Markdown
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => commandService.triggerCommand(SUPER_EXPORT_HTML, notes[0].title)}>
|
||||
<Icon type="rich-text" className={iconClass} />
|
||||
Export as HTML
|
||||
</MenuItem>
|
||||
</MenuSection>
|
||||
</Menu>
|
||||
</Popover>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MenuItem
|
||||
onClick={() => {
|
||||
if (application.isNativeMobileWeb()) {
|
||||
void shareSelectedNotes(application, notes)
|
||||
} else {
|
||||
const hasSuperNote = notes.some((note) => note.noteType === NoteType.Super)
|
||||
|
||||
if (hasSuperNote) {
|
||||
setShowExportSuperModal(true)
|
||||
createNoteExport(application, notes)
|
||||
.then((result) => {
|
||||
if (!result) {
|
||||
return
|
||||
}
|
||||
|
||||
void downloadSelectedItems()
|
||||
const { blob, fileName } = result
|
||||
|
||||
shareBlobOnMobile(application.mobileDevice, application.isNativeMobileWeb(), blob, fileName).catch(
|
||||
console.error,
|
||||
)
|
||||
})
|
||||
.catch(console.error)
|
||||
} else {
|
||||
exportSelectedItems()
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -407,13 +363,11 @@ const NotesOptions = ({ notes, closeMenu }: NotesOptionsProps) => {
|
||||
{application.platform === Platform.Android ? 'Share' : 'Export'}
|
||||
</MenuItem>
|
||||
{application.platform === Platform.Android && (
|
||||
<MenuItem onClick={() => downloadSelectedNotesOnAndroid(application, notes)}>
|
||||
<MenuItem onClick={exportSelectedItems}>
|
||||
<Icon type="download" className={iconClass} />
|
||||
Export
|
||||
</MenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<MenuItem onClick={duplicateSelectedItems} disabled={areSomeNotesInReadonlySharedVault}>
|
||||
<Icon type="copy" className={iconClass} />
|
||||
Duplicate
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { PrefKey, PrefValue } from '@standardnotes/snjs'
|
||||
import { useApplication } from '../ApplicationProvider'
|
||||
import Dropdown from '../Dropdown/Dropdown'
|
||||
import Modal from '../Modal/Modal'
|
||||
import usePreference from '@/Hooks/usePreference'
|
||||
import RadioButtonGroup from '../RadioButtonGroup/RadioButtonGroup'
|
||||
import { useEffect } from 'react'
|
||||
import Switch from '../Switch/Switch'
|
||||
|
||||
type Props = {
|
||||
exportNotes: () => void
|
||||
@@ -12,6 +14,17 @@ type Props = {
|
||||
const SuperExportModal = ({ exportNotes, close }: Props) => {
|
||||
const application = useApplication()
|
||||
const superNoteExportFormat = usePreference(PrefKey.SuperNoteExportFormat)
|
||||
const superNoteExportEmbedBehavior = usePreference(PrefKey.SuperNoteExportEmbedBehavior)
|
||||
const superNoteExportUseMDFrontmatter = usePreference(PrefKey.SuperNoteExportUseMDFrontmatter)
|
||||
|
||||
useEffect(() => {
|
||||
if (superNoteExportFormat === 'json' && superNoteExportEmbedBehavior === 'separate') {
|
||||
void application.setPreference(PrefKey.SuperNoteExportEmbedBehavior, 'reference')
|
||||
}
|
||||
if (superNoteExportFormat === 'md' && superNoteExportEmbedBehavior === 'reference') {
|
||||
void application.setPreference(PrefKey.SuperNoteExportEmbedBehavior, 'separate')
|
||||
}
|
||||
}, [application, superNoteExportEmbedBehavior, superNoteExportFormat])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -28,20 +41,21 @@ const SuperExportModal = ({ exportNotes, close }: Props) => {
|
||||
{
|
||||
label: 'Export',
|
||||
type: 'primary',
|
||||
onClick: exportNotes,
|
||||
onClick: () => {
|
||||
close()
|
||||
exportNotes()
|
||||
},
|
||||
mobileSlot: 'right',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<div className="mb-4">
|
||||
<div className="mb-1 text-base">
|
||||
We detected your selection includes Super notes. How do you want to export them?
|
||||
</div>
|
||||
<Dropdown
|
||||
label="Super notes export format"
|
||||
<div className="mb-2">
|
||||
<div className="mb-3 text-base">We detected your selection includes Super notes.</div>
|
||||
<div className="mb-1">What format do you want to export them in?</div>
|
||||
<RadioButtonGroup
|
||||
items={[
|
||||
{ label: 'Keep as Super', value: 'json' },
|
||||
{ label: 'Markdown', value: 'md' },
|
||||
{ label: 'Super (.json)', value: 'json' },
|
||||
{ label: 'Markdown (.md)', value: 'md' },
|
||||
{ label: 'HTML', value: 'html' },
|
||||
]}
|
||||
value={superNoteExportFormat}
|
||||
@@ -52,12 +66,47 @@ const SuperExportModal = ({ exportNotes, close }: Props) => {
|
||||
)
|
||||
}}
|
||||
/>
|
||||
{superNoteExportFormat === 'md' && (
|
||||
<div className="mt-2 text-xs text-passive-0">
|
||||
Note that conversion to Markdown is not lossless. Some features like collapsible blocks and formatting like
|
||||
superscript/subscript may not be correctly converted.
|
||||
</div>
|
||||
<div className="text-passive-0">
|
||||
Note that if you convert Super notes to Markdown then import them back into Standard Notes in the future, you
|
||||
will lose some formatting that the Markdown format is incapable of expressing, such as collapsible blocks and
|
||||
embeds.
|
||||
)}
|
||||
</div>
|
||||
{superNoteExportFormat === 'md' && (
|
||||
<div className="mt-4">
|
||||
<Switch
|
||||
checked={superNoteExportUseMDFrontmatter}
|
||||
onChange={(checked) => {
|
||||
void application.setPreference(
|
||||
PrefKey.SuperNoteExportUseMDFrontmatter,
|
||||
checked as PrefValue[PrefKey.SuperNoteExportUseMDFrontmatter],
|
||||
)
|
||||
}}
|
||||
className="!flex items-center"
|
||||
>
|
||||
<span className="ml-2">Export with frontmatter</span>
|
||||
</Switch>
|
||||
</div>
|
||||
)}
|
||||
{superNoteExportFormat !== 'json' && (
|
||||
<div className="mb-2 mt-4">
|
||||
<div className="mb-1">How do you want embedded files to be handled?</div>
|
||||
<RadioButtonGroup
|
||||
items={[
|
||||
{ label: 'Inline', value: 'inline' },
|
||||
{ label: 'Export separately', value: 'separate' },
|
||||
].concat(superNoteExportFormat !== 'md' ? [{ label: 'Keep as reference', value: 'reference' }] : [])}
|
||||
value={superNoteExportEmbedBehavior}
|
||||
onChange={(value) => {
|
||||
void application.setPreference(
|
||||
PrefKey.SuperNoteExportEmbedBehavior,
|
||||
value as PrefValue[PrefKey.SuperNoteExportEmbedBehavior],
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { RemoteImageNode } from '../../Plugins/RemoteImagePlugin/RemoteImageNode
|
||||
import { InlineFileNode } from '../../Plugins/InlineFilePlugin/InlineFileNode'
|
||||
import { CreateEditorArgs } from 'lexical'
|
||||
import { ListHTMLExportNode } from '../../Plugins/List/ListHTMLExportNode'
|
||||
import { FileExportNode } from './FileExportNode'
|
||||
|
||||
const CommonNodes = [
|
||||
AutoLinkNode,
|
||||
@@ -46,8 +47,10 @@ const CommonNodes = [
|
||||
]
|
||||
|
||||
export const BlockEditorNodes = [...CommonNodes, ListNode]
|
||||
export const HTMLExportNodes: CreateEditorArgs['nodes'] = [
|
||||
|
||||
export const SuperExportNodes: CreateEditorArgs['nodes'] = [
|
||||
...CommonNodes,
|
||||
FileExportNode,
|
||||
ListHTMLExportNode,
|
||||
{
|
||||
replace: ListNode,
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
getTextContent(): string {
|
||||
return `${this.__mimeType.startsWith('image/') ? '!' : ''}[${this.__fileName}](${this.__src})`
|
||||
}
|
||||
|
||||
decorate(_editor: LexicalEditor, config: EditorConfig): JSX.Element {
|
||||
const embedBlockTheme = config.theme.embedBlock || {}
|
||||
const className = {
|
||||
|
||||
@@ -37,7 +37,6 @@ import PasswordPlugin from './Plugins/PasswordPlugin/PasswordPlugin'
|
||||
import { useCommandService } from '@/Components/CommandProvider'
|
||||
import { SUPER_SHOW_MARKDOWN_PREVIEW } from '@standardnotes/ui-services'
|
||||
import { SuperNoteMarkdownPreview } from './SuperNoteMarkdownPreview'
|
||||
import { ExportPlugin } from './Plugins/ExportPlugin/ExportPlugin'
|
||||
import GetMarkdownPlugin, { GetMarkdownPluginInterface } from './Plugins/GetMarkdownPlugin/GetMarkdownPlugin'
|
||||
import { useResponsiveEditorFontSize } from '@/Utils/getPlaintextFontSize'
|
||||
import ReadonlyPlugin from './Plugins/ReadonlyPlugin/ReadonlyPlugin'
|
||||
@@ -243,7 +242,6 @@ export const SuperEditor: FunctionComponent<Props> = ({
|
||||
/>
|
||||
<NodeObserverPlugin nodeType={BubbleNode} onRemove={handleBubbleRemove} />
|
||||
<NodeObserverPlugin nodeType={FileNode} onRemove={handleBubbleRemove} />
|
||||
<ExportPlugin />
|
||||
{readonly === undefined && <ReadonlyPlugin note={note.current} />}
|
||||
<AutoFocusPlugin isEnabled={controller.isTemplateNote} />
|
||||
<SuperSearchContextProvider>
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
isUIFeatureAnIframeFeature,
|
||||
spaceSeparatedStrings,
|
||||
} from '@standardnotes/snjs'
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useApplication } from '../ApplicationProvider'
|
||||
import IframeFeatureView from '../ComponentView/IframeFeatureView'
|
||||
import Icon from '../Icon/Icon'
|
||||
@@ -52,7 +52,10 @@ const SuperNoteConverter = ({
|
||||
return 'json'
|
||||
}, [uiFeature])
|
||||
|
||||
const convertedContent = useMemo(() => {
|
||||
const [convertedContent, setConvertedContent] = useState<string>('')
|
||||
|
||||
useEffect(() => {
|
||||
const convertContent = async () => {
|
||||
if (note.text.length === 0) {
|
||||
return note.text
|
||||
}
|
||||
@@ -64,6 +67,8 @@ const SuperNoteConverter = ({
|
||||
}
|
||||
|
||||
return note.text
|
||||
}
|
||||
convertContent().then(setConvertedContent).catch(console.error)
|
||||
}, [format, note])
|
||||
|
||||
const componentViewer = useMemo(() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createHeadlessEditor } from '@lexical/headless'
|
||||
import { $convertToMarkdownString, $convertFromMarkdownString } from '@lexical/markdown'
|
||||
import { SuperConverterServiceInterface } from '@standardnotes/snjs'
|
||||
import { FileItem, PrefKey, PrefValue, SuperConverterServiceInterface } from '@standardnotes/snjs'
|
||||
import {
|
||||
$createParagraphNode,
|
||||
$getRoot,
|
||||
@@ -11,13 +11,14 @@ import {
|
||||
ParagraphNode,
|
||||
} from 'lexical'
|
||||
import BlocksEditorTheme from '../Lexical/Theme/Theme'
|
||||
import { BlockEditorNodes, HTMLExportNodes } from '../Lexical/Nodes/AllNodes'
|
||||
import { SuperExportNodes } from '../Lexical/Nodes/AllNodes'
|
||||
import { MarkdownTransformers } from '../MarkdownTransformers'
|
||||
import { $generateHtmlFromNodes, $generateNodesFromDOM } from '@lexical/html'
|
||||
|
||||
import { FileNode } from '../Plugins/EncryptedFilePlugin/Nodes/FileNode'
|
||||
import { $createFileExportNode } from '../Lexical/Nodes/FileExportNode'
|
||||
import { $createInlineFileNode } from '../Plugins/InlineFilePlugin/InlineFileNode'
|
||||
export class HeadlessSuperConverter implements SuperConverterServiceInterface {
|
||||
private editor: LexicalEditor
|
||||
private htmlExportEditor: LexicalEditor
|
||||
|
||||
constructor() {
|
||||
this.editor = createHeadlessEditor({
|
||||
@@ -25,14 +26,7 @@ export class HeadlessSuperConverter implements SuperConverterServiceInterface {
|
||||
theme: BlocksEditorTheme,
|
||||
editable: false,
|
||||
onError: (error: Error) => console.error(error),
|
||||
nodes: [...BlockEditorNodes],
|
||||
})
|
||||
this.htmlExportEditor = createHeadlessEditor({
|
||||
namespace: 'BlocksEditor',
|
||||
theme: BlocksEditorTheme,
|
||||
editable: false,
|
||||
onError: (error: Error) => console.error(error),
|
||||
nodes: HTMLExportNodes,
|
||||
nodes: SuperExportNodes,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -45,34 +39,80 @@ export class HeadlessSuperConverter implements SuperConverterServiceInterface {
|
||||
}
|
||||
}
|
||||
|
||||
convertSuperStringToOtherFormat(superString: string, toFormat: 'txt' | 'md' | 'html' | 'json'): string {
|
||||
async convertSuperStringToOtherFormat(
|
||||
superString: string,
|
||||
toFormat: 'txt' | 'md' | 'html' | 'json',
|
||||
config?: {
|
||||
embedBehavior?: PrefValue[PrefKey.SuperNoteExportEmbedBehavior]
|
||||
getFileItem?: (id: string) => FileItem | undefined
|
||||
getFileBase64?: (id: string) => Promise<string | undefined>
|
||||
},
|
||||
): Promise<string> {
|
||||
if (superString.length === 0) {
|
||||
return superString
|
||||
}
|
||||
|
||||
if (toFormat === 'html') {
|
||||
this.htmlExportEditor.setEditorState(this.htmlExportEditor.parseEditorState(superString))
|
||||
const { embedBehavior, getFileItem, getFileBase64 } = config ?? { embedBehavior: 'reference' }
|
||||
|
||||
let content: string | undefined
|
||||
|
||||
this.htmlExportEditor.update(
|
||||
() => {
|
||||
content = $generateHtmlFromNodes(this.htmlExportEditor)
|
||||
},
|
||||
{ discrete: true },
|
||||
)
|
||||
|
||||
if (typeof content !== 'string') {
|
||||
throw new Error('Could not export note')
|
||||
if (embedBehavior === 'separate' && !getFileItem) {
|
||||
throw new Error('getFileItem must be provided when embedBehavior is "separate"')
|
||||
}
|
||||
|
||||
return content
|
||||
if (embedBehavior === 'inline' && !getFileItem && !getFileBase64) {
|
||||
throw new Error('getFileItem and getFileBase64 must be provided when embedBehavior is "inline"')
|
||||
}
|
||||
|
||||
this.editor.setEditorState(this.editor.parseEditorState(superString))
|
||||
|
||||
let content: string | undefined
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
this.editor.update(
|
||||
() => {
|
||||
if (embedBehavior === 'reference') {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
if (!getFileItem) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
const fileNodes = $nodesOfType(FileNode)
|
||||
Promise.all(
|
||||
fileNodes.map(async (fileNode) => {
|
||||
const fileItem = getFileItem(fileNode.getId())
|
||||
if (!fileItem) {
|
||||
return
|
||||
}
|
||||
if (embedBehavior === 'inline' && getFileBase64) {
|
||||
const fileBase64 = await getFileBase64(fileNode.getId())
|
||||
if (!fileBase64) {
|
||||
return
|
||||
}
|
||||
this.editor.update(
|
||||
() => {
|
||||
const inlineFileNode = $createInlineFileNode(fileBase64, fileItem.mimeType, fileItem.name)
|
||||
fileNode.replace(inlineFileNode)
|
||||
},
|
||||
{ discrete: true },
|
||||
)
|
||||
} else {
|
||||
this.editor.update(
|
||||
() => {
|
||||
const fileExportNode = $createFileExportNode(fileItem.name, fileItem.mimeType)
|
||||
fileNode.replace(fileExportNode)
|
||||
},
|
||||
{ discrete: true },
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
.then(() => resolve())
|
||||
.catch(console.error)
|
||||
},
|
||||
{ discrete: true },
|
||||
)
|
||||
})
|
||||
|
||||
this.editor.update(
|
||||
() => {
|
||||
switch (toFormat) {
|
||||
@@ -87,6 +127,9 @@ export class HeadlessSuperConverter implements SuperConverterServiceInterface {
|
||||
content = $convertToMarkdownString(MarkdownTransformers)
|
||||
break
|
||||
}
|
||||
case 'html':
|
||||
content = $generateHtmlFromNodes(this.editor)
|
||||
break
|
||||
case 'json':
|
||||
default:
|
||||
content = superString
|
||||
@@ -183,4 +226,23 @@ export class HeadlessSuperConverter implements SuperConverterServiceInterface {
|
||||
|
||||
return JSON.stringify(this.editor.getEditorState())
|
||||
}
|
||||
|
||||
getEmbeddedFileIDsFromSuperString(superString: string): string[] {
|
||||
if (superString.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
this.editor.setEditorState(this.editor.parseEditorState(superString))
|
||||
|
||||
const ids: string[] = []
|
||||
|
||||
this.editor.getEditorState().read(() => {
|
||||
const fileNodes = $nodesOfType(FileNode)
|
||||
fileNodes.forEach((fileNode) => {
|
||||
ids.push(fileNode.getId())
|
||||
})
|
||||
})
|
||||
|
||||
return ids
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ const Switch = ({
|
||||
disabled = false,
|
||||
tabIndex,
|
||||
forceDesktopStyle,
|
||||
children,
|
||||
}: {
|
||||
checked: boolean
|
||||
onChange: (checked: boolean) => void
|
||||
@@ -15,20 +16,12 @@ const Switch = ({
|
||||
disabled?: boolean
|
||||
tabIndex?: number
|
||||
forceDesktopStyle?: boolean
|
||||
children?: React.ReactNode
|
||||
}) => {
|
||||
const isActive = checked && !disabled
|
||||
|
||||
return (
|
||||
<label
|
||||
className={classNames(
|
||||
'relative box-content inline-block flex-shrink-0 cursor-pointer rounded-full border-2 border-solid border-transparent bg-clip-padding transition-colors duration-150 ease-out',
|
||||
'ring-2 ring-transparent focus-within:border-default focus-within:shadow-none focus-within:outline-none focus-within:ring-info',
|
||||
disabled ? 'opacity-50' : '',
|
||||
isActive ? 'bg-info' : 'bg-neutral',
|
||||
forceDesktopStyle ? 'h-4.5 w-8' : 'h-7 w-12 md:h-4.5 md:w-8',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<label className={classNames(disabled ? 'opacity-50' : '', className)}>
|
||||
<VisuallyHidden>
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
@@ -38,6 +31,14 @@ const Switch = ({
|
||||
tabIndex={tabIndex}
|
||||
/>
|
||||
</VisuallyHidden>
|
||||
<div
|
||||
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',
|
||||
isActive ? 'bg-info' : 'bg-neutral',
|
||||
forceDesktopStyle ? 'h-4.5 w-8' : 'h-7 w-12 md:h-4.5 md:w-8',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
'absolute top-1/2 block -translate-y-1/2 rounded-full bg-default transition-transform duration-150 ease-out',
|
||||
@@ -49,6 +50,8 @@ const Switch = ({
|
||||
: '',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{children}
|
||||
</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> {
|
||||
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 { NoteType, PrefKey, SNNote, PrefDefaults } from '@standardnotes/snjs'
|
||||
import { WebApplicationInterface } from '@standardnotes/ui-services'
|
||||
import { NoteType, PrefKey, SNNote, PrefDefaults, FileItem, PrefValue, pluralize } from '@standardnotes/snjs'
|
||||
import { WebApplicationInterface, parseAndCreateZippableFileName, sanitizeFileName } from '@standardnotes/ui-services'
|
||||
import { ZipDirectoryEntry } from '@zip.js/zip.js'
|
||||
|
||||
export const getNoteFormat = (application: WebApplicationInterface, note: SNNote) => {
|
||||
if (note.noteType === NoteType.Super) {
|
||||
@@ -21,7 +23,49 @@ export const getNoteFileName = (application: WebApplicationInterface, note: SNNo
|
||||
return `${note.title}.${format}`
|
||||
}
|
||||
|
||||
export const getNoteBlob = (application: WebApplicationInterface, note: SNNote) => {
|
||||
const headlessSuperConverter = new HeadlessSuperConverter()
|
||||
|
||||
// @ts-expect-error Using inline loaders to load CSS as string
|
||||
import superEditorCSS from '!css-loader!sass-loader!../Components/SuperEditor/Lexical/Theme/editor.scss'
|
||||
// @ts-expect-error Using inline loaders to load CSS as string
|
||||
import snColorsCSS from '!css-loader!sass-loader!@standardnotes/styles/src/Styles/_colors.scss'
|
||||
// @ts-expect-error Using inline loaders to load CSS as string
|
||||
import exportOverridesCSS from '!css-loader!sass-loader!../Components/SuperEditor/Lexical/Theme/export-overrides.scss'
|
||||
import { getBase64FromBlob } from './Utils'
|
||||
import { ToastType, addToast, dismissToast } from '@standardnotes/toast'
|
||||
|
||||
const superHTML = (note: SNNote, content: string) => `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>${note.title}</title>
|
||||
<style>
|
||||
${snColorsCSS.toString()}
|
||||
${superEditorCSS.toString()}
|
||||
${exportOverridesCSS.toString()}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
${content}
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
const superMarkdown = (note: SNNote, content: string) => `---
|
||||
title: ${note.title}
|
||||
created_at: ${note.created_at.toISOString()}
|
||||
updated_at: ${note.serverUpdatedAt.toISOString()}
|
||||
uuid: ${note.uuid}
|
||||
---
|
||||
|
||||
${content}
|
||||
`
|
||||
|
||||
export const getNoteBlob = async (
|
||||
application: WebApplication,
|
||||
note: SNNote,
|
||||
superEmbedBehavior: PrefValue[PrefKey.SuperNoteExportEmbedBehavior],
|
||||
) => {
|
||||
const format = getNoteFormat(application, note)
|
||||
let type: string
|
||||
switch (format) {
|
||||
@@ -38,12 +82,162 @@ export const getNoteBlob = (application: WebApplicationInterface, note: SNNote)
|
||||
type = 'text/plain'
|
||||
break
|
||||
}
|
||||
const content =
|
||||
note.noteType === NoteType.Super
|
||||
? new HeadlessSuperConverter().convertSuperStringToOtherFormat(note.text, format)
|
||||
: note.text
|
||||
const blob = new Blob([content], {
|
||||
if (note.noteType === NoteType.Super) {
|
||||
const content = await headlessSuperConverter.convertSuperStringToOtherFormat(note.text, format, {
|
||||
embedBehavior: superEmbedBehavior,
|
||||
getFileItem: (id) => application.items.findItem<FileItem>(id),
|
||||
getFileBase64: async (id) => {
|
||||
const fileItem = application.items.findItem<FileItem>(id)
|
||||
if (!fileItem) {
|
||||
return
|
||||
}
|
||||
const fileBlob = await application.filesController.getFileBlob(fileItem)
|
||||
if (!fileBlob) {
|
||||
return
|
||||
}
|
||||
return await getBase64FromBlob(fileBlob)
|
||||
},
|
||||
})
|
||||
const useMDFrontmatter =
|
||||
format === 'md' &&
|
||||
application.getPreference(
|
||||
PrefKey.SuperNoteExportUseMDFrontmatter,
|
||||
PrefDefaults[PrefKey.SuperNoteExportUseMDFrontmatter],
|
||||
)
|
||||
const result =
|
||||
format === 'html' ? superHTML(note, content) : useMDFrontmatter ? superMarkdown(note, content) : content
|
||||
const blob = new Blob([result], {
|
||||
type,
|
||||
})
|
||||
return blob
|
||||
}
|
||||
const blob = new Blob([note.text], {
|
||||
type,
|
||||
})
|
||||
return blob
|
||||
}
|
||||
|
||||
const isSuperNote = (note: SNNote) => {
|
||||
return note.noteType === NoteType.Super
|
||||
}
|
||||
|
||||
const noteHasEmbeddedFiles = (note: SNNote) => {
|
||||
return note.text.includes('"type":"snfile"')
|
||||
}
|
||||
|
||||
const noteRequiresFolder = (
|
||||
note: SNNote,
|
||||
superExportFormat: PrefValue[PrefKey.SuperNoteExportFormat],
|
||||
superEmbedBehavior: PrefValue[PrefKey.SuperNoteExportEmbedBehavior],
|
||||
) => {
|
||||
if (!isSuperNote(note)) {
|
||||
return false
|
||||
}
|
||||
if (superExportFormat === 'json') {
|
||||
return false
|
||||
}
|
||||
if (superEmbedBehavior !== 'separate') {
|
||||
return false
|
||||
}
|
||||
return noteHasEmbeddedFiles(note)
|
||||
}
|
||||
|
||||
const addEmbeddedFilesToFolder = async (application: WebApplication, note: SNNote, folder: ZipDirectoryEntry) => {
|
||||
try {
|
||||
const embeddedFileIDs = headlessSuperConverter.getEmbeddedFileIDsFromSuperString(note.text)
|
||||
for (const embeddedFileID of embeddedFileIDs) {
|
||||
const fileItem = application.items.findItem<FileItem>(embeddedFileID)
|
||||
if (!fileItem) {
|
||||
continue
|
||||
}
|
||||
const embeddedFileBlob = await application.filesController.getFileBlob(fileItem)
|
||||
if (!embeddedFileBlob) {
|
||||
continue
|
||||
}
|
||||
folder.addBlob(parseAndCreateZippableFileName(fileItem.title), embeddedFileBlob)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
export const createNoteExport = async (
|
||||
application: WebApplication,
|
||||
notes: SNNote[],
|
||||
): Promise<
|
||||
| {
|
||||
blob: Blob
|
||||
fileName: string
|
||||
}
|
||||
| undefined
|
||||
> => {
|
||||
if (notes.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const toast = addToast({
|
||||
type: ToastType.Progress,
|
||||
message: `Exporting ${notes.length} ${pluralize(notes.length, 'note', 'notes')}...`,
|
||||
})
|
||||
|
||||
const superExportFormatPref = application.getPreference(
|
||||
PrefKey.SuperNoteExportFormat,
|
||||
PrefDefaults[PrefKey.SuperNoteExportFormat],
|
||||
)
|
||||
const superEmbedBehaviorPref = application.getPreference(
|
||||
PrefKey.SuperNoteExportEmbedBehavior,
|
||||
PrefDefaults[PrefKey.SuperNoteExportEmbedBehavior],
|
||||
)
|
||||
|
||||
if (notes.length === 1 && !noteRequiresFolder(notes[0], superExportFormatPref, superEmbedBehaviorPref)) {
|
||||
const blob = await getNoteBlob(application, notes[0], superEmbedBehaviorPref)
|
||||
const fileName = getNoteFileName(application, notes[0])
|
||||
dismissToast(toast)
|
||||
return {
|
||||
blob,
|
||||
fileName,
|
||||
}
|
||||
}
|
||||
|
||||
const zip = await import('@zip.js/zip.js')
|
||||
const zipFS = new zip.fs.FS()
|
||||
const { root } = zipFS
|
||||
|
||||
if (notes.length === 1 && noteRequiresFolder(notes[0], superExportFormatPref, superEmbedBehaviorPref)) {
|
||||
const blob = await getNoteBlob(application, notes[0], superEmbedBehaviorPref)
|
||||
const fileName = parseAndCreateZippableFileName(getNoteFileName(application, notes[0]))
|
||||
root.addBlob(fileName, blob)
|
||||
|
||||
await addEmbeddedFilesToFolder(application, notes[0], root)
|
||||
|
||||
const zippedBlob = await zipFS.exportBlob()
|
||||
dismissToast(toast)
|
||||
return {
|
||||
blob: zippedBlob,
|
||||
fileName: fileName + '.zip',
|
||||
}
|
||||
}
|
||||
|
||||
for (const note of notes) {
|
||||
const blob = await getNoteBlob(application, note, superEmbedBehaviorPref)
|
||||
const fileName = parseAndCreateZippableFileName(getNoteFileName(application, note))
|
||||
|
||||
if (!noteRequiresFolder(note, superExportFormatPref, superEmbedBehaviorPref)) {
|
||||
root.addBlob(fileName, blob)
|
||||
continue
|
||||
}
|
||||
|
||||
const folder = root.addDirectory(sanitizeFileName(note.title))
|
||||
folder.addBlob(fileName, blob)
|
||||
await addEmbeddedFilesToFolder(application, note, folder)
|
||||
}
|
||||
|
||||
const zippedBlob = await zipFS.exportBlob()
|
||||
|
||||
dismissToast(toast)
|
||||
|
||||
return {
|
||||
blob: zippedBlob,
|
||||
fileName: `Standard Notes Export - ${application.archiveService.formattedDateForExports()}.zip`,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user