diff --git a/manifest.json b/manifest.json index 90ec14c..dc910f0 100644 --- a/manifest.json +++ b/manifest.json @@ -2,7 +2,7 @@ "id": "podnotes", "name": "PodNotes", "version": "2.20.2", - "minAppVersion": "1.11.5", + "minAppVersion": "1.13.0", "description": "Helps you write notes on podcasts.", "author": "Christian B. B. Houmann", "authorUrl": "https://bagerbach.com", diff --git a/src/TemplateEngine.ts b/src/TemplateEngine.ts index 28e0b56..e8594a8 100644 --- a/src/TemplateEngine.ts +++ b/src/TemplateEngine.ts @@ -186,7 +186,7 @@ function escapeMarkdownText(text: string): string { * must maintain that context boundary. */ function escapeMarkdownBodyText(text: string): string { - // eslint-disable-next-line no-control-regex + // eslint-disable-next-line no-control-regex -- Control characters must be collapsed before feed text enters Markdown. const singleLine = text.replace(/[\u0000-\u001f\u007f]+/g, " ").trim(); return escapeMarkdownText(singleLine); } @@ -243,7 +243,7 @@ function feedHtmlToMarkdown(html: string): string { for (const element of document.querySelectorAll( "img, audio, video, source, iframe, object, embed, link", )) { - const alt = element instanceof HTMLImageElement ? element.alt.trim() : ""; + const alt = element.instanceOf(HTMLImageElement) ? element.alt.trim() : ""; element.replaceWith(document.createTextNode(alt)); } return neutralizeMarkdownEmbeds( @@ -561,7 +561,7 @@ export function getFeedNoteWikilink(feedTitle: string): string { * URLs are unchanged. */ function sanitizeUrlForTemplate(url: string): string { - // eslint-disable-next-line no-control-regex + // eslint-disable-next-line no-control-regex -- URL control characters must be encoded before interpolation. return url.replace(/[\u0000-\u0020"'`()[\]<>\\]/g, (char) => { return `%${char.charCodeAt(0).toString(16).toUpperCase().padStart(2, "0")}`; }); @@ -598,7 +598,7 @@ export function replaceIllegalFileNameCharactersInString(string: string) { .replace(/[\\,#%&{}/*<>$'":@\u2023|?[\]]/g, "") // Replace any control characters (newlines, tabs, carriage returns) // with spaces so they can never end up in a file name. - // eslint-disable-next-line no-control-regex + // eslint-disable-next-line no-control-regex -- File names cannot contain control characters. .replace(/[\u0000-\u001f]/g, " ") // Collapse every run of whitespace into a single space. .replace(/\s+/g, " ") diff --git a/src/commands.ts b/src/commands.ts index 5f4b104..fbf7bf3 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -11,7 +11,6 @@ import { FeedSuggestModal, orderFeedsByCurrent } from "src/ui/FeedSuggestModal"; import downloadEpisodeWithNotice from "src/downloadEpisode"; import getUniversalPodcastLink from "src/getUniversalPodcastLink"; import { getEpisodeMediaType } from "src/utility/mediaType"; -import type { IconType } from "src/types/IconType"; import type PodNotes from "src/main"; /** @@ -59,7 +58,7 @@ export function registerCommands(plugin: PodNotes): void { plugin.addCommand({ id: "podnotes-show-leaf", name: "Show player", - icon: "podcast" as IconType, + icon: "podcast", // Always available, and always reveals the view. The previous // checkCallback hid this command whenever a leaf already existed, so // once the view was open-but-hidden (collapsed sidebar, sidebar @@ -73,7 +72,7 @@ export function registerCommands(plugin: PodNotes): void { plugin.addCommand({ id: "start-playing", name: "Play Podcast", - icon: "play-circle" as IconType, + icon: "play-circle", checkCallback: (checking) => { if (checking) { return !plugin.api.isPlaying && !!plugin.api.podcast; @@ -86,7 +85,7 @@ export function registerCommands(plugin: PodNotes): void { plugin.addCommand({ id: "stop-playing", name: "Stop Podcast", - icon: "stop-circle" as IconType, + icon: "stop-circle", checkCallback: (checking) => { if (checking) { return plugin.api.isPlaying && !!plugin.api.podcast; @@ -99,7 +98,7 @@ export function registerCommands(plugin: PodNotes): void { plugin.addCommand({ id: "skip-backward", name: "Skip Backward", - icon: "skip-back" as IconType, + icon: "skip-back", checkCallback: (checking) => { // Skipping only seeks the position, so it is available whenever an // episode is loaded — paused or playing — matching the always-active @@ -116,7 +115,7 @@ export function registerCommands(plugin: PodNotes): void { plugin.addCommand({ id: "skip-forward", name: "Skip Forward", - icon: "skip-forward" as IconType, + icon: "skip-forward", checkCallback: (checking) => { if (checking) { return !!plugin.api.podcast; @@ -129,7 +128,7 @@ export function registerCommands(plugin: PodNotes): void { plugin.addCommand({ id: "download-playing-episode", name: "Download Playing Episode", - icon: "download" as IconType, + icon: "download", checkCallback: (checking) => { if (checking) { return !!plugin.api.podcast; @@ -148,7 +147,7 @@ export function registerCommands(plugin: PodNotes): void { plugin.addCommand({ id: "reorder-queue", name: "Reorder Queue", - icon: "list-ordered" as IconType, + icon: "list-ordered", checkCallback: (checking) => { if (checking) { return get(queue).episodes.length > 1; @@ -161,7 +160,7 @@ export function registerCommands(plugin: PodNotes): void { plugin.addCommand({ id: "capture-timestamp", name: "Capture Timestamp", - icon: "clock" as IconType, + icon: "clock", // Keep this an editorCallback (not editorCheckCallback): an unconditional // editor command stays addable to the mobile editor toolbar / command // picker even before an episode is loaded, whereas a checkCallback that @@ -181,7 +180,7 @@ export function registerCommands(plugin: PodNotes): void { plugin.addCommand({ id: "capture-segment-10s", name: "Capture Last 10 Seconds", - icon: "scissors" as IconType, + icon: "scissors", editorCheckCallback: (checking, editor) => { if (checking) { return canCaptureTimestamp(); @@ -194,7 +193,7 @@ export function registerCommands(plugin: PodNotes): void { plugin.addCommand({ id: "capture-segment-20s", name: "Capture Last 20 Seconds", - icon: "scissors" as IconType, + icon: "scissors", editorCheckCallback: (checking, editor) => { if (checking) { return canCaptureTimestamp(); @@ -211,7 +210,7 @@ export function registerCommands(plugin: PodNotes): void { // "Create podcast feed note" command below (issue #163). The id is kept // for backward compatibility (hotkeys/API). name: "Create episode note", - icon: "file-plus" as IconType, + icon: "file-plus", checkCallback: (checking) => { if (checking) { return ( @@ -228,7 +227,7 @@ export function registerCommands(plugin: PodNotes): void { plugin.addCommand({ id: "create-podcast-feed-note", name: "Create podcast feed note", - icon: "file-plus" as IconType, + icon: "file-plus", checkCallback: (checking) => { const feeds = Object.values(get(savedFeeds)); const canCreate = @@ -255,7 +254,7 @@ export function registerCommands(plugin: PodNotes): void { plugin.addCommand({ id: "get-share-link-episode", name: "Copy universal episode link to clipboard", - icon: "share" as IconType, + icon: "share", checkCallback: (checking) => { if (checking) { return !!plugin.api.podcast; @@ -268,7 +267,7 @@ export function registerCommands(plugin: PodNotes): void { plugin.addCommand({ id: "podnotes-toggle-playback", name: "Toggle playback", - icon: "play" as IconType, + icon: "play", checkCallback: (checking) => { if (checking) { return !!plugin.api.podcast; @@ -281,7 +280,7 @@ export function registerCommands(plugin: PodNotes): void { plugin.addCommand({ id: "increase-playback-rate", name: "Increase playback rate", - icon: "gauge" as IconType, + icon: "gauge", checkCallback: (checking) => { if (checking) { return !!plugin.api.podcast; @@ -294,7 +293,7 @@ export function registerCommands(plugin: PodNotes): void { plugin.addCommand({ id: "decrease-playback-rate", name: "Decrease playback rate", - icon: "gauge" as IconType, + icon: "gauge", checkCallback: (checking) => { if (checking) { return !!plugin.api.podcast; @@ -307,7 +306,7 @@ export function registerCommands(plugin: PodNotes): void { plugin.addCommand({ id: "reset-playback-rate", name: "Reset playback rate", - icon: "rotate-ccw" as IconType, + icon: "rotate-ccw", checkCallback: (checking) => { if (checking) { return !!plugin.api.podcast; diff --git a/src/createPodcastNote.test.ts b/src/createPodcastNote.test.ts index 1b4f55f..48de3b6 100644 --- a/src/createPodcastNote.test.ts +++ b/src/createPodcastNote.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { TFile } from "obsidian"; +import { TFile, TFolder } from "obsidian"; import createPodcastNote, { getPodcastNote } from "./createPodcastNote"; import { plugin } from "./store"; import type { Episode } from "./types/Episode"; @@ -156,7 +156,17 @@ describe("getPodcastNote title fallbacks (#315)", () => { }); function fileAt(path: string): TFile { - return Object.assign(Object.create(TFile.prototype), { path }) as TFile; + return Object.assign(Object.create(TFile.prototype), { + path, + extension: path.split(".").pop() ?? "", + }) as TFile; + } + + function folderWith(files: TFile[]): TFolder { + return Object.assign(Object.create(TFolder.prototype), { + path: "podcasts", + children: files, + }) as TFolder; } it("opens a note written with the 2.16 filename sanitizer", () => { @@ -168,8 +178,11 @@ describe("getPodcastNote title fallbacks (#315)", () => { plugin.set({ app: { vault: { - getAbstractFileByPath: vi.fn((path: string) => files.get(path) ?? null), - getMarkdownFiles: vi.fn(() => [...files.values()]), + getAbstractFileByPath: vi.fn((path: string) => + path === "podcasts" + ? folderWith([...files.values()]) + : (files.get(path) ?? null), + ), }, }, settings: { @@ -190,8 +203,11 @@ describe("getPodcastNote title fallbacks (#315)", () => { plugin.set({ app: { vault: { - getAbstractFileByPath: vi.fn((path: string) => files.get(path) ?? null), - getMarkdownFiles: vi.fn(() => [...files.values()]), + getAbstractFileByPath: vi.fn((path: string) => + path === "podcasts" + ? folderWith([...files.values()]) + : (files.get(path) ?? null), + ), createFolder: vi.fn(async () => {}), create: vi.fn(async (path: string) => { createdFiles.push({ path }); @@ -229,10 +245,10 @@ describe("getPodcastNote title fallbacks (#315)", () => { plugin.set({ app: { vault: { - getAbstractFileByPath: vi.fn((path: string) => - path === part2Note.path ? part2Note : null, - ), - getMarkdownFiles: vi.fn(() => [part2Note]), + getAbstractFileByPath: vi.fn((path: string) => { + if (path === "podcasts") return folderWith([part2Note]); + return path === part2Note.path ? part2Note : null; + }), createFolder: vi.fn(async () => {}), create: vi.fn(async (path: string) => { createdFiles.push({ path }); @@ -271,10 +287,10 @@ describe("getPodcastNote title fallbacks (#315)", () => { plugin.set({ app: { vault: { - getAbstractFileByPath: vi.fn((path: string) => - path === androidNote.path ? androidNote : null, - ), - getMarkdownFiles: vi.fn(() => [androidNote]), + getAbstractFileByPath: vi.fn((path: string) => { + if (path === "podcasts") return folderWith([androidNote]); + return path === androidNote.path ? androidNote : null; + }), }, }, settings: { @@ -291,8 +307,9 @@ describe("getPodcastNote title fallbacks (#315)", () => { plugin.set({ app: { vault: { - getAbstractFileByPath: vi.fn(() => null), - getMarkdownFiles: vi.fn(() => [other]), + getAbstractFileByPath: vi.fn((path: string) => + path === "podcasts" ? folderWith([other]) : null, + ), }, }, settings: { diff --git a/src/createPodcastNote.ts b/src/createPodcastNote.ts index 3857a3a..5805a57 100644 --- a/src/createPodcastNote.ts +++ b/src/createPodcastNote.ts @@ -1,4 +1,4 @@ -import { Notice, TFile } from "obsidian"; +import { Notice, TFile, TFolder } from "obsidian"; import { FilePathTemplateEngine, legacyReplaceIllegalFileNameCharactersInString, @@ -124,15 +124,14 @@ export function getPodcastNote(episode: Episode): TFile | null { function findPodcastNoteByTitleOverlap(episode: Episode): TFile | null { const { vault } = get(plugin).app; - if (typeof vault.getMarkdownFiles !== "function") { - return null; - } - const expectedPath = getPodcastNotePath(episode); - const folder = parentFolder(expectedPath); - const siblings = vault - .getMarkdownFiles() - .filter((file) => parentFolder(file.path) === folder && file instanceof TFile); + const folderPath = parentFolder(expectedPath); + const folder = folderPath ? vault.getAbstractFileByPath(folderPath) : vault.getRoot(); + if (!(folder instanceof TFolder)) return null; + + const siblings = folder.children.filter( + (file): file is TFile => file instanceof TFile && file.extension === "md", + ); return ( findUniqueTitleMatch(getPodcastNoteTitleCandidates(episode), siblings, (file) => diff --git a/src/download/streaming.ts b/src/download/streaming.ts index 9f8f358..9d94661 100644 --- a/src/download/streaming.ts +++ b/src/download/streaming.ts @@ -1,4 +1,3 @@ -import { type DataAdapter } from "obsidian"; import { get } from "svelte/store"; import { plugin } from "../store"; import { assertFetchableUrl } from "../utility/assertFetchableUrl"; @@ -47,13 +46,6 @@ function tooLargeError(maxSize: number): Error { return new Error(`Download exceeds the maximum allowed size (${maxMb} MB). Aborting.`); } -// Obsidian's DataAdapter typings declare `appendBinary` as always present -// (public since API 1.13), but our minAppVersion predates its availability, -// so we re-declare it as optional and runtime-guard every call site. -export interface BinaryAppendAdapter extends Omit { - appendBinary?(path: string, data: ArrayBuffer): Promise; -} - export interface RangeProbe { firstChunk: ArrayBuffer; contentType: string; @@ -61,11 +53,8 @@ export interface RangeProbe { totalSize: number | null; } -// Keep the single unsafe cast here so the "where we step outside the types" -// boundary is greppable in one place: the cast widens `appendBinary` back to -// optional for Obsidian runtimes older than API 1.13. -export function appendableAdapter(): BinaryAppendAdapter { - return get(plugin).app.vault.adapter as unknown as BinaryAppendAdapter; +export function downloadAdapter() { + return get(plugin).app.vault.adapter; } function readHeader(headers: Record | undefined, name: string): string | undefined { @@ -159,7 +148,7 @@ export async function writeStreamedFile( maxSize: number = MAX_DOWNLOAD_SIZE, ): Promise { assertFetchableUrl(url); - const adapter = appendableAdapter(); + const adapter = downloadAdapter(); if (probe.firstChunk.byteLength > maxSize) { throw tooLargeError(maxSize); @@ -169,9 +158,9 @@ export async function writeStreamedFile( let written = probe.firstChunk.byteLength; onProgress?.(written, probe.totalSize); - // Server returned the whole body (ignored Range), or this adapter can't - // append: the first response already holds everything we can get. - if (!probe.supportsRange || typeof adapter.appendBinary !== "function") { + // Server returned the whole body after ignoring Range, so the first response + // already contains the complete download. + if (!probe.supportsRange) { return written; } @@ -259,7 +248,7 @@ export function isPartialPath(path: string): boolean { // memory win. (We never finalize by reading the temp back into memory and // re-writing it: that whole-file buffer is exactly the #113 OOM this path avoids.) export async function moveIntoPlace(tmpPath: string, filePath: string): Promise { - await appendableAdapter().rename(tmpPath, filePath); + await downloadAdapter().rename(tmpPath, filePath); } // Remove temp partials orphaned in `folder` by a previous download that was hard- @@ -272,7 +261,7 @@ export async function sweepStalePartials( folder: string, isActive: (path: string) => boolean, ): Promise { - const adapter = appendableAdapter(); + const adapter = downloadAdapter(); try { const listing = await adapter.list(folder); for (const entry of listing.files) { diff --git a/src/downloadEpisode.test.ts b/src/downloadEpisode.test.ts index 012b08f..cffb19f 100644 --- a/src/downloadEpisode.test.ts +++ b/src/downloadEpisode.test.ts @@ -3,6 +3,7 @@ import { get } from "svelte/store"; import { Notice, requestUrl, TFile } from "obsidian"; import downloadEpisodeWithNotice, { getEpisodeAudioBuffer, + removeDownloadedEpisode, safeDownloadBasename, safeDownloadFilePath, } from "./downloadEpisode"; @@ -32,7 +33,7 @@ function deferred() { return { promise, reject, resolve }; } -function setupVault({ streaming = false }: { streaming?: boolean } = {}) { +function setupVault() { const present = new Set(); // vault index (getAbstractFileByPath) const disk = new Set(); // raw filesystem (adapter.exists) const createdFolders: string[] = []; @@ -48,15 +49,17 @@ function setupVault({ streaming = false }: { streaming?: boolean } = {}) { createdFolders.push(path); }); const deleteFile = vi.fn(async (_file: unknown) => {}); + const trashFile = vi.fn(async (file: TFile) => { + present.delete(file.path); + disk.delete(file.path); + }); const removeFile = vi.fn(async (path: string) => { disk.delete(path); }); // Adapter writes land on "disk" but NOT in the vault index (present), // mirroring how getAbstractFileByPath can miss a freshly adapter-written file - // until the watcher reconciles it. The streaming download path additionally - // needs writeBinary + appendBinary; the legacy path uses neither and falls - // back to vault.createBinary. + // until the watcher reconciles it. const writeBinary = vi.fn(async (path: string, data: ArrayBuffer) => { disk.add(path); written.set(path, data.byteLength); @@ -88,25 +91,25 @@ function setupVault({ streaming = false }: { streaming?: boolean } = {}) { return { files, folders: [] as string[] }; }); - const adapter: Record = { + const adapter = { exists: async (path: string) => disk.has(path) || present.has(path), remove: removeFile, + writeBinary, + appendBinary, + rename, + list, }; - if (streaming) { - adapter.writeBinary = writeBinary; - adapter.appendBinary = appendBinary; - adapter.rename = rename; - adapter.list = list; - } const app = { vault: { - getAbstractFileByPath: (path: string) => (present.has(path) ? new TFile() : null), + getAbstractFileByPath: (path: string) => + present.has(path) ? Object.assign(new TFile(), { path }) : null, createBinary, createFolder, delete: deleteFile, adapter, }, + fileManager: { trashFile }, }; // The download code (and ensureFolderExists's default) read app from the // plugin store; the global `app` is set to the same mock too so any other @@ -121,6 +124,7 @@ function setupVault({ streaming = false }: { streaming?: boolean } = {}) { createBinary, createFolder, deleteFile, + trashFile, removeFile, writeBinary, appendBinary, @@ -130,6 +134,18 @@ function setupVault({ streaming = false }: { streaming?: boolean } = {}) { }; } +function expectStreamedTo( + vault: ReturnType, + filePath: string, + data: ArrayBuffer, +): void { + expect(vault.writeBinary).toHaveBeenCalledTimes(1); + const [temporaryPath, writtenData] = vault.writeBinary.mock.calls[0]; + expect(temporaryPath).toMatch(/\.podnotes-partial$/); + expect(writtenData).toBe(data); + expect(vault.rename).toHaveBeenCalledWith(temporaryPath, filePath); +} + function makeEpisode(overrides: Partial = {}): Episode { return { title: "My Title", @@ -156,7 +172,7 @@ afterEach(() => { describe("downloadEpisodeWithNotice (download command path)", () => { it("saves extensionless video downloads using the response content type", async () => { - const { createBinary, createdFolders } = setupVault(); + const vault = setupVault(); const buffer = bytes(0x00, 0x00, 0x00, 0x18); requestUrlMock.mockResolvedValue({ status: 200, @@ -178,13 +194,8 @@ describe("downloadEpisodeWithNotice (download command path)", () => { setTimeoutSpy.mockRestore(); } - expect(createdFolders).toEqual(["Podcasts", "Podcasts/Pod"]); - expect(createBinary).toHaveBeenCalledTimes(1); - const [writtenPath, writtenData] = createBinary.mock.calls[0]; - expect(writtenPath).toBe("Podcasts/Pod/Video Title.mp4"); - // The active non-streaming fallback must pass the response buffer straight - // through to createBinary without making another whole-file copy (#113). - expect(writtenData).toBe(buffer); + expect(vault.createdFolders).toEqual(["Podcasts", "Podcasts/Pod"]); + expectStreamedTo(vault, "Podcasts/Pod/Video Title.mp4", buffer); const recorded = get(downloadedEpisodes)["Pod"]?.[0]; expect(recorded).toMatchObject({ title: "Video Title", @@ -195,7 +206,7 @@ describe("downloadEpisodeWithNotice (download command path)", () => { }); it("rejects unsupported extensionless video downloads instead of saving them as mp3", async () => { - const { createBinary } = setupVault(); + const vault = setupVault(); const buffer = bytes(0x00, 0x01, 0x02, 0x03); requestUrlMock.mockResolvedValue({ status: 200, @@ -212,12 +223,12 @@ describe("downloadEpisodeWithNotice (download command path)", () => { "Not a playable media file", ); - expect(createBinary).not.toHaveBeenCalled(); + expect(vault.createBinary).not.toHaveBeenCalled(); expect(get(downloadedEpisodes)["Pod"]).toBeUndefined(); }); it("rejects blank-type extensionless video downloads instead of saving them as mp3", async () => { - const { createBinary } = setupVault(); + const vault = setupVault(); const buffer = bytes(0x00, 0x01, 0x02, 0x03); requestUrlMock.mockResolvedValue({ status: 200, @@ -234,12 +245,12 @@ describe("downloadEpisodeWithNotice (download command path)", () => { "Not a playable media file", ); - expect(createBinary).not.toHaveBeenCalled(); + expect(vault.createBinary).not.toHaveBeenCalled(); expect(get(downloadedEpisodes)["Pod"]).toBeUndefined(); }); it("saves video/ogg downloads with a video extension even when bytes have an Ogg signature", async () => { - const { createBinary } = setupVault(); + const vault = setupVault(); const buffer = bytes(0x4f, 0x67, 0x67, 0x53); requestUrlMock.mockResolvedValue({ status: 200, @@ -261,7 +272,7 @@ describe("downloadEpisodeWithNotice (download command path)", () => { setTimeoutSpy.mockRestore(); } - expect(createBinary).toHaveBeenCalledWith("Podcasts/Ogg Video Title.ogv", buffer); + expectStreamedTo(vault, "Podcasts/Ogg Video Title.ogv", buffer); const recorded = get(downloadedEpisodes)["Pod"]?.[0]; expect(recorded).toMatchObject({ title: "Ogg Video Title", @@ -272,7 +283,7 @@ describe("downloadEpisodeWithNotice (download command path)", () => { }); it("uses an unambiguous video URL extension before the Ogg audio signature", async () => { - const { createBinary } = setupVault(); + const vault = setupVault(); const buffer = bytes(0x4f, 0x67, 0x67, 0x53); requestUrlMock.mockResolvedValue({ status: 200, @@ -294,14 +305,14 @@ describe("downloadEpisodeWithNotice (download command path)", () => { setTimeoutSpy.mockRestore(); } - expect(createBinary).toHaveBeenCalledWith("Podcasts/Generic Ogg Video.ogv", buffer); + expectStreamedTo(vault, "Podcasts/Generic Ogg Video.ogv", buffer); const recorded = get(downloadedEpisodes)["Pod"]?.[0]; expect(recorded?.filePath).toBe("Podcasts/Generic Ogg Video.ogv"); expect(recorded?.mediaType).toBe("video"); }); it("saves audio/mp4 downloads with an audio extension even when the URL ends in mp4", async () => { - const { createBinary } = setupVault(); + const vault = setupVault(); const buffer = bytes(0x00, 0x00, 0x00, 0x18); requestUrlMock.mockResolvedValue({ status: 200, @@ -323,7 +334,7 @@ describe("downloadEpisodeWithNotice (download command path)", () => { setTimeoutSpy.mockRestore(); } - expect(createBinary).toHaveBeenCalledWith("Podcasts/Audio MP4 Title.m4a", buffer); + expectStreamedTo(vault, "Podcasts/Audio MP4 Title.m4a", buffer); const recorded = get(downloadedEpisodes)["Pod"]?.[0]; expect(recorded).toMatchObject({ title: "Audio MP4 Title", @@ -334,7 +345,8 @@ describe("downloadEpisodeWithNotice (download command path)", () => { }); it("ignores a foreign file at the provisional URL-extension path when the sniffed final path is free (Codex #290)", async () => { - const { createBinary, present } = setupVault(); + const vault = setupVault(); + const { present } = vault; // A different episode's file occupies the path the URL extension implies // (.mp4). The response sniffs to .m4a, so the real destination is free and // the fast-path check must fall through to the probe instead of throwing a @@ -361,14 +373,14 @@ describe("downloadEpisodeWithNotice (download command path)", () => { setTimeoutSpy.mockRestore(); } - expect(createBinary).toHaveBeenCalledWith("Podcasts/Audio MP4 Title.m4a", buffer); + expectStreamedTo(vault, "Podcasts/Audio MP4 Title.m4a", buffer); expect(get(downloadedEpisodes)["Pod"]?.[0]).toMatchObject({ filePath: "Podcasts/Audio MP4 Title.m4a", }); }); it("saves an audio/mp4 download with a generic ISO-BMFF brand as m4a, not mp4 (Codex #213)", async () => { - const { createBinary } = setupVault(); + const vault = setupVault(); // Real ISO-BMFF: 4-byte box size, 'ftyp', then a generic 'mp42' major brand. // detectAudioFileExtension returns "mp4" for this; for an audio download it // must still be saved as m4a so it isn't treated as an ambiguous container. @@ -406,11 +418,11 @@ describe("downloadEpisodeWithNotice (download command path)", () => { setTimeoutSpy.mockRestore(); } - expect(createBinary).toHaveBeenCalledWith("Podcasts/Brandy MP4 Title.m4a", buffer); + expectStreamedTo(vault, "Podcasts/Brandy MP4 Title.m4a", buffer); }); it("preserves audio/webm downloads as audio WebM files", async () => { - const { createBinary } = setupVault(); + const vault = setupVault(); const buffer = bytes(0x00, 0x00, 0x00, 0x18); requestUrlMock.mockResolvedValue({ status: 200, @@ -432,7 +444,7 @@ describe("downloadEpisodeWithNotice (download command path)", () => { setTimeoutSpy.mockRestore(); } - expect(createBinary).toHaveBeenCalledWith("Podcasts/Audio WebM Title.webm", buffer); + expectStreamedTo(vault, "Podcasts/Audio WebM Title.webm", buffer); const recorded = get(downloadedEpisodes)["Pod"]?.[0]; expect(recorded).toMatchObject({ title: "Audio WebM Title", @@ -509,7 +521,7 @@ describe("downloadEpisodeWithNotice (download command path)", () => { }); it("rejects an HTML error page served at a .mp3 URL instead of saving it (#DL-07)", async () => { - const { createBinary } = setupVault(); + const vault = setupVault(); requestUrlMock.mockResolvedValue({ status: 200, headers: { "content-type": "text/html; charset=utf-8" }, @@ -532,7 +544,7 @@ describe("downloadEpisodeWithNotice (download command path)", () => { setTimeoutSpy.mockRestore(); } - expect(createBinary).not.toHaveBeenCalled(); + expect(vault.createBinary).not.toHaveBeenCalled(); expect(get(downloadedEpisodes)["Pod"]).toBeUndefined(); }); @@ -583,7 +595,7 @@ describe("downloadEpisodeWithNotice (download command path)", () => { // octet-stream is genuinely ambiguous and is intentionally NOT rejected up // front — real CDNs serve media this way, so it falls through to the // extension/signature heuristic. - const { createBinary } = setupVault(); + const vault = setupVault(); const buffer = bytes(0x49, 0x44, 0x33, 0x01); requestUrlMock.mockResolvedValue({ status: 200, @@ -603,7 +615,7 @@ describe("downloadEpisodeWithNotice (download command path)", () => { setTimeoutSpy.mockRestore(); } - expect(createBinary).toHaveBeenCalledWith("Podcasts/My Title.mp3", buffer); + expectStreamedTo(vault, "Podcasts/My Title.mp3", buffer); }); }); @@ -631,6 +643,24 @@ describe("safeDownloadBasename (#183)", () => { }); }); +describe("removeDownloadedEpisode", () => { + it("trashes the indexed download through FileManager", async () => { + const vault = setupVault(); + const episode = makeEpisode(); + const filePath = "Podcasts/My Title.mp3"; + vault.present.add(filePath); + vault.disk.add(filePath); + downloadedEpisodes.addEpisode(episode, filePath, 4); + + await removeDownloadedEpisode(episode); + + expect(vault.trashFile).toHaveBeenCalledOnce(); + expect(vault.trashFile.mock.calls[0][0].path).toBe(filePath); + expect(vault.removeFile).not.toHaveBeenCalled(); + expect(get(downloadedEpisodes)[episode.podcastName]).toEqual([]); + }); +}); + describe("safeDownloadFilePath (#22)", () => { it("caps a long title's file name while keeping the audio extension", () => { const result = safeDownloadFilePath( @@ -1210,7 +1240,7 @@ describe("downloadEpisodeWithNotice (streaming range path)", () => { } it("streams a ranged (206) download in chunks via writeBinary + appendBinary", async () => { - const v = setupVault({ streaming: true }); + const v = setupVault(); requestUrlMock .mockResolvedValueOnce( rangeResponse(206, [1, 2, 3, 4, 5, 6, 7, 8], { @@ -1235,7 +1265,7 @@ describe("downloadEpisodeWithNotice (streaming range path)", () => { }); it("streams to a dot-prefixed temp the watchers don't see, then renames it into place", async () => { - const v = setupVault({ streaming: true }); + const v = setupVault(); requestUrlMock .mockResolvedValueOnce( rangeResponse(206, [1, 2, 3, 4, 5, 6, 7, 8], { @@ -1269,7 +1299,7 @@ describe("downloadEpisodeWithNotice (streaming range path)", () => { }); it("cleans up the temp (not the final path) when the move into place fails", async () => { - const v = setupVault({ streaming: true }); + const v = setupVault(); v.rename.mockRejectedValueOnce(new Error("rename boom")); requestUrlMock .mockResolvedValueOnce( @@ -1298,7 +1328,7 @@ describe("downloadEpisodeWithNotice (streaming range path)", () => { }); it("sweeps an orphaned partial from a prior killed download before streaming", async () => { - const v = setupVault({ streaming: true }); + const v = setupVault(); // A partial left behind in the target folder by a download that was killed // mid-stream (the OOM crash this fix addresses). v.disk.add("Podcasts/.My Title.mp3.dead-orphan.podnotes-partial"); @@ -1326,7 +1356,7 @@ describe("downloadEpisodeWithNotice (streaming range path)", () => { }); it("writes the whole body in one shot when the server ignores Range (200)", async () => { - const v = setupVault({ streaming: true }); + const v = setupVault(); requestUrlMock.mockResolvedValue( rangeResponse(200, [0xff, 0xfb, 0x90, 0x00, 1, 2, 3, 4], { "content-type": "audio/mpeg", @@ -1341,7 +1371,7 @@ describe("downloadEpisodeWithNotice (streaming range path)", () => { }); it("never reuses a sequential shared destination for a different episode", async () => { - const v = setupVault({ streaming: true }); + const v = setupVault(); const template = "Podcasts/shared"; const episodeA = makeEpisode({ title: "Episode A", podcastName: "Podcast A" }); const episodeB = makeEpisode({ title: "Episode B", podcastName: "Podcast B" }); @@ -1405,7 +1435,7 @@ describe("downloadEpisodeWithNotice (streaming range path)", () => { }); it("removes the partial file via the adapter (not yet vault-indexed) and rethrows on a mid-stream failure", async () => { - const v = setupVault({ streaming: true }); + const v = setupVault(); requestUrlMock .mockResolvedValueOnce( rangeResponse(206, [1, 2, 3, 4, 5, 6, 7, 8], { diff --git a/src/downloadEpisode.ts b/src/downloadEpisode.ts index 812ec34..6fea28f 100644 --- a/src/downloadEpisode.ts +++ b/src/downloadEpisode.ts @@ -23,7 +23,6 @@ import { isSameMediaSource, } from "./utility/mediaType"; import { - appendableAdapter, moveIntoPlace, partialPathFor, probeAndFetchFirstChunk, @@ -52,9 +51,14 @@ interface DownloadedFile { byteLength: number; } -// Whole-file download: only the legacy fallback (adapters without binary append) -// and transcription's getEpisodeAudioBuffer still need the entire buffer at once. -// The Download command streams instead — see downloadEpisodeToDisk. +function downloadRequestError(error: unknown): Error { + return error instanceof NetworkError + ? new Error(`Failed to download episode: ${error.message}`) + : new Error("Failed to download episode."); +} + +// Transcription needs the entire audio buffer at once. Episode downloads use the +// bounded streaming path instead - see downloadEpisodeToDisk. async function downloadFile(url: string): Promise { let response; try { @@ -65,10 +69,7 @@ async function downloadFile(url: string): Promise { acceptedStatuses: [200], }); } catch (error: unknown) { - if (error instanceof NetworkError) { - throw new Error(`Failed to download episode: ${error.message}`); - } - throw new Error("Failed to download episode."); + throw downloadRequestError(error); } const data = response.arrayBuffer; @@ -158,10 +159,8 @@ function reusableExistingDownloadSize(episode: Episode, filePath: string): numbe return downloadedEpisodes.getEpisode(episode)?.size; } -// Download an episode to a vault file with bounded memory. Streams via Range -// chunks when the adapter supports binary append; otherwise falls back to the -// legacy whole-file buffer (so a non-appendable adapter is never truncated). -// Returns the on-disk path. +// Download an episode to a vault file with bounded memory using the binary append +// API guaranteed by the plugin's minimum Obsidian version. Returns the on-disk path. async function startDownloadEpisodeToDisk( episode: Episode, downloadPathTemplate: string, @@ -181,28 +180,12 @@ async function startDownloadEpisodeToDisk( } } - const adapter = appendableAdapter(); - const canStream = - typeof adapter.writeBinary === "function" && typeof adapter.appendBinary === "function"; - - if (!canStream) { - const { data, contentType } = await downloadFile(episode.streamUrl); - const { extension, filePath } = resolveDownloadTarget( - episode, - downloadPathTemplate, - data, - contentType, - ); - const existingSize = reusableExistingDownloadSize(episode, filePath); - if (existingSize !== undefined) { - downloadedEpisodes.addEpisode(episode, filePath, existingSize); - return filePath; - } - await createEpisodeFile({ episode, downloadPathTemplate, data, extension }); - return filePath; + let probe; + try { + probe = await probeAndFetchFirstChunk(episode.streamUrl); + } catch (error) { + throw downloadRequestError(error); } - - const probe = await probeAndFetchFirstChunk(episode.streamUrl); const { filePath } = resolveDownloadTarget( episode, downloadPathTemplate, @@ -304,7 +287,7 @@ function createNoticeDoc(title: string) { const container = doc.createDiv(); container.setCssStyles({ width: "100%", display: "flex" }); - const titleEl = container.createEl("span", { text: title }); + const titleEl = container.createSpan({ text: title }); titleEl.setCssStyles({ textAlign: "center", fontWeight: "bold", @@ -368,35 +351,6 @@ export function safeDownloadFilePath( return enforceMaxPathLength(`${basename}.${extension}`, `.${extension}`); } -async function createEpisodeFile({ - episode, - downloadPathTemplate, - data, - extension, -}: { - episode: Episode; - downloadPathTemplate: string; - data: ArrayBuffer; - extension: string; -}) { - const { app } = get(plugin); - const filePath = safeDownloadFilePath(downloadPathTemplate, episode, extension); - - // `createBinary` throws if a parent folder is missing, which previously left - // users with a templated path like `podcast/{{podcast}}/{{title}}` unable to - // download anything (issue #86). Create the folders first. - const folderPath = filePath.split("/").slice(0, -1).join("/"); - await ensureFolderExists(folderPath); - - try { - await app.vault.createBinary(filePath, data); - } catch (error: unknown) { - throw new Error(`Failed to write file "${filePath}": ${getErrorMessage(error)}`); - } - - downloadedEpisodes.addEpisode(episode, filePath, data.byteLength); -} - /** * Remove a downloaded episode: drop it from the offline set and delete its * backing vault file. This composes the pure store removal with the file I/O so @@ -419,7 +373,7 @@ async function deleteEpisodeFile(filePath: string): Promise { try { const file = app.vault.getAbstractFileByPath(filePath); if (file instanceof TFile) { - await app.vault.delete(file); + await app.fileManager.trashFile(file); return; } // Streamed downloads are written through the adapter, so the vault index diff --git a/src/global.d.ts b/src/global.d.ts index 721cc04..273465f 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -39,7 +39,7 @@ declare global { } declare module "*.svelte" { - import type { ComponentType } from "svelte"; - const component: ComponentType; + import type { Component } from "svelte"; + const component: Component; export default component; } diff --git a/src/main.ts b/src/main.ts index 4277ebb..57dd74f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -30,7 +30,6 @@ import { createPodcastNoteFileIfNotExists, getPodcastNote } from "./createPodcas import type PartialAppExtension from "./global"; import podNotesURIHandler from "./URIHandler"; import getContextMenuHandler from "./getContextMenuHandler"; -import type { IconType } from "./types/IconType"; import { TranscriptionService } from "./services/TranscriptionService"; import { type Unsubscriber } from "svelte/store"; import { normalizePlaybackRate } from "./utility/playbackRate"; @@ -44,6 +43,7 @@ import { CredentialRepository } from "./services/CredentialRepository"; import { FeedUrlRepository } from "./services/FeedUrlRepository"; import { migratePrivateFeedUrls, scrubMigratedEpisodeUrls } from "./services/privateFeeds"; import { evictCachedFeedUrls } from "./services/FeedCacheService"; +import { toError } from "./utility/toError"; type MediaSessionActionName = | "previoustrack" @@ -183,7 +183,7 @@ export default class PodNotes extends Plugin implements IPodNotes { // sidebar header can overflow and hide the view's tab icon (the original // report in #55), but the ribbon is always reachable, so users can always // reopen PodNotes. - this.addRibbonIcon("podcast" as IconType, "Show PodNotes", () => { + this.addRibbonIcon("podcast", "Show PodNotes", () => { void this.activateView(); }); @@ -547,7 +547,9 @@ export default class PodNotes extends Plugin implements IPodNotes { this.pendingSave = this.cloneSettings(); } catch (error) { console.error("PodNotes: failed to snapshot settings", error); - const failure = Promise.reject(error); + const failure = Promise.reject( + toError(error, "PodNotes could not snapshot settings before saving."), + ); void failure.catch(() => undefined); return failure; } diff --git a/src/persistence/collectionCodecs.ts b/src/persistence/collectionCodecs.ts index f4c9405..e2e47f2 100644 --- a/src/persistence/collectionCodecs.ts +++ b/src/persistence/collectionCodecs.ts @@ -73,7 +73,7 @@ export function decodePodNotes(value: unknown, warnings: Set): Record encodeEpisode(episode)); const currentEpisode = value.currentEpisode ? encodeEpisode(value.currentEpisode) : undefined; if (currentEpisode) encoded.currentEpisode = currentEpisode; diff --git a/src/persistence/podNotesData.ts b/src/persistence/podNotesData.ts index ad3a7fb..721dfe5 100644 --- a/src/persistence/podNotesData.ts +++ b/src/persistence/podNotesData.ts @@ -131,7 +131,7 @@ export function encodePodNotesData( downloadedEpisodes: mapRecord(validated.downloadedEpisodes, (episodes) => episodes.map((episode) => encodeEpisode(episode)), ), - } as PersistedPodNotesDataV2; + }; } function readSchemaVersion(root: UnknownRecord): PodNotesDataSchemaVersion { diff --git a/src/services/TranscriptionService.ts b/src/services/TranscriptionService.ts index f2022a7..d2ec146 100644 --- a/src/services/TranscriptionService.ts +++ b/src/services/TranscriptionService.ts @@ -4,6 +4,7 @@ import type PodNotes from "../main"; import { getEpisodeAudioBuffer } from "../downloadEpisode"; import { TranscriptTemplateEngine } from "../TemplateEngine"; import { ensureFolderExists } from "../utility/ensureFolderExists"; +import { toError } from "../utility/toError"; import type { Episode } from "src/types/Episode"; import { getEpisodeTranscriptPath } from "src/utility/getEpisodeTranscriptPath"; import { createChunkFiles, getMimeType } from "./audioChunker"; @@ -451,7 +452,7 @@ export class TranscriptionService { const onAbort = () => { window.clearTimeout(timeout); signal.removeEventListener("abort", onAbort); - reject(this.getAbortReason()); + reject(toError(this.getAbortReason(), "PodNotes transcription was aborted.")); }; signal.addEventListener("abort", onAbort, { once: true }); @@ -470,7 +471,7 @@ export class TranscriptionService { if (settled) return; settled = true; cleanup(); - reject(this.getAbortReason()); + reject(toError(this.getAbortReason(), "PodNotes transcription was aborted.")); }; signal.addEventListener("abort", onAbort, { once: true }); @@ -493,7 +494,7 @@ export class TranscriptionService { } settled = true; cleanup(); - reject(error); + reject(toError(error, "PodNotes transcription failed.")); }, ); if (signal.aborted) onAbort(); diff --git a/src/services/diarization/openaiProvider.ts b/src/services/diarization/openaiProvider.ts index f6c7ed1..a1d4817 100644 --- a/src/services/diarization/openaiProvider.ts +++ b/src/services/diarization/openaiProvider.ts @@ -1,4 +1,5 @@ import type { OpenAI } from "openai"; +import { toError } from "../../utility/toError"; import { type DiarizedSegment, OPENAI_DIARIZE_MODEL } from "./types"; import { parseOpenAIDiarizedSegments } from "./segments"; @@ -108,9 +109,7 @@ function waitForRetry(delayMs: number, signal: AbortSignal): Promise { const onAbort = () => { window.clearTimeout(timeout); signal.removeEventListener("abort", onAbort); - reject( - signal.reason ?? new DOMException("OpenAI diarization was aborted.", "AbortError"), - ); + reject(toError(signal.reason, "OpenAI diarization was aborted.")); }; signal.addEventListener("abort", onAbort, { once: true }); diff --git a/src/settingsTransfer.ts b/src/settingsTransfer.ts index 4ef26a3..1cfd819 100644 --- a/src/settingsTransfer.ts +++ b/src/settingsTransfer.ts @@ -167,7 +167,7 @@ export function serializeSettings( version: SETTINGS_EXPORT_VERSION, pluginVersion, exportedAt: nowISO, - settings: out as Partial, + settings: out, ...(Object.keys(secrets).length > 0 ? { secrets } : {}), }; } @@ -335,7 +335,7 @@ function sanitizeImportedSettings(source: Record): Partial; + return out; } function extractLegacySecrets(source: Record): CredentialValues { @@ -363,7 +363,7 @@ function parseSecretsPayload(value: unknown): { values: CredentialValues } | { e } } - return { values: normalizeSecrets(value as CredentialValues) }; + return { values: normalizeSecrets(value) }; } function normalizeSecrets(values: CredentialValues): CredentialValues { diff --git a/src/store/index.ts b/src/store/index.ts index 319d0c9..bbd786b 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -353,7 +353,7 @@ export const localFiles = (() => { * Mirrors downloadedEpisodes into the Local Files playlist (issue #176). * * downloadedEpisodes is the authoritative set of offline-available episodes: - * downloads (createEpisodeFile) and manual local files (getContextMenuHandler) + * downloads (downloadEpisodeToDisk) and manual local files (getContextMenuHandler) * both write there. Entries are copied verbatim so filePath/size and the real * podcastName survive — playback resolves the local file from downloadedEpisodes * keyed by podcastName::title, so coercing podcastName would break it. diff --git a/src/ui/settings/PodNotesSettingsTab.import.test.ts b/src/ui/settings/PodNotesSettingsTab.import.test.ts index bfc3d65..b4650a3 100644 --- a/src/ui/settings/PodNotesSettingsTab.import.test.ts +++ b/src/ui/settings/PodNotesSettingsTab.import.test.ts @@ -29,7 +29,7 @@ describe("PodNotesSettingsTab settings import", () => { saveSettingsStrict: vi.fn().mockResolvedValue(undefined), } as unknown as PodNotes; const tab = new PodNotesSettingsTab({} as App, plugin); - vi.spyOn(tab, "display").mockImplementation(() => {}); + vi.spyOn(tab, "update").mockImplementation(() => {}); await ( tab as unknown as { @@ -58,7 +58,7 @@ describe("PodNotesSettingsTab settings import", () => { .mockResolvedValueOnce(undefined), } as unknown as PodNotes; const tab = new PodNotesSettingsTab({} as App, plugin); - const display = vi.spyOn(tab, "display").mockImplementation(() => {}); + const update = vi.spyOn(tab, "update").mockImplementation(() => {}); const notice = vi.spyOn(obsidian, "Notice"); vi.spyOn(console, "error").mockImplementation(() => undefined); @@ -72,7 +72,7 @@ describe("PodNotesSettingsTab settings import", () => { expect(plugin.settings).toBe(previous); expect(get(playbackRate)).toBe(1); - expect(display).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); expect(notice).toHaveBeenCalledWith( "Could not import PodNotes settings. The failed import was rolled back without overwriting newer changes.", 10000, @@ -95,7 +95,7 @@ describe("PodNotesSettingsTab settings import", () => { const enabledInput = tab.containerEl.createEl("input"); const disabledButton = tab.containerEl.createEl("button"); disabledButton.disabled = true; - vi.spyOn(tab, "display").mockImplementation(() => {}); + vi.spyOn(tab, "update").mockImplementation(() => {}); const importing = ( tab as unknown as { @@ -129,7 +129,7 @@ describe("PodNotesSettingsTab settings import", () => { saveSettingsStrict: vi.fn().mockResolvedValue(undefined), } as unknown as PodNotes; const tab = new PodNotesSettingsTab({} as App, plugin); - vi.spyOn(tab, "display").mockImplementation(() => {}); + vi.spyOn(tab, "update").mockImplementation(() => {}); await ( tab as unknown as { @@ -432,7 +432,7 @@ describe("PodNotesSettingsTab settings import", () => { saveData, }); const tab = new PodNotesSettingsTab({} as App, plugin); - vi.spyOn(tab, "display").mockImplementation(() => {}); + vi.spyOn(tab, "update").mockImplementation(() => {}); vi.spyOn(console, "error").mockImplementation(() => undefined); let ui = "shared-newer"; const secret = { @@ -514,7 +514,7 @@ describe("PodNotesSettingsTab settings import", () => { const unsubscribe = bindStoresToSettings(plugin); Object.assign(plugin, { isReady: true }); const tab = new PodNotesSettingsTab({} as App, plugin); - vi.spyOn(tab, "display").mockImplementation(() => {}); + vi.spyOn(tab, "update").mockImplementation(() => {}); vi.spyOn(console, "error").mockImplementation(() => undefined); try { diff --git a/src/ui/settings/PodNotesSettingsTab.ts b/src/ui/settings/PodNotesSettingsTab.ts index b2d5583..3057cfc 100644 --- a/src/ui/settings/PodNotesSettingsTab.ts +++ b/src/ui/settings/PodNotesSettingsTab.ts @@ -7,6 +7,7 @@ import { PluginSettingTab, SecretComponent, Setting, + type SettingDefinitionItem, } from "obsidian"; import type PodNotes from "../../main"; import PodcastQueryGrid from "./PodcastQueryGrid.svelte"; @@ -66,6 +67,38 @@ interface SecretReferenceSaveResult { type ImportMutationResult = "failed" | "applied" | "complete"; +const SETTINGS_SEARCH_ALIASES = [ + "Search podcasts", + "Playlists", + "Keep a queue", + "Latest episodes per podcast", + "Default playback rate", + "Default volume", + "Skip backward length", + "Skip forward length", + "Capture timestamp format", + "Timestamp offset", + "Note creation file path", + "Note creation template", + "Feed note file path", + "Feed note template", + "Episode download path", + "Cache podcast feeds", + "Cache duration", + "Clear cached feeds", + "Import OPML", + "Export OPML", + "Import preferences", + "Export preferences", + "OpenAI API key", + "Transcript file path", + "Transcript template", + "Speaker diarization", + "Diarization provider", + "Deepgram API key", + "Speaker label format", +]; + function isPlainRecord(value: unknown): value is Record { if (typeof value !== "object" || value === null || Array.isArray(value)) return false; const prototype = Object.getPrototypeOf(value); @@ -185,9 +218,9 @@ function stackSettingVertically(setting: Setting): void { * content. Shared by the path/template demo fields that echo what the configured * template resolves to. */ -function renderMarkdownPreview(markdown: string, el: HTMLElement): void { +function renderMarkdownPreview(app: App, markdown: string, el: HTMLElement): void { el.empty(); - void MarkdownRenderer.renderMarkdown(markdown, el, "", new Component()); + void MarkdownRenderer.render(app, markdown, el, "", new Component()); } export class PodNotesSettingsTab extends PluginSettingTab { @@ -211,13 +244,26 @@ export class PodNotesSettingsTab extends PluginSettingTab { this.settingsTab = this; } - override display(): void { - const { containerEl } = this; + override getSettingDefinitions(): SettingDefinitionItem[] { + return [ + { + name: "Podcast preferences", + aliases: SETTINGS_SEARCH_ALIASES, + render: (setting) => { + const { settingEl } = setting; + settingEl.empty(); + settingEl.classList.remove("setting-item"); + this.renderSettings(settingEl); + return () => this.unmountSettingsComponents(); + }, + }, + ]; + } + private renderSettings(containerEl: HTMLElement): void { + this.unmountSettingsComponents(); containerEl.empty(); - new Setting(containerEl).setName("PodNotes").setHeading(); - const settingsContainer = containerEl.createDiv(); settingsContainer.classList.add("settings-container"); @@ -255,6 +301,10 @@ export class PodNotesSettingsTab extends PluginSettingTab { } override hide(): void { + this.unmountSettingsComponents(); + } + + private unmountSettingsComponents(): void { if (this.podcastQueryGrid) { void unmount(this.podcastQueryGrid); this.podcastQueryGrid = null; @@ -327,8 +377,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { .onChange((value) => { this.plugin.settings.defaultPlaybackRate = value; void this.plugin.saveSettings(); - }) - .setDynamicTooltip(), + }), ); } @@ -343,8 +392,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { .onChange((value) => { this.plugin.settings.defaultVolume = value; void this.plugin.saveSettings(); - }) - .setDynamicTooltip(), + }), ); } @@ -381,7 +429,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { private addNoteSettings(settingsContainer: HTMLDivElement) { const container = settingsContainer.createDiv(); - new Setting(container).setName("Note settings").setHeading(); + new Setting(container).setName("Episode notes").setHeading(); const timestampSetting = new Setting(container) .setName("Capture timestamp format") @@ -411,7 +459,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { } const demoVal = TimestampTemplateEngine(value); - renderMarkdownPreview(demoVal, timestampFormatDemoEl); + renderMarkdownPreview(this.app, demoVal, timestampFormatDemoEl); }; new Setting(container) @@ -449,7 +497,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { void this.plugin.saveSettings(); const demoVal = FilePathTemplateEngine(value, randomEpisode); - renderMarkdownPreview(demoVal, noteCreationFilePathDemoEl); + renderMarkdownPreview(this.app, demoVal, noteCreationFilePathDemoEl); }); textComponent.inputEl.setCssStyles({ width: "100%" }); }); @@ -493,7 +541,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { private addFeedNoteSettings(settingsContainer: HTMLDivElement) { const container = settingsContainer.createDiv(); - new Setting(container).setName("Podcast feed note settings").setHeading(); + new Setting(container).setName("Feed notes").setHeading(); const desc = container.createEl("p", { text: @@ -529,7 +577,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { const renderFeedPathDemo = (value: string) => { const demoVal = FeedFilePathTemplateEngine(value, randomFeed); - renderMarkdownPreview(demoVal, feedNotePathDemoEl); + renderMarkdownPreview(this.app, demoVal, feedNotePathDemoEl); }; renderFeedPathDemo(this.plugin.settings.feedNote.path); @@ -550,7 +598,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { } private addDownloadSettings(container: HTMLDivElement) { - new Setting(container).setName("Download settings").setHeading(); + new Setting(container).setName("Downloads").setHeading(); const randomEpisode = getRandomEpisode(); @@ -582,7 +630,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { // empty path resolves to ".mp3" at the vault root (#183). Warn inline. const refreshDownloadPathHints = (value: string) => { const demoVal = DownloadPathTemplateEngine(value, randomEpisode); - renderMarkdownPreview(`${demoVal}.mp3`, downloadFilePathDemoEl); + renderMarkdownPreview(this.app, `${demoVal}.mp3`, downloadFilePathDemoEl); // Match only the forms DownloadPathTemplateEngine actually resolves — // {{title}} or {{title:...}}. A looser test (e.g. \s* after {{, or \b) @@ -617,7 +665,6 @@ export class PodNotesSettingsTab extends PluginSettingTab { slider .setLimits(1, 24, 1) .setValue(this.plugin.settings.feedCache.ttlHours) - .setDynamicTooltip() .onChange(async (value) => { this.plugin.settings.feedCache.ttlHours = value; await this.plugin.saveSettings(); @@ -696,7 +743,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { } private addSettingsTransferControls(containerEl: HTMLElement): void { - new Setting(containerEl).setName("Settings & templates").setHeading(); + new Setting(containerEl).setName("Preferences & templates").setHeading(); new Setting(containerEl) .setName("Import settings") @@ -755,11 +802,10 @@ export class PodNotesSettingsTab extends PluginSettingTab { const tooLargeMessage = options.tooLargeMessage ?? "That file is too large to be a PodNotes settings file."; - const fileInput = activeDocument.createElement("input"); + const fileInput = activeDocument.body.createEl("input"); fileInput.type = "file"; fileInput.accept = accept; fileInput.setCssStyles({ display: "none" }); - activeDocument.body.appendChild(fileInput); // The native picker firing "cancel" (no selection) never triggers // "change", so clean up the orphaned input then too. @@ -894,7 +940,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { if (result === "failed") return; await this.waitForSettingsMutationLaneToDrain(); - this.display(); + this.update(); if (result === "complete") new Notice("Imported PodNotes settings."); } finally { await this.waitForSettingsMutationLaneToDrain(); @@ -1138,7 +1184,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { } private addTranscriptSettings(container: HTMLDivElement) { - new Setting(container).setName("Transcript settings").setHeading(); + new Setting(container).setName("Transcripts").setHeading(); const randomEpisode = getRandomEpisode(); @@ -1361,7 +1407,7 @@ class ConfirmModal extends Modal { .addButton((button) => button .setButtonText(this.confirmText) - .setWarning() + .setDestructive() .onClick(() => { this.close(); this.onConfirm(); diff --git a/src/utility/networkRequest.ts b/src/utility/networkRequest.ts index c7b0038..db35c49 100644 --- a/src/utility/networkRequest.ts +++ b/src/utility/networkRequest.ts @@ -6,10 +6,10 @@ export const MAX_NETWORK_TIMEOUT_MS = 2_147_483_647; export const DEFAULT_MAX_REQUEST_BODY_BYTES = 16 * 1024 * 1024; export const DEFAULT_MAX_RESPONSE_BYTES = 32 * 1024 * 1024; -const ARRAY_BUFFER_BYTE_LENGTH_GETTER = Object.getOwnPropertyDescriptor( +const ARRAY_BUFFER_BYTE_LENGTH_DESCRIPTOR = Object.getOwnPropertyDescriptor( ArrayBuffer.prototype, "byteLength", -)?.get; +); export type NetworkErrorCode = | "invalid-options" @@ -213,12 +213,12 @@ function arrayBufferByteLength(value: unknown): number | undefined { if ( typeof value !== "object" || value === null || - ARRAY_BUFFER_BYTE_LENGTH_GETTER === undefined + ARRAY_BUFFER_BYTE_LENGTH_DESCRIPTOR?.get === undefined ) { return undefined; } try { - const byteLength = Reflect.apply(ARRAY_BUFFER_BYTE_LENGTH_GETTER, value, []); + const byteLength = ARRAY_BUFFER_BYTE_LENGTH_DESCRIPTOR.get.call(value); return Number.isSafeInteger(byteLength) && byteLength >= 0 ? byteLength : undefined; } catch { // Reject views, SharedArrayBuffer, proxies, and objects spoofing toStringTag. diff --git a/src/utility/toError.ts b/src/utility/toError.ts new file mode 100644 index 0000000..491f2a1 --- /dev/null +++ b/src/utility/toError.ts @@ -0,0 +1,3 @@ +export function toError(reason: unknown, fallbackMessage: string): Error { + return reason instanceof Error ? reason : new Error(fallbackMessage); +} diff --git a/tests/e2e/marketplace-review-runtime.test.ts b/tests/e2e/marketplace-review-runtime.test.ts new file mode 100644 index 0000000..5ba23ac --- /dev/null +++ b/tests/e2e/marketplace-review-runtime.test.ts @@ -0,0 +1,803 @@ +import type { PluginHandle } from "obsidian-e2e"; +import { describe, expect, test } from "vitest"; +import type { Episode } from "../../src/types/Episode"; +import type { IPodNotesSettings } from "../../src/types/IPodNotesSettings"; +import { + createPodNotesE2EHarness, + evalJsonAsync, + openPodNotesView, + PLUGIN_ID, + RELOAD_OPTIONS, + WAIT_OPTS, + waitForPodNotesReady, +} from "./harness"; + +type PodNotesData = Partial; + +const AUDIO_BYTES = Uint8Array.from([ + 0xff, 0xfb, 0x90, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, +]); +const DOWNLOAD_URL = "https://e2e.podnotes.test/episode.mp3"; +const getContext = createPodNotesE2EHarness("marketplace-review-runtime"); + +describe("marketplace review runtime paths", () => { + test("renders searchable declarative settings and completes export/import with persistence", async () => { + const { obsidian, plugin, sandbox } = getContext(); + const exportName = "marketplace-settings-export.json"; + const exportPath = sandbox.path(exportName); + + try { + await openSettings(obsidian); + + const initial = await obsidian.dev.evalJson<{ + definitionCount: number; + headings: string[]; + indexedAliases: number; + renderedControls: number; + }>(` + (() => { + const tab = app.setting.pluginTabs.find((candidate) => candidate.id === ${JSON.stringify(PLUGIN_ID)}); + const definitions = tab?.getSettingDefinitions?.() ?? []; + return { + definitionCount: definitions.length, + headings: Array.from(document.querySelectorAll(".modal.mod-settings .setting-item-heading .setting-item-name")) + .map((element) => element.textContent?.trim() ?? ""), + indexedAliases: definitions.flatMap((definition) => definition.aliases ?? []).length, + renderedControls: document.querySelectorAll(".modal.mod-settings .setting-item").length, + }; + })() + `); + + expect(initial.definitionCount).toBe(1); + expect(initial.indexedAliases).toBe(29); + expect(initial.renderedControls).toBeGreaterThan(25); + expect(initial.headings).toEqual( + expect.arrayContaining([ + "Search Podcasts", + "Playlists", + "Episode notes", + "Feed notes", + "Downloads", + "Preferences & templates", + "Transcripts", + ]), + ); + expect(initial.headings).not.toContain("PodNotes"); + expect(initial.headings.every((heading) => !/settings/i.test(heading))).toBe(true); + + await evalJsonAsync( + obsidian, + `(async () => { + const input = document.querySelector('.modal.mod-settings input[placeholder="Search settings..."]'); + if (!(input instanceof HTMLInputElement)) throw new Error("Settings search input not found."); + input.value = "Episode download path"; + input.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText" })); + return true; + })()`, + ); + await obsidian.waitFor( + async () => + await obsidian.dev.evalJson( + 'document.querySelector(".setting-search-results")?.textContent?.includes("Podcast preferences") ?? false', + ), + WAIT_OPTS, + ); + + await evalJsonAsync( + obsidian, + `(() => { + app.setting.close(); + return true; + })()`, + ); + await openSettings(obsidian); + await setSettingInputValue(obsidian, "Default Playback Rate", "1.5"); + await plugin.waitForData( + (data) => data.defaultPlaybackRate === 1.5, + WAIT_OPTS, + ); + + await setSettingInputValue(obsidian, "Export settings", exportPath); + await clickSettingButton(obsidian, "Export settings", "Export"); + const exported = await sandbox.waitForContent( + exportName, + (contents) => contents.includes('"defaultPlaybackRate": 1.5'), + WAIT_OPTS, + ); + const envelope = JSON.parse(exported) as { + settings: { defaultPlaybackRate?: number }; + type: string; + version: number; + }; + expect(envelope).toMatchObject({ + type: "podnotes-settings", + version: 2, + settings: { defaultPlaybackRate: 1.5 }, + }); + + await setSettingInputValue(obsidian, "Default Playback Rate", "1.2"); + await plugin.waitForData( + (data) => data.defaultPlaybackRate === 1.2, + WAIT_OPTS, + ); + + await installFilePickerResult(obsidian, exported); + await clickSettingButton(obsidian, "Import settings", "Import"); + await obsidian.waitFor( + async () => + await obsidian.dev.evalJson(` + Array.from(document.querySelectorAll(".modal h3")) + .some((heading) => heading.textContent?.trim() === "Import PodNotes settings?") + `), + WAIT_OPTS, + ); + const confirmState = await obsidian.dev.evalJson<{ + destructive: boolean; + fileInputWasAttached: boolean; + }>(` + (() => { + const modal = Array.from(document.querySelectorAll(".modal")).find((candidate) => + Array.from(candidate.querySelectorAll("h3")).some( + (heading) => heading.textContent?.trim() === "Import PodNotes settings?", + ), + ); + const button = Array.from(modal?.querySelectorAll("button") ?? []).find( + (candidate) => candidate.textContent?.trim() === "Import", + ); + return { + destructive: button?.classList.contains("mod-destructive") ?? false, + fileInputWasAttached: window.__podnotesReviewFileInputAttached === true, + }; + })() + `); + expect(confirmState).toEqual({ destructive: true, fileInputWasAttached: true }); + + await evalJsonAsync( + obsidian, + `(() => { + const modal = Array.from(document.querySelectorAll(".modal")).find((candidate) => + Array.from(candidate.querySelectorAll("h3")).some( + (heading) => heading.textContent?.trim() === "Import PodNotes settings?", + ), + ); + const button = Array.from(modal?.querySelectorAll("button") ?? []).find( + (candidate) => candidate.textContent?.trim() === "Import", + ); + if (!(button instanceof HTMLButtonElement)) throw new Error("Import confirmation not found."); + button.click(); + return true; + })()`, + ); + + await plugin.waitForData( + (data) => data.defaultPlaybackRate === 1.5, + WAIT_OPTS, + ); + await obsidian.waitFor( + async () => await settingInputHasValue(obsidian, "Default Playback Rate", "1.5"), + WAIT_OPTS, + ); + expect(await obsidian.dev.runtimeErrors()).toEqual([]); + } finally { + await restoreFilePicker(obsidian); + await evalJsonAsync( + obsidian, + `(async () => { + app.setting.close(); + const path = ${JSON.stringify(exportPath)}; + if (await app.vault.adapter.exists(path)) await app.vault.adapter.remove(path); + return true; + })()`, + ).catch(() => undefined); + } + }, 60_000); + + test("streams a ranged download through the real adapter and renders it in Local Files", async () => { + const { obsidian, plugin, sandbox } = getContext(); + const episode = createEpisode("E2E Streaming Episode", DOWNLOAD_URL); + const downloadPath = sandbox.path("downloads/{{title}}"); + const expectedPath = sandbox.path("downloads/E2E Streaming Episode.mp3"); + + await seedEpisode(plugin, episode, (data) => { + data.download = { path: downloadPath }; + data.downloadedEpisodes = {}; + }); + await waitForPodNotesReady(obsidian); + await installDownloadInstrumentation(obsidian); + + try { + await obsidian.command(`${PLUGIN_ID}:download-playing-episode`).run(); + await sandbox.waitForExists("downloads/E2E Streaming Episode.mp3", WAIT_OPTS); + await plugin.waitForData( + (data) => + data.downloadedEpisodes?.[episode.podcastName]?.[0]?.size === + AUDIO_BYTES.byteLength, + WAIT_OPTS, + ); + + const downloaded = await evalJsonAsync<{ + appendCalls: number; + bytes: number[]; + partialFiles: string[]; + renameCalls: number; + requests: Array<{ range: string; url: string }>; + writeCalls: number; + }>( + obsidian, + `(async () => { + const file = app.vault.getAbstractFileByPath(${JSON.stringify(expectedPath)}); + if (!file) throw new Error("Downloaded file was not indexed."); + const bytes = new Uint8Array(await app.vault.readBinary(file)); + const listing = await app.vault.adapter.list(${JSON.stringify(sandbox.path("downloads"))}); + const calls = window.__podnotesReviewDownloadHooks.calls; + return { + appendCalls: calls.append.length, + bytes: Array.from(bytes), + partialFiles: listing.files.filter((path) => path.endsWith(".podnotes-partial")), + renameCalls: calls.rename.length, + requests: calls.requests, + writeCalls: calls.write.length, + }; + })()`, + ); + expect(downloaded).toEqual({ + appendCalls: 1, + bytes: Array.from(AUDIO_BYTES), + partialFiles: [], + renameCalls: 1, + requests: [ + { url: DOWNLOAD_URL, range: "bytes=0-4194303" }, + { url: DOWNLOAD_URL, range: "bytes=8-15" }, + ], + writeCalls: 1, + }); + + await openPodNotesView(obsidian); + await evalJsonAsync( + obsidian, + `(async () => { + const leaf = app.workspace.getLeaf("tab"); + await leaf.setViewState({ type: "podcast_player_view" }); + await app.workspace.revealLeaf(leaf); + app.workspace.setActiveLeaf(leaf, { focus: true }); + return true; + })()`, + ); + await evalJsonAsync( + obsidian, + `(() => { + const gridButton = document.querySelector( + '.workspace-leaf.mod-active .podcast-view button[aria-label="Podcast grid"]', + ); + if (!(gridButton instanceof HTMLButtonElement)) { + throw new Error("Podcast grid navigation button not found."); + } + gridButton.click(); + return true; + })()`, + ); + await obsidian.waitFor( + async () => + await obsidian.dev.evalJson( + "Boolean(document.querySelector('.workspace-leaf.mod-active .podcast-view button.playlist-card[aria-label=\"Local Files\"]'))", + ), + WAIT_OPTS, + ); + await evalJsonAsync( + obsidian, + `(() => { + const localFiles = document.querySelector( + '.workspace-leaf.mod-active .podcast-view button.playlist-card[aria-label="Local Files"]', + ); + if (!(localFiles instanceof HTMLButtonElement)) throw new Error("Local Files card not found."); + localFiles.click(); + return true; + })()`, + ); + await obsidian.waitFor( + async () => + await obsidian.dev.evalJson( + `Boolean(document.querySelector(${JSON.stringify(`.workspace-leaf.mod-active [aria-label="More options for ${episode.title}"]`)}))`, + ), + WAIT_OPTS, + ); + const localFilesState = await obsidian.dev.evalJson<{ + episodeVisible: boolean; + overflowVisible: boolean; + }>(` + (() => { + const overflow = document.querySelector( + ${JSON.stringify(`.workspace-leaf.mod-active [aria-label="More options for ${episode.title}"]`)}, + ); + return { + episodeVisible: Array.from(document.querySelectorAll(".workspace-leaf.mod-active .episode-item-title")) + .some((candidate) => candidate.textContent?.trim() === ${JSON.stringify(episode.title)}), + overflowVisible: overflow instanceof HTMLButtonElement && overflow.getBoundingClientRect().width > 0, + }; + })() + `); + expect(localFilesState).toEqual({ episodeVisible: true, overflowVisible: true }); + expect(await obsidian.dev.runtimeErrors()).toEqual([]); + } finally { + await restoreDownloadInstrumentation(obsidian); + } + }, 60_000); + + test("finds a uniquely renamed episode note within only its expected folder", async () => { + const { obsidian, plugin, sandbox } = getContext(); + const episode = createEpisode( + "E2E Renamed Episode 42", + "https://example.com/e2e-renamed-episode.mp3", + ); + const noteTemplate = sandbox.path("notes/{{title}}.md"); + const renamedPath = sandbox.path("notes/E2E Renamed Episode 42 notes.md"); + const expectedPath = sandbox.path("notes/E2E Renamed Episode 42.md"); + + await seedEpisode(plugin, episode, (data) => { + data.note = { path: noteTemplate, template: "# {{title}}\n" }; + }); + await waitForPodNotesReady(obsidian); + + try { + await evalJsonAsync( + obsidian, + `(async () => { + const folderPath = ${JSON.stringify(sandbox.path("notes"))}; + if (!app.vault.getAbstractFileByPath(folderPath)) await app.vault.createFolder(folderPath); + await app.vault.create( + ${JSON.stringify(renamedPath)}, + ${JSON.stringify("# Existing renamed note\n")}, + ); + return true; + })()`, + ); + + await obsidian.command(`${PLUGIN_ID}:create-podcast-note`).run(); + await obsidian.waitFor( + async () => + await obsidian.dev.evalJson( + `app.workspace.getActiveFile()?.path === ${JSON.stringify(renamedPath)}`, + ), + WAIT_OPTS, + ); + const state = await obsidian.dev.evalJson<{ + exactCreated: boolean; + folderChildren: string[]; + }>(` + (() => { + const folder = app.vault.getAbstractFileByPath(${JSON.stringify(sandbox.path("notes"))}); + return { + exactCreated: Boolean(app.vault.getAbstractFileByPath(${JSON.stringify(expectedPath)})), + folderChildren: (folder?.children ?? []).map((child) => child.path), + }; + })() + `); + expect(state.exactCreated).toBe(false); + expect(state.folderChildren).toEqual([renamedPath]); + expect(await obsidian.dev.runtimeErrors()).toEqual([]); + } finally { + await evalJsonAsync( + obsidian, + `(async () => { + if (await app.vault.adapter.exists(${JSON.stringify(renamedPath)})) { + await app.vault.adapter.remove(${JSON.stringify(renamedPath)}); + } + return true; + })()`, + ).catch(() => undefined); + } + }); + + test("copies the universal link through the real command and clipboard boundary", async () => { + const { obsidian, plugin } = getContext(); + const episode = createEpisode( + "E2E Clipboard Episode", + "https://example.com/e2e-clipboard.mp3", + ); + + await seedEpisode(plugin, episode, (data) => { + data.savedFeeds = { + [episode.podcastName]: { + title: episode.podcastName, + url: episode.feedUrl ?? "", + artworkUrl: "", + collectionId: "42", + }, + }; + }); + await waitForPodNotesReady(obsidian); + await installClipboardInstrumentation(obsidian); + + try { + await obsidian.command(`${PLUGIN_ID}:get-share-link-episode`).run(); + try { + await obsidian.waitFor( + async () => + await obsidian.dev.evalJson( + "window.__podnotesReviewClipboardHooks.writes.length === 1", + ), + WAIT_OPTS, + ); + } catch (error) { + const debug = await obsidian.dev.evalJson(` + ({ + redirected: window.__podnotesReviewClipboardHooks?.redirected ?? [], + writes: window.__podnotesReviewClipboardHooks?.writes ?? [], + notices: Array.from(document.querySelectorAll(".notice")).map((notice) => notice.textContent), + }) + `); + throw new Error( + `Clipboard command did not settle: ${JSON.stringify({ debug, waitError: String(error) })}`, + ); + } + const state = await obsidian.dev.evalJson<{ + redirected: string[]; + writes: string[]; + }>(` + ({ + redirected: window.__podnotesReviewClipboardHooks.redirected, + writes: window.__podnotesReviewClipboardHooks.writes, + }) + `); + expect(state.writes).toEqual(["https://pod.link/42/episode/e2e-episode-id"]); + expect(state.redirected).toEqual(["https://pod.link/42.json?limit=1000"]); + await obsidian.waitFor( + async () => + await obsidian.dev.evalJson( + `Array.from(document.querySelectorAll(".notice")) + .some((notice) => notice.textContent?.includes("Universal episode link copied to clipboard."))`, + ), + WAIT_OPTS, + ); + expect(await obsidian.dev.runtimeErrors()).toEqual([]); + } finally { + await restoreClipboardInstrumentation(obsidian); + } + }); + + test("normalizes raw async rejection reasons to Error objects in the live bundle", async () => { + const { obsidian } = getContext(); + const result = await evalJsonAsync<{ + lifecycle: { isError: boolean; message: string }; + retryAbort: { isError: boolean; message: string }; + snapshot: { isError: boolean; message: string }; + }>( + obsidian, + `(async () => { + const podnotes = app.plugins.plugins.${PLUGIN_ID}; + const originalStructuredClone = window.structuredClone; + let snapshot; + try { + window.structuredClone = () => { throw "raw snapshot failure"; }; + await podnotes.saveSettingsStrict(); + snapshot = { isError: false, message: "resolved" }; + } catch (error) { + snapshot = { isError: error instanceof Error, message: error?.message ?? String(error) }; + } finally { + window.structuredClone = originalStructuredClone; + } + + const service = podnotes.getTranscriptionService(); + let lifecycle; + try { + await service.waitForLifecycle(Promise.reject("raw lifecycle failure")); + lifecycle = { isError: false, message: "resolved" }; + } catch (error) { + lifecycle = { isError: error instanceof Error, message: error?.message ?? String(error) }; + } + + const retryPromise = service.waitForRetry(10_000); + service.lifetimeAbortController.abort("raw abort reason"); + let retryAbort; + try { + await retryPromise; + retryAbort = { isError: false, message: "resolved" }; + } catch (error) { + retryAbort = { isError: error instanceof Error, message: error?.message ?? String(error) }; + } + + return { lifecycle, retryAbort, snapshot }; + })()`, + ); + + expect(result).toEqual({ + lifecycle: { isError: true, message: "PodNotes transcription failed." }, + retryAbort: { isError: true, message: "PodNotes transcription was aborted." }, + snapshot: { + isError: true, + message: "PodNotes could not snapshot settings before saving.", + }, + }); + expect(await obsidian.dev.runtimeErrors()).toEqual([]); + }); +}); + +function createEpisode(title: string, streamUrl: string): Episode { + return { + title, + itunesTitle: title, + streamUrl, + url: streamUrl, + feedUrl: "https://example.com/e2e-feed.xml", + description: "", + content: "", + podcastName: "E2E Podcast", + artworkUrl: "", + mediaType: "audio", + }; +} + +async function seedEpisode( + plugin: PluginHandle, + episode: Episode, + mutate: (data: PodNotesData) => void = () => undefined, +): Promise { + await plugin.updateDataAndReload((data) => { + data.currentEpisode = episode; + data.playedEpisodes = {}; + mutate(data); + }, RELOAD_OPTIONS); +} + +async function openSettings(obsidian: Parameters[0]): Promise { + await evalJsonAsync( + obsidian, + `(async () => { + app.setting.open(); + app.setting.closeActiveTab(); + app.setting.openTabById(${JSON.stringify(PLUGIN_ID)}); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + return true; + })()`, + ); + await obsidian.waitFor( + async () => + await obsidian.dev.evalJson(` + Array.from(document.querySelectorAll(".modal.mod-settings .setting-item-name")) + .some((element) => element.textContent?.trim() === "Default Playback Rate") + `), + WAIT_OPTS, + ); +} + +async function setSettingInputValue( + obsidian: Parameters[0], + settingName: string, + value: string, +): Promise { + await evalJsonAsync( + obsidian, + `(() => { + const setting = Array.from(document.querySelectorAll(".modal.mod-settings .setting-item")).find( + (candidate) => candidate.querySelector(".setting-item-name")?.textContent?.trim() === ${JSON.stringify(settingName)}, + ); + const input = setting?.querySelector("input"); + if (!(input instanceof HTMLInputElement)) { + throw new Error(${JSON.stringify(`Input not found for ${settingName}.`)}); + } + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set; + if (!setter) throw new Error("Native input value setter not found."); + setter.call(input, ${JSON.stringify(value)}); + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("change", { bubbles: true })); + return true; + })()`, + ); +} + +async function settingInputHasValue( + obsidian: Parameters[0], + settingName: string, + value: string, +): Promise { + return await obsidian.dev.evalJson(` + (() => { + const setting = Array.from(document.querySelectorAll(".modal.mod-settings .setting-item")).find( + (candidate) => candidate.querySelector(".setting-item-name")?.textContent?.trim() === ${JSON.stringify(settingName)}, + ); + return setting?.querySelector("input")?.value === ${JSON.stringify(value)}; + })() + `); +} + +async function clickSettingButton( + obsidian: Parameters[0], + settingName: string, + buttonText: string, +): Promise { + await evalJsonAsync( + obsidian, + `(() => { + const setting = Array.from(document.querySelectorAll(".modal.mod-settings .setting-item")).find( + (candidate) => candidate.querySelector(".setting-item-name")?.textContent?.trim() === ${JSON.stringify(settingName)}, + ); + const button = Array.from(setting?.querySelectorAll("button") ?? []).find( + (candidate) => candidate.textContent?.trim() === ${JSON.stringify(buttonText)}, + ); + if (!(button instanceof HTMLButtonElement)) { + throw new Error(${JSON.stringify(`${buttonText} button not found for ${settingName}.`)}); + } + button.click(); + return true; + })()`, + ); +} + +async function installFilePickerResult( + obsidian: Parameters[0], + contents: string, +): Promise { + await evalJsonAsync( + obsidian, + `(() => { + window.__podnotesReviewOriginalInputClick = HTMLInputElement.prototype.click; + HTMLInputElement.prototype.click = function () { + if (this.type !== "file") { + return window.__podnotesReviewOriginalInputClick.call(this); + } + window.__podnotesReviewFileInputAttached = this.parentElement === activeDocument.body; + const file = new File([${JSON.stringify(contents)}], "PodNotes_Settings.json", { + type: "application/json", + }); + Object.defineProperty(this, "files", { configurable: true, value: [file] }); + this.dispatchEvent(new Event("change", { bubbles: true })); + }; + return true; + })()`, + ); +} + +async function restoreFilePicker(obsidian: Parameters[0]): Promise { + await obsidian.dev + .evalJson(` + (() => { + if (window.__podnotesReviewOriginalInputClick) { + HTMLInputElement.prototype.click = window.__podnotesReviewOriginalInputClick; + } + delete window.__podnotesReviewOriginalInputClick; + delete window.__podnotesReviewFileInputAttached; + return true; + })() + `) + .catch(() => undefined); +} + +async function installDownloadInstrumentation( + obsidian: Parameters[0], +): Promise { + await evalJsonAsync( + obsidian, + `(() => { + const adapter = app.vault.adapter; + const ipc = window.electron.ipcRenderer; + const originals = { + appendBinary: adapter.appendBinary, + ipcSend: ipc.send, + rename: adapter.rename, + writeBinary: adapter.writeBinary, + }; + const calls = { append: [], rename: [], requests: [], write: [] }; + ipc.send = function (channel, ...args) { + const [replyChannel, request] = args; + if ( + channel === "request-url" && + typeof replyChannel === "string" && + request?.url === ${JSON.stringify(DOWNLOAD_URL)} + ) { + const rangeHeader = request.headers?.Range ?? request.headers?.range ?? ""; + calls.requests.push({ url: request.url, range: rangeHeader }); + const match = /^bytes=(\\d+)-(\\d+)$/.exec(rangeHeader); + const bytes = Uint8Array.from(${JSON.stringify(Array.from(AUDIO_BYTES))}); + const start = match ? Number(match[1]) : 0; + const end = match ? Math.min(start + 7, bytes.byteLength - 1) : bytes.byteLength - 1; + const body = bytes.slice(start, end + 1).buffer; + queueMicrotask(() => ipc.emit(replyChannel, {}, { + status: match ? 206 : 200, + headers: { + "content-length": String(body.byteLength), + "content-range": match ? "bytes " + start + "-" + end + "/" + bytes.byteLength : undefined, + "content-type": "audio/mpeg", + }, + body, + })); + return; + } + return originals.ipcSend.call(ipc, channel, ...args); + }; + adapter.writeBinary = async function (path, data) { + calls.write.push({ path, size: data.byteLength }); + return await originals.writeBinary.call(adapter, path, data); + }; + adapter.appendBinary = async function (path, data) { + calls.append.push({ path, size: data.byteLength }); + return await originals.appendBinary.call(adapter, path, data); + }; + adapter.rename = async function (from, to) { + calls.rename.push({ from, to }); + return await originals.rename.call(adapter, from, to); + }; + window.__podnotesReviewDownloadHooks = { adapter, calls, ipc, originals }; + return true; + })()`, + ); +} + +async function restoreDownloadInstrumentation( + obsidian: Parameters[0], +): Promise { + await obsidian.dev + .evalJson(` + (() => { + const hooks = window.__podnotesReviewDownloadHooks; + if (!hooks) return true; + hooks.adapter.appendBinary = hooks.originals.appendBinary; + hooks.adapter.rename = hooks.originals.rename; + hooks.adapter.writeBinary = hooks.originals.writeBinary; + hooks.ipc.send = hooks.originals.ipcSend; + delete window.__podnotesReviewDownloadHooks; + return true; + })() + `) + .catch(() => undefined); +} + +async function installClipboardInstrumentation( + obsidian: Parameters[0], +): Promise { + await evalJsonAsync( + obsidian, + `(() => { + const ipc = window.electron.ipcRenderer; + const writes = []; + const redirected = []; + const originalWriteText = navigator.clipboard.writeText; + const originalSend = ipc.send; + navigator.clipboard.writeText = async (value) => { writes.push(value); }; + ipc.send = function (channel, ...args) { + const [replyChannel, request] = args; + if ( + channel === "request-url" && + typeof replyChannel === "string" && + request?.url?.startsWith("https://pod.link/") + ) { + redirected.push(request.url); + const body = new TextEncoder().encode(JSON.stringify({ + episodes: [{ episodeId: "e2e-episode-id", title: "E2E Clipboard Episode" }], + })).buffer; + queueMicrotask(() => ipc.emit(replyChannel, {}, { + status: 200, + headers: { "content-type": "application/json" }, + body, + })); + return; + } + return originalSend.call(ipc, channel, ...args); + }; + window.__podnotesReviewClipboardHooks = { + ipc, + originalSend, + originalWriteText, + redirected, + writes, + }; + return true; + })()`, + ); +} + +async function restoreClipboardInstrumentation( + obsidian: Parameters[0], +): Promise { + await obsidian.dev + .evalJson(` + (() => { + const hooks = window.__podnotesReviewClipboardHooks; + if (!hooks) return true; + hooks.ipc.send = hooks.originalSend; + navigator.clipboard.writeText = hooks.originalWriteText; + delete window.__podnotesReviewClipboardHooks; + return true; + })() + `) + .catch(() => undefined); +} diff --git a/tests/e2e/podnotes-runtime.test.ts b/tests/e2e/podnotes-runtime.test.ts index b6b704e..61e29a7 100644 --- a/tests/e2e/podnotes-runtime.test.ts +++ b/tests/e2e/podnotes-runtime.test.ts @@ -695,6 +695,21 @@ describe("PodNotes runtime", () => { })()`, ); await waitForPodNotesReady(obsidian); + await evalJsonAsync( + obsidian, + `(() => { + for (const notice of document.querySelectorAll(".notice")) { + const text = notice.textContent ?? ""; + if ( + text.includes("PodNotes data schema v3 requires a newer version of PodNotes.") || + (text.includes("Failed to load plugin") && text.includes("podnotes")) + ) { + notice.remove(); + } + } + return true; + })()`, + ); await obsidian.dev.resetDiagnostics().catch(() => undefined); } }); diff --git a/tests/mocks/obsidian.ts b/tests/mocks/obsidian.ts index 6838da3..5e5f8a3 100644 --- a/tests/mocks/obsidian.ts +++ b/tests/mocks/obsidian.ts @@ -24,12 +24,21 @@ export class Component { export class TFile { path: string; + extension: string; - constructor(path: string) { + constructor(path = "") { this.path = path; + this.extension = path.split(".").pop() ?? ""; } } +export class TFolder { + constructor( + public path: string, + public children: Array = [], + ) {} +} + export class Notice { constructor(public message?: string) {} @@ -137,6 +146,11 @@ export class ButtonComponent extends BaseInteractiveElement { return this; } + setDestructive() { + this.buttonEl.classList.add("mod-destructive"); + return this; + } + setClass(value: string) { this.buttonEl.className = value; return this; @@ -325,10 +339,20 @@ export class PluginSettingTab { } display(): void {} + update(): void {} hide(): void {} } export const MarkdownRenderer = { + render: async ( + _app: App, + markdown: string, + container: HTMLElement, + _source: string, + _component: Component, + ) => { + container.textContent = markdown; + }, renderMarkdown: async ( markdown: string, container: HTMLElement, diff --git a/vitest.setup.ts b/vitest.setup.ts index b0bacba..9268f55 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -176,6 +176,21 @@ if (!Element.prototype.scrollIntoView) { Element.prototype.scrollIntoView = () => {}; } +if (!(Element.prototype as unknown as { instanceOf?: unknown }).instanceOf) { + ( + Element.prototype as unknown as { + instanceOf: (constructor: typeof Element) => boolean; + } + ).instanceOf = function (this: Element, constructor: typeof Element): boolean { + const localConstructor = this.ownerDocument.defaultView?.[ + constructor.name as keyof Window + ] as unknown; + const elementConstructor = + typeof localConstructor === "function" ? localConstructor : constructor; + return this instanceof elementConstructor; + }; +} + if ( !(HTMLElement.prototype as unknown as { setAttr?: (name: string, value: string) => void }) .setAttr @@ -203,6 +218,7 @@ function installCreateEl(proto: object): void { const helpers = proto as { createEl?: (tag: keyof HTMLElementTagNameMap, options?: CreateElOptions) => HTMLElement; createDiv?: (options?: CreateElOptions) => HTMLDivElement; + createSpan?: (options?: CreateElOptions) => HTMLSpanElement; }; if (!helpers.createEl) { @@ -232,6 +248,20 @@ function installCreateEl(proto: object): void { return createEl.call(this, "div", options) as HTMLDivElement; }; } + + if (!helpers.createSpan) { + helpers.createSpan = function (this: ObsidianDomContainer, options: CreateElOptions = {}) { + const createEl = ( + this as ObsidianDomContainer & { + createEl: ( + tag: keyof HTMLElementTagNameMap, + options?: CreateElOptions, + ) => HTMLElement; + } + ).createEl; + return createEl.call(this, "span", options) as HTMLSpanElement; + }; + } } installCreateEl(HTMLElement.prototype);