diff --git a/docs/docs/templates.md b/docs/docs/templates.md index 40c2d53c..a1e0f839 100644 --- a/docs/docs/templates.md +++ b/docs/docs/templates.md @@ -42,6 +42,8 @@ This template will be used to create the note text. You can use the following sy - With no argument it renders a human clock: `4:05` (under an hour) or `1:02:03` (an hour or more). - `{{duration:minutes}}` → total whole minutes (e.g. `62`); `{{duration:seconds}}` → total seconds (e.g. `3723`). - Any other argument is treated as a clock format using the tokens `H`/`HH`, `h`/`hh`, `m`/`mm`, `s`/`ss`, `A`/`a` — e.g. `{{duration:HH:mm:ss}}` → `01:02:03`. (Unlike `{{date}}`, `[literal]` bracket escaping is not supported here.) +- `{{chapters}}`: A Markdown list of the episode's chapters, using Podcasting 2.0 `` data when the feed provides an HTTP(S) chapters URL. Each line renders as `- 0:00 Chapter title`, sorted by start time. Hidden chapters (`toc: false`) are skipped, and chapter titles are inserted as escaped plain text. Empty when the episode has no chapters URL, the chapters request fails, or the chapters file contains no visible chapters. + - You can use `{{chapters:> }}` to prepend each rendered chapter line with `>` (to put the chapter list in a blockquote). - `{{artwork}}`: The URL of the podcast artwork. If no artwork is found, an empty string will be used. - `{{episodelink}}`: A clickable `obsidian://podnotes` link that reopens this episode in the PodNotes player and **resumes from where you left off** (or starts at the beginning if you have never played it, or have already finished it). The resume position is resolved when you click the link — not baked in when the note is created — so the link always jumps to your latest position. Put it in your template to get a "back to the episode" link on every note, e.g. `[▶️ Resume in PodNotes]({{episodelink}})`. The value is the bare URL, so wrap it in your own Markdown link text. It is empty when the episode has no feed URL or local file path to address it by. See [issue #35](https://github.com/chhoumann/PodNotes/issues/35). diff --git a/src/TemplateEngine.test.ts b/src/TemplateEngine.test.ts index c0b9f70a..62c3b3d3 100644 --- a/src/TemplateEngine.test.ts +++ b/src/TemplateEngine.test.ts @@ -523,6 +523,60 @@ describe("{{duration}} tag (#88)", () => { }); }); +describe("{{chapters}} tag (#47)", () => { + const chapters = [ + { startTime: 65, title: "Deep Dive" }, + { startTime: 0, title: " Intro\nSegment " }, + { startTime: 90, title: "Sponsor", toc: false }, + ]; + + beforeEach(() => { + plugin.set({ settings: { feedNote: { path: "" }, savedFeeds: {} } } as never); + }); + + it("renders visible chapters as a sorted Markdown list", () => { + expect(NoteTemplateEngine("{{chapters}}", demoEpisode, { chapters })).toBe( + "- 0:00 Intro Segment\n- 1:05 Deep Dive", + ); + }); + + it("can prepend each rendered chapter line", () => { + expect(NoteTemplateEngine("{{chapters:> }}", demoEpisode, { chapters })).toBe( + "> - 0:00 Intro Segment\n> - 1:05 Deep Dive", + ); + }); + + it("escapes chapter titles so feed-controlled text cannot inject Markdown", () => { + expect( + NoteTemplateEngine("{{chapters}}", demoEpisode, { + chapters: [ + { + startTime: 0, + title: "[click](obsidian://podnotes) ![pixel](https://example.com/pixel)", + }, + ], + }), + ).toBe( + "- 0:00 \\[click\\]\\(obsidian://podnotes\\) \\!\\[pixel\\]\\(https://example\\.com/pixel\\)", + ); + }); + + it("renders visible untitled chapters as timestamp-only entries", () => { + expect( + NoteTemplateEngine("{{chapters}}", demoEpisode, { + chapters: [ + { startTime: 35, title: "" }, + { startTime: 65, title: "Deep Dive" }, + ], + }), + ).toBe("- 0:35\n- 1:05 Deep Dive"); + }); + + it("renders empty when no chapters were fetched", () => { + expect(NoteTemplateEngine("[{{chapters}}]", demoEpisode)).toBe("[]"); + }); +}); + describe("TranscriptTemplateEngine new tags (#75/#34/#88)", () => { const fixture: Episode = { ...demoEpisode, episodeNumber: 42, duration: 3723 }; diff --git a/src/TemplateEngine.ts b/src/TemplateEngine.ts index 0b40f497..27a3341d 100644 --- a/src/TemplateEngine.ts +++ b/src/TemplateEngine.ts @@ -13,6 +13,8 @@ import buildEpisodeResumeLink from "./utility/buildEpisodeResumeLink"; import addExtension from "./utility/addExtension"; import { enforceMaxPathLength } from "./utility/enforceMaxPathLength"; import type { PodcastSegmentTimes } from "./utility/podcastSegment"; +import type { Chapter } from "./types/Chapter"; +import { normalizeChapters } from "./utility/normalizeChapters"; // Each tag is either a literal string or a function taking at most one argument // (the raw text after the leading colon, e.g. the format in {{date:YYYY}}). The @@ -26,6 +28,21 @@ interface Tags { type AddTagFn = (tag: Lowercase, value: TagValue) => void; type ReplacerFn = (template: string) => string; +const TEMPLATE_TAG_REGEX = /\{\{(.*?)(:\s*?.+?)?\}\}/g; + +export interface NoteTemplateContext { + chapters?: Chapter[]; +} + +export function templateHasTag( + template: string, + tag: Lowercase, +): boolean { + return Array.from(template.matchAll(TEMPLATE_TAG_REGEX)).some( + ([, tagId]) => tagId.toLowerCase() === tag, + ); +} + function useTemplateEngine(): Readonly<[ReplacerFn, AddTagFn]> { const tags: Tags = {}; @@ -38,7 +55,7 @@ function useTemplateEngine(): Readonly<[ReplacerFn, AddTagFn]> { function replacer(template: string): string { return template.replace( - /\{\{(.*?)(:\s*?.+?)?\}\}/g, + TEMPLATE_TAG_REGEX, (match: string, tagId: string, params: string) => { const tagValue = tags[tagId.toLowerCase()]; if (tagValue === null || tagValue === undefined) { @@ -91,7 +108,41 @@ function resolveEpisodeNumber(episode: Episode): number | undefined { return episode.episodeNumber ?? parseEpisodeNumberFromTitle(episode.title); } -export function NoteTemplateEngine(template: string, episode: Episode) { +function formatChapterTitle(title: string): string { + return title.replace(/\s+/g, " ").trim(); +} + +function escapeMarkdownText(text: string): string { + return text + .replace(/\\/g, "\\\\") + .replace(//g, ">") + .replace(/([`*_{}[\]()#+.!|-])/g, "\\$1"); +} + +function formatTemplateChapters( + chapters: Chapter[] | undefined, + prependToLines?: string, +): string { + const lines = normalizeChapters(chapters ?? []).map((chapter) => { + const title = formatChapterTitle(chapter.title); + const escapedTitle = title ? ` ${escapeMarkdownText(title)}` : ""; + + return `- ${formatDuration(chapter.startTime)}${escapedTitle}`; + }); + + if (!prependToLines) { + return lines.join("\n"); + } + + return lines.map((line) => `${prependToLines}${line}`).join("\n"); +} + +export function NoteTemplateEngine( + template: string, + episode: Episode, + context: NoteTemplateContext = {}, +) { const [replacer, addTag] = useTemplateEngine(); addTag("title", episode.title); @@ -143,6 +194,11 @@ export function NoteTemplateEngine(template: string, episode: Episode) { ? formatDuration(episode.duration, format) : "", ); + // Podcasting 2.0 chapters, fetched before note creation when the template + // asks for them. Empty when the feed has no chapters URL or fetching fails. + addTag("chapters", (prependToLines?: string) => + formatTemplateChapters(context.chapters, prependToLines), + ); addTag( "podcast", replaceIllegalFileNameCharactersInString(episode.podcastName), diff --git a/src/createPodcastNote.test.ts b/src/createPodcastNote.test.ts new file mode 100644 index 00000000..16648870 --- /dev/null +++ b/src/createPodcastNote.test.ts @@ -0,0 +1,133 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TFile } from "obsidian"; +import createPodcastNote from "./createPodcastNote"; +import { plugin } from "./store"; +import type { Episode } from "./types/Episode"; + +const mockFetchChapters = vi.hoisted(() => vi.fn()); + +vi.mock("./utility/fetchChapters", () => ({ + fetchChapters: mockFetchChapters, +})); + +const episode: Episode = { + title: "Chaptered Episode", + streamUrl: "https://example.com/episode.mp3", + url: "https://example.com/episode", + description: "", + content: "", + podcastName: "Chaptered Show", + feedUrl: "https://example.com/feed.xml", + episodeDate: new Date("2024-01-01T00:00:00.000Z"), + chaptersUrl: "https://example.com/chapters.json", +}; + +function bootstrapAppMock(existingFile?: TFile) { + const createdFiles: Array<{ path: string; data: string }> = []; + const leaf = { openFile: vi.fn() }; + const appMock = { + vault: { + getAbstractFileByPath: vi.fn((path: string) => + path === existingFile?.path ? existingFile : null, + ), + createFolder: vi.fn(async () => {}), + create: vi.fn(async (path: string, data: string) => { + const file = { path, data }; + createdFiles.push(file); + return file; + }), + }, + workspace: { + getLeaf: vi.fn(() => leaf), + }, + }; + + (globalThis as { app?: typeof appMock }).app = appMock; + + return { createdFiles, leaf }; +} + +describe("createPodcastNote chapters template support (#47)", () => { + beforeEach(() => { + mockFetchChapters.mockResolvedValue([ + { startTime: 65, title: "Deep Dive" }, + { startTime: 0, title: "Intro" }, + ]); + }); + + afterEach(() => { + mockFetchChapters.mockReset(); + plugin.set(undefined as never); + delete (globalThis as Record).app; + }); + + it("fetches and renders chapters only when the note template asks for them", async () => { + const { createdFiles, leaf } = bootstrapAppMock(); + plugin.set({ + settings: { + note: { + path: "PodNotes/{{title}}", + template: "# {{title}}\n\n{{chapters}}", + }, + feedNote: { path: "" }, + savedFeeds: {}, + }, + } as never); + + await createPodcastNote(episode); + + expect(mockFetchChapters).toHaveBeenCalledWith( + "https://example.com/chapters.json", + ); + expect(createdFiles[0]).toMatchObject({ + path: "PodNotes/Chaptered Episode.md", + data: "# Chaptered Episode\n\n- 0:00 Intro\n- 1:05 Deep Dive", + }); + expect(leaf.openFile).toHaveBeenCalledWith( + expect.objectContaining({ path: "PodNotes/Chaptered Episode.md" }), + ); + }); + + it("opens an existing note without fetching chapters", async () => { + const existingFile = Object.assign(Object.create(TFile.prototype), { + path: "PodNotes/Chaptered Episode.md", + }) as TFile; + const { createdFiles, leaf } = bootstrapAppMock(existingFile); + plugin.set({ + settings: { + note: { + path: "PodNotes/{{title}}", + template: "# {{title}}\n\n{{chapters}}", + }, + }, + } as never); + + await createPodcastNote(episode); + + expect(mockFetchChapters).not.toHaveBeenCalled(); + expect(createdFiles).toEqual([]); + expect(leaf.openFile).toHaveBeenCalledWith(existingFile); + }); + + it("does not fetch chapters for templates that do not use the tag", async () => { + const { createdFiles } = bootstrapAppMock(); + plugin.set({ + settings: { + note: { + path: "PodNotes/{{title}}", + template: "# {{title}}", + }, + feedNote: { path: "" }, + savedFeeds: {}, + }, + } as never); + + await createPodcastNote(episode); + + expect(mockFetchChapters).not.toHaveBeenCalled(); + expect(createdFiles[0]).toMatchObject({ + path: "PodNotes/Chaptered Episode.md", + data: "# Chaptered Episode", + }); + }); +}); diff --git a/src/createPodcastNote.ts b/src/createPodcastNote.ts index 19b46f4e..371876e3 100644 --- a/src/createPodcastNote.ts +++ b/src/createPodcastNote.ts @@ -1,11 +1,17 @@ import { Notice, TFile } from "obsidian"; -import { FilePathTemplateEngine, NoteTemplateEngine } from "./TemplateEngine"; +import { + FilePathTemplateEngine, + NoteTemplateEngine, + templateHasTag, +} from "./TemplateEngine"; import type { Episode } from "./types/Episode"; +import type { Chapter } from "./types/Chapter"; import { get } from "svelte/store"; import { plugin } from "./store"; import addExtension from "./utility/addExtension"; import { enforceMaxPathLength } from "./utility/enforceMaxPathLength"; import { ensureFolderExists } from "./utility/ensureFolderExists"; +import { fetchChapters } from "./utility/fetchChapters"; /** * Resolve the on-disk path of an episode's note from the configured template. @@ -40,16 +46,36 @@ export default async function createPodcastNote( export async function createPodcastNoteFileIfNotExists( episode: Episode, ): Promise { + const existingFile = getPodcastNote(episode); + if (existingFile) { + new Notice(`Note for "${episode.title}" already exists`); + return existingFile; + } + const pluginInstance = get(plugin); const filePathDotMd = getPodcastNotePath(episode); + const template = pluginInstance.settings.note.template; + const chapters = await getTemplateChapters(template, episode); const content = NoteTemplateEngine( - pluginInstance.settings.note.template, + template, episode, + { chapters }, ); return await createFileIfNotExists(filePathDotMd, content, episode); } +async function getTemplateChapters( + template: string, + episode: Episode, +): Promise { + if (!episode.chaptersUrl || !templateHasTag(template, "chapters")) { + return undefined; + } + + return await fetchChapters(episode.chaptersUrl); +} + export function getPodcastNote(episode: Episode): TFile | null { const filePathDotMd = getPodcastNotePath(episode); const file = app.vault.getAbstractFileByPath(filePathDotMd); diff --git a/src/main.ts b/src/main.ts index 2e3f174e..3771e5f7 100644 --- a/src/main.ts +++ b/src/main.ts @@ -378,7 +378,7 @@ export default class PodNotes extends Plugin implements IPodNotes { ); } - createPodcastNote(this.api.podcast); + void createPodcastNote(this.api.podcast); }, }); diff --git a/src/parser/feedParser.test.ts b/src/parser/feedParser.test.ts index 0828ce0b..439a9710 100644 --- a/src/parser/feedParser.test.ts +++ b/src/parser/feedParser.test.ts @@ -176,6 +176,25 @@ const rssFeedWithEpisodeNumberAndDuration = ` `; +const rssFeedWithPodcastChapters = ` + + + Chaptered Podcast + https://example.com + + Chaptered Episode + + Mon, 01 Jan 2024 00:00:00 GMT + + + + Episode Without Chapters + + Tue, 02 Jan 2024 00:00:00 GMT + + +`; + describe("FeedParser", () => { beforeEach(() => { vi.clearAllMocks(); @@ -369,6 +388,32 @@ describe("FeedParser", () => { expect(episode.feedUrl).toBe("https://example.com/feed.xml"); }); + test("parses Podcasting 2.0 chapter URLs from episodes (#47)", async () => { + mockRequestWithTimeout + .mockResolvedValueOnce({ + text: rssFeedWithPodcastChapters, + status: 200, + headers: {}, + arrayBuffer: new ArrayBuffer(0), + json: {}, + }) + .mockResolvedValueOnce({ + text: rssFeedWithPodcastChapters, + status: 200, + headers: {}, + arrayBuffer: new ArrayBuffer(0), + json: {}, + }); + + const parser = new FeedParser(); + const episodes = await parser.getEpisodes("https://example.com/feed.xml"); + + expect(episodes[0].chaptersUrl).toBe( + "https://example.com/chapters.json", + ); + expect(episodes[1].chaptersUrl).toBeUndefined(); + }); + test("filters out invalid episodes missing required fields", async () => { mockRequestWithTimeout .mockResolvedValueOnce({ diff --git a/src/utility/fetchChapters.test.ts b/src/utility/fetchChapters.test.ts new file mode 100644 index 00000000..0fed5678 --- /dev/null +++ b/src/utility/fetchChapters.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fetchChapters } from "./fetchChapters"; + +const mockRequestWithTimeout = vi.hoisted(() => vi.fn()); + +vi.mock("./networkRequest", () => ({ + requestWithTimeout: mockRequestWithTimeout, +})); + +describe("fetchChapters", () => { + beforeEach(() => { + mockRequestWithTimeout.mockReset(); + }); + + it("normalizes malformed chapter entries instead of returning raw JSON", async () => { + mockRequestWithTimeout.mockResolvedValue({ + text: JSON.stringify({ + version: "1.2.0", + chapters: [ + { startTime: 65, title: "Deep Dive" }, + { startTime: 35 }, + { startTime: 30, title: null }, + { startTime: Number.NaN, title: "Bad time" }, + { startTime: 10, title: "Hidden", toc: false }, + { startTime: 0, title: "Intro" }, + ], + }), + }); + + await expect( + fetchChapters("https://example.com/chapters.json"), + ).resolves.toEqual([ + { startTime: 0, title: "Intro" }, + { startTime: 35, title: "" }, + { startTime: 65, title: "Deep Dive" }, + ]); + }); + + it("ignores unsupported chapter URL protocols", async () => { + await expect(fetchChapters("file:///tmp/chapters.json")).resolves.toEqual([]); + expect(mockRequestWithTimeout).not.toHaveBeenCalled(); + }); + + it("ignores oversized chapter payloads", async () => { + mockRequestWithTimeout.mockResolvedValue({ + text: " ".repeat(1_000_001), + }); + + await expect( + fetchChapters("https://example.com/chapters.json"), + ).resolves.toEqual([]); + }); +}); diff --git a/src/utility/fetchChapters.ts b/src/utility/fetchChapters.ts index 51f4ab0b..b55319b0 100644 --- a/src/utility/fetchChapters.ts +++ b/src/utility/fetchChapters.ts @@ -1,29 +1,42 @@ import type { Chapter, ChaptersData } from "src/types/Chapter"; import { requestWithTimeout } from "./networkRequest"; +import { normalizeChapters } from "./normalizeChapters"; + +const MAX_CHAPTERS_RESPONSE_CHARS = 1_000_000; /** * Fetches and parses podcast chapters from a chapters URL. * Returns an empty array if the URL is invalid or the request fails. */ export async function fetchChapters(chaptersUrl: string): Promise { - if (!chaptersUrl) { - return []; - } + if (!chaptersUrl || !isSupportedChaptersUrl(chaptersUrl)) { + return []; + } + + try { + const response = await requestWithTimeout(chaptersUrl, { timeoutMs: 10000 }); + if (response.text.length > MAX_CHAPTERS_RESPONSE_CHARS) { + return []; + } - try { - const response = await requestWithTimeout(chaptersUrl, { timeoutMs: 10000 }); - const data: ChaptersData = JSON.parse(response.text); + const data: ChaptersData = JSON.parse(response.text); - if (!data.chapters || !Array.isArray(data.chapters)) { - return []; - } + if (!data.chapters || !Array.isArray(data.chapters)) { + return []; + } + + return normalizeChapters(data.chapters); + } catch { + console.warn("Failed to fetch chapters."); + return []; + } +} - // Filter out hidden chapters (toc === false) and sort by start time - return data.chapters - .filter((chapter) => chapter.toc !== false) - .sort((a, b) => a.startTime - b.startTime); - } catch (error) { - console.warn("Failed to fetch chapters:", error); - return []; - } +function isSupportedChaptersUrl(chaptersUrl: string): boolean { + try { + const url = new URL(chaptersUrl); + return url.protocol === "https:" || url.protocol === "http:"; + } catch { + return false; + } } diff --git a/src/utility/normalizeChapters.ts b/src/utility/normalizeChapters.ts new file mode 100644 index 00000000..7dd21a84 --- /dev/null +++ b/src/utility/normalizeChapters.ts @@ -0,0 +1,33 @@ +import type { Chapter } from "src/types/Chapter"; + +const MAX_VISIBLE_CHAPTERS = 500; +const MAX_CHAPTER_TITLE_CHARS = 500; + +export function normalizeChapters(chapters: readonly unknown[]): Chapter[] { + return chapters + .filter(isVisibleChapter) + .map((chapter) => ({ + ...chapter, + title: + typeof chapter.title === "string" + ? chapter.title.slice(0, MAX_CHAPTER_TITLE_CHARS) + : "", + })) + .sort((a, b) => a.startTime - b.startTime) + .slice(0, MAX_VISIBLE_CHAPTERS); +} + +function isVisibleChapter( + chapter: unknown, +): chapter is Omit & { title?: string } { + if (!chapter || typeof chapter !== "object") { + return false; + } + + const candidate = chapter as Partial; + return ( + candidate.toc !== false && + (candidate.title === undefined || typeof candidate.title === "string") && + Number.isFinite(candidate.startTime) + ); +}