diff --git a/docs/docs/templates.md b/docs/docs/templates.md index e84a7ec..40c2d53 100644 --- a/docs/docs/templates.md +++ b/docs/docs/templates.md @@ -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 @@ -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 `` 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}}`. @@ -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). diff --git a/scripts/provision-obsidian-e2e-vault.mjs b/scripts/provision-obsidian-e2e-vault.mjs index 38e72dd..4b3e2f2 100644 --- a/scripts/provision-obsidian-e2e-vault.mjs +++ b/scripts/provision-obsidian-e2e-vault.mjs @@ -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", diff --git a/src/TemplateEngine.test.ts b/src/TemplateEngine.test.ts index ca1ddb6..b1fb14a 100644 --- a/src/TemplateEngine.test.ts +++ b/src/TemplateEngine.test.ts @@ -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}}. @@ -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", diff --git a/src/constants.ts b/src/constants.ts index 255dfd0..0e2761a 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -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: { diff --git a/src/main.ts b/src/main.ts index ec4a43d..711f6f9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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"; @@ -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() { diff --git a/src/settingsMigrations.test.ts b/src/settingsMigrations.test.ts index 5cc52ee..cb93433 100644 --- a/src/settingsMigrations.test.ts +++ b/src/settingsMigrations.test.ts @@ -3,6 +3,7 @@ import { DEFAULT_SETTINGS } from "./constants"; import { LEGACY_EMPTY_DOWNLOAD_PATH, migrateDownloadPath, + migrateNoteSettings, } from "./settingsMigrations"; describe("download path default (#183)", () => { @@ -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); + }); +}); diff --git a/src/settingsMigrations.ts b/src/settingsMigrations.ts index def5710..7a386cb 100644 --- a/src/settingsMigrations.ts +++ b/src/settingsMigrations.ts @@ -49,3 +49,48 @@ export function migrateDownloadPath( return storedPath; } + +/** + * The episode note path and template both used to default to "" (empty). With an + * empty path OR an empty template the "Create episode note" command is disabled + * (`src/main.ts` gates it on both being non-empty), so a fresh install could not + * create episode notes at all until the user hand-wrote a template. Issue #160 + * gives both a Bases-friendly default. Users who never touched note settings have + * the legacy empty note `{ path: "", template: "" }` persisted in `data.json`, + * which overrides the new default on load, so it is migrated to the new default. + */ +type StoredNote = { path?: string | null; template?: string | null }; + +/** + * Upgrades the legacy empty episode-note settings to the current Bases-friendly + * default, preserving any configuration the user made. + * + * The migration fires ONLY when the WHOLE note is the legacy default — both path + * and template empty/absent — i.e. the exact value a never-configured install has + * persisted. The moment the user has set EITHER field, the note feature has been + * engaged, so both fields are preserved verbatim — including a deliberately empty + * field a user relies on to keep "Create episode note" disabled (the command + * gates on emptiness). This is the conservative reading of "migrate only when the + * stored value is still the old default": a partially-configured note is not the + * old default and is never silently overwritten. + * + * `null`/`undefined` fields (a missing key or hand-edited `data.json`) are + * coalesced to "" both so a fully-empty/absent note still upgrades and so a `null` + * never reaches FilePathTemplateEngine, where `null.replace(...)` would throw. + */ +export function migrateNoteSettings(storedNote: StoredNote | null | undefined): { + path: string; + template: string; +} { + const path = storedNote?.path ?? ""; + const template = storedNote?.template ?? ""; + + if (path === "" && template === "") { + return { + path: DEFAULT_SETTINGS.note.path, + template: DEFAULT_SETTINGS.note.template, + }; + } + + return { path, template }; +} diff --git a/src/ui/settings/PodNotesSettingsTab.ts b/src/ui/settings/PodNotesSettingsTab.ts index 0570e6d..ac654e5 100644 --- a/src/ui/settings/PodNotesSettingsTab.ts +++ b/src/ui/settings/PodNotesSettingsTab.ts @@ -336,15 +336,21 @@ export class PodNotesSettingsTab extends PluginSettingTab { textArea.inputEl.style.width = "100%"; textArea.inputEl.style.height = "25vh"; + // A Bases-friendly hint mirroring the shipped default: structured + // frontmatter properties Bases can query, with the raw title in the + // body where YAML rules don't apply. textArea.setPlaceholder( - "## {{title}}" + - "\n![]({{artwork}})" + - "\n### Metadata" + - "\nPodcast:: {{podcast}}" + - "\nEpisode:: {{title}}" + - "\nPublishDate:: {{date:YYYY-MM-DD}}" + - "\n### Description" + - "\n> {{description}}", + "---" + + "\ntype: podcastEpisode" + + '\npodcast: "{{podcastlink}}"' + + '\nurl: "{{url}}"' + + "\ndate: {{date:YYYY-MM-DD}}" + + "\ntags:" + + "\n - podcastEpisode" + + "\n---" + + "\n# {{title}}" + + "\n\n![]({{artwork}})" + + "\n\n{{description}}", ); });