From 2a0692219c0073f41e8de5fe382c739490505651 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Wed, 14 Oct 2026 18:23:38 +0200 Subject: [PATCH 01/17] test(e2e): add the Transcript panel journeys The full player has no Transcript panel yet, so all three fail on choosing it. --- tests/e2e/journey-transcript.test.ts | 227 +++++++++++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 tests/e2e/journey-transcript.test.ts diff --git a/tests/e2e/journey-transcript.test.ts b/tests/e2e/journey-transcript.test.ts new file mode 100644 index 00000000..efe1b98f --- /dev/null +++ b/tests/e2e/journey-transcript.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, onTestFinished, test } from "vitest"; +import { createPodNotesE2EHarness } from "./harness"; +import { dismissNotices, runCommand, setOffline, showPodNotes } from "./journey/actions"; +import { click, pressKey, typeText } from "./journey/input"; +import { PLAYER_VIEW, ui, type UiTarget } from "./journey/targets"; +import { eventually, isDisabled, isShown, notices, waitUntilShown } from "./journey/wait"; +import { + type FixtureEpisode, + type FixtureFeed, + type FixtureServer, + pubDate, + startFixtureServer, +} from "./journey/fixtures"; +import { readVaultText, seedLibrary, storedEpisode, waitForPlayback } from "./journey/library"; +import { jsLiteral, PLUGIN_ID } from "./runtime"; + +const getContext = createPodNotesE2EHarness("journey-transcript"); + +const CUES: ReadonlyArray = [ + ["Maya Lind", "Welcome back to Field Notes."], + ["Maya Lind", "Today we build a memory palace together."], + ["Ana Ilic", "Where does the palace start?"], + ["Ana Ilic", "At your own front door."], + ["Maya Lind", "Then every room holds one idea."], + ["Maya Lind", "The stranger the image, the longer it stays."], + ["Ana Ilic", "And spaced repetition does the rest."], + ["Ana Ilic", "Walk the Palace again a day later."], + ["Maya Lind", "Then a week later, then a month later."], + ["Maya Lind", "That is the whole method."], +]; +const LINES = CUES.map(([, text]) => text); +const clock = (seconds: number) => `00:00:${String(seconds).padStart(2, "0")}.000`; +const VTT = `WEBVTT\n\n${CUES.map( + ([speaker, text], index) => + `${clock(index * 6)} --> ${clock(index * 6 + 6)}\n${text}`, +).join("\n\n")}\n`; + +const CAPTIONED: FixtureEpisode = { + title: "Memory Palaces", + seconds: 60, + pubDate: pubDate(0), + transcripts: [{ path: "1.vtt", type: "text/vtt", body: VTT }], +}; +const PLAIN: FixtureEpisode = { title: "No Captions", seconds: 60, pubDate: pubDate(1) }; +const FEED: FixtureFeed = { + title: "Journey Transcript", + slug: "transcript", + artwork: true, + episodes: [CAPTIONED, PLAIN], +}; +const NOTE_PATH = `Transcripts/${FEED.title}/${CAPTIONED.title}.md`; + +const TRANSCRIPT = `${PLAYER_VIEW} section.pn-player ol[aria-label="Transcript"]`; +const panelButton = (label: string) => + ({ + name: `transcript panel's ${label}`, + elements: `[...document.querySelectorAll(${jsLiteral(`${PLAYER_VIEW} section.pn-player .pn-panel button`)})].filter((el) => el.getAttribute("aria-label") === ${jsLiteral(label)} || el.textContent.trim().startsWith(${jsLiteral(label)}))`, + }) satisfies UiTarget; +const line = (text: string): UiTarget => ({ + name: `transcript line ${text}`, + elements: `[...document.querySelectorAll(${jsLiteral(`${TRANSCRIPT} button`)})].filter((el) => el.lastElementChild?.textContent.trim() === ${jsLiteral(text)})`, +}); +const findField: UiTarget = { + name: "Find in transcript field", + elements: `[...document.querySelectorAll(${jsLiteral(`${PLAYER_VIEW} section.pn-player input[aria-label="Find in transcript"]`)})]`, +}; + +type ShownTranscript = Readonly<{ + lines: string[]; + current: string | null; + hit: string | null; + marks: string[]; +}>; + +/** The panel's lines by their text, the current one, the Find hit, and the marked matches. */ +async function shownTranscript(): Promise { + return await getContext().obsidian.dev.evalJson(`(() => { + const list = document.querySelector(${jsLiteral(TRANSCRIPT)}); + const text = (row) => row?.lastElementChild?.textContent.trim() ?? null; + const rows = list ? [...list.querySelectorAll("li > :last-child")] : []; + return { + lines: rows.map(text), + current: text(list?.querySelector("[aria-current='true']")), + hit: text(list?.querySelector(".is-hit")), + marks: list ? [...list.querySelectorAll("mark")].map((mark) => mark.textContent) : [], + }; + })()`); +} + +async function serve(): Promise { + const server = await startFixtureServer([FEED]); + onTestFinished(() => server.stop()); + return server; +} + +/** Restores `episode` paused at its start, opens the full player, and chooses Transcript. */ +async function openTranscript(episode: FixtureEpisode): Promise { + const context = getContext(); + const server = await serve(); + await seedLibrary(context, server, { + subscriptions: [FEED], + current: storedEpisode(server, FEED, episode), + }); + await showPodNotes(context.obsidian); + if (!(await isShown(context.obsidian, ui.fullPlayer))) + await click(context.obsidian, ui.openPlayer); + await waitUntilShown(context.obsidian, ui.fullPlayer); + await click(context.obsidian, ui.playerPanel("Transcript")); + return server; +} + +/** + * Waits for the transcript note and the notice that its transcription finished, reads the + * note with its date normalized, and deletes it, so the next transcription writes it again. + */ +async function takeNote(): Promise { + const { obsidian, sandbox } = getContext(); + await sandbox.waitForExists(NOTE_PATH, { timeoutMs: 30_000 }); + await obsidian.waitFor( + async () => + (await notices(obsidian)).some((notice) => + notice.endsWith("Saved the transcript the feed publishes."), + ), + { timeoutMs: 10_000, intervalMs: 100, message: "the transcription finished" }, + ); + const text = await readVaultText(obsidian, sandbox.path(NOTE_PATH)); + await obsidian.dev.evalJsonAsync(`(async () => { + await app.vault.delete(app.vault.getAbstractFileByPath(${jsLiteral(sandbox.path(NOTE_PATH))})); + return true; + })()`); + await dismissNotices(obsidian); + return text.replace(/^Date: .+$/m, "Date: "); +} + +describe("transcript journeys", () => { + test("the feed's transcript follows playback, seeks where tapped, finds a word, and saves the command's note", async () => { + const { obsidian } = getContext(); + await openTranscript(CAPTIONED); + const all = { lines: LINES, current: LINES[0], hit: null, marks: [] }; + expect(await eventually(obsidian, shownTranscript, all, 15_000)).toEqual(all); + + await click(obsidian, ui.playbackToggle); + await waitForPlayback(obsidian, (playback) => playback.playing, "playing from the start"); + const second = { ...all, current: LINES[1] }; + expect( + await eventually(obsidian, shownTranscript, second, 15_000), + "the current line follows playback", + ).toEqual(second); + + await click(obsidian, line(LINES[6])); + const landed = await waitForPlayback( + obsidian, + (playback) => playback.playing && playback.seconds >= 36 && playback.seconds < 40, + "playing from the tapped line", + ); + await waitForPlayback( + obsidian, + (playback) => playback.playing && playback.seconds > landed.seconds + 0.5, + "playback continues past the tapped line", + ); + expect((await shownTranscript()).current).toBe(LINES[6]); + await click(obsidian, ui.playbackToggle); + await waitForPlayback(obsidian, (playback) => !playback.playing, "paused"); + + await click(obsidian, panelButton("Find in transcript")); + await waitUntilShown(obsidian, findField); + await typeText(obsidian, "PALACE"); + const found = { + lines: [LINES[1], LINES[2], LINES[7]], + current: null, + hit: LINES[1], + marks: ["palace", "palace", "Palace"], + }; + expect(await eventually(obsidian, shownTranscript, found), "Find").toEqual(found); + await pressKey(obsidian, "Enter"); + expect((await eventually(obsidian, shownTranscript, { ...found, hit: LINES[2] })).hit).toBe( + LINES[2], + ); + await pressKey(obsidian, "Escape"); + expect(await eventually(obsidian, () => isShown(obsidian, findField), false)).toBe(false); + expect(await isShown(obsidian, ui.fullPlayer), "Escape closes Find, not the player").toBe( + true, + ); + expect((await shownTranscript()).lines).toEqual(LINES); + + await dismissNotices(obsidian); + await click(obsidian, panelButton("Save as note")); + const fromPanel = await takeNote(); + await runCommand(obsidian, `${PLUGIN_ID}:podnotes-transcribe`); + const fromCommand = await takeNote(); + expect(fromPanel).toMatch( + /^# Memory Palaces\n\nPodcast: Journey Transcript\nDate: \n/, + ); + expect(fromPanel).toContain("Today we build a memory palace together."); + expect(fromPanel).toBe(fromCommand); + }, 120_000); + + test("an episode whose feed publishes no transcript offers the paid transcription", async () => { + const { obsidian } = getContext(); + await openTranscript(PLAIN); + const transcribe = panelButton("Transcribe with OpenAI"); + await waitUntilShown(obsidian, transcribe); + + expect( + await obsidian.dev.evalJson( + `(${transcribe.elements})[0].closest(".pn-panel").textContent.trim()`, + ), + ).toBe("No transcript in this feedTranscribe with OpenAIPaid"); + expect(await isDisabled(obsidian, transcribe)).toBe(false); + }, 60_000); + + test("offline, the paid transcription is disabled", async () => { + const { obsidian } = getContext(); + await openTranscript(PLAIN); + const transcribe = panelButton("Transcribe with OpenAI"); + await waitUntilShown(obsidian, transcribe); + + await setOffline(obsidian, true); + onTestFinished(() => setOffline(obsidian, false)); + + expect(await eventually(obsidian, () => isDisabled(obsidian, transcribe), true)).toBe(true); + await setOffline(obsidian, false); + expect(await eventually(obsidian, () => isDisabled(obsidian, transcribe), false)).toBe( + false, + ); + }, 60_000); +}); From cb6dbd5e5c5c115f165931af2ad5a30c7b3faacc Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Wed, 14 Oct 2026 18:26:54 +0200 Subject: [PATCH 02/17] refactor(transcript): add the panel's transcript lines and the view's transcription verbs groupCues takes its soft and hard limits, so the panel groups word-level and half-sentence cues into sentence lines while every note keeps its paragraphs. transcript.ts loads, groups and caches the current episode's published transcript. ShellServices gains the two transcription commands, IPodNotes exposes the transcription service, and SearchField forwards onkeydown. --- src/services/publishedTranscript/render.ts | 26 +++- src/types/IPodNotes.ts | 2 + src/ui/PodcastView/SearchField.svelte | 3 + src/ui/PodcastView/context.ts | 4 + src/ui/PodcastView/index.ts | 4 + src/ui/PodcastView/transcript.test.ts | 170 +++++++++++++++++++++ src/ui/PodcastView/transcript.ts | 59 +++++++ tests/mocks/services.ts | 14 +- 8 files changed, 274 insertions(+), 8 deletions(-) create mode 100644 src/ui/PodcastView/transcript.test.ts create mode 100644 src/ui/PodcastView/transcript.ts diff --git a/src/services/publishedTranscript/render.ts b/src/services/publishedTranscript/render.ts index 27f30d60..07fba715 100644 --- a/src/services/publishedTranscript/render.ts +++ b/src/services/publishedTranscript/render.ts @@ -25,9 +25,14 @@ export interface TranscriptParagraph { readonly text: string; } +/** From `soft` seconds on, a sentence end breaks; no group spans `hard` seconds or more. */ +export interface GroupLimits { + readonly soft: number; + readonly hard: number; +} + const PARAGRAPH_GAP_SECONDS = 5; -const PARAGRAPH_SOFT_SECONDS = 30; -const PARAGRAPH_HARD_SECONDS = 60; +const PARAGRAPHS: GroupLimits = { soft: 30, hard: 60 }; const ENDS_SENTENCE = /[.!?]["')\]]*$/; /** @@ -41,12 +46,15 @@ export const MAX_PARAGRAPH_LENGTH = 2000; /** A speaker label repeats on each of its paragraphs. Real names stay under 20 characters. */ const MAX_SPEAKER_LENGTH = 100; -export function groupCues(cues: readonly TranscriptCue[]): TranscriptParagraph[] { +export function groupCues( + cues: readonly TranscriptCue[], + limits = PARAGRAPHS, +): TranscriptParagraph[] { const paragraphs: TranscriptParagraph[] = []; for (const cue of cues) { const end = cue.end ?? cue.start; const last = paragraphs[paragraphs.length - 1]; - if (last && continues(last, cue)) { + if (last && continues(last, cue, limits)) { paragraphs[paragraphs.length - 1] = { ...last, end: Math.max(last.end, end), @@ -59,13 +67,17 @@ export function groupCues(cues: readonly TranscriptCue[]): TranscriptParagraph[] return paragraphs; } -function continues(paragraph: TranscriptParagraph, cue: TranscriptCue): boolean { +function continues( + paragraph: TranscriptParagraph, + cue: TranscriptCue, + { soft, hard }: GroupLimits, +): boolean { const span = paragraph.end - paragraph.start; return ( (cue.speaker === undefined || cue.speaker === paragraph.speaker) && cue.start - paragraph.end < PARAGRAPH_GAP_SECONDS && - span < PARAGRAPH_HARD_SECONDS && - !(span >= PARAGRAPH_SOFT_SECONDS && ENDS_SENTENCE.test(paragraph.text)) && + span < hard && + !(span >= soft && ENDS_SENTENCE.test(paragraph.text)) && paragraph.text.length + 1 + cue.text.length <= MAX_PARAGRAPH_LENGTH ); } diff --git a/src/types/IPodNotes.ts b/src/types/IPodNotes.ts index b3096455..dbf18939 100644 --- a/src/types/IPodNotes.ts +++ b/src/types/IPodNotes.ts @@ -3,6 +3,7 @@ import type { PlayerController } from "src/player/PlayerController"; import type { CaptureService } from "src/services/CaptureService"; import type { FeedRepository } from "src/services/FeedRepository"; import type { FeedUrlRepository } from "src/services/FeedUrlRepository"; +import type { TranscriptionService } from "src/services/TranscriptionService"; import type { Navigation } from "src/ui/PodcastView/Navigation"; import type { Readable } from "svelte/store"; import type { IPodNotesSettings } from "./IPodNotesSettings"; @@ -26,4 +27,5 @@ export interface IPodNotes { showPlayer(): Promise; openSettingsTab(): void; setAutoQueue(enabled: boolean): void; + getTranscriptionService(): TranscriptionService; } diff --git a/src/ui/PodcastView/SearchField.svelte b/src/ui/PodcastView/SearchField.svelte index 3b8561d7..07a35fa2 100644 --- a/src/ui/PodcastView/SearchField.svelte +++ b/src/ui/PodcastView/SearchField.svelte @@ -8,6 +8,7 @@ autofocus = false, large = false, oninput, + onkeydown, }: { value: string; placeholder: string; @@ -15,6 +16,7 @@ /** The first run's wider, taller field. */ large?: boolean; oninput(value: string): void; + onkeydown?(event: KeyboardEvent): void; } = $props(); let input: HTMLInputElement; @@ -34,6 +36,7 @@ if (!(event as Event as InputEvent).isComposing) oninput(event.currentTarget.value); }} oncompositionend={(event) => oninput(event.currentTarget.value)} + {onkeydown} {@attach (el) => { if (!autofocus) return; // A view mounts before Obsidian reveals its leaf, and a hidden field cannot take focus. diff --git a/src/ui/PodcastView/context.ts b/src/ui/PodcastView/context.ts index 9cd6e0af..a8c99512 100644 --- a/src/ui/PodcastView/context.ts +++ b/src/ui/PodcastView/context.ts @@ -51,6 +51,10 @@ export interface ShellServices { captureTimestamp(): Promise; /** Opens Obsidian's settings at the PodNotes tab. */ openSettings(): void; + /** "Transcribe current episode": the feed's transcript into the episode's transcript note. */ + saveTranscriptNote(): void; + /** "Transcribe current episode from audio", the paid transcription. */ + transcribeFromAudio(): void; /** Whether playback queues the episode the user switches from, and plays the queue on. */ setAutoQueue(enabled: boolean): void; /** Whether the episode has a note: a vault lookup, so a caller decides when to ask again. */ diff --git a/src/ui/PodcastView/index.ts b/src/ui/PodcastView/index.ts index 87148fd8..c032aa35 100644 --- a/src/ui/PodcastView/index.ts +++ b/src/ui/PodcastView/index.ts @@ -141,6 +141,10 @@ export class MainView extends ItemView { settings, captureTimestamp: () => plugin.capture.timestamp(), openSettings: () => plugin.openSettingsTab(), + saveTranscriptNote: () => + void plugin.getTranscriptionService().transcribeCurrentEpisode(), + transcribeFromAudio: () => + plugin.getTranscriptionService().transcribeCurrentEpisodeFromAudio(), setAutoQueue: (enabled) => plugin.setAutoQueue(enabled), hasNote: (episode) => getPodcastNote(episode) !== null, rowHasNote, diff --git a/src/ui/PodcastView/transcript.test.ts b/src/ui/PodcastView/transcript.test.ts new file mode 100644 index 00000000..b5ae2015 --- /dev/null +++ b/src/ui/PodcastView/transcript.test.ts @@ -0,0 +1,170 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import type { ParsedEpisodeDocument, TranscriptLink } from "src/parser/feedDocumentParser"; +import { FeedRepository } from "src/services/FeedRepository"; +import type { Episode } from "src/types/Episode"; +import { NetworkError } from "src/utility/networkRequest"; +import { MemoryStorage, stubLegacyStorage } from "../../../tests/mocks/feeds"; +import { transcriptFixture } from "../../../tests/mocks/transcriptFixtures"; +import { cachedTranscript, lineAt, loadTranscript } from "./transcript"; + +const fetchTextMock = vi.hoisted(() => vi.fn()); + +vi.mock("src/utility/networkRequest", async (importOriginal) => ({ + ...(await importOriginal()), + fetchTextWithTimeout: fetchTextMock, +})); + +const signal = new AbortController().signal; + +function episode(number: number, transcripts: TranscriptLink[] = []) { + const item: ParsedEpisodeDocument = { + title: `Episode ${number}`, + streamUrl: `https://media.test/${number}.mp3`, + description: "", + content: "", + episodeDate: new Date(Date.UTC(2026, 0, number)), + mediaType: "audio", + transcripts, + }; + const played: Episode = { + title: item.title, + streamUrl: item.streamUrl, + url: "", + description: "", + content: "", + podcastName: "Show", + feedUrl: "https://show.test/feed.xml", + }; + return { item, played }; +} + +function repository(items: ParsedEpisodeDocument[]) { + const load = vi.fn(async () => ({ feed: { title: "Show" }, episodes: items })); + const feeds = new FeedRepository({ + load, + resolveUrl: (feed) => feed.url, + storage: new MemoryStorage(), + now: () => Date.UTC(2026, 9, 12), + }); + feeds.setSubscriptions({ + Show: { title: "Show", url: "https://show.test/feed.xml", artworkUrl: "" }, + }); + return { feeds, load }; +} + +const vtt = (...cues: string[]) => + `WEBVTT\n\n${cues.map((cue, index) => `00:00:0${index}.000 --> 00:00:0${index + 1}.000\n${cue}`).join("\n\n")}\n`; + +beforeEach(() => { + stubLegacyStorage(); + fetchTextMock.mockReset(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("lineAt", () => { + test("is the last line to start at or before the time, and -1 before the first", () => { + const lines = [{ start: 2 }, { start: 6 }, { start: 12 }]; + + expect([1, 2, 11.9, 12, 500].map((time) => lineAt(lines, time))).toEqual([-1, 0, 1, 2, 2]); + }); +}); + +describe("loadTranscript", () => { + test("merges a word-level transcript into sentence lines a tap can land in", async () => { + const { item, played } = episode(1, [{ url: "1.json", type: "application/json" }]); + const { feeds } = repository([item]); + fetchTextMock.mockResolvedValue(transcriptFixture("buzzcast.json")); + + const transcript = await loadTranscript(feeds, played, signal); + + expect(transcript?.kind).toBe("timed"); + expect(transcript?.lines.slice(0, 3)).toEqual([ + { + start: 0.16, + end: 15.04, + speaker: "Alban", + text: "Moments ago, I just hit a professional milestone, a new accomplishment. All right, I'm gonna be using air quotes for this, but the first code that I ever wrote, and I'm now using air quotes for I wrote is now live in Buzzsprout as of like four minutes ago.", + }, + { + start: 15.2, + end: 17.28, + speaker: "Kevin", + text: "Oh, your PR got accepted and merged?", + }, + { start: 17.44, end: 18.32, speaker: "Alban", text: "It's merged." }, + ]); + expect(fetchTextMock).toHaveBeenCalledWith("https://show.test/1.json", expect.anything()); + }); + + test("an untimed transcript is its paragraphs", async () => { + const { item, played } = episode(2, [{ url: "2.txt", type: "text/plain" }]); + const { feeds } = repository([item]); + fetchTextMock.mockResolvedValue("First paragraph.\n\nSecond paragraph."); + + await expect(loadTranscript(feeds, played, signal)).resolves.toEqual({ + kind: "untimed", + lines: [{ text: "First paragraph." }, { text: "Second paragraph." }], + }); + }); + + test("a feed without a transcript is none, and stays known as none", async () => { + const { item, played } = episode(3); + const { feeds, load } = repository([item]); + + expect(cachedTranscript(played)).toBeUndefined(); + await expect(loadTranscript(feeds, played, signal)).resolves.toBeNull(); + + expect(cachedTranscript(played)).toBeNull(); + expect(load).toHaveBeenCalledOnce(); + expect(fetchTextMock).not.toHaveBeenCalled(); + }); + + test("keeps one episode: loading another replaces it", async () => { + const first = episode(4, [{ url: "4.vtt", type: "text/vtt" }]); + const second = episode(5); + const { feeds } = repository([first.item, second.item]); + fetchTextMock.mockResolvedValue(vtt("Only line.")); + + const loaded = await loadTranscript(feeds, first.played, signal); + expect(cachedTranscript(first.played)).toBe(loaded); + await loadTranscript(feeds, second.played, signal); + + expect(cachedTranscript(first.played)).toBeUndefined(); + expect(cachedTranscript(second.played)).toBeNull(); + }); + + test("a transcript that failed to load is not kept, so the next load fetches it again", async () => { + const { item, played } = episode(6, [{ url: "6.vtt", type: "text/vtt" }]); + const { feeds } = repository([item]); + fetchTextMock.mockRejectedValueOnce(new NetworkError("unexpected-status", 500)); + fetchTextMock.mockResolvedValueOnce(vtt("Back again.")); + + await expect(loadTranscript(feeds, played, signal)).rejects.toThrow( + "The published transcript could not be fetched", + ); + expect(cachedTranscript(played)).toBeUndefined(); + + await expect(loadTranscript(feeds, played, signal)).resolves.toEqual({ + kind: "timed", + lines: [{ start: 0, end: 1, speaker: undefined, text: "Back again." }], + }); + expect(fetchTextMock).toHaveBeenCalledTimes(2); + }); + + test("a feed that did not load fails rather than reporting no transcript", async () => { + const { item, played } = episode(7, [{ url: "7.vtt", type: "text/vtt" }]); + const { feeds, load } = repository([item]); + load.mockRejectedValueOnce(new Error("offline")); + + await expect(loadTranscript(feeds, played, signal)).rejects.toThrow(); + expect(cachedTranscript(played)).toBeUndefined(); + + fetchTextMock.mockResolvedValue(vtt("Found once the feed loads.")); + await expect(loadTranscript(feeds, played, signal)).resolves.toMatchObject({ + kind: "timed", + }); + }); +}); diff --git a/src/ui/PodcastView/transcript.ts b/src/ui/PodcastView/transcript.ts new file mode 100644 index 00000000..a82ba914 --- /dev/null +++ b/src/ui/PodcastView/transcript.ts @@ -0,0 +1,59 @@ +import { get } from "svelte/store"; +import type { FeedRepository } from "src/services/FeedRepository"; +import { + fetchPublishedTranscript, + findPublishedTranscript, +} from "src/services/publishedTranscript"; +import { + type GroupLimits, + groupCues, + type TranscriptParagraph, +} from "src/services/publishedTranscript/render"; +import type { Episode } from "src/types/Episode"; +import { getEpisodeKey } from "src/utility/episodeKey"; + +/** Sentence-sized lines: a word-level JSON file and half-sentence captions both merge to lines a tap can land in. */ +const LINES: GroupLimits = { soft: 5, hard: 30 }; + +export type Transcript = + | { readonly kind: "timed"; readonly lines: readonly TranscriptParagraph[] } + | { readonly kind: "untimed"; readonly lines: readonly { readonly text: string }[] }; + +/** The last line to start at or before `time`, by time, never by index. -1 before the first. */ +export function lineAt(lines: readonly { start: number }[], time: number): number { + let at = -1; + for (const [index, line] of lines.entries()) if (line.start <= time) at = index; + return at; +} + +let cached: { key: string; transcript: Transcript | null } | undefined; + +/** Undefined when not cached; null when the episode's feed publishes no transcript. */ +export function cachedTranscript(episode: Episode): Transcript | null | undefined { + return cached?.key === getEpisodeKey(episode) ? cached.transcript : undefined; +} + +/** + * The episode's published transcript as lines, cached on success, or null when its feed + * publishes none. Throws when the transcript or the feed could not be read, so the panel + * offers Retry, never payment. A transcript's address can carry a private feed's own, so it + * stays inside this function. + */ +export async function loadTranscript( + feeds: FeedRepository, + episode: Episode, + signal: AbortSignal, +): Promise { + const source = await findPublishedTranscript(feeds, episode); + if (!source && get(feeds).get(episode.podcastName)?.status === "error") { + throw new Error("The feed did not load."); + } + const parsed = source && (await fetchPublishedTranscript(feeds, source, signal)); + const transcript: Transcript | null = !parsed + ? null + : parsed.kind === "timed" + ? { kind: "timed", lines: groupCues(parsed.cues, LINES) } + : { kind: "untimed", lines: parsed.paragraphs.map((text) => ({ text })) }; + cached = { key: getEpisodeKey(episode), transcript }; + return transcript; +} diff --git a/tests/mocks/services.ts b/tests/mocks/services.ts index b72a302d..a5cde938 100644 --- a/tests/mocks/services.ts +++ b/tests/mocks/services.ts @@ -76,6 +76,8 @@ export type TestServices = ShellServices & { readonly library: TestLibrary; readonly captureTimestamp: ReturnType Promise>>; readonly openSettings: ReturnType void>>; + readonly saveTranscriptNote: ReturnType void>>; + readonly transcribeFromAudio: ReturnType void>>; readonly setAutoQueue: ReturnType void>>; readonly hasNote: ReturnType boolean>>; readonly rowHasNote: Writable<(episode: Episode) => boolean>; @@ -93,7 +95,15 @@ export type TestServices = ShellServices & { */ export function createTestServices( overrides: Partial< - Omit + Omit< + TestServices, + | "captureTimestamp" + | "openSettings" + | "saveTranscriptNote" + | "transcribeFromAudio" + | "setAutoQueue" + | "hasNote" + > > = {}, ): { services: TestServices; @@ -138,6 +148,8 @@ export function createTestServices( settings: derived(plugin, ($plugin) => $plugin.settings), captureTimestamp: vi.fn(async () => null), openSettings: vi.fn(), + saveTranscriptNote: vi.fn(), + transcribeFromAudio: vi.fn(), setAutoQueue: vi.fn((enabled: boolean) => { settings.autoQueue = enabled; plugin.update((host) => host); From c29510b7cf9878378c1585d0a6d9bc1e607296fe Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Wed, 14 Oct 2026 18:41:41 +0200 Subject: [PATCH 03/17] feat(ui): add the full player's Transcript panel The player gains a Transcript panel between Chapters and Up next. It shows the feed's published transcript as sentence lines with times and speakers, marks and follows the line playing now, plays from a tapped line, finds a word, and saves the note the Transcribe current episode command writes. A user scroll stops following until Follow brings the line back. Without a published transcript it offers the paid transcription from audio. Chapters and transcript lines share one row style in Panel. --- src/types/IconType.ts | 1 + src/ui/PodcastView/ChaptersPanel.svelte | 52 +-- src/ui/PodcastView/Panel.svelte | 51 ++- src/ui/PodcastView/Player.svelte | 40 +- src/ui/PodcastView/Player.test.ts | 21 +- .../PodcastView.integration.test.ts | 2 + src/ui/PodcastView/TranscriptPanel.svelte | 391 ++++++++++++++++ src/ui/PodcastView/TranscriptPanel.test.ts | 419 ++++++++++++++++++ tests/e2e/journey-transcript.test.ts | 13 +- 9 files changed, 912 insertions(+), 78 deletions(-) create mode 100644 src/ui/PodcastView/TranscriptPanel.svelte create mode 100644 src/ui/PodcastView/TranscriptPanel.test.ts diff --git a/src/types/IconType.ts b/src/types/IconType.ts index c459da63..62ab801b 100644 --- a/src/types/IconType.ts +++ b/src/types/IconType.ts @@ -105,6 +105,7 @@ export const ICON_LIST = [ "calendar", "camera-off", "camera", + "captions", "car", "carrot", "cast", diff --git a/src/ui/PodcastView/ChaptersPanel.svelte b/src/ui/PodcastView/ChaptersPanel.svelte index 75c54ac2..75c9a50b 100644 --- a/src/ui/PodcastView/ChaptersPanel.svelte +++ b/src/ui/PodcastView/ChaptersPanel.svelte @@ -31,7 +31,7 @@
  • moveTab(event, index)}>{PANEL_LABELS[kind]} {/each} @@ -171,7 +181,7 @@ aria-pressed={shown === kind} disabled={failed} onclick={() => (panel = shown === kind ? undefined : kind)} - >{kind === "chapters" ? "Chapters" : "Up next"}{PANEL_LABELS[kind]} {/each} diff --git a/src/ui/PodcastView/Player.test.ts b/src/ui/PodcastView/Player.test.ts index 62d03714..2c831f35 100644 --- a/src/ui/PodcastView/Player.test.ts +++ b/src/ui/PodcastView/Player.test.ts @@ -382,12 +382,14 @@ describe("the player's panels", () => { expect(player.querySelector(".pn-np-art")).not.toBeNull(); }); - test("an episode without chapters offers no Chapters", async () => { + test("an episode without chapters offers its Transcript and Up next, no Chapters", async () => { await openPlayer(); expect( - within(screen.getByRole("group", { name: "Player panels" })).getAllByRole("button"), - ).toEqual([switchButton("Up next")]); + within(screen.getByRole("group", { name: "Player panels" })) + .getAllByRole("button") + .map((choice) => choice.textContent), + ).toEqual(["Transcript", "Up next"]); }); test("at wide width, the right column shows Chapters, or Up next without chapters", async () => { @@ -411,7 +413,10 @@ describe("the player's panels", () => { await tick(); await tick(); - expect(screen.getAllByRole("tab").map((tab) => tab.textContent)).toEqual(["Up next"]); + expect(screen.getAllByRole("tab").map((tab) => tab.textContent)).toEqual([ + "Transcript", + "Up next", + ]); expect(screen.getByRole("tab", { name: "Up next" })).toHaveAttribute( "aria-selected", "true", @@ -421,7 +426,7 @@ describe("the player's panels", () => { test("at wide width, arrow keys, Home and End move focus between the tabs and show each one's panel", async () => { await openPlayer(withChapters, 820); - await vi.waitFor(() => expect(screen.getAllByRole("tab")).toHaveLength(2)); + await vi.waitFor(() => expect(screen.getAllByRole("tab")).toHaveLength(3)); const tabs = () => screen.getAllByRole("tab").map((tab) => ({ name: tab.textContent, @@ -436,17 +441,23 @@ describe("the player's panels", () => { }; screen.getByRole("tab", { name: "Chapters" }).focus(); + expect(await key("ArrowRight")).toEqual([ + "Transcript", + screen.getByRole("tab", { name: "Transcript" }).id, + ]); expect(await key("ArrowRight")).toEqual([ "Up next", screen.getByRole("tab", { name: "Up next" }).id, ]); expect(tabs()).toEqual([ { name: "Chapters", selected: "false", tabIndex: -1, focused: false }, + { name: "Transcript", selected: "false", tabIndex: -1, focused: false }, { name: "Up next", selected: "true", tabIndex: 0, focused: true }, ]); expect(within(screen.getByRole("tabpanel")).getByText("Queue is empty")).toBeVisible(); expect((await key("ArrowRight"))[0]).toBe("Chapters"); expect((await key("ArrowLeft"))[0]).toBe("Up next"); + expect((await key("ArrowLeft"))[0]).toBe("Transcript"); expect((await key("Home"))[0]).toBe("Chapters"); expect((await key("Home"))[0]).toBe("Chapters"); expect((await key("End"))[0]).toBe("Up next"); diff --git a/src/ui/PodcastView/PodcastView.integration.test.ts b/src/ui/PodcastView/PodcastView.integration.test.ts index a57f5f66..3d8fd8c0 100644 --- a/src/ui/PodcastView/PodcastView.integration.test.ts +++ b/src/ui/PodcastView/PodcastView.integration.test.ts @@ -48,6 +48,8 @@ function renderView() { settings: readable(structuredClone(DEFAULT_SETTINGS)), captureTimestamp: async () => null, openSettings: () => {}, + saveTranscriptNote: () => {}, + transcribeFromAudio: () => {}, setAutoQueue: () => {}, hasNote: () => false, rowHasNote: readable(() => false), diff --git a/src/ui/PodcastView/TranscriptPanel.svelte b/src/ui/PodcastView/TranscriptPanel.svelte new file mode 100644 index 00000000..79d62840 --- /dev/null +++ b/src/ui/PodcastView/TranscriptPanel.svelte @@ -0,0 +1,391 @@ + + +
    + + {#if load.kind === "loading"} +
    Loading transcript
    + {:else if load.kind === "error"} +
    + + Transcript didn't load + +
    + {:else if load.kind === "none"} +
    + + {isLocalFile(episode) + ? "No transcript yet" + : "No transcript in this feed"} + {#if audio} + + {/if} +
    + {:else if shown.length === 0} +
  • No lines match
  • + {:else} + {#each shown as { line, index }, at (index)} +
  • + {#if line.speaker && line.speaker !== shown[at - 1]?.line.speaker} +
    {line.speaker}
    + {/if} + {#if line.start === undefined} +

    {@render text(line.text)}

    + {:else} + {@const start = line.start} + + {/if} +
  • + {/each} + {/if} + +
    + {#if current >= 0 && !following && !finding} + + {/if} +
    + + +{#snippet text(value: string)}{#if needle}{#each pieces(value) as piece, i (i)}{#if i % 2}{piece}{:else}{piece}{/if}{/each}{:else}{value}{/if}{/snippet} + +{#snippet head()} + {#if finding} + { + query = value; + hit = 0; + }} + onkeydown={findKey} + /> + + {:else} + + Feed transcript + Free + + + + {/if} +{/snippet} + + diff --git a/src/ui/PodcastView/TranscriptPanel.test.ts b/src/ui/PodcastView/TranscriptPanel.test.ts new file mode 100644 index 00000000..b9f183fe --- /dev/null +++ b/src/ui/PodcastView/TranscriptPanel.test.ts @@ -0,0 +1,419 @@ +// @vitest-environment jsdom +import { fireEvent, render, screen, within } from "@testing-library/svelte"; +import { tick } from "svelte"; +import { get } from "svelte/store"; +import { afterEach, beforeEach, describe, expect, type Mock, test, vi } from "vitest"; +import type { ParsedFeedEpisodes, TranscriptLink } from "src/parser/feedDocumentParser"; +import { FeedRepository } from "src/services/FeedRepository"; +import { plugin } from "src/store"; +import type { Episode } from "src/types/Episode"; +import { NetworkError } from "src/utility/networkRequest"; +import type { FakeDocument } from "../../../tests/mocks/fakeMedia"; +import { feedItem, MemoryStorage } from "../../../tests/mocks/feeds"; +import type { Keymap as FakeKeymap } from "../../../tests/mocks/obsidian"; +import { + installResizeObserver, + type FakeResizeObservers, +} from "../../../tests/mocks/resizeObserver"; +import { createTestServices, type TestServices } from "../../../tests/mocks/services"; +import PodcastView from "./PodcastView.svelte"; + +const fetchTextMock = vi.hoisted(() => vi.fn()); + +vi.mock("src/utility/networkRequest", async (importOriginal) => ({ + ...(await importOriginal()), + fetchTextWithTimeout: fetchTextMock, +})); + +const CUES: ReadonlyArray = [ + ["Maya Lind", "Welcome back to Field Notes."], + ["Maya Lind", "Today we build a memory palace together."], + ["Ana Ilic", "Where does the palace start?"], + ["Ana Ilic", "At your own front door."], + ["Maya Lind", "Then every room holds one idea."], + ["Maya Lind", "The stranger the image, the longer it stays."], + ["Ana Ilic", "And spaced repetition does the rest."], + ["Ana Ilic", "Walk the Palace again a day later."], + ["Maya Lind", "Then a week later, then a month later."], + ["Maya Lind", "That is the whole method."], +]; +const LINES = CUES.map(([, text]) => text); +const clock = (seconds: number) => `00:00:${String(seconds).padStart(2, "0")}.000`; +const VTT = `WEBVTT\n\n${CUES.map( + ([speaker, text], index) => + `${clock(index * 6)} --> ${clock(index * 6 + 6)}\n${text}`, +).join("\n\n")}\n`; +const VTT_LINK: TranscriptLink = { url: "1.vtt", type: "text/vtt" }; +const SECRET = "https://private.test/s3cret/feed.xml?auth=s3cret"; + +let services: TestServices; +let doc: FakeDocument; +let dispose: () => void; +let widths: FakeResizeObservers; +let feeds: FeedRepository; +let loadFeed: Mock<(url: string) => Promise>; +let episodes: Array<{ episode: Episode; transcripts: TranscriptLink[] }>; +let made = 0; + +/** A new episode of Field Notes, so the one-episode cache never carries over from a test before. */ +function episodeOf(transcripts: TranscriptLink[], overrides: Partial = {}): Episode { + made++; + const episode: Episode = { + title: `Episode ${made}`, + streamUrl: `https://pod.test/${made}.mp3`, + url: "", + description: "", + content: "", + podcastName: "Field Notes", + feedUrl: "https://pod.test/feed.xml", + duration: 1800, + ...overrides, + }; + episodes.push({ episode, transcripts }); + return episode; +} + +beforeEach(() => { + episodes = []; + fetchTextMock.mockReset(); + fetchTextMock.mockResolvedValue(VTT); + loadFeed = vi.fn(async () => ({ + feed: { title: "Field Notes" }, + episodes: episodes.map(({ episode, transcripts }) => ({ + ...feedItem(episode), + transcripts, + })), + })); + feeds = new FeedRepository({ + load: loadFeed, + resolveUrl: (feed) => feed.url, + storage: new MemoryStorage(), + now: Date.now, + }); + ({ services, doc, dispose } = createTestServices({ feeds })); + services.library.savedFeeds.set({ + "Field Notes": { title: "Field Notes", url: "https://pod.test/feed.xml", artworkUrl: "" }, + }); + widths = installResizeObserver(); +}); + +afterEach(() => { + widths.uninstall(); + dispose(); + vi.restoreAllMocks(); +}); + +/** The full player at `time` of `played`, paused, with its Transcript panel chosen. */ +async function openTranscript(played: Episode, time = 0, width = 320) { + const result = render(PodcastView, { props: { services } }); + widths.resize(result.container.querySelector(".podcast-view")!, width); + services.player.play(played); + doc.media.duration = 1800; + doc.media.emit("loadedmetadata"); + services.player.pause(); + doc.media.currentTime = time; + doc.media.emit("timeupdate"); + services.navigation.expand(); + await tick(); + await fireEvent.click(switchButton("Transcript")); + return result; +} + +async function at(time: number) { + doc.media.currentTime = time; + doc.media.emit("timeupdate"); + await tick(); +} + +const switchButton = (name: string) => + within(screen.getByRole("group", { name: "Player panels" })).getByRole("button", { name }); +const button = (name: string | RegExp) => screen.getByRole("button", { name }); +const transcript = () => screen.findByRole("list", { name: "Transcript" }); +const lineButton = (text: string) => + within(screen.getByRole("list", { name: "Transcript" })).getByRole("button", { + name: new RegExp(`${text}$`), + }); +const rows = () => + within(screen.getByRole("list", { name: "Transcript" })) + .getAllByRole("listitem") + .map((row) => row.textContent?.replace(/\s+/g, " ").trim()); +const currentLine = () => + within(screen.getByRole("list", { name: "Transcript" })).queryByRole("button", { + current: true, + })?.lastElementChild?.textContent; +const hitLine = () => + screen.getByRole("list", { name: "Transcript" }).querySelector(".is-hit")?.lastElementChild + ?.textContent; +const marks = () => [...document.querySelectorAll("mark")].map((mark) => mark.textContent); +const keymap = () => services.app.keymap as unknown as FakeKeymap; +const press = (key: string, target: Element = document.activeElement ?? document.body) => { + const event = new KeyboardEvent("keydown", { key, bubbles: true }); + Object.defineProperty(event, "target", { value: target }); + keymap().dispatch(event); +}; + +/** + * Lays the list's rows out 40 px apart in a list 120 px tall, as the browser would: its + * scroll position stays within the rows, and a change to it fires `scroll` soon after. + */ +function layOut(list: HTMLElement) { + let top = 0; + Object.defineProperty(list, "clientHeight", { configurable: true, value: 120 }); + Object.defineProperty(list, "scrollTop", { + configurable: true, + get: () => top, + set: (value: number) => { + const next = Math.max(0, Math.min(value, list.children.length * 40 - 120)); + if (next === top) return; + top = next; + queueMicrotask(() => list.dispatchEvent(new Event("scroll"))); + }, + }); + vi.spyOn(Element.prototype, "getBoundingClientRect").mockImplementation(function ( + this: Element, + ) { + const y = this === list ? 0 : [...list.children].indexOf(this) * 40 - top; + return { top: y, bottom: y + 40, left: 0, right: 300 } as DOMRect; + }); +} + +describe("a published transcript", () => { + test("lists its lines with their times and speakers, and marks the line playing now", async () => { + await openTranscript(episodeOf([VTT_LINK]), 0); + await transcript(); + + expect(rows()).toEqual([ + "Maya Lind 0:00 Welcome back to Field Notes.", + "0:06 Today we build a memory palace together.", + "Ana Ilic 0:12 Where does the palace start?", + "0:18 At your own front door.", + "Maya Lind 0:24 Then every room holds one idea.", + "0:30 The stranger the image, the longer it stays.", + "Ana Ilic 0:36 And spaced repetition does the rest.", + "0:42 Walk the Palace again a day later.", + "Maya Lind 0:48 Then a week later, then a month later.", + "0:54 That is the whole method.", + ]); + expect(currentLine()).toBe(LINES[0]); + await at(7); + expect(currentLine()).toBe(LINES[1]); + await at(43); + expect(currentLine()).toBe(LINES[7]); + + await fireEvent.click(button("Find in transcript")); + await fireEvent.input(screen.getByRole("searchbox", { name: "Find in transcript" }), { + target: { value: "palace" }, + }); + expect(currentLine()).toBe(LINES[7]); + }); + + test("a tapped line plays from its start", async () => { + await openTranscript(episodeOf([VTT_LINK]), 0); + await transcript(); + expect(get(services.player.state).status).toBe("paused"); + + await fireEvent.click(lineButton(LINES[6])); + + expect(get(services.player.state)).toMatchObject({ currentTime: 36, status: "playing" }); + expect(currentLine()).toBe(LINES[6]); + }); + + test("follows the line playing now until the user scrolls, then Follow brings it back", async () => { + await openTranscript(episodeOf([VTT_LINK]), 0); + const list = await transcript(); + layOut(list); + + await at(13); + await tick(); + expect(list.scrollTop, "row 2, a third down").toBe(40); + expect(screen.queryByRole("button", { name: "Follow" })).toBeNull(); + + list.scrollTop = 30; + await tick(); + await tick(); + const follow = button("Follow"); + expect(follow.closest("[aria-live=polite]")).not.toBeNull(); + await at(19); + await tick(); + expect(list.scrollTop, "the reader stays where they scrolled").toBe(30); + + await fireEvent.click(follow); + await tick(); + expect(list.scrollTop, "row 3, a third down").toBe(80); + expect(screen.queryByRole("button", { name: "Follow" })).toBeNull(); + expect(get(services.player.state).status).toBe("paused"); + }); + + test("Find shows the lines with a word, Enter and Shift+Enter step through them, and Escape closes Find before the player", async () => { + await openTranscript(episodeOf([VTT_LINK]), 0); + await transcript(); + await fireEvent.click(button("Find in transcript")); + const field = screen.getByRole("searchbox", { name: "Find in transcript" }); + + await fireEvent.input(field, { target: { value: "PALACE" } }); + expect(rows()).toEqual([ + "Maya Lind 0:06 Today we build a memory palace together.", + "Ana Ilic 0:12 Where does the palace start?", + "0:42 Walk the Palace again a day later.", + ]); + expect(marks()).toEqual(["palace", "palace", "Palace"]); + expect(hitLine()).toBe(LINES[1]); + const step = async (shiftKey: boolean) => { + await fireEvent.keyDown(field, { key: "Enter", shiftKey }); + return hitLine(); + }; + expect([await step(false), await step(false), await step(false)]).toEqual([ + LINES[2], + LINES[7], + LINES[1], + ]); + expect([await step(true), await step(true)]).toEqual([LINES[7], LINES[2]]); + + await fireEvent.input(field, { target: { value: "zebra" } }); + expect(rows()).toEqual(["No lines match"]); + + press("Escape", field); + await tick(); + expect(screen.queryByRole("searchbox")).toBeNull(); + expect(rows()).toHaveLength(10); + expect(button("Find in transcript")).toHaveFocus(); + expect(services.navigation.current.expanded).toBe(true); + + press("Escape"); + await tick(); + expect(services.navigation.current.expanded).toBe(false); + }); + + test("Save as note runs the transcribe command, which writes the note", async () => { + const vault = { create: vi.fn(), modify: vi.fn(), process: vi.fn() }; + Object.assign(services.app, { vault }); + await openTranscript(episodeOf([VTT_LINK]), 0); + await transcript(); + + await fireEvent.click(button("Save as note")); + + expect(services.saveTranscriptNote).toHaveBeenCalledExactlyOnceWith(); + expect([vault.create, vault.modify, vault.process].map((spy) => spy.mock.calls)).toEqual([ + [], + [], + [], + ]); + expect(fetchTextMock).toHaveBeenCalledOnce(); + services.online.set(false); + await tick(); + expect(button("Save as note")).toBeDisabled(); + }); + + test("Save as note is off for a video, as the command is", async () => { + await openTranscript(episodeOf([VTT_LINK], { mediaType: "video" }), 0); + await transcript(); + + expect(button("Save as note")).toBeDisabled(); + }); + + test("an untimed transcript reads as paragraphs, with no time to play from or follow", async () => { + fetchTextMock.mockResolvedValue("First paragraph.\n\nSecond paragraph."); + await openTranscript(episodeOf([{ url: "1.txt", type: "text/plain" }]), 30); + const list = await transcript(); + + list.scrollTop = 30; + await fireEvent.scroll(list); + + expect(rows()).toEqual(["First paragraph.", "Second paragraph."]); + expect(within(list).queryAllByRole("button")).toEqual([]); + expect(list.querySelector("[aria-current]")).toBeNull(); + expect(screen.queryByRole("button", { name: "Follow" })).toBeNull(); + }); + + test("shows once loaded: back from Up next it neither loads again nor fetches again", async () => { + let answer!: (text: string) => void; + fetchTextMock.mockReturnValue(new Promise((resolve) => (answer = resolve))); + await openTranscript(episodeOf([VTT_LINK]), 0); + expect(await screen.findByText("Loading transcript")).toHaveAttribute("role", "status"); + answer(VTT); + await transcript(); + + await fireEvent.click(switchButton("Up next")); + await fireEvent.click(switchButton("Transcript")); + + expect(screen.getByRole("list", { name: "Transcript" })).toBeInTheDocument(); + expect(screen.queryByText("Loading transcript")).toBeNull(); + expect(fetchTextMock).toHaveBeenCalledOnce(); + }); +}); + +describe("a transcript that did not load", () => { + test("offers Retry, which fetches it again", async () => { + fetchTextMock.mockRejectedValueOnce(new NetworkError("unexpected-status", 500)); + await openTranscript(episodeOf([VTT_LINK]), 0); + await screen.findByText("Transcript didn't load"); + + await fireEvent.click(button("Retry")); + + await transcript(); + expect(fetchTextMock).toHaveBeenCalledTimes(2); + }); + + test("never shows or logs a private feed's address from the failure", async () => { + const logged = (["error", "warn", "info", "log", "debug"] as const).map((level) => + vi.spyOn(console, level), + ); + vi.spyOn(feeds, "itemUrl").mockImplementation(() => { + throw new Error(`Could not resolve 1.vtt against ${SECRET}`); + }); + const { container } = await openTranscript(episodeOf([VTT_LINK]), 0); + await screen.findByText("Transcript didn't load"); + + expect(container.innerHTML).not.toContain("s3cret"); + expect(JSON.stringify(logged.map((spy) => spy.mock.calls))).not.toContain("s3cret"); + }); + + test("when the feed itself did not load, offers Retry, not the paid transcription", async () => { + const served = loadFeed.getMockImplementation()!; + loadFeed.mockRejectedValue(new Error("offline")); + await openTranscript(episodeOf([VTT_LINK]), 0); + + await screen.findByText("Transcript didn't load"); + expect(screen.queryByRole("button", { name: /^Transcribe with/ })).toBeNull(); + loadFeed.mockImplementation(served); + await fireEvent.click(button("Retry")); + await transcript(); + }); +}); + +describe("an episode whose feed publishes no transcript", () => { + test("offers the paid transcription, off while offline", async () => { + await openTranscript(episodeOf([]), 0); + await screen.findByText("No transcript in this feed"); + const paid = button("Transcribe with OpenAI Paid"); + + await fireEvent.click(paid); + expect(services.transcribeFromAudio).toHaveBeenCalledExactlyOnceWith(); + + services.online.set(false); + await tick(); + expect(paid).toBeDisabled(); + }); + + test("names Deepgram when diarization uses it", async () => { + const { settings } = get(plugin); + settings.transcript.diarization = { + ...settings.transcript.diarization, + enabled: true, + provider: "deepgram", + }; + plugin.update((host) => host); + await openTranscript(episodeOf([]), 0); + + expect( + await screen.findByRole("button", { name: /^Transcribe with/ }), + ).toHaveAccessibleName("Transcribe with Deepgram Paid"); + }); + + test("offers no paid transcription for a video", async () => { + await openTranscript(episodeOf([], { mediaType: "video" }), 0); + + await screen.findByText("No transcript in this feed"); + expect(screen.queryByRole("button", { name: /^Transcribe with/ })).toBeNull(); + }); +}); diff --git a/tests/e2e/journey-transcript.test.ts b/tests/e2e/journey-transcript.test.ts index efe1b98f..7d4cc8e2 100644 --- a/tests/e2e/journey-transcript.test.ts +++ b/tests/e2e/journey-transcript.test.ts @@ -51,11 +51,10 @@ const FEED: FixtureFeed = { const NOTE_PATH = `Transcripts/${FEED.title}/${CAPTIONED.title}.md`; const TRANSCRIPT = `${PLAYER_VIEW} section.pn-player ol[aria-label="Transcript"]`; -const panelButton = (label: string) => - ({ - name: `transcript panel's ${label}`, - elements: `[...document.querySelectorAll(${jsLiteral(`${PLAYER_VIEW} section.pn-player .pn-panel button`)})].filter((el) => el.getAttribute("aria-label") === ${jsLiteral(label)} || el.textContent.trim().startsWith(${jsLiteral(label)}))`, - }) satisfies UiTarget; +const panelButton = (label: string): UiTarget => ({ + name: `transcript panel's ${label}`, + elements: `[...document.querySelectorAll(${jsLiteral(`${PLAYER_VIEW} section.pn-player .pn-panel button`)})].filter((el) => el.getAttribute("aria-label") === ${jsLiteral(label)} || el.textContent.trim().startsWith(${jsLiteral(label)}))`, +}); const line = (text: string): UiTarget => ({ name: `transcript line ${text}`, elements: `[...document.querySelectorAll(${jsLiteral(`${TRANSCRIPT} button`)})].filter((el) => el.lastElementChild?.textContent.trim() === ${jsLiteral(text)})`, @@ -203,9 +202,9 @@ describe("transcript journeys", () => { expect( await obsidian.dev.evalJson( - `(${transcribe.elements})[0].closest(".pn-panel").textContent.trim()`, + `(${transcribe.elements})[0].closest(".pn-panel").textContent.replace(/\\s+/g, " ").trim()`, ), - ).toBe("No transcript in this feedTranscribe with OpenAIPaid"); + ).toBe("No transcript in this feed Transcribe with OpenAI Paid"); expect(await isDisabled(obsidian, transcribe)).toBe(false); }, 60_000); From 65c850b09583e8067e4a9013c7e07456ccc4f7e8 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Wed, 14 Oct 2026 18:43:46 +0200 Subject: [PATCH 04/17] test(ui): check the player stays open before the rest of Find's Escape --- src/ui/PodcastView/TranscriptPanel.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/PodcastView/TranscriptPanel.test.ts b/src/ui/PodcastView/TranscriptPanel.test.ts index b9f183fe..3fa6357f 100644 --- a/src/ui/PodcastView/TranscriptPanel.test.ts +++ b/src/ui/PodcastView/TranscriptPanel.test.ts @@ -274,10 +274,10 @@ describe("a published transcript", () => { press("Escape", field); await tick(); + expect(services.navigation.current.expanded, "the player stays open").toBe(true); expect(screen.queryByRole("searchbox")).toBeNull(); expect(rows()).toHaveLength(10); expect(button("Find in transcript")).toHaveFocus(); - expect(services.navigation.current.expanded).toBe(true); press("Escape"); await tick(); From f2216b0a82434109e73f49ab8e320f967681eceb Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Wed, 14 Oct 2026 18:49:10 +0200 Subject: [PATCH 05/17] build: raise the gzip budget to 103,000 bytes for the Transcript panel --- AGENTS.md | 2 +- scripts/size-baseline.md | 12 ++++++++++++ scripts/size.mjs | 2 +- scripts/size.test.ts | 2 +- 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6082e69e..f0c36a49 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -66,7 +66,7 @@ a tsconfig matches no files, which `lint:tsconfig` catches. `tests/tooling/svelteCheck.test.ts` runs the real `check:a11y` in temporary workspaces and requires it to fail on each of these cases. -After `npm run build`, `npm run size` enforces 330,000 raw bytes and 100,000 gzip bytes at level 9 for root `main.js` in the Test workflow's Build and sizes worker. `npm run size:data` reports a synthetic v2 stress library with 50 feeds, 2,000 history entries, and 200 saved episodes from frozen NPR metadata, including duplicated snapshots. Its 1.5 MB document goal is advisory. Pass an additive candidate JSON path to report optional-field overhead without changing existing data. In the isolated Obsidian instance, `npm run count:writes -- start`, `report`, and `stop` record writes to PodNotes' exact `data.json` path. Stop the recorder before stopping the instance, because recorder commands start a stopped instance; plugin reloads invalidate its counts. +After `npm run build`, `npm run size` enforces 330,000 raw bytes and 103,000 gzip bytes at level 9 for root `main.js` in the Test workflow's Build and sizes worker. `npm run size:data` reports a synthetic v2 stress library with 50 feeds, 2,000 history entries, and 200 saved episodes from frozen NPR metadata, including duplicated snapshots. Its 1.5 MB document goal is advisory. Pass an additive candidate JSON path to report optional-field overhead without changing existing data. In the isolated Obsidian instance, `npm run count:writes -- start`, `report`, and `stop` record writes to PodNotes' exact `data.json` path. Stop the recorder before stopping the instance, because recorder commands start a stopped instance; plugin reloads invalidate its counts. Before opening a PR or cutting a release, run the CI-equivalent checks locally: diff --git a/scripts/size-baseline.md b/scripts/size-baseline.md index 2997e665..79a3a30e 100644 --- a/scripts/size-baseline.md +++ b/scripts/size-baseline.md @@ -12,6 +12,18 @@ main.js raw: 307131 bytes; budget 330000 bytes; 22869 bytes remaining main.js gzip (level 9): 97161 bytes; budget 100000 bytes; 2839 bytes remaining ``` +The gzip budget is now 103,000 bytes. On 2026-10-14 it rose from 100,000 for +the full player's Transcript panel, which the owner approved at about 1.9 KB +and which measured 2,576 bytes. The coordinator set 103,000 to keep the +panel's Find and Follow chip and to leave room for fixes in flight. The +numbers above and the rejection proof below were measured against the old +budget. With the panel: + +```text +main.js raw: 320728 bytes; budget 330000 bytes; 9272 bytes remaining +main.js gzip (level 9): 101619 bytes; budget 103000 bytes; 1381 bytes remaining +``` + Gzip uses Node's `gzipSync` at level 9, without a filename or timestamp. Budgets use decimal bytes. The brief's 96,533-byte reference is unexplained. This report fixes the compressor instead of comparing compressed sizes across diff --git a/scripts/size.mjs b/scripts/size.mjs index e9a4a4b4..a0023fec 100644 --- a/scripts/size.mjs +++ b/scripts/size.mjs @@ -10,7 +10,7 @@ try { if (!bundle.length) throw new Error(`${path} is empty. Run npm run build first.`); const measurements = [ { name: "raw", bytes: bundle.length, budget: 330_000 }, - { name: "gzip (level 9)", bytes: gzipSync(bundle, { level: 9 }).length, budget: 100_000 }, + { name: "gzip (level 9)", bytes: gzipSync(bundle, { level: 9 }).length, budget: 103_000 }, ]; const rows = measurements.map(({ name, bytes, budget }) => { const overage = Math.max(0, bytes - budget); diff --git a/scripts/size.test.ts b/scripts/size.test.ts index 56252347..9bdb6d9f 100644 --- a/scripts/size.test.ts +++ b/scripts/size.test.ts @@ -30,7 +30,7 @@ describe("bundle budget CLI", () => { const result = check("gzip.js", randomBytes(110_000)); expect(result.status).toBe(1); expect(result.stdout).toMatch( - /gzip \(level 9\): \d+ bytes; budget 100000 bytes; OVER by \d+ bytes/, + /gzip \(level 9\): \d+ bytes; budget 103000 bytes; OVER by \d+ bytes/, ); expect(result.stdout).toContain("budget 330000 bytes; 220000 bytes remaining"); }); From a33746e969c6632febcbde94f5b7e7b24351791b Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Wed, 14 Oct 2026 18:49:10 +0200 Subject: [PATCH 06/17] fix(ui): announce the transcript's load error and title its Save button --- src/ui/PodcastView/TranscriptPanel.svelte | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ui/PodcastView/TranscriptPanel.svelte b/src/ui/PodcastView/TranscriptPanel.svelte index 79d62840..1416be1b 100644 --- a/src/ui/PodcastView/TranscriptPanel.svelte +++ b/src/ui/PodcastView/TranscriptPanel.svelte @@ -156,7 +156,7 @@ {#if load.kind === "loading"}
    Loading transcript
    {:else if load.kind === "error"} -
    +