From 571465e9a04ab0760a3676fca020e7124b13172c Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 07:22:04 +0200 Subject: [PATCH 01/15] test(settings): walk the API key buttons as Obsidian renders them The settings mock rendered the secret picker as a text input, so the unit naming test checked a control users never see. It now renders Obsidian's Link... button, and both the unit test and the real-app Tab walk include the OpenAI and Deepgram key buttons. --- src/ui/settings/PodNotesSettingsTab.test.ts | 20 ++++++++++++-- tests/e2e/settings-tab.test.ts | 29 ++++++++++++++++----- tests/mocks/obsidian.ts | 25 ++++++++++++++++-- 3 files changed, 63 insertions(+), 11 deletions(-) diff --git a/src/ui/settings/PodNotesSettingsTab.test.ts b/src/ui/settings/PodNotesSettingsTab.test.ts index 7a2fb5f4..1e1ce0ba 100644 --- a/src/ui/settings/PodNotesSettingsTab.test.ts +++ b/src/ui/settings/PodNotesSettingsTab.test.ts @@ -57,6 +57,7 @@ function open(saved: Partial = {}) { persistenceUnknownFields: {}, manifest: { dir: ".obsidian/plugins/podnotes", version: "3.0.0" }, app: { + secretStorage: new obsidian.SecretStorage(), vault: { adapter: { exists: vi.fn().mockResolvedValue(true), @@ -195,7 +196,7 @@ describe("sections", () => { const { tab } = open(); const fields = [ ...tab.containerEl.querySelectorAll( - '.setting-item-control :is(input:not([tabindex="-1"]), select, textarea, [tabindex="0"])', + '.setting-item-control :is(input:not([tabindex="-1"]), select, textarea, [tabindex="0"], .setting-secret-value + button)', ), ]; const rowOf = (field: Element) => @@ -228,11 +229,26 @@ describe("sections", () => { "Export settings", ]); for (const field of fields) { - expect(field).toHaveAccessibleName(rowOf(field)); + expect(field).toHaveAccessibleName( + field.matches("button") ? `Link... ${rowOf(field)}` : rowOf(field), + ); expect(field).not.toHaveAttribute("aria-label"); } }); + test("an API key's button keeps its own words and adds its row's name", () => { + const { plugin, tab, row } = open({ openAISecretId: "podnotes-openai-api-key" }); + plugin.app.secretStorage.setSecret("podnotes-openai-api-key", "sk-test"); + tab.display(); + + expect(row("OpenAI API key").querySelector("button")).toHaveAccessibleName( + "Change OpenAI API key", + ); + expect(row("Deepgram API key").querySelector("button")).toHaveAccessibleName( + "Link... Deepgram API key", + ); + }); + test("each row renders under its own name, and the speaker rows apply only with diarization", () => { const { row, toggle, select } = open(); const disabled = (name: string) => row(name).classList.contains("is-disabled"); diff --git a/tests/e2e/settings-tab.test.ts b/tests/e2e/settings-tab.test.ts index 99f3797d..8ead6150 100644 --- a/tests/e2e/settings-tab.test.ts +++ b/tests/e2e/settings-tab.test.ts @@ -67,14 +67,14 @@ async function settingValue(obsidian: ObsidianClient, name: string): Promise; /** - * The settings field that has focus, as a screen reader reads it: its row's name, and the name - * Chromium computes for it and where from. Undefined when focus is on anything else, such as - * a button. + * The settings field or API key button that has focus, as a screen reader reads it: its row's + * name, and the name Chromium computes for it and where from. Undefined when focus is on + * anything else, such as an Import button. */ async function focusedField(obsidian: ObsidianClient): Promise { const named = await accessibleName( obsidian, - `document.activeElement.matches(".modal.mod-settings .setting-item-control :is(input, select, textarea, [tabindex='0'])") ? document.activeElement : null`, + `document.activeElement.matches(".modal.mod-settings .setting-item-control :is(input, select, textarea, [tabindex='0'], .setting-secret-value + button)") ? document.activeElement : null`, ); if (named === undefined) return undefined; const row = await obsidian.dev.evalJson( @@ -88,10 +88,16 @@ describe("PodNotes settings tab", () => { const { obsidian } = getContext(); await openPodNotesSettings(obsidian); onTestFinished(() => closeSettings(obsidian)); - // The diarization rows take focus only while diarization is on. + // The diarization rows take focus only while diarization is on, and the Deepgram key + // only with Deepgram, the provider after OpenAI. if ((await settingValue(obsidian, "Speaker diarization")) !== true) { await click(obsidian, ui.settingControl("Speaker diarization", ".checkbox-container")); } + const provider = `(${ui.settingControl("Diarization provider", "select").elements})[0]`; + if ((await obsidian.dev.evalJson(`${provider}.value`)) !== "deepgram") { + await obsidian.dev.evalJson(`${provider}.focus(), true`); + await pressKey(obsidian, "ArrowDown"); + } await click(obsidian, ui.settingControl("Skip back (s)", "input")); const fields: Field[] = []; for (let presses = 0; presses < 60 && fields.at(-1)?.row !== "Export settings"; presses++) { @@ -113,10 +119,12 @@ describe("PodNotes settings tab", () => { "Episode note template", "Podcast note path", "Podcast note template", + "OpenAI API key", "Transcript path", "Transcript template", "Speaker diarization", "Diarization provider", + "Deepgram API key", "Speaker label format", "Download path", "Export OPML", @@ -124,10 +132,17 @@ describe("PodNotes settings tab", () => { "Include API keys", "Export settings", ]); - // A focused toggle's label would also take the name of the checkbox inside it. + // A focused toggle's label would also take the name of the checkbox inside it. An API + // key's button keeps its own words, Link... or Change, before its row's name. expect( fields.filter(({ row, name, from }) => name !== row || from !== "aria-labelledby"), - ).toEqual([]); + ).toEqual( + ["OpenAI API key", "Deepgram API key"].map((row) => ({ + row, + name: expect.stringMatching(new RegExp(`^(Link\\.\\.\\.|Change) ${row}$`)), + from: "aria-labelledby", + })), + ); }); test("search finds a row of each section by name, and none of the removed rows", async () => { diff --git a/tests/mocks/obsidian.ts b/tests/mocks/obsidian.ts index 3784e0da..27668c2f 100644 --- a/tests/mocks/obsidian.ts +++ b/tests/mocks/obsidian.ts @@ -522,12 +522,33 @@ export class TextComponent extends BaseInteractiveElement { } } -export class SecretComponent extends TextComponent { +/** Obsidian's secret picker: a button that reads Change while its secret is on this device, else Link... */ +export class SecretComponent { + private readonly buttonEl: HTMLButtonElement; + constructor( public app: App, container: HTMLElement, ) { - super(container); + container + .createDiv({ cls: "clickable-icon mod-warning" }) + .setAttribute("aria-label", "Secrets are not encrypted"); + container.createDiv({ cls: "setting-secret-value" }); + this.buttonEl = container.createEl("button", { text: "Link..." }); + } + + setValue(value: string) { + this.buttonEl.textContent = this.app.secretStorage.getSecret(value) ? "Change" : "Link..."; + return this; + } + + setDisabled(disabled: boolean) { + this.buttonEl.disabled = disabled; + return this; + } + + onChange(_callback: (value: string) => unknown) { + return this; } } From 1c5c6c8c77f2c7fa647e547cd2f05374d4eeb262 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 07:22:09 +0200 Subject: [PATCH 02/15] fix(settings): name the API key buttons by their row Obsidian's secret picker is a button that reads Link... or Change, so a screen reader could not tell the OpenAI key from the Deepgram key. Each button is now labelled by its own words and its row's name, for example "Change OpenAI API key", so the visible text stays in the name. --- src/ui/settings/PodNotesSettingsTab.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/ui/settings/PodNotesSettingsTab.ts b/src/ui/settings/PodNotesSettingsTab.ts index 03088835..5896e0e0 100644 --- a/src/ui/settings/PodNotesSettingsTab.ts +++ b/src/ui/settings/PodNotesSettingsTab.ts @@ -92,7 +92,8 @@ let settingNames = 0; /** * Obsidian names no row's field after the row, and shows an aria-label as a tooltip, so * each field is labelled by the row's name. A toggle takes focus on its label, not its - * checkbox. + * checkbox. An API key's button reads Link... or Change, so it keeps those words before the + * row's name. */ const group = (heading: string, items: SettingDefinitionRender[]): SettingDefinitionGroup => ({ type: "group", @@ -107,6 +108,10 @@ const group = (heading: string, items: SettingDefinitionRender[]): SettingDefini "input, select, textarea, .checkbox-container", )) field.setAttribute("aria-labelledby", nameEl.id); + for (const button of controlEl.querySelectorAll(".setting-secret-value + button")) { + button.id = `${nameEl.id}-button`; + button.setAttribute("aria-labelledby", `${button.id} ${nameEl.id}`); + } return end; }, })), From 1db168b6552042fe79330362404078fc51b23a8d Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 07:22:09 +0200 Subject: [PATCH 03/15] test(notes): check where notes open around the PodNotes view In the main area, Save as note, Open note, Create note and Create podcast note must leave the view in sight, and in a sidebar a note opens in the main area. --- tests/e2e/journey-transcript.test.ts | 135 ++++++++++++++++++++++++++- 1 file changed, 134 insertions(+), 1 deletion(-) diff --git a/tests/e2e/journey-transcript.test.ts b/tests/e2e/journey-transcript.test.ts index fa0eb8cc..93cd337c 100644 --- a/tests/e2e/journey-transcript.test.ts +++ b/tests/e2e/journey-transcript.test.ts @@ -29,7 +29,7 @@ import { waitForPlayback, writeVaultFile, } from "./journey/library"; -import { jsLiteral, PLUGIN_ID } from "./runtime"; +import { jsLiteral, PLUGIN_ID, VIEW_TYPE } from "./runtime"; const getContext = createPodNotesE2EHarness("journey-transcript"); @@ -112,6 +112,62 @@ async function serve(): Promise { return server; } +type Placement = Readonly<{ + view: boolean; + notes: ReadonlyArray>; +}>; + +/** + * Whether the view is in sight, and each leaf that shows one of `paths`: in the view's own tab + * group, beside the view in the main area, in the main area while the view is in a sidebar, + * or in a sidebar. + */ +async function placement(paths: readonly string[]): Promise { + return await getContext().obsidian.dev.evalJson(`(() => { + const { workspace } = app; + const view = workspace.getLeavesOfType(${jsLiteral(VIEW_TYPE)})[0]; + const where = (leaf) => + leaf.getRoot() !== workspace.rootSplit + ? "sidebar" + : leaf.parent === view.parent + ? "the view's tabs" + : view.getRoot() === workspace.rootSplit + ? "beside the view" + : "main area"; + const notes = []; + workspace.iterateAllLeaves((leaf) => { + const path = leaf.view.file?.path; + if (${jsLiteral(paths)}.includes(path)) + notes.push({ path, where: where(leaf), shown: leaf.view.containerEl.isShown() }); + }); + return { view: view.view.containerEl.isShown(), notes }; + })()`); +} + +/** Waits until the leaf in focus shows `path`, as once a note opens. */ +async function waitForOpened(path: string): Promise { + const { obsidian } = getContext(); + await obsidian.waitFor( + () => + obsidian.dev.evalJson( + `app.workspace.activeLeaf?.view.file?.path === ${jsLiteral(path)}`, + ), + { timeoutMs: 30_000, intervalMs: 100, message: `${path} opened` }, + ); +} + +/** Closes every leaf that shows `path`, as a user closes its tab. */ +async function closeNote(path: string): Promise { + await getContext().obsidian.dev.evalJson(`(() => { + const leaves = []; + app.workspace.iterateAllLeaves((leaf) => { + if (leaf.view.file?.path === ${jsLiteral(path)}) leaves.push(leaf); + }); + for (const leaf of leaves) leaf.detach(); + return true; + })()`); +} + /** The full player wide, so every line shows and none needs a scroll that stops following. */ async function openTranscript(episode: FixtureEpisode): Promise { const context = getContext(); @@ -397,6 +453,83 @@ describe("transcript journeys", () => { expect(server.requests().length, "no request").toBe(requests); }); + test("in the main area, a note PodNotes opens goes beside the view, which stays in sight, and in a sidebar to the main area", async () => { + const context = getContext(); + const { obsidian, sandbox } = context; + const server = await serve(); + await seedLibrary(context, server, { + subscriptions: [FEED], + current: storedEpisode(server, FEED, CAPTIONED), + }); + const transcript = sandbox.path(NOTE_PATH); + const episodeNote = sandbox.path(`PodNotes/${FEED.title}/${CAPTIONED.title}.md`); + const podcastNote = sandbox.path(`PodNotes/Podcasts/${FEED.title}.md`); + const behind = sandbox.path("Behind the view.md"); + await writeVaultFile(obsidian, behind, "A note in a tab behind the view.\n"); + await placePodNotes(obsidian, "main area"); + await obsidian.dev.evalJsonAsync(`(async () => { + const { workspace } = app; + const view = workspace.getLeavesOfType(${jsLiteral(VIEW_TYPE)})[0]; + const others = []; + workspace.iterateRootLeaves((leaf) => { + if (leaf !== view) others.push(leaf); + }); + for (const leaf of others) leaf.detach(); + const tab = workspace.createLeafInParent(view.parent, 0); + await tab.openFile(app.vault.getFileByPath(${jsLiteral(behind)}), { active: false }); + workspace.setActiveLeaf(view, { focus: true }); + return true; + })()`); + if (!(await isShown(obsidian, ui.fullPlayer))) await click(obsidian, ui.openPlayer); + await waitUntilShown(obsidian, ui.fullPlayer); + await click(obsidian, ui.playerPanel("Transcript")); + await eventually(obsidian, async () => (await shownTranscript()).lines, LINES, 15_000); + const notes = [transcript, episodeNote, podcastNote]; + const beside = (path: string) => ({ + view: true, + notes: [{ path, where: "beside the view", shown: true }], + }); + + await dismissNotices(obsidian); + await click(obsidian, panelButton("Save as note")); + await waitForOpened(transcript); + expect(await placement(notes), "Save as note").toEqual(beside(transcript)); + await dismissNotices(obsidian); + await click(obsidian, panelButton("Open note")); + await waitForOpened(transcript); + expect(await placement(notes), "Open note reuses the split").toEqual(beside(transcript)); + + await closeNote(transcript); + await click(obsidian, panelButton("Open note")); + await waitForOpened(transcript); + expect(await placement(notes), "Open note").toEqual(beside(transcript)); + + await closeNote(transcript); + await click(obsidian, ui.playerControl("Episode options")); + await click(obsidian, ui.menuItem("Create note")); + await waitForOpened(episodeNote); + expect(await placement(notes), "Create note").toEqual(beside(episodeNote)); + + await closeNote(episodeNote); + await click(obsidian, ui.collapsePlayer); + await click(obsidian, ui.podcastCard(FEED.title)); + await click(obsidian, ui.podcastOptions); + await click(obsidian, ui.menuItem("Create podcast note")); + await waitForOpened(podcastNote); + expect(await placement(notes), "Create podcast note").toEqual(beside(podcastNote)); + + await closeNote(podcastNote); + await placePodNotes(obsidian, { sidebar: 320 }); + if (!(await isShown(obsidian, ui.fullPlayer))) await click(obsidian, ui.openPlayer); + await click(obsidian, ui.playerControl("Episode options")); + await click(obsidian, ui.menuItem("Open note")); + await waitForOpened(episodeNote); + expect(await placement(notes), "Open note in a sidebar").toEqual({ + view: true, + notes: [{ path: episodeNote, where: "main area", shown: true }], + }); + }, 120_000); + test("an episode whose feed publishes no transcript offers the paid transcription", async () => { const { obsidian } = getContext(); await openTranscript(PLAIN); From 90840c0fc76ead214326caa3401c52e89f0d3d57 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 07:22:14 +0200 Subject: [PATCH 04/15] fix(notes): open notes beside the PodNotes view in the main area With the view active in the main area and no note in sight, Obsidian opens a note in a new tab in the view's own tab group, which hides the player. Episode, podcast and transcript notes now open through openNote, which splits beside the view in that case and otherwise opens where Obsidian would. A later note reuses the split. Sidebars and phones keep their behavior. --- src/createFeedNote.test.ts | 2 + src/createFeedNote.ts | 7 +-- src/createPodcastNote.test.ts | 18 ++++++-- src/createPodcastNote.ts | 5 ++- src/services/TranscriptionService.ts | 5 ++- .../PodcastView.integration.test.ts | 1 + src/ui/PodcastView/TranscriptPanel.svelte | 3 +- src/ui/openNote.ts | 44 +++++++++++++++++++ tests/mocks/obsidian.ts | 5 +++ tests/mocks/transcriptionService.ts | 1 + 10 files changed, 79 insertions(+), 12 deletions(-) create mode 100644 src/ui/openNote.ts diff --git a/src/createFeedNote.test.ts b/src/createFeedNote.test.ts index 9cb90dbb..f941af31 100644 --- a/src/createFeedNote.test.ts +++ b/src/createFeedNote.test.ts @@ -56,6 +56,7 @@ function setupVault(options: { }), }, workspace: { + getActiveViewOfType: () => null, getLeaf: () => ({ openFile: (file: TFile) => opened.push(file.path), }), @@ -124,6 +125,7 @@ describe("createFeedNote", () => { createFolder: vi.fn(async () => {}), }, workspace: { + getActiveViewOfType: () => null, getLeaf: () => ({ openFile: (file: TFile) => opened.push(file.path), }), diff --git a/src/createFeedNote.ts b/src/createFeedNote.ts index 27faaa71..2c8864e9 100644 --- a/src/createFeedNote.ts +++ b/src/createFeedNote.ts @@ -6,6 +6,7 @@ import { plugin } from "./store"; import addExtension from "./utility/addExtension"; import { enforceMaxPathLength } from "./utility/enforceMaxPathLength"; import { ensureFolderExists } from "./utility/ensureFolderExists"; +import { openNote } from "./ui/openNote"; /** * Resolve the on-disk path of a feed's note from the feed-note path template. @@ -42,7 +43,7 @@ export function openFeedNote(feed: PodcastFeed): void { return; } - void get(plugin).app.workspace.getLeaf().openFile(file); + void openNote(get(plugin).app, file); } export default async function createFeedNote(feed: PodcastFeed): Promise { @@ -57,7 +58,7 @@ export default async function createFeedNote(feed: PodcastFeed): Promise { const existing = getFeedNote(feed); if (existing) { new Notice(`Note for "${feed.title}" already exists`); - void get(plugin).app.workspace.getLeaf().openFile(existing); + void openNote(get(plugin).app, existing); return; } @@ -71,7 +72,7 @@ export default async function createFeedNote(feed: PodcastFeed): Promise { try { const file = await createFileIfNotExists(filePathDotMd, content, feed); - void get(plugin).app.workspace.getLeaf().openFile(file); + void openNote(get(plugin).app, file); } catch (error) { console.error(error); new Notice(`Failed to create note: "${filePathDotMd}"`); diff --git a/src/createPodcastNote.test.ts b/src/createPodcastNote.test.ts index f2d19a22..5d6bb07c 100644 --- a/src/createPodcastNote.test.ts +++ b/src/createPodcastNote.test.ts @@ -46,6 +46,7 @@ function bootstrapAppMock(existingFile?: TFile) { }), }, workspace: { + getActiveViewOfType: () => null, getLeaf: vi.fn(() => leaf), }, }; @@ -163,7 +164,7 @@ describe("a note another writer creates at the path first", () => { throw new Error("File already exists."); }), }, - workspace: { getLeaf: vi.fn(() => leaf) }, + workspace: { getActiveViewOfType: () => null, getLeaf: vi.fn(() => leaf) }, }, settings: { note: { path: "PodNotes/{{title}}", template: "# {{title}}" } }, } as never); @@ -274,7 +275,10 @@ describe("getPodcastNote title fallbacks (#315)", () => { return { path }; }), }, - workspace: { getLeaf: vi.fn(() => ({ openFile: vi.fn() })) }, + workspace: { + getActiveViewOfType: () => null, + getLeaf: vi.fn(() => ({ openFile: vi.fn() })), + }, }, settings: { note: { path: "podcasts/{{podcast}} - {{title}}", template: "# {{title}}" }, @@ -346,7 +350,10 @@ describe("getPodcastNote title fallbacks (#315)", () => { return { path }; }), }, - workspace: { getLeaf: vi.fn(() => ({ openFile: vi.fn() })) }, + workspace: { + getActiveViewOfType: () => null, + getLeaf: vi.fn(() => ({ openFile: vi.fn() })), + }, }, settings: { note: { path: "podcasts/{{podcast}} - {{title}}", template: "# {{title}}" }, @@ -464,7 +471,10 @@ describe("episode notes identified by guid (#349)", () => { frontmatter: notes.find((note) => note.path === file.path)?.frontmatter, }), }, - workspace: { getLeaf: vi.fn(() => ({ openFile: vi.fn() })) }, + workspace: { + getActiveViewOfType: () => null, + getLeaf: vi.fn(() => ({ openFile: vi.fn() })), + }, }, settings: { note: { path: "podcasts/{{title}}", template } }, } as never); diff --git a/src/createPodcastNote.ts b/src/createPodcastNote.ts index d2041d58..0b75cd1e 100644 --- a/src/createPodcastNote.ts +++ b/src/createPodcastNote.ts @@ -17,6 +17,7 @@ import { findUniqueTitleMatch } from "./utility/episodeTitleMatch"; import { findByGuid } from "./utility/findByGuid"; import { addFrontmatterFields } from "./utility/noteFrontmatter"; import { vaultSafeLinkTarget } from "./services/privateFeeds"; +import { openNote } from "./ui/openNote"; /** * Resolve the on-disk path of an episode's note from the configured template. @@ -79,7 +80,7 @@ export default async function createPodcastNote(episode: Episode): Promise const { file, created } = await createPodcastNoteFileIfNotExists(episode); if (!created) new Notice(`Note for "${episode.title}" already exists`); - void get(plugin).app.workspace.getLeaf().openFile(file); + void openNote(get(plugin).app, file); } catch (error) { console.error(error); new Notice(`Failed to create note: "${getPodcastNotePath(episode)}"`); @@ -206,7 +207,7 @@ export function openPodcastNote(episode: Episode): void { return; } - void get(plugin).app.workspace.getLeaf().openFile(file); + void openNote(get(plugin).app, file); } async function createFileIfNotExists( diff --git a/src/services/TranscriptionService.ts b/src/services/TranscriptionService.ts index 72a74277..3474293e 100644 --- a/src/services/TranscriptionService.ts +++ b/src/services/TranscriptionService.ts @@ -6,6 +6,7 @@ import { ensureFolderExists } from "../utility/ensureFolderExists"; import { toError } from "../utility/toError"; import type { Episode } from "src/types/Episode"; import { getEpisodeTranscriptPath } from "src/utility/getEpisodeTranscriptPath"; +import { openNote } from "src/ui/openNote"; import { TimerNotice } from "src/ui/TimerNotice"; import { createChunkFiles, getMimeType } from "./audioChunker"; import { createTranscription, retryDelayMs } from "./openaiTranscription"; @@ -553,11 +554,11 @@ export class TranscriptionService { this.assertActive(); const newFile = await vault.create(transcriptPath, transcriptContent); this.assertActive(); - await this.plugin.app.workspace.getLeaf().openFile(newFile); + await openNote(this.plugin.app, newFile); } else if (file instanceof TFile) { // File already exists - open it without overwriting this.assertActive(); - await this.plugin.app.workspace.getLeaf().openFile(file); + await openNote(this.plugin.app, file); } else { throw new Error("Expected a file but found a folder at transcript path."); } diff --git a/src/ui/PodcastView/PodcastView.integration.test.ts b/src/ui/PodcastView/PodcastView.integration.test.ts index a4d1e1de..6b39f04f 100644 --- a/src/ui/PodcastView/PodcastView.integration.test.ts +++ b/src/ui/PodcastView/PodcastView.integration.test.ts @@ -222,6 +222,7 @@ function bootstrapAppMock() { }), }, workspace: { + getActiveViewOfType: () => null, getLeaf: vi.fn(() => leaf), }, }; diff --git a/src/ui/PodcastView/TranscriptPanel.svelte b/src/ui/PodcastView/TranscriptPanel.svelte index 12716054..1513ed7c 100644 --- a/src/ui/PodcastView/TranscriptPanel.svelte +++ b/src/ui/PodcastView/TranscriptPanel.svelte @@ -6,6 +6,7 @@ import { getEpisodeTranscriptPath } from "src/utility/getEpisodeTranscriptPath"; import { getEpisodeMediaType } from "src/utility/mediaType"; import { icon } from "../obsidian/icon"; + import { openNote } from "../openNote"; import { useServices } from "./context"; import Panel, { reveal } from "./Panel.svelte"; import SearchField from "./SearchField.svelte"; @@ -277,7 +278,7 @@ type="button" class="pn-btn pn-tx-save" disabled={!note && !$online} - onclick={() => (note ? app.workspace.getLeaf(false).openFile(note) : saveTranscriptNote())} + onclick={() => (note ? openNote(app, note) : saveTranscriptNote())} >{note ? "Open note" : "Save as note"} { + const view = workspace.getActiveViewOfType(ItemView)?.leaf; + const covered = + view?.view.getViewType() === VIEW_TYPE && + !Platform.isPhone && + view.getRoot() !== workspace.leftSplit && + view.getRoot() !== workspace.rightSplit && + !noteInSight(workspace, view); + const leaf = covered ? workspace.createLeafBySplit(view) : workspace.getLeaf(); + await leaf.openFile(file); +} + +/** + * Whether a leaf that getLeaf(false) would open a note in shows beside the view: in its window, + * which every popout's root is shared across, and outside the sidebars. + */ +function noteInSight(workspace: Workspace, view: WorkspaceLeaf): boolean { + let found = false; + workspace.iterateAllLeaves((leaf) => { + found ||= + leaf.getContainer() === view.getContainer() && + leaf.getRoot() === view.getRoot() && + leaf.view.navigation && + !leaf.getViewState().pinned && + leaf.view.containerEl.isShown(); + }); + return found; +} diff --git a/tests/mocks/obsidian.ts b/tests/mocks/obsidian.ts index 27668c2f..6bdd2e29 100644 --- a/tests/mocks/obsidian.ts +++ b/tests/mocks/obsidian.ts @@ -106,6 +106,11 @@ interface EventRef { export class Workspace { /** Obsidian makes a leaf active on focus or a press in it. */ activeLeaf: { view: { scope: Scope | null } } | null = null; + + getActiveViewOfType(type: abstract new (...args: never[]) => T): T | null { + const view = this.activeLeaf?.view; + return view instanceof type ? view : null; + } private readonly refs = new Set(); on(name: string, callback: () => unknown): EventRef { diff --git a/tests/mocks/transcriptionService.ts b/tests/mocks/transcriptionService.ts index 25d914f8..e6959c70 100644 --- a/tests/mocks/transcriptionService.ts +++ b/tests/mocks/transcriptionService.ts @@ -122,6 +122,7 @@ export function createMockPlugin( createFolder: vi.fn(), }, workspace: { + getActiveViewOfType: () => null, getLeaf: vi.fn(() => ({ openFile: vi.fn(), })), From a340b3681e77aafc67ab92a8efcda2c92a5a3a33 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 07:23:40 +0200 Subject: [PATCH 05/15] refactor(player): share isPlaying from the player The view, the API and the media session each decided that a loading episode counts as playing. isPlaying now lives beside PlayerStatus and all three use it. A media session test pins the loading and ended states. --- src/API/API.ts | 5 ++--- src/player/PlayerController.test.ts | 17 ++++++++++++++++- src/player/PlayerController.ts | 5 +++++ src/player/mediaSession.test.ts | 14 ++++++++++++++ src/player/mediaSession.ts | 18 +++++++++++++----- src/ui/PodcastView/Deck.svelte | 3 ++- src/ui/PodcastView/EpisodeRow.svelte | 3 ++- src/ui/PodcastView/MiniPlayer.svelte | 3 ++- src/ui/PodcastView/Player.svelte | 3 ++- src/ui/PodcastView/episodeRow.test.ts | 17 ----------------- src/ui/PodcastView/episodeRow.ts | 6 ------ 11 files changed, 58 insertions(+), 36 deletions(-) diff --git a/src/API/API.ts b/src/API/API.ts index 0e018e98..12ad4454 100644 --- a/src/API/API.ts +++ b/src/API/API.ts @@ -12,6 +12,7 @@ import { } from "src/utility/playbackRate"; import { getEpisodeTranscriptPath } from "src/utility/getEpisodeTranscriptPath"; import { normalizeSkipLength } from "src/utility/skipLength"; +import { isPlaying } from "src/player/PlayerController"; export class API implements IAPI { private get player() { @@ -42,10 +43,8 @@ export class API implements IAPI { this.player.seek(value); } - /** True while loading too: it plays once the media is ready, and Stop can cancel that. */ public get isPlaying(): boolean { - const { status } = this.state; - return status === "playing" || status === "loading"; + return isPlaying(this.state.status); } public get volume(): number { diff --git a/src/player/PlayerController.test.ts b/src/player/PlayerController.test.ts index 5469ff9b..4d0234f1 100644 --- a/src/player/PlayerController.test.ts +++ b/src/player/PlayerController.test.ts @@ -9,7 +9,7 @@ import type { IPodNotesSettings } from "src/types/IPodNotesSettings"; import { getEpisodeKey } from "src/utility/episodeKey"; import { privateFeedPlaceholder } from "src/utility/privateFeedUrl"; import { FakeDocument, FakeParent, type FakeMedia } from "../../tests/mocks/fakeMedia"; -import { PlayerController } from "./PlayerController"; +import { isPlaying, PlayerController, type PlayerStatus } from "./PlayerController"; const episode = (title: string, extra: Partial = {}): Episode => ({ title, @@ -117,6 +117,21 @@ function seekedByBrowser(media: FakeMedia, time: number, at = now) { const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); +test("counts a loading episode as playing, and no other status", () => { + const playing: Record = { + empty: false, + restored: false, + loading: true, + playing: true, + paused: false, + ended: false, + error: false, + }; + + for (const [status, expected] of Object.entries(playing)) + expect(isPlaying(status as PlayerStatus), status).toBe(expected); +}); + describe("synced progress handoff", () => { function paused() { const context = setup(); diff --git a/src/player/PlayerController.ts b/src/player/PlayerController.ts index 25915e9f..52a7e3d9 100644 --- a/src/player/PlayerController.ts +++ b/src/player/PlayerController.ts @@ -24,6 +24,11 @@ export type PlayerStatus = | "ended" | "error"; +/** A loading episode plays once ready, so it counts as playing: it shows Pause, and Stop cancels the load. */ +export function isPlaying(status: PlayerStatus): status is "playing" | "loading" { + return status === "playing" || status === "loading"; +} + export interface PlayerState { status: PlayerStatus; episode: Episode | undefined; diff --git a/src/player/mediaSession.test.ts b/src/player/mediaSession.test.ts index 987de643..106d8b7f 100644 --- a/src/player/mediaSession.test.ts +++ b/src/player/mediaSession.test.ts @@ -165,6 +165,20 @@ describe("what the lock screen shows", () => { expect(context.session.position).toEqual({ duration: 3600, playbackRate: 1, position: 40 }); }); + test("an episode still loading shows as playing, and one that ended as paused", () => { + const context = setup(); + + context.player.play(A); + expect(context.state().status).toBe("loading"); + expect(context.session.playbackState).toBe("playing"); + + loaded(context.media(), 60); + reach(context.media(), 60); + context.media().emit("ended"); + expect(context.state().status).toBe("ended"); + expect(context.session.playbackState).toBe("paused"); + }); + test("the position follows the speed, and a pause", () => { const context = setup(); playing(context, 40); diff --git a/src/player/mediaSession.ts b/src/player/mediaSession.ts index 6ff2cba5..0427c2e6 100644 --- a/src/player/mediaSession.ts +++ b/src/player/mediaSession.ts @@ -4,7 +4,12 @@ import type { Episode } from "src/types/Episode"; import type { NextTrackAction, PreviousTrackAction } from "src/types/Headset"; import type { IPodNotesSettings } from "src/types/IPodNotesSettings"; import { getEpisodeKey } from "src/utility/episodeKey"; -import type { PlayerController, PlayerState, PlayerStatus } from "./PlayerController"; +import { + isPlaying, + type PlayerController, + type PlayerState, + type PlayerStatus, +} from "./PlayerController"; interface MediaSessionHost { mediaSession: MediaSession | undefined; @@ -19,11 +24,12 @@ interface MediaSessionHost { const CHAPTER_RESTART_S = 2; const CHAPTER_TOLERANCE_S = 0.25; -const PLAYBACK_STATES: Record = { +const IDLE_PLAYBACK_STATES: Record< + Exclude, + MediaSessionPlaybackState +> = { empty: "none", restored: "none", - loading: "playing", - playing: "playing", paused: "paused", ended: "paused", error: "none", @@ -176,7 +182,9 @@ export function bindMediaSession( } setHandlers(!!state.episode); showMetadata(state.episode); - const playbackState = PLAYBACK_STATES[state.status]; + const playbackState = isPlaying(state.status) + ? "playing" + : IDLE_PLAYBACK_STATES[state.status]; attempt(() => { mediaSession.playbackState = playbackState; }); diff --git a/src/ui/PodcastView/Deck.svelte b/src/ui/PodcastView/Deck.svelte index 426c1e12..e95d21cf 100644 --- a/src/ui/PodcastView/Deck.svelte +++ b/src/ui/PodcastView/Deck.svelte @@ -9,7 +9,8 @@ import { icon } from "../obsidian/icon"; import { chapterAt } from "./chapters"; import { useServices } from "./context"; - import { isPlaying, sourceOf } from "./episodeRow"; + import { isPlaying } from "src/player/PlayerController"; + import { sourceOf } from "./episodeRow"; import { anchorOf, noteEntry, rateMenu, showMenu } from "./menus"; import type { Page } from "./pages"; import Scrubber from "./Scrubber.svelte"; diff --git a/src/ui/PodcastView/EpisodeRow.svelte b/src/ui/PodcastView/EpisodeRow.svelte index 9b008130..5e1011d4 100644 --- a/src/ui/PodcastView/EpisodeRow.svelte +++ b/src/ui/PodcastView/EpisodeRow.svelte @@ -4,7 +4,8 @@ import { icon } from "../obsidian/icon"; import { useServices } from "./context"; import Cover from "./Cover.svelte"; - import { availabilityOf, episodeRowModel, isPlaying } from "./episodeRow"; + import { isPlaying } from "src/player/PlayerController"; + import { availabilityOf, episodeRowModel } from "./episodeRow"; import { anchorOf, type MenuAnchor } from "./menus"; let { diff --git a/src/ui/PodcastView/MiniPlayer.svelte b/src/ui/PodcastView/MiniPlayer.svelte index a5d12124..cf30d948 100644 --- a/src/ui/PodcastView/MiniPlayer.svelte +++ b/src/ui/PodcastView/MiniPlayer.svelte @@ -3,7 +3,8 @@ import { icon } from "../obsidian/icon"; import { useServices } from "./context"; import Cover from "./Cover.svelte"; - import { formatMinutes, isPlaying, sourceOf, tileOf } from "./episodeRow"; + import { isPlaying } from "src/player/PlayerController"; + import { formatMinutes, sourceOf, tileOf } from "./episodeRow"; import type { Layout } from "./layout"; let { layout }: { layout: Layout } = $props(); diff --git a/src/ui/PodcastView/Player.svelte b/src/ui/PodcastView/Player.svelte index b4489a5f..3c33c4f0 100644 --- a/src/ui/PodcastView/Player.svelte +++ b/src/ui/PodcastView/Player.svelte @@ -8,7 +8,8 @@ import { useServices } from "./context"; import Cover from "./Cover.svelte"; import Deck from "./Deck.svelte"; - import { isPlaying, tileOf } from "./episodeRow"; + import { isPlaying } from "src/player/PlayerController"; + import { tileOf } from "./episodeRow"; import type { Layout } from "./layout"; import { anchorOf, episodeMenu, showMenu } from "./menus"; import TranscriptPanel from "./TranscriptPanel.svelte"; diff --git a/src/ui/PodcastView/episodeRow.test.ts b/src/ui/PodcastView/episodeRow.test.ts index c92d1243..f172b294 100644 --- a/src/ui/PodcastView/episodeRow.test.ts +++ b/src/ui/PodcastView/episodeRow.test.ts @@ -1,12 +1,10 @@ import { describe, expect, test } from "vitest"; -import type { PlayerStatus } from "src/player/PlayerController"; import type { Episode } from "src/types/Episode"; import type { LocalEpisode } from "src/types/LocalEpisode"; import { availabilityOf, episodeRowModel, formatMinutes, - isPlaying, rowKeys, secondsLeft, totalSeconds, @@ -46,21 +44,6 @@ const record = (time: number, duration: number, finished = false) => ({ const row = (changes: Partial = {}, of: Episode = episode) => episodeRowModel(of, { ...state, ...changes }); -test("counts a loading episode as playing, and no other status", () => { - const playing: Record = { - empty: false, - restored: false, - loading: true, - playing: true, - paused: false, - ended: false, - error: false, - }; - - for (const [status, expected] of Object.entries(playing)) - expect(isPlaying(status as PlayerStatus), status).toBe(expected); -}); - describe("an episode row", () => { test("shows the date and the duration of an episode not yet played", () => { expect(row()).toMatchObject({ diff --git a/src/ui/PodcastView/episodeRow.ts b/src/ui/PodcastView/episodeRow.ts index 18a75f34..408a1067 100644 --- a/src/ui/PodcastView/episodeRow.ts +++ b/src/ui/PodcastView/episodeRow.ts @@ -1,4 +1,3 @@ -import type { PlayerStatus } from "src/player/PlayerController"; import { downloadedCopy, offlinePlayable } from "src/player/source"; import type DownloadedEpisode from "src/types/DownloadedEpisode"; import type { Episode } from "src/types/Episode"; @@ -8,11 +7,6 @@ import { getPlayedEpisode, type PlayedEpisodeMap } from "src/utility/episodeStat import { isLocalFile } from "src/utility/isLocalFile"; import { getEpisodeMediaType, isSameMediaSource } from "src/utility/mediaType"; -/** A loading episode plays once ready, so it already shows Pause. */ -export function isPlaying(status: PlayerStatus): boolean { - return status === "playing" || status === "loading"; -} - export type Downloads = Readonly>; /** From debc216aee57d29bdd1c8e8e68a7eda9db0a3432 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 07:27:06 +0200 Subject: [PATCH 06/15] refactor(transcripts): resolve the transcript note in one place The panel, the service and the API each rendered the transcript path and looked the note up. TranscriptionService.ts now exports transcriptNote, and getEpisodeTranscriptPath folds into it. A service test pins the refusal to transcribe an episode whose note is already there. --- src/API/API.test.ts | 18 +++---- src/API/API.ts | 18 ++----- src/services/TranscriptionService.test.ts | 14 ++++++ src/services/TranscriptionService.ts | 60 +++++++++++++---------- src/ui/PodcastView/TranscriptPanel.svelte | 6 +-- src/utility/getEpisodeTranscriptPath.ts | 8 --- tests/mocks/transcriptionService.ts | 12 ++--- 7 files changed, 67 insertions(+), 69 deletions(-) delete mode 100644 src/utility/getEpisodeTranscriptPath.ts diff --git a/src/API/API.test.ts b/src/API/API.test.ts index eeef3910..4c05c916 100644 --- a/src/API/API.test.ts +++ b/src/API/API.test.ts @@ -421,7 +421,7 @@ describe("API volume (AP-07)", () => { describe("API transcript access", () => { function setTranscriptVault(files: Record) { const createTFile = (path: string): TFile => Object.assign(new TFile(), { path }); - const getAbstractFileByPath = vi.fn((path: string) => + const getFileByPath = vi.fn((path: string) => Object.prototype.hasOwnProperty.call(files, path) ? createTFile(path) : null, ); const read = vi.fn(async (file: TFile) => files[file.path] ?? ""); @@ -432,10 +432,10 @@ describe("API transcript access", () => { path: "Transcripts/{{podcast}}/{{title}}.md", }, }, - { getAbstractFileByPath, read } as unknown as Partial, + { getFileByPath, read } as unknown as Partial, ); - return { api, player, getAbstractFileByPath, read }; + return { api, player, getFileByPath, read }; } test("returns null when no episode is loaded", async () => { @@ -445,14 +445,12 @@ describe("API transcript access", () => { }); test("returns null when the current episode has no generated transcript file", async () => { - const { api, player, getAbstractFileByPath, read } = setTranscriptVault({}); + const { api, player, getFileByPath, read } = setTranscriptVault({}); player.restore(feedEpisode); await expect(api.getTranscript()).resolves.toBeNull(); - expect(getAbstractFileByPath).toHaveBeenCalledWith( - "Transcripts/Feed Podcast/Feed Episode.md", - ); + expect(getFileByPath).toHaveBeenCalledWith("Transcripts/Feed Podcast/Feed Episode.md"); expect(read).not.toHaveBeenCalled(); }); @@ -475,15 +473,13 @@ describe("API transcript access", () => { test("reads the generated transcript note for an explicit episode", async () => { const transcript = "Local episode transcript"; - const { api, player, getAbstractFileByPath } = setTranscriptVault({ + const { api, player, getFileByPath } = setTranscriptVault({ "Transcripts/local file/Local Episode.md": transcript, }); player.restore(feedEpisode); await expect(api.getTranscript(localEpisode)).resolves.toBe(transcript); - expect(getAbstractFileByPath).toHaveBeenCalledWith( - "Transcripts/local file/Local Episode.md", - ); + expect(getFileByPath).toHaveBeenCalledWith("Transcripts/local file/Local Episode.md"); }); }); diff --git a/src/API/API.ts b/src/API/API.ts index 12ad4454..495b9b16 100644 --- a/src/API/API.ts +++ b/src/API/API.ts @@ -1,7 +1,6 @@ import type { Episode } from "src/types/Episode"; import { formatEpisodeTimestamp } from "src/utility/formatEpisodeTimestamp"; import type { IAPI } from "./IAPI"; -import { TFile } from "obsidian"; import { plugin } from "src/store"; import { get } from "svelte/store"; import { formatPodcastSegment, normalizePodcastSegmentTimes } from "src/utility/podcastSegment"; @@ -10,7 +9,7 @@ import { normalizePlaybackRate, PLAYBACK_RATE_STEP, } from "src/utility/playbackRate"; -import { getEpisodeTranscriptPath } from "src/utility/getEpisodeTranscriptPath"; +import { transcriptNote } from "src/services/TranscriptionService"; import { normalizeSkipLength } from "src/utility/skipLength"; import { isPlaying } from "src/player/PlayerController"; @@ -110,18 +109,9 @@ export class API implements IAPI { return null; } - const pluginInstance = get(plugin); - const transcriptPath = getEpisodeTranscriptPath( - episode, - pluginInstance.settings.transcript.path, - ); - const transcriptFile = pluginInstance.app.vault.getAbstractFileByPath(transcriptPath); - - if (!(transcriptFile instanceof TFile)) { - return null; - } - - return await pluginInstance.app.vault.read(transcriptFile); + const { app, settings } = get(plugin); + const file = transcriptNote(app.vault, settings, episode); + return file ? await app.vault.read(file) : null; } start(): void { diff --git a/src/services/TranscriptionService.test.ts b/src/services/TranscriptionService.test.ts index e01bc6bf..1898b28f 100644 --- a/src/services/TranscriptionService.test.ts +++ b/src/services/TranscriptionService.test.ts @@ -70,6 +70,20 @@ describe("TranscriptionService", () => { ]); }); + test("refuses an episode whose note is already at the transcript path", async () => { + const path = "Transcripts/Test Podcast/Test Episode.md"; + const service = new TranscriptionService( + createMockPlugin({ existingTranscriptPath: path }), + ); + + await service.transcribeCurrentEpisode(); + + expect(noticeMessages).toEqual([ + `You've already transcribed this episode - found ${path}.`, + ]); + expect(getEpisodeAudioBufferMock).not.toHaveBeenCalled(); + }); + test("shows notice when no episode is playing", async () => { const mockPlugin = createMockPlugin({ podcast: null }); const service = new TranscriptionService(mockPlugin); diff --git a/src/services/TranscriptionService.ts b/src/services/TranscriptionService.ts index 3474293e..521433d0 100644 --- a/src/services/TranscriptionService.ts +++ b/src/services/TranscriptionService.ts @@ -1,11 +1,12 @@ -import { Notice, TFile } from "obsidian"; +import { Notice, TFile, type Vault } from "obsidian"; import type PodNotes from "../main"; import { getEpisodeAudioBuffer } from "../downloadEpisode"; -import { TranscriptTemplateEngine } from "../TemplateEngine"; +import { FilePathTemplateEngine, TranscriptTemplateEngine } from "../TemplateEngine"; +import { enforceMaxPathLength, lastSegmentExtension } from "../utility/enforceMaxPathLength"; import { ensureFolderExists } from "../utility/ensureFolderExists"; import { toError } from "../utility/toError"; import type { Episode } from "src/types/Episode"; -import { getEpisodeTranscriptPath } from "src/utility/getEpisodeTranscriptPath"; +import type { IPodNotesSettings } from "src/types/IPodNotesSettings"; import { openNote } from "src/ui/openNote"; import { TimerNotice } from "src/ui/TimerNotice"; import { createChunkFiles, getMimeType } from "./audioChunker"; @@ -30,6 +31,26 @@ import { export const TRANSCRIBE_FROM_AUDIO_COMMAND = "Transcribe current episode from audio"; +type TranscriptSettings = Pick; + +/** + * Where an episode's transcript note goes, capped so a long title can't trip ENAMETOOLONG. + * The extension comes from the transcript path, so a custom one is kept. + */ +function transcriptPath(settings: TranscriptSettings, episode: Episode): string { + const rendered = FilePathTemplateEngine(settings.transcript.path, episode); + return enforceMaxPathLength(rendered, lastSegmentExtension(rendered)); +} + +/** The episode's transcript note, if one is at the transcript path. */ +export function transcriptNote( + vault: Vault, + settings: TranscriptSettings, + episode: Episode, +): TFile | null { + return vault.getFileByPath(transcriptPath(settings, episode)); +} + // A chunk that exhausts its retries leaves this placeholder in its transcript // slot; the pattern matches it so buildTranscriptBody can tell real speech apart // from a body made only of error markers. Keep the builder and matcher in sync. @@ -130,10 +151,9 @@ export class TranscriptionService { } private enqueue(job: TranscriptionJob): void { - const transcriptPath = this.getTranscriptPath(job.episode); - const existingFile = this.plugin.app.vault.getAbstractFileByPath(transcriptPath); - if (existingFile instanceof TFile) { - new Notice(`You've already transcribed this episode - found ${transcriptPath}.`); + const existing = transcriptNote(this.plugin.app.vault, this.plugin.settings, job.episode); + if (existing) { + new Notice(`You've already transcribed this episode - found ${existing.path}.`); return; } @@ -190,11 +210,10 @@ export class TranscriptionService { }; try { - const transcriptPath = this.getTranscriptPath(episode); - const existingFile = this.plugin.app.vault.getAbstractFileByPath(transcriptPath); - if (existingFile instanceof TFile) { + const existing = transcriptNote(this.plugin.app.vault, this.plugin.settings, episode); + if (existing) { notice.stop(); - updateNotice(`Transcript already exists - skipped (${transcriptPath}).`); + updateNotice(`Transcript already exists - skipped (${existing.path}).`); return; } @@ -518,20 +537,9 @@ export class TranscriptionService { }); } - /** - * The on-disk path of an episode's transcript note, capped so a long title - * can't trip ENAMETOOLONG (#22). Used for the existence checks and the write - * alike so they always agree on the same path. The extension comes from the - * configured template (not forced to ".md") so a custom transcript path keeps - * the user's chosen suffix. - */ - private getTranscriptPath(episode: Episode): string { - return getEpisodeTranscriptPath(episode, this.plugin.settings.transcript.path); - } - private async saveTranscription(episode: Episode, transcriptBody: string): Promise { this.assertActive(); - const transcriptPath = this.getTranscriptPath(episode); + const path = transcriptPath(this.plugin.settings, episode); const transcriptContent = TranscriptTemplateEngine( this.plugin.settings.transcript.template, episode, @@ -544,15 +552,15 @@ export class TranscriptionService { // "Folder already exists" thrown when a case-insensitive lookup misses an // existing folder, which the old hand-rolled loop surfaced as a spurious // failure (the #87 class of bug). - const directory = transcriptPath.substring(0, transcriptPath.lastIndexOf("/")); + const directory = path.substring(0, path.lastIndexOf("/")); await ensureFolderExists(directory, vault, () => this.assertActive()); this.assertActive(); - const file = vault.getAbstractFileByPath(transcriptPath); + const file = vault.getAbstractFileByPath(path); if (!file) { this.assertActive(); - const newFile = await vault.create(transcriptPath, transcriptContent); + const newFile = await vault.create(path, transcriptContent); this.assertActive(); await openNote(this.plugin.app, newFile); } else if (file instanceof TFile) { diff --git a/src/ui/PodcastView/TranscriptPanel.svelte b/src/ui/PodcastView/TranscriptPanel.svelte index 1513ed7c..5cea738e 100644 --- a/src/ui/PodcastView/TranscriptPanel.svelte +++ b/src/ui/PodcastView/TranscriptPanel.svelte @@ -3,7 +3,7 @@ import type { Episode } from "src/types/Episode"; import { formatDuration } from "src/utility/formatDuration"; import { isLocalFile } from "src/utility/isLocalFile"; - import { getEpisodeTranscriptPath } from "src/utility/getEpisodeTranscriptPath"; + import { transcriptNote } from "src/services/TranscriptionService"; import { getEpisodeMediaType } from "src/utility/mediaType"; import { icon } from "../obsidian/icon"; import { openNote } from "../openNote"; @@ -64,9 +64,7 @@ // deleted since changes rowHasNote, and the transcript path is a setting. const note = $derived.by(() => { void $rowHasNote; - return app.vault.getFileByPath( - getEpisodeTranscriptPath(episode, $settings.transcript.path), - ); + return transcriptNote(app.vault, $settings, episode); }); const provider = $derived.by(() => { const { diarization } = $settings.transcript; diff --git a/src/utility/getEpisodeTranscriptPath.ts b/src/utility/getEpisodeTranscriptPath.ts deleted file mode 100644 index 288271c2..00000000 --- a/src/utility/getEpisodeTranscriptPath.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { FilePathTemplateEngine } from "src/TemplateEngine"; -import type { Episode } from "src/types/Episode"; -import { enforceMaxPathLength, lastSegmentExtension } from "src/utility/enforceMaxPathLength"; - -export function getEpisodeTranscriptPath(episode: Episode, transcriptPathTemplate: string): string { - const rendered = FilePathTemplateEngine(transcriptPathTemplate, episode); - return enforceMaxPathLength(rendered, lastSegmentExtension(rendered)); -} diff --git a/tests/mocks/transcriptionService.ts b/tests/mocks/transcriptionService.ts index e6959c70..974c5e14 100644 --- a/tests/mocks/transcriptionService.ts +++ b/tests/mocks/transcriptionService.ts @@ -1,3 +1,4 @@ +import { TFile } from "obsidian"; import { afterEach, beforeEach, vi } from "vitest"; import type PodNotes from "src/main"; import type { TranscriptLink } from "src/parser/feedDocumentParser"; @@ -77,6 +78,9 @@ export function createMockPlugin( loadFeed = async () => true, } = overrides; + const existing = (path: string) => + path === existingTranscriptPath ? Object.assign(new TFile(), { path }) : null; + return { settings: { openAISecretId: openAIKey ? "openai-secret" : "", @@ -111,12 +115,8 @@ export function createMockPlugin( }, app: { vault: { - getAbstractFileByPath: vi.fn((path: string) => { - if (existingTranscriptPath && path === existingTranscriptPath) { - return { path }; - } - return null; - }), + getFileByPath: vi.fn(existing), + getAbstractFileByPath: vi.fn(existing), readBinary: vi.fn(), create: vi.fn(), createFolder: vi.fn(), From 7b58cda6bffb10d6f0d5d53ed897192b1dd63654 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 07:43:47 +0200 Subject: [PATCH 07/15] test(notes): check notes opened from a popout and on a phone A popout gets the same split beside the view, which a second note reuses. A phone keeps Obsidian's tab. Each test deletes the notes it makes. --- tests/e2e/journey-transcript.test.ts | 146 +++++++++++++++++++++++++-- 1 file changed, 138 insertions(+), 8 deletions(-) diff --git a/tests/e2e/journey-transcript.test.ts b/tests/e2e/journey-transcript.test.ts index 93cd337c..f6a96be5 100644 --- a/tests/e2e/journey-transcript.test.ts +++ b/tests/e2e/journey-transcript.test.ts @@ -3,7 +3,9 @@ import { FIXTURE_HOST, SUPERCAST_FIXTURE_HOST } from "../../scripts/e2e-instance import { createPodNotesE2EHarness } from "./harness"; import { asPhone, + closePopoutAndReload, dismissNotices, + moveViewToPopout, runCommand, placePodNotes, setOffline, @@ -119,21 +121,23 @@ type Placement = Readonly<{ /** * Whether the view is in sight, and each leaf that shows one of `paths`: in the view's own tab - * group, beside the view in the main area, in the main area while the view is in a sidebar, - * or in a sidebar. + * group, beside the view in its window or sidebar, else in the main area, another popout or a + * sidebar. */ async function placement(paths: readonly string[]): Promise { return await getContext().obsidian.dev.evalJson(`(() => { const { workspace } = app; const view = workspace.getLeavesOfType(${jsLiteral(VIEW_TYPE)})[0]; const where = (leaf) => - leaf.getRoot() !== workspace.rootSplit - ? "sidebar" - : leaf.parent === view.parent + leaf.getContainer() === view.getContainer() && leaf.getRoot() === view.getRoot() + ? leaf.parent === view.parent ? "the view's tabs" - : view.getRoot() === workspace.rootSplit - ? "beside the view" - : "main area"; + : "beside the view" + : leaf.getRoot() === workspace.rootSplit + ? "main area" + : leaf.getRoot() === workspace.floatingSplit + ? "another popout" + : "sidebar"; const notes = []; workspace.iterateAllLeaves((leaf) => { const path = leaf.view.file?.path; @@ -144,6 +148,19 @@ async function placement(paths: readonly string[]): Promise { })()`); } +/** Deletes the notes at `paths` once the test ends. */ +function deleteWhenDone(paths: readonly string[]): void { + onTestFinished(() => + getContext().obsidian.dev.evalJsonAsync(`(async () => { + for (const path of ${jsLiteral(paths)}) { + const file = app.vault.getFileByPath(path); + if (file) await app.vault.delete(file); + } + return true; + })()`), + ); +} + /** Waits until the leaf in focus shows `path`, as once a note opens. */ async function waitForOpened(path: string): Promise { const { obsidian } = getContext(); @@ -156,6 +173,34 @@ async function waitForOpened(path: string): Promise { ); } +/** Presses what `selector` matches in the PodNotes view's popout window, with real input. */ +async function pressInPopout(selector: string, text = ""): Promise { + const pressed = await getContext().obsidian.dev.evalJsonAsync(`(async () => { + const remote = require("@electron/remote"); + const view = app.workspace.getLeavesOfType(${jsLiteral(VIEW_TYPE)})[0]; + const doc = view.view.containerEl.ownerDocument; + let win; + for (const each of remote.BrowserWindow.getAllWindows()) + if (await each.webContents.executeJavaScript(${jsLiteral(`Boolean(document.querySelector(${jsLiteral(PLAYER_VIEW)}))`)})) win = each; + for (let i = 0; i < 50; i++) { + const target = [...doc.querySelectorAll(${jsLiteral(selector)})].find( + (each) => each.offsetParent && (!${jsLiteral(text)} || each.textContent.trim() === ${jsLiteral(text)}), + ); + if (target) { + const box = target.getBoundingClientRect(); + const point = { x: Math.round(box.left + box.width / 2), y: Math.round(box.top + box.height / 2) }; + win.webContents.sendInputEvent({ type: "mouseMove", ...point }); + for (const type of ["mouseDown", "mouseUp"]) + win.webContents.sendInputEvent({ type, ...point, button: "left", clickCount: 1 }); + return true; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + return false; + })()`); + if (!pressed) throw new Error(`Nothing to press at ${selector} ${text} in the popout.`); +} + /** Closes every leaf that shows `path`, as a user closes its tab. */ async function closeNote(path: string): Promise { await getContext().obsidian.dev.evalJson(`(() => { @@ -465,6 +510,7 @@ describe("transcript journeys", () => { const episodeNote = sandbox.path(`PodNotes/${FEED.title}/${CAPTIONED.title}.md`); const podcastNote = sandbox.path(`PodNotes/Podcasts/${FEED.title}.md`); const behind = sandbox.path("Behind the view.md"); + deleteWhenDone([transcript, episodeNote, podcastNote, behind]); await writeVaultFile(obsidian, behind, "A note in a tab behind the view.\n"); await placePodNotes(obsidian, "main area"); await obsidian.dev.evalJsonAsync(`(async () => { @@ -530,6 +576,90 @@ describe("transcript journeys", () => { }); }, 120_000); + test("in a popout, a note PodNotes opens goes beside the view, not to a note in another popout, and a later one reuses that split", async () => { + const context = getContext(); + const { obsidian, sandbox } = context; + const server = await serve(); + await seedLibrary(context, server, { subscriptions: [FEED] }); + const podcastNote = sandbox.path(`PodNotes/Podcasts/${FEED.title}.md`); + const elsewhere = sandbox.path("In another popout.md"); + deleteWhenDone([podcastNote, elsewhere]); + await writeVaultFile(obsidian, podcastNote, `# ${FEED.title}\n`); + await writeVaultFile(obsidian, elsewhere, "A note in another popout.\n"); + await placePodNotes(obsidian, "main area"); + await click(obsidian, ui.podcastCard(FEED.title)); + await waitUntilShown(obsidian, ui.podcastOptions); + await obsidian.dev.evalJsonAsync(`(async () => { + await app.workspace.openPopoutLeaf().openFile(app.vault.getFileByPath(${jsLiteral(elsewhere)})); + return true; + })()`); + await moveViewToPopout(obsidian); + const beside = { + view: true, + notes: [ + { path: elsewhere, where: "another popout", shown: true }, + { path: podcastNote, where: "beside the view", shown: true }, + ], + }; + + try { + for (const time of ["first", "again"]) { + await pressInPopout('button[aria-label="Podcast options"]'); + await pressInPopout(".menu .menu-item", "Open podcast note"); + await waitForOpened(podcastNote); + expect( + await placement([podcastNote, elsewhere]), + `Open podcast note ${time}`, + ).toEqual(beside); + } + } finally { + // A note left in a popout keeps it open after the view leaves. + await closeNote(elsewhere); + await closeNote(podcastNote); + await closePopoutAndReload(obsidian); + } + }); + + test("on a phone, Save as note and Open note show the transcript note in a tab, as Obsidian opens a link", async () => { + const context = getContext(); + const { obsidian, sandbox } = context; + await asPhone(obsidian); + const server = await serve(); + await seedLibrary(context, server, { + subscriptions: [FEED], + current: storedEpisode(server, FEED, CAPTIONED), + }); + await placePodNotes(obsidian, "main area"); + if (!(await isShown(obsidian, ui.fullPlayer))) await click(obsidian, ui.openPlayer); + await waitUntilShown(obsidian, ui.fullPlayer); + await click(obsidian, ui.playerPanel("Transcript")); + await eventually(obsidian, async () => (await shownTranscript()).lines, LINES, 15_000); + const transcript = sandbox.path(NOTE_PATH); + deleteWhenDone([transcript]); + + await dismissNotices(obsidian); + await click(obsidian, panelButton("Save as note")); + await waitForOpened(transcript); + expect(await placement([transcript]), "Save as note").toEqual({ + view: false, + notes: [{ path: transcript, where: "the view's tabs", shown: true }], + }); + + await obsidian.dev.evalJson( + `app.workspace.setActiveLeaf(app.workspace.getLeavesOfType(${jsLiteral(VIEW_TYPE)})[0], { focus: true }), true`, + ); + await dismissNotices(obsidian); + await click(obsidian, panelButton("Open note")); + await waitForOpened(transcript); + expect(await placement([transcript]), "Open note opens a tab of its own").toEqual({ + view: false, + notes: [ + { path: transcript, where: "the view's tabs", shown: false }, + { path: transcript, where: "the view's tabs", shown: true }, + ], + }); + }); + test("an episode whose feed publishes no transcript offers the paid transcription", async () => { const { obsidian } = getContext(); await openTranscript(PLAIN); From 556ad53461cf1dfde81d99e6f64d9a09a12aadbc Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 09:18:47 +0200 Subject: [PATCH 08/15] test(notes): check notes opened while the view is stacked with notes A stacked tab group shows every tab, so a note stacked with the PodNotes view counted as a note in sight, and opening there scrolled the player out of the window. The e2e placement check now measures sight by the window, and a unit and an e2e case cover a stacked view with and without a note beside it. --- src/ui/openNote.test.ts | 96 ++++++++++++++++++++++++++++ tests/e2e/journey-transcript.test.ts | 65 ++++++++++++++++++- 2 files changed, 158 insertions(+), 3 deletions(-) create mode 100644 src/ui/openNote.test.ts diff --git a/src/ui/openNote.test.ts b/src/ui/openNote.test.ts new file mode 100644 index 00000000..3c3987f8 --- /dev/null +++ b/src/ui/openNote.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, test } from "vitest"; +import { ItemView, TFile, type App } from "obsidian"; +import { VIEW_TYPE } from "src/constants"; +import { openNote } from "./openNote"; + +type Group = { readonly stacked: boolean }; + +interface FakeLeaf { + readonly name: string; + readonly parent: Group; + readonly view: ItemView & { navigation: boolean }; + readonly opened: string[]; + getRoot(): object; + getContainer(): object; + getViewState(): { type: string; pinned: boolean }; + openFile(file: TFile): Promise; +} + +const mainArea = {}; +const mainWindow = {}; + +function leaf(name: string, parent: Group, type = "markdown"): FakeLeaf { + const view = Object.assign(Object.create(ItemView.prototype) as ItemView, { + navigation: type !== VIEW_TYPE, + getViewType: () => type, + // A stacked group shows every tab side by side, scrolled or not. + containerEl: { isShown: () => true }, + }); + const opened: string[] = []; + return { + name, + parent, + view, + opened, + getRoot: () => mainArea, + getContainer: () => mainWindow, + getViewState: () => ({ type, pinned: false }), + openFile: async (file) => void opened.push(file.path), + }; +} + +/** + * A desktop main area with the PodNotes view active. `recent` is the note Obsidian's + * getLeaf(false) returns: in a stacked group every tab counts, and it takes the one active last. + */ +function workspace(leaves: FakeLeaf[], recent: FakeLeaf) { + const view = leaves.find((each) => each.view.getViewType() === VIEW_TYPE)!; + const splits: FakeLeaf[] = []; + const app = { + workspace: { + leftSplit: {}, + rightSplit: {}, + getActiveViewOfType: (type: typeof ItemView) => + view.view instanceof type ? Object.assign(view.view, { leaf: view }) : null, + iterateAllLeaves: (callback: (each: FakeLeaf) => void) => leaves.forEach(callback), + getLeaf: () => recent, + createLeafBySplit: (target: FakeLeaf) => { + const split = leaf(`split beside ${target.name}`, { stacked: false }); + splits.push(split); + return split; + }, + }, + } as unknown as App; + return { app, splits }; +} + +const note = Object.assign(new TFile(), { path: "Transcripts/Episode.md" }); + +describe("a note PodNotes opens while its view is stacked with notes", () => { + test("goes to a split beside the view, not to a note stacked with it", async () => { + const stack = { stacked: true }; + const stacked = leaf("stacked note", stack); + const { app, splits } = workspace([stacked, leaf("view", stack, VIEW_TYPE)], stacked); + + await openNote(app, note); + + expect(stacked.opened).toEqual([]); + expect(splits.map((split) => [split.name, split.opened])).toEqual([ + ["split beside view", [note.path]], + ]); + }); + + test("goes to a note in another tab group, even when a stacked one was active last", async () => { + const stack = { stacked: true }; + const stacked = leaf("stacked note", stack); + const beside = leaf("note beside", { stacked: false }); + const { app, splits } = workspace( + [stacked, leaf("view", stack, VIEW_TYPE), beside], + stacked, + ); + + await openNote(app, note); + + expect([stacked.opened, beside.opened, splits]).toEqual([[], [note.path], []]); + }); +}); diff --git a/tests/e2e/journey-transcript.test.ts b/tests/e2e/journey-transcript.test.ts index f6a96be5..e340d0ec 100644 --- a/tests/e2e/journey-transcript.test.ts +++ b/tests/e2e/journey-transcript.test.ts @@ -122,7 +122,8 @@ type Placement = Readonly<{ /** * Whether the view is in sight, and each leaf that shows one of `paths`: in the view's own tab * group, beside the view in its window or sidebar, else in the main area, another popout or a - * sidebar. + * sidebar. In sight means shown, and inside its window: a stacked tab group shows every tab, + * scrolled out of the window or not. */ async function placement(paths: readonly string[]): Promise { return await getContext().obsidian.dev.evalJson(`(() => { @@ -138,13 +139,18 @@ async function placement(paths: readonly string[]): Promise { : leaf.getRoot() === workspace.floatingSplit ? "another popout" : "sidebar"; + const inSight = (leaf) => { + const el = leaf.view.containerEl; + const box = el.getBoundingClientRect(); + return el.isShown() && box.width > 0 && box.left >= 0 && box.right <= el.win.innerWidth + 1; + }; const notes = []; workspace.iterateAllLeaves((leaf) => { const path = leaf.view.file?.path; if (${jsLiteral(paths)}.includes(path)) - notes.push({ path, where: where(leaf), shown: leaf.view.containerEl.isShown() }); + notes.push({ path, where: where(leaf), shown: inSight(leaf) }); }); - return { view: view.view.containerEl.isShown(), notes }; + return { view: inSight(view), notes }; })()`); } @@ -576,6 +582,59 @@ describe("transcript journeys", () => { }); }, 120_000); + test("with the view stacked with a note, a note PodNotes opens goes beside the view and keeps it in sight, also when the stacked note was active last", async () => { + const context = getContext(); + const { obsidian, sandbox } = context; + const server = await serve(); + await seedLibrary(context, server, { + subscriptions: [FEED], + current: storedEpisode(server, FEED, CAPTIONED), + }); + const transcript = sandbox.path(NOTE_PATH); + const stacked = sandbox.path("Stacked with the view.md"); + deleteWhenDone([transcript, stacked]); + await writeVaultFile(obsidian, stacked, "A note stacked with the view.\n"); + await placePodNotes(obsidian, "main area"); + const viewTabs = `app.workspace.getLeavesOfType(${jsLiteral(VIEW_TYPE)})[0].parent`; + await obsidian.dev.evalJsonAsync(`(async () => { + const { workspace } = app; + const view = workspace.getLeavesOfType(${jsLiteral(VIEW_TYPE)})[0]; + const others = []; + workspace.iterateRootLeaves((leaf) => { + if (leaf !== view) others.push(leaf); + }); + for (const leaf of others) leaf.detach(); + const tab = workspace.createLeafInParent(view.parent, 0); + await tab.openFile(app.vault.getFileByPath(${jsLiteral(stacked)})); + view.parent.setStacked(true); + workspace.setActiveLeaf(view, { focus: true }); + return true; + })()`); + if (!(await isShown(obsidian, ui.fullPlayer))) await click(obsidian, ui.openPlayer); + await waitUntilShown(obsidian, ui.fullPlayer); + await click(obsidian, ui.playerPanel("Transcript")); + await eventually(obsidian, async () => (await shownTranscript()).lines, LINES, 15_000); + const beside = { + view: true, + notes: [{ path: transcript, where: "beside the view", shown: true }], + }; + + await dismissNotices(obsidian); + await click(obsidian, panelButton("Save as note")); + await waitForOpened(transcript); + expect(await placement([transcript]), "Save as note").toEqual(beside); + + await obsidian.dev.evalJson(`(() => { + const stack = ${viewTabs}; + app.workspace.setActiveLeaf(stack.children.find((leaf) => leaf.view.file?.path === ${jsLiteral(stacked)})); + return true; + })()`); + await dismissNotices(obsidian); + await click(obsidian, panelButton("Open note")); + await waitForOpened(transcript); + expect(await placement([transcript]), "Open note after the stacked note").toEqual(beside); + }); + test("in a popout, a note PodNotes opens goes beside the view, not to a note in another popout, and a later one reuses that split", async () => { const context = getContext(); const { obsidian, sandbox } = context; From 13c844f5611b4e06e8dd333a4e1bbde1a4699759 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 09:18:47 +0200 Subject: [PATCH 09/15] fix(notes): never open a note in the PodNotes view's own tab group While the view is active in the main area or a popout, openNote now picks the leaf itself: the first note in sight in another tab group of that window, else a split beside the view. getLeaf() could take a note stacked with the view, which scrolls the player out of sight. --- src/ui/openNote.ts | 44 ++++++++++++++++++++++++++------------------ 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/src/ui/openNote.ts b/src/ui/openNote.ts index 504528fc..a33ef48a 100644 --- a/src/ui/openNote.ts +++ b/src/ui/openNote.ts @@ -9,36 +9,44 @@ import { import { VIEW_TYPE } from "src/constants"; /** - * Opens a note where Obsidian opens a link: in a note in sight, else in a new tab in the active - * tab group. With the PodNotes view active in the main area, that tab would cover the view, so - * the note opens in a split beside it, which later notes then reuse. A phone shows one tab at a - * time anyway. + * Opens a note where Obsidian opens a link, except while the PodNotes view is active in the main + * area or a popout. There Obsidian would add a tab to the view's tab group, or take a note + * stacked with the view, and either moves the view out of sight. So the note goes to a note in + * sight in another tab group of that window, else to a split beside the view, which later notes + * then reuse. A phone shows one tab at a time anyway. */ export async function openNote({ workspace }: App, file: TFile): Promise { const view = workspace.getActiveViewOfType(ItemView)?.leaf; - const covered = - view?.view.getViewType() === VIEW_TYPE && - !Platform.isPhone && - view.getRoot() !== workspace.leftSplit && - view.getRoot() !== workspace.rightSplit && - !noteInSight(workspace, view); - const leaf = covered ? workspace.createLeafBySplit(view) : workspace.getLeaf(); - await leaf.openFile(file); + if ( + view?.view.getViewType() !== VIEW_TYPE || + Platform.isPhone || + view.getRoot() === workspace.leftSplit || + view.getRoot() === workspace.rightSplit + ) { + await workspace.getLeaf().openFile(file); + return; + } + const leaf = noteBeside(workspace, view) ?? workspace.createLeafBySplit(view); + await leaf.openFile(file, { active: true }); } /** - * Whether a leaf that getLeaf(false) would open a note in shows beside the view: in its window, - * which every popout's root is shared across, and outside the sidebars. + * The first note in sight in the view's window, which every popout's root is shared across, + * outside the view's tab group and the sidebars. */ -function noteInSight(workspace: Workspace, view: WorkspaceLeaf): boolean { - let found = false; +function noteBeside(workspace: Workspace, view: WorkspaceLeaf): WorkspaceLeaf | undefined { + let found: WorkspaceLeaf | undefined; workspace.iterateAllLeaves((leaf) => { - found ||= + if ( + !found && + leaf.parent !== view.parent && leaf.getContainer() === view.getContainer() && leaf.getRoot() === view.getRoot() && leaf.view.navigation && !leaf.getViewState().pinned && - leaf.view.containerEl.isShown(); + leaf.view.containerEl.isShown() + ) + found = leaf; }); return found; } From cf086e8474a708e3139b8e9384b1b469510abeba Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 09:26:29 +0200 Subject: [PATCH 10/15] test(queue): check the grip's focus ring after a touch drag and an arrow key On a phone, a touch drag left the grip with the keyboard focus ring. The test drags by touch as the first input on the Queue page, then moves the episode with an arrow key, which should show the ring. --- tests/e2e/journey-lists.test.ts | 43 ++++++++++++++++++++++++++++++++- tests/e2e/journey/input.ts | 24 ++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/tests/e2e/journey-lists.test.ts b/tests/e2e/journey-lists.test.ts index ef677c50..3d7c1e90 100644 --- a/tests/e2e/journey-lists.test.ts +++ b/tests/e2e/journey-lists.test.ts @@ -2,14 +2,16 @@ import type { ObsidianClient } from "obsidian-e2e"; import { describe, expect, onTestFinished, test } from "vitest"; import { createPodNotesE2EHarness } from "./harness"; import { + asPhone, closePodNotes, + placePodNotes, reloadObsidian, runFromPalette, showLibrary, showList, showPodNotes, } from "./journey/actions"; -import { click, drag, pointOn, pressKey, pressMouse, typeText } from "./journey/input"; +import { click, drag, pointOn, pressKey, pressMouse, touchDrag, typeText } from "./journey/input"; import { ui, type UiTarget } from "./journey/targets"; import { eventually, @@ -183,6 +185,45 @@ describe("list journeys", () => { await expectGripFocused(TWO.title, 3, 4); }); + test("on a phone, a grip dragged by touch shows no focus ring, and one an arrow key moved does", async () => { + const context = getContext(); + const { obsidian } = context; + await asPhone(obsidian); + const server = await serve(); + await seedLibrary(context, server, { + subscriptions: [FEED], + queue: QUEUE.map((each) => storedEpisode(server, FEED, each)), + }); + await placePodNotes(obsidian, "main area"); + // As when the view reopens on its saved Queue page, so the drag is the first touch. + await obsidian.dev.evalJson( + `app.plugins.plugins.${PLUGIN_ID}.navigation.replace({ kind: "queue" }), true`, + ); + await waitUntilShown(obsidian, ui.queueGrip(FOUR.title)); + const ring = () => + obsidian.dev.evalJson<{ label: string | null; ring: boolean }>(`(() => { + const focused = document.activeElement; + return { + label: focused?.getAttribute("aria-label") ?? null, + ring: Boolean(focused?.matches(":focus-visible")) && getComputedStyle(focused).outlineStyle !== "none", + }; + })()`); + const grip = (position: number) => + `Reorder ${FOUR.title}, position ${position} of 4. Use arrow keys to move.`; + + await touchDrag( + obsidian, + await pointOn(obsidian, ui.queueGrip(FOUR.title)), + await pointOn(obsidian, ui.queueRow(TWO.title), "top"), + ); + await expectQueue(titles([ONE, FOUR, TWO, THREE])); + expect(await ring(), "after a touch drag").toEqual({ label: grip(2), ring: false }); + + await pressKey(obsidian, "ArrowDown"); + await expectQueue(titles([ONE, TWO, FOUR, THREE])); + expect(await ring(), "after an arrow key").toEqual({ label: grip(3), ring: true }); + }); + test("a drag places its episode by the row it ends on when playback queues another episode mid-drag", async () => { const { obsidian } = getContext(); const server = await openQueue(PLAYING); diff --git a/tests/e2e/journey/input.ts b/tests/e2e/journey/input.ts index 4497417b..de93799e 100644 --- a/tests/e2e/journey/input.ts +++ b/tests/e2e/journey/input.ts @@ -197,6 +197,30 @@ export async function drag( await mouse.release(); } +/** Drags a finger from `from` to `to` at a hand's pace: a short hold, then `steps` even steps. */ +export async function touchDrag( + obsidian: ObsidianClient, + from: Point, + to: Point, + steps = 8, +): Promise { + const touch = (type: string, points: Point[]) => + cdp(obsidian, "Input.dispatchTouchEvent", { type, touchPoints: points }); + const pause = () => new Promise((resolve) => setTimeout(resolve, 120)); + await touch("touchStart", [from]); + await pause(); + for (let step = 1; step <= steps; step++) { + await touch("touchMove", [ + { + x: from.x + ((to.x - from.x) * step) / steps, + y: from.y + ((to.y - from.y) * step) / steps, + }, + ]); + await pause(); + } + await touch("touchEnd", []); +} + export async function typeInto( obsidian: ObsidianClient, target: UiTarget, From 110997b61d1cb4c81a53e30f890819de1660c335 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 09:26:29 +0200 Subject: [PATCH 11/15] fix(queue): focus a dragged grip without the keyboard focus ring The grip still takes focus on a press, so arrow keys move its episode after a click or a drag, but it shows the ring only once a key moves it. --- src/ui/PodcastView/QueueList.svelte | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/ui/PodcastView/QueueList.svelte b/src/ui/PodcastView/QueueList.svelte index 209ae5fb..69245785 100644 --- a/src/ui/PodcastView/QueueList.svelte +++ b/src/ui/PodcastView/QueueList.svelte @@ -32,13 +32,14 @@ /** * Listens on the grip's own window, which is a popout's when the view is in one, * until the button is released. Pointer capture is not used, because the keyed rows - * move the grip's node and a moved node can lose it. + * move the grip's node and a moved node can lose it. The grip takes focus for the arrow + * keys, without the ring a touch would otherwise leave on it. */ function start(event: PointerEvent, episode: Episode) { if (event.button !== 0 || drag) return; const grip = event.currentTarget as HTMLElement; event.preventDefault(); - grip.focus(); + grip.focus({ focusVisible: false }); const { pointerId } = event; const win = grip.win; const move = (moved: PointerEvent) => { @@ -47,7 +48,7 @@ if (moved.pointerType === "mouse" && moved.buttons === 0) return drop(); place(moved.clientX, moved.clientY); // Moving the row's node takes focus from its grip. - void tick().then(() => grip.focus({ preventScroll: true })); + void tick().then(() => grip.focus({ preventScroll: true, focusVisible: false })); }; const release = (released: PointerEvent) => { if (released.pointerId === pointerId) drop(); From 11ea1b926939f1b6b16b05def2a89ed8f9088638 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 09:28:24 +0200 Subject: [PATCH 12/15] test(notes): check that a pinned note beside the view is left alone Save as note and Open note must open beside the view rather than in a pinned note, in a unit case and in the real app. --- src/ui/openNote.test.ts | 21 ++++++++++-- tests/e2e/journey-transcript.test.ts | 49 ++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/src/ui/openNote.test.ts b/src/ui/openNote.test.ts index 3c3987f8..8a93449f 100644 --- a/src/ui/openNote.test.ts +++ b/src/ui/openNote.test.ts @@ -19,7 +19,7 @@ interface FakeLeaf { const mainArea = {}; const mainWindow = {}; -function leaf(name: string, parent: Group, type = "markdown"): FakeLeaf { +function leaf(name: string, parent: Group, type = "markdown", pinned = false): FakeLeaf { const view = Object.assign(Object.create(ItemView.prototype) as ItemView, { navigation: type !== VIEW_TYPE, getViewType: () => type, @@ -34,7 +34,7 @@ function leaf(name: string, parent: Group, type = "markdown"): FakeLeaf { opened, getRoot: () => mainArea, getContainer: () => mainWindow, - getViewState: () => ({ type, pinned: false }), + getViewState: () => ({ type, pinned }), openFile: async (file) => void opened.push(file.path), }; } @@ -94,3 +94,20 @@ describe("a note PodNotes opens while its view is stacked with notes", () => { expect([stacked.opened, beside.opened, splits]).toEqual([[], [note.path], []]); }); }); + +test("a note PodNotes opens goes to a split beside the view, not to a pinned note beside it", async () => { + const tabs = { stacked: false }; + const pinned = leaf("pinned note", { stacked: false }, "markdown", true); + // Obsidian passes over a pinned note and adds a tab to the view's group. + const { app, splits } = workspace( + [leaf("view", tabs, VIEW_TYPE), pinned], + leaf("new tab over the view", tabs), + ); + + await openNote(app, note); + + expect(pinned.opened).toEqual([]); + expect(splits.map((split) => [split.name, split.opened])).toEqual([ + ["split beside view", [note.path]], + ]); +}); diff --git a/tests/e2e/journey-transcript.test.ts b/tests/e2e/journey-transcript.test.ts index e340d0ec..18200c29 100644 --- a/tests/e2e/journey-transcript.test.ts +++ b/tests/e2e/journey-transcript.test.ts @@ -635,6 +635,55 @@ describe("transcript journeys", () => { expect(await placement([transcript]), "Open note after the stacked note").toEqual(beside); }); + test("with a pinned note beside the view, Save as note and Open note leave it and open beside the view", async () => { + const context = getContext(); + const { obsidian, sandbox } = context; + const server = await serve(); + await seedLibrary(context, server, { + subscriptions: [FEED], + current: storedEpisode(server, FEED, CAPTIONED), + }); + const transcript = sandbox.path(NOTE_PATH); + const pinned = sandbox.path("Pinned beside the view.md"); + deleteWhenDone([transcript, pinned]); + await writeVaultFile(obsidian, pinned, "A pinned note beside the view.\n"); + await placePodNotes(obsidian, "main area"); + await obsidian.dev.evalJsonAsync(`(async () => { + const { workspace } = app; + const view = workspace.getLeavesOfType(${jsLiteral(VIEW_TYPE)})[0]; + const others = []; + workspace.iterateRootLeaves((leaf) => { + if (leaf !== view) others.push(leaf); + }); + for (const leaf of others) leaf.detach(); + const beside = workspace.createLeafBySplit(view); + await beside.openFile(app.vault.getFileByPath(${jsLiteral(pinned)})); + beside.setPinned(true); + workspace.setActiveLeaf(view, { focus: true }); + return true; + })()`); + if (!(await isShown(obsidian, ui.fullPlayer))) await click(obsidian, ui.openPlayer); + await waitUntilShown(obsidian, ui.fullPlayer); + await click(obsidian, ui.playerPanel("Transcript")); + await eventually(obsidian, async () => (await shownTranscript()).lines, LINES, 15_000); + const beside = { + view: true, + notes: [ + { path: transcript, where: "beside the view", shown: true }, + { path: pinned, where: "beside the view", shown: true }, + ], + }; + + await dismissNotices(obsidian); + await click(obsidian, panelButton("Save as note")); + await waitForOpened(transcript); + expect(await placement([transcript, pinned]), "Save as note").toEqual(beside); + await dismissNotices(obsidian); + await click(obsidian, panelButton("Open note")); + await waitForOpened(transcript); + expect(await placement([transcript, pinned]), "Open note").toEqual(beside); + }); + test("in a popout, a note PodNotes opens goes beside the view, not to a note in another popout, and a later one reuses that split", async () => { const context = getContext(); const { obsidian, sandbox } = context; From 075fe0554ceac60ee00e11aa7570d22c3af08302 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 09:42:08 +0200 Subject: [PATCH 13/15] test(notes): check the player's controls stay in the window, and a hidden tab beside the view In a stacked tab group, Back, Play and Forward must stay inside the window and on top after Save as note and Open note. Beside the view, a hidden tab behind a pinned note must be left alone too. --- tests/e2e/journey-transcript.test.ts | 31 +++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/tests/e2e/journey-transcript.test.ts b/tests/e2e/journey-transcript.test.ts index 18200c29..217b5254 100644 --- a/tests/e2e/journey-transcript.test.ts +++ b/tests/e2e/journey-transcript.test.ts @@ -167,6 +167,19 @@ function deleteWhenDone(paths: readonly string[]): void { ); } +/** Whether the full player's Back, Play and Forward controls are inside their window and on top. */ +async function transportInSight(): Promise { + return await getContext().obsidian.dev.evalJson(`(() => { + const controls = [...document.querySelectorAll(${jsLiteral(`${PLAYER_VIEW} section.pn-player :is(.pn-skip, .pn-play)`)})]; + return controls.length === 3 && controls.every((control) => { + const box = control.getBoundingClientRect(); + const win = control.ownerDocument.defaultView; + const hit = control.ownerDocument.elementFromPoint(box.left + box.width / 2, box.top + box.height / 2); + return box.width > 0 && box.left >= 0 && box.top >= 0 && box.right <= win.innerWidth && box.bottom <= win.innerHeight && control.contains(hit); + }); + })()`); +} + /** Waits until the leaf in focus shows `path`, as once a note opens. */ async function waitForOpened(path: string): Promise { const { obsidian } = getContext(); @@ -622,6 +635,7 @@ describe("transcript journeys", () => { await dismissNotices(obsidian); await click(obsidian, panelButton("Save as note")); await waitForOpened(transcript); + expect(await transportInSight(), "Back, Play and Forward after Save as note").toBe(true); expect(await placement([transcript]), "Save as note").toEqual(beside); await obsidian.dev.evalJson(`(() => { @@ -632,10 +646,11 @@ describe("transcript journeys", () => { await dismissNotices(obsidian); await click(obsidian, panelButton("Open note")); await waitForOpened(transcript); + expect(await transportInSight(), "Back, Play and Forward after Open note").toBe(true); expect(await placement([transcript]), "Open note after the stacked note").toEqual(beside); }); - test("with a pinned note beside the view, Save as note and Open note leave it and open beside the view", async () => { + test("with a pinned note and a hidden tab beside the view, Save as note and Open note leave both and open beside the view", async () => { const context = getContext(); const { obsidian, sandbox } = context; const server = await serve(); @@ -645,8 +660,10 @@ describe("transcript journeys", () => { }); const transcript = sandbox.path(NOTE_PATH); const pinned = sandbox.path("Pinned beside the view.md"); - deleteWhenDone([transcript, pinned]); + const hidden = sandbox.path("Behind the pinned note.md"); + deleteWhenDone([transcript, pinned, hidden]); await writeVaultFile(obsidian, pinned, "A pinned note beside the view.\n"); + await writeVaultFile(obsidian, hidden, "A tab behind the pinned note.\n"); await placePodNotes(obsidian, "main area"); await obsidian.dev.evalJsonAsync(`(async () => { const { workspace } = app; @@ -656,8 +673,11 @@ describe("transcript journeys", () => { if (leaf !== view) others.push(leaf); }); for (const leaf of others) leaf.detach(); - const beside = workspace.createLeafBySplit(view); + const behind = workspace.createLeafBySplit(view); + await behind.openFile(app.vault.getFileByPath(${jsLiteral(hidden)})); + const beside = workspace.createLeafInParent(behind.parent, 1); await beside.openFile(app.vault.getFileByPath(${jsLiteral(pinned)})); + workspace.setActiveLeaf(beside); beside.setPinned(true); workspace.setActiveLeaf(view, { focus: true }); return true; @@ -670,6 +690,7 @@ describe("transcript journeys", () => { view: true, notes: [ { path: transcript, where: "beside the view", shown: true }, + { path: hidden, where: "beside the view", shown: false }, { path: pinned, where: "beside the view", shown: true }, ], }; @@ -677,11 +698,11 @@ describe("transcript journeys", () => { await dismissNotices(obsidian); await click(obsidian, panelButton("Save as note")); await waitForOpened(transcript); - expect(await placement([transcript, pinned]), "Save as note").toEqual(beside); + expect(await placement([transcript, hidden, pinned]), "Save as note").toEqual(beside); await dismissNotices(obsidian); await click(obsidian, panelButton("Open note")); await waitForOpened(transcript); - expect(await placement([transcript, pinned]), "Open note").toEqual(beside); + expect(await placement([transcript, hidden, pinned]), "Open note").toEqual(beside); }); test("in a popout, a note PodNotes opens goes beside the view, not to a note in another popout, and a later one reuses that split", async () => { From 19982fe4efb3795d0d6541cfc501cb98d3318cc9 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 09:42:08 +0200 Subject: [PATCH 14/15] refactor(queue): keep the plain refocus while a grip moves Only the press needs focusVisible: false. A refocus while the row moves shows no ring on its own, so it stays as it was. --- src/ui/PodcastView/QueueList.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/PodcastView/QueueList.svelte b/src/ui/PodcastView/QueueList.svelte index 69245785..39a1b000 100644 --- a/src/ui/PodcastView/QueueList.svelte +++ b/src/ui/PodcastView/QueueList.svelte @@ -48,7 +48,7 @@ if (moved.pointerType === "mouse" && moved.buttons === 0) return drop(); place(moved.clientX, moved.clientY); // Moving the row's node takes focus from its grip. - void tick().then(() => grip.focus({ preventScroll: true, focusVisible: false })); + void tick().then(() => grip.focus({ preventScroll: true })); }; const release = (released: PointerEvent) => { if (released.pointerId === pointerId) drop(); From 1a0e0a4e3c2b251ee2eec578ac7686d4d8fdbcda Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Thu, 15 Oct 2026 09:46:40 +0200 Subject: [PATCH 15/15] test(notes): run the stacked-view check at the pane width At 864 px a stack scrolled to the note leaves the player's controls out of the window, so the controls check catches it on its own. --- tests/e2e/journey-transcript.test.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/e2e/journey-transcript.test.ts b/tests/e2e/journey-transcript.test.ts index 217b5254..2b12ac1d 100644 --- a/tests/e2e/journey-transcript.test.ts +++ b/tests/e2e/journey-transcript.test.ts @@ -608,6 +608,19 @@ describe("transcript journeys", () => { deleteWhenDone([transcript, stacked]); await writeVaultFile(obsidian, stacked, "A note stacked with the view.\n"); await placePodNotes(obsidian, "main area"); + // The design's pane width, where a stack scrolled to the note leaves the player's controls + // out of the window. + const size = await obsidian.dev.evalJson<[number, number]>(`(() => { + const win = require("@electron/remote").getCurrentWindow(); + const size = win.getContentSize(); + win.setContentSize(864, Math.max(size[1], 760)); + return size; + })()`); + onTestFinished(() => + obsidian.dev.evalJson( + `require("@electron/remote").getCurrentWindow().setContentSize(${size[0]}, ${size[1]}), true`, + ), + ); const viewTabs = `app.workspace.getLeavesOfType(${jsLiteral(VIEW_TYPE)})[0].parent`; await obsidian.dev.evalJsonAsync(`(async () => { const { workspace } = app;