diff --git a/docs/docs/api.md b/docs/docs/api.md index 1ac88940..415f6ebc 100644 --- a/docs/docs/api.md +++ b/docs/docs/api.md @@ -2,6 +2,7 @@ ```js export interface IAPI { readonly podcast: Episode; + readonly transcript: Promise; readonly isPlaying: boolean; readonly length: number; currentTime: number; @@ -19,6 +20,7 @@ export interface IAPI { endTime: number, linkify?: boolean, ): string; + getTranscript(episode?: Episode): Promise; start(): void; stop(): void; togglePlayback(): void; @@ -49,6 +51,57 @@ export interface Episode { } ``` +## `transcript` +This is a convenience getter for `getTranscript()`: + +```js +const transcript = await app.plugins.plugins.podnotes.api.transcript; +``` + +It returns the generated transcript note for the current episode, or `null` if no +episode is loaded or no transcript file exists yet. It does not call OpenAI or +create a new transcript. Generate the transcript first with PodNotes' **Transcribe +current episode** command. + +If your transcript template includes metadata such as the title and date, that +metadata is included in the returned text. Set the transcript template to +`{{transcript}}` if your macro should receive only the transcript body. + +## `getTranscript(episode?: Episode)` +This reads the generated transcript note for the provided episode. If no episode +is provided, it reads the current episode's transcript. + +```js +const api = app.plugins.plugins.podnotes.api; +const transcript = await api.getTranscript(); +``` + +### QuickAdd AI prompt example +PodNotes exposes the transcript text; the AI summarization is done by your +QuickAdd macro or whichever AI service/plugin your macro calls. + +```js +module.exports = async (params) => { + const podnotes = params.app.plugins.plugins.podnotes?.api; + if (!podnotes) throw new Error("PodNotes is not enabled."); + + const transcript = await podnotes.transcript; + if (!transcript) { + throw new Error( + "Generate a transcript for the current PodNotes episode first.", + ); + } + + const prompt = `Summarize this podcast transcript in five bullet points. + +Transcript: +${transcript}`; + + // Send `prompt` to the AI action/provider used by your QuickAdd macro. + return prompt; +}; +``` + ## `getPodcastTimeFormatted(format: string, linkify?: boolean)` This function will return the current playback time formatted according to the given (moment) format. If `linkify` is true, the time will be linked to the current episode at the given time. This is used by PodNotes to play from the recorded time. diff --git a/docs/docs/transcripts.md b/docs/docs/transcripts.md index 955ed0e0..b859ec7f 100644 --- a/docs/docs/transcripts.md +++ b/docs/docs/transcripts.md @@ -21,9 +21,13 @@ To create a transcript: 3. PodNotes will fetch the audio for the episode you are playing (reusing an already-downloaded copy when one exists), split it into chunks, and send these chunks to OpenAI for transcription. The transcription always uses the currently playing episode's own audio, regardless of your download path settings. 4. Once the transcription is complete, a new file will be created at the specified location with the transcribed content. +Generated transcript notes are also available to workflow plugins through the +[PodNotes API](./api.md#transcript), so tools such as QuickAdd or Templater can +read the text and send it to the AI provider configured in your own macro. + ## Transcript Template -The transcript template works similarly to the [note template](./templates.md#note-template), but with the added `{{template}}` placeholder. +The transcript template works similarly to the [note template](./templates.md#note-template), but with the added `{{transcript}}` placeholder. ## Speaker Diarization diff --git a/src/API/API.test.ts b/src/API/API.test.ts index 6929a474..cfdc1257 100644 --- a/src/API/API.test.ts +++ b/src/API/API.test.ts @@ -1,5 +1,6 @@ +import { TFile } from "obsidian"; import { get } from "svelte/store"; -import { beforeEach, describe, expect, test } from "vitest"; +import { beforeEach, describe, expect, test, vi } from "vitest"; import { API } from "./API"; import { @@ -157,3 +158,86 @@ describe("API playback rate controls", () => { expect(api.playbackRate).toBe(1.8); }); }); + +describe("API transcript access", () => { + function setTranscriptVault(files: Record) { + const createTFile = (path: string): TFile => + Object.assign(new TFile(), { path }); + const getAbstractFileByPath = vi.fn((path: string) => + Object.prototype.hasOwnProperty.call(files, path) + ? createTFile(path) + : null, + ); + const read = vi.fn(async (file: TFile) => files[file.path] ?? ""); + + plugin.set({ + settings: { + defaultPlaybackRate: 1.8, + transcript: { + path: "Transcripts/{{podcast}}/{{title}}.md", + }, + }, + app: { + vault: { + getAbstractFileByPath, + read, + }, + }, + } as never); + + return { getAbstractFileByPath, read }; + } + + test("returns null when no episode is loaded", async () => { + setTranscriptVault({}); + const api = new API(); + + await expect(api.getTranscript()).resolves.toBeNull(); + }); + + test("returns null when the current episode has no generated transcript file", async () => { + currentEpisode.set(feedEpisode); + const { getAbstractFileByPath, read } = setTranscriptVault({}); + const api = new API(); + + await expect(api.getTranscript()).resolves.toBeNull(); + + expect(getAbstractFileByPath).toHaveBeenCalledWith( + "Transcripts/Feed Podcast/Feed Episode.md", + ); + expect(read).not.toHaveBeenCalled(); + }); + + test("reads the generated transcript note for the current episode", async () => { + currentEpisode.set(feedEpisode); + const transcript = "# Feed Episode\n\nTranscript body for AI macros."; + const { read } = setTranscriptVault({ + "Transcripts/Feed Podcast/Feed Episode.md": transcript, + }); + const api = new API(); + + await expect(api.getTranscript()).resolves.toBe(transcript); + await expect(api.transcript).resolves.toBe(transcript); + + expect(read).toHaveBeenCalledWith( + expect.objectContaining({ + path: "Transcripts/Feed Podcast/Feed Episode.md", + }), + ); + }); + + test("reads the generated transcript note for an explicit episode", async () => { + currentEpisode.set(feedEpisode); + const transcript = "Local episode transcript"; + const { getAbstractFileByPath } = setTranscriptVault({ + "Transcripts/local file/Local Episode.md": transcript, + }); + const api = new API(); + + await expect(api.getTranscript(localEpisode)).resolves.toBe(transcript); + + expect(getAbstractFileByPath).toHaveBeenCalledWith( + "Transcripts/local file/Local Episode.md", + ); + }); +}); diff --git a/src/API/API.ts b/src/API/API.ts index 02aa1c02..537f9d98 100644 --- a/src/API/API.ts +++ b/src/API/API.ts @@ -1,6 +1,7 @@ import type { Episode } from "src/types/Episode"; import { formatSeconds } from "src/utility/formatSeconds"; import type { IAPI } from "./IAPI"; +import { TFile } from "obsidian"; import { currentEpisode, currentTime, @@ -24,6 +25,7 @@ import { normalizePlaybackRate, PLAYBACK_RATE_STEP, } from "src/utility/playbackRate"; +import { getEpisodeTranscriptPath } from "src/utility/getEpisodeTranscriptPath"; const clampVolume = (value: number): number => Math.min(1, Math.max(0, value)); @@ -33,6 +35,10 @@ export class API implements IAPI { return get(currentEpisode); } + public get transcript(): Promise { + return this.getTranscript(); + } + public get length(): number { return get(duration); } @@ -141,6 +147,26 @@ export class API implements IAPI { return `[${segment}](${url.href})`; } + async getTranscript(episode = this.podcast): Promise { + if (!episode) { + return null; + } + + const pluginInstance = get(plugin); + const transcriptPath = getEpisodeTranscriptPath( + episode, + pluginInstance.settings.transcript.path, + ); + const transcriptFile = + pluginInstance.app.vault.getAbstractFileByPath(transcriptPath); + + if (!(transcriptFile instanceof TFile)) { + return null; + } + + return await pluginInstance.app.vault.read(transcriptFile); + } + private getEpisodeLinkTarget(): string | undefined { const epIsLocal = isLocalFile(this.podcast); return !epIsLocal diff --git a/src/API/IAPI.ts b/src/API/IAPI.ts index 3ba04f16..7c17fc50 100644 --- a/src/API/IAPI.ts +++ b/src/API/IAPI.ts @@ -2,6 +2,7 @@ import type { Episode } from 'src/types/Episode'; export interface IAPI { readonly podcast: Episode; + readonly transcript: Promise; readonly isPlaying: boolean; readonly length: number; currentTime: number; @@ -20,6 +21,8 @@ export interface IAPI { endTime: number, linkify?: boolean, ): string; + + getTranscript(episode?: Episode): Promise; start(): void; stop(): void; diff --git a/src/services/TranscriptionService.ts b/src/services/TranscriptionService.ts index f28141dd..ef5df2fa 100644 --- a/src/services/TranscriptionService.ts +++ b/src/services/TranscriptionService.ts @@ -2,16 +2,10 @@ import { Notice, TFile } from "obsidian"; import type { OpenAI } from "openai"; import type PodNotes from "../main"; import { getEpisodeAudioBuffer } from "../downloadEpisode"; -import { - FilePathTemplateEngine, - TranscriptTemplateEngine, -} from "../TemplateEngine"; -import { - enforceMaxPathLength, - lastSegmentExtension, -} from "../utility/enforceMaxPathLength"; +import { TranscriptTemplateEngine } from "../TemplateEngine"; import { ensureFolderExists } from "../utility/ensureFolderExists"; import type { Episode } from "src/types/Episode"; +import { getEpisodeTranscriptPath } from "src/utility/getEpisodeTranscriptPath"; import { type DiarizationAudio, type DiarizationProviderId, @@ -570,11 +564,10 @@ export class TranscriptionService { * the user's chosen suffix. */ private getTranscriptPath(episode: Episode): string { - const rendered = FilePathTemplateEngine( - this.plugin.settings.transcript.path, + return getEpisodeTranscriptPath( episode, + this.plugin.settings.transcript.path, ); - return enforceMaxPathLength(rendered, lastSegmentExtension(rendered)); } private async saveTranscription( diff --git a/src/utility/getEpisodeTranscriptPath.ts b/src/utility/getEpisodeTranscriptPath.ts new file mode 100644 index 00000000..e119e308 --- /dev/null +++ b/src/utility/getEpisodeTranscriptPath.ts @@ -0,0 +1,14 @@ +import { FilePathTemplateEngine } from "src/TemplateEngine"; +import type { Episode } from "src/types/Episode"; +import { + enforceMaxPathLength, + lastSegmentExtension, +} from "src/utility/enforceMaxPathLength"; + +export function getEpisodeTranscriptPath( + episode: Episode, + transcriptPathTemplate: string, +): string { + const rendered = FilePathTemplateEngine(transcriptPathTemplate, episode); + return enforceMaxPathLength(rendered, lastSegmentExtension(rendered)); +}