diff --git a/scripts/persistence-size.test.ts b/scripts/persistence-size.test.ts index cafb3c3a..671f97ad 100644 --- a/scripts/persistence-size.test.ts +++ b/scripts/persistence-size.test.ts @@ -66,7 +66,7 @@ describe("persistence byte fixture", () => { expect(decoded.changed).toBe(false); expect( JSON.parse(JSON.stringify(encodePodNotesData(decoded.settings, decoded.unknownFields))), - ).toEqual(data); + ).toEqual({ ...data, libraryId: "" }); expect(Object.keys(data.savedFeeds)).toHaveLength(50); expect(Object.keys(data.playedEpisodes)).toHaveLength(2000); const downloads = Object.values(decoded.settings.downloadedEpisodes).flat(); diff --git a/src/main.persistence.test.ts b/src/main.persistence.test.ts index 862d3074..ad79197d 100644 --- a/src/main.persistence.test.ts +++ b/src/main.persistence.test.ts @@ -801,3 +801,201 @@ describe("PodNotes persistence integration", () => { expect(secondResolved).toBe(true); }); }); + +describe("a device that started before sync delivered its data.json", () => { + const feed = (title: string) => ({ + title, + url: `https://example.com/${title}.xml`, + artworkUrl: "", + }); + const library = JSON.parse( + JSON.stringify( + encodePodNotesData({ + ...structuredClone(DEFAULT_SETTINGS), + savedFeeds: { "Library Feed": feed("Library Feed") }, + playedEpisodes: { + "Library Feed::Episode": { + title: "Episode", + podcastName: "Library Feed", + time: 1234, + duration: 3600, + finished: false, + }, + }, + playlists: { + "Road Trip": { + icon: "list", + name: "Road Trip", + shouldEpisodeRemoveAfterPlay: false, + shouldRepeat: false, + episodes: [], + }, + }, + defaultPlaybackRate: 1.5, + }), + ), + ); + + async function startDevice(data?: unknown) { + let disk = structuredClone(data); + const plugin = makePlugin(); + const saveData = vi.fn(async (next: unknown) => { + disk = JSON.parse(JSON.stringify(next)); + }); + Object.assign(plugin, { loadData: vi.fn(async () => structuredClone(disk)), saveData }); + Object.assign(plugin.app.vault.adapter, { + exists: vi.fn(async () => disk !== undefined), + read: vi.fn(async () => dataFileText(disk)), + }); + await plugin.loadSettings(); + attachDataFileWriter(plugin); + return { + plugin, + file: () => structuredClone(disk), + writes: () => saveData.mock.calls.length, + receive: async (file: unknown) => { + disk = structuredClone(file); + await plugin.onExternalSettingsChange(); + await plugin.flushSaves(); + }, + }; + } + + function expectBothLibraries(...devices: Array<{ plugin: PodNotes }>) { + for (const { plugin } of devices) { + expect(Object.keys(plugin.settings.savedFeeds).sort()).toEqual([ + "Library Feed", + "New Device Feed", + ]); + expect(plugin.settings.playedEpisodes["Library Feed::Episode"]?.time).toBe(1234); + expect(Object.keys(plugin.settings.playlists)).toEqual(["Road Trip"]); + expect(plugin.settings.defaultPlaybackRate).toBe(1.5); + } + } + + it("keeps both libraries when the new device's file reaches the library first", async () => { + const established = await startDevice(library); + const newDevice = await startDevice(); + newDevice.plugin.settings.savedFeeds = { "New Device Feed": feed("New Device Feed") }; + await newDevice.plugin.saveSettingsStrict(); + + await established.receive(newDevice.file()); + await newDevice.receive(established.file()); + + expectBothLibraries(established, newDevice); + expect(newDevice.plugin.settings).toEqual(established.plugin.settings); + }); + + it("keeps both libraries when the library reaches the new device first", async () => { + const established = await startDevice(library); + const newDevice = await startDevice(); + newDevice.plugin.settings.savedFeeds = { "New Device Feed": feed("New Device Feed") }; + await newDevice.plugin.saveSettingsStrict(); + + await newDevice.receive(established.file()); + await established.receive(newDevice.file()); + + expectBothLibraries(established, newDevice); + expect(newDevice.plugin.settings).toEqual(established.plugin.settings); + }); + + it("keeps the library when the file a new device wrote at install reaches it", async () => { + const established = await startDevice(library); + const newDevice = await startDevice(); + + await established.receive(newDevice.file()); + await newDevice.receive(established.file()); + + for (const { plugin } of [established, newDevice]) { + expect(Object.keys(plugin.settings.savedFeeds)).toEqual(["Library Feed"]); + expect(plugin.settings.playedEpisodes["Library Feed::Episode"]?.time).toBe(1234); + expect(plugin.settings.defaultPlaybackRate).toBe(1.5); + } + }); + + it("still takes a podcast removed on another device of the same library", async () => { + const established = await startDevice(library); + const newDevice = await startDevice(); + await newDevice.receive(established.file()); + + delete newDevice.plugin.settings.savedFeeds["Library Feed"]; + await newDevice.plugin.saveSettingsStrict(); + await established.receive(newDevice.file()); + + expect(established.plugin.settings.savedFeeds).toEqual({}); + }); + + it("settles on one file when the two devices take each other's files at once", async () => { + const queued = (title: string) => ({ + title, + podcastName: "Pod", + streamUrl: `https://example.com/${title}.mp3`, + url: "", + description: "", + content: "", + }); + const established = await startDevice(library); + established.plugin.settings.queue.episodes = [queued("Library Episode")]; + await established.plugin.saveSettingsStrict(); + const newDevice = await startDevice(); + newDevice.plugin.settings.savedFeeds = { "New Device Feed": feed("New Device Feed") }; + newDevice.plugin.settings.queue.episodes = [queued("New Device Episode")]; + await newDevice.plugin.saveSettingsStrict(); + + const [libraryFile, newDeviceFile] = [established.file(), newDevice.file()]; + await established.receive(newDeviceFile); + await newDevice.receive(libraryFile); + const writes = [established.writes(), newDevice.writes()]; + await newDevice.receive(established.file()); + + expectBothLibraries(established, newDevice); + expect(newDevice.plugin.settings).toEqual(established.plugin.settings); + expect([established.writes(), newDevice.writes()]).toEqual(writes); + }); + + it("keeps the new device's podcast when Obsidian Sync key-merges the files on the library's device", async () => { + const established = await startDevice(library); + established.plugin.settings.defaultVolume = 0.5; + await established.plugin.saveSettingsStrict(); + const newDevice = await startDevice(); + newDevice.plugin.settings.savedFeeds = { "New Device Feed": feed("New Device Feed") }; + await newDevice.plugin.saveSettingsStrict(); + + const keyMerged = { ...(newDevice.file() as object), ...(established.file() as object) }; + await newDevice.receive(keyMerged); + await established.receive(keyMerged); + await established.receive(newDevice.file()); + await newDevice.receive(established.file()); + + expectBothLibraries(established, newDevice); + expect(newDevice.plugin.settings).toEqual(established.plugin.settings); + }); + + it("keeps the library when a device that could not read its file at start writes a new one", async () => { + const established = await startDevice(library); + const device = makePlugin(); + let text = '{"schemaVersion": 2, "savedFeeds": {"Library Fe'; + Object.assign(device, { + loadData: vi.fn(async () => (text.endsWith("}") ? JSON.parse(text) : undefined)), + saveData: vi.fn(async (next: unknown) => { + text = dataFileText(next); + }), + }); + Object.assign(device.app.vault.adapter, { + exists: vi.fn(async () => text !== ""), + read: vi.fn(async () => text), + }); + vi.spyOn(console, "error").mockImplementation(() => undefined); + await device.loadSettings(); + attachDataFileWriter(device); + + text = ""; + await device.onExternalSettingsChange(); + device.settings.defaultVolume = 0.5; + await device.saveSettingsStrict(); + await established.receive(JSON.parse(text)); + + expect(Object.keys(established.plugin.settings.savedFeeds)).toEqual(["Library Feed"]); + expect(established.plugin.settings.defaultVolume).toBe(0.5); + }); +}); diff --git a/src/main.ts b/src/main.ts index b2c2857f..94ae8d7b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -37,7 +37,9 @@ import { decodePodNotesData, encodePodNotesData, PODNOTES_DATA_SCHEMA_VERSION, + newLibraryId, PodNotesDataError, + toDocument, type PersistedPodNotesDataV2, } from "./persistence/podNotesData"; import { jsonValuesEqual, restoreDropped } from "./persistence/merge"; @@ -91,14 +93,6 @@ function holdsData(text: string, data: unknown): boolean { } } -/** data.json as plain JSON: what the merge compares and what saveData writes. */ -function toDocument( - settings: IPodNotesSettings, - unknownFields: Record, -): PersistedPodNotesDataV2 { - return JSON.parse(JSON.stringify(encodePodNotesData(settings, unknownFields))); -} - class PodNotesSchemaMigrationError extends Error { constructor( public readonly originalCause: unknown, @@ -513,7 +507,7 @@ export default class PodNotes extends Plugin implements IPodNotes { // not read or parse. if (data == null && !(await this.dataFileHoldsNothing())) { this.settings = structuredClone(DEFAULT_SETTINGS); - this.persistenceUnknownFields = {}; + this.persistenceUnknownFields = { libraryId: newLibraryId() }; this.refuseSaves( "unreadable-data-file", `PodNotes could not read ${this.dataFilePath()}, so it left the file unchanged and started with default settings. Nothing is saved until the file can be read again, or until you delete it and restart Obsidian.`, diff --git a/src/persistence/merge.test.ts b/src/persistence/merge.test.ts index 6883225d..b7ca6b8a 100644 --- a/src/persistence/merge.test.ts +++ b/src/persistence/merge.test.ts @@ -3,7 +3,7 @@ import { DEFAULT_SETTINGS } from "src/constants"; import type { PlayedEpisode } from "src/types/PlayedEpisode"; import type { PodcastFeed } from "src/types/PodcastFeed"; import type { PersistedEpisode, PersistedPlaylist } from "./episodeCodec"; -import { jsonValuesEqual, restoreDropped, threeWayMergeSettings } from "./merge"; +import { jsonValuesEqual, mergeDataFile, restoreDropped, threeWayMergeSettings } from "./merge"; import { encodePodNotesData, type PersistedPodNotesDataV2 } from "./podNotesData"; type Data = PersistedPodNotesDataV2; @@ -485,6 +485,91 @@ describe("threeWayMergeSettings", () => { }); }); +describe("mergeDataFile", () => { + const earlierId = "2026-10-11T09:00:00.000Z 0b9d"; + const laterId = "2026-10-11T10:00:00.000Z 41c7"; + + function library(podcastName: string, libraryId: string): Data { + const doc: Data = JSON.parse( + JSON.stringify(encodePodNotesData(structuredClone(DEFAULT_SETTINGS), { libraryId })), + ); + doc.savedFeeds = { [podcastName]: feed(podcastName) }; + doc.playedEpisodes = { + [`${podcastName}::Ep 1`]: { ...played("Ep 1", 10), podcastName }, + }; + doc.queue.episodes = [episode("Ep 1", podcastName)]; + return doc; + } + + it.each([ + ["a library started before ids", ""], + ["a library with an id", earlierId], + ])("takes a podcast another device of %s removed", (_, libraryId) => { + const base = { ...data(), libraryId }; + const disk = structuredClone(base); + delete disk.savedFeeds["Pod A"]; + + expect(mergeDataFile(base, structuredClone(base), disk).savedFeeds).toEqual({}); + }); + + it("keeps every entry of two libraries that meet, whichever device merges", () => { + const started = library("Pod A", ""); + started.defaultPlaybackRate = 1.5; + const joined = library("Pod B", earlierId); + + for (const [local, disk] of [ + [started, joined], + [joined, started], + ]) { + const merged = mergeDataFile(local, structuredClone(local), disk); + expect(Object.keys(merged.savedFeeds).sort()).toEqual(["Pod A", "Pod B"]); + expect(Object.keys(merged.playedEpisodes).sort()).toEqual([ + "Pod A::Ep 1", + "Pod B::Ep 1", + ]); + expect(merged.queue.episodes.map((entry) => entry.podcastName).sort()).toEqual([ + "Pod A", + "Pod B", + ]); + expect(merged.defaultPlaybackRate).toBe(1.5); + } + }); + + it("keeps the lineage of the library started first", () => { + const unmarked = library("Pod A", ""); + const earlier = library("Pod B", earlierId); + const later = library("Pod C", laterId); + + expect(mergeDataFile(unmarked, unmarked, earlier).libraryId).toBe(""); + expect(mergeDataFile(earlier, earlier, unmarked).libraryId).toBe(""); + expect(mergeDataFile(later, later, earlier).libraryId).toBe(earlierId); + expect(mergeDataFile(earlier, earlier, later).libraryId).toBe(earlierId); + }); + + it("keeps the earlier library memory joined but has not saved yet when another arrives", () => { + const earlier = library("Pod B", earlierId); + const joinedUnmarked = { ...earlier, libraryId: "" }; + + expect(mergeDataFile(earlier, joinedUnmarked, library("Pod C", laterId)).libraryId).toBe( + "", + ); + }); + + it("keeps the episodes of a playlist both libraries created under one name", () => { + const started = library("Pod A", ""); + const joined = library("Pod B", earlierId); + started.playlists = { "Road Trip": playlist("Road Trip", [episode("Ep 1", "Pod A")]) }; + joined.playlists = { "Road Trip": playlist("Road Trip", [episode("Ep 1", "Pod B")]) }; + + const merged = mergeDataFile(started, structuredClone(started), joined); + + expect(merged.playlists["Road Trip"].episodes.map((entry) => entry.podcastName)).toEqual([ + "Pod A", + "Pod B", + ]); + }); +}); + describe("restoreDropped", () => { it("puts back the entries the codec dropped and keeps every entry still there whole", () => { const base = data(); diff --git a/src/persistence/merge.ts b/src/persistence/merge.ts index b68a68e7..200b9949 100644 --- a/src/persistence/merge.ts +++ b/src/persistence/merge.ts @@ -1,6 +1,8 @@ +import { DEFAULT_SETTINGS } from "src/constants"; import type { Episode } from "src/types/Episode"; import { getEpisodeKey } from "src/utility/episodeKey"; import { isPlainObject, type UnknownRecord } from "./codecUtils"; +import { toDocument } from "./podNotesData"; /** * Structural equality for JSON-like settings values. Key order does not matter; @@ -175,11 +177,23 @@ export function threeWayMergeSettings( if (jsonValuesEqual(disk, base)) return structuredClone(local); if (isAtomicPath(path)) return mergeWhole(base, local, disk); - if (isPlainObject(base) && isPlainObject(local) && isPlainObject(disk)) { - const keys = new Set([...Object.keys(base), ...Object.keys(local), ...Object.keys(disk)]); + if ( + (base === undefined || isPlainObject(base)) && + isPlainObject(local) && + isPlainObject(disk) + ) { + const ancestor: UnknownRecord = base ?? {}; + const keys = new Set([ + ...Object.keys(ancestor), + ...Object.keys(local), + ...Object.keys(disk), + ]); const merged: UnknownRecord = {}; for (const key of keys) { - const value = threeWayMergeSettings(base[key], local[key], disk[key], [...path, key]); + const value = threeWayMergeSettings(ancestor[key], local[key], disk[key], [ + ...path, + key, + ]); // JSON has no undefined: a merged-away key must be absent, or the // written document never equals the same document read back. if (value !== undefined) merged[key] = value; @@ -202,6 +216,20 @@ export function threeWayMergeSettings( return structuredClone(local); } +export function mergeDataFile(base: T, local: T, disk: T): T { + const libraryOf = (doc: T) => (doc as UnknownRecord).libraryId as string; + if (libraryOf(base) === libraryOf(disk)) return threeWayMergeSettings(base, local, disk); + + const emptyLibrary = toDocument(DEFAULT_SETTINGS) as T; + const merged = threeWayMergeSettings(emptyLibrary, local, disk) as UnknownRecord; + merged.libraryId = startedFirst(libraryOf(local), libraryOf(disk)); + return merged as T; +} + +function startedFirst(library: string, other: string): string { + return library < other ? library : other; +} + /** * `disk` with the entries the codec dropped while reading `raw` put back from * `base`: map entries, and episode list entries by episode key. A dropped entry diff --git a/src/persistence/podNotesData.test.ts b/src/persistence/podNotesData.test.ts index 52b811ed..38606445 100644 --- a/src/persistence/podNotesData.test.ts +++ b/src/persistence/podNotesData.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { DEFAULT_SETTINGS } from "src/constants"; import type DownloadedEpisode from "src/types/DownloadedEpisode"; import type { Episode } from "src/types/Episode"; @@ -79,6 +79,44 @@ describe("PodNotes data schema", () => { expect(second.settings.queue.episodes).toEqual([]); }); + it("gives each new install its own library id, sorting after earlier installs and every unmarked library", () => { + vi.spyOn(crypto, "randomUUID") + .mockReturnValueOnce("ffffffff-ffff-4fff-bfff-ffffffffffff") + .mockReturnValue("00000000-0000-4000-8000-000000000000"); + vi.setSystemTime(new Date("2026-10-11T09:00:00.000Z")); + const [earlier, sameInstant] = [decodePodNotesData(null), decodePodNotesData(undefined)]; + vi.setSystemTime(new Date("2026-10-11T10:00:00.000Z")); + const later = decodePodNotesData(null); + vi.useRealTimers(); + vi.restoreAllMocks(); + const [earlierId, sameInstantId, laterId] = [earlier, sameInstant, later].map( + ({ unknownFields }) => unknownFields.libraryId, + ); + + expect([earlierId, sameInstantId, laterId]).toEqual(Array(3).fill(expect.any(String))); + expect(sameInstantId).not.toBe(earlierId); + expect(["", earlierId, laterId].sort()).toEqual(["", earlierId, laterId]); + expect(encodePodNotesData(later.settings, later.unknownFields).libraryId).toBe(laterId); + }); + + it.each([ + ["names none", undefined, ""], + ["names one that is not text", 7, ""], + ["names one", "2026-10-11T09:00:00.000Z 0b9d", "2026-10-11T09:00:00.000Z 0b9d"], + ])( + "keeps the library id of a file that %s for the save to write back", + (_, libraryId, kept) => { + const file = JSON.parse( + JSON.stringify({ ...encodePodNotesData(DEFAULT_SETTINGS), libraryId }), + ); + const decoded = decodePodNotesData(file); + + expect(encodePodNotesData(decoded.settings, decoded.unknownFields).libraryId).toBe( + kept, + ); + }, + ); + it("round-trips every persisted episode container with canonical dates", () => { const settings = settingsWithEveryEpisodeContainer(); const persisted = encodePodNotesData(settings); diff --git a/src/persistence/podNotesData.ts b/src/persistence/podNotesData.ts index fa647d87..bca9b335 100644 --- a/src/persistence/podNotesData.ts +++ b/src/persistence/podNotesData.ts @@ -61,8 +61,15 @@ export type PersistedPodNotesSettings = Omit< export type PersistedPodNotesDataV2 = PersistedPodNotesSettings & UnknownRecord & { schemaVersion: typeof PODNOTES_DATA_SCHEMA_VERSION; + libraryId: string; }; +const LIBRARY_BEFORE_IDS = ""; + +export function newLibraryId(): string { + return `${new Date().toISOString()} ${crypto.randomUUID()}`; +} + /** * Decode and validate plugin data before any store sees it. * @@ -72,8 +79,9 @@ export type PersistedPodNotesDataV2 = PersistedPodNotesSettings & * does not understand. */ export function decodePodNotesData(value: unknown): DecodedPodNotesData { - // No data.json yet is a new install, which starts from the current defaults. - if (value === null || value === undefined) value = structuredClone(DEFAULT_SETTINGS); + if (value === null || value === undefined) { + value = { ...structuredClone(DEFAULT_SETTINGS), libraryId: newLibraryId() }; + } if (!isPlainObject(value)) { throw new PodNotesDataError( @@ -120,6 +128,10 @@ export function encodePodNotesData( ...copyUnknownRootFields(unknownFields), ...validated, schemaVersion: PODNOTES_DATA_SCHEMA_VERSION, + libraryId: + typeof unknownFields.libraryId === "string" + ? unknownFields.libraryId + : LIBRARY_BEFORE_IDS, currentEpisode: validated.currentEpisode ? encodeEpisode(validated.currentEpisode) : undefined, @@ -133,6 +145,13 @@ export function encodePodNotesData( }; } +export function toDocument( + settings: IPodNotesSettings, + unknownFields?: UnknownRecord, +): PersistedPodNotesDataV2 { + return JSON.parse(JSON.stringify(encodePodNotesData(settings, unknownFields))); +} + function readSchemaVersion(root: UnknownRecord): PodNotesDataSchemaVersion { if (!Object.prototype.hasOwnProperty.call(root, "schemaVersion")) return 0; diff --git a/src/persistence/writer.test.ts b/src/persistence/writer.test.ts index b07e7975..11d81084 100644 --- a/src/persistence/writer.test.ts +++ b/src/persistence/writer.test.ts @@ -249,6 +249,41 @@ describe("DataFileWriter saves", () => { expect(state.disk).toEqual(doc()); }); + it("keeps a rollback when the file a failed write reached is reported as changed", async () => { + const { access, state, writer } = setup(); + access.write.mockImplementationOnce(async (next: Doc) => { + state.disk = structuredClone(next); + throw new Error("could not verify"); + }); + state.memory.defaultVolume = 0.5; + await expect(writer.save()).rejects.toThrow("could not verify"); + + state.memory.defaultVolume = 1; + await writer.onExternalChange(); + await writer.save(); + + expect(state.memory).toEqual(doc()); + expect(state.disk).toEqual(doc()); + }); + + it("keeps a removal made after a change reported elsewhere showed a failed write reached disk", async () => { + const { access, state, writer } = setup(); + access.write.mockImplementationOnce(async (next: Doc) => { + state.disk = structuredClone(next); + throw new Error("could not verify"); + }); + state.memory.defaultVolume = 0.5; + state.disk!.savedFeeds.Synced = { title: "Synced" }; + await expect(writer.save()).rejects.toThrow("could not verify"); + + await writer.onExternalChange(); + delete state.memory.savedFeeds.Synced; + await writer.save(); + + expect(state.memory.savedFeeds.Synced).toBeUndefined(); + expect(state.disk!.savedFeeds.Synced).toBeUndefined(); + }); + it("keeps a change made elsewhere that a failed write carried to disk", async () => { const { access, state, writer } = setup(); access.write.mockImplementationOnce(async (next: Doc) => { @@ -360,7 +395,6 @@ describe("DataFileWriter changes made elsewhere", () => { await writer.onExternalChange(); expect(state.memory.savedFeeds.Synced).toEqual({ title: "Synced" }); - expect(writer.hasBase).toBe(true); expect(clock.pending()).toBe(0); expect(state.writes).toHaveLength(0); }); @@ -415,7 +449,8 @@ describe("DataFileWriter changes made elsewhere", () => { await writer.onExternalChange(); expect(file.state.memory).toEqual(file.state.disk); - expect(writer.hasBase).toBe(true); + await writer.save(); + expect(file.access.write).not.toHaveBeenCalled(); }); }); diff --git a/src/persistence/writer.ts b/src/persistence/writer.ts index 4b34b621..934fbd62 100644 --- a/src/persistence/writer.ts +++ b/src/persistence/writer.ts @@ -1,5 +1,5 @@ import { toError } from "src/utility/toError"; -import { jsonValuesEqual, threeWayMergeSettings } from "./merge"; +import { jsonValuesEqual, mergeDataFile, threeWayMergeSettings } from "./merge"; /** Timers of the main window. A popout's timers stop when the popout closes. */ export interface Clock { @@ -56,11 +56,6 @@ export class DataFileWriter { private base: Doc | null, ) {} - /** Whether a readable data.json has been loaded or written. */ - get hasBase(): boolean { - return this.base !== null; - } - /** * Save after any save already running; changes made until it starts share it. * Inside `checkpoint`, wait for the checkpoint interval instead. @@ -182,15 +177,13 @@ export class DataFileWriter { this.lastSaveAt = this.clock.now(); const disk = await this.file.read(this.base); - if (this.failedWrite && disk && jsonValuesEqual(disk, this.failedWrite.written)) { - this.settle(this.failedWrite.local, this.failedWrite.written); - } + this.settleFailedWrite(disk); const base = this.base; // Captured after the read, so edits made while it was in flight are written too. const local = this.file.local(); let toWrite = local; if (disk && base) { - toWrite = threeWayMergeSettings(base, local, disk); + toWrite = mergeDataFile(base, local, disk); } else if (!disk && this.failedWrite) { // A failed write that left nothing on disk held the changes made elsewhere: // write them again, with the edits made here since. @@ -223,6 +216,12 @@ export class DataFileWriter { ); } + private settleFailedWrite(disk: Doc | null): void { + if (!this.failedWrite || !disk || !jsonValuesEqual(disk, this.failedWrite.written)) return; + this.settle(this.failedWrite.local, this.failedWrite.written); + this.failedWrite = null; + } + private async applyExternalChange(): Promise { let disk: Doc | null; try { @@ -234,12 +233,13 @@ export class DataFileWriter { } // A missing file is not a request to reset everything. if (!disk || this.unloaded) return; + this.settleFailedWrite(disk); const base = this.base; if (base && jsonValuesEqual(disk, base)) return; const local = this.file.local(); - const merged = base ? threeWayMergeSettings(base, local, disk) : structuredClone(disk); + const merged = base ? mergeDataFile(base, local, disk) : structuredClone(disk); this.base = disk; if (!jsonValuesEqual(merged, local)) this.publish(merged); if (jsonValuesEqual(merged, disk)) this.cancelTimer(); diff --git a/tests/e2e/sync-persistence.test.ts b/tests/e2e/sync-persistence.test.ts index 2ee3283d..0c866a5d 100644 --- a/tests/e2e/sync-persistence.test.ts +++ b/tests/e2e/sync-persistence.test.ts @@ -1,6 +1,6 @@ -import { readFile, writeFile } from "node:fs/promises"; +import { readFile, rm, writeFile } from "node:fs/promises"; import type { PluginHandle } from "obsidian-e2e"; -import { describe, expect, test } from "vitest"; +import { describe, expect, onTestFinished, test } from "vitest"; import type { Episode } from "../../src/types/Episode"; import type { LocalEpisode } from "../../src/types/LocalEpisode"; import type { PodcastFeed } from "../../src/types/PodcastFeed"; @@ -18,6 +18,17 @@ import { waitForPlaybackState, waitForPodNotesReady, } from "./harness"; +import { + assertNoRuntimeErrors, + click, + closeSettings, + listedEpisodes, + openPodNotesSettings, + showPodNotes, + ui, +} from "./journey/actions"; +import { type FixtureFeed, pubDate, startFixtureServer } from "./journey/fixtures"; +import { seedLibrary, waitForLibrary } from "./journey/library"; // Obsidian calls Plugin#onExternalSettingsChange when data.json is newer than // the plugin's last write: Obsidian Sync, iCloud, another instance on the vault, @@ -328,6 +339,113 @@ describe("PodNotes data.json changed elsewhere", () => { })()`); } }); + + async function startAsNewDevice(plugin: PluginHandle): Promise { + // A write through the harness first, so it restores this file after the test. + await plugin.data().patch((data) => data); + await rm(await plugin.dataPath()); + await plugin.reload(RELOAD_OPTIONS); + const file = await plugin.data().read(); + expect(file.savedFeeds).toEqual({}); + expect(file.libraryId).toMatch(/^\d{4}-/); + return file; + } + + test("keeps its library when a new device's file replaces data.json", async () => { + const { obsidian, plugin } = getContext(); + const library = await plugin.data().read(); + addSyncedChanges(library); + const newDeviceFile = await startAsNewDevice(plugin); + await plugin.data().write(library); + await plugin.reload(RELOAD_OPTIONS); + + await plugin.data().write(newDeviceFile); + + const disk = await plugin.waitForData( + (data) => Boolean(data.savedFeeds?.[SYNCED_FEED.title]), + WAIT_OPTS, + ); + expect(disk.playedEpisodes?.["Synced Podcast::Synced Episode"]).toEqual(SYNCED_PROGRESS); + expect(await obsidian.dev.evalJson(MEMORY)).toMatchObject({ + feed: true, + playlist: true, + progress: 1234, + }); + }); + + test("keeps a new device's change when the library replaces its data.json", async () => { + const { obsidian, plugin } = getContext(); + const library = await plugin.data().read(); + addSyncedChanges(library); + await startAsNewDevice(plugin); + await setVolume(obsidian, 0.42); + await plugin.waitForData((data) => data.defaultVolume === 0.42, WAIT_OPTS); + + await plugin.data().write(library); + + const disk = await plugin.waitForData( + (data) => data.defaultVolume === 0.42 && Boolean(data.savedFeeds?.[SYNCED_FEED.title]), + WAIT_OPTS, + ); + expect(disk.playedEpisodes?.["Synced Podcast::Synced Episode"]).toEqual(SYNCED_PROGRESS); + expect(await obsidian.dev.evalJson(MEMORY)).toMatchObject({ + feed: true, + playlist: true, + progress: 1234, + volume: 0.42, + }); + }); + + test("shows both names when the library saved a podcast under an earlier name than a new device", async () => { + const context = getContext(); + const { obsidian, plugin } = context; + const current: FixtureFeed = { + title: "Current Show Name", + slug: "renamed-show", + artwork: true, + episodes: [{ title: "Renamed Show Episode", seconds: 6, pubDate: pubDate(0) }], + }; + const earlier: FixtureFeed = { ...current, title: "Earlier Show Name" }; + const server = await startFixtureServer([current]); + onTestFinished(() => server.stop()); + onTestFinished(() => closeSettings(obsidian)); + await seedLibrary(context, server, { subscriptions: [earlier] }); + const library = await plugin.data().read(); + const newDeviceFile = await startAsNewDevice(plugin); + newDeviceFile.savedFeeds = { + [current.title]: { + title: current.title, + url: server.feedUrl(current), + artworkUrl: server.artworkUrl(current), + }, + }; + await plugin.data().write(library); + await plugin.reload(RELOAD_OPTIONS); + + await plugin.data().write(newDeviceFile); + + const merged = await waitForLibrary( + plugin, + (saved) => saved.subscriptions.length === 2, + "both names of the podcast saved", + ); + expect(merged.subscriptions.map((feed) => feed.url)).toEqual([ + server.feedUrl(current), + server.feedUrl(current), + ]); + await showPodNotes(obsidian); + for (const title of [earlier.title, current.title]) { + await click(obsidian, ui.podcastGridTab); + await click(obsidian, ui.podcastCard(title)); + await obsidian.waitFor(async () => (await listedEpisodes(obsidian)).length > 0, { + timeoutMs: 15_000, + message: `the episodes of ${title}`, + }); + expect(await listedEpisodes(obsidian)).toEqual(["Renamed Show Episode"]); + } + await openPodNotesSettings(obsidian); + await assertNoRuntimeErrors(obsidian); + }); }); describe("PodNotes queue identity", () => { diff --git a/tests/fixtures/persistence/large-v2.mjs b/tests/fixtures/persistence/large-v2.mjs index 347385e9..99b1f927 100644 --- a/tests/fixtures/persistence/large-v2.mjs +++ b/tests/fixtures/persistence/large-v2.mjs @@ -7,7 +7,7 @@ export function createLargeV2Fixture(includeGuid = false) { /** @type {{feed: import("../../../src/types/PodcastFeed").PodcastFeed, episodes: SavedEpisode[], guids: string[]}} */ const sample = JSON.parse(readFileSync("tests/fixtures/persistence/feed-sample.json", "utf8")); const data = - /** @type {import("../../../src/persistence/podNotesData").PersistedPodNotesDataV2} */ ({ + /** @type {import("../../../src/persistence/podNotesData").PersistedPodNotesSettings & { schemaVersion: 2 }} */ ({ ...structuredClone(config.defaultData), schemaVersion: 2, });