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 0e018e98..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,8 +9,9 @@ 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"; export class API implements IAPI { private get player() { @@ -42,10 +42,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 { @@ -111,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/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/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/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 72a74277..521433d0 100644 --- a/src/services/TranscriptionService.ts +++ b/src/services/TranscriptionService.ts @@ -1,11 +1,13 @@ -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"; import { createTranscription, retryDelayMs } from "./openaiTranscription"; @@ -29,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. @@ -129,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; } @@ -189,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; } @@ -517,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, @@ -543,21 +552,21 @@ 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 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/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/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/QueueList.svelte b/src/ui/PodcastView/QueueList.svelte index 209ae5fb..39a1b000 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) => { diff --git a/src/ui/PodcastView/TranscriptPanel.svelte b/src/ui/PodcastView/TranscriptPanel.svelte index 12716054..5cea738e 100644 --- a/src/ui/PodcastView/TranscriptPanel.svelte +++ b/src/ui/PodcastView/TranscriptPanel.svelte @@ -3,9 +3,10 @@ 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"; import { useServices } from "./context"; import Panel, { reveal } from "./Panel.svelte"; import SearchField from "./SearchField.svelte"; @@ -63,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; @@ -277,7 +276,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 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>; /** diff --git a/src/ui/openNote.test.ts b/src/ui/openNote.test.ts new file mode 100644 index 00000000..8a93449f --- /dev/null +++ b/src/ui/openNote.test.ts @@ -0,0 +1,113 @@ +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", pinned = false): 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 }), + 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], []]); + }); +}); + +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/src/ui/openNote.ts b/src/ui/openNote.ts new file mode 100644 index 00000000..a33ef48a --- /dev/null +++ b/src/ui/openNote.ts @@ -0,0 +1,52 @@ +import { + ItemView, + Platform, + type App, + type TFile, + type Workspace, + type WorkspaceLeaf, +} from "obsidian"; +import { VIEW_TYPE } from "src/constants"; + +/** + * 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; + 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 }); +} + +/** + * 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 noteBeside(workspace: Workspace, view: WorkspaceLeaf): WorkspaceLeaf | undefined { + let found: WorkspaceLeaf | undefined; + workspace.iterateAllLeaves((leaf) => { + if ( + !found && + leaf.parent !== view.parent && + leaf.getContainer() === view.getContainer() && + leaf.getRoot() === view.getRoot() && + leaf.view.navigation && + !leaf.getViewState().pinned && + leaf.view.containerEl.isShown() + ) + found = leaf; + }); + return found; +} 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/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; }, })), 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/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-transcript.test.ts b/tests/e2e/journey-transcript.test.ts index fa0eb8cc..2b12ac1d 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, @@ -29,7 +31,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 +114,124 @@ 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 its window or sidebar, else in the main area, another popout or a + * 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(`(() => { + const { workspace } = app; + const view = workspace.getLeavesOfType(${jsLiteral(VIEW_TYPE)})[0]; + const where = (leaf) => + leaf.getContainer() === view.getContainer() && leaf.getRoot() === view.getRoot() + ? leaf.parent === view.parent + ? "the view's tabs" + : "beside the view" + : leaf.getRoot() === workspace.rootSplit + ? "main area" + : 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: inSight(leaf) }); + }); + return { view: inSight(view), notes }; + })()`); +} + +/** 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; + })()`), + ); +} + +/** 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(); + await obsidian.waitFor( + () => + obsidian.dev.evalJson( + `app.workspace.activeLeaf?.view.file?.path === ${jsLiteral(path)}`, + ), + { timeoutMs: 30_000, intervalMs: 100, message: `${path} opened` }, + ); +} + +/** 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(`(() => { + 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 +517,291 @@ 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"); + 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 () => { + 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("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"); + // 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; + 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 transportInSight(), "Back, Play and Forward after Save as note").toBe(true); + 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 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 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(); + 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"); + 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; + 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 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; + })()`); + 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: hidden, where: "beside the view", shown: false }, + { 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, hidden, pinned]), "Save as note").toEqual(beside); + await dismissNotices(obsidian); + await click(obsidian, panelButton("Open note")); + await waitForOpened(transcript); + 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 () => { + 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); 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, diff --git a/tests/e2e/settings-tab.test.ts b/tests/e2e/settings-tab.test.ts index ace80ed6..52214fa9 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..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 { @@ -522,12 +527,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; } } diff --git a/tests/mocks/transcriptionService.ts b/tests/mocks/transcriptionService.ts index 25d914f8..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,17 +115,14 @@ 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(), }, workspace: { + getActiveViewOfType: () => null, getLeaf: vi.fn(() => ({ openFile: vi.fn(), })),