import type { Folder, Subscription } from "@/lib/types/subscription" function escapeXml(text: string): string { return text .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'") } export function generateOpml( subscriptions: Subscription[], folders: Folder[] ): string { const lines: string[] = [ '', '', " ", " asa.news subscriptions", ` ${new Date().toUTCString()}`, " ", " ", ] const folderMap = new Map() for (const folder of folders) { folderMap.set(folder.folderIdentifier, folder) } const subscriptionsByFolder = new Map() for (const subscription of subscriptions) { const key = subscription.folderIdentifier const existing = subscriptionsByFolder.get(key) ?? [] existing.push(subscription) subscriptionsByFolder.set(key, existing) } const ungrouped = subscriptionsByFolder.get(null) ?? [] for (const subscription of ungrouped) { const title = escapeXml(subscription.customTitle ?? subscription.feedTitle) const xmlUrl = escapeXml(subscription.feedUrl) lines.push( ` ` ) } for (const folder of folders) { const folderSubscriptions = subscriptionsByFolder.get(folder.folderIdentifier) ?? [] const folderName = escapeXml(folder.name) lines.push(` `) for (const subscription of folderSubscriptions) { const title = escapeXml(subscription.customTitle ?? subscription.feedTitle) const xmlUrl = escapeXml(subscription.feedUrl) lines.push( ` ` ) } lines.push(" ") } lines.push(" ") lines.push("") return lines.join("\n") } export interface ParsedOpmlFeed { url: string title: string } export interface ParsedOpmlGroup { folderName: string | null feeds: ParsedOpmlFeed[] } export function parseOpml(xmlString: string): ParsedOpmlGroup[] { const parser = new DOMParser() const document = parser.parseFromString(xmlString, "application/xml") const parseError = document.querySelector("parsererror") if (parseError) { throw new Error("invalid opml file") } const body = document.querySelector("body") if (!body) { throw new Error("invalid opml: no body element") } const groups: ParsedOpmlGroup[] = [] const ungroupedFeeds: ParsedOpmlFeed[] = [] const topLevelOutlines = body.querySelectorAll(":scope > outline") for (const outline of topLevelOutlines) { const xmlUrl = outline.getAttribute("xmlUrl") if (xmlUrl) { ungroupedFeeds.push({ url: xmlUrl, title: outline.getAttribute("title") ?? outline.getAttribute("text") ?? xmlUrl, }) } else { const folderName = outline.getAttribute("title") ?? outline.getAttribute("text") const feeds: ParsedOpmlFeed[] = [] const childOutlines = outline.querySelectorAll(":scope > outline") for (const child of childOutlines) { const childXmlUrl = child.getAttribute("xmlUrl") if (childXmlUrl) { feeds.push({ url: childXmlUrl, title: child.getAttribute("title") ?? child.getAttribute("text") ?? childXmlUrl, }) } } if (feeds.length > 0) { groups.push({ folderName: folderName, feeds }) } } } if (ungroupedFeeds.length > 0) { groups.unshift({ folderName: null, feeds: ungroupedFeeds }) } return groups } export function downloadOpml( subscriptions: Subscription[], folders: Folder[] ): void { const opmlContent = generateOpml(subscriptions, folders) const blob = new Blob([opmlContent], { type: "application/xml" }) const url = URL.createObjectURL(blob) const anchor = window.document.createElement("a") anchor.href = url anchor.download = "asa-news-subscriptions.opml" window.document.body.appendChild(anchor) anchor.click() window.document.body.removeChild(anchor) URL.revokeObjectURL(url) }