feat: added opml support f1f98bd8
Steve · 2025-11-05 10:20 5 file(s) · +415 −7
README.md +2 −2
8 8
## Roadmap
9 9
10 10
- [x] Mark posts as read/unread
11 -
- [ ] Import/Export OPML
12 -
- [ ] Import/Export account through mnemonic
11 +
- [x] Import/Export OPML
12 +
- [x] Import/Export account through mnemonic
13 13
- [x] Refresh/Update Feeds
14 14
- [ ] Tweakcn theme switching
TODO.md +1 −1
16 16
- [x] Change Reset to "Log out" or something along those lines
17 17
- [x] Add About to settings menu?
18 18
- [x] Add Home screen with start button + acknowledgements
19 -
- [ ] Import + Export OPML in settings
19 +
- [x] Import + Export OPML in settings
src/App.tsx +104 −1
15 15
	extractPostContent,
16 16
	extractPostDate,
17 17
} from "@/lib/feed-operations";
18 +
import { parseOPML } from "@/lib/opml";
18 19
import {
19 20
	Dialog,
20 21
	DialogContent,
22 23
	DialogHeader,
23 24
	DialogTitle,
24 25
} from "@/components/ui/dialog";
25 -
import { Upload } from "lucide-react";
26 +
import { Upload, FileUp } from "lucide-react";
26 27
import { Mnemonic } from "@evolu/common";
27 28
28 29
function App() {
33 34
	const [errorMessage, setErrorMessage] = React.useState("");
34 35
	const [isRestoreDialogOpen, setIsRestoreDialogOpen] = React.useState(false);
35 36
	const [restoreMnemonic, setRestoreMnemonic] = React.useState("");
37 +
	const [isImportingOPML, setIsImportingOPML] = React.useState(false);
38 +
	const fileInputRef = React.useRef<HTMLInputElement>(null);
36 39
37 40
	const evolu = useEvolu();
38 41
52 55
		}
53 56
	}
54 57
58 +
	async function handleImportOPML(file: File) {
59 +
		setIsImportingOPML(true);
60 +
		const importToast = toast.loading("Reading OPML file...");
61 +
62 +
		try {
63 +
			const fileContent = await file.text();
64 +
			const opmlFeeds = parseOPML(fileContent);
65 +
66 +
			toast.loading(`Found ${opmlFeeds.length} feeds. Importing...`, {
67 +
				id: importToast,
68 +
			});
69 +
70 +
			let successCount = 0;
71 +
			let failCount = 0;
72 +
73 +
			for (let i = 0; i < opmlFeeds.length; i++) {
74 +
				const feed = opmlFeeds[i];
75 +
				toast.loading(
76 +
					`Importing feed ${i + 1}/${opmlFeeds.length}: ${feed.title}`,
77 +
					{ id: importToast },
78 +
				);
79 +
80 +
				try {
81 +
					const xmlData = await fetchFeedWithFallback(feed.feedUrl);
82 +
					const { feedData, posts, isAtom } = parseFeedXml(xmlData);
83 +
84 +
					const result = evolu.insert("rssFeed", {
85 +
						feedUrl: feed.feedUrl,
86 +
						title: feed.title,
87 +
						description:
88 +
							feed.description ||
89 +
							feedData.description ||
90 +
							feedData.subtitle ||
91 +
							"",
92 +
						category: feed.category || "Uncategorized",
93 +
						dateUpdated: new Date().toISOString(),
94 +
					});
95 +
96 +
					for (const post of posts) {
97 +
						evolu.insert("rssPost", {
98 +
							title: post.title,
99 +
							author: extractPostAuthor(post, isAtom, feedData.title),
100 +
							publishedDate: extractPostDate(post),
101 +
							link: extractPostLink(post, isAtom),
102 +
							feedId: result.value.id,
103 +
							content: extractPostContent(post),
104 +
						});
105 +
					}
106 +
107 +
					successCount++;
108 +
				} catch (error) {
109 +
					console.error(`Failed to import feed: ${feed.title}`, error);
110 +
					failCount++;
111 +
				}
112 +
			}
113 +
114 +
			toast.success(
115 +
				`Import complete! Success: ${successCount}, Failed: ${failCount}`,
116 +
				{ id: importToast },
117 +
			);
118 +
		} catch (error) {
119 +
			console.error("Failed to import OPML:", error);
120 +
			toast.error("Failed to import OPML. Please check the file format.", {
121 +
				id: importToast,
122 +
			});
123 +
		} finally {
124 +
			setIsImportingOPML(false);
125 +
			if (fileInputRef.current) {
126 +
				fileInputRef.current.value = "";
127 +
			}
128 +
		}
129 +
	}
130 +
131 +
	function handleImportClick() {
132 +
		fileInputRef.current?.click();
133 +
	}
134 +
135 +
	function handleFileSelect(event: React.ChangeEvent<HTMLInputElement>) {
136 +
		const file = event.target.files?.[0];
137 +
		if (file) {
138 +
			handleImportOPML(file);
139 +
		}
140 +
	}
141 +
55 142
	async function addFeed() {
56 143
		if (!urlInput.trim()) {
57 144
			setErrorMessage("Please enter a URL");
159 246
								{errorMessage}
160 247
							</div>
161 248
						)}
249 +
						<input
250 +
							ref={fileInputRef}
251 +
							type="file"
252 +
							accept=".opml,.xml"
253 +
							onChange={handleFileSelect}
254 +
							className="hidden"
255 +
						/>
256 +
						<Button
257 +
							variant="outline"
258 +
							onClick={handleImportClick}
259 +
							disabled={isImportingOPML}
260 +
							className="w-full"
261 +
						>
262 +
							<FileUp className="h-4 w-4 mr-2" />
263 +
							{isImportingOPML ? "Importing OPML..." : "Import OPML"}
264 +
						</Button>
162 265
						<Button
163 266
							variant="outline"
164 267
							onClick={() => setIsRestoreDialogOpen(true)}
src/components/nav-user.tsx +168 −3
1 -
import { BookKey, Copy, Eye, EyeOff, Info, Trash2, Upload } from "lucide-react";
1 +
import {
2 +
	BookKey,
3 +
	Copy,
4 +
	Eye,
5 +
	EyeOff,
6 +
	Info,
7 +
	Trash2,
8 +
	Upload,
9 +
	Download,
10 +
	FileUp,
11 +
} from "lucide-react";
2 12
import {
3 13
	Dialog,
4 14
	DialogContent,
8 18
	DialogTrigger,
9 19
} from "@/components/ui/dialog";
10 20
import { Button } from "@/components/ui/button";
11 -
import { use, useState } from "react";
12 -
import { useEvolu, reset } from "@/lib/evolu";
21 +
import { use, useState, useRef } from "react";
22 +
import { useEvolu, reset, allFeedsQuery } from "@/lib/evolu";
23 +
import { useQuery } from "@evolu/react";
24 +
import {
25 +
	generateOPML,
26 +
	parseOPML,
27 +
	downloadOPML,
28 +
	type OPMLFeed,
29 +
} from "@/lib/opml";
30 +
import {
31 +
	fetchFeedWithFallback,
32 +
	parseFeedXml,
33 +
	extractPostLink,
34 +
	extractPostAuthor,
35 +
	extractPostContent,
36 +
	extractPostDate,
37 +
} from "@/lib/feed-operations";
13 38
14 39
import {
15 40
	DropdownMenu,
32 57
	const [isDialogOpen, setIsDialogOpen] = useState(false);
33 58
	const [isRestoreDialogOpen, setIsRestoreDialogOpen] = useState(false);
34 59
	const [isAboutDialogOpen, setIsAboutDialogOpen] = useState(false);
60 +
	const [isImportOPMLDialogOpen, setIsImportOPMLDialogOpen] = useState(false);
35 61
	const [backupPhrase, setBackupPhrase] = useState<Mnemonic | null>();
36 62
	const [isRevealed, setIsRevealed] = useState(false);
37 63
	const [isCopied, setIsCopied] = useState(false);
38 64
	const [restoreMnemonic, setRestoreMnemonic] = useState("");
65 +
	const [isImporting, setIsImporting] = useState(false);
66 +
	const [importProgress, setImportProgress] = useState("");
67 +
	const fileInputRef = useRef<HTMLInputElement>(null);
39 68
40 69
	function maskPhrase(phrase: string | null | undefined) {
41 70
		if (!phrase) return "";
48 77
49 78
	const evolu = useEvolu();
50 79
	const owner = use(evolu.appOwner);
80 +
	const feeds = useQuery(allFeedsQuery);
51 81
52 82
	function backup() {
53 83
		setBackupPhrase(owner.mnemonic);
54 84
	}
55 85
86 +
	async function handleExportOPML() {
87 +
		try {
88 +
			const opmlContent = generateOPML(feeds);
89 +
			downloadOPML(opmlContent);
90 +
		} catch (error) {
91 +
			console.error("Failed to export OPML:", error);
92 +
			alert("Failed to export OPML. Please try again.");
93 +
		}
94 +
	}
95 +
96 +
	async function handleImportOPML(file: File) {
97 +
		setIsImporting(true);
98 +
		setImportProgress("Reading OPML file...");
99 +
100 +
		try {
101 +
			const fileContent = await file.text();
102 +
			const opmlFeeds = parseOPML(fileContent);
103 +
104 +
			setImportProgress(`Found ${opmlFeeds.length} feeds. Importing...`);
105 +
106 +
			let successCount = 0;
107 +
			let failCount = 0;
108 +
109 +
			for (let i = 0; i < opmlFeeds.length; i++) {
110 +
				const feed = opmlFeeds[i];
111 +
				setImportProgress(
112 +
					`Importing feed ${i + 1}/${opmlFeeds.length}: ${feed.title}`,
113 +
				);
114 +
115 +
				try {
116 +
					const xmlData = await fetchFeedWithFallback(feed.feedUrl);
117 +
					const { feedData, posts, isAtom } = parseFeedXml(xmlData);
118 +
119 +
					const result = evolu.insert("rssFeed", {
120 +
						feedUrl: feed.feedUrl,
121 +
						title: feed.title,
122 +
						description:
123 +
							feed.description ||
124 +
							feedData.description ||
125 +
							feedData.subtitle ||
126 +
							"",
127 +
						category: feed.category || "Uncategorized",
128 +
						dateUpdated: new Date().toISOString(),
129 +
					});
130 +
131 +
					for (const post of posts) {
132 +
						evolu.insert("rssPost", {
133 +
							title: post.title,
134 +
							author: extractPostAuthor(post, isAtom, feedData.title),
135 +
							publishedDate: extractPostDate(post),
136 +
							link: extractPostLink(post, isAtom),
137 +
							feedId: result.value.id,
138 +
							content: extractPostContent(post),
139 +
						});
140 +
					}
141 +
142 +
					successCount++;
143 +
				} catch (error) {
144 +
					console.error(`Failed to import feed: ${feed.title}`, error);
145 +
					failCount++;
146 +
				}
147 +
			}
148 +
149 +
			setImportProgress(
150 +
				`Import complete! Success: ${successCount}, Failed: ${failCount}`,
151 +
			);
152 +
			setTimeout(() => {
153 +
				setIsImportOPMLDialogOpen(false);
154 +
				setIsImporting(false);
155 +
				setImportProgress("");
156 +
				if (fileInputRef.current) {
157 +
					fileInputRef.current.value = "";
158 +
				}
159 +
			}, 2000);
160 +
		} catch (error) {
161 +
			console.error("Failed to import OPML:", error);
162 +
			setImportProgress("Failed to import OPML. Please check the file format.");
163 +
			setIsImporting(false);
164 +
		}
165 +
	}
166 +
167 +
	function handleFileSelect(event: React.ChangeEvent<HTMLInputElement>) {
168 +
		const file = event.target.files?.[0];
169 +
		if (file) {
170 +
			handleImportOPML(file);
171 +
		}
172 +
	}
173 +
56 174
	function copyToClipboard() {
57 175
		if (backupPhrase) {
58 176
			navigator.clipboard.writeText(backupPhrase);
131 249
									</DropdownMenuItem>
132 250
								</DropdownMenuGroup>
133 251
								<DropdownMenuSeparator />
252 +
								<DropdownMenuGroup>
253 +
									<DropdownMenuItem onClick={handleExportOPML}>
254 +
										<Download />
255 +
										Export OPML
256 +
									</DropdownMenuItem>
257 +
									<DropdownMenuItem
258 +
										onClick={() => setIsImportOPMLDialogOpen(true)}
259 +
									>
260 +
										<FileUp />
261 +
										Import OPML
262 +
									</DropdownMenuItem>
263 +
								</DropdownMenuGroup>
264 +
								<DropdownMenuSeparator />
134 265
								<DropdownMenuItem onClick={reset}>
135 266
									<Trash2 />
136 267
									Clear Data
257 388
							</a>
258 389
							.
259 390
						</p>
391 +
					</div>
392 +
				</DialogContent>
393 +
			</Dialog>
394 +
			<Dialog
395 +
				open={isImportOPMLDialogOpen}
396 +
				onOpenChange={setIsImportOPMLDialogOpen}
397 +
			>
398 +
				<DialogContent>
399 +
					<DialogHeader>
400 +
						<DialogTitle>Import OPML</DialogTitle>
401 +
						<DialogDescription>
402 +
							Import your RSS feeds from an OPML file. This will add all feeds
403 +
							from the file to your collection.
404 +
						</DialogDescription>
405 +
					</DialogHeader>
406 +
					<div className="space-y-4">
407 +
						{isImporting ? (
408 +
							<div className="p-4 bg-muted rounded-lg">
409 +
								<p className="text-sm font-mono">{importProgress}</p>
410 +
							</div>
411 +
						) : (
412 +
							<>
413 +
								<input
414 +
									ref={fileInputRef}
415 +
									type="file"
416 +
									accept=".opml,.xml"
417 +
									onChange={handleFileSelect}
418 +
									className="w-full p-4 bg-muted rounded-lg text-sm file:mr-4 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-primary file:text-primary-foreground hover:file:bg-primary/90"
419 +
								/>
420 +
								<p className="text-xs text-muted-foreground">
421 +
									Select an OPML file (.opml or .xml) to import your feeds.
422 +
								</p>
423 +
							</>
424 +
						)}
260 425
					</div>
261 426
				</DialogContent>
262 427
			</Dialog>
src/lib/opml.ts (added) +140 −0
1 +
import type { RSSFeedId } from "./scheme";
2 +
3 +
export interface OPMLFeed {
4 +
	title: string;
5 +
	feedUrl: string;
6 +
	description?: string;
7 +
	category?: string;
8 +
}
9 +
10 +
export interface RSSFeed {
11 +
	id: RSSFeedId;
12 +
	feedUrl: string;
13 +
	title: string;
14 +
	description: string | null;
15 +
	category: string | null;
16 +
	dateUpdated: string | null;
17 +
	isDeleted: number;
18 +
}
19 +
20 +
/**
21 +
 * Generate OPML XML from feeds
22 +
 */
23 +
export function generateOPML(feeds: readonly RSSFeed[]): string {
24 +
	const now = new Date().toUTCString();
25 +
	const categories = new Map<string, RSSFeed[]>();
26 +
27 +
	// Group feeds by category
28 +
	for (const feed of feeds) {
29 +
		const category = feed.category || "Uncategorized";
30 +
		if (!categories.has(category)) {
31 +
			categories.set(category, []);
32 +
		}
33 +
		categories.get(category)?.push(feed);
34 +
	}
35 +
36 +
	let outlines = "";
37 +
38 +
	// Generate outline elements
39 +
	for (const [category, categoryFeeds] of categories) {
40 +
		outlines += `\n    <outline text="${escapeXml(category)}" title="${escapeXml(category)}">`;
41 +
		for (const feed of categoryFeeds) {
42 +
			const description = feed.description || "";
43 +
			outlines += `\n      <outline type="rss" text="${escapeXml(feed.title)}" title="${escapeXml(feed.title)}" xmlUrl="${escapeXml(feed.feedUrl)}" description="${escapeXml(description)}"/>`;
44 +
		}
45 +
		outlines += "\n    </outline>";
46 +
	}
47 +
48 +
	const opml = `<?xml version="1.0" encoding="UTF-8"?>
49 +
<opml version="2.0">
50 +
  <head>
51 +
    <title>Alcove Feeds</title>
52 +
    <dateCreated>${now}</dateCreated>
53 +
  </head>
54 +
  <body>${outlines}
55 +
  </body>
56 +
</opml>`;
57 +
58 +
	return opml;
59 +
}
60 +
61 +
/**
62 +
 * Parse OPML XML and extract feeds
63 +
 */
64 +
export function parseOPML(opmlContent: string): OPMLFeed[] {
65 +
	const parser = new DOMParser();
66 +
	const xmlDoc = parser.parseFromString(opmlContent, "text/xml");
67 +
68 +
	// Check for parsing errors
69 +
	const parserError = xmlDoc.querySelector("parsererror");
70 +
	if (parserError) {
71 +
		throw new Error("Invalid OPML file: XML parsing failed");
72 +
	}
73 +
74 +
	const feeds: OPMLFeed[] = [];
75 +
	const outlines = xmlDoc.querySelectorAll("outline");
76 +
77 +
	for (const outline of outlines) {
78 +
		const type = outline.getAttribute("type");
79 +
		const xmlUrl = outline.getAttribute("xmlUrl");
80 +
81 +
		// If this outline has an xmlUrl, it's a feed
82 +
		if (xmlUrl) {
83 +
			const feed: OPMLFeed = {
84 +
				title:
85 +
					outline.getAttribute("title") ||
86 +
					outline.getAttribute("text") ||
87 +
					"Untitled Feed",
88 +
				feedUrl: xmlUrl,
89 +
				description: outline.getAttribute("description") || undefined,
90 +
			};
91 +
92 +
			// Try to find category from parent outline
93 +
			const parent = outline.parentElement;
94 +
			if (parent?.tagName === "outline" && !parent.getAttribute("xmlUrl")) {
95 +
				feed.category =
96 +
					parent.getAttribute("title") ||
97 +
					parent.getAttribute("text") ||
98 +
					undefined;
99 +
			}
100 +
101 +
			feeds.push(feed);
102 +
		}
103 +
	}
104 +
105 +
	if (feeds.length === 0) {
106 +
		throw new Error("No feeds found in OPML file");
107 +
	}
108 +
109 +
	return feeds;
110 +
}
111 +
112 +
/**
113 +
 * Escape special XML characters
114 +
 */
115 +
function escapeXml(text: string): string {
116 +
	return text
117 +
		.replace(/&/g, "&amp;")
118 +
		.replace(/</g, "&lt;")
119 +
		.replace(/>/g, "&gt;")
120 +
		.replace(/"/g, "&quot;")
121 +
		.replace(/'/g, "&apos;");
122 +
}
123 +
124 +
/**
125 +
 * Download OPML file to user's device
126 +
 */
127 +
export function downloadOPML(
128 +
	opmlContent: string,
129 +
	filename = "alcove-feeds.opml",
130 +
): void {
131 +
	const blob = new Blob([opmlContent], { type: "text/xml" });
132 +
	const url = URL.createObjectURL(blob);
133 +
	const link = document.createElement("a");
134 +
	link.href = url;
135 +
	link.download = filename;
136 +
	document.body.appendChild(link);
137 +
	link.click();
138 +
	document.body.removeChild(link);
139 +
	URL.revokeObjectURL(url);
140 +
}