Skip to content

Commit 1996c59

Browse files
committed
Merge branch 'master' into vite-migration-setup
Integrate master's PR #124 changes (feed caching and UI accessibility improvements) with strict TypeScript configuration. Resolved conflicts by: - Accepting master's lazy initialization for TranscriptionService via getTranscriptionService() - Accepting master's new performance settings and feed caching features - Accepting master's accessibility improvements to UI components - Applying strict TypeScript fixes: definite assignment assertions (!), optional properties (?), and override keywords where required - Fixing type safety: optional chaining for nullable controller cleanup - Consolidating imports and removing unused variables to pass linting All CI checks pass: lint, format:check, typecheck, build, and tests.
2 parents b114631 + 28d3c2e commit 1996c59

19 files changed

+463
-161
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: 31 additions & 17 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,27 +103,22 @@ 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({
111109
id: "podnotes-show-leaf",
112110
name: "Show PodNotes",
113111
icon: "podcast" as IconType,
114-
checkCallback: function (this: PodNotes, checking: boolean) {
115-
if (checking) {
116-
return !this.app.workspace.getLeavesOfType(VIEW_TYPE).length;
117-
}
118-
119-
const leaf = this.app.workspace.getRightLeaf(false);
120-
if (leaf) {
121-
leaf.setViewState({
122-
type: VIEW_TYPE,
123-
});
124-
}
125-
}.bind(this),
126-
});
112+
checkCallback: (checking: boolean) => {
113+
if (checking) {
114+
return !this.app.workspace.getLeavesOfType(VIEW_TYPE).length;
115+
}
116+
117+
this.app.workspace.getRightLeaf(false)?.setViewState({
118+
type: VIEW_TYPE,
119+
});
120+
},
121+
});
127122

128123
this.addCommand({
129124
id: "start-playing",
@@ -268,7 +263,18 @@ export default class PodNotes extends Plugin implements IPodNotes {
268263
this.addCommand({
269264
id: "podnotes-transcribe",
270265
name: "Transcribe current episode",
271-
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+
},
272278
});
273279

274280
this.addSettingTab(new PodNotesSettingsTab(this.app, this));
@@ -314,6 +320,14 @@ export default class PodNotes extends Plugin implements IPodNotes {
314320
}
315321
}
316322

323+
private getTranscriptionService(): TranscriptionService {
324+
if (!this.transcriptionService) {
325+
this.transcriptionService = new TranscriptionService(this);
326+
}
327+
328+
return this.transcriptionService;
329+
}
330+
317331
override onunload() {
318332
this.playedEpisodeController?.off();
319333
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: 36 additions & 14 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 {
@@ -8,10 +8,6 @@ import {
88
} from "../TemplateEngine";
99
import type { Episode } from "src/types/Episode";
1010

11-
function getErrorMessage(error: unknown): string {
12-
return error instanceof Error ? error.message : String(error);
13-
}
14-
1511
function TimerNotice(heading: string, initialMessage: string) {
1612
let currentMessage = initialMessage;
1713
const startTime = Date.now();
@@ -54,16 +50,13 @@ function formatTime(ms: number): string {
5450

5551
export class TranscriptionService {
5652
private plugin: PodNotes;
57-
private client: OpenAI;
53+
private client: OpenAI | null = null;
54+
private cachedApiKey: string | null = null;
5855
private MAX_RETRIES = 3;
5956
private isTranscribing = false;
6057

6158
constructor(plugin: PodNotes) {
6259
this.plugin = plugin;
63-
this.client = new OpenAI({
64-
apiKey: this.plugin.settings.openAIApiKey,
65-
dangerouslyAllowBrowser: true,
66-
});
6760
}
6861

6962
async transcribeCurrentEpisode(): Promise<void> {
@@ -72,6 +65,13 @@ export class TranscriptionService {
7265
return;
7366
}
7467

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+
7575
const currentEpisode = this.plugin.api.podcast;
7676
if (!currentEpisode) {
7777
new Notice("No episode is currently playing.");
@@ -128,9 +128,10 @@ export class TranscriptionService {
128128

129129
notice.stop();
130130
notice.update("Transcription completed and saved.");
131-
} catch (error: unknown) {
131+
} catch (error) {
132132
console.error("Transcription error:", error);
133-
notice.update(`Transcription failed: ${getErrorMessage(error)}`);
133+
const message = error instanceof Error ? error.message : String(error);
134+
notice.update(`Transcription failed: ${message}`);
134135
} finally {
135136
this.isTranscribing = false;
136137
setTimeout(() => notice.hide(), 5000);
@@ -182,6 +183,7 @@ export class TranscriptionService {
182183
files: File[],
183184
updateNotice: (message: string) => void,
184185
): Promise<string> {
186+
const client = await this.getClient();
185187
const transcriptions: string[] = new Array(files.length);
186188
let completedChunks = 0;
187189

@@ -199,15 +201,15 @@ export class TranscriptionService {
199201
let retries = 0;
200202
while (retries < this.MAX_RETRIES) {
201203
try {
202-
const result = await this.client.audio.transcriptions.create({
204+
const result = await client.audio.transcriptions.create({
203205
model: "whisper-1",
204206
file,
205207
});
206208
transcriptions[index] = result.text;
207209
completedChunks++;
208210
updateProgress();
209211
break;
210-
} catch (error: unknown) {
212+
} catch (error) {
211213
retries++;
212214
if (retries >= this.MAX_RETRIES) {
213215
console.error(
@@ -265,4 +267,24 @@ export class TranscriptionService {
265267
throw new Error("Expected a file but got a folder");
266268
}
267269
}
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+
}
268290
}

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>

0 commit comments

Comments
 (0)