Skip to content

Commit

Permalink
feat: improved OMPL import: much faster import, choose file anywhere …
Browse files Browse the repository at this point in the history
…on your device to import, show import progress
  • Loading branch information
chhoumann committed Jul 18, 2024
1 parent ebfed58 commit 10b02fa
Show file tree
Hide file tree
Showing 3 changed files with 176 additions and 95 deletions.
11 changes: 7 additions & 4 deletions docs/docs/import_export.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@ Similarly, you can also export your podcasts from PodNotes to such apps.

## Importing

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

## Exporting

You can export your saved feeds to `opml` format.
First designate a file path to save to (or use the default), and click _Export_.
First designate a file path to save to (or use the default), and click _Export_.
115 changes: 100 additions & 15 deletions src/opml.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,50 @@
import { type App, Notice } from "obsidian";
import { type App, Notice, TFile } from "obsidian";
import FeedParser from "./parser/feedParser";
import { savedFeeds } from "./store";
import type { PodcastFeed } from "./types/PodcastFeed";
import { get } from "svelte/store";

async function importOPML(opml: string) {
function TimerNotice(heading: string, initialMessage: string) {
let currentMessage = initialMessage;
const startTime = Date.now();
let stopTime: number;
const notice = new Notice(initialMessage, 0);

function formatMsg(message: string): string {
return `${heading} (${getTime()}):\n\n${message}`;
}

function update(message: string) {
currentMessage = message;
notice.setMessage(formatMsg(currentMessage));
}

const interval = setInterval(() => {
notice.setMessage(formatMsg(currentMessage));
}, 1000);

function getTime(): string {
return formatTime(stopTime ? stopTime - startTime : Date.now() - startTime);
}

return {
update,
hide: () => notice.hide(),
stop: () => {
stopTime = Date.now();
clearInterval(interval);
},
};
}

function formatTime(ms: number): string {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
return `${hours.toString().padStart(2, "0")}:${(minutes % 60).toString().padStart(2, "0")}:${(seconds % 60).toString().padStart(2, "0")}`;
}

async function importOPML(opml: string): Promise<void> {
try {
const dp = new DOMParser();
const dom = dp.parseFromString(opml, "application/xml");
Expand Down Expand Up @@ -33,17 +74,47 @@ async function importOPML(opml: string) {
throw new Error("No valid podcast entries found in OPML");
}

const existingSavedFeeds = get(savedFeeds);
const newPodcastsToAdd = incompletePodcastsToAdd.filter(
(pod) =>
!Object.values(existingSavedFeeds).some(
(savedPod) => savedPod.url === pod.url,
),
);

const notice = TimerNotice("Importing podcasts", "Preparing to import...");
let completedImports = 0;

const updateProgress = () => {
const progress = (
(completedImports / newPodcastsToAdd.length) *
100
).toFixed(1);
notice.update(
`Importing... ${completedImports}/${newPodcastsToAdd.length} podcasts completed (${progress}%)`,
);
};

updateProgress();

const podcasts: (PodcastFeed | null)[] = await Promise.all(
incompletePodcastsToAdd.map(async (feed) => {
newPodcastsToAdd.map(async (feed) => {
try {
return await new FeedParser().getFeed(feed.url);
const result = await new FeedParser().getFeed(feed.url);
completedImports++;
updateProgress();
return result;
} catch (error) {
console.error(`Failed to fetch feed for ${feed.title}: ${error}`);
completedImports++;
updateProgress();
return null;
}
}),
);

notice.stop();

const validPodcasts = podcasts.filter(
(pod): pod is PodcastFeed => pod !== null,
);
Expand All @@ -53,25 +124,31 @@ async function importOPML(opml: string) {
if (feeds[pod.title]) continue;
feeds[pod.title] = structuredClone(pod);
}

return feeds;
});

new Notice(
`OPML ingested. Saved ${validPodcasts.length} / ${incompletePodcastsToAdd.length} podcasts.`,
const skippedCount =
incompletePodcastsToAdd.length - newPodcastsToAdd.length;
notice.update(
`OPML import complete. Saved ${validPodcasts.length} new podcasts. Skipped ${skippedCount} existing podcasts.`,
);

if (validPodcasts.length !== incompletePodcastsToAdd.length) {
const missingPodcasts = incompletePodcastsToAdd.filter(
(pod) => !validPodcasts.find((v) => v.url === pod.url),
if (validPodcasts.length !== newPodcastsToAdd.length) {
const failedImports = newPodcastsToAdd.length - validPodcasts.length;
console.error(`Failed to import ${failedImports} podcasts.`);
new Notice(
`Failed to import ${failedImports} podcasts. Check console for details.`,
10000,
);

for (const missingPod of missingPodcasts) {
new Notice(`Failed to save ${missingPod.title}...`, 60000);
}
}

setTimeout(() => notice.hide(), 5000);
} catch (error) {
console.error("Error importing OPML:", error);
new Notice(
`Error importing OPML: ${error instanceof Error ? error.message : "Unknown error"}`,
10000,
);
}
}

Expand All @@ -97,7 +174,15 @@ async function exportOPML(

new Notice(`Exported ${feeds.length} podcast feeds to file "${filePath}".`);
} catch (error) {
new Notice(`Unable to create podcast export file:\n\n${error}`);
if (error instanceof Error) {
if (error.message.includes("Folder does not exist")) {
new Notice("Unable to create export file: Folder does not exist.");
} else {
new Notice(`Unable to create podcast export file:\n\n${error.message}`);
}
} else {
new Notice("An unexpected error occurred during export.");
}

console.error(error);
}
Expand Down
145 changes: 69 additions & 76 deletions src/ui/settings/PodNotesSettingsTab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,7 @@ export class PodNotesSettingsTab extends PluginSettingTab {
this.addSkipLengthSettings(settingsContainer);
this.addNoteSettings(settingsContainer);
this.addDownloadSettings(settingsContainer);
this.addImportSettings(settingsContainer);
this.addExportSettings(settingsContainer);
this.addImportExportSettings(settingsContainer);
this.addTranscriptSettings(settingsContainer);
}

Expand Down Expand Up @@ -263,84 +262,78 @@ export class PodNotesSettingsTab extends PluginSettingTab {
const downloadFilePathDemoEl = container.createDiv();
}

addImportSettings(settingsContainer: HTMLDivElement) {
const setting = new Setting(settingsContainer);
const opmlFiles = this.app.vault
.getAllLoadedFiles()
.filter(
(file) =>
file instanceof TFile &&
file.extension.toLowerCase().endsWith("opml"),
private addImportExportSettings(containerEl: HTMLElement): void {
containerEl.createEl("h3", { text: "Import/Export" });

new Setting(containerEl)
.setName("Import OPML")
.setDesc("Import podcasts from an OPML file.")
.addButton((button) =>
button.setButtonText("Import").onClick(() => {
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = ".opml";
fileInput.style.display = "none";
document.body.appendChild(fileInput);
fileInput.click();

fileInput.onchange = async (e: Event) => {
const target = e.target as HTMLInputElement;
const file = target.files?.[0];

if (file) {
const reader = new FileReader();
reader.onload = async (event) => {
const contents = event.target?.result as string;
if (contents) {
try {
await importOPML(contents);
} catch (e) {
console.error("Error importing OPML:", e);
new Notice(
`Error importing OPML: ${e instanceof Error ? e.message : "Unknown error"}`,
10000,
);
}
}
};
reader.readAsText(file);
} else {
new Notice("No file selected");
}
};
}),
);

const detectedOpmlFile = opmlFiles[0];
let exportFilePath = "PodNotes_Export.opml";

let value = detectedOpmlFile ? detectedOpmlFile.path : "";

setting
.setName("Import")
.setDesc("Import podcasts from other services with OPML files.");
setting.addText((text) => {
text.setPlaceholder(
detectedOpmlFile ? detectedOpmlFile.path : "path to opml file",
);
text.onChange((v) => {
value = v;
});
text.setValue(value);
});

setting.addButton((importBtn) =>
importBtn.setButtonText("Import").onClick(() => {


const inputFile = this.app.vault.getAbstractFileByPath(value);

if (!inputFile || !(inputFile instanceof TFile)) {
new Notice(
`Invalid file path, could not find opml file at location "${value}".`,
new Setting(containerEl)
.setName("Export OPML")
.setDesc("Export saved podcast feeds to an OPML file.")
.addText((text) =>
text
.setPlaceholder("Export file name")
.setValue(exportFilePath)
.onChange((value) => {
exportFilePath = value;
}),
)
.addButton((button) =>
button.setButtonText("Export").onClick(() => {
const feeds = Object.values(get(savedFeeds));
if (feeds.length === 0) {
new Notice("No podcasts to export.");
return;
}
exportOPML(
this.app,
feeds,
exportFilePath.endsWith(".opml")
? exportFilePath
: `${exportFilePath}.opml`,
);
return;
}

new Notice("Starting import...");
const filecontents = await this.app.vault.cachedRead(inputFile);
importOPML(filecontents);
}),
);
}

addExportSettings(settingsContainer: HTMLDivElement) {
const setting = new Setting(settingsContainer);
setting
.setName("Export")
.setDesc("Export saved podcast feeds to OPML file.");

let value = "PodNotes_Export.opml";

setting.addText((text) => {
text.setPlaceholder("Target path");
text.onChange((v) => {
value = v;
});
text.setValue(value);
});
setting.addButton((btn) =>
btn.setButtonText("Export").onClick(() => {
const feeds = Object.values(get(savedFeeds));

if (feeds.length === 0) {
new Notice("Nothing to export.");
return;
}

exportOPML(
this.app,
feeds,
value.endsWith(".opml") ? value : `${value}.opml`,
);
}),
);
}),
);
}

private addTranscriptSettings(container: HTMLDivElement) {
Expand Down

0 comments on commit 10b02fa

Please sign in to comment.