Skip to content
Merged
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
4 changes: 2 additions & 2 deletions .claude/skills/verify/features/episode-notes.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Episode notes

A user creates a note for the playing episode. PodNotes fills
`Note creation template` and writes it to `Note creation file path`, by default
`Episode note template` and writes it to `Episode note path`, by default
`PodNotes/{{podcast}}/{{title}}.md`, then opens it. The note links back to the
episode with a `Resume in PodNotes` URI.

Expand All @@ -18,7 +18,7 @@ episode with a `Resume in PodNotes` URI.
- With an episode loaded, run `PodNotes: Create episode note` from the palette.
- In an episode list, click the episode's `More options` button, or right-click the episode, then `Create note`.
- On a podcast's page, click `...` (`Podcast options`), or right-click its Library card, then `Create podcast note`.
- Edit the path and template in Settings > PodNotes under `Episode notes`.
- Edit the path and template in Settings > PodNotes under `Notes`.

## Driving it with ui.mjs

Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/verify/features/player.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,5 +47,5 @@ Preconditions:
- Audio keeps playing on every page, with the full player collapsed, in settings, and with the view closed. The one media element is `audio.podnotes-media` (or `video.podnotes-media`) in the main window, not inside the view.
- After a restart the view opens on its last page, and the mini player shows the last episode paused at its saved position. Nothing loads or plays until you press play. A sync that lands before then updates the shown episode and position, unless you moved the position.
- A video plays in the main window only. With the view in a popout, the player shows the artwork and the video keeps playing hidden in the main window.
- With `Keep a queue of episodes you switch away from` on (the default), switching episodes queues the previous one, and it plays again when the current one ends.
- With `Queue episodes I switch away from` on (the default), switching episodes queues the previous one, and it plays again when the current one ends.
- Headless Obsidian plays to a null audio sink. Prove playback through `api.currentTime` advancing, not by sound.
4 changes: 2 additions & 2 deletions .claude/skills/verify/features/timestamps-uris.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,15 @@ moment.
- `ts-capture` inserts the formatted current time at the cursor ("Capture Timestamp").
- `ts-segment` inserts a linked range ending now ("Capture Last 10 Seconds", "Capture Last 20 Seconds").
- `ts-undo` confirms every capture, from the player, the mini player, a command or the headset, in a Notice: `Timestamp added to <note>`, or `Created <note>` when it created the episode note. Its Undo puts the note back as the capture found it, only while the note is still exactly as the capture left it; any later change, also a pending save, gets the Notice `The note isn't as the capture left it, so the timestamp stays`. A created note is kept.
- `ts-format` applies the `Capture timestamp format` and `Timestamp offset (s)` settings.
- `ts-format` applies the `Timestamp template` and `Timestamp offset (s)` settings.
- `uri-open` reopens the linked episode at `time`, also after another episode played.
- `uri-segment` plays a linked range and stops at its end (`endTime`).
- `uri-errors` shows a Notice for a missing `url` or `episodeName`, or for a bad `time`.

## How to get to it (user POV)

- With a note open and an episode playing, run `PodNotes: Capture Timestamp` from the palette or a hotkey.
- Set the format in Settings > PodNotes > `Capture timestamp format`, for example `- {{linktime}} `.
- Set the format in Settings > PodNotes > `Timestamp template`, for example `- {{linktime}} `.
- Click a captured link in Reading view or Live Preview.
- Open an `obsidian://podnotes?...` URI from outside Obsidian.

Expand Down
4 changes: 2 additions & 2 deletions .claude/skills/verify/features/transcripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ A user transcribes the current episode. When its feed publishes a transcript
(`<podcast:transcript>`), PodNotes writes it as timestamped paragraphs with no
API key and no call to OpenAI or Deepgram. Otherwise, and always through
`Transcribe current episode from audio`, it sends the audio to the configured
provider with the user's key. The note goes to `Transcript file path`, filled
provider with the user's key. The note goes to `Transcript path`, filled
from `Transcript template`, and opens.

## Sub-features
Expand All @@ -19,7 +19,7 @@ from `Transcript template`, and opens.
## How to get to it (user POV)

- With an audio episode loaded, run `PodNotes: Transcribe current episode` or `PodNotes: Transcribe current episode from audio` from the palette.
- Set the path, template and API keys in Settings > PodNotes under `Transcripts`. Timestamps follow `Capture timestamp format` under `Episode notes`: links for `{{linktime}}`, plain times for `{{time}}`.
- Set the path, template and API keys in Settings > PodNotes under `Transcripts`. Timestamps follow `Timestamp template` under `Notes`: links for `{{linktime}}`, plain times for `{{time}}`.

## Driving it with ui.mjs

Expand Down
6 changes: 2 additions & 4 deletions obsidian-e2e.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ export default {
playlists: {},
skipBackwardLength: 15,
skipForwardLength: 15,
headsetPreviousTrack: "captureTimestamp",
headsetNextTrack: "skipForward",
timestamp: {
template: "- {{linktime}} ",
offset: 0,
Expand Down Expand Up @@ -121,10 +123,6 @@ export default {
speakerTemplate: "**{{speaker}}:** ",
},
},
feedCache: {
enabled: true,
ttlHours: 6,
},
},
buildCommand: "npm run build",
// Emit legacy PODNOTES_E2E_* env aliases alongside the canonical OBSIDIAN_E2E_*
Expand Down
1 change: 0 additions & 1 deletion scripts/persistence-size.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@ describe("persistence byte fixture", () => {
load: async () => parsed,
resolveUrl: (feed) => feed.url,
storage: new MemoryStorage(),
cache: () => ({ enabled: false, ttlHours: 6 }),
now: Date.now,
});
expect(await feeds.channelAt({ url: sample.source.url })).toEqual(sample.feed);
Expand Down
1 change: 0 additions & 1 deletion src/URIHandler.feeds.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,6 @@ describe("podNotesURIHandler with saved feeds", () => {
load,
resolveUrl: (feed) => resolveFeedUrl(feed, feedUrls),
storage: new MemoryStorage(),
cache: () => ({ enabled: false, ttlHours: 6 }),
now: Date.now,
});
const subscriptions = { [patron.title]: patron, [testEpisode.podcastName]: publicFeed };
Expand Down
12 changes: 5 additions & 7 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,8 @@ export const DEFAULT_SETTINGS: IPodNotesSettings = {
playlists: ownRecord(),
skipBackwardLength: 15,
skipForwardLength: 15,
headsetPreviousTrack: "captureTimestamp",
headsetNextTrack: "skipForward",
currentEpisode: undefined,

timestamp: {
Expand All @@ -96,7 +98,7 @@ export const DEFAULT_SETTINGS: IPodNotesSettings = {
// user to fill and give Bases columns to sort and filter on. The raw
// {{title}} (which may contain quotes/colons) and {{url}} (a feed URL, or for
// a local file a vault link whose name may contain a quote) live in the BODY,
// where YAML rules don't apply — keeping the verbatim, unescaped tag values
// where YAML rules don't apply, keeping the verbatim, unescaped tag values
// out of quoted frontmatter scalars so the frontmatter never fails to parse
// (issue #160 review). {{podcastlink}} ties each episode to its feed note
// (#163). See issue #160.
Expand Down Expand Up @@ -145,8 +147,8 @@ export const DEFAULT_SETTINGS: IPodNotesSettings = {

download: {
// Must include a per-episode token ({{title}}). An empty or token-less path
// resolves every episode to the same name — e.g. "" -> ".mp3", a hidden
// dotfile at the vault root that Obsidian never indexes — so the first
// resolves every episode to the same name (e.g. "" -> ".mp3", a hidden
// dotfile at the vault root that Obsidian never indexes), so the first
// download writes junk and the second throws "File already exists" (#183).
path: "PodNotes/{{podcast}}/{{title}}",
},
Expand All @@ -168,8 +170,4 @@ export const DEFAULT_SETTINGS: IPodNotesSettings = {
speakerTemplate: DEFAULT_SPEAKER_TEMPLATE,
},
},
feedCache: {
enabled: true,
ttlHours: 6,
},
};
1 change: 0 additions & 1 deletion src/createFeedNote.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,6 @@ describe("createFeedNote", () => {
load,
resolveUrl: (feed) => resolveFeedUrl(feed, feedUrls),
storage: new MemoryStorage(),
cache: () => ({ enabled: false, ttlHours: 6 }),
now: Date.now,
});
feeds.setSubscriptions({ [saved.title]: saved });
Expand Down
54 changes: 52 additions & 2 deletions src/main.persistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,16 @@ function makePlugin(

const settled = () => new Promise((resolve) => setTimeout(resolve, 0));

function savedByThisVersion(released: Record<string, unknown>): Record<string, unknown> {
const data: Record<string, unknown> = {
...structuredClone(released),
headsetPreviousTrack: "captureTimestamp",
headsetNextTrack: "skipForward",
};
delete data.feedCache;
return data;
}

async function saveInBackground(plugin: PodNotes): Promise<void> {
void plugin.saveSettings();
await settled();
Expand Down Expand Up @@ -376,7 +386,7 @@ describe("PodNotes persistence integration", () => {

expect(saveData).toHaveBeenCalledTimes(1);
expect(saveData.mock.calls[0][0]).toEqual({
...released,
...savedByThisVersion(released),
defaultVolume: 0.4,
libraryId: "",
});
Expand Down Expand Up @@ -411,7 +421,7 @@ describe("PodNotes persistence integration", () => {
const { urlSecretId } = written.savedFeeds["PRIV2 Both"];
expect(urlSecretId).toMatch(/^podnotes-feed-url-[0-9a-f-]{36}$/);
expect(written).toEqual({
...released2211,
...savedByThisVersion(released2211),
libraryId: "",
savedFeeds: { "PRIV2 Both": { ...released2211.savedFeeds["PRIV2 Both"], urlSecretId } },
});
Expand Down Expand Up @@ -784,6 +794,46 @@ describe("PodNotes persistence integration", () => {
expect(saveData.mock.lastCall?.[0].queue.episodes).toHaveLength(2);
});

it("drops a saved feed cache preference at the next save, even one an older device syncs back", async () => {
const saveData = vi.fn().mockResolvedValue(undefined);
const plugin = makePlugin(saveData, { dataFileExists: true });
const older = {
...structuredClone(released2211),
feedCache: { enabled: false, ttlHours: 1 },
};
Object.assign(plugin, { loadData: vi.fn().mockResolvedValue(structuredClone(older)) });

await plugin.loadSettings();
expect(saveData).not.toHaveBeenCalled();
attachDataFileWriter(plugin);
plugin.settings.defaultVolume = 0.4;
await plugin.saveSettingsStrict();
expect(saveData.mock.lastCall?.[0]).toEqual({
...savedByThisVersion(released2211),
defaultVolume: 0.4,
libraryId: "",
});

Object.assign(plugin, {
loadData: vi.fn().mockResolvedValue({
...structuredClone(older),
defaultVolume: 0.4,
skipForwardLength: 45,
}),
});
await plugin.onExternalSettingsChange();
plugin.settings.skipBackwardLength = 20;
await plugin.saveSettingsStrict();

expect(saveData.mock.lastCall?.[0]).toEqual({
...savedByThisVersion(released2211),
defaultVolume: 0.4,
skipForwardLength: 45,
skipBackwardLength: 20,
libraryId: "",
});
});

it("takes progress another device made even when the synced file had to repair another record", async () => {
const plugin = makePlugin(vi.fn().mockResolvedValue(undefined));
const progress = (title: string, time: number) => ({
Expand Down
4 changes: 3 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ import { FeedUrlRepository } from "./services/FeedUrlRepository";
import { privateFeedAddresses, protectPrivateFeeds, resolveFeedUrl } from "./services/privateFeeds";
import { redactPrivateFeedAddresses } from "./utility/privateFeedUrl";
import { FeedRepository, loadFeed } from "./services/FeedRepository";
import { fetchChapters } from "./utility/fetchChapters";
import { onlineStatus } from "./services/online";

type WriteRefusal =
Expand Down Expand Up @@ -153,7 +154,6 @@ export default class PodNotes extends Plugin implements IPodNotes {
load: loadFeed,
resolveUrl: (feed) => resolveFeedUrl(feed, this.feedUrls),
storage: this.app,
cache: () => this.settings.feedCache,
now: Date.now,
});

Expand Down Expand Up @@ -237,9 +237,11 @@ export default class PodNotes extends Plugin implements IPodNotes {
bindMediaSession(this.player, {
mediaSession: window.navigator.mediaSession,
MediaMetadata: window.MediaMetadata,
settings: () => this.settings,
captureTimestamp: () => void this.capture.timestamp(),
skipBackward: () => this.api.skipBackward(),
skipForward: () => this.api.skipForward(),
chapters: fetchChapters,
}),
);
this.isReady = true;
Expand Down
5 changes: 3 additions & 2 deletions src/opml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,10 @@ async function importOPML(opml: string): Promise<void> {
notice.update("No new podcasts to import.");
return;
}
const progress = ((completedImports / total) * 100).toFixed(1);
notice.update(
`Importing... ${completedImports}/${total} podcasts completed (${progress}%)`,
total === 1
? "Importing 1 podcast..."
: `Importing ${total} podcasts: ${completedImports} done.`,
);
};

Expand Down
21 changes: 21 additions & 0 deletions src/persistence/codecUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,27 @@ export function mapRecord<T, R>(
return result;
}

export function isOption<T extends string>(
options: Readonly<Record<T, unknown>>,
value: unknown,
): value is T {
return typeof value === "string" && Object.prototype.hasOwnProperty.call(options, value);
}

export function readOption<T extends string>(
record: UnknownRecord,
key: string,
options: Readonly<Record<T, unknown>>,
fallback: T,
warnings: Set<string>,
): T {
const value = record[key];
if (value === undefined) return fallback;
if (isOption(options, value)) return value;
warn(warnings, key, "unknown value");
return fallback;
}

export function isPlainObject(value: unknown): value is UnknownRecord {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
Expand Down
71 changes: 69 additions & 2 deletions src/persistence/podNotesData.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,21 @@ function settingsWithEveryEpisodeContainer(): IPodNotesSettings {
};
}

function decodeFeedCacheAs2211(value: unknown): { enabled: boolean; ttlHours: number } {
const record =
typeof value === "object" && value !== null && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
const { enabled, ttlHours } = record;
return {
enabled: typeof enabled === "boolean" ? enabled : true,
ttlHours:
typeof ttlHours === "number" && Number.isFinite(ttlHours) && ttlHours > 0
? ttlHours
: 6,
};
}

describe("PodNotes data schema", () => {
it.each([
["query", "https://private.example/feed?auth=ENCODE_SENTINEL"],
Expand Down Expand Up @@ -534,14 +549,66 @@ describe("PodNotes data schema", () => {
expect(decoded.warnings).toEqual(["currentEpisode.guid: expected a string"]);
});

it("loads a 2.21.1 library unchanged", () => {
it("loads a 2.21.1 library unchanged, and saves it with the headset defaults and no feed cache preference", () => {
const decoded = decodePodNotesData(structuredClone(released2211));
const { feedCache, ...rest } = released2211;

expect(decoded.warnings).toEqual([]);
expect(decoded.changed).toBe(false);
expect(feedCache).toEqual({ enabled: true, ttlHours: 6 });
expect(
JSON.parse(JSON.stringify(encodePodNotesData(decoded.settings, decoded.unknownFields))),
).toEqual({ ...released2211, libraryId: "" });
).toEqual({
...rest,
headsetPreviousTrack: "captureTimestamp",
headsetNextTrack: "skipForward",
libraryId: "",
});
});

it("reads the headset mapping, and an action the button does not have as its default", () => {
const decoded = decodePodNotesData({
...structuredClone(released2211),
headsetPreviousTrack: "previousChapter",
headsetNextTrack: "nextInQueue",
});
expect(decoded.warnings).toEqual([]);
expect([decoded.settings.headsetPreviousTrack, decoded.settings.headsetNextTrack]).toEqual([
"previousChapter",
"nextInQueue",
]);

for (const action of ["skipForward", "toString", "__proto__", 3]) {
const repaired = decodePodNotesData({
...structuredClone(released2211),
headsetPreviousTrack: action,
});
expect(repaired.settings.headsetPreviousTrack).toBe("captureTimestamp");
expect(repaired.warnings).toEqual(["headsetPreviousTrack: unknown value"]);
}
});

it("reads a feed cache turned off as no setting at all, keeping the fields it does not know", () => {
const decoded = decodePodNotesData({
...structuredClone(released2211),
feedCache: { enabled: false, ttlHours: 1 },
futureField: { kept: true },
});
const encoded = JSON.parse(
JSON.stringify(encodePodNotesData(decoded.settings, decoded.unknownFields)),
);

expect(decoded.warnings).toEqual([]);
expect(decoded.changed).toBe(false);
expect(decoded.unknownFields).toEqual({ futureField: { kept: true } });
expect(encoded).not.toHaveProperty("feedCache");
expect(encoded.futureField).toEqual({ kept: true });
expect(decodeFeedCacheAs2211(encoded.feedCache)).toEqual({ enabled: true, ttlHours: 6 });
expect(decodeFeedCacheAs2211(released2211.feedCache)).toEqual(released2211.feedCache);
expect(decodeFeedCacheAs2211({ enabled: false, ttlHours: 1 })).toEqual({
enabled: false,
ttlHours: 1,
});
});

it.each([
Expand Down
3 changes: 3 additions & 0 deletions src/persistence/podNotesData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,12 @@ export const PODNOTES_DATA_SCHEMA_VERSION = 2;
/** Plaintext fields accepted only as migration input and never persisted again. */
const RETIRED_PLAINTEXT_SECRET_KEYS = new Set(["openAIApiKey", "diarizationApiKey"]);

const RETIRED_SETTING_KEYS = ["feedCache"];

const KNOWN_TOP_LEVEL_KEYS = new Set([
...Object.keys(DEFAULT_SETTINGS),
...RETIRED_PLAINTEXT_SECRET_KEYS,
...RETIRED_SETTING_KEYS,
"schemaVersion",
]);

Expand Down
Loading
Loading