1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
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)
}
|