diff --git a/src/lib/opml.ts b/src/lib/opml.ts new file mode 100644 index 0000000..64112d1 --- /dev/null +++ b/src/lib/opml.ts @@ -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 = ` + + + ${escapeXml(title)} + ${dateString} + ${dateString} + + +` + + feedUrls.forEach((url) => { + try { + const urlObj = new URL(url) + const feedTitle = urlObj.hostname.replace(/^www\./, '') + opml += ` \n` + } catch { + // Invalid URL, skip it + } + }) + + opml += ` +` + + return opml +} + +/** + * Escape XML special characters + */ +function escapeXml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +/** + * 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) +} + diff --git a/src/pages/secondary/RssFeedSettingsPage/index.tsx b/src/pages/secondary/RssFeedSettingsPage/index.tsx index 760c28b..96fa9d0 100644 --- a/src/pages/secondary/RssFeedSettingsPage/index.tsx +++ b/src/pages/secondary/RssFeedSettingsPage/index.tsx @@ -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 setHasChange(true) } + const handleImportOpml = async (event: React.ChangeEvent) => { + 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 {/* RSS Feed List */} - {t('RSS Feeds')} + + {t('RSS Feeds')} + + + + {t('Export OPML')} + + + + + + {t('Import OPML')} + + + + + + {t('Add RSS feed URLs to subscribe to. If no feeds are configured, the default feed will be used.')}