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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/docs/templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<podcast:chapters>` 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).

Expand Down
54 changes: 54 additions & 0 deletions src/TemplateEngine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down
60 changes: 58 additions & 2 deletions src/TemplateEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -26,6 +28,21 @@ interface Tags {
type AddTagFn = (tag: Lowercase<string>, 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<string>,
): boolean {
return Array.from(template.matchAll(TEMPLATE_TAG_REGEX)).some(
([, tagId]) => tagId.toLowerCase() === tag,
);
}

function useTemplateEngine(): Readonly<[ReplacerFn, AddTagFn]> {
const tags: Tags = {};

Expand All @@ -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) {
Expand Down Expand Up @@ -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, "&lt;")
.replace(/>/g, "&gt;")
.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);
Expand Down Expand Up @@ -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),
Expand Down
133 changes: 133 additions & 0 deletions src/createPodcastNote.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>).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",
});
});
});
30 changes: 28 additions & 2 deletions src/createPodcastNote.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -40,16 +46,36 @@ export default async function createPodcastNote(
export async function createPodcastNoteFileIfNotExists(
episode: Episode,
): Promise<TFile> {
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<Chapter[] | undefined> {
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);
Expand Down
2 changes: 1 addition & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,7 +378,7 @@ export default class PodNotes extends Plugin implements IPodNotes {
);
}

createPodcastNote(this.api.podcast);
void createPodcastNote(this.api.podcast);
},
});

Expand Down
Loading
Loading