diff --git a/.claude/skills/verify/SKILL.md b/.claude/skills/verify/SKILL.md index de327b5f..448ecf3b 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: @@ -199,7 +200,9 @@ its profile. It keeps the vault and `.obsidian-e2e-artifacts/`. Delete `covered by ` when it stays covered, as under the full player. `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. 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)); 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/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/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 0f8d37f4..421d9e37 100644 --- a/src/URIHandler.feeds.test.ts +++ b/src/URIHandler.feeds.test.ts @@ -117,6 +117,22 @@ describe("podNotesURIHandler with saved feeds", () => { expect(reveal).toHaveBeenCalledTimes(1); }); + 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 d7404996..746edfa5 100644 --- a/src/URIHandler.test.ts +++ b/src/URIHandler.test.ts @@ -736,6 +736,137 @@ 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", async () => { + loadedAt(retitled, 10); + mockEpisodesAt.mockResolvedValue([trailer, retitled]); + + await link({ guid: "ep-1", time: "90" }); + + 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 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" }; + 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 = { + ...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 160c3747..24ff344a 100644 --- a/src/URIHandler.ts +++ b/src/URIHandler.ts @@ -9,11 +9,13 @@ 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"; type PodNotesProtocolData = ObsidianProtocolData & { + guid?: string; end?: string; endTime?: string; to?: string; @@ -89,7 +91,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) { @@ -147,11 +149,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 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 === guid) && (nameCandidates.includes(loaded.title) || nameCandidates.some((name) => titleTokenSimilarity(name, loaded.title) === 1)); @@ -231,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" @@ -268,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/commands.test.ts b/src/commands.test.ts new file mode 100644 index 00000000..0031b09f --- /dev/null +++ b/src/commands.test.ts @@ -0,0 +1,577 @@ +// @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 { privateFeedPlaceholder } from "./utility/privateFeedUrl"; +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", + 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 opened: string[] = []; + 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: 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 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; + 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("goes to the episode note when the last note is in Reading view", async () => { + const { plugin, openNote, focusPodNotes, read } = await loadPlugin(playing); + const { mode, editor } = openNote("Journal.md", "Notes"); + mode.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"); + }); + + 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); + + 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("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, + 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"), + ); + }); + }); +}); + +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("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("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({ + ...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 6b3df16a..c2432cd0 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,17 +1,15 @@ -import { Notice, type Editor } from "obsidian"; +import { Notice } 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"; +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 { notifyPrivateFeedUnavailable } from "src/services/privateFeeds"; import type PodNotes from "src/main"; /** @@ -20,42 +18,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 +120,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 +135,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,33 +144,41 @@ 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); }, }); 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.entries(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", + ([key, feed]) => { + void pickEpisodeForNote(plugin, key, feed.title); + }, + ).open(); }, }); @@ -246,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(); }, }); @@ -348,3 +311,30 @@ export function registerCommands(plugin: PodNotes): void { }, }); } + +async function pickEpisodeForNote(plugin: PodNotes, key: string, title: string): Promise { + // 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) { + if (state?.error === "unavailable") { + notifyPrivateFeedUnavailable(title); + } else { + 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/createPodcastNote.test.ts b/src/createPodcastNote.test.ts index 48de3b6f..eeb1b9d3 100644 --- a/src/createPodcastNote.test.ts +++ b/src/createPodcastNote.test.ts @@ -1,8 +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()); @@ -55,6 +61,7 @@ describe("createPodcastNote chapters template support (#47)", () => { afterEach(() => { mockFetchChapters.mockReset(); + vi.restoreAllMocks(); plugin.set(undefined as never); }); @@ -84,7 +91,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 +112,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 () => { @@ -130,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", @@ -320,3 +378,188 @@ 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("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'); + + 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("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 } }, + { 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..8689ec17 100644 --- a/src/createPodcastNote.ts +++ b/src/createPodcastNote.ts @@ -14,11 +14,13 @@ 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. * 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 { @@ -73,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) { @@ -82,20 +85,30 @@ 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); const template = pluginInstance.settings.note.template; const chapters = await getTemplateChapters(template, episode); - const content = NoteTemplateEngine(template, episode, { chapters }); + // Sync can write the note while the chapters load. + const arrivedFile = chapters && getPodcastNote(episode); + if (arrivedFile) return { file: arrivedFile, created: false }; - return await createFileIfNotExists(filePathDotMd, content, episode); + const content = NoteTemplateEngine(template, episode, { chapters }); + const identified = + episode.guid && episode.feedUrl + ? addFrontmatterFields(content, [ + ["guid", episode.guid], + ["feedUrl", episode.feedUrl], + ]) + : content; + + return createFileIfNotExists(filePathDotMd, identified); } async function getTemplateChapters( @@ -112,6 +125,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 +138,18 @@ 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 && typeof frontmatter.guid === "string" + ? [{ 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 +168,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; } @@ -154,28 +182,19 @@ export function openPodcastNote(epiosode: Episode): void { 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; - } - +): 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; } diff --git a/src/main.activateView.test.ts b/src/main.activateView.test.ts index 4862c227..2fff9572 100644 --- a/src/main.activateView.test.ts +++ b/src/main.activateView.test.ts @@ -285,6 +285,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(), }, @@ -393,18 +394,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 () => { @@ -535,10 +534,13 @@ describe("PodNotes onload wiring (#55)", () => { replaceSelection: vi.fn(), }; Object.assign(plugin.app.workspace, { - activeEditor: { editor, file: { basename: "Daily note" } }, + activeEditor: { editor, file: { path: "Journal/Daily note.md" } }, }); - await expect(plugin.captureTimestampToEditorOrNote()).resolves.toBe("Daily note"); + await expect(plugin.capture.timestamp()).resolves.toMatchObject({ + destination: "editor", + path: "Journal/Daily note.md", + }); expect(editor.replaceSelection).toHaveBeenCalledWith(expect.stringContaining("00:20:34")); }); @@ -547,7 +549,7 @@ describe("PodNotes onload wiring (#55)", () => { const notice = vi.spyOn(obsidian, "Notice"); plugin.settings.note.path = ""; - await expect(plugin.captureTimestampToEditorOrNote()).resolves.toBeNull(); + await expect(plugin.capture.timestamp()).resolves.toBeNull(); expect(notice.mock.calls).toEqual([ ["Set an episode note path and template to capture timestamps"], ]); 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 3ebaf439..637b87da 100644 --- a/src/main.ts +++ b/src/main.ts @@ -12,15 +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 TFile, - 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"; @@ -29,14 +21,12 @@ import { MainView } from "src/ui/PodcastView"; import { Navigation } from "src/ui/PodcastView/Navigation"; 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 { get, type Unsubscriber } from "svelte/store"; import { PlayerController } from "./player/PlayerController"; import { bindMediaSession } from "./player/mediaSession"; @@ -109,6 +99,7 @@ export default class PodNotes extends Plugin implements IPodNotes { public feedUrls!: FeedUrlRepository; public player!: PlayerController; public feeds!: FeedRepository; + public capture!: CaptureService; public navigation!: Navigation; private views = new Set(); @@ -198,6 +189,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). @@ -244,7 +236,7 @@ export default class PodNotes extends Plugin implements IPodNotes { bindMediaSession(this.player, { mediaSession: window.navigator.mediaSession, MediaMetadata: window.MediaMetadata, - captureTimestamp: () => void this.captureTimestampToEditorOrNote(), + captureTimestamp: () => void this.capture.timestamp(), skipBackward: () => this.api.skipBackward(), skipForward: () => this.api.skipForward(), }), @@ -365,9 +357,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); @@ -376,70 +366,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; - } - - /** - * Into the active editor, else appended to the episode note. Resolves to the - * note's name, or null when nothing was captured or the editor has no note. - */ - async captureTimestampToEditorOrNote(): Promise { - const active = this.app.workspace.activeEditor; - if (this.captureTimestamp(active?.editor)) return active?.file?.basename ?? null; - - return (await this.appendTimestampToEpisodeNote())?.basename ?? null; - } - - private async appendTimestampToEpisodeNote(): Promise { - if (!this.api.podcast) return null; - - // This path runs from the media-session (headphone) handler and the mini - // player, where there is no editor to surface a failure, so a press that - // adds nothing looked like it did nothing. Say why instead (NT-13). - const { timestamp, note } = this.settings; - if (!timestamp.template || !note.path || !note.template) { - new Notice("Set an episode note path and template to capture timestamps"); - return null; - } - - 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 file; - } catch (error) { - console.error("PodNotes: failed to capture timestamp into episode note", error); - new Notice("Failed to capture timestamp into episode note"); - return null; - } - } - override onunload() { // While the player and the store bindings still save what a rename changes. this.closeRenames?.(); 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/CaptureService.ts b/src/services/CaptureService.ts new file mode 100644 index 00000000..296097b3 --- /dev/null +++ b/src/services/CaptureService.ts @@ -0,0 +1,130 @@ +import { MarkdownView, Notice, type MarkdownFileInfo, type WorkspaceLeaf } from "obsidian"; +import { TimestampTemplateEngine } from "src/TemplateEngine"; +import { createPodcastNoteFileIfNotExists } 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 { + private activeLeaf: WorkspaceLeaf | null; + // Clicking in the PodNotes view leaves workspace.activeEditor null. + private lastEditorLeaf: WorkspaceLeaf | null = null; + + constructor(private readonly plugin: PodNotes) { + const { workspace } = plugin.app; + this.activeLeaf = workspace.getActiveViewOfType(MarkdownView)?.leaf ?? null; + plugin.registerEvent( + workspace.on("active-leaf-change", (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.editingView(this.lastEditorLeaf)) this.lastEditorLeaf = 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 { 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 }; + } 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 one used last. */ + private editorTarget(): MarkdownFileInfo | null { + const active = this.plugin.app.workspace.activeEditor; + // A note in Reading view keeps its hidden editor, where written text is lost. + const reading = active instanceof MarkdownView && active.getMode() === "preview"; + return active?.editor && !reading ? active : this.editingView(this.lastEditorLeaf); + } + + /** 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.getMode() === "source" + ? leaf.view + : null; + } +} diff --git a/src/services/FeedRepository.test.ts b/src/services/FeedRepository.test.ts index 8c9dca66..6cdd4a96 100644 --- a/src/services/FeedRepository.test.ts +++ b/src/services/FeedRepository.test.ts @@ -458,42 +458,109 @@ 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 () => { + 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("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" }]; 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("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 () => { diff --git a/src/services/FeedRepository.ts b/src/services/FeedRepository.ts index 0afffa95..80a5ac48 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. */ @@ -147,22 +148,18 @@ 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. */ - 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) ?? []; + // A guid is unique only within its feed, and another feed can share the subscription's name. const item = - feed && - this.sources - .get(feed.url) - ?.doc?.items.find(({ streamUrl }) => streamUrl === episode.streamUrl); - return item && { guid: item.guid, transcripts: item.transcripts ?? [] }; + (feed?.url === episode.feedUrl ? findByGuid(items, episode.guid) : undefined) ?? + items.find(({ streamUrl }) => streamUrl === episode.streamUrl); + return item && { transcripts: item.transcripts ?? [] }; } itemUrl(key: FeedKey, reference: string): string | undefined { @@ -290,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); @@ -356,11 +353,12 @@ 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 repeated = repeatedGuids(items); + return items.map((item) => ({ title: item.title, streamUrl: item.streamUrl, url: item.itemLink || feed.url, @@ -370,12 +368,14 @@ function projectEpisode( artworkUrl: item.artworkUrl || feed.artworkUrl, 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 && !repeated.has(item.guid) ? 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 { @@ -401,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( 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")); 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/types/IPodNotes.ts b/src/types/IPodNotes.ts index ea65a47c..d7b8418d 100644 --- a/src/types/IPodNotes.ts +++ b/src/types/IPodNotes.ts @@ -1,5 +1,6 @@ import type { IAPI } from "src/API/IAPI"; import type { PlayerController } from "src/player/PlayerController"; +import type { CaptureService } from "src/services/CaptureService"; import type { FeedRepository } from "src/services/FeedRepository"; import type { Navigation } from "src/ui/PodcastView/Navigation"; import type { IPodNotesSettings } from "./IPodNotesSettings"; @@ -14,10 +15,10 @@ export interface IPodNotes { player: PlayerController; feeds: FeedRepository; navigation: Navigation; + capture: CaptureService; shouldMountPodcastView(): boolean; unregisterPodcastView(view: PodNotesViewRegistration): void; saveSettings(): Promise; showPlayer(): Promise; openSettingsTab(): void; - captureTimestampToEditorOrNote(): Promise; } 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/src/ui/PodcastView/MiniPlayer.svelte b/src/ui/PodcastView/MiniPlayer.svelte index 70d94394..7cb82a15 100644 --- a/src/ui/PodcastView/MiniPlayer.svelte +++ b/src/ui/PodcastView/MiniPlayer.svelte @@ -27,7 +27,7 @@ } async function capture() { - const note = await captureTimestamp(); + const note = (await captureTimestamp())?.path.split("/").pop()?.replace(/\.md$/, ""); if (note) new Notice(`Timestamp added to ${note}`); } diff --git a/src/ui/PodcastView/PodcastView.shell.test.ts b/src/ui/PodcastView/PodcastView.shell.test.ts index 855fa152..922c8df5 100644 --- a/src/ui/PodcastView/PodcastView.shell.test.ts +++ b/src/ui/PodcastView/PodcastView.shell.test.ts @@ -333,7 +333,12 @@ describe("mini player", () => { services.captureTimestamp.mockResolvedValueOnce(null); await fireEvent.click(button("Capture timestamp")); - services.captureTimestamp.mockResolvedValueOnce("Episode 1 Launch"); + services.captureTimestamp.mockResolvedValueOnce({ + destination: "note", + path: "PodNotes/Pod/Episode 1 Launch.md", + text: "- 00:00:01\n", + created: false, + }); await fireEvent.click(button("Capture timestamp")); await tick(); diff --git a/src/ui/PodcastView/context.ts b/src/ui/PodcastView/context.ts index ce849c9b..909f833a 100644 --- a/src/ui/PodcastView/context.ts +++ b/src/ui/PodcastView/context.ts @@ -3,6 +3,7 @@ import { createContext } from "svelte"; import type { Readable } from "svelte/store"; import type { IAPI } from "src/API/IAPI"; import type { PlayerController } from "src/player/PlayerController"; +import type { Capture } from "src/services/CaptureService"; import type { FeedRepository } from "src/services/FeedRepository"; import type DownloadedEpisode from "src/types/DownloadedEpisode"; import type { IPodNotesSettings } from "src/types/IPodNotesSettings"; @@ -33,8 +34,8 @@ export interface ShellServices { readonly library: Library; /** Current through a settings toggle or an import, which replaces the object. */ readonly settings: Readable; - /** Into the active note, else into the episode note. Resolves to that note's name, or null. */ - captureTimestamp(): Promise; + /** Into the note being written, else into the episode note. Null when nothing was captured. */ + captureTimestamp(): Promise; /** Opens Obsidian's settings at the PodNotes tab. */ openSettings(): void; } diff --git a/src/ui/PodcastView/index.ts b/src/ui/PodcastView/index.ts index 404371c7..4a9a151a 100644 --- a/src/ui/PodcastView/index.ts +++ b/src/ui/PodcastView/index.ts @@ -98,7 +98,7 @@ export class MainView extends ItemView { episodeListLimit, }, settings: derived(pluginStore, ($plugin) => $plugin.settings), - captureTimestamp: () => plugin.captureTimestampToEditorOrNote(), + captureTimestamp: () => plugin.capture.timestamp(), openSettings: () => plugin.openSettingsTab(), }; } 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..ea24c524 100644 --- a/src/utility/buildEpisodeResumeLink.ts +++ b/src/utility/buildEpisodeResumeLink.ts @@ -21,8 +21,8 @@ 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.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/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/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/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 new file mode 100644 index 00000000..a51e43e1 --- /dev/null +++ b/src/utility/noteFrontmatter.ts @@ -0,0 +1,63 @@ +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 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, + fields: ReadonlyArray, +): string { + const lines = content.split("\n"); + const end = + lines[0].trim() === "---" + ? lines.findIndex((line, index) => index > 0 && line.trim() === "---") + : -1; + 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; + + 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( + /[\u007f-\u009f\ufffe\uffff]/g, + (char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`, + ); +} diff --git a/tests/e2e/journey-notes.test.ts b/tests/e2e/journey-notes.test.ts new file mode 100644 index 00000000..0aca6fa9 --- /dev/null +++ b/tests/e2e/journey-notes.test.ts @@ -0,0 +1,643 @@ +import { describe, expect, onTestFinished, test } from "vitest"; +import { createPodNotesE2EHarness } from "./harness"; +import { + click, + clickInEditor, + closePopoutTab, + closeSettings, + dismissNotices, + isShown, + openInEditor, + openInReadingView, + openPodNotesSettings, + reloadObsidian, + runFromPalette, + showLibrary, + 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, + { + episodes = [ORIGINAL, STEADY], + noteTemplate, + }: { episodes?: readonly FixtureEpisode[]; noteTemplate?: string } = {}, +): Promise { + const context = getContext(); + const show: FixtureFeed = { title, slug: "identity", artwork: true, episodes }; + const server = await startFixtureServer([show]); + onTestFinished(() => server.stop()); + await seedLibrary(context, server, { subscriptions: [show], noteTemplate }); + 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`, + * 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("&"); + const shown = end === undefined ? hms(seconds) : `${hms(seconds)}-${hms(end)}`; + return `- [${shown}](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 showLibrary(obsidian); + 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` }, + ); +} + +/** 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'); +} + +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 showLibrary(obsidian); + 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-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 showLibrary(obsidian); + 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 showLibrary(obsidian); + 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"); + 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-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"); + 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 75bd47c3..9285d287 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<{ @@ -93,6 +103,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)})`, @@ -129,6 +144,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}`)})`, + }), pageScroller: byCss("page scroller", `${PLAYER_VIEW} .pn-scroll`), } as const; @@ -300,6 +321,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()}`); @@ -402,6 +437,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, @@ -520,8 +621,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) => { 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); } diff --git a/tests/e2e/podnotes-runtime.test.ts b/tests/e2e/podnotes-runtime.test.ts index bb43995c..57ae39ea 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); @@ -1312,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/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. 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, diff --git a/tests/mocks/obsidian.ts b/tests/mocks/obsidian.ts index 70216a25..9fbbefe8 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(); @@ -148,6 +150,17 @@ export class ItemView { async setState(_state: unknown, _result: unknown): Promise {} } +export class MarkdownView { + editor: unknown; + file: TFile | null = null; + + constructor(public leaf: WorkspaceLeaf) {} + + getMode(): "source" | "preview" { + return "source"; + } +} + export class Modal { app: App; containerEl: HTMLElement; @@ -181,7 +194,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 { @@ -528,6 +545,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 = { diff --git a/tests/mocks/services.ts b/tests/mocks/services.ts index 20ec29c2..28adf5b4 100644 --- a/tests/mocks/services.ts +++ b/tests/mocks/services.ts @@ -2,6 +2,7 @@ import { App } from "obsidian"; import { vi } from "vitest"; import { DEFAULT_EPISODE_LIST_LIMIT, DEFAULT_SETTINGS } from "src/constants"; import { PlayerController } from "src/player/PlayerController"; +import type { Capture } from "src/services/CaptureService"; import { FeedRepository } from "src/services/FeedRepository"; import { derived, writable, type Readable, type Writable } from "svelte/store"; import { @@ -56,7 +57,7 @@ export const pluginLibrary: TestLibrary = { export type TestServices = ShellServices & { readonly library: TestLibrary; - readonly captureTimestamp: ReturnType Promise>>; + readonly captureTimestamp: ReturnType Promise>>; readonly openSettings: ReturnType void>>; };