Skip to content

Commit 28d3c2e

Browse files
authored
Improve feed caching and UI accessibility (#124)
* feat: improve caching and ui accessibility * fix: correct aria-pressed type in Icon component Change pressedAttr from 'string | undefined' to 'true | false | undefined' to match the expected type for the aria-pressed attribute. * fix: update tests to match accessibility improvements - Image.test.ts: add interactive={true} prop since Image now only renders as button when interactive - TopBar.test.ts: replace tabindex checks with disabled attribute checks for semantic button elements - TopBar.test.ts: update test name and use click instead of keyboard event (native buttons handle keyboard automatically) * fix: restore static import of FeedParser for test compatibility - Revert dynamic import back to static import to fix vitest mock - Add feedCache settings to integration test mock for completeness - Dynamic imports bypass vitest mocks, breaking integration tests
1 parent 8005ee8 commit 28d3c2e

19 files changed

+443
-137
lines changed

src/constants.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,4 +70,8 @@ export const DEFAULT_SETTINGS: IPodNotesSettings = {
7070
template:
7171
"# {{title}}\n\nPodcast: {{podcast}}\nDate: {{date}}\n\n{{transcript}}",
7272
},
73+
feedCache: {
74+
enabled: true,
75+
ttlHours: 6,
76+
},
7377
};

src/main.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ export default class PodNotes extends Plugin implements IPodNotes {
6464
private downloadedEpisodesController: StoreController<{
6565
[podcastName: string]: DownloadedEpisode[];
6666
}>;
67-
private transcriptionService: TranscriptionService;
67+
private transcriptionService?: TranscriptionService;
6868

6969
private maxLayoutReadyAttempts = 10;
7070
private layoutReadyAttempts = 0;
@@ -103,8 +103,6 @@ export default class PodNotes extends Plugin implements IPodNotes {
103103
this,
104104
).on();
105105

106-
this.transcriptionService = new TranscriptionService(this);
107-
108106
this.api = new API();
109107

110108
this.addCommand({
@@ -265,7 +263,18 @@ export default class PodNotes extends Plugin implements IPodNotes {
265263
this.addCommand({
266264
id: "podnotes-transcribe",
267265
name: "Transcribe current episode",
268-
callback: () => this.transcriptionService.transcribeCurrentEpisode(),
266+
checkCallback: (checking) => {
267+
const canTranscribe =
268+
!!this.api.podcast && !!this.settings.openAIApiKey?.trim();
269+
270+
if (checking) {
271+
return canTranscribe;
272+
}
273+
274+
if (canTranscribe) {
275+
void this.getTranscriptionService().transcribeCurrentEpisode();
276+
}
277+
},
269278
});
270279

271280
this.addSettingTab(new PodNotesSettingsTab(this.app, this));
@@ -311,6 +320,14 @@ export default class PodNotes extends Plugin implements IPodNotes {
311320
}
312321
}
313322

323+
private getTranscriptionService(): TranscriptionService {
324+
if (!this.transcriptionService) {
325+
this.transcriptionService = new TranscriptionService(this);
326+
}
327+
328+
return this.transcriptionService;
329+
}
330+
314331
onunload() {
315332
this?.playedEpisodeController.off();
316333
this?.savedFeedsController.off();

src/services/FeedCacheService.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import type { Episode } from "src/types/Episode";
2+
import type { PodcastFeed } from "src/types/PodcastFeed";
3+
4+
type SerializableEpisode = Omit<Episode, "episodeDate"> & {
5+
episodeDate?: string;
6+
};
7+
8+
interface CachedFeedData {
9+
episodes: SerializableEpisode[];
10+
updatedAt: number;
11+
}
12+
13+
type FeedCache = Record<string, CachedFeedData>;
14+
15+
const STORAGE_KEY = "podnotes:feed-cache:v1";
16+
const DEFAULT_TTL_MS = 1000 * 60 * 60 * 6; // 6 hours.
17+
const MAX_EPISODES_PER_FEED = 75;
18+
19+
let cache: FeedCache | null = null;
20+
21+
function getStorage(): Storage | null {
22+
try {
23+
return typeof localStorage === "undefined" ? null : localStorage;
24+
} catch (error) {
25+
console.error("Unable to access localStorage for feed cache:", error);
26+
return null;
27+
}
28+
}
29+
30+
function loadCache(): FeedCache {
31+
if (cache) {
32+
return cache;
33+
}
34+
35+
const storage = getStorage();
36+
if (!storage) {
37+
cache = {};
38+
return cache;
39+
}
40+
41+
try {
42+
const raw = storage.getItem(STORAGE_KEY);
43+
if (!raw) {
44+
cache = {};
45+
return cache;
46+
}
47+
48+
const parsed = JSON.parse(raw) as FeedCache;
49+
cache = parsed;
50+
return cache;
51+
} catch (error) {
52+
console.error("Failed to parse feed cache:", error);
53+
cache = {};
54+
return cache;
55+
}
56+
}
57+
58+
function persistCache(): void {
59+
const storage = getStorage();
60+
if (!storage) {
61+
return;
62+
}
63+
64+
try {
65+
storage.setItem(STORAGE_KEY, JSON.stringify(cache ?? {}));
66+
} catch (error) {
67+
console.error("Failed to persist feed cache:", error);
68+
}
69+
}
70+
71+
function serializeEpisode(episode: Episode): SerializableEpisode {
72+
return {
73+
...episode,
74+
episodeDate: episode.episodeDate?.toISOString(),
75+
};
76+
}
77+
78+
function deserializeEpisode(episode: SerializableEpisode): Episode {
79+
return {
80+
...episode,
81+
episodeDate: episode.episodeDate ? new Date(episode.episodeDate) : undefined,
82+
};
83+
}
84+
85+
function getFeedKey(feed: PodcastFeed): string {
86+
return feed.url ?? feed.title;
87+
}
88+
89+
export function getCachedEpisodes(
90+
feed: PodcastFeed,
91+
maxAgeMs: number = DEFAULT_TTL_MS,
92+
): Episode[] | null {
93+
const store = loadCache();
94+
const cacheKey = getFeedKey(feed);
95+
const cachedValue = store[cacheKey];
96+
97+
if (!cachedValue) {
98+
return null;
99+
}
100+
101+
const isExpired = Date.now() - cachedValue.updatedAt > maxAgeMs;
102+
if (isExpired) {
103+
delete store[cacheKey];
104+
persistCache();
105+
return null;
106+
}
107+
108+
return cachedValue.episodes.map(deserializeEpisode);
109+
}
110+
111+
export function setCachedEpisodes(feed: PodcastFeed, episodes: Episode[]): void {
112+
if (!episodes.length) {
113+
return;
114+
}
115+
116+
const store = loadCache();
117+
const cacheKey = getFeedKey(feed);
118+
119+
store[cacheKey] = {
120+
updatedAt: Date.now(),
121+
episodes: episodes
122+
.slice(0, MAX_EPISODES_PER_FEED)
123+
.map(serializeEpisode),
124+
};
125+
126+
persistCache();
127+
}
128+
129+
export function clearFeedCache(): void {
130+
cache = {};
131+
const storage = getStorage();
132+
try {
133+
storage?.removeItem(STORAGE_KEY);
134+
} catch (error) {
135+
console.error("Failed to clear feed cache:", error);
136+
}
137+
}

src/services/TranscriptionService.ts

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Notice, TFile } from "obsidian";
2-
import { OpenAI } from "openai";
2+
import type { OpenAI } from "openai";
33
import type PodNotes from "../main";
44
import { downloadEpisode } from "../downloadEpisode";
55
import {
@@ -50,16 +50,13 @@ function formatTime(ms: number): string {
5050

5151
export class TranscriptionService {
5252
private plugin: PodNotes;
53-
private client: OpenAI;
53+
private client: OpenAI | null = null;
54+
private cachedApiKey: string | null = null;
5455
private MAX_RETRIES = 3;
5556
private isTranscribing = false;
5657

5758
constructor(plugin: PodNotes) {
5859
this.plugin = plugin;
59-
this.client = new OpenAI({
60-
apiKey: this.plugin.settings.openAIApiKey,
61-
dangerouslyAllowBrowser: true,
62-
});
6360
}
6461

6562
async transcribeCurrentEpisode(): Promise<void> {
@@ -68,6 +65,13 @@ export class TranscriptionService {
6865
return;
6966
}
7067

68+
if (!this.plugin.settings.openAIApiKey?.trim()) {
69+
new Notice(
70+
"Please add your OpenAI API key in the transcript settings first.",
71+
);
72+
return;
73+
}
74+
7175
const currentEpisode = this.plugin.api.podcast;
7276
if (!currentEpisode) {
7377
new Notice("No episode is currently playing.");
@@ -126,7 +130,8 @@ export class TranscriptionService {
126130
notice.update("Transcription completed and saved.");
127131
} catch (error) {
128132
console.error("Transcription error:", error);
129-
notice.update(`Transcription failed: ${error.message}`);
133+
const message = error instanceof Error ? error.message : String(error);
134+
notice.update(`Transcription failed: ${message}`);
130135
} finally {
131136
this.isTranscribing = false;
132137
setTimeout(() => notice.hide(), 5000);
@@ -178,6 +183,7 @@ export class TranscriptionService {
178183
files: File[],
179184
updateNotice: (message: string) => void,
180185
): Promise<string> {
186+
const client = await this.getClient();
181187
const transcriptions: string[] = new Array(files.length);
182188
let completedChunks = 0;
183189

@@ -195,7 +201,7 @@ export class TranscriptionService {
195201
let retries = 0;
196202
while (retries < this.MAX_RETRIES) {
197203
try {
198-
const result = await this.client.audio.transcriptions.create({
204+
const result = await client.audio.transcriptions.create({
199205
model: "whisper-1",
200206
file,
201207
});
@@ -261,4 +267,24 @@ export class TranscriptionService {
261267
throw new Error("Expected a file but got a folder");
262268
}
263269
}
270+
271+
private async getClient(): Promise<OpenAI> {
272+
const apiKey = this.plugin.settings.openAIApiKey?.trim();
273+
if (!apiKey) {
274+
throw new Error("Missing OpenAI API key");
275+
}
276+
277+
if (this.client && this.cachedApiKey === apiKey) {
278+
return this.client;
279+
}
280+
281+
const { OpenAI } = await import("openai");
282+
this.client = new OpenAI({
283+
apiKey,
284+
dangerouslyAllowBrowser: true,
285+
});
286+
this.cachedApiKey = apiKey;
287+
288+
return this.client;
289+
}
264290
}

src/types/IPodNotesSettings.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,4 +36,8 @@ export interface IPodNotesSettings {
3636
path: string;
3737
template: string;
3838
};
39+
feedCache: {
40+
enabled: boolean;
41+
ttlHours: number;
42+
};
3943
}

src/ui/PodcastView/EpisodeList.svelte

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,14 @@
5050
<Icon
5151
icon={hidePlayedEpisodes ? "eye-off" : "eye"}
5252
size={25}
53+
label={hidePlayedEpisodes ? "Show played episodes" : "Hide played episodes"}
54+
pressed={hidePlayedEpisodes}
5355
on:click={() => (hidePlayedEpisodes = !hidePlayedEpisodes)}
5456
/>
5557
<Icon
5658
icon="refresh-cw"
5759
size={25}
60+
label="Refresh episodes"
5861
on:click={() => dispatch("clickRefresh")}
5962
/>
6063
</div>

src/ui/PodcastView/EpisodeListItem.svelte

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,6 @@
1717
dispatch("contextMenu", { episode, event });
1818
}
1919
20-
function onKeyActivate(event: KeyboardEvent) {
21-
if (event.key === "Enter" || event.key === " ") {
22-
event.preventDefault();
23-
onClickEpisode();
24-
}
25-
}
26-
2720
let _date: Date;
2821
let date: string;
2922
@@ -33,13 +26,11 @@
3326
}
3427
</script>
3528

36-
<div
29+
<button
30+
type="button"
3731
class="podcast-episode-item"
3832
on:click={onClickEpisode}
3933
on:contextmenu={onContextMenu}
40-
role="button"
41-
tabindex="0"
42-
on:keydown={onKeyActivate}
4334
>
4435
{#if showEpisodeImage && episode?.artworkUrl}
4536
<div class="podcast-episode-thumbnail-container">
@@ -60,7 +51,7 @@
6051
<span class="episode-item-date">{date.toUpperCase()}</span>
6152
<span class={`episode-item-title ${episodeFinished && "strikeout"}`}>{episode.title}</span>
6253
</div>
63-
</div>
54+
</button>
6455

6556
<style>
6657
.podcast-episode-item {
@@ -72,14 +63,17 @@
7263
width: 100%;
7364
border: solid 1px var(--background-divider);
7465
gap: 0.25rem;
66+
background: transparent;
67+
text-align: left;
7568
}
7669
77-
.podcast-episode-item:hover {
78-
background-color: var(--background-divider);
70+
.podcast-episode-item:focus-visible {
71+
outline: 2px solid var(--interactive-accent);
72+
outline-offset: 2px;
7973
}
8074
8175
.podcast-episode-item:hover {
82-
cursor: pointer;
76+
background-color: var(--background-divider);
8377
}
8478
8579
.strikeout {

0 commit comments

Comments
 (0)