From 8dc4fa80122712c8d33efb039190cb6515a2704e Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 16 Jun 2026 22:32:15 +0200 Subject: [PATCH 1/2] feat(templates): add episode chapters tag --- docs/docs/templates.md | 2 + src/TemplateEngine.test.ts | 43 ++++++++++ src/TemplateEngine.ts | 59 ++++++++++++- src/createPodcastNote.test.ts | 133 ++++++++++++++++++++++++++++++ src/createPodcastNote.ts | 30 ++++++- src/main.ts | 2 +- src/parser/feedParser.test.ts | 45 ++++++++++ src/utility/fetchChapters.test.ts | 52 ++++++++++++ src/utility/fetchChapters.ts | 47 +++++++---- src/utility/normalizeChapters.ts | 29 +++++++ 10 files changed, 420 insertions(+), 22 deletions(-) create mode 100644 src/createPodcastNote.test.ts create mode 100644 src/utility/fetchChapters.test.ts create mode 100644 src/utility/normalizeChapters.ts 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..b4d1436a 100644 --- a/src/TemplateEngine.test.ts +++ b/src/TemplateEngine.test.ts @@ -523,6 +523,49 @@ 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 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..39f197e6 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,40 @@ 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) => + `- ${formatDuration(chapter.startTime)} ${escapeMarkdownText(formatChapterTitle(chapter.title))}`, + ); + + 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 +193,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..0620cb28 --- /dev/null +++ b/src/utility/fetchChapters.test.ts @@ -0,0 +1,52 @@ +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: 0 }, + { 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: 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..03c9102f --- /dev/null +++ b/src/utility/normalizeChapters.ts @@ -0,0 +1,29 @@ +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: chapter.title.slice(0, MAX_CHAPTER_TITLE_CHARS), + })) + .filter((chapter) => chapter.title.trim().length > 0) + .sort((a, b) => a.startTime - b.startTime) + .slice(0, MAX_VISIBLE_CHAPTERS); +} + +function isVisibleChapter(chapter: unknown): chapter is Chapter { + if (!chapter || typeof chapter !== "object") { + return false; + } + + const candidate = chapter as Partial; + return ( + candidate.toc !== false && + typeof candidate.title === "string" && + Number.isFinite(candidate.startTime) + ); +} From dd086c1793399144f908b1580bc5bae1257e92df Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Wed, 17 Jun 2026 08:20:45 +0200 Subject: [PATCH 2/2] codex: address PR review feedback (#207) --- src/TemplateEngine.test.ts | 11 +++++++++++ src/TemplateEngine.ts | 11 ++++++----- src/utility/fetchChapters.test.ts | 3 ++- src/utility/normalizeChapters.ts | 12 ++++++++---- 4 files changed, 27 insertions(+), 10 deletions(-) diff --git a/src/TemplateEngine.test.ts b/src/TemplateEngine.test.ts index b4d1436a..62c3b3d3 100644 --- a/src/TemplateEngine.test.ts +++ b/src/TemplateEngine.test.ts @@ -561,6 +561,17 @@ describe("{{chapters}} tag (#47)", () => { ); }); + 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("[]"); }); diff --git a/src/TemplateEngine.ts b/src/TemplateEngine.ts index 39f197e6..27a3341d 100644 --- a/src/TemplateEngine.ts +++ b/src/TemplateEngine.ts @@ -124,11 +124,12 @@ function formatTemplateChapters( chapters: Chapter[] | undefined, prependToLines?: string, ): string { - const lines = normalizeChapters(chapters ?? []) - .map( - (chapter) => - `- ${formatDuration(chapter.startTime)} ${escapeMarkdownText(formatChapterTitle(chapter.title))}`, - ); + 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"); diff --git a/src/utility/fetchChapters.test.ts b/src/utility/fetchChapters.test.ts index 0620cb28..0fed5678 100644 --- a/src/utility/fetchChapters.test.ts +++ b/src/utility/fetchChapters.test.ts @@ -18,7 +18,7 @@ describe("fetchChapters", () => { version: "1.2.0", chapters: [ { startTime: 65, title: "Deep Dive" }, - { startTime: 0 }, + { startTime: 35 }, { startTime: 30, title: null }, { startTime: Number.NaN, title: "Bad time" }, { startTime: 10, title: "Hidden", toc: false }, @@ -31,6 +31,7 @@ describe("fetchChapters", () => { fetchChapters("https://example.com/chapters.json"), ).resolves.toEqual([ { startTime: 0, title: "Intro" }, + { startTime: 35, title: "" }, { startTime: 65, title: "Deep Dive" }, ]); }); diff --git a/src/utility/normalizeChapters.ts b/src/utility/normalizeChapters.ts index 03c9102f..7dd21a84 100644 --- a/src/utility/normalizeChapters.ts +++ b/src/utility/normalizeChapters.ts @@ -8,14 +8,18 @@ export function normalizeChapters(chapters: readonly unknown[]): Chapter[] { .filter(isVisibleChapter) .map((chapter) => ({ ...chapter, - title: chapter.title.slice(0, MAX_CHAPTER_TITLE_CHARS), + title: + typeof chapter.title === "string" + ? chapter.title.slice(0, MAX_CHAPTER_TITLE_CHARS) + : "", })) - .filter((chapter) => chapter.title.trim().length > 0) .sort((a, b) => a.startTime - b.startTime) .slice(0, MAX_VISIBLE_CHAPTERS); } -function isVisibleChapter(chapter: unknown): chapter is Chapter { +function isVisibleChapter( + chapter: unknown, +): chapter is Omit & { title?: string } { if (!chapter || typeof chapter !== "object") { return false; } @@ -23,7 +27,7 @@ function isVisibleChapter(chapter: unknown): chapter is Chapter { const candidate = chapter as Partial; return ( candidate.toc !== false && - typeof candidate.title === "string" && + (candidate.title === undefined || typeof candidate.title === "string") && Number.isFinite(candidate.startTime) ); }