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 @@ -19,6 +19,8 @@ This template will be used to create the file path for the note. You can use the

`{{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}}`).

New notes are always created at the current sanitized path. When opening an existing note, PodNotes also checks the older filename sanitizer (used through 2.16, which stripped dots) and, if needed, a unique same-folder title match. Notes created before that sanitizer change, or after a feed retitles an episode, stay linked to the player.

## Note template
This template will be used to create the note text. You can use the following syntax:

Expand Down
2 changes: 1 addition & 1 deletion docs/docs/timestamps.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ Timestamps can be created with the `Capture Timestamp` Obsidian command.
This will make PodNotes capture the current playback time to the active note, in the format given in the plugin settings.
PodNotes can also capture recent playback segments with the `Capture Last 10 Seconds` and `Capture Last 20 Seconds` commands.

With the default `{{linktime}}` format, each captured timestamp becomes a clickable link that reopens the episode at that exact moment:
With the default `{{linktime}}` format, each captured timestamp becomes a clickable link that reopens the episode at that exact moment. If the feed later rearranges the episode title (for example moving a guest name), the link still resolves as long as the new title keeps the same distinctive words:

![Notes with clickable timestamp links](resources/timestamps.png)

Expand Down
34 changes: 34 additions & 0 deletions src/TemplateEngine.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
FeedFilePathTemplateEngine,
FeedNoteTemplateEngine,
FilePathTemplateEngine,
legacyReplaceIllegalFileNameCharactersInString,
NoteTemplateEngine,
TimestampTemplateEngine,
TranscriptTemplateEngine,
Expand Down Expand Up @@ -65,6 +66,18 @@ describe("replaceIllegalFileNameCharactersInString (via DownloadPathTemplateEngi
it("does not strip hyphens or ordinary letters", () => {
expect(sanitizeTitle("Part 1 - The Beginning")).toBe("Part 1 - The Beginning");
});

it("keeps mid-title ellipses that the 2.16 sanitizer stripped", () => {
expect(sanitizeTitle("Episode #001 ... Presocratic Philosophy - Ionian")).toBe(
"Episode 001 ... Presocratic Philosophy - Ionian",
);
expect(
legacyReplaceIllegalFileNameCharactersInString(
"Episode #001 ... Presocratic Philosophy - Ionian",
),
).toBe("Episode 001 Presocratic Philosophy - Ionian");
expect(legacyReplaceIllegalFileNameCharactersInString("a\nb\nc")).toBe("a b c");
});
});

const demoEpisode: Episode = {
Expand All @@ -79,6 +92,27 @@ const demoEpisode: Episode = {
episodeDate: new Date("2024-01-01"),
};

describe("FilePathTemplateEngine legacy sanitizer (#315)", () => {
const episode: Episode = {
...demoEpisode,
title: "Episode #001 ... Presocratic Philosophy - Ionian",
podcastName: "Philosophize This!",
};

it("renders the current and 2.16 filenames for the same episode", () => {
expect(FilePathTemplateEngine("podcasts/{{podcast}} - {{title}}", episode)).toBe(
"podcasts/Philosophize This! - Episode 001 ... Presocratic Philosophy - Ionian",
);
expect(
FilePathTemplateEngine(
"podcasts/{{podcast}} - {{title}}",
episode,
legacyReplaceIllegalFileNameCharactersInString,
),
).toBe("podcasts/Philosophize This! - Episode 001 Presocratic Philosophy - Ionian");
});
});

describe("DownloadPathTemplateEngine extension stripping (#DL-04)", () => {
it("strips only a trailing template extension", () => {
expect(DownloadPathTemplateEngine("Podcasts/{{title}}.mp3", demoEpisode)).toBe(
Expand Down
39 changes: 32 additions & 7 deletions src/TemplateEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,9 +116,14 @@ function resolveEpisodeNumber(episode: Episode): number | undefined {
* the tag is used with an argument (e.g. `{{title:_}}`), collapses whitespace to
* that replacement. Shared by the file-name {{title}}/{{podcast}} tags.
*/
function legalizedNameTag(rawValue: string): TagValue {
type FileNameSanitizer = (value: string) => string;

function legalizedNameTag(
rawValue: string,
sanitizeFileName: FileNameSanitizer = replaceIllegalFileNameCharactersInString,
): TagValue {
return (whitespaceReplacement?: string) => {
const legal = replaceIllegalFileNameCharactersInString(rawValue);
const legal = sanitizeFileName(rawValue);
return whitespaceReplacement ? legal.replace(/\s+/g, whitespaceReplacement) : legal;
};
}
Expand All @@ -130,9 +135,13 @@ function legalizedNameTag(rawValue: string): TagValue {
* {{currentdate}}, and {{episodenumber}}. NoteTemplateEngine intentionally does
* NOT use this — there {{title}} is the raw episode title, not a file name.
*/
function addEpisodeFileNameTags(addTag: AddTagFn, episode: Episode): void {
addTag("title", legalizedNameTag(episode.title));
addTag("podcast", legalizedNameTag(episode.podcastName));
function addEpisodeFileNameTags(
addTag: AddTagFn,
episode: Episode,
sanitizeFileName: FileNameSanitizer = replaceIllegalFileNameCharactersInString,
): void {
addTag("title", legalizedNameTag(episode.title, sanitizeFileName));
addTag("podcast", legalizedNameTag(episode.podcastName, sanitizeFileName));
addTag("date", (format?: string) =>
episode.episodeDate ? formatDate(episode.episodeDate, format ?? "YYYY-MM-DD") : "",
);
Expand Down Expand Up @@ -399,10 +408,14 @@ export function TimestampTemplateEngine(
return replacer(template);
}

export function FilePathTemplateEngine(template: string, episode: Episode) {
export function FilePathTemplateEngine(
template: string,
episode: Episode,
sanitizeFileName: FileNameSanitizer = replaceIllegalFileNameCharactersInString,
) {
const [replacer, addTag] = useTemplateEngine();

addEpisodeFileNameTags(addTag, episode);
addEpisodeFileNameTags(addTag, episode, sanitizeFileName);

return replacer(template);
}
Expand Down Expand Up @@ -597,3 +610,15 @@ export function replaceIllegalFileNameCharactersInString(string: string) {
.trim()
);
}

/**
* Filename sanitizer used through PodNotes 2.16. Dots were stripped, so a title
* like "Episode #001 ..." became "Episode 001". Note lookup still has to find
* files created with that scheme.
*/
export function legacyReplaceIllegalFileNameCharactersInString(string: string) {
return string
.replace(/[\\,#%&{}/*<>$'":@\u2023|\\.?]/g, "")
.replace(/\n/g, " ")
.replace(" ", " ");
}
140 changes: 140 additions & 0 deletions src/URIHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,146 @@ describe("podNotesURIHandler", () => {
expect(mockGetEpisodes).not.toHaveBeenCalled();
});

test("resolves a feed episode after the publisher rearranges the title (#315)", async () => {
const currentTitle =
"Access Your Best Self With Mind-Body Practices, Belief Testing & Imagination | Dr. Martha Beck";
const hubermanEpisode: Episode = {
...testEpisode,
title: currentTitle,
url: "https://pod.example.com/martha",
streamUrl: "https://pod.example.com/martha.mp3",
podcastName: "Huberman Lab",
};
mockGetEpisodes.mockResolvedValue([hubermanEpisode, testEpisode]);

await podNotesURIHandler(
{
action: "podnotes",
url: testFeedUrl,
episodeName:
"Dr. Martha Beck: Access Your Best Self With Mind-Body Practices, Belief Testing & Imagination",
time: "2580",
},
api as never,
);

expect(get(currentEpisode)).toMatchObject({ title: currentTitle });
expect(get(requestedPlaybackTime)).toEqual({
episodeKey: `Huberman Lab::${currentTitle}`,
time: 2580,
});
});

test("seeks the already-loaded episode when a timestamp uses the old title (#315)", async () => {
const currentTitle =
"Access Your Best Self With Mind-Body Practices, Belief Testing & Imagination | Dr. Martha Beck";
const hubermanEpisode: Episode = {
...testEpisode,
title: currentTitle,
podcastName: "Huberman Lab",
};
currentEpisode.set(hubermanEpisode);
viewState.set(ViewState.Player);
isPaused.set(true);

await podNotesURIHandler(
{
action: "podnotes",
url: testFeedUrl,
episodeName:
"Dr. Martha Beck: Access Your Best Self With Mind-Body Practices, Belief Testing & Imagination",
time: "90",
},
api as never,
);

expect(get(currentTime)).toBe(90);
expect(get(isPaused)).toBe(false);
expect(mockGetEpisodes).not.toHaveBeenCalled();
});

test("does not seek a loaded Part 2 when the link is for Part 1 (#315)", async () => {
const part1: Episode = {
...testEpisode,
title: "The History of Rome: The Fall of the Republic, Part 1",
url: "https://pod.example.com/part1",
streamUrl: "https://pod.example.com/part1.mp3",
podcastName: "The History of Rome",
};
const part2: Episode = {
...part1,
title: "The History of Rome: The Fall of the Republic, Part 2",
url: "https://pod.example.com/part2",
streamUrl: "https://pod.example.com/part2.mp3",
};
currentEpisode.set(part2);
viewState.set(ViewState.Player);
isPaused.set(true);
mockGetEpisodes.mockResolvedValue([part1, part2]);

await podNotesURIHandler(
{
action: "podnotes",
url: testFeedUrl,
episodeName: part1.title,
time: "90",
},
api as never,
);

expect(mockGetEpisodes).toHaveBeenCalledWith(testFeedUrl);
expect(get(currentEpisode)).toMatchObject({ title: part1.title });
expect(get(requestedPlaybackTime)).toEqual({
episodeKey: `${part1.podcastName}::${part1.title}`,
time: 90,
});
});

test("does not pick a leftover similar episode when the linked title is gone (#315)", async () => {
mockGetEpisodes.mockResolvedValue([
{
...testEpisode,
title: "Weekly News Roundup Extra",
},
]);

await podNotesURIHandler(
{
action: "podnotes",
url: testFeedUrl,
episodeName: "Weekly News Roundup",
time: "10",
},
api as never,
);

expect(get(currentEpisode)).toBeUndefined();
expect(get(viewState)).toBe(ViewState.PodcastGrid);
});

test("does not treat a weakly similar feed title as a match (#315)", async () => {
mockGetEpisodes.mockResolvedValue([
{
...testEpisode,
title: "Optimize Testosterone & Estrogen | Dr. Kyle Gillett",
},
]);

await podNotesURIHandler(
{
action: "podnotes",
url: testFeedUrl,
episodeName:
"Dr. Martha Beck: Access Your Best Self With Mind-Body Practices, Belief Testing & Imagination",
time: "10",
},
api as never,
);

expect(get(currentEpisode)).toBeUndefined();
expect(get(viewState)).toBe(ViewState.PodcastGrid);
});

test("shows a notice and changes nothing when the episode cannot be found", async () => {
mockGetEpisodes.mockResolvedValue([]);

Expand Down
34 changes: 26 additions & 8 deletions src/URIHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { isFetchableUrl } from "./utility/assertFetchableUrl";
import { resolveFeedUrl } from "./services/privateFeeds";
import { isPrivateFeedPlaceholder, parsePrivateFeedPlaceholder } from "./utility/privateFeedUrl";
import { getEpisodeKey } from "./utility/episodeKey";
import { findUniqueTitleMatch, titleTokenSimilarity } from "./utility/episodeTitleMatch";
import { ViewState } from "./types/ViewState";

type PodNotesProtocolData = ObsidianProtocolData & {
Expand Down Expand Up @@ -56,7 +57,21 @@ function findEpisodeByCandidates(
if (episode) return episode;
}

return undefined;
return findUniqueTitleMatch(nameCandidates, episodes, (episode) => episode.title, 1);
}

function findLocalEpisodeByCandidates(nameCandidates: string[]): Episode | undefined {
for (const name of nameCandidates) {
const episode = localFiles.getLocalEpisode(name);
if (episode) return episode;
}

return findUniqueTitleMatch(
nameCandidates,
get(localFiles).episodes,
(episode) => episode.title,
1,
);
}

/**
Expand Down Expand Up @@ -119,7 +134,14 @@ export default async function podNotesURIHandler(
// a rare false-positive — a current-format link to a "X+Y" episode while a distinct "X Y" twin
// is loaded resumes the loaded twin instead of switching — which we accept over regressing the
// far more common same-episode legacy-link case.
const episodeIsPlaying = !!currentEp && nameCandidates.includes(currentEp.title);
// A retitled same-episode link (guest name moved) has the same token set.
// Sub-1.0 overlap is not enough here: "… Part 1" vs the loaded "… Part 2"
// scores ~0.78, and with only one candidate the uniqueness guard is vacuous.
// Those links fall through to the feed lookup, which can still exact-match.
const episodeIsPlaying =
!!currentEp &&
(nameCandidates.includes(currentEp.title) ||
nameCandidates.some((name) => titleTokenSimilarity(name, currentEp.title) === 1));
const playerIsVisible = get(viewState) === ViewState.Player;

if (episodeIsPlaying) {
Expand Down Expand Up @@ -172,17 +194,13 @@ export default async function podNotesURIHandler(
// shadow private links.
const isPlaceholderLink = isPrivateFeedPlaceholder(url);
if (!isPlaceholderLink && localFile) {
episode = nameCandidates
.map((name) => localFiles.getLocalEpisode(name))
.find((ep) => ep !== undefined);
episode = findLocalEpisodeByCandidates(nameCandidates);
} else if (!isPlaceholderLink && !candidateValues(url).some((u) => /^https?:\/\//i.test(u))) {
// The probe found no file, yet `url` has no http(s) scheme, so it can only be
// a vault path whose file was moved/renamed. Resolve the episode by name from
// the local-files store rather than handing the bare path to FeedParser (which
// would fail and show a misleading "Could not load the podcast feed").
episode = nameCandidates
.map((name) => localFiles.getLocalEpisode(name))
.find((ep) => ep !== undefined);
episode = findLocalEpisodeByCandidates(nameCandidates);
} else {
// Links for a private feed carry the non-fetchable placeholder, not the
// secret URL. Resolution is local-only: the placeholder's feed name must
Expand Down
Loading