Skip to content

Commit 0632eea

Browse files
committed
Merge branch 'better-opml-import' into staging
2 parents 67e55a7 + c688e3e commit 0632eea

File tree

8 files changed

+425
-259
lines changed

8 files changed

+425
-259
lines changed

docs/docs/import_export.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@ Similarly, you can also export your podcasts from PodNotes to such apps.
44

55
## Importing
66

7-
You can import podcasts by placing a `opml` file in your Obsidian vault.
8-
Inside settings, PodNotes will automatically try to detect these, and suggest them.
9-
Then you need only click _Import_ to add them to your saved feeds.
7+
To import podcasts, follow these steps:
8+
1. Go to the PodNotes settings in Obsidian.
9+
2. Find the "Import" section.
10+
3. Click the "Import OPML" button.
11+
4. A file selection dialog will open. Choose your OPML file.
12+
5. The selected podcasts will be imported into PodNotes.
1013

1114
## Exporting
1215

1316
You can export your saved feeds to `opml` format.
14-
First designate a file path to save to (or use the default), and click _Export_.
17+
First designate a file path to save to (or use the default), and click _Export_.

src/main.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -106,15 +106,15 @@ export default class PodNotes extends Plugin implements IPodNotes {
106106
id: "podnotes-show-leaf",
107107
name: "Show PodNotes",
108108
icon: "podcast" as IconType,
109-
checkCallback(checking: boolean) {
109+
checkCallback: function (checking: boolean) {
110110
if (checking) {
111-
return !app.workspace.getLeavesOfType(VIEW_TYPE).length;
111+
return !this.app.workspace.getLeavesOfType(VIEW_TYPE).length;
112112
}
113113

114-
app.workspace.getRightLeaf(false).setViewState({
114+
this.app.workspace.getRightLeaf(false).setViewState({
115115
type: VIEW_TYPE,
116116
});
117-
},
117+
}.bind(this),
118118
});
119119

120120
this.addCommand({
@@ -285,9 +285,13 @@ export default class PodNotes extends Plugin implements IPodNotes {
285285
return;
286286
}
287287

288-
this.app.workspace.getRightLeaf(false).setViewState({
289-
type: VIEW_TYPE,
290-
});
288+
const leaf = this.app.workspace.getRightLeaf(false);
289+
290+
if (leaf) {
291+
leaf.setViewState({
292+
type: VIEW_TYPE,
293+
});
294+
}
291295
}
292296

293297
onunload() {

src/opml.ts

Lines changed: 151 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,68 +1,166 @@
1-
import { TFile, Notice } from "obsidian";
1+
import { type App, Notice, TFile } from "obsidian";
22
import FeedParser from "./parser/feedParser";
33
import { savedFeeds } from "./store";
4-
import { PodcastFeed } from "./types/PodcastFeed";
5-
6-
async function importOPML(targetFile: TFile) {
7-
const fileContent = await app.vault.cachedRead(targetFile);
8-
const dp = new DOMParser();
9-
const dom = dp.parseFromString(fileContent, "application/xml");
10-
11-
const podcastEntryNodes = dom.querySelectorAll("outline[text][xmlUrl]");
12-
const incompletePodcastsToAdd: Pick<PodcastFeed, "title" | "url">[] = [];
13-
for (let i = 0; i < podcastEntryNodes.length; i++) {
14-
const node = podcastEntryNodes.item(i);
15-
16-
const text = node.getAttribute("text");
17-
const xmlUrl = node.getAttribute("xmlUrl");
18-
if (!text || !xmlUrl) {
19-
continue;
20-
}
4+
import type { PodcastFeed } from "./types/PodcastFeed";
5+
import { get } from "svelte/store";
216

22-
incompletePodcastsToAdd.push({
23-
title: text,
24-
url: xmlUrl,
25-
});
7+
function TimerNotice(heading: string, initialMessage: string) {
8+
let currentMessage = initialMessage;
9+
const startTime = Date.now();
10+
let stopTime: number;
11+
const notice = new Notice(initialMessage, 0);
12+
13+
function formatMsg(message: string): string {
14+
return `${heading} (${getTime()}):\n\n${message}`;
15+
}
16+
17+
function update(message: string) {
18+
currentMessage = message;
19+
notice.setMessage(formatMsg(currentMessage));
2620
}
2721

28-
const podcasts: PodcastFeed[] = await Promise.all(
29-
incompletePodcastsToAdd.map(async (feed) => {
30-
return new FeedParser().getFeed(feed.url);
31-
})
32-
);
22+
const interval = setInterval(() => {
23+
notice.setMessage(formatMsg(currentMessage));
24+
}, 1000);
25+
26+
function getTime(): string {
27+
return formatTime(stopTime ? stopTime - startTime : Date.now() - startTime);
28+
}
29+
30+
return {
31+
update,
32+
hide: () => notice.hide(),
33+
stop: () => {
34+
stopTime = Date.now();
35+
clearInterval(interval);
36+
},
37+
};
38+
}
3339

34-
savedFeeds.update((feeds) => {
35-
for (const pod of podcasts) {
36-
if (feeds[pod.title]) continue;
37-
feeds[pod.title] = structuredClone(pod);
40+
function formatTime(ms: number): string {
41+
const seconds = Math.floor(ms / 1000);
42+
const minutes = Math.floor(seconds / 60);
43+
const hours = Math.floor(minutes / 60);
44+
return `${hours.toString().padStart(2, "0")}:${(minutes % 60).toString().padStart(2, "0")}:${(seconds % 60).toString().padStart(2, "0")}`;
45+
}
46+
47+
async function importOPML(opml: string): Promise<void> {
48+
try {
49+
const dp = new DOMParser();
50+
const dom = dp.parseFromString(opml, "application/xml");
51+
52+
if (dom.documentElement.nodeName === "parsererror") {
53+
throw new Error("Invalid XML format");
3854
}
3955

40-
return feeds;
41-
});
56+
const podcastEntryNodes = dom.querySelectorAll("outline[text][xmlUrl]");
57+
const incompletePodcastsToAdd: Pick<PodcastFeed, "title" | "url">[] = [];
58+
for (let i = 0; i < podcastEntryNodes.length; i++) {
59+
const node = podcastEntryNodes.item(i);
60+
61+
const text = node.getAttribute("text");
62+
const xmlUrl = node.getAttribute("xmlUrl");
63+
if (!text || !xmlUrl) {
64+
continue;
65+
}
66+
67+
incompletePodcastsToAdd.push({
68+
title: text,
69+
url: xmlUrl,
70+
});
71+
}
72+
73+
if (incompletePodcastsToAdd.length === 0) {
74+
throw new Error("No valid podcast entries found in OPML");
75+
}
4276

43-
new Notice(
44-
`${targetFile.name} ingested. Saved ${podcasts.length} / ${incompletePodcastsToAdd.length} podcasts.`
45-
);
77+
const existingSavedFeeds = get(savedFeeds);
78+
const newPodcastsToAdd = incompletePodcastsToAdd.filter(
79+
(pod) =>
80+
!Object.values(existingSavedFeeds).some(
81+
(savedPod) => savedPod.url === pod.url,
82+
),
83+
);
4684

47-
if (podcasts.length !== incompletePodcastsToAdd.length) {
48-
const missingPodcasts = incompletePodcastsToAdd.filter(
49-
(pod) => !podcasts.find((v) => v.url === pod.url)
85+
const notice = TimerNotice("Importing podcasts", "Preparing to import...");
86+
let completedImports = 0;
87+
88+
const updateProgress = () => {
89+
const progress = (
90+
(completedImports / newPodcastsToAdd.length) *
91+
100
92+
).toFixed(1);
93+
notice.update(
94+
`Importing... ${completedImports}/${newPodcastsToAdd.length} podcasts completed (${progress}%)`,
95+
);
96+
};
97+
98+
updateProgress();
99+
100+
const podcasts: (PodcastFeed | null)[] = await Promise.all(
101+
newPodcastsToAdd.map(async (feed) => {
102+
try {
103+
const result = await new FeedParser().getFeed(feed.url);
104+
completedImports++;
105+
updateProgress();
106+
return result;
107+
} catch (error) {
108+
console.error(`Failed to fetch feed for ${feed.title}: ${error}`);
109+
completedImports++;
110+
updateProgress();
111+
return null;
112+
}
113+
}),
50114
);
51115

52-
for (const missingPod of missingPodcasts) {
53-
new Notice(`Failed to save ${missingPod.title}...`, 60000);
116+
notice.stop();
117+
118+
const validPodcasts = podcasts.filter(
119+
(pod): pod is PodcastFeed => pod !== null,
120+
);
121+
122+
savedFeeds.update((feeds) => {
123+
for (const pod of validPodcasts) {
124+
if (feeds[pod.title]) continue;
125+
feeds[pod.title] = structuredClone(pod);
126+
}
127+
return feeds;
128+
});
129+
130+
const skippedCount =
131+
incompletePodcastsToAdd.length - newPodcastsToAdd.length;
132+
notice.update(
133+
`OPML import complete. Saved ${validPodcasts.length} new podcasts. Skipped ${skippedCount} existing podcasts.`,
134+
);
135+
136+
if (validPodcasts.length !== newPodcastsToAdd.length) {
137+
const failedImports = newPodcastsToAdd.length - validPodcasts.length;
138+
console.error(`Failed to import ${failedImports} podcasts.`);
139+
new Notice(
140+
`Failed to import ${failedImports} podcasts. Check console for details.`,
141+
10000,
142+
);
54143
}
144+
145+
setTimeout(() => notice.hide(), 5000);
146+
} catch (error) {
147+
console.error("Error importing OPML:", error);
148+
new Notice(
149+
`Error importing OPML: ${error instanceof Error ? error.message : "Unknown error"}`,
150+
10000,
151+
);
55152
}
56153
}
57154

58155
async function exportOPML(
156+
app: App,
59157
feeds: PodcastFeed[],
60-
filePath = "PodNotes_Export.opml"
158+
filePath = "PodNotes_Export.opml",
61159
) {
62160
const header = `<?xml version="1.0" encoding="utf=8" standalone="no"?>`;
63161
const opml = (child: string) => `<opml version="1.0">${child}</opml>`;
64162
const head = (child: string) => `<head>${child}</head>`;
65-
const title = `<title>PodNotes Feeds</title>`;
163+
const title = "<title>PodNotes Feeds</title>";
66164
const body = (child: string) => `<body>${child}</body>`;
67165
const feedOutline = (feed: PodcastFeed) =>
68166
`<outline text="${feed.title}" type="rss" xmlUrl="${feed.url}" />`;
@@ -74,13 +172,17 @@ async function exportOPML(
74172
try {
75173
await app.vault.create(filePath, doc);
76174

77-
new Notice(
78-
`Exported ${feeds.length} podcast feeds to file "${filePath}".`
79-
);
175+
new Notice(`Exported ${feeds.length} podcast feeds to file "${filePath}".`);
80176
} catch (error) {
81-
new Notice(
82-
`Unable to create podcast export file:\n\n${error}`
83-
);
177+
if (error instanceof Error) {
178+
if (error.message.includes("Folder does not exist")) {
179+
new Notice("Unable to create export file: Folder does not exist.");
180+
} else {
181+
new Notice(`Unable to create podcast export file:\n\n${error.message}`);
182+
}
183+
} else {
184+
new Notice("An unexpected error occurred during export.");
185+
}
84186

85187
console.error(error);
86188
}

src/parser/feedParser.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { PodcastFeed } from "src/types/PodcastFeed";
1+
import type { PodcastFeed } from "src/types/PodcastFeed";
22
import { requestUrl } from "obsidian";
3-
import { Episode } from "src/types/Episode";
3+
import type { Episode } from "src/types/Episode";
44

55
export default class FeedParser {
66
private feed: PodcastFeed | undefined;
@@ -83,9 +83,7 @@ export default class FeedParser {
8383
return !!ep;
8484
}
8585

86-
return Array.from(items)
87-
.map(this.parseItem.bind(this))
88-
.filter(isEpisode);
86+
return Array.from(items).map(this.parseItem.bind(this)).filter(isEpisode);
8987
}
9088

9189
protected parseItem(item: Element): Episode | null {
@@ -96,7 +94,7 @@ export default class FeedParser {
9694
const contentEl = item.querySelector("*|encoded");
9795
const pubDateEl = item.querySelector("pubDate");
9896
const itunesImageEl = item.querySelector("image");
99-
const itunesTitleEl = item.getElementsByTagName('itunes:title')[0];
97+
const itunesTitleEl = item.getElementsByTagName("itunes:title")[0];
10098

10199
if (!titleEl || !streamUrlEl || !pubDateEl) {
102100
return null;
@@ -122,7 +120,7 @@ export default class FeedParser {
122120
artworkUrl,
123121
episodeDate: pubDate,
124122
feedUrl: this.feed?.url || "",
125-
itunesTitle: itunesTitle || ""
123+
itunesTitle: itunesTitle || "",
126124
};
127125
}
128126

0 commit comments

Comments
 (0)