Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion scripts/persistence-size.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
198 changes: 198 additions & 0 deletions src/main.persistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
12 changes: 3 additions & 9 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string, unknown>,
): PersistedPodNotesDataV2 {
return JSON.parse(JSON.stringify(encodePodNotesData(settings, unknownFields)));
}

class PodNotesSchemaMigrationError extends Error {
constructor(
public readonly originalCause: unknown,
Expand Down Expand Up @@ -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.`,
Expand Down
87 changes: 86 additions & 1 deletion src/persistence/merge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading