From b27a8267d61a71de9e67080d762279e17f839c34 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sun, 11 Oct 2026 16:25:54 +0200 Subject: [PATCH 1/7] test: reproduce private feed and vault file leaks in import and export Each test fails on master: - an imported data.json keeps a private feed URL in queue, favorites and playlist episode snapshots on disk - re-importing an OPML that lists a saved private feed fetches it again and saves it a second time - a settings export carries Local Files and local-file episodes, and an import brings another vault's local files into this one --- src/opml.test.ts | 21 +++ src/settingsTransfer.test.ts | 27 ++++ .../PodNotesSettingsTab.transfer.test.ts | 130 ++++++++++++++++++ 3 files changed, 178 insertions(+) create mode 100644 src/ui/settings/PodNotesSettingsTab.transfer.test.ts diff --git a/src/opml.test.ts b/src/opml.test.ts index 71bc2584..40c06c4a 100644 --- a/src/opml.test.ts +++ b/src/opml.test.ts @@ -212,6 +212,27 @@ describe("importOPML (IE-01)", () => { expect(Object.keys(get(savedFeeds))).toEqual(["Existing"]); }); + it("skips a saved private feed listed by its real url instead of saving it again", async () => { + const privateUrl = "https://private.test/feed?auth=s3cret"; + feedUrlSecrets.clear(); + feedUrlSecrets.set("podnotes-feed-url", privateUrl); + const saved = { + title: "Private", + url: "podnotes-private-feed:Private", + urlSecretId: "podnotes-feed-url", + artworkUrl: "", + }; + savedFeeds.set({ Private: saved }); + + await importOPML( + ``, + ); + + expect(channelAt).not.toHaveBeenCalled(); + expect(get(savedFeeds)).toEqual({ Private: saved }); + expect(noticeMessages.some((m) => m.includes("Skipped 1 existing podcasts"))).toBe(true); + }); + it("never renders 'NaN%' when every feed is already subscribed", async () => { savedFeeds.set({ Existing: { diff --git a/src/settingsTransfer.test.ts b/src/settingsTransfer.test.ts index 7a985850..1a6941eb 100644 --- a/src/settingsTransfer.test.ts +++ b/src/settingsTransfer.test.ts @@ -152,6 +152,33 @@ describe("serializeSettings", () => { expect(exported.timestamp).toEqual(settings.timestamp); }); + it("leaves this vault's files behind but keeps the rest of the library", () => { + const episode = (podcastName: string, title: string) => ({ + title, + streamUrl: `app://local/vault/${title}.mp3`, + url: "", + description: "", + content: "", + podcastName, + }); + const memo = { ...episode("local file", "Memo"), filePath: "Audio/Memo.mp3" }; + const show = episode("Show", "Ep 1"); + const settings = makeSettings({ + localFiles: { ...DEFAULT_SETTINGS.localFiles, episodes: [memo] }, + queue: { ...DEFAULT_SETTINGS.queue, episodes: [memo, show] }, + favorites: { ...DEFAULT_SETTINGS.favorites, episodes: [memo] }, + playlists: { Mix: { ...DEFAULT_SETTINGS.queue, name: "Mix", episodes: [show, memo] } }, + }); + + const { settings: exported } = serializeSettings(settings, {}, "2.16.0", NOW); + + expect(exported).not.toHaveProperty("localFiles"); + expect(JSON.stringify(exported)).not.toContain("Memo"); + expect(exported.queue?.episodes.map(({ title }) => title)).toEqual(["Ep 1"]); + expect(exported.favorites?.episodes).toEqual([]); + expect(exported.playlists?.Mix.episodes.map(({ title }) => title)).toEqual(["Ep 1"]); + }); + it("encodes dates inside exported playlists as canonical text", () => { const date = new Date("2024-03-01T10:05:03.000Z"); const settings = makeSettings({ diff --git a/src/ui/settings/PodNotesSettingsTab.transfer.test.ts b/src/ui/settings/PodNotesSettingsTab.transfer.test.ts new file mode 100644 index 00000000..fbaa4a13 --- /dev/null +++ b/src/ui/settings/PodNotesSettingsTab.transfer.test.ts @@ -0,0 +1,130 @@ +// @vitest-environment jsdom +import { within } from "@testing-library/svelte"; +import { get } from "svelte/store"; +import { describe, expect, it, onTestFinished, vi } from "vitest"; +import type { App } from "obsidian"; +import * as obsidian from "obsidian"; + +import { DEFAULT_SETTINGS } from "src/constants"; +import PodNotes from "src/main"; +import { FeedUrlRepository } from "src/services/FeedUrlRepository"; +import { favorites, localFiles, playedEpisodes, playlists, queue, savedFeeds } from "src/store"; +import { bindStoresToSettings } from "src/store/persistence"; +import type { IPodNotesSettings } from "src/types/IPodNotesSettings"; +import { PodNotesSettingsTab } from "./PodNotesSettingsTab"; +import { attachDataFileWriter, dataFileText } from "../../../tests/mocks/dataFileWriter"; + +const privateUrl = "https://private.example/feed.xml?auth=s3cret"; + +function episode(podcastName: string, title: string, feedUrl?: string) { + return { + title, + streamUrl: `https://media.example/${title}.mp3`, + url: "", + description: "", + content: "", + podcastName, + ...(feedUrl ? { feedUrl } : {}), + episodeDate: "2026-10-09T09:00:00.000Z", + }; +} + +/** A loaded plugin whose data.json is `disk()`, importing files through the confirm dialog. */ +function setup(current: Partial = {}) { + const settings = { ...structuredClone(DEFAULT_SETTINGS), ...structuredClone(current) }; + let disk: Record = { ...structuredClone(settings), schemaVersion: 2 }; + const plugin = Object.create(PodNotes.prototype) as PodNotes; + Object.assign(plugin, { + settings, + credentials: { storeValues: vi.fn(() => ({})) }, + feedUrls: new FeedUrlRepository(new obsidian.SecretStorage()), + isReady: true, + persistenceUnknownFields: {}, + app: { + vault: { + adapter: { + exists: vi.fn().mockResolvedValue(true), + read: vi.fn(async () => dataFileText(disk)), + }, + }, + }, + manifest: { dir: ".obsidian/plugins/podnotes" }, + loadData: vi.fn(async () => structuredClone(disk)), + saveData: async (snapshot: Record) => { + disk = structuredClone(snapshot); + }, + }); + attachDataFileWriter(plugin); + savedFeeds.set(settings.savedFeeds); + playedEpisodes.set(settings.playedEpisodes); + playlists.set(settings.playlists); + queue.set(settings.queue); + favorites.set(settings.favorites); + localFiles.set(settings.localFiles); + onTestFinished(bindStoresToSettings(plugin)); + const tab = new PodNotesSettingsTab({} as App, plugin); + vi.spyOn(tab, "update").mockImplementation(() => {}); + const notice = vi.spyOn(obsidian, "Notice"); + onTestFinished(() => notice.mockRestore()); + + function chooseFile(contents: unknown) { + (tab as unknown as { handleSettingsImport(contents: string): void }).handleSettingsImport( + JSON.stringify(contents), + ); + const dialog = within(document.body.lastElementChild as HTMLElement); + return { + message: dialog.getByRole("heading").nextElementSibling?.textContent ?? "", + async confirm() { + dialog.getByRole("button", { name: "Import" }).click(); + await vi.waitFor(() => + expect(notice).toHaveBeenCalledWith("Imported PodNotes settings."), + ); + }, + }; + } + return { disk: () => disk as unknown as IPodNotesSettings, chooseFile }; +} + +describe("importing a settings file", () => { + it("never writes a private feed URL from an imported data.json, even in episode snapshots", async () => { + const { disk, chooseFile } = setup(); + const privateEpisode = episode("Private", "Private Episode", privateUrl); + + await chooseFile({ + ...structuredClone(DEFAULT_SETTINGS), + schemaVersion: 2, + savedFeeds: { Private: { title: "Private", url: privateUrl, artworkUrl: "" } }, + queue: { ...DEFAULT_SETTINGS.queue, episodes: [privateEpisode] }, + favorites: { ...DEFAULT_SETTINGS.favorites, episodes: [privateEpisode] }, + playlists: { + Trip: { ...DEFAULT_SETTINGS.queue, name: "Trip", episodes: [privateEpisode] }, + }, + }).confirm(); + + expect(JSON.stringify(disk())).not.toContain("s3cret"); + expect(disk().savedFeeds.Private.url).toBe("podnotes-private-feed:Private"); + expect(disk().queue.episodes[0].feedUrl).toBe("podnotes-private-feed:Private"); + }); + + it("keeps this vault's local files and never brings another vault's", async () => { + const mine = { ...episode("local file", "Mine"), filePath: "Audio/Mine.mp3" }; + const theirs = { ...episode("local file", "Theirs"), filePath: "Elsewhere/Theirs.mp3" }; + const theirShow = episode("Show", "Their Episode"); + const { disk, chooseFile } = setup({ + localFiles: { ...DEFAULT_SETTINGS.localFiles, episodes: [mine] as never }, + }); + + await chooseFile({ + ...structuredClone(DEFAULT_SETTINGS), + schemaVersion: 2, + localFiles: { ...DEFAULT_SETTINGS.localFiles, episodes: [theirs] }, + queue: { ...DEFAULT_SETTINGS.queue, episodes: [theirs, theirShow] }, + favorites: { ...DEFAULT_SETTINGS.favorites, episodes: [theirs] }, + }).confirm(); + + expect(JSON.stringify(disk())).not.toContain("Theirs"); + expect(disk().localFiles.episodes.map(({ title }) => title)).toEqual(["Mine"]); + expect(get(localFiles).episodes.map(({ title }) => title)).toEqual(["Mine"]); + expect(disk().queue.episodes.map(({ title }) => title)).toEqual(["Their Episode"]); + }); +}); From 8cc82c208e0d5544339c1fb904415eaabd1c6b46 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 12 Oct 2026 12:25:27 +0200 Subject: [PATCH 2/7] fix: keep private feed secrets and vault files out of settings and OPML transfers - Importing a data.json with a private feed now replaces its URL in queue, favorites, playlist and history snapshots too, not only in the feed list. Interning and scrubbing are one function, protectPrivateFeeds, used by both the load-time migration and the import. - Re-importing an OPML that lists a saved private feed recognizes it by its real URL, so it is skipped instead of fetched and saved a second time. - Settings export and import leave this vault's local files behind: Local Files is not exported, and local-file episodes are dropped from the queue, favorites and playlists. Playback progress now travels; on import this vault's progress wins and the file adds the rest. - The import dialog names the queue and favorites it replaces. - The OPML failure notice no longer points to a console that production builds strip. --- src/main.ts | 17 +- src/opml.ts | 68 +++----- src/services/privateFeeds.test.ts | 153 ++++++++++-------- src/services/privateFeeds.ts | 48 +++--- src/settingsTransfer.test.ts | 38 +++++ src/settingsTransfer.ts | 94 +++++------ .../PodNotesSettingsTab.transfer.test.ts | 43 +++++ src/ui/settings/PodNotesSettingsTab.ts | 66 +++----- 8 files changed, 287 insertions(+), 240 deletions(-) diff --git a/src/main.ts b/src/main.ts index 94ae8d7b..ed2ac37c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -46,11 +46,7 @@ import { jsonValuesEqual, restoreDropped } from "./persistence/merge"; import { type Clock, type DataFileAccess, DataFileWriter } from "./persistence/writer"; import { CredentialRepository } from "./services/CredentialRepository"; import { FeedUrlRepository } from "./services/FeedUrlRepository"; -import { - migratePrivateFeedUrls, - resolveFeedUrl, - scrubMigratedEpisodeUrls, -} from "./services/privateFeeds"; +import { protectPrivateFeeds, resolveFeedUrl } from "./services/privateFeeds"; import { FeedRepository, loadFeed } from "./services/FeedRepository"; type MediaSessionActionName = @@ -563,14 +559,11 @@ export default class PodNotes extends Plugin implements IPodNotes { // Best-effort: a failure keeps that feed's URL where it was, is // reported, and never breaks loading. this.feedUrls ??= new FeedUrlRepository(this.app.secretStorage); - const feedMigration = migratePrivateFeedUrls(settings.savedFeeds, this.feedUrls); - if (feedMigration.replacements.size > 0) { - settings = scrubMigratedEpisodeUrls( - { ...settings, savedFeeds: feedMigration.savedFeeds }, - feedMigration.replacements, - ); + const feedMigration = protectPrivateFeeds(settings, this.feedUrls); + if (feedMigration.moved > 0) { + settings = feedMigration.settings; await this.writeDataFile(encodePodNotesData(settings, decoded.unknownFields)); - const count = feedMigration.replacements.size; + const count = feedMigration.moved; new Notice( `Moved ${count} private feed ${count === 1 ? "URL" : "URLs"} into Obsidian SecretStorage.`, ); diff --git a/src/opml.ts b/src/opml.ts index fd0616f7..7e947195 100644 --- a/src/opml.ts +++ b/src/opml.ts @@ -1,6 +1,6 @@ import { type App, Notice } from "obsidian"; import { plugin, savedFeeds } from "./store"; -import { internPrivateFeed, resolveFeedUrl } from "./services/privateFeeds"; +import { internPrivateFeed, isSavedFeed, resolveFeedUrl } from "./services/privateFeeds"; import type { PodcastFeed } from "./types/PodcastFeed"; import { TimerNotice } from "./ui/TimerNotice"; import { get } from "svelte/store"; @@ -13,14 +13,8 @@ import { get } from "svelte/store"; */ function getAttributeCaseInsensitive(node: Element, name: string): string | null { const target = name.toLowerCase(); - for (let i = 0; i < node.attributes.length; i++) { - const attr = node.attributes.item(i); - if (attr && attr.name.toLowerCase() === target) { - const value = attr.value?.trim(); - return value ? value : null; - } - } - return null; + const attr = Array.from(node.attributes).find((attr) => attr.name.toLowerCase() === target); + return attr?.value.trim() || null; } async function importOPML(opml: string): Promise { @@ -39,31 +33,21 @@ async function importOPML(opml: string): Promise { // Select on the always-lowercase tag and the camelCase-safe `text` attr; // the xmlUrl attribute is read case-insensitively below so feeds written // with a lowercase/variant casing (e.g. `xmlurl`) aren't silently dropped. - const podcastEntryNodes = dom.querySelectorAll("outline[text]"); const incompletePodcastsToAdd: Pick[] = []; - for (let i = 0; i < podcastEntryNodes.length; i++) { - const node = podcastEntryNodes.item(i); - - const text = node.getAttribute("text"); - const xmlUrl = getAttributeCaseInsensitive(node, "xmlUrl"); - if (!text || !xmlUrl) { - continue; - } - - incompletePodcastsToAdd.push({ - title: text, - url: xmlUrl, - }); + for (const node of Array.from(dom.querySelectorAll("outline[text]"))) { + const title = node.getAttribute("text"); + const url = getAttributeCaseInsensitive(node, "xmlUrl"); + if (title && url) incompletePodcastsToAdd.push({ title, url }); } if (incompletePodcastsToAdd.length === 0) { throw new Error("No valid podcast entries found in OPML"); } - const existingSavedFeeds = get(savedFeeds); + const existingSavedFeeds = Object.values(get(savedFeeds)); + const { feeds, feedUrls } = get(plugin); const newPodcastsToAdd = incompletePodcastsToAdd.filter( - (pod) => - !Object.values(existingSavedFeeds).some((savedPod) => savedPod.url === pod.url), + (pod) => !existingSavedFeeds.some((saved) => isSavedFeed(saved, pod.url, feedUrls)), ); const notice = TimerNotice("Importing podcasts", "Preparing to import..."); @@ -89,15 +73,13 @@ async function importOPML(opml: string): Promise { const podcasts: (PodcastFeed | null)[] = await Promise.all( newPodcastsToAdd.map(async (feed) => { try { - const result = await get(plugin).feeds.channelAt({ url: feed.url }); - completedImports++; - updateProgress(); - return result; + return await feeds.channelAt({ url: feed.url }); } catch (error) { console.error(`Failed to fetch feed for ${feed.title}: ${error}`); + return null; + } finally { completedImports++; updateProgress(); - return null; } }), ); @@ -110,14 +92,14 @@ async function importOPML(opml: string): Promise { // from an earlier import in this batch or a previously saved feed) are // dropped. Count what is actually written so the summary doesn't over-report. let savedCount = 0; - savedFeeds.update((feeds) => { + savedFeeds.update((saved) => { for (const pod of validPodcasts) { - if (feeds[pod.title]) continue; + if (saved[pod.title]) continue; // Imported private URLs go straight to SecretStorage, never data.json. - feeds[pod.title] = internPrivateFeed(structuredClone(pod), get(plugin).feedUrls); + saved[pod.title] = internPrivateFeed(structuredClone(pod), feedUrls); savedCount++; } - return feeds; + return saved; }); // Feeds skipped before fetching because their URL was already subscribed. @@ -135,10 +117,7 @@ async function importOPML(opml: string): Promise { if (validPodcasts.length !== newPodcastsToAdd.length) { const failedImports = newPodcastsToAdd.length - validPodcasts.length; console.error(`Failed to import ${failedImports} podcasts.`); - new Notice( - `Failed to import ${failedImports} podcasts. Check console for details.`, - 10000, - ); + new Notice(`Failed to import ${failedImports} podcasts.`, 10000); } window.setTimeout(() => notice.dispose(), 5000); @@ -152,11 +131,6 @@ async function importOPML(opml: string): Promise { } async function exportOPML(app: App, feeds: PodcastFeed[], filePath = "PodNotes_Export.opml") { - const header = ``; - const opml = (child: string) => `${child}`; - const head = (child: string) => `${child}`; - const title = "PodNotes Feeds"; - const body = (child: string) => `${child}`; // Escape every interpolated attribute value so titles/URLs containing &, <, // >, or " produce well-formed XML that round-trips back through importOPML. // `&` must run first so the entities written by the later replaces are not @@ -177,10 +151,8 @@ async function exportOPML(app: App, feeds: PodcastFeed[], filePath = "PodNotes_E if (feed.urlSecretId && url) exportedPrivateFeeds += 1; return ``; }; - const feedsOutline = (_feeds: PodcastFeed[]) => - `${feeds.map(feedOutline).join("")}`; - - const doc = header + opml(`${head(title)}\n${body(feedsOutline(feeds))}`); + const doc = `PodNotes Feeds +${feeds.map(feedOutline).join("")}`; try { await app.vault.create(filePath, doc); diff --git a/src/services/privateFeeds.test.ts b/src/services/privateFeeds.test.ts index 8538f9f1..a109c713 100644 --- a/src/services/privateFeeds.test.ts +++ b/src/services/privateFeeds.test.ts @@ -1,13 +1,10 @@ import { describe, expect, it, vi } from "vitest"; import type { SecretStorage } from "obsidian"; +import { DEFAULT_SETTINGS } from "src/constants"; +import type { IPodNotesSettings } from "src/types/IPodNotesSettings"; import type { PodcastFeed } from "src/types/PodcastFeed"; import { FeedUrlRepository } from "./FeedUrlRepository"; -import { - internPrivateFeed, - migratePrivateFeedUrls, - resolveFeedUrl, - scrubMigratedEpisodeUrls, -} from "./privateFeeds"; +import { internPrivateFeed, protectPrivateFeeds, resolveFeedUrl } from "./privateFeeds"; function repository(initial: Record = {}) { const values = new Map(Object.entries(initial)); @@ -110,34 +107,91 @@ describe("resolveFeedUrl", () => { }); }); -describe("migratePrivateFeedUrls", () => { +function settings(overrides: Partial): IPodNotesSettings { + return { ...structuredClone(DEFAULT_SETTINGS), ...overrides }; +} + +function episode(title: string, feedUrl: string) { + return { + title, + streamUrl: `https://cdn.example/${title}.mp3`, + url: "", + description: "", + content: "", + podcastName: "Patreon", + feedUrl, + episodeDate: new Date("2026-01-01T00:00:00Z"), + }; +} + +describe("protectPrivateFeeds", () => { it("moves only credential-bearing feeds and reports the count", () => { const { feedUrls, values } = repository(); - const { savedFeeds, replacements, failed } = migratePrivateFeedUrls( - { - Public: feed({ title: "Public" }), - Patreon: feed({ title: "Patreon", url: PRIVATE_URL }), - Basic: feed({ title: "Basic", url: "https://user:pw@feeds.example.com/rss" }), - }, + const { + settings: safe, + moved, + failed, + } = protectPrivateFeeds( + settings({ + savedFeeds: { + Public: feed({ title: "Public" }), + Patreon: feed({ title: "Patreon", url: PRIVATE_URL }), + Basic: feed({ title: "Basic", url: "https://user:pw@feeds.example.com/rss" }), + }, + }), feedUrls, ); - expect(replacements.size).toBe(2); + expect(moved).toBe(2); expect(failed).toEqual([]); - expect(savedFeeds.Public.url).toBe("https://feeds.example.com/rss"); - expect(savedFeeds.Patreon.url).toBe("podnotes-private-feed:Patreon"); - expect(savedFeeds.Basic.urlSecretId).toMatch(/^podnotes-feed-url-/); - expect(JSON.stringify(savedFeeds)).not.toMatch(/se-cret|user:pw/); - expect(values.get(savedFeeds.Patreon.urlSecretId as string)).toBe(PRIVATE_URL); - expect(replacements.get(PRIVATE_URL)).toBe("podnotes-private-feed:Patreon"); + expect(safe.savedFeeds.Public.url).toBe("https://feeds.example.com/rss"); + expect(safe.savedFeeds.Patreon.url).toBe("podnotes-private-feed:Patreon"); + expect(safe.savedFeeds.Basic.urlSecretId).toMatch(/^podnotes-feed-url-/); + expect(JSON.stringify(safe)).not.toMatch(/se-cret|user:pw/); + expect(values.get(safe.savedFeeds.Patreon.urlSecretId as string)).toBe(PRIVATE_URL); }); - it("is idempotent: a second run over migrated feeds changes nothing", () => { + it("replaces the moved URL in every persisted episode snapshot and nothing else", () => { const { feedUrls } = repository(); - const first = migratePrivateFeedUrls({ Patreon: feed({ url: PRIVATE_URL }) }, feedUrls); - const second = migratePrivateFeedUrls(first.savedFeeds, feedUrls); - expect(second.replacements.size).toBe(0); - expect(second.savedFeeds).toEqual(first.savedFeeds); + const queued = episode("Ep 2", PRIVATE_URL); + const { settings: safe } = protectPrivateFeeds( + settings({ + savedFeeds: { Patreon: feed({ title: "Patreon", url: PRIVATE_URL }) }, + currentEpisode: { ...episode("Ep 1", PRIVATE_URL), url: PRIVATE_URL }, + queue: { ...DEFAULT_SETTINGS.queue, episodes: [queued] }, + favorites: { ...DEFAULT_SETTINGS.favorites, episodes: [queued] }, + playlists: { Mix: { ...DEFAULT_SETTINGS.queue, name: "Mix", episodes: [queued] } }, + downloadedEpisodes: { Patreon: [{ ...queued, filePath: "Ep 2.mp3", size: 1 }] }, + playedEpisodes: { + "Ep 3": { + title: "Ep 3", + podcastName: "Other", + time: 10, + duration: 20, + finished: false, + }, + }, + }), + feedUrls, + ); + + expect(JSON.stringify(safe)).not.toContain("se-cret"); + expect(safe.currentEpisode?.url).toBe("podnotes-private-feed:Patreon"); + expect(safe.queue.episodes[0].feedUrl).toBe("podnotes-private-feed:Patreon"); + expect(safe.queue.episodes[0].streamUrl).toBe("https://cdn.example/Ep 2.mp3"); + expect(safe.queue.episodes[0].episodeDate).toEqual(queued.episodeDate); + expect(safe.downloadedEpisodes.Patreon[0].feedUrl).toBe("podnotes-private-feed:Patreon"); + }); + + it("is idempotent: a second run over protected settings changes nothing", () => { + const { feedUrls } = repository(); + const first = protectPrivateFeeds( + settings({ savedFeeds: { Patreon: feed({ url: PRIVATE_URL }) } }), + feedUrls, + ); + const second = protectPrivateFeeds(first.settings, feedUrls); + expect(second.moved).toBe(0); + expect(second.settings).toEqual(first.settings); }); it("reports feeds whose URL could not be protected", () => { @@ -145,54 +199,11 @@ describe("migratePrivateFeedUrls", () => { (api.setSecret as ReturnType).mockImplementation(() => { throw new Error("locked"); }); - const { failed, replacements } = migratePrivateFeedUrls( - { Patreon: feed({ title: "Patreon", url: PRIVATE_URL }) }, + const { failed, moved } = protectPrivateFeeds( + settings({ savedFeeds: { Patreon: feed({ title: "Patreon", url: PRIVATE_URL }) } }), feedUrls, ); expect(failed).toEqual(["Patreon"]); - expect(replacements.size).toBe(0); - }); -}); - -describe("scrubMigratedEpisodeUrls", () => { - const replacements = new Map([[PRIVATE_URL, "podnotes-private-feed:Patreon"]]); - - it("rewrites url/feedUrl in nested episode snapshots and nothing else", () => { - const settings = { - currentEpisode: { title: "Ep 1", url: PRIVATE_URL, feedUrl: PRIVATE_URL }, - queue: { - episodes: [ - { - title: "Ep 2", - feedUrl: PRIVATE_URL, - streamUrl: "https://cdn.example/e2.mp3", - }, - ], - }, - playedEpisodes: { "Ep 3": { feedUrl: "https://public.example/rss", time: 10 } }, - }; - const scrubbed = scrubMigratedEpisodeUrls(settings, replacements); - expect(scrubbed.currentEpisode.url).toBe("podnotes-private-feed:Patreon"); - expect(scrubbed.currentEpisode.feedUrl).toBe("podnotes-private-feed:Patreon"); - expect(scrubbed.queue.episodes[0].feedUrl).toBe("podnotes-private-feed:Patreon"); - expect(scrubbed.queue.episodes[0].streamUrl).toBe("https://cdn.example/e2.mp3"); - expect(scrubbed.playedEpisodes["Ep 3"].feedUrl).toBe("https://public.example/rss"); - expect(JSON.stringify(scrubbed)).not.toContain("se-cret"); - }); - - it("preserves Date instances and untouched subtrees by reference", () => { - const date = new Date("2026-01-01T00:00:00Z"); - const settings = { - currentEpisode: { title: "Ep", feedUrl: PRIVATE_URL, episodeDate: date }, - untouched: { nested: { value: 1 } }, - }; - const scrubbed = scrubMigratedEpisodeUrls(settings, replacements); - expect(scrubbed.currentEpisode.episodeDate).toBe(date); - expect(scrubbed.untouched).toBe(settings.untouched); - }); - - it("is a no-op without replacements", () => { - const settings = { currentEpisode: { feedUrl: PRIVATE_URL } }; - expect(scrubMigratedEpisodeUrls(settings, new Map())).toBe(settings); + expect(moved).toBe(0); }); }); diff --git a/src/services/privateFeeds.ts b/src/services/privateFeeds.ts index f63c7518..a015f11c 100644 --- a/src/services/privateFeeds.ts +++ b/src/services/privateFeeds.ts @@ -1,4 +1,5 @@ import { Notice } from "obsidian"; +import type { IPodNotesSettings } from "src/types/IPodNotesSettings"; import type { PodcastFeed } from "src/types/PodcastFeed"; import { isCredentialBearingUrl, @@ -80,48 +81,49 @@ export function notifyPrivateFeedUnavailable(title: string): void { ); } -export interface PrivateFeedMigration { - savedFeeds: Record; - /** Old credential-bearing URL -> the placeholder that replaced it. */ - replacements: Map; +export interface PrivateFeedProtection { + settings: IPodNotesSettings; + /** How many saved feeds moved their URL into SecretStorage. */ + moved: number; /** Feeds whose URL could NOT be protected (SecretStorage failure). */ failed: string[]; } /** - * Move every credential-bearing saved-feed URL into SecretStorage. - * Best-effort per feed: one failing feed keeps its URL, is reported in - * `failed`, and the rest still move. + * Make settings safe to persist: move every credential-bearing saved-feed URL + * into SecretStorage, and replace the same URLs in persisted episode snapshots + * (played history, queue, favorites, playlists, local files, downloads, and + * the current episode all persist full episodes whose `url`/`feedUrl` were + * stamped at fetch time). Best-effort per feed: one failing feed keeps its + * URL, is reported in `failed`, and the rest still move. */ -export function migratePrivateFeedUrls( - savedFeeds: Record, +export function protectPrivateFeeds( + settings: IPodNotesSettings, feedUrls: FeedUrlRepository, -): PrivateFeedMigration { +): PrivateFeedProtection { const replacements = new Map(); const failed: string[] = []; - const result: Record = {}; - for (const [key, feed] of Object.entries(savedFeeds)) { + const savedFeeds: Record = {}; + for (const [key, feed] of Object.entries(settings.savedFeeds)) { const interned = internPrivateFeed(feed, feedUrls, key); if (interned !== feed) { replacements.set(feed.url, interned.url); } else if (isCredentialBearingUrl(feed.url)) { failed.push(key); } - result[key] = interned; + savedFeeds[key] = interned; } - return { savedFeeds: result, replacements, failed }; + return { + settings: scrubEpisodeUrls({ ...settings, savedFeeds }, replacements), + moved: replacements.size, + failed, + }; } -/** - * Rewrite persisted Episode snapshots that still carry a migrated feed's old - * credential-bearing URL (played history, queue, favorites, playlists, local - * files, downloads, and the current episode all persist full episode objects - * whose `url`/`feedUrl` were stamped at fetch time). - */ -export function scrubMigratedEpisodeUrls(value: T, replacements: Map): T { +function scrubEpisodeUrls(value: T, replacements: Map): T { if (replacements.size === 0) return value; if (Array.isArray(value)) { - return value.map((entry) => scrubMigratedEpisodeUrls(entry, replacements)) as T; + return value.map((entry) => scrubEpisodeUrls(entry, replacements)) as T; } if (typeof value !== "object" || value === null) return value; // Decoded settings carry Date instances (episodeDate); recursing into one @@ -136,7 +138,7 @@ export function scrubMigratedEpisodeUrls(value: T, replacements: Map { expect(exported.playlists?.Mix.episodes.map(({ title }) => title)).toEqual(["Ep 1"]); }); + it("carries playback progress", () => { + const progress = { + title: "Ep 1", + podcastName: "Show", + time: 30, + duration: 60, + finished: false, + }; + const settings = makeSettings({ playedEpisodes: { "Show::Ep 1": progress } }); + + const { settings: exported } = serializeSettings(settings, {}, "2.16.0", NOW); + + expect(exported.playedEpisodes).toEqual({ "Show::Ep 1": progress }); + }); + it("encodes dates inside exported playlists as canonical text", () => { const date = new Date("2024-03-01T10:05:03.000Z"); const settings = makeSettings({ @@ -502,6 +517,29 @@ describe("mergeImportedSettings", () => { expect(merged.playedEpisodes).toEqual(current.playedEpisodes); }); + it("adds imported progress without overwriting this vault's", () => { + const played = (title: string, time: number) => ({ + title, + podcastName: "Show", + time, + duration: 60, + finished: false, + }); + const current = makeSettings({ playedEpisodes: { "Show::Ep 1": played("Ep 1", 10) } }); + + const merged = mergeImportedSettings(current, { + playedEpisodes: { + "Show::Ep 1": played("Ep 1", 50), + "Show::Ep 2": played("Ep 2", 20), + }, + }); + + expect(merged.playedEpisodes).toEqual({ + "Show::Ep 1": played("Ep 1", 10), + "Show::Ep 2": played("Ep 2", 20), + }); + }); + it("backfills partial nested objects from defaults so no field is blanked", () => { const current = makeSettings(); // A hand-edited file that only sets timestamp.offset. diff --git a/src/settingsTransfer.ts b/src/settingsTransfer.ts index 0a5a6186..1a63d9ad 100644 --- a/src/settingsTransfer.ts +++ b/src/settingsTransfer.ts @@ -1,4 +1,5 @@ import { DEFAULT_SETTINGS } from "./constants"; +import { DANGEROUS_KEYS, isPlainObject, mapRecord } from "./persistence/codecUtils"; import { decodePodNotesData, encodePodNotesData, @@ -7,7 +8,9 @@ import { PodNotesDataError, } from "./persistence/podNotesData"; import type { CredentialValues } from "./types/Credentials"; +import type { Episode } from "./types/Episode"; import type { IPodNotesSettings } from "./types/IPodNotesSettings"; +import { isLocalFile } from "./utility/isLocalFile"; /** * Serialization for the "import/export settings & templates" feature (issue #162). @@ -23,15 +26,15 @@ export const SETTINGS_EXPORT_TYPE = "podnotes-settings"; export const SETTINGS_EXPORT_VERSION = 2; /** - * Runtime / vault-specific state that must never travel between vaults: playback - * progress, the episode->note path map, on-disk download bookkeeping, and the - * currently-playing episode. These are excluded from export and dropped on - * import (so a copied raw `data.json` still imports cleanly as "settings only"). + * Vault-specific state that must never travel between vaults: the episode->note + * path map, on-disk download bookkeeping and the Local Files list mirrored from + * it, and the currently-playing episode. These are excluded from export and + * dropped on import (so a copied raw `data.json` still imports cleanly). */ export const EXCLUDED_KEYS: readonly (keyof IPodNotesSettings)[] = [ "podNotes", - "playedEpisodes", "downloadedEpisodes", + "localFiles", "currentEpisode", ]; @@ -65,9 +68,6 @@ export function describeSecrets(secrets: CredentialValues): string[] { .map((key) => SECRET_KEY_LABELS[key]); } -/** Keys that, if copied into the settings object, could pollute Object.prototype. */ -const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]); - /** Known top-level setting keys that are safe to import (excludes runtime state). */ const IMPORTABLE_KEYS = new Set( Object.keys(DEFAULT_SETTINGS).filter( @@ -117,34 +117,43 @@ export type ParseResult = | { ok: false; error: string }; /** - * Build a versioned export envelope from the live settings, copying only - * allow-listed keys by name (never spreading the whole object). Runtime state and - * SecretStorage references are always excluded. Plaintext values appear only in - * the separate payload explicitly passed by the caller. - */ -/** - * SecretStorage references are device-local capabilities and never transfer - * data: an imported urlSecretId is at best meaningless on this device. The - * private URL itself stays behind (placeholder) unless the source device - * exports it another way; the feed is re-added on the target device. + * The part of a settings payload that travels between vaults. SecretStorage + * references are device-local: an imported urlSecretId is at best meaningless + * on this device, so a private feed keeps only its placeholder and is re-added + * on the target device. A local file's episode points into this vault. */ -function stripFeedUrlSecretReferences(savedFeeds: unknown): unknown { - if (typeof savedFeeds !== "object" || savedFeeds === null || Array.isArray(savedFeeds)) { - return savedFeeds; +function portable(settings: T): T { + const out = { ...settings } as Record; + if (isPlainObject(out.savedFeeds)) { + out.savedFeeds = mapRecord(out.savedFeeds, (feed) => + isPlainObject(feed) ? { ...feed, urlSecretId: undefined } : feed, + ); } - const out: Record = {}; - for (const [key, feed] of Object.entries(savedFeeds)) { - if (typeof feed === "object" && feed !== null && !Array.isArray(feed)) { - const rest = { ...(feed as Record) }; - delete rest.urlSecretId; - out[key] = rest; - } else { - out[key] = feed; - } + for (const key of ["queue", "favorites"]) { + if (key in out) out[key] = withoutLocalFiles(out[key]); } - return out; + if (isPlainObject(out.playlists)) out.playlists = mapRecord(out.playlists, withoutLocalFiles); + return out as T; +} + +function withoutLocalFiles(playlist: unknown): unknown { + return isPlainObject(playlist) && Array.isArray(playlist.episodes) + ? { + ...playlist, + episodes: playlist.episodes.filter( + (episode) => + !isPlainObject(episode) || !isLocalFile(episode as unknown as Episode), + ), + } + : playlist; } +/** + * Build a versioned export envelope from the live settings, copying only + * allow-listed keys by name (never spreading the whole object). Runtime state and + * SecretStorage references are always excluded. Plaintext values appear only in + * the separate payload explicitly passed by the caller. + */ export function serializeSettings( settings: IPodNotesSettings, opts: ExportOptions, @@ -159,7 +168,6 @@ export function serializeSettings( if (!IMPORTABLE_KEYS.has(key)) continue; out[key] = persisted[key]; } - out.savedFeeds = stripFeedUrlSecretReferences(out.savedFeeds); const secrets = normalizeSecrets(opts.secrets ?? {}); return { @@ -167,7 +175,7 @@ export function serializeSettings( version: SETTINGS_EXPORT_VERSION, pluginVersion, exportedAt: nowISO, - settings: out, + settings: portable(out), ...(Object.keys(secrets).length > 0 ? { secrets } : {}), }; } @@ -246,12 +254,7 @@ export function parseImport(jsonText: string): ParseResult { secrets = extractLegacySecrets(source); } - const settings = sanitizeImportedSettings(source); - if ("savedFeeds" in settings) { - (settings as Record).savedFeeds = stripFeedUrlSecretReferences( - settings.savedFeeds, - ); - } + const settings = portable(sanitizeImportedSettings(source)); if (Object.keys(settings).length === 0 && Object.keys(secrets).length === 0) { return { @@ -283,7 +286,12 @@ export function mergeImportedSettings( current: IPodNotesSettings, imported: Partial, ): IPodNotesSettings { - const merged: IPodNotesSettings = { ...current, ...imported }; + const merged: IPodNotesSettings = { + ...current, + ...imported, + // Progress this vault already has wins; the import adds the rest. + playedEpisodes: { ...imported.playedEpisodes, ...current.playedEpisodes }, + }; for (const key of NESTED_KEYS) { merged[key] = { @@ -318,7 +326,7 @@ function sanitizeImportedSettings(source: Record): Partial, value as Record, ); - } else if (key === "favorites" || key === "localFiles") { + } else if (key === "favorites") { // Built-in playlist objects must carry an `episodes` array; consumers // (PlaylistCard, context menu, removeEpisodeFromPlaylists) iterate it // without guarding. Drop the key when it's malformed so the merge falls @@ -412,7 +420,3 @@ function typeMatchesDefault(defaultValue: unknown, value: unknown): boolean { if (typeof defaultValue === "object") return isPlainObject(value); return typeof value === typeof defaultValue; } - -function isPlainObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} diff --git a/src/ui/settings/PodNotesSettingsTab.transfer.test.ts b/src/ui/settings/PodNotesSettingsTab.transfer.test.ts index fbaa4a13..db49a999 100644 --- a/src/ui/settings/PodNotesSettingsTab.transfer.test.ts +++ b/src/ui/settings/PodNotesSettingsTab.transfer.test.ts @@ -127,4 +127,47 @@ describe("importing a settings file", () => { expect(get(localFiles).episodes.map(({ title }) => title)).toEqual(["Mine"]); expect(disk().queue.episodes.map(({ title }) => title)).toEqual(["Their Episode"]); }); + + it("brings progress for episodes this vault has not played and keeps its own", async () => { + const played = (title: string, time: number) => ({ + title, + podcastName: "Show", + time, + duration: 60, + finished: false, + }); + const { disk, chooseFile } = setup({ + playedEpisodes: { "Show::Ep 1": played("Ep 1", 10) }, + }); + + await chooseFile({ + ...structuredClone(DEFAULT_SETTINGS), + schemaVersion: 2, + playedEpisodes: { + "Show::Ep 1": played("Ep 1", 50), + "Show::Ep 2": played("Ep 2", 20), + }, + }).confirm(); + + const expected = { "Show::Ep 1": played("Ep 1", 10), "Show::Ep 2": played("Ep 2", 20) }; + expect(disk().playedEpisodes).toEqual(expected); + expect(get(playedEpisodes)).toEqual(expected); + }); + + it("names the queue and favorites it replaces before importing", () => { + const { chooseFile } = setup({ + queue: { ...DEFAULT_SETTINGS.queue, episodes: [episode("Show", "Queued")] as never }, + favorites: { + ...DEFAULT_SETTINGS.favorites, + episodes: [episode("Show", "Loved")] as never, + }, + }); + + const { message } = chooseFile({ + queue: { ...DEFAULT_SETTINGS.queue, episodes: [] }, + favorites: { ...DEFAULT_SETTINGS.favorites, episodes: [] }, + }); + + expect(message).toContain("Also replaced: queue, favorites."); + }); }); diff --git a/src/ui/settings/PodNotesSettingsTab.ts b/src/ui/settings/PodNotesSettingsTab.ts index 3c8e3e21..922c9f87 100644 --- a/src/ui/settings/PodNotesSettingsTab.ts +++ b/src/ui/settings/PodNotesSettingsTab.ts @@ -23,8 +23,8 @@ import { episodeListLimit, favorites, hidePlayedEpisodes, - localFiles, playbackRate, + playedEpisodes, playlists, plugin, queue, @@ -38,7 +38,7 @@ import type { PodcastFeed } from "src/types/PodcastFeed"; import type { IPodNotesSettings } from "src/types/IPodNotesSettings"; import { get } from "svelte/store"; import { exportOPML, importOPML } from "src/opml"; -import { migratePrivateFeedUrls } from "src/services/privateFeeds"; +import { protectPrivateFeeds } from "src/services/privateFeeds"; import { describeSecrets, mergeImportedSettings, @@ -685,21 +685,9 @@ export class PodNotesSettingsTab extends PluginSettingTab { .setDesc("Import podcasts from an OPML file.") .addButton((button) => button.setButtonText("Import").onClick(() => { - this.pickFile( - ".opml", - async (contents) => { - try { - await importOPML(contents); - } catch (e) { - console.error("Error importing OPML:", e); - new Notice( - `Error importing OPML: ${e instanceof Error ? e.message : "Unknown error"}`, - 10000, - ); - } - }, - { tooLargeMessage: "That file is too large to be an OPML file." }, - ); + this.pickFile(".opml", importOPML, { + tooLargeMessage: "That file is too large to be an OPML file.", + }); }), ); @@ -742,7 +730,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { new Setting(containerEl) .setName("Import settings") .setDesc( - "Import PodNotes preferences, templates, feeds, and playlists from a settings file. Playback progress and downloads are not affected.", + "Import PodNotes preferences, templates, feeds, playlists, and playback progress from a settings file.", ) .addButton((button) => button.setButtonText("Import").onClick(() => { @@ -766,7 +754,9 @@ export class PodNotesSettingsTab extends PluginSettingTab { new Setting(containerEl) .setName("Export settings") - .setDesc("Export PodNotes preferences, templates, feeds, and playlists.") + .setDesc( + "Export PodNotes preferences, templates, feeds, playlists, and playback progress.", + ) .addText((text) => text .setPlaceholder("Export file name") @@ -886,26 +876,21 @@ export class PodNotesSettingsTab extends PluginSettingTab { } // Warn about a collection only when the user CURRENTLY has data there AND - // the import carries that collection (so it will be replaced wholesale — + // the import carries that collection (so it will be replaced wholesale - // including by an explicitly empty collection, which silently wiped feeds // with no warning before). Keying off the imported data's contents alone // missed exactly that data-loss case (SA-09). - const willReplace = ( - key: keyof IPodNotesSettings, - current: Record | undefined, - ): boolean => - Object.prototype.hasOwnProperty.call(result.settings, key) && - Object.keys(current ?? {}).length > 0; - - const sections: string[] = []; - if (willReplace("savedFeeds", this.plugin.settings.savedFeeds)) { - sections.push("podcast feeds"); - } - if (willReplace("playlists", this.plugin.settings.playlists)) { - sections.push("playlists"); - } + const { settings } = this.plugin; + const sections = Object.entries({ + savedFeeds: settings.savedFeeds, + playlists: settings.playlists, + queue: settings.queue.episodes, + favorites: settings.favorites.episodes, + }) + .filter(([key, current]) => key in result.settings && Object.keys(current).length > 0) + .map(([key]) => (key === "savedFeeds" ? "podcast feeds" : key)); sections.push(...describeSecrets(result.secrets)); - const detail = sections.length ? ` This also replaces your ${sections.join(", ")}.` : ""; + const detail = sections.length ? ` Also replaced: ${sections.join(", ")}.` : ""; const secretDetail = Object.keys(result.secrets).length > 0 ? " Existing Obsidian secrets are never overwritten; conflicts are saved under a new PodNotes name." @@ -914,7 +899,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { new ConfirmModal( this.app, "Import PodNotes settings?", - `This overwrites your current PodNotes preferences and templates with the imported values.${detail}${secretDetail} Your playback progress and downloads are kept.`, + `This overwrites your current PodNotes preferences and templates with the imported values.${detail}${secretDetail} Your playback progress, downloads, and local files are kept.`, "Import", () => { void this.applyImportedSettings(result.settings, result.secrets); @@ -960,13 +945,12 @@ export class PodNotesSettingsTab extends PluginSettingTab { return "failed"; } - const merged = mergeImportedSettings(previous, { ...imported, ...secretReferences }); // A raw data.json import can carry credential-bearing feed URLs; intern // them BEFORE the strict persist below so the secret never reaches disk. - merged.savedFeeds = migratePrivateFeedUrls( - merged.savedFeeds, + const merged = protectPrivateFeeds( + mergeImportedSettings(previous, { ...imported, ...secretReferences }), this.plugin.feedUrls, - ).savedFeeds; + ).settings; const failedCandidate = structuredClone(merged); const concurrentStoreChanges = observePersistedStoreChanges(); this.plugin.settings = merged; @@ -1018,7 +1002,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { playlists.set(merged.playlists); favorites.set(merged.favorites); queue.set(merged.queue); - localFiles.set(merged.localFiles); + playedEpisodes.set(merged.playedEpisodes); hidePlayedEpisodes.set(merged.hidePlayedEpisodes); const sanitizedLimit = sanitizeEpisodeListLimit(merged.episodeListLimit); merged.episodeListLimit = sanitizedLimit; From 3f4cdc0c4d07110c419426470b7733bfc1cf07a6 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 12 Oct 2026 12:52:33 +0200 Subject: [PATCH 3/7] fix: repair URLs earlier imports leaked, revive private feeds from OPML, keep progress imported during playback - On load, a saved private feed's real URL is replaced in episode snapshots too, so data.json files that an earlier import already leaked into are repaired once; a load that changes nothing writes nothing. - OPML import gives a saved private feed whose URL is not on this device the listed URL instead of dropping it as a duplicate title, so settings import plus OPML import moves private feeds between vaults. - Progress imported while an episode plays is no longer lost when the player saves a position during the import; the live store wins and the file adds the rest. - Import re-hydration trusts the codec's normalization instead of repeating it, and stale or narrating comments are removed. --- src/main.persistence.test.ts | 46 +++++++++++++++++++ src/main.ts | 7 +-- src/opml.test.ts | 22 +++++++++ src/opml.ts | 4 +- src/services/privateFeeds.test.ts | 43 +++++++++++------ src/services/privateFeeds.ts | 33 +++++-------- src/settingsTransfer.test.ts | 19 ++++---- src/settingsTransfer.ts | 30 +++--------- .../PodNotesSettingsTab.transfer.test.ts | 46 +++++++++++++++---- src/ui/settings/PodNotesSettingsTab.ts | 19 +++----- 10 files changed, 173 insertions(+), 96 deletions(-) diff --git a/src/main.persistence.test.ts b/src/main.persistence.test.ts index ad79197d..0238f1c5 100644 --- a/src/main.persistence.test.ts +++ b/src/main.persistence.test.ts @@ -255,6 +255,52 @@ describe("PodNotes persistence integration", () => { ); }); + it("removes a private feed URL an earlier import left in data.json, then stops rewriting it", async () => { + const saveData = vi.fn().mockResolvedValue(undefined); + const plugin = makePlugin(saveData, { dataFileExists: true }); + const id = "podnotes-feed-url-00000000-0000-4000-8000-000000000000"; + const secretUrl = "https://private.example/feed.xml?auth=leaked"; + const { storage } = memorySecretStorage({ [id]: secretUrl }); + Object.assign(plugin, { app: { ...plugin.app, secretStorage: storage } }); + await plugin.saveData( + encodePodNotesData({ + ...structuredClone(DEFAULT_SETTINGS), + savedFeeds: { + Private: { + title: "Private", + url: "podnotes-private-feed:Private", + urlSecretId: id, + artworkUrl: "", + }, + }, + queue: { + ...DEFAULT_SETTINGS.queue, + episodes: [ + { + title: "Ep", + streamUrl: "https://media.example/ep.mp3", + url: "", + description: "", + content: "", + podcastName: "Private", + feedUrl: secretUrl, + }, + ], + }, + }), + ); + saveData.mockClear(); + + await plugin.loadSettings(); + const written = JSON.stringify(saveData.mock.calls.at(-1)?.[0]); + expect(written).toContain('"feedUrl":"podnotes-private-feed:Private"'); + expect(written).not.toContain("auth=leaked"); + + const writes = saveData.mock.calls.length; + await plugin.loadSettings(); + expect(saveData).toHaveBeenCalledTimes(writes); + }); + it("starts on an empty data.json as on a new install", async () => { const plugin = makePlugin(undefined, { dataFileExists: true }); let text = ""; diff --git a/src/main.ts b/src/main.ts index ed2ac37c..f8bcaf04 100644 --- a/src/main.ts +++ b/src/main.ts @@ -560,13 +560,10 @@ export default class PodNotes extends Plugin implements IPodNotes { // reported, and never breaks loading. this.feedUrls ??= new FeedUrlRepository(this.app.secretStorage); const feedMigration = protectPrivateFeeds(settings, this.feedUrls); - if (feedMigration.moved > 0) { + if (feedMigration.settings !== settings) { settings = feedMigration.settings; await this.writeDataFile(encodePodNotesData(settings, decoded.unknownFields)); - const count = feedMigration.moved; - new Notice( - `Moved ${count} private feed ${count === 1 ? "URL" : "URLs"} into Obsidian SecretStorage.`, - ); + new Notice("Moved private feed URLs into Obsidian SecretStorage."); } if (feedMigration.failed.length > 0) { new Notice( diff --git a/src/opml.test.ts b/src/opml.test.ts index 40c06c4a..9b8b629d 100644 --- a/src/opml.test.ts +++ b/src/opml.test.ts @@ -233,6 +233,28 @@ describe("importOPML (IE-01)", () => { expect(noticeMessages.some((m) => m.includes("Skipped 1 existing podcasts"))).toBe(true); }); + it("gives a saved private feed whose URL is not on this device the listed URL", async () => { + const privateUrl = "https://private.test/feed?auth=s3cret"; + feedUrlSecrets.clear(); + savedFeeds.set({ + Private: { title: "Private", url: "podnotes-private-feed:Private", artworkUrl: "" }, + }); + channelAt.mockImplementation(async ({ url }: { url: string }) => ({ + title: "Private", + url, + artworkUrl: "", + })); + + await importOPML( + ``, + ); + + const revived = get(savedFeeds).Private; + expect(Object.keys(get(savedFeeds))).toEqual(["Private"]); + expect(revived.url).toBe("podnotes-private-feed:Private"); + expect(feedUrlSecrets.get(revived.urlSecretId as string)).toBe(privateUrl); + }); + it("never renders 'NaN%' when every feed is already subscribed", async () => { savedFeeds.set({ Existing: { diff --git a/src/opml.ts b/src/opml.ts index 7e947195..98c507f1 100644 --- a/src/opml.ts +++ b/src/opml.ts @@ -94,7 +94,9 @@ async function importOPML(opml: string): Promise { let savedCount = 0; savedFeeds.update((saved) => { for (const pod of validPodcasts) { - if (saved[pod.title]) continue; + // A saved private feed whose URL is not on this device takes the listed URL. + if (saved[pod.title] && resolveFeedUrl(saved[pod.title], feedUrls) !== null) + continue; // Imported private URLs go straight to SecretStorage, never data.json. saved[pod.title] = internPrivateFeed(structuredClone(pod), feedUrls); savedCount++; diff --git a/src/services/privateFeeds.test.ts b/src/services/privateFeeds.test.ts index a109c713..d689a048 100644 --- a/src/services/privateFeeds.test.ts +++ b/src/services/privateFeeds.test.ts @@ -125,13 +125,9 @@ function episode(title: string, feedUrl: string) { } describe("protectPrivateFeeds", () => { - it("moves only credential-bearing feeds and reports the count", () => { + it("moves only credential-bearing feeds", () => { const { feedUrls, values } = repository(); - const { - settings: safe, - moved, - failed, - } = protectPrivateFeeds( + const { settings: safe, failed } = protectPrivateFeeds( settings({ savedFeeds: { Public: feed({ title: "Public" }), @@ -142,7 +138,6 @@ describe("protectPrivateFeeds", () => { feedUrls, ); - expect(moved).toBe(2); expect(failed).toEqual([]); expect(safe.savedFeeds.Public.url).toBe("https://feeds.example.com/rss"); expect(safe.savedFeeds.Patreon.url).toBe("podnotes-private-feed:Patreon"); @@ -189,9 +184,27 @@ describe("protectPrivateFeeds", () => { settings({ savedFeeds: { Patreon: feed({ url: PRIVATE_URL }) } }), feedUrls, ); - const second = protectPrivateFeeds(first.settings, feedUrls); - expect(second.moved).toBe(0); - expect(second.settings).toEqual(first.settings); + expect(protectPrivateFeeds(first.settings, feedUrls).settings).toBe(first.settings); + }); + + it("replaces a protected feed's URL that an earlier import left in episode snapshots", () => { + const { feedUrls } = repository(); + const id = feedUrls.store(PRIVATE_URL); + const saved = feed({ + title: "Patreon", + url: "podnotes-private-feed:Patreon", + urlSecretId: id, + }); + const leaked = settings({ + savedFeeds: { Patreon: saved }, + queue: { ...DEFAULT_SETTINGS.queue, episodes: [episode("Ep 2", PRIVATE_URL)] }, + }); + + const { settings: safe } = protectPrivateFeeds(leaked, feedUrls); + + expect(JSON.stringify(safe)).not.toContain("se-cret"); + expect(safe.queue.episodes[0].feedUrl).toBe("podnotes-private-feed:Patreon"); + expect(safe.savedFeeds.Patreon).toEqual(saved); }); it("reports feeds whose URL could not be protected", () => { @@ -199,11 +212,11 @@ describe("protectPrivateFeeds", () => { (api.setSecret as ReturnType).mockImplementation(() => { throw new Error("locked"); }); - const { failed, moved } = protectPrivateFeeds( - settings({ savedFeeds: { Patreon: feed({ title: "Patreon", url: PRIVATE_URL }) } }), - feedUrls, - ); + const unprotected = settings({ + savedFeeds: { Patreon: feed({ title: "Patreon", url: PRIVATE_URL }) }, + }); + const { failed, settings: result } = protectPrivateFeeds(unprotected, feedUrls); expect(failed).toEqual(["Patreon"]); - expect(moved).toBe(0); + expect(result).toBe(unprotected); }); }); diff --git a/src/services/privateFeeds.ts b/src/services/privateFeeds.ts index a015f11c..a65ce437 100644 --- a/src/services/privateFeeds.ts +++ b/src/services/privateFeeds.ts @@ -83,20 +83,10 @@ export function notifyPrivateFeedUnavailable(title: string): void { export interface PrivateFeedProtection { settings: IPodNotesSettings; - /** How many saved feeds moved their URL into SecretStorage. */ - moved: number; /** Feeds whose URL could NOT be protected (SecretStorage failure). */ failed: string[]; } -/** - * Make settings safe to persist: move every credential-bearing saved-feed URL - * into SecretStorage, and replace the same URLs in persisted episode snapshots - * (played history, queue, favorites, playlists, local files, downloads, and - * the current episode all persist full episodes whose `url`/`feedUrl` were - * stamped at fetch time). Best-effort per feed: one failing feed keeps its - * URL, is reported in `failed`, and the rest still move. - */ export function protectPrivateFeeds( settings: IPodNotesSettings, feedUrls: FeedUrlRepository, @@ -104,36 +94,37 @@ export function protectPrivateFeeds( const replacements = new Map(); const failed: string[] = []; const savedFeeds: Record = {}; + let moved = false; for (const [key, feed] of Object.entries(settings.savedFeeds)) { const interned = internPrivateFeed(feed, feedUrls, key); if (interned !== feed) { + moved = true; replacements.set(feed.url, interned.url); } else if (isCredentialBearingUrl(feed.url)) { failed.push(key); } + // Also replace a protected feed's URL that an older import left in snapshots. + const url = interned.urlSecretId && feedUrls.resolve(interned.urlSecretId); + if (url) replacements.set(url, interned.url); savedFeeds[key] = interned; } return { - settings: scrubEpisodeUrls({ ...settings, savedFeeds }, replacements), - moved: replacements.size, + settings: scrubEpisodeUrls(moved ? { ...settings, savedFeeds } : settings, replacements), failed, }; } +/** Returns `value` itself when no URL changed, so callers can tell whether to save. */ function scrubEpisodeUrls(value: T, replacements: Map): T { - if (replacements.size === 0) return value; - if (Array.isArray(value)) { - return value.map((entry) => scrubEpisodeUrls(entry, replacements)) as T; - } - if (typeof value !== "object" || value === null) return value; + if (replacements.size === 0 || typeof value !== "object" || value === null) return value; + const isArray = Array.isArray(value); // Decoded settings carry Date instances (episodeDate); recursing into one // would flatten it into {}. Non-plain objects hold no episode URLs. - if (Object.getPrototypeOf(value) !== Object.prototype) return value; + if (!isArray && Object.getPrototypeOf(value) !== Object.prototype) return value; - const record = value as Record; let changed = false; - const next: Record = {}; - for (const [key, entry] of Object.entries(record)) { + const next = (isArray ? [] : {}) as Record; + for (const [key, entry] of Object.entries(value)) { let scrubbed: unknown; if ((key === "url" || key === "feedUrl") && typeof entry === "string") { scrubbed = replacements.get(entry) ?? entry; diff --git a/src/settingsTransfer.test.ts b/src/settingsTransfer.test.ts index 5a9fe959..01288b4c 100644 --- a/src/settingsTransfer.test.ts +++ b/src/settingsTransfer.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { DEFAULT_SETTINGS } from "./constants"; import { - EXCLUDED_KEYS, + VAULT_LOCAL_KEYS, SETTINGS_EXPORT_TYPE, SETTINGS_EXPORT_VERSION, describeSecrets, @@ -87,7 +87,7 @@ describe("serializeSettings", () => { const { settings: exported } = serializeSettings(settings, {}, "2.16.0", NOW); - for (const key of EXCLUDED_KEYS) { + for (const key of VAULT_LOCAL_KEYS) { expect(exported).not.toHaveProperty(key as string); } }); @@ -153,16 +153,19 @@ describe("serializeSettings", () => { }); it("leaves this vault's files behind but keeps the rest of the library", () => { - const episode = (podcastName: string, title: string) => ({ + const episode = (podcastName: string, title: string, streamUrl: string) => ({ title, - streamUrl: `app://local/vault/${title}.mp3`, + streamUrl, url: "", description: "", content: "", podcastName, }); - const memo = { ...episode("local file", "Memo"), filePath: "Audio/Memo.mp3" }; - const show = episode("Show", "Ep 1"); + const memo = { + ...episode("local file", "Memo", "app://local/vault/Audio/Memo.mp3"), + filePath: "Audio/Memo.mp3", + }; + const show = episode("Show", "Ep 1", "https://media.example/ep1.mp3"); const settings = makeSettings({ localFiles: { ...DEFAULT_SETTINGS.localFiles, episodes: [memo] }, queue: { ...DEFAULT_SETTINGS.queue, episodes: [memo, show] }, @@ -285,7 +288,7 @@ describe("parseImport", () => { if (result.ok) { expect(result.meta.fromEnvelope).toBe(false); expect(result.settings.defaultVolume).toBe(0.3); - for (const key of EXCLUDED_KEYS) { + for (const key of VAULT_LOCAL_KEYS) { expect(result.settings).not.toHaveProperty(key as string); } } @@ -503,7 +506,7 @@ describe("parseImport", () => { }); describe("mergeImportedSettings", () => { - it("overrides preferences while preserving excluded runtime state", () => { + it("overrides preferences while preserving this vault's progress", () => { const current = makeSettings({ defaultVolume: 1, playedEpisodes: { diff --git a/src/settingsTransfer.ts b/src/settingsTransfer.ts index 1a63d9ad..d81eda59 100644 --- a/src/settingsTransfer.ts +++ b/src/settingsTransfer.ts @@ -25,13 +25,7 @@ import { isLocalFile } from "./utility/isLocalFile"; export const SETTINGS_EXPORT_TYPE = "podnotes-settings"; export const SETTINGS_EXPORT_VERSION = 2; -/** - * Vault-specific state that must never travel between vaults: the episode->note - * path map, on-disk download bookkeeping and the Local Files list mirrored from - * it, and the currently-playing episode. These are excluded from export and - * dropped on import (so a copied raw `data.json` still imports cleanly). - */ -export const EXCLUDED_KEYS: readonly (keyof IPodNotesSettings)[] = [ +export const VAULT_LOCAL_KEYS: readonly (keyof IPodNotesSettings)[] = [ "podNotes", "downloadedEpisodes", "localFiles", @@ -68,11 +62,11 @@ export function describeSecrets(secrets: CredentialValues): string[] { .map((key) => SECRET_KEY_LABELS[key]); } -/** Known top-level setting keys that are safe to import (excludes runtime state). */ +/** Known top-level setting keys that are safe to import (excludes vault-local state). */ const IMPORTABLE_KEYS = new Set( Object.keys(DEFAULT_SETTINGS).filter( (key) => - !EXCLUDED_KEYS.includes(key as keyof IPodNotesSettings) && + !VAULT_LOCAL_KEYS.includes(key as keyof IPodNotesSettings) && !SECRET_REFERENCE_KEYS.has(key as keyof IPodNotesSettings), ), ); @@ -116,14 +110,9 @@ export type ParseResult = } | { ok: false; error: string }; -/** - * The part of a settings payload that travels between vaults. SecretStorage - * references are device-local: an imported urlSecretId is at best meaningless - * on this device, so a private feed keeps only its placeholder and is re-added - * on the target device. A local file's episode points into this vault. - */ function portable(settings: T): T { const out = { ...settings } as Record; + // SecretStorage is device-local: an imported urlSecretId is at best meaningless here. if (isPlainObject(out.savedFeeds)) { out.savedFeeds = mapRecord(out.savedFeeds, (feed) => isPlainObject(feed) ? { ...feed, urlSecretId: undefined } : feed, @@ -148,12 +137,6 @@ function withoutLocalFiles(playlist: unknown): unknown { : playlist; } -/** - * Build a versioned export envelope from the live settings, copying only - * allow-listed keys by name (never spreading the whole object). Runtime state and - * SecretStorage references are always excluded. Plaintext values appear only in - * the separate payload explicitly passed by the caller. - */ export function serializeSettings( settings: IPodNotesSettings, opts: ExportOptions, @@ -277,8 +260,8 @@ export function parseImport(jsonText: string): ParseResult { } /** - * Merge validated imported settings over the current settings. Runtime state - * (excluded keys) is untouched because it is never present in `imported`. Nested + * Merge validated imported settings over the current settings. Vault-local + * state is untouched because it is never present in `imported`. Nested * objects are backfilled from defaults so a partial/hand-edited file cannot blank * out fields the rest of the plugin assumes exist (e.g. `timestamp.template`). */ @@ -289,7 +272,6 @@ export function mergeImportedSettings( const merged: IPodNotesSettings = { ...current, ...imported, - // Progress this vault already has wins; the import adds the rest. playedEpisodes: { ...imported.playedEpisodes, ...current.playedEpisodes }, }; diff --git a/src/ui/settings/PodNotesSettingsTab.transfer.test.ts b/src/ui/settings/PodNotesSettingsTab.transfer.test.ts index db49a999..59e43508 100644 --- a/src/ui/settings/PodNotesSettingsTab.transfer.test.ts +++ b/src/ui/settings/PodNotesSettingsTab.transfer.test.ts @@ -29,8 +29,15 @@ function episode(podcastName: string, title: string, feedUrl?: string) { }; } -/** A loaded plugin whose data.json is `disk()`, importing files through the confirm dialog. */ -function setup(current: Partial = {}) { +function played(title: string, time: number) { + return { title, podcastName: "Show", time, duration: 60, finished: false }; +} + +function setup( + current: Partial = {}, + beforeWrite: (write: number) => Promise = async () => {}, +) { + let writes = 0; const settings = { ...structuredClone(DEFAULT_SETTINGS), ...structuredClone(current) }; let disk: Record = { ...structuredClone(settings), schemaVersion: 2 }; const plugin = Object.create(PodNotes.prototype) as PodNotes; @@ -51,6 +58,7 @@ function setup(current: Partial = {}) { manifest: { dir: ".obsidian/plugins/podnotes" }, loadData: vi.fn(async () => structuredClone(disk)), saveData: async (snapshot: Record) => { + await beforeWrite(++writes); disk = structuredClone(snapshot); }, }); @@ -129,13 +137,6 @@ describe("importing a settings file", () => { }); it("brings progress for episodes this vault has not played and keeps its own", async () => { - const played = (title: string, time: number) => ({ - title, - podcastName: "Show", - time, - duration: 60, - finished: false, - }); const { disk, chooseFile } = setup({ playedEpisodes: { "Show::Ep 1": played("Ep 1", 10) }, }); @@ -154,6 +155,33 @@ describe("importing a settings file", () => { expect(get(playedEpisodes)).toEqual(expected); }); + it("keeps imported progress when the player saves a position during the import", async () => { + let releaseFirstWrite = () => {}; + const firstWrite = new Promise((resolve) => (releaseFirstWrite = resolve)); + let writing = false; + const { disk, chooseFile } = setup( + { playedEpisodes: { "Show::Ep 1": played("Ep 1", 10) } }, + async (write) => { + writing = true; + if (write === 1) await firstWrite; + }, + ); + + const importing = chooseFile({ + ...structuredClone(DEFAULT_SETTINGS), + schemaVersion: 2, + playedEpisodes: { "Show::Ep 2": played("Ep 2", 20) }, + }).confirm(); + await vi.waitFor(() => expect(writing).toBe(true)); + playedEpisodes.setEpisodeTime(episode("Show", "Ep 1") as never, 15, 60, false); + releaseFirstWrite(); + await importing; + + const expected = { "Show::Ep 1": played("Ep 1", 15), "Show::Ep 2": played("Ep 2", 20) }; + expect(get(playedEpisodes)).toEqual(expected); + expect(disk().playedEpisodes).toEqual(expected); + }); + it("names the queue and favorites it replaces before importing", () => { const { chooseFile } = setup({ queue: { ...DEFAULT_SETTINGS.queue, episodes: [episode("Show", "Queued")] as never }, diff --git a/src/ui/settings/PodNotesSettingsTab.ts b/src/ui/settings/PodNotesSettingsTab.ts index 922c9f87..494074b4 100644 --- a/src/ui/settings/PodNotesSettingsTab.ts +++ b/src/ui/settings/PodNotesSettingsTab.ts @@ -45,7 +45,6 @@ import { parseImport, serializeSettings, } from "src/settingsTransfer"; -import { normalizePlaybackRate } from "src/utility/playbackRate"; import { DEFAULT_SPEAKER_TEMPLATE, type DiarizationProviderId } from "src/services/diarization"; import type { CredentialKind, CredentialValues } from "src/types/Credentials"; import { observePersistedStoreChanges } from "src/store/persistence"; @@ -875,11 +874,6 @@ export class PodNotesSettingsTab extends PluginSettingTab { return; } - // Warn about a collection only when the user CURRENTLY has data there AND - // the import carries that collection (so it will be replaced wholesale - - // including by an explicitly empty collection, which silently wiped feeds - // with no warning before). Keying off the imported data's contents alone - // missed exactly that data-loss case (SA-09). const { settings } = this.plugin; const sections = Object.entries({ savedFeeds: settings.savedFeeds, @@ -951,6 +945,7 @@ export class PodNotesSettingsTab extends PluginSettingTab { mergeImportedSettings(previous, { ...imported, ...secretReferences }), this.plugin.feedUrls, ).settings; + const progress = merged.playedEpisodes; const failedCandidate = structuredClone(merged); const concurrentStoreChanges = observePersistedStoreChanges(); this.plugin.settings = merged; @@ -1002,14 +997,12 @@ export class PodNotesSettingsTab extends PluginSettingTab { playlists.set(merged.playlists); favorites.set(merged.favorites); queue.set(merged.queue); - playedEpisodes.set(merged.playedEpisodes); + // The player may have saved a position since the merge: keep it, add the rest. + playedEpisodes.update((live) => ({ ...progress, ...live })); hidePlayedEpisodes.set(merged.hidePlayedEpisodes); - const sanitizedLimit = sanitizeEpisodeListLimit(merged.episodeListLimit); - merged.episodeListLimit = sanitizedLimit; - episodeListLimit.set(sanitizedLimit); - const importedVolume = Number.isFinite(merged.defaultVolume) ? merged.defaultVolume : 1; - volume.set(Math.min(1, Math.max(0, importedVolume))); - playbackRate.set(normalizePlaybackRate(merged.defaultPlaybackRate)); + episodeListLimit.set(merged.episodeListLimit); + volume.set(merged.defaultVolume); + playbackRate.set(merged.defaultPlaybackRate); try { // Store setters can canonicalize or deduplicate their slices. Await one From bfcc5cb45e3357cae4920f1942b0c01870aef0b2 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 12 Oct 2026 13:11:56 +0200 Subject: [PATCH 4/7] fix: replace any private feed URL in episode snapshots, not only ones this device can resolve An episode snapshot's feedUrl, and its url when it fell back to the feed URL, becomes its podcast's placeholder whenever it is credential-bearing. This needs no secret on this device, so importing a backup that an older build already leaked into no longer writes the URL, and snapshots of unsubscribed private feeds are cleaned too. The load notice now reads accurately when a load only repairs snapshots. --- src/main.ts | 2 +- src/services/privateFeeds.test.ts | 11 +++++ src/services/privateFeeds.ts | 45 +++++++++---------- .../PodNotesSettingsTab.transfer.test.ts | 22 +++++++++ 4 files changed, 55 insertions(+), 25 deletions(-) diff --git a/src/main.ts b/src/main.ts index f8bcaf04..692afb51 100644 --- a/src/main.ts +++ b/src/main.ts @@ -563,7 +563,7 @@ export default class PodNotes extends Plugin implements IPodNotes { if (feedMigration.settings !== settings) { settings = feedMigration.settings; await this.writeDataFile(encodePodNotesData(settings, decoded.unknownFields)); - new Notice("Moved private feed URLs into Obsidian SecretStorage."); + new Notice("Moved private feed URLs out of data.json."); } if (feedMigration.failed.length > 0) { new Notice( diff --git a/src/services/privateFeeds.test.ts b/src/services/privateFeeds.test.ts index d689a048..bc123d71 100644 --- a/src/services/privateFeeds.test.ts +++ b/src/services/privateFeeds.test.ts @@ -207,6 +207,17 @@ describe("protectPrivateFeeds", () => { expect(safe.savedFeeds.Patreon).toEqual(saved); }); + it("keeps an episode's own link even when it carries a token", () => { + const { feedUrls } = repository(); + const link = "https://cdn.example/post?token=abc"; + const linked = { ...episode("Ep 2", "podnotes-private-feed:Patreon"), url: link }; + const { settings: safe } = protectPrivateFeeds( + settings({ queue: { ...DEFAULT_SETTINGS.queue, episodes: [linked] } }), + feedUrls, + ); + expect(safe.queue.episodes[0].url).toBe(link); + }); + it("reports feeds whose URL could not be protected", () => { const { api, feedUrls } = repository(); (api.setSecret as ReturnType).mockImplementation(() => { diff --git a/src/services/privateFeeds.ts b/src/services/privateFeeds.ts index a65ce437..f504e733 100644 --- a/src/services/privateFeeds.ts +++ b/src/services/privateFeeds.ts @@ -91,46 +91,43 @@ export function protectPrivateFeeds( settings: IPodNotesSettings, feedUrls: FeedUrlRepository, ): PrivateFeedProtection { - const replacements = new Map(); const failed: string[] = []; const savedFeeds: Record = {}; let moved = false; for (const [key, feed] of Object.entries(settings.savedFeeds)) { const interned = internPrivateFeed(feed, feedUrls, key); - if (interned !== feed) { - moved = true; - replacements.set(feed.url, interned.url); - } else if (isCredentialBearingUrl(feed.url)) { - failed.push(key); - } - // Also replace a protected feed's URL that an older import left in snapshots. - const url = interned.urlSecretId && feedUrls.resolve(interned.urlSecretId); - if (url) replacements.set(url, interned.url); + if (interned !== feed) moved = true; + else if (isCredentialBearingUrl(feed.url)) failed.push(key); savedFeeds[key] = interned; } - return { - settings: scrubEpisodeUrls(moved ? { ...settings, savedFeeds } : settings, replacements), - failed, - }; + return { settings: scrubEpisodeUrls(moved ? { ...settings, savedFeeds } : settings), failed }; } -/** Returns `value` itself when no URL changed, so callers can tell whether to save. */ -function scrubEpisodeUrls(value: T, replacements: Map): T { - if (replacements.size === 0 || typeof value !== "object" || value === null) return value; +/** + * Episode snapshots stamp their feed's URL, and an episode without a link of + * its own takes the feed URL as `url` too. A private one becomes its podcast's + * placeholder, whether or not this device holds the secret. Returns `value` + * itself when nothing changed, so callers can tell whether to save. + */ +function scrubEpisodeUrls(value: T): T { + if (typeof value !== "object" || value === null) return value; const isArray = Array.isArray(value); // Decoded settings carry Date instances (episodeDate); recursing into one // would flatten it into {}. Non-plain objects hold no episode URLs. if (!isArray && Object.getPrototypeOf(value) !== Object.prototype) return value; + const record = value as Record; let changed = false; const next = (isArray ? [] : {}) as Record; - for (const [key, entry] of Object.entries(value)) { - let scrubbed: unknown; - if ((key === "url" || key === "feedUrl") && typeof entry === "string") { - scrubbed = replacements.get(entry) ?? entry; - } else { - scrubbed = scrubEpisodeUrls(entry, replacements); - } + for (const [key, entry] of Object.entries(record)) { + const scrubbed = + (key === "url" || key === "feedUrl") && + typeof entry === "string" && + entry === record.feedUrl && + typeof record.podcastName === "string" && + isCredentialBearingUrl(entry) + ? privateFeedPlaceholder(record.podcastName) + : scrubEpisodeUrls(entry); if (scrubbed !== entry) changed = true; next[key] = scrubbed; } diff --git a/src/ui/settings/PodNotesSettingsTab.transfer.test.ts b/src/ui/settings/PodNotesSettingsTab.transfer.test.ts index 59e43508..f2d781e8 100644 --- a/src/ui/settings/PodNotesSettingsTab.transfer.test.ts +++ b/src/ui/settings/PodNotesSettingsTab.transfer.test.ts @@ -114,6 +114,28 @@ describe("importing a settings file", () => { expect(disk().queue.episodes[0].feedUrl).toBe("podnotes-private-feed:Private"); }); + it("never writes a private feed URL that an older build left in the imported file", async () => { + const { disk, chooseFile } = setup(); + const leaked = episode("Private", "Private Episode", privateUrl); + + await chooseFile({ + ...structuredClone(DEFAULT_SETTINGS), + schemaVersion: 2, + savedFeeds: { + Private: { + title: "Private", + url: "podnotes-private-feed:Private", + urlSecretId: "podnotes-feed-url-00000000-0000-4000-8000-000000000000", + artworkUrl: "", + }, + }, + queue: { ...DEFAULT_SETTINGS.queue, episodes: [leaked] }, + }).confirm(); + + expect(JSON.stringify(disk())).not.toContain("s3cret"); + expect(disk().queue.episodes[0].feedUrl).toBe("podnotes-private-feed:Private"); + }); + it("keeps this vault's local files and never brings another vault's", async () => { const mine = { ...episode("local file", "Mine"), filePath: "Audio/Mine.mp3" }; const theirs = { ...episode("local file", "Theirs"), filePath: "Elsewhere/Theirs.mp3" }; From 3d5f9b01aacfd66757d3d3291d211de9f7efde85 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 12 Oct 2026 13:30:54 +0200 Subject: [PATCH 5/7] fix: show one notice when a private feed cannot be protected A load that cleans episode snapshots while a feed's URL still could not move into SecretStorage showed both the move notice and the failure notice. It now shows only the failure notice. A test pins that such a feed keeps its raw URL while its snapshots get the placeholder. --- src/main.ts | 4 +++- src/services/privateFeeds.test.ts | 14 +++++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/main.ts b/src/main.ts index cc2f89db..43534c78 100644 --- a/src/main.ts +++ b/src/main.ts @@ -542,7 +542,9 @@ export default class PodNotes extends Plugin implements IPodNotes { if (feedMigration.settings !== settings) { settings = feedMigration.settings; await this.writeDataFile(encodePodNotesData(settings, decoded.unknownFields)); - new Notice("Moved private feed URLs out of data.json."); + if (feedMigration.failed.length === 0) { + new Notice("Moved private feed URLs out of data.json."); + } } if (feedMigration.failed.length > 0) { new Notice( diff --git a/src/services/privateFeeds.test.ts b/src/services/privateFeeds.test.ts index bc123d71..932d30f7 100644 --- a/src/services/privateFeeds.test.ts +++ b/src/services/privateFeeds.test.ts @@ -223,11 +223,15 @@ describe("protectPrivateFeeds", () => { (api.setSecret as ReturnType).mockImplementation(() => { throw new Error("locked"); }); - const unprotected = settings({ - savedFeeds: { Patreon: feed({ title: "Patreon", url: PRIVATE_URL }) }, - }); - const { failed, settings: result } = protectPrivateFeeds(unprotected, feedUrls); + const { failed, settings: result } = protectPrivateFeeds( + settings({ + savedFeeds: { Patreon: feed({ title: "Patreon", url: PRIVATE_URL }) }, + queue: { ...DEFAULT_SETTINGS.queue, episodes: [episode("Ep 2", PRIVATE_URL)] }, + }), + feedUrls, + ); expect(failed).toEqual(["Patreon"]); - expect(result).toBe(unprotected); + expect(result.savedFeeds.Patreon.url).toBe(PRIVATE_URL); + expect(result.queue.episodes[0].feedUrl).toBe("podnotes-private-feed:Patreon"); }); }); From 177c49e113c42c4cd7a4339b37df2106f205ac1d Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 12 Oct 2026 14:13:51 +0200 Subject: [PATCH 6/7] test: reproduce the V-404 findings in private feed protection and transfers Each fails on 3d5f9b0: - an episode without feedUrl keeps a saved private feed's URL - a feed saved under a key other than its title gets episode placeholders named by the title, which links cannot resolve - an import whose private feed URL SecretStorage cannot keep reports success and persists the URL - a playlist's current episode and progress records carry vault paths out of an export and into an import - a private feed synced to disk while an import saves is written raw --- src/services/privateFeeds.test.ts | 45 ++++++++++++++ src/settingsTransfer.test.ts | 33 ++++++++++ .../PodNotesSettingsTab.transfer.test.ts | 60 ++++++++++++++++++- 3 files changed, 136 insertions(+), 2 deletions(-) diff --git a/src/services/privateFeeds.test.ts b/src/services/privateFeeds.test.ts index 932d30f7..60dfdfe4 100644 --- a/src/services/privateFeeds.test.ts +++ b/src/services/privateFeeds.test.ts @@ -207,6 +207,51 @@ describe("protectPrivateFeeds", () => { expect(safe.savedFeeds.Patreon).toEqual(saved); }); + it("replaces the feed URL in an episode that has no feedUrl", () => { + const { feedUrls } = repository(); + const linkOnly: Partial> = { + ...episode("Ep 2", ""), + url: PRIVATE_URL, + }; + delete linkOnly.feedUrl; + const { settings: safe } = protectPrivateFeeds( + settings({ + savedFeeds: { Patreon: feed({ title: "Patreon", url: PRIVATE_URL }) }, + queue: { ...DEFAULT_SETTINGS.queue, episodes: [linkOnly as never] }, + }), + feedUrls, + ); + expect(JSON.stringify(safe)).not.toContain("se-cret"); + expect(safe.queue.episodes[0].url).toBe("podnotes-private-feed:Patreon"); + }); + + it("names a feed saved under another key by that key in its episodes", () => { + const { feedUrls } = repository(); + const id = feedUrls.store(PRIVATE_URL); + const aliased = { ...episode("Ep 2", PRIVATE_URL), podcastName: "Channel Title" }; + const queue = { ...DEFAULT_SETTINGS.queue, episodes: [aliased] }; + const raw = protectPrivateFeeds( + settings({ + savedFeeds: { "Saved Key": feed({ title: "Channel Title", url: PRIVATE_URL }) }, + queue, + }), + feedUrls, + ).settings; + const placeholder = "podnotes-private-feed:Saved%20Key"; + const protectedHere = feed({ title: "Channel Title", url: placeholder, urlSecretId: id }); + const strandedHere = feed({ title: "Channel Title", url: placeholder }); + + expect(raw.savedFeeds["Saved Key"].url).toBe(placeholder); + expect(raw.queue.episodes[0].feedUrl).toBe(placeholder); + for (const saved of [protectedHere, strandedHere]) { + const repaired = protectPrivateFeeds( + settings({ savedFeeds: { "Saved Key": saved }, queue }), + feedUrls, + ).settings; + expect(repaired.queue.episodes[0].feedUrl).toBe(placeholder); + } + }); + it("keeps an episode's own link even when it carries a token", () => { const { feedUrls } = repository(); const link = "https://cdn.example/post?token=abc"; diff --git a/src/settingsTransfer.test.ts b/src/settingsTransfer.test.ts index 01288b4c..f5f157e4 100644 --- a/src/settingsTransfer.test.ts +++ b/src/settingsTransfer.test.ts @@ -182,6 +182,39 @@ describe("serializeSettings", () => { expect(exported.playlists?.Mix.episodes.map(({ title }) => title)).toEqual(["Ep 1"]); }); + it("carries no vault path in a playlist's current episode or in progress records", () => { + const memo = { + title: "Memo", + streamUrl: "app://local/vault/Audio/Memo.mp3", + url: "[[Audio/Memo.mp3]]", + description: "", + content: "", + podcastName: "local file", + filePath: "Audio/Memo.mp3", + }; + const progress = { + title: "Memo", + podcastName: "local file", + time: 3, + duration: 9, + finished: false, + }; + const settings = makeSettings({ + queue: { ...DEFAULT_SETTINGS.queue, currentEpisode: memo }, + favorites: { ...DEFAULT_SETTINGS.favorites, currentEpisode: memo }, + playlists: { Mix: { ...DEFAULT_SETTINGS.queue, name: "Mix", currentEpisode: memo } }, + playedEpisodes: { "local file::Memo": { ...progress, ...memo } as never }, + }); + + const { settings: exported } = serializeSettings(settings, {}, "2.16.0", NOW); + const imported = parseImport(JSON.stringify(settings)); + + for (const transferred of [exported, imported.ok ? imported.settings : {}]) { + expect(JSON.stringify(transferred)).not.toContain("Audio/Memo.mp3"); + expect(transferred.playedEpisodes).toEqual({ "local file::Memo": progress }); + } + }); + it("carries playback progress", () => { const progress = { title: "Ep 1", diff --git a/src/ui/settings/PodNotesSettingsTab.transfer.test.ts b/src/ui/settings/PodNotesSettingsTab.transfer.test.ts index 438a8b87..c3791c9c 100644 --- a/src/ui/settings/PodNotesSettingsTab.transfer.test.ts +++ b/src/ui/settings/PodNotesSettingsTab.transfer.test.ts @@ -83,17 +83,21 @@ function setup( JSON.stringify(contents), ); const dialog = within(document.body.lastElementChild as HTMLElement); + const click = () => dialog.getByRole("button", { name: "Import" }).click(); return { message: dialog.getByRole("heading").nextElementSibling?.textContent ?? "", + click, async confirm() { - dialog.getByRole("button", { name: "Import" }).click(); + click(); await vi.waitFor(() => expect(notice).toHaveBeenCalledWith("Imported PodNotes settings."), ); }, }; } - return { disk: () => disk as unknown as IPodNotesSettings, chooseFile }; + const sync = (change: Partial) => + Object.assign(disk, structuredClone(change)); + return { plugin, notice, sync, disk: () => disk as unknown as IPodNotesSettings, chooseFile }; } describe("importing a settings file", () => { @@ -117,6 +121,58 @@ describe("importing a settings file", () => { expect(disk().queue.episodes[0].feedUrl).toBe("podnotes-private-feed:Private"); }); + it("protects a private feed that another device synced while the import was saving", async () => { + const { disk, sync, chooseFile } = setup(); + sync({ + savedFeeds: { Synced: { title: "Synced", url: privateUrl, artworkUrl: "" } }, + queue: { + ...DEFAULT_SETTINGS.queue, + episodes: [episode("Synced", "Ep", privateUrl) as never], + }, + }); + + await chooseFile({ + ...structuredClone(DEFAULT_SETTINGS), + schemaVersion: 2, + defaultVolume: 0.5, + }).confirm(); + + expect(JSON.stringify(disk())).not.toContain("s3cret"); + expect(disk().savedFeeds.Synced.url).toBe("podnotes-private-feed:Synced"); + expect(disk().queue.episodes[0].feedUrl).toBe("podnotes-private-feed:Synced"); + }); + + it("refuses an import whose private feed URL SecretStorage cannot keep", async () => { + const { plugin, notice, disk, chooseFile } = setup(); + const before = { settings: plugin.settings, disk: JSON.stringify(disk()) }; + const setSecret = vi + .spyOn(obsidian.SecretStorage.prototype, "setSecret") + .mockImplementation(() => { + throw new Error("locked"); + }); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + onTestFinished(() => { + setSecret.mockRestore(); + consoleError.mockRestore(); + }); + + chooseFile({ + ...structuredClone(DEFAULT_SETTINGS), + schemaVersion: 2, + savedFeeds: { Private: { title: "Private", url: privateUrl, artworkUrl: "" } }, + }).click(); + + await vi.waitFor(() => + expect(notice).toHaveBeenCalledWith( + "Could not finish importing secrets into Obsidian SecretStorage. Existing settings were kept, and retrying is safe.", + 10000, + ), + ); + expect(notice).not.toHaveBeenCalledWith("Imported PodNotes settings."); + expect(plugin.settings).toBe(before.settings); + expect(JSON.stringify(disk())).toBe(before.disk); + }); + it("never writes a private feed URL that an older build left in the imported file", async () => { const { disk, chooseFile } = setup(); const leaked = episode("Private", "Private Episode", privateUrl); From 5c1b4689dabdd95d847d5347fdc35b7a2e232986 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Mon, 12 Oct 2026 14:29:37 +0200 Subject: [PATCH 7/7] fix: close the V-404 gaps in private feed protection and vault-path transfer - A private feed's real URL maps to the placeholder that names its saved key, and that mapping applies to an episode's url and feedUrl, so an episode without feedUrl loses the URL too and links still resolve when the saved key differs from the title. A private URL no saved feed matches still becomes its podcast's placeholder. - An import whose private feed URL SecretStorage cannot keep is refused before anything is written, with one notice shared with the API-key failure. - Settings transfers drop a playlist's current episode, which no player reads, and keep only title, podcast, time, duration and finished in progress records, so neither carries a vault path. - The writer protects the data.json it reads before merging it, so a private feed another device synced is never written back raw. - Both export error handlers show the error itself instead of a reworded copy for a missing folder. --- src/main.ts | 3 +- src/opml.ts | 13 ++---- src/services/privateFeeds.test.ts | 13 +++--- src/services/privateFeeds.ts | 45 ++++++++++++------- src/settingsTransfer.ts | 38 +++++++++++----- .../PodNotesSettingsTab.import.test.ts | 5 +-- src/ui/settings/PodNotesSettingsTab.ts | 33 +++++++------- 7 files changed, 84 insertions(+), 66 deletions(-) diff --git a/src/main.ts b/src/main.ts index 43534c78..20e0c511 100644 --- a/src/main.ts +++ b/src/main.ts @@ -677,8 +677,9 @@ export default class PodNotes extends Plugin implements IPodNotes { throw new Error(`PodNotes could not read ${path} without repairing it`); } this.liftUnreadableRefusal(); + // A private feed synced here raw is protected before it can be merged and written. const doc: Record = toDocument( - decoded.settings, + protectPrivateFeeds(decoded.settings, this.feedUrls).settings, decoded.unknownFields, ); // An entry the codec dropped while repairing a value (an invalid episode, a diff --git a/src/opml.ts b/src/opml.ts index 98c507f1..29bdf6e0 100644 --- a/src/opml.ts +++ b/src/opml.ts @@ -167,16 +167,9 @@ async function exportOPML(app: App, feeds: PodcastFeed[], filePath = "PodNotes_E ); } } catch (error) { - if (error instanceof Error) { - if (error.message.includes("Folder does not exist")) { - new Notice("Unable to create export file: Folder does not exist."); - } else { - new Notice(`Unable to create podcast export file:\n\n${error.message}`); - } - } else { - new Notice("An unexpected error occurred during export."); - } - + new Notice( + `Unable to create podcast export file:\n\n${error instanceof Error ? error.message : "Unknown error"}`, + ); console.error(error); } } diff --git a/src/services/privateFeeds.test.ts b/src/services/privateFeeds.test.ts index 60dfdfe4..fa851adf 100644 --- a/src/services/privateFeeds.test.ts +++ b/src/services/privateFeeds.test.ts @@ -239,17 +239,14 @@ describe("protectPrivateFeeds", () => { ).settings; const placeholder = "podnotes-private-feed:Saved%20Key"; const protectedHere = feed({ title: "Channel Title", url: placeholder, urlSecretId: id }); - const strandedHere = feed({ title: "Channel Title", url: placeholder }); + const repaired = protectPrivateFeeds( + settings({ savedFeeds: { "Saved Key": protectedHere }, queue }), + feedUrls, + ).settings; expect(raw.savedFeeds["Saved Key"].url).toBe(placeholder); expect(raw.queue.episodes[0].feedUrl).toBe(placeholder); - for (const saved of [protectedHere, strandedHere]) { - const repaired = protectPrivateFeeds( - settings({ savedFeeds: { "Saved Key": saved }, queue }), - feedUrls, - ).settings; - expect(repaired.queue.episodes[0].feedUrl).toBe(placeholder); - } + expect(repaired.queue.episodes[0].feedUrl).toBe(placeholder); }); it("keeps an episode's own link even when it carries a token", () => { diff --git a/src/services/privateFeeds.ts b/src/services/privateFeeds.ts index f504e733..8db34744 100644 --- a/src/services/privateFeeds.ts +++ b/src/services/privateFeeds.ts @@ -93,23 +93,42 @@ export function protectPrivateFeeds( ): PrivateFeedProtection { const failed: string[] = []; const savedFeeds: Record = {}; + // A private feed's real URL maps to the placeholder naming its saved key, + // which links resolve by. + const byUrl = new Map(); let moved = false; for (const [key, feed] of Object.entries(settings.savedFeeds)) { const interned = internPrivateFeed(feed, feedUrls, key); if (interned !== feed) moved = true; else if (isCredentialBearingUrl(feed.url)) failed.push(key); + if (isPrivateFeedPlaceholder(interned.url)) { + byUrl.set(feed.url, interned.url); + const url = interned.urlSecretId && feedUrls.resolve(interned.urlSecretId); + if (url) byUrl.set(url, interned.url); + } savedFeeds[key] = interned; } - return { settings: scrubEpisodeUrls(moved ? { ...settings, savedFeeds } : settings), failed }; + // Snapshots stamp `feedUrl`, and an episode without a link of its own takes + // the feed URL as `url`. A private URL no saved feed matches still goes: its + // podcast's placeholder stands for it. + const placeholderFor = (url: string, episode: Record) => + byUrl.get(url) ?? + (url === episode.feedUrl && + typeof episode.podcastName === "string" && + isCredentialBearingUrl(url) + ? privateFeedPlaceholder(episode.podcastName) + : url); + return { + settings: scrubEpisodeUrls(moved ? { ...settings, savedFeeds } : settings, placeholderFor), + failed, + }; } -/** - * Episode snapshots stamp their feed's URL, and an episode without a link of - * its own takes the feed URL as `url` too. A private one becomes its podcast's - * placeholder, whether or not this device holds the secret. Returns `value` - * itself when nothing changed, so callers can tell whether to save. - */ -function scrubEpisodeUrls(value: T): T { +/** Returns `value` itself when no URL changed, so callers can tell whether to save. */ +function scrubEpisodeUrls( + value: T, + placeholderFor: (url: string, episode: Record) => string, +): T { if (typeof value !== "object" || value === null) return value; const isArray = Array.isArray(value); // Decoded settings carry Date instances (episodeDate); recursing into one @@ -121,13 +140,9 @@ function scrubEpisodeUrls(value: T): T { const next = (isArray ? [] : {}) as Record; for (const [key, entry] of Object.entries(record)) { const scrubbed = - (key === "url" || key === "feedUrl") && - typeof entry === "string" && - entry === record.feedUrl && - typeof record.podcastName === "string" && - isCredentialBearingUrl(entry) - ? privateFeedPlaceholder(record.podcastName) - : scrubEpisodeUrls(entry); + (key === "url" || key === "feedUrl") && typeof entry === "string" + ? placeholderFor(entry, record) + : scrubEpisodeUrls(entry, placeholderFor); if (scrubbed !== entry) changed = true; next[key] = scrubbed; } diff --git a/src/settingsTransfer.ts b/src/settingsTransfer.ts index d81eda59..e27a7d70 100644 --- a/src/settingsTransfer.ts +++ b/src/settingsTransfer.ts @@ -119,22 +119,40 @@ function portable(settings: T): T { ); } for (const key of ["queue", "favorites"]) { - if (key in out) out[key] = withoutLocalFiles(out[key]); + if (key in out) out[key] = portablePlaylist(out[key]); + } + if (isPlainObject(out.playlists)) out.playlists = mapRecord(out.playlists, portablePlaylist); + // Older versions kept whole episodes, local paths included, in progress records. + if (isPlainObject(out.playedEpisodes)) { + out.playedEpisodes = mapRecord(out.playedEpisodes, (record) => + isPlainObject(record) + ? { + title: record.title, + podcastName: record.podcastName, + time: record.time, + duration: record.duration, + finished: record.finished, + } + : record, + ); } - if (isPlainObject(out.playlists)) out.playlists = mapRecord(out.playlists, withoutLocalFiles); return out as T; } -function withoutLocalFiles(playlist: unknown): unknown { - return isPlainObject(playlist) && Array.isArray(playlist.episodes) - ? { - ...playlist, - episodes: playlist.episodes.filter( +/** A playlist without local files. Its current episode is dropped too: no player reads it. */ +function portablePlaylist(playlist: unknown): unknown { + if (!isPlainObject(playlist)) return playlist; + const { episodes } = playlist; + return { + ...playlist, + currentEpisode: undefined, + episodes: Array.isArray(episodes) + ? episodes.filter( (episode) => !isPlainObject(episode) || !isLocalFile(episode as unknown as Episode), - ), - } - : playlist; + ) + : episodes, + }; } export function serializeSettings( diff --git a/src/ui/settings/PodNotesSettingsTab.import.test.ts b/src/ui/settings/PodNotesSettingsTab.import.test.ts index f1b21d3f..f68598c5 100644 --- a/src/ui/settings/PodNotesSettingsTab.import.test.ts +++ b/src/ui/settings/PodNotesSettingsTab.import.test.ts @@ -184,10 +184,7 @@ describe("PodNotesSettingsTab settings import", () => { expect(plugin.settings).toBe(previous); expect(plugin.saveSettingsStrict).not.toHaveBeenCalled(); - expect(notice).toHaveBeenCalledWith( - expect.stringContaining("retrying will safely reuse"), - 10000, - ); + expect(notice).toHaveBeenCalledWith(expect.stringContaining("retrying is safe"), 10000); }); it("restores previous secret references when the settings save fails", async () => { diff --git a/src/ui/settings/PodNotesSettingsTab.ts b/src/ui/settings/PodNotesSettingsTab.ts index 449e63ef..3e0d5f1a 100644 --- a/src/ui/settings/PodNotesSettingsTab.ts +++ b/src/ui/settings/PodNotesSettingsTab.ts @@ -854,13 +854,9 @@ export class PodNotesSettingsTab extends PluginSettingTab { : `Exported PodNotes settings to "${fileName}".`, ); } catch (error) { - if (error instanceof Error && error.message.includes("Folder does not exist")) { - new Notice("Unable to create export file: folder does not exist."); - } else { - new Notice( - `Unable to export settings:\n\n${error instanceof Error ? error.message : "Unknown error"}`, - ); - } + new Notice( + `Unable to export settings:\n\n${error instanceof Error ? error.message : "Unknown error"}`, + ); console.error("PodNotes: failed to export settings", error); } } @@ -925,24 +921,25 @@ export class PodNotesSettingsTab extends PluginSettingTab { ): Promise { const previousSettings = this.plugin.settings; const previous = structuredClone(previousSettings); - let secretReferences: Partial = {}; + let merged: IPodNotesSettings; try { - secretReferences = this.plugin.credentials.storeValues(secrets); + const secretReferences = this.plugin.credentials.storeValues(secrets); + // A raw data.json import can carry credential-bearing feed URLs; intern + // them BEFORE the strict persist below so the secret never reaches disk. + const protection = protectPrivateFeeds( + mergeImportedSettings(previous, { ...imported, ...secretReferences }), + this.plugin.feedUrls, + ); + if (protection.failed.length > 0) throw new Error(); + merged = protection.settings; } catch (error) { new Notice( - "Could not finish importing API keys into Obsidian SecretStorage. Existing settings were kept, and retrying will safely reuse any PodNotes secrets already created.", + "Could not finish importing secrets into Obsidian SecretStorage. Existing settings were kept, and retrying is safe.", 10000, ); - console.error("PodNotes: failed to import credentials into SecretStorage", error); + console.error("PodNotes: failed to import secrets into SecretStorage", error); return "failed"; } - - // A raw data.json import can carry credential-bearing feed URLs; intern - // them BEFORE the strict persist below so the secret never reaches disk. - const merged = protectPrivateFeeds( - mergeImportedSettings(previous, { ...imported, ...secretReferences }), - this.plugin.feedUrls, - ).settings; const progress = merged.playedEpisodes; const failedCandidate = structuredClone(merged); const concurrentStoreChanges = observePersistedStoreChanges();