Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/TemplateEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
163 changes: 162 additions & 1 deletion src/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import("./utility/networkRequest")>()),
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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]]);
});
});
});

Expand Down
2 changes: 1 addition & 1 deletion src/createPodcastNote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
49 changes: 49 additions & 0 deletions src/persistence/podNotesData.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -929,3 +929,52 @@ describe("timestamp template (#345)", () => {
expect(decoded.warnings).toEqual(warnings);
});
});

describe("QuickAdd choice", () => {
const saved = (timestamp: Record<string, unknown>) =>
decodePodNotesData({
...listenerData,
schemaVersion: 2,
openAISecretId: "",
deepgramSecretId: "",
timestamp,
});
const persisted = ({ settings, unknownFields }: ReturnType<typeof decodePodNotesData>) =>
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"}');
});
});
5 changes: 4 additions & 1 deletion src/persistence/settingsCodec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
readNullableString,
readOption,
readString,
setOptionalString,
type UnknownRecord,
warn,
} from "./codecUtils";
Expand Down Expand Up @@ -146,7 +147,7 @@ const LEGACY_TIMESTAMP_TEMPLATE = "- {{time}} ";

function decodeTimestamp(value: unknown, warnings: Set<string>): IPodNotesSettings["timestamp"] {
const record = optionalRecord(value, warnings, "timestamp");
return {
const timestamp = {
...copySafeObject(record),
template: readString(record, "template", LEGACY_TIMESTAMP_TEMPLATE, warnings, "timestamp"),
offset: readFiniteNumber(
Expand All @@ -157,6 +158,8 @@ function decodeTimestamp(value: unknown, warnings: Set<string>): IPodNotesSettin
"timestamp",
),
};
setOptionalString(timestamp, record, "quickAddChoiceId", warnings, "timestamp");
return timestamp;
}

function decodeNote(
Expand Down
68 changes: 68 additions & 0 deletions src/quickAdd.ts
Original file line number Diff line number Diff line change
@@ -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<QuickAddChoice>;
executeChoice(ref: { id: string }, variables: Record<string, string>): Promise<void>;
/** Skips a choice whose id QuickAdd already has. */
importPackage(packageJson: string): Promise<unknown>;
}

/** 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<QuickAddApi> } | 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",
},
},
},
],
});
Loading
Loading