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 eeaa505b..71bd29c9 100644 --- a/src/commands.test.ts +++ b/src/commands.test.ts @@ -4,13 +4,14 @@ 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"; 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()), @@ -41,6 +42,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; @@ -242,6 +254,14 @@ 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 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. */ interface Tab { readonly leaf: WorkspaceLeaf; @@ -848,6 +868,147 @@ 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([ + [{ id: "log" }, "20:24-20:34"], + [{ id: "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 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([[{ id: "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([ + [ + { id: "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 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 = ""; + 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.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); + const executeChoice = withQuickAdd(plugin); + plugin.settings.timestamp.quickAddChoiceId = "deleted"; + const { editor } = openNote("Journal.md", "Notes\n"); + focusPodNotes(); + + run("capture-timestamp"); + + await vi.waitFor(() => + expect(said()).toEqual([ + "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); + }); + + 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).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/persistence/podNotesData.test.ts b/src/persistence/podNotesData.test.ts index e55aa36e..d4a3a152 100644 --- a/src/persistence/podNotesData.test.ts +++ b/src/persistence/podNotesData.test.ts @@ -929,3 +929,52 @@ 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'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.quickAddChoiceId).toBe("log"); + expect(persisted(decoded)).toBe( + '{"template":"- {{time}} ","offset":0,"quickAddChoiceId":"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 id that is not text, with a warning", () => { + const decoded = saved({ template: "- {{time}} ", offset: 0, quickAddChoiceId: 42 }); + + expect(decoded.warnings).toEqual(["timestamp.quickAddChoiceId: expected a string"]); + expect(persisted(decoded)).toBe('{"template":"- {{time}} ","offset":0}'); + }); + + // 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" }); + + 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..78487091 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, "quickAddChoiceId", warnings, "timestamp"); + return timestamp; } function decodeNote( diff --git a/src/quickAdd.ts b/src/quickAdd.ts new file mode 100644 index 00000000..85675a7b --- /dev/null +++ b/src/quickAdd.ts @@ -0,0 +1,68 @@ +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; + /** 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. */ +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."; + +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/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..92e7ee8f 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 { findQuickAdd, MISSING_CHOICE } from "src/quickAdd"; +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,44 @@ 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:}}. 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 +>; + +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 +105,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 +123,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 +142,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 choiceId = settings.timestamp.quickAddChoiceId; + if (choiceId) { + await this.runQuickAdd(choiceId, quickAddVariables(episode, rendered)); + return null; + } + const { text } = rendered; const target = this.editorTarget(); if (target?.editor) { @@ -150,6 +207,24 @@ export class CaptureService { } } + /** 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.api.executeChoice({ id }, 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/types/IPodNotesSettings.ts b/src/types/IPodNotesSettings.ts index 9e46fe66..246e1011 100644 --- a/src/types/IPodNotesSettings.ts +++ b/src/types/IPodNotesSettings.ts @@ -41,6 +41,8 @@ export interface IPodNotesSettings { timestamp: { template: string; offset: number; + /** 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 b58e0e52..7c155fb9 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), @@ -168,6 +171,7 @@ describe("sections", () => { [ "Timestamp template", "Timestamp offset (s)", + "QuickAdd choice", "Episode note path", "Episode note template", "Podcast note path", @@ -193,12 +197,14 @@ 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"], .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; @@ -211,6 +217,8 @@ describe("sections", () => { "Next track", "Timestamp template", "Timestamp offset (s)", + "QuickAdd choice", + "QuickAdd choice", "Episode note path", "Episode note template", "Podcast note path", @@ -230,7 +238,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"); } @@ -620,6 +628,162 @@ 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...", "Create"]); + + 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...", "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"); + 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("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) => { + 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 4658bc39..ad738ec1 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 { CAPTURE_CHOICE_ID, CAPTURE_PACKAGE, 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,6 +328,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { }, "Captures this many seconds earlier.", ), + this.quickAddRow(), this.textRow("Episode note path", { ...this.text((settings) => settings.note, "path"), preview: pathPreview(FilePathTemplateEngine), @@ -711,6 +720,84 @@ 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 create!: ButtonComponent; + let clear!: ExtraButtonComponent; + setting + .addButton((button) => { + // 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; + } + 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) => { + 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") + .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...", + ); + choose.buttonEl.classList.toggle(NAMED_BY_ROW, "api" in quickAdd); + create.buttonEl.toggle("api" in quickAdd && !id); + clear.extraSettingsEl.toggle(Boolean(id)); + }); + }, + }; + } + private importRow(name: string, pick: () => void): SettingDefinitionRender { return { name, 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 : ""; } diff --git a/tests/e2e/quickadd-capture.test.ts b/tests/e2e/quickadd-capture.test.ts new file mode 100644 index 00000000..f3e1abe9 --- /dev/null +++ b/tests/e2e/quickadd-capture.test.ts @@ -0,0 +1,643 @@ +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 { + asPhone, + closeSettings, + openPodNotesSettings, + runCommand, + showLibrary, + showPodNotes, +} from "./journey/actions"; +import { startFixtureServer, type FixtureFeed, type FixtureServer } from "./journey/fixtures"; +import { cdp, click, pressKey, typeText } from "./journey/input"; +import { readVaultText, seedLibrary, waitForPlayback } from "./journey/library"; +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"); + +/** 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 OLD_QUICKADD_CACHE = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + `../../.obsidian-e2e-artifacts/releases/quickadd-${OLD_QUICKADD_VERSION}`, +); +/** 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 = { + 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"; +const NOTE = "PodNotes/QuickAdd podcast/Captured episode.md"; +const LOG_ID = "podnotes-e2e-capture"; + +const sha256 = (bytes: Buffer) => createHash("sha256").update(bytes).digest("hex"); + +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(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/${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 ${OLD_QUICKADD_VERSION} ${name} digest`).toBe( + digest, + ); + await fs.writeFile(cached, bytes); + } + files.set(name, bytes); + } + return files; +} + +/** 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( + [ + "useQuickAddTemplateFolder", + "incrementFileNameSettingMoveToDefaultBehavior", + "consolidateFileExistsBehavior", + "repairTemplateFileExistsBehavior", + "mutualExclusionInsertAfterAndWriteToBottomOfFile", + "setVersionAfterUpdateModalRelease", + "addDefaultAIProviders", + "removeMacroIndirection", + "migrateFileOpeningSettings", + "backfillFileOpeningDefaults", + "setProviderModelDiscoveryMode", + "migrateProviderApiKeysToSecretStorage", + "migrateToMultipleTemplateFolders", + "refreshStaleDefaultModelSeeds", + "pinAiModelRefs", + ].map((migration) => [migration, true]), + ), + choices, +}); + +/** 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 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; + 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(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(); + if (${enable}) await app.plugins.enablePlugin("quickadd"); + const quickAdd = app.plugins.plugins.quickadd; + return { + 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 choices", + ).toEqual({ + installed: version, + choices: enable ? choices.map((choice) => (choice as { name: string }).name) : null, + saved: false, + }); +} + +/** 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) => { + Object.assign(data.timestamp, timestamp); + }); + 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(NOTE); + onTestFinished(() => + obsidian.dev.evalJsonAsync(`(async () => { + const file = app.vault.getFileByPath(${jsLiteral(notePath)}); + if (file) await app.vault.delete(file); + return true; + })()`), + ); + 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 } = getContext(); + const played = await playAt(seconds, { template: "{{linktime}}", quickAddChoiceId }); + await runCommand(obsidian, `${PLUGIN_ID}:create-podcast-note`); + // 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; +} + +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 { + return [ + "---\n", + 'guid: "captured-episode"\n', + `feedUrl: "${server.feedUrl(FEED)}"\n`, + "---\n", + "# Captured episode\n\n## Notes\n", + ...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(); + 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 installQuickAdd(await quickAddBuild(), [LOG]); + 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("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...", "Create"], "row-unset"); + + 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); + 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("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)} `; + 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}\n- ${link(server, 40)} `; + 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]); + 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("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); + 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 = () => + 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 settingsShow(["Install QuickAdd", "Clear"], "row-install"); + await closeSettings(obsidian); + + await captureRefused( + notePath, + episodeNote(server, []), + "Install QuickAdd to capture with a QuickAdd choice", + ); + expect(await editorText()).toBe(episodeNote(server, [])); + + 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 8d4d8120..62eb688f 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( 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.