From 6958e09c9443bc4d50cff2c57b08a48fd775d298 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 13 Nov 2026 21:37:36 +0100 Subject: [PATCH 1/2] test(credentials): pin key handling a credentials table will derive Each test passes on the hand-written code. Together they catch wrong derivations that no existing test sees: the -1 SecretStorage ID suffix that released versions accept, the provider an export error names, the kind a settings key row adopts a secret for, and the key migration notices. --- src/main.persistence.test.ts | 49 +++++++++++++++++++ src/persistence/podNotesData.test.ts | 19 +++++++ src/services/CredentialRepository.test.ts | 17 +++++++ .../PodNotesSettingsTab.import.test.ts | 31 ++++++++++++ 4 files changed, 116 insertions(+) diff --git a/src/main.persistence.test.ts b/src/main.persistence.test.ts index 2c276112..f723f962 100644 --- a/src/main.persistence.test.ts +++ b/src/main.persistence.test.ts @@ -730,6 +730,55 @@ describe("PodNotes persistence integration", () => { expect(persisted).not.toHaveProperty("diarizationApiKey"); }); + it("names exactly the legacy keys it moved into SecretStorage", async () => { + const notice = vi.spyOn(obsidian, "Notice"); + const migrate = async (legacy: Record) => { + notice.mockClear(); + const { storage } = memorySecretStorage(); + const plugin = makePlugin(); + Object.assign(plugin, { + app: { ...plugin.app, secretStorage: storage }, + credentials: new CredentialRepository(storage), + loadData: vi.fn().mockResolvedValue({ schemaVersion: 1, ...legacy }), + }); + await plugin.loadSettings(); + return notice.mock.calls; + }; + + expect(await migrate({ openAIApiKey: "sk", diarizationApiKey: "dg" })).toEqual([ + ["Moved your OpenAI and Deepgram API keys into Obsidian SecretStorage."], + ]); + expect(await migrate({ diarizationApiKey: "dg" })).toEqual([ + ["Moved your Deepgram API key into Obsidian SecretStorage."], + ]); + }); + + it("says its API keys could not move when only a Deepgram key fails to migrate", async () => { + const notice = vi.spyOn(obsidian, "Notice"); + const storage = { + getSecret: vi.fn(() => null), + setSecret: vi.fn(() => { + throw new Error("keychain unavailable"); + }), + listSecrets: vi.fn(() => []), + } as unknown as SecretStorage; + const plugin = makePlugin(); + Object.assign(plugin, { + app: { ...plugin.app, secretStorage: storage }, + credentials: new CredentialRepository(storage), + loadData: vi.fn().mockResolvedValue({ schemaVersion: 1, diarizationApiKey: "dg" }), + }); + vi.spyOn(console, "error").mockImplementation(() => undefined); + + await expect(plugin.loadSettings()).rejects.toThrow("keychain unavailable"); + expect(notice.mock.calls).toEqual([ + [ + "PodNotes could not securely migrate its API keys. Your existing data was kept unchanged. Restart PodNotes to retry.", + 0, + ], + ]); + }); + it("leaves legacy data untouched when SecretStorage migration fails", async () => { const saveData = vi.fn().mockResolvedValue(undefined); const raw = { schemaVersion: 1, openAIApiKey: "sk", defaultVolume: 0.3 }; diff --git a/src/persistence/podNotesData.test.ts b/src/persistence/podNotesData.test.ts index d4a3a152..72351737 100644 --- a/src/persistence/podNotesData.test.ts +++ b/src/persistence/podNotesData.test.ts @@ -467,6 +467,25 @@ describe("PodNotes data schema", () => { ); }); + it("keeps every numbered SecretStorage ID that released versions accept", () => { + const accepted = decodePodNotesData({ + schemaVersion: 2, + openAISecretId: "podnotes-openai-api-key-1", + deepgramSecretId: "podnotes-deepgram-api-key-10", + }); + const rejected = decodePodNotesData({ + schemaVersion: 2, + openAISecretId: "podnotes-openai-api-key-0", + deepgramSecretId: "podnotes-deepgram-api-key-01", + }); + + expect(accepted.settings.openAISecretId).toBe("podnotes-openai-api-key-1"); + expect(accepted.settings.deepgramSecretId).toBe("podnotes-deepgram-api-key-10"); + expect(accepted.warnings).toEqual([]); + expect(rejected.settings.openAISecretId).toBe(""); + expect(rejected.settings.deepgramSecretId).toBe(""); + }); + it("salvages valid fields and only drops individually invalid collection entries", () => { const validEpisode = { ...episode("Valid"), diff --git a/src/services/CredentialRepository.test.ts b/src/services/CredentialRepository.test.ts index 3b4df3d9..d381a2ce 100644 --- a/src/services/CredentialRepository.test.ts +++ b/src/services/CredentialRepository.test.ts @@ -178,4 +178,21 @@ describe("CredentialRepository", () => { }), ).toThrow("not available in SecretStorage on this device"); }); + + it("names the provider whose configured key is missing from an explicit export", () => { + const { api } = storage({ "podnotes-openai-api-key": "sk" }); + const repository = new CredentialRepository(api); + + expect(() => + repository.exportValues( + settings({ + openAISecretId: "podnotes-openai-api-key", + deepgramSecretId: "podnotes-deepgram-api-key", + }), + { requireConfigured: true }, + ), + ).toThrow( + "The selected Deepgram API key is not available in SecretStorage on this device.", + ); + }); }); diff --git a/src/ui/settings/PodNotesSettingsTab.import.test.ts b/src/ui/settings/PodNotesSettingsTab.import.test.ts index d3c66c33..dc352602 100644 --- a/src/ui/settings/PodNotesSettingsTab.import.test.ts +++ b/src/ui/settings/PodNotesSettingsTab.import.test.ts @@ -8,6 +8,7 @@ import { DEFAULT_SETTINGS } from "src/constants"; import { decodePodNotesData, PODNOTES_DATA_SCHEMA_VERSION } from "src/persistence/podNotesData"; import PodNotes from "src/main"; import { PlayerController } from "src/player/PlayerController"; +import { CredentialRepository } from "src/services/CredentialRepository"; import { episodeListLimit, hidePlayedEpisodes, savedFeeds } from "src/store"; import { bindStoresToSettings } from "src/store/persistence"; import type { Episode } from "src/types/Episode"; @@ -380,6 +381,36 @@ describe("PodNotesSettingsTab settings import", () => { ]); }); + it("copies a secret selected in each key row into that row's provider ID", async () => { + const storage = new obsidian.SecretStorage(); + storage.setSecret("shared-api-key", "shared-value"); + const plugin = { + settings: structuredClone(DEFAULT_SETTINGS), + credentials: new CredentialRepository(storage), + saveSettingsStrict: vi.fn().mockResolvedValue(undefined), + } as unknown as PodNotes; + const tab = new PodNotesSettingsTab({} as App, withPlayer(plugin)); + const secret = { setValue: vi.fn() }; + const select = ( + tab as unknown as { + handleSecretSelection: ( + key: "openAISecretId" | "deepgramSecretId", + value: string, + component: typeof secret, + onSettled: () => void, + ) => Promise; + } + ).handleSecretSelection.bind(tab); + + await select("openAISecretId", "shared-api-key", secret, vi.fn()); + await select("deepgramSecretId", "shared-api-key", secret, vi.fn()); + + expect(plugin.settings.openAISecretId).toBe("podnotes-openai-api-key"); + expect(plugin.settings.deepgramSecretId).toBe("podnotes-deepgram-api-key"); + expect(storage.getSecret("podnotes-openai-api-key")).toBe("shared-value"); + expect(storage.getSecret("podnotes-deepgram-api-key")).toBe("shared-value"); + }); + it("keeps a newer selection authoritative when an older save fails", async () => { const firstSave = deferred(); let disk = "podnotes-openai-api-key"; From ff81dbdf950829aec591e4264f068cd9f858a28f Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Fri, 13 Nov 2026 21:37:36 +0100 Subject: [PATCH 2/2] refactor(credentials): derive API key handling from one table CREDENTIALS in src/types/Credentials.ts holds each kind's settings field, export key and label. The credential repository, settings decoder, settings transfer, settings tab and key migration notice loop over it. IPodNotesSettings takes its key fields from it, so a kind with no default in DEFAULT_SETTINGS fails typecheck. The plaintext key map from before 2.19.0 stays fixed. --- src/main.ts | 13 +++--- src/persistence/settingsCodec.ts | 37 +++++++++------- src/services/CredentialRepository.ts | 58 ++++++++------------------ src/settingsTransfer.ts | 43 +++++++++---------- src/types/Credentials.ts | 39 ++++++++++------- src/types/IPodNotesSettings.ts | 7 +--- src/ui/settings/PodNotesSettingsTab.ts | 46 ++++++++++---------- 7 files changed, 114 insertions(+), 129 deletions(-) diff --git a/src/main.ts b/src/main.ts index b8179770..c75b45f3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -54,6 +54,7 @@ import { WriteRefusedError, } from "./persistence/writer"; import { CredentialRepository } from "./services/CredentialRepository"; +import { CREDENTIAL_KINDS, CREDENTIALS } from "./types/Credentials"; import { FeedUrlRepository } from "./services/FeedUrlRepository"; import { privateFeedAddresses, protectPrivateFeeds, resolveFeedUrl } from "./services/privateFeeds"; import { redactPrivateFeedAddresses } from "./utility/privateFeedUrl"; @@ -370,6 +371,9 @@ export default class PodNotes extends Plugin implements IPodNotes { decoded.retiredPlaintextPresent || movedFeeds ) { + const migratedProviders = CREDENTIAL_KINDS.filter( + (kind) => decoded.legacySecrets[CREDENTIALS[kind].exportKey], + ).map((kind) => CREDENTIALS[kind].label); // SecretStorage writes are synchronous. Store every legacy value and // verify it first, then write the v2 snapshot directly because the normal // save queue intentionally stays dormant until onload finishes. A v2 file @@ -388,15 +392,8 @@ export default class PodNotes extends Plugin implements IPodNotes { ); if (written.kind !== "verified") throw this.writeFailure(); } catch (error) { - throw new PodNotesSchemaMigrationError( - error, - Boolean(decoded.legacySecrets.openAI || decoded.legacySecrets.deepgram), - ); + throw new PodNotesSchemaMigrationError(error, migratedProviders.length > 0); } - const migratedProviders = [ - decoded.legacySecrets.openAI ? "OpenAI" : null, - decoded.legacySecrets.deepgram ? "Deepgram" : null, - ].filter((provider): provider is string => provider !== null); if (migratedProviders.length > 0) { new Notice( `Moved your ${migratedProviders.join(" and ")} API ${migratedProviders.length === 1 ? "key" : "keys"} into Obsidian SecretStorage.`, diff --git a/src/persistence/settingsCodec.ts b/src/persistence/settingsCodec.ts index 78487091..b1da3c84 100644 --- a/src/persistence/settingsCodec.ts +++ b/src/persistence/settingsCodec.ts @@ -13,7 +13,12 @@ import { } from "src/settingsMigrations"; import { NEXT_TRACK_ACTIONS, PREVIOUS_TRACK_ACTIONS } from "src/types/Headset"; import type { IPodNotesSettings } from "src/types/IPodNotesSettings"; -import { isPodNotesSecretId, type CredentialKind } from "src/types/Credentials"; +import { + CREDENTIAL_KINDS, + CREDENTIALS, + isPodNotesSecretId, + type CredentialReferenceSettings, +} from "src/types/Credentials"; import { sanitizeEpisodeListLimit } from "src/utility/episodeListLimit"; import { normalizePlaybackRate } from "src/utility/playbackRate"; import { @@ -119,23 +124,27 @@ export function decodeSettings( feedNote: decodeFeedNote(root.feedNote, warnings), download: decodeDownload(root.download, warnings), downloadedEpisodes: decodeDownloadedEpisodes(root.downloadedEpisodes, warnings), - openAISecretId: decodeSecretId(root, "openAISecretId", warnings), - deepgramSecretId: decodeSecretId(root, "deepgramSecretId", warnings), + ...decodeSecretIds(root, warnings), transcript: decodeTranscript(root.transcript, warnings), }; } -function decodeSecretId( - root: UnknownRecord, - key: "openAISecretId" | "deepgramSecretId", - warnings: Set, -): string { - const value = readString(root, key, DEFAULT_SETTINGS[key], warnings); - const kind: CredentialKind = key === "openAISecretId" ? "openai" : "deepgram"; - if (!value || isPodNotesSecretId(kind, value)) return value; - - warn(warnings, key, "foreign or wrong-provider SecretStorage ID; reference was removed"); - return ""; +function decodeSecretIds(root: UnknownRecord, warnings: Set): CredentialReferenceSettings { + const ids = {} as CredentialReferenceSettings; + for (const kind of CREDENTIAL_KINDS) { + const key = CREDENTIALS[kind].settingsKey; + const value = readString(root, key, DEFAULT_SETTINGS[key], warnings); + const owned = !value || isPodNotesSecretId(kind, value); + if (!owned) { + warn( + warnings, + key, + "foreign or wrong-provider SecretStorage ID; reference was removed", + ); + } + ids[key] = owned ? value : ""; + } + return ids; } /** diff --git a/src/services/CredentialRepository.ts b/src/services/CredentialRepository.ts index 95f86c05..d17e19cd 100644 --- a/src/services/CredentialRepository.ts +++ b/src/services/CredentialRepository.ts @@ -1,34 +1,18 @@ import type { SecretStorage } from "obsidian"; import type { IPodNotesSettings } from "src/types/IPodNotesSettings"; import { + CREDENTIAL_KINDS, + CREDENTIALS, isPodNotesSecretId, isValidSecretId, + secretBaseId, type CredentialKind, type CredentialReferences, type CredentialValues, } from "src/types/Credentials"; -export type { CredentialKind, CredentialReferences, CredentialValues } from "src/types/Credentials"; - export type CredentialStatus = "unconfigured" | "available" | "missing"; -const CREDENTIALS: Record< - CredentialKind, - { - baseId: string; - settingsKey: keyof Pick; - } -> = { - openai: { - baseId: "podnotes-openai-api-key", - settingsKey: "openAISecretId", - }, - deepgram: { - baseId: "podnotes-deepgram-api-key", - settingsKey: "deepgramSecretId", - }, -}; - /** * Narrow boundary around Obsidian's vault-local SecretStorage. * @@ -64,25 +48,18 @@ export class CredentialRepository { settings: IPodNotesSettings, options: { requireConfigured?: boolean } = {}, ): CredentialValues { - const openAI = this.get(settings, "openai"); - const deepgram = this.get(settings, "deepgram"); - if (options.requireConfigured) { - if (settings.openAISecretId.trim() && !openAI) { - throw new Error( - "The selected OpenAI API key is not available in SecretStorage on this device.", - ); - } - if (settings.deepgramSecretId.trim() && !deepgram) { + const values: CredentialValues = {}; + for (const kind of CREDENTIAL_KINDS) { + const { settingsKey, exportKey, label } = CREDENTIALS[kind]; + const value = this.get(settings, kind); + if (options.requireConfigured && settings[settingsKey].trim() && !value) { throw new Error( - "The selected Deepgram API key is not available in SecretStorage on this device.", + `The selected ${label} API key is not available in SecretStorage on this device.`, ); } + if (value) values[exportKey] = value; } - - return { - ...(openAI ? { openAI } : {}), - ...(deepgram ? { deepgram } : {}), - }; + return values; } /** @@ -92,12 +69,11 @@ export class CredentialRepository { */ storeValues(values: CredentialValues): CredentialReferences { const references: CredentialReferences = {}; - const openAI = values.openAI?.trim(); - const deepgram = values.deepgram?.trim(); - - if (openAI) references.openAISecretId = this.store("openai", openAI); - if (deepgram) references.deepgramSecretId = this.store("deepgram", deepgram); - + for (const kind of CREDENTIAL_KINDS) { + const { settingsKey, exportKey } = CREDENTIALS[kind]; + const value = values[exportKey]?.trim(); + if (value) references[settingsKey] = this.store(kind, value); + } return references; } @@ -122,7 +98,7 @@ export class CredentialRepository { } private store(kind: CredentialKind, secret: string): string { - const { baseId } = CREDENTIALS[kind]; + const baseId = secretBaseId(kind); for (let suffix = 1; suffix <= 10_000; suffix++) { const id = suffix === 1 ? baseId : `${baseId}-${suffix}`; diff --git a/src/settingsTransfer.ts b/src/settingsTransfer.ts index c04d7bc4..45e13fc7 100644 --- a/src/settingsTransfer.ts +++ b/src/settingsTransfer.ts @@ -7,7 +7,7 @@ import { type PersistedPodNotesSettings, PodNotesDataError, } from "./persistence/podNotesData"; -import type { CredentialValues } from "./types/Credentials"; +import { CREDENTIAL_KINDS, CREDENTIALS, type CredentialValues } from "./types/Credentials"; import type { Episode } from "./types/Episode"; import type { IPodNotesSettings } from "./types/IPodNotesSettings"; import { ownRecord } from "./utility/ownRecord"; @@ -35,23 +35,16 @@ export const VAULT_LOCAL_KEYS: readonly (keyof IPodNotesSettings)[] = [ ]; /** Provider API-key references are device-local, not settings transfer data. */ -const SECRET_REFERENCE_KEYS: ReadonlySet = new Set([ - "openAISecretId", - "deepgramSecretId", -]); +const SECRET_REFERENCE_KEYS: ReadonlySet = new Set( + CREDENTIAL_KINDS.map((kind) => CREDENTIALS[kind].settingsKey), +); +/** Plaintext fields that versions before 2.19.0 wrote. No new kind joins them. */ const LEGACY_SECRET_KEYS = { openAIApiKey: "openAI", diarizationApiKey: "deepgram", } as const; -/** Human-facing names for each secret, so export/import copy can name exactly - * which keys leave or enter the vault instead of hard-coding "OpenAI". */ -const SECRET_KEY_LABELS: Record = { - openAI: "OpenAI API key", - deepgram: "Deepgram API key", -}; - /** * The human-facing labels for the secrets actually present (non-empty) in a * settings object. Used to keep the export toggle, the export notice, and the @@ -59,9 +52,9 @@ const SECRET_KEY_LABELS: Record = { * user is never told only "OpenAI API key" (and vice versa). See issue #168. */ export function describeSecrets(secrets: CredentialValues): string[] { - return (Object.keys(SECRET_KEY_LABELS) as (keyof CredentialValues)[]) - .filter((key) => Boolean(secrets[key]?.trim())) - .map((key) => SECRET_KEY_LABELS[key]); + return CREDENTIAL_KINDS.filter((kind) => + Boolean(secrets[CREDENTIALS[kind].exportKey]?.trim()), + ).map((kind) => `${CREDENTIALS[kind].label} API key`); } /** Known top-level setting keys that are safe to import (excludes vault-local state). */ @@ -382,9 +375,10 @@ function parseSecretsPayload(value: unknown): { values: CredentialValues } | { e return { error: "Export file has an invalid secrets payload." }; } - for (const key of ["openAI", "deepgram"] as const) { - if (value[key] !== undefined && typeof value[key] !== "string") { - return { error: `Export file has an invalid ${SECRET_KEY_LABELS[key]}.` }; + for (const kind of CREDENTIAL_KINDS) { + const { exportKey, label } = CREDENTIALS[kind]; + if (value[exportKey] !== undefined && typeof value[exportKey] !== "string") { + return { error: `Export file has an invalid ${label} API key.` }; } } @@ -392,12 +386,13 @@ function parseSecretsPayload(value: unknown): { values: CredentialValues } | { e } function normalizeSecrets(values: CredentialValues): CredentialValues { - const openAI = values.openAI?.trim(); - const deepgram = values.deepgram?.trim(); - return { - ...(openAI ? { openAI } : {}), - ...(deepgram ? { deepgram } : {}), - }; + const normalized: CredentialValues = {}; + for (const kind of CREDENTIAL_KINDS) { + const key = CREDENTIALS[kind].exportKey; + const value = values[key]?.trim(); + if (value) normalized[key] = value; + } + return normalized; } /** Drop any playlist entry that isn't a plain object with an `episodes` array. */ diff --git a/src/types/Credentials.ts b/src/types/Credentials.ts index f14030c1..1c18c7da 100644 --- a/src/types/Credentials.ts +++ b/src/types/Credentials.ts @@ -1,29 +1,40 @@ -export type CredentialKind = "openai" | "deepgram"; +/** + * One row per API key PodNotes can hold. Released versions store a kind's key + * under `podnotes--api-key` and read its settingsKey and exportKey from + * synced data and export files, so none of them may change. + */ +export const CREDENTIALS = { + openai: { settingsKey: "openAISecretId", exportKey: "openAI", label: "OpenAI" }, + deepgram: { settingsKey: "deepgramSecretId", exportKey: "deepgram", label: "Deepgram" }, +} as const; -export interface CredentialValues { - openAI?: string; - deepgram?: string; -} +export type CredentialKind = keyof typeof CREDENTIALS; +export const CREDENTIAL_KINDS = Object.keys(CREDENTIALS) as readonly CredentialKind[]; -export interface CredentialReferences { - openAISecretId?: string; - deepgramSecretId?: string; -} +type Credential = (typeof CREDENTIALS)[CredentialKind]; +export type CredentialSettingsKey = Credential["settingsKey"]; + +/** Plaintext keys, as a settings export carries them under `secrets`. */ +export type CredentialValues = Partial>; + +export type CredentialReferences = Partial>; + +/** SecretStorage IDs of the API keys. They never contain a key itself. */ +export type CredentialReferenceSettings = Record; /** Obsidian SecretStorage IDs are lowercase alphanumeric strings with dashes. */ export function isValidSecretId(value: string): boolean { return /^(?=.*[a-z0-9])[a-z0-9-]+$/.test(value); } -const PODNOTES_SECRET_ID_PATTERNS: Record = { - openai: /^podnotes-openai-api-key(?:-[1-9]\d*)?$/, - deepgram: /^podnotes-deepgram-api-key(?:-[1-9]\d*)?$/, -}; +export function secretBaseId(kind: CredentialKind): string { + return `podnotes-${kind}-api-key`; +} /** * Persisted references are capabilities, not arbitrary SecretStorage lookups. * Keep each provider confined to the IDs PodNotes owns for that provider. */ export function isPodNotesSecretId(kind: CredentialKind, value: string): boolean { - return PODNOTES_SECRET_ID_PATTERNS[kind].test(value); + return new RegExp(`^${secretBaseId(kind)}(?:-[1-9]\\d*)?$`).test(value); } diff --git a/src/types/IPodNotesSettings.ts b/src/types/IPodNotesSettings.ts index 246e1011..01f1c2a7 100644 --- a/src/types/IPodNotesSettings.ts +++ b/src/types/IPodNotesSettings.ts @@ -6,8 +6,9 @@ import type { Episode } from "./Episode"; import type DownloadedEpisode from "./DownloadedEpisode"; import type { DiarizationProviderId } from "src/services/diarization/types"; import type { NextTrackAction, PreviousTrackAction } from "./Headset"; +import type { CredentialReferenceSettings } from "./Credentials"; -export interface IPodNotesSettings { +export interface IPodNotesSettings extends CredentialReferenceSettings { savedFeeds: { [podcastName: string]: PodcastFeed }; podNotes: { [episodeName: string]: PodNote }; defaultPlaybackRate: number; @@ -59,10 +60,6 @@ export interface IPodNotesSettings { path: string; }; downloadedEpisodes: { [podcastName: string]: DownloadedEpisode[] }; - /** SecretStorage ID for the OpenAI API key. Never contains the key itself. */ - openAISecretId: string; - /** SecretStorage ID for the Deepgram API key. Never contains the key itself. */ - deepgramSecretId: string; transcript: { path: string; template: string; diff --git a/src/ui/settings/PodNotesSettingsTab.ts b/src/ui/settings/PodNotesSettingsTab.ts index ad738ec1..95e2b5ef 100644 --- a/src/ui/settings/PodNotesSettingsTab.ts +++ b/src/ui/settings/PodNotesSettingsTab.ts @@ -51,11 +51,16 @@ import { parseImport, serializeSettings, } from "src/settingsTransfer"; -import type { CredentialKind, CredentialValues } from "src/types/Credentials"; +import { + CREDENTIAL_KINDS, + CREDENTIALS, + type CredentialKind, + type CredentialSettingsKey, + type CredentialValues, +} from "src/types/Credentials"; import { observePersistedStoreChanges } from "src/store/persistence"; import { PLAYBACK_RATES } from "src/utility/playbackRate"; -type SecretReferenceKey = "openAISecretId" | "deepgramSecretId"; type SettingsControl = | HTMLButtonElement | HTMLInputElement @@ -225,10 +230,9 @@ function restoreFailedImportChanges( } } -const CREDENTIAL_KIND_BY_REFERENCE: Record = { - openAISecretId: "openai", - deepgramSecretId: "deepgram", -}; +const CREDENTIAL_KIND_BY_REFERENCE = Object.fromEntries( + CREDENTIAL_KINDS.map((kind) => [CREDENTIALS[kind].settingsKey, kind]), +) as Record; function stackSettingVertically(setting: Setting): void { setting.settingEl.setCssStyles({ @@ -242,10 +246,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { plugin: PodNotes; private settingsMutationTail: Promise = Promise.resolve(); - private credentialSaveGenerations: Record = { - openAISecretId: 0, - deepgramSecretId: 0, - }; + private readonly credentialSaveGenerations = new Map(); private settingsInteractionLockCount = 0; private settingsControlDisabledStates = new Map(); private readonly drafts = new Set<() => void>(); @@ -348,7 +349,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { }), ]), group("Transcripts", [ - this.secretRow("OpenAI API key", "openAISecretId"), + this.secretRow("openai"), this.textRow("Transcript path", { ...this.text((settings) => settings.transcript, "path"), preview: pathPreview(FilePathTemplateEngine), @@ -381,7 +382,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { ({ enabled }) => enabled, ), this.whenDiarizing( - this.secretRow("Deepgram API key", "deepgramSecretId"), + this.secretRow("deepgram"), ({ enabled, provider }) => enabled && provider === "deepgram", ), this.whenDiarizing( @@ -693,16 +694,14 @@ export class PodNotesSettingsTab extends PluginSettingTab { }; } - private secretRow(name: string, key: SecretReferenceKey): SettingDefinitionRender { + private secretRow(kind: CredentialKind): SettingDefinitionRender { + const { settingsKey: key, label } = CREDENTIALS[kind]; return { - name, + name: `${label} API key`, render: (setting) => { const describe = () => setting.setDesc( - this.plugin.credentials.status( - this.plugin.settings, - CREDENTIAL_KIND_BY_REFERENCE[key], - ) === "missing" + this.plugin.credentials.status(this.plugin.settings, kind) === "missing" ? "The selected secret is not available on this device. Select an existing secret or create one." : "", ); @@ -1123,22 +1122,23 @@ export class PodNotesSettingsTab extends PluginSettingTab { } private async saveSecretReference( - key: SecretReferenceKey, + key: CredentialSettingsKey, selectedId: string | null, ): Promise { - const generation = ++this.credentialSaveGenerations[key]; + const generation = (this.credentialSaveGenerations.get(key) ?? 0) + 1; + this.credentialSaveGenerations.set(key, generation); const operation = this.enqueueSettingsMutation(() => this.persistSecretReference(key, selectedId), ); const result = await operation; return { ...result, - isLatest: generation === this.credentialSaveGenerations[key], + isLatest: generation === this.credentialSaveGenerations.get(key), }; } private async persistSecretReference( - key: SecretReferenceKey, + key: CredentialSettingsKey, selectedId: string | null, ): Promise> { const previous = this.plugin.settings[key]; @@ -1189,7 +1189,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { } private async handleSecretSelection( - key: SecretReferenceKey, + key: CredentialSettingsKey, selectedId: string | null, secret: Pick, onLatestSettled: () => void,