From 5d0c07401528b48ef738faf70b9edf3ea432e92e Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sun, 18 Oct 2026 13:21:34 +0200 Subject: [PATCH 01/11] feat(settings): add a QuickAdd choice to the timestamp settings --- src/persistence/podNotesData.test.ts | 48 +++++++++++++++++++++ src/persistence/settingsCodec.ts | 5 ++- src/types/IPodNotesSettings.ts | 2 + src/ui/settings/PodNotesSettingsTab.test.ts | 18 ++++++++ src/ui/settings/PodNotesSettingsTab.ts | 6 +++ 5 files changed, 78 insertions(+), 1 deletion(-) diff --git a/src/persistence/podNotesData.test.ts b/src/persistence/podNotesData.test.ts index e55aa36e..efadd5d1 100644 --- a/src/persistence/podNotesData.test.ts +++ b/src/persistence/podNotesData.test.ts @@ -929,3 +929,51 @@ describe("timestamp template (#345)", () => { expect(decoded.warnings).toEqual(warnings); }); }); + +describe("QuickAdd choice", () => { + const saved = (timestamp: Record) => + decodePodNotesData({ + ...listenerData, + schemaVersion: 2, + openAISecretId: "", + deepgramSecretId: "", + timestamp, + }); + const persisted = ({ settings, unknownFields }: ReturnType) => + JSON.stringify(encodePodNotesData(settings, unknownFields).timestamp); + + it("keeps the choice through load and save", () => { + const decoded = saved({ template: "- {{time}} ", offset: 0, quickAddChoice: "Log" }); + + expect(decoded.warnings).toEqual([]); + expect(decoded.changed).toBe(false); + expect(decoded.settings.timestamp.quickAddChoice).toBe("Log"); + expect(persisted(decoded)).toBe( + '{"template":"- {{time}} ","offset":0,"quickAddChoice":"Log"}', + ); + }); + + it("writes no choice for data without one", () => { + const decoded = saved({ template: "- {{time}} ", offset: 0 }); + + expect(persisted(decoded)).toBe('{"template":"- {{time}} ","offset":0}'); + expect(persisted(decodePodNotesData(null))).toBe( + '{"template":"- {{linktime}} ","offset":0}', + ); + }); + + it("drops a choice that is not text, with a warning", () => { + const decoded = saved({ template: "- {{time}} ", offset: 0, quickAddChoice: 42 }); + + expect(decoded.warnings).toEqual(["timestamp.quickAddChoice: expected a string"]); + expect(persisted(decoded)).toBe('{"template":"- {{time}} ","offset":0}'); + }); + + // 2.22.0 decodes the timestamp setting as this version decodes a field it does not know. + it("keeps a timestamp field this version does not know through load and save", () => { + const decoded = saved({ template: "- {{time}} ", offset: 0, nextField: "kept" }); + + expect(decoded.warnings).toEqual([]); + expect(persisted(decoded)).toBe('{"template":"- {{time}} ","offset":0,"nextField":"kept"}'); + }); +}); diff --git a/src/persistence/settingsCodec.ts b/src/persistence/settingsCodec.ts index 107c198c..2de3db0c 100644 --- a/src/persistence/settingsCodec.ts +++ b/src/persistence/settingsCodec.ts @@ -32,6 +32,7 @@ import { readNullableString, readOption, readString, + setOptionalString, type UnknownRecord, warn, } from "./codecUtils"; @@ -146,7 +147,7 @@ const LEGACY_TIMESTAMP_TEMPLATE = "- {{time}} "; function decodeTimestamp(value: unknown, warnings: Set): IPodNotesSettings["timestamp"] { const record = optionalRecord(value, warnings, "timestamp"); - return { + const timestamp = { ...copySafeObject(record), template: readString(record, "template", LEGACY_TIMESTAMP_TEMPLATE, warnings, "timestamp"), offset: readFiniteNumber( @@ -157,6 +158,8 @@ function decodeTimestamp(value: unknown, warnings: Set): IPodNotesSettin "timestamp", ), }; + setOptionalString(timestamp, record, "quickAddChoice", warnings, "timestamp"); + return timestamp; } function decodeNote( diff --git a/src/types/IPodNotesSettings.ts b/src/types/IPodNotesSettings.ts index 9e46fe66..7e95978b 100644 --- a/src/types/IPodNotesSettings.ts +++ b/src/types/IPodNotesSettings.ts @@ -41,6 +41,8 @@ export interface IPodNotesSettings { timestamp: { template: string; offset: number; + /** The QuickAdd choice a capture runs instead of writing the timestamp itself. */ + quickAddChoice?: string; }; note: { diff --git a/src/ui/settings/PodNotesSettingsTab.test.ts b/src/ui/settings/PodNotesSettingsTab.test.ts index b58e0e52..193d9507 100644 --- a/src/ui/settings/PodNotesSettingsTab.test.ts +++ b/src/ui/settings/PodNotesSettingsTab.test.ts @@ -168,6 +168,7 @@ describe("sections", () => { [ "Timestamp template", "Timestamp offset (s)", + "QuickAdd choice", "Episode note path", "Episode note template", "Podcast note path", @@ -211,6 +212,7 @@ describe("sections", () => { "Next track", "Timestamp template", "Timestamp offset (s)", + "QuickAdd choice", "Episode note path", "Episode note template", "Podcast note path", @@ -292,6 +294,22 @@ describe("text fields", () => { expect(preview()).toBe("00:01:32"); }); + test("the QuickAdd choice shows the saved one and saves the one typed", async () => { + const { field, disk } = open({ + timestamp: { template: "- {{time}} ", offset: 0, quickAddChoice: "Log" }, + }); + expect(field("QuickAdd choice").value).toBe("Log"); + + retype(field("QuickAdd choice"), "Podcast log"); + complete(field("QuickAdd choice")); + + expect((await disk()).timestamp).toEqual({ + template: "- {{time}} ", + offset: 0, + quickAddChoice: "Podcast log", + }); + }); + test("each keystroke applies at once; the save waits for 500 ms without one and tells an open view", async () => { fakeTypingTime(); const { plugin, field, writes, disk } = open(); diff --git a/src/ui/settings/PodNotesSettingsTab.ts b/src/ui/settings/PodNotesSettingsTab.ts index 4658bc39..09e1ca53 100644 --- a/src/ui/settings/PodNotesSettingsTab.ts +++ b/src/ui/settings/PodNotesSettingsTab.ts @@ -320,6 +320,12 @@ export class PodNotesSettingsTab extends PluginSettingTab { }, "Captures this many seconds earlier.", ), + this.textRow("QuickAdd choice", { + read: () => this.plugin.settings.timestamp.quickAddChoice ?? "", + write: (value) => { + this.plugin.settings.timestamp.quickAddChoice = value; + }, + }), this.textRow("Episode note path", { ...this.text((settings) => settings.note, "path"), preview: pathPreview(FilePathTemplateEngine), From 64f88077bdebdfccabaf85d82ca15e0e3545cc97 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sun, 18 Oct 2026 13:26:00 +0200 Subject: [PATCH 02/11] feat(capture): run the QuickAdd choice when one is set --- src/TemplateEngine.ts | 2 +- src/commands.test.ts | 139 +++++++++++++++++++++++++- src/createPodcastNote.ts | 2 +- src/global.d.ts | 8 +- src/services/CaptureService.test.ts | 126 +++++++++++++++++++++++ src/services/CaptureService.ts | 80 +++++++++++++-- src/utility/formatEpisodeTimestamp.ts | 16 ++- 7 files changed, 358 insertions(+), 15 deletions(-) create mode 100644 src/services/CaptureService.test.ts diff --git a/src/TemplateEngine.ts b/src/TemplateEngine.ts index 5d522c86..b0918f42 100644 --- a/src/TemplateEngine.ts +++ b/src/TemplateEngine.ts @@ -308,7 +308,7 @@ function formatTemplateChapters(chapters: Chapter[] | undefined, prependToLines? * Every other (feed) episode's url is attacker-controlled and is sanitized so it * cannot inject Markdown/wikilinks on the bare `{{url}}` line. */ -function resolveEpisodeUrl(episode: Episode): string { +export function resolveEpisodeUrl(episode: Episode): string { return isLocalFile(episode) && episode.filePath ? episode.url : sanitizeUrlForTemplate(episode.url); diff --git a/src/commands.test.ts b/src/commands.test.ts index aaaeefb5..1baff31c 100644 --- a/src/commands.test.ts +++ b/src/commands.test.ts @@ -4,7 +4,7 @@ import { MarkdownView, TFile, TFolder, type WorkspaceLeaf } from "obsidian"; import { get } from "svelte/store"; import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from "vitest"; import PodNotes from "./main"; -import { VIEW_TYPE } from "./constants"; +import { QUEUE_SETTINGS, VIEW_TYPE } from "./constants"; import type { Episode } from "./types/Episode"; import { PickerModal } from "./ui/PickerModal"; import { fetchTextWithTimeout } from "./utility/networkRequest"; @@ -41,6 +41,17 @@ const playing = { note: { path: "PodNotes/{{podcast}}/{{title}}.md", template: "# {{title}}" }, }; const NOTE_PATH = "PodNotes/Pod/Saved Episode.md"; +/** Playing an episode its feed can link to, with another episode next in the queue. */ +const linkedPlaying = { + ...playing, + currentEpisode: { ...episode, feedUrl: "https://example.com/feed.xml", guid: "saved-1" }, + queue: { + ...QUEUE_SETTINGS, + episodes: [{ ...episode, title: "Queued Episode", url: "https://example.com/queued" }], + }, +}; +const LINK = + "obsidian://podnotes?episodeName=Saved%20Episode&url=https%3A%2F%2Fexample.com%2Ffeed.xml&guid=saved-1"; function vaultFile(path: string): TFile { const name = path.split("/").pop() ?? path; @@ -243,6 +254,16 @@ async function undo() { const REFUSED = "The note isn't as the capture left it, so the timestamp stays"; +/** QuickAdd as Obsidian holds it while it is enabled, with "Log" set as the QuickAdd choice. */ +function withQuickAdd( + plugin: PodNotes, + executeChoice = vi.fn(async (_choice: string, _variables: Record) => {}), +) { + Object.assign(plugin.app, { plugins: { plugins: { quickadd: { api: { executeChoice } } } } }); + plugin.settings.timestamp.quickAddChoice = "Log"; + return executeChoice; +} + /** A note's tab, and the changes a user can make to it. */ interface Tab { readonly leaf: WorkspaceLeaf; @@ -849,6 +870,122 @@ describe("capture", () => { expect(read(NOTE_PATH)).toBe("# Saved Episode\n- 00:20:24-00:20:34\n- 00:20:34\n"), ); }); + + it("go to the QuickAdd choice when one is set", async () => { + const { plugin, run, notes } = await loadPlugin(linkedPlaying); + const executeChoice = withQuickAdd(plugin); + + run("capture-segment-10s"); + handlers.get("previoustrack")!(); + + await vi.waitFor(() => expect(executeChoice).toHaveBeenCalledTimes(2)); + expect(executeChoice.mock.calls.map(([choice, { time }]) => [choice, time])).toEqual([ + ["Log", "20:24-20:34"], + ["Log", "20:34"], + ]); + expect(notes.size).toBe(0); + }); + }); + + describe("with a QuickAdd choice", () => { + const moment = { + timestamp: "- 00:20:34", + time: "20:34", + seconds: "1234", + link: `${LINK}&time=1234`, + episode: "Saved Episode", + podcast: "Pod", + url: "https://example.com/saved", + episodeNote: NOTE_PATH, + }; + + it("runs it once with the capture, and writes nothing itself", async () => { + const { plugin, run, notes } = await loadPlugin(linkedPlaying); + const executeChoice = withQuickAdd(plugin); + + run("capture-timestamp"); + + await vi.waitFor(() => expect(executeChoice.mock.calls).toEqual([["Log", moment]])); + expect(notes.size).toBe(0); + expect(said()).toEqual([]); + }); + + it("runs it with a segment's range, and leaves the editor as it is", async () => { + const { plugin, run, openNote, focusPodNotes } = await loadPlugin(linkedPlaying); + const executeChoice = withQuickAdd(plugin); + const { editor } = openNote("Journal.md", "Notes\n"); + focusPodNotes(); + + run("capture-segment-10s"); + + await vi.waitFor(() => + expect(executeChoice.mock.calls).toEqual([ + [ + "Log", + { + ...moment, + timestamp: "- 00:20:24-00:20:34", + time: "20:24-20:34", + seconds: "1224", + link: `${LINK}&time=1224&endTime=1234`, + }, + ], + ]), + ); + expect(editor.getValue()).toBe("Notes\n"); + expect(said()).toEqual([]); + }); + + it("captures as before when the choice is blank", async () => { + const { plugin, run, openNote, focusPodNotes } = await loadPlugin(linkedPlaying); + const executeChoice = withQuickAdd(plugin); + plugin.settings.timestamp.quickAddChoice = " "; + const { editor } = openNote("Journal.md", "Notes\n"); + focusPodNotes(); + + run("capture-timestamp"); + + expect(editor.getValue()).toBe("Notes\n- 00:20:34"); + expect(said()).toEqual(["Timestamp added to Journal"]); + expect(executeChoice.mock.calls).toEqual([]); + }); + + it("says QuickAdd is needed, and writes nothing, while QuickAdd is not enabled", async () => { + const { plugin, run, openNote, focusPodNotes, notes } = await loadPlugin(linkedPlaying); + withQuickAdd(plugin); + Object.assign(plugin.app, { plugins: { plugins: {} } }); + const { editor } = openNote("Journal.md", "Notes\n"); + focusPodNotes(); + + run("capture-timestamp"); + + await vi.waitFor(() => + expect(said()).toEqual([ + "Install and enable QuickAdd to capture with a QuickAdd choice", + ]), + ); + expect(editor.getValue()).toBe("Notes\n"); + expect(notes.size).toBe(0); + }); + + it("logs a choice that fails or is cancelled, and leaves no unhandled rejection", async () => { + const { plugin, run } = await loadPlugin(linkedPlaying); + const failure = new Error("Input cancelled by user"); + withQuickAdd(plugin, vi.fn().mockRejectedValue(failure)); + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + const unhandled: unknown[] = []; + const listen = (reason: unknown) => unhandled.push(reason); + process.on("unhandledRejection", listen); + try { + run("capture-timestamp"); + await new Promise((resolve) => setTimeout(resolve, 20)); + } finally { + process.off("unhandledRejection", listen); + } + + expect(unhandled).toEqual([]); + expect(logged.mock.calls).toEqual([["PodNotes: the QuickAdd choice failed", failure]]); + }); }); }); diff --git a/src/createPodcastNote.ts b/src/createPodcastNote.ts index 4a56e862..8e1ac7b9 100644 --- a/src/createPodcastNote.ts +++ b/src/createPodcastNote.ts @@ -25,7 +25,7 @@ import { openNote } from "./ui/openNote"; * the exact same (length-capped) path, otherwise a truncated note would be * re-created on every invocation. See issue #22. */ -function getPodcastNotePath(episode: Episode): string { +export function getPodcastNotePath(episode: Episode): string { return renderPodcastNotePath(get(plugin).settings.note.path, episode); } diff --git a/src/global.d.ts b/src/global.d.ts index 119ef79b..0f5d347e 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -26,7 +26,13 @@ interface App2 extends App { manifest: Manifest; app: App; } - >; + > & { + quickadd?: { + api: { + executeChoice(choice: string, variables: Record): Promise; + }; + }; + }; requestSaveConfig: () => void; updates: Record; }; diff --git a/src/services/CaptureService.test.ts b/src/services/CaptureService.test.ts new file mode 100644 index 00000000..be1ce073 --- /dev/null +++ b/src/services/CaptureService.test.ts @@ -0,0 +1,126 @@ +import { SecretStorage } from "obsidian"; +import { afterEach, expect, test } from "vitest"; +import { DEFAULT_SETTINGS } from "src/constants"; +import { plugin } from "src/store"; +import type { Episode } from "src/types/Episode"; +import { quickAddVariables } from "./CaptureService"; +import { FeedUrlRepository } from "./FeedUrlRepository"; + +const address = "https://private.example.com/rss?auth=NOTE_SENTINEL"; +const placeholder = "podnotes-private-feed:Paid"; + +const publicEpisode: Episode = { + title: "Saved Episode", + podcastName: "Pod", + feedUrl: "https://example.com/feed.xml", + guid: "saved-1", + url: "https://example.com/episodes/saved (1)", + streamUrl: "https://example.com/saved.mp3", + description: "", + content: "", +}; + +const privateEpisode: Episode = { + title: "Private episode", + podcastName: "Paid", + feedUrl: address, + guid: "episode-1", + url: "https://private.example.com/episode?token=EPISODE_TOKEN", + streamUrl: "https://private.example.com/audio.mp3?token=MEDIA_SIGNATURE", + description: `Your feed: ${address}.`, + content: "", +}; + +function library(subscribed: boolean) { + const feedUrls = new FeedUrlRepository(new SecretStorage()); + const urlSecretId = feedUrls.store(address); + plugin.set({ + feedUrls, + settings: { + ...structuredClone(DEFAULT_SETTINGS), + savedFeeds: subscribed + ? { Paid: { title: "Paid", url: placeholder, urlSecretId, artworkUrl: "" } } + : {}, + }, + } as never); +} + +afterEach(() => plugin.set(undefined as never)); + +test("a moment gives QuickAdd its text, position, link and episode", () => { + library(false); + expect(quickAddVariables(publicEpisode, { text: "- 01:02:03 ", start: 3723.5 })).toEqual({ + timestamp: "- 01:02:03 ", + time: "1:02:03", + seconds: "3723", + link: "obsidian://podnotes?episodeName=Saved%20Episode&url=https%3A%2F%2Fexample.com%2Ffeed.xml&guid=saved-1&time=3723.5", + episode: "Saved Episode", + podcast: "Pod", + url: "https://example.com/episodes/saved%20%281%29", + episodeNote: "PodNotes/Pod/Saved Episode.md", + }); +}); + +test("a segment gives QuickAdd its range", () => { + library(false); + expect( + quickAddVariables(publicEpisode, { + text: "- 01:01:53-01:02:03 ", + start: 3713.5, + end: 3723.5, + }), + ).toEqual({ + timestamp: "- 01:01:53-01:02:03 ", + time: "1:01:53-1:02:03", + seconds: "3713", + link: "obsidian://podnotes?episodeName=Saved%20Episode&url=https%3A%2F%2Fexample.com%2Ffeed.xml&guid=saved-1&time=3713.5&endTime=3723.5", + episode: "Saved Episode", + podcast: "Pod", + url: "https://example.com/episodes/saved%20%281%29", + episodeNote: "PodNotes/Pod/Saved Episode.md", + }); +}); + +test("a subscribed private feed links by its placeholder", () => { + library(true); + expect( + quickAddVariables( + { ...privateEpisode, feedUrl: placeholder }, + { text: "- 1:05", start: 65 }, + ), + ).toEqual({ + timestamp: "- 1:05", + time: "1:05", + seconds: "65", + link: "obsidian://podnotes?episodeName=Private%20episode&url=podnotes-private-feed%3APaid&guid=episode-1&time=65", + episode: "Private episode", + podcast: "Paid", + url: "https://private.example.com/episode?token=EPISODE_TOKEN", + episodeNote: "PodNotes/Paid/Private episode.md", + }); +}); + +test.each([ + ["subscribed", true, placeholder], + ["unsubscribed", false, address], +])("a %s private feed's address is in no value", (_, subscribed, feedUrl) => { + library(subscribed); + const quoting: Episode = { + ...privateEpisode, + feedUrl, + title: `Feed ${address}`, + url: `${address}"`, + }; + expect( + quickAddVariables(quoting, { text: `- 1:05 from ${address}`, start: 65, end: 70 }), + ).toEqual({ + timestamp: "- 1:05 from podnotes-private-feed:Paid", + time: "1:05-1:10", + seconds: "65", + link: "", + episode: "Feed podnotes-private-feed:Paid", + podcast: "Paid", + url: "podnotes-private-feed:Paid%22", + episodeNote: "PodNotes/Paid/Feed podnotes-private-feedPaid.md", + }); +}); diff --git a/src/services/CaptureService.ts b/src/services/CaptureService.ts index 4f387cfe..8477f941 100644 --- a/src/services/CaptureService.ts +++ b/src/services/CaptureService.ts @@ -7,9 +7,14 @@ import { type TFile, type WorkspaceLeaf, } from "obsidian"; -import { TimestampTemplateEngine } from "src/TemplateEngine"; -import { createPodcastNoteFileIfNotExists } from "src/createPodcastNote"; +import type App2 from "src/global"; +import { resolveEpisodeUrl, TimestampTemplateEngine } from "src/TemplateEngine"; +import { createPodcastNoteFileIfNotExists, getPodcastNotePath } from "src/createPodcastNote"; +import { withoutPrivateFeedAddresses } from "src/services/privateFeeds"; +import type { Episode } from "src/types/Episode"; import { noticeWithUndo } from "src/ui/undoNotice"; +import { episodeTimestampLink } from "src/utility/formatEpisodeTimestamp"; +import { formatDuration } from "src/utility/formatDuration"; import { prepareTimestampForInsertion } from "src/utility/prepareTimestampInsertion"; import { createRecentPodcastSegment, getSegmentCaptureTemplate } from "src/utility/podcastSegment"; import type PodNotes from "src/main"; @@ -24,6 +29,40 @@ export interface Capture { created: boolean; } +/** A capture's text and the position it marks: a moment, or a segment's range. */ +interface Rendered { + readonly text: string; + readonly start: number; + readonly end?: number; +} + +/** What a QuickAdd choice reads about a capture, as {{VALUE:}}. */ +export type QuickAddVariables = Record< + "timestamp" | "time" | "seconds" | "link" | "episode" | "podcast" | "url" | "episodeNote", + string +>; + +export function quickAddVariables( + episode: Episode, + { text, start, end }: Rendered, +): QuickAddVariables { + const variables: QuickAddVariables = { + timestamp: text, + time: + end === undefined + ? formatDuration(start) + : `${formatDuration(start)}-${formatDuration(end)}`, + seconds: String(Math.floor(start)), + link: episodeTimestampLink(episode, start, end), + episode: episode.title, + podcast: episode.podcastName, + url: resolveEpisodeUrl(withoutPrivateFeedAddresses(episode)), + episodeNote: getPodcastNotePath(episode), + }; + // The episode names its feed's address, so redaction finds it even when no saved feed does. + return withoutPrivateFeedAddresses([variables, episode] as const)[0]; +} + /** A note's text as a capture found it and as it left it, and the caret it found in its editor. */ interface Snapshot { readonly before: string; @@ -62,7 +101,11 @@ export class CaptureService { } timestamp(): Promise { - return this.capture((template) => TimestampTemplateEngine(template)); + const { api, settings } = this.plugin; + return this.capture((template) => ({ + text: TimestampTemplateEngine(template), + start: Math.max(0, api.currentTime - (settings.timestamp.offset ?? 0)), + })); } segment(seconds: number): Promise { @@ -76,11 +119,15 @@ export class CaptureService { new Notice("Play more of the episode before capturing a segment"); return null; } - return TimestampTemplateEngine(getSegmentCaptureTemplate(template), { segment }); + return { + text: TimestampTemplateEngine(getSegmentCaptureTemplate(template), { segment }), + start: segment.startTime, + end: segment.endTime, + }; }); } - private async capture(render: (template: string) => string | null): Promise { + private async capture(render: (template: string) => Rendered | null): Promise { const { api, settings } = this.plugin; const episode = api.podcast; if (!episode) { @@ -91,8 +138,14 @@ export class CaptureService { new Notice("Set a Timestamp template in settings to capture a timestamp."); return null; } - const text = render(settings.timestamp.template); - if (text === null) return null; + const rendered = render(settings.timestamp.template); + if (rendered === null) return null; + const choice = settings.timestamp.quickAddChoice?.trim(); + if (choice) { + await this.runQuickAdd(choice, quickAddVariables(episode, rendered)); + return null; + } + const { text } = rendered; const target = this.editorTarget(); if (target?.editor) { @@ -150,6 +203,19 @@ export class CaptureService { } } + private async runQuickAdd(choice: string, variables: QuickAddVariables): Promise { + const quickAdd = (this.plugin.app as App2).plugins.plugins.quickadd?.api; + if (!quickAdd) { + new Notice("Install and enable QuickAdd to capture with a QuickAdd choice"); + return; + } + try { + await quickAdd.executeChoice(choice, variables); + } catch (error) { + console.error("PodNotes: the QuickAdd choice failed", error); + } + } + /** * Says where the capture went, with an Undo that takes it back. `undo` returns false, * leaving the note as it is, unless the note is still exactly as the capture left it. diff --git a/src/utility/formatEpisodeTimestamp.ts b/src/utility/formatEpisodeTimestamp.ts index e2785214..c60c82fa 100644 --- a/src/utility/formatEpisodeTimestamp.ts +++ b/src/utility/formatEpisodeTimestamp.ts @@ -16,8 +16,16 @@ export function formatEpisodeTimestamp( endSeconds === undefined ? formatSeconds(seconds, format) : formatPodcastSegment(seconds, endSeconds, format); - const target = linkify ? vaultSafeLinkTarget(episode, episodeLinkTarget(episode)) : undefined; - return target - ? `[${time}](${encodePodnotesURI(episode, target, seconds, endSeconds).href})` - : time; + const link = linkify ? episodeTimestampLink(episode, seconds, endSeconds) : ""; + return link ? `[${time}](${link})` : time; +} + +/** The obsidian://podnotes link that plays `episode` from `seconds`, or "" when no link may address it. */ +export function episodeTimestampLink( + episode: Episode, + seconds: number, + endSeconds?: number, +): string { + const target = vaultSafeLinkTarget(episode, episodeLinkTarget(episode)); + return target ? encodePodnotesURI(episode, target, seconds, endSeconds).href : ""; } From 7361c1a80077c5e9351e75acc9666a30d0a9a96a Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sun, 18 Oct 2026 13:32:24 +0200 Subject: [PATCH 03/11] test(e2e): capture through a real QuickAdd choice --- tests/e2e/quickadd-capture.test.ts | 320 +++++++++++++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 tests/e2e/quickadd-capture.test.ts diff --git a/tests/e2e/quickadd-capture.test.ts b/tests/e2e/quickadd-capture.test.ts new file mode 100644 index 00000000..061f09b1 --- /dev/null +++ b/tests/e2e/quickadd-capture.test.ts @@ -0,0 +1,320 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, onTestFinished, test } from "vitest"; +import { createPodNotesE2EHarness } from "./harness"; +import { runCommand, showLibrary, showPodNotes } from "./journey/actions"; +import { startFixtureServer, type FixtureFeed, type FixtureServer } from "./journey/fixtures"; +import { click } from "./journey/input"; +import { readVaultText, seedLibrary, waitForPlayback } from "./journey/library"; +import { ui } from "./journey/targets"; +import { eventually, notices } from "./journey/wait"; +import { jsLiteral, PLUGIN_ID } from "./runtime"; +import type { IPodNotesSettings } from "../../src/types/IPodNotesSettings"; + +const getContext = createPodNotesE2EHarness("quickadd-capture"); + +const QUICKADD_VERSION = "2.32.1"; +const QUICKADD_SHA256: Record = { + "main.js": "6423b2d14b60bc51fc6b6f61f835e08c5842d28e5a1a43ab5601ccd37b4e44c5", + "manifest.json": "b7610b4c1d37c2c73f781c294c1a380e731e797f9858d2d825e7facc1ac3add1", +}; +const QUICKADD_CACHE = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + `../../node_modules/.cache/podnotes-e2e/quickadd-${QUICKADD_VERSION}`, +); +const CHOICE = "Log timestamp"; +const EVIDENCE = path.resolve(".obsidian-e2e-artifacts/quickadd-capture"); + +const FEED: FixtureFeed = { + title: "QuickAdd podcast", + slug: "quickadd", + artwork: false, + episodes: [{ title: "Captured episode", seconds: 60, guid: "captured-episode" }], +}; +const NOTE_TEMPLATE = "# {{title}}\n\n## Notes\n- Earlier thought\n\n## Later\n"; + +/** QuickAdd's released files, downloaded once and checked on every use. */ +async function quickAddRelease(): Promise> { + const files = new Map(); + await fs.mkdir(QUICKADD_CACHE, { recursive: true }); + for (const [name, sha256] of Object.entries(QUICKADD_SHA256)) { + const cached = path.join(QUICKADD_CACHE, name); + let bytes = await fs.readFile(cached).catch(() => null); + if (!bytes) { + const response = await fetch( + `https://github.com/chhoumann/quickadd/releases/download/${QUICKADD_VERSION}/${name}`, + ); + if (!response.ok) throw new Error(`QuickAdd ${name}: HTTP ${response.status}`); + bytes = Buffer.from(await response.arrayBuffer()); + } + const actual = createHash("sha256").update(bytes).digest("hex"); + if (actual !== sha256) { + await fs.rm(cached, { force: true }); + throw new Error(`QuickAdd ${name} has SHA-256 ${actual}, expected ${sha256}`); + } + await fs.writeFile(cached, bytes); + files.set(name, bytes); + } + return files; +} + +/** QuickAdd's data.json with one Capture choice, already migrated and announced. */ +const quickAddData = { + version: QUICKADD_VERSION, + announceUpdates: "none", + migrations: Object.fromEntries( + [ + "useQuickAddTemplateFolder", + "incrementFileNameSettingMoveToDefaultBehavior", + "consolidateFileExistsBehavior", + "repairTemplateFileExistsBehavior", + "mutualExclusionInsertAfterAndWriteToBottomOfFile", + "setVersionAfterUpdateModalRelease", + "addDefaultAIProviders", + "removeMacroIndirection", + "migrateFileOpeningSettings", + "backfillFileOpeningDefaults", + "setProviderModelDiscoveryMode", + "migrateProviderApiKeysToSecretStorage", + "migrateToMultipleTemplateFolders", + "refreshStaleDefaultModelSeeds", + "pinAiModelRefs", + ].map((migration) => [migration, true]), + ), + choices: [ + { + id: "podnotes-e2e-capture", + name: CHOICE, + type: "Capture", + command: false, + captureTo: "{{VALUE:episodeNote}}", + captureToActiveFile: false, + captureToCanvasNodeId: "", + activeFileWritePosition: "cursor", + createFileIfItDoesntExist: { enabled: false, createWithTemplate: false, template: "" }, + format: { enabled: true, format: "- {{VALUE:timestamp}}" }, + prepend: false, + appendLink: false, + copyLinkToClipboard: false, + task: false, + insertAfter: { + enabled: true, + after: "## Notes", + insertAtEnd: false, + considerSubsections: false, + createIfNotFound: false, + createIfNotFoundLocation: "top", + inline: false, + replaceExisting: false, + blankLineAfterMatchMode: "auto", + promptHeading: false, + }, + insertBefore: { + enabled: false, + before: "", + createIfNotFound: false, + createIfNotFoundLocation: "top", + }, + newLineCapture: { enabled: false, direction: "below" }, + openFile: false, + fileOpening: { location: "tab", direction: "vertical", mode: "default", focus: true }, + templater: { afterCapture: "none" }, + }, + ], +}; + +/** Installs and enables the real QuickAdd release for this test only. */ +async function enableQuickAdd(): Promise { + const { obsidian } = getContext(); + const files = await quickAddRelease(); + const configDir = await obsidian.dev.evalJson("app.vault.configDir"); + const directory = path.join(await obsidian.vaultPath(), configDir, "plugins", "quickadd"); + onTestFinished(async () => { + await obsidian.dev.evalJsonAsync(`(async () => { + const quickAdd = app.plugins.plugins.quickadd; + await app.plugins.disablePlugin("quickadd"); + await quickAdd?.persistChain; + return true; + })()`); + await fs.rm(directory, { recursive: true, force: true }); + await obsidian.dev.evalJsonAsync( + "(async () => { await app.plugins.loadManifests(); return true; })()", + ); + }); + await fs.mkdir(directory, { recursive: true }); + await fs.writeFile(path.join(directory, "data.json"), JSON.stringify(quickAddData)); + await fs.writeFile(path.join(directory, "main.js"), files.get("main.js")!); + await fs.writeFile(path.join(directory, "manifest.json"), files.get("manifest.json")!); + expect( + await obsidian.dev.evalJsonAsync(`(async () => { + await app.plugins.loadManifests(); + await app.plugins.enablePlugin("quickadd"); + const quickAdd = app.plugins.plugins.quickadd; + return { + version: quickAdd?.manifest.version, + choices: quickAdd?.settings.choices.map(({ name }) => name), + saved: app.plugins.enabledPlugins.has("quickadd"), + }; + })()`), + "QuickAdd loads in memory only, with the seeded choice", + ).toEqual({ version: QUICKADD_VERSION, choices: [CHOICE], saved: false }); +} + +/** Plays the fixture episode, pauses it at `seconds`, and opens its new episode note. */ +async function noteAt(seconds: number): Promise<{ server: FixtureServer; notePath: string }> { + const context = getContext(); + const { obsidian, plugin, sandbox } = context; + const server = await startFixtureServer([FEED]); + onTestFinished(() => server.stop()); + await seedLibrary(context, server, { subscriptions: [FEED], noteTemplate: NOTE_TEMPLATE }); + await plugin.updateDataAndReload((data) => { + data.timestamp.template = "{{linktime}}"; + data.timestamp.quickAddChoice = CHOICE; + }); + await showPodNotes(obsidian); + await showLibrary(obsidian); + await click(obsidian, ui.podcastCard(FEED.title)); + await click(obsidian, ui.episode("Captured episode")); + await waitForPlayback(obsidian, (state) => (state.media?.seconds ?? 0) > 0, "episode loaded"); + await click(obsidian, ui.playbackToggle); + await waitForPlayback(obsidian, (state) => !state.playing, "episode paused"); + await seek(seconds); + const notePath = sandbox.path("PodNotes/QuickAdd podcast/Captured episode.md"); + onTestFinished(() => + obsidian.dev.evalJsonAsync(`(async () => { + const file = app.vault.getFileByPath(${jsLiteral(notePath)}); + if (file) await app.vault.delete(file); + return true; + })()`), + ); + await runCommand(obsidian, `${PLUGIN_ID}:create-podcast-note`); + await sandbox.waitForExists("PodNotes/QuickAdd podcast/Captured episode.md", { + timeoutMs: 10_000, + }); + return { server, notePath }; +} + +async function seek(seconds: number): Promise { + await getContext().obsidian.dev.evalJson( + `app.plugins.plugins.${PLUGIN_ID}.api.currentTime = ${jsLiteral(seconds)}`, + ); +} + +function episodeNote(server: FixtureServer, captured: number[]): string { + const link = (seconds: number) => + `- [00:00:${seconds}](obsidian://podnotes?episodeName=Captured%20episode&url=${encodeURIComponent(server.feedUrl(FEED))}&guid=captured-episode&time=${seconds})\n`; + return [ + "---\n", + 'guid: "captured-episode"\n', + `feedUrl: "${server.feedUrl(FEED)}"\n`, + "---\n", + "# Captured episode\n\n## Notes\n", + ...captured.map(link), + "- Earlier thought\n\n## Later\n", + ].join(""); +} + +describe("captures with a QuickAdd choice", () => { + test("the command, the player and the headset run the choice, which writes after the heading", async () => { + const { obsidian } = getContext(); + await obsidian.dev.evalJson(`(() => { + const session = navigator.mediaSession; + const original = session.setActionHandler; + const handlers = new Map(); + session.setActionHandler = function(action, handler) { + handlers.set(action, handler); + return original.call(this, action, handler); + }; + window.__quickAddMedia = { session, original, handlers }; + return true; + })()`); + onTestFinished(async () => { + await obsidian.dev.evalJson(`(() => { + const recorder = window.__quickAddMedia; + if (recorder) recorder.session.setActionHandler = recorder.original; + delete window.__quickAddMedia; + return true; + })()`); + }); + await enableQuickAdd(); + const { server, notePath } = await noteAt(30); + expect(await readVaultText(obsidian, notePath), "the new episode note").toBe( + episodeNote(server, []), + ); + + await runCommand(obsidian, `${PLUGIN_ID}:capture-timestamp`); + expect( + await eventually( + obsidian, + () => readVaultText(obsidian, notePath), + episodeNote(server, [30]), + 10_000, + ), + "the command's capture", + ).toBe(episodeNote(server, [30])); + + await seek(40); + await click(obsidian, ui.playerTimestamp); + expect( + await eventually( + obsidian, + () => readVaultText(obsidian, notePath), + episodeNote(server, [40, 30]), + 10_000, + ), + "the player's capture", + ).toBe(episodeNote(server, [40, 30])); + + await seek(50); + await obsidian.dev.evalJson(`(() => { + const handler = window.__quickAddMedia.handlers.get("previoustrack"); + if (!handler) throw new Error("The headset capture handler is not installed"); + handler({ action: "previoustrack" }); + return true; + })()`); + expect( + await eventually( + obsidian, + () => readVaultText(obsidian, notePath), + episodeNote(server, [50, 40, 30]), + 10_000, + ), + "the headset's capture", + ).toBe(episodeNote(server, [50, 40, 30])); + expect(await notices(obsidian)).not.toContain("Timestamp added to Captured episode"); + expect((await obsidian.exec("dev:errors")).stdout.trim()).toBe("No errors captured."); + await fs.mkdir(EVIDENCE, { recursive: true }); + await obsidian.dev.screenshot(path.join(EVIDENCE, "episode-note.png")); + }, 90_000); + + test("without QuickAdd enabled, a capture says so and changes no note", async () => { + const { obsidian } = getContext(); + const { server, notePath } = await noteAt(30); + const editorText = () => + obsidian.dev.evalJson( + `app.workspace.getLeavesOfType("markdown").find((leaf) => leaf.view.file?.path === ${jsLiteral(notePath)})?.view.editor.getValue() ?? null`, + ); + expect( + await eventually(obsidian, editorText, episodeNote(server, [])), + "the episode note open in its editor", + ).toBe(episodeNote(server, [])); + + await runCommand(obsidian, `${PLUGIN_ID}:capture-timestamp`); + + expect( + await eventually( + obsidian, + async () => + (await notices(obsidian)).includes( + "Install and enable QuickAdd to capture with a QuickAdd choice", + ), + true, + ), + ).toBe(true); + expect(await editorText()).toBe(episodeNote(server, [])); + expect(await readVaultText(obsidian, notePath)).toBe(episodeNote(server, [])); + expect((await obsidian.exec("dev:errors")).stdout.trim()).toBe("No errors captured."); + }, 60_000); +}); From 5df7047b79afb197f8bf762f8764967eb1dce17b Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sun, 18 Oct 2026 14:02:42 +0200 Subject: [PATCH 04/11] test(e2e): list the QuickAdd choice row and cache QuickAdd beside other releases --- tests/e2e/quickadd-capture.test.ts | 25 +++++++++++-------------- tests/e2e/settings-tab.test.ts | 1 + 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/tests/e2e/quickadd-capture.test.ts b/tests/e2e/quickadd-capture.test.ts index 061f09b1..cfa68a9c 100644 --- a/tests/e2e/quickadd-capture.test.ts +++ b/tests/e2e/quickadd-capture.test.ts @@ -22,7 +22,7 @@ const QUICKADD_SHA256: Record = { }; const QUICKADD_CACHE = path.resolve( path.dirname(fileURLToPath(import.meta.url)), - `../../node_modules/.cache/podnotes-e2e/quickadd-${QUICKADD_VERSION}`, + `../../.obsidian-e2e-artifacts/releases/quickadd-${QUICKADD_VERSION}`, ); const CHOICE = "Log timestamp"; const EVIDENCE = path.resolve(".obsidian-e2e-artifacts/quickadd-capture"); @@ -35,26 +35,23 @@ const FEED: FixtureFeed = { }; const NOTE_TEMPLATE = "# {{title}}\n\n## Notes\n- Earlier thought\n\n## Later\n"; -/** QuickAdd's released files, downloaded once and checked on every use. */ +const sha256 = (bytes: Buffer) => createHash("sha256").update(bytes).digest("hex"); + +/** QuickAdd's released files, cached in the git-ignored artifacts folder. */ async function quickAddRelease(): Promise> { const files = new Map(); await fs.mkdir(QUICKADD_CACHE, { recursive: true }); - for (const [name, sha256] of Object.entries(QUICKADD_SHA256)) { + for (const [name, digest] of Object.entries(QUICKADD_SHA256)) { const cached = path.join(QUICKADD_CACHE, name); let bytes = await fs.readFile(cached).catch(() => null); - if (!bytes) { - const response = await fetch( - `https://github.com/chhoumann/quickadd/releases/download/${QUICKADD_VERSION}/${name}`, - ); - if (!response.ok) throw new Error(`QuickAdd ${name}: HTTP ${response.status}`); + if (!bytes || sha256(bytes) !== digest) { + const url = `https://github.com/chhoumann/quickadd/releases/download/${QUICKADD_VERSION}/${name}`; + const response = await fetch(url); + if (!response.ok) throw new Error(`${url} answered ${response.status}.`); bytes = Buffer.from(await response.arrayBuffer()); + expect(sha256(bytes), `the QuickAdd ${QUICKADD_VERSION} ${name} digest`).toBe(digest); + await fs.writeFile(cached, bytes); } - const actual = createHash("sha256").update(bytes).digest("hex"); - if (actual !== sha256) { - await fs.rm(cached, { force: true }); - throw new Error(`QuickAdd ${name} has SHA-256 ${actual}, expected ${sha256}`); - } - await fs.writeFile(cached, bytes); files.set(name, bytes); } return files; diff --git a/tests/e2e/settings-tab.test.ts b/tests/e2e/settings-tab.test.ts index 8d4d8120..3df12295 100644 --- a/tests/e2e/settings-tab.test.ts +++ b/tests/e2e/settings-tab.test.ts @@ -115,6 +115,7 @@ describe("PodNotes settings tab", () => { "Next track", "Timestamp template", "Timestamp offset (s)", + "QuickAdd choice", "Episode note path", "Episode note template", "Podcast note path", From a25b975236ed5cc0293189b411ce8c2869349583 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sun, 18 Oct 2026 14:02:42 +0200 Subject: [PATCH 05/11] docs(capture): note that QuickAdd variables are plain text --- src/services/CaptureService.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/services/CaptureService.ts b/src/services/CaptureService.ts index 8477f941..8c0f00fe 100644 --- a/src/services/CaptureService.ts +++ b/src/services/CaptureService.ts @@ -36,7 +36,11 @@ interface Rendered { readonly end?: number; } -/** What a QuickAdd choice reads about a capture, as {{VALUE:}}. */ +/** + * What a QuickAdd choice reads about a capture, as {{VALUE:}}. Values are plain text, + * not escaped for a note body as note templates are, since a choice can also put them in a + * path or a property. + */ export type QuickAddVariables = Record< "timestamp" | "time" | "seconds" | "link" | "episode" | "podcast" | "url" | "episodeNote", string From 74556c5f3c2326bc386c1a86f03a17bc3310cc7f Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sun, 18 Oct 2026 21:42:11 +0200 Subject: [PATCH 06/11] feat(settings): pick the QuickAdd choice in QuickAdd's own picker The QuickAdd choice row no longer takes a typed name. Choose... opens QuickAdd's choice picker, and PodNotes stores the picked choice's id, so a choice renamed in QuickAdd keeps working and the row shows its current name. The x button clears the setting and leaves QuickAdd alone. A capture runs the choice by its id. When QuickAdd is not installed, is disabled, predates choice picking, or no longer has the choice, the capture shows one notice that names the fix and writes nothing. The row's button says Install, Enable or Update QuickAdd, or Missing choice, and the first three open Community plugins. The unreleased timestamp.quickAddChoice (a name) becomes timestamp.quickAddChoiceId. src/quickAdd.ts is the one place that knows QuickAdd's API. --- src/commands.test.ts | 62 ++- src/global.d.ts | 8 +- src/persistence/podNotesData.test.ts | 17 +- src/persistence/settingsCodec.ts | 2 +- src/quickAdd.ts | 30 ++ src/services/CaptureService.ts | 23 +- src/types/IPodNotesSettings.ts | 4 +- src/ui/settings/PodNotesSettingsTab.test.ts | 135 ++++++- src/ui/settings/PodNotesSettingsTab.ts | 67 +++- tests/e2e/quickadd-capture.test.ts | 397 +++++++++++++++----- tests/e2e/settings-tab.test.ts | 17 +- tests/mocks/obsidian.ts | 38 +- tests/mocks/quickAdd.ts | 40 ++ vitest.setup.dom.ts | 9 + 14 files changed, 666 insertions(+), 183 deletions(-) create mode 100644 src/quickAdd.ts create mode 100644 tests/mocks/quickAdd.ts diff --git a/src/commands.test.ts b/src/commands.test.ts index 1e55476b..1ce81745 100644 --- a/src/commands.test.ts +++ b/src/commands.test.ts @@ -11,6 +11,7 @@ import { fetchTextWithTimeout } from "./utility/networkRequest"; import { privateFeedPlaceholder } from "./utility/privateFeedUrl"; import { dataFileText } from "../tests/mocks/dataFileWriter"; import { fakeEditor, editInTableCell } from "../tests/mocks/captureEditor"; +import { fakeQuickAdd, unusableQuickAdd } from "../tests/mocks/quickAdd"; vi.mock("./utility/networkRequest", async (importOriginal) => ({ ...(await importOriginal()), @@ -253,14 +254,12 @@ async function undo() { const REFUSED = "The note isn't as the capture left it, so the timestamp stays"; -/** QuickAdd as Obsidian holds it while it is enabled, with "Log" set as the QuickAdd choice. */ -function withQuickAdd( - plugin: PodNotes, - executeChoice = vi.fn(async (_choice: string, _variables: Record) => {}), -) { - Object.assign(plugin.app, { plugins: { plugins: { quickadd: { api: { executeChoice } } } } }); - plugin.settings.timestamp.quickAddChoice = "Log"; - return executeChoice; +/** QuickAdd as Obsidian holds it while it is enabled, with its choice "Log" set as the QuickAdd choice. */ +function withQuickAdd(plugin: PodNotes) { + const quickAdd = fakeQuickAdd({ log: "Log" }); + Object.assign(plugin.app, { plugins: quickAdd.plugins }); + plugin.settings.timestamp.quickAddChoiceId = "log"; + return quickAdd.api.executeChoice; } /** A note's tab, and the changes a user can make to it. */ @@ -879,8 +878,8 @@ describe("capture", () => { await vi.waitFor(() => expect(executeChoice).toHaveBeenCalledTimes(2)); expect(executeChoice.mock.calls.map(([choice, { time }]) => [choice, time])).toEqual([ - ["Log", "20:24-20:34"], - ["Log", "20:34"], + [{ id: "log" }, "20:24-20:34"], + [{ id: "log" }, "20:34"], ]); expect(notes.size).toBe(0); }); @@ -898,13 +897,15 @@ describe("capture", () => { episodeNote: NOTE_PATH, }; - it("runs it once with the capture, and writes nothing itself", async () => { + it("runs it by its id once with the capture, and writes nothing itself", async () => { const { plugin, run, notes } = await loadPlugin(linkedPlaying); const executeChoice = withQuickAdd(plugin); run("capture-timestamp"); - await vi.waitFor(() => expect(executeChoice.mock.calls).toEqual([["Log", moment]])); + await vi.waitFor(() => + expect(executeChoice.mock.calls).toEqual([[{ id: "log" }, moment]]), + ); expect(notes.size).toBe(0); expect(said()).toEqual([]); }); @@ -920,7 +921,7 @@ describe("capture", () => { await vi.waitFor(() => expect(executeChoice.mock.calls).toEqual([ [ - "Log", + { id: "log" }, { ...moment, timestamp: "- 00:20:24-00:20:34", @@ -938,7 +939,7 @@ describe("capture", () => { it("captures as before when the choice is blank", async () => { const { plugin, run, openNote, focusPodNotes } = await loadPlugin(linkedPlaying); const executeChoice = withQuickAdd(plugin); - plugin.settings.timestamp.quickAddChoice = " "; + plugin.settings.timestamp.quickAddChoiceId = ""; const { editor } = openNote("Journal.md", "Notes\n"); focusPodNotes(); @@ -949,10 +950,32 @@ describe("capture", () => { expect(executeChoice.mock.calls).toEqual([]); }); - it("says QuickAdd is needed, and writes nothing, while QuickAdd is not enabled", async () => { + it.each(["Install", "Enable", "Update"] as const)( + "says %s QuickAdd, and writes nothing, until QuickAdd can run the choice", + async (fix) => { + const { plugin, run, openNote, focusPodNotes, notes } = + await loadPlugin(linkedPlaying); + const { executeChoice, plugins } = unusableQuickAdd(fix); + Object.assign(plugin.app, { plugins }); + plugin.settings.timestamp.quickAddChoiceId = "log"; + const { editor } = openNote("Journal.md", "Notes\n"); + focusPodNotes(); + + run("capture-timestamp"); + + await vi.waitFor(() => + expect(said()).toEqual([`${fix} QuickAdd to capture with a QuickAdd choice`]), + ); + expect(executeChoice).not.toHaveBeenCalled(); + expect(editor.getValue()).toBe("Notes\n"); + expect(notes.size).toBe(0); + }, + ); + + it("says the choice no longer exists, and writes nothing, once QuickAdd deletes it", async () => { const { plugin, run, openNote, focusPodNotes, notes } = await loadPlugin(linkedPlaying); - withQuickAdd(plugin); - Object.assign(plugin.app, { plugins: { plugins: {} } }); + const executeChoice = withQuickAdd(plugin); + plugin.settings.timestamp.quickAddChoiceId = "deleted"; const { editor } = openNote("Journal.md", "Notes\n"); focusPodNotes(); @@ -960,9 +983,10 @@ describe("capture", () => { await vi.waitFor(() => expect(said()).toEqual([ - "Install and enable QuickAdd to capture with a QuickAdd choice", + "The QuickAdd choice no longer exists. Choose another in PodNotes settings.", ]), ); + expect(executeChoice).not.toHaveBeenCalled(); expect(editor.getValue()).toBe("Notes\n"); expect(notes.size).toBe(0); }); @@ -970,7 +994,7 @@ describe("capture", () => { it("logs a choice that fails or is cancelled, and leaves no unhandled rejection", async () => { const { plugin, run } = await loadPlugin(linkedPlaying); const failure = new Error("Input cancelled by user"); - withQuickAdd(plugin, vi.fn().mockRejectedValue(failure)); + withQuickAdd(plugin).mockRejectedValue(failure); const logged = vi.spyOn(console, "error").mockImplementation(() => {}); const unhandled: unknown[] = []; const listen = (reason: unknown) => unhandled.push(reason); diff --git a/src/global.d.ts b/src/global.d.ts index 0f5d347e..119ef79b 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -26,13 +26,7 @@ interface App2 extends App { manifest: Manifest; app: App; } - > & { - quickadd?: { - api: { - executeChoice(choice: string, variables: Record): Promise; - }; - }; - }; + >; requestSaveConfig: () => void; updates: Record; }; diff --git a/src/persistence/podNotesData.test.ts b/src/persistence/podNotesData.test.ts index efadd5d1..d4a3a152 100644 --- a/src/persistence/podNotesData.test.ts +++ b/src/persistence/podNotesData.test.ts @@ -942,14 +942,14 @@ describe("QuickAdd choice", () => { const persisted = ({ settings, unknownFields }: ReturnType) => JSON.stringify(encodePodNotesData(settings, unknownFields).timestamp); - it("keeps the choice through load and save", () => { - const decoded = saved({ template: "- {{time}} ", offset: 0, quickAddChoice: "Log" }); + it("keeps the choice's id through load and save", () => { + const decoded = saved({ template: "- {{time}} ", offset: 0, quickAddChoiceId: "log" }); expect(decoded.warnings).toEqual([]); expect(decoded.changed).toBe(false); - expect(decoded.settings.timestamp.quickAddChoice).toBe("Log"); + expect(decoded.settings.timestamp.quickAddChoiceId).toBe("log"); expect(persisted(decoded)).toBe( - '{"template":"- {{time}} ","offset":0,"quickAddChoice":"Log"}', + '{"template":"- {{time}} ","offset":0,"quickAddChoiceId":"log"}', ); }); @@ -962,14 +962,15 @@ describe("QuickAdd choice", () => { ); }); - it("drops a choice that is not text, with a warning", () => { - const decoded = saved({ template: "- {{time}} ", offset: 0, quickAddChoice: 42 }); + it("drops a choice id that is not text, with a warning", () => { + const decoded = saved({ template: "- {{time}} ", offset: 0, quickAddChoiceId: 42 }); - expect(decoded.warnings).toEqual(["timestamp.quickAddChoice: expected a string"]); + expect(decoded.warnings).toEqual(["timestamp.quickAddChoiceId: expected a string"]); expect(persisted(decoded)).toBe('{"template":"- {{time}} ","offset":0}'); }); - // 2.22.0 decodes the timestamp setting as this version decodes a field it does not know. + // 2.22.0 knows no quickAddChoiceId, and decodes the timestamp setting as this version + // decodes a field it does not know, so a 2.22.0 device keeps the id when it saves. it("keeps a timestamp field this version does not know through load and save", () => { const decoded = saved({ template: "- {{time}} ", offset: 0, nextField: "kept" }); diff --git a/src/persistence/settingsCodec.ts b/src/persistence/settingsCodec.ts index 2de3db0c..78487091 100644 --- a/src/persistence/settingsCodec.ts +++ b/src/persistence/settingsCodec.ts @@ -158,7 +158,7 @@ function decodeTimestamp(value: unknown, warnings: Set): IPodNotesSettin "timestamp", ), }; - setOptionalString(timestamp, record, "quickAddChoice", warnings, "timestamp"); + setOptionalString(timestamp, record, "quickAddChoiceId", warnings, "timestamp"); return timestamp; } diff --git a/src/quickAdd.ts b/src/quickAdd.ts new file mode 100644 index 00000000..940a1f5b --- /dev/null +++ b/src/quickAdd.ts @@ -0,0 +1,30 @@ +import type { App } from "obsidian"; +import type App2 from "./global"; + +interface QuickAddChoice { + id: string; + name: string; +} + +/** What PodNotes calls on QuickAdd's API. QuickAdd added all of it with choice picking. */ +interface QuickAddApi { + getChoice(ref: { id: string }): QuickAddChoice | null; + /** Opens QuickAdd's choice picker. Rejects when the user dismisses it. */ + pickChoice(): Promise; + executeChoice(ref: { id: string }, variables: Record): Promise; +} + +/** QuickAdd's API, or what the user has to do before PodNotes can use it. */ +type QuickAdd = { api: QuickAddApi } | { fix: "Install" | "Enable" | "Update" }; + +/** `pickChoice` arrived with everything else PodNotes calls, so it stands in for a version check. */ +export function findQuickAdd(app: App): QuickAdd { + const { plugins, manifests } = (app as App2).plugins; + const quickAdd = plugins.quickadd as unknown as { api: Partial } | undefined; + const api = quickAdd?.api; + if (typeof api?.pickChoice === "function") return { api: api as QuickAddApi }; + return { fix: quickAdd ? "Update" : manifests.quickadd ? "Enable" : "Install" }; +} + +export const MISSING_CHOICE = + "The QuickAdd choice no longer exists. Choose another in PodNotes settings."; diff --git a/src/services/CaptureService.ts b/src/services/CaptureService.ts index 8c0f00fe..92e7ee8f 100644 --- a/src/services/CaptureService.ts +++ b/src/services/CaptureService.ts @@ -7,7 +7,7 @@ import { type TFile, type WorkspaceLeaf, } from "obsidian"; -import type App2 from "src/global"; +import { findQuickAdd, MISSING_CHOICE } from "src/quickAdd"; import { resolveEpisodeUrl, TimestampTemplateEngine } from "src/TemplateEngine"; import { createPodcastNoteFileIfNotExists, getPodcastNotePath } from "src/createPodcastNote"; import { withoutPrivateFeedAddresses } from "src/services/privateFeeds"; @@ -144,9 +144,9 @@ export class CaptureService { } const rendered = render(settings.timestamp.template); if (rendered === null) return null; - const choice = settings.timestamp.quickAddChoice?.trim(); - if (choice) { - await this.runQuickAdd(choice, quickAddVariables(episode, rendered)); + const choiceId = settings.timestamp.quickAddChoiceId; + if (choiceId) { + await this.runQuickAdd(choiceId, quickAddVariables(episode, rendered)); return null; } const { text } = rendered; @@ -207,14 +207,19 @@ export class CaptureService { } } - private async runQuickAdd(choice: string, variables: QuickAddVariables): Promise { - const quickAdd = (this.plugin.app as App2).plugins.plugins.quickadd?.api; - if (!quickAdd) { - new Notice("Install and enable QuickAdd to capture with a QuickAdd choice"); + /** Runs the QuickAdd choice. When QuickAdd can't, one notice names the fix and nothing is written. */ + private async runQuickAdd(id: string, variables: QuickAddVariables): Promise { + const quickAdd = findQuickAdd(this.plugin.app); + if ("fix" in quickAdd) { + new Notice(`${quickAdd.fix} QuickAdd to capture with a QuickAdd choice`); + return; + } + if (!quickAdd.api.getChoice({ id })) { + new Notice(MISSING_CHOICE); return; } try { - await quickAdd.executeChoice(choice, variables); + await quickAdd.api.executeChoice({ id }, variables); } catch (error) { console.error("PodNotes: the QuickAdd choice failed", error); } diff --git a/src/types/IPodNotesSettings.ts b/src/types/IPodNotesSettings.ts index 7e95978b..246e1011 100644 --- a/src/types/IPodNotesSettings.ts +++ b/src/types/IPodNotesSettings.ts @@ -41,8 +41,8 @@ export interface IPodNotesSettings { timestamp: { template: string; offset: number; - /** The QuickAdd choice a capture runs instead of writing the timestamp itself. */ - quickAddChoice?: string; + /** The id of the QuickAdd choice a capture runs instead of writing the timestamp itself. */ + quickAddChoiceId?: string; }; note: { diff --git a/src/ui/settings/PodNotesSettingsTab.test.ts b/src/ui/settings/PodNotesSettingsTab.test.ts index 193d9507..a0daf4e8 100644 --- a/src/ui/settings/PodNotesSettingsTab.test.ts +++ b/src/ui/settings/PodNotesSettingsTab.test.ts @@ -14,6 +14,7 @@ import type { IPodNotesSettings } from "src/types/IPodNotesSettings"; import { PodNotesSettingsTab } from "./PodNotesSettingsTab"; import { attachDataFileWriter, dataFileText } from "../../../tests/mocks/dataFileWriter"; import { FakeDocument } from "../../../tests/mocks/fakeMedia"; +import { fakeQuickAdd, unusableQuickAdd } from "../../../tests/mocks/quickAdd"; const openTabs = new Set(); @@ -58,6 +59,8 @@ function open(saved: Partial = {}) { manifest: { dir: ".obsidian/plugins/podnotes", version: "3.0.0" }, app: { secretStorage: new obsidian.SecretStorage(), + plugins: unusableQuickAdd("Install").plugins, + setting: { openTabById: vi.fn() }, vault: { adapter: { exists: vi.fn().mockResolvedValue(true), @@ -196,10 +199,10 @@ describe("sections", () => { test("each field is named after its row where it takes focus, without an aria-label Obsidian would show as a tooltip", () => { const { tab } = open(); const fields = [ - ...tab.containerEl.querySelectorAll( - '.setting-item-control :is(input:not([tabindex="-1"]), select, textarea, [tabindex="0"], .setting-secret-value + button)', + ...tab.containerEl.querySelectorAll( + '.setting-item-control :is(input:not([tabindex="-1"]), select, textarea, [tabindex="0"], button[aria-labelledby])', ), - ]; + ].filter((field) => field.style.display !== "none"); const rowOf = (field: Element) => field.closest(".setting-item")?.querySelector(".setting-item-name")?.textContent; @@ -232,7 +235,7 @@ describe("sections", () => { ]); for (const field of fields) { expect(field).toHaveAccessibleName( - field.matches("button") ? `Link... ${rowOf(field)}` : rowOf(field), + field.matches("button") ? `${field.textContent} ${rowOf(field)}` : rowOf(field), ); expect(field).not.toHaveAttribute("aria-label"); } @@ -294,22 +297,6 @@ describe("text fields", () => { expect(preview()).toBe("00:01:32"); }); - test("the QuickAdd choice shows the saved one and saves the one typed", async () => { - const { field, disk } = open({ - timestamp: { template: "- {{time}} ", offset: 0, quickAddChoice: "Log" }, - }); - expect(field("QuickAdd choice").value).toBe("Log"); - - retype(field("QuickAdd choice"), "Podcast log"); - complete(field("QuickAdd choice")); - - expect((await disk()).timestamp).toEqual({ - template: "- {{time}} ", - offset: 0, - quickAddChoice: "Podcast log", - }); - }); - test("each keystroke applies at once; the save waits for 500 ms without one and tells an open view", async () => { fakeTypingTime(); const { plugin, field, writes, disk } = open(); @@ -638,6 +625,114 @@ describe("text fields", () => { }); }); +describe("QuickAdd choice", () => { + const TIMESTAMP = { template: "- {{time}} ", offset: 0 }; + + /** Opens the tab with `plugins` as `app.plugins`. */ + function withQuickAdd(plugins: object, quickAddChoiceId?: string) { + const opened = open({ timestamp: { ...TIMESTAMP, quickAddChoiceId } }); + Object.assign(opened.plugin.app, { plugins }); + opened.tab.display(); + const controls = () => [ + ...opened + .row("QuickAdd choice") + .querySelectorAll(".setting-item-control > *"), + ]; + return { + ...opened, + /** The row's controls a user sees, by their text, or an icon by its tooltip. */ + shown: () => + controls() + .filter((control) => control.style.display !== "none") + .map((control) => control.textContent || control.getAttribute("aria-label")), + click: (label: string) => + controls() + .find( + (control) => + (control.textContent || control.getAttribute("aria-label")) === label, + )! + .click(), + }; + } + + test("Choose... opens QuickAdd's picker and stores the id of the choice picked", async () => { + const quickAdd = fakeQuickAdd({ log: "Log" }); + quickAdd.api.pickChoice.mockResolvedValue({ id: "log", name: "Log" }); + const { shown, click, disk } = withQuickAdd(quickAdd.plugins); + expect(shown()).toEqual(["Choose..."]); + + click("Choose..."); + + expect((await disk()).timestamp).toEqual({ ...TIMESTAMP, quickAddChoiceId: "log" }); + expect(shown()).toEqual(["Log", "Clear"]); + }); + + test("Escape in the picker keeps the choice set before", async () => { + const quickAdd = fakeQuickAdd({ log: "Log" }); + quickAdd.api.pickChoice.mockRejectedValue(new Error("No input given.")); + const { shown, click, writes, plugin } = withQuickAdd(quickAdd.plugins, "log"); + + click("Log"); + + expect(await writes()).toBe(0); + expect(quickAdd.api.pickChoice).toHaveBeenCalledOnce(); + expect(plugin.settings.timestamp.quickAddChoiceId).toBe("log"); + expect(shown()).toEqual(["Log", "Clear"]); + }); + + test("Clear unsets the choice and leaves QuickAdd's choices alone", async () => { + const quickAdd = fakeQuickAdd({ log: "Log" }); + const { shown, click, disk } = withQuickAdd(quickAdd.plugins, "log"); + + click("Clear"); + + expect((await disk()).timestamp).toEqual(TIMESTAMP); + expect(shown()).toEqual(["Choose..."]); + expect(quickAdd.choices).toEqual({ log: "Log" }); + }); + + test("shows the choice under its name in QuickAdd, after a rename too", () => { + const quickAdd = fakeQuickAdd({ log: "Log" }); + const { shown, tab } = withQuickAdd(quickAdd.plugins, "log"); + expect(shown()).toEqual(["Log", "Clear"]); + + quickAdd.choices.log = "Podcast log"; + tab.display(); + + expect(shown()).toEqual(["Podcast log", "Clear"]); + }); + + test("says Missing choice for a choice QuickAdd no longer has, and picks another", async () => { + const quickAdd = fakeQuickAdd({ log: "Log" }); + quickAdd.api.pickChoice.mockResolvedValue({ id: "log", name: "Log" }); + const { shown, click, disk } = withQuickAdd(quickAdd.plugins, "deleted"); + expect(shown()).toEqual(["Missing choice", "Clear"]); + + click("Missing choice"); + + expect((await disk()).timestamp.quickAddChoiceId).toBe("log"); + expect(shown()).toEqual(["Log", "Clear"]); + }); + + test.each(["Install", "Enable", "Update"] as const)( + "says %s QuickAdd, which opens Community plugins, until QuickAdd can pick choices", + async (fix) => { + const { plugins } = unusableQuickAdd(fix); + const { shown, click, writes, plugin } = withQuickAdd(plugins); + expect(shown()).toEqual([`${fix} QuickAdd`]); + + click(`${fix} QuickAdd`); + + expect( + (plugin.app as unknown as { setting: { openTabById: unknown } }).setting + .openTabById, + ).toHaveBeenCalledWith("community-plugins"); + expect(await writes()).toBe(0); + expect(withQuickAdd(plugins, "log").shown()).toEqual([`${fix} QuickAdd`, "Clear"]); + }, + ); +}); + describe("Default speed", () => { const options = (select: HTMLSelectElement) => [...select.options].map((option) => option.textContent); diff --git a/src/ui/settings/PodNotesSettingsTab.ts b/src/ui/settings/PodNotesSettingsTab.ts index 09e1ca53..13ac1f03 100644 --- a/src/ui/settings/PodNotesSettingsTab.ts +++ b/src/ui/settings/PodNotesSettingsTab.ts @@ -1,6 +1,8 @@ import { type App, + type ButtonComponent, Component, + type ExtraButtonComponent, MarkdownRenderer, Modal, Notice, @@ -12,7 +14,9 @@ import { type TextAreaComponent, type TextComponent, } from "obsidian"; +import type App2 from "src/global"; import type PodNotes from "../../main"; +import { findQuickAdd } from "src/quickAdd"; import { pickFile } from "../pickFile"; import { DownloadPathTemplateEngine, @@ -89,11 +93,13 @@ const HEADSET_ALIASES = ["headset", "lock screen", "media keys"]; let settingNames = 0; +const NAMED_BY_ROW = "pn-named-by-row"; + /** * 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. An API key's button reads Link... or Change, so it keeps those words before the - * row's name. + * checkbox. An API key's button reads Link... or Change, and a QuickAdd choice button + * Choose... or the choice's name, so they keep those words before the row's name. */ const group = (heading: string, items: SettingDefinitionRender[]): SettingDefinitionGroup => ({ type: "group", @@ -108,8 +114,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`; + for (const [index, button] of controlEl + .querySelectorAll(`.setting-secret-value + button, .${NAMED_BY_ROW}`) + .entries()) { + button.id = `${nameEl.id}-${index}`; button.setAttribute("aria-labelledby", `${button.id} ${nameEl.id}`); } return end; @@ -320,12 +328,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { }, "Captures this many seconds earlier.", ), - this.textRow("QuickAdd choice", { - read: () => this.plugin.settings.timestamp.quickAddChoice ?? "", - write: (value) => { - this.plugin.settings.timestamp.quickAddChoice = value; - }, - }), + this.quickAddRow(), this.textRow("Episode note path", { ...this.text((settings) => settings.note, "path"), preview: pathPreview(FilePathTemplateEngine), @@ -717,6 +720,50 @@ export class PodNotesSettingsTab extends PluginSettingTab { }; } + /** QuickAdd owns the choices, their names and the picker; the row stores a choice's id. */ + private quickAddRow(): SettingDefinitionRender { + return { + name: "QuickAdd choice", + render: (setting) => { + const select = (id?: string) => { + this.plugin.settings.timestamp.quickAddChoiceId = id; + this.changed(); + }; + let choose!: ButtonComponent; + let clear!: ExtraButtonComponent; + setting + .addButton((button) => { + choose = button.setClass(NAMED_BY_ROW).onClick(async () => { + const quickAdd = findQuickAdd(this.app); + if ("fix" in quickAdd) { + (this.app as App2).setting.openTabById("community-plugins"); + return; + } + const picked = await quickAdd.api.pickChoice().catch(() => null); + if (picked) select(picked.id); + }); + }) + .addExtraButton((button) => { + clear = button + .setIcon("x") + .setTooltip("Clear") + .onClick(() => select()); + }); + return this.follow(setting, ({ timestamp: { quickAddChoiceId: id } }) => { + const quickAdd = findQuickAdd(this.app); + choose.setButtonText( + "fix" in quickAdd + ? `${quickAdd.fix} QuickAdd` + : id + ? (quickAdd.api.getChoice({ id })?.name ?? "Missing choice") + : "Choose...", + ); + clear.extraSettingsEl.toggle(Boolean(id)); + }); + }, + }; + } + private importRow(name: string, pick: () => void): SettingDefinitionRender { return { name, diff --git a/tests/e2e/quickadd-capture.test.ts b/tests/e2e/quickadd-capture.test.ts index cfa68a9c..5c3c23a0 100644 --- a/tests/e2e/quickadd-capture.test.ts +++ b/tests/e2e/quickadd-capture.test.ts @@ -4,27 +4,36 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, onTestFinished, test } from "vitest"; import { createPodNotesE2EHarness } from "./harness"; -import { runCommand, showLibrary, showPodNotes } from "./journey/actions"; +import { + closeSettings, + openPodNotesSettings, + runCommand, + showLibrary, + showPodNotes, +} from "./journey/actions"; import { startFixtureServer, type FixtureFeed, type FixtureServer } from "./journey/fixtures"; -import { click } from "./journey/input"; +import { cdp, click, pressKey, typeText } from "./journey/input"; import { readVaultText, seedLibrary, waitForPlayback } from "./journey/library"; -import { ui } from "./journey/targets"; -import { eventually, notices } from "./journey/wait"; +import { ui, type UiTarget } from "./journey/targets"; +import { eventually, notices, waitUntilShown } from "./journey/wait"; import { jsLiteral, PLUGIN_ID } from "./runtime"; import type { IPodNotesSettings } from "../../src/types/IPodNotesSettings"; const getContext = createPodNotesE2EHarness("quickadd-capture"); -const QUICKADD_VERSION = "2.32.1"; -const QUICKADD_SHA256: Record = { +/** The last QuickAdd release before choice picking. */ +const OLD_QUICKADD_VERSION = "2.32.1"; +const OLD_QUICKADD_SHA256: Record = { "main.js": "6423b2d14b60bc51fc6b6f61f835e08c5842d28e5a1a43ab5601ccd37b4e44c5", "manifest.json": "b7610b4c1d37c2c73f781c294c1a380e731e797f9858d2d825e7facc1ac3add1", }; -const QUICKADD_CACHE = path.resolve( +const OLD_QUICKADD_CACHE = path.resolve( path.dirname(fileURLToPath(import.meta.url)), - `../../.obsidian-e2e-artifacts/releases/quickadd-${QUICKADD_VERSION}`, + `../../.obsidian-e2e-artifacts/releases/quickadd-${OLD_QUICKADD_VERSION}`, ); -const CHOICE = "Log timestamp"; +/** A QuickAdd build with choice picking, until a QuickAdd release has it. */ +const QUICKADD_DIR = process.env.OBSIDIAN_E2E_QUICKADD_DIR; +const QUICKADD_FILES = ["main.js", "manifest.json", "styles.css"]; const EVIDENCE = path.resolve(".obsidian-e2e-artifacts/quickadd-capture"); const FEED: FixtureFeed = { @@ -34,22 +43,28 @@ const FEED: FixtureFeed = { episodes: [{ title: "Captured episode", seconds: 60, guid: "captured-episode" }], }; const NOTE_TEMPLATE = "# {{title}}\n\n## Notes\n- Earlier thought\n\n## Later\n"; +const NOTE = "PodNotes/QuickAdd podcast/Captured episode.md"; +const LOG_ID = "podnotes-e2e-capture"; const sha256 = (bytes: Buffer) => createHash("sha256").update(bytes).digest("hex"); -/** QuickAdd's released files, cached in the git-ignored artifacts folder. */ -async function quickAddRelease(): Promise> { +type QuickAddFiles = Map; + +/** QuickAdd 2.32.1's released files, cached in the git-ignored artifacts folder. */ +async function oldQuickAdd(): Promise { const files = new Map(); - await fs.mkdir(QUICKADD_CACHE, { recursive: true }); - for (const [name, digest] of Object.entries(QUICKADD_SHA256)) { - const cached = path.join(QUICKADD_CACHE, name); + await fs.mkdir(OLD_QUICKADD_CACHE, { recursive: true }); + for (const [name, digest] of Object.entries(OLD_QUICKADD_SHA256)) { + const cached = path.join(OLD_QUICKADD_CACHE, name); let bytes = await fs.readFile(cached).catch(() => null); if (!bytes || sha256(bytes) !== digest) { - const url = `https://github.com/chhoumann/quickadd/releases/download/${QUICKADD_VERSION}/${name}`; + const url = `https://github.com/chhoumann/quickadd/releases/download/${OLD_QUICKADD_VERSION}/${name}`; const response = await fetch(url); if (!response.ok) throw new Error(`${url} answered ${response.status}.`); bytes = Buffer.from(await response.arrayBuffer()); - expect(sha256(bytes), `the QuickAdd ${QUICKADD_VERSION} ${name} digest`).toBe(digest); + expect(sha256(bytes), `the QuickAdd ${OLD_QUICKADD_VERSION} ${name} digest`).toBe( + digest, + ); await fs.writeFile(cached, bytes); } files.set(name, bytes); @@ -57,9 +72,70 @@ async function quickAddRelease(): Promise> { return files; } -/** QuickAdd's data.json with one Capture choice, already migrated and announced. */ -const quickAddData = { - version: QUICKADD_VERSION, +/** The QuickAdd build in OBSIDIAN_E2E_QUICKADD_DIR, each file checked against its build.json. */ +async function quickAddBuild(): Promise { + if (!QUICKADD_DIR) + throw new Error( + "Set OBSIDIAN_E2E_QUICKADD_DIR to a QuickAdd build with choice picking: main.js, manifest.json, styles.css and build.json.", + ); + const build = JSON.parse(await fs.readFile(path.join(QUICKADD_DIR, "build.json"), "utf8")) as { + files: Record; + }; + const files = new Map(); + for (const name of QUICKADD_FILES) { + const bytes = await fs.readFile(path.join(QUICKADD_DIR, name)); + expect(sha256(bytes), `${name} as build.json records it`).toBe(build.files[name]); + files.set(name, bytes); + } + return files; +} + +/** A Capture choice that adds a bullet with the timestamp right after the Notes heading. */ +const logChoice = (name: string) => ({ + id: LOG_ID, + name, + type: "Capture", + command: false, + captureTo: "{{VALUE:episodeNote}}", + captureToActiveFile: false, + captureToCanvasNodeId: "", + activeFileWritePosition: "cursor", + createFileIfItDoesntExist: { enabled: false, createWithTemplate: false, template: "" }, + format: { enabled: true, format: "- {{VALUE:timestamp}}" }, + prepend: false, + appendLink: false, + copyLinkToClipboard: false, + task: false, + insertAfter: { + enabled: true, + after: "## Notes", + insertAtEnd: false, + considerSubsections: false, + createIfNotFound: false, + createIfNotFoundLocation: "top", + inline: false, + replaceExisting: false, + blankLineAfterMatchMode: "auto", + promptHeading: false, + }, + insertBefore: { + enabled: false, + before: "", + createIfNotFound: false, + createIfNotFoundLocation: "top", + }, + newLineCapture: { enabled: false, direction: "below" }, + openFile: false, + fileOpening: { location: "tab", direction: "vertical", mode: "default", focus: true }, + templater: { afterCapture: "none" }, +}); +const LOG = logChoice("Log timestamp"); +/** A choice to pick past in QuickAdd's picker. */ +const MEETING = { ...logChoice("Meeting note"), id: "podnotes-e2e-meeting" }; + +/** QuickAdd's data.json with `choices`, already migrated and announced. */ +const quickAddData = (version: string, choices: object[]) => ({ + version, announceUpdates: "none", migrations: Object.fromEntries( [ @@ -80,54 +156,19 @@ const quickAddData = { "pinAiModelRefs", ].map((migration) => [migration, true]), ), - choices: [ - { - id: "podnotes-e2e-capture", - name: CHOICE, - type: "Capture", - command: false, - captureTo: "{{VALUE:episodeNote}}", - captureToActiveFile: false, - captureToCanvasNodeId: "", - activeFileWritePosition: "cursor", - createFileIfItDoesntExist: { enabled: false, createWithTemplate: false, template: "" }, - format: { enabled: true, format: "- {{VALUE:timestamp}}" }, - prepend: false, - appendLink: false, - copyLinkToClipboard: false, - task: false, - insertAfter: { - enabled: true, - after: "## Notes", - insertAtEnd: false, - considerSubsections: false, - createIfNotFound: false, - createIfNotFoundLocation: "top", - inline: false, - replaceExisting: false, - blankLineAfterMatchMode: "auto", - promptHeading: false, - }, - insertBefore: { - enabled: false, - before: "", - createIfNotFound: false, - createIfNotFoundLocation: "top", - }, - newLineCapture: { enabled: false, direction: "below" }, - openFile: false, - fileOpening: { location: "tab", direction: "vertical", mode: "default", focus: true }, - templater: { afterCapture: "none" }, - }, - ], -}; + choices, +}); -/** Installs and enables the real QuickAdd release for this test only. */ -async function enableQuickAdd(): Promise { +/** Installs QuickAdd from `files` with `choices` for this test only, enabled in memory unless told not to. */ +async function installQuickAdd( + files: QuickAddFiles, + choices: object[], + { enable = true } = {}, +): Promise { const { obsidian } = getContext(); - const files = await quickAddRelease(); const configDir = await obsidian.dev.evalJson("app.vault.configDir"); const directory = path.join(await obsidian.vaultPath(), configDir, "plugins", "quickadd"); + const { version } = JSON.parse(files.get("manifest.json")!.toString()) as { version: string }; onTestFinished(async () => { await obsidian.dev.evalJsonAsync(`(async () => { const quickAdd = app.plugins.plugins.quickadd; @@ -141,34 +182,58 @@ async function enableQuickAdd(): Promise { ); }); await fs.mkdir(directory, { recursive: true }); - await fs.writeFile(path.join(directory, "data.json"), JSON.stringify(quickAddData)); - await fs.writeFile(path.join(directory, "main.js"), files.get("main.js")!); - await fs.writeFile(path.join(directory, "manifest.json"), files.get("manifest.json")!); + await fs.writeFile( + path.join(directory, "data.json"), + JSON.stringify(quickAddData(version, choices)), + ); + for (const [name, bytes] of files) await fs.writeFile(path.join(directory, name), bytes); expect( await obsidian.dev.evalJsonAsync(`(async () => { await app.plugins.loadManifests(); - await app.plugins.enablePlugin("quickadd"); + if (${enable}) await app.plugins.enablePlugin("quickadd"); const quickAdd = app.plugins.plugins.quickadd; return { - version: quickAdd?.manifest.version, - choices: quickAdd?.settings.choices.map(({ name }) => name), + installed: app.plugins.manifests.quickadd?.version, + choices: quickAdd?.settings.choices.map(({ name }) => name) ?? null, saved: app.plugins.enabledPlugins.has("quickadd"), }; })()`), - "QuickAdd loads in memory only, with the seeded choice", - ).toEqual({ version: QUICKADD_VERSION, choices: [CHOICE], saved: false }); + "QuickAdd loads in memory only, with the seeded choices", + ).toEqual({ + installed: version, + choices: enable ? choices.map((choice) => (choice as { name: string }).name) : null, + saved: false, + }); } -/** Plays the fixture episode, pauses it at `seconds`, and opens its new episode note. */ -async function noteAt(seconds: number): Promise<{ server: FixtureServer; notePath: string }> { +/** Gives QuickAdd these choices as a sync from another device does: in data.json, then reloaded. */ +async function syncQuickAddChoices(choices: object[]): Promise { + expect( + await getContext().obsidian.dev.evalJsonAsync(`(async () => { + const quickAdd = app.plugins.plugins.quickadd; + const file = app.vault.configDir + "/plugins/quickadd/data.json"; + const data = JSON.parse(await app.vault.adapter.read(file)); + data.choices = ${JSON.stringify(choices)}; + await app.vault.adapter.write(file, JSON.stringify(data)); + await quickAdd.onExternalSettingsChange(); + return quickAdd.settings.choices.map(({ name }) => name); + })()`), + "QuickAdd's choices after the sync", + ).toEqual(choices.map((choice) => (choice as { name: string }).name)); +} + +/** Plays the fixture episode, paused at `seconds`, with these timestamp settings. */ +async function playAt( + seconds: number, + timestamp: Partial, +): Promise<{ server: FixtureServer; notePath: string }> { const context = getContext(); const { obsidian, plugin, sandbox } = context; const server = await startFixtureServer([FEED]); onTestFinished(() => server.stop()); await seedLibrary(context, server, { subscriptions: [FEED], noteTemplate: NOTE_TEMPLATE }); await plugin.updateDataAndReload((data) => { - data.timestamp.template = "{{linktime}}"; - data.timestamp.quickAddChoice = CHOICE; + Object.assign(data.timestamp, timestamp); }); await showPodNotes(obsidian); await showLibrary(obsidian); @@ -178,7 +243,7 @@ async function noteAt(seconds: number): Promise<{ server: FixtureServer; notePat await click(obsidian, ui.playbackToggle); await waitForPlayback(obsidian, (state) => !state.playing, "episode paused"); await seek(seconds); - const notePath = sandbox.path("PodNotes/QuickAdd podcast/Captured episode.md"); + const notePath = sandbox.path(NOTE); onTestFinished(() => obsidian.dev.evalJsonAsync(`(async () => { const file = app.vault.getFileByPath(${jsLiteral(notePath)}); @@ -186,33 +251,101 @@ async function noteAt(seconds: number): Promise<{ server: FixtureServer; notePat return true; })()`), ); - await runCommand(obsidian, `${PLUGIN_ID}:create-podcast-note`); - await sandbox.waitForExists("PodNotes/QuickAdd podcast/Captured episode.md", { - timeoutMs: 10_000, - }); return { server, notePath }; } +/** Plays the fixture episode at `seconds` with this choice set, and creates its episode note. */ +async function noteAt( + seconds: number, + quickAddChoiceId = LOG_ID, +): Promise<{ server: FixtureServer; notePath: string }> { + const { obsidian, sandbox } = getContext(); + const played = await playAt(seconds, { template: "{{linktime}}", quickAddChoiceId }); + await runCommand(obsidian, `${PLUGIN_ID}:create-podcast-note`); + await sandbox.waitForExists(NOTE, { timeoutMs: 10_000 }); + return played; +} + async function seek(seconds: number): Promise { await getContext().obsidian.dev.evalJson( `app.plugins.plugins.${PLUGIN_ID}.api.currentTime = ${jsLiteral(seconds)}`, ); } +const link = (server: FixtureServer, seconds: number) => + `[00:00:${seconds}](obsidian://podnotes?episodeName=Captured%20episode&url=${encodeURIComponent(server.feedUrl(FEED))}&guid=captured-episode&time=${seconds})`; + function episodeNote(server: FixtureServer, captured: number[]): string { - const link = (seconds: number) => - `- [00:00:${seconds}](obsidian://podnotes?episodeName=Captured%20episode&url=${encodeURIComponent(server.feedUrl(FEED))}&guid=captured-episode&time=${seconds})\n`; return [ "---\n", 'guid: "captured-episode"\n', `feedUrl: "${server.feedUrl(FEED)}"\n`, "---\n", "# Captured episode\n\n## Notes\n", - ...captured.map(link), + ...captured.map((seconds) => `- ${link(server, seconds)}\n`), "- Earlier thought\n\n## Later\n", ].join(""); } +const QUICKADD_ROW = `[...document.querySelectorAll(".modal.mod-settings .setting-item")].filter((row) => row.querySelector(".setting-item-name")?.textContent === "QuickAdd choice")`; + +/** The QuickAdd choice row's control a user sees as `label`: its text, or an icon's tooltip. */ +const rowControl = (label: string): UiTarget => ({ + name: `QuickAdd choice control ${label}`, + elements: `${QUICKADD_ROW}.flatMap((row) => [...row.querySelector(".setting-item-control").children]).filter((control) => (control.textContent || control.getAttribute("aria-label")) === ${jsLiteral(label)})`, +}); + +/** The QuickAdd choice row's controls a user sees, by their text or an icon's tooltip. */ +async function quickAddRow(): Promise { + return await getContext().obsidian.dev.evalJson( + `${QUICKADD_ROW}.flatMap((row) => [...row.querySelector(".setting-item-control").children]).filter((control) => control.getClientRects().length > 0).map((control) => control.textContent || control.getAttribute("aria-label"))`, + ); +} + +/** Opens PodNotes settings until the QuickAdd choice row shows `shown`, and saves the row as `name`. */ +async function settingsShow(shown: string[], name: string): Promise { + const { obsidian } = getContext(); + await openPodNotesSettings(obsidian); + onTestFinished(() => closeSettings(obsidian)); + expect(await eventually(obsidian, quickAddRow, shown), "the QuickAdd choice row").toEqual( + shown, + ); + await screenshot(name, QUICKADD_ROW); +} + +/** Saves what the user sees of the first element `elements` evaluates to, at twice its size. */ +async function screenshot(name: string, elements: string): Promise { + const { obsidian } = getContext(); + const clip = await obsidian.dev.evalJson(`(() => { + const element = (${elements})[0]; + element.scrollIntoView({ block: "center" }); + const { x, y, width, height } = element.getBoundingClientRect(); + return { x, y, width, height, scale: 2 }; + })()`); + const { data } = (await cdp(obsidian, "Page.captureScreenshot", { format: "png", clip })) as { + data: string; + }; + await fs.mkdir(EVIDENCE, { recursive: true }); + await fs.writeFile(path.join(EVIDENCE, `${name}.png`), Buffer.from(data, "base64")); +} + +async function savedChoiceId(): Promise { + const data = await getContext().plugin.data().read(); + return data.timestamp.quickAddChoiceId; +} + +/** Captures with the command, and checks that a notice says `notice` and no note changed. */ +async function captureRefused(notePath: string, before: string, notice: string): Promise { + const { obsidian } = getContext(); + await runCommand(obsidian, `${PLUGIN_ID}:capture-timestamp`); + expect( + await eventually(obsidian, async () => (await notices(obsidian)).includes(notice), true), + `the notice "${notice}"`, + ).toBe(true); + expect(await readVaultText(obsidian, notePath), "the episode note").toBe(before); + expect((await obsidian.exec("dev:errors")).stdout.trim()).toBe("No errors captured."); +} + describe("captures with a QuickAdd choice", () => { test("the command, the player and the headset run the choice, which writes after the heading", async () => { const { obsidian } = getContext(); @@ -235,7 +368,7 @@ describe("captures with a QuickAdd choice", () => { return true; })()`); }); - await enableQuickAdd(); + await installQuickAdd(await quickAddBuild(), [LOG]); const { server, notePath } = await noteAt(30); expect(await readVaultText(obsidian, notePath), "the new episode note").toBe( episodeNote(server, []), @@ -286,7 +419,71 @@ describe("captures with a QuickAdd choice", () => { await obsidian.dev.screenshot(path.join(EVIDENCE, "episode-note.png")); }, 90_000); - test("without QuickAdd enabled, a capture says so and changes no note", async () => { + test("Choose... picks a choice in QuickAdd's own picker, by typing and Enter", async () => { + const { obsidian } = getContext(); + await installQuickAdd(await quickAddBuild(), [MEETING, LOG]); + await settingsShow(["Choose..."], "row-unset"); + + await click(obsidian, rowControl("Choose...")); + const picker = '[...document.querySelectorAll(".modal.qa-choice-suggester")]'; + await waitUntilShown(obsidian, { name: "QuickAdd's choice picker", elements: picker }); + await typeText(obsidian, "Log"); + await screenshot("picker", picker); + await pressKey(obsidian, "Enter"); + + expect(await eventually(obsidian, quickAddRow, ["Log timestamp", "Clear"])).toEqual([ + "Log timestamp", + "Clear", + ]); + expect(await eventually(obsidian, savedChoiceId, LOG_ID), "the saved choice").toBe(LOG_ID); + await screenshot("row-set", QUICKADD_ROW); + expect((await obsidian.exec("dev:errors")).stdout.trim()).toBe("No errors captured."); + }, 60_000); + + test("a choice renamed in QuickAdd still runs and shows its new name; a deleted one writes nothing", async () => { + const { obsidian } = getContext(); + await installQuickAdd(await quickAddBuild(), [LOG]); + const { server, notePath } = await noteAt(30); + + await syncQuickAddChoices([logChoice("Podcast log")]); + await settingsShow(["Podcast log", "Clear"], "row-renamed"); + await closeSettings(obsidian); + await runCommand(obsidian, `${PLUGIN_ID}:capture-timestamp`); + expect( + await eventually( + obsidian, + () => readVaultText(obsidian, notePath), + episodeNote(server, [30]), + 10_000, + ), + "the renamed choice's capture", + ).toBe(episodeNote(server, [30])); + + await syncQuickAddChoices([MEETING]); + await settingsShow(["Missing choice", "Clear"], "row-missing"); + await closeSettings(obsidian); + await seek(40); + await captureRefused( + notePath, + episodeNote(server, [30]), + "The QuickAdd choice no longer exists. Choose another in PodNotes settings.", + ); + }, 90_000); + + test("QuickAdd 2.32.1, from before choice picking, asks for an update and runs nothing", async () => { + await installQuickAdd(await oldQuickAdd(), [LOG]); + const { server, notePath } = await noteAt(30); + await settingsShow(["Update QuickAdd", "Clear"], "row-update"); + await closeSettings(getContext().obsidian); + + await captureRefused( + notePath, + episodeNote(server, []), + "Update QuickAdd to capture with a QuickAdd choice", + ); + }, 60_000); + + test("without QuickAdd installed, or enabled, a capture says which and changes no note", async () => { const { obsidian } = getContext(); const { server, notePath } = await noteAt(30); const editorText = () => @@ -297,21 +494,23 @@ describe("captures with a QuickAdd choice", () => { await eventually(obsidian, editorText, episodeNote(server, [])), "the episode note open in its editor", ).toBe(episodeNote(server, [])); + await settingsShow(["Install QuickAdd", "Clear"], "row-install"); + await closeSettings(obsidian); - await runCommand(obsidian, `${PLUGIN_ID}:capture-timestamp`); - - expect( - await eventually( - obsidian, - async () => - (await notices(obsidian)).includes( - "Install and enable QuickAdd to capture with a QuickAdd choice", - ), - true, - ), - ).toBe(true); + await captureRefused( + notePath, + episodeNote(server, []), + "Install QuickAdd to capture with a QuickAdd choice", + ); expect(await editorText()).toBe(episodeNote(server, [])); - expect(await readVaultText(obsidian, notePath)).toBe(episodeNote(server, [])); - expect((await obsidian.exec("dev:errors")).stdout.trim()).toBe("No errors captured."); + + await installQuickAdd(await oldQuickAdd(), [LOG], { enable: false }); + await settingsShow(["Enable QuickAdd", "Clear"], "row-enable"); + await closeSettings(obsidian); + await captureRefused( + notePath, + episodeNote(server, []), + "Enable QuickAdd to capture with a QuickAdd choice", + ); }, 60_000); }); diff --git a/tests/e2e/settings-tab.test.ts b/tests/e2e/settings-tab.test.ts index 3df12295..e9c5cf08 100644 --- a/tests/e2e/settings-tab.test.ts +++ b/tests/e2e/settings-tab.test.ts @@ -74,7 +74,7 @@ type Field = Readonly<{ row: string; name: string; from?: string }>; 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'], .setting-secret-value + button)") ? document.activeElement : null`, + `document.activeElement.matches(".modal.mod-settings .setting-item-control :is(input, select, textarea, [tabindex='0'], button[aria-labelledby])") ? document.activeElement : null`, ); if (named === undefined) return undefined; const row = await obsidian.dev.evalJson( @@ -134,15 +134,18 @@ describe("PodNotes settings tab", () => { "Export settings", ]); // 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. + // key's button keeps its own words, Link... or Change, before its row's name, and so + // does the QuickAdd choice button, which asks for QuickAdd in this vault. expect( fields.filter(({ row, name, from }) => name !== row || from !== "aria-labelledby"), ).toEqual( - ["OpenAI API key", "Deepgram API key"].map((row) => ({ - row, - name: expect.stringMatching(new RegExp(`^(Link\\.\\.\\.|Change) ${row}$`)), - from: "aria-labelledby", - })), + [ + { row: "QuickAdd choice", name: "Install QuickAdd QuickAdd choice" }, + ...["OpenAI API key", "Deepgram API key"].map((row) => ({ + row, + name: expect.stringMatching(new RegExp(`^(Link\\.\\.\\.|Change) ${row}$`)), + })), + ].map((field) => ({ ...field, from: "aria-labelledby" })), ); }); diff --git a/tests/mocks/obsidian.ts b/tests/mocks/obsidian.ts index 70f8352a..2bb915fb 100644 --- a/tests/mocks/obsidian.ts +++ b/tests/mocks/obsidian.ts @@ -361,7 +361,7 @@ export class ButtonComponent extends BaseInteractiveElement { } setClass(value: string) { - this.buttonEl.className = value; + this.buttonEl.classList.add(value); return this; } @@ -419,6 +419,38 @@ export class DropdownComponent extends BaseInteractiveElement { } } +/** Obsidian 1.14's markup: a focusable icon named by its tooltip. */ +export class ExtraButtonComponent { + readonly extraSettingsEl: HTMLDivElement; + private clickCallback?: () => void; + + constructor(container: HTMLElement) { + this.extraSettingsEl = container.createDiv({ cls: "clickable-icon extra-setting-button" }); + this.extraSettingsEl.tabIndex = 0; + this.extraSettingsEl.addEventListener("click", () => this.clickCallback?.()); + } + + setIcon(icon: string) { + this.extraSettingsEl.setAttribute("data-icon", icon); + return this; + } + + setTooltip(tooltip: string) { + this.extraSettingsEl.setAttribute("aria-label", tooltip); + return this; + } + + setDisabled(disabled: boolean) { + this.extraSettingsEl.classList.toggle("is-disabled", disabled); + return this; + } + + onClick(callback: () => void) { + this.clickCallback = callback; + return this; + } +} + /** As Obsidian's: setValue runs the change callback when the value changes. */ /** Obsidian 1.14's markup: the focusable label holds a checkbox that takes no focus. */ export class ToggleComponent extends BaseInteractiveElement { @@ -641,6 +673,10 @@ export class Setting { return this.add(new ButtonComponent(this.controlEl), callback); } + addExtraButton(callback: (component: ExtraButtonComponent) => unknown) { + return this.add(new ExtraButtonComponent(this.controlEl), callback); + } + addComponent(callback: (container: HTMLElement) => T) { this.components.push(callback(this.controlEl)); return this; diff --git a/tests/mocks/quickAdd.ts b/tests/mocks/quickAdd.ts new file mode 100644 index 00000000..5d8dfafc --- /dev/null +++ b/tests/mocks/quickAdd.ts @@ -0,0 +1,40 @@ +import { vi } from "vitest"; + +/** + * `app.plugins` with QuickAdd enabled and holding `choices`, by id to name. Importing a + * package adds its choices, skipping an id QuickAdd already has, as QuickAdd does. + */ +export function fakeQuickAdd(choices: Record = {}) { + const api = { + getChoice: ({ id }: { id: string }) => { + const name = choices[id]; + return name === undefined ? null : { id, name }; + }, + pickChoice: vi.fn<() => Promise<{ id: string; name: string }>>(), + executeChoice: vi.fn( + async (_ref: { id: string }, _variables: Record) => {}, + ), + importPackage: vi.fn(async (packageJson: string) => { + const { choices: added } = JSON.parse(packageJson) as { + choices: { choice: { id: string; name: string } }[]; + }; + for (const { choice } of added) choices[choice.id] ??= choice.name; + }), + }; + return { + api, + choices, + plugins: { manifests: { quickadd: {} }, plugins: { quickadd: { api } } }, + }; +} + +/** `app.plugins` with QuickAdd missing, disabled, or older than choice picking, by the fix each needs. */ +export function unusableQuickAdd(fix: "Install" | "Enable" | "Update") { + const executeChoice = vi.fn(async () => {}); + const plugins = { + Install: { manifests: {}, plugins: {} }, + Enable: { manifests: { quickadd: {} }, plugins: {} }, + Update: { manifests: { quickadd: {} }, plugins: { quickadd: { api: { executeChoice } } } }, + }[fix]; + return { executeChoice, plugins }; +} diff --git a/vitest.setup.dom.ts b/vitest.setup.dom.ts index f8abcba2..286379a0 100644 --- a/vitest.setup.dom.ts +++ b/vitest.setup.dom.ts @@ -229,6 +229,15 @@ if (!(HTMLElement.prototype as unknown as { empty?: () => void }).empty) { }; } +if (!(HTMLElement.prototype as unknown as { toggle?: unknown }).toggle) { + (HTMLElement.prototype as unknown as { toggle: (show: boolean) => void }).toggle = function ( + this: HTMLElement, + show: boolean, + ) { + this.style.display = show ? "" : "none"; + }; +} + // Obsidian augments HTMLElement with setCssStyles (assigns a batch of inline // styles, the sanctioned alternative to direct `el.style.x = y` writes). jsdom // has no such method, so mirror Obsidian's behaviour for component/DOM tests. From 1ae3c577fba636b6e505dc80180b5679fbb8ab7e Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sun, 18 Oct 2026 21:42:35 +0200 Subject: [PATCH 07/11] feat(settings): create QuickAdd's ready-made capture choice in one click With no QuickAdd choice set, Create sits next to Choose.... It hands QuickAdd a one-choice package, Podcast capture with the fixed id podnotes-capture, and selects it. The choice captures to the episode note, creates the note when it is missing, and adds each timestamp at the end of its ## Notes section, creating the heading at the bottom. Its format is the timestamp alone, since the timestamp template already starts with a bullet. QuickAdd skips a choice id it already has, so a second Create selects the existing choice under its current name and overwrites nothing. --- src/commands.test.ts | 2 +- src/quickAdd.ts | 38 ++++++++++++++++++ src/ui/settings/PodNotesSettingsTab.test.ts | 44 ++++++++++++++++++++- src/ui/settings/PodNotesSettingsTab.ts | 24 ++++++++++- tests/e2e/quickadd-capture.test.ts | 43 +++++++++++++++++++- 5 files changed, 146 insertions(+), 5 deletions(-) diff --git a/src/commands.test.ts b/src/commands.test.ts index 1ce81745..71bd29c9 100644 --- a/src/commands.test.ts +++ b/src/commands.test.ts @@ -936,7 +936,7 @@ describe("capture", () => { expect(said()).toEqual([]); }); - it("captures as before when the choice is blank", async () => { + it("captures as before with an empty choice id in data.json", async () => { const { plugin, run, openNote, focusPodNotes } = await loadPlugin(linkedPlaying); const executeChoice = withQuickAdd(plugin); plugin.settings.timestamp.quickAddChoiceId = ""; diff --git a/src/quickAdd.ts b/src/quickAdd.ts index 940a1f5b..85675a7b 100644 --- a/src/quickAdd.ts +++ b/src/quickAdd.ts @@ -12,6 +12,8 @@ interface QuickAddApi { /** Opens QuickAdd's choice picker. Rejects when the user dismisses it. */ pickChoice(): Promise; executeChoice(ref: { id: string }, variables: Record): Promise; + /** Skips a choice whose id QuickAdd already has. */ + importPackage(packageJson: string): Promise; } /** QuickAdd's API, or what the user has to do before PodNotes can use it. */ @@ -28,3 +30,39 @@ export function findQuickAdd(app: App): QuickAdd { export const MISSING_CHOICE = "The QuickAdd choice no longer exists. Choose another in PodNotes settings."; + +export const CAPTURE_CHOICE_ID = "podnotes-capture"; + +/** + * QuickAdd's ready-made capture choice. Its format adds no bullet, since the timestamp + * template starts with one. Settings it leaves out keep a new QuickAdd Capture's defaults. + */ +export const CAPTURE_PACKAGE = JSON.stringify({ + schemaVersion: 1, + quickAddVersion: "", + createdAt: "", + rootChoiceIds: [CAPTURE_CHOICE_ID], + assets: [], + choices: [ + { + parentChoiceId: null, + pathHint: ["Podcast capture"], + choice: { + id: CAPTURE_CHOICE_ID, + name: "Podcast capture", + type: "Capture", + command: false, + captureTo: "{{VALUE:episodeNote}}", + createFileIfItDoesntExist: { enabled: true, createWithTemplate: false }, + format: { enabled: true, format: "{{VALUE:timestamp}}" }, + insertAfter: { + enabled: true, + after: "## Notes", + insertAtEnd: true, + createIfNotFound: true, + createIfNotFoundLocation: "bottom", + }, + }, + }, + ], +}); diff --git a/src/ui/settings/PodNotesSettingsTab.test.ts b/src/ui/settings/PodNotesSettingsTab.test.ts index a0daf4e8..185f339b 100644 --- a/src/ui/settings/PodNotesSettingsTab.test.ts +++ b/src/ui/settings/PodNotesSettingsTab.test.ts @@ -659,7 +659,7 @@ describe("QuickAdd choice", () => { const quickAdd = fakeQuickAdd({ log: "Log" }); quickAdd.api.pickChoice.mockResolvedValue({ id: "log", name: "Log" }); const { shown, click, disk } = withQuickAdd(quickAdd.plugins); - expect(shown()).toEqual(["Choose..."]); + expect(shown()).toEqual(["Choose...", "Create"]); click("Choose..."); @@ -687,10 +687,50 @@ describe("QuickAdd choice", () => { click("Clear"); expect((await disk()).timestamp).toEqual(TIMESTAMP); - expect(shown()).toEqual(["Choose..."]); + expect(shown()).toEqual(["Choose...", "Create"]); expect(quickAdd.choices).toEqual({ log: "Log" }); }); + test("Create adds QuickAdd's ready-made choice and stores it", async () => { + const quickAdd = fakeQuickAdd({ log: "Log" }); + const { shown, click, disk } = withQuickAdd(quickAdd.plugins); + + click("Create"); + + expect((await disk()).timestamp).toEqual({ + ...TIMESTAMP, + quickAddChoiceId: "podnotes-capture", + }); + expect(quickAdd.choices).toEqual({ log: "Log", "podnotes-capture": "Podcast capture" }); + expect(shown()).toEqual(["Podcast capture", "Clear"]); + }); + + test("Create again keeps the ready-made choice QuickAdd already has, under its name there", async () => { + const quickAdd = fakeQuickAdd({ "podnotes-capture": "Episode log" }); + const { shown, click, disk } = withQuickAdd(quickAdd.plugins); + + click("Create"); + + expect((await disk()).timestamp.quickAddChoiceId).toBe("podnotes-capture"); + expect(shown()).toEqual(["Episode log", "Clear"]); + }); + + test("Create says so when QuickAdd fails to import its choice, and stores nothing", async () => { + const quickAdd = fakeQuickAdd(); + quickAdd.api.importPackage.mockRejectedValue(new Error("Could not save settings")); + vi.spyOn(console, "error").mockImplementation(() => {}); + const notice = vi.spyOn(obsidian, "Notice"); + const { shown, click, writes } = withQuickAdd(quickAdd.plugins); + + click("Create"); + + await vi.waitFor(() => + expect(notice).toHaveBeenCalledWith("Could not create the QuickAdd choice"), + ); + expect(await writes()).toBe(0); + expect(shown()).toEqual(["Choose...", "Create"]); + }); + test("shows the choice under its name in QuickAdd, after a rename too", () => { const quickAdd = fakeQuickAdd({ log: "Log" }); const { shown, tab } = withQuickAdd(quickAdd.plugins, "log"); diff --git a/src/ui/settings/PodNotesSettingsTab.ts b/src/ui/settings/PodNotesSettingsTab.ts index 13ac1f03..4afc7e28 100644 --- a/src/ui/settings/PodNotesSettingsTab.ts +++ b/src/ui/settings/PodNotesSettingsTab.ts @@ -16,7 +16,7 @@ import { } from "obsidian"; import type App2 from "src/global"; import type PodNotes from "../../main"; -import { findQuickAdd } from "src/quickAdd"; +import { CAPTURE_CHOICE_ID, CAPTURE_PACKAGE, findQuickAdd } from "src/quickAdd"; import { pickFile } from "../pickFile"; import { DownloadPathTemplateEngine, @@ -730,6 +730,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { this.changed(); }; let choose!: ButtonComponent; + let create!: ButtonComponent; let clear!: ExtraButtonComponent; setting .addButton((button) => { @@ -743,6 +744,26 @@ export class PodNotesSettingsTab extends PluginSettingTab { if (picked) select(picked.id); }); }) + .addButton((button) => { + create = button + .setButtonText("Create") + .setClass(NAMED_BY_ROW) + .onClick(async () => { + const quickAdd = findQuickAdd(this.app); + if ("fix" in quickAdd) return; + await quickAdd.api + .importPackage(CAPTURE_PACKAGE) + .catch((error) => + console.error( + "PodNotes: QuickAdd could not import the choice", + error, + ), + ); + if (quickAdd.api.getChoice({ id: CAPTURE_CHOICE_ID })) + select(CAPTURE_CHOICE_ID); + else new Notice("Could not create the QuickAdd choice"); + }); + }) .addExtraButton((button) => { clear = button .setIcon("x") @@ -758,6 +779,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { ? (quickAdd.api.getChoice({ id })?.name ?? "Missing choice") : "Choose...", ); + create.buttonEl.toggle("api" in quickAdd && !id); clear.extraSettingsEl.toggle(Boolean(id)); }); }, diff --git a/tests/e2e/quickadd-capture.test.ts b/tests/e2e/quickadd-capture.test.ts index 5c3c23a0..76973763 100644 --- a/tests/e2e/quickadd-capture.test.ts +++ b/tests/e2e/quickadd-capture.test.ts @@ -422,7 +422,7 @@ describe("captures with a QuickAdd choice", () => { test("Choose... picks a choice in QuickAdd's own picker, by typing and Enter", async () => { const { obsidian } = getContext(); await installQuickAdd(await quickAddBuild(), [MEETING, LOG]); - await settingsShow(["Choose..."], "row-unset"); + await settingsShow(["Choose...", "Create"], "row-unset"); await click(obsidian, rowControl("Choose...")); const picker = '[...document.querySelectorAll(".modal.qa-choice-suggester")]'; @@ -440,6 +440,47 @@ describe("captures with a QuickAdd choice", () => { expect((await obsidian.exec("dev:errors")).stdout.trim()).toBe("No errors captured."); }, 60_000); + test("Create adds QuickAdd's ready-made choice, whose captures create the note and add one bullet each", async () => { + const { obsidian } = getContext(); + await installQuickAdd(await quickAddBuild(), []); + const { server, notePath } = await playAt(30, {}); + await settingsShow(["Choose...", "Create"], "row-before-create"); + + await click(obsidian, rowControl("Create")); + + expect(await eventually(obsidian, quickAddRow, ["Podcast capture", "Clear"])).toEqual([ + "Podcast capture", + "Clear", + ]); + expect(await eventually(obsidian, savedChoiceId, "podnotes-capture")).toBe( + "podnotes-capture", + ); + await closeSettings(obsidian); + expect( + await obsidian.dev.evalJson("app.vault.getFileByPath(" + jsLiteral(notePath) + ")"), + "no episode note before the capture", + ).toBeNull(); + + await runCommand(obsidian, `${PLUGIN_ID}:capture-timestamp`); + const first = `## Notes\n- ${link(server, 30)} \n`; + expect( + await eventually( + obsidian, + () => readVaultText(obsidian, notePath).catch(() => ""), + first, + ), + "the first capture", + ).toBe(first); + await seek(40); + await runCommand(obsidian, `${PLUGIN_ID}:capture-timestamp`); + const second = `${first}- ${link(server, 40)} \n`; + expect( + await eventually(obsidian, () => readVaultText(obsidian, notePath), second), + "the second capture, last in the section", + ).toBe(second); + expect((await obsidian.exec("dev:errors")).stdout.trim()).toBe("No errors captured."); + }, 90_000); + test("a choice renamed in QuickAdd still runs and shows its new name; a deleted one writes nothing", async () => { const { obsidian } = getContext(); await installQuickAdd(await quickAddBuild(), [LOG]); From 6fa87ab5aa9c8330117e3634adb526cb6a486e5f Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sun, 18 Oct 2026 22:17:41 +0200 Subject: [PATCH 08/11] fix(settings): read the button that asks for QuickAdd by its own words A screen reader read "Install QuickAdd QuickAdd choice". The QuickAdd choice button takes its row's name only while QuickAdd is ready, where it reads Choose... or the choice's name; Install, Enable and Update QuickAdd read as they are. --- src/ui/settings/PodNotesSettingsTab.test.ts | 13 ++++++++++++- src/ui/settings/PodNotesSettingsTab.ts | 3 ++- tests/e2e/settings-tab.test.ts | 16 ++++++---------- 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/ui/settings/PodNotesSettingsTab.test.ts b/src/ui/settings/PodNotesSettingsTab.test.ts index 185f339b..7c155fb9 100644 --- a/src/ui/settings/PodNotesSettingsTab.test.ts +++ b/src/ui/settings/PodNotesSettingsTab.test.ts @@ -197,7 +197,9 @@ describe("sections", () => { }); test("each field is named after its row where it takes focus, without an aria-label Obsidian would show as a tooltip", () => { - const { tab } = open(); + const { plugin, tab } = open(); + Object.assign(plugin.app, { plugins: fakeQuickAdd().plugins }); + tab.display(); const fields = [ ...tab.containerEl.querySelectorAll( '.setting-item-control :is(input:not([tabindex="-1"]), select, textarea, [tabindex="0"], button[aria-labelledby])', @@ -216,6 +218,7 @@ describe("sections", () => { "Timestamp template", "Timestamp offset (s)", "QuickAdd choice", + "QuickAdd choice", "Episode note path", "Episode note template", "Podcast note path", @@ -754,6 +757,14 @@ describe("QuickAdd choice", () => { expect(shown()).toEqual(["Log", "Clear"]); }); + test("a button that asks for QuickAdd is named by its own words alone", () => { + const { row } = withQuickAdd(unusableQuickAdd("Install").plugins); + + expect(row("QuickAdd choice").querySelector("button")).toHaveAccessibleName( + "Install QuickAdd", + ); + }); + test.each(["Install", "Enable", "Update"] as const)( "says %s QuickAdd, which opens Community plugins, until QuickAdd can pick choices", async (fix) => { diff --git a/src/ui/settings/PodNotesSettingsTab.ts b/src/ui/settings/PodNotesSettingsTab.ts index 4afc7e28..1c672e45 100644 --- a/src/ui/settings/PodNotesSettingsTab.ts +++ b/src/ui/settings/PodNotesSettingsTab.ts @@ -734,7 +734,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { let clear!: ExtraButtonComponent; setting .addButton((button) => { - choose = button.setClass(NAMED_BY_ROW).onClick(async () => { + choose = button.onClick(async () => { const quickAdd = findQuickAdd(this.app); if ("fix" in quickAdd) { (this.app as App2).setting.openTabById("community-plugins"); @@ -779,6 +779,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { ? (quickAdd.api.getChoice({ id })?.name ?? "Missing choice") : "Choose...", ); + choose.buttonEl.classList.toggle(NAMED_BY_ROW, "api" in quickAdd); create.buttonEl.toggle("api" in quickAdd && !id); clear.extraSettingsEl.toggle(Boolean(id)); }); diff --git a/tests/e2e/settings-tab.test.ts b/tests/e2e/settings-tab.test.ts index e9c5cf08..62eb688f 100644 --- a/tests/e2e/settings-tab.test.ts +++ b/tests/e2e/settings-tab.test.ts @@ -115,7 +115,6 @@ describe("PodNotes settings tab", () => { "Next track", "Timestamp template", "Timestamp offset (s)", - "QuickAdd choice", "Episode note path", "Episode note template", "Podcast note path", @@ -134,18 +133,15 @@ describe("PodNotes settings tab", () => { "Export settings", ]); // 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, and so - // does the QuickAdd choice button, which asks for QuickAdd in this vault. + // 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( - [ - { row: "QuickAdd choice", name: "Install QuickAdd QuickAdd choice" }, - ...["OpenAI API key", "Deepgram API key"].map((row) => ({ - row, - name: expect.stringMatching(new RegExp(`^(Link\\.\\.\\.|Change) ${row}$`)), - })), - ].map((field) => ({ ...field, from: "aria-labelledby" })), + ["OpenAI API key", "Deepgram API key"].map((row) => ({ + row, + name: expect.stringMatching(new RegExp(`^(Link\\.\\.\\.|Change) ${row}$`)), + from: "aria-labelledby", + })), ); }); From 971f16f1276b731072e9f4fa20d37fbac13e2ea7 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sun, 18 Oct 2026 22:17:41 +0200 Subject: [PATCH 09/11] test(e2e): find QuickAdd's picker by its own class and match a new note's text QuickAdd's picker is a prompt, not a .modal. A capture into a note QuickAdd creates ends without a trailing newline, and the next one starts on a new line. Both journeys now pass against the QuickAdd build with choice picking. --- tests/e2e/quickadd-capture.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/e2e/quickadd-capture.test.ts b/tests/e2e/quickadd-capture.test.ts index 76973763..bd2a61c1 100644 --- a/tests/e2e/quickadd-capture.test.ts +++ b/tests/e2e/quickadd-capture.test.ts @@ -425,8 +425,9 @@ describe("captures with a QuickAdd choice", () => { await settingsShow(["Choose...", "Create"], "row-unset"); await click(obsidian, rowControl("Choose...")); - const picker = '[...document.querySelectorAll(".modal.qa-choice-suggester")]'; + const picker = '[...document.querySelectorAll(".qa-choice-suggester")]'; await waitUntilShown(obsidian, { name: "QuickAdd's choice picker", elements: picker }); + await obsidian.dev.screenshot(path.join(EVIDENCE, "picker-open.png")); await typeText(obsidian, "Log"); await screenshot("picker", picker); await pressKey(obsidian, "Enter"); @@ -462,7 +463,7 @@ describe("captures with a QuickAdd choice", () => { ).toBeNull(); await runCommand(obsidian, `${PLUGIN_ID}:capture-timestamp`); - const first = `## Notes\n- ${link(server, 30)} \n`; + const first = `## Notes\n- ${link(server, 30)} `; expect( await eventually( obsidian, @@ -473,7 +474,7 @@ describe("captures with a QuickAdd choice", () => { ).toBe(first); await seek(40); await runCommand(obsidian, `${PLUGIN_ID}:capture-timestamp`); - const second = `${first}- ${link(server, 40)} \n`; + const second = `${first}\n- ${link(server, 40)} `; expect( await eventually(obsidian, () => readVaultText(obsidian, notePath), second), "the second capture, last in the section", From bfcd3028ad1df8b4f62f33d891c79dd4c5ef77bb Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sun, 18 Oct 2026 23:19:02 +0200 Subject: [PATCH 10/11] fix(settings): keep a long QuickAdd choice name inside its button, and Choose...'s label while the picker is open On a phone or in a narrow window, a long choice name drew past its button and pushed Clear out of the row; beside the row's name it squeezed the name away. The button now ends the name in an ellipsis, and the row's control may shrink. Choose... no longer hands Obsidian the picker's promise, which swapped its label for a spinner while QuickAdd's picker was open. The picker covers the button, so it still takes no second click. The e2e checks the row's bounds at 900 px, 720 px and on a phone, and the label behind the open picker. --- src/ui/settings/PodNotesSettingsTab.ts | 17 ++++-- tests/e2e/quickadd-capture.test.ts | 73 ++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 3 deletions(-) diff --git a/src/ui/settings/PodNotesSettingsTab.ts b/src/ui/settings/PodNotesSettingsTab.ts index 1c672e45..ad738ec1 100644 --- a/src/ui/settings/PodNotesSettingsTab.ts +++ b/src/ui/settings/PodNotesSettingsTab.ts @@ -734,14 +734,25 @@ export class PodNotesSettingsTab extends PluginSettingTab { let clear!: ExtraButtonComponent; setting .addButton((button) => { - choose = button.onClick(async () => { + // Returns no promise, or Obsidian swaps the label for a spinner while the picker is open. + choose = button.onClick(() => { const quickAdd = findQuickAdd(this.app); if ("fix" in quickAdd) { (this.app as App2).setting.openTabById("community-plugins"); return; } - const picked = await quickAdd.api.pickChoice().catch(() => null); - if (picked) select(picked.id); + quickAdd.api.pickChoice().then( + ({ id }) => select(id), + () => {}, + ); + }); + // A long choice name ends in an ellipsis inside the button, so the row's name + // and Clear keep their place. + setting.controlEl.setCssStyles({ minWidth: "0" }); + choose.buttonEl.setCssStyles({ + display: "block", + overflow: "hidden", + textOverflow: "ellipsis", }); }) .addButton((button) => { diff --git a/tests/e2e/quickadd-capture.test.ts b/tests/e2e/quickadd-capture.test.ts index bd2a61c1..3c189cdc 100644 --- a/tests/e2e/quickadd-capture.test.ts +++ b/tests/e2e/quickadd-capture.test.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url"; import { describe, expect, onTestFinished, test } from "vitest"; import { createPodNotesE2EHarness } from "./harness"; import { + asPhone, closeSettings, openPodNotesSettings, runCommand, @@ -427,6 +428,17 @@ describe("captures with a QuickAdd choice", () => { await click(obsidian, rowControl("Choose...")); const picker = '[...document.querySelectorAll(".qa-choice-suggester")]'; await waitUntilShown(obsidian, { name: "QuickAdd's choice picker", elements: picker }); + expect( + await obsidian.dev.evalJson(`(() => { + const button = ${rowControl("Choose...").elements}[0]; + const { left, top, width, height } = button.getBoundingClientRect(); + return { + label: getComputedStyle(button).color !== "rgba(0, 0, 0, 0)", + clickable: button.contains(document.elementFromPoint(left + width / 2, top + height / 2)), + }; + })()`), + "Choose... behind the open picker keeps its label and takes no second click", + ).toEqual({ label: true, clickable: false }); await obsidian.dev.screenshot(path.join(EVIDENCE, "picker-open.png")); await typeText(obsidian, "Log"); await screenshot("picker", picker); @@ -512,6 +524,67 @@ describe("captures with a QuickAdd choice", () => { ); }, 90_000); + test("a long choice name stays on one line in its button, beside the row's name and Clear, in a narrow window and on a phone", async () => { + const { obsidian, plugin } = getContext(); + const name = "Podcast research notes for my weekly engineering and product review"; + await installQuickAdd(await quickAddBuild(), [logChoice(name)]); + await plugin.updateDataAndReload((data) => { + data.timestamp.quickAddChoiceId = LOG_ID; + }); + const size = await obsidian.dev.evalJson<[number, number]>( + 'require("@electron/remote").getCurrentWindow().getContentSize()', + ); + onTestFinished(() => + obsidian.dev.evalJson( + `require("@electron/remote").getCurrentWindow().setContentSize(${size[0]}, ${size[1]}), true`, + ), + ); + const fits = async (where: string) => + expect( + await obsidian.dev.evalJson(`(() => { + const row = ${QUICKADD_ROW}[0]; + const [button, clear] = [...row.querySelector(".setting-item-control").children].filter((control) => control.getClientRects().length > 0); + const label = row.querySelector(".setting-item-name"); + const text = document.createRange(); + text.selectNodeContents(button); + const [r, b, c] = [row, button, clear].map((element) => element.getBoundingClientRect()); + return { + rowOverflows: row.scrollWidth > row.clientWidth, + labelSqueezed: label.scrollWidth > label.clientWidth, + nameCut: button.scrollWidth > button.clientWidth, + buttonInRow: b.left >= r.left && b.right <= r.right, + nameStartsInButton: text.getBoundingClientRect().left >= b.left, + clearInRow: c.left >= b.right && c.right <= r.right, + }; + })()`), + `the QuickAdd choice row ${where}`, + ).toEqual({ + rowOverflows: false, + labelSqueezed: false, + nameCut: true, + buttonInRow: true, + nameStartsInButton: true, + clearInRow: true, + }); + + // 900 px keeps the row's name beside its controls; 720 px stacks them. + for (const width of [900, 720]) { + await obsidian.dev.evalJson( + `require("@electron/remote").getCurrentWindow().setContentSize(${width}, ${Math.max(size[1], 760)}), true`, + ); + await settingsShow([name, "Clear"], `row-long-${width}`); + await fits(`in a ${width} px window`); + await closeSettings(obsidian); + } + + await asPhone(obsidian); + await obsidian.dev.evalJsonAsync( + '(async () => { await app.plugins.enablePlugin("quickadd"); return true; })()', + ); + await settingsShow([name, "Clear"], "row-long-phone"); + await fits("on a phone"); + }, 120_000); + test("QuickAdd 2.32.1, from before choice picking, asks for an update and runs nothing", async () => { await installQuickAdd(await oldQuickAdd(), [LOG]); const { server, notePath } = await noteAt(30); From e4ccd04cf18ed287db5847042ab020c1064964e3 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sun, 18 Oct 2026 23:19:02 +0200 Subject: [PATCH 11/11] test(e2e): open Settings only after the new episode note has focus Obsidian closes Settings when it focuses a note, and PodNotes opens the episode note after creating it. A test that opened Settings once the file existed lost Settings to the note's focus about one run in ten, timing out on the PodNotes tab. noteAt now waits for the note's focus. --- tests/e2e/quickadd-capture.test.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/e2e/quickadd-capture.test.ts b/tests/e2e/quickadd-capture.test.ts index 3c189cdc..f3e1abe9 100644 --- a/tests/e2e/quickadd-capture.test.ts +++ b/tests/e2e/quickadd-capture.test.ts @@ -260,10 +260,22 @@ async function noteAt( seconds: number, quickAddChoiceId = LOG_ID, ): Promise<{ server: FixtureServer; notePath: string }> { - const { obsidian, sandbox } = getContext(); + const { obsidian } = getContext(); const played = await playAt(seconds, { template: "{{linktime}}", quickAddChoiceId }); await runCommand(obsidian, `${PLUGIN_ID}:create-podcast-note`); - await sandbox.waitForExists(NOTE, { timeoutMs: 10_000 }); + // Obsidian closes Settings when it focuses a note, so a test opens Settings only after this. + expect( + await eventually( + obsidian, + () => + obsidian.dev.evalJson( + `Boolean(app.workspace.getLeavesOfType("markdown").find((leaf) => leaf.view.file?.path === ${jsLiteral(played.notePath)})?.containerEl.contains(document.activeElement))`, + ), + true, + 10_000, + ), + "the new episode note opened, with focus", + ).toBe(true); return played; }