feat: added opml support
f1f98bd8
5 file(s) · +415 −7
| 8 | 8 | ## Roadmap |
|
| 9 | 9 | ||
| 10 | 10 | - [x] Mark posts as read/unread |
|
| 11 | - | - [ ] Import/Export OPML |
|
| 12 | - | - [ ] Import/Export account through mnemonic |
|
| 11 | + | - [x] Import/Export OPML |
|
| 12 | + | - [x] Import/Export account through mnemonic |
|
| 13 | 13 | - [x] Refresh/Update Feeds |
|
| 14 | 14 | - [ ] Tweakcn theme switching |
| 16 | 16 | - [x] Change Reset to "Log out" or something along those lines |
|
| 17 | 17 | - [x] Add About to settings menu? |
|
| 18 | 18 | - [x] Add Home screen with start button + acknowledgements |
|
| 19 | - | - [ ] Import + Export OPML in settings |
|
| 19 | + | - [x] Import + Export OPML in settings |
| 15 | 15 | extractPostContent, |
|
| 16 | 16 | extractPostDate, |
|
| 17 | 17 | } from "@/lib/feed-operations"; |
|
| 18 | + | import { parseOPML } from "@/lib/opml"; |
|
| 18 | 19 | import { |
|
| 19 | 20 | Dialog, |
|
| 20 | 21 | DialogContent, |
|
| 22 | 23 | DialogHeader, |
|
| 23 | 24 | DialogTitle, |
|
| 24 | 25 | } from "@/components/ui/dialog"; |
|
| 25 | - | import { Upload } from "lucide-react"; |
|
| 26 | + | import { Upload, FileUp } from "lucide-react"; |
|
| 26 | 27 | import { Mnemonic } from "@evolu/common"; |
|
| 27 | 28 | ||
| 28 | 29 | function App() { |
|
| 33 | 34 | const [errorMessage, setErrorMessage] = React.useState(""); |
|
| 34 | 35 | const [isRestoreDialogOpen, setIsRestoreDialogOpen] = React.useState(false); |
|
| 35 | 36 | const [restoreMnemonic, setRestoreMnemonic] = React.useState(""); |
|
| 37 | + | const [isImportingOPML, setIsImportingOPML] = React.useState(false); |
|
| 38 | + | const fileInputRef = React.useRef<HTMLInputElement>(null); |
|
| 36 | 39 | ||
| 37 | 40 | const evolu = useEvolu(); |
|
| 38 | 41 | ||
| 52 | 55 | } |
|
| 53 | 56 | } |
|
| 54 | 57 | ||
| 58 | + | async function handleImportOPML(file: File) { |
|
| 59 | + | setIsImportingOPML(true); |
|
| 60 | + | const importToast = toast.loading("Reading OPML file..."); |
|
| 61 | + | ||
| 62 | + | try { |
|
| 63 | + | const fileContent = await file.text(); |
|
| 64 | + | const opmlFeeds = parseOPML(fileContent); |
|
| 65 | + | ||
| 66 | + | toast.loading(`Found ${opmlFeeds.length} feeds. Importing...`, { |
|
| 67 | + | id: importToast, |
|
| 68 | + | }); |
|
| 69 | + | ||
| 70 | + | let successCount = 0; |
|
| 71 | + | let failCount = 0; |
|
| 72 | + | ||
| 73 | + | for (let i = 0; i < opmlFeeds.length; i++) { |
|
| 74 | + | const feed = opmlFeeds[i]; |
|
| 75 | + | toast.loading( |
|
| 76 | + | `Importing feed ${i + 1}/${opmlFeeds.length}: ${feed.title}`, |
|
| 77 | + | { id: importToast }, |
|
| 78 | + | ); |
|
| 79 | + | ||
| 80 | + | try { |
|
| 81 | + | const xmlData = await fetchFeedWithFallback(feed.feedUrl); |
|
| 82 | + | const { feedData, posts, isAtom } = parseFeedXml(xmlData); |
|
| 83 | + | ||
| 84 | + | const result = evolu.insert("rssFeed", { |
|
| 85 | + | feedUrl: feed.feedUrl, |
|
| 86 | + | title: feed.title, |
|
| 87 | + | description: |
|
| 88 | + | feed.description || |
|
| 89 | + | feedData.description || |
|
| 90 | + | feedData.subtitle || |
|
| 91 | + | "", |
|
| 92 | + | category: feed.category || "Uncategorized", |
|
| 93 | + | dateUpdated: new Date().toISOString(), |
|
| 94 | + | }); |
|
| 95 | + | ||
| 96 | + | for (const post of posts) { |
|
| 97 | + | evolu.insert("rssPost", { |
|
| 98 | + | title: post.title, |
|
| 99 | + | author: extractPostAuthor(post, isAtom, feedData.title), |
|
| 100 | + | publishedDate: extractPostDate(post), |
|
| 101 | + | link: extractPostLink(post, isAtom), |
|
| 102 | + | feedId: result.value.id, |
|
| 103 | + | content: extractPostContent(post), |
|
| 104 | + | }); |
|
| 105 | + | } |
|
| 106 | + | ||
| 107 | + | successCount++; |
|
| 108 | + | } catch (error) { |
|
| 109 | + | console.error(`Failed to import feed: ${feed.title}`, error); |
|
| 110 | + | failCount++; |
|
| 111 | + | } |
|
| 112 | + | } |
|
| 113 | + | ||
| 114 | + | toast.success( |
|
| 115 | + | `Import complete! Success: ${successCount}, Failed: ${failCount}`, |
|
| 116 | + | { id: importToast }, |
|
| 117 | + | ); |
|
| 118 | + | } catch (error) { |
|
| 119 | + | console.error("Failed to import OPML:", error); |
|
| 120 | + | toast.error("Failed to import OPML. Please check the file format.", { |
|
| 121 | + | id: importToast, |
|
| 122 | + | }); |
|
| 123 | + | } finally { |
|
| 124 | + | setIsImportingOPML(false); |
|
| 125 | + | if (fileInputRef.current) { |
|
| 126 | + | fileInputRef.current.value = ""; |
|
| 127 | + | } |
|
| 128 | + | } |
|
| 129 | + | } |
|
| 130 | + | ||
| 131 | + | function handleImportClick() { |
|
| 132 | + | fileInputRef.current?.click(); |
|
| 133 | + | } |
|
| 134 | + | ||
| 135 | + | function handleFileSelect(event: React.ChangeEvent<HTMLInputElement>) { |
|
| 136 | + | const file = event.target.files?.[0]; |
|
| 137 | + | if (file) { |
|
| 138 | + | handleImportOPML(file); |
|
| 139 | + | } |
|
| 140 | + | } |
|
| 141 | + | ||
| 55 | 142 | async function addFeed() { |
|
| 56 | 143 | if (!urlInput.trim()) { |
|
| 57 | 144 | setErrorMessage("Please enter a URL"); |
|
| 159 | 246 | {errorMessage} |
|
| 160 | 247 | </div> |
|
| 161 | 248 | )} |
|
| 249 | + | <input |
|
| 250 | + | ref={fileInputRef} |
|
| 251 | + | type="file" |
|
| 252 | + | accept=".opml,.xml" |
|
| 253 | + | onChange={handleFileSelect} |
|
| 254 | + | className="hidden" |
|
| 255 | + | /> |
|
| 256 | + | <Button |
|
| 257 | + | variant="outline" |
|
| 258 | + | onClick={handleImportClick} |
|
| 259 | + | disabled={isImportingOPML} |
|
| 260 | + | className="w-full" |
|
| 261 | + | > |
|
| 262 | + | <FileUp className="h-4 w-4 mr-2" /> |
|
| 263 | + | {isImportingOPML ? "Importing OPML..." : "Import OPML"} |
|
| 264 | + | </Button> |
|
| 162 | 265 | <Button |
|
| 163 | 266 | variant="outline" |
|
| 164 | 267 | onClick={() => setIsRestoreDialogOpen(true)} |
|
| 1 | - | import { BookKey, Copy, Eye, EyeOff, Info, Trash2, Upload } from "lucide-react"; |
|
| 1 | + | import { |
|
| 2 | + | BookKey, |
|
| 3 | + | Copy, |
|
| 4 | + | Eye, |
|
| 5 | + | EyeOff, |
|
| 6 | + | Info, |
|
| 7 | + | Trash2, |
|
| 8 | + | Upload, |
|
| 9 | + | Download, |
|
| 10 | + | FileUp, |
|
| 11 | + | } from "lucide-react"; |
|
| 2 | 12 | import { |
|
| 3 | 13 | Dialog, |
|
| 4 | 14 | DialogContent, |
|
| 8 | 18 | DialogTrigger, |
|
| 9 | 19 | } from "@/components/ui/dialog"; |
|
| 10 | 20 | import { Button } from "@/components/ui/button"; |
|
| 11 | - | import { use, useState } from "react"; |
|
| 12 | - | import { useEvolu, reset } from "@/lib/evolu"; |
|
| 21 | + | import { use, useState, useRef } from "react"; |
|
| 22 | + | import { useEvolu, reset, allFeedsQuery } from "@/lib/evolu"; |
|
| 23 | + | import { useQuery } from "@evolu/react"; |
|
| 24 | + | import { |
|
| 25 | + | generateOPML, |
|
| 26 | + | parseOPML, |
|
| 27 | + | downloadOPML, |
|
| 28 | + | type OPMLFeed, |
|
| 29 | + | } from "@/lib/opml"; |
|
| 30 | + | import { |
|
| 31 | + | fetchFeedWithFallback, |
|
| 32 | + | parseFeedXml, |
|
| 33 | + | extractPostLink, |
|
| 34 | + | extractPostAuthor, |
|
| 35 | + | extractPostContent, |
|
| 36 | + | extractPostDate, |
|
| 37 | + | } from "@/lib/feed-operations"; |
|
| 13 | 38 | ||
| 14 | 39 | import { |
|
| 15 | 40 | DropdownMenu, |
|
| 32 | 57 | const [isDialogOpen, setIsDialogOpen] = useState(false); |
|
| 33 | 58 | const [isRestoreDialogOpen, setIsRestoreDialogOpen] = useState(false); |
|
| 34 | 59 | const [isAboutDialogOpen, setIsAboutDialogOpen] = useState(false); |
|
| 60 | + | const [isImportOPMLDialogOpen, setIsImportOPMLDialogOpen] = useState(false); |
|
| 35 | 61 | const [backupPhrase, setBackupPhrase] = useState<Mnemonic | null>(); |
|
| 36 | 62 | const [isRevealed, setIsRevealed] = useState(false); |
|
| 37 | 63 | const [isCopied, setIsCopied] = useState(false); |
|
| 38 | 64 | const [restoreMnemonic, setRestoreMnemonic] = useState(""); |
|
| 65 | + | const [isImporting, setIsImporting] = useState(false); |
|
| 66 | + | const [importProgress, setImportProgress] = useState(""); |
|
| 67 | + | const fileInputRef = useRef<HTMLInputElement>(null); |
|
| 39 | 68 | ||
| 40 | 69 | function maskPhrase(phrase: string | null | undefined) { |
|
| 41 | 70 | if (!phrase) return ""; |
|
| 48 | 77 | ||
| 49 | 78 | const evolu = useEvolu(); |
|
| 50 | 79 | const owner = use(evolu.appOwner); |
|
| 80 | + | const feeds = useQuery(allFeedsQuery); |
|
| 51 | 81 | ||
| 52 | 82 | function backup() { |
|
| 53 | 83 | setBackupPhrase(owner.mnemonic); |
|
| 54 | 84 | } |
|
| 55 | 85 | ||
| 86 | + | async function handleExportOPML() { |
|
| 87 | + | try { |
|
| 88 | + | const opmlContent = generateOPML(feeds); |
|
| 89 | + | downloadOPML(opmlContent); |
|
| 90 | + | } catch (error) { |
|
| 91 | + | console.error("Failed to export OPML:", error); |
|
| 92 | + | alert("Failed to export OPML. Please try again."); |
|
| 93 | + | } |
|
| 94 | + | } |
|
| 95 | + | ||
| 96 | + | async function handleImportOPML(file: File) { |
|
| 97 | + | setIsImporting(true); |
|
| 98 | + | setImportProgress("Reading OPML file..."); |
|
| 99 | + | ||
| 100 | + | try { |
|
| 101 | + | const fileContent = await file.text(); |
|
| 102 | + | const opmlFeeds = parseOPML(fileContent); |
|
| 103 | + | ||
| 104 | + | setImportProgress(`Found ${opmlFeeds.length} feeds. Importing...`); |
|
| 105 | + | ||
| 106 | + | let successCount = 0; |
|
| 107 | + | let failCount = 0; |
|
| 108 | + | ||
| 109 | + | for (let i = 0; i < opmlFeeds.length; i++) { |
|
| 110 | + | const feed = opmlFeeds[i]; |
|
| 111 | + | setImportProgress( |
|
| 112 | + | `Importing feed ${i + 1}/${opmlFeeds.length}: ${feed.title}`, |
|
| 113 | + | ); |
|
| 114 | + | ||
| 115 | + | try { |
|
| 116 | + | const xmlData = await fetchFeedWithFallback(feed.feedUrl); |
|
| 117 | + | const { feedData, posts, isAtom } = parseFeedXml(xmlData); |
|
| 118 | + | ||
| 119 | + | const result = evolu.insert("rssFeed", { |
|
| 120 | + | feedUrl: feed.feedUrl, |
|
| 121 | + | title: feed.title, |
|
| 122 | + | description: |
|
| 123 | + | feed.description || |
|
| 124 | + | feedData.description || |
|
| 125 | + | feedData.subtitle || |
|
| 126 | + | "", |
|
| 127 | + | category: feed.category || "Uncategorized", |
|
| 128 | + | dateUpdated: new Date().toISOString(), |
|
| 129 | + | }); |
|
| 130 | + | ||
| 131 | + | for (const post of posts) { |
|
| 132 | + | evolu.insert("rssPost", { |
|
| 133 | + | title: post.title, |
|
| 134 | + | author: extractPostAuthor(post, isAtom, feedData.title), |
|
| 135 | + | publishedDate: extractPostDate(post), |
|
| 136 | + | link: extractPostLink(post, isAtom), |
|
| 137 | + | feedId: result.value.id, |
|
| 138 | + | content: extractPostContent(post), |
|
| 139 | + | }); |
|
| 140 | + | } |
|
| 141 | + | ||
| 142 | + | successCount++; |
|
| 143 | + | } catch (error) { |
|
| 144 | + | console.error(`Failed to import feed: ${feed.title}`, error); |
|
| 145 | + | failCount++; |
|
| 146 | + | } |
|
| 147 | + | } |
|
| 148 | + | ||
| 149 | + | setImportProgress( |
|
| 150 | + | `Import complete! Success: ${successCount}, Failed: ${failCount}`, |
|
| 151 | + | ); |
|
| 152 | + | setTimeout(() => { |
|
| 153 | + | setIsImportOPMLDialogOpen(false); |
|
| 154 | + | setIsImporting(false); |
|
| 155 | + | setImportProgress(""); |
|
| 156 | + | if (fileInputRef.current) { |
|
| 157 | + | fileInputRef.current.value = ""; |
|
| 158 | + | } |
|
| 159 | + | }, 2000); |
|
| 160 | + | } catch (error) { |
|
| 161 | + | console.error("Failed to import OPML:", error); |
|
| 162 | + | setImportProgress("Failed to import OPML. Please check the file format."); |
|
| 163 | + | setIsImporting(false); |
|
| 164 | + | } |
|
| 165 | + | } |
|
| 166 | + | ||
| 167 | + | function handleFileSelect(event: React.ChangeEvent<HTMLInputElement>) { |
|
| 168 | + | const file = event.target.files?.[0]; |
|
| 169 | + | if (file) { |
|
| 170 | + | handleImportOPML(file); |
|
| 171 | + | } |
|
| 172 | + | } |
|
| 173 | + | ||
| 56 | 174 | function copyToClipboard() { |
|
| 57 | 175 | if (backupPhrase) { |
|
| 58 | 176 | navigator.clipboard.writeText(backupPhrase); |
|
| 131 | 249 | </DropdownMenuItem> |
|
| 132 | 250 | </DropdownMenuGroup> |
|
| 133 | 251 | <DropdownMenuSeparator /> |
|
| 252 | + | <DropdownMenuGroup> |
|
| 253 | + | <DropdownMenuItem onClick={handleExportOPML}> |
|
| 254 | + | <Download /> |
|
| 255 | + | Export OPML |
|
| 256 | + | </DropdownMenuItem> |
|
| 257 | + | <DropdownMenuItem |
|
| 258 | + | onClick={() => setIsImportOPMLDialogOpen(true)} |
|
| 259 | + | > |
|
| 260 | + | <FileUp /> |
|
| 261 | + | Import OPML |
|
| 262 | + | </DropdownMenuItem> |
|
| 263 | + | </DropdownMenuGroup> |
|
| 264 | + | <DropdownMenuSeparator /> |
|
| 134 | 265 | <DropdownMenuItem onClick={reset}> |
|
| 135 | 266 | <Trash2 /> |
|
| 136 | 267 | Clear Data |
|
| 257 | 388 | </a> |
|
| 258 | 389 | . |
|
| 259 | 390 | </p> |
|
| 391 | + | </div> |
|
| 392 | + | </DialogContent> |
|
| 393 | + | </Dialog> |
|
| 394 | + | <Dialog |
|
| 395 | + | open={isImportOPMLDialogOpen} |
|
| 396 | + | onOpenChange={setIsImportOPMLDialogOpen} |
|
| 397 | + | > |
|
| 398 | + | <DialogContent> |
|
| 399 | + | <DialogHeader> |
|
| 400 | + | <DialogTitle>Import OPML</DialogTitle> |
|
| 401 | + | <DialogDescription> |
|
| 402 | + | Import your RSS feeds from an OPML file. This will add all feeds |
|
| 403 | + | from the file to your collection. |
|
| 404 | + | </DialogDescription> |
|
| 405 | + | </DialogHeader> |
|
| 406 | + | <div className="space-y-4"> |
|
| 407 | + | {isImporting ? ( |
|
| 408 | + | <div className="p-4 bg-muted rounded-lg"> |
|
| 409 | + | <p className="text-sm font-mono">{importProgress}</p> |
|
| 410 | + | </div> |
|
| 411 | + | ) : ( |
|
| 412 | + | <> |
|
| 413 | + | <input |
|
| 414 | + | ref={fileInputRef} |
|
| 415 | + | type="file" |
|
| 416 | + | accept=".opml,.xml" |
|
| 417 | + | onChange={handleFileSelect} |
|
| 418 | + | className="w-full p-4 bg-muted rounded-lg text-sm file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-primary file:text-primary-foreground hover:file:bg-primary/90" |
|
| 419 | + | /> |
|
| 420 | + | <p className="text-xs text-muted-foreground"> |
|
| 421 | + | Select an OPML file (.opml or .xml) to import your feeds. |
|
| 422 | + | </p> |
|
| 423 | + | </> |
|
| 424 | + | )} |
|
| 260 | 425 | </div> |
|
| 261 | 426 | </DialogContent> |
|
| 262 | 427 | </Dialog> |
|
| 1 | + | import type { RSSFeedId } from "./scheme"; |
|
| 2 | + | ||
| 3 | + | export interface OPMLFeed { |
|
| 4 | + | title: string; |
|
| 5 | + | feedUrl: string; |
|
| 6 | + | description?: string; |
|
| 7 | + | category?: string; |
|
| 8 | + | } |
|
| 9 | + | ||
| 10 | + | export interface RSSFeed { |
|
| 11 | + | id: RSSFeedId; |
|
| 12 | + | feedUrl: string; |
|
| 13 | + | title: string; |
|
| 14 | + | description: string | null; |
|
| 15 | + | category: string | null; |
|
| 16 | + | dateUpdated: string | null; |
|
| 17 | + | isDeleted: number; |
|
| 18 | + | } |
|
| 19 | + | ||
| 20 | + | /** |
|
| 21 | + | * Generate OPML XML from feeds |
|
| 22 | + | */ |
|
| 23 | + | export function generateOPML(feeds: readonly RSSFeed[]): string { |
|
| 24 | + | const now = new Date().toUTCString(); |
|
| 25 | + | const categories = new Map<string, RSSFeed[]>(); |
|
| 26 | + | ||
| 27 | + | // Group feeds by category |
|
| 28 | + | for (const feed of feeds) { |
|
| 29 | + | const category = feed.category || "Uncategorized"; |
|
| 30 | + | if (!categories.has(category)) { |
|
| 31 | + | categories.set(category, []); |
|
| 32 | + | } |
|
| 33 | + | categories.get(category)?.push(feed); |
|
| 34 | + | } |
|
| 35 | + | ||
| 36 | + | let outlines = ""; |
|
| 37 | + | ||
| 38 | + | // Generate outline elements |
|
| 39 | + | for (const [category, categoryFeeds] of categories) { |
|
| 40 | + | outlines += `\n <outline text="${escapeXml(category)}" title="${escapeXml(category)}">`; |
|
| 41 | + | for (const feed of categoryFeeds) { |
|
| 42 | + | const description = feed.description || ""; |
|
| 43 | + | outlines += `\n <outline type="rss" text="${escapeXml(feed.title)}" title="${escapeXml(feed.title)}" xmlUrl="${escapeXml(feed.feedUrl)}" description="${escapeXml(description)}"/>`; |
|
| 44 | + | } |
|
| 45 | + | outlines += "\n </outline>"; |
|
| 46 | + | } |
|
| 47 | + | ||
| 48 | + | const opml = `<?xml version="1.0" encoding="UTF-8"?> |
|
| 49 | + | <opml version="2.0"> |
|
| 50 | + | <head> |
|
| 51 | + | <title>Alcove Feeds</title> |
|
| 52 | + | <dateCreated>${now}</dateCreated> |
|
| 53 | + | </head> |
|
| 54 | + | <body>${outlines} |
|
| 55 | + | </body> |
|
| 56 | + | </opml>`; |
|
| 57 | + | ||
| 58 | + | return opml; |
|
| 59 | + | } |
|
| 60 | + | ||
| 61 | + | /** |
|
| 62 | + | * Parse OPML XML and extract feeds |
|
| 63 | + | */ |
|
| 64 | + | export function parseOPML(opmlContent: string): OPMLFeed[] { |
|
| 65 | + | const parser = new DOMParser(); |
|
| 66 | + | const xmlDoc = parser.parseFromString(opmlContent, "text/xml"); |
|
| 67 | + | ||
| 68 | + | // Check for parsing errors |
|
| 69 | + | const parserError = xmlDoc.querySelector("parsererror"); |
|
| 70 | + | if (parserError) { |
|
| 71 | + | throw new Error("Invalid OPML file: XML parsing failed"); |
|
| 72 | + | } |
|
| 73 | + | ||
| 74 | + | const feeds: OPMLFeed[] = []; |
|
| 75 | + | const outlines = xmlDoc.querySelectorAll("outline"); |
|
| 76 | + | ||
| 77 | + | for (const outline of outlines) { |
|
| 78 | + | const type = outline.getAttribute("type"); |
|
| 79 | + | const xmlUrl = outline.getAttribute("xmlUrl"); |
|
| 80 | + | ||
| 81 | + | // If this outline has an xmlUrl, it's a feed |
|
| 82 | + | if (xmlUrl) { |
|
| 83 | + | const feed: OPMLFeed = { |
|
| 84 | + | title: |
|
| 85 | + | outline.getAttribute("title") || |
|
| 86 | + | outline.getAttribute("text") || |
|
| 87 | + | "Untitled Feed", |
|
| 88 | + | feedUrl: xmlUrl, |
|
| 89 | + | description: outline.getAttribute("description") || undefined, |
|
| 90 | + | }; |
|
| 91 | + | ||
| 92 | + | // Try to find category from parent outline |
|
| 93 | + | const parent = outline.parentElement; |
|
| 94 | + | if (parent?.tagName === "outline" && !parent.getAttribute("xmlUrl")) { |
|
| 95 | + | feed.category = |
|
| 96 | + | parent.getAttribute("title") || |
|
| 97 | + | parent.getAttribute("text") || |
|
| 98 | + | undefined; |
|
| 99 | + | } |
|
| 100 | + | ||
| 101 | + | feeds.push(feed); |
|
| 102 | + | } |
|
| 103 | + | } |
|
| 104 | + | ||
| 105 | + | if (feeds.length === 0) { |
|
| 106 | + | throw new Error("No feeds found in OPML file"); |
|
| 107 | + | } |
|
| 108 | + | ||
| 109 | + | return feeds; |
|
| 110 | + | } |
|
| 111 | + | ||
| 112 | + | /** |
|
| 113 | + | * Escape special XML characters |
|
| 114 | + | */ |
|
| 115 | + | function escapeXml(text: string): string { |
|
| 116 | + | return text |
|
| 117 | + | .replace(/&/g, "&") |
|
| 118 | + | .replace(/</g, "<") |
|
| 119 | + | .replace(/>/g, ">") |
|
| 120 | + | .replace(/"/g, """) |
|
| 121 | + | .replace(/'/g, "'"); |
|
| 122 | + | } |
|
| 123 | + | ||
| 124 | + | /** |
|
| 125 | + | * Download OPML file to user's device |
|
| 126 | + | */ |
|
| 127 | + | export function downloadOPML( |
|
| 128 | + | opmlContent: string, |
|
| 129 | + | filename = "alcove-feeds.opml", |
|
| 130 | + | ): void { |
|
| 131 | + | const blob = new Blob([opmlContent], { type: "text/xml" }); |
|
| 132 | + | const url = URL.createObjectURL(blob); |
|
| 133 | + | const link = document.createElement("a"); |
|
| 134 | + | link.href = url; |
|
| 135 | + | link.download = filename; |
|
| 136 | + | document.body.appendChild(link); |
|
| 137 | + | link.click(); |
|
| 138 | + | document.body.removeChild(link); |
|
| 139 | + | URL.revokeObjectURL(url); |
|
| 140 | + | } |