Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
2a06922
test(e2e): add the Transcript panel journeys
chhoumann Oct 14, 2026
cb6dbd5
refactor(transcript): add the panel's transcript lines and the view's…
chhoumann Oct 14, 2026
c29510b
feat(ui): add the full player's Transcript panel
chhoumann Oct 14, 2026
65c850b
test(ui): check the player stays open before the rest of Find's Escape
chhoumann Oct 14, 2026
f2216b0
build: raise the gzip budget to 103,000 bytes for the Transcript panel
chhoumann Oct 14, 2026
a33746e
fix(ui): announce the transcript's load error and title its Save button
chhoumann Oct 14, 2026
64396c0
fix(ui): keep the panel list's name out of Obsidian's hover tooltip
chhoumann Oct 14, 2026
db43d11
test(verify): add the full player's Transcript screens state
chhoumann Oct 14, 2026
901c0c9
fix(ui): mark Find matches with Obsidian 1.14's highlight color, keep…
chhoumann Oct 14, 2026
cdf94dd
Merge remote-tracking branch 'origin/master' into feat/ui-transcript-…
chhoumann Oct 14, 2026
31dd061
fix(ui): show Retry when the feed lookup fails, redact quoted private…
chhoumann Oct 14, 2026
3bacebd
fix(ui): mark the Find hit in untimed transcripts and cover a saved p…
chhoumann Oct 14, 2026
9b5ed5c
Merge remote-tracking branch 'origin/master' into feat/ui-transcript-…
chhoumann Oct 14, 2026
5c282c2
test(ui): type the saved private feed's plugin store in the transcrip…
chhoumann Oct 14, 2026
4a818d5
refactor(ui): trim the transcript panel's comments and name its checks
chhoumann Oct 14, 2026
8e69aa9
fix(transcripts): redact a credential-bearing transcript address wher…
chhoumann Oct 14, 2026
fa0de9b
docs(transcripts): say what the grouping limits actually bound
chhoumann Oct 14, 2026
92a132c
fix(ui): make every panel head control and the Follow chip 44 px tall…
chhoumann Oct 14, 2026
915b761
test(ui): cover Find across an episode switch, the transcript's priva…
chhoumann Oct 14, 2026
46594f3
Merge branch 'master' into feat/ui-transcript-panel
chhoumann Oct 14, 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
8 changes: 4 additions & 4 deletions .claude/skills/verify/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,14 +251,14 @@ its profile. It keeps the vault and `.obsidian-e2e-artifacts/`. Delete
the design has one. The states are `first-run`, `add-podcast`, `library`,
`episodes`, `error-feed`, `queue`, `playlists`, `playlist`, `latest`,
`played`, `local-files`, `offline` and `status-bar`, and the player's
`now-playing`, `mini-player`, `capture`, `chapters`, `up-next`,
`now-playing`, `mini-player`, `capture`, `chapters`, `up-next`, `transcript`,
`up-next-empty`, `no-chapters`, `video`, `local-player` and `media-error`.
`set player` runs the player's states and `set all` every state; both go on
past a failed state and fail at the end. `set` opens each page by its page
JSON, so a state works on any version of the view. A player state plays an
episode until its media loads and pauses it at a fixed second.
`capture`, `chapters`, `up-next` and `up-next-empty` then press the full
player's `Timestamp`, `Chapters` or `Up next` button, and fail at once
`capture`, `chapters`, `up-next`, `up-next-empty` and `transcript` then press the full
player's `Timestamp`, `Chapters`, `Up next` or `Transcript` button, and fail at once
without a button that reads exactly that, as on a full player before v3.
`shot` hides Obsidian's status bar, except in `status-bar`, which puts a
`9 properties` item in it over the mini player and has no phone shot, because
Expand All @@ -269,7 +269,7 @@ its profile. It keeps the vault and `.obsidian-e2e-artifacts/`. Delete
`library`, `retitle`, `requests`, `audio [vault path]` or `stop`. `library`
prints the URL of a manifest of eight podcasts, one whose feed answers 404, and
one more feed to add. Their episodes' media is silence as long as the feed
says. `#213` has chapters, and `Signal & Noise` has a 10 s video episode that
says. `#213` has chapters and a WebVTT transcript, and `Signal & Noise` has a 10 s video episode that
ffmpeg makes on its first request and one whose media answers 404. `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
Expand Down
88 changes: 86 additions & 2 deletions .claude/skills/verify/fixtures.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
// podcasts as PodNotes saves them (Night Owl Science's feed
// answers 404) and one more feed to add. Each episode's
// media is silence as long as its feed says. #213 has
// chapters; Signal & Noise has a video episode, a 10 s
// chapters and a WebVTT transcript with voices; Signal & Noise has a video episode, a 10 s
// test pattern ffmpeg makes on first request, and an
// episode whose media answers 404
// node .claude/skills/verify/fixtures.mjs retitle rename the first feed's episodes, keeping their GUIDs, until the server stops
Expand Down Expand Up @@ -206,10 +206,86 @@ const transcript = (file) => fs.readFileSync(path.join(transcriptFixtures, file)
/**
* @typedef {object} EpisodeExtra
* @property {[startTime: number, title: string][]} [chapters] Podcasting 2.0 JSON chapters.
* @property {[startTime: number, speaker: string, text: string][]} [transcript] A WebVTT transcript with voices.
* @property {boolean} [video] The media is the test pattern.
* @property {boolean} [missing] The media answers 404.
*/

/** @type {[number, string, string][]} */
const TRANSCRIPT_213 = [
[
2172,
"Maya Lind",
"Let's get practical. Someone is listening on a walk right now. What do they actually do?",
],
[
2179,
"Ana Ilić",
"Pick a place you know by heart. Your childhood home works for most people.",
],
[
2187,
"Ana Ilić",
"Not a place you visited once. A place you could walk through with your eyes closed.",
],
[2195, "Maya Lind", "Okay. I've got my grandmother's flat."],
[
2199,
"Ana Ilić",
"Perfect. Take the first idea from the book you're reading and put it at the front door.",
],
[
2207,
"Ana Ilić",
"But make it strange. Give it a smell or a sound. Make it too big for the doorway.",
],
[2216, "Ana Ilić", "The brain is lazy about ordinary things. It holds on to the absurd."],
[2224, "Maya Lind", "So the trick is that you're not memorizing a list."],
[2229, "Ana Ilić", "Exactly. You're walking through a place you already know."],
[
2236,
"Ana Ilić",
"Each room holds one idea, and the order of the rooms is the order of the argument.",
],
[2245, "Maya Lind", "And when you need it back?"],
[2248, "Ana Ilić", "You take the walk again. Hallway, kitchen, the stairs."],
[2256, "Ana Ilić", "People are always surprised how much comes back, and how fast."],
[
2264,
"Maya Lind",
"Does it hold up for abstract ideas, though? Arguments, not lists of things?",
],
[
2273,
"Ana Ilić",
"That's where it gets interesting. You have to turn the abstract into a scene.",
],
[2281, "Ana Ilić", "'Opportunity cost' becomes a man at your front door holding two keys,"],
[2287, "Ana Ilić", "and he will only give you one of them."],
[2297, "Maya Lind", "I'm never going to forget that man now."],
[2301, "Ana Ilić", "That's the point. The stranger the image, the longer it stays."],
[2308, "Ana Ilić", "Then spaced repetition does the rest. You revisit the walk a day later,"],
[2314, "Ana Ilić", "then a week later, then a month later."],
[2321, "Maya Lind", "Which brings us to the apps. Do you use any?"],
[2326, "Ana Ilić", "Honestly? Mostly a pen and the margins of the book."],
[2333, "Ana Ilić", "The app can schedule the walk. It can't take it for you."],
];

/** @param {number} seconds */
function vttClock(seconds) {
const pad = (/** @type {number} */ n) => String(n).padStart(2, "0");
return `${pad(Math.floor(seconds / 3600))}:${pad(Math.floor((seconds % 3600) / 60))}:${pad(seconds % 60)}.000`;
}

/** @param {[number, string, string][]} cues */
function vtt(cues) {
const blocks = cues.map(([start, speaker, text], i) => {
const end = cues[i + 1]?.[0] ?? start + 7;
return `${vttClock(start)} --> ${vttClock(end)}\n<v ${speaker}>${text}`;
});
return `WEBVTT\n\n${blocks.join("\n\n")}\n`;
}

// The design prototype's sample library, so captures compare with its screens.
/** @type {LibraryPodcast[]} */
const LIBRARY = [
Expand All @@ -225,6 +301,7 @@ const LIBRARY = [
"2026-10-02",
4350,
{
transcript: TRANSCRIPT_213,
chapters: [
[0, "Intro"],
[220, "Why we forget"],
Expand Down Expand Up @@ -371,6 +448,7 @@ function libraryMedia({ slug, episodes }, n) {
? { file: `${route}.mp4`, type: "video/mp4", length: 0 }
: { file: `${route}.wav`, type: "audio/wav", length: silentWav(seconds).length }),
chaptersUrl: extra?.chapters && `${base}${route}.json`,
transcriptUrl: extra?.transcript && `${base}${route}.vtt`,
};
}

Expand All @@ -386,7 +464,7 @@ function libraryFeedXml(podcast) {
<pubDate>${new Date(`${date}T12:00:00Z`).toUTCString()}</pubDate>
<description>${xml(title)}.</description>
<enclosure url="${base}${media.file}" type="${media.type}" length="${media.length}"/>
<itunes:duration>${seconds}</itunes:duration>${media.chaptersUrl ? `\n\t\t\t<podcast:chapters url="${media.chaptersUrl}" type="application/json+chapters"/>` : ""}
<itunes:duration>${seconds}</itunes:duration>${media.chaptersUrl ? `\n\t\t\t<podcast:chapters url="${media.chaptersUrl}" type="application/json+chapters"/>` : ""}${media.transcriptUrl ? `\n\t\t\t<podcast:transcript url="${media.transcriptUrl}" type="text/vtt"/>` : ""}
</item>`;
});
return `<?xml version="1.0" encoding="UTF-8"?>
Expand Down Expand Up @@ -502,6 +580,12 @@ function serve() {
Buffer.from(JSON.stringify({ version: "1.2.0", chapters })),
];
}
if (extra?.transcript) {
routes[`${route}.vtt`] = [
"text/vtt; charset=utf-8",
Buffer.from(vtt(extra.transcript)),
];
}
if (extra?.video) {
/** @type {Promise<Buffer> | undefined} */
let made;
Expand Down
6 changes: 3 additions & 3 deletions .claude/skills/verify/screens.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,16 @@
// States: first-run, add-podcast, library, episodes, error-feed, queue,
// playlists, playlist, latest, played, local-files, offline, status-bar, and the
// player's: now-playing, mini-player, capture, chapters, up-next, up-next-empty,
// no-chapters, video, local-player, media-error. player runs the player's states
// transcript, no-chapters, video, local-player, media-error. player runs the player's states
// and all runs every state; both go on past a failed state and fail at the end.
// status-bar is the queue with the mini player and a "9 properties" item in
// Obsidian's status bar, which that state's shots keep. It has no phone shot:
// Obsidian hides the status bar on mobile.
// A player state plays the scene's current episode until its media loads, then
// pauses it at a fixed second, and opens the full player over the Library;
// mini-player stays on the podcast's page. media-error waits for the load to
// fail instead. capture, chapters, up-next and up-next-empty then press the full
// player's Timestamp, Chapters or Up next button with real input, so they fail
// fail instead. capture, chapters, up-next, up-next-empty and transcript then press the full
// player's Timestamp, Chapters, Up next or Transcript button with real input, so they fail
// at once on a full player without a button that reads exactly that. capture
// keeps the "Timestamp added to" notice in its shots.
import { execFileSync } from "node:child_process";
Expand Down
5 changes: 5 additions & 0 deletions .claude/skills/verify/screens/scenes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,11 @@ export const STATES = {
// #213's last chapter.
chapters: { ...FULL_PLAYER, press: "Chapters", text: () => "Listener question" },
"up-next": { ...FULL_PLAYER, press: "Up next", text: () => QUEUE[0][1] },
transcript: {
...FULL_PLAYER,
press: "Transcript",
text: () => "and he will only give you one of them.",
},
"up-next-empty": {
...FULL_PLAYER,
extra: (data) => {
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ a tsconfig matches no files, which `lint:tsconfig` catches.
`tests/tooling/svelteCheck.test.ts` runs the real `check:a11y` in temporary
workspaces and requires it to fail on each of these cases.

After `npm run build`, `npm run size` enforces 330,000 raw bytes and 100,000 gzip bytes at level 9 for root `main.js` in the Test workflow's Build and sizes worker. `npm run size:data` reports a synthetic v2 stress library with 50 feeds, 2,000 history entries, and 200 saved episodes from frozen NPR metadata, including duplicated snapshots. Its 1.5 MB document goal is advisory. Pass an additive candidate JSON path to report optional-field overhead without changing existing data. In the isolated Obsidian instance, `npm run count:writes -- start`, `report`, and `stop` record writes to PodNotes' exact `data.json` path. Stop the recorder before stopping the instance, because recorder commands start a stopped instance; plugin reloads invalidate its counts.
After `npm run build`, `npm run size` enforces 330,000 raw bytes and 103,000 gzip bytes at level 9 for root `main.js` in the Test workflow's Build and sizes worker. `npm run size:data` reports a synthetic v2 stress library with 50 feeds, 2,000 history entries, and 200 saved episodes from frozen NPR metadata, including duplicated snapshots. Its 1.5 MB document goal is advisory. Pass an additive candidate JSON path to report optional-field overhead without changing existing data. In the isolated Obsidian instance, `npm run count:writes -- start`, `report`, and `stop` record writes to PodNotes' exact `data.json` path. Stop the recorder before stopping the instance, because recorder commands start a stopped instance; plugin reloads invalidate its counts.

Before opening a PR or cutting a release, run the CI-equivalent checks locally:

Expand Down
12 changes: 12 additions & 0 deletions scripts/size-baseline.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,18 @@ main.js raw: 307131 bytes; budget 330000 bytes; 22869 bytes remaining
main.js gzip (level 9): 97161 bytes; budget 100000 bytes; 2839 bytes remaining
```

The gzip budget is now 103,000 bytes. On 2026-10-14 it rose from 100,000 for
the full player's Transcript panel, which the owner approved at about 1.9 KB
and which measured 2,576 bytes. The coordinator set 103,000 to keep the
panel's Find and Follow chip and to leave room for fixes in flight. The
numbers above and the rejection proof below were measured against the old
budget. With the panel:

```text
main.js raw: 320728 bytes; budget 330000 bytes; 9272 bytes remaining
main.js gzip (level 9): 101619 bytes; budget 103000 bytes; 1381 bytes remaining
```

Gzip uses Node's `gzipSync` at level 9, without a filename or timestamp.
Budgets use decimal bytes. The brief's 96,533-byte reference is unexplained.
This report fixes the compressor instead of comparing compressed sizes across
Expand Down
2 changes: 1 addition & 1 deletion scripts/size.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ try {
if (!bundle.length) throw new Error(`${path} is empty. Run npm run build first.`);
const measurements = [
{ name: "raw", bytes: bundle.length, budget: 330_000 },
{ name: "gzip (level 9)", bytes: gzipSync(bundle, { level: 9 }).length, budget: 100_000 },
{ name: "gzip (level 9)", bytes: gzipSync(bundle, { level: 9 }).length, budget: 103_000 },
];
const rows = measurements.map(({ name, bytes, budget }) => {
const overage = Math.max(0, bytes - budget);
Expand Down
2 changes: 1 addition & 1 deletion scripts/size.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ describe("bundle budget CLI", () => {
const result = check("gzip.js", randomBytes(110_000));
expect(result.status).toBe(1);
expect(result.stdout).toMatch(
/gzip \(level 9\): \d+ bytes; budget 100000 bytes; OVER by \d+ bytes/,
/gzip \(level 9\): \d+ bytes; budget 103000 bytes; OVER by \d+ bytes/,
);
expect(result.stdout).toContain("budget 330000 bytes; 220000 bytes remaining");
});
Expand Down
35 changes: 35 additions & 0 deletions src/services/TranscriptionService.published.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,41 @@ describe("published transcripts", () => {
expect(diarizeWithDeepgramMock).not.toHaveBeenCalled();
});

test("a transcript quoting its own credential-bearing address and its private feed's keeps neither in the note", async () => {
const feed = "https://private.example.com/rss?auth=FEED_SENTINEL";
const captions = "https://private.example.com/caption.vtt?token=TX_SENTINEL";
fetchTextMock.mockResolvedValue(
`WEBVTT\n\n00:00:00.480 --> 00:00:04.410\n<v Host ${captions}>Feed ${feed} and captions ${captions}\n`,
);
const plugin = createMockPlugin({
openAIKey: "",
podcast: { ...mockEpisode, feedUrl: feed },
feedUrl: feed,
transcripts: [{ url: "caption.vtt?token=TX_SENTINEL", type: "text/vtt" }],
});
const service = new TranscriptionService(plugin);

await service.transcribeCurrentEpisode();
await finished(service, "Saved the transcript the feed publishes.");

expect(fetchTextMock).toHaveBeenCalledWith(captions, expect.anything());
const hidden = "podnotes-private-feed:Test\\%20Podcast";
expect(plugin.app.vault.create).toHaveBeenCalledExactlyOnceWith(
"Transcripts/Test Podcast/Test Episode.md",
`# Test Episode\n\n00:00:00 **Host ${hidden}:** Feed ${hidden} and captions ${hidden}`,
);
});

test("saving only the feed's transcript never sends the audio to a provider", async () => {
const plugin = createMockPlugin({ transcripts: [] });

await new TranscriptionService(plugin).transcribeCurrentEpisode({ paid: false });

expect(noticeMessages).toEqual(["No published transcript was found for this episode."]);
expect(getEpisodeAudioBufferMock).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
});

test("links of unknown types keep today's key notice", async () => {
const plugin = createMockPlugin({
openAIKey: "",
Expand Down
6 changes: 5 additions & 1 deletion src/services/TranscriptionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ export class TranscriptionService {
this.plugin = plugin;
}

async transcribeCurrentEpisode(): Promise<void> {
/** `paid: false` stops at a Notice when no published transcript is found, instead of sending the audio to the user's provider. */
async transcribeCurrentEpisode({ paid = true } = {}): Promise<void> {
if (this.disposed) return;
const episode = this.plugin.api.podcast;
if (!episode) {
Expand All @@ -79,6 +80,8 @@ export class TranscriptionService {

if (transcript) {
this.enqueue({ episode, source: { kind: "published", transcript } });
} else if (!paid) {
new Notice("No published transcript was found for this episode.");
} else if (this.hasRequiredKey()) {
this.enqueue({ episode, source: { kind: "audio" } });
} else {
Expand Down Expand Up @@ -235,6 +238,7 @@ export class TranscriptionService {
const parsed = await this.waitForLifecycle(
fetchPublishedTranscript(
this.plugin.feeds,
episode,
transcript,
this.lifetimeAbortController.signal,
),
Expand Down
7 changes: 6 additions & 1 deletion src/services/publishedTranscript/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,7 @@ describe("fetchPublishedTranscript", () => {

const transcript = await fetchPublishedTranscript(
feeds,
episodeOf(show),
{ key: "Show", reference: "transcripts/1.vtt", format: "vtt" },
signal,
);
Expand All @@ -242,6 +243,7 @@ describe("fetchPublishedTranscript", () => {
await expect(
fetchPublishedTranscript(
feeds,
episodeOf(show),
{ key: "Hidden", reference: "1.vtt", format: "vtt" },
signal,
),
Expand All @@ -268,6 +270,7 @@ describe("fetchPublishedTranscript", () => {
await expect(
fetchPublishedTranscript(
feeds,
episodeOf(show),
{ key: "Show", reference: "1.vtt", format: "vtt" },
controller.signal,
),
Expand All @@ -281,6 +284,7 @@ describe("fetchPublishedTranscript", () => {
await expect(
fetchPublishedTranscript(
feeds,
episodeOf(show),
{ key: "Show", reference: "1.json", format: "json" },
signal,
),
Expand All @@ -295,6 +299,7 @@ describe("fetchPublishedTranscript", () => {
await expect(
fetchPublishedTranscript(
feeds,
episodeOf(show),
{ key: "Gone", reference: "1.vtt", format: "vtt" },
signal,
),
Expand All @@ -311,7 +316,7 @@ describe("fetchPublishedTranscript", () => {
fetchTextMock.mockResolvedValue(transcriptFixture("build-your-saas.vtt"));

const source = await findPublishedTranscript(feeds, episodeOf(hidden));
await fetchPublishedTranscript(feeds, source!, signal);
await fetchPublishedTranscript(feeds, episodeOf(hidden), source!, signal);

const stored = [...storage.values.values()].map(String).join("\n");
expect(stored).toContain('"url":"1.vtt"');
Expand Down
Loading
Loading