Browse Source

publication export

imwald
Silberengel 5 months ago
parent
commit
0ba08f7a28
  1. 162
      src/components/ArticleExportMenu/ArticleExportMenu.tsx
  2. 2
      src/components/KindFilter/index.tsx
  3. 8
      src/components/Note/AsciidocArticle/AsciidocArticle.tsx
  4. 7
      src/components/Note/LongFormArticlePreview.tsx
  5. 28
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  6. 7
      src/components/Note/PublicationCard.tsx
  7. 174
      src/components/Note/PublicationIndex/PublicationIndex.tsx
  8. 4
      src/components/Note/WikiCard.tsx
  9. 10
      src/components/Note/index.tsx
  10. 14
      src/services/local-storage.service.ts

162
src/components/ArticleExportMenu/ArticleExportMenu.tsx

@ -0,0 +1,162 @@ @@ -0,0 +1,162 @@
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { MoreVertical, FileDown } from 'lucide-react'
import { contentParserService } from '@/services/content-parser.service'
import logger from '@/lib/logger'
import { Event } from 'nostr-tools'
interface ArticleExportMenuProps {
event: Event
title: string
}
export default function ArticleExportMenu({ event, title }: ArticleExportMenuProps) {
const exportArticle = async (format: 'pdf' | 'epub' | 'latex' | 'adoc' | 'html') => {
try {
const content = event.content
const filename = `${title}.${format}`
let blob: Blob = new Blob([''])
if (format === 'adoc') {
// Export raw AsciiDoc content
blob = new Blob([content], { type: 'text/plain' })
} else if (format === 'html') {
// Parse the AsciiDoc content to HTML
const parsedContent = await contentParserService.parseContent(content, {
eventKind: event.kind,
enableMath: true,
enableSyntaxHighlighting: true
})
const htmlDocument = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>${title}</title>
<style>
body { font-family: Georgia, serif; max-width: 800px; margin: 0 auto; padding: 20px; line-height: 1.6; }
h1 { color: #333; border-bottom: 2px solid #333; padding-bottom: 10px; }
h2 { color: #555; margin-top: 30px; }
code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-family: monospace; }
pre { background: #f4f4f4; padding: 15px; border-radius: 5px; overflow-x: auto; }
blockquote { border-left: 4px solid #ddd; margin-left: 0; padding-left: 20px; color: #666; }
</style>
</head>
<body>
<article>
<h1>${title}</h1>
${parsedContent.html}
</article>
</body>
</html>`
blob = new Blob([htmlDocument], { type: 'text/html' })
} else if (format === 'latex') {
// Basic LaTeX conversion
let processedContent = content.replace(/^= (.+)$/gm, '\\section{$1}')
processedContent = processedContent.replace(/^== (.+)$/gm, '\\subsection{$1}')
processedContent = processedContent.replace(/^=== (.+)$/gm, '\\subsubsection{$1}')
blob = new Blob([processedContent], { type: 'text/plain' })
} else if (format === 'pdf' || format === 'epub') {
// Parse the AsciiDoc content to HTML using the content parser
const parsedContent = await contentParserService.parseContent(content, {
eventKind: event.kind,
enableMath: true,
enableSyntaxHighlighting: true
})
const htmlDocument = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>${title}</title>
<style>
body { font-family: Georgia, serif; max-width: 800px; margin: 0 auto; padding: 20px; line-height: 1.6; }
h1 { color: #333; border-bottom: 2px solid #333; padding-bottom: 10px; }
h2 { color: #555; margin-top: 30px; }
code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-family: monospace; }
pre { background: #f4f4f4; padding: 15px; border-radius: 5px; overflow-x: auto; }
blockquote { border-left: 4px solid #ddd; margin-left: 0; padding-left: 20px; color: #666; }
</style>
</head>
<body>
<article>
<h1>${title}</h1>
${parsedContent.html}
</article>
</body>
</html>`
blob = new Blob([htmlDocument], { type: 'text/html' })
}
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
logger.info(`[ArticleExportMenu] Exported article as ${format}`)
} catch (error) {
logger.error('[ArticleExportMenu] Error exporting article:', error)
alert('Failed to export article. Please try again.')
}
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button variant="ghost" size="icon" className="shrink-0">
<MoreVertical className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onClick={(e) => e.stopPropagation()} className="w-56">
<DropdownMenuItem onClick={() => exportArticle('html')}>
<FileDown className="mr-2 h-4 w-4" />
<div>
<div>Export as HTML</div>
<div className="text-xs text-muted-foreground">Ready to view in browser</div>
</div>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => exportArticle('adoc')}>
<FileDown className="mr-2 h-4 w-4" />
<div>
<div>Export as AsciiDoc</div>
<div className="text-xs text-muted-foreground">Raw .adoc file</div>
</div>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => exportArticle('pdf')}>
<FileDown className="mr-2 h-4 w-4" />
<div>
<div>Export as PDF</div>
<div className="text-xs text-muted-foreground">HTML - use browser Print to PDF</div>
</div>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => exportArticle('epub')}>
<FileDown className="mr-2 h-4 w-4" />
<div>
<div>Export as EPUB</div>
<div className="text-xs text-muted-foreground">HTML - convert with Calibre</div>
</div>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => exportArticle('latex')}>
<FileDown className="mr-2 h-4 w-4" />
<div>
<div>Export as LaTeX</div>
<div className="text-xs text-muted-foreground">Basic conversion</div>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

2
src/components/KindFilter/index.tsx

@ -16,6 +16,8 @@ const KIND_FILTER_OPTIONS = [ @@ -16,6 +16,8 @@ const KIND_FILTER_OPTIONS = [
{ kindGroup: [kinds.ShortTextNote, ExtendedKind.COMMENT], label: 'Posts' },
{ kindGroup: [kinds.Repost], label: 'Reposts' },
{ kindGroup: [kinds.LongFormArticle], label: 'Articles' },
{ kindGroup: [ExtendedKind.PUBLICATION], label: 'Publications' },
{ kindGroup: [ExtendedKind.WIKI_ARTICLE], label: 'Wiki Articles' },
{ kindGroup: [kinds.Highlights], label: 'Highlights' },
{ kindGroup: [ExtendedKind.POLL], label: 'Polls' },
{ kindGroup: [ExtendedKind.VOICE, ExtendedKind.VOICE_COMMENT], label: 'Voice Posts' },

8
src/components/Note/AsciidocArticle/AsciidocArticle.tsx

@ -16,10 +16,12 @@ import { ExtendedKind } from '@/constants' @@ -16,10 +16,12 @@ import { ExtendedKind } from '@/constants'
export default function AsciidocArticle({
event,
className
className,
hideImagesAndInfo = false
}: {
event: Event
className?: string
hideImagesAndInfo?: boolean
}) {
const { push } = useSecondaryPage()
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
@ -309,7 +311,7 @@ export default function AsciidocArticle({ @@ -309,7 +311,7 @@ export default function AsciidocArticle({
/>
{/* Image Carousel - Collapsible */}
{allImages.length > 0 && (
{!hideImagesAndInfo && allImages.length > 0 && (
<Collapsible open={isImagesOpen} onOpenChange={setIsImagesOpen} className="mt-8">
<CollapsibleTrigger asChild>
<Button variant="outline" className="w-full justify-between">
@ -324,7 +326,7 @@ export default function AsciidocArticle({ @@ -324,7 +326,7 @@ export default function AsciidocArticle({
)}
{/* Collapsible Article Info - only for article-type events */}
{isArticleType && (parsedContent?.links?.length > 0 || parsedContent?.nostrLinks?.length > 0 || parsedContent?.highlightSources?.length > 0 || parsedContent?.hashtags?.length > 0) && (
{!hideImagesAndInfo && isArticleType && (parsedContent?.links?.length > 0 || parsedContent?.nostrLinks?.length > 0 || parsedContent?.highlightSources?.length > 0 || parsedContent?.hashtags?.length > 0) && (
<Collapsible open={isInfoOpen} onOpenChange={setIsInfoOpen} className="mt-4">
<CollapsibleTrigger asChild>
<Button variant="outline" className="w-full justify-between">

7
src/components/Note/LongFormArticlePreview.tsx

@ -6,6 +6,7 @@ import { useScreenSize } from '@/providers/ScreenSizeProvider' @@ -6,6 +6,7 @@ import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Event, kinds } from 'nostr-tools'
import { useMemo } from 'react'
import Image from '../Image'
import ArticleExportMenu from '../ArticleExportMenu/ArticleExportMenu'
export default function LongFormArticlePreview({
event,
@ -65,6 +66,9 @@ export default function LongFormArticlePreview({ @@ -65,6 +66,9 @@ export default function LongFormArticlePreview({
{titleComponent}
{summaryComponent}
{tagsComponent}
<div className="flex justify-end">
<ArticleExportMenu event={event} title={metadata.title || 'Article'} />
</div>
</div>
</div>
</div>
@ -89,6 +93,9 @@ export default function LongFormArticlePreview({ @@ -89,6 +93,9 @@ export default function LongFormArticlePreview({
{titleComponent}
{summaryComponent}
{tagsComponent}
<div className="flex justify-end">
<ArticleExportMenu event={event} title={metadata.title || 'Article'} />
</div>
</div>
</div>
</div>

28
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -20,10 +20,12 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component @@ -20,10 +20,12 @@ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/component
export default function MarkdownArticle({
event,
className
className,
showImageGallery = false
}: {
event: Event
className?: string
showImageGallery?: boolean
}) {
const { push } = useSecondaryPage()
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
@ -164,12 +166,24 @@ export default function MarkdownArticle({ @@ -164,12 +166,24 @@ export default function MarkdownArticle({
return <>{children}</>
},
img: () => {
// Don't render inline images - they'll be shown in the carousel
return null
img: ({ src }) => {
if (!src) return null
// If showing image gallery, don't render inline images - they'll be shown in the carousel
if (showImageGallery) {
return null
}
// For all other content, render images inline
return (
<ImageWithLightbox
image={{ url: src, pubkey: event.pubkey }}
className="max-w-full rounded-lg my-2"
/>
)
}
}) as Components,
[]
[showImageGallery, event.pubkey]
)
return (
@ -273,8 +287,8 @@ export default function MarkdownArticle({ @@ -273,8 +287,8 @@ export default function MarkdownArticle({
{event.content}
</Markdown>
{/* Image Carousel - Collapsible */}
{allImages.length > 0 && (
{/* Image Carousel - Only show for article content (30023, 30041, 30818) */}
{showImageGallery && allImages.length > 0 && (
<Collapsible open={isImagesOpen} onOpenChange={setIsImagesOpen} className="mt-8">
<CollapsibleTrigger asChild>
<Button variant="outline" className="w-full justify-between">

7
src/components/Note/PublicationCard.tsx

@ -8,6 +8,7 @@ import { nip19 } from 'nostr-tools' @@ -8,6 +8,7 @@ import { nip19 } from 'nostr-tools'
import { useMemo } from 'react'
import { BookOpen } from 'lucide-react'
import Image from '../Image'
import ArticleExportMenu from '../ArticleExportMenu/ArticleExportMenu'
export default function PublicationCard({
event,
@ -105,7 +106,8 @@ export default function PublicationCard({ @@ -105,7 +106,8 @@ export default function PublicationCard({
{titleComponent}
{summaryComponent}
{tagsComponent}
<div className="flex justify-end">
<div className="flex justify-end gap-2 items-center">
<ArticleExportMenu event={event} title={metadata.title || 'Article'} />
{alexandriaButton}
</div>
</div>
@ -132,7 +134,8 @@ export default function PublicationCard({ @@ -132,7 +134,8 @@ export default function PublicationCard({
{titleComponent}
{summaryComponent}
{tagsComponent}
<div className="flex justify-end">
<div className="flex justify-end gap-2 items-center">
<ArticleExportMenu event={event} title={metadata.title || 'Article'} />
{alexandriaButton}
</div>
</div>

174
src/components/Note/PublicationIndex/PublicationIndex.tsx

@ -6,6 +6,15 @@ import AsciidocArticle from '../AsciidocArticle/AsciidocArticle' @@ -6,6 +6,15 @@ import AsciidocArticle from '../AsciidocArticle/AsciidocArticle'
import { generateBech32IdFromATag } from '@/lib/tag'
import client from '@/services/client.service'
import logger from '@/lib/logger'
import { Button } from '@/components/ui/button'
import { contentParserService } from '@/services/content-parser.service'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { MoreVertical, FileDown } from 'lucide-react'
interface PublicationReference {
coordinate: string
@ -138,6 +147,136 @@ export default function PublicationIndex({ @@ -138,6 +147,136 @@ export default function PublicationIndex({
}
}
// Export publication in different formats
const exportPublication = async (format: 'pdf' | 'epub' | 'latex' | 'adoc' | 'html') => {
try {
// Collect all content from references
const contentParts: string[] = []
for (const ref of references) {
if (!ref.event) continue
// Extract title
const title = ref.event.tags.find(tag => tag[0] === 'title')?.[1] || 'Untitled'
// Extract raw content
let content = ref.event.content
if (format === 'adoc') {
// For AsciiDoc, output the raw content with title
contentParts.push(`= ${title}\n\n${content}\n\n`)
} else if (format === 'html') {
// For HTML, parse the AsciiDoc content to HTML
const parsedContent = await contentParserService.parseContent(content, {
eventKind: ref.kind,
enableMath: true,
enableSyntaxHighlighting: true
})
contentParts.push(`<article>
<h1>${title}</h1>
${parsedContent.html}
</article>`)
} else if (format === 'latex') {
// Convert to LaTeX
content = content.replace(/^= (.+)$/gm, '\\section{$1}')
content = content.replace(/^== (.+)$/gm, '\\subsection{$1}')
content = content.replace(/^=== (.+)$/gm, '\\subsubsection{$1}')
contentParts.push(`\\section*{${title}}\n\n${content}\n\n`)
} else if (format === 'pdf' || format === 'epub') {
// For PDF/EPUB, we need to export as HTML that can be converted
// Parse the AsciiDoc content to HTML using the content parser
const parsedContent = await contentParserService.parseContent(content, {
eventKind: ref.kind,
enableMath: true,
enableSyntaxHighlighting: true
})
contentParts.push(`<article>
<h1>${title}</h1>
${parsedContent.html}
</article>`)
}
}
const fullContent = contentParts.join('\n')
const filename = `${metadata.title || 'publication'}.${format}`
let blob: Blob = new Blob([''])
if (format === 'html') {
// For HTML, wrap the content in a full HTML document
const htmlDocument = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>${metadata.title || 'Publication'}</title>
<style>
body { font-family: Georgia, serif; max-width: 800px; margin: 0 auto; padding: 20px; line-height: 1.6; }
h1 { color: #333; border-bottom: 2px solid #333; padding-bottom: 10px; }
h2 { color: #555; margin-top: 30px; }
code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-family: monospace; }
pre { background: #f4f4f4; padding: 15px; border-radius: 5px; overflow-x: auto; }
blockquote { border-left: 4px solid #ddd; margin-left: 0; padding-left: 20px; color: #666; }
table { border-collapse: collapse; width: 100%; margin: 20px 0; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #f2f2f2; }
</style>
</head>
<body>
${fullContent}
</body>
</html>`
blob = new Blob([htmlDocument], { type: 'text/html' })
} else if (format === 'pdf' || format === 'epub') {
// For PDF/EPUB, wrap the HTML content in a full HTML document
const htmlDocument = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>${metadata.title || 'Publication'}</title>
<style>
body { font-family: Georgia, serif; max-width: 800px; margin: 0 auto; padding: 20px; line-height: 1.6; }
h1 { color: #333; border-bottom: 2px solid #333; padding-bottom: 10px; }
h2 { color: #555; margin-top: 30px; }
code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; font-family: monospace; }
pre { background: #f4f4f4; padding: 15px; border-radius: 5px; overflow-x: auto; }
blockquote { border-left: 4px solid #ddd; margin-left: 0; padding-left: 20px; color: #666; }
table { border-collapse: collapse; width: 100%; margin: 20px 0; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background-color: #f2f2f2; }
</style>
</head>
<body>
${fullContent}
</body>
</html>`
blob = new Blob([htmlDocument], { type: 'text/html' })
} else {
// For AsciiDoc or LaTeX formats, use the raw content
blob = new Blob([fullContent], {
type: format === 'latex' ? 'text/plain' : 'text/plain'
})
}
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
logger.info(`[PublicationIndex] Exported publication as ${format}`)
} catch (error) {
logger.error('[PublicationIndex] Error exporting publication:', error)
alert('Failed to export publication. Please try again.')
}
}
// Extract references from 'a' tags
const referencesData = useMemo(() => {
const refs: PublicationReference[] = []
@ -225,7 +364,38 @@ export default function PublicationIndex({ @@ -225,7 +364,38 @@ export default function PublicationIndex({
{/* Publication Metadata */}
<div className="prose prose-zinc max-w-none dark:prose-invert">
<header className="mb-8 border-b pb-6">
<h1 className="text-4xl font-bold mb-4 leading-tight break-words">{metadata.title}</h1>
<div className="flex items-start justify-between gap-4 mb-4">
<h1 className="text-4xl font-bold leading-tight break-words flex-1">{metadata.title}</h1>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="shrink-0">
<MoreVertical className="h-5 w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => exportPublication('html')}>
<FileDown className="mr-2 h-4 w-4" />
Export as HTML
</DropdownMenuItem>
<DropdownMenuItem onClick={() => exportPublication('adoc')}>
<FileDown className="mr-2 h-4 w-4" />
Export as AsciiDoc
</DropdownMenuItem>
<DropdownMenuItem onClick={() => exportPublication('pdf')}>
<FileDown className="mr-2 h-4 w-4" />
Export as PDF
</DropdownMenuItem>
<DropdownMenuItem onClick={() => exportPublication('epub')}>
<FileDown className="mr-2 h-4 w-4" />
Export as EPUB
</DropdownMenuItem>
<DropdownMenuItem onClick={() => exportPublication('latex')}>
<FileDown className="mr-2 h-4 w-4" />
Export as LaTeX
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{metadata.summary && (
<blockquote className="border-l-4 border-primary pl-6 italic text-muted-foreground mb-4 text-lg leading-relaxed">
<p className="break-words">{metadata.summary}</p>
@ -300,7 +470,7 @@ export default function PublicationIndex({ @@ -300,7 +470,7 @@ export default function PublicationIndex({
// Render 30041 or 30818 content as AsciidocArticle
return (
<div key={index} id={sectionId} className="scroll-mt-4">
<AsciidocArticle event={ref.event} />
<AsciidocArticle event={ref.event} hideImagesAndInfo={true} />
</div>
)
} else {

4
src/components/Note/WikiCard.tsx

@ -8,6 +8,7 @@ import { nip19 } from 'nostr-tools' @@ -8,6 +8,7 @@ import { nip19 } from 'nostr-tools'
import { useMemo } from 'react'
import { BookOpen, Globe } from 'lucide-react'
import Image from '../Image'
import ArticleExportMenu from '../ArticleExportMenu/ArticleExportMenu'
export default function WikiCard({
event,
@ -89,7 +90,8 @@ export default function WikiCard({ @@ -89,7 +90,8 @@ export default function WikiCard({
)
const buttons = (
<div className="flex gap-2 flex-wrap">
<div className="flex gap-2 flex-wrap items-center">
<ArticleExportMenu event={event} title={metadata.title || 'Article'} />
{dTag && (
<button
onClick={handleWikistrClick}

10
src/components/Note/index.tsx

@ -120,7 +120,7 @@ export default function Note({ @@ -120,7 +120,7 @@ export default function Note({
)
} else if (event.kind === kinds.LongFormArticle) {
content = showFull ? (
<MarkdownArticle className="mt-2" event={event} />
<MarkdownArticle className="mt-2" event={event} showImageGallery={true} />
) : (
<LongFormArticlePreview className="mt-2" event={event} />
)
@ -159,8 +159,12 @@ export default function Note({ @@ -159,8 +159,12 @@ export default function Note({
} else if (event.kind === ExtendedKind.ZAP_REQUEST || event.kind === ExtendedKind.ZAP_RECEIPT) {
content = <Zap className="mt-2" event={event} />
} else {
// Use MarkdownArticle for all other kinds (including kinds 1 and 11)
content = <MarkdownArticle className="mt-2" event={event} />
// Use MarkdownArticle for all other kinds
// Only 30023, 30041, and 30818 will show image gallery and article info
const showImageGallery = event.kind === kinds.LongFormArticle ||
event.kind === ExtendedKind.PUBLICATION_CONTENT ||
event.kind === ExtendedKind.WIKI_ARTICLE
content = <MarkdownArticle className="mt-2" event={event} showImageGallery={showImageGallery} />
}
return (

14
src/services/local-storage.service.ts

@ -195,10 +195,22 @@ class LocalStorageService { @@ -195,10 +195,22 @@ class LocalStorageService {
showKinds.splice(repostIndex, 1)
}
}
if (showKindsVersion < 4) {
// Add publications and wiki articles to existing users' filters
if (!showKinds.includes(ExtendedKind.PUBLICATION)) {
showKinds.push(ExtendedKind.PUBLICATION)
}
if (!showKinds.includes(ExtendedKind.PUBLICATION_CONTENT)) {
showKinds.push(ExtendedKind.PUBLICATION_CONTENT)
}
if (!showKinds.includes(ExtendedKind.WIKI_ARTICLE)) {
showKinds.push(ExtendedKind.WIKI_ARTICLE)
}
}
this.showKinds = showKinds
}
window.localStorage.setItem(StorageKey.SHOW_KINDS, JSON.stringify(this.showKinds))
window.localStorage.setItem(StorageKey.SHOW_KINDS_VERSION, '3')
window.localStorage.setItem(StorageKey.SHOW_KINDS_VERSION, '4')
this.hideContentMentioningMutedUsers =
window.localStorage.getItem(StorageKey.HIDE_CONTENT_MENTIONING_MUTED_USERS) === 'true'

Loading…
Cancel
Save