diff --git a/docs/docs/templates.md b/docs/docs/templates.md index 665f9c4..d253deb 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 0ef18e7..ea69604 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 04f6797..122887e 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,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 = { @@ -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( diff --git a/src/TemplateEngine.ts b/src/TemplateEngine.ts index 81031dc..28e0b56 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/g, " ") + .replace(" ", " "); +} diff --git a/src/URIHandler.test.ts b/src/URIHandler.test.ts index 668790e..476f87b 100644 --- a/src/URIHandler.test.ts +++ b/src/URIHandler.test.ts @@ -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([]); diff --git a/src/URIHandler.ts b/src/URIHandler.ts index fbb5f56..054c601 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, titleTokenSimilarity } from "./utility/episodeTitleMatch"; import { ViewState } from "./types/ViewState"; type PodNotesProtocolData = ObsidianProtocolData & { @@ -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, + ); } /** @@ -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) { @@ -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 diff --git a/src/createPodcastNote.test.ts b/src/createPodcastNote.test.ts index 8f21061..1b4f55f 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,177 @@ 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 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 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"); + + 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 aa8bf7f..3857a3a 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,53 @@ 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); +} - const filePath = FilePathTemplateEngine(pluginInstance.settings.note.path, episode); +function renderPodcastNotePath( + template: string, + episode: Episode, + sanitizeFileName?: (value: string) => string, +): string { + return enforceMaxPathLength( + addExtension(FilePathTemplateEngine(template, episode, sanitizeFileName), "md"), + ); +} + +function getPodcastNotePathCandidates(episode: Episode): string[] { + const template = get(plugin).settings.note.path; + return [ + ...new Set([ + renderPodcastNotePath(template, episode), + renderPodcastNotePath( + template, + episode, + legacyReplaceIllegalFileNameCharactersInString, + ), + ]), + ]; +} + +function parentFolder(path: string): string { + const separator = path.lastIndexOf("/"); + return separator === -1 ? "" : path.slice(0, separator); +} - return enforceMaxPathLength(addExtension(filePath, "md")); +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 { @@ -62,14 +110,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(getPodcastNoteTitleCandidates(episode), 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 0000000..da5d714 --- /dev/null +++ b/src/utility/episodeTitleMatch.test.ts @@ -0,0 +1,77 @@ +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", + ), + ).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("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" }, + ]; + + expect( + findUniqueTitleMatch( + ["Dr. Martha Beck: Access Your Best Self"], + episodes, + (episode) => episode.title, + ), + ).toEqual(episodes[0]); + }); + + it("refuses two candidates at the same qualifying score", () => { + expect( + findUniqueTitleMatch( + ["Weekly News Roundup"], + [{ title: "Weekly News Roundup Extra" }, { title: "Weekly News Roundup Bonus" }], + (episode) => episode.title, + ), + ).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 new file mode 100644 index 0000000..afdf9d5 --- /dev/null +++ b/src/utility/episodeTitleMatch.ts @@ -0,0 +1,88 @@ +/** + * 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); +} + +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 = 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) { + 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`. 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[], + 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 !== 1) return undefined; + + return scored[0].candidate; +}