From da3b8641944e6d03164923e695279b46117e44f4 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 06:00:30 +0200 Subject: [PATCH 01/27] test(verify): rename the fixture feed's episodes on demand, keeping their GUIDs --- .claude/skills/verify/fixtures.mjs | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/.claude/skills/verify/fixtures.mjs b/.claude/skills/verify/fixtures.mjs index 954d5a3f..0f0c8bd0 100755 --- a/.claude/skills/verify/fixtures.mjs +++ b/.claude/skills/verify/fixtures.mjs @@ -5,6 +5,7 @@ // node .claude/skills/verify/fixtures.mjs feed start or reuse the feed server, print the feed URL // node .claude/skills/verify/fixtures.mjs feed-b same server, print a second feed whose episode titles repeat the first's // node .claude/skills/verify/fixtures.mjs transcripts same server, print a feed whose episodes publish transcripts +// node .claude/skills/verify/fixtures.mjs retitle rename the first feed's episodes, keeping their GUIDs, until the server stops // node .claude/skills/verify/fixtures.mjs requests print the paths the server has answered, oldest first // node .claude/skills/verify/fixtures.mjs audio [path] write a 60 s WAV into this worktree's vault // (default path: "Fixtures/Local Fixture.wav") @@ -57,12 +58,14 @@ function wav(seconds = 60) { return buffer; } -/** @param {number} audioBytes @param {string} title @param {string} prefix */ -function feedXml(audioBytes, title, prefix) { +const RETITLED = ["", "Renamed Opening Show", "Renamed Closing Show"]; + +/** @param {number} audioBytes @param {string} title @param {string} prefix @param {boolean} [retitled] */ +function feedXml(audioBytes, title, prefix, retitled = false) { /** @param {number} n @param {string} date */ const item = (n, date) => ` - Fixture Episode ${n} + ${retitled ? RETITLED[n] : `Fixture Episode ${n}`} podnotes-fixture${prefix}-${n} ${date} Fixture episode ${n} for PodNotes verification. @@ -154,6 +157,11 @@ function serve() { /** @type {string[]} */ const requests = []; const server = http.createServer((request, response) => { + if (request.method === "POST" && request.url === "/__retitle") { + routes["/feed.xml"][1] = Buffer.from(feedXml(audio.length, FEED_TITLE, "", true)); + response.end(); + return; + } if (request.method === "POST" && request.url === "/__stop") { server.close(); response.end(() => process.exit(0)); @@ -255,6 +263,16 @@ async function printRequests() { for (const line of /** @type {string[]} */ (await response.json())) console.log(line); } +async function retitleFeed() { + const response = await fetch(`${local}/__retitle`, { + method: "POST", + signal: AbortSignal.timeout(1000), + }); + if (!response.ok) + throw new Error("The fixture feed is not running. Run fixtures.mjs feed first."); + console.log(RETITLED.slice(1).join("\n")); +} + async function stopFeed() { await fetch(`${local}/__stop`, { method: "POST", @@ -268,12 +286,13 @@ try { else if (verb === "feed") await startFeed(feedUrl); else if (verb === "feed-b") await startFeed(feedBUrl); else if (verb === "transcripts") await startFeed(transcriptsUrl); + else if (verb === "retitle") await retitleFeed(); else if (verb === "requests") await printRequests(); else if (verb === "audio") await writeAudio(arg); else if (verb === "stop") await stopFeed(); else throw new Error( - "Usage: fixtures.mjs feed | feed-b | transcripts | requests | audio [vault-relative path] | stop", + "Usage: fixtures.mjs feed | feed-b | transcripts | retitle | requests | audio [vault-relative path] | stop", ); } catch (error) { console.error(error instanceof Error ? error.message : String(error)); From 1f07d8324ebe5389c3a1679601c01cf84b877dfb Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 05:55:01 +0200 Subject: [PATCH 02/27] feat(capture): capture into the last focused editor or the episode note Capture Timestamp, Capture Last 10/20 Seconds and the headphones' previous-track button now share one CaptureService. A capture goes to the focused editor, else to the markdown editor focused last (clicking in the PodNotes view leaves no active editor), else to the episode note, which is created from the note template when missing and appended to atomically. The three commands become plain callbacks so they run with no editor focused. Saved mobile toolbar entries are looked up by command id, so they keep working. --- src/commands.test.ts | 318 +++++++++++++++++++++++++++++ src/commands.ts | 73 +------ src/main.activateView.test.ts | 19 +- src/main.fileRenames.test.ts | 1 + src/main.ts | 80 +------- src/services/CaptureService.ts | 123 +++++++++++ tests/e2e/podnotes-runtime.test.ts | 17 +- tests/mocks/obsidian.ts | 7 + 8 files changed, 486 insertions(+), 152 deletions(-) create mode 100644 src/commands.test.ts create mode 100644 src/services/CaptureService.ts diff --git a/src/commands.test.ts b/src/commands.test.ts new file mode 100644 index 00000000..634d79ab --- /dev/null +++ b/src/commands.test.ts @@ -0,0 +1,318 @@ +// @vitest-environment jsdom +import * as obsidian from "obsidian"; +import { MarkdownView, TFile, TFolder, type WorkspaceLeaf } from "obsidian"; +import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from "vitest"; +import PodNotes from "./main"; +import { VIEW_TYPE } from "./constants"; +import type { Episode } from "./types/Episode"; +import { dataFileText } from "../tests/mocks/dataFileWriter"; + +const episode: Episode = { + title: "Saved Episode", + streamUrl: "https://example.com/saved.mp3", + url: "https://example.com/saved", + description: "", + content: "", + podcastName: "Pod", +}; +const playing = { + schemaVersion: 2, + currentEpisode: episode, + playedEpisodes: { + "Pod::Saved Episode": { + title: "Saved Episode", + podcastName: "Pod", + time: 1234, + duration: 3600, + finished: false, + }, + }, + timestamp: { template: "- {{time}}", offset: 0 }, + note: { path: "PodNotes/{{podcast}}/{{title}}.md", template: "# {{title}}" }, +}; +const NOTE_PATH = "PodNotes/Pod/Saved Episode.md"; + +function vaultFile(path: string): TFile { + const name = path.split("/").pop() ?? path; + return Object.assign(new TFile(), { + path, + name, + basename: name.replace(/\.md$/, ""), + extension: "md", + }); +} + +/** Enough of CodeMirror for a capture: text, a caret, and replaceSelection. */ +function fakeEditor(text: string, caret = text.length) { + let value = text; + let at = caret; + const lines = () => value.split("\n"); + return { + getValue: () => value, + getLine: (line: number) => lines()[line] ?? "", + lineCount: () => lines().length, + getCursor: () => { + const before = value.slice(0, at).split("\n"); + return { line: before.length - 1, ch: before[before.length - 1].length }; + }, + replaceSelection: (replacement: string) => { + value = value.slice(0, at) + replacement + value.slice(at); + at += replacement.length; + }, + }; +} + +function fakeVault() { + const notes = new Map(); + return { + notes, + read: (path: string) => notes.get(path)?.content, + vault: { + on: vi.fn(() => ({})), + getAbstractFileByPath: (path: string) => notes.get(path)?.file ?? null, + getRoot: () => Object.assign(new TFolder(), { path: "", children: [] }), + createFolder: async () => {}, + create: async (path: string, content: string) => { + const file = vaultFile(path); + notes.set(path, { file, content }); + return file; + }, + process: async (file: TFile, change: (content: string) => string) => { + const note = notes.get(file.path)!; + note.content = change(note.content); + return note.content; + }, + }, + }; +} + +function fakeWorkspace() { + const listeners = new Map void>>(); + const trigger = (name: string, ...args: unknown[]) => { + for (const listener of listeners.get(name) ?? []) listener(...args); + }; + const open: WorkspaceLeaf[] = []; + const podNotesLeaf = { view: { getViewType: () => VIEW_TYPE } } as unknown as WorkspaceLeaf; + const workspace = { + activeEditor: null as MarkdownView | null, + on: vi.fn((name: string, listener: (...args: unknown[]) => void) => { + listeners.set(name, [...(listeners.get(name) ?? []), listener]); + return {}; + }), + getLeavesOfType: (type: string) => (type === "markdown" ? [...open] : []), + getActiveViewOfType: () => null, + getRightLeaf: () => null, + revealLeaf: vi.fn(), + onLayoutReady: vi.fn(), + getLeaf: () => ({ openFile: vi.fn() }), + }; + + return { + workspace, + /** Opens a note and puts the caret in it. */ + openNote(path: string, text: string, caret?: number) { + const leaf = {} as WorkspaceLeaf; + const editor = fakeEditor(text, caret); + const view = Object.assign(new MarkdownView(leaf), { editor, file: vaultFile(path) }); + leaf.view = view; + open.push(leaf); + workspace.activeEditor = view; + trigger("active-leaf-change", leaf); + return { leaf, editor }; + }, + /** A click in the PodNotes view: its leaf is active and no editor is. */ + focusPodNotes() { + workspace.activeEditor = null; + trigger("active-leaf-change", podNotesLeaf); + }, + close(leaf: WorkspaceLeaf) { + open.splice(open.indexOf(leaf), 1); + if (workspace.activeEditor === leaf.view) workspace.activeEditor = null; + trigger("layout-change"); + }, + }; +} + +const loaded: PodNotes[] = []; + +async function loadPlugin(data: Record) { + const commands: obsidian.Command[] = []; + const { vault, read, notes } = fakeVault(); + const workspace = fakeWorkspace(); + const plugin = Object.create(PodNotes.prototype) as PodNotes; + Object.assign(plugin, { + loadData: vi.fn().mockResolvedValue(data), + saveData: vi.fn().mockResolvedValue(undefined), + addCommand: vi.fn((command: obsidian.Command) => commands.push(command)), + addRibbonIcon: vi.fn(() => document.createElement("div")), + addSettingTab: vi.fn(), + registerView: vi.fn(), + registerObsidianProtocolHandler: vi.fn(), + registerEvent: vi.fn(), + registerDomEvent: vi.fn(), + writeRefusal: null, + writeInFlight: null, + storeUnsubscribers: [], + views: new Set(), + manifest: { dir: ".obsidian/plugins/podnotes" }, + app: { + secretStorage: { + getSecret: vi.fn(() => null), + setSecret: vi.fn(), + listSecrets: vi.fn(() => []), + }, + loadLocalStorage: vi.fn(() => null), + saveLocalStorage: vi.fn(), + vault: { + ...vault, + adapter: { + exists: vi.fn(async () => true), + read: vi.fn(async () => + dataFileText(vi.mocked(plugin.saveData).mock.lastCall?.[0]), + ), + }, + }, + workspace: workspace.workspace, + }, + }); + await plugin.onload(); + loaded.push(plugin); + + const run = (id: string) => commands.find((command) => command.id === id)!.callback!(); + return { plugin, commands, run, read, notes, ...workspace }; +} + +let notice: MockInstance; + +beforeEach(() => { + // jsdom implements no media loading; unload releases the player's element. + vi.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {}); + notice = vi.spyOn(obsidian, "Notice"); +}); + +afterEach(() => { + for (const plugin of loaded.splice(0)) plugin.onunload(); + vi.restoreAllMocks(); +}); + +describe("capture", () => { + it("lands at the caret of the editor focused before the PodNotes view", async () => { + const { run, openNote, focusPodNotes, notes } = await loadPlugin(playing); + const { editor } = openNote("Journal.md", "Listening notes\n\nLater", 16); + focusPodNotes(); + + run("capture-timestamp"); + + expect(editor.getValue()).toBe("Listening notes\n- 00:20:34\nLater"); + expect(notes.size).toBe(0); + expect(notice).not.toHaveBeenCalled(); + }); + + it("keeps a table row intact when the caret is in a cell", async () => { + const { plugin, run, openNote, focusPodNotes } = await loadPlugin(playing); + plugin.settings.timestamp.template = "{{time}} | seen\n"; + const { editor } = openNote("Table.md", "| Time | Note |\n| --- | --- |\n| "); + focusPodNotes(); + + run("capture-timestamp"); + + expect(editor.getValue()).toBe("| Time | Note |\n| --- | --- |\n| 00:20:34 \\| seen "); + }); + + it("goes to the episode note once the last editor's tab is closed", async () => { + const { run, openNote, focusPodNotes, close, read } = await loadPlugin(playing); + const { leaf, editor } = openNote("Journal.md", ""); + focusPodNotes(); + close(leaf); + + run("capture-timestamp"); + + await vi.waitFor(() => expect(read(NOTE_PATH)).toBe("# Saved Episode\n- 00:20:34\n")); + expect(editor.getValue()).toBe(""); + }); + + it("creates the episode note from its template, then appends to it", async () => { + const { plugin, read } = await loadPlugin(playing); + + expect(await plugin.capture.timestamp()).toEqual({ + destination: "note", + path: NOTE_PATH, + text: "- 00:20:34\n", + created: true, + }); + expect(await plugin.capture.timestamp()).toEqual({ + destination: "note", + path: NOTE_PATH, + text: "- 00:20:34\n", + created: false, + }); + expect(read(NOTE_PATH)).toBe("# Saved Episode\n- 00:20:34\n- 00:20:34\n"); + expect(notice).not.toHaveBeenCalled(); + }); + + it("asks for an episode when none is loaded, and writes nothing", async () => { + const { run, openNote, notes } = await loadPlugin({ + ...playing, + currentEpisode: undefined, + }); + const { editor } = openNote("Journal.md", "Notes"); + + run("capture-timestamp"); + + expect(notice).toHaveBeenCalledWith("Play an episode to capture"); + expect(editor.getValue()).toBe("Notes"); + expect(notes.size).toBe(0); + }); + + describe("segments and the headphones' previous-track button", () => { + const handlers = new Map void>(); + const original = Object.getOwnPropertyDescriptor(window.navigator, "mediaSession"); + + beforeEach(() => { + Object.defineProperty(window.navigator, "mediaSession", { + configurable: true, + value: { + setActionHandler: (action: string, handler: (() => void) | null) => { + if (handler) handlers.set(action, handler); + else handlers.delete(action); + }, + setPositionState: () => {}, + }, + }); + }); + + afterEach(() => { + handlers.clear(); + if (original) Object.defineProperty(window.navigator, "mediaSession", original); + else Reflect.deleteProperty(window.navigator, "mediaSession"); + }); + + it("go to the editor focused before the PodNotes view", async () => { + const { plugin, run, openNote, focusPodNotes, notes } = await loadPlugin(playing); + plugin.settings.timestamp.template = "- {{time}}\n"; + const { editor } = openNote("Journal.md", ""); + focusPodNotes(); + + run("capture-segment-10s"); + run("capture-segment-20s"); + handlers.get("previoustrack")!(); + + expect(editor.getValue()).toBe( + "- 00:20:24-00:20:34\n- 00:20:14-00:20:34\n- 00:20:34\n", + ); + expect(notes.size).toBe(0); + }); + + it("go to the episode note with no editor open", async () => { + const { run, read } = await loadPlugin(playing); + + run("capture-segment-10s"); + await vi.waitFor(() => expect(read(NOTE_PATH)).toContain("00:20:34\n")); + handlers.get("previoustrack")!(); + + await vi.waitFor(() => + expect(read(NOTE_PATH)).toBe("# Saved Episode\n- 00:20:24-00:20:34\n- 00:20:34\n"), + ); + }); + }); +}); diff --git a/src/commands.ts b/src/commands.ts index 4b4cf841..89129b70 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,10 +1,6 @@ -import { Notice, type Editor } from "obsidian"; import { get } from "svelte/store"; import { queue, savedFeeds } from "src/store"; import { QueueReorderModal } from "src/ui/QueueReorderModal"; -import { TimestampTemplateEngine } from "src/TemplateEngine"; -import { prepareTimestampForInsertion } from "src/utility/prepareTimestampInsertion"; -import { createRecentPodcastSegment, getSegmentCaptureTemplate } from "src/utility/podcastSegment"; import createPodcastNote from "src/createPodcastNote"; import createFeedNote from "src/createFeedNote"; import { FeedSuggestModal, orderFeedsByCurrent } from "src/ui/FeedSuggestModal"; @@ -20,42 +16,6 @@ import type PodNotes from "src/main"; * live state off the passed plugin (api/settings/app) at invocation time. */ export function registerCommands(plugin: PodNotes): void { - const canCaptureTimestamp = () => !!plugin.api.podcast && !!plugin.settings.timestamp.template; - const insertCapture = (editor: Editor, capture: string) => { - // Insert with replaceSelection (not getCursor + replaceRange + - // setCursor): it drops the text at the live cursor and lets the - // editor place the caret after it, which is reliable inside Live - // Preview table cells where hand-computed positions land in the - // wrong cell. Inside a table the capture is escaped so pipes and - // newlines don't break the row. See issue #165. - const cursor = editor.getCursor("from"); - const textToInsert = prepareTimestampForInsertion(capture, { - getLine: (line) => editor.getLine(line), - lineCount: editor.lineCount(), - cursorLine: cursor.line, - }); - - editor.replaceSelection(textToInsert); - }; - const captureRecentSegment = (editor: Editor, lengthSeconds: number) => { - const segment = createRecentPodcastSegment( - plugin.api.currentTime, - lengthSeconds, - plugin.settings.timestamp.offset ?? 0, - ); - - if (!segment) { - new Notice("Play more of the episode before capturing a segment"); - return; - } - - const capture = TimestampTemplateEngine( - getSegmentCaptureTemplate(plugin.settings.timestamp.template), - { segment }, - ); - insertCapture(editor, capture); - }; - plugin.addCommand({ id: "podnotes-show-leaf", name: "Show player", @@ -158,23 +118,14 @@ export function registerCommands(plugin: PodNotes): void { }, }); + // Plain callbacks: with no editor focused a capture goes to the episode note, and + // a check would hide them from the mobile toolbar's picker until an episode loads. plugin.addCommand({ id: "capture-timestamp", name: "Capture Timestamp", icon: "clock", - // Keep this an editorCallback (not editorCheckCallback): an unconditional - // editor command stays addable to the mobile editor toolbar / command - // picker even before an episode is loaded, whereas a checkCallback that - // returns false would filter it out (and an e2e test pins this). Surface a - // Notice when capture isn't possible instead of silently no-opping (TS-01). - editorCallback: (editor) => { - if (plugin.captureTimestamp(editor)) { - return; - } - - new Notice( - "Play an episode and set a Capture timestamp format in settings to capture a timestamp.", - ); + callback: () => { + void plugin.capture.timestamp(); }, }); @@ -182,12 +133,8 @@ export function registerCommands(plugin: PodNotes): void { id: "capture-segment-10s", name: "Capture Last 10 Seconds", icon: "scissors", - editorCheckCallback: (checking, editor) => { - if (checking) { - return canCaptureTimestamp(); - } - - captureRecentSegment(editor, 10); + callback: () => { + void plugin.capture.segment(10); }, }); @@ -195,12 +142,8 @@ export function registerCommands(plugin: PodNotes): void { id: "capture-segment-20s", name: "Capture Last 20 Seconds", icon: "scissors", - editorCheckCallback: (checking, editor) => { - if (checking) { - return canCaptureTimestamp(); - } - - captureRecentSegment(editor, 20); + callback: () => { + void plugin.capture.segment(20); }, }); diff --git a/src/main.activateView.test.ts b/src/main.activateView.test.ts index 6e891fcc..df330e9d 100644 --- a/src/main.activateView.test.ts +++ b/src/main.activateView.test.ts @@ -280,6 +280,7 @@ describe("PodNotes onload wiring (#55)", () => { on: vi.fn(() => ({})), getLeavesOfType: vi.fn(() => []), getRightLeaf: vi.fn(() => null), + getActiveViewOfType: vi.fn(() => null), revealLeaf: vi.fn(), detachLeavesOfType: vi.fn(), }, @@ -388,18 +389,16 @@ describe("PodNotes onload wiring (#55)", () => { expect(showCmd?.checkCallback).toBeUndefined(); }); - it("registers Capture Timestamp as an editor callback so the mobile toolbar can add it", async () => { - // Must stay an editorCallback (not editorCheckCallback): an unconditional - // editor command remains addable to the mobile editor toolbar / command - // picker before any episode is loaded; it surfaces a Notice when capture - // isn't possible rather than no-opping silently (TS-01 / Codex review #213). + it("registers the capture commands as plain callbacks, so they run with no editor focused", async () => { const { commands } = await loadPlugin(); + const kinds = ["callback", "checkCallback", "editorCallback", "editorCheckCallback"]; - const captureCmd = commands.find((c) => c.id === "capture-timestamp"); - expect(captureCmd).toBeDefined(); - expect(typeof captureCmd?.editorCallback).toBe("function"); - expect(captureCmd?.editorCheckCallback).toBeUndefined(); - expect(captureCmd?.checkCallback).toBeUndefined(); + for (const id of ["capture-timestamp", "capture-segment-10s", "capture-segment-20s"]) { + const command = commands.find((c) => c.id === id); + expect(kinds.filter((kind) => typeof command?.[kind] === "function")).toEqual([ + "callback", + ]); + } }); it("registers playback-rate commands for hotkeys", async () => { diff --git a/src/main.fileRenames.test.ts b/src/main.fileRenames.test.ts index c1c3c41b..774541e6 100644 --- a/src/main.fileRenames.test.ts +++ b/src/main.fileRenames.test.ts @@ -102,6 +102,7 @@ async function loadPlugin(data: Record): Promise { on: vi.fn(() => ({})), getLeavesOfType: vi.fn(() => []), getRightLeaf: vi.fn(() => null), + getActiveViewOfType: vi.fn(() => null), revealLeaf: vi.fn(), }, }, diff --git a/src/main.ts b/src/main.ts index f94a2f61..49610727 100644 --- a/src/main.ts +++ b/src/main.ts @@ -12,7 +12,7 @@ import { } from "src/store"; import { bindStoresToSettings } from "src/store/persistence"; import { registerCommands } from "src/commands"; -import { normalizePath, Notice, Platform, Plugin, type Editor, type WorkspaceLeaf } from "obsidian"; +import { normalizePath, Notice, Platform, Plugin, type WorkspaceLeaf } from "obsidian"; import { API } from "src/API/API"; import type { IAPI } from "src/API/IAPI"; import { VIEW_TYPE } from "src/constants"; @@ -20,14 +20,12 @@ import { PodNotesSettingsTab } from "src/ui/settings/PodNotesSettingsTab"; import { MainView } from "src/ui/PodcastView"; import type { IPodNotesSettings } from "./types/IPodNotesSettings"; import type { IPodNotes } from "./types/IPodNotes"; -import { TimestampTemplateEngine } from "./TemplateEngine"; -import { prepareTimestampForInsertion } from "./utility/prepareTimestampInsertion"; -import { createPodcastNoteFileIfNotExists, getPodcastNote } from "./createPodcastNote"; import type PartialAppExtension from "./global"; import podNotesURIHandler from "./URIHandler"; import getContextMenuHandler from "./getContextMenuHandler"; import getVaultRenameHandler from "./getVaultRenameHandler"; import { TranscriptionService } from "./services/TranscriptionService"; +import { CaptureService } from "./services/CaptureService"; import type { Unsubscriber } from "svelte/store"; import { PlayerController } from "./player/PlayerController"; import { bindMediaSession } from "./player/mediaSession"; @@ -100,6 +98,7 @@ export default class PodNotes extends Plugin implements IPodNotes { public feedUrls!: FeedUrlRepository; public player!: PlayerController; public feeds!: FeedRepository; + public capture!: CaptureService; private views = new Set(); @@ -188,6 +187,7 @@ export default class PodNotes extends Plugin implements IPodNotes { ); this.api = new API(); + this.capture = new CaptureService(this); // All user-facing commands live in src/commands.ts; this keeps onload // focused on lifecycle wiring (stores, view, ribbon, protocol, media session). @@ -233,7 +233,7 @@ export default class PodNotes extends Plugin implements IPodNotes { bindMediaSession(this.player, { mediaSession: window.navigator.mediaSession, MediaMetadata: window.MediaMetadata, - captureTimestamp: () => void this.captureTimestampFromMediaSession(), + captureTimestamp: () => void this.capture.timestamp(), skipBackward: () => this.api.skipBackward(), skipForward: () => this.api.skipForward(), }), @@ -340,9 +340,7 @@ export default class PodNotes extends Plugin implements IPodNotes { return Platform.isMobileApp || this.app.isMobile === true; } - // Public so the command registrations in src/commands.ts can reach them; both - // are still only invoked from command callbacks (and, for captureTimestamp, - // the media-session handler below). + // Public so the command registrations in src/commands.ts can reach it. getTranscriptionService(): TranscriptionService { if (!this.transcriptionService) { this.transcriptionService = new TranscriptionService(this); @@ -351,72 +349,6 @@ export default class PodNotes extends Plugin implements IPodNotes { return this.transcriptionService; } - captureTimestamp(editor: Editor | null | undefined): boolean { - if (!editor || !this.api.podcast || !this.settings.timestamp.template) { - return false; - } - - const capture = TimestampTemplateEngine(this.settings.timestamp.template); - - // Insert with replaceSelection (not getCursor + replaceRange + setCursor): - // it drops the text at the live cursor and lets the editor place the caret - // after it, which is reliable inside Live Preview table cells where - // hand-computed positions land in the wrong cell. Inside a table the capture - // is escaped so pipes and newlines don't break the row. See issue #165. - const cursor = editor.getCursor("from"); - const textToInsert = prepareTimestampForInsertion(capture, { - getLine: (line) => editor.getLine(line), - lineCount: editor.lineCount(), - cursorLine: cursor.line, - }); - - editor.replaceSelection(textToInsert); - return true; - } - - private captureTimestampInActiveEditor(): boolean { - return this.captureTimestamp(this.app.workspace.activeEditor?.editor); - } - - private async appendTimestampToEpisodeNote(): Promise { - if ( - !this.api.podcast || - !this.settings.timestamp.template || - !this.settings.note.path || - !this.settings.note.template - ) { - return false; - } - - // This path runs from the media-session (headphone) handler, where there is - // no editor to surface a failure, so swallowing an error left a button - // press looking like it did nothing. Report it instead (NT-13). - try { - const capture = TimestampTemplateEngine(this.settings.timestamp.template); - const textToAppend = capture.endsWith("\n") ? capture : `${capture}\n`; - const file = - getPodcastNote(this.api.podcast) ?? - (await createPodcastNoteFileIfNotExists(this.api.podcast)); - const content = await this.app.vault.read(file); - const separator = content.length > 0 && !content.endsWith("\n") ? "\n" : ""; - - await this.app.vault.modify(file, `${content}${separator}${textToAppend}`); - return true; - } catch (error) { - console.error("PodNotes: failed to capture timestamp into episode note", error); - new Notice("Failed to capture timestamp into episode note"); - return false; - } - } - - private async captureTimestampFromMediaSession(): Promise { - if (this.captureTimestampInActiveEditor()) { - return true; - } - - return await this.appendTimestampToEpisodeNote(); - } - override onunload() { // While the player and the store bindings still save what a rename changes. this.closeRenames?.(); diff --git a/src/services/CaptureService.ts b/src/services/CaptureService.ts new file mode 100644 index 00000000..c034fb41 --- /dev/null +++ b/src/services/CaptureService.ts @@ -0,0 +1,123 @@ +import { MarkdownView, Notice, type MarkdownFileInfo, type WorkspaceLeaf } from "obsidian"; +import { TimestampTemplateEngine } from "src/TemplateEngine"; +import { createPodcastNoteFileIfNotExists, getPodcastNote } from "src/createPodcastNote"; +import { prepareTimestampForInsertion } from "src/utility/prepareTimestampInsertion"; +import { createRecentPodcastSegment, getSegmentCaptureTemplate } from "src/utility/podcastSegment"; +import type PodNotes from "src/main"; + +export interface Capture { + destination: "editor" | "note"; + /** Vault path of the note the text went into. */ + path: string; + /** The text as inserted. */ + text: string; + /** The episode note did not exist and was created for this capture. */ + created: boolean; +} + +/** Puts a capture where the user writes: the editor they used last, else the episode note. */ +export class CaptureService { + // Clicking in the PodNotes view leaves workspace.activeEditor null. + private lastMarkdownLeaf: WorkspaceLeaf | null; + + constructor(private readonly plugin: PodNotes) { + const { workspace } = plugin.app; + this.lastMarkdownLeaf = workspace.getActiveViewOfType(MarkdownView)?.leaf ?? null; + plugin.registerEvent( + workspace.on("active-leaf-change", (leaf) => { + if (leaf?.view instanceof MarkdownView) this.lastMarkdownLeaf = leaf; + }), + ); + plugin.registerEvent( + workspace.on("layout-change", () => { + if (!this.lastMarkdownView()) this.lastMarkdownLeaf = null; + }), + ); + } + + timestamp(): Promise { + return this.capture((template) => TimestampTemplateEngine(template)); + } + + segment(seconds: number): Promise { + return this.capture((template) => { + const segment = createRecentPodcastSegment( + this.plugin.api.currentTime, + seconds, + this.plugin.settings.timestamp.offset ?? 0, + ); + if (!segment) { + new Notice("Play more of the episode before capturing a segment"); + return null; + } + return TimestampTemplateEngine(getSegmentCaptureTemplate(template), { segment }); + }); + } + + private async capture(render: (template: string) => string | null): Promise { + const { api, settings } = this.plugin; + const episode = api.podcast; + if (!episode) { + new Notice("Play an episode to capture"); + return null; + } + if (!settings.timestamp.template) { + new Notice("Set a Capture timestamp format in settings to capture a timestamp."); + return null; + } + const text = render(settings.timestamp.template); + if (text === null) return null; + + const target = this.editorTarget(); + if (target?.editor) { + const { editor } = target; + // replaceSelection lets the editor place the caret, which hand-computed + // positions get wrong in Live Preview table cells. Inside a table the text is + // escaped so pipes and newlines don't break the row (#165). + const inserted = prepareTimestampForInsertion(text, { + getLine: (line) => editor.getLine(line), + lineCount: editor.lineCount(), + cursorLine: editor.getCursor("from").line, + }); + editor.replaceSelection(inserted); + return { + destination: "editor", + path: target.file?.path ?? "", + text: inserted, + created: false, + }; + } + + if (!settings.note.path || !settings.note.template) { + new Notice("Set an episode note path and template to capture timestamps"); + return null; + } + try { + const existing = getPodcastNote(episode); + const file = existing ?? (await createPodcastNoteFileIfNotExists(episode)); + const appended = text.endsWith("\n") ? text : `${text}\n`; + await this.plugin.app.vault.process(file, (content) => + content.length > 0 && !content.endsWith("\n") + ? `${content}\n${appended}` + : `${content}${appended}`, + ); + return { destination: "note", path: file.path, text: appended, created: !existing }; + } catch (error) { + console.error("PodNotes: failed to capture timestamp into episode note", error); + new Notice("Failed to capture timestamp into episode note"); + return null; + } + } + + /** The focused editor, which covers canvas and embedded editors, else the last markdown one. */ + private editorTarget(): MarkdownFileInfo | null { + const active = this.plugin.app.workspace.activeEditor; + return active?.editor ? active : this.lastMarkdownView(); + } + + private lastMarkdownView(): MarkdownView | null { + const leaf = this.lastMarkdownLeaf; + const open = leaf && this.plugin.app.workspace.getLeavesOfType("markdown").includes(leaf); + return open && leaf.view instanceof MarkdownView ? leaf.view : null; + } +} diff --git a/tests/e2e/podnotes-runtime.test.ts b/tests/e2e/podnotes-runtime.test.ts index bb43995c..ff9b8d9c 100644 --- a/tests/e2e/podnotes-runtime.test.ts +++ b/tests/e2e/podnotes-runtime.test.ts @@ -27,6 +27,7 @@ describe("PodNotes runtime", () => { await openPodNotesView(obsidian); const state = await obsidian.dev.evalJson<{ + captureUsesCallback: boolean; captureUsesEditorCallback: boolean; captureUsesEditorCheckCallback: boolean; hasCaptureSegment10Command: boolean; @@ -37,6 +38,8 @@ describe("PodNotes runtime", () => { viewCount: number; }>(` (() => ({ + captureUsesCallback: + typeof app.commands?.commands?.[${JSON.stringify(`${PLUGIN_ID}:capture-timestamp`)}]?.callback === "function", captureUsesEditorCallback: typeof app.commands?.commands?.[${JSON.stringify(`${PLUGIN_ID}:capture-timestamp`)}]?.editorCallback === "function", captureUsesEditorCheckCallback: @@ -55,7 +58,8 @@ describe("PodNotes runtime", () => { `); expect(state).toMatchObject({ - captureUsesEditorCallback: true, + captureUsesCallback: true, + captureUsesEditorCallback: false, captureUsesEditorCheckCallback: false, hasCaptureSegment10Command: true, hasCaptureSegment20Command: true, @@ -322,7 +326,7 @@ describe("PodNotes runtime", () => { } }); - test("previous-track Media Session action appends to the episode note without an active editor", async () => { + test("previous-track Media Session action appends to the episode note with no editor open", async () => { const { obsidian, plugin, sandbox } = getContext(); const audioPath = await seedAudio(obsidian, sandbox); const episode = createLocalEpisode("E2E Background Capture Episode", audioPath); @@ -339,11 +343,18 @@ describe("PodNotes runtime", () => { await installMediaSessionRecorder(obsidian); try { await waitForPodNotesReady(obsidian); + await obsidian.dev.evalJson(` + (() => { + app.workspace.detachLeavesOfType("markdown"); + return true; + })() + `); await openPodNotesView(obsidian); await setPlayback(obsidian, { currentTime: 185, paused: true }); const noEditor = await obsidian.dev.evalJson(` - !app.workspace.activeEditor?.editor + !app.workspace.activeEditor?.editor && + app.workspace.getLeavesOfType("markdown").length === 0 `); expect(noEditor).toBe(true); diff --git a/tests/mocks/obsidian.ts b/tests/mocks/obsidian.ts index eb734074..3956b2ca 100644 --- a/tests/mocks/obsidian.ts +++ b/tests/mocks/obsidian.ts @@ -63,6 +63,13 @@ export const normalizePath = (path: string): string => { export class WorkspaceLeaf {} export class ItemView {} +export class MarkdownView { + editor: unknown; + file: TFile | null = null; + + constructor(public leaf: WorkspaceLeaf) {} +} + export class Modal { app: App; containerEl: HTMLElement; From 5ab2a1ddb9d0479d1130d8e0b9bb5e1717647c9a Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 05:58:11 +0200 Subject: [PATCH 03/27] feat(notes): create an episode note without playing, from a podcast and episode picker Create episode note is now available with nothing loaded once a podcast is saved. It asks for a podcast, loads its feed, asks for an episode in feed order and creates that episode's note, without touching the player. With an episode loaded it creates that episode's note as before. FeedSuggestModal becomes the generic PickerModal, shared by the feed note and episode note commands. --- src/commands.test.ts | 111 +++++++++++++++++- src/commands.ts | 70 ++++++++--- src/ui/FeedSuggestModal.ts | 47 -------- ...ggestModal.test.ts => PickerModal.test.ts} | 2 +- src/ui/PickerModal.ts | 43 +++++++ tests/mocks/obsidian.ts | 6 +- 6 files changed, 215 insertions(+), 64 deletions(-) delete mode 100644 src/ui/FeedSuggestModal.ts rename src/ui/{FeedSuggestModal.test.ts => PickerModal.test.ts} (93%) create mode 100644 src/ui/PickerModal.ts diff --git a/src/commands.test.ts b/src/commands.test.ts index 634d79ab..11741b97 100644 --- a/src/commands.test.ts +++ b/src/commands.test.ts @@ -1,12 +1,20 @@ // @vitest-environment jsdom import * as obsidian from "obsidian"; import { MarkdownView, TFile, TFolder, type WorkspaceLeaf } from "obsidian"; +import { get } from "svelte/store"; import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from "vitest"; import PodNotes from "./main"; import { VIEW_TYPE } from "./constants"; import type { Episode } from "./types/Episode"; +import { PickerModal } from "./ui/PickerModal"; +import { fetchTextWithTimeout } from "./utility/networkRequest"; import { dataFileText } from "../tests/mocks/dataFileWriter"; +vi.mock("./utility/networkRequest", async (importOriginal) => ({ + ...(await importOriginal()), + fetchTextWithTimeout: vi.fn(), +})); + const episode: Episode = { title: "Saved Episode", streamUrl: "https://example.com/saved.mp3", @@ -92,6 +100,7 @@ function fakeWorkspace() { for (const listener of listeners.get(name) ?? []) listener(...args); }; const open: WorkspaceLeaf[] = []; + const opened: string[] = []; const podNotesLeaf = { view: { getViewType: () => VIEW_TYPE } } as unknown as WorkspaceLeaf; const workspace = { activeEditor: null as MarkdownView | null, @@ -104,11 +113,13 @@ function fakeWorkspace() { getRightLeaf: () => null, revealLeaf: vi.fn(), onLayoutReady: vi.fn(), - getLeaf: () => ({ openFile: vi.fn() }), + getLeaf: () => ({ openFile: async (file: TFile) => opened.push(file.path) }), }; return { workspace, + /** Paths of the files opened in a tab. */ + opened, /** Opens a note and puts the caret in it. */ openNote(path: string, text: string, caret?: number) { const leaf = {} as WorkspaceLeaf; @@ -316,3 +327,101 @@ describe("capture", () => { }); }); }); + +describe("Create episode note", () => { + const feed = { title: "Pod", url: "https://feeds.test/pod.xml", artworkUrl: "" }; + const withFeed = { schemaVersion: 2, note: playing.note, savedFeeds: { Pod: feed } }; + const rss = (items: string[]) => + `Pod${items + .map( + (title) => + `${title}`, + ) + .join("")}`; + const command = (commands: obsidian.Command[]) => + commands.find(({ id }) => id === "create-podcast-note")!.checkCallback!; + + /** The pickers opened so far, as the user sees them. */ + function watchPickers() { + const open = vi.spyOn(PickerModal.prototype, "open"); + const shown = (index: number) => { + const modal = open.mock.contexts[index] as PickerModal; + const items = modal.getItems(); + const texts = items.map((item) => modal.getItemText(item)); + return { + placeholder: modal.inputEl.placeholder, + texts, + choose: (text: string) => modal.onChooseItem(items[texts.indexOf(text)]), + }; + }; + return { open, shown }; + } + + it("picks a podcast, then one of its episodes, and creates that episode's note without playing it", async () => { + vi.mocked(fetchTextWithTimeout).mockResolvedValue(rss(["Two", "One"])); + const pickers = watchPickers(); + const { plugin, commands, read, opened } = await loadPlugin(withFeed); + + expect(command(commands)(true)).toBe(true); + command(commands)(false); + + expect(pickers.shown(0)).toMatchObject({ placeholder: "Select a podcast", texts: ["Pod"] }); + pickers.shown(0).choose("Pod"); + + await vi.waitFor(() => expect(pickers.open).toHaveBeenCalledTimes(2)); + expect(pickers.shown(1)).toMatchObject({ + placeholder: "Select an episode", + texts: ["Two", "One"], + }); + pickers.shown(1).choose("One"); + + await vi.waitFor(() => expect(opened).toEqual(["PodNotes/Pod/One.md"])); + expect(read("PodNotes/Pod/One.md")).toBe("# One"); + expect(plugin.api.podcast).toBeUndefined(); + expect(get(plugin.player.state).status).toBe("empty"); + }); + + it.each([ + [ + "cannot be loaded", + () => vi.mocked(fetchTextWithTimeout).mockRejectedValue(new Error("offline")), + "Could not load the podcast feed", + ], + [ + "has no episodes", + () => vi.mocked(fetchTextWithTimeout).mockResolvedValue(rss([])), + "No episodes in this podcast", + ], + ])("says so when the podcast %s", async (_, respond, message) => { + respond(); + const pickers = watchPickers(); + const { commands, notes } = await loadPlugin(withFeed); + + command(commands)(false); + pickers.shown(0).choose("Pod"); + + await vi.waitFor(() => expect(notice).toHaveBeenCalledWith(message)); + expect(pickers.open).toHaveBeenCalledTimes(1); + expect(notes.size).toBe(0); + }); + + it("creates the loaded episode's note without a picker", async () => { + const pickers = watchPickers(); + const { commands, read, opened } = await loadPlugin({ + ...playing, + savedFeeds: { Pod: feed }, + }); + + command(commands)(false); + + await vi.waitFor(() => expect(opened).toEqual([NOTE_PATH])); + expect(read(NOTE_PATH)).toBe("# Saved Episode"); + expect(pickers.open).not.toHaveBeenCalled(); + }); + + it("is unavailable with nothing loaded and no saved podcast", async () => { + const { commands } = await loadPlugin({ schemaVersion: 2, note: playing.note }); + + expect(command(commands)(true)).toBe(false); + }); +}); diff --git a/src/commands.ts b/src/commands.ts index 89129b70..e108270f 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,14 +1,16 @@ +import { Notice } from "obsidian"; import { get } from "svelte/store"; import { queue, savedFeeds } from "src/store"; import { QueueReorderModal } from "src/ui/QueueReorderModal"; import createPodcastNote from "src/createPodcastNote"; import createFeedNote from "src/createFeedNote"; -import { FeedSuggestModal, orderFeedsByCurrent } from "src/ui/FeedSuggestModal"; +import { PickerModal, orderFeedsByCurrent } from "src/ui/PickerModal"; import downloadEpisodeWithNotice from "src/downloadEpisode"; import getUniversalPodcastLink from "src/getUniversalPodcastLink"; import { getEpisodeMediaType } from "src/utility/mediaType"; import { TRANSCRIBE_FROM_AUDIO_COMMAND } from "src/services/TranscriptionService"; import type PodNotes from "src/main"; +import type { PodcastFeed } from "src/types/PodcastFeed"; /** * Registers every PodNotes command on the plugin. Extracted from main.onload so @@ -149,22 +151,34 @@ export function registerCommands(plugin: PodNotes): void { plugin.addCommand({ id: "create-podcast-note", - // Despite the id, this creates a note for the CURRENT EPISODE. The - // visible name was corrected to disambiguate it from the feed-level - // "Create podcast feed note" command below (issue #163). The id is kept - // for backward compatibility (hotkeys/API). + // Despite the id, this creates an episode note: the loaded episode's, else one + // picked from a saved podcast. The visible name was corrected to disambiguate + // it from the feed-level "Create podcast feed note" command below (issue + // #163). The id is kept for backward compatibility (hotkeys/API). name: "Create episode note", icon: "file-plus", checkCallback: (checking) => { + const { path, template } = plugin.settings.note; + const episode = plugin.api.podcast; + const feeds = Object.values(get(savedFeeds)); if (checking) { - return ( - !!plugin.api.podcast && - !!plugin.settings.note.path && - !!plugin.settings.note.template - ); + return !!path && !!template && (!!episode || feeds.length > 0); + } + + if (episode) { + void createPodcastNote(episode); + return; } - void createPodcastNote(plugin.api.podcast); + new PickerModal( + plugin.app, + feeds, + (feed) => feed.title, + "Select a podcast", + (feed) => { + void pickEpisodeForNote(plugin, feed); + }, + ).open(); }, }); @@ -189,9 +203,15 @@ export function registerCommands(plugin: PodNotes): void { // picker opens on the most likely choice without requiring playback. const orderedFeeds = orderFeedsByCurrent(feeds, plugin.api.podcast?.podcastName); - new FeedSuggestModal(plugin.app, orderedFeeds, (feed) => { - void createFeedNote(feed); - }).open(); + new PickerModal( + plugin.app, + orderedFeeds, + (feed) => feed.title, + "Select a podcast to create or open its note", + (feed) => { + void createFeedNote(feed); + }, + ).open(); }, }); @@ -291,3 +311,25 @@ export function registerCommands(plugin: PodNotes): void { }, }); } + +async function pickEpisodeForNote(plugin: PodNotes, feed: PodcastFeed): Promise { + await plugin.feeds.load([feed.title], "cached"); + const state = get(plugin.feeds).get(feed.title); + const episodes = state?.episodes ?? []; + if (episodes.length === 0) { + new Notice( + state?.error ? "Could not load the podcast feed" : "No episodes in this podcast", + ); + return; + } + + new PickerModal( + plugin.app, + episodes, + (episode) => episode.title, + "Select an episode", + (episode) => { + void createPodcastNote(episode); + }, + ).open(); +} diff --git a/src/ui/FeedSuggestModal.ts b/src/ui/FeedSuggestModal.ts deleted file mode 100644 index 4ffc37be..00000000 --- a/src/ui/FeedSuggestModal.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { type App, FuzzySuggestModal } from "obsidian"; -import type { PodcastFeed } from "src/types/PodcastFeed"; - -/** - * Order feeds so the currently-playing podcast's feed (if any) sorts first, so - * the picker opens on the most likely choice. Pure, for testability. - */ -export function orderFeedsByCurrent( - feeds: PodcastFeed[], - currentPodcastName?: string, -): PodcastFeed[] { - if (!currentPodcastName) return feeds; - - return [ - ...feeds.filter((feed) => feed.title === currentPodcastName), - ...feeds.filter((feed) => feed.title !== currentPodcastName), - ]; -} - -/** - * Fuzzy picker over the user's saved podcast feeds, used by the "Create podcast - * feed note" command so a feed note can be created without playing an episode - * (issue #161). - */ -export class FeedSuggestModal extends FuzzySuggestModal { - private feeds: PodcastFeed[]; - private onChoose: (feed: PodcastFeed) => void; - - constructor(app: App, feeds: PodcastFeed[], onChoose: (feed: PodcastFeed) => void) { - super(app); - this.feeds = feeds; - this.onChoose = onChoose; - this.setPlaceholder("Select a podcast to create or open its note"); - } - - getItems(): PodcastFeed[] { - return this.feeds; - } - - getItemText(feed: PodcastFeed): string { - return feed.title; - } - - onChooseItem(feed: PodcastFeed): void { - this.onChoose(feed); - } -} diff --git a/src/ui/FeedSuggestModal.test.ts b/src/ui/PickerModal.test.ts similarity index 93% rename from src/ui/FeedSuggestModal.test.ts rename to src/ui/PickerModal.test.ts index 93582a06..51589533 100644 --- a/src/ui/FeedSuggestModal.test.ts +++ b/src/ui/PickerModal.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { orderFeedsByCurrent } from "./FeedSuggestModal"; +import { orderFeedsByCurrent } from "./PickerModal"; import type { PodcastFeed } from "src/types/PodcastFeed"; const feed = (title: string): PodcastFeed => ({ diff --git a/src/ui/PickerModal.ts b/src/ui/PickerModal.ts new file mode 100644 index 00000000..c916896d --- /dev/null +++ b/src/ui/PickerModal.ts @@ -0,0 +1,43 @@ +import { type App, FuzzySuggestModal } from "obsidian"; +import type { PodcastFeed } from "src/types/PodcastFeed"; + +/** + * Order feeds so the currently-playing podcast's feed (if any) sorts first, so + * the picker opens on the most likely choice. Pure, for testability. + */ +export function orderFeedsByCurrent( + feeds: PodcastFeed[], + currentPodcastName?: string, +): PodcastFeed[] { + if (!currentPodcastName) return feeds; + + return [ + ...feeds.filter((feed) => feed.title === currentPodcastName), + ...feeds.filter((feed) => feed.title !== currentPodcastName), + ]; +} + +export class PickerModal extends FuzzySuggestModal { + constructor( + app: App, + private readonly items: readonly T[], + private readonly getText: (item: T) => string, + placeholder: string, + private readonly onChoose: (item: T) => void, + ) { + super(app); + this.setPlaceholder(placeholder); + } + + getItems(): T[] { + return [...this.items]; + } + + getItemText(item: T): string { + return this.getText(item); + } + + onChooseItem(item: T): void { + this.onChoose(item); + } +} diff --git a/tests/mocks/obsidian.ts b/tests/mocks/obsidian.ts index 3956b2ca..30cef8d1 100644 --- a/tests/mocks/obsidian.ts +++ b/tests/mocks/obsidian.ts @@ -103,7 +103,11 @@ export class Modal { export class SuggestModal extends Modal { declare protected readonly itemType?: T; - setPlaceholder(_placeholder: string): void {} + inputEl = document.createElement("input"); + + setPlaceholder(placeholder: string): void { + this.inputEl.placeholder = placeholder; + } } export class FuzzySuggestModal extends SuggestModal { From 697c427dc742a677e8eb47369f9a672d3d4c9bcb Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 05:51:08 +0200 Subject: [PATCH 04/27] feat(episodes): carry the feed GUID --- scripts/persistence-size.test.ts | 6 ++- src/persistence/episodeCodec.ts | 1 + src/persistence/podNotesData.test.ts | 56 ++++++++++++++++++++++++++++ src/services/FeedRepository.test.ts | 19 +++++++--- src/services/FeedRepository.ts | 1 + src/types/Episode.ts | 2 + src/utility/findByGuid.ts | 9 +++++ tests/fixtures/persistence/README.md | 5 ++- 8 files changed, 90 insertions(+), 9 deletions(-) create mode 100644 src/utility/findByGuid.ts diff --git a/scripts/persistence-size.test.ts b/scripts/persistence-size.test.ts index 671f97ad..19c585f4 100644 --- a/scripts/persistence-size.test.ts +++ b/scripts/persistence-size.test.ts @@ -54,9 +54,11 @@ describe("persistence byte fixture", () => { }); expect(await feeds.channelAt({ url: sample.source.url })).toEqual(sample.feed); expect(JSON.parse(JSON.stringify(await feeds.episodesAt(sample.source.url)))).toEqual( - sample.episodes, + sample.episodes.map((episode: object, index: number) => ({ + ...episode, + guid: sample.guids[index], + })), ); - expect(parsed.episodes.map((episode) => episode.guid)).toEqual(sample.guids); }); it("retains a complete stress library through the released v2 codec", () => { diff --git a/src/persistence/episodeCodec.ts b/src/persistence/episodeCodec.ts index 0b709ab5..cb3caee5 100644 --- a/src/persistence/episodeCodec.ts +++ b/src/persistence/episodeCodec.ts @@ -46,6 +46,7 @@ export function decodeEpisode( decoded.podcastName = readString(value, "podcastName", "", warnings, path); setOptionalString(decoded, value, "feedUrl", warnings, path); + setOptionalString(decoded, value, "guid", warnings, path); setOptionalString(decoded, value, "artworkUrl", warnings, path); setOptionalString(decoded, value, "itunesTitle", warnings, path); setOptionalString(decoded, value, "chaptersUrl", warnings, path); diff --git a/src/persistence/podNotesData.test.ts b/src/persistence/podNotesData.test.ts index 2bb0c3f9..ef870281 100644 --- a/src/persistence/podNotesData.test.ts +++ b/src/persistence/podNotesData.test.ts @@ -10,6 +10,7 @@ import { PODNOTES_DATA_SCHEMA_VERSION, PodNotesDataError, } from "./podNotesData"; +import released2211 from "../../tests/fixtures/persistence/released-2.21.1.json"; const episodeDate = new Date("2024-03-01T10:05:03.000Z"); @@ -25,6 +26,10 @@ function episode(title: string): Episode { }; } +function isEpisodeRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && "streamUrl" in value; +} + function playlist(name: string, item: Episode): Playlist { return { name, @@ -488,6 +493,57 @@ describe("PodNotes data schema", () => { expect(decoded.settings.downloadedEpisodes["Old Podcast"][0].size).toBe(0); }); + it("keeps an episode's guid in every persisted episode container through load and save", () => { + const stored = JSON.parse( + JSON.stringify(encodePodNotesData(settingsWithEveryEpisodeContainer())), + (_key, value: unknown) => + isEpisodeRecord(value) ? { ...value, guid: `guid-${value.title}` } : value, + ) as Record; + + const decoded = decodePodNotesData(stored); + const { settings } = decoded; + + expect(decoded.warnings).toEqual([]); + expect( + [ + settings.currentEpisode, + settings.queue.episodes[0], + settings.favorites.episodes[0], + settings.playlists.Custom.currentEpisode, + settings.downloadedEpisodes["Test Podcast"][0], + ].map((saved) => saved?.guid), + ).toEqual([ + "guid-Current", + "guid-Queued", + "guid-Favorite", + "guid-Custom", + "guid-Downloaded", + ]); + expect( + JSON.parse(JSON.stringify(encodePodNotesData(settings, decoded.unknownFields))), + ).toEqual(stored); + }); + + it("drops a guid that is not a string", () => { + const decoded = decodePodNotesData({ + currentEpisode: { ...episode("Restored"), guid: 12345 }, + }); + + expect(decoded.settings.currentEpisode).toMatchObject({ title: "Restored" }); + expect(decoded.settings.currentEpisode).not.toHaveProperty("guid"); + expect(decoded.warnings).toEqual(["currentEpisode.guid: expected a string"]); + }); + + it("loads a 2.21.1 library unchanged", () => { + const decoded = decodePodNotesData(structuredClone(released2211)); + + expect(decoded.warnings).toEqual([]); + expect(decoded.changed).toBe(false); + expect( + JSON.parse(JSON.stringify(encodePodNotesData(decoded.settings, decoded.unknownFields))), + ).toEqual({ ...released2211, libraryId: "" }); + }); + const storedDownload = (downloadedByPodNotes: unknown) => ({ ...episode("Downloaded"), episodeDate: episodeDate.toISOString(), diff --git a/src/services/FeedRepository.test.ts b/src/services/FeedRepository.test.ts index 8c9dca66..6cc6acac 100644 --- a/src/services/FeedRepository.test.ts +++ b/src/services/FeedRepository.test.ts @@ -458,16 +458,25 @@ describe("FeedRepository", () => { }); }); - test("episodes saved to data.json carry no feed-only metadata", async () => { + test("episodes saved to data.json carry their item's guid but not its transcripts", async () => { const t = setup(); const transcripts = [{ url: "https://media.test/1.vtt", type: "text/vtt" }]; - await loaded(t, { Alpha: alpha }, doc("Alpha", [item(1, { guid: "guid-1", transcripts })])); + await loaded( + t, + { Alpha: alpha }, + doc("Alpha", [item(1, { guid: "guid-1", transcripts }), item(2)]), + ); + const [withGuid, withoutGuid] = t.feed("Alpha")!.episodes; - const persisted = encodeEpisode(t.feed("Alpha")!.episodes[0]); + const persisted = JSON.parse(JSON.stringify(encodeEpisode(withGuid))); - expect(persisted).toMatchObject({ title: "Episode 1", podcastName: "Alpha" }); - expect(persisted).not.toHaveProperty("guid"); + expect(persisted).toMatchObject({ + title: "Episode 1", + podcastName: "Alpha", + guid: "guid-1", + }); expect(persisted).not.toHaveProperty("transcripts"); + expect(JSON.parse(JSON.stringify(encodeEpisode(withoutGuid)))).not.toHaveProperty("guid"); }); test("reads an episode's guid and transcripts from its own subscription's feed", async () => { diff --git a/src/services/FeedRepository.ts b/src/services/FeedRepository.ts index 0afffa95..b10a027b 100644 --- a/src/services/FeedRepository.ts +++ b/src/services/FeedRepository.ts @@ -370,6 +370,7 @@ function projectEpisode( artworkUrl: item.artworkUrl || feed.artworkUrl, episodeDate: item.episodeDate, feedUrl: feed.url, + guid: item.guid, itunesTitle: item.itunesTitle || "", episodeNumber: item.episodeNumber, duration: item.duration, diff --git a/src/types/Episode.ts b/src/types/Episode.ts index 92053ae6..e6f8623d 100644 --- a/src/types/Episode.ts +++ b/src/types/Episode.ts @@ -8,6 +8,8 @@ export interface Episode { content: string; podcastName: string; feedUrl?: string; + /** The feed item's ``. It finds the episode within its feed for links and notes, and is not a storage key. */ + guid?: string; artworkUrl?: string; episodeDate?: Date; itunesTitle?: string; diff --git a/src/utility/findByGuid.ts b/src/utility/findByGuid.ts new file mode 100644 index 00000000..433588ba --- /dev/null +++ b/src/utility/findByGuid.ts @@ -0,0 +1,9 @@ +/** The only item with this guid. Undefined for a missing guid, no match, or several matches. */ +export function findByGuid( + items: readonly T[], + guid: string | undefined, +): T | undefined { + if (!guid) return undefined; + const matches = items.filter((item) => item.guid === guid); + return matches.length === 1 ? matches[0] : undefined; +} diff --git a/tests/fixtures/persistence/README.md b/tests/fixtures/persistence/README.md index dda76c9a..c103be7d 100644 --- a/tests/fixtures/persistence/README.md +++ b/tests/fixtures/persistence/README.md @@ -3,8 +3,9 @@ `planet-money.xml` contains the channel and first four items retrieved from on 2026-10-11. Episode descriptions, content, dates, and enclosure URLs are retained. No media is downloaded. -`feed-sample.json` freezes the feed and episode projection PodNotes saves. The -fixture test compares it with what `FeedRepository` projects from the feed. +`feed-sample.json` freezes the feed and episode projection PodNotes saves, +with each episode's guid in `guids`. The fixture test compares it with what +`FeedRepository` projects from the feed. Long dashes use JSON escapes and XML character references outside CDATA to preserve parsed feed text while following the repository's character rule. From 53adca19c6a249fe05dd56db4606f763a620a69b Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 05:54:18 +0200 Subject: [PATCH 05/27] feat(links): add guid to PodNotes links and resolve by GUID first --- src/API/API.test.ts | 28 +++++++ src/API/API.ts | 29 ++------ src/URIHandler.feeds.test.ts | 16 ++++ src/URIHandler.test.ts | 85 ++++++++++++++++++++++ src/URIHandler.ts | 14 +++- src/utility/buildEpisodeResumeLink.test.ts | 8 ++ src/utility/buildEpisodeResumeLink.ts | 2 +- src/utility/encodePodnotesURI.test.ts | 29 ++++++-- src/utility/encodePodnotesURI.ts | 13 ++-- src/utility/formatEpisodeTimestamp.ts | 11 ++- tests/e2e/podnotes-runtime.test.ts | 7 +- tests/mocks/feeds.ts | 1 + 12 files changed, 198 insertions(+), 45 deletions(-) diff --git a/src/API/API.test.ts b/src/API/API.test.ts index eda7465d..8c6ea9aa 100644 --- a/src/API/API.test.ts +++ b/src/API/API.test.ts @@ -3,6 +3,7 @@ import { get } from "svelte/store"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { API } from "./API"; +import { TimestampTemplateEngine } from "src/TemplateEngine"; import { DEFAULT_SETTINGS } from "src/constants"; import { PlayerController } from "src/player/PlayerController"; import { downloadedEpisodes, playedEpisodes, plugin, queue } from "src/store"; @@ -140,6 +141,33 @@ describe("API.getPodcastSegmentFormatted", () => { }); }); +describe("timestamp links", () => { + const feedLink = "episodeName=Feed%20Episode&url=https%3A%2F%2Fpod.example.com%2Ffeed.xml"; + + function capture(episode: Episode): string { + const { player } = setup(); + player.restore(episode); + plugin.update((host) => ({ ...host, api: new API() }) as never); + return TimestampTemplateEngine("{{linktime}} {{linksegment}}", { + segment: { startTime: 115, endTime: 125 }, + }); + } + + test("{{linktime}} and {{linksegment}} carry the episode's guid", () => { + expect(capture({ ...feedEpisode, guid: "ep-1" })).toBe( + `[00:00:00](obsidian://podnotes?${feedLink}&guid=ep-1&time=0) ` + + `[00:01:55-00:02:05](obsidian://podnotes?${feedLink}&guid=ep-1&time=115&endTime=125)`, + ); + }); + + test("{{linktime}} and {{linksegment}} have no guid for an episode without one", () => { + expect(capture(feedEpisode)).toBe( + `[00:00:00](obsidian://podnotes?${feedLink}&time=0) ` + + `[00:01:55-00:02:05](obsidian://podnotes?${feedLink}&time=115&endTime=125)`, + ); + }); +}); + describe("API playback rate controls", () => { test("increases and decreases the runtime playback rate in tenth steps", () => { const { api, media } = setup({ defaultPlaybackRate: 1 }); diff --git a/src/API/API.ts b/src/API/API.ts index 8c755df8..2f36573e 100644 --- a/src/API/API.ts +++ b/src/API/API.ts @@ -4,8 +4,6 @@ import type { IAPI } from "./IAPI"; import { TFile } from "obsidian"; import { plugin } from "src/store"; import { get } from "svelte/store"; -import encodePodnotesURI from "src/utility/encodePodnotesURI"; -import { episodeLinkTarget } from "src/utility/episodeLinkTarget"; import { formatPodcastSegment, normalizePodcastSegmentTimes } from "src/utility/podcastSegment"; import { adjustPlaybackRate, @@ -103,27 +101,16 @@ export class API implements IAPI { throw new Error("No podcast loaded"); } - const segmentTimes = normalizePodcastSegmentTimes(startTime, endTime); - const segment = segmentTimes - ? formatPodcastSegment(segmentTimes.startTime, segmentTimes.endTime, format) - : formatPodcastSegment(startTime, endTime, format); + const segment = normalizePodcastSegmentTimes(startTime, endTime); + if (!segment) return formatPodcastSegment(startTime, endTime, format); - if (!linkify || !segmentTimes) return segment; - - const target = episodeLinkTarget(this.podcast); - - if (!target) { - return segment; - } - - const url = encodePodnotesURI( - this.podcast.title, - target, - segmentTimes.startTime, - segmentTimes.endTime, + return formatEpisodeTimestamp( + this.podcast, + segment.startTime, + format, + linkify, + segment.endTime, ); - - return `[${segment}](${url.href})`; } async getTranscript(episode = this.podcast): Promise { diff --git a/src/URIHandler.feeds.test.ts b/src/URIHandler.feeds.test.ts index 63ddd2c4..425069be 100644 --- a/src/URIHandler.feeds.test.ts +++ b/src/URIHandler.feeds.test.ts @@ -117,6 +117,22 @@ describe("podNotesURIHandler with saved feeds", () => { expect(get(viewState)).toBe(ViewState.Player); }); + test("a private feed link plays the episode with its guid after the publisher retitled it", async () => { + follow(); + const retitled = { ...first, title: "Bonus: behind the scenes", guid: "patron-1" }; + load.mockResolvedValue(feedDocument("Patron Show", [second, retitled])); + + await podNotesURIHandler({ + action: "podnotes", + url: privateLink, + episodeName: first.title, + guid: "patron-1", + time: "5", + }); + + expect(playing()).toMatchObject({ title: retitled.title, guid: "patron-1" }); + }); + test("a link to a private feed by its secret URL plays the subscription's episode, which keeps the secret out", async () => { follow(); const linkless = { ...second, url: "" }; diff --git a/src/URIHandler.test.ts b/src/URIHandler.test.ts index 5c993866..f2ec5146 100644 --- a/src/URIHandler.test.ts +++ b/src/URIHandler.test.ts @@ -738,6 +738,91 @@ describe("a timestamp link PodNotes wrote for the loaded episode", () => { }); }); +describe("a link that carries the episode's guid", () => { + const retitled: Episode = { + ...testEpisode, + title: "Season finale: what we learned", + guid: "ep-1", + }; + const trailer: Episode = { + ...testEpisode, + title: "Trailer", + streamUrl: "https://pod.example.com/trailer.mp3", + }; + + test("plays the feed episode with that guid after the publisher retitled it and reused its title", async () => { + loadedAt({ ...testEpisode, guid: "ep-1" }, 754); + const written = new API().getPodcastTimeFormatted("HH:mm:ss", true); + loadedAt(plusEpisode, 10); + mockEpisodesAt.mockResolvedValue([ + { ...trailer, title: testEpisode.title, guid: "ep-2" }, + retitled, + ]); + + await follow(written); + metadata(); + + expect(state().episode).toMatchObject({ title: retitled.title, guid: "ep-1" }); + expect(media().currentTime).toBe(754); + }); + + test("falls back to the title when two feed episodes share the guid", async () => { + mockEpisodesAt.mockResolvedValue([ + { ...trailer, guid: "ep-1" }, + { ...testEpisode, guid: "ep-1" }, + ]); + + await link({ guid: "ep-1", time: "30" }); + + expect(state().episode).toMatchObject({ title: testEpisode.title }); + }); + + test("falls back to the title when no feed episode has the guid", async () => { + mockEpisodesAt.mockResolvedValue([trailer, { ...testEpisode, guid: "ep-1" }]); + + await link({ guid: "ep-2", time: "30" }); + + expect(state().episode).toMatchObject({ title: testEpisode.title }); + }); + + test("a link without a guid plays the titled episode, not the one feed episode without a guid", async () => { + mockEpisodesAt.mockResolvedValue([trailer, { ...testEpisode, guid: "ep-1" }]); + + await link({ time: "30" }); + + expect(state().episode).toMatchObject({ title: testEpisode.title }); + }); + + test("resumes the loaded episode by its guid after it was retitled, without fetching", async () => { + loadedAt(retitled, 10); + + await link({ guid: "ep-1", time: "90" }); + + expect(mockEpisodesAt).not.toHaveBeenCalled(); + expect(state().episode).toMatchObject({ title: retitled.title }); + expect(media().currentTime).toBe(90); + expect(media().paused).toBe(false); + }); + + test("does not take the loaded episode of another feed for one with the same guid", async () => { + const otherFeed = "https://other.example.com/feed.xml"; + const other: Episode = { + ...testEpisode, + title: "Other show's episode", + streamUrl: "https://other.example.com/1.mp3", + feedUrl: otherFeed, + guid: "ep-1", + }; + loadedAt(retitled, 10); + mockEpisodesAt.mockResolvedValue([other]); + + await link({ url: otherFeed, episodeName: "Something else", guid: "ep-1", time: "5" }); + + expect(mockEpisodesAt).toHaveBeenCalledWith(otherFeed); + expect(media().src).toBe(other.streamUrl); + }); +}); + describe("a link to a vault file at its path", () => { test("plays the file at the link's time when it is not in Local Files", async () => { vaultFiles = ["Fixtures/Orphan.mp3"]; diff --git a/src/URIHandler.ts b/src/URIHandler.ts index 1f786882..9f24953c 100644 --- a/src/URIHandler.ts +++ b/src/URIHandler.ts @@ -9,12 +9,14 @@ import { isSavedFeed, resolveFeedUrl } from "./services/privateFeeds"; import { isPrivateFeedPlaceholder, parsePrivateFeedPlaceholder } from "./utility/privateFeedUrl"; import { findUniqueTitleMatch, titleTokenSimilarity } from "./utility/episodeTitleMatch"; import { episodeLinkTarget } from "./utility/episodeLinkTarget"; +import { findByGuid } from "./utility/findByGuid"; import { isLocalFile } from "./utility/isLocalFile"; import { localEpisodeFromFile } from "./utility/localEpisodeFromFile"; import { getEpisodeMediaType, getUnambiguousMediaTypeFromPath } from "./utility/mediaType"; import { ViewState } from "./types/ViewState"; type PodNotesProtocolData = ObsidianProtocolData & { + guid?: string; end?: string; endTime?: string; to?: string; @@ -90,7 +92,7 @@ function parseSeconds(value: string): number { let latestRequest = 0; export default async function podNotesURIHandler( - { url: linkUrl, episodeName, time, endTime, end, to }: PodNotesProtocolData, + { url: linkUrl, episodeName, guid, time, endTime, end, to }: PodNotesProtocolData, revealPlayer?: RevealPodNotesPlayer, ) { if (!linkUrl || !episodeName) { @@ -153,7 +155,8 @@ export default async function podNotesURIHandler( !!loadedLinkTarget && (!isLocalFile(loaded) || localFile?.path === loadedLinkTarget) && candidateValues(url).includes(loadedLinkTarget) && - (nameCandidates.includes(loaded.title) || + ((!!guid && loaded.guid === guid) || + nameCandidates.includes(loaded.title) || nameCandidates.some((name) => titleTokenSimilarity(name, loaded.title) === 1)); if (episodeIsLoaded) { @@ -233,7 +236,9 @@ export default async function podNotesURIHandler( await feeds.load([placeholderKey], "network"); if (request !== latestRequest) return; const state = get(feeds).get(placeholderKey); - episode = findEpisodeByCandidates(state?.episodes ?? [], nameCandidates); + const episodes = state?.episodes ?? []; + episode = + findByGuid(episodes, guid) ?? findEpisodeByCandidates(episodes, nameCandidates); if (!episode && (!state || state.error)) { new Notice( state?.error === "network" @@ -270,7 +275,8 @@ export default async function podNotesURIHandler( return; } if (request !== latestRequest) return; - episode = findEpisodeByCandidates(episodes, nameCandidates); + episode = + findByGuid(episodes, guid) ?? findEpisodeByCandidates(episodes, nameCandidates); } } diff --git a/src/utility/buildEpisodeResumeLink.test.ts b/src/utility/buildEpisodeResumeLink.test.ts index 19f013ca..58f22898 100644 --- a/src/utility/buildEpisodeResumeLink.test.ts +++ b/src/utility/buildEpisodeResumeLink.test.ts @@ -32,6 +32,14 @@ describe("buildEpisodeResumeLink (#35)", () => { expect(parsed.searchParams.get("url")).toBe("https://pod.example.com/feed.xml"); // No time => URIHandler resumes from the last played location. expect(parsed.searchParams.has("time")).toBe(false); + expect(parsed.searchParams.has("guid")).toBe(false); + }); + + test("carries a feed episode's guid", () => { + const parsed = new URL(buildEpisodeResumeLink({ ...remoteEpisode, guid: "ep-1" })); + + expect(parsed.searchParams.get("guid")).toBe("ep-1"); + expect(parsed.searchParams.get("url")).toBe("https://pod.example.com/feed.xml"); }); test("addresses a local-file episode by its own file path", () => { diff --git a/src/utility/buildEpisodeResumeLink.ts b/src/utility/buildEpisodeResumeLink.ts index 2f4e44ef..e17b4e05 100644 --- a/src/utility/buildEpisodeResumeLink.ts +++ b/src/utility/buildEpisodeResumeLink.ts @@ -24,5 +24,5 @@ export default function buildEpisodeResumeLink(episode: Episode): string { // working link to emit — degrade to "" rather than a dead link (UR-03). if (!target || !episode.title) return ""; - return encodePodnotesURI(episode.title, target).href; + return encodePodnotesURI(episode, target).href; } diff --git a/src/utility/encodePodnotesURI.test.ts b/src/utility/encodePodnotesURI.test.ts index cc362ee3..aac536eb 100644 --- a/src/utility/encodePodnotesURI.test.ts +++ b/src/utility/encodePodnotesURI.test.ts @@ -15,7 +15,7 @@ function obsidianDecode(href: string, key: string): string { describe("encodePodnotesURI", () => { test("encodes spaces as %20 and a literal '+' as %2B, and href preserves them", () => { const url = encodePodnotesURI( - "Episode 50: C++ Tips", + { title: "Episode 50: C++ Tips" }, "https://pod.example.com/feed.xml", 30, ); @@ -37,29 +37,46 @@ describe("encodePodnotesURI", () => { ]; for (const title of titles) { - const url = encodePodnotesURI(title, "https://x/feed", 0); + const url = encodePodnotesURI({ title }, "https://x/feed", 0); expect(obsidianDecode(url.href, "episodeName")).toBe(title); } }); test("round-trips a feed url / local path containing a literal '+'", () => { - const url = encodePodnotesURI("Ep", "Notes+/C++ Tips.mp3", 5); + const url = encodePodnotesURI({ title: "Ep" }, "Notes+/C++ Tips.mp3", 5); expect(obsidianDecode(url.href, "url")).toBe("Notes+/C++ Tips.mp3"); }); test("omits the time param when time is undefined", () => { - const url = encodePodnotesURI("Title", "https://x/feed"); + const url = encodePodnotesURI({ title: "Title" }, "https://x/feed"); expect(url.href).not.toContain("time="); }); test("encodes segment end time when a start time is present", () => { - const url = encodePodnotesURI("Title", "https://x/feed", 115, 125); + const url = encodePodnotesURI({ title: "Title" }, "https://x/feed", 115, 125); expect(param(url.href, "time")).toBe("115"); expect(param(url.href, "endTime")).toBe("125"); }); + test("carries the episode's guid between the url and the times, and omits it without one", () => { + const url = encodePodnotesURI( + { title: "Ep", guid: "tag:a+b c/1?x=1&y" }, + "https://x/feed", + 5, + 9, + ); + + expect(url.href).toBe( + "obsidian://podnotes?episodeName=Ep&url=https%3A%2F%2Fx%2Ffeed&guid=tag%3Aa%2Bb%20c%2F1%3Fx%3D1%26y&time=5&endTime=9", + ); + expect(obsidianDecode(url.href, "guid")).toBe("tag:a+b c/1?x=1&y"); + expect(encodePodnotesURI({ title: "Ep" }, "https://x/feed", 5).href).toBe( + "obsidian://podnotes?episodeName=Ep&url=https%3A%2F%2Fx%2Ffeed&time=5", + ); + }); + test("omits segment end time when no start time is present", () => { - const url = encodePodnotesURI("Title", "https://x/feed", undefined, 125); + const url = encodePodnotesURI({ title: "Title" }, "https://x/feed", undefined, 125); expect(url.href).not.toContain("time="); expect(url.href).not.toContain("endTime="); }); diff --git a/src/utility/encodePodnotesURI.ts b/src/utility/encodePodnotesURI.ts index b8c05711..82ec5b0b 100644 --- a/src/utility/encodePodnotesURI.ts +++ b/src/utility/encodePodnotesURI.ts @@ -1,14 +1,17 @@ +import type { Episode } from "src/types/Episode"; + export default function encodePodnotesURI( - title: string, - feedUrl: string, + episode: Pick, + target: string, time?: number, endTime?: number, ): URL { const url = new URL(`obsidian://podnotes`); const params = new URLSearchParams(); - params.set("episodeName", title); - params.set("url", feedUrl); + params.set("episodeName", episode.title); + params.set("url", target); + if (episode.guid) params.set("guid", episode.guid); if (time !== undefined) { params.set("time", time.toString()); @@ -20,7 +23,7 @@ export default function encodePodnotesURI( // Obsidian decodes protocol query values with decodeURIComponent only, which does NOT turn // '+' into a space. URLSearchParams serializes spaces as '+' and a literal '+' as '%2B', so // replacing '+' with '%20' yields plain percent-encoding that round-trips losslessly through - // Obsidian's decoder — including titles or local-file paths that contain a literal '+'. + // Obsidian's decoder, including titles or local-file paths that contain a literal '+'. url.search = params.toString().replace(/\+/g, "%20"); return url; diff --git a/src/utility/formatEpisodeTimestamp.ts b/src/utility/formatEpisodeTimestamp.ts index bad3f60f..d481ae47 100644 --- a/src/utility/formatEpisodeTimestamp.ts +++ b/src/utility/formatEpisodeTimestamp.ts @@ -2,14 +2,21 @@ import type { Episode } from "src/types/Episode"; import encodePodnotesURI from "./encodePodnotesURI"; import { episodeLinkTarget } from "./episodeLinkTarget"; import { formatSeconds } from "./formatSeconds"; +import { formatPodcastSegment } from "./podcastSegment"; export function formatEpisodeTimestamp( episode: Episode, seconds: number, format: string, linkify: boolean, + endSeconds?: number, ): string { - const time = formatSeconds(seconds, format); + const time = + endSeconds === undefined + ? formatSeconds(seconds, format) + : formatPodcastSegment(seconds, endSeconds, format); const target = linkify ? episodeLinkTarget(episode) : undefined; - return target ? `[${time}](${encodePodnotesURI(episode.title, target, seconds).href})` : time; + return target + ? `[${time}](${encodePodnotesURI(episode, target, seconds, endSeconds).href})` + : time; } diff --git a/tests/e2e/podnotes-runtime.test.ts b/tests/e2e/podnotes-runtime.test.ts index ff9b8d9c..57ae39ea 100644 --- a/tests/e2e/podnotes-runtime.test.ts +++ b/tests/e2e/podnotes-runtime.test.ts @@ -1323,12 +1323,7 @@ function expectedTimestampLink( endTime?: number, ): string { // Use the production encoder so this helper can never drift from the real wire format. - const uri = encodePodnotesURI( - episode.title, - episode.filePath ?? episode.streamUrl, - time, - endTime, - ); + const uri = encodePodnotesURI(episode, episode.filePath ?? episode.streamUrl, time, endTime); return `[${label}](${uri.href})`; } diff --git a/tests/mocks/feeds.ts b/tests/mocks/feeds.ts index b4ed953c..57e3af49 100644 --- a/tests/mocks/feeds.ts +++ b/tests/mocks/feeds.ts @@ -38,6 +38,7 @@ export function feedItem(episode: Episode): ParsedEpisodeDocument { return { title: episode.title, streamUrl: episode.streamUrl, + guid: episode.guid, itemLink: episode.url, description: episode.description, content: episode.content, From f1c1ff1eb5252f415ef48e5c588fc78e71fa49a0 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 05:58:43 +0200 Subject: [PATCH 06/27] feat(notes): find episode notes by GUID --- src/createPodcastNote.test.ts | 150 +++++++++++++++++++++++++++++++++ src/createPodcastNote.ts | 32 +++++-- src/utility/noteFrontmatter.ts | 31 +++++++ 3 files changed, 208 insertions(+), 5 deletions(-) create mode 100644 src/utility/noteFrontmatter.ts diff --git a/src/createPodcastNote.test.ts b/src/createPodcastNote.test.ts index 48de3b6f..8343b7d3 100644 --- a/src/createPodcastNote.test.ts +++ b/src/createPodcastNote.test.ts @@ -320,3 +320,153 @@ describe("getPodcastNote title fallbacks (#315)", () => { expect(getPodcastNote(hubermanEpisode)).toBeNull(); }); }); + +describe("episode notes identified by guid (#349)", () => { + const feedUrl = "https://example.com/feed.xml"; + const identified: Episode = { + title: "Season Finale", + streamUrl: "https://example.com/finale.mp3", + url: "https://example.com/finale", + description: "", + content: "", + podcastName: "The Show", + feedUrl, + guid: "finale-1", + }; + + afterEach(() => { + plugin.set(undefined as never); + }); + + interface Note { + path: string; + frontmatter?: Record; + } + + /** A vault holding `notes`, all in "podcasts", whose metadata cache knows their frontmatter. */ + function vaultWith(notes: Note[], template = "# {{title}}") { + const files = notes.map(({ path }) => + Object.assign(Object.create(TFile.prototype), { path, extension: "md" }), + ) as TFile[]; + const folder = Object.assign(Object.create(TFolder.prototype), { + path: "podcasts", + children: files, + }) as TFolder; + const created: Array<{ path: string; data: string }> = []; + plugin.set({ + app: { + vault: { + getAbstractFileByPath: vi.fn((path: string) => + path === "podcasts" + ? folder + : (files.find((file) => file.path === path) ?? null), + ), + getMarkdownFiles: () => files, + createFolder: vi.fn(async () => {}), + create: vi.fn(async (path: string, data: string) => { + created.push({ path, data }); + return { path }; + }), + }, + metadataCache: { + getFileCache: (file: TFile) => ({ + frontmatter: notes.find((note) => note.path === file.path)?.frontmatter, + }), + }, + workspace: { getLeaf: vi.fn(() => ({ openFile: vi.fn() })) }, + }, + settings: { note: { path: "podcasts/{{title}}", template } }, + } as never); + return { files, created }; + } + + it("adds quoted guid and feedUrl lines to the template's frontmatter", async () => { + const { created } = vaultWith([], '---\ntitle: "{{title}}"\n---\n# {{title}}'); + + await createPodcastNote(identified); + + expect(created).toEqual([ + { + path: "podcasts/Season Finale.md", + data: `---\ntitle: "Season Finale"\nguid: "finale-1"\nfeedUrl: "${feedUrl}"\n---\n# Season Finale`, + }, + ]); + }); + + it("adds a frontmatter block to a template without one", async () => { + const { created } = vaultWith([]); + + await createPodcastNote(identified); + + expect(created[0].data).toBe( + `---\nguid: "finale-1"\nfeedUrl: "${feedUrl}"\n---\n# Season Finale`, + ); + }); + + it("does not repeat a feedUrl the template already renders", async () => { + const { created } = vaultWith([], '---\nfeedUrl: "{{feedurl}}"\n---\n'); + + await createPodcastNote(identified); + + expect(created[0].data).toBe(`---\nfeedUrl: "${feedUrl}"\nguid: "finale-1"\n---\n`); + }); + + it("adds nothing for an episode without a guid", async () => { + const { created } = vaultWith([], '---\ntitle: "{{title}}"\n---\n'); + + await createPodcastNote({ ...identified, guid: undefined }); + + expect(created[0].data).toBe('---\ntitle: "Season Finale"\n---\n'); + }); + + it("writes a guid with quotes, colons, newlines and control characters as an escaped string", async () => { + const guid = 'tag: "a"\nb\\c\u007f\u0085\ufffe 123'; + const { created } = vaultWith([]); + + await createPodcastNote({ ...identified, guid }); + + const line = created[0].data.split("\n")[1]; + expect(line).toBe(String.raw`guid: "tag: \"a\"\nb\\c\u007f\u0085\ufffe 123"`); + expect(JSON.parse(line.slice("guid: ".length))).toBe(guid); + }); + + it("finds a retitled episode's note by its guid", async () => { + const { files, created } = vaultWith([ + { path: "podcasts/Old Title.md", frontmatter: { guid: "finale-1", feedUrl } }, + ]); + + expect(getPodcastNote(identified)).toBe(files[0]); + + await createPodcastNote(identified); + expect(created).toEqual([]); + }); + + it("prefers the note with the episode's guid over another episode's note at its path", () => { + const { files } = vaultWith([ + { path: "podcasts/Season Finale.md", frontmatter: { guid: "finale-0", feedUrl } }, + { path: "podcasts/Season Finale 2.md", frontmatter: { guid: "finale-1", feedUrl } }, + ]); + + expect(getPodcastNote(identified)).toBe(files[1]); + }); + + it("falls back to the title when two notes have the episode's guid", () => { + const { files } = vaultWith([ + { path: "podcasts/Old Title.md", frontmatter: { guid: "finale-1", feedUrl } }, + { path: "podcasts/Season Finale.md", frontmatter: { guid: "finale-1", feedUrl } }, + ]); + + expect(getPodcastNote(identified)).toBe(files[1]); + }); + + it("ignores a note with the episode's guid from another feed", () => { + vaultWith([ + { + path: "podcasts/Old Title.md", + frontmatter: { guid: "finale-1", feedUrl: "https://other.example.com/feed.xml" }, + }, + ]); + + expect(getPodcastNote(identified)).toBeNull(); + }); +}); diff --git a/src/createPodcastNote.ts b/src/createPodcastNote.ts index bdb70c4d..a3776b09 100644 --- a/src/createPodcastNote.ts +++ b/src/createPodcastNote.ts @@ -14,6 +14,8 @@ import { enforceMaxPathLength } from "./utility/enforceMaxPathLength"; import { ensureFolderExists } from "./utility/ensureFolderExists"; import { fetchChapters } from "./utility/fetchChapters"; import { findUniqueTitleMatch } from "./utility/episodeTitleMatch"; +import { findByGuid } from "./utility/findByGuid"; +import { addFrontmatterFields } from "./utility/noteFrontmatter"; /** * Resolve the on-disk path of an episode's note from the configured template. @@ -94,8 +96,15 @@ export async function createPodcastNoteFileIfNotExists(episode: Episode): Promis const template = pluginInstance.settings.note.template; const chapters = await getTemplateChapters(template, episode); const content = NoteTemplateEngine(template, episode, { chapters }); - - return await createFileIfNotExists(filePathDotMd, content, episode); + const identified = + episode.guid && episode.feedUrl + ? addFrontmatterFields(content, [ + ["guid", episode.guid], + ["feedUrl", episode.feedUrl], + ]) + : content; + + return await createFileIfNotExists(filePathDotMd, identified, episode); } async function getTemplateChapters( @@ -112,6 +121,9 @@ async function getTemplateChapters( export function getPodcastNote(episode: Episode): TFile | null { const { vault } = get(plugin).app; + const identified = findPodcastNoteByGuid(episode); + if (identified) return identified; + for (const filePath of getPodcastNotePathCandidates(episode)) { const file = vault.getAbstractFileByPath(filePath); if (file instanceof TFile) { @@ -122,6 +134,16 @@ export function getPodcastNote(episode: Episode): TFile | null { return findPodcastNoteByTitleOverlap(episode); } +function findPodcastNoteByGuid({ guid, feedUrl }: Episode): TFile | undefined { + if (!guid || !feedUrl) return undefined; + const { vault, metadataCache } = get(plugin).app; + const notes = vault.getMarkdownFiles().flatMap((file) => { + const frontmatter = metadataCache.getFileCache(file)?.frontmatter; + return frontmatter?.feedUrl === feedUrl ? [{ file, guid: frontmatter.guid }] : []; + }); + return findByGuid(notes, guid)?.file; +} + function findPodcastNoteByTitleOverlap(episode: Episode): TFile | null { const { vault } = get(plugin).app; const expectedPath = getPodcastNotePath(episode); @@ -140,11 +162,11 @@ function findPodcastNoteByTitleOverlap(episode: Episode): TFile | null { ); } -export function openPodcastNote(epiosode: Episode): void { - const file = getPodcastNote(epiosode); +export function openPodcastNote(episode: Episode): void { + const file = getPodcastNote(episode); if (!file) { - new Notice(`Note for "${epiosode.title}" does not exist`); + new Notice(`Note for "${episode.title}" does not exist`); return; } diff --git a/src/utility/noteFrontmatter.ts b/src/utility/noteFrontmatter.ts new file mode 100644 index 00000000..fe3be7da --- /dev/null +++ b/src/utility/noteFrontmatter.ts @@ -0,0 +1,31 @@ +/** + * Adds each field the content's frontmatter lacks, before its closing line, or as a new + * frontmatter block when the content has none. + */ +export function addFrontmatterFields( + content: string, + fields: ReadonlyArray, +): string { + const lines = content.split("\n"); + const end = + lines[0].trim() === "---" + ? lines.findIndex((line, index) => index > 0 && line.trim() === "---") + : -1; + const frontmatter = end > 0 ? lines.slice(1, end) : []; + const added = fields + .filter(([key]) => !frontmatter.some((line) => new RegExp(`^${key}\\s*:`).test(line))) + .map(([key, value]) => `${key}: ${yamlString(value)}`); + + if (!added.length) return content; + if (end < 0) return `---\n${added.join("\n")}\n---\n${content}`; + lines.splice(end, 0, ...added); + return lines.join("\n"); +} + +// A JSON string is a YAML double-quoted scalar once the characters YAML rejects raw are escaped. +function yamlString(value: string): string { + return JSON.stringify(value).replace( + /[\u007f-\u009f\ufffe\uffff]/g, + (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`, + ); +} From 00ca522cfd527edc232806dd0eec952ac1378510 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 06:00:06 +0200 Subject: [PATCH 07/27] fix(transcripts): match published transcripts by GUID --- src/services/FeedRepository.test.ts | 20 +++++++++++-------- src/services/FeedRepository.ts | 15 ++++++-------- .../publishedTranscript/index.test.ts | 14 +++++++++++++ 3 files changed, 32 insertions(+), 17 deletions(-) diff --git a/src/services/FeedRepository.test.ts b/src/services/FeedRepository.test.ts index 6cc6acac..d62da686 100644 --- a/src/services/FeedRepository.test.ts +++ b/src/services/FeedRepository.test.ts @@ -479,30 +479,34 @@ describe("FeedRepository", () => { expect(JSON.parse(JSON.stringify(encodeEpisode(withoutGuid)))).not.toHaveProperty("guid"); }); - test("reads an episode's guid and transcripts from its own subscription's feed", async () => { + test("reads an episode's transcripts from its own subscription's feed, by guid first", async () => { const t = setup(); const transcripts = [{ url: "https://media.test/b.vtt", type: "text/vtt" }]; const items = [ item(1, { title: "Rerun", guid: "guid-a" }), item(2, { title: "Rerun", guid: "guid-b", transcripts }), + item(3), ]; await loaded( t, { Alpha: alpha, "Alpha Alias": { ...alpha, title: "Alpha Alias" } }, doc("Alpha", items), ); - const [first] = t.feed("Alpha")!.episodes; + const [first, , third] = t.feed("Alpha")!.episodes; const [, second] = t.feed("Alpha Alias")!.episodes; + const rotated = (episode: typeof first) => ({ + ...episode, + streamUrl: "https://media.test/rotated.mp3", + }); - expect(t.repo.episodeMetadata("Alpha", first)).toEqual({ guid: "guid-a", transcripts: [] }); - expect(t.repo.episodeMetadata("Alpha Alias", second)).toEqual({ - guid: "guid-b", + expect(t.repo.episodeMetadata("Alpha", first)).toEqual({ transcripts: [] }); + expect(t.repo.episodeMetadata("Alpha Alias", second)).toEqual({ transcripts }); + expect(t.repo.episodeMetadata("Alpha", rotated(second))).toEqual({ transcripts }); + expect(t.repo.episodeMetadata("Alpha", { ...second, guid: "guid-gone" })).toEqual({ transcripts, }); expect(t.repo.episodeMetadata("Beta", first)).toBeUndefined(); - expect( - t.repo.episodeMetadata("Alpha", { ...first, streamUrl: "https://media.test/gone.mp3" }), - ).toBeUndefined(); + expect(t.repo.episodeMetadata("Alpha", rotated(third))).toBeUndefined(); }); test("episodes keep their array until the feed's document, title or artwork changes", async () => { diff --git a/src/services/FeedRepository.ts b/src/services/FeedRepository.ts index b10a027b..3a5fb68c 100644 --- a/src/services/FeedRepository.ts +++ b/src/services/FeedRepository.ts @@ -10,6 +10,7 @@ import { DANGEROUS_KEYS, isPlainObject } from "src/persistence/codecUtils"; import { decodeDate, newestFirst } from "src/persistence/dateCodec"; import type { Episode } from "src/types/Episode"; import type { PodcastFeed } from "src/types/PodcastFeed"; +import { findByGuid } from "src/utility/findByGuid"; import { fetchTextWithTimeout } from "src/utility/networkRequest"; /** A savedFeeds key, the only identity of a subscription. */ @@ -152,17 +153,13 @@ export class FeedRepository implements Readable> } /** Feed metadata that episodes do not carry, for one of a subscription's episodes. */ - episodeMetadata( - key: FeedKey, - episode: Episode, - ): { guid?: string; transcripts: TranscriptLink[] } | undefined { + episodeMetadata(key: FeedKey, episode: Episode): { transcripts: TranscriptLink[] } | undefined { const feed = this.subscriptions.get(key); + const items = (feed && this.sources.get(feed.url)?.doc?.items) ?? []; const item = - feed && - this.sources - .get(feed.url) - ?.doc?.items.find(({ streamUrl }) => streamUrl === episode.streamUrl); - return item && { guid: item.guid, transcripts: item.transcripts ?? [] }; + findByGuid(items, episode.guid) ?? + items.find(({ streamUrl }) => streamUrl === episode.streamUrl); + return item && { transcripts: item.transcripts ?? [] }; } itemUrl(key: FeedKey, reference: string): string | undefined { diff --git a/src/services/publishedTranscript/index.test.ts b/src/services/publishedTranscript/index.test.ts index 067bd1dd..cf2ef66b 100644 --- a/src/services/publishedTranscript/index.test.ts +++ b/src/services/publishedTranscript/index.test.ts @@ -161,6 +161,20 @@ describe("findPublishedTranscript", () => { expect(load).toHaveBeenCalledOnce(); }); + test("finds the episode's item by its guid after the feed rotated its enclosure URL", async () => { + const { feeds } = repository([ + { + ...item([{ url: "t.vtt", type: "text/vtt" }]), + guid: "ep-1", + streamUrl: "https://media.test/1.mp3?token=rotated", + }, + ]); + + await expect( + findPublishedTranscript(feeds, { ...episodeOf(show), guid: "ep-1" }), + ).resolves.toMatchObject({ reference: "t.vtt" }); + }); + test("asks an unreachable feed once and finds nothing", async () => { const { feeds, load } = repository([]); load.mockRejectedValue(new Error("offline")); From 97a7490b66529625cf0a7ec9e773cf4bdaa78122 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 06:05:16 +0200 Subject: [PATCH 08/27] chore: drop em-dashes from two comments in touched files --- src/createPodcastNote.ts | 2 +- src/utility/buildEpisodeResumeLink.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/createPodcastNote.ts b/src/createPodcastNote.ts index a3776b09..9565ce58 100644 --- a/src/createPodcastNote.ts +++ b/src/createPodcastNote.ts @@ -20,7 +20,7 @@ import { addFrontmatterFields } from "./utility/noteFrontmatter"; /** * Resolve the on-disk path of an episode's note from the configured template. * Centralized so the existence check, the open action, and creation all agree on - * the exact same (length-capped) path — otherwise a truncated note would be + * the exact same (length-capped) path, otherwise a truncated note would be * re-created on every invocation. See issue #22. */ function getPodcastNotePath(episode: Episode): string { diff --git a/src/utility/buildEpisodeResumeLink.ts b/src/utility/buildEpisodeResumeLink.ts index e17b4e05..ea24c524 100644 --- a/src/utility/buildEpisodeResumeLink.ts +++ b/src/utility/buildEpisodeResumeLink.ts @@ -21,7 +21,7 @@ export default function buildEpisodeResumeLink(episode: Episode): string { const target = episodeLinkTarget(episode) ?? downloadedEpisodes.getEpisode(episode)?.filePath; // URIHandler rejects an empty episodeName, so without a title there is no - // working link to emit — degrade to "" rather than a dead link (UR-03). + // working link to emit: degrade to "" rather than a dead link (UR-03). if (!target || !episode.title) return ""; return encodePodnotesURI(episode, target).href; From dced281371c4cf929d450433421d69be1d52d215 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 06:20:24 +0200 Subject: [PATCH 09/27] fix(links): let a link's guid only rule the loaded episode out A guid can repeat within a feed, so the loaded-episode shortcut no longer accepts the loaded episode on a guid match alone. A guid that differs from the loaded episode's sends the link through the feed lookup, which refuses duplicate guids and falls back to the title. --- src/URIHandler.test.ts | 35 ++++++++++++++++++++++++++++++++--- src/URIHandler.ts | 6 ++++-- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/src/URIHandler.test.ts b/src/URIHandler.test.ts index f2ec5146..2d10236e 100644 --- a/src/URIHandler.test.ts +++ b/src/URIHandler.test.ts @@ -793,17 +793,46 @@ describe("a link that carries the episode's guid", () => { expect(state().episode).toMatchObject({ title: testEpisode.title }); }); - test("resumes the loaded episode by its guid after it was retitled, without fetching", async () => { + test("resumes the loaded episode by its guid after it was retitled", async () => { loadedAt(retitled, 10); + mockEpisodesAt.mockResolvedValue([trailer, retitled]); await link({ guid: "ep-1", time: "90" }); - expect(mockEpisodesAt).not.toHaveBeenCalled(); - expect(state().episode).toMatchObject({ title: retitled.title }); + expect(mockEpisodesAt).toHaveBeenCalledWith(testFeedUrl); + expect(state().episode).toMatchObject({ title: retitled.title, guid: "ep-1" }); expect(media().currentTime).toBe(90); expect(media().paused).toBe(false); }); + test("plays the feed episode with the link's guid, not a loaded same-titled episode with another guid", async () => { + const linked: Episode = { ...retitled, streamUrl: "https://pod.example.com/finale.mp3" }; + loadedAt({ ...testEpisode, guid: "ep-2" }, 10); + mockEpisodesAt.mockResolvedValue([{ ...testEpisode, guid: "ep-2" }, linked]); + + await link({ guid: "ep-1", time: "30" }); + metadata(); + + expect(mockEpisodesAt).toHaveBeenCalledWith(testFeedUrl); + expect(state().episode).toMatchObject({ title: linked.title, guid: "ep-1" }); + expect(media().src).toBe(linked.streamUrl); + expect(media().currentTime).toBe(30); + }); + + test("plays the titled episode, not the loaded one, when both share the link's guid", async () => { + const loadedTwin: Episode = { ...trailer, guid: "ep-1" }; + const named: Episode = { ...testEpisode, guid: "ep-1" }; + loadedAt(loadedTwin, 10); + mockEpisodesAt.mockResolvedValue([loadedTwin, named]); + + await link({ guid: "ep-1", time: "30" }); + metadata(); + + expect(state().episode).toMatchObject({ title: named.title }); + expect(media().src).toBe(named.streamUrl); + expect(media().currentTime).toBe(30); + }); + test("does not take the loaded episode of another feed for one with the same guid", async () => { const otherFeed = "https://other.example.com/feed.xml"; const other: Episode = { diff --git a/src/URIHandler.ts b/src/URIHandler.ts index 9f24953c..ce71f23d 100644 --- a/src/URIHandler.ts +++ b/src/URIHandler.ts @@ -150,13 +150,15 @@ export default async function podNotesURIHandler( // 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. + // A guid only rules the loaded episode out: a feed can repeat a guid, and only the + // feed lookup can tell that it is unique. const episodeIsLoaded = !!loaded && !!loadedLinkTarget && (!isLocalFile(loaded) || localFile?.path === loadedLinkTarget) && candidateValues(url).includes(loadedLinkTarget) && - ((!!guid && loaded.guid === guid) || - nameCandidates.includes(loaded.title) || + (!guid || !loaded.guid || loaded.guid === guid) && + (nameCandidates.includes(loaded.title) || nameCandidates.some((name) => titleTokenSimilarity(name, loaded.title) === 1)); if (episodeIsLoaded) { From 10fedcee568b8103b0e63bbef729635f1a674709 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 06:20:47 +0200 Subject: [PATCH 10/27] fix(notes): treat a quoted frontmatter key as present A template that writes "feedUrl": or 'guid': already has the key, so adding it again would give the note a duplicate key that Obsidian rejects. --- src/createPodcastNote.test.ts | 11 +++++++++++ src/utility/noteFrontmatter.ts | 4 +++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/createPodcastNote.test.ts b/src/createPodcastNote.test.ts index 8343b7d3..caf57bc8 100644 --- a/src/createPodcastNote.test.ts +++ b/src/createPodcastNote.test.ts @@ -411,6 +411,17 @@ describe("episode notes identified by guid (#349)", () => { expect(created[0].data).toBe(`---\nfeedUrl: "${feedUrl}"\nguid: "finale-1"\n---\n`); }); + it("does not repeat keys the template writes in quotes", async () => { + const template = `---\n"feedUrl": "{{feedurl}}"\n'guid': "{{title}}"\n---\n`; + const { created } = vaultWith([], template); + + await createPodcastNote(identified); + + expect(created[0].data).toBe( + `---\n"feedUrl": "${feedUrl}"\n'guid': "Season Finale"\n---\n`, + ); + }); + it("adds nothing for an episode without a guid", async () => { const { created } = vaultWith([], '---\ntitle: "{{title}}"\n---\n'); diff --git a/src/utility/noteFrontmatter.ts b/src/utility/noteFrontmatter.ts index fe3be7da..77d9c4b2 100644 --- a/src/utility/noteFrontmatter.ts +++ b/src/utility/noteFrontmatter.ts @@ -13,7 +13,9 @@ export function addFrontmatterFields( : -1; const frontmatter = end > 0 ? lines.slice(1, end) : []; const added = fields - .filter(([key]) => !frontmatter.some((line) => new RegExp(`^${key}\\s*:`).test(line))) + .filter( + ([key]) => !frontmatter.some((line) => new RegExp(`^(["']?)${key}\\1\\s*:`).test(line)), + ) .map(([key, value]) => `${key}: ${yamlString(value)}`); if (!added.length) return content; From 17e3e25fbe25da3a900cd2907f5944b6fd6b85a5 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 06:21:47 +0200 Subject: [PATCH 11/27] fix(capture): skip a note in Reading view A MarkdownView in Reading view still returns its hidden editor, and text written there is lost. A capture now treats such a note as no editor, whether it is focused or was the last note focused, and goes to the episode note. --- src/commands.test.ts | 13 +++++++++++++ src/services/CaptureService.ts | 4 +++- tests/mocks/obsidian.ts | 4 ++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/commands.test.ts b/src/commands.test.ts index 11741b97..4aac1d49 100644 --- a/src/commands.test.ts +++ b/src/commands.test.ts @@ -242,6 +242,19 @@ describe("capture", () => { expect(editor.getValue()).toBe(""); }); + it("goes to the episode note when the last note is in Reading view", async () => { + const { plugin, openNote, focusPodNotes, read } = await loadPlugin(playing); + const { leaf, editor } = openNote("Journal.md", "Notes"); + vi.spyOn(leaf.view as MarkdownView, "getMode").mockReturnValue("preview"); + + expect(await plugin.capture.timestamp()).toMatchObject({ destination: "note" }); + focusPodNotes(); + expect(await plugin.capture.timestamp()).toMatchObject({ destination: "note" }); + + expect(read(NOTE_PATH)).toBe("# Saved Episode\n- 00:20:34\n- 00:20:34\n"); + expect(editor.getValue()).toBe("Notes"); + }); + it("creates the episode note from its template, then appends to it", async () => { const { plugin, read } = await loadPlugin(playing); diff --git a/src/services/CaptureService.ts b/src/services/CaptureService.ts index c034fb41..4b31f0d4 100644 --- a/src/services/CaptureService.ts +++ b/src/services/CaptureService.ts @@ -112,7 +112,9 @@ export class CaptureService { /** The focused editor, which covers canvas and embedded editors, else the last markdown one. */ private editorTarget(): MarkdownFileInfo | null { const active = this.plugin.app.workspace.activeEditor; - return active?.editor ? active : this.lastMarkdownView(); + const target = active?.editor ? active : this.lastMarkdownView(); + // A note in Reading view keeps its hidden editor, where written text is lost. + return target instanceof MarkdownView && target.getMode() === "preview" ? null : target; } private lastMarkdownView(): MarkdownView | null { diff --git a/tests/mocks/obsidian.ts b/tests/mocks/obsidian.ts index 30cef8d1..8db201ef 100644 --- a/tests/mocks/obsidian.ts +++ b/tests/mocks/obsidian.ts @@ -68,6 +68,10 @@ export class MarkdownView { file: TFile | null = null; constructor(public leaf: WorkspaceLeaf) {} + + getMode(): "source" | "preview" { + return "source"; + } } export class Modal { From ddfffc19cf32150be30c45610340d4c6cfa06d89 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 06:22:13 +0200 Subject: [PATCH 12/27] fix(transcripts): match by guid only within the episode's own feed A subscription of another feed that shares the episode's podcast name no longer lends its transcript through a shared guid. Other feeds still match by enclosure URL as before. --- src/services/FeedRepository.test.ts | 15 +++++++++++++++ src/services/FeedRepository.ts | 3 ++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/services/FeedRepository.test.ts b/src/services/FeedRepository.test.ts index d62da686..b9ea5584 100644 --- a/src/services/FeedRepository.test.ts +++ b/src/services/FeedRepository.test.ts @@ -509,6 +509,21 @@ describe("FeedRepository", () => { expect(t.repo.episodeMetadata("Alpha", rotated(third))).toBeUndefined(); }); + test("does not match an episode of another feed by guid, even under the subscription's name", async () => { + const t = setup(); + const transcripts = [{ url: "https://media.test/1.vtt", type: "text/vtt" }]; + await loaded(t, { Alpha: alpha }, doc("Alpha", [item(1, { guid: "guid-1", transcripts })])); + const [subscribed] = t.feed("Alpha")!.episodes; + + const namesake = { + ...subscribed, + streamUrl: "https://namesake.test/1.mp3", + feedUrl: "https://namesake.test/feed.xml", + }; + + expect(t.repo.episodeMetadata("Alpha", namesake)).toBeUndefined(); + }); + test("episodes keep their array until the feed's document, title or artwork changes", async () => { const t = setup(); await loaded(t, { Alpha: alpha, Beta: beta }, doc("Show", [item(1)])); diff --git a/src/services/FeedRepository.ts b/src/services/FeedRepository.ts index 3a5fb68c..3520c4d4 100644 --- a/src/services/FeedRepository.ts +++ b/src/services/FeedRepository.ts @@ -156,8 +156,9 @@ export class FeedRepository implements Readable> episodeMetadata(key: FeedKey, episode: Episode): { transcripts: TranscriptLink[] } | undefined { const feed = this.subscriptions.get(key); const items = (feed && this.sources.get(feed.url)?.doc?.items) ?? []; + // A guid is unique only within its feed, and another feed can share the subscription's name. const item = - findByGuid(items, episode.guid) ?? + (feed?.url === episode.feedUrl ? findByGuid(items, episode.guid) : undefined) ?? items.find(({ streamUrl }) => streamUrl === episode.streamUrl); return item && { transcripts: item.transcripts ?? [] }; } From df9bfa1c82b3c42c98bc1edd99a38fba22217585 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 06:23:36 +0200 Subject: [PATCH 13/27] refactor(notes): look up the episode note once per capture createPodcastNoteFileIfNotExists now returns the note and whether it was created, and shows no notice. createPodcastNote says an existing note exists, as before, and a capture into an existing note stays quiet. A capture scans the vault once instead of three times. --- src/commands.test.ts | 12 ++++++++++++ src/createPodcastNote.test.ts | 6 +++++- src/createPodcastNote.ts | 28 ++++++++-------------------- src/services/CaptureService.ts | 7 +++---- 4 files changed, 28 insertions(+), 25 deletions(-) diff --git a/src/commands.test.ts b/src/commands.test.ts index 4aac1d49..2b528acf 100644 --- a/src/commands.test.ts +++ b/src/commands.test.ts @@ -274,6 +274,18 @@ describe("capture", () => { expect(notice).not.toHaveBeenCalled(); }); + it("appends to an episode note the user already has, without a notice", async () => { + const { plugin, notes, read } = await loadPlugin(playing); + notes.set(NOTE_PATH, { file: vaultFile(NOTE_PATH), content: "# Mine" }); + + expect(await plugin.capture.timestamp()).toMatchObject({ + destination: "note", + created: false, + }); + expect(read(NOTE_PATH)).toBe("# Mine\n- 00:20:34\n"); + expect(notice).not.toHaveBeenCalled(); + }); + it("asks for an episode when none is loaded, and writes nothing", async () => { const { run, openNote, notes } = await loadPlugin({ ...playing, diff --git a/src/createPodcastNote.test.ts b/src/createPodcastNote.test.ts index caf57bc8..fbda6e17 100644 --- a/src/createPodcastNote.test.ts +++ b/src/createPodcastNote.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as obsidian from "obsidian"; import { TFile, TFolder } from "obsidian"; import createPodcastNote, { getPodcastNote } from "./createPodcastNote"; import { plugin } from "./store"; @@ -55,6 +56,7 @@ describe("createPodcastNote chapters template support (#47)", () => { afterEach(() => { mockFetchChapters.mockReset(); + vi.restoreAllMocks(); plugin.set(undefined as never); }); @@ -84,7 +86,8 @@ describe("createPodcastNote chapters template support (#47)", () => { ); }); - it("opens an existing note without fetching chapters", async () => { + it("says an existing note exists and opens it without fetching chapters", async () => { + const notice = vi.spyOn(obsidian, "Notice"); const existingFile = Object.assign(Object.create(TFile.prototype), { path: "PodNotes/Chaptered Episode.md", }) as TFile; @@ -104,6 +107,7 @@ describe("createPodcastNote chapters template support (#47)", () => { expect(mockFetchChapters).not.toHaveBeenCalled(); expect(createdFiles).toEqual([]); expect(leaf.openFile).toHaveBeenCalledWith(existingFile); + expect(notice).toHaveBeenCalledWith('Note for "Chaptered Episode" already exists'); }); it("does not fetch chapters for templates that do not use the tag", async () => { diff --git a/src/createPodcastNote.ts b/src/createPodcastNote.ts index 9565ce58..cbfd196c 100644 --- a/src/createPodcastNote.ts +++ b/src/createPodcastNote.ts @@ -75,7 +75,8 @@ function getPodcastNoteTitleCandidates(episode: Episode): string[] { export default async function createPodcastNote(episode: Episode): Promise { try { - const file = await createPodcastNoteFileIfNotExists(episode); + const { file, created } = await createPodcastNoteFileIfNotExists(episode); + if (!created) new Notice(`Note for "${episode.title}" already exists`); void get(plugin).app.workspace.getLeaf().openFile(file); } catch (error) { @@ -84,12 +85,11 @@ export default async function createPodcastNote(episode: Episode): Promise } } -export async function createPodcastNoteFileIfNotExists(episode: Episode): Promise { +export async function createPodcastNoteFileIfNotExists( + episode: Episode, +): Promise<{ file: TFile; created: boolean }> { const existingFile = getPodcastNote(episode); - if (existingFile) { - new Notice(`Note for "${episode.title}" already exists`); - return existingFile; - } + if (existingFile) return { file: existingFile, created: false }; const pluginInstance = get(plugin); const filePathDotMd = getPodcastNotePath(episode); @@ -104,7 +104,7 @@ export async function createPodcastNoteFileIfNotExists(episode: Episode): Promis ]) : content; - return await createFileIfNotExists(filePathDotMd, identified, episode); + return { file: await createFileIfNotExists(filePathDotMd, identified), created: true }; } async function getTemplateChapters( @@ -173,19 +173,7 @@ export function openPodcastNote(episode: Episode): void { void get(plugin).app.workspace.getLeaf().openFile(file); } -async function createFileIfNotExists( - path: string, - content: string, - episode: Episode, -): Promise { - const file = getPodcastNote(episode); - - if (file) { - new Notice(`Note for "${episode.title}" already exists`); - - return file; - } - +async function createFileIfNotExists(path: string, content: string): Promise { const folderPath = path.split("/").slice(0, -1).join("/"); await ensureFolderExists(folderPath); diff --git a/src/services/CaptureService.ts b/src/services/CaptureService.ts index 4b31f0d4..37635379 100644 --- a/src/services/CaptureService.ts +++ b/src/services/CaptureService.ts @@ -1,6 +1,6 @@ import { MarkdownView, Notice, type MarkdownFileInfo, type WorkspaceLeaf } from "obsidian"; import { TimestampTemplateEngine } from "src/TemplateEngine"; -import { createPodcastNoteFileIfNotExists, getPodcastNote } from "src/createPodcastNote"; +import { createPodcastNoteFileIfNotExists } from "src/createPodcastNote"; import { prepareTimestampForInsertion } from "src/utility/prepareTimestampInsertion"; import { createRecentPodcastSegment, getSegmentCaptureTemplate } from "src/utility/podcastSegment"; import type PodNotes from "src/main"; @@ -93,15 +93,14 @@ export class CaptureService { return null; } try { - const existing = getPodcastNote(episode); - const file = existing ?? (await createPodcastNoteFileIfNotExists(episode)); + const { file, created } = await createPodcastNoteFileIfNotExists(episode); const appended = text.endsWith("\n") ? text : `${text}\n`; await this.plugin.app.vault.process(file, (content) => content.length > 0 && !content.endsWith("\n") ? `${content}\n${appended}` : `${content}${appended}`, ); - return { destination: "note", path: file.path, text: appended, created: !existing }; + return { destination: "note", path: file.path, text: appended, created }; } catch (error) { console.error("PodNotes: failed to capture timestamp into episode note", error); new Notice("Failed to capture timestamp into episode note"); From 4c5d5e46878951ac6898d5759f3eb15275bff193 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 06:23:56 +0200 Subject: [PATCH 14/27] fix(notes): count only a string guid in a note's frontmatter --- src/createPodcastNote.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/createPodcastNote.ts b/src/createPodcastNote.ts index cbfd196c..704e2963 100644 --- a/src/createPodcastNote.ts +++ b/src/createPodcastNote.ts @@ -139,7 +139,9 @@ function findPodcastNoteByGuid({ guid, feedUrl }: Episode): TFile | undefined { const { vault, metadataCache } = get(plugin).app; const notes = vault.getMarkdownFiles().flatMap((file) => { const frontmatter = metadataCache.getFileCache(file)?.frontmatter; - return frontmatter?.feedUrl === feedUrl ? [{ file, guid: frontmatter.guid }] : []; + return frontmatter?.feedUrl === feedUrl && typeof frontmatter.guid === "string" + ? [{ file, guid: frontmatter.guid }] + : []; }); return findByGuid(notes, guid)?.file; } From 354f1b8cc412374839d4ef6050ef48ce05b6f379 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 06:25:02 +0200 Subject: [PATCH 15/27] fix(notes): load the picked podcast by its saved key The episode picker for Create episode note now loads a podcast by its savedFeeds key, so a podcast saved under another name than its title still lists its episodes. A private podcast whose address is not on this device gets the usual private feed notice. --- src/commands.test.ts | 40 ++++++++++++++++++++++++++++++++++++++++ src/commands.ts | 26 +++++++++++++++----------- 2 files changed, 55 insertions(+), 11 deletions(-) diff --git a/src/commands.test.ts b/src/commands.test.ts index 2b528acf..6e385d13 100644 --- a/src/commands.test.ts +++ b/src/commands.test.ts @@ -8,6 +8,7 @@ import { VIEW_TYPE } from "./constants"; import type { Episode } from "./types/Episode"; import { PickerModal } from "./ui/PickerModal"; import { fetchTextWithTimeout } from "./utility/networkRequest"; +import { privateFeedPlaceholder } from "./utility/privateFeedUrl"; import { dataFileText } from "../tests/mocks/dataFileWriter"; vi.mock("./utility/networkRequest", async (importOriginal) => ({ @@ -430,6 +431,45 @@ describe("Create episode note", () => { expect(notes.size).toBe(0); }); + it("loads the podcast saved under another name than its title", async () => { + vi.mocked(fetchTextWithTimeout).mockResolvedValue(rss(["One"])); + const pickers = watchPickers(); + const { commands } = await loadPlugin({ + ...withFeed, + savedFeeds: { "Old Name": feed }, + }); + + command(commands)(false); + expect(pickers.shown(0).texts).toEqual(["Pod"]); + pickers.shown(0).choose("Pod"); + + await vi.waitFor(() => expect(pickers.open).toHaveBeenCalledTimes(2)); + expect(pickers.shown(1).texts).toEqual(["One"]); + }); + + it("says a private podcast's address is not on this device", async () => { + const pickers = watchPickers(); + const { commands, notes } = await loadPlugin({ + ...withFeed, + savedFeeds: { + Pod: { ...feed, url: privateFeedPlaceholder("Pod"), urlSecretId: "pod-feed-url" }, + }, + }); + + command(commands)(false); + pickers.shown(0).choose("Pod"); + + await vi.waitFor(() => + expect(notice).toHaveBeenCalledWith( + 'The private feed address for "Pod" is not available on this device.', + ), + ); + expect(notice).not.toHaveBeenCalledWith("Could not load the podcast feed"); + expect(fetchTextWithTimeout).not.toHaveBeenCalled(); + expect(pickers.open).toHaveBeenCalledTimes(1); + expect(notes.size).toBe(0); + }); + it("creates the loaded episode's note without a picker", async () => { const pickers = watchPickers(); const { commands, read, opened } = await loadPlugin({ diff --git a/src/commands.ts b/src/commands.ts index e108270f..8cdb4e47 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -9,8 +9,8 @@ import downloadEpisodeWithNotice from "src/downloadEpisode"; import getUniversalPodcastLink from "src/getUniversalPodcastLink"; import { getEpisodeMediaType } from "src/utility/mediaType"; import { TRANSCRIBE_FROM_AUDIO_COMMAND } from "src/services/TranscriptionService"; +import { notifyPrivateFeedUnavailable } from "src/services/privateFeeds"; import type PodNotes from "src/main"; -import type { PodcastFeed } from "src/types/PodcastFeed"; /** * Registers every PodNotes command on the plugin. Extracted from main.onload so @@ -160,7 +160,7 @@ export function registerCommands(plugin: PodNotes): void { checkCallback: (checking) => { const { path, template } = plugin.settings.note; const episode = plugin.api.podcast; - const feeds = Object.values(get(savedFeeds)); + const feeds = Object.entries(get(savedFeeds)); if (checking) { return !!path && !!template && (!!episode || feeds.length > 0); } @@ -173,10 +173,10 @@ export function registerCommands(plugin: PodNotes): void { new PickerModal( plugin.app, feeds, - (feed) => feed.title, + ([, feed]) => feed.title, "Select a podcast", - (feed) => { - void pickEpisodeForNote(plugin, feed); + ([key, feed]) => { + void pickEpisodeForNote(plugin, key, feed.title); }, ).open(); }, @@ -312,14 +312,18 @@ export function registerCommands(plugin: PodNotes): void { }); } -async function pickEpisodeForNote(plugin: PodNotes, feed: PodcastFeed): Promise { - await plugin.feeds.load([feed.title], "cached"); - const state = get(plugin.feeds).get(feed.title); +async function pickEpisodeForNote(plugin: PodNotes, key: string, title: string): Promise { + await plugin.feeds.load([key], "cached"); + const state = get(plugin.feeds).get(key); const episodes = state?.episodes ?? []; if (episodes.length === 0) { - new Notice( - state?.error ? "Could not load the podcast feed" : "No episodes in this podcast", - ); + if (state?.error === "unavailable") { + notifyPrivateFeedUnavailable(title); + } else { + new Notice( + state?.error ? "Could not load the podcast feed" : "No episodes in this podcast", + ); + } return; } From 64639534f8d5f7d30e0394f5f08ae19805bde036 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 06:25:44 +0200 Subject: [PATCH 16/27] chore(verify): describe capture from anywhere and the retitle fixture Capture commands now run from anywhere, Create episode note works with nothing loaded once a podcast is saved, and fixtures.mjs has a retitle verb. The verify skill said otherwise or did not list it. --- .claude/skills/verify/SKILL.md | 9 ++++++--- .claude/skills/verify/features/episode-notes.md | 2 +- .claude/skills/verify/features/timestamps-uris.md | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md index 3fb34926..bd8940da 100644 --- a/.claude/skills/verify/SKILL.md +++ b/.claude/skills/verify/SKILL.md @@ -100,8 +100,9 @@ read state. ``` The direct path is `npm run obsidian:e2e -- command id=podnotes:podnotes-show-leaf`. - Command ids are in `src/commands.ts`. Editor commands such as - `capture-timestamp` only exist while a Markdown editor is focused. + Command ids are in `src/commands.ts`. Capture commands such as + `capture-timestamp` run from anywhere. They write into the last focused + editor, else the episode note. - **URIs.** Click the link in a note (Reading view), or deliver it the way Obsidian does when the OS hands it over: @@ -197,7 +198,9 @@ its profile. It keeps the vault and `.obsidian-e2e-artifacts/`. Delete `fill` and `wait` wait up to 10 s; `--timeout ` changes that. `key` and `uri` do not wait. Each CLI call fails after 15 s without an answer. - `.claude/skills/verify/fixtures.mjs` runs as `feed`, `feed-b`, `transcripts`, - `requests`, `audio [vault path]` or `stop`. `requests` prints the paths + `retitle`, `requests`, `audio [vault path]` or `stop`. `retitle` renames the + first feed's two episodes to `Renamed Opening Show` and `Renamed Closing Show`, + keeping their GUIDs, until the server stops. `requests` prints the paths PodNotes fetched from the server. The feed URL uses the host `podnotes.test`, because PodNotes refuses literal loopback hosts. The instance maps it to 127.0.0.1 with Chromium's `--host-resolver-rules`, so it needs no DNS, and an diff --git a/.claude/skills/verify/features/episode-notes.md b/.claude/skills/verify/features/episode-notes.md index bc298c12..b8ca04ab 100644 --- a/.claude/skills/verify/features/episode-notes.md +++ b/.claude/skills/verify/features/episode-notes.md @@ -34,5 +34,5 @@ Preconditions: ## Gotchas -- The palette lists `Create episode note` only while an episode is loaded and both the note path and template settings are non-empty. +- The palette lists `Create episode note` only while both the note path and template settings are non-empty, and an episode is loaded or a podcast is saved. With nothing loaded it asks for a podcast, then for an episode. - `{{podcastlink}}` in the default template links the feed note path, which does not exist until you run `Create podcast feed note`. An unresolved link in the new note is expected. diff --git a/.claude/skills/verify/features/timestamps-uris.md b/.claude/skills/verify/features/timestamps-uris.md index 53644a39..8e72f3b3 100644 --- a/.claude/skills/verify/features/timestamps-uris.md +++ b/.claude/skills/verify/features/timestamps-uris.md @@ -38,7 +38,7 @@ Preconditions: ## Gotchas -- Capture commands are editor commands. The palette lists them only while a Markdown editor is focused, so click into the note first. +- Capture commands run from anywhere. A capture goes to the focused editor, else the last focused Markdown editor, else the episode note. A note in Reading view counts as no editor, so switch it to editing first. - The default format is `- {{linktime}} `. A vault whose `data.json` holds a `{{time}}` format inserts plain text with no link, so check `timestamp.template` before proving links. - Obsidian writes the note about 2 s after the edit. Read after a short wait, or read the editor with `CLI eval code='app.workspace.getLeavesOfType("markdown")[0].view.editor.getValue()'`. - A clicked link leaves the app through `xdg-open` and comes back through Obsidian's single-instance handoff, which the runner sets up on Linux. Never run `xdg-open` yourself: it reaches the system Obsidian profile. From 44a2ac748cf5155793dc266820c3c30f524c44c0 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 06:40:05 +0200 Subject: [PATCH 17/27] fix(links): take the loaded episode for a guid link only when its guid matches A loaded episode without a guid may be another feed episode that reused its title, so a link that carries a guid now goes through the feed lookup unless the loaded episode has the same guid. --- src/URIHandler.test.ts | 17 +++++++++++++++++ src/URIHandler.ts | 6 +++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/URIHandler.test.ts b/src/URIHandler.test.ts index 2d10236e..af987c8f 100644 --- a/src/URIHandler.test.ts +++ b/src/URIHandler.test.ts @@ -819,6 +819,23 @@ describe("a link that carries the episode's guid", () => { expect(media().currentTime).toBe(30); }); + test("plays the feed episode with the link's guid, not a loaded same-titled episode without a guid", async () => { + const linked: Episode = { ...retitled, streamUrl: "https://pod.example.com/finale.mp3" }; + loadedAt(testEpisode, 10); + mockEpisodesAt.mockResolvedValue([ + { ...trailer, title: testEpisode.title, guid: "ep-2" }, + linked, + ]); + + await link({ guid: "ep-1", time: "30" }); + metadata(); + + expect(mockEpisodesAt).toHaveBeenCalledWith(testFeedUrl); + expect(state().episode).toMatchObject({ title: linked.title, guid: "ep-1" }); + expect(media().src).toBe(linked.streamUrl); + expect(media().currentTime).toBe(30); + }); + test("plays the titled episode, not the loaded one, when both share the link's guid", async () => { const loadedTwin: Episode = { ...trailer, guid: "ep-1" }; const named: Episode = { ...testEpisode, guid: "ep-1" }; diff --git a/src/URIHandler.ts b/src/URIHandler.ts index ce71f23d..f20b1cde 100644 --- a/src/URIHandler.ts +++ b/src/URIHandler.ts @@ -150,14 +150,14 @@ export default async function podNotesURIHandler( // 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. - // A guid only rules the loaded episode out: a feed can repeat a guid, and only the - // feed lookup can tell that it is unique. + // A link with a guid takes the loaded episode only when its guid matches. Otherwise the feed + // lookup decides, and only it can tell whether the feed repeats the guid. const episodeIsLoaded = !!loaded && !!loadedLinkTarget && (!isLocalFile(loaded) || localFile?.path === loadedLinkTarget) && candidateValues(url).includes(loadedLinkTarget) && - (!guid || !loaded.guid || loaded.guid === guid) && + (!guid || loaded.guid === guid) && (nameCandidates.includes(loaded.title) || nameCandidates.some((name) => titleTokenSimilarity(name, loaded.title) === 1)); From 41fe789ee4b80e6e06023ccada72bb1ff63747f7 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 06:41:10 +0200 Subject: [PATCH 18/27] fix(notes): return the episode note that appears while its chapters load Creating a note awaits the chapter fetch, during which sync or another capture can write the episode's note. Look the note up again after that wait and return it instead of writing a second note. --- src/createPodcastNote.test.ts | 31 ++++++++++++++++++++++++++++++- src/createPodcastNote.ts | 4 ++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/createPodcastNote.test.ts b/src/createPodcastNote.test.ts index fbda6e17..7a56309b 100644 --- a/src/createPodcastNote.test.ts +++ b/src/createPodcastNote.test.ts @@ -1,9 +1,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import * as obsidian from "obsidian"; import { TFile, TFolder } from "obsidian"; -import createPodcastNote, { getPodcastNote } from "./createPodcastNote"; +import createPodcastNote, { + createPodcastNoteFileIfNotExists, + getPodcastNote, +} from "./createPodcastNote"; import { plugin } from "./store"; +import type { Chapter } from "./types/Chapter"; import type { Episode } from "./types/Episode"; +import { deferred } from "../tests/mocks/feeds"; const mockFetchChapters = vi.hoisted(() => vi.fn()); @@ -456,6 +461,30 @@ describe("episode notes identified by guid (#349)", () => { expect(created).toEqual([]); }); + it("returns a note with the episode's guid that appeared while its chapters loaded", async () => { + const chapters = deferred(); + mockFetchChapters.mockReturnValueOnce(chapters.promise); + const notes: Note[] = []; + const { files, created } = vaultWith(notes, "# {{title}}\n\n{{chapters}}"); + + const creating = createPodcastNoteFileIfNotExists({ + ...identified, + chaptersUrl: "https://example.com/chapters.json", + }); + const synced = { + path: "podcasts/Finale (synced).md", + frontmatter: { guid: "finale-1", feedUrl }, + }; + notes.push(synced); + files.push( + Object.assign(Object.create(TFile.prototype), { path: synced.path, extension: "md" }), + ); + chapters.resolve([]); + + expect(await creating).toEqual({ file: files[0], created: false }); + expect(created).toEqual([]); + }); + it("prefers the note with the episode's guid over another episode's note at its path", () => { const { files } = vaultWith([ { path: "podcasts/Season Finale.md", frontmatter: { guid: "finale-0", feedUrl } }, diff --git a/src/createPodcastNote.ts b/src/createPodcastNote.ts index 704e2963..a6f081d8 100644 --- a/src/createPodcastNote.ts +++ b/src/createPodcastNote.ts @@ -95,6 +95,10 @@ export async function createPodcastNoteFileIfNotExists( const filePathDotMd = getPodcastNotePath(episode); const template = pluginInstance.settings.note.template; const chapters = await getTemplateChapters(template, episode); + // Sync can write the note while the chapters load. + const arrivedFile = chapters && getPodcastNote(episode); + if (arrivedFile) return { file: arrivedFile, created: false }; + const content = NoteTemplateEngine(template, episode, { chapters }); const identified = episode.guid && episode.feedUrl From 1c1c23353948a08e87843c76e37b6df369df441b Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 06:41:53 +0200 Subject: [PATCH 19/27] fix(notes): report a note another create wrote first as not created When vault.create fails because the note now exists at its path, the note is returned with created: false, so Create podcast note says it already exists and a capture does not report a new note. --- src/createPodcastNote.test.ts | 49 +++++++++++++++++++++++++++++++++++ src/createPodcastNote.ts | 11 +++++--- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/createPodcastNote.test.ts b/src/createPodcastNote.test.ts index 7a56309b..eeb1b9d3 100644 --- a/src/createPodcastNote.test.ts +++ b/src/createPodcastNote.test.ts @@ -139,6 +139,55 @@ describe("createPodcastNote chapters template support (#47)", () => { }); }); +describe("a note another writer creates at the path first", () => { + afterEach(() => { + vi.restoreAllMocks(); + plugin.set(undefined as never); + }); + + /** A vault where the note appears at its path just as PodNotes creates it there. */ + function racedVault() { + let raced: TFile | null = null; + const leaf = { openFile: vi.fn() }; + plugin.set({ + app: { + vault: { + getAbstractFileByPath: vi.fn((path: string) => + path === raced?.path ? raced : null, + ), + createFolder: vi.fn(async () => {}), + create: vi.fn(async (path: string) => { + raced = Object.assign(Object.create(TFile.prototype), { path }) as TFile; + throw new Error("File already exists."); + }), + }, + workspace: { getLeaf: vi.fn(() => leaf) }, + }, + settings: { note: { path: "PodNotes/{{title}}", template: "# {{title}}" } }, + } as never); + return { leaf, raced: () => raced }; + } + + it("returns that note as not created", async () => { + const vault = racedVault(); + + const result = await createPodcastNoteFileIfNotExists(episode); + + expect(result.file.path).toBe("PodNotes/Chaptered Episode.md"); + expect(result).toEqual({ file: vault.raced(), created: false }); + }); + + it("says the note already exists and opens it", async () => { + const notice = vi.spyOn(obsidian, "Notice"); + const vault = racedVault(); + + await createPodcastNote(episode); + + expect(notice).toHaveBeenCalledWith('Note for "Chaptered Episode" already exists'); + expect(vault.leaf.openFile).toHaveBeenCalledWith(vault.raced()); + }); +}); + describe("getPodcastNote title fallbacks (#315)", () => { const philosophizeEpisode: Episode = { title: "Episode #001 ... Presocratic Philosophy - Ionian", diff --git a/src/createPodcastNote.ts b/src/createPodcastNote.ts index a6f081d8..8689ec17 100644 --- a/src/createPodcastNote.ts +++ b/src/createPodcastNote.ts @@ -108,7 +108,7 @@ export async function createPodcastNoteFileIfNotExists( ]) : content; - return { file: await createFileIfNotExists(filePathDotMd, identified), created: true }; + return createFileIfNotExists(filePathDotMd, identified); } async function getTemplateChapters( @@ -179,19 +179,22 @@ export function openPodcastNote(episode: Episode): void { void get(plugin).app.workspace.getLeaf().openFile(file); } -async function createFileIfNotExists(path: string, content: string): Promise { +async function createFileIfNotExists( + path: string, + content: string, +): Promise<{ file: TFile; created: boolean }> { const folderPath = path.split("/").slice(0, -1).join("/"); await ensureFolderExists(folderPath); const { app } = get(plugin); try { - return await app.vault.create(path, content); + return { file: await app.vault.create(path, content), created: true }; } catch (error) { // A racing create may have won between the check and here; treat an // already-existing file as success rather than surfacing an error. const raced = app.vault.getAbstractFileByPath(path); if (raced instanceof TFile) { - return raced; + return { file: raced, created: false }; } throw error; } From 31398cd47166e42b0f993440b77ced717c27e948 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 07:08:15 +0200 Subject: [PATCH 20/27] test(e2e): journeys for GUID notes and links, the episode picker, and capture destinations J-NOTE-09 retitles an episode on the fixture server and proves its note, its guid= timestamp link and its Resume link still find it, while an old link without a GUID still plays its episode. J-NOTE-10 creates an episode note from the palette's podcast and episode pickers with nothing loaded. J-TS-08, J-TS-09 and J-TS-10 prove Capture Timestamp lands at the caret of the editor used last, in a popout too, and otherwise in the episode note. The fixture server can now retitle an episode, and the journey actions can run a command from the palette, open and click an editor in any window, and close a popout with its tab. Closing a popout can leave Obsidian looping a ResizeObserver in the main window, with PodNotes disabled too, so the popout journey reloads at the end and accepts only that error. --- tests/e2e/journey-notes.test.ts | 477 ++++++++++++++++++++++++++++++++ tests/e2e/journey/actions.ts | 117 +++++++- tests/e2e/journey/fixtures.ts | 53 ++-- 3 files changed, 619 insertions(+), 28 deletions(-) create mode 100644 tests/e2e/journey-notes.test.ts diff --git a/tests/e2e/journey-notes.test.ts b/tests/e2e/journey-notes.test.ts new file mode 100644 index 00000000..2bd74cdd --- /dev/null +++ b/tests/e2e/journey-notes.test.ts @@ -0,0 +1,477 @@ +import { describe, expect, onTestFinished, test } from "vitest"; +import { createPodNotesE2EHarness } from "./harness"; +import { + click, + clickInEditor, + closePopoutTab, + closeSettings, + dismissNotices, + isShown, + openInEditor, + openInReadingView, + openPodNotesSettings, + reloadObsidian, + runFromPalette, + showPodNotes, + ui, + waitUntilShown, +} from "./journey/actions"; +import { + type FixtureEpisode, + type FixtureFeed, + type FixtureServer, + pubDate, + startFixtureServer, +} from "./journey/fixtures"; +import { + type PlaybackObservation, + playbackOver, + readLibrary, + readVaultText, + seedLibrary, + waitForPlayback, + writeVaultFile, +} from "./journey/library"; +import { PLUGIN_ID } from "./runtime"; + +const getContext = createPodNotesE2EHarness("journey-notes"); + +const ORIGINAL: FixtureEpisode = { + title: "Original Title", + seconds: 60, + pubDate: pubDate(0), + guid: "identity-original", +}; +const STEADY: FixtureEpisode = { + title: "Steady Episode", + seconds: 60, + pubDate: pubDate(1), + guid: "identity-steady", +}; +const RETITLED = "Completely Different Name"; + +type Journey = Readonly<{ server: FixtureServer; show: FixtureFeed }>; + +/** Subscribes to a podcast named `title`, so each journey writes its notes to its own folder. */ +async function setUp(title: string): Promise { + const context = getContext(); + const show: FixtureFeed = { + title, + slug: "identity", + artwork: true, + episodes: [ORIGINAL, STEADY], + }; + const server = await startFixtureServer([show]); + onTestFinished(() => server.stop()); + await seedLibrary(context, server, { subscriptions: [show] }); + await context.obsidian.plugin("command-palette").enable({ filter: "core" }); + return { server, show }; +} + +function notePath(show: FixtureFeed, title: string): string { + return getContext().sandbox.path(`PodNotes/${show.title}/${title}.md`); +} + +function hms(seconds: number): string { + const whole = Math.floor(seconds); + return [whole / 3600, (whole % 3600) / 60, whole % 60] + .map((part) => String(Math.floor(part)).padStart(2, "0")) + .join(":"); +} + +/** The default "Capture timestamp format", `- {{linktime}} `, for `episode` at `seconds`. */ +function capture({ server, show }: Journey, episode: FixtureEpisode, seconds: number): string { + const query = [ + `episodeName=${encodeURIComponent(episode.title)}`, + `url=${encodeURIComponent(server.feedUrl(show))}`, + `guid=${encodeURIComponent(episode.guid ?? "")}`, + `time=${seconds}`, + ].join("&"); + return `- [${hms(seconds)}](obsidian://podnotes?${query}) `; +} + +function frontmatter(note: string): string[] { + const [, block] = /^---\n([\s\S]*?)\n---\n/.exec(note) ?? []; + if (block === undefined) throw new Error(`The note has no frontmatter: ${note}`); + return block.split("\n"); +} + +async function playFromList(show: FixtureFeed, title: string): Promise { + const { obsidian } = getContext(); + await showPodNotes(obsidian); + await click(obsidian, ui.podcastGridTab); + await click(obsidian, ui.podcastCard(show.title)); + await click(obsidian, ui.episode(title)); + await waitForPlayback( + obsidian, + (playback) => playback.episode?.title === title && (playback.media?.seconds ?? 0) > 0, + `${title} playing from the episode list`, + ); +} + +async function pauseInView(): Promise { + const { obsidian } = getContext(); + await click(obsidian, ui.playbackToggle); + await waitForPlayback(obsidian, (playback) => !playback.playing, "paused in the PodNotes view"); + const [, paused] = await playbackOver(obsidian, 500); + return paused; +} + +async function activeEditorPath(): Promise { + return await getContext().obsidian.dev.evalJson( + "app.workspace.activeEditor?.editor ? (app.workspace.activeEditor.file?.path ?? '') : null", + ); +} + +async function loadedEpisode(): Promise { + return await getContext().obsidian.dev.evalJson( + `app.plugins.plugins.${PLUGIN_ID}.api.podcast ?? null`, + ); +} + +async function exists(path: string): Promise { + return await getContext().obsidian.dev.evalJson( + `Boolean(app.vault.getAbstractFileByPath(${JSON.stringify(path)}))`, + ); +} + +async function waitForText( + path: string, + predicate: (text: string) => boolean, + message: string, +): Promise { + const { obsidian } = getContext(); + let last = ""; + await obsidian + .waitFor( + async () => { + last = (await exists(path)) ? await readVaultText(obsidian, path) : ""; + return predicate(last); + }, + { timeoutMs: 10_000, intervalMs: 200, message }, + ) + .catch((error: Error) => { + error.message += ` Last text: ${JSON.stringify(last)}`; + throw error; + }); + return last; +} + +async function waitForActiveFile(path: string, message: string): Promise { + await getContext().obsidian.waitFor( + () => + getContext().obsidian.dev.evalJson( + `app.workspace.getActiveFile()?.path === ${JSON.stringify(path)}`, + ), + { timeoutMs: 10_000, intervalMs: 100, message }, + ); +} + +async function waitForPrompt(placeholder: string): Promise { + await getContext().obsidian.waitFor( + () => + getContext().obsidian.dev.evalJson( + `document.querySelector(".prompt .prompt-input")?.placeholder === ${JSON.stringify(placeholder)}`, + ), + { timeoutMs: 10_000, intervalMs: 100, message: `the "${placeholder}" picker` }, + ); +} + +async function detachMarkdownTabs(): Promise { + await getContext().obsidian.dev.evalJson('app.workspace.detachLeavesOfType("markdown"), true'); +} + +describe("episode note and capture journeys", () => { + test("J-NOTE-09 a retitled episode keeps its note, its timestamp links, and its Resume link", async () => { + const { obsidian, sandbox } = getContext(); + const journey = await setUp("Journey Retitle"); + const { server, show } = journey; + const feedParam = encodeURIComponent(server.feedUrl(show)); + const original = notePath(show, ORIGINAL.title); + const log = sandbox.path("Listening log.md"); + + await playFromList(show, ORIGINAL.title); + await waitForPlayback( + obsidian, + (playback) => playback.seconds >= 3, + "three seconds played", + ); + await runFromPalette(obsidian, "Create episode note"); + const note = await waitForText( + original, + (text) => text.includes("\n# Original Title\n"), + "the episode note created", + ); + expect(frontmatter(note), "the note records the episode's GUID and feed").toEqual( + expect.arrayContaining([ + `guid: "${ORIGINAL.guid}"`, + `feedUrl: "${server.feedUrl(show)}"`, + ]), + ); + expect(note).toContain( + `\n[Resume in PodNotes](obsidian://podnotes?episodeName=Original%20Title&url=${feedParam}&guid=${ORIGINAL.guid})\n`, + ); + + const paused = await pauseInView(); + await writeVaultFile( + obsidian, + log, + `[Old Steady link](obsidian://podnotes?episodeName=Steady%20Episode&url=${feedParam}&time=20)\n\n`, + ); + await openInEditor(obsidian, log); + expect(await clickInEditor(obsidian, log, 1, 0), "caret on the log's empty line").toEqual({ + line: 1, + ch: 0, + }); + await runFromPalette(obsidian, "Capture Timestamp"); + const captured = capture(journey, ORIGINAL, paused.seconds); + await waitForText( + log, + (text) => text.endsWith(`\n${captured}\n`), + "the timestamp captured into the log", + ); + + server.retitle(show, ORIGINAL, RETITLED); + await openPodNotesSettings(obsidian); + await click(obsidian, ui.settingControl("Clear cached feeds", "button")); + await closeSettings(obsidian); + await dismissNotices(obsidian); + await click(obsidian, ui.podcastGridTab); + await click(obsidian, ui.podcastCard(show.title)); + await waitUntilShown(obsidian, ui.episode(RETITLED)); + + await click(obsidian, ui.episodeMenu(RETITLED)); + await waitUntilShown(obsidian, ui.menuItem("Open Note")); + expect(await isShown(obsidian, ui.menuItem("Create Note")), "no Create Note offered").toBe( + false, + ); + await click(obsidian, ui.menuItem("Open Note")); + await waitForActiveFile(original, "Open Note opening the original note"); + expect(await exists(notePath(show, RETITLED)), "no note made for the new title").toBe( + false, + ); + + await click(obsidian, ui.episode(STEADY.title)); + await waitForPlayback( + obsidian, + (playback) => + playback.episode?.title === STEADY.title && (playback.media?.seconds ?? 0) > 0, + "Steady Episode loaded", + ); + await openInReadingView(obsidian, log); + await click(obsidian, ui.readingLink(hms(paused.seconds))); + const linked = await waitForPlayback( + obsidian, + (playback) => + playback.episode?.title === RETITLED && (playback.media?.seconds ?? 0) > 0, + "the GUID link playing the retitled episode", + ); + expect(linked.media?.src).toBe(server.mediaUrl(show, ORIGINAL)); + expect(linked.seconds).toBeGreaterThanOrEqual(paused.seconds); + expect(linked.seconds).toBeLessThan(paused.seconds + 3); + + await playFromList(show, STEADY.title); + await openInReadingView(obsidian, original); + await click(obsidian, ui.readingLink("Resume in PodNotes")); + const resumed = await waitForPlayback( + obsidian, + (playback) => + playback.episode?.title === RETITLED && + playback.playing && + (playback.media?.seconds ?? 0) > 0, + "the note's Resume link playing the retitled episode", + ); + expect(resumed.media?.src).toBe(server.mediaUrl(show, ORIGINAL)); + + await openInReadingView(obsidian, log); + await click(obsidian, ui.readingLink("Old Steady link")); + const steady = await waitForPlayback( + obsidian, + (playback) => + playback.episode?.title === STEADY.title && (playback.media?.seconds ?? 0) > 0, + "the old link without a GUID playing Steady Episode", + ); + expect(steady.media?.src).toBe(server.mediaUrl(show, STEADY)); + expect(steady.seconds).toBeGreaterThanOrEqual(20); + expect(steady.seconds).toBeLessThan(23); + expect((await obsidian.dev.notices()).map(({ message }) => message)).not.toContain( + "Episode not found", + ); + }, 120_000); + + test("J-NOTE-10 Create episode note with nothing playing picks a podcast, then an episode", async () => { + const { obsidian, plugin } = getContext(); + const { server, show } = await setUp("Journey Picker"); + expect(await loadedEpisode(), "nothing loaded").toBeNull(); + + await runFromPalette(obsidian, "Create episode note"); + await waitForPrompt("Select a podcast"); + await click(obsidian, ui.suggestion(show.title)); + await waitForPrompt("Select an episode"); + await click(obsidian, ui.suggestion(STEADY.title)); + + const created = notePath(show, STEADY.title); + const note = await waitForText( + created, + (text) => text.includes("\n# Steady Episode\n"), + "the picked episode's note created", + ); + expect(frontmatter(note)).toEqual(expect.arrayContaining([`guid: "${STEADY.guid}"`])); + await waitForActiveFile(created, "the created note opened"); + expect(await exists(notePath(show, ORIGINAL.title)), "no note for the other episode").toBe( + false, + ); + expect(await loadedEpisode(), "the player stays empty").toBeNull(); + expect((await readLibrary(plugin)).currentEpisode).toBeNull(); + expect(server.requests().filter((path) => path.endsWith(".wav"))).toEqual([]); + }); + + test("J-TS-08 Capture Timestamp from the palette lands at the caret of the editor used last", async () => { + const { obsidian, sandbox } = getContext(); + const journey = await setUp("Journey Caret"); + const draft = sandbox.path("Draft.md"); + await writeVaultFile(obsidian, draft, "Before the cut after the cut\nSecond line\n"); + + await playFromList(journey.show, ORIGINAL.title); + await openInEditor(obsidian, draft); + expect(await clickInEditor(obsidian, draft, 0, 14), "caret mid-line").toEqual({ + line: 0, + ch: 14, + }); + const paused = await pauseInView(); + expect(await activeEditorPath(), "the PodNotes view has focus").toBeNull(); + + await runFromPalette(obsidian, "Capture Timestamp"); + const text = await waitForText( + draft, + (value) => value.includes("obsidian://podnotes"), + "the capture saved into the draft", + ); + expect(text).toBe( + `Before the cut${capture(journey, ORIGINAL, paused.seconds)} after the cut\nSecond line\n`, + ); + expect( + await exists(notePath(journey.show, ORIGINAL.title)), + "no episode note created", + ).toBe(false); + }); + + test("J-TS-10 Capture Timestamp with no editor open fills the episode note, one capture per line", async () => { + const { obsidian } = getContext(); + const journey = await setUp("Journey No Editor"); + const episodeNote = notePath(journey.show, ORIGINAL.title); + await detachMarkdownTabs(); + + await playFromList(journey.show, ORIGINAL.title); + const first = await pauseInView(); + expect( + await obsidian.dev.evalJson('app.workspace.getLeavesOfType("markdown").length'), + "no markdown tab open", + ).toBe(0); + await runFromPalette(obsidian, "Capture Timestamp"); + const firstLine = capture(journey, ORIGINAL, first.seconds); + await waitForText( + episodeNote, + (text) => text.endsWith(`\n${firstLine}\n`), + "the first capture in a new episode note", + ); + + await click(obsidian, ui.playbackToggle); + await waitForPlayback( + obsidian, + (playback) => playback.playing && playback.seconds > first.seconds + 1, + "playing on", + ); + const second = await pauseInView(); + await runFromPalette(obsidian, "Capture Timestamp"); + const secondLine = capture(journey, ORIGINAL, second.seconds); + const note = await waitForText( + episodeNote, + (text) => text.includes(secondLine), + "the second capture appended", + ); + + expect(note).toMatch(/^---\ntype: podcastEpisode\n/); + expect(note).toContain("\n# Original Title\n"); + expect(frontmatter(note)).toEqual( + expect.arrayContaining([ + `guid: "${ORIGINAL.guid}"`, + `feedUrl: "${journey.server.feedUrl(journey.show)}"`, + ]), + ); + expect(note.endsWith(`\n${firstLine}\n${secondLine}\n`), note).toBe(true); + }); + + test("J-TS-09 Capture Timestamp lands in a popout editor, then in the episode note once the popout closes", async () => { + const { obsidian, sandbox } = getContext(); + const journey = await setUp("Journey Popout"); + const draft = sandbox.path("Popout draft.md"); + const episodeNote = notePath(journey.show, ORIGINAL.title); + await writeVaultFile(obsidian, draft, "Popout notes\n"); + await detachMarkdownTabs(); + + await playFromList(journey.show, ORIGINAL.title); + try { + expect( + await obsidian.dev.evalJsonAsync(`(async () => { + const leaf = app.workspace.openPopoutLeaf(); + await leaf.openFile(app.vault.getAbstractFileByPath(${JSON.stringify(draft)}), { state: { mode: "source" } }); + return leaf.view.containerEl.ownerDocument !== document; + })()`), + "the draft open in a popout window", + ).toBe(true); + expect( + await clickInEditor(obsidian, draft, 0, 12), + "caret at the popout line's end", + ).toEqual({ line: 0, ch: 12 }); + expect(await activeEditorPath()).toBe(draft); + // A real click in the main window gives it the OS focus, which CDP input does not. + await obsidian.dev.evalJson( + 'require("@electron/remote").getCurrentWindow().focus(), true', + ); + const paused = await pauseInView(); + expect( + await activeEditorPath(), + "the main window's PodNotes view has focus", + ).toBeNull(); + + await runFromPalette(obsidian, "Capture Timestamp"); + const captured = capture(journey, ORIGINAL, paused.seconds); + const text = await waitForText( + draft, + (value) => value.includes("obsidian://podnotes"), + "the capture saved into the popout draft", + ); + expect(text).toBe(`Popout notes${captured}\n`); + expect(await exists(episodeNote), "no episode note yet").toBe(false); + + await closePopoutTab(obsidian, draft); + await runFromPalette(obsidian, "Capture Timestamp"); + const note = await waitForText( + episodeNote, + (value) => value.endsWith(`\n${captured}\n`), + "the next capture in the episode note", + ); + expect(frontmatter(note)).toEqual(expect.arrayContaining([`guid: "${ORIGINAL.guid}"`])); + expect(await readVaultText(obsidian, draft), "the closed draft untouched").toBe( + `Popout notes${captured}\n`, + ); + } finally { + const popouts = await obsidian.dev.evalJson( + "app.workspace.floatingSplit.children.length", + ); + if (popouts > 0) await closePopoutTab(obsidian, draft); + await obsidian.dev.evalJsonAsync(`(async () => { + app.workspace.requestSaveLayout.cancel(); + await app.workspace.saveLayout(); + return true; + })()`); + // Obsidian can keep a ResizeObserver loop running in the main window after a popout + // closes, with PodNotes disabled too, until the window reloads. + await reloadObsidian( + obsidian, + "ResizeObserver loop completed with undelivered notifications.", + ); + } + }); +}); diff --git a/tests/e2e/journey/actions.ts b/tests/e2e/journey/actions.ts index 1cefd9ac..89de86ea 100644 --- a/tests/e2e/journey/actions.ts +++ b/tests/e2e/journey/actions.ts @@ -10,15 +10,25 @@ import { } from "obsidian-e2e/runner"; import { PLUGIN_ID, VIEW_TYPE, waitForPodNotesReady } from "../runtime"; -export async function assertNoRuntimeErrors(obsidian: ObsidianClient): Promise { - const early = await obsidian.dev.evalJson("window.__podnotesJourneyEarlyErrors"); +/** `ignored` is an error message to accept, and only that message. */ +export async function assertNoRuntimeErrors( + obsidian: ObsidianClient, + ignored?: string, +): Promise { + const kept = (message: string) => !ignored || !message.trim().endsWith(ignored); + const early = await obsidian.dev.evalJson("window.__podnotesJourneyEarlyErrors"); if (!Array.isArray(early)) throw new Error("The startup error recorder is missing."); - if (early.length) throw new Error(`Startup runtime errors: ${JSON.stringify(early)}`); - const errors = await obsidian.dev.runtimeErrors(); + const startup = early.filter(({ message }: { message: string }) => kept(message)); + if (startup.length) throw new Error(`Startup runtime errors: ${JSON.stringify(startup)}`); + const errors = (await obsidian.dev.runtimeErrors()).filter(({ message }) => kept(message)); if (errors.length) throw new Error(`Unrelated renderer errors: ${JSON.stringify(errors)}`); const { stdout } = await obsidian.exec("dev:errors"); - if (stdout.trim() !== "No errors captured.") - throw new Error(`Obsidian runtime errors: ${stdout.trim()}`); + const logged = stdout + .split("\n") + .filter((line) => line.trim() && kept(line)) + .join("\n"); + if (logged && logged.trim() !== "No errors captured.") + throw new Error(`Obsidian runtime errors: ${logged.trim()}`); } export type UiTarget = Readonly<{ @@ -65,6 +75,11 @@ export const ui = { "on-screen playback toggle", `${PLAYER_VIEW} .episode-player button[aria-label="Toggle playback"]`, ), + episodeMenu: (title: string) => + byCss( + `More options for ${title}`, + `${PLAYER_VIEW} button[aria-label=${q(`More options for ${title}`)}]`, + ), menuItem: (title: string): UiTarget => ({ name: `menu item ${title}`, elements: `[...document.querySelectorAll(".menu .menu-item")].filter((el) => el.querySelector(".menu-item-title")?.textContent.trim() === ${q(title)})`, @@ -101,6 +116,12 @@ export const ui = { `.modal.mod-settings button[aria-label=${q(`Remove ${title} podcast`)}]`, ), podcastViewport: byCss("PodNotes view", PLAYER_VIEW), + promptInput: byCss("prompt input", ".prompt .prompt-input"), + suggestion: (text: string) => byCss(`suggestion ${text}`, ".prompt .suggestion-item", text), + paletteCommand: (name: string): UiTarget => ({ + name: `palette command PodNotes: ${name}`, + elements: `[...document.querySelectorAll(".prompt .suggestion-item")].filter((el) => el.querySelector(".suggestion-prefix")?.textContent === "PodNotes" && el.querySelector(".suggestion-title")?.textContent === ${q(`PodNotes${name}`)})`, + }), } as const; type Located = @@ -236,6 +257,20 @@ export async function dismissNotices(obsidian: ObsidianClient): Promise { ); } +/** Opens the command palette with its hotkey, types `name` and clicks PodNotes' command. */ +export async function runFromPalette(obsidian: ObsidianClient, name: string): Promise { + const hotkey = { + key: "p", + code: "KeyP", + windowsVirtualKeyCode: 80, + modifiers: process.platform === "darwin" ? 4 : 2, + }; + for (const type of ["keyDown", "keyUp"]) + await cdp(obsidian, "Input.dispatchKeyEvent", { type, ...hotkey }); + await typeInto(obsidian, ui.promptInput, name); + await click(obsidian, ui.paletteCommand(name)); +} + export async function runCommand(obsidian: ObsidianClient, id: string): Promise { const { stdout } = await obsidian.exec("command", { id }); if (/^Error/m.test(stdout)) throw new Error(`command ${id}: ${stdout.trim()}`); @@ -322,6 +357,72 @@ export async function openInReadingView(obsidian: ObsidianClient, notePath: stri })()`); } +export async function openInEditor(obsidian: ObsidianClient, notePath: string): Promise { + await obsidian.dev.evalJsonAsync(`(async () => { + const file = app.vault.getAbstractFileByPath(${q(notePath)}); + if (!file) throw new Error(${q(`${notePath} does not exist.`)}); + const leaf = app.workspace.getLeaf("tab"); + await leaf.openFile(file, { active: true, state: { mode: "source" } }); + return true; + })()`); +} + +/** + * Renderer code that finds the markdown leaf showing `notePath` as `leaf`, and + * defines `press(x, y)`, a real click at that point of the window holding it, + * which CDP cannot reach when it is a popout. + */ +function inNoteWindow(notePath: string): string { + return ` + const remote = require("@electron/remote"); + const leaf = app.workspace.getLeavesOfType("markdown").find((each) => each.view.file?.path === ${q(notePath)}); + if (!leaf) throw new Error(${q(`${notePath} is not open in an editor.`)}); + const main = remote.getCurrentWindow(); + const others = remote.BrowserWindow.getAllWindows().filter((win) => win.id !== main.id); + if (leaf.view.containerEl.ownerDocument !== document && others.length !== 1) + throw new Error("Expected exactly one popout window, found " + others.length + "."); + const win = leaf.view.containerEl.ownerDocument === document ? main : others[0]; + const press = (x, y) => { + const point = { x: Math.round(x), y: Math.round(y) }; + win.webContents.sendInputEvent({ type: "mouseMove", ...point }); + for (const type of ["mouseDown", "mouseUp"]) + win.webContents.sendInputEvent({ type, ...point, button: "left", clickCount: 1 }); + };`; +} + +/** + * Clicks the open editor of `notePath` at `line` and `ch` (both zero-based), in + * whichever window holds it, and returns where the caret landed. + */ +export async function clickInEditor( + obsidian: ObsidianClient, + notePath: string, + line: number, + ch: number, +): Promise<{ line: number; ch: number }> { + return await obsidian.dev.evalJsonAsync<{ line: number; ch: number }>(`(async () => { + ${inNoteWindow(notePath)} + const { cm } = leaf.view.editor; + const at = cm.coordsAtPos(cm.state.doc.line(${line + 1}).from + ${ch}); + press(at.left, (at.top + at.bottom) / 2); + await new Promise((resolve) => setTimeout(resolve, 200)); + return leaf.view.editor.getCursor(); + })()`); +} + +/** Clicks the close button of the popout tab showing `notePath`, which closes its window. */ +export async function closePopoutTab(obsidian: ObsidianClient, notePath: string): Promise { + const closed = await obsidian.dev.evalJsonAsync(`(async () => { + ${inNoteWindow(notePath)} + const close = leaf.tabHeaderEl.querySelector(".workspace-tab-header-inner-close-button").getBoundingClientRect(); + press(close.left + close.width / 2, close.top + close.height / 2); + for (let i = 0; i < 50 && remote.BrowserWindow.getAllWindows().length > 1; i++) + await new Promise((resolve) => setTimeout(resolve, 100)); + return remote.BrowserWindow.getAllWindows().length === 1; + })()`); + if (!closed) throw new Error(`The popout showing ${notePath} did not close.`); +} + export async function revealInFileExplorer( obsidian: ObsidianClient, filePath: string, @@ -440,8 +541,8 @@ function processAlive(pid: number): boolean { } } -export async function reloadObsidian(obsidian: ObsidianClient): Promise { - await assertNoRuntimeErrors(obsidian); +export async function reloadObsidian(obsidian: ObsidianClient, ignored?: string): Promise { + await assertNoRuntimeErrors(obsidian, ignored); await obsidian.dev.evalJson("window.__podnotesJourneyBeforeReload = true"); await obsidian.exec("reload"); await obsidian.waitFor( diff --git a/tests/e2e/journey/fixtures.ts b/tests/e2e/journey/fixtures.ts index 44f30336..a6b5b27a 100644 --- a/tests/e2e/journey/fixtures.ts +++ b/tests/e2e/journey/fixtures.ts @@ -37,6 +37,8 @@ export type FixtureServer = Readonly<{ artworkUrl(feed: FixtureFeed): string; audio(feed: FixtureFeed, episode: FixtureEpisode): Buffer; holdFeed(feed: FixtureFeed): () => Promise; + /** Serves `episode` under `title` from now on, with its GUID and media unchanged. */ + retitle(feed: FixtureFeed, episode: FixtureEpisode, title: string): void; requests(): readonly string[]; stop(): Promise; }>; @@ -91,6 +93,28 @@ export async function startFixtureServer(feeds: readonly FixtureFeed[]): Promise return route.body; }; + const serveFeed = (feed: FixtureFeed) => + routes.set(feedPath(feed), { + type: "application/rss+xml", + body: Buffer.from( + rss( + feed, + feed.artwork ? artworkUrl(feed) : null, + (episode) => + episode.seconds === null + ? null + : { + url: `${origin}${mediaPath(feed, episode)}`, + bytes: audio(feed, episode).length, + }, + (transcript) => + transcript.absolute + ? `${origin}/${feed.slug}/${transcript.path}` + : transcript.path, + ), + ), + }); + for (const feed of feeds) { feed.episodes.forEach((episode, index) => { if (episode.seconds !== null) { @@ -112,26 +136,7 @@ export async function startFixtureServer(feeds: readonly FixtureFeed[]): Promise cache: true, }); } - routes.set(feedPath(feed), { - type: "application/rss+xml", - body: Buffer.from( - rss( - feed, - feed.artwork ? artworkUrl(feed) : null, - (episode) => - episode.seconds === null - ? null - : { - url: `${origin}${mediaPath(feed, episode)}`, - bytes: audio(feed, episode).length, - }, - (transcript) => - transcript.absolute - ? `${origin}/${feed.slug}/${transcript.path}` - : transcript.path, - ), - ), - }); + serveFeed(feed); } return { @@ -156,6 +161,14 @@ export async function startFixtureServer(feeds: readonly FixtureFeed[]): Promise await done; }; }, + retitle: (feed, episode, title) => { + serveFeed({ + ...feed, + episodes: feed.episodes.map((each) => + each === episode ? { ...each, title } : each, + ), + }); + }, requests: () => [...requests], stop: () => new Promise((resolve) => { From ffc67b692781bbc87c505b844062997ff2024384 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 08:05:44 +0200 Subject: [PATCH 21/27] fix(episodes): leave out a guid that repeats within a feed Two items of one feed can carry the same guid. Projected onto both episodes, it let a note of one episode open for the other, and become its capture destination. A repeated guid now identifies neither episode, so their notes and links fall back to the path and title rules. --- src/services/FeedRepository.test.ts | 19 +++++++++++++++++++ src/services/FeedRepository.ts | 20 +++++++++++--------- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/src/services/FeedRepository.test.ts b/src/services/FeedRepository.test.ts index b9ea5584..da2f73f7 100644 --- a/src/services/FeedRepository.test.ts +++ b/src/services/FeedRepository.test.ts @@ -479,6 +479,25 @@ describe("FeedRepository", () => { expect(JSON.parse(JSON.stringify(encodeEpisode(withoutGuid)))).not.toHaveProperty("guid"); }); + test("a guid that two items of a feed share identifies neither episode", async () => { + const t = setup(); + const items = [ + item(1, { guid: "shared" }), + item(2, { guid: "shared" }), + item(3, { guid: "own" }), + ]; + await loaded(t, { Alpha: alpha }, doc("Alpha", items)); + const linked = t.repo.episodesAt(alpha.url); + t.requests[1].respond.resolve(doc("Alpha", items)); + + for (const episodes of [t.feed("Alpha")!.episodes, await linked]) + expect(episodes.map(({ title, guid }) => [title, guid])).toEqual([ + ["Episode 1", undefined], + ["Episode 2", undefined], + ["Episode 3", "own"], + ]); + }); + test("reads an episode's transcripts from its own subscription's feed, by guid first", async () => { const t = setup(); const transcripts = [{ url: "https://media.test/b.vtt", type: "text/vtt" }]; diff --git a/src/services/FeedRepository.ts b/src/services/FeedRepository.ts index 3520c4d4..d22a26d4 100644 --- a/src/services/FeedRepository.ts +++ b/src/services/FeedRepository.ts @@ -148,8 +148,7 @@ export class FeedRepository implements Readable> */ async episodesAt(url: string): Promise { const doc = await this.documentAt({ url }, "network"); - const feed = channelFeed(doc.channel, url); - return doc.items.map((item) => projectEpisode(item, feed)); + return projectEpisodes(doc.items, channelFeed(doc.channel, url)); } /** Feed metadata that episodes do not carry, for one of a subscription's episodes. */ @@ -288,7 +287,7 @@ export class FeedRepository implements Readable> doc, title: feed.title, artworkUrl: feed.artworkUrl, - episodes: doc?.items.map((item) => projectEpisode(item, feed)) ?? [], + episodes: doc ? projectEpisodes(doc.items, feed) : [], }; } projections.set(key, projection); @@ -354,11 +353,13 @@ export async function loadFeed(url: string): Promise { return parser.parseEpisodes(xml); } -function projectEpisode( - item: ParsedEpisodeDocument, +function projectEpisodes( + items: readonly ParsedEpisodeDocument[], feed: Pick, -): Episode { - return { +): Episode[] { + const guidCounts = new Map(); + for (const { guid } of items) if (guid) guidCounts.set(guid, (guidCounts.get(guid) ?? 0) + 1); + return items.map((item) => ({ title: item.title, streamUrl: item.streamUrl, url: item.itemLink || feed.url, @@ -368,13 +369,14 @@ function projectEpisode( artworkUrl: item.artworkUrl || feed.artworkUrl, episodeDate: item.episodeDate, feedUrl: feed.url, - guid: item.guid, + // A guid that several items repeat identifies none of them, so notes and links never get it. + guid: item.guid && guidCounts.get(item.guid) === 1 ? item.guid : undefined, itunesTitle: item.itunesTitle || "", episodeNumber: item.episodeNumber, duration: item.duration, chaptersUrl: item.chaptersUrl, mediaType: item.mediaType, - }; + })); } function channelFeed(channel: ParsedFeedDocument, url: string): PodcastFeed { From 3b9fe47aeaadcdb63d4bf607594c167987ba31e1 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 08:09:18 +0200 Subject: [PATCH 22/27] fix(capture): keep the editor used last when a note in Reading view takes focus Every markdown tab that took focus became the remembered editor, a note in Reading view too, so opening one sent the next capture to the episode note while the editor stayed open. Obsidian fires no event when a note switches between Reading view and editing, so a note now counts by its mode when focus leaves it, and only an open note in Source or Live Preview mode receives a capture. --- src/commands.test.ts | 88 +++++++++++++++++++++++++++++----- src/services/CaptureService.ts | 26 ++++++---- 2 files changed, 91 insertions(+), 23 deletions(-) diff --git a/src/commands.test.ts b/src/commands.test.ts index 6e385d13..2e52bba5 100644 --- a/src/commands.test.ts +++ b/src/commands.test.ts @@ -117,21 +117,26 @@ function fakeWorkspace() { getLeaf: () => ({ openFile: async (file: TFile) => opened.push(file.path) }), }; + /** Opens a note in a tab that takes focus. Its `mode` switches it between Reading view and editing. */ + const openNote = (path: string, text: string, caret?: number, reading = false) => { + const leaf = {} as WorkspaceLeaf; + const editor = fakeEditor(text, caret); + const view = Object.assign(new MarkdownView(leaf), { editor, file: vaultFile(path) }); + const mode = vi.spyOn(view, "getMode").mockReturnValue(reading ? "preview" : "source"); + leaf.view = view; + open.push(leaf); + workspace.activeEditor = view; + trigger("active-leaf-change", leaf); + return { leaf, editor, mode }; + }; + return { workspace, /** Paths of the files opened in a tab. */ opened, - /** Opens a note and puts the caret in it. */ - openNote(path: string, text: string, caret?: number) { - const leaf = {} as WorkspaceLeaf; - const editor = fakeEditor(text, caret); - const view = Object.assign(new MarkdownView(leaf), { editor, file: vaultFile(path) }); - leaf.view = view; - open.push(leaf); - workspace.activeEditor = view; - trigger("active-leaf-change", leaf); - return { leaf, editor }; - }, + /** Opens a note in editing mode and puts the caret in it. */ + openNote: (path: string, text: string, caret?: number) => openNote(path, text, caret), + openInReadingView: (path: string, text: string) => openNote(path, text, undefined, true), /** A click in the PodNotes view: its leaf is active and no editor is. */ focusPodNotes() { workspace.activeEditor = null; @@ -245,8 +250,8 @@ describe("capture", () => { it("goes to the episode note when the last note is in Reading view", async () => { const { plugin, openNote, focusPodNotes, read } = await loadPlugin(playing); - const { leaf, editor } = openNote("Journal.md", "Notes"); - vi.spyOn(leaf.view as MarkdownView, "getMode").mockReturnValue("preview"); + const { mode, editor } = openNote("Journal.md", "Notes"); + mode.mockReturnValue("preview"); expect(await plugin.capture.timestamp()).toMatchObject({ destination: "note" }); focusPodNotes(); @@ -256,6 +261,63 @@ describe("capture", () => { expect(editor.getValue()).toBe("Notes"); }); + describe("past a note in Reading view", () => { + async function editorThenReading() { + const session = await loadPlugin(playing); + session.plugin.settings.timestamp.template = "- {{time}}\n"; + const editing = session.openNote("Journal.md", "Before\nAfter", 7); + const reading = session.openInReadingView("Article.md", "Read only"); + return { ...session, editing, reading }; + } + + it("lands at the caret of the editor used before it", async () => { + const { run, focusPodNotes, editing, reading, notes } = await editorThenReading(); + focusPodNotes(); + + run("capture-timestamp"); + run("capture-segment-10s"); + + expect(editing.editor.getValue()).toBe( + "Before\n- 00:20:34\n- 00:20:24-00:20:34\nAfter", + ); + expect(reading.editor.getValue()).toBe("Read only"); + expect(notes.size).toBe(0); + }); + + it("lands in the editor used before it while the Reading view has focus", async () => { + const { run, editing, reading, notes } = await editorThenReading(); + + run("capture-timestamp"); + + expect(editing.editor.getValue()).toBe("Before\n- 00:20:34\nAfter"); + expect(reading.editor.getValue()).toBe("Read only"); + expect(notes.size).toBe(0); + }); + + it("lands in that note once it is switched to editing while it has focus", async () => { + const { run, focusPodNotes, editing, reading } = await editorThenReading(); + reading.mode.mockReturnValue("source"); + focusPodNotes(); + + run("capture-timestamp"); + + expect(reading.editor.getValue()).toBe("Read only- 00:20:34\n"); + expect(editing.editor.getValue()).toBe("Before\nAfter"); + }); + }); + + it("goes to the episode note once the editor used last is switched to Reading view", async () => { + const { run, openNote, focusPodNotes, read } = await loadPlugin(playing); + const { mode, editor } = openNote("Journal.md", "Notes"); + focusPodNotes(); + mode.mockReturnValue("preview"); + + run("capture-timestamp"); + + await vi.waitFor(() => expect(read(NOTE_PATH)).toBe("# Saved Episode\n- 00:20:34\n")); + expect(editor.getValue()).toBe("Notes"); + }); + it("creates the episode note from its template, then appends to it", async () => { const { plugin, read } = await loadPlugin(playing); diff --git a/src/services/CaptureService.ts b/src/services/CaptureService.ts index 37635379..296097b3 100644 --- a/src/services/CaptureService.ts +++ b/src/services/CaptureService.ts @@ -17,20 +17,24 @@ export interface Capture { /** Puts a capture where the user writes: the editor they used last, else the episode note. */ export class CaptureService { + private activeLeaf: WorkspaceLeaf | null; // Clicking in the PodNotes view leaves workspace.activeEditor null. - private lastMarkdownLeaf: WorkspaceLeaf | null; + private lastEditorLeaf: WorkspaceLeaf | null = null; constructor(private readonly plugin: PodNotes) { const { workspace } = plugin.app; - this.lastMarkdownLeaf = workspace.getActiveViewOfType(MarkdownView)?.leaf ?? null; + this.activeLeaf = workspace.getActiveViewOfType(MarkdownView)?.leaf ?? null; plugin.registerEvent( workspace.on("active-leaf-change", (leaf) => { - if (leaf?.view instanceof MarkdownView) this.lastMarkdownLeaf = leaf; + // Switching a note between Reading view and editing fires no workspace event, + // so a note counts by the mode it has when focus leaves it. + if (this.editingView(this.activeLeaf)) this.lastEditorLeaf = this.activeLeaf; + this.activeLeaf = leaf; }), ); plugin.registerEvent( workspace.on("layout-change", () => { - if (!this.lastMarkdownView()) this.lastMarkdownLeaf = null; + if (!this.editingView(this.lastEditorLeaf)) this.lastEditorLeaf = null; }), ); } @@ -108,17 +112,19 @@ export class CaptureService { } } - /** The focused editor, which covers canvas and embedded editors, else the last markdown one. */ + /** The focused editor, which covers canvas and embedded editors, else the one used last. */ private editorTarget(): MarkdownFileInfo | null { const active = this.plugin.app.workspace.activeEditor; - const target = active?.editor ? active : this.lastMarkdownView(); // A note in Reading view keeps its hidden editor, where written text is lost. - return target instanceof MarkdownView && target.getMode() === "preview" ? null : target; + const reading = active instanceof MarkdownView && active.getMode() === "preview"; + return active?.editor && !reading ? active : this.editingView(this.lastEditorLeaf); } - private lastMarkdownView(): MarkdownView | null { - const leaf = this.lastMarkdownLeaf; + /** The leaf's note while it is open in Source or Live Preview mode. */ + private editingView(leaf: WorkspaceLeaf | null): MarkdownView | null { const open = leaf && this.plugin.app.workspace.getLeavesOfType("markdown").includes(leaf); - return open && leaf.view instanceof MarkdownView ? leaf.view : null; + return open && leaf.view instanceof MarkdownView && leaf.view.getMode() === "source" + ? leaf.view + : null; } } From b622f9afad595d5766ef929fd415cd8ab38d2439 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 08:10:05 +0200 Subject: [PATCH 23/27] build(deps-dev): parse YAML in tests with the library Obsidian bundles Obsidian's parseYaml is eemeli/yaml 2. The obsidian mock now parses with the same library and options, so unit tests see the YAML Obsidian sees. --- package-lock.json | 3 ++- package.json | 3 ++- tests/mocks/obsidian.ts | 5 +++++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5e6f8abc..954a461b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,8 @@ "typescript": "6.0.3", "vite": "^8.3.2", "vitest": "^5.0.3", - "wrangler": "4.149.0" + "wrangler": "4.149.0", + "yaml": "^2.8.4" }, "engines": { "node": "^22.22.2" diff --git a/package.json b/package.json index cb67e1a0..99a97cd5 100644 --- a/package.json +++ b/package.json @@ -65,7 +65,8 @@ "typescript": "6.0.3", "vite": "^8.3.2", "vitest": "^5.0.3", - "wrangler": "4.149.0" + "wrangler": "4.149.0", + "yaml": "^2.8.4" }, "overrides": { "@actions/http-client": { diff --git a/tests/mocks/obsidian.ts b/tests/mocks/obsidian.ts index 8db201ef..c3bbfb5d 100644 --- a/tests/mocks/obsidian.ts +++ b/tests/mocks/obsidian.ts @@ -1,3 +1,5 @@ +import { parse } from "yaml"; + export class SecretStorage { private values = new Map(); @@ -458,6 +460,9 @@ export const requestUrl = async () => ({ export const htmlToMarkdown = (value: string) => value; +// Obsidian's parseYaml is eemeli/yaml's parse(text, null, {}). +export const parseYaml = (text: string): unknown => parse(text, {}); + // Mutable so tests can simulate a specific OS (e.g. set isMacOS=false, isLinux=true). // Defaults to a desktop macOS profile, matching the typical dev environment. export const Platform = { From 90a4337d489042da3beb29a7137ed4b14cbd2964 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 08:12:30 +0200 Subject: [PATCH 24/27] fix(notes): add a note's guid and feedUrl by the YAML its frontmatter holds A line pattern decided which keys a template's frontmatter already had, so an indented key or an escaped one such as "gu\u0069d" got a second, unindented guid or feedUrl, and Obsidian showed Invalid properties. Existing keys now come from Obsidian's own YAML parser, new keys take the indentation of the mapping's first key, and frontmatter that is not a mapping, or would not read back as the same mapping plus the fields, is left as it is. --- src/utility/noteFrontmatter.test.ts | 90 +++++++++++++++++++++++++++++ src/utility/noteFrontmatter.ts | 48 ++++++++++++--- 2 files changed, 129 insertions(+), 9 deletions(-) create mode 100644 src/utility/noteFrontmatter.test.ts diff --git a/src/utility/noteFrontmatter.test.ts b/src/utility/noteFrontmatter.test.ts new file mode 100644 index 00000000..4c33ecea --- /dev/null +++ b/src/utility/noteFrontmatter.test.ts @@ -0,0 +1,90 @@ +import { parseYaml } from "obsidian"; +import { describe, expect, it } from "vitest"; +import { addFrontmatterFields } from "./noteFrontmatter"; + +const feedUrl = "https://example.com/feed.xml"; +const fields = [ + ["guid", "finale-1"], + ["feedUrl", feedUrl], +] as const; + +function properties(note: string): unknown { + const [, block] = /^---\n([\s\S]*?)\n---\n/.exec(note) ?? []; + return parseYaml(block); +} + +describe("addFrontmatterFields", () => { + it("adds a frontmatter block to content without one", () => { + expect(addFrontmatterFields("# Finale\n", fields)).toBe( + `---\nguid: "finale-1"\nfeedUrl: "${feedUrl}"\n---\n# Finale\n`, + ); + }); + + it("adds the fields before the closing line, keeping every existing line as written", () => { + const frontmatter = [ + "---", + "title: 'It''s a finale'", + "date: 2024-01-01", + "count: 0x1F", + "empty:", + "# a comment", + "tags:", + " - podcast", + "description: |", + " Two", + " lines", + ].join("\n"); + + expect(addFrontmatterFields(`${frontmatter}\n---\nBody`, fields)).toBe( + `${frontmatter}\nguid: "finale-1"\nfeedUrl: "${feedUrl}"\n---\nBody`, + ); + }); + + it.each([ + ["an indented key", '---\n guid: "mine"\n feedUrl: "existing"\n---\n'], + ["a quoted key", `---\n"guid": "mine"\n'feedUrl': "existing"\n---\n`], + ["an escaped key", '---\n"gu\\u0069d": "mine"\n"feed\\x55rl": "existing"\n---\n'], + ])("does not repeat %s YAML reads as a field", (_, note) => { + expect(addFrontmatterFields(note, fields)).toBe(note); + }); + + it("adds only the field an escaped key does not spell", () => { + const note = '---\n"gu\\u0069d": "mine"\n---\n'; + + const result = addFrontmatterFields(note, fields); + + expect(result).toBe(`---\n"gu\\u0069d": "mine"\nfeedUrl: "${feedUrl}"\n---\n`); + expect(properties(result)).toEqual({ guid: "mine", feedUrl }); + }); + + it("indents the fields like a mapping indented by two spaces", () => { + const note = '---\n# Episode\n title: "Finale"\n tags:\n - podcast\n---\n# Finale'; + + const result = addFrontmatterFields(note, fields); + + expect(result).toBe( + `---\n# Episode\n title: "Finale"\n tags:\n - podcast\n guid: "finale-1"\n feedUrl: "${feedUrl}"\n---\n# Finale`, + ); + expect(properties(result)).toEqual({ + title: "Finale", + tags: ["podcast"], + guid: "finale-1", + feedUrl, + }); + }); + + it("adds the fields to frontmatter that holds only a comment", () => { + expect(addFrontmatterFields("---\n# Filled in later\n---\n", fields)).toBe( + `---\n# Filled in later\nguid: "finale-1"\nfeedUrl: "${feedUrl}"\n---\n`, + ); + }); + + it.each([ + ["a flow mapping", "---\n{a: 1}\n---\n# Finale"], + ["invalid YAML", "---\ntitle: [unclosed\n---\n# Finale"], + ["a list", "---\n- one\n- two\n---\n# Finale"], + ["a scalar", "---\njust text\n---\n# Finale"], + ])("leaves %s as it is", (_, note) => { + expect(addFrontmatterFields(note, fields)).toBe(note); + }); +}); diff --git a/src/utility/noteFrontmatter.ts b/src/utility/noteFrontmatter.ts index 77d9c4b2..a51e43e1 100644 --- a/src/utility/noteFrontmatter.ts +++ b/src/utility/noteFrontmatter.ts @@ -1,6 +1,10 @@ +import { parseYaml } from "obsidian"; +import { isPlainObject } from "src/persistence/codecUtils"; + /** * Adds each field the content's frontmatter lacks, before its closing line, or as a new - * frontmatter block when the content has none. + * frontmatter block when the content has none. Frontmatter that is not a YAML mapping, or + * would not read back as the same mapping plus the fields, is left as it is. */ export function addFrontmatterFields( content: string, @@ -11,19 +15,45 @@ export function addFrontmatterFields( lines[0].trim() === "---" ? lines.findIndex((line, index) => index > 0 && line.trim() === "---") : -1; - const frontmatter = end > 0 ? lines.slice(1, end) : []; - const added = fields - .filter( - ([key]) => !frontmatter.some((line) => new RegExp(`^(["']?)${key}\\1\\s*:`).test(line)), - ) - .map(([key, value]) => `${key}: ${yamlString(value)}`); + if (end < 0) return `---\n${fieldLines(fields, "").join("\n")}\n---\n${content}`; + + const frontmatter = lines.slice(1, end); + const existing = parseMapping(frontmatter); + if (!existing) return content; + const missing = fields.filter(([key]) => !Object.prototype.hasOwnProperty.call(existing, key)); + if (!missing.length) return content; + + // Keys indented like the mapping's first key stay in that mapping. + const firstKey = frontmatter.find((line) => line.trim() && !line.trimStart().startsWith("#")); + const indent = firstKey ? firstKey.slice(0, firstKey.length - firstKey.trimStart().length) : ""; + const added = fieldLines(missing, indent); + const result = parseMapping([...frontmatter, ...added]); + const intact = + result && + Object.entries(existing).every( + ([key, value]) => JSON.stringify(result[key]) === JSON.stringify(value), + ) && + missing.every(([key, value]) => result[key] === value); + if (!intact) return content; - if (!added.length) return content; - if (end < 0) return `---\n${added.join("\n")}\n---\n${content}`; lines.splice(end, 0, ...added); return lines.join("\n"); } +function fieldLines(fields: ReadonlyArray, indent: string): string[] { + return fields.map(([key, value]) => `${indent}${key}: ${yamlString(value)}`); +} + +/** The lines' YAML as a mapping, empty for no YAML, or undefined for anything else. */ +function parseMapping(lines: readonly string[]): Record | undefined { + try { + const parsed: unknown = parseYaml(lines.join("\n")) ?? {}; + return isPlainObject(parsed) ? parsed : undefined; + } catch { + return undefined; + } +} + // A JSON string is a YAML double-quoted scalar once the characters YAML rejects raw are escaped. function yamlString(value: string): string { return JSON.stringify(value).replace( From 017895eb6ce64041f5fdc9f0b8b7724bf5e1327e Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 08:15:12 +0200 Subject: [PATCH 25/27] test(e2e): journeys for a GUID two episodes share, a note in Reading view, and indented frontmatter --- tests/e2e/journey-notes.test.ts | 187 ++++++++++++++++++++++++++++++-- tests/e2e/journey/library.ts | 2 + 2 files changed, 178 insertions(+), 11 deletions(-) diff --git a/tests/e2e/journey-notes.test.ts b/tests/e2e/journey-notes.test.ts index 2bd74cdd..25f31f26 100644 --- a/tests/e2e/journey-notes.test.ts +++ b/tests/e2e/journey-notes.test.ts @@ -53,17 +53,18 @@ const RETITLED = "Completely Different Name"; type Journey = Readonly<{ server: FixtureServer; show: FixtureFeed }>; /** Subscribes to a podcast named `title`, so each journey writes its notes to its own folder. */ -async function setUp(title: string): Promise { +async function setUp( + title: string, + { + episodes = [ORIGINAL, STEADY], + noteTemplate, + }: { episodes?: readonly FixtureEpisode[]; noteTemplate?: string } = {}, +): Promise { const context = getContext(); - const show: FixtureFeed = { - title, - slug: "identity", - artwork: true, - episodes: [ORIGINAL, STEADY], - }; + const show: FixtureFeed = { title, slug: "identity", artwork: true, episodes }; const server = await startFixtureServer([show]); onTestFinished(() => server.stop()); - await seedLibrary(context, server, { subscriptions: [show] }); + await seedLibrary(context, server, { subscriptions: [show], noteTemplate }); await context.obsidian.plugin("command-palette").enable({ filter: "core" }); return { server, show }; } @@ -79,15 +80,25 @@ function hms(seconds: number): string { .join(":"); } -/** The default "Capture timestamp format", `- {{linktime}} `, for `episode` at `seconds`. */ -function capture({ server, show }: Journey, episode: FixtureEpisode, seconds: number): string { +/** + * The default "Capture timestamp format", `- {{linktime}} `, for `episode` at `seconds`, + * or the segment from `seconds` to `end` that it captures as `- {{linksegment}} `. + */ +function capture( + { server, show }: Journey, + episode: FixtureEpisode, + seconds: number, + end?: number, +): string { const query = [ `episodeName=${encodeURIComponent(episode.title)}`, `url=${encodeURIComponent(server.feedUrl(show))}`, `guid=${encodeURIComponent(episode.guid ?? "")}`, `time=${seconds}`, + ...(end === undefined ? [] : [`endTime=${end}`]), ].join("&"); - return `- [${hms(seconds)}](obsidian://podnotes?${query}) `; + const shown = end === undefined ? hms(seconds) : `${hms(seconds)}-${hms(end)}`; + return `- [${shown}](obsidian://podnotes?${query}) `; } function frontmatter(note: string): string[] { @@ -177,6 +188,36 @@ async function waitForPrompt(placeholder: string): Promise { ); } +/** The titles in the open menu, once it shows. */ +async function menuTitles(): Promise { + const { obsidian } = getContext(); + return await obsidian.waitFor( + async () => { + const titles = await obsidian.dev.evalJson( + '[...document.querySelectorAll(".menu .menu-item-title")].map((el) => el.textContent.trim())', + ); + return titles.length > 0 ? titles : false; + }, + { timeoutMs: 10_000, intervalMs: 100, message: "the menu open" }, + ); +} + +/** The properties Obsidian reads from the note at `path`, once it reads any. */ +async function properties(path: string): Promise> { + const { obsidian } = getContext(); + return await obsidian.waitFor( + async () => + (await obsidian.dev.evalJson | null>( + `app.metadataCache.getFileCache(app.vault.getAbstractFileByPath(${JSON.stringify(path)}))?.frontmatter ?? null`, + )) ?? false, + { + timeoutMs: 10_000, + intervalMs: 200, + message: `Obsidian reading the properties of ${path}`, + }, + ); +} + async function detachMarkdownTabs(): Promise { await getContext().obsidian.dev.evalJson('app.workspace.detachLeavesOfType("markdown"), true'); } @@ -326,6 +367,82 @@ describe("episode note and capture journeys", () => { expect(server.requests().filter((path) => path.endsWith(".wav"))).toEqual([]); }); + test("J-NOTE-11 two episodes that share a GUID each get their own note", async () => { + const { obsidian } = getContext(); + const alpha: FixtureEpisode = { + title: "Alpha Original", + seconds: 60, + pubDate: pubDate(0), + guid: "shared-guid", + }; + const beta: FixtureEpisode = { ...alpha, title: "Beta Different", pubDate: pubDate(1) }; + const { show } = await setUp("Journey Shared GUID", { episodes: [alpha, beta] }); + const alphaNote = notePath(show, alpha.title); + const betaNote = notePath(show, beta.title); + + await showPodNotes(obsidian); + await click(obsidian, ui.podcastGridTab); + await click(obsidian, ui.podcastCard(show.title)); + await click(obsidian, ui.episodeMenu(alpha.title)); + await click(obsidian, ui.menuItem("Create Note")); + const alphaText = await waitForText( + alphaNote, + (text) => text.includes("\n# Alpha Original\n"), + "Alpha's note created", + ); + + await click(obsidian, ui.episodeMenu(beta.title)); + const titles = await menuTitles(); + expect(titles, "Beta's menu offers a note of its own").toContain("Create Note"); + expect(titles).not.toContain("Open Note"); + await click(obsidian, ui.menuItem("Create Note")); + const betaText = await waitForText( + betaNote, + (text) => text.includes("\n# Beta Different\n"), + "Beta's own note created", + ); + await waitForActiveFile(betaNote, "Beta's note opened"); + + expect(await readVaultText(obsidian, alphaNote), "Alpha's note untouched").toBe(alphaText); + for (const text of [alphaText, betaText]) + expect( + frontmatter(text).filter((line) => line.startsWith("guid:")), + "a GUID two episodes share identifies neither", + ).toEqual([]); + }); + + test("J-NOTE-12 a note template with indented frontmatter keys gets the GUID in the same mapping", async () => { + const { obsidian } = getContext(); + const { server, show } = await setUp("Journey Indented", { + noteTemplate: + '---\n type: podcastEpisode\n feedUrl: "{{feedurl}}"\n---\n# {{title}}\n', + }); + const note = notePath(show, ORIGINAL.title); + const feedUrl = server.feedUrl(show); + + await showPodNotes(obsidian); + await click(obsidian, ui.podcastGridTab); + await click(obsidian, ui.podcastCard(show.title)); + await click(obsidian, ui.episodeMenu(ORIGINAL.title)); + await click(obsidian, ui.menuItem("Create Note")); + const text = await waitForText( + note, + (value) => value.includes("\n# Original Title\n"), + "the episode note created", + ); + + expect(frontmatter(text)).toEqual([ + " type: podcastEpisode", + ` feedUrl: "${feedUrl}"`, + ` guid: "${ORIGINAL.guid}"`, + ]); + expect(await properties(note)).toEqual({ + type: "podcastEpisode", + feedUrl, + guid: ORIGINAL.guid, + }); + }); + test("J-TS-08 Capture Timestamp from the palette lands at the caret of the editor used last", async () => { const { obsidian, sandbox } = getContext(); const journey = await setUp("Journey Caret"); @@ -356,6 +473,54 @@ describe("episode note and capture journeys", () => { ).toBe(false); }); + test("J-TS-11 captures land in the editor used last, not in a note opened since in Reading view", async () => { + const { obsidian, sandbox } = getContext(); + const journey = await setUp("Journey Reading Tab"); + const remembered = sandbox.path("Remembered.md"); + const reading = sandbox.path("Reading other.md"); + await writeVaultFile(obsidian, remembered, "Before the cut after the cut\n"); + await writeVaultFile(obsidian, reading, "Read only\n"); + + await playFromList(journey.show, ORIGINAL.title); + await openInEditor(obsidian, remembered); + expect(await clickInEditor(obsidian, remembered, 0, 14), "caret mid-line").toEqual({ + line: 0, + ch: 14, + }); + await openInReadingView(obsidian, reading); + expect( + await obsidian.dev.evalJson>( + 'Object.fromEntries(app.workspace.getLeavesOfType("markdown").map((leaf) => [leaf.view.file?.path, leaf.view.getMode()]))', + ), + "the editor and the Reading view both open", + ).toMatchObject({ [remembered]: "source", [reading]: "preview" }); + const paused = await pauseInView(); + expect(await activeEditorPath(), "the PodNotes view has focus").toBeNull(); + + await runFromPalette(obsidian, "Capture Timestamp"); + await runFromPalette(obsidian, "Capture Last 10 Seconds"); + const timestamp = capture(journey, ORIGINAL, paused.seconds); + const segment = capture( + journey, + ORIGINAL, + Math.max(0, paused.seconds - 10), + paused.seconds, + ); + const text = await waitForText( + remembered, + (value) => value.includes("endTime="), + "both captures saved into the editor used last", + ); + expect(text).toBe(`Before the cut${timestamp}${segment} after the cut\n`); + expect(await readVaultText(obsidian, reading), "the Reading view note untouched").toBe( + "Read only\n", + ); + expect( + await exists(notePath(journey.show, ORIGINAL.title)), + "no episode note created", + ).toBe(false); + }); + test("J-TS-10 Capture Timestamp with no editor open fills the episode note, one capture per line", async () => { const { obsidian } = getContext(); const journey = await setUp("Journey No Editor"); diff --git a/tests/e2e/journey/library.ts b/tests/e2e/journey/library.ts index 8f46a35c..4636f35f 100644 --- a/tests/e2e/journey/library.ts +++ b/tests/e2e/journey/library.ts @@ -50,6 +50,7 @@ export type LibrarySeed = Readonly<{ }>; openAISecretId?: string; transcriptTemplate?: string; + noteTemplate?: string; }>; type JourneyContext = Readonly<{ @@ -377,6 +378,7 @@ export async function seedLibrary( ); if (seed.openAISecretId) data.openAISecretId = seed.openAISecretId; if (seed.transcriptTemplate) data.transcript.template = seed.transcriptTemplate; + if (seed.noteTemplate) data.note.template = seed.noteTemplate; }, RELOAD_OPTIONS); await dismissNotices(obsidian); } From 91711ebb3a03c75844a80220675deceb4c677882 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 08:45:06 +0200 Subject: [PATCH 26/27] fix(feeds): keep a repeated guid repeated when the cache keeps only a feed's newest episodes --- src/services/FeedRepository.test.ts | 20 ++++++++++++++++++++ src/services/FeedRepository.ts | 22 ++++++++++++++++++---- 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/services/FeedRepository.test.ts b/src/services/FeedRepository.test.ts index da2f73f7..6cdd4a96 100644 --- a/src/services/FeedRepository.test.ts +++ b/src/services/FeedRepository.test.ts @@ -498,6 +498,26 @@ describe("FeedRepository", () => { ]); }); + test("a guid whose twin the cache cut identifies no episode after a restart", async () => { + const first = setup(); + const items = Array.from({ length: 76 }, (_, index) => + item(76 - index, { guid: `own-${76 - index}` }), + ); + items[0] = item(76, { guid: "shared" }); + items[75] = item(1, { guid: "shared" }); + await loaded(first, { Alpha: alpha }, doc("Alpha", items)); + expect(first.cached()[alpha.url].items).toHaveLength(75); + + const t = setup({ storage: first.storage }); + t.repo.setSubscriptions({ Alpha: alpha }); + await t.repo.ensureAll(); + + expect(t.load).not.toHaveBeenCalled(); + const guids = new Map(t.feed("Alpha")!.episodes.map(({ title, guid }) => [title, guid])); + expect(guids.get("Episode 76")).toBeUndefined(); + expect(guids.get("Episode 75")).toBe("own-75"); + }); + test("reads an episode's transcripts from its own subscription's feed, by guid first", async () => { const t = setup(); const transcripts = [{ url: "https://media.test/b.vtt", type: "text/vtt" }]; diff --git a/src/services/FeedRepository.ts b/src/services/FeedRepository.ts index d22a26d4..80a5ac48 100644 --- a/src/services/FeedRepository.ts +++ b/src/services/FeedRepository.ts @@ -357,8 +357,7 @@ function projectEpisodes( items: readonly ParsedEpisodeDocument[], feed: Pick, ): Episode[] { - const guidCounts = new Map(); - for (const { guid } of items) if (guid) guidCounts.set(guid, (guidCounts.get(guid) ?? 0) + 1); + const repeated = repeatedGuids(items); return items.map((item) => ({ title: item.title, streamUrl: item.streamUrl, @@ -370,7 +369,7 @@ function projectEpisodes( episodeDate: item.episodeDate, feedUrl: feed.url, // A guid that several items repeat identifies none of them, so notes and links never get it. - guid: item.guid && guidCounts.get(item.guid) === 1 ? item.guid : undefined, + guid: item.guid && !repeated.has(item.guid) ? item.guid : undefined, itunesTitle: item.itunesTitle || "", episodeNumber: item.episodeNumber, duration: item.duration, @@ -402,7 +401,22 @@ const MAX_CACHE_BYTES = 4 * 1024 * 1024; function keepNewestItems(doc: FeedDocument): FeedDocument { if (doc.items.length <= MAX_ITEMS_PER_FEED) return doc; const kept = new Set([...doc.items].sort(newestFirst).slice(0, MAX_ITEMS_PER_FEED)); - return { ...doc, items: doc.items.filter((item) => kept.has(item)), complete: false }; + // The cut can drop one of two items that share a guid, which would make the other look unique. + const repeated = repeatedGuids(doc.items); + const items = doc.items + .filter((item) => kept.has(item)) + .map((item) => + item.guid && repeated.has(item.guid) ? { ...item, guid: undefined } : item, + ); + return { ...doc, items, complete: false }; +} + +/** Guids that more than one item carries. */ +function repeatedGuids(items: readonly ParsedEpisodeDocument[]): Set { + const seen = new Set(); + const repeated = new Set(); + for (const { guid } of items) if (guid) (seen.has(guid) ? repeated : seen).add(guid); + return repeated; } function evictOldest( From 62c16e59891528ab2029e92d9ced14966c26aa82 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Tue, 13 Oct 2026 08:45:06 +0200 Subject: [PATCH 27/27] fix(notes): list every episode of a long podcast in the episode note picker --- src/commands.test.ts | 23 +++++++++++++++++++++++ src/commands.ts | 3 ++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/commands.test.ts b/src/commands.test.ts index 2e52bba5..0031b09f 100644 --- a/src/commands.test.ts +++ b/src/commands.test.ts @@ -532,6 +532,29 @@ describe("Create episode note", () => { expect(notes.size).toBe(0); }); + it("lists a long podcast's older episodes when a restart left only its newest cached", async () => { + vi.mocked(fetchTextWithTimeout).mockResolvedValue(rss(["Two", "One"])); + const pickers = watchPickers(); + const { plugin, commands } = await loadPlugin(withFeed); + const newest = { title: "Two", streamUrl: "https://media.test/Two.mp3", description: "" }; + vi.mocked(plugin.app.loadLocalStorage).mockReturnValue( + JSON.stringify({ + [feed.url]: { + channel: { title: "Pod" }, + items: [{ ...newest, content: "", mediaType: "audio" }], + complete: false, + updatedAt: Date.now(), + }, + }), + ); + + command(commands)(false); + pickers.shown(0).choose("Pod"); + + await vi.waitFor(() => expect(pickers.open).toHaveBeenCalledTimes(2)); + expect(pickers.shown(1).texts).toEqual(["Two", "One"]); + }); + it("creates the loaded episode's note without a picker", async () => { const pickers = watchPickers(); const { commands, read, opened } = await loadPlugin({ diff --git a/src/commands.ts b/src/commands.ts index fae007cc..c2432cd0 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -313,7 +313,8 @@ export function registerCommands(plugin: PodNotes): void { } async function pickEpisodeForNote(plugin: PodNotes, key: string, title: string): Promise { - await plugin.feeds.load([key], "cached"); + // The cache keeps a long feed's newest episodes, so only a complete feed lists them all. + await plugin.feeds.load([key], "complete"); const state = get(plugin.feeds).get(key); const episodes = state?.episodes ?? []; if (episodes.length === 0) {