From 36195015a02dddce9e2c955a2ad88bdad30746f2 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Wed, 12 Aug 2026 21:06:26 +0200 Subject: [PATCH 1/4] fix(notes): keep player and timestamp links working after title changes Opening an episode note now also checks the pre-2.17 filename sanitizer and a unique same-folder title match, so notes created before dots were preserved (or after a feed rearranges a title) stay linked. Timestamp links use the same title-overlap fallback against the live feed. Fixes #315 --- docs/docs/templates.md | 2 + docs/docs/timestamps.md | 2 +- src/TemplateEngine.test.ts | 33 +++++++++ src/TemplateEngine.ts | 39 ++++++++-- src/URIHandler.test.ts | 81 ++++++++++++++++++++ src/URIHandler.ts | 30 ++++++-- src/createPodcastNote.test.ts | 102 +++++++++++++++++++++++++- src/createPodcastNote.ts | 72 ++++++++++++++++-- src/utility/episodeTitleMatch.test.ts | 53 +++++++++++++ src/utility/episodeTitleMatch.ts | 65 ++++++++++++++++ 10 files changed, 454 insertions(+), 25 deletions(-) create mode 100644 src/utility/episodeTitleMatch.test.ts create mode 100644 src/utility/episodeTitleMatch.ts diff --git a/docs/docs/templates.md b/docs/docs/templates.md index 665f9c47..d253debe 100644 --- a/docs/docs/templates.md +++ b/docs/docs/templates.md @@ -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: diff --git a/docs/docs/timestamps.md b/docs/docs/timestamps.md index 0ef18e75..ea696045 100644 --- a/docs/docs/timestamps.md +++ b/docs/docs/timestamps.md @@ -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) diff --git a/src/TemplateEngine.test.ts b/src/TemplateEngine.test.ts index 04f67971..05da793e 100644 --- a/src/TemplateEngine.test.ts +++ b/src/TemplateEngine.test.ts @@ -4,6 +4,7 @@ import { FeedFilePathTemplateEngine, FeedNoteTemplateEngine, FilePathTemplateEngine, + legacyReplaceIllegalFileNameCharactersInString, NoteTemplateEngine, TimestampTemplateEngine, TranscriptTemplateEngine, @@ -65,6 +66,17 @@ 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"); + }); }); const demoEpisode: Episode = { @@ -79,6 +91,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( diff --git a/src/TemplateEngine.ts b/src/TemplateEngine.ts index 81031dc2..29287618 100644 --- a/src/TemplateEngine.ts +++ b/src/TemplateEngine.ts @@ -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; }; } @@ -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") : "", ); @@ -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); } @@ -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/, " ") + .replace(" ", " "); +} diff --git a/src/URIHandler.test.ts b/src/URIHandler.test.ts index 668790e1..64f81aa3 100644 --- a/src/URIHandler.test.ts +++ b/src/URIHandler.test.ts @@ -417,6 +417,87 @@ 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 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([]); diff --git a/src/URIHandler.ts b/src/URIHandler.ts index fbb5f569..49954b4a 100644 --- a/src/URIHandler.ts +++ b/src/URIHandler.ts @@ -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 } from "./utility/episodeTitleMatch"; import { ViewState } from "./types/ViewState"; type PodNotesProtocolData = ObsidianProtocolData & { @@ -56,7 +57,20 @@ function findEpisodeByCandidates( if (episode) return episode; } - return undefined; + return findUniqueTitleMatch(nameCandidates, episodes, (episode) => episode.title); +} + +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, + ); } /** @@ -119,7 +133,11 @@ 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); + const episodeIsPlaying = + !!currentEp && + (nameCandidates.includes(currentEp.title) || + findUniqueTitleMatch(nameCandidates, [currentEp], (episode) => episode.title) === + currentEp); const playerIsVisible = get(viewState) === ViewState.Player; if (episodeIsPlaying) { @@ -172,17 +190,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 diff --git a/src/createPodcastNote.test.ts b/src/createPodcastNote.test.ts index 8f21061d..d7e1f14c 100644 --- a/src/createPodcastNote.test.ts +++ b/src/createPodcastNote.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { TFile } from "obsidian"; -import createPodcastNote from "./createPodcastNote"; +import createPodcastNote, { getPodcastNote } from "./createPodcastNote"; import { plugin } from "./store"; import type { Episode } from "./types/Episode"; @@ -129,3 +129,103 @@ describe("createPodcastNote chapters template support (#47)", () => { }); }); }); + +describe("getPodcastNote title fallbacks (#315)", () => { + const philosophizeEpisode: Episode = { + title: "Episode #001 ... Presocratic Philosophy - Ionian", + streamUrl: "https://example.com/ep001.mp3", + url: "https://example.com/ep001", + description: "", + content: "", + podcastName: "Philosophize This!", + feedUrl: "https://feeds.megaphone.fm/QCD6036500916", + }; + + const hubermanEpisode: Episode = { + title: "Access Your Best Self With Mind-Body Practices, Belief Testing & Imagination | Dr. Martha Beck", + streamUrl: "https://example.com/martha.mp3", + url: "https://example.com/martha", + description: "", + content: "", + podcastName: "Huberman Lab", + feedUrl: "https://feeds.megaphone.fm/hubermanlab", + }; + + afterEach(() => { + plugin.set(undefined as never); + }); + + function fileAt(path: string): TFile { + return Object.assign(Object.create(TFile.prototype), { path }) as TFile; + } + + it("opens a note written with the 2.16 filename sanitizer", () => { + const legacy = fileAt( + "podcasts/Philosophize This! - Episode 001 Presocratic Philosophy - Ionian.md", + ); + const files = new Map([[legacy.path, legacy]]); + + plugin.set({ + app: { + vault: { + getAbstractFileByPath: vi.fn((path: string) => files.get(path) ?? null), + getMarkdownFiles: vi.fn(() => [...files.values()]), + }, + }, + settings: { + note: { path: "podcasts/{{podcast}} - {{title}}", template: "# {{title}}" }, + }, + } as never); + + expect(getPodcastNote(philosophizeEpisode)).toBe(legacy); + }); + + it("opens a note whose filename still has the pre-retitle episode words", async () => { + const legacy = fileAt( + "podcasts/Huberman Lab - Dr. Martha Beck Access Your Best Self With Mind-Body Practices Belief Testing Imagination.md", + ); + const files = new Map([[legacy.path, legacy]]); + const createdFiles: Array<{ path: string }> = []; + + plugin.set({ + app: { + vault: { + getAbstractFileByPath: vi.fn((path: string) => files.get(path) ?? null), + getMarkdownFiles: vi.fn(() => [...files.values()]), + createFolder: vi.fn(async () => {}), + create: vi.fn(async (path: string) => { + createdFiles.push({ path }); + return { path }; + }), + }, + workspace: { getLeaf: vi.fn(() => ({ openFile: vi.fn() })) }, + }, + settings: { + note: { path: "podcasts/{{podcast}} - {{title}}", template: "# {{title}}" }, + }, + } as never); + + expect(getPodcastNote(hubermanEpisode)).toBe(legacy); + + await createPodcastNote(hubermanEpisode); + expect(createdFiles).toEqual([]); + }); + + it("does not open a different episode that only shares generic words", () => { + const other = fileAt("podcasts/Huberman Lab - Optimize Testosterone Dr Kyle Gillett.md"); + + plugin.set({ + app: { + vault: { + getAbstractFileByPath: vi.fn(() => null), + getMarkdownFiles: vi.fn(() => [other]), + }, + }, + settings: { + note: { path: "podcasts/{{podcast}} - {{title}}", template: "# {{title}}" }, + }, + } as never); + + expect(getPodcastNote(hubermanEpisode)).toBeNull(); + }); +}); diff --git a/src/createPodcastNote.ts b/src/createPodcastNote.ts index aa8bf7f4..f43a6d03 100644 --- a/src/createPodcastNote.ts +++ b/src/createPodcastNote.ts @@ -1,5 +1,10 @@ import { Notice, TFile } from "obsidian"; -import { FilePathTemplateEngine, NoteTemplateEngine, templateHasTag } from "./TemplateEngine"; +import { + FilePathTemplateEngine, + legacyReplaceIllegalFileNameCharactersInString, + NoteTemplateEngine, + templateHasTag, +} from "./TemplateEngine"; import type { Episode } from "./types/Episode"; import type { Chapter } from "./types/Chapter"; import { get } from "svelte/store"; @@ -8,6 +13,7 @@ import addExtension from "./utility/addExtension"; import { enforceMaxPathLength } from "./utility/enforceMaxPathLength"; import { ensureFolderExists } from "./utility/ensureFolderExists"; import { fetchChapters } from "./utility/fetchChapters"; +import { findUniqueTitleMatch } from "./utility/episodeTitleMatch"; /** * Resolve the on-disk path of an episode's note from the configured template. @@ -16,11 +22,40 @@ import { fetchChapters } from "./utility/fetchChapters"; * re-created on every invocation. See issue #22. */ export function getPodcastNotePath(episode: Episode): string { - const pluginInstance = get(plugin); + return renderPodcastNotePath(get(plugin).settings.note.path, episode); +} + +function renderPodcastNotePath( + template: string, + episode: Episode, + sanitizeFileName?: (value: string) => string, +): string { + return enforceMaxPathLength( + addExtension(FilePathTemplateEngine(template, episode, sanitizeFileName), "md"), + ); +} - const filePath = FilePathTemplateEngine(pluginInstance.settings.note.path, episode); +function getPodcastNotePathCandidates(episode: Episode): string[] { + const template = get(plugin).settings.note.path; + return [ + ...new Set([ + renderPodcastNotePath(template, episode), + renderPodcastNotePath( + template, + episode, + legacyReplaceIllegalFileNameCharactersInString, + ), + ]), + ]; +} - return enforceMaxPathLength(addExtension(filePath, "md")); +function parentFolder(path: string): string { + const separator = path.lastIndexOf("/"); + return separator === -1 ? "" : path.slice(0, separator); +} + +function noteBasename(path: string): string { + return (path.split("/").pop() ?? "").replace(/\.md$/i, ""); } export default async function createPodcastNote(episode: Episode): Promise { @@ -62,14 +97,35 @@ async function getTemplateChapters( } export function getPodcastNote(episode: Episode): TFile | null { - const filePathDotMd = getPodcastNotePath(episode); - const file = get(plugin).app.vault.getAbstractFileByPath(filePathDotMd); + const { vault } = get(plugin).app; + + for (const filePath of getPodcastNotePathCandidates(episode)) { + const file = vault.getAbstractFileByPath(filePath); + if (file instanceof TFile) { + return file; + } + } + + return findPodcastNoteByTitleOverlap(episode); +} - if (!file || !(file instanceof TFile)) { +function findPodcastNoteByTitleOverlap(episode: Episode): TFile | null { + const { vault } = get(plugin).app; + if (typeof vault.getMarkdownFiles !== "function") { return null; } - return file; + const expectedPath = getPodcastNotePath(episode); + const folder = parentFolder(expectedPath); + const siblings = vault + .getMarkdownFiles() + .filter((file) => parentFolder(file.path) === folder && file instanceof TFile); + + return ( + findUniqueTitleMatch([noteBasename(expectedPath)], siblings, (file) => + noteBasename(file.path), + ) ?? null + ); } export function openPodcastNote(epiosode: Episode): void { diff --git a/src/utility/episodeTitleMatch.test.ts b/src/utility/episodeTitleMatch.test.ts new file mode 100644 index 00000000..cd399ec8 --- /dev/null +++ b/src/utility/episodeTitleMatch.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { findUniqueTitleMatch, titleTokenSimilarity, titleTokens } from "./episodeTitleMatch"; + +describe("episodeTitleMatch", () => { + it("treats punctuation and word order as irrelevant", () => { + const current = + "Access Your Best Self With Mind-Body Practices, Belief Testing & Imagination | Dr. Martha Beck"; + const legacy = + "Dr. Martha Beck: Access Your Best Self With Mind-Body Practices, Belief Testing & Imagination"; + + expect(titleTokenSimilarity(current, legacy)).toBe(1); + }); + + it("treats stripped ellipses as the same title as the dotted form", () => { + const current = "Philosophize This! - Episode 001 ... Presocratic Philosophy - Ionian"; + const legacy = "Philosophize This! - Episode 001 Presocratic Philosophy - Ionian"; + + expect(titleTokens(current)).toEqual(titleTokens(legacy)); + expect(titleTokenSimilarity(current, legacy)).toBe(1); + }); + + it("does not match distinct numbered episodes", () => { + expect( + titleTokenSimilarity( + "Episode #001 ... Presocratic Philosophy - Ionian", + "Episode #007 ... Daoism", + ), + ).toBeLessThan(0.75); + }); + + it("returns the unique strong match and refuses a tie", () => { + const episodes = [ + { title: "Access Your Best Self | Dr. Martha Beck" }, + { title: "Optimize Testosterone | Dr. Kyle Gillett" }, + ]; + + expect( + findUniqueTitleMatch( + ["Dr. Martha Beck: Access Your Best Self"], + episodes, + (episode) => episode.title, + ), + ).toEqual(episodes[0]); + + expect( + findUniqueTitleMatch( + ["Interview"], + [{ title: "Interview with Alice" }, { title: "Interview with Bob" }], + (episode) => episode.title, + ), + ).toBeUndefined(); + }); +}); diff --git a/src/utility/episodeTitleMatch.ts b/src/utility/episodeTitleMatch.ts new file mode 100644 index 00000000..cd971181 --- /dev/null +++ b/src/utility/episodeTitleMatch.ts @@ -0,0 +1,65 @@ +/** + * Token-overlap matching for episode titles that have been rearranged or + * lightly rewritten (guest name moved, punctuation changed) while still + * referring to the same episode. + */ + +export const MIN_TITLE_SIMILARITY = 0.75; + +export function titleTokens(title: string): Set { + const tokens = title + .toLowerCase() + .replace(/[^a-z0-9]+/g, " ") + .trim() + .split(/\s+/) + .filter(Boolean); + + return new Set(tokens); +} + +export function titleTokenSimilarity(left: string, right: string): number { + const a = titleTokens(left); + const b = titleTokens(right); + if (a.size === 0 || b.size === 0) return 0; + + let intersection = 0; + for (const token of a) { + if (b.has(token)) intersection += 1; + } + + return intersection / (a.size + b.size - intersection); +} + +/** + * Return the single candidate whose title is a strong unique match for any of + * `targets`. Ties (two candidates at the same top score) return undefined so + * we never open or play the wrong episode. + */ +export function findUniqueTitleMatch( + targets: readonly string[], + candidates: readonly T[], + getTitle: (candidate: T) => string, + minSimilarity = MIN_TITLE_SIMILARITY, +): T | undefined { + const scored: Array<{ candidate: T; score: number }> = []; + + for (const candidate of candidates) { + const title = getTitle(candidate); + let score = 0; + for (const target of targets) { + score = Math.max(score, titleTokenSimilarity(target, title)); + } + if (score >= minSimilarity) { + scored.push({ candidate, score }); + } + } + + if (scored.length === 0) return undefined; + + scored.sort((left, right) => right.score - left.score); + if (scored.length > 1 && scored[0].score === scored[1].score) { + return undefined; + } + + return scored[0].candidate; +} From 951d0453a2da515cc7adeaecede77f6bade0ff47 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Wed, 12 Aug 2026 21:11:35 +0200 Subject: [PATCH 2/4] fix(notes): replace every newline in the 2.16 filename sanitizer CodeQL flagged the first-only /\n/ replace as incomplete sanitization. Filenames cannot contain leftover newlines, so making this global does not change note lookup. --- src/TemplateEngine.test.ts | 1 + src/TemplateEngine.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/TemplateEngine.test.ts b/src/TemplateEngine.test.ts index 05da793e..122887e2 100644 --- a/src/TemplateEngine.test.ts +++ b/src/TemplateEngine.test.ts @@ -76,6 +76,7 @@ describe("replaceIllegalFileNameCharactersInString (via DownloadPathTemplateEngi "Episode #001 ... Presocratic Philosophy - Ionian", ), ).toBe("Episode 001 Presocratic Philosophy - Ionian"); + expect(legacyReplaceIllegalFileNameCharactersInString("a\nb\nc")).toBe("a b c"); }); }); diff --git a/src/TemplateEngine.ts b/src/TemplateEngine.ts index 29287618..28e0b56f 100644 --- a/src/TemplateEngine.ts +++ b/src/TemplateEngine.ts @@ -619,6 +619,6 @@ export function replaceIllegalFileNameCharactersInString(string: string) { export function legacyReplaceIllegalFileNameCharactersInString(string: string) { return string .replace(/[\\,#%&{}/*<>$'":@\u2023|\\.?]/g, "") - .replace(/\n/, " ") + .replace(/\n/g, " ") .replace(" ", " "); } From e3f2d732cfbbe599b50ad1d80c099e2672464b3c Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Wed, 12 Aug 2026 21:31:31 +0200 Subject: [PATCH 3/4] fix(notes): do not fuzzy-match distinct numbered episodes The playing-episode URI shortcut now requires a full token-set match so a Part 1 timestamp cannot seek a loaded Part 2. Conflicting numeric tokens score 0, so a same-folder Part 2 note is not treated as Part 1. The tie-guard test now uses two candidates at the same qualifying score. --- src/URIHandler.test.ts | 37 +++++++++++++++++++++++ src/URIHandler.ts | 9 ++++-- src/createPodcastNote.test.ts | 43 +++++++++++++++++++++++++++ src/utility/episodeTitleMatch.test.ts | 22 +++++++++++--- src/utility/episodeTitleMatch.ts | 32 ++++++++++++++++++-- 5 files changed, 134 insertions(+), 9 deletions(-) diff --git a/src/URIHandler.test.ts b/src/URIHandler.test.ts index 64f81aa3..121afa63 100644 --- a/src/URIHandler.test.ts +++ b/src/URIHandler.test.ts @@ -475,6 +475,43 @@ describe("podNotesURIHandler", () => { 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 treat a weakly similar feed title as a match (#315)", async () => { mockGetEpisodes.mockResolvedValue([ { diff --git a/src/URIHandler.ts b/src/URIHandler.ts index 49954b4a..cb6af954 100644 --- a/src/URIHandler.ts +++ b/src/URIHandler.ts @@ -22,7 +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 } from "./utility/episodeTitleMatch"; +import { findUniqueTitleMatch, titleTokenSimilarity } from "./utility/episodeTitleMatch"; import { ViewState } from "./types/ViewState"; type PodNotesProtocolData = ObsidianProtocolData & { @@ -133,11 +133,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. + // 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) || - findUniqueTitleMatch(nameCandidates, [currentEp], (episode) => episode.title) === - currentEp); + nameCandidates.some((name) => titleTokenSimilarity(name, currentEp.title) === 1)); const playerIsVisible = get(viewState) === ViewState.Player; if (episodeIsPlaying) { diff --git a/src/createPodcastNote.test.ts b/src/createPodcastNote.test.ts index d7e1f14c..4c069d83 100644 --- a/src/createPodcastNote.test.ts +++ b/src/createPodcastNote.test.ts @@ -211,6 +211,49 @@ describe("getPodcastNote title fallbacks (#315)", () => { expect(createdFiles).toEqual([]); }); + it("does not treat a Part 2 note as the Part 1 note", async () => { + const part1: Episode = { + title: "The Fall of the Republic, Part 1", + streamUrl: "https://example.com/part1.mp3", + url: "https://example.com/part1", + description: "", + content: "", + podcastName: "The History of Rome", + feedUrl: "https://example.com/rome.xml", + }; + const part2Note = fileAt( + "podcasts/The History of Rome - The Fall of the Republic, Part 2.md", + ); + const createdFiles: Array<{ path: string }> = []; + + plugin.set({ + app: { + vault: { + getAbstractFileByPath: vi.fn((path: string) => + path === part2Note.path ? part2Note : null, + ), + getMarkdownFiles: vi.fn(() => [part2Note]), + createFolder: vi.fn(async () => {}), + create: vi.fn(async (path: string) => { + createdFiles.push({ path }); + return { path }; + }), + }, + workspace: { getLeaf: vi.fn(() => ({ openFile: vi.fn() })) }, + }, + settings: { + note: { path: "podcasts/{{podcast}} - {{title}}", template: "# {{title}}" }, + }, + } as never); + + expect(getPodcastNote(part1)).toBeNull(); + + await createPodcastNote(part1); + expect(createdFiles).toEqual([ + { path: "podcasts/The History of Rome - The Fall of the Republic Part 1.md" }, + ]); + }); + it("does not open a different episode that only shares generic words", () => { const other = fileAt("podcasts/Huberman Lab - Optimize Testosterone Dr Kyle Gillett.md"); diff --git a/src/utility/episodeTitleMatch.test.ts b/src/utility/episodeTitleMatch.test.ts index cd399ec8..b219d9d3 100644 --- a/src/utility/episodeTitleMatch.test.ts +++ b/src/utility/episodeTitleMatch.test.ts @@ -25,10 +25,22 @@ describe("episodeTitleMatch", () => { "Episode #001 ... Presocratic Philosophy - Ionian", "Episode #007 ... Daoism", ), - ).toBeLessThan(0.75); + ).toBe(0); + expect( + titleTokenSimilarity( + "The History of Rome: The Fall of the Republic, Part 1", + "The History of Rome: The Fall of the Republic, Part 2", + ), + ).toBe(0); }); - it("returns the unique strong match and refuses a tie", () => { + it("treats padded and unpadded episode numbers as the same number", () => { + expect(titleTokenSimilarity("Episode 001 The Beginning", "Episode 1 The Beginning")).toBe( + 1, + ); + }); + + it("returns the unique strong match", () => { const episodes = [ { title: "Access Your Best Self | Dr. Martha Beck" }, { title: "Optimize Testosterone | Dr. Kyle Gillett" }, @@ -41,11 +53,13 @@ describe("episodeTitleMatch", () => { (episode) => episode.title, ), ).toEqual(episodes[0]); + }); + it("refuses two candidates at the same qualifying score", () => { expect( findUniqueTitleMatch( - ["Interview"], - [{ title: "Interview with Alice" }, { title: "Interview with Bob" }], + ["Weekly News Roundup"], + [{ title: "Weekly News Roundup Extra" }, { title: "Weekly News Roundup Bonus" }], (episode) => episode.title, ), ).toBeUndefined(); diff --git a/src/utility/episodeTitleMatch.ts b/src/utility/episodeTitleMatch.ts index cd971181..b28106f2 100644 --- a/src/utility/episodeTitleMatch.ts +++ b/src/utility/episodeTitleMatch.ts @@ -17,10 +17,38 @@ export function titleTokens(title: string): Set { return new Set(tokens); } +function canonicalizeTokens(tokens: Set): Set { + const canonical = new Set(); + for (const token of tokens) { + canonical.add(/^\d+$/.test(token) ? String(Number(token)) : token); + } + return canonical; +} + +function numericTokens(tokens: Set): Set { + const numbers = new Set(); + for (const token of tokens) { + if (/^\d+$/.test(token)) numbers.add(token); + } + return numbers; +} + +function numericTokenSetsConflict(left: Set, right: Set): boolean { + const a = numericTokens(left); + const b = numericTokens(right); + if (a.size === 0 || b.size === 0) return false; + if (a.size !== b.size) return true; + for (const value of a) { + if (!b.has(value)) return true; + } + return false; +} + export function titleTokenSimilarity(left: string, right: string): number { - const a = titleTokens(left); - const b = titleTokens(right); + const a = canonicalizeTokens(titleTokens(left)); + const b = canonicalizeTokens(titleTokens(right)); if (a.size === 0 || b.size === 0) return 0; + if (numericTokenSetsConflict(a, b)) return 0; let intersection = 0; for (const token of a) { From 118112abf7cd4b502a86334424679cba3d7b7472 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Wed, 12 Aug 2026 21:42:28 +0200 Subject: [PATCH 4/4] fix(notes): ignore path boilerplate and refuse ambiguous title matches Same-folder note lookup now compares sanitized episode titles, not the full basename, so a long {{podcast}} prefix cannot pull in a sibling. Feed and local-file fallbacks require a full token-set match, and findUniqueTitleMatch refuses whenever more than one candidate qualifies. --- src/URIHandler.test.ts | 22 +++++++++++++++++++ src/URIHandler.ts | 3 ++- src/createPodcastNote.test.ts | 31 +++++++++++++++++++++++++++ src/createPodcastNote.ts | 15 ++++++++++++- src/utility/episodeTitleMatch.test.ts | 10 +++++++++ src/utility/episodeTitleMatch.ts | 11 +++------- 6 files changed, 82 insertions(+), 10 deletions(-) diff --git a/src/URIHandler.test.ts b/src/URIHandler.test.ts index 121afa63..476f87b7 100644 --- a/src/URIHandler.test.ts +++ b/src/URIHandler.test.ts @@ -512,6 +512,28 @@ describe("podNotesURIHandler", () => { }); }); + 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([ { diff --git a/src/URIHandler.ts b/src/URIHandler.ts index cb6af954..054c6012 100644 --- a/src/URIHandler.ts +++ b/src/URIHandler.ts @@ -57,7 +57,7 @@ function findEpisodeByCandidates( if (episode) return episode; } - return findUniqueTitleMatch(nameCandidates, episodes, (episode) => episode.title); + return findUniqueTitleMatch(nameCandidates, episodes, (episode) => episode.title, 1); } function findLocalEpisodeByCandidates(nameCandidates: string[]): Episode | undefined { @@ -70,6 +70,7 @@ function findLocalEpisodeByCandidates(nameCandidates: string[]): Episode | undef nameCandidates, get(localFiles).episodes, (episode) => episode.title, + 1, ); } diff --git a/src/createPodcastNote.test.ts b/src/createPodcastNote.test.ts index 4c069d83..1b4f55fb 100644 --- a/src/createPodcastNote.test.ts +++ b/src/createPodcastNote.test.ts @@ -254,6 +254,37 @@ describe("getPodcastNote title fallbacks (#315)", () => { ]); }); + it("does not treat a sibling note as a match just because they share a long path prefix", () => { + const apple: Episode = { + title: "Apple", + streamUrl: "https://example.com/apple.mp3", + url: "https://example.com/apple", + description: "", + content: "", + podcastName: "The Really Long Daily Technology Podcast", + feedUrl: "https://example.com/tech.xml", + }; + const androidNote = fileAt( + "podcasts/The Really Long Daily Technology Podcast - Android.md", + ); + + plugin.set({ + app: { + vault: { + getAbstractFileByPath: vi.fn((path: string) => + path === androidNote.path ? androidNote : null, + ), + getMarkdownFiles: vi.fn(() => [androidNote]), + }, + }, + settings: { + note: { path: "podcasts/{{podcast}} - {{title}}", template: "# {{title}}" }, + }, + } as never); + + expect(getPodcastNote(apple)).toBeNull(); + }); + it("does not open a different episode that only shares generic words", () => { const other = fileAt("podcasts/Huberman Lab - Optimize Testosterone Dr Kyle Gillett.md"); diff --git a/src/createPodcastNote.ts b/src/createPodcastNote.ts index f43a6d03..3857a3ae 100644 --- a/src/createPodcastNote.ts +++ b/src/createPodcastNote.ts @@ -58,6 +58,19 @@ function noteBasename(path: string): string { return (path.split("/").pop() ?? "").replace(/\.md$/i, ""); } +function getPodcastNoteTitleCandidates(episode: Episode): string[] { + return [ + ...new Set([ + FilePathTemplateEngine("{{title}}", episode), + FilePathTemplateEngine( + "{{title}}", + episode, + legacyReplaceIllegalFileNameCharactersInString, + ), + ]), + ]; +} + export default async function createPodcastNote(episode: Episode): Promise { try { const file = await createPodcastNoteFileIfNotExists(episode); @@ -122,7 +135,7 @@ function findPodcastNoteByTitleOverlap(episode: Episode): TFile | null { .filter((file) => parentFolder(file.path) === folder && file instanceof TFile); return ( - findUniqueTitleMatch([noteBasename(expectedPath)], siblings, (file) => + findUniqueTitleMatch(getPodcastNoteTitleCandidates(episode), siblings, (file) => noteBasename(file.path), ) ?? null ); diff --git a/src/utility/episodeTitleMatch.test.ts b/src/utility/episodeTitleMatch.test.ts index b219d9d3..da5d714e 100644 --- a/src/utility/episodeTitleMatch.test.ts +++ b/src/utility/episodeTitleMatch.test.ts @@ -64,4 +64,14 @@ describe("episodeTitleMatch", () => { ), ).toBeUndefined(); }); + + it("refuses when two candidates both qualify at different scores", () => { + expect( + findUniqueTitleMatch( + ["The Weekly News Roundup"], + [{ title: "The Weekly News Roundup" }, { title: "The Weekly News Roundup Extra" }], + (episode) => episode.title, + ), + ).toBeUndefined(); + }); }); diff --git a/src/utility/episodeTitleMatch.ts b/src/utility/episodeTitleMatch.ts index b28106f2..afdf9d53 100644 --- a/src/utility/episodeTitleMatch.ts +++ b/src/utility/episodeTitleMatch.ts @@ -60,8 +60,8 @@ export function titleTokenSimilarity(left: string, right: string): number { /** * Return the single candidate whose title is a strong unique match for any of - * `targets`. Ties (two candidates at the same top score) return undefined so - * we never open or play the wrong episode. + * `targets`. If more than one candidate meets `minSimilarity`, the match is + * ambiguous and we return undefined rather than taking the highest score. */ export function findUniqueTitleMatch( targets: readonly string[], @@ -82,12 +82,7 @@ export function findUniqueTitleMatch( } } - if (scored.length === 0) return undefined; - - scored.sort((left, right) => right.score - left.score); - if (scored.length > 1 && scored[0].score === scored[1].score) { - return undefined; - } + if (scored.length !== 1) return undefined; return scored[0].candidate; }