Skip to content

Commit ec573eb

Browse files
authored
feat(templates): add {{currentDate}}, {{episodeNumber}}, {{duration}} template variables (#189)
* feat(templates): add {{currentDate}}, {{episodeNumber}}, {{duration}} tags - {{currentDate}}: note-creation date, distinct from episode {{date}} (#75) - {{episodeNumber}}: from <itunes:episode>, best-effort title fallback (#34) - {{duration}}: from <itunes:duration>, clock/minutes/seconds formats (#88) Parse episodeNumber/duration in feedParser; add parse/format utilities and unit tests for the new tags, parser, and utilities. * fix(templates): harden new template vars after review - parseEpisodeNumber: fix ReDoS (remove adjacent \s* quantifiers, bound title prefix); reject decimal-title misreads (10.5) and precision-losing itunes:episode - parseDuration: reject non-finite/implausible durations (no Infinity), drop dead length<2 guard, pin non-normalized colon behavior - TemplateEngine: stop comma-splitting tag args so formats like {{currentDate:MMMM D, YYYY}} render; extract formatEpisodeNumber to a utility - FeedCacheService: bump cache key v1->v2 so new fields populate after upgrade - tests: cover DownloadPath/Transcript engines, duration=0, cache round-trip, ReDoS regression, numeric guards - docs: correct duration format support (no bracket escaping), path-tag safety, episodeNumber fallback wording * fix(devx): sync E2E provision-vault seed with DEFAULT_SETTINGS DEFAULT_PODNOTES_DATA had drifted from DEFAULT_SETTINGS (missing autoQueue and feedNote, stale download.path default from #108/#163/#186), which failed the drift-guard test in CI and seeded an outdated data.json into E2E vaults. Bring the seed back in sync so the guard test passes and provisioned vaults load with current defaults. * fix(templates): address second-round review findings - parseEpisodeNumber: also reject decimals after a label marker (Ep 10.5 / #10.5 / E10.5 no longer parse as 10) - TemplateEngine: resolve {{episodeNumber}} via a title fallback at render so persisted/restored episodes (e.g. currentEpisode) still populate it; tighten TagValue to the single-argument contract - FeedCacheService: actively delete the superseded v1 cache key on load so a stale blob can't orphan or exhaust the localStorage quota - tests: labeled-decimal cases, plausible-duration boundary, formatDuration(0) and uppercase keyword, {{duration}} unreplaced in download paths, v1 cleanup - docs: note {{duration}} needs a refetch for pre-upgrade current episodes * fix(templates): address third-round review (cache clear path, test/doc polish) - FeedCacheService: also delete legacy keys in clearFeedCache (a clear before first load would otherwise leave v1 behind); share a removeLegacyCaches helper - tests: pin episodeNumber=0 at the engine level, {{duration}} unreplaced in file-path templates, formatDuration sub-hour boundary (3599), v1 clear path - docs: fuller episode-number title examples, accurate {{duration}} path-engine exclusion rationale, and a note that feed notes use {{date}} not {{currentDate}}
1 parent a8b7a4a commit ec573eb

17 files changed

Lines changed: 752 additions & 14 deletions

docs/docs/templates.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@ This template will be used to create the file path for the note. You can use the
1212
- `{{podcast}}`: The name of the podcast. You can add a format, e.g. `{{podcast:_}}` to replace spaces with underscores.
1313
- `{{date}}`: The publish date of the podcast episode. Please note that this is not always available, and will be empty if it is not.
1414
- You can use `{{date:format}}` to specify a custom [Moment.js](https://momentjs.com) format. E.g. `{{date:YYYY-MM-DD}}`.
15+
- `{{currentDate}}`: The current date (when the note is created), as opposed to `{{date}}` (the episode publish date). Supports the same format argument, e.g. `{{currentDate:YYYY-MM-DD}}`.
16+
- `{{episodeNumber}}`: The episode number (see the [note template](#note-template) section for how it is sourced). Use an all-zeros width to zero-pad for sortable file names, e.g. `{{episodeNumber:000}}``042`. Empty when the number is unknown.
1517

16-
Both syntax items will be formatted such that it is safe to use in a file path.
17-
This means the following characters will be removed: `\ , # % & / { } * < > $ ' " : @ ‣ | ?`.
18+
`{{title}}` and `{{podcast}}` are sanitized so they are safe to use in a file path: the following characters are removed: `\ , # % & / { } * < > $ ' " : @ ‣ | ?`. `{{episodeNumber}}` is always file-safe. `{{date}}` and `{{currentDate}}` are inserted as-is, so when using them in a path, avoid format strings that contain path-illegal characters (e.g. `{{currentDate:HH:mm}}`).
1819

1920
## Note template
2021
This template will be used to create the note text. You can use the following syntax:
@@ -30,6 +31,14 @@ This template will be used to create the note text. You can use the following sy
3031
- `{{url}}`: The URL of the podcast episode.
3132
- `{{date}}`: The publish date of the podcast episode.
3233
- You can use `{{date:format}}` to specify a custom [Moment.js](https://momentjs.com) format. E.g. `{{date:YYYY-MM-DD}}`.
34+
- `{{currentDate}}`: The current date — i.e. when the note is created — as opposed to `{{date}}`, which is the episode's publish date. Useful for a "captured on" metadata field.
35+
- Supports the same format argument, e.g. `{{currentDate:YYYY-MM-DD}}`.
36+
- `{{episodeNumber}}`: The episode number. PodNotes uses the feed's `<itunes:episode>` tag when present. If it is missing or not a number, PodNotes makes a **best-effort** guess from the start of the episode title — a leading marker (`#12 ...`, `Ep 12 ...`, `Ep. 12 ...`, `Ep #12 ...`, `Episode 12 ...`, `E12 ...`) or a leading number followed by a separator (`12: ...`, `12 - ...`, `12. ...`, `12) ...`). This guess can be wrong for titles that simply begin with an unrelated number (e.g. `2024: A Year in Review`), so for feeds without `<itunes:episode>` treat it as approximate. The tag is empty when no number can be determined.
37+
- You can zero-pad with an all-zeros width, e.g. `{{episodeNumber:000}}` → `042` (handy for sortable file names). Any other argument is ignored and the bare number is returned.
38+
- `{{duration}}`: The episode's duration, from the feed's `<itunes:duration>` tag. Empty when the feed doesn't provide one. Not available in file-path/download-path templates — even though `{{duration:seconds}}`/`{{duration:minutes}}` would be file-safe, the tag is excluded from path templates entirely so the default colon-containing clock output can't accidentally end up in a file name. Note: an episode that was already the *current* episode before you upgraded PodNotes may show an empty duration until you re-open it from its feed (older saved episodes predate this field).
39+
- With no argument it renders a human clock: `4:05` (under an hour) or `1:02:03` (an hour or more).
40+
- `{{duration:minutes}}` → total whole minutes (e.g. `62`); `{{duration:seconds}}` → total seconds (e.g. `3723`).
41+
- 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.)
3342
- `{{artwork}}`: The URL of the podcast artwork. If no artwork is found, an empty string will be used.
3443

3544
### Linking an episode to its podcast (feed) note
@@ -70,7 +79,7 @@ In a feed note, `{{url}}` and `{{artwork}}` describe the **feed** (the note's su
7079
- `{{artwork}}` / `{{feedartwork}}`: The URL of the podcast artwork.
7180
- `{{author}}`: The podcast author (`<itunes:author>`), if present.
7281
- `{{description}}`: The podcast's description. Supports the `{{description:> }}` prepend syntax, like episode notes.
73-
- `{{date}}`: The current date (when the note is created). Supports `{{date:format}}`.
82+
- `{{date}}`: The current date (when the note is created). Supports `{{date:format}}`. (In feed notes, `{{date}}` already returns the current date, so `{{currentDate}}` is not registered here — use `{{date}}`.)
7483

7584
The file path template supports `{{title}}`/`{{podcast}}` (with the optional `{{podcast:_}}` whitespace-replacement format) and `{{date}}`.
7685

scripts/provision-obsidian-e2e-vault.mjs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ export const DEFAULT_PODNOTES_DATA = {
4242
shouldRepeat: false,
4343
episodes: [],
4444
},
45+
autoQueue: true,
4546
playlists: {},
4647
skipBackwardLength: 15,
4748
skipForwardLength: 15,
@@ -53,8 +54,25 @@ export const DEFAULT_PODNOTES_DATA = {
5354
path: "",
5455
template: "",
5556
},
57+
feedNote: {
58+
path: "PodNotes/Podcasts/{{podcast}}.md",
59+
template:
60+
"---\n" +
61+
"type: podcast\n" +
62+
'podcast: "{{podcast}}"\n' +
63+
'image: "{{artwork}}"\n' +
64+
'url: "{{url}}"\n' +
65+
'feedUrl: "{{feedurl}}"\n' +
66+
"tags:\n" +
67+
" - podcast\n" +
68+
"---\n" +
69+
"# {{title}}\n" +
70+
"{{author}}\n\n" +
71+
"![]({{artwork}})\n\n" +
72+
"{{description}}\n",
73+
},
5674
download: {
57-
path: "",
75+
path: "PodNotes/{{podcast}}/{{title}}",
5876
},
5977
downloadedEpisodes: {},
6078
localFiles: {

src/TemplateEngine.test.ts

Lines changed: 155 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
import { describe, expect, it } from "vitest";
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22
import {
33
DownloadPathTemplateEngine,
44
FeedFilePathTemplateEngine,
55
FeedNoteTemplateEngine,
6+
FilePathTemplateEngine,
67
NoteTemplateEngine,
8+
TranscriptTemplateEngine,
79
getFeedNoteWikilink,
810
} from "./TemplateEngine";
911
import type { Episode } from "./types/Episode";
@@ -222,3 +224,155 @@ describe("getFeedNoteWikilink (#163)", () => {
222224
expect(getFeedNoteWikilink("My Show: A Podcast")).toBe("[[My Show A Podcast]]");
223225
});
224226
});
227+
228+
describe("{{currentDate}} tag (#75)", () => {
229+
beforeEach(() => {
230+
plugin.set({ settings: { feedNote: { path: "" }, savedFeeds: {} } } as never);
231+
vi.useFakeTimers();
232+
// A creation date deliberately different from the episode publish date.
233+
vi.setSystemTime(new Date("2026-06-15T08:30:00"));
234+
});
235+
236+
afterEach(() => {
237+
vi.useRealTimers();
238+
});
239+
240+
it("renders today's date, defaulting to YYYY-MM-DD", () => {
241+
expect(NoteTemplateEngine("{{currentDate}}", demoEpisode)).toBe("2026-06-15");
242+
});
243+
244+
it("supports a Moment.js format and is distinct from the episode {{date}}", () => {
245+
expect(
246+
NoteTemplateEngine("{{currentDate:YYYY}} vs {{date:YYYY}}", demoEpisode),
247+
).toBe("2026 vs 2024");
248+
});
249+
250+
it("supports a format containing commas (not truncated by the engine)", () => {
251+
expect(
252+
NoteTemplateEngine("{{currentDate:MMMM D, YYYY}}", demoEpisode),
253+
).toBe("June 15, 2026");
254+
});
255+
256+
it("is available in file-path and download-path templates", () => {
257+
expect(FilePathTemplateEngine("{{currentDate}}", demoEpisode)).toBe(
258+
"2026-06-15",
259+
);
260+
expect(DownloadPathTemplateEngine("{{currentDate}}", demoEpisode)).toBe(
261+
"2026-06-15",
262+
);
263+
});
264+
});
265+
266+
describe("{{episodeNumber}} tag (#34)", () => {
267+
const numbered: Episode = { ...demoEpisode, episodeNumber: 42 };
268+
269+
beforeEach(() => {
270+
plugin.set({ settings: { feedNote: { path: "" }, savedFeeds: {} } } as never);
271+
});
272+
273+
it("renders the episode number", () => {
274+
expect(NoteTemplateEngine("{{episodeNumber}}", numbered)).toBe("42");
275+
});
276+
277+
it("zero-pads when given an all-zeros width", () => {
278+
expect(NoteTemplateEngine("{{episodeNumber:000}}", numbered)).toBe("042");
279+
});
280+
281+
it("renders a stored episode number of 0 (not treated as absent)", () => {
282+
const ep0: Episode = { ...demoEpisode, episodeNumber: 0 };
283+
expect(NoteTemplateEngine("{{episodeNumber}}", ep0)).toBe("0");
284+
});
285+
286+
it("falls back to the title when no number is stored (e.g. persisted episodes)", () => {
287+
const titleOnly: Episode = { ...demoEpisode, title: "#7 Lucky Seven" };
288+
expect(NoteTemplateEngine("{{episodeNumber}}", titleOnly)).toBe("7");
289+
});
290+
291+
it("renders empty when neither the field nor the title has a number", () => {
292+
const noNumber: Episode = { ...demoEpisode, title: "A Show With No Number" };
293+
expect(NoteTemplateEngine("{{episodeNumber}}", noNumber)).toBe("");
294+
});
295+
296+
it("is available (and file-safe) in file-path and download-path templates", () => {
297+
expect(
298+
FilePathTemplateEngine("{{episodeNumber:000}} {{title}}", numbered),
299+
).toBe("042 Episode 1");
300+
expect(
301+
DownloadPathTemplateEngine("{{episodeNumber:000}} {{title}}", numbered),
302+
).toBe("042 Episode 1");
303+
});
304+
});
305+
306+
describe("{{duration}} tag (#88)", () => {
307+
// 1h 02m 03s.
308+
const withDuration: Episode = { ...demoEpisode, duration: 3723 };
309+
310+
beforeEach(() => {
311+
plugin.set({ settings: { feedNote: { path: "" }, savedFeeds: {} } } as never);
312+
});
313+
314+
it("renders a human clock by default", () => {
315+
expect(NoteTemplateEngine("{{duration}}", withDuration)).toBe("1:02:03");
316+
});
317+
318+
it("supports the minutes and seconds keywords", () => {
319+
expect(NoteTemplateEngine("{{duration:minutes}}", withDuration)).toBe("62");
320+
expect(NoteTemplateEngine("{{duration:seconds}}", withDuration)).toBe("3723");
321+
});
322+
323+
it("supports a Moment.js clock format", () => {
324+
expect(NoteTemplateEngine("{{duration:HH:mm:ss}}", withDuration)).toBe(
325+
"01:02:03",
326+
);
327+
});
328+
329+
it("renders a zero duration as 0:00 (not empty)", () => {
330+
const zero: Episode = { ...demoEpisode, duration: 0 };
331+
expect(NoteTemplateEngine("{{duration}}", zero)).toBe("0:00");
332+
});
333+
334+
it("renders empty when the episode has no duration", () => {
335+
expect(NoteTemplateEngine("{{duration}}", demoEpisode)).toBe("");
336+
});
337+
338+
it("is not registered in file-path/download-path templates (left unreplaced)", () => {
339+
// Intentionally absent there — the clock format's colons are path-illegal.
340+
expect(FilePathTemplateEngine("{{duration}}", withDuration)).toBe(
341+
"{{duration}}",
342+
);
343+
expect(DownloadPathTemplateEngine("{{duration}}", withDuration)).toBe(
344+
"{{duration}}",
345+
);
346+
});
347+
});
348+
349+
describe("TranscriptTemplateEngine new tags (#75/#34/#88)", () => {
350+
const fixture: Episode = { ...demoEpisode, episodeNumber: 42, duration: 3723 };
351+
352+
beforeEach(() => {
353+
plugin.set({ settings: { feedNote: { path: "" }, savedFeeds: {} } } as never);
354+
vi.useFakeTimers();
355+
vi.setSystemTime(new Date("2026-06-15T08:30:00"));
356+
});
357+
358+
afterEach(() => {
359+
vi.useRealTimers();
360+
});
361+
362+
it("renders all three new tags in transcript notes", () => {
363+
expect(
364+
TranscriptTemplateEngine(
365+
"{{currentDate}}|{{episodeNumber:000}}|{{duration:minutes}}",
366+
fixture,
367+
"the transcript",
368+
),
369+
).toBe("2026-06-15|042|62");
370+
});
371+
372+
it("leaves number/duration empty when absent", () => {
373+
const blank: Episode = { ...demoEpisode, title: "A Show With No Number" };
374+
expect(
375+
TranscriptTemplateEngine("[{{episodeNumber}}][{{duration}}]", blank, "t"),
376+
).toBe("[][]");
377+
});
378+
});

src/TemplateEngine.ts

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,14 @@ import type { Episode } from "src/types/Episode";
66
import type { PodcastFeed } from "src/types/PodcastFeed";
77
import getUrlExtension from "./utility/getUrlExtension";
88
import { formatDate } from "./utility/formatDate";
9+
import { formatDuration } from "./utility/formatDuration";
10+
import { formatEpisodeNumber } from "./utility/formatEpisodeNumber";
11+
import { parseEpisodeNumberFromTitle } from "./utility/parseEpisodeNumber";
912

10-
type TagValue = string | ((...args: string[]) => string);
13+
// Each tag is either a literal string or a function taking at most one argument
14+
// (the raw text after the leading colon, e.g. the format in {{date:YYYY}}). The
15+
// engine never passes more than one argument; commas are part of that argument.
16+
type TagValue = string | ((arg?: string) => string);
1117

1218
interface Tags {
1319
[tag: string]: TagValue;
@@ -53,11 +59,11 @@ function useTemplateEngine(): Readonly<[ReplacerFn, AddTagFn]> {
5359

5460
if (typeof tagValue === "function") {
5561
if (params) {
56-
// Remove initial colon with splice.
57-
const splitParams = params.slice(1).split(",");
58-
const args = Array.isArray(splitParams) ? splitParams : [params];
59-
60-
return tagValue(...args);
62+
// Pass everything after the leading colon as a single argument.
63+
// No tag takes more than one argument, and splitting on "," would
64+
// corrupt format strings that legitimately contain commas
65+
// (e.g. {{currentDate:MMMM D, YYYY}}).
66+
return tagValue(params.slice(1));
6167
}
6268

6369
return tagValue();
@@ -71,6 +77,16 @@ function useTemplateEngine(): Readonly<[ReplacerFn, AddTagFn]> {
7177
return [replacer, addTag] as const;
7278
}
7379

80+
/**
81+
* Resolve the episode number for templates: prefer the value captured at parse
82+
* time (from `<itunes:episode>` or the title), then fall back to a title parse so
83+
* persisted/older episode snapshots that predate the stored field (e.g. a restored
84+
* `currentEpisode`) still resolve `{{episodeNumber}}`.
85+
*/
86+
function resolveEpisodeNumber(episode: Episode): number | undefined {
87+
return episode.episodeNumber ?? parseEpisodeNumberFromTitle(episode.title);
88+
}
89+
7490
export function NoteTemplateEngine(template: string, episode: Episode) {
7591
const [replacer, addTag] = useTemplateEngine();
7692

@@ -108,6 +124,21 @@ export function NoteTemplateEngine(template: string, episode: Episode) {
108124
? formatDate(episode.episodeDate, format ?? "YYYY-MM-DD")
109125
: "",
110126
);
127+
// The current date the note is created on, distinct from {{date}} (the episode
128+
// publish date). Supports the same Moment.js format arg. See issue #75.
129+
addTag("currentdate", (format?: string) =>
130+
formatDate(new Date(), format ?? "YYYY-MM-DD"),
131+
);
132+
// Episode number from <itunes:episode>, else best-effort from the title. See #34.
133+
addTag("episodenumber", (pad?: string) =>
134+
formatEpisodeNumber(resolveEpisodeNumber(episode), pad),
135+
);
136+
// Episode duration from <itunes:duration>. See issue #88.
137+
addTag("duration", (format?: string) =>
138+
episode.duration !== undefined
139+
? formatDuration(episode.duration, format)
140+
: "",
141+
);
111142
addTag(
112143
"podcast",
113144
replaceIllegalFileNameCharactersInString(episode.podcastName),
@@ -171,6 +202,12 @@ export function FilePathTemplateEngine(template: string, episode: Episode) {
171202
? formatDate(episode.episodeDate, format ?? "YYYY-MM-DD")
172203
: "",
173204
);
205+
addTag("currentdate", (format?: string) =>
206+
formatDate(new Date(), format ?? "YYYY-MM-DD"),
207+
);
208+
addTag("episodenumber", (pad?: string) =>
209+
formatEpisodeNumber(resolveEpisodeNumber(episode), pad),
210+
);
174211

175212
return replacer(template);
176213
}
@@ -207,6 +244,12 @@ export function DownloadPathTemplateEngine(template: string, episode: Episode) {
207244
? formatDate(episode.episodeDate, format ?? "YYYY-MM-DD")
208245
: "",
209246
);
247+
addTag("currentdate", (format?: string) =>
248+
formatDate(new Date(), format ?? "YYYY-MM-DD"),
249+
);
250+
addTag("episodenumber", (pad?: string) =>
251+
formatEpisodeNumber(resolveEpisodeNumber(episode), pad),
252+
);
210253

211254
return replacer(templateWithoutExtension);
212255
}
@@ -239,6 +282,17 @@ export function TranscriptTemplateEngine(
239282
? formatDate(episode.episodeDate, format ?? "YYYY-MM-DD")
240283
: "",
241284
);
285+
addTag("currentdate", (format?: string) =>
286+
formatDate(new Date(), format ?? "YYYY-MM-DD"),
287+
);
288+
addTag("episodenumber", (pad?: string) =>
289+
formatEpisodeNumber(resolveEpisodeNumber(episode), pad),
290+
);
291+
addTag("duration", (format?: string) =>
292+
episode.duration !== undefined
293+
? formatDuration(episode.duration, format)
294+
: "",
295+
);
242296
addTag("transcript", transcription);
243297
addTag("description", (prependToLines?: string) => {
244298
if (prependToLines) {

0 commit comments

Comments
 (0)