Repository navigation
fix(persistence): merge data.json with changes made on other devices instead of overwriting them - #388
Merged
Conversation
…ickAdd Port QuickAdd's settingsPersistMerge to PodNotes' data.json document: equal sides and unchanged-side shortcuts, plain objects merged per key, and local winning a conflict. Episode lists (queue, favorites, Local Files, playlists, per-podcast downloads) merge per episode by its podcastName and title key. A current episode, a progress record, and an episode entry both sides changed are taken whole from this device, so no merged record mixes fields from two devices.
…instead of overwriting them PodNotes rewrote all of data.json about every 5 seconds during playback from memory, so progress, feeds and playlists another device synced in were lost at the next save. It had no onExternalSettingsChange, and the player's save on hide listened to the main window even in a popout. Saves now go through a writer ported from QuickAdd: one promise chain, a 1 s debounce on the main window's timers, a disk read right before each write, and a three-way merge of what was last loaded or written, memory and disk. Memory takes the merged changes only after the write lands. onExternalSettingsChange merges a synced file into memory without changing the loaded episode, its position or the queue, and adopts a data.json that could not be read at load once it becomes readable. A value the codec had to repair is not merged as a deletion. Saves flush on hide of the player's own window (also after a move into a popout), on hide of the app, and on unload; on quit the pending save is written through Obsidian's awaited quit task and nothing starts after it. Fixes #357
Deploying podnotes with
|
| Latest commit: |
27610ad
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://714a4f20.podnotes.pages.dev |
| Branch Preview URL: | https://fix-sync-safe-persistence.podnotes.pages.dev |
Same-titled episodes from two podcasts can share the queue now, and a title-keyed each block failed with each_key_duplicate and rendered no rows.
…on that cannot be taken as is - Settings changes save as soon as they happen, as before this branch; only the player's periodic position is a checkpoint, written at most every 10 s. Closing the main window right after a change keeps it, and quit goes back to starting no write and waiting for one in flight (#370). - Closing the player, in a popout too, saves its position at once. - No recovery that writes over a data.json: a file cut short by a failed write, or one another writer left unreadable, is never written over, and a file that could not be read at startup is taken only once it reads without repair. - A data.json an older version synced in is never written over: saves stop with a Notice until a restart moves its API keys into SecretStorage. - A synced episode whose title or podcast name is not text no longer breaks every save.
…anges save at once The harness read data.json while the edit's immediate save was writing it.
…readable, and resume once it reads Without the recovery that rewrote a file this device cut short, every save failed silently until a restart, by which time the file was lost.
A write cut off on a full disk leaves 0 bytes, which hold nothing to lose: the next save writes everything in memory, and a start proceeds as on a new install. A non-empty file that cannot be read is still never written over.
…file, and refuse on read errors - A failed write that left data.json empty held the changes made on other devices; the retry writes them again, with the edits made since. - An unreadable-file refusal lifts once the file is missing or empty. - A read that fails, as on a permission error, refuses saves with the Notice instead of treating the file as empty, and lifts on the next good read.
… and leave loadData errors as they were A refusal ends with the plugin instance, but its sticky Notice outlived a disable or reload. Obsidian's loadData already resolves undefined when it cannot read the file, so its rejections keep their own error.
This was referenced Oct 11, 2026
This was referenced Oct 13, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Changes made on another device (Obsidian Sync, iCloud, a second Obsidian on the vault) now survive PodNotes' next save and show up without a restart. The player in a popout window saves its position when that window is hidden or closed. Two same-titled episodes from different podcasts can both stay in the queue, and the reorder modal lists both.
Fixes #357
Fixes #356
How it works, ported from QuickAdd (d418f01f, 46753e48, 40dd5b4f, 0142db3d):
podcastName::title). An entry both devices changed is taken whole from this device: a progress record, a saved feed (its URL and private-feed secret belong together), a note mapping, a current episode, and each episode in the queue, favorites, Local Files, playlists and downloads (so a stream URL, a file path and the "downloaded by PodNotes" mark never come from two devices). An entry PodNotes had to drop while reading an invalid file counts as kept, not deleted; an entry the file does not have is a deletion. Unknown fields at every level survive.onExternalSettingsChangemerges the synced file into memory and updates the feed, playlist, queue, favorites, downloads and played stores. It never switches the playing episode, seeks, or touches the queue order.saveDataswallows write errors, so PodNotes reads the file back after every write and treats a mismatch as a failed save. It never writes over a non-empty data.json it cannot read, or cannot check (a permission error, say): saves stop with a Notice and resume once the file reads again, or is missing or empty. An empty data.json, which a write cut off on a full disk leaves, holds nothing to lose and is treated like a missing one; the next save writes it again with what the failed write held, changes from other devices included. It never writes over a data.json from an older version (one synced in from another device): saves stop with a Notice until a restart, whose migration moves the file's API keys into SecretStorage. A data.json that could not be read at startup is taken over only once it reads without any repair.Limits of sync support:
Scope: data.json stays schema v2 with no format change. Not included: atomic file replace (deferred), and two grid issues found while testing (a podcast whose artwork has not loaded, or fails to load, is hidden from the grid, and the card keeps a failed state when the artwork URL changes).
Verification
Runtime: Obsidian 1.13.7 (installer 1.13.7), Linux, headless isolated worktree instance. Feeds: the verify-skill fixtures
PodNotes Fixtureand the newPodNotes Fixture B(fixtures.mjs feed-b, same episode titles), 60 s WAV episodes, added through Settings and played from the episode list. The "other device" is a script that rewrites data.json with a temp file and rename, like a sync client.onExternalSettingsChange.electronWindow.close()50 ms after a volume change, three traced runs: the save started about 1 ms after the change and finished beforequit; no write at or afterpagehide; data.json complete with the new volume.tests/e2e/sync-persistence.test.ts(QuickAdd's external-change cases with PodNotes data, the playing-episode case, unload during a read, adoption, Same-titled episodes from different podcasts collapse into one queue entry #356),tests/e2e/player-popout-save.test.ts(hide with an observed save first and a read before the next checkpoint; visible close; close then reload) andtests/e2e/quit-save.test.ts(closes the real main window 50 ms after a change and reads data.json from the test process while Obsidian is down). The journey suite's#357 known failure J-EDGE-12now passes and is a plain test.npm run test:e2epasses all 53 tests on the head commit, from a fresh vault.Checks on the head commit (Node 22.23.2):
npm run lint: passnpm run format:check: passnpm run typecheck: passnpm run build: passnpm run size: pass (raw 314,794 of 330,000 bytes, gzip 99,368 of 100,000)npm run size:data: passnpm run test: pass (svelte-check, then Vitest 86 files, 1386 tests)npm run docs:build: pass (Astro Starlight)npm run test:e2e: pass (11 files, 53 tests)Release impact
Storage: no schema or format change. data.json is read before each write and read back after it. Changes save at once; the playing position is written at most every 10 s, and at once on pause, hide and close. PodNotes stops saving, with a Notice, while data.json is unreadable and not empty, or comes from an older version. Queue membership now tells same-titled episodes of different podcasts apart; existing queues keep their entries. No API or URI changes. Docs: the feature list mentions sync support. Bundle:
main.jsgzip is 99,368 of the 100,000-byte budget.Note
Merge synced
data.jsonchanges from other devices instead of overwriting themDataFileWriterthat merges externaldata.jsonchanges (from Obsidian Sync and similar tools) into local settings instead of overwriting them, without a restartisSameEpisodein episodeKey.ts) instead of title alone, so same-titled episodes from different podcasts coexist in the queue across the store, context menus, and reorder actionsEpisodePlayerin EpisodePlayer.svelte now persists the playback position immediately when its window becomes hidden or the component is destroyed, and flushes pending saves at that pointPodNotes.saveSettings,saveSettingsStrict,checkpoint, andflushSavesin main.ts now delegate toDataFileWriterand reject with typed refusal messages when the file is unreadable or older-schema; external settings changes go throughonExternalSettingsChange→DataFileWriter.onExternalChange. Existing consumers of these APIs should be checkedMacroscope summarized 27610ad.