diff --git a/scripts/provision-obsidian-e2e-vault.mjs b/scripts/provision-obsidian-e2e-vault.mjs index f700cd8..fbf8fc6 100644 --- a/scripts/provision-obsidian-e2e-vault.mjs +++ b/scripts/provision-obsidian-e2e-vault.mjs @@ -21,12 +21,13 @@ const PLUGIN_ID = "podnotes"; // "=> true" so an echoed command can't be mistaken for a positive result. export const PODNOTES_READY_EVAL = `Boolean(app.plugins.plugins[${JSON.stringify(PLUGIN_ID)}])`; -// A valid, empty PodNotes settings document. Mirrors DEFAULT_SETTINGS in -// src/constants.ts so a freshly provisioned vault loads with clean state instead -// of QuickAdd's { choices, migrations } shape. Keep in sync with constants.ts. -// (currentEpisode is intentionally omitted — DEFAULT_SETTINGS sets it to +// A valid, empty PodNotes schema-v1 document. Mirrors DEFAULT_SETTINGS plus the +// persistence marker so a freshly provisioned vault loads with clean state +// instead of QuickAdd's { choices, migrations } shape. Keep in sync with +// constants.ts. (currentEpisode is intentionally omitted - DEFAULT_SETTINGS sets it to // undefined, which JSON cannot represent and PodNotes treats as absent.) export const DEFAULT_PODNOTES_DATA = { + schemaVersion: 1, savedFeeds: {}, podNotes: {}, defaultPlaybackRate: 1, diff --git a/scripts/provision-obsidian-e2e-vault.test.ts b/scripts/provision-obsidian-e2e-vault.test.ts index b95fa44..556e9db 100644 --- a/scripts/provision-obsidian-e2e-vault.test.ts +++ b/scripts/provision-obsidian-e2e-vault.test.ts @@ -49,14 +49,15 @@ describe("provision-obsidian-e2e-vault", () => { expect(options.vaultPath).toBe("/tmp/podnotes-repo/vaults/podnotes-a"); }); - it("seeds a data.json that mirrors the real DEFAULT_SETTINGS", () => { + it("seeds schema v1 data that mirrors the real DEFAULT_SETTINGS", () => { // JSON cannot represent currentEpisode: undefined, so compare the - // serialized forms — this is exactly what lands in the vault's data.json + // serialized forms - this is exactly what lands in the vault's data.json // and it fails if a new setting is added to src/constants.ts without // updating DEFAULT_PODNOTES_DATA. - expect(JSON.parse(JSON.stringify(DEFAULT_PODNOTES_DATA))).toEqual( - JSON.parse(JSON.stringify(DEFAULT_SETTINGS)), - ); + expect(JSON.parse(JSON.stringify(DEFAULT_PODNOTES_DATA))).toEqual({ + schemaVersion: 1, + ...JSON.parse(JSON.stringify(DEFAULT_SETTINGS)), + }); }); it("defaults the vault name to podnotes-", () => { diff --git a/src/TemplateEngine.test.ts b/src/TemplateEngine.test.ts index a540d7c..b5289be 100644 --- a/src/TemplateEngine.test.ts +++ b/src/TemplateEngine.test.ts @@ -164,6 +164,15 @@ describe("empty tag arguments (NT-05/CH-09)", () => { plugin.set({ settings: { feedNote: { path: "" }, savedFeeds: {} } } as never); expect(NoteTemplateEngine("{{date:YYYY}}", demoEpisode)).toBe("2024"); }); + + it("renders a JSON-restored episode date without throwing", () => { + const restoredEpisode = { + ...demoEpisode, + episodeDate: "2024-01-01T00:00:00.000Z" as unknown as Date, + }; + + expect(NoteTemplateEngine("{{date:YYYY-MM-DD}}", restoredEpisode)).toBe("2024-01-01"); + }); }); describe("NoteTemplateEngine feed-scoped tags (#163)", () => { diff --git a/src/main.persistence.test.ts b/src/main.persistence.test.ts new file mode 100644 index 0000000..52a32e7 --- /dev/null +++ b/src/main.persistence.test.ts @@ -0,0 +1,151 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { DEFAULT_SETTINGS } from "./constants"; +import PodNotes from "./main"; + +function makePlugin( + saveData: (data: unknown) => Promise = vi.fn().mockResolvedValue(undefined), +): PodNotes { + const plugin = Object.create(PodNotes.prototype) as PodNotes; + Object.assign(plugin, { + isReady: true, + settings: structuredClone(DEFAULT_SETTINGS), + pendingSave: null, + pendingSaveWaiters: [], + saveScheduled: false, + saveChain: Promise.resolve(), + persistenceUnknownFields: {}, + saveData, + }); + return plugin; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("PodNotes persistence integration", () => { + it("loads legacy JSON dates as Date instances", async () => { + const plugin = makePlugin(); + Object.assign(plugin, { + loadData: vi.fn().mockResolvedValue({ + currentEpisode: { + title: "Restored", + streamUrl: "restored.mp3", + url: "", + description: "", + content: "", + podcastName: "Podcast", + episodeDate: "2024-03-01T10:05:03.000Z", + }, + }), + }); + + await plugin.loadSettings(); + + expect(plugin.settings.currentEpisode?.episodeDate).toEqual( + new Date("2024-03-01T10:05:03.000Z"), + ); + }); + + it("refuses future data before any save can overwrite it", async () => { + const saveData = vi.fn().mockResolvedValue(undefined); + const plugin = makePlugin(saveData); + Object.assign(plugin, { + loadData: vi.fn().mockResolvedValue({ schemaVersion: 2 }), + }); + vi.spyOn(console, "error").mockImplementation(() => undefined); + + await expect(plugin.loadSettings()).rejects.toThrow(/schema v2/); + expect(saveData).not.toHaveBeenCalled(); + }); + + it("writes schema v1, canonical dates, and preserved unknown fields", async () => { + const saveData = vi.fn().mockResolvedValue(undefined); + const plugin = makePlugin(saveData); + plugin.settings.currentEpisode = { + title: "Current", + streamUrl: "current.mp3", + url: "", + description: "", + content: "", + podcastName: "Podcast", + episodeDate: new Date("2024-03-01T10:05:03.000Z"), + }; + Object.assign(plugin, { persistenceUnknownFields: { retained: { enabled: true } } }); + + await plugin.saveSettings(); + + expect(saveData).toHaveBeenCalledWith( + expect.objectContaining({ + schemaVersion: 1, + retained: { enabled: true }, + currentEpisode: expect.objectContaining({ + episodeDate: "2024-03-01T10:05:03.000Z", + }), + }), + ); + }); + + it("rejects a strict caller when saveData fails", async () => { + const failure = new Error("disk full"); + const plugin = makePlugin(vi.fn().mockRejectedValue(failure)); + vi.spyOn(console, "error").mockImplementation(() => undefined); + + await expect(plugin.saveSettingsStrict()).rejects.toBe(failure); + }); + + it("keeps best-effort saves nonrejecting while logging disk failure", async () => { + const failure = new Error("disk full"); + const plugin = makePlugin(vi.fn().mockRejectedValue(failure)); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + + await expect(plugin.saveSettings()).resolves.toBeUndefined(); + expect(consoleError).toHaveBeenCalledWith("PodNotes: failed to save settings", failure); + }); + + it("normalizes a synchronous snapshot failure into the two save contracts", async () => { + const failure = new Error("cannot clone"); + const plugin = makePlugin(); + vi.spyOn(globalThis, "structuredClone").mockImplementation(() => { + throw failure; + }); + vi.spyOn(console, "error").mockImplementation(() => undefined); + + let strict: Promise | undefined; + expect(() => { + strict = plugin.saveSettingsStrict(); + }).not.toThrow(); + await expect(strict).rejects.toBe(failure); + await expect(plugin.saveSettings()).resolves.toBeUndefined(); + }); + + it("keeps later callers pending until their newer snapshot is durable", async () => { + const resolvers: Array<() => void> = []; + const writes: unknown[] = []; + const plugin = makePlugin( + vi.fn((data: unknown) => { + writes.push(data); + return new Promise((resolve) => resolvers.push(resolve)); + }), + ); + + const first = plugin.saveSettingsStrict(); + await vi.waitFor(() => expect(writes).toHaveLength(1)); + plugin.settings.defaultVolume = 0.25; + const second = plugin.saveSettingsStrict(); + let secondResolved = false; + void second.then(() => { + secondResolved = true; + }); + + resolvers[0](); + await first; + await vi.waitFor(() => expect(writes).toHaveLength(2)); + expect(secondResolved).toBe(false); + expect(writes[1]).toEqual(expect.objectContaining({ defaultVolume: 0.25 })); + + resolvers[1](); + await second; + expect(secondResolved).toBe(true); + }); +}); diff --git a/src/main.ts b/src/main.ts index eabfc63..15e4ea2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,7 +9,6 @@ import { queue, savedFeeds, hidePlayedEpisodes, - sanitizeEpisodeListLimit, playbackRate, plugin, volume, @@ -20,14 +19,7 @@ import { registerCommands } from "src/commands"; import { Notice, Platform, Plugin, type Editor, type WorkspaceLeaf } from "obsidian"; import { API } from "src/API/API"; import type { IAPI } from "src/API/IAPI"; -import { DEFAULT_SETTINGS, VIEW_TYPE } from "src/constants"; -import { - migrateDownloadPath, - migrateFeedNoteSettings, - migrateNoteSettings, - migrateSkipLength, - migrateTranscriptSettings, -} from "src/settingsMigrations"; +import { VIEW_TYPE } from "src/constants"; import { PodNotesSettingsTab } from "src/ui/settings/PodNotesSettingsTab"; import { MainView } from "src/ui/PodcastView"; import type { IPodNotesSettings } from "./types/IPodNotesSettings"; @@ -42,6 +34,11 @@ import type { IconType } from "./types/IconType"; import { TranscriptionService } from "./services/TranscriptionService"; import { type Unsubscriber } from "svelte/store"; import { normalizePlaybackRate } from "./utility/playbackRate"; +import { + decodePodNotesData, + encodePodNotesData, + PodNotesDataError, +} from "./persistence/podNotesData"; type MediaSessionActionName = | "previoustrack" @@ -54,6 +51,11 @@ type MediaSessionActionName = | "seekto" | "skipad"; +interface SaveWaiter { + resolve: () => void; + reject: (error: unknown) => void; +} + export default class PodNotes extends Plugin implements IPodNotes { public api!: IAPI; public override settings!: IPodNotesSettings; @@ -76,8 +78,10 @@ export default class PodNotes extends Plugin implements IPodNotes { private podcastViewMountEnabled = true; private isReady = false; private pendingSave: IPodNotesSettings | null = null; + private pendingSaveWaiters: SaveWaiter[] = []; private saveScheduled = false; private saveChain: Promise = Promise.resolve(); + private persistenceUnknownFields: Record = {}; private mediaSessionActions: MediaSessionActionName[] = []; override async onload() { @@ -413,84 +417,83 @@ export default class PodNotes extends Plugin implements IPodNotes { } async loadSettings() { - const loadedData = await this.loadData(); - - this.settings = Object.assign({}, DEFAULT_SETTINGS, loadedData); - this.settings.timestamp = { - ...DEFAULT_SETTINGS.timestamp, - ...loadedData?.timestamp, - }; - // Build a fresh download object so we never mutate the shared - // DEFAULT_SETTINGS.download, then migrate the legacy empty default (#183). - this.settings.download = { - ...DEFAULT_SETTINGS.download, - ...loadedData?.download, - }; - this.settings.download.path = migrateDownloadPath(this.settings.download.path); - // Normalise the persisted limit so a malformed value (e.g. 0 from an older - // data.json) is repaired in the settings object too, not just clamped for - // runtime behaviour, and so a later saveSettings() can't re-persist it (#114). - this.settings.episodeListLimit = sanitizeEpisodeListLimit(this.settings.episodeListLimit); - // Repair persisted skip lengths so a cleared field (NaN -> null in JSON) - // can't feed the skip arithmetic and corrupt the playback position (PB-02). - this.settings.skipBackwardLength = migrateSkipLength( - this.settings.skipBackwardLength, - DEFAULT_SETTINGS.skipBackwardLength, - ); - this.settings.skipForwardLength = migrateSkipLength( - this.settings.skipForwardLength, - DEFAULT_SETTINGS.skipForwardLength, - ); - // Self-heal a corrupt persisted default playback rate so the loaded store - // and the settings slider both read a clamped value (ST-02). - this.settings.defaultPlaybackRate = normalizePlaybackRate( - this.settings.defaultPlaybackRate, - ); - // Upgrade the legacy empty episode-note default to the Bases-friendly - // default, preserving any path/template the user configured (#160). Returns - // a fresh object, so DEFAULT_SETTINGS.note is never mutated. - this.settings.note = migrateNoteSettings(loadedData?.note); - // Backfill the diarization defaults onto the stored transcript object so an - // existing user (who has only { path, template } persisted) gets a valid - // transcript.diarization instead of undefined (#168). - this.settings.transcript = migrateTranscriptSettings(loadedData?.transcript); - // Backfill a partial/legacy feedNote so a missing template can't crash - // createFeedNote's `template.replace(...)` (ST-08). - this.settings.feedNote = migrateFeedNoteSettings(loadedData?.feedNote); + try { + const decoded = decodePodNotesData(await this.loadData()); + this.settings = decoded.settings; + this.persistenceUnknownFields = decoded.unknownFields; + + if (decoded.warnings.length > 0) { + console.warn("PodNotes repaired invalid persisted values:", decoded.warnings); + } + } catch (error) { + if (error instanceof PodNotesDataError) { + new Notice(error.message, 0); + console.error("PodNotes refused to load unsafe persisted data", error); + } + throw error; + } + } + + saveSettings(): Promise { + return this.requestSettingsSave().catch(() => undefined); } - async saveSettings() { - if (!this.isReady) return; + /** Awaitable save for migrations/imports that must not report success on failure. */ + saveSettingsStrict(): Promise { + return this.requestSettingsSave(); + } - this.pendingSave = this.cloneSettings(); + private requestSettingsSave(): Promise { + if (!this.isReady) return Promise.resolve(); - if (this.saveScheduled) { - return this.saveChain; + try { + this.pendingSave = this.cloneSettings(); + } catch (error) { + console.error("PodNotes: failed to snapshot settings", error); + const failure = Promise.reject(error); + void failure.catch(() => undefined); + return failure; } - this.saveScheduled = true; + const completion = new Promise((resolve, reject) => { + this.pendingSaveWaiters.push({ resolve, reject }); + }); + // Attach a handler so even a mistakenly ignored strict save cannot become an + // unhandled rejection. Awaiting the original promise still receives failure. + void completion.catch(() => undefined); - this.saveChain = this.saveChain - .then(async () => { - while (this.pendingSave) { - const snapshot = this.pendingSave; - this.pendingSave = null; - await this.saveData(snapshot); - } - }) - .catch((error) => { - console.error("PodNotes: failed to save settings", error); - }) - .finally(() => { - this.saveScheduled = false; - - // If a save was requested while we were saving, run again. - if (this.pendingSave) { - void this.saveSettings(); - } - }); + if (!this.saveScheduled) { + this.saveScheduled = true; + this.saveChain = this.runSaveLoop(); + } + + return completion; + } - return this.saveChain; + private async runSaveLoop(): Promise { + try { + while (this.pendingSave) { + const snapshot = this.pendingSave; + const waiters = this.pendingSaveWaiters; + this.pendingSave = null; + this.pendingSaveWaiters = []; + + try { + await this.saveData( + encodePodNotesData(snapshot, this.persistenceUnknownFields), + ); + for (const waiter of waiters) waiter.resolve(); + } catch (error) { + console.error("PodNotes: failed to save settings", error); + for (const waiter of waiters) waiter.reject(error); + } + } + } finally { + // No await occurs between the loop's empty check and this assignment, so a + // request cannot land in a gap where it sees a scheduled loop that has + // already decided to exit. + this.saveScheduled = false; + } } private cloneSettings(): IPodNotesSettings { diff --git a/src/parser/feedParser.test.ts b/src/parser/feedParser.test.ts index b3f8ad6..6eaa283 100644 --- a/src/parser/feedParser.test.ts +++ b/src/parser/feedParser.test.ts @@ -404,6 +404,28 @@ describe("FeedParser", () => { expect(episode.feedUrl).toBe("https://example.com/feed.xml"); }); + test("keeps an episode with a malformed publication date but omits the date", async () => { + const malformedDateFeed = ` + + + Test Podcast + + Malformed Date Episode + + definitely not a date + + +`; + mockRequestWithTimeout.mockResolvedValueOnce(feedResponse(malformedDateFeed)); + + const parser = new FeedParser(); + const episodes = await parser.getEpisodes("https://example.com/feed.xml"); + + expect(episodes).toHaveLength(1); + expect(episodes[0]).toMatchObject({ title: "Malformed Date Episode" }); + expect(episodes[0].episodeDate).toBeUndefined(); + }); + test("parses Podcasting 2.0 chapter URLs from episodes (#47)", async () => { mockRequestWithTimeout.mockResolvedValueOnce(feedResponse(rssFeedWithPodcastChapters)); diff --git a/src/parser/feedParser.ts b/src/parser/feedParser.ts index 49ec82b..5bc367c 100644 --- a/src/parser/feedParser.ts +++ b/src/parser/feedParser.ts @@ -1,5 +1,6 @@ import type { PodcastFeed } from "src/types/PodcastFeed"; import type { Episode } from "src/types/Episode"; +import { decodeDate } from "src/persistence/dateCodec"; import { requestWithTimeout } from "src/utility/networkRequest"; import { parseEpisodeNumber } from "src/utility/parseEpisodeNumber"; import { parseDurationToSeconds } from "src/utility/parseDuration"; @@ -194,7 +195,7 @@ export default class FeedParser { const url = linkEl?.textContent || ""; const description = descriptionEl?.textContent || ""; const content = contentEl?.textContent || ""; - const pubDate = new Date(pubDateEl.textContent as string); + const pubDate = decodeDate(pubDateEl.textContent); const artworkUrl = itunesImageEl?.getAttribute("href") || this.feed?.artworkUrl; const itunesTitle = itunesTitleEl?.textContent; const episodeNumber = parseEpisodeNumber(itunesEpisodeEl?.textContent, title); diff --git a/src/persistence/codecUtils.ts b/src/persistence/codecUtils.ts new file mode 100644 index 0000000..11ad9e5 --- /dev/null +++ b/src/persistence/codecUtils.ts @@ -0,0 +1,195 @@ +export type UnknownRecord = Record; + +export const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]); + +export function readString( + record: UnknownRecord, + key: string, + fallback: string, + warnings: Set, + basePath = "", +): string { + const value = record[key]; + if (value === undefined) return fallback; + if (typeof value === "string") return value; + warn(warnings, joinPath(basePath, key), "expected a string"); + return fallback; +} + +export function readNullableString( + record: UnknownRecord, + key: string, + warnings: Set, + basePath: string, +): string | null | undefined { + const value = record[key]; + if (value === undefined || value === null || typeof value === "string") return value; + warn(warnings, joinPath(basePath, key), "expected a string"); + return undefined; +} + +export function readBoolean( + record: UnknownRecord, + key: string, + fallback: boolean, + warnings: Set, + basePath = "", +): boolean { + const value = record[key]; + if (value === undefined) return fallback; + if (typeof value === "boolean") return value; + warn(warnings, joinPath(basePath, key), "expected a boolean"); + return fallback; +} + +export function readFiniteNumber( + record: UnknownRecord, + key: string, + fallback: number, + warnings: Set, + basePath = "", +): number { + const value = record[key]; + if (value === undefined) return fallback; + if (typeof value === "number" && Number.isFinite(value)) return value; + warn(warnings, joinPath(basePath, key), "expected a finite number"); + return fallback; +} + +export function readNonNegativeNumber( + record: UnknownRecord, + key: string, + fallback: number, + warnings: Set, + basePath: string, +): number { + const value = record[key]; + if (value === undefined) return fallback; + if (typeof value === "number" && Number.isFinite(value) && value >= 0) return value; + warn(warnings, joinPath(basePath, key), "expected a non-negative number"); + return fallback; +} + +export function readPositiveNumber( + record: UnknownRecord, + key: string, + fallback: number, + warnings: Set, + basePath: string, +): number { + const value = record[key]; + if (value === undefined) return fallback; + if (typeof value === "number" && Number.isFinite(value) && value > 0) return value; + warn(warnings, joinPath(basePath, key), "expected a positive number"); + return fallback; +} + +export function readClampedNumber( + record: UnknownRecord, + key: string, + fallback: number, + minimum: number, + maximum: number, + warnings: Set, +): number { + const value = record[key]; + if (value === undefined) return fallback; + if (typeof value !== "number" || !Number.isFinite(value)) { + warn(warnings, key, "expected a finite number"); + return fallback; + } + const clamped = Math.min(maximum, Math.max(minimum, value)); + if (clamped !== value) warn(warnings, key, "value was clamped"); + return clamped; +} + +export function setOptionalString( + target: UnknownRecord, + source: UnknownRecord, + key: string, + warnings: Set, + basePath: string, +): void { + const value = source[key]; + if (value === undefined || value === null) { + delete target[key]; + } else if (typeof value === "string") { + target[key] = value; + } else { + delete target[key]; + warn(warnings, joinPath(basePath, key), "expected a string"); + } +} + +export function setOptionalFiniteNumber( + target: UnknownRecord, + source: UnknownRecord, + key: string, + warnings: Set, + basePath: string, + minimum: number, +): void { + const value = source[key]; + if (value === undefined || value === null) { + delete target[key]; + } else if (typeof value === "number" && Number.isFinite(value) && value >= minimum) { + target[key] = value; + } else { + delete target[key]; + warn(warnings, joinPath(basePath, key), `expected a finite number >= ${minimum}`); + } +} + +export function optionalRecord(value: unknown, warnings: Set, path: string): UnknownRecord { + if (value === undefined || value === null) return {}; + if (isPlainObject(value)) return value; + warn(warnings, path, "expected an object"); + return {}; +} + +export function copySafeObject(value: UnknownRecord): UnknownRecord { + const copy: UnknownRecord = {}; + for (const [key, field] of Object.entries(value)) { + if (!DANGEROUS_KEYS.has(key)) copy[key] = field; + } + return copy; +} + +export function safeEntries( + value: UnknownRecord, + warnings: Set, + path: string, +): [string, unknown][] { + const entries: [string, unknown][] = []; + for (const [key, field] of Object.entries(value)) { + if (DANGEROUS_KEYS.has(key)) { + warn(warnings, `${path}.${key}`, "unsafe key was removed"); + continue; + } + entries.push([key, field]); + } + return entries; +} + +export function mapRecord( + value: Record, + mapper: (entry: T) => R, +): Record { + const result: Record = {}; + for (const [key, entry] of Object.entries(value)) { + if (!DANGEROUS_KEYS.has(key)) result[key] = mapper(entry); + } + return result; +} + +export function isPlainObject(value: unknown): value is UnknownRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function warn(warnings: Set, path: string, reason: string): void { + warnings.add(`${path}: ${reason}`); +} + +function joinPath(basePath: string, key: string): string { + return basePath ? `${basePath}.${key}` : key; +} diff --git a/src/persistence/collectionCodecs.ts b/src/persistence/collectionCodecs.ts new file mode 100644 index 0000000..9b051aa --- /dev/null +++ b/src/persistence/collectionCodecs.ts @@ -0,0 +1,185 @@ +import type DownloadedEpisode from "src/types/DownloadedEpisode"; +import type { Playlist } from "src/types/Playlist"; +import type { PodcastFeed } from "src/types/PodcastFeed"; +import type { PodNote } from "src/types/PodNotes"; +import type { PlayedEpisode } from "src/types/PlayedEpisode"; +import { + copySafeObject, + isPlainObject, + readBoolean, + readNonNegativeNumber, + readString, + safeEntries, + setOptionalString, + warn, +} from "./codecUtils"; +import { decodeEpisode, decodePlaylist } from "./episodeCodec"; + +export function decodeSavedFeeds( + value: unknown, + warnings: Set, +): Record { + if (!isPlainObject(value)) { + if (value !== undefined) warn(warnings, "savedFeeds", "expected an object"); + return {}; + } + + const feeds: Record = {}; + for (const [key, candidate] of safeEntries(value, warnings, "savedFeeds")) { + if (!isPlainObject(candidate)) { + warn(warnings, `savedFeeds.${key}`, "expected an object; feed was skipped"); + continue; + } + + const feed = copySafeObject(candidate); + feed.title = readString(candidate, "title", key, warnings, `savedFeeds.${key}`); + feed.url = readString(candidate, "url", "", warnings, `savedFeeds.${key}`); + feed.artworkUrl = readString(candidate, "artworkUrl", "", warnings, `savedFeeds.${key}`); + setOptionalString(feed, candidate, "description", warnings, `savedFeeds.${key}`); + setOptionalString(feed, candidate, "link", warnings, `savedFeeds.${key}`); + setOptionalString(feed, candidate, "author", warnings, `savedFeeds.${key}`); + if (typeof candidate.collectionId === "number" && Number.isFinite(candidate.collectionId)) { + feed.collectionId = String(candidate.collectionId); + warn(warnings, `savedFeeds.${key}.collectionId`, "number was converted to text"); + } else { + setOptionalString(feed, candidate, "collectionId", warnings, `savedFeeds.${key}`); + } + feeds[key] = feed as unknown as PodcastFeed; + } + return feeds; +} + +export function decodePodNotes(value: unknown, warnings: Set): Record { + if (!isPlainObject(value)) { + if (value !== undefined) warn(warnings, "podNotes", "expected an object"); + return {}; + } + + const notes: Record = {}; + for (const [key, candidate] of safeEntries(value, warnings, "podNotes")) { + if (!isPlainObject(candidate)) { + warn(warnings, `podNotes.${key}`, "expected an object; note mapping was skipped"); + continue; + } + notes[key] = { + ...copySafeObject(candidate), + episodeName: readString(candidate, "episodeName", key, warnings, `podNotes.${key}`), + filePath: readString(candidate, "filePath", "", warnings, `podNotes.${key}`), + podcastFeedKey: readString( + candidate, + "podcastFeedKey", + "", + warnings, + `podNotes.${key}`, + ), + } as PodNote; + } + return notes; +} + +export function decodePlayedEpisodes( + value: unknown, + warnings: Set, +): Record { + if (!isPlainObject(value)) { + if (value !== undefined) warn(warnings, "playedEpisodes", "expected an object"); + return {}; + } + + const played: Record = {}; + for (const [key, candidate] of safeEntries(value, warnings, "playedEpisodes")) { + if (!isPlainObject(candidate)) { + warn(warnings, `playedEpisodes.${key}`, "expected an object; progress was skipped"); + continue; + } + played[key] = { + ...copySafeObject(candidate), + title: readString(candidate, "title", key, warnings, `playedEpisodes.${key}`), + podcastName: readString( + candidate, + "podcastName", + "", + warnings, + `playedEpisodes.${key}`, + ), + time: readNonNegativeNumber(candidate, "time", 0, warnings, `playedEpisodes.${key}`), + duration: readNonNegativeNumber( + candidate, + "duration", + 0, + warnings, + `playedEpisodes.${key}`, + ), + finished: readBoolean(candidate, "finished", false, warnings, `playedEpisodes.${key}`), + } as PlayedEpisode; + } + return played; +} + +export function decodePlaylists(value: unknown, warnings: Set): Record { + if (!isPlainObject(value)) { + if (value !== undefined) warn(warnings, "playlists", "expected an object"); + return {}; + } + + const playlists: Record = {}; + for (const [key, candidate] of safeEntries(value, warnings, "playlists")) { + if (!isPlainObject(candidate)) { + warn(warnings, `playlists.${key}`, "expected an object; playlist was skipped"); + continue; + } + playlists[key] = decodePlaylist( + candidate, + { + name: key, + icon: "list", + episodes: [], + shouldEpisodeRemoveAfterPlay: false, + shouldRepeat: false, + }, + warnings, + `playlists.${key}`, + ); + } + return playlists; +} + +export function decodeDownloadedEpisodes( + value: unknown, + warnings: Set, +): Record { + if (!isPlainObject(value)) { + if (value !== undefined) warn(warnings, "downloadedEpisodes", "expected an object"); + return {}; + } + + const downloads: Record = {}; + for (const [podcastName, candidate] of safeEntries(value, warnings, "downloadedEpisodes")) { + if (!Array.isArray(candidate)) { + warn( + warnings, + `downloadedEpisodes.${podcastName}`, + "expected an array; download list was skipped", + ); + continue; + } + + downloads[podcastName] = candidate.flatMap((entry, index) => { + const path = `downloadedEpisodes.${podcastName}[${index}]`; + const decoded = decodeEpisode(entry, warnings, path); + if (!decoded || !isPlainObject(entry) || typeof entry.filePath !== "string") { + if (decoded) + warn(warnings, `${path}.filePath`, "missing path; download was skipped"); + return []; + } + + const size = + typeof entry.size === "number" && Number.isFinite(entry.size) && entry.size >= 0 + ? entry.size + : 0; + if (size !== entry.size) warn(warnings, `${path}.size`, "value was normalized"); + return [{ ...decoded, filePath: entry.filePath, size } as DownloadedEpisode]; + }); + } + return downloads; +} diff --git a/src/persistence/dateCodec.test.ts b/src/persistence/dateCodec.test.ts new file mode 100644 index 0000000..7a3e195 --- /dev/null +++ b/src/persistence/dateCodec.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { dateTimestamp, decodeDate, encodeDate } from "./dateCodec"; + +describe("dateCodec", () => { + const iso = "2024-03-01T10:05:03.000Z"; + + it("decodes Date instances and ISO text to cloned valid dates", () => { + const source = new Date(iso); + const fromDate = decodeDate(source); + const fromText = decodeDate(iso); + + expect(fromDate).toEqual(source); + expect(fromDate).not.toBe(source); + expect(fromText).toEqual(source); + }); + + it("encodes valid inputs as canonical ISO-8601 text", () => { + expect(encodeDate(new Date(iso))).toBe(iso); + expect(encodeDate("2024-03-01T11:05:03+01:00")).toBe(iso); + }); + + it.each([undefined, null, "", " ", "not-a-date", 1_709_290_000_000, {}, []])( + "rejects invalid or unsupported input %p without throwing", + (value) => { + expect(decodeDate(value)).toBeUndefined(); + expect(encodeDate(value)).toBeUndefined(); + expect(dateTimestamp(value)).toBeUndefined(); + }, + ); + + it("returns a timestamp only for valid inputs", () => { + expect(dateTimestamp(iso)).toBe(new Date(iso).getTime()); + }); +}); diff --git a/src/persistence/dateCodec.ts b/src/persistence/dateCodec.ts new file mode 100644 index 0000000..5f76943 --- /dev/null +++ b/src/persistence/dateCodec.ts @@ -0,0 +1,34 @@ +/** + * Decode a date crossing a JSON or external-data boundary. + * + * Runtime PodNotes objects use real `Date` instances. Persisted dates use ISO + * strings. Keeping the conversion here prevents the two representations from + * leaking into the rest of the application. + */ +export function decodeDate(value: unknown): Date | undefined { + let decoded: Date; + + if (value instanceof Date) { + decoded = new Date(value.getTime()); + } else if (typeof value === "string" && value.trim() !== "") { + decoded = new Date(value); + } else { + return undefined; + } + + return Number.isFinite(decoded.getTime()) ? decoded : undefined; +} + +/** Encode a runtime date as canonical ISO-8601 text without ever throwing. */ +export function encodeDate(value: unknown): string | undefined { + return decodeDate(value)?.toISOString(); +} + +/** Return a comparable epoch timestamp for a valid date-like value. */ +export function dateTimestamp(value: unknown): number | undefined { + if (value instanceof Date) { + const timestamp = value.getTime(); + return Number.isFinite(timestamp) ? timestamp : undefined; + } + return decodeDate(value)?.getTime(); +} diff --git a/src/persistence/episodeCodec.ts b/src/persistence/episodeCodec.ts new file mode 100644 index 0000000..e2eca5a --- /dev/null +++ b/src/persistence/episodeCodec.ts @@ -0,0 +1,175 @@ +import type { Episode } from "src/types/Episode"; +import type { Playlist } from "src/types/Playlist"; +import { + copySafeObject, + isPlainObject, + readBoolean, + readString, + setOptionalFiniteNumber, + setOptionalString, + type UnknownRecord, + warn, +} from "./codecUtils"; +import { decodeDate, encodeDate } from "./dateCodec"; + +export type PersistedEpisode = Omit & { + episodeDate?: string; +} & UnknownRecord; + +export type PersistedPlaylist = Omit & { + episodes: PersistedEpisode[]; + currentEpisode?: PersistedEpisode; +} & UnknownRecord; + +/** Decode a standalone episode at a persistence, import, or cache boundary. */ +export function decodeEpisode( + value: unknown, + warnings: Set = new Set(), + path = "episode", +): Episode | undefined { + if (!isPlainObject(value)) { + warn(warnings, path, "expected an object"); + return undefined; + } + + if (typeof value.title !== "string") { + warn(warnings, `${path}.title`, "expected a string; episode was skipped"); + return undefined; + } + + const decoded = copySafeObject(value); + decoded.title = value.title; + decoded.streamUrl = readString(value, "streamUrl", "", warnings, path); + decoded.url = readString(value, "url", "", warnings, path); + decoded.description = readString(value, "description", "", warnings, path); + decoded.content = readString(value, "content", "", warnings, path); + decoded.podcastName = readString(value, "podcastName", "", warnings, path); + + setOptionalString(decoded, value, "feedUrl", warnings, path); + setOptionalString(decoded, value, "artworkUrl", warnings, path); + setOptionalString(decoded, value, "itunesTitle", warnings, path); + setOptionalString(decoded, value, "chaptersUrl", warnings, path); + // Local and downloaded episode projections carry this structural extension + // while still flowing through Episode-typed playlists. + setOptionalString(decoded, value, "filePath", warnings, path); + + setOptionalFiniteNumber(decoded, value, "episodeNumber", warnings, path, 0); + setOptionalFiniteNumber(decoded, value, "duration", warnings, path, 0); + setOptionalFiniteNumber(decoded, value, "size", warnings, path, 0); + + if (value.mediaType === undefined) { + delete decoded.mediaType; + } else if (value.mediaType === "audio" || value.mediaType === "video") { + decoded.mediaType = value.mediaType; + } else { + delete decoded.mediaType; + warn(warnings, `${path}.mediaType`, "expected audio or video"); + } + + if (value.episodeDate === undefined || value.episodeDate === null || value.episodeDate === "") { + delete decoded.episodeDate; + } else { + const episodeDate = decodeDate(value.episodeDate); + if (episodeDate) { + decoded.episodeDate = episodeDate; + } else { + delete decoded.episodeDate; + warn(warnings, `${path}.episodeDate`, "invalid date was removed"); + } + } + + return decoded as unknown as Episode; +} + +/** Encode an episode while preserving safe structural extension fields. */ +export function encodeEpisode(value: Episode): PersistedEpisode; +export function encodeEpisode(value: undefined): undefined; +export function encodeEpisode(value: Episode | undefined): PersistedEpisode | undefined { + if (!value) return undefined; + + const encoded = copySafeObject(value as unknown as UnknownRecord); + const episodeDate = encodeDate(value.episodeDate); + if (episodeDate) encoded.episodeDate = episodeDate; + else delete encoded.episodeDate; + return encoded as PersistedEpisode; +} + +/** Decode a playlist and every episode snapshot it owns. */ +export function decodePlaylist( + value: unknown, + fallback: Playlist, + warnings: Set = new Set(), + path = "playlist", +): Playlist { + if (!isPlainObject(value)) { + if (value !== undefined) warn(warnings, path, "expected an object"); + return clonePlaylist(fallback); + } + + const decoded = copySafeObject(value); + decoded.icon = readString(value, "icon", fallback.icon, warnings, path); + decoded.name = readString(value, "name", fallback.name, warnings, path); + decoded.shouldEpisodeRemoveAfterPlay = readBoolean( + value, + "shouldEpisodeRemoveAfterPlay", + fallback.shouldEpisodeRemoveAfterPlay, + warnings, + path, + ); + decoded.shouldRepeat = readBoolean( + value, + "shouldRepeat", + fallback.shouldRepeat, + warnings, + path, + ); + + if (Array.isArray(value.episodes)) { + decoded.episodes = value.episodes.flatMap((episode, index) => { + const result = decodeEpisode(episode, warnings, `${path}.episodes[${index}]`); + return result ? [result] : []; + }); + } else { + if (value.episodes !== undefined) warn(warnings, `${path}.episodes`, "expected an array"); + decoded.episodes = fallback.episodes + .map((episode, index) => + decodeEpisode(episode, warnings, `${path}.fallbackEpisodes[${index}]`), + ) + .filter((episode): episode is Episode => Boolean(episode)); + } + + const currentEpisode = + value.currentEpisode === undefined || value.currentEpisode === null + ? undefined + : decodeEpisode(value.currentEpisode, warnings, `${path}.currentEpisode`); + if (currentEpisode) decoded.currentEpisode = currentEpisode; + else delete decoded.currentEpisode; + + if (value.isVirtual === undefined) { + delete decoded.isVirtual; + } else if (typeof value.isVirtual === "boolean") { + decoded.isVirtual = value.isVirtual; + } else { + delete decoded.isVirtual; + warn(warnings, `${path}.isVirtual`, "expected a boolean"); + } + + return decoded as unknown as Playlist; +} + +export function encodePlaylist(value: Playlist): PersistedPlaylist { + const encoded = copySafeObject(value as unknown as UnknownRecord); + encoded.episodes = value.episodes.map((episode) => encodeEpisode(episode)); + const currentEpisode = value.currentEpisode ? encodeEpisode(value.currentEpisode) : undefined; + if (currentEpisode) encoded.currentEpisode = currentEpisode; + else delete encoded.currentEpisode; + return encoded as PersistedPlaylist; +} + +function clonePlaylist(value: Playlist): Playlist { + return { + ...value, + episodes: value.episodes.map((episode) => ({ ...episode })), + currentEpisode: value.currentEpisode ? { ...value.currentEpisode } : undefined, + }; +} diff --git a/src/persistence/podNotesData.test.ts b/src/persistence/podNotesData.test.ts new file mode 100644 index 0000000..6aa1f61 --- /dev/null +++ b/src/persistence/podNotesData.test.ts @@ -0,0 +1,257 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_SETTINGS } from "src/constants"; +import type DownloadedEpisode from "src/types/DownloadedEpisode"; +import type { Episode } from "src/types/Episode"; +import type { IPodNotesSettings } from "src/types/IPodNotesSettings"; +import type { Playlist } from "src/types/Playlist"; +import { + decodePodNotesData, + encodePodNotesData, + PODNOTES_DATA_SCHEMA_VERSION, + PodNotesDataError, +} from "./podNotesData"; + +const episodeDate = new Date("2024-03-01T10:05:03.000Z"); + +function episode(title: string): Episode { + return { + title, + streamUrl: `https://example.com/${title}.mp3`, + url: `https://example.com/${title}`, + description: `${title} description`, + content: `${title} content`, + podcastName: "Test Podcast", + episodeDate, + }; +} + +function playlist(name: string, item: Episode): Playlist { + return { + name, + icon: "list", + episodes: [item], + currentEpisode: item, + shouldEpisodeRemoveAfterPlay: false, + shouldRepeat: false, + }; +} + +function settingsWithEveryEpisodeContainer(): IPodNotesSettings { + const current = episode("Current"); + const queued = episode("Queued"); + const favorite = episode("Favorite"); + const local = { ...episode("Local"), filePath: "Podcasts/local.mp3" }; + const custom = episode("Custom"); + const downloaded = { + ...episode("Downloaded"), + filePath: "Podcasts/downloaded.mp3", + size: 42, + } satisfies DownloadedEpisode; + + return { + ...structuredClone(DEFAULT_SETTINGS), + currentEpisode: current, + queue: playlist("Queue", queued), + favorites: playlist("Favorites", favorite), + localFiles: playlist("Local Files", local), + playlists: { Custom: playlist("Custom", custom) }, + downloadedEpisodes: { "Test Podcast": [downloaded] }, + }; +} + +describe("PodNotes data schema", () => { + it("loads fresh data as independent deep-cloned defaults", () => { + const first = decodePodNotesData(undefined); + const second = decodePodNotesData(null); + + expect(first.sourceVersion).toBe(0); + expect(first.warnings).toEqual([]); + expect(first.settings).toEqual(DEFAULT_SETTINGS); + first.settings.queue.episodes.push(episode("Mutation")); + first.settings.savedFeeds.Modified = { + title: "Modified", + url: "https://example.com/feed.xml", + artworkUrl: "", + }; + + expect(DEFAULT_SETTINGS.queue.episodes).toEqual([]); + expect(DEFAULT_SETTINGS.savedFeeds).toEqual({}); + expect(second.settings.queue.episodes).toEqual([]); + }); + + it("round-trips every persisted episode container with canonical dates", () => { + const settings = settingsWithEveryEpisodeContainer(); + const persisted = encodePodNotesData(settings); + const json = JSON.parse(JSON.stringify(persisted)) as Record; + const decoded = decodePodNotesData(json); + + expect(persisted.schemaVersion).toBe(PODNOTES_DATA_SCHEMA_VERSION); + expect((persisted.currentEpisode as Record).episodeDate).toBe( + episodeDate.toISOString(), + ); + expect(decoded.sourceVersion).toBe(1); + expect(decoded.changed).toBe(false); + expect(decoded.settings.currentEpisode?.episodeDate).toEqual(episodeDate); + expect(decoded.settings.queue.episodes[0].episodeDate).toEqual(episodeDate); + expect(decoded.settings.queue.currentEpisode?.episodeDate).toEqual(episodeDate); + expect(decoded.settings.favorites.episodes[0].episodeDate).toEqual(episodeDate); + expect(decoded.settings.localFiles.episodes[0].episodeDate).toEqual(episodeDate); + expect(decoded.settings.playlists.Custom.episodes[0].episodeDate).toEqual(episodeDate); + expect(decoded.settings.playlists.Custom.currentEpisode?.episodeDate).toEqual(episodeDate); + expect(decoded.settings.downloadedEpisodes["Test Podcast"][0].episodeDate).toEqual( + episodeDate, + ); + expect(decoded.settings.localFiles.episodes[0]).toMatchObject({ + filePath: "Podcasts/local.mp3", + }); + }); + + it("migrates valid legacy values through the existing repair rules", () => { + const decoded = decodePodNotesData({ + defaultPlaybackRate: 99, + episodeListLimit: 0, + skipBackwardLength: null, + download: { path: "" }, + note: { path: "", template: "" }, + transcript: { path: "custom.md", template: "{{transcript}}" }, + feedNote: { path: "custom-feed.md" }, + }); + + expect(decoded.sourceVersion).toBe(0); + expect(decoded.settings.defaultPlaybackRate).toBe(4); + expect(decoded.settings.episodeListLimit).toBe(DEFAULT_SETTINGS.episodeListLimit); + expect(decoded.settings.skipBackwardLength).toBe(DEFAULT_SETTINGS.skipBackwardLength); + expect(decoded.settings.download.path).toBe(DEFAULT_SETTINGS.download.path); + expect(decoded.settings.note).toEqual(DEFAULT_SETTINGS.note); + expect(decoded.settings.transcript).toMatchObject({ + path: "custom.md", + template: "{{transcript}}", + diarization: DEFAULT_SETTINGS.transcript.diarization, + }); + expect(decoded.settings.feedNote).toEqual({ + path: "custom-feed.md", + template: DEFAULT_SETTINGS.feedNote.template, + }); + }); + + it("preserves an intentionally disabled note feature in schema v1 and on save", () => { + const decoded = decodePodNotesData({ + schemaVersion: 1, + note: { path: "", template: "" }, + }); + const persisted = encodePodNotesData(decoded.settings); + + expect(decoded.settings.note).toEqual({ path: "", template: "" }); + expect(persisted.note).toEqual({ path: "", template: "" }); + }); + + it("salvages valid fields and only drops individually invalid collection entries", () => { + const validEpisode = { + ...episode("Valid"), + episodeDate: episodeDate.toISOString(), + }; + const decoded = decodePodNotesData({ + schemaVersion: 1, + defaultVolume: 3, + queue: { + name: "Queue", + icon: "list-ordered", + shouldEpisodeRemoveAfterPlay: true, + shouldRepeat: false, + episodes: [validEpisode, { title: 42 }], + }, + downloadedEpisodes: { + Podcast: [ + { ...validEpisode, filePath: "episode.mp3" }, + { ...validEpisode, title: "No path" }, + ], + }, + playlists: { Valid: playlist("Valid", episode("Playlist")), Broken: null }, + }); + + expect(decoded.settings.defaultVolume).toBe(1); + expect(decoded.settings.queue.episodes.map((item) => item.title)).toEqual(["Valid"]); + expect(decoded.settings.downloadedEpisodes.Podcast).toHaveLength(1); + expect(decoded.settings.downloadedEpisodes.Podcast[0]).toMatchObject({ + filePath: "episode.mp3", + size: 0, + }); + expect(Object.keys(decoded.settings.playlists)).toEqual(["Valid"]); + expect(decoded.warnings).toContain("defaultVolume: value was clamped"); + expect(decoded.warnings).toContain( + "queue.episodes[1].title: expected a string; episode was skipped", + ); + }); + + it("removes an invalid date while preserving the episode", () => { + const decoded = decodePodNotesData({ + currentEpisode: { ...episode("Restored"), episodeDate: "not-a-date" }, + }); + + expect(decoded.settings.currentEpisode).toMatchObject({ title: "Restored" }); + expect(decoded.settings.currentEpisode?.episodeDate).toBeUndefined(); + expect(decoded.warnings).toContain("currentEpisode.episodeDate: invalid date was removed"); + }); + + it("backfills fields missing from early episode and download snapshots", () => { + const early = { + title: "Early", + streamUrl: "early.mp3", + url: "", + description: "", + podcastName: "Old Podcast", + filePath: "early.mp3", + }; + const decoded = decodePodNotesData({ + currentEpisode: early, + downloadedEpisodes: { "Old Podcast": [early] }, + }); + + expect(decoded.settings.currentEpisode?.content).toBe(""); + expect(decoded.settings.downloadedEpisodes["Old Podcast"][0].size).toBe(0); + }); + + it("preserves safe unknown root and nested fields across a v1 save", () => { + const decoded = decodePodNotesData({ + schemaVersion: 1, + futureRootField: { enabled: true }, + queue: { + ...DEFAULT_SETTINGS.queue, + futurePlaylistField: "kept", + }, + }); + const persisted = encodePodNotesData(decoded.settings, decoded.unknownFields); + + expect(persisted.futureRootField).toEqual({ enabled: true }); + expect((persisted.queue as Record).futurePlaylistField).toBe("kept"); + }); + + it("removes prototype-pollution keys", () => { + const raw = JSON.parse( + '{"schemaVersion":1,"__proto__":{"polluted":true},"playlists":{"constructor":{"episodes":[]}}}', + ) as Record; + const decoded = decodePodNotesData(raw); + const persisted = encodePodNotesData(decoded.settings, decoded.unknownFields); + + expect(decoded.unknownFields).not.toHaveProperty("__proto__"); + expect(decoded.settings.playlists).toEqual({}); + expect(persisted).not.toHaveProperty("__proto__"); + expect(({} as { polluted?: boolean }).polluted).toBeUndefined(); + }); + + it.each([[], "data", 42, true])("fails closed for malformed root %p", (value) => { + expect(() => decodePodNotesData(value)).toThrowError(PodNotesDataError); + }); + + it.each([0, -1, 1.5, "1", null])("fails closed for invalid schema version %p", (version) => { + expect(() => decodePodNotesData({ schemaVersion: version })).toThrowError( + /invalid schemaVersion/, + ); + }); + + it("fails closed for a future schema version", () => { + expect(() => decodePodNotesData({ schemaVersion: 2 })).toThrowError( + /schema v2 requires a newer version/, + ); + }); +}); diff --git a/src/persistence/podNotesData.ts b/src/persistence/podNotesData.ts new file mode 100644 index 0000000..873373a --- /dev/null +++ b/src/persistence/podNotesData.ts @@ -0,0 +1,135 @@ +import { DEFAULT_SETTINGS } from "src/constants"; +import type { IPodNotesSettings } from "src/types/IPodNotesSettings"; +import { DANGEROUS_KEYS, isPlainObject, mapRecord, type UnknownRecord } from "./codecUtils"; +import { + encodeEpisode, + encodePlaylist, + type PersistedEpisode, + type PersistedPlaylist, +} from "./episodeCodec"; +import { decodeSettings } from "./settingsCodec"; + +export { decodeEpisode, decodePlaylist, encodeEpisode, encodePlaylist } from "./episodeCodec"; + +export const PODNOTES_DATA_SCHEMA_VERSION = 1; + +const KNOWN_TOP_LEVEL_KEYS = new Set([...Object.keys(DEFAULT_SETTINGS), "schemaVersion"]); + +export class PodNotesDataError extends Error { + constructor( + message: string, + public readonly code: "invalid-data" | "unsupported-version", + ) { + super(message); + this.name = "PodNotesDataError"; + } +} + +export interface DecodedPodNotesData { + settings: IPodNotesSettings; + sourceVersion: 0 | typeof PODNOTES_DATA_SCHEMA_VERSION; + changed: boolean; + warnings: string[]; + /** Unknown v0/v1 root fields retained so a normal save does not erase them. */ + unknownFields: UnknownRecord; +} + +export type PersistedPodNotesSettings = Omit< + IPodNotesSettings, + "currentEpisode" | "favorites" | "queue" | "localFiles" | "playlists" | "downloadedEpisodes" +> & { + currentEpisode?: PersistedEpisode; + favorites: PersistedPlaylist; + queue: PersistedPlaylist; + localFiles: PersistedPlaylist; + playlists: Record; + downloadedEpisodes: Record; +}; + +export type PersistedPodNotesDataV1 = PersistedPodNotesSettings & + UnknownRecord & { + schemaVersion: typeof PODNOTES_DATA_SCHEMA_VERSION; + }; + +/** + * Decode and validate plugin data before any store sees it. + * + * Missing `schemaVersion` is the legacy v0 shape. V1 deliberately remains flat + * so existing data files, rollback paths, and E2E tooling stay compatible. A + * newer version fails closed so an older plugin can never overwrite data it + * does not understand. + */ +export function decodePodNotesData(value: unknown): DecodedPodNotesData { + if (value === null || value === undefined) value = {}; + + if (!isPlainObject(value)) { + throw new PodNotesDataError( + "PodNotes data.json does not contain an object. The file was not modified.", + "invalid-data", + ); + } + + const sourceVersion = readSchemaVersion(value); + const warnings = new Set(); + const settings = decodeSettings(value, warnings, sourceVersion); + const unknownFields = copyUnknownRootFields(value); + + return { + settings, + sourceVersion, + changed: sourceVersion !== PODNOTES_DATA_SCHEMA_VERSION || warnings.size > 0, + warnings: [...warnings], + unknownFields, + }; +} + +/** Serialize a validated runtime snapshot with canonical dates and schema. */ +export function encodePodNotesData( + settings: IPodNotesSettings, + unknownFields: UnknownRecord = {}, +): PersistedPodNotesDataV1 { + const validated = decodeSettings(settings as unknown as UnknownRecord, new Set(), 1); + + return { + ...copyUnknownRootFields(unknownFields), + ...validated, + schemaVersion: PODNOTES_DATA_SCHEMA_VERSION, + currentEpisode: validated.currentEpisode + ? encodeEpisode(validated.currentEpisode) + : undefined, + favorites: encodePlaylist(validated.favorites), + queue: encodePlaylist(validated.queue), + localFiles: encodePlaylist(validated.localFiles), + playlists: mapRecord(validated.playlists, (playlist) => encodePlaylist(playlist)), + downloadedEpisodes: mapRecord(validated.downloadedEpisodes, (episodes) => + episodes.map((episode) => encodeEpisode(episode)), + ), + } as PersistedPodNotesDataV1; +} + +function readSchemaVersion(root: UnknownRecord): 0 | 1 { + if (!Object.prototype.hasOwnProperty.call(root, "schemaVersion")) return 0; + + const version = root.schemaVersion; + if (version === PODNOTES_DATA_SCHEMA_VERSION) return version; + if (typeof version === "number" && Number.isInteger(version) && version > 1) { + throw new PodNotesDataError( + `PodNotes data schema v${version} requires a newer version of PodNotes. The file was not modified.`, + "unsupported-version", + ); + } + + throw new PodNotesDataError( + "PodNotes data.json has an invalid schemaVersion. The file was not modified.", + "invalid-data", + ); +} + +function copyUnknownRootFields(root: UnknownRecord): UnknownRecord { + const copy: UnknownRecord = {}; + for (const [key, value] of Object.entries(root)) { + if (DANGEROUS_KEYS.has(key) || KNOWN_TOP_LEVEL_KEYS.has(key)) continue; + copy[key] = value; + } + return copy; +} diff --git a/src/persistence/settingsCodec.ts b/src/persistence/settingsCodec.ts new file mode 100644 index 0000000..7e1c9eb --- /dev/null +++ b/src/persistence/settingsCodec.ts @@ -0,0 +1,253 @@ +import { DEFAULT_SETTINGS } from "src/constants"; +import { + migrateDownloadPath, + migrateFeedNoteSettings, + migrateNoteSettings, + migrateSkipLength, + migrateTranscriptSettings, +} from "src/settingsMigrations"; +import type { IPodNotesSettings } from "src/types/IPodNotesSettings"; +import { sanitizeEpisodeListLimit } from "src/utility/episodeListLimit"; +import { normalizePlaybackRate } from "src/utility/playbackRate"; +import { + decodeDownloadedEpisodes, + decodePlayedEpisodes, + decodePlaylists, + decodePodNotes, + decodeSavedFeeds, +} from "./collectionCodecs"; +import { + copySafeObject, + optionalRecord, + readBoolean, + readClampedNumber, + readFiniteNumber, + readNullableString, + readPositiveNumber, + readString, + type UnknownRecord, + warn, +} from "./codecUtils"; +import { decodeEpisode, decodePlaylist } from "./episodeCodec"; + +export function decodeSettings( + root: UnknownRecord, + warnings: Set, + sourceVersion: 0 | 1 = 1, +): IPodNotesSettings { + const defaultPlaybackRate = normalizePlaybackRate(root.defaultPlaybackRate); + if ( + root.defaultPlaybackRate !== undefined && + (typeof root.defaultPlaybackRate !== "number" || + root.defaultPlaybackRate !== defaultPlaybackRate) + ) { + warn(warnings, "defaultPlaybackRate", "value was normalized"); + } + + const defaultVolume = readClampedNumber( + root, + "defaultVolume", + DEFAULT_SETTINGS.defaultVolume, + 0, + 1, + warnings, + ); + const episodeListLimit = sanitizeEpisodeListLimit(root.episodeListLimit); + if ( + root.episodeListLimit !== undefined && + (typeof root.episodeListLimit !== "number" || root.episodeListLimit !== episodeListLimit) + ) { + warn(warnings, "episodeListLimit", "value was normalized"); + } + + return { + savedFeeds: decodeSavedFeeds(root.savedFeeds, warnings), + podNotes: decodePodNotes(root.podNotes, warnings), + defaultPlaybackRate, + defaultVolume, + hidePlayedEpisodes: readBoolean( + root, + "hidePlayedEpisodes", + DEFAULT_SETTINGS.hidePlayedEpisodes, + warnings, + ), + episodeListLimit, + playedEpisodes: decodePlayedEpisodes(root.playedEpisodes, warnings), + skipBackwardLength: decodeSkipLength( + root.skipBackwardLength, + DEFAULT_SETTINGS.skipBackwardLength, + "skipBackwardLength", + warnings, + ), + skipForwardLength: decodeSkipLength( + root.skipForwardLength, + DEFAULT_SETTINGS.skipForwardLength, + "skipForwardLength", + warnings, + ), + playlists: decodePlaylists(root.playlists, warnings), + queue: decodePlaylist(root.queue, DEFAULT_SETTINGS.queue, warnings, "queue"), + autoQueue: readBoolean(root, "autoQueue", DEFAULT_SETTINGS.autoQueue, warnings), + favorites: decodePlaylist( + root.favorites, + DEFAULT_SETTINGS.favorites, + warnings, + "favorites", + ), + localFiles: decodePlaylist( + root.localFiles, + DEFAULT_SETTINGS.localFiles, + warnings, + "localFiles", + ), + currentEpisode: + root.currentEpisode === undefined || root.currentEpisode === null + ? undefined + : decodeEpisode(root.currentEpisode, warnings, "currentEpisode"), + timestamp: decodeTimestamp(root.timestamp, warnings), + note: decodeNote(root.note, warnings, sourceVersion), + feedNote: decodeFeedNote(root.feedNote, warnings), + download: decodeDownload(root.download, warnings), + downloadedEpisodes: decodeDownloadedEpisodes(root.downloadedEpisodes, warnings), + openAIApiKey: readString(root, "openAIApiKey", DEFAULT_SETTINGS.openAIApiKey, warnings), + diarizationApiKey: readString( + root, + "diarizationApiKey", + DEFAULT_SETTINGS.diarizationApiKey, + warnings, + ), + transcript: decodeTranscript(root.transcript, warnings), + feedCache: decodeFeedCache(root.feedCache, warnings), + }; +} + +function decodeTimestamp(value: unknown, warnings: Set): IPodNotesSettings["timestamp"] { + const record = optionalRecord(value, warnings, "timestamp"); + return { + ...copySafeObject(record), + template: readString( + record, + "template", + DEFAULT_SETTINGS.timestamp.template, + warnings, + "timestamp", + ), + offset: readFiniteNumber( + record, + "offset", + DEFAULT_SETTINGS.timestamp.offset, + warnings, + "timestamp", + ), + }; +} + +function decodeNote( + value: unknown, + warnings: Set, + sourceVersion: 0 | 1, +): IPodNotesSettings["note"] { + const record = optionalRecord(value, warnings, "note"); + const path = readNullableString(record, "path", warnings, "note"); + const template = readNullableString(record, "template", warnings, "note"); + const validated = + sourceVersion === 0 + ? migrateNoteSettings({ path, template }) + : { + path: typeof path === "string" ? path : DEFAULT_SETTINGS.note.path, + template: + typeof template === "string" ? template : DEFAULT_SETTINGS.note.template, + }; + return { ...copySafeObject(record), ...validated }; +} + +function decodeFeedNote(value: unknown, warnings: Set): IPodNotesSettings["feedNote"] { + const record = optionalRecord(value, warnings, "feedNote"); + const migrated = migrateFeedNoteSettings({ + path: readNullableString(record, "path", warnings, "feedNote"), + template: readNullableString(record, "template", warnings, "feedNote"), + }); + return { ...copySafeObject(record), ...migrated }; +} + +function decodeDownload(value: unknown, warnings: Set): IPodNotesSettings["download"] { + const record = optionalRecord(value, warnings, "download"); + return { + ...copySafeObject(record), + path: migrateDownloadPath(readNullableString(record, "path", warnings, "download")), + }; +} + +function decodeTranscript(value: unknown, warnings: Set): IPodNotesSettings["transcript"] { + const record = optionalRecord(value, warnings, "transcript"); + const diarization = optionalRecord(record.diarization, warnings, "transcript.diarization"); + const migrated = migrateTranscriptSettings({ + path: record.path as string | null | undefined, + template: record.template as string | null | undefined, + diarization, + }); + + if (record.path !== undefined && typeof record.path !== "string") { + warn(warnings, "transcript.path", "expected a string"); + } + if (record.template !== undefined && typeof record.template !== "string") { + warn(warnings, "transcript.template", "expected a string"); + } + if (diarization.enabled !== undefined && typeof diarization.enabled !== "boolean") { + warn(warnings, "transcript.diarization.enabled", "expected a boolean"); + } + if ( + diarization.provider !== undefined && + diarization.provider !== "openai" && + diarization.provider !== "deepgram" + ) { + warn(warnings, "transcript.diarization.provider", "unknown provider"); + } + if ( + diarization.speakerTemplate !== undefined && + typeof diarization.speakerTemplate !== "string" + ) { + warn(warnings, "transcript.diarization.speakerTemplate", "expected a string"); + } + + return { + ...copySafeObject(record), + ...migrated, + diarization: { + ...copySafeObject(diarization), + ...migrated.diarization, + }, + }; +} + +function decodeFeedCache(value: unknown, warnings: Set): IPodNotesSettings["feedCache"] { + const record = optionalRecord(value, warnings, "feedCache"); + return { + ...copySafeObject(record), + enabled: readBoolean( + record, + "enabled", + DEFAULT_SETTINGS.feedCache.enabled, + warnings, + "feedCache", + ), + ttlHours: readPositiveNumber( + record, + "ttlHours", + DEFAULT_SETTINGS.feedCache.ttlHours, + warnings, + "feedCache", + ), + }; +} + +function decodeSkipLength( + value: unknown, + fallback: number, + path: string, + warnings: Set, +): number { + const decoded = migrateSkipLength(value, fallback); + if (value !== undefined && value !== decoded) warn(warnings, path, "value was normalized"); + return decoded; +} diff --git a/src/services/FeedCacheService.test.ts b/src/services/FeedCacheService.test.ts index 8ce87e8..52275ed 100644 --- a/src/services/FeedCacheService.test.ts +++ b/src/services/FeedCacheService.test.ts @@ -191,4 +191,78 @@ describe("App-backed (vault-scoped) storage", () => { plugin.set(undefined as unknown as PodNotes); }); + + test("salvages a cached episode with an invalid date without creating Invalid Date", async () => { + const backing = new Map(); + backing.set( + "podnotes:feed-cache:v5", + JSON.stringify({ + [testFeed.url]: { + updatedAt: Date.now(), + episodes: [{ ...createEpisode(1), episodeDate: "not-a-date" }], + }, + }), + ); + const app = { + loadLocalStorage: (key: string) => backing.get(key) ?? null, + saveLocalStorage: (key: string, value: string | null) => { + if (value == null) backing.delete(key); + else backing.set(key, value); + }, + }; + + vi.resetModules(); + const freshStore = await import("../store"); + const freshSvc = await import("./FeedCacheService"); + (freshStore.plugin as typeof plugin).set({ app } as unknown as PodNotes); + + const cached = freshSvc.getCachedEpisodes(testFeed); + expect(cached).toHaveLength(1); + expect(cached?.[0]).toMatchObject({ title: "Episode 1" }); + expect(cached?.[0].episodeDate).toBeUndefined(); + }); + + test.each([ + JSON.stringify([]), + JSON.stringify({ + [testFeed.url]: { updatedAt: "yesterday", episodes: [createEpisode(1)] }, + }), + ])("ignores a structurally invalid cache without throwing", async (stored) => { + const backing = new Map([["podnotes:feed-cache:v5", stored]]); + const app = { + loadLocalStorage: (key: string) => backing.get(key) ?? null, + saveLocalStorage: (key: string, value: string | null) => { + if (value == null) backing.delete(key); + else backing.set(key, value); + }, + }; + + vi.resetModules(); + const freshStore = await import("../store"); + const freshSvc = await import("./FeedCacheService"); + (freshStore.plugin as typeof plugin).set({ app } as unknown as PodNotes); + + expect(() => freshSvc.getCachedEpisodes(testFeed)).not.toThrow(); + expect(freshSvc.getCachedEpisodes(testFeed)).toBeNull(); + }); + + test("drops prototype-pollution cache keys", async () => { + const stored = `{"__proto__":{"updatedAt":1,"episodes":[]},"${testFeed.url}":{"updatedAt":${Date.now()},"episodes":[]}}`; + const backing = new Map([["podnotes:feed-cache:v5", stored]]); + const app = { + loadLocalStorage: (key: string) => backing.get(key) ?? null, + saveLocalStorage: (key: string, value: string | null) => { + if (value == null) backing.delete(key); + else backing.set(key, value); + }, + }; + + vi.resetModules(); + const freshStore = await import("../store"); + const freshSvc = await import("./FeedCacheService"); + (freshStore.plugin as typeof plugin).set({ app } as unknown as PodNotes); + + expect(freshSvc.getCachedEpisodes(testFeed)).toEqual([]); + expect(({} as { updatedAt?: number }).updatedAt).toBeUndefined(); + }); }); diff --git a/src/services/FeedCacheService.ts b/src/services/FeedCacheService.ts index 6c1130e..9c8fb63 100644 --- a/src/services/FeedCacheService.ts +++ b/src/services/FeedCacheService.ts @@ -2,6 +2,9 @@ import { get } from "svelte/store"; import { plugin } from "../store"; import type { Episode } from "src/types/Episode"; import type { PodcastFeed } from "src/types/PodcastFeed"; +import { dateTimestamp, decodeDate, encodeDate } from "src/persistence/dateCodec"; +import { decodeEpisode } from "src/persistence/episodeCodec"; +import { DANGEROUS_KEYS } from "src/persistence/codecUtils"; type SerializableEpisode = Omit & { episodeDate?: string; @@ -135,8 +138,7 @@ function loadCache(): FeedCache { return cache; } - const parsed = JSON.parse(raw) as FeedCache; - cache = parsed; + cache = decodeCache(JSON.parse(raw)); return cache; } catch (error) { console.error("Failed to parse feed cache:", error); @@ -145,6 +147,39 @@ function loadCache(): FeedCache { } } +function decodeCache(value: unknown): FeedCache { + if (!isPlainObject(value)) { + throw new TypeError("Feed cache root must be an object"); + } + + const decoded: FeedCache = {}; + for (const [key, candidate] of Object.entries(value)) { + if (DANGEROUS_KEYS.has(key)) continue; + if ( + !isPlainObject(candidate) || + !Array.isArray(candidate.episodes) || + typeof candidate.updatedAt !== "number" || + !Number.isFinite(candidate.updatedAt) || + candidate.updatedAt < 0 + ) { + continue; + } + + decoded[key] = { + updatedAt: candidate.updatedAt, + episodes: candidate.episodes.flatMap((episode) => { + const runtimeEpisode = decodeEpisode(episode); + return runtimeEpisode ? [serializeEpisode(runtimeEpisode)] : []; + }), + }; + } + return decoded; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + function evictOldestEntries(cacheData: FeedCache, targetSizeBytes: number): FeedCache { const entries = Object.entries(cacheData); @@ -211,14 +246,12 @@ function persistCache(): void { function serializeEpisode(episode: Episode): SerializableEpisode { return { ...episode, - episodeDate: episode.episodeDate?.toISOString(), + episodeDate: encodeDate(episode.episodeDate), }; } function episodeTimestamp(episode: Episode): number { - if (!episode.episodeDate) return 0; - const time = new Date(episode.episodeDate).getTime(); - return Number.isNaN(time) ? 0 : time; + return dateTimestamp(episode.episodeDate) ?? 0; } /** @@ -245,7 +278,7 @@ function selectNewestEpisodes(episodes: Episode[], limit: number): Episode[] { function deserializeEpisode(episode: SerializableEpisode): Episode { return { ...episode, - episodeDate: episode.episodeDate ? new Date(episode.episodeDate) : undefined, + episodeDate: decodeDate(episode.episodeDate), }; } diff --git a/src/settingsTransfer.test.ts b/src/settingsTransfer.test.ts index 1a2b425..92b37c7 100644 --- a/src/settingsTransfer.test.ts +++ b/src/settingsTransfer.test.ts @@ -105,6 +105,29 @@ describe("serializeSettings", () => { expect(exported.savedFeeds).toEqual(settings.savedFeeds); expect(exported.timestamp).toEqual(settings.timestamp); }); + + it("encodes dates inside exported playlists as canonical text", () => { + const date = new Date("2024-03-01T10:05:03.000Z"); + const settings = makeSettings({ + queue: { + ...DEFAULT_SETTINGS.queue, + episodes: [ + { + title: "Queued", + streamUrl: "queued.mp3", + url: "", + description: "", + content: "", + podcastName: "Podcast", + episodeDate: date, + }, + ], + }, + }); + + const exported = serializeSettings(settings, { includeSecret: false }, "2.17.3", NOW); + expect(exported.settings.queue?.episodes[0].episodeDate).toBe(date.toISOString()); + }); }); describe("parseImport", () => { @@ -191,6 +214,13 @@ describe("parseImport", () => { expect(result.ok).toBe(false); }); + it("rejects a raw data file from a newer persistence schema", () => { + const result = parseImport(JSON.stringify({ schemaVersion: 2, defaultVolume: 0.5 })); + expect(result).toEqual( + expect.objectContaining({ ok: false, error: expect.stringContaining("schema v2") }), + ); + }); + it("drops a wrong-typed top-level value", () => { const result = parseImport( JSON.stringify({ defaultVolume: "evil", note: { path: "p", template: "t" } }), @@ -358,6 +388,29 @@ describe("mergeImportedSettings", () => { expect(merged.timestamp.template).toBe(DEFAULT_SETTINGS.timestamp.template); }); + it("revives imported playlist dates before hydrating live stores", () => { + const date = "2024-03-01T10:05:03.000Z"; + const current = makeSettings(); + const merged = mergeImportedSettings(current, { + queue: { + ...DEFAULT_SETTINGS.queue, + episodes: [ + { + title: "Queued", + streamUrl: "queued.mp3", + url: "", + description: "", + content: "", + podcastName: "Podcast", + episodeDate: date as unknown as Date, + }, + ], + }, + }); + + expect(merged.queue.episodes[0].episodeDate).toEqual(new Date(date)); + }); + it("replaces collection settings wholesale rather than merging them", () => { const current = makeSettings({ savedFeeds: { diff --git a/src/settingsTransfer.ts b/src/settingsTransfer.ts index 2955a60..b29df41 100644 --- a/src/settingsTransfer.ts +++ b/src/settingsTransfer.ts @@ -1,5 +1,10 @@ import { DEFAULT_SETTINGS } from "./constants"; -import { migrateTranscriptSettings } from "./settingsMigrations"; +import { + decodePodNotesData, + encodePodNotesData, + type PersistedPodNotesSettings, + PodNotesDataError, +} from "./persistence/podNotesData"; import type { IPodNotesSettings } from "./types/IPodNotesSettings"; /** @@ -83,7 +88,7 @@ export interface SettingsEnvelope { version: number; pluginVersion: string; exportedAt: string; - settings: Partial; + settings: Partial; } export interface ExportOptions { @@ -116,12 +121,13 @@ export function serializeSettings( nowISO: string, ): SettingsEnvelope { const out: Record = {}; + const persisted = encodePodNotesData(settings); for (const key of Object.keys(settings)) { if (DANGEROUS_KEYS.has(key)) continue; if (!IMPORTABLE_KEYS.has(key)) continue; if (SECRET_KEYS.has(key as keyof IPodNotesSettings) && !opts.includeSecret) continue; - out[key] = settings[key as keyof IPodNotesSettings]; + out[key] = persisted[key]; } return { @@ -129,7 +135,7 @@ export function serializeSettings( version: SETTINGS_EXPORT_VERSION, pluginVersion, exportedAt: nowISO, - settings: out as Partial, + settings: out as Partial, }; } @@ -181,6 +187,17 @@ export function parseImport(jsonText: string): ParseResult { } source = raw.settings; + } else if (Object.prototype.hasOwnProperty.call(raw, "schemaVersion")) { + try { + // Validate raw data.json imports against the same schema gate as plugin + // startup. The field sanitizer below still keeps import partial. + decodePodNotesData(raw); + } catch (error) { + if (error instanceof PodNotesDataError) { + return { ok: false, error: error.message }; + } + throw error; + } } const settings = sanitizeImportedSettings(source); @@ -224,14 +241,10 @@ export function mergeImportedSettings( } as never; } - // The per-key spread above is only one level deep, so an imported - // `transcript.diarization` overrides the whole nested object — which could - // carry an unknown provider or drop `speakerTemplate`. Run the same migration - // the load path uses so the import path converges on a clamped, fully-formed - // transcript instead of relying on the next reload to repair it (#168). - merged.transcript = migrateTranscriptSettings(merged.transcript); - - return merged; + // Converge import and startup on the same deep validators and date revival. + // Marking the in-memory merge as v1 avoids treating this internal call as a + // legacy migration while preserving the import envelope's independent version. + return decodePodNotesData({ ...merged, schemaVersion: 1 }).settings; } function sanitizeImportedSettings(source: Record): Partial { diff --git a/src/store/feeds.ts b/src/store/feeds.ts index 887487c..22fef49 100644 --- a/src/store/feeds.ts +++ b/src/store/feeds.ts @@ -1,7 +1,11 @@ import { get, readable, writable } from "svelte/store"; import type { Episode } from "src/types/Episode"; import type { PodcastFeed } from "src/types/PodcastFeed"; -import { DEFAULT_EPISODE_LIST_LIMIT, MAX_EPISODE_LIST_LIMIT } from "src/constants"; +import { DEFAULT_EPISODE_LIST_LIMIT } from "src/constants"; +import { dateTimestamp } from "src/persistence/dateCodec"; +import { sanitizeEpisodeListLimit } from "src/utility/episodeListLimit"; + +export { sanitizeEpisodeListLimit } from "src/utility/episodeListLimit"; /** * Saved-feed metadata, the per-feed episode cache, and the aggregated "Latest @@ -23,31 +27,11 @@ export const episodeCache = writable<{ [podcastName: string]: Episode[] }>({}); */ export const episodeListLimit = writable(DEFAULT_EPISODE_LIST_LIMIT); -/** - * Coerce a stored/raw limit into a usable positive integer, falling back to the - * default for missing/NaN/zero/negative values and clamping the upper bound so a - * stray huge number can't materialise an unbounded list. - */ -export function sanitizeEpisodeListLimit(value: unknown): number { - const numeric = typeof value === "number" ? value : Number(value); - if (!Number.isFinite(numeric) || numeric < 1) { - return DEFAULT_EPISODE_LIST_LIMIT; - } - - return Math.min(Math.floor(numeric), MAX_EPISODE_LIST_LIMIT); -} - type LatestEpisodesByFeed = Map; type FeedEpisodeSources = Map; function getEpisodeTimestamp(episode?: Episode): number { - if (!episode?.episodeDate) return 0; - - // An Invalid Date coerces to NaN, which makes every comparison false and - // produces an unstable/incorrect sort order. Collapse it to 0 (sorts as - // oldest), matching FeedCacheService.episodeTimestamp (FP-12). - const timestamp = Number(episode.episodeDate); - return Number.isFinite(timestamp) ? timestamp : 0; + return dateTimestamp(episode?.episodeDate) ?? 0; } function getLatestEpisodesForFeed(episodes: Episode[], perFeedLimit: number): Episode[] { diff --git a/src/ui/settings/PodNotesSettingsTab.import.test.ts b/src/ui/settings/PodNotesSettingsTab.import.test.ts index f1f9f51..464e126 100644 --- a/src/ui/settings/PodNotesSettingsTab.import.test.ts +++ b/src/ui/settings/PodNotesSettingsTab.import.test.ts @@ -1,6 +1,7 @@ import { get } from "svelte/store"; import { describe, expect, it, vi } from "vitest"; import type { App } from "obsidian"; +import * as obsidian from "obsidian"; import { DEFAULT_SETTINGS } from "src/constants"; import type PodNotes from "src/main"; @@ -13,6 +14,7 @@ describe("PodNotesSettingsTab settings import", () => { const plugin = { settings: structuredClone(DEFAULT_SETTINGS), saveSettings: vi.fn().mockResolvedValue(undefined), + saveSettingsStrict: vi.fn().mockResolvedValue(undefined), } as unknown as PodNotes; const tab = new PodNotesSettingsTab({} as App, plugin); vi.spyOn(tab, "display").mockImplementation(() => {}); @@ -27,6 +29,41 @@ describe("PodNotesSettingsTab settings import", () => { expect(plugin.settings.defaultPlaybackRate).toBe(2.3); expect(get(playbackRate)).toBe(2.3); - expect(plugin.saveSettings).toHaveBeenCalledTimes(1); + expect(plugin.saveSettingsStrict).toHaveBeenCalledTimes(2); + }); + + it("restores the previous settings and reports a strict save failure", async () => { + playbackRate.set(1); + const previous = structuredClone(DEFAULT_SETTINGS); + const failure = new Error("disk full"); + const plugin = { + settings: previous, + saveSettings: vi.fn().mockResolvedValue(undefined), + saveSettingsStrict: vi + .fn() + .mockRejectedValueOnce(failure) + .mockResolvedValueOnce(undefined), + } as unknown as PodNotes; + const tab = new PodNotesSettingsTab({} as App, plugin); + const display = vi.spyOn(tab, "display").mockImplementation(() => {}); + const notice = vi.spyOn(obsidian, "Notice"); + vi.spyOn(console, "error").mockImplementation(() => undefined); + + await ( + tab as unknown as { + applyImportedSettings: ( + imported: Partial, + ) => Promise; + } + ).applyImportedSettings({ defaultPlaybackRate: 2.3 }); + + expect(plugin.settings).toBe(previous); + expect(get(playbackRate)).toBe(1); + expect(display).not.toHaveBeenCalled(); + expect(notice).toHaveBeenCalledWith( + "Could not import PodNotes settings. Previous settings were kept.", + 10000, + ); + expect(plugin.saveSettingsStrict).toHaveBeenCalledTimes(2); }); }); diff --git a/src/ui/settings/PodNotesSettingsTab.ts b/src/ui/settings/PodNotesSettingsTab.ts index 0519664..bd7af5e 100644 --- a/src/ui/settings/PodNotesSettingsTab.ts +++ b/src/ui/settings/PodNotesSettingsTab.ts @@ -749,8 +749,37 @@ export class PodNotesSettingsTab extends PluginSettingTab { } private async applyImportedSettings(imported: Partial): Promise { - const merged = mergeImportedSettings(this.plugin.settings, imported); + const previous = this.plugin.settings; + const merged = mergeImportedSettings(previous, imported); this.plugin.settings = merged; + try { + // Persist before mutating live stores so a disk failure can restore the + // previous in-memory settings without leaving the UI half-imported. + await this.plugin.saveSettingsStrict(); + } catch (error) { + this.plugin.settings = previous; + try { + // A store event may have queued a newer merged snapshot while the first + // write was pending. Queue the rollback after it and wait for durability + // before claiming that the previous settings were kept. + await this.plugin.saveSettingsStrict(); + new Notice( + "Could not import PodNotes settings. Previous settings were kept.", + 10000, + ); + } catch (rollbackError) { + new Notice( + "Could not import PodNotes settings or persist the rollback. Previous settings remain active for this session.", + 10000, + ); + console.error( + "PodNotes: failed to persist settings-import rollback", + rollbackError, + ); + } + console.error("PodNotes: failed to persist imported settings", error); + return; + } // Re-hydrate the live stores so the running UI and the persistence // bindings reflect the import. Keys without a store (templates, paths, @@ -768,7 +797,19 @@ export class PodNotesSettingsTab extends PluginSettingTab { volume.set(Math.min(1, Math.max(0, importedVolume))); playbackRate.set(normalizePlaybackRate(merged.defaultPlaybackRate)); - await this.plugin.saveSettings(); + try { + // Store setters can canonicalize or deduplicate their slices. Await one + // final strict snapshot so the success notice means that live state is + // durable too. + await this.plugin.saveSettingsStrict(); + } catch (error) { + new Notice( + "Imported PodNotes settings, but could not finish saving normalized live state. Change any setting to retry.", + 10000, + ); + console.error("PodNotes: failed to persist normalized imported settings", error); + return; + } // Re-emit the plugin store so an open player/grid recomputes Queue tile/list // visibility (and any other $plugin-derived UI) after an import, mirroring the // autoQueue toggle. Today the queue.set above already triggers that recompute; diff --git a/src/utility/episodeListLimit.ts b/src/utility/episodeListLimit.ts new file mode 100644 index 0000000..b3ac522 --- /dev/null +++ b/src/utility/episodeListLimit.ts @@ -0,0 +1,11 @@ +import { DEFAULT_EPISODE_LIST_LIMIT, MAX_EPISODE_LIST_LIMIT } from "src/constants"; + +/** Coerce a persisted per-feed episode limit into its supported integer range. */ +export function sanitizeEpisodeListLimit(value: unknown): number { + const numeric = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(numeric) || numeric < 1) { + return DEFAULT_EPISODE_LIST_LIMIT; + } + + return Math.min(Math.floor(numeric), MAX_EPISODE_LIST_LIMIT); +} diff --git a/src/utility/formatDate.test.ts b/src/utility/formatDate.test.ts index bacd6e7..bd6ccbb 100644 --- a/src/utility/formatDate.test.ts +++ b/src/utility/formatDate.test.ts @@ -14,6 +14,19 @@ describe("formatDate", () => { // Noon edge case const noonDate = new Date("2024-01-01T12:00:00"); + it("formats a date restored from JSON text", () => { + expect(formatDate("2024-03-01T10:05:03", "YYYY-MM-DD HH:mm:ss")).toBe( + "2024-03-01 10:05:03", + ); + }); + + it.each([undefined, null, "", "not-a-date", new Date("invalid")])( + "returns an empty value for invalid input %p", + (value) => { + expect(formatDate(value, "YYYY-MM-DD")).toBe(""); + }, + ); + describe("year tokens", () => { it("formats YYYY as 4-digit year", () => { expect(formatDate(testDate, "YYYY")).toBe("2024"); diff --git a/src/utility/formatDate.ts b/src/utility/formatDate.ts index 5ec3afc..3679797 100644 --- a/src/utility/formatDate.ts +++ b/src/utility/formatDate.ts @@ -1,3 +1,5 @@ +import { decodeDate } from "src/persistence/dateCodec"; + /** * Formats a date using Moment.js-style format tokens for backward compatibility. * Common tokens supported: @@ -11,7 +13,10 @@ * - A: AM/PM, a: am/pm * - [text]: literal text (escaped, not parsed as tokens) */ -export function formatDate(date: Date, format: string): string { +export function formatDate(value: unknown, format: string): string { + const date = decodeDate(value); + if (!date) return ""; + const year = date.getFullYear(); const month = date.getMonth(); const day = date.getDate(); diff --git a/tests/e2e/podnotes-runtime.test.ts b/tests/e2e/podnotes-runtime.test.ts index a9eb0b7..78800a1 100644 --- a/tests/e2e/podnotes-runtime.test.ts +++ b/tests/e2e/podnotes-runtime.test.ts @@ -16,7 +16,10 @@ import { waitForPodNotesReady, } from "./harness"; -type PodNotesData = Partial; +type PodNotesData = Partial & { + schemaVersion?: number; + legacyExtension?: { enabled: boolean }; +}; type PlaybackState = { currentTime: number | null; @@ -416,6 +419,97 @@ describe("PodNotes runtime", () => { expect(data.defaultVolume).toBe(1); expect(runtimeVolume).toBe(1); }); + + test("migrates a legacy JSON date through note creation and the next durable save", async () => { + const { obsidian, plugin, sandbox } = getContext(); + const audioPath = await seedAudio(sandbox, "legacy-date-episode.mp3"); + const isoDate = "2024-03-01T10:05:03.000Z"; + const episode = { + ...createLocalEpisode("E2E Legacy Date Episode", audioPath), + episodeDate: isoDate as unknown as Date, + }; + + await seedRuntimeData(plugin, sandbox, episode, { + currentEpisode: episode, + legacyData: true, + note: { + path: sandbox.path("legacy-date-note.md"), + template: "date: {{date:YYYY-MM-DD}}\n", + }, + }); + await waitForPodNotesReady(obsidian); + + const beforeSave = await plugin.data().read(); + expect(beforeSave.schemaVersion).toBeUndefined(); + expect(beforeSave.legacyExtension).toEqual({ enabled: true }); + const runtimeDate = await evalJsonAsync<{ isDate: boolean; iso: string }>( + obsidian, + `(() => { + const value = app.plugins.plugins.${PLUGIN_ID}.settings.currentEpisode.episodeDate; + return { isDate: value instanceof Date, iso: value.toISOString() }; + })()`, + ); + expect(runtimeDate).toEqual({ isDate: true, iso: isoDate }); + + await obsidian.command(`${PLUGIN_ID}:create-podcast-note`).run(); + const note = await sandbox.waitForContent( + "legacy-date-note.md", + (value) => value.includes("date: 2024-03-01"), + WAIT_OPTS, + ); + expect(note).toContain("date: 2024-03-01"); + + await setVolume(obsidian, 0.42); + const persisted = await plugin.waitForData( + (data) => data.schemaVersion === 1 && data.defaultVolume === 0.42, + WAIT_OPTS, + ); + expect(persisted.legacyExtension).toEqual({ enabled: true }); + expect(persisted.currentEpisode?.episodeDate).toBe(isoDate); + expect(await obsidian.dev.runtimeErrors()).toEqual([]); + }); + + test("refuses a future schema without modifying its data", async () => { + const { obsidian, plugin } = getContext(); + const original = await plugin.data().read(); + const future = { + ...original, + schemaVersion: 2, + legacyExtension: { enabled: true }, + }; + + await plugin.disable(); + await plugin.data().write(future); + try { + await plugin.enable().catch(() => undefined); + await obsidian.sleep(200); + + const after = await plugin.data().read(); + const state = await obsidian.dev.evalJson<{ hasCommand: boolean; ready: boolean }>( + `(() => ({ + hasCommand: Boolean(app.commands.commands[${JSON.stringify(`${PLUGIN_ID}:podnotes-show-leaf`)}]), + ready: app.plugins.plugins.${PLUGIN_ID}?.isReady === true, + }))()`, + ); + expect(after).toEqual(future); + expect(state).toEqual({ hasCommand: false, ready: false }); + } finally { + await plugin.data().write(original); + // A failed onload can leave Obsidian's enabled flag set without a live + // plugin instance. Await the plugin manager directly so cleanup cannot + // return before the restored onload finishes. + await evalJsonAsync( + obsidian, + `(async () => { + await app.plugins.disablePlugin(${JSON.stringify(PLUGIN_ID)}); + await app.plugins.enablePlugin(${JSON.stringify(PLUGIN_ID)}); + return Boolean(app.plugins.plugins.${PLUGIN_ID}); + })()`, + ); + await waitForPodNotesReady(obsidian); + await obsidian.dev.resetDiagnostics().catch(() => undefined); + } + }); }); async function seedAudio(sandbox: SandboxApi, fileName: string): Promise { @@ -504,6 +598,7 @@ async function seedRuntimeData( note?: { path: string; template: string }; played?: { duration: number; time: number }; timestampTemplate?: string; + legacyData?: boolean; } = {}, ): Promise { const placeholderEpisode = createLocalEpisode( @@ -517,6 +612,10 @@ async function seedRuntimeData( } await plugin.updateDataAndReload((data) => { + if (options.legacyData) { + delete data.schemaVersion; + data.legacyExtension = { enabled: true }; + } data.currentEpisode = options.currentEpisode ?? placeholderEpisode; data.defaultPlaybackRate = options.defaultPlaybackRate ?? 1; data.defaultVolume = 1;