Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
da3b864
test(verify): rename the fixture feed's episodes on demand, keeping t…
chhoumann Oct 13, 2026
1f07d83
feat(capture): capture into the last focused editor or the episode note
chhoumann Oct 13, 2026
5ab2a1d
feat(notes): create an episode note without playing, from a podcast a…
chhoumann Oct 13, 2026
697c427
feat(episodes): carry the feed GUID
chhoumann Oct 13, 2026
53adca1
feat(links): add guid to PodNotes links and resolve by GUID first
chhoumann Oct 13, 2026
f1c1ff1
feat(notes): find episode notes by GUID
chhoumann Oct 13, 2026
00ca522
fix(transcripts): match published transcripts by GUID
chhoumann Oct 13, 2026
97a7490
chore: drop em-dashes from two comments in touched files
chhoumann Oct 13, 2026
dced281
fix(links): let a link's guid only rule the loaded episode out
chhoumann Oct 13, 2026
10fedce
fix(notes): treat a quoted frontmatter key as present
chhoumann Oct 13, 2026
17e3e25
fix(capture): skip a note in Reading view
chhoumann Oct 13, 2026
ddfffc1
fix(transcripts): match by guid only within the episode's own feed
chhoumann Oct 13, 2026
df9bfa1
refactor(notes): look up the episode note once per capture
chhoumann Oct 13, 2026
4c5d5e4
fix(notes): count only a string guid in a note's frontmatter
chhoumann Oct 13, 2026
354f1b8
fix(notes): load the picked podcast by its saved key
chhoumann Oct 13, 2026
6463953
chore(verify): describe capture from anywhere and the retitle fixture
chhoumann Oct 13, 2026
44a2ac7
fix(links): take the loaded episode for a guid link only when its gui…
chhoumann Oct 13, 2026
41fe789
fix(notes): return the episode note that appears while its chapters load
chhoumann Oct 13, 2026
1c1c233
fix(notes): report a note another create wrote first as not created
chhoumann Oct 13, 2026
31398cd
test(e2e): journeys for GUID notes and links, the episode picker, and…
chhoumann Oct 13, 2026
fb967a8
Merge remote-tracking branch 'origin/master' into feat/note-identity
chhoumann Oct 13, 2026
ffc67b6
fix(episodes): leave out a guid that repeats within a feed
chhoumann Oct 13, 2026
3b9fe47
fix(capture): keep the editor used last when a note in Reading view t…
chhoumann Oct 13, 2026
b622f9a
build(deps-dev): parse YAML in tests with the library Obsidian bundles
chhoumann Oct 13, 2026
90a4337
fix(notes): add a note's guid and feedUrl by the YAML its frontmatter…
chhoumann Oct 13, 2026
017895e
test(e2e): journeys for a GUID two episodes share, a note in Reading …
chhoumann Oct 13, 2026
1448221
Merge origin/master (#434 v3 shell) into feat/note-identity
chhoumann Oct 13, 2026
d2ed715
Merge remote-tracking branch 'origin/master' into feat/note-identity
chhoumann Oct 13, 2026
91711eb
fix(feeds): keep a repeated guid repeated when the cache keeps only a…
chhoumann Oct 13, 2026
62c16e5
fix(notes): list every episode of a long podcast in the episode note …
chhoumann Oct 13, 2026
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
9 changes: 6 additions & 3 deletions .claude/skills/verify/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,9 @@ read state.
```

The direct path is `npm run obsidian:e2e -- command id=podnotes:podnotes-show-leaf`.
Command ids are in `src/commands.ts`. Editor commands such as
`capture-timestamp` only exist while a Markdown editor is focused.
Command ids are in `src/commands.ts`. Capture commands such as
`capture-timestamp` run from anywhere. They write into the last focused
editor, else the episode note.

- **URIs.** Click the link in a note (Reading view), or deliver it the way
Obsidian does when the OS hands it over:
Expand Down Expand Up @@ -199,7 +200,9 @@ its profile. It keeps the vault and `.obsidian-e2e-artifacts/`. Delete
`covered by <tag.class>` when it stays covered, as under the full player. `key` and
`uri` do not wait. Each CLI call fails after 15 s without an answer.
- `.claude/skills/verify/fixtures.mjs` runs as `feed`, `feed-b`, `transcripts`,
`requests`, `audio [vault path]` or `stop`. `requests` prints the paths
`retitle`, `requests`, `audio [vault path]` or `stop`. `retitle` renames the
first feed's two episodes to `Renamed Opening Show` and `Renamed Closing Show`,
keeping their GUIDs, until the server stops. `requests` prints the paths
PodNotes fetched from the server. The feed URL uses the host `podnotes.test`,
because PodNotes refuses literal loopback hosts. The instance maps it to
127.0.0.1 with Chromium's `--host-resolver-rules`, so it needs no DNS, and an
Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/verify/features/episode-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,5 +34,5 @@ Preconditions:

## Gotchas

- The palette lists `Create episode note` only while an episode is loaded and both the note path and template settings are non-empty.
- The palette lists `Create episode note` only while both the note path and template settings are non-empty, and an episode is loaded or a podcast is saved. With nothing loaded it asks for a podcast, then for an episode.
- `{{podcastlink}}` in the default template links the feed note path, which does not exist until you run `Create podcast feed note`. An unresolved link in the new note is expected.
2 changes: 1 addition & 1 deletion .claude/skills/verify/features/timestamps-uris.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Preconditions:

## Gotchas

- Capture commands are editor commands. The palette lists them only while a Markdown editor is focused, so click into the note first.
- Capture commands run from anywhere. A capture goes to the focused editor, else the last focused Markdown editor, else the episode note. A note in Reading view counts as no editor, so switch it to editing first.
- The default format is `- {{linktime}} `. A vault whose `data.json` holds a `{{time}}` format inserts plain text with no link, so check `timestamp.template` before proving links.
- Obsidian writes the note about 2 s after the edit. Read after a short wait, or read the editor with `CLI eval code='app.workspace.getLeavesOfType("markdown")[0].view.editor.getValue()'`.
- A clicked link leaves the app through `xdg-open` and comes back through Obsidian's single-instance handoff, which the runner sets up on Linux. Never run `xdg-open` yourself: it reaches the system Obsidian profile.
Expand Down
27 changes: 23 additions & 4 deletions .claude/skills/verify/fixtures.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// node .claude/skills/verify/fixtures.mjs feed start or reuse the feed server, print the feed URL
// node .claude/skills/verify/fixtures.mjs feed-b same server, print a second feed whose episode titles repeat the first's
// node .claude/skills/verify/fixtures.mjs transcripts same server, print a feed whose episodes publish transcripts
// node .claude/skills/verify/fixtures.mjs retitle rename the first feed's episodes, keeping their GUIDs, until the server stops
// node .claude/skills/verify/fixtures.mjs requests print the paths the server has answered, oldest first
// node .claude/skills/verify/fixtures.mjs audio [path] write a 60 s WAV into this worktree's vault
// (default path: "Fixtures/Local Fixture.wav")
Expand Down Expand Up @@ -57,12 +58,14 @@ function wav(seconds = 60) {
return buffer;
}

/** @param {number} audioBytes @param {string} title @param {string} prefix */
function feedXml(audioBytes, title, prefix) {
const RETITLED = ["", "Renamed Opening Show", "Renamed Closing Show"];

/** @param {number} audioBytes @param {string} title @param {string} prefix @param {boolean} [retitled] */
function feedXml(audioBytes, title, prefix, retitled = false) {
/** @param {number} n @param {string} date */
const item = (n, date) => `
<item>
<title>Fixture Episode ${n}</title>
<title>${retitled ? RETITLED[n] : `Fixture Episode ${n}`}</title>
<guid isPermaLink="false">podnotes-fixture${prefix}-${n}</guid>
<pubDate>${date}</pubDate>
<description>Fixture episode ${n} for PodNotes verification.</description>
Expand Down Expand Up @@ -154,6 +157,11 @@ function serve() {
/** @type {string[]} */
const requests = [];
const server = http.createServer((request, response) => {
if (request.method === "POST" && request.url === "/__retitle") {
routes["/feed.xml"][1] = Buffer.from(feedXml(audio.length, FEED_TITLE, "", true));
response.end();
return;
}
if (request.method === "POST" && request.url === "/__stop") {
server.close();
response.end(() => process.exit(0));
Expand Down Expand Up @@ -255,6 +263,16 @@ async function printRequests() {
for (const line of /** @type {string[]} */ (await response.json())) console.log(line);
}

async function retitleFeed() {
const response = await fetch(`${local}/__retitle`, {
method: "POST",
signal: AbortSignal.timeout(1000),
});
if (!response.ok)
throw new Error("The fixture feed is not running. Run fixtures.mjs feed first.");
console.log(RETITLED.slice(1).join("\n"));
}

async function stopFeed() {
await fetch(`${local}/__stop`, {
method: "POST",
Expand All @@ -268,12 +286,13 @@ try {
else if (verb === "feed") await startFeed(feedUrl);
else if (verb === "feed-b") await startFeed(feedBUrl);
else if (verb === "transcripts") await startFeed(transcriptsUrl);
else if (verb === "retitle") await retitleFeed();
else if (verb === "requests") await printRequests();
else if (verb === "audio") await writeAudio(arg);
else if (verb === "stop") await stopFeed();
else
throw new Error(
"Usage: fixtures.mjs feed | feed-b | transcripts | requests | audio [vault-relative path] | stop",
"Usage: fixtures.mjs feed | feed-b | transcripts | retitle | requests | audio [vault-relative path] | stop",
);
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
Expand Down
3 changes: 2 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@
"typescript": "6.0.3",
"vite": "^8.3.2",
"vitest": "^5.0.3",
"wrangler": "4.149.0"
"wrangler": "4.149.0",
"yaml": "^2.8.4"
},
"overrides": {
"@actions/http-client": {
Expand Down
6 changes: 4 additions & 2 deletions scripts/persistence-size.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,11 @@ describe("persistence byte fixture", () => {
});
expect(await feeds.channelAt({ url: sample.source.url })).toEqual(sample.feed);
expect(JSON.parse(JSON.stringify(await feeds.episodesAt(sample.source.url)))).toEqual(
sample.episodes,
sample.episodes.map((episode: object, index: number) => ({
...episode,
guid: sample.guids[index],
})),
);
expect(parsed.episodes.map((episode) => episode.guid)).toEqual(sample.guids);
});

it("retains a complete stress library through the released v2 codec", () => {
Expand Down
28 changes: 28 additions & 0 deletions src/API/API.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { get } from "svelte/store";
import { beforeEach, describe, expect, test, vi } from "vitest";

import { API } from "./API";
import { TimestampTemplateEngine } from "src/TemplateEngine";
import { DEFAULT_SETTINGS } from "src/constants";
import { PlayerController } from "src/player/PlayerController";
import { downloadedEpisodes, playedEpisodes, plugin, queue } from "src/store";
Expand Down Expand Up @@ -140,6 +141,33 @@ describe("API.getPodcastSegmentFormatted", () => {
});
});

describe("timestamp links", () => {
const feedLink = "episodeName=Feed%20Episode&url=https%3A%2F%2Fpod.example.com%2Ffeed.xml";

function capture(episode: Episode): string {
const { player } = setup();
player.restore(episode);
plugin.update((host) => ({ ...host, api: new API() }) as never);
return TimestampTemplateEngine("{{linktime}} {{linksegment}}", {
segment: { startTime: 115, endTime: 125 },
});
}

test("{{linktime}} and {{linksegment}} carry the episode's guid", () => {
expect(capture({ ...feedEpisode, guid: "ep-1" })).toBe(
`[00:00:00](obsidian://podnotes?${feedLink}&guid=ep-1&time=0) ` +
`[00:01:55-00:02:05](obsidian://podnotes?${feedLink}&guid=ep-1&time=115&endTime=125)`,
);
});

test("{{linktime}} and {{linksegment}} have no guid for an episode without one", () => {
expect(capture(feedEpisode)).toBe(
`[00:00:00](obsidian://podnotes?${feedLink}&time=0) ` +
`[00:01:55-00:02:05](obsidian://podnotes?${feedLink}&time=115&endTime=125)`,
);
});
});

describe("API playback rate controls", () => {
test("increases and decreases the runtime playback rate in tenth steps", () => {
const { api, media } = setup({ defaultPlaybackRate: 1 });
Expand Down
29 changes: 8 additions & 21 deletions src/API/API.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ import type { IAPI } from "./IAPI";
import { TFile } from "obsidian";
import { plugin } from "src/store";
import { get } from "svelte/store";
import encodePodnotesURI from "src/utility/encodePodnotesURI";
import { episodeLinkTarget } from "src/utility/episodeLinkTarget";
import { formatPodcastSegment, normalizePodcastSegmentTimes } from "src/utility/podcastSegment";
import {
adjustPlaybackRate,
Expand Down Expand Up @@ -103,27 +101,16 @@ export class API implements IAPI {
throw new Error("No podcast loaded");
}

const segmentTimes = normalizePodcastSegmentTimes(startTime, endTime);
const segment = segmentTimes
? formatPodcastSegment(segmentTimes.startTime, segmentTimes.endTime, format)
: formatPodcastSegment(startTime, endTime, format);
const segment = normalizePodcastSegmentTimes(startTime, endTime);
if (!segment) return formatPodcastSegment(startTime, endTime, format);

if (!linkify || !segmentTimes) return segment;

const target = episodeLinkTarget(this.podcast);

if (!target) {
return segment;
}

const url = encodePodnotesURI(
this.podcast.title,
target,
segmentTimes.startTime,
segmentTimes.endTime,
return formatEpisodeTimestamp(
this.podcast,
segment.startTime,
format,
linkify,
segment.endTime,
);

return `[${segment}](${url.href})`;
}

async getTranscript(episode = this.podcast): Promise<string | null> {
Expand Down
16 changes: 16 additions & 0 deletions src/URIHandler.feeds.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,22 @@ describe("podNotesURIHandler with saved feeds", () => {
expect(reveal).toHaveBeenCalledTimes(1);
});

test("a private feed link plays the episode with its guid after the publisher retitled it", async () => {
follow();
const retitled = { ...first, title: "Bonus: behind the scenes", guid: "patron-1" };
load.mockResolvedValue(feedDocument("Patron Show", [second, retitled]));

await podNotesURIHandler({
action: "podnotes",
url: privateLink,
episodeName: first.title,
guid: "patron-1",
time: "5",
});

expect(playing()).toMatchObject({ title: retitled.title, guid: "patron-1" });
});

test("a link to a private feed by its secret URL plays the subscription's episode, which keeps the secret out", async () => {
follow();
const linkless = { ...second, url: "" };
Expand Down
131 changes: 131 additions & 0 deletions src/URIHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -736,6 +736,137 @@ describe("a timestamp link PodNotes wrote for the loaded episode", () => {
});
});

describe("a link that carries the episode's guid", () => {
const retitled: Episode = {
...testEpisode,
title: "Season finale: what we learned",
guid: "ep-1",
};
const trailer: Episode = {
...testEpisode,
title: "Trailer",
streamUrl: "https://pod.example.com/trailer.mp3",
};

test("plays the feed episode with that guid after the publisher retitled it and reused its title", async () => {
loadedAt({ ...testEpisode, guid: "ep-1" }, 754);
const written = new API().getPodcastTimeFormatted("HH:mm:ss", true);
loadedAt(plusEpisode, 10);
mockEpisodesAt.mockResolvedValue([
{ ...trailer, title: testEpisode.title, guid: "ep-2" },
retitled,
]);

await follow(written);
metadata();

expect(state().episode).toMatchObject({ title: retitled.title, guid: "ep-1" });
expect(media().currentTime).toBe(754);
});

test("falls back to the title when two feed episodes share the guid", async () => {
mockEpisodesAt.mockResolvedValue([
{ ...trailer, guid: "ep-1" },
{ ...testEpisode, guid: "ep-1" },
]);

await link({ guid: "ep-1", time: "30" });

expect(state().episode).toMatchObject({ title: testEpisode.title });
});

test("falls back to the title when no feed episode has the guid", async () => {
mockEpisodesAt.mockResolvedValue([trailer, { ...testEpisode, guid: "ep-1" }]);

await link({ guid: "ep-2", time: "30" });

expect(state().episode).toMatchObject({ title: testEpisode.title });
});

test("a link without a guid plays the titled episode, not the one feed episode without a guid", async () => {
mockEpisodesAt.mockResolvedValue([trailer, { ...testEpisode, guid: "ep-1" }]);

await link({ time: "30" });

expect(state().episode).toMatchObject({ title: testEpisode.title });
});

test("resumes the loaded episode by its guid after it was retitled", async () => {
loadedAt(retitled, 10);
mockEpisodesAt.mockResolvedValue([trailer, retitled]);

await link({ guid: "ep-1", time: "90" });

expect(mockEpisodesAt).toHaveBeenCalledWith(testFeedUrl);
expect(state().episode).toMatchObject({ title: retitled.title, guid: "ep-1" });
expect(media().currentTime).toBe(90);
expect(media().paused).toBe(false);
});

test("plays the feed episode with the link's guid, not a loaded same-titled episode with another guid", async () => {
const linked: Episode = { ...retitled, streamUrl: "https://pod.example.com/finale.mp3" };
loadedAt({ ...testEpisode, guid: "ep-2" }, 10);
mockEpisodesAt.mockResolvedValue([{ ...testEpisode, guid: "ep-2" }, linked]);

await link({ guid: "ep-1", time: "30" });
metadata();

expect(mockEpisodesAt).toHaveBeenCalledWith(testFeedUrl);
expect(state().episode).toMatchObject({ title: linked.title, guid: "ep-1" });
expect(media().src).toBe(linked.streamUrl);
expect(media().currentTime).toBe(30);
});

test("plays the feed episode with the link's guid, not a loaded same-titled episode without a guid", async () => {
const linked: Episode = { ...retitled, streamUrl: "https://pod.example.com/finale.mp3" };
loadedAt(testEpisode, 10);
mockEpisodesAt.mockResolvedValue([
{ ...trailer, title: testEpisode.title, guid: "ep-2" },
linked,
]);

await link({ guid: "ep-1", time: "30" });
metadata();

expect(mockEpisodesAt).toHaveBeenCalledWith(testFeedUrl);
expect(state().episode).toMatchObject({ title: linked.title, guid: "ep-1" });
expect(media().src).toBe(linked.streamUrl);
expect(media().currentTime).toBe(30);
});

test("plays the titled episode, not the loaded one, when both share the link's guid", async () => {
const loadedTwin: Episode = { ...trailer, guid: "ep-1" };
const named: Episode = { ...testEpisode, guid: "ep-1" };
loadedAt(loadedTwin, 10);
mockEpisodesAt.mockResolvedValue([loadedTwin, named]);

await link({ guid: "ep-1", time: "30" });
metadata();

expect(state().episode).toMatchObject({ title: named.title });
expect(media().src).toBe(named.streamUrl);
expect(media().currentTime).toBe(30);
});

test("does not take the loaded episode of another feed for one with the same guid", async () => {
const otherFeed = "https://other.example.com/feed.xml";
const other: Episode = {
...testEpisode,
title: "Other show's episode",
streamUrl: "https://other.example.com/1.mp3",
feedUrl: otherFeed,
guid: "ep-1",
};
loadedAt(retitled, 10);
mockEpisodesAt.mockResolvedValue([other]);

await link({ url: otherFeed, episodeName: "Something else", guid: "ep-1", time: "5" });

expect(mockEpisodesAt).toHaveBeenCalledWith(otherFeed);
expect(media().src).toBe(other.streamUrl);
});
});

describe("a link to a vault file at its path", () => {
test("plays the file at the link's time when it is not in Local Files", async () => {
vaultFiles = ["Fixtures/Orphan.mp3"];
Expand Down
Loading
Loading