src/lib/opml.ts 3.2 K raw
1
import { allFeedsQuery } from "./evolu";
2
3
type Feed = typeof allFeedsQuery.Row;
4
5
/**
6
 * Type for parsed OPML feed data
7
 */
8
export interface OPMLFeed {
9
	title: string;
10
	feedUrl: string;
11
	description?: string;
12
	category?: string;
13
}
14
15
/**
16
 * Generate OPML XML from feeds
17
 */
18
export function generateOPML(feeds: readonly Feed[]): string {
19
	const now = new Date().toUTCString();
20
	const categories = new Map<string, Feed[]>();
21
22
	// Group feeds by category
23
	for (const feed of feeds) {
24
		const category = feed.category || "Uncategorized";
25
		if (!categories.has(category)) {
26
			categories.set(category, []);
27
		}
28
		categories.get(category)?.push(feed);
29
	}
30
31
	let outlines = "";
32
33
	// Generate outline elements
34
	for (const [category, categoryFeeds] of categories) {
35
		outlines += `\n    <outline text="${escapeXml(category)}" title="${escapeXml(category)}">`;
36
		for (const feed of categoryFeeds) {
37
			const description = feed.description || "";
38
			outlines += `\n      <outline type="rss" text="${escapeXml(feed.title)}" title="${escapeXml(feed.title)}" xmlUrl="${escapeXml(feed.feedUrl)}" description="${escapeXml(description)}"/>`;
39
		}
40
		outlines += "\n    </outline>";
41
	}
42
43
	const opml = `<?xml version="1.0" encoding="UTF-8"?>
44
<opml version="2.0">
45
  <head>
46
    <title>Alcove Feeds</title>
47
    <dateCreated>${now}</dateCreated>
48
  </head>
49
  <body>${outlines}
50
  </body>
51
</opml>`;
52
53
	return opml;
54
}
55
56
/**
57
 * Parse OPML XML and extract feeds
58
 */
59
export function parseOPML(opmlContent: string): OPMLFeed[] {
60
	const parser = new DOMParser();
61
	const xmlDoc = parser.parseFromString(opmlContent, "text/xml");
62
63
	// Check for parsing errors
64
	const parserError = xmlDoc.querySelector("parsererror");
65
	if (parserError) {
66
		throw new Error("Invalid OPML file: XML parsing failed");
67
	}
68
69
	const feeds: OPMLFeed[] = [];
70
	const outlines = xmlDoc.querySelectorAll("outline");
71
72
	for (const outline of outlines) {
73
		const xmlUrl = outline.getAttribute("xmlUrl");
74
75
		// If this outline has an xmlUrl, it's a feed
76
		if (xmlUrl) {
77
			const feed: OPMLFeed = {
78
				title:
79
					outline.getAttribute("title") ||
80
					outline.getAttribute("text") ||
81
					"Untitled Feed",
82
				feedUrl: xmlUrl,
83
				description: outline.getAttribute("description") || undefined,
84
			};
85
86
			// Try to find category from parent outline
87
			const parent = outline.parentElement;
88
			if (parent?.tagName === "outline" && !parent.getAttribute("xmlUrl")) {
89
				feed.category =
90
					parent.getAttribute("title") ||
91
					parent.getAttribute("text") ||
92
					undefined;
93
			}
94
95
			feeds.push(feed);
96
		}
97
	}
98
99
	if (feeds.length === 0) {
100
		throw new Error("No feeds found in OPML file");
101
	}
102
103
	return feeds;
104
}
105
106
/**
107
 * Escape special XML characters
108
 */
109
function escapeXml(text: string): string {
110
	return text
111
		.replace(/&/g, "&amp;")
112
		.replace(/</g, "&lt;")
113
		.replace(/>/g, "&gt;")
114
		.replace(/"/g, "&quot;")
115
		.replace(/'/g, "&apos;");
116
}
117
118
/**
119
 * Download OPML file to user's device
120
 */
121
export function downloadOPML(
122
	opmlContent: string,
123
	filename = "alcove-feeds.opml",
124
): void {
125
	const blob = new Blob([opmlContent], { type: "text/xml" });
126
	const url = URL.createObjectURL(blob);
127
	const link = document.createElement("a");
128
	link.href = url;
129
	link.download = filename;
130
	document.body.appendChild(link);
131
	link.click();
132
	document.body.removeChild(link);
133
	URL.revokeObjectURL(url);
134
}