diff options
Diffstat (limited to 'apps/web/lib/opml.ts')
| -rw-r--r-- | apps/web/lib/opml.ts | 161 |
1 files changed, 161 insertions, 0 deletions
diff --git a/apps/web/lib/opml.ts b/apps/web/lib/opml.ts new file mode 100644 index 0000000..bd0c3a7 --- /dev/null +++ b/apps/web/lib/opml.ts @@ -0,0 +1,161 @@ +import type { Folder, Subscription } from "@/lib/types/subscription" + +function escapeXml(text: string): string { + return text + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, """) + .replace(/'/g, "'") +} + +export function generateOpml( + subscriptions: Subscription[], + folders: Folder[] +): string { + const lines: string[] = [ + '<?xml version="1.0" encoding="UTF-8"?>', + '<opml version="2.0">', + " <head>", + " <title>asa.news subscriptions</title>", + ` <dateCreated>${new Date().toUTCString()}</dateCreated>`, + " </head>", + " <body>", + ] + + const folderMap = new Map<string, Folder>() + + for (const folder of folders) { + folderMap.set(folder.folderIdentifier, folder) + } + + const subscriptionsByFolder = new Map<string | null, Subscription[]>() + + 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( + ` <outline type="rss" text="${title}" title="${title}" xmlUrl="${xmlUrl}" />` + ) + } + + for (const folder of folders) { + const folderSubscriptions = + subscriptionsByFolder.get(folder.folderIdentifier) ?? [] + const folderName = escapeXml(folder.name) + lines.push(` <outline text="${folderName}" title="${folderName}">`) + + for (const subscription of folderSubscriptions) { + const title = escapeXml(subscription.customTitle ?? subscription.feedTitle) + const xmlUrl = escapeXml(subscription.feedUrl) + lines.push( + ` <outline type="rss" text="${title}" title="${title}" xmlUrl="${xmlUrl}" />` + ) + } + + lines.push(" </outline>") + } + + lines.push(" </body>") + lines.push("</opml>") + + 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) +} |