Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 47 additions & 5 deletions docs/docs/templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ PodNotes can create notes from templates. These templates can contain certain sy
To use templates, you can use the `Create episode note` Obsidian command (previously named `Create podcast note`).
This requires you to have defined a template for both the file path and note text.

PodNotes ships a sensible default for both, so the command works out of the box on a fresh install. The default note template uses structured frontmatter properties that [Obsidian Bases](https://help.obsidian.md/bases) can sort, filter, and group on (see [Using the default template with Bases](#using-the-default-template-with-bases) below). You can customize either template under PodNotes settings at any time, and your customizations are always preserved across updates.

PodNotes can also create a note for a whole podcast (the feed). See [Podcast feed notes](#podcast-feed-notes) below.

## File path
Expand All @@ -28,7 +30,7 @@ This template will be used to create the note text. You can use the following sy
- You can use `{{content:> }}` to prepend each new line with a `>` (to put the entire content in a blockquote).

- `{{podcast}}`: The name of the podcast.
- `{{url}}`: The URL of the podcast episode.
- `{{url}}`: The URL of the podcast episode. For a local-file episode this is a link to the file rather than a web URL. Tag values are inserted verbatim, so when you place one in a quoted YAML property (e.g. `url: "{{url}}"`) it stays valid for well-formed feed URLs and ordinary file names (a URL or file name containing a literal `"` would need escaping).
- `{{stream}}`: The direct URL of the episode's audio file — the RSS `<enclosure>` URL for podcast feeds, or the underlying audio source for Pocket Casts and local-file episodes. Handy for embedding the raw audio or linking to the source. An empty string is used in the rare case no audio URL is available. Available in episode note templates only.
- `{{date}}`: The publish date of the podcast episode.
- You can use `{{date:format}}` to specify a custom [Moment.js](https://momentjs.com) format. E.g. `{{date:YYYY-MM-DD}}`.
Expand All @@ -51,22 +53,62 @@ In an episode note, `{{url}}` and `{{artwork}}` always describe the **episode**.
- `{{feedartwork}}`: The podcast's (feed) artwork. Falls back to the episode artwork if the feed isn't saved.
- `{{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.)

A Bases-friendly episode template that links back to the feed note:
### Using the default template with Bases
PodNotes ships this default episode note template, which links each episode back to its feed note and exposes structured frontmatter properties for [Obsidian Bases](https://help.obsidian.md/bases):

```
---
type: podcastEpisode
podcast: "{{podcastlink}}"
title: "{{title}}"
image: "{{artwork}}"
url: "{{url}}"
date: {{date:YYYY-MM-DD}}
tags:
- podcastEpisode
status:
rating:
favorite: false
---
# {{title}}

![]({{artwork}})

[Resume in PodNotes]({{episodelink}})

{{url}}

{{description}}
```

The frontmatter is built so it stays **valid YAML** for every episode, because only values that can never contain a YAML-hostile character go into it:

- The full episode title goes in the body as the `# {{title}}` heading, where YAML rules don't apply (a raw title can contain `"` or `:`, which would break a frontmatter scalar).
- `{{podcastlink}}` is quoted so its leading `[[` isn't read as a YAML flow sequence, and the linked name is sanitized. (If you customize *Feed note file path* to a folder containing a literal `"` or `\`, that character flows into the link verbatim — keep the feed-note path to ordinary path characters.)
- `{{url}}` and `{{artwork}}` are kept in the **body** (a bare link and a Markdown image), not in the frontmatter. Tag values are inserted verbatim, and for a local-file episode `{{url}}` is a vault link whose name can contain a `"`; keeping it out of a quoted scalar means an awkward URL or file name can never break the note's properties. If your episodes all come from feeds (so `{{url}}` is always a well-formed web URL), you can add `url: "{{url}}"` back to the frontmatter.
- `date:` is a bare `YYYY-MM-DD` (or empty/null when the feed has no publish date).
- `status`, `rating`, and `favorite` are left for you to fill in — they give Bases columns to sort and filter on (e.g. mark an episode `favorite: true`, or set `status` to `to-listen`/`listening`/`listened`).

A starter Bases view (save as e.g. `Podcast Episodes.base`) that lists every episode note, grouped by listening status, with a column for each property:

```yaml
filters:
and:
- 'type == "podcastEpisode"'
views:
- type: table
name: Episodes
order:
- file.name
- note.podcast
- note.date
- note.status
- note.rating
- note.favorite
groupBy:
property: note.status
direction: ASC
```

Open the base in Obsidian to sort, filter, or add views from the view options. Because `note.podcast` resolves to the linked [feed note](#podcast-feed-notes), you can also group by show or pivot from a feed note to all of its episodes.

## Podcast feed notes
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).

Expand Down
19 changes: 17 additions & 2 deletions scripts/provision-obsidian-e2e-vault.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,23 @@ export const DEFAULT_PODNOTES_DATA = {
offset: 0,
},
note: {
path: "",
template: "",
path: "PodNotes/{{podcast}}/{{title}}.md",
template:
"---\n" +
"type: podcastEpisode\n" +
'podcast: "{{podcastlink}}"\n' +
"date: {{date:YYYY-MM-DD}}\n" +
"tags:\n" +
" - podcastEpisode\n" +
"status:\n" +
"rating:\n" +
"favorite: false\n" +
"---\n" +
"# {{title}}\n\n" +
"![]({{artwork}})\n\n" +
"[Resume in PodNotes]({{episodelink}})\n\n" +
"{{url}}\n\n" +
"{{description}}\n",
},
feedNote: {
path: "PodNotes/Podcasts/{{podcast}}.md",
Expand Down
103 changes: 103 additions & 0 deletions src/TemplateEngine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import type { Episode } from "./types/Episode";
import type { PodcastFeed } from "./types/PodcastFeed";
import { downloadedEpisodes, plugin } from "./store";
import { DEFAULT_SETTINGS } from "./constants";

// The illegal-character sanitizer is private; exercise it through
// DownloadPathTemplateEngine, which applies it to {{title}} and {{podcast}}.
Expand Down Expand Up @@ -144,6 +145,108 @@ describe("NoteTemplateEngine feed-scoped tags (#163)", () => {
});
});

describe("NoteTemplateEngine renders URL tags verbatim (#160 review)", () => {
beforeEach(() => {
plugin.set({
settings: { feedNote: { path: "" }, savedFeeds: {} },
} as never);
});

it("does not mutate {{url}}/{{episodeurl}} — local-file wikilinks pass through", () => {
// For local-file episodes episode.url is a wikilink, not a URL
// (getContextMenuHandler stores generateMarkdownLink). The engine must not
// strip characters from it, or the link would point at a different file.
const localFile = {
...demoEpisode,
url: '[[Talk "A".mp3]]',
} as Episode;
expect(NoteTemplateEngine("{{url}}", localFile)).toBe('[[Talk "A".mp3]]');
expect(NoteTemplateEngine("{{episodeurl}}", localFile)).toBe(
'[[Talk "A".mp3]]',
);
});

it("renders a normal episode URL and artwork verbatim", () => {
expect(NoteTemplateEngine("{{url}}|{{artwork}}", demoEpisode)).toBe(
"https://example.com/ep1|https://example.com/ep1.png",
);
});
});

describe("default note template renders valid frontmatter (#160)", () => {
beforeEach(() => {
plugin.set({
settings: {
feedNote: { path: "PodNotes/Podcasts/{{podcast}}.md" },
savedFeeds: {},
},
} as never);
downloadedEpisodes.set({});
});

function frontmatterOf(rendered: string): string {
const match = rendered.match(/^---\n([\s\S]*?)\n---\n/);
expect(match).not.toBeNull();
return (match as RegExpMatchArray)[1];
}

it("keeps frontmatter valid even when title/url carry YAML-hostile characters", () => {
// A local-file episode whose name contains a quote is the worst case: the
// title carries quotes/colons and {{url}} is a wikilink containing a quote.
// Both must stay in the BODY (never a quoted frontmatter scalar) so the
// frontmatter always parses. See issue #160 review.
const episode: Episode = {
...demoEpisode,
title: 'Why "AI": a deep dive: part 2',
url: '[[Audio/Talk "A".mp3]]',
podcastName: "My Show",
};
const rendered = NoteTemplateEngine(
DEFAULT_SETTINGS.note.template,
episode,
);
const frontmatter = frontmatterOf(rendered);
const line = (key: string) =>
frontmatter.split("\n").find((l) => l.startsWith(`${key}:`));

// The podcast link is quoted so its leading [[ isn't read as a flow sequence.
expect(line("podcast")).toBe(
'podcast: "[[PodNotes/Podcasts/My Show|My Show]]"',
);
// The url is NOT in the frontmatter (it could carry a quote for local files).
expect(line("url")).toBeUndefined();
// Every frontmatter line has balanced double-quotes.
for (const l of frontmatter.split("\n")) {
expect((l.match(/"/g) ?? []).length % 2).toBe(0);
}
// The raw title (quotes/colons) and the raw url (a quote-bearing wikilink)
// live only in the body, where YAML rules don't apply.
const body = rendered.slice(rendered.indexOf("\n---\n") + 5);
expect(body).toContain('# Why "AI": a deep dive: part 2');
expect(body).toContain('[[Audio/Talk "A".mp3]]');
expect(frontmatter).not.toContain("deep dive");
expect(frontmatter).not.toContain("Talk");
});

it("renders an ISO date when present and an empty (null) date otherwise", () => {
const withDate = NoteTemplateEngine(
DEFAULT_SETTINGS.note.template,
demoEpisode,
);
expect(
withDate.split("\n").find((l) => l.startsWith("date:")),
).toBe("date: 2024-01-01");

const noDate = NoteTemplateEngine(DEFAULT_SETTINGS.note.template, {
...demoEpisode,
episodeDate: undefined,
});
const dateLine = noDate.split("\n").find((l) => l.startsWith("date:"));
// Empty publish date renders as a null property, never a broken value.
expect(dateLine?.replace(/\s+$/, "")).toBe("date:");
});
});

describe("FeedNoteTemplateEngine (#163)", () => {
const feed: PodcastFeed = {
title: "My Show: A Podcast",
Expand Down
34 changes: 32 additions & 2 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,38 @@ export const DEFAULT_SETTINGS: IPodNotesSettings = {
},

note: {
path: "",
template: "",
// Group episode notes under a per-podcast folder, matching the download
// path convention (PodNotes/{{podcast}}/{{title}}). See issue #160.
path: "PodNotes/{{podcast}}/{{title}}.md",
// Bases-friendly frontmatter so new installs get queryable episode metadata
// out of the box (issue #160). Every value here is guaranteed valid YAML
// because none of them can contain a YAML-hostile character: {{podcastlink}}
// is a wikilink whose name is sanitized (no quotes) and is quoted so its
// leading "[[" can't be read as a flow sequence, and {{date:YYYY-MM-DD}} is
// either an ISO date or empty (null). status/rating/favorite are left for the
// user to fill and give Bases columns to sort and filter on. The raw
// {{title}} (which may contain quotes/colons) and {{url}} (a feed URL, or for
// a local file a vault link whose name may contain a quote) live in the BODY,
// where YAML rules don't apply — keeping the verbatim, unescaped tag values
// out of quoted frontmatter scalars so the frontmatter never fails to parse
// (issue #160 review). {{podcastlink}} ties each episode to its feed note
// (#163). See issue #160.
template:
"---\n" +
"type: podcastEpisode\n" +
'podcast: "{{podcastlink}}"\n' +
"date: {{date:YYYY-MM-DD}}\n" +
"tags:\n" +
" - podcastEpisode\n" +
"status:\n" +
"rating:\n" +
"favorite: false\n" +
"---\n" +
"# {{title}}\n\n" +
"![]({{artwork}})\n\n" +
"[Resume in PodNotes]({{episodelink}})\n\n" +
"{{url}}\n\n" +
"{{description}}\n",
},

feedNote: {
Expand Down
9 changes: 8 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ import { Plugin, type WorkspaceLeaf } from "obsidian";
import { API } from "src/API/API";
import type { IAPI } from "src/API/IAPI";
import { DEFAULT_SETTINGS, VIEW_TYPE } from "src/constants";
import { migrateDownloadPath } from "src/settingsMigrations";
import {
migrateDownloadPath,
migrateNoteSettings,
} from "src/settingsMigrations";
import { PodNotesSettingsTab } from "src/ui/settings/PodNotesSettingsTab";
import { MainView } from "src/ui/PodcastView";
import { QueueReorderModal } from "src/ui/QueueReorderModal";
Expand Down Expand Up @@ -495,6 +498,10 @@ export default class PodNotes extends Plugin implements IPodNotes {
this.settings.episodeListLimit = sanitizeEpisodeListLimit(
this.settings.episodeListLimit,
);
// Upgrade the legacy empty episode-note default to the Bases-friendly
// default, preserving any path/template the user configured (#160). Returns
// a fresh object, so DEFAULT_SETTINGS.note is never mutated.
this.settings.note = migrateNoteSettings(loadedData?.note);
}

async saveSettings() {
Expand Down
76 changes: 76 additions & 0 deletions src/settingsMigrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { DEFAULT_SETTINGS } from "./constants";
import {
LEGACY_EMPTY_DOWNLOAD_PATH,
migrateDownloadPath,
migrateNoteSettings,
} from "./settingsMigrations";

describe("download path default (#183)", () => {
Expand Down Expand Up @@ -47,3 +48,78 @@ describe("migrateDownloadPath (#183)", () => {
expect(migrateDownloadPath(once)).toBe(DEFAULT_SETTINGS.download.path);
});
});

describe("episode note defaults (#160)", () => {
it("ships a non-empty path with per-episode and per-podcast tokens", () => {
expect(DEFAULT_SETTINGS.note.path).not.toBe("");
expect(DEFAULT_SETTINGS.note.path).toMatch(/\{\{\s*title\b/i);
expect(DEFAULT_SETTINGS.note.path).toMatch(/\{\{\s*podcast\b/i);
});

it("ships a non-empty Bases-friendly template with frontmatter", () => {
const template = DEFAULT_SETTINGS.note.template;
expect(template).not.toBe("");
// Opens with a YAML frontmatter block...
expect(template.startsWith("---\n")).toBe(true);
// ...that closes before the body H1.
expect(template.indexOf("\n---\n")).toBeLessThan(
template.indexOf("# {{title}}"),
);
// Carries the structured properties Bases sorts/filters on.
expect(template).toContain("type: podcastEpisode");
expect(template).toMatch(/^tags:/m);
});
});

describe("migrateNoteSettings (#160)", () => {
const DEFAULT_NOTE = {
path: DEFAULT_SETTINGS.note.path,
template: DEFAULT_SETTINGS.note.template,
};

it("upgrades the legacy empty note (both fields empty) to the default", () => {
expect(migrateNoteSettings({ path: "", template: "" })).toEqual(
DEFAULT_NOTE,
);
});

it("treats an absent note (undefined/null/empty object) as the legacy default", () => {
expect(migrateNoteSettings(undefined)).toEqual(DEFAULT_NOTE);
expect(migrateNoteSettings(null)).toEqual(DEFAULT_NOTE);
expect(migrateNoteSettings({})).toEqual(DEFAULT_NOTE);
});

it("coalesces null/undefined fields and still upgrades a fully-absent note", () => {
// A corrupted/hand-edited data.json could carry nulls; they must not reach
// the path/template engines (null.replace would throw) and a wholly-empty
// note still upgrades.
expect(migrateNoteSettings({ path: null, template: null })).toEqual(
DEFAULT_NOTE,
);
});

it("preserves a fully-configured note verbatim", () => {
const custom = {
path: "inputs/podcasts/{{podcast}} - {{title}}.md",
template: "## {{title}}\n{{description}}",
};
expect(migrateNoteSettings(custom)).toEqual(custom);
});

it("never overwrites a deliberately-empty field once the user configured the other", () => {
// Custom path + empty template = note creation deliberately disabled; the
// empty template must NOT be filled with the new default (would re-enable
// the command). Symmetric for a custom template + empty path.
expect(
migrateNoteSettings({ path: "Custom/{{title}}.md", template: "" }),
).toEqual({ path: "Custom/{{title}}.md", template: "" });
expect(
migrateNoteSettings({ path: "", template: "## {{title}}" }),
).toEqual({ path: "", template: "## {{title}}" });
});

it("is idempotent on the current default", () => {
const once = migrateNoteSettings(DEFAULT_NOTE);
expect(migrateNoteSettings(once)).toEqual(DEFAULT_NOTE);
});
});
Loading
Loading