Skip to content

Commit db0de47

Browse files
authored
feat(notes): add podcast feed-level notes (#163) (#187)
Add first-class notes for a whole podcast feed (not just episodes): - New command "Create podcast feed note" opens a fuzzy picker over saved feeds and creates/opens the feed note without requiring playback (#161). - Rename the existing command's visible name to "Create episode note" (id kept as create-podcast-note for hotkey/API compatibility). - New FeedNoteTemplateEngine/FeedFilePathTemplateEngine plus episode-side tags ({{episodeurl}}, {{episodeartwork}}, {{feedurl}}, {{feedartwork}}, {{podcastlink}}); {{url}}/{{artwork}} keep meaning the note's own subject. - Extend PodcastFeed + FeedParser.getFeed with description/link/author (channel-scoped, atom:link-safe, no longer throws when <link> is absent). - New feedNote { path, template } setting with Bases-friendly defaults (#160); registered for settings import/export. - Feed-note creation is race-safe and enriches metadata on demand.
1 parent 46a6486 commit db0de47

19 files changed

Lines changed: 1148 additions & 7 deletions

docs/docs/commands.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,24 @@ This will capture the current timestamp of the currently playing episode.
3333

3434
See [timestamps](timestamps.md) for more information on timestamp templates.
3535

36-
## Create Podcast Note
36+
## Create episode note
3737
This will create a note for the currently playing episode.
3838

39+
(This command was previously named "Create Podcast Note". The name was changed to
40+
distinguish it from the feed-level command below; existing hotkeys are unaffected.)
41+
3942
See [templates](templates.md) for more information on note templates.
4043

44+
## Create podcast feed note
45+
This creates a note for a whole podcast (the feed as a whole), not a single
46+
episode. It does **not** require playing anything: running the command opens a
47+
picker of your saved podcasts, and choosing one creates (or opens) that podcast's
48+
note. You can also create a feed note from an episode's right-click menu
49+
("Create feed note").
50+
51+
See [templates](templates.md#podcast-feed-notes) for the feed note template and
52+
its available tags.
53+
4154
## Copy universal episode link to clipboard
4255
This will copy the universal episode link to the clipboard.
4356

docs/docs/templates.md

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
PodNotes can create notes from templates. These templates can contain certain syntax, which will be expanded to metadata about the podcast episode you are listening to.
22

3-
To use templates, you can use the `Create podcast note` Obsidian command.
3+
To use templates, you can use the `Create episode note` Obsidian command (previously named `Create podcast note`).
44
This requires you to have defined a template for both the file path and note text.
55

6+
PodNotes can also create a note for a whole podcast (the feed). See [Podcast feed notes](#podcast-feed-notes) below.
7+
68
## File path
79
This template will be used to create the file path for the note. You can use the following syntax:
810

@@ -29,3 +31,47 @@ This template will be used to create the note text. You can use the following sy
2931
- `{{date}}`: The publish date of the podcast episode.
3032
- You can use `{{date:format}}` to specify a custom [Moment.js](https://momentjs.com) format. E.g. `{{date:YYYY-MM-DD}}`.
3133
- `{{artwork}}`: The URL of the podcast artwork. If no artwork is found, an empty string will be used.
34+
35+
### Linking an episode to its podcast (feed) note
36+
In an episode note, `{{url}}` and `{{artwork}}` always describe the **episode**. To reference the parent podcast (feed), use these additional tags:
37+
38+
- `{{episodeurl}}` / `{{episodeartwork}}`: Explicit aliases of `{{url}}` / `{{artwork}}` (the episode's own URL and artwork). Use these when you also use the feed tags below and want to be unambiguous.
39+
- `{{feedurl}}`: The podcast's RSS feed URL.
40+
- `{{feedartwork}}`: The podcast's (feed) artwork. Falls back to the episode artwork if the feed isn't saved.
41+
- `{{podcastlink}}`: A ready-made wikilink to the podcast's [feed note](#podcast-feed-notes). It points at the same file the feed note is created at, so episodes and the feed note link up automatically. When the feed-note path has a folder it is path-qualified (e.g. `[[PodNotes/Podcasts/My Show|My Show]]`) so it can't resolve to an unrelated note that shares the basename; otherwise it's a plain `[[My Show]]`. (Avoid putting a `{{date}}` in the feed-note path, since the episode side can't reproduce the feed note's creation date.)
42+
43+
A Bases-friendly episode template that links back to the feed note:
44+
45+
```
46+
---
47+
type: podcastEpisode
48+
podcast: "{{podcastlink}}"
49+
title: "{{title}}"
50+
image: "{{artwork}}"
51+
url: "{{url}}"
52+
date: {{date:YYYY-MM-DD}}
53+
tags:
54+
- podcastEpisode
55+
---
56+
{{description}}
57+
```
58+
59+
## Podcast feed notes
60+
A *feed note* is a single parent note for an entire podcast (the feed), which episode notes can link to (great for [Obsidian Bases](https://help.obsidian.md/bases) / Dataview rollups).
61+
62+
Create one with the `Create podcast feed note` command (pick a saved podcast — no playback needed) or from an episode's right-click menu. Configure the feed note **file path** and **template** under PodNotes settings → *Podcast feed note settings*. PodNotes ships sensible Bases-friendly defaults.
63+
64+
In a feed note, `{{url}}` and `{{artwork}}` describe the **feed** (the note's subject). Available tags:
65+
66+
- `{{title}}`: The podcast's name.
67+
- `{{podcast}}` / `{{safeTitle}}`: The podcast's name, formatted safely for file paths/links (special characters removed).
68+
- `{{url}}`: The podcast's website (from the feed's `<link>`). May be empty if the feed has no website.
69+
- `{{feedurl}}`: The podcast's RSS feed URL.
70+
- `{{artwork}}` / `{{feedartwork}}`: The URL of the podcast artwork.
71+
- `{{author}}`: The podcast author (`<itunes:author>`), if present.
72+
- `{{description}}`: The podcast's description. Supports the `{{description:> }}` prepend syntax, like episode notes.
73+
- `{{date}}`: The current date (when the note is created). Supports `{{date:format}}`.
74+
75+
The file path template supports `{{title}}`/`{{podcast}}` (with the optional `{{podcast:_}}` whitespace-replacement format) and `{{date}}`.
76+
77+
The default feed note path is `PodNotes/Podcasts/{{podcast}}.md`, so an episode's `{{podcastlink}}` (`[[Podcast Name]]`) resolves to it automatically.

src/TemplateEngine.test.ts

Lines changed: 174 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
import { describe, expect, it } from "vitest";
2-
import { DownloadPathTemplateEngine } from "./TemplateEngine";
2+
import {
3+
DownloadPathTemplateEngine,
4+
FeedFilePathTemplateEngine,
5+
FeedNoteTemplateEngine,
6+
NoteTemplateEngine,
7+
getFeedNoteWikilink,
8+
} from "./TemplateEngine";
39
import type { Episode } from "./types/Episode";
10+
import type { PodcastFeed } from "./types/PodcastFeed";
11+
import { plugin } from "./store";
412

513
// The illegal-character sanitizer is private; exercise it through
614
// DownloadPathTemplateEngine, which applies it to {{title}} and {{podcast}}.
@@ -49,3 +57,168 @@ describe("replaceIllegalFileNameCharactersInString (via DownloadPathTemplateEngi
4957
);
5058
});
5159
});
60+
61+
const demoEpisode: Episode = {
62+
title: "Episode 1",
63+
streamUrl: "https://example.com/ep1.mp3",
64+
url: "https://example.com/ep1",
65+
description: "",
66+
content: "",
67+
podcastName: "My Show",
68+
feedUrl: "https://example.com/feed.xml",
69+
artworkUrl: "https://example.com/ep1.png",
70+
episodeDate: new Date("2024-01-01"),
71+
};
72+
73+
describe("NoteTemplateEngine feed-scoped tags (#163)", () => {
74+
it("keeps {{url}} and {{artwork}} pointing at the episode itself", () => {
75+
plugin.set({ settings: { feedNote: { path: "" }, savedFeeds: {} } } as never);
76+
expect(NoteTemplateEngine("{{url}}|{{artwork}}", demoEpisode)).toBe(
77+
"https://example.com/ep1|https://example.com/ep1.png",
78+
);
79+
});
80+
81+
it("adds episode aliases and {{feedurl}}", () => {
82+
plugin.set({ settings: { feedNote: { path: "" }, savedFeeds: {} } } as never);
83+
expect(NoteTemplateEngine("{{episodeurl}}", demoEpisode)).toBe(
84+
"https://example.com/ep1",
85+
);
86+
expect(NoteTemplateEngine("{{episodeartwork}}", demoEpisode)).toBe(
87+
"https://example.com/ep1.png",
88+
);
89+
expect(NoteTemplateEngine("{{feedurl}}", demoEpisode)).toBe(
90+
"https://example.com/feed.xml",
91+
);
92+
});
93+
94+
it("resolves {{feedartwork}} from the saved feed, else the episode art", () => {
95+
plugin.set({
96+
settings: {
97+
feedNote: { path: "" },
98+
savedFeeds: {
99+
"My Show": {
100+
title: "My Show",
101+
url: "x",
102+
artworkUrl: "https://example.com/feed.png",
103+
},
104+
},
105+
},
106+
} as never);
107+
expect(NoteTemplateEngine("{{feedartwork}}", demoEpisode)).toBe(
108+
"https://example.com/feed.png",
109+
);
110+
111+
plugin.set({ settings: { feedNote: { path: "" }, savedFeeds: {} } } as never);
112+
expect(NoteTemplateEngine("{{feedartwork}}", demoEpisode)).toBe(
113+
"https://example.com/ep1.png",
114+
);
115+
});
116+
117+
it("emits {{podcastlink}} as a path-qualified wikilink to the feed note", () => {
118+
plugin.set({
119+
settings: {
120+
feedNote: { path: "PodNotes/Podcasts/{{podcast}}.md" },
121+
savedFeeds: {},
122+
},
123+
} as never);
124+
expect(NoteTemplateEngine("{{podcastlink}}", demoEpisode)).toBe(
125+
"[[PodNotes/Podcasts/My Show|My Show]]",
126+
);
127+
});
128+
});
129+
130+
describe("FeedNoteTemplateEngine (#163)", () => {
131+
const feed: PodcastFeed = {
132+
title: "My Show: A Podcast",
133+
url: "https://example.com/feed.xml",
134+
artworkUrl: "https://example.com/art.png",
135+
link: "https://example.com",
136+
description: "<p>Great show</p>",
137+
author: "Jane Doe",
138+
};
139+
140+
it("maps {{url}}/{{artwork}} to the feed and exposes feed metadata", () => {
141+
expect(FeedNoteTemplateEngine("{{url}}", feed)).toBe("https://example.com");
142+
expect(FeedNoteTemplateEngine("{{feedurl}}", feed)).toBe(
143+
"https://example.com/feed.xml",
144+
);
145+
expect(FeedNoteTemplateEngine("{{artwork}}", feed)).toBe(
146+
"https://example.com/art.png",
147+
);
148+
expect(FeedNoteTemplateEngine("{{feedartwork}}", feed)).toBe(
149+
"https://example.com/art.png",
150+
);
151+
expect(FeedNoteTemplateEngine("{{author}}", feed)).toBe("Jane Doe");
152+
// htmlToMarkdown is a passthrough in the test mock.
153+
expect(FeedNoteTemplateEngine("{{description}}", feed)).toBe("<p>Great show</p>");
154+
});
155+
156+
it("exposes raw {{title}} and sanitized {{podcast}}/{{safetitle}}", () => {
157+
expect(FeedNoteTemplateEngine("{{title}}", feed)).toBe("My Show: A Podcast");
158+
expect(FeedNoteTemplateEngine("{{podcast}}", feed)).toBe("My Show A Podcast");
159+
expect(FeedNoteTemplateEngine("{{safetitle}}", feed)).toBe("My Show A Podcast");
160+
});
161+
162+
it("leaves {{url}} empty when the feed has no website link", () => {
163+
expect(FeedNoteTemplateEngine("{{url}}", { ...feed, link: undefined })).toBe("");
164+
});
165+
166+
it("strips quote/backslash from URL tags so quoted YAML frontmatter stays valid", () => {
167+
const malformed: PodcastFeed = {
168+
...feed,
169+
link: 'https://example.com/a?x="b"\\c',
170+
artworkUrl: 'https://example.com/art".png',
171+
};
172+
expect(FeedNoteTemplateEngine("{{url}}", malformed)).toBe(
173+
"https://example.com/a?x=bc",
174+
);
175+
expect(FeedNoteTemplateEngine("{{artwork}}", malformed)).toBe(
176+
"https://example.com/art.png",
177+
);
178+
});
179+
});
180+
181+
describe("FeedFilePathTemplateEngine (#163)", () => {
182+
const feed: PodcastFeed = {
183+
title: "My Show: A Podcast",
184+
url: "u",
185+
artworkUrl: "",
186+
};
187+
188+
it("sanitizes the feed title for {{podcast}} and {{title}}", () => {
189+
expect(
190+
FeedFilePathTemplateEngine("PodNotes/Podcasts/{{podcast}}.md", feed),
191+
).toBe("PodNotes/Podcasts/My Show A Podcast.md");
192+
});
193+
194+
it("supports a whitespace-replacement argument", () => {
195+
expect(FeedFilePathTemplateEngine("{{podcast:-}}", feed)).toBe(
196+
"My-Show-A-Podcast",
197+
);
198+
});
199+
});
200+
201+
describe("getFeedNoteWikilink (#163)", () => {
202+
it("path-qualifies the link when the feed-note path has a folder", () => {
203+
plugin.set({
204+
settings: { feedNote: { path: "PodNotes/Podcasts/{{podcast}}.md" } },
205+
} as never);
206+
expect(getFeedNoteWikilink("My Show: A Podcast")).toBe(
207+
"[[PodNotes/Podcasts/My Show A Podcast|My Show A Podcast]]",
208+
);
209+
});
210+
211+
it("uses a plain wikilink when the feed-note path has no folder", () => {
212+
plugin.set({
213+
settings: { feedNote: { path: "{{podcast}}.md" } },
214+
} as never);
215+
expect(getFeedNoteWikilink("My Show: A Podcast")).toBe(
216+
"[[My Show A Podcast]]",
217+
);
218+
});
219+
220+
it("falls back to a plain sanitized link when no path is configured", () => {
221+
plugin.set({ settings: { feedNote: { path: "" } } } as never);
222+
expect(getFeedNoteWikilink("My Show: A Podcast")).toBe("[[My Show A Podcast]]");
223+
});
224+
});

0 commit comments

Comments
 (0)