Browse Source

implement OPML import/export

imwald
Silberengel 4 months ago
parent
commit
dd0921e3e7
  1. 130
      src/lib/opml.ts
  2. 107
      src/pages/secondary/RssFeedSettingsPage/index.tsx

130
src/lib/opml.ts

@ -0,0 +1,130 @@ @@ -0,0 +1,130 @@
/**
* OPML (Outline Processor Markup Language) utilities for RSS feed import/export
*/
export interface OpmlFeed {
title?: string
xmlUrl: string
htmlUrl?: string
text?: string
}
/**
* Parse an OPML file and extract RSS feed URLs
*/
export function parseOpml(opmlText: string): OpmlFeed[] {
const feeds: OpmlFeed[] = []
try {
const parser = new DOMParser()
const doc = parser.parseFromString(opmlText, 'text/xml')
// Check for parsing errors
const parserError = doc.querySelector('parsererror')
if (parserError) {
throw new Error('Invalid OPML file format')
}
// Find all outline elements with xmlUrl (RSS feeds)
const outlines = doc.querySelectorAll('outline[xmlUrl]')
outlines.forEach((outline) => {
const xmlUrl = outline.getAttribute('xmlUrl')
const htmlUrl = outline.getAttribute('htmlUrl')
const title = outline.getAttribute('title')
const text = outline.getAttribute('text')
if (xmlUrl) {
feeds.push({
xmlUrl,
htmlUrl: htmlUrl || undefined,
title: title || undefined,
text: text || undefined
})
}
})
// Also check for nested outlines (some OPML files nest feeds)
const allOutlines = doc.querySelectorAll('outline')
allOutlines.forEach((outline) => {
const xmlUrl = outline.getAttribute('xmlUrl')
if (xmlUrl && !feeds.some(f => f.xmlUrl === xmlUrl)) {
const htmlUrl = outline.getAttribute('htmlUrl')
const title = outline.getAttribute('title')
const text = outline.getAttribute('text')
feeds.push({
xmlUrl,
htmlUrl: htmlUrl || undefined,
title: title || undefined,
text: text || undefined
})
}
})
return feeds
} catch (error) {
throw new Error(`Failed to parse OPML file: ${error instanceof Error ? error.message : String(error)}`)
}
}
/**
* Generate an OPML file from a list of feed URLs
*/
export function generateOpml(feedUrls: string[], title: string = 'RSS Feeds'): string {
const now = new Date()
const dateString = now.toUTCString()
let opml = `<?xml version="1.0" encoding="UTF-8"?>
<opml version="2.0">
<head>
<title>${escapeXml(title)}</title>
<dateCreated>${dateString}</dateCreated>
<dateModified>${dateString}</dateModified>
</head>
<body>
`
feedUrls.forEach((url) => {
try {
const urlObj = new URL(url)
const feedTitle = urlObj.hostname.replace(/^www\./, '')
opml += ` <outline type="rss" text="${escapeXml(feedTitle)}" title="${escapeXml(feedTitle)}" xmlUrl="${escapeXml(url)}" htmlUrl="${escapeXml(url)}"/>\n`
} catch {
// Invalid URL, skip it
}
})
opml += ` </body>
</opml>`
return opml
}
/**
* Escape XML special characters
*/
function escapeXml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}
/**
* Download a file with the given content and filename
*/
export function downloadFile(content: string, filename: string, mimeType: string = 'application/xml') {
const blob = new Blob([content], { type: mimeType })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}

107
src/pages/secondary/RssFeedSettingsPage/index.tsx

@ -9,11 +9,13 @@ import { Switch } from '@/components/ui/switch' @@ -9,11 +9,13 @@ import { Switch } from '@/components/ui/switch'
import storage from '@/services/local-storage.service'
import { createRssFeedListDraftEvent } from '@/lib/draft-event'
import { showPublishingFeedback, showSimplePublishSuccess, showPublishingError } from '@/lib/publishing-feedback'
import { CloudUpload, Loader, Trash2, Plus } from 'lucide-react'
import { CloudUpload, Loader, Trash2, Plus, Download, Upload } from 'lucide-react'
import logger from '@/lib/logger'
import { ExtendedKind } from '@/constants'
import indexedDb from '@/services/indexed-db.service'
import rssFeedService from '@/services/rss-feed.service'
import { parseOpml, generateOpml, downloadFile } from '@/lib/opml'
import { toast } from 'sonner'
const RssFeedSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => {
const { t } = useTranslation()
@ -107,6 +109,74 @@ const RssFeedSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index @@ -107,6 +109,74 @@ const RssFeedSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index
setHasChange(true)
}
const handleImportOpml = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]
if (!file) return
// Reset input
event.target.value = ''
try {
const text = await file.text()
const feeds = parseOpml(text)
if (feeds.length === 0) {
toast.error(t('No RSS feeds found in OPML file'))
return
}
// Extract URLs from OPML feeds
const urls = feeds.map(feed => feed.xmlUrl).filter((url): url is string => {
try {
new URL(url)
return true
} catch {
return false
}
})
if (urls.length === 0) {
toast.error(t('No valid RSS feed URLs found in OPML file'))
return
}
// Merge with existing feeds (avoid duplicates)
const existingUrls = new Set(feedUrls)
const newUrls = urls.filter(url => !existingUrls.has(url))
if (newUrls.length === 0) {
toast.info(t('All feeds from OPML file are already added'))
return
}
setFeedUrls([...feedUrls, ...newUrls])
setHasChange(true)
toast.success(t('Imported {{count}} feed(s) from OPML file', { count: newUrls.length }))
} catch (error) {
logger.error('[RssFeedSettingsPage] Failed to import OPML file', { error })
toast.error(t('Failed to import OPML file: {{error}}', {
error: error instanceof Error ? error.message : String(error)
}))
}
}
const handleExportOpml = () => {
if (feedUrls.length === 0) {
toast.error(t('No feeds to export'))
return
}
try {
const opmlContent = generateOpml(feedUrls, 'Jumble RSS Feeds')
const filename = `jumble-rss-feeds-${new Date().toISOString().split('T')[0]}.opml`
downloadFile(opmlContent, filename, 'application/xml')
toast.success(t('RSS feeds exported to OPML file'))
} catch (error) {
logger.error('[RssFeedSettingsPage] Failed to export OPML file', { error })
toast.error(t('Failed to export OPML file'))
}
}
const handleSave = async () => {
if (!pubkey) {
logger.error('[RssFeedSettingsPage] Cannot save: no pubkey')
@ -368,7 +438,40 @@ const RssFeedSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index @@ -368,7 +438,40 @@ const RssFeedSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index
{/* RSS Feed List */}
<div className="space-y-4">
<div className="space-y-2">
<Label>{t('RSS Feeds')}</Label>
<div className="flex items-center justify-between">
<Label>{t('RSS Feeds')}</Label>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handleExportOpml}
disabled={feedUrls.length === 0}
className="text-xs"
>
<Download className="h-3 w-3 mr-1" />
{t('Export OPML')}
</Button>
<label>
<Button
variant="outline"
size="sm"
asChild
className="text-xs cursor-pointer"
>
<span>
<Upload className="h-3 w-3 mr-1" />
{t('Import OPML')}
</span>
</Button>
<input
type="file"
accept=".opml,application/xml,text/xml"
onChange={handleImportOpml}
className="hidden"
/>
</label>
</div>
</div>
<div className="text-muted-foreground text-xs">
{t('Add RSS feed URLs to subscribe to. If no feeds are configured, the default feed will be used.')}
</div>

Loading…
Cancel
Save