From 1f5809dee7cd22bc0dee0cbfa0eba09972fc4e9f Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 16 Jun 2026 10:30:43 +0200 Subject: [PATCH 1/5] feat(notes): ship a Bases-friendly default episode note template (#160) The episode note path/template both defaulted to "" (empty), which left the "Create episode note" command disabled on a fresh install and gave new users no structured metadata. Ship sensible defaults: - note.path: PodNotes/{{podcast}}/{{title}}.md (matches the download convention) - note.template: Bases-friendly frontmatter (type/podcast-link/url/date/tags + user-fillable status/rating/favorite), with the raw title as the body H1 so YAML rules never apply to it. {{podcastlink}} ties each episode to its #163 feed note for Bases/Dataview rollups. Make the frontmatter bulletproof: NoteTemplateEngine now sanitizes the URL tags ({{url}}/{{stream}}/{{artwork}}/{{episodeurl}}/{{episodeartwork}}/{{feedurl}}/ {{feedartwork}}) by stripping " and \\ (lossless for valid URLs), mirroring FeedNoteTemplateEngine, so a quoted scalar like url: "{{url}}" stays valid. Migrate existing users without clobbering customizations: migrateNotePath / migrateNoteTemplate upgrade ONLY the exact legacy empty value (or absent), so any non-empty path/template the user configured is preserved. Mirrors the #183 migrateDownloadPath precedent and is wired into loadSettings. Update the e2e provisioning seed to match (keeps the seed/DEFAULT_SETTINGS drift test green and seeds the new default for verification), refresh the settings placeholder, and document the default + an example .base view in templates.md. Verified in the isolated worktree Obsidian vault: notes created via the real create-podcast-note command (incl. a hostile title/URL and a no-date episode) parse into clean frontmatter via Obsidian's properties parser. --- docs/docs/templates.md | 49 +++++++++- scripts/provision-obsidian-e2e-vault.mjs | 19 +++- src/TemplateEngine.test.ts | 114 +++++++++++++++++++++++ src/TemplateEngine.ts | 25 +++-- src/constants.ts | 32 ++++++- src/main.ts | 16 +++- src/settingsMigrations.test.ts | 87 +++++++++++++++++ src/settingsMigrations.ts | 57 ++++++++++++ src/ui/settings/PodNotesSettingsTab.ts | 22 +++-- 9 files changed, 396 insertions(+), 25 deletions(-) diff --git a/docs/docs/templates.md b/docs/docs/templates.md index e84a7ec..97ce46e 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. The URL tags (`{{url}}`, `{{stream}}`, `{{artwork}}`, `{{episodeurl}}`, `{{episodeartwork}}`, `{{feedurl}}`, `{{feedartwork}}`) have any `"` or `\` stripped so they are always safe inside a double-quoted YAML frontmatter scalar like `url: "{{url}}"`. This is lossless for well-formed URLs (which never contain those characters). - `{{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,61 @@ 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}}) + {{description}} ``` +The frontmatter is built so it is **always valid YAML**, even for episodes with awkward titles or URLs: + +- 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. +- `{{url}}` and `{{artwork}}` are quoted, and PodNotes strips `"`/`\` from URL tags (see above) so the scalars stay intact. +- `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 your episodes and lets you sort/filter on those properties: + +```yaml +filters: + and: + - type == "podcastEpisode" +views: + - type: table + name: Episodes + order: + - file.name + - podcast + - date + - status + - rating + - favorite + sort: + - property: date + direction: DESC +``` + +`podcast` resolves to the linked [feed note](#podcast-feed-notes), so you can group episodes 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..f172488 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' + + 'url: "{{url}}"\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" + + "{{description}}\n", }, feedNote: { path: "PodNotes/Podcasts/{{podcast}}.md", diff --git a/src/TemplateEngine.test.ts b/src/TemplateEngine.test.ts index ca1ddb6..138ade8 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,119 @@ describe("NoteTemplateEngine feed-scoped tags (#163)", () => { }); }); +describe("NoteTemplateEngine URL sanitization (#160)", () => { + beforeEach(() => { + plugin.set({ + settings: { feedNote: { path: "" }, savedFeeds: {} }, + } as never); + }); + + it("leaves well-formed URLs untouched (lossless for valid URLs)", () => { + expect( + NoteTemplateEngine( + "{{url}}|{{artwork}}|{{stream}}|{{feedurl}}", + demoEpisode, + ), + ).toBe( + "https://example.com/ep1|https://example.com/ep1.png|https://example.com/ep1.mp3|https://example.com/feed.xml", + ); + }); + + it("strips quote/backslash from every URL tag so quoted YAML stays valid", () => { + const malformed: Episode = { + ...demoEpisode, + url: 'https://example.com/a?x="b"\\c', + streamUrl: 'https://example.com/a".mp3', + artworkUrl: 'https://example.com/art".png', + feedUrl: 'https://example.com/feed".xml', + }; + expect(NoteTemplateEngine("{{url}}", malformed)).toBe( + "https://example.com/a?x=bc", + ); + expect(NoteTemplateEngine("{{episodeurl}}", malformed)).toBe( + "https://example.com/a?x=bc", + ); + expect(NoteTemplateEngine("{{stream}}", malformed)).toBe( + "https://example.com/a.mp3", + ); + expect(NoteTemplateEngine("{{artwork}}", malformed)).toBe( + "https://example.com/art.png", + ); + expect(NoteTemplateEngine("{{episodeartwork}}", malformed)).toBe( + "https://example.com/art.png", + ); + expect(NoteTemplateEngine("{{feedurl}}", malformed)).toBe( + "https://example.com/feed.xml", + ); + }); +}); + +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 for hostile titles/URLs", () => { + const hostile: Episode = { + ...demoEpisode, + title: 'Why "AI": a deep dive: part 2', + url: 'https://example.com/ep?x="q"\\c', + artworkUrl: 'https://example.com/art".png', + podcastName: "My Show", + }; + const rendered = NoteTemplateEngine(DEFAULT_SETTINGS.note.template, hostile); + const frontmatter = frontmatterOf(rendered); + const line = (key: string) => + frontmatter.split("\n").find((l) => l.startsWith(`${key}:`)); + + // The quoted url scalar must not carry an internal quote/backslash that + // would terminate or escape the scalar and break the YAML. + expect(line("url")).toBe('url: "https://example.com/ep?x=qc"'); + // 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]]"', + ); + // 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 (with quotes/colons) lives only in the body H1. + const body = rendered.slice(rendered.indexOf("\n---\n") + 5); + expect(body).toContain('# Why "AI": a deep dive: part 2'); + expect(frontmatter).not.toContain("deep dive"); + }); + + 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/TemplateEngine.ts b/src/TemplateEngine.ts index add62f3..f75cbe7 100644 --- a/src/TemplateEngine.ts +++ b/src/TemplateEngine.ts @@ -120,8 +120,13 @@ export function NoteTemplateEngine(template: string, episode: Episode) { return htmlToMarkdown(episode.content); }); addTag("safetitle", replaceIllegalFileNameCharactersInString(episode.title)); - addTag("stream", episode.streamUrl); - addTag("url", episode.url); + // URL tags are sanitized (quote/backslash stripped) so they stay valid inside a + // quoted YAML frontmatter scalar — the Bases-friendly default note template + // quotes {{url}}. The strip is lossless for well-formed URLs (a valid URL never + // contains a raw double-quote or backslash) and mirrors FeedNoteTemplateEngine. + // See issue #160. + addTag("stream", sanitizeUrlForTemplate(episode.streamUrl)); + addTag("url", sanitizeUrlForTemplate(episode.url)); addTag("date", (format?: string) => episode.episodeDate ? formatDate(episode.episodeDate, format ?? "YYYY-MM-DD") @@ -146,17 +151,21 @@ export function NoteTemplateEngine(template: string, episode: Episode) { "podcast", replaceIllegalFileNameCharactersInString(episode.podcastName), ); - addTag("artwork", episode.artworkUrl ?? ""); + addTag("artwork", sanitizeUrlForTemplate(episode.artworkUrl ?? "")); // Feed-scoped tags so an episode note can reference its parent podcast feed // without changing the meaning of the existing {{url}}/{{artwork}} tags - // (which always describe the episode itself). See issue #163. - addTag("episodeurl", episode.url); - addTag("episodeartwork", episode.artworkUrl ?? ""); - addTag("feedurl", episode.feedUrl ?? ""); + // (which always describe the episode itself). See issue #163. These are URL + // tags too, so they get the same quoted-YAML-safe sanitization (#160). + addTag("episodeurl", sanitizeUrlForTemplate(episode.url)); + addTag("episodeartwork", sanitizeUrlForTemplate(episode.artworkUrl ?? "")); + addTag("feedurl", sanitizeUrlForTemplate(episode.feedUrl ?? "")); const parentFeed = get(plugin)?.settings?.savedFeeds?.[episode.podcastName]; - addTag("feedartwork", parentFeed?.artworkUrl ?? episode.artworkUrl ?? ""); + addTag( + "feedartwork", + sanitizeUrlForTemplate(parentFeed?.artworkUrl ?? episode.artworkUrl ?? ""), + ); // A ready-made wikilink to the parent feed's note, pointing at the same file // createFeedNote writes (derived from the feed-note path setting). addTag("podcastlink", getFeedNoteWikilink(episode.podcastName)); diff --git a/src/constants.ts b/src/constants.ts index 255dfd0..a26fea9 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -82,8 +82,36 @@ 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 placed in the YAML frontmatter is + // guaranteed valid YAML: {{podcastlink}} is a sanitized wikilink (and is + // quoted so its leading "[[" can't be read as a flow sequence), {{url}} is + // sanitized by NoteTemplateEngine (quote/backslash stripped) so it stays + // valid inside a quoted scalar, and {{date:YYYY-MM-DD}} is either an ISO date + // or empty (null). The raw {{title}} (which may contain quotes/colons) lives + // in the note body as the H1, where YAML rules don't apply. status/rating/ + // favorite are left for the user to fill and give Bases columns to sort and + // filter on. {{podcastlink}} ties each episode to its feed note (#163). See + // issue #160. + template: + "---\n" + + "type: podcastEpisode\n" + + 'podcast: "{{podcastlink}}"\n' + + 'url: "{{url}}"\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" + + "{{description}}\n", }, feedNote: { diff --git a/src/main.ts b/src/main.ts index ec4a43d..c63a523 100644 --- a/src/main.ts +++ b/src/main.ts @@ -16,7 +16,11 @@ 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, + migrateNotePath, + migrateNoteTemplate, +} from "src/settingsMigrations"; import { PodNotesSettingsTab } from "src/ui/settings/PodNotesSettingsTab"; import { MainView } from "src/ui/PodcastView"; import { QueueReorderModal } from "src/ui/QueueReorderModal"; @@ -495,6 +499,16 @@ export default class PodNotes extends Plugin implements IPodNotes { this.settings.episodeListLimit = sanitizeEpisodeListLimit( this.settings.episodeListLimit, ); + // Build a fresh note object so we never mutate the shared + // DEFAULT_SETTINGS.note, then migrate the legacy empty defaults (#160). + this.settings.note = { + ...DEFAULT_SETTINGS.note, + ...(loadedData?.note ?? {}), + }; + this.settings.note.path = migrateNotePath(this.settings.note.path); + this.settings.note.template = migrateNoteTemplate( + this.settings.note.template, + ); } async saveSettings() { diff --git a/src/settingsMigrations.test.ts b/src/settingsMigrations.test.ts index 5cc52ee..beb9006 100644 --- a/src/settingsMigrations.test.ts +++ b/src/settingsMigrations.test.ts @@ -2,7 +2,11 @@ import { describe, expect, it } from "vitest"; import { DEFAULT_SETTINGS } from "./constants"; import { LEGACY_EMPTY_DOWNLOAD_PATH, + LEGACY_EMPTY_NOTE_PATH, + LEGACY_EMPTY_NOTE_TEMPLATE, migrateDownloadPath, + migrateNotePath, + migrateNoteTemplate, } from "./settingsMigrations"; describe("download path default (#183)", () => { @@ -47,3 +51,86 @@ 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("migrateNotePath (#160)", () => { + it("upgrades the legacy empty path to the current default", () => { + expect(migrateNotePath(LEGACY_EMPTY_NOTE_PATH)).toBe( + DEFAULT_SETTINGS.note.path, + ); + expect(migrateNotePath("")).toBe(DEFAULT_SETTINGS.note.path); + }); + + it("treats an absent value (undefined/null) as the legacy default", () => { + expect(migrateNotePath(undefined)).toBe(DEFAULT_SETTINGS.note.path); + // null is reachable via a corrupted/hand-edited data.json; mapping it to the + // default also keeps null out of FilePathTemplateEngine (would crash). + expect(migrateNotePath(null)).toBe(DEFAULT_SETTINGS.note.path); + }); + + it("preserves any non-empty custom path verbatim", () => { + expect(migrateNotePath("inputs/podcasts/{{podcast}} - {{title}}.md")).toBe( + "inputs/podcasts/{{podcast}} - {{title}}.md", + ); + expect(migrateNotePath("Notes")).toBe("Notes"); + }); + + it("is idempotent on the current default", () => { + const once = migrateNotePath(DEFAULT_SETTINGS.note.path); + expect(migrateNotePath(once)).toBe(DEFAULT_SETTINGS.note.path); + }); +}); + +describe("migrateNoteTemplate (#160)", () => { + it("upgrades the legacy empty template to the current default", () => { + expect(migrateNoteTemplate(LEGACY_EMPTY_NOTE_TEMPLATE)).toBe( + DEFAULT_SETTINGS.note.template, + ); + expect(migrateNoteTemplate("")).toBe(DEFAULT_SETTINGS.note.template); + }); + + it("treats an absent value (undefined/null) as the legacy default", () => { + expect(migrateNoteTemplate(undefined)).toBe( + DEFAULT_SETTINGS.note.template, + ); + expect(migrateNoteTemplate(null)).toBe(DEFAULT_SETTINGS.note.template); + }); + + it("preserves any non-empty custom template verbatim", () => { + const custom = "## {{title}}\n{{description}}"; + expect(migrateNoteTemplate(custom)).toBe(custom); + }); + + it("migrates path and template independently", () => { + // A user who customized only the path keeps it while still gaining the new + // template default (and vice versa) — neither field resets the other. + expect(migrateNotePath("Custom/{{title}}.md")).toBe("Custom/{{title}}.md"); + expect(migrateNoteTemplate("")).toBe(DEFAULT_SETTINGS.note.template); + }); + + it("is idempotent on the current default", () => { + const once = migrateNoteTemplate(DEFAULT_SETTINGS.note.template); + expect(migrateNoteTemplate(once)).toBe(DEFAULT_SETTINGS.note.template); + }); +}); diff --git a/src/settingsMigrations.ts b/src/settingsMigrations.ts index def5710..bf76bb8 100644 --- a/src/settingsMigrations.ts +++ b/src/settingsMigrations.ts @@ -49,3 +49,60 @@ 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 customized these have the + * exact empty value persisted in `data.json`, which overrides the new default on + * load, so each is migrated independently from the legacy empty value. + */ +export const LEGACY_EMPTY_NOTE_PATH = ""; +export const LEGACY_EMPTY_NOTE_TEMPLATE = ""; + +/** + * Upgrades the legacy empty episode-note path to the current Bases-friendly + * default, preserving any non-empty path the user configured. + * + * ONLY the exact legacy empty value (or an absent value) is migrated. An empty + * path leaves the note command disabled, so replacing it is strictly an + * improvement — the command stays manual, so an existing user is never forced to + * create notes, they simply gain the option with a sensible default. + * `null`/`undefined` (a missing key or hand-edited `data.json`) map to the default + * both to apply the intended value and to keep a `null` from reaching + * FilePathTemplateEngine, where `null.replace(...)` would throw. + */ +export function migrateNotePath( + storedPath: string | null | undefined, +): string { + if ( + storedPath === undefined || + storedPath === null || + storedPath === LEGACY_EMPTY_NOTE_PATH + ) { + return DEFAULT_SETTINGS.note.path; + } + + return storedPath; +} + +/** + * Upgrades the legacy empty episode-note template to the current Bases-friendly + * default, preserving any non-empty template the user configured. Migrated + * independently of the path so customizing one field never resets the other. + */ +export function migrateNoteTemplate( + storedTemplate: string | null | undefined, +): string { + if ( + storedTemplate === undefined || + storedTemplate === null || + storedTemplate === LEGACY_EMPTY_NOTE_TEMPLATE + ) { + return DEFAULT_SETTINGS.note.template; + } + + return storedTemplate; +} 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}}", ); }); From 940dcf4f8397bafb7a4f2de95234d6e3ea02f452 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 16 Jun 2026 10:38:30 +0200 Subject: [PATCH 2/5] fix(notes): harden URL-tag sanitization and correct Bases docs (#160 review) Address ultracode review findings on the #160 default template: - NoteTemplateEngine: guard {{url}}/{{stream}}/{{episodeurl}} with `?? ""` before sanitizeUrlForTemplate, matching the other URL tags. A corrupted/hand-edited data.json could surface a null/undefined on these typed-string fields, where the unguarded `.replace` would throw and abort note creation. Add a regression test covering a corrupted episode. - docs/templates.md: the default puts {{artwork}} in the body as a Markdown image, not a quoted frontmatter scalar; correct the YAML-safety bullet to say so. - docs/templates.md: quote the example .base filter condition ('type == "podcastEpisode"') to match Obsidian's canonical Bases syntax. --- docs/docs/templates.md | 4 ++-- src/TemplateEngine.test.ts | 22 ++++++++++++++++++++++ src/TemplateEngine.ts | 10 ++++++---- 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/docs/docs/templates.md b/docs/docs/templates.md index 97ce46e..5ca5bc8 100644 --- a/docs/docs/templates.md +++ b/docs/docs/templates.md @@ -81,7 +81,7 @@ The frontmatter is built so it is **always valid YAML**, even for episodes with - 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. -- `{{url}}` and `{{artwork}}` are quoted, and PodNotes strips `"`/`\` from URL tags (see above) so the scalars stay intact. +- `{{url}}` is a quoted frontmatter scalar (`url: "{{url}}"`), and PodNotes strips `"`/`\` from all URL tags (see above) so it stays intact. `{{artwork}}` sits in the body as a Markdown image (`![]({{artwork}})`); the same stripping keeps that link well-formed. - `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`). @@ -90,7 +90,7 @@ A starter Bases view (save as e.g. `Podcast Episodes.base`) that lists your epis ```yaml filters: and: - - type == "podcastEpisode" + - 'type == "podcastEpisode"' views: - type: table name: Episodes diff --git a/src/TemplateEngine.test.ts b/src/TemplateEngine.test.ts index 138ade8..f61ec1c 100644 --- a/src/TemplateEngine.test.ts +++ b/src/TemplateEngine.test.ts @@ -190,6 +190,28 @@ describe("NoteTemplateEngine URL sanitization (#160)", () => { "https://example.com/feed.xml", ); }); + + it("renders empty (never throws) when URL fields are missing/corrupted", () => { + // Typed as non-optional strings, but a hand-edited/corrupted data.json could + // surface null/undefined. The `?? ""` guard must keep that from throwing in + // sanitizeUrlForTemplate and aborting note creation. + const corrupted = { + ...demoEpisode, + url: undefined, + streamUrl: undefined, + artworkUrl: undefined, + feedUrl: undefined, + } as unknown as Episode; + expect(() => + NoteTemplateEngine(DEFAULT_SETTINGS.note.template, corrupted), + ).not.toThrow(); + expect( + NoteTemplateEngine( + "{{url}}|{{stream}}|{{episodeurl}}|{{artwork}}|{{feedurl}}", + corrupted, + ), + ).toBe("||||"); + }); }); describe("default note template renders valid frontmatter (#160)", () => { diff --git a/src/TemplateEngine.ts b/src/TemplateEngine.ts index f75cbe7..0d4157a 100644 --- a/src/TemplateEngine.ts +++ b/src/TemplateEngine.ts @@ -124,9 +124,11 @@ export function NoteTemplateEngine(template: string, episode: Episode) { // quoted YAML frontmatter scalar — the Bases-friendly default note template // quotes {{url}}. The strip is lossless for well-formed URLs (a valid URL never // contains a raw double-quote or backslash) and mirrors FeedNoteTemplateEngine. - // See issue #160. - addTag("stream", sanitizeUrlForTemplate(episode.streamUrl)); - addTag("url", sanitizeUrlForTemplate(episode.url)); + // The `?? ""` keeps a corrupted/hand-edited data.json (where a typed-string URL + // is actually null/undefined) from throwing in sanitizeUrlForTemplate and + // aborting note creation — same guard the other URL tags below use. See #160. + addTag("stream", sanitizeUrlForTemplate(episode.streamUrl ?? "")); + addTag("url", sanitizeUrlForTemplate(episode.url ?? "")); addTag("date", (format?: string) => episode.episodeDate ? formatDate(episode.episodeDate, format ?? "YYYY-MM-DD") @@ -157,7 +159,7 @@ export function NoteTemplateEngine(template: string, episode: Episode) { // without changing the meaning of the existing {{url}}/{{artwork}} tags // (which always describe the episode itself). See issue #163. These are URL // tags too, so they get the same quoted-YAML-safe sanitization (#160). - addTag("episodeurl", sanitizeUrlForTemplate(episode.url)); + addTag("episodeurl", sanitizeUrlForTemplate(episode.url ?? "")); addTag("episodeartwork", sanitizeUrlForTemplate(episode.artworkUrl ?? "")); addTag("feedurl", sanitizeUrlForTemplate(episode.feedUrl ?? "")); const parentFeed = From 851980ddc9596c2971db6f6376f262606355cd56 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 16 Jun 2026 10:56:13 +0200 Subject: [PATCH 3/5] fix(notes): tighten note migration and Bases docs (#160 adversarial review) Address findings from the adversarial review of the #160 default template: - Migration: only upgrade the legacy empty note when the WHOLE note is the old default (path AND template both empty/absent). Replace the per-field migrateNotePath/migrateNoteTemplate with migrateNoteSettings so a user who configured one field and deliberately left the other empty (e.g. a custom path with an empty template to keep "Create episode note" disabled) is never silently re-enabled. Coalesce null fields so a corrupted data.json can't reach the path/template engines. Update tests to cover the preserve-partial case. - docs/templates.md: the example .base used a `sort:` view key, which is not part of the documented Obsidian Bases schema (sorting is view UI state; only `order` + `groupBy` are documented). Switch to `groupBy` (by listening status) and `note.`-prefixed properties, matching the canonical syntax. - Qualify the "always valid YAML" claim (docs + constants comment): the guarantee holds for the default and any ordinary feed-note path; a feed-note path with a literal quote/backslash in a folder segment would flow into {{podcastlink}}. --- docs/docs/templates.md | 24 +++++----- src/constants.ts | 22 +++++---- src/main.ts | 17 ++----- src/settingsMigrations.test.ts | 85 +++++++++++++++------------------- src/settingsMigrations.ts | 72 ++++++++++++---------------- 5 files changed, 96 insertions(+), 124 deletions(-) diff --git a/docs/docs/templates.md b/docs/docs/templates.md index 5ca5bc8..40ac87e 100644 --- a/docs/docs/templates.md +++ b/docs/docs/templates.md @@ -77,15 +77,15 @@ favorite: false {{description}} ``` -The frontmatter is built so it is **always valid YAML**, even for episodes with awkward titles or URLs: +With the default feed-note path, the frontmatter stays **valid YAML** even for episodes with awkward titles or URLs: - 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. +- `{{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}}` is a quoted frontmatter scalar (`url: "{{url}}"`), and PodNotes strips `"`/`\` from all URL tags (see above) so it stays intact. `{{artwork}}` sits in the body as a Markdown image (`![]({{artwork}})`); the same stripping keeps that link well-formed. - `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 your episodes and lets you sort/filter on those properties: +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: @@ -96,17 +96,17 @@ views: name: Episodes order: - file.name - - podcast - - date - - status - - rating - - favorite - sort: - - property: date - direction: DESC + - note.podcast + - note.date + - note.status + - note.rating + - note.favorite + groupBy: + property: note.status + direction: ASC ``` -`podcast` resolves to the linked [feed note](#podcast-feed-notes), so you can group episodes by show or pivot from a feed note to all of its episodes. +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/src/constants.ts b/src/constants.ts index a26fea9..9821236 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -86,16 +86,18 @@ export const DEFAULT_SETTINGS: IPodNotesSettings = { // 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 placed in the YAML frontmatter is - // guaranteed valid YAML: {{podcastlink}} is a sanitized wikilink (and is - // quoted so its leading "[[" can't be read as a flow sequence), {{url}} is - // sanitized by NoteTemplateEngine (quote/backslash stripped) so it stays - // valid inside a quoted scalar, and {{date:YYYY-MM-DD}} is either an ISO date - // or empty (null). The raw {{title}} (which may contain quotes/colons) lives - // in the note body as the H1, where YAML rules don't apply. status/rating/ - // favorite are left for the user to fill and give Bases columns to sort and - // filter on. {{podcastlink}} ties each episode to its feed note (#163). See - // issue #160. + // out of the box (issue #160). With the default feed-note path, every value + // placed in the YAML frontmatter renders as valid YAML: {{podcastlink}} is a + // sanitized wikilink (and is quoted so its leading "[[" can't be read as a + // flow sequence), {{url}} is sanitized by NoteTemplateEngine (quote/backslash + // stripped) so it stays valid inside a quoted scalar, and {{date:YYYY-MM-DD}} + // is either an ISO date or empty (null). The raw {{title}} (which may contain + // quotes/colons) lives in the note body as the H1, where YAML rules don't + // apply. status/rating/favorite are left for the user to fill and give Bases + // columns to sort and filter on. {{podcastlink}} ties each episode to its + // feed note (#163); a feed-note path with a literal quote/backslash in a + // folder segment would flow into the quoted scalar, but the default path and + // any ordinary path are safe. See issue #160. template: "---\n" + "type: podcastEpisode\n" + diff --git a/src/main.ts b/src/main.ts index c63a523..711f6f9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -18,8 +18,7 @@ import type { IAPI } from "src/API/IAPI"; import { DEFAULT_SETTINGS, VIEW_TYPE } from "src/constants"; import { migrateDownloadPath, - migrateNotePath, - migrateNoteTemplate, + migrateNoteSettings, } from "src/settingsMigrations"; import { PodNotesSettingsTab } from "src/ui/settings/PodNotesSettingsTab"; import { MainView } from "src/ui/PodcastView"; @@ -499,16 +498,10 @@ export default class PodNotes extends Plugin implements IPodNotes { this.settings.episodeListLimit = sanitizeEpisodeListLimit( this.settings.episodeListLimit, ); - // Build a fresh note object so we never mutate the shared - // DEFAULT_SETTINGS.note, then migrate the legacy empty defaults (#160). - this.settings.note = { - ...DEFAULT_SETTINGS.note, - ...(loadedData?.note ?? {}), - }; - this.settings.note.path = migrateNotePath(this.settings.note.path); - this.settings.note.template = migrateNoteTemplate( - this.settings.note.template, - ); + // 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 beb9006..cb93433 100644 --- a/src/settingsMigrations.test.ts +++ b/src/settingsMigrations.test.ts @@ -2,11 +2,8 @@ import { describe, expect, it } from "vitest"; import { DEFAULT_SETTINGS } from "./constants"; import { LEGACY_EMPTY_DOWNLOAD_PATH, - LEGACY_EMPTY_NOTE_PATH, - LEGACY_EMPTY_NOTE_TEMPLATE, migrateDownloadPath, - migrateNotePath, - migrateNoteTemplate, + migrateNoteSettings, } from "./settingsMigrations"; describe("download path default (#183)", () => { @@ -74,63 +71,55 @@ describe("episode note defaults (#160)", () => { }); }); -describe("migrateNotePath (#160)", () => { - it("upgrades the legacy empty path to the current default", () => { - expect(migrateNotePath(LEGACY_EMPTY_NOTE_PATH)).toBe( - DEFAULT_SETTINGS.note.path, - ); - expect(migrateNotePath("")).toBe(DEFAULT_SETTINGS.note.path); - }); +describe("migrateNoteSettings (#160)", () => { + const DEFAULT_NOTE = { + path: DEFAULT_SETTINGS.note.path, + template: DEFAULT_SETTINGS.note.template, + }; - it("treats an absent value (undefined/null) as the legacy default", () => { - expect(migrateNotePath(undefined)).toBe(DEFAULT_SETTINGS.note.path); - // null is reachable via a corrupted/hand-edited data.json; mapping it to the - // default also keeps null out of FilePathTemplateEngine (would crash). - expect(migrateNotePath(null)).toBe(DEFAULT_SETTINGS.note.path); - }); - - it("preserves any non-empty custom path verbatim", () => { - expect(migrateNotePath("inputs/podcasts/{{podcast}} - {{title}}.md")).toBe( - "inputs/podcasts/{{podcast}} - {{title}}.md", + it("upgrades the legacy empty note (both fields empty) to the default", () => { + expect(migrateNoteSettings({ path: "", template: "" })).toEqual( + DEFAULT_NOTE, ); - expect(migrateNotePath("Notes")).toBe("Notes"); }); - it("is idempotent on the current default", () => { - const once = migrateNotePath(DEFAULT_SETTINGS.note.path); - expect(migrateNotePath(once)).toBe(DEFAULT_SETTINGS.note.path); - }); -}); - -describe("migrateNoteTemplate (#160)", () => { - it("upgrades the legacy empty template to the current default", () => { - expect(migrateNoteTemplate(LEGACY_EMPTY_NOTE_TEMPLATE)).toBe( - DEFAULT_SETTINGS.note.template, - ); - expect(migrateNoteTemplate("")).toBe(DEFAULT_SETTINGS.note.template); + 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("treats an absent value (undefined/null) as the legacy default", () => { - expect(migrateNoteTemplate(undefined)).toBe( - DEFAULT_SETTINGS.note.template, + 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, ); - expect(migrateNoteTemplate(null)).toBe(DEFAULT_SETTINGS.note.template); }); - it("preserves any non-empty custom template verbatim", () => { - const custom = "## {{title}}\n{{description}}"; - expect(migrateNoteTemplate(custom)).toBe(custom); + 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("migrates path and template independently", () => { - // A user who customized only the path keeps it while still gaining the new - // template default (and vice versa) — neither field resets the other. - expect(migrateNotePath("Custom/{{title}}.md")).toBe("Custom/{{title}}.md"); - expect(migrateNoteTemplate("")).toBe(DEFAULT_SETTINGS.note.template); + 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 = migrateNoteTemplate(DEFAULT_SETTINGS.note.template); - expect(migrateNoteTemplate(once)).toBe(DEFAULT_SETTINGS.note.template); + const once = migrateNoteSettings(DEFAULT_NOTE); + expect(migrateNoteSettings(once)).toEqual(DEFAULT_NOTE); }); }); diff --git a/src/settingsMigrations.ts b/src/settingsMigrations.ts index bf76bb8..7a386cb 100644 --- a/src/settingsMigrations.ts +++ b/src/settingsMigrations.ts @@ -55,54 +55,42 @@ export function migrateDownloadPath( * 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 customized these have the - * exact empty value persisted in `data.json`, which overrides the new default on - * load, so each is migrated independently from the legacy empty value. + * 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. */ -export const LEGACY_EMPTY_NOTE_PATH = ""; -export const LEGACY_EMPTY_NOTE_TEMPLATE = ""; +type StoredNote = { path?: string | null; template?: string | null }; /** - * Upgrades the legacy empty episode-note path to the current Bases-friendly - * default, preserving any non-empty path the user configured. + * Upgrades the legacy empty episode-note settings to the current Bases-friendly + * default, preserving any configuration the user made. * - * ONLY the exact legacy empty value (or an absent value) is migrated. An empty - * path leaves the note command disabled, so replacing it is strictly an - * improvement — the command stays manual, so an existing user is never forced to - * create notes, they simply gain the option with a sensible default. - * `null`/`undefined` (a missing key or hand-edited `data.json`) map to the default - * both to apply the intended value and to keep a `null` from reaching - * FilePathTemplateEngine, where `null.replace(...)` would throw. + * 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 migrateNotePath( - storedPath: string | null | undefined, -): string { - if ( - storedPath === undefined || - storedPath === null || - storedPath === LEGACY_EMPTY_NOTE_PATH - ) { - return DEFAULT_SETTINGS.note.path; - } +export function migrateNoteSettings(storedNote: StoredNote | null | undefined): { + path: string; + template: string; +} { + const path = storedNote?.path ?? ""; + const template = storedNote?.template ?? ""; - return storedPath; -} - -/** - * Upgrades the legacy empty episode-note template to the current Bases-friendly - * default, preserving any non-empty template the user configured. Migrated - * independently of the path so customizing one field never resets the other. - */ -export function migrateNoteTemplate( - storedTemplate: string | null | undefined, -): string { - if ( - storedTemplate === undefined || - storedTemplate === null || - storedTemplate === LEGACY_EMPTY_NOTE_TEMPLATE - ) { - return DEFAULT_SETTINGS.note.template; + if (path === "" && template === "") { + return { + path: DEFAULT_SETTINGS.note.path, + template: DEFAULT_SETTINGS.note.template, + }; } - return storedTemplate; + return { path, template }; } From 1d59e3c41a44fa49c0cb7384b69f7af8912abcb1 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 16 Jun 2026 11:41:03 +0200 Subject: [PATCH 4/5] fix(notes): render URL tags verbatim, don't mutate them globally (#160 review) Codex review (P2): globally stripping "/\ from NoteTemplateEngine's URL tags corrupts {{url}} for local-file episodes, where episode.url is a wikilink from generateMarkdownLink (e.g. a file named Talk "A".mp3 would link to Talk A.mp3), and changes existing custom templates that use {{url}} outside YAML. Revert the URL-tag sanitization added earlier in this branch back to master's verbatim behavior. The default frontmatter stays valid YAML for the common case without mutating tag values: feed episode URLs are well-formed (no quotes), the {{podcastlink}} name is already sanitized, and ordinary local-file links contain no quotes. A URL or file name with a literal quote is the one case that would need adjusting, which is documented. sanitizeUrlForTemplate stays in use by FeedNoteTemplateEngine, where {{url}} is always a real website URL. Add a test guarding that {{url}}/{{episodeurl}} pass through verbatim (local-file wikilinks preserved), and update the docs/comment claims accordingly. --- docs/docs/templates.md | 4 +- src/TemplateEngine.test.ts | 90 +++++++++++--------------------------- src/TemplateEngine.ts | 27 ++++-------- src/constants.ts | 24 +++++----- 4 files changed, 48 insertions(+), 97 deletions(-) diff --git a/docs/docs/templates.md b/docs/docs/templates.md index 40ac87e..982917e 100644 --- a/docs/docs/templates.md +++ b/docs/docs/templates.md @@ -30,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. The URL tags (`{{url}}`, `{{stream}}`, `{{artwork}}`, `{{episodeurl}}`, `{{episodeartwork}}`, `{{feedurl}}`, `{{feedartwork}}`) have any `"` or `\` stripped so they are always safe inside a double-quoted YAML frontmatter scalar like `url: "{{url}}"`. This is lossless for well-formed URLs (which never contain those characters). +- `{{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}}`. @@ -81,7 +81,7 @@ With the default feed-note path, the frontmatter stays **valid YAML** even for e - 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}}` is a quoted frontmatter scalar (`url: "{{url}}"`), and PodNotes strips `"`/`\` from all URL tags (see above) so it stays intact. `{{artwork}}` sits in the body as a Markdown image (`![]({{artwork}})`); the same stripping keeps that link well-formed. +- `{{url}}` is a quoted frontmatter scalar (`url: "{{url}}"`). Well-formed feed URLs (and ordinary local-file links) contain no `"`, so the scalar stays valid; the tag value is inserted verbatim, so a URL/file name with a literal `"` is the one case you'd need to adjust. `{{artwork}}` sits in the body as a Markdown image (`![]({{artwork}})`), not in 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`). diff --git a/src/TemplateEngine.test.ts b/src/TemplateEngine.test.ts index f61ec1c..f4ca60c 100644 --- a/src/TemplateEngine.test.ts +++ b/src/TemplateEngine.test.ts @@ -145,72 +145,31 @@ describe("NoteTemplateEngine feed-scoped tags (#163)", () => { }); }); -describe("NoteTemplateEngine URL sanitization (#160)", () => { +describe("NoteTemplateEngine renders URL tags verbatim (#160 review)", () => { beforeEach(() => { plugin.set({ settings: { feedNote: { path: "" }, savedFeeds: {} }, } as never); }); - it("leaves well-formed URLs untouched (lossless for valid URLs)", () => { - expect( - NoteTemplateEngine( - "{{url}}|{{artwork}}|{{stream}}|{{feedurl}}", - demoEpisode, - ), - ).toBe( - "https://example.com/ep1|https://example.com/ep1.png|https://example.com/ep1.mp3|https://example.com/feed.xml", - ); - }); - - it("strips quote/backslash from every URL tag so quoted YAML stays valid", () => { - const malformed: Episode = { + 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: 'https://example.com/a?x="b"\\c', - streamUrl: 'https://example.com/a".mp3', - artworkUrl: 'https://example.com/art".png', - feedUrl: 'https://example.com/feed".xml', - }; - expect(NoteTemplateEngine("{{url}}", malformed)).toBe( - "https://example.com/a?x=bc", - ); - expect(NoteTemplateEngine("{{episodeurl}}", malformed)).toBe( - "https://example.com/a?x=bc", - ); - expect(NoteTemplateEngine("{{stream}}", malformed)).toBe( - "https://example.com/a.mp3", - ); - expect(NoteTemplateEngine("{{artwork}}", malformed)).toBe( - "https://example.com/art.png", - ); - expect(NoteTemplateEngine("{{episodeartwork}}", malformed)).toBe( - "https://example.com/art.png", - ); - expect(NoteTemplateEngine("{{feedurl}}", malformed)).toBe( - "https://example.com/feed.xml", + url: '[[Talk "A".mp3]]', + } as Episode; + expect(NoteTemplateEngine("{{url}}", localFile)).toBe('[[Talk "A".mp3]]'); + expect(NoteTemplateEngine("{{episodeurl}}", localFile)).toBe( + '[[Talk "A".mp3]]', ); }); - it("renders empty (never throws) when URL fields are missing/corrupted", () => { - // Typed as non-optional strings, but a hand-edited/corrupted data.json could - // surface null/undefined. The `?? ""` guard must keep that from throwing in - // sanitizeUrlForTemplate and aborting note creation. - const corrupted = { - ...demoEpisode, - url: undefined, - streamUrl: undefined, - artworkUrl: undefined, - feedUrl: undefined, - } as unknown as Episode; - expect(() => - NoteTemplateEngine(DEFAULT_SETTINGS.note.template, corrupted), - ).not.toThrow(); - expect( - NoteTemplateEngine( - "{{url}}|{{stream}}|{{episodeurl}}|{{artwork}}|{{feedurl}}", - corrupted, - ), - ).toBe("||||"); + it("renders a normal episode URL and artwork verbatim", () => { + expect(NoteTemplateEngine("{{url}}|{{artwork}}", demoEpisode)).toBe( + "https://example.com/ep1|https://example.com/ep1.png", + ); }); }); @@ -231,22 +190,25 @@ describe("default note template renders valid frontmatter (#160)", () => { return (match as RegExpMatchArray)[1]; } - it("keeps frontmatter valid even for hostile titles/URLs", () => { - const hostile: Episode = { + it("keeps frontmatter valid for an awkward title (raw title stays in the body)", () => { + // The title carries quotes/colons that would break a frontmatter scalar; it + // must render only in the body H1. The url is a well-formed feed URL (the + // common case for the default template) and sits in the quoted scalar. + const episode: Episode = { ...demoEpisode, title: 'Why "AI": a deep dive: part 2', - url: 'https://example.com/ep?x="q"\\c', - artworkUrl: 'https://example.com/art".png', + url: "https://example.com/ep?x=q&y=2", podcastName: "My Show", }; - const rendered = NoteTemplateEngine(DEFAULT_SETTINGS.note.template, hostile); + 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 quoted url scalar must not carry an internal quote/backslash that - // would terminate or escape the scalar and break the YAML. - expect(line("url")).toBe('url: "https://example.com/ep?x=qc"'); + expect(line("url")).toBe('url: "https://example.com/ep?x=q&y=2"'); // 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]]"', diff --git a/src/TemplateEngine.ts b/src/TemplateEngine.ts index 0d4157a..add62f3 100644 --- a/src/TemplateEngine.ts +++ b/src/TemplateEngine.ts @@ -120,15 +120,8 @@ export function NoteTemplateEngine(template: string, episode: Episode) { return htmlToMarkdown(episode.content); }); addTag("safetitle", replaceIllegalFileNameCharactersInString(episode.title)); - // URL tags are sanitized (quote/backslash stripped) so they stay valid inside a - // quoted YAML frontmatter scalar — the Bases-friendly default note template - // quotes {{url}}. The strip is lossless for well-formed URLs (a valid URL never - // contains a raw double-quote or backslash) and mirrors FeedNoteTemplateEngine. - // The `?? ""` keeps a corrupted/hand-edited data.json (where a typed-string URL - // is actually null/undefined) from throwing in sanitizeUrlForTemplate and - // aborting note creation — same guard the other URL tags below use. See #160. - addTag("stream", sanitizeUrlForTemplate(episode.streamUrl ?? "")); - addTag("url", sanitizeUrlForTemplate(episode.url ?? "")); + addTag("stream", episode.streamUrl); + addTag("url", episode.url); addTag("date", (format?: string) => episode.episodeDate ? formatDate(episode.episodeDate, format ?? "YYYY-MM-DD") @@ -153,21 +146,17 @@ export function NoteTemplateEngine(template: string, episode: Episode) { "podcast", replaceIllegalFileNameCharactersInString(episode.podcastName), ); - addTag("artwork", sanitizeUrlForTemplate(episode.artworkUrl ?? "")); + addTag("artwork", episode.artworkUrl ?? ""); // Feed-scoped tags so an episode note can reference its parent podcast feed // without changing the meaning of the existing {{url}}/{{artwork}} tags - // (which always describe the episode itself). See issue #163. These are URL - // tags too, so they get the same quoted-YAML-safe sanitization (#160). - addTag("episodeurl", sanitizeUrlForTemplate(episode.url ?? "")); - addTag("episodeartwork", sanitizeUrlForTemplate(episode.artworkUrl ?? "")); - addTag("feedurl", sanitizeUrlForTemplate(episode.feedUrl ?? "")); + // (which always describe the episode itself). See issue #163. + addTag("episodeurl", episode.url); + addTag("episodeartwork", episode.artworkUrl ?? ""); + addTag("feedurl", episode.feedUrl ?? ""); const parentFeed = get(plugin)?.settings?.savedFeeds?.[episode.podcastName]; - addTag( - "feedartwork", - sanitizeUrlForTemplate(parentFeed?.artworkUrl ?? episode.artworkUrl ?? ""), - ); + addTag("feedartwork", parentFeed?.artworkUrl ?? episode.artworkUrl ?? ""); // A ready-made wikilink to the parent feed's note, pointing at the same file // createFeedNote writes (derived from the feed-note path setting). addTag("podcastlink", getFeedNoteWikilink(episode.podcastName)); diff --git a/src/constants.ts b/src/constants.ts index 9821236..a1e3560 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -86,18 +86,18 @@ export const DEFAULT_SETTINGS: IPodNotesSettings = { // 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). With the default feed-note path, every value - // placed in the YAML frontmatter renders as valid YAML: {{podcastlink}} is a - // sanitized wikilink (and is quoted so its leading "[[" can't be read as a - // flow sequence), {{url}} is sanitized by NoteTemplateEngine (quote/backslash - // stripped) so it stays valid inside a quoted scalar, and {{date:YYYY-MM-DD}} - // is either an ISO date or empty (null). The raw {{title}} (which may contain - // quotes/colons) lives in the note body as the H1, where YAML rules don't - // apply. status/rating/favorite are left for the user to fill and give Bases - // columns to sort and filter on. {{podcastlink}} ties each episode to its - // feed note (#163); a feed-note path with a literal quote/backslash in a - // folder segment would flow into the quoted scalar, but the default path and - // any ordinary path are safe. See issue #160. + // out of the box (issue #160). The values are chosen to render as valid YAML + // for the common case: {{podcastlink}} is a wikilink whose name is sanitized + // (no quotes) and is quoted so its leading "[[" can't be read as a flow + // sequence; {{url}} is a well-formed feed URL (or, for local files, a vault + // link) placed in a quoted scalar; and {{date:YYYY-MM-DD}} is either an ISO + // date or empty (null). Tag values are inserted verbatim (not escaped), so a + // URL/feed-note path containing a literal quote is the one case that would + // need adjusting. The raw {{title}} (which may contain quotes/colons) lives + // in the note body as the H1, where YAML rules don't apply. status/rating/ + // favorite are left for the user to fill and give Bases columns to sort and + // filter on. {{podcastlink}} ties each episode to its feed note (#163). See + // issue #160. template: "---\n" + "type: podcastEpisode\n" + From b69864348f61cf273463d005f63cbdbb6a7d1543 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 16 Jun 2026 12:51:09 +0200 Subject: [PATCH 5/5] fix(notes): keep {{url}} out of the default frontmatter scalar (#160 review) Codex review (P2): after rendering URL tags verbatim, a local-file episode whose name contains a double quote (e.g. Talk "A".mp3, playable on macOS/Linux) makes {{url}} a wikilink with a quote, so url: "{{url}}" renders url: "[[Talk "A".mp3]]" and the whole frontmatter fails to parse, hiding every property from Bases. Move {{url}} (and it was already true for {{artwork}}) into the note body, where YAML rules don't apply, so the verbatim tag value can never break the frontmatter. The frontmatter now contains only values that can't carry a YAML-hostile character (type, the sanitized {{podcastlink}}, an ISO/empty date, tags, and the user-fillable status/rating/favorite). Feed-only users can add url: "{{url}}" back; documented. Update the example, the e2e seed, and the test (now exercises a quote-bearing local-file url to prove the frontmatter stays parseable). --- docs/docs/templates.md | 7 ++++--- scripts/provision-obsidian-e2e-vault.mjs | 2 +- src/TemplateEngine.test.ts | 19 ++++++++++------- src/constants.ts | 26 ++++++++++++------------ 4 files changed, 30 insertions(+), 24 deletions(-) diff --git a/docs/docs/templates.md b/docs/docs/templates.md index 982917e..40c2d53 100644 --- a/docs/docs/templates.md +++ b/docs/docs/templates.md @@ -60,7 +60,6 @@ PodNotes ships this default episode note template, which links each episode back --- type: podcastEpisode podcast: "{{podcastlink}}" -url: "{{url}}" date: {{date:YYYY-MM-DD}} tags: - podcastEpisode @@ -74,14 +73,16 @@ favorite: false [Resume in PodNotes]({{episodelink}}) +{{url}} + {{description}} ``` -With the default feed-note path, the frontmatter stays **valid YAML** even for episodes with awkward titles or URLs: +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}}` is a quoted frontmatter scalar (`url: "{{url}}"`). Well-formed feed URLs (and ordinary local-file links) contain no `"`, so the scalar stays valid; the tag value is inserted verbatim, so a URL/file name with a literal `"` is the one case you'd need to adjust. `{{artwork}}` sits in the body as a Markdown image (`![]({{artwork}})`), not in the frontmatter. +- `{{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`). diff --git a/scripts/provision-obsidian-e2e-vault.mjs b/scripts/provision-obsidian-e2e-vault.mjs index f172488..4b3e2f2 100644 --- a/scripts/provision-obsidian-e2e-vault.mjs +++ b/scripts/provision-obsidian-e2e-vault.mjs @@ -57,7 +57,6 @@ export const DEFAULT_PODNOTES_DATA = { "---\n" + "type: podcastEpisode\n" + 'podcast: "{{podcastlink}}"\n' + - 'url: "{{url}}"\n' + "date: {{date:YYYY-MM-DD}}\n" + "tags:\n" + " - podcastEpisode\n" + @@ -68,6 +67,7 @@ export const DEFAULT_PODNOTES_DATA = { "# {{title}}\n\n" + "![]({{artwork}})\n\n" + "[Resume in PodNotes]({{episodelink}})\n\n" + + "{{url}}\n\n" + "{{description}}\n", }, feedNote: { diff --git a/src/TemplateEngine.test.ts b/src/TemplateEngine.test.ts index f4ca60c..b1fb14a 100644 --- a/src/TemplateEngine.test.ts +++ b/src/TemplateEngine.test.ts @@ -190,14 +190,15 @@ describe("default note template renders valid frontmatter (#160)", () => { return (match as RegExpMatchArray)[1]; } - it("keeps frontmatter valid for an awkward title (raw title stays in the body)", () => { - // The title carries quotes/colons that would break a frontmatter scalar; it - // must render only in the body H1. The url is a well-formed feed URL (the - // common case for the default template) and sits in the quoted scalar. + 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: "https://example.com/ep?x=q&y=2", + url: '[[Audio/Talk "A".mp3]]', podcastName: "My Show", }; const rendered = NoteTemplateEngine( @@ -208,19 +209,23 @@ describe("default note template renders valid frontmatter (#160)", () => { const line = (key: string) => frontmatter.split("\n").find((l) => l.startsWith(`${key}:`)); - expect(line("url")).toBe('url: "https://example.com/ep?x=q&y=2"'); // 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 (with quotes/colons) lives only in the body H1. + // 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", () => { diff --git a/src/constants.ts b/src/constants.ts index a1e3560..0e2761a 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -86,23 +86,22 @@ export const DEFAULT_SETTINGS: IPodNotesSettings = { // 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). The values are chosen to render as valid YAML - // for the common case: {{podcastlink}} is a wikilink whose name is sanitized - // (no quotes) and is quoted so its leading "[[" can't be read as a flow - // sequence; {{url}} is a well-formed feed URL (or, for local files, a vault - // link) placed in a quoted scalar; and {{date:YYYY-MM-DD}} is either an ISO - // date or empty (null). Tag values are inserted verbatim (not escaped), so a - // URL/feed-note path containing a literal quote is the one case that would - // need adjusting. The raw {{title}} (which may contain quotes/colons) lives - // in the note body as the H1, where YAML rules don't apply. status/rating/ - // favorite are left for the user to fill and give Bases columns to sort and - // filter on. {{podcastlink}} ties each episode to its feed note (#163). See - // issue #160. + // 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' + - 'url: "{{url}}"\n' + "date: {{date:YYYY-MM-DD}}\n" + "tags:\n" + " - podcastEpisode\n" + @@ -113,6 +112,7 @@ export const DEFAULT_SETTINGS: IPodNotesSettings = { "# {{title}}\n\n" + "![]({{artwork}})\n\n" + "[Resume in PodNotes]({{episodelink}})\n\n" + + "{{url}}\n\n" + "{{description}}\n", },