summaryrefslogtreecommitdiff
path: root/apps/web/lib/opml.ts
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/lib/opml.ts')
-rw-r--r--apps/web/lib/opml.ts161
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, "&lt;")
+ .replace(/>/g, "&gt;")
+ .replace(/"/g, "&quot;")
+ .replace(/'/g, "&apos;")
+}
+
+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)
+}