-
Notifications
You must be signed in to change notification settings - Fork 0
Detect upstream changes before saving #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -79,6 +79,14 @@ each song as a revisioned custom post, and exposes it under a URL slug you | |
| choose (default `/troche`). Viewing requires being logged in; editing requires | ||
| a capability you grant per user on **Settings → Troche**. | ||
|
|
||
| Because saving is per song rather than per library, having the app open on two | ||
| machines is safe: when a tab comes back into view, and again before it saves, it | ||
| checks which songs have moved and folds in anything it hasn't touched itself. | ||
| Only a song edited in both places at once needs a decision, and it says so | ||
| rather than overwriting quietly. Saves are conditional on the version the tab | ||
| last saw, so even a write that races another machine is refused and turned into | ||
|
Comment on lines
+86
to
+87
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Preserve the previous token after a successful 🤖 Prompt for AI Agents |
||
| that same question. | ||
|
Comment on lines
+87
to
+88
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift Make the token check and update atomic.
🤖 Prompt for AI Agents |
||
|
|
||
| ### Build | ||
|
|
||
| ```bash | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -72,8 +72,20 @@ subpath and the plugin directory. | |
| - **Autosave replaces the Save button:** ~10s debounce, fires only when dirty, | ||
| flush on `visibilitychange`/`pagehide`. Status indicator: "Saved ✓ / | ||
| Saving… / Offline — changes kept locally." | ||
| - **Conflicts: last write wins**; revisions are the safety net. Dirty-only | ||
| saving means idle open tabs never clobber. | ||
| - **Conflicts: reconcile per song, prompt only when it's genuinely ambiguous.** | ||
| Dirty-only saving already means idle open tabs never clobber. On top of that, | ||
| the app checks `GET /library/state` (version tokens, no content) when a tab | ||
| becomes visible and again before each save. A song changed on another machine | ||
| that this tab hasn't touched is adopted silently; one added there is pulled | ||
| in; one trashed there is dropped. Only a song edited in *both* places, or | ||
| edited here and trashed there, surfaces to the user — those are held back | ||
| from saving until resolved, while the rest of the library keeps autosaving. | ||
| Whichever copy loses is still recoverable from the song's revisions. | ||
| Checking before a save narrows the window but can't close it — the check and | ||
| the write aren't one operation — so `PUT` is conditional: it carries the token | ||
| the client last saw in `X-Troche-Expect-Token` and the server refuses with 409 | ||
| if the song moved in between. A refusal reconciles and surfaces the same | ||
| per-song choice, so a lost race is a question, never a silent overwrite. | ||
|
Comment on lines
+87
to
+88
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift Protect DELETE with the expected token.
🤖 Prompt for AI Agents |
||
| - **Share/Export/Import stay functionally untouched** in both modes (one | ||
| codebase; they're client-side and cost nothing). Export/Import double as the | ||
| seeding path: each bandmate exports their existing localStorage library and | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,15 @@ | ||
| import { useCallback, useEffect, useRef, useState } from "react"; | ||
| import { Music, Plus, RotateCcw } from "lucide-react"; | ||
| import { PALETTE } from "./constants.js"; | ||
| import { uid, normalizeLibrary } from "./utils.js"; | ||
| import { uid, normalizeLibrary, mergeFlags } from "./utils.js"; | ||
| import { defaultLibrary, defaultSong } from "./defaults.js"; | ||
| import { | ||
| loadLibrary, | ||
| saveLibrary, | ||
| syncUpstream, | ||
| releaseHold, | ||
| forgetHandle, | ||
| cacheLibrary, | ||
| clearBuffer, | ||
| wpMode, | ||
| canEdit, | ||
|
|
@@ -17,6 +21,7 @@ import { Header } from "./components/Header.jsx"; | |
| import { Transport } from "./components/Transport.jsx"; | ||
| import { PartBlock } from "./components/PartBlock.jsx"; | ||
| import { PrintChart } from "./components/PrintChart.jsx"; | ||
| import { SyncNotice } from "./components/SyncNotice.jsx"; | ||
| import { styles, css } from "./styles.js"; | ||
|
|
||
| // Matches the CSS mobile breakpoint (see styles.js). Used to drop the | ||
|
|
@@ -35,6 +40,15 @@ function useIsMobile() { | |
| return m; | ||
| } | ||
|
|
||
| // The app renders off an active song, so the library must never reach zero. | ||
| // A blank song stands in — the same fallback a fresh, empty install gets, and | ||
| // left un-dirty so it's never pushed to everyone's server on its own. | ||
| function ensureNonEmpty(lib) { | ||
| if (lib.songs.length) return lib; | ||
| const blank = defaultSong("New Song"); | ||
| return { ...lib, songs: [blank], activeId: blank.id }; | ||
| } | ||
|
|
||
| export default function App() { | ||
| const [library, setLibrary] = useState(null); | ||
| const [loading, setLoading] = useState(true); | ||
|
|
@@ -71,6 +85,15 @@ export default function App() { | |
| // brief confirmation that a share link was copied | ||
| const [shareFlash, setShareFlash] = useState(false); | ||
|
|
||
| // Songs another machine changed that need a decision before they can save | ||
| // again: { kind: "conflict" | "orphan", wpId, id, name, theirs? }. Their | ||
| // writes are held in storage.js until resolved; the rest of the library | ||
| // keeps autosaving normally. | ||
| const [flags, setFlags] = useState([]); | ||
|
|
||
| // Count of songs quietly pulled in from another machine, for a passing note. | ||
| const [pullFlash, setPullFlash] = useState(0); | ||
|
|
||
| useEffect(() => { | ||
| // Consume the shared payload (if any) before loading from storage. The | ||
| // hash is stripped regardless of parse success so the URL is clean. | ||
|
|
@@ -321,12 +344,103 @@ export default function App() { | |
| })); | ||
| }, []); | ||
|
|
||
| // Pull down anything another machine changed and fold it into local state. | ||
| // Songs this tab hasn't touched are adopted silently; only genuine conflicts | ||
| // surface. Returns the library to work from — the merged one when something | ||
| // came in, the current one otherwise. | ||
| const runSync = useCallback(async () => { | ||
| const current = libraryRef.current; | ||
| if (!wpMode || !current) return current; | ||
|
|
||
| const result = await syncUpstream(current); | ||
| if (!result) return current; | ||
| if (result.authExpired) { | ||
| setWpStatus("expired"); | ||
| return current; | ||
| } | ||
|
|
||
| const merged = ensureNonEmpty(result.library); | ||
| setLibrary(merged); | ||
| setFlags((cur) => mergeFlags(cur, result.conflicts, result.orphans)); | ||
| if (result.pulled) { | ||
| setPullFlash(result.pulled); | ||
| setTimeout(() => setPullFlash(0), 6000); | ||
| } | ||
| return merged; | ||
| }, []); | ||
|
|
||
| const dismissFlag = (wpId) => setFlags((cur) => cur.filter((f) => f.wpId !== wpId)); | ||
|
|
||
| // Apply a resolution to local state and to the offline buffer together. | ||
| // Resolving in favour of the server leaves nothing to save — local already | ||
| // matches — and the buffer is otherwise only written by a save, so without | ||
| // this it would go on serving the copy the user just rejected to the next | ||
| // offline reload. | ||
| const applyResolution = (next) => { | ||
| setLibrary(next); | ||
| cacheLibrary(next); | ||
| }; | ||
|
|
||
| // Conflict → keep this tab's copy. Releasing the hold lets the next save | ||
| // overwrite the other machine's version; that version stays recoverable from | ||
| // the song's revisions in wp-admin. | ||
| const keepMine = (flag) => { | ||
| releaseHold(flag.wpId); | ||
| dismissFlag(flag.wpId); | ||
| setDirty(true); | ||
| }; | ||
|
|
||
| // Conflict → take the other machine's copy, dropping this tab's edits to that | ||
| // song. Its id stays local so the active-song selection survives. | ||
| const useTheirs = (flag) => { | ||
| releaseHold(flag.wpId); | ||
| const cur = libraryRef.current; | ||
| applyResolution({ | ||
| ...cur, | ||
| songs: cur.songs.map((s) => (s.id === flag.id ? { ...flag.theirs, id: s.id } : s)), | ||
| }); | ||
| dismissFlag(flag.wpId); | ||
| }; | ||
|
|
||
| // Orphan → keep a song that was trashed elsewhere. Dropping its server handle | ||
| // makes the next save re-create it rather than PUT to a trashed post. | ||
| const keepDeleted = (flag) => { | ||
| forgetHandle(flag.id, flag.wpId); | ||
| const cur = libraryRef.current; | ||
| applyResolution({ | ||
| ...cur, | ||
| songs: cur.songs.map((s) => | ||
| s.id === flag.id ? (({ wpId, wpToken, ...rest }) => rest)(s) : s | ||
| ), | ||
| }); | ||
| dismissFlag(flag.wpId); | ||
| setDirty(true); | ||
| }; | ||
|
|
||
| // Orphan → accept the deletion here too. | ||
| const discardDeleted = (flag) => { | ||
| releaseHold(flag.wpId); | ||
| const cur = libraryRef.current; | ||
| const songs = cur.songs.filter((s) => s.id !== flag.id); | ||
| const activeId = songs.some((s) => s.id === cur.activeId) | ||
| ? cur.activeId | ||
| : songs[0]?.id ?? null; | ||
| applyResolution(ensureNonEmpty({ ...cur, songs, activeId })); | ||
| dismissFlag(flag.wpId); | ||
| }; | ||
|
|
||
| // The single save path used by autosave, the manual Save button (local mode), | ||
| // and manual retries. Reads the latest library through the ref. | ||
| const runSave = useCallback( | ||
| async (opts = {}) => { | ||
| const lib = libraryRef.current; | ||
| let lib = libraryRef.current; | ||
| if (!lib) return; | ||
|
|
||
| // Reconcile first, so a save that has been sitting in the debounce window | ||
| // can't push over something that landed from another machine meanwhile. | ||
| if (wpMode) lib = await runSync(); | ||
| if (!lib) return; | ||
|
|
||
| setSaving(true); | ||
| const res = await saveLibrary(lib, opts); | ||
| setSaving(false); | ||
|
|
@@ -345,11 +459,15 @@ export default function App() { | |
| setWpStatus("expired"); | ||
| } else if (res.stale) { | ||
| setWpStatus("stale"); | ||
| } else if (res.diverged) { | ||
| // A song was trashed elsewhere between the sync and the write. Resync | ||
| // to classify it and surface the choice. | ||
| runSync(); | ||
| } else if (res.offline) { | ||
| setWpStatus("offline"); | ||
| } | ||
| }, | ||
| [adoptWpIds] | ||
| [adoptWpIds, runSync] | ||
| ); | ||
|
|
||
| const handleSave = () => { | ||
|
|
@@ -368,13 +486,20 @@ export default function App() { | |
|
|
||
| // Best-effort flush when the tab is hidden or unloaded, so edits inside the | ||
| // debounce window aren't lost. WP saves use keepalive so they survive unload. | ||
| // Coming back to a visible tab is the other half: it's exactly the moment | ||
| // this laptop might have gone stale against another one, so reconcile then | ||
| // rather than polling in the background. | ||
| const syncRef = useRef(runSync); | ||
| syncRef.current = runSync; | ||
|
|
||
| useEffect(() => { | ||
| const flush = () => { | ||
| if (!dirtyRef.current || !libraryRef.current) return; | ||
| saveLibrary(libraryRef.current, { keepalive: true }); | ||
| }; | ||
| const onVis = () => { | ||
| if (document.visibilityState === "hidden") flush(); | ||
| else syncRef.current(); | ||
| }; | ||
| document.addEventListener("visibilitychange", onVis); | ||
| window.addEventListener("pagehide", flush); | ||
|
|
@@ -557,6 +682,8 @@ export default function App() { | |
| ? "expired" | ||
| : wpStatus === "stale" | ||
| ? "stale" | ||
| : flags.length | ||
| ? "conflict" | ||
| : wpStatus === "offline" | ||
| ? "offline" | ||
| : dirty | ||
|
|
@@ -606,6 +733,15 @@ export default function App() { | |
| onReload={() => window.location.reload()} | ||
| /> | ||
|
|
||
| <SyncNotice | ||
| flags={flags} | ||
| pulled={pullFlash} | ||
| onKeepMine={keepMine} | ||
| onUseTheirs={useTheirs} | ||
| onKeepDeleted={keepDeleted} | ||
| onDiscardDeleted={discardDeleted} | ||
| /> | ||
|
Comment on lines
+736
to
+743
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Do not show write-resolution actions to read-only users.
Pass the read-only state to 🤖 Prompt for AI Agents |
||
|
|
||
| <Transport | ||
| playing={playing} | ||
| togglePlay={togglePlay} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| import { TriangleAlert, ArrowDownToLine } from "lucide-react"; | ||
|
|
||
| // Sits under the header in WP mode and reports what came in from another | ||
| // machine. | ||
| // | ||
| // Two very different messages share the strip. The pulled-songs line is | ||
| // informational and disappears on its own — those songs were adopted with no | ||
| // decision to make. The flags are blocking: each names a song whose saves are | ||
| // held until the user picks a side, so each carries its own two buttons rather | ||
| // than one library-wide "reload", which would throw away local edits. | ||
| export function SyncNotice({ | ||
| flags, | ||
| pulled, | ||
| onKeepMine, | ||
| onUseTheirs, | ||
| onKeepDeleted, | ||
| onDiscardDeleted, | ||
| }) { | ||
| if (!flags.length && !pulled) return null; | ||
|
|
||
| return ( | ||
| <div className="sa-sync"> | ||
| {!!pulled && !flags.length && ( | ||
| <div className="sa-sync-row info"> | ||
| <ArrowDownToLine size={15} /> | ||
| <span className="sa-sync-text"> | ||
| Updated {pulled} song{pulled === 1 ? "" : "s"} from another device. | ||
| </span> | ||
| </div> | ||
| )} | ||
|
|
||
| {flags.map((flag) => ( | ||
| <div className="sa-sync-row" key={flag.wpId}> | ||
| <TriangleAlert size={15} /> | ||
| <span className="sa-sync-text"> | ||
| <strong>{flag.name || "Untitled"}</strong>{" "} | ||
| {flag.kind === "orphan" | ||
| ? "was deleted on another device, but you've edited it here." | ||
| : "was also edited on another device."} | ||
| </span> | ||
| {flag.kind === "orphan" ? ( | ||
| <> | ||
| <button className="sa-btn ghost" onClick={() => onKeepDeleted(flag)}> | ||
| Keep mine | ||
| </button> | ||
| <button className="sa-btn ghost" onClick={() => onDiscardDeleted(flag)}> | ||
| Delete here too | ||
| </button> | ||
| </> | ||
| ) : ( | ||
| <> | ||
| <button className="sa-btn ghost" onClick={() => onKeepMine(flag)}> | ||
| Keep mine | ||
| </button> | ||
| <button className="sa-btn ghost" onClick={() => onUseTheirs(flag)}> | ||
| Use theirs | ||
| </button> | ||
| </> | ||
| )} | ||
| </div> | ||
| ))} | ||
| </div> | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Handle local-delete/remote-edit conflicts before changing
unclaimed.A locally deleted song remains in
prevSnapshot. If another device edits it,unclaimedrestores it. FilteringunclaimedbyprevSnapshotprevents resurrection, butdoWpSavethen includes the ID intoTrashand can delete the other device’s edit without a decision. Add a deletion-conflict hold and resolution path that blocksDELETEuntil the user chooses; the safety descriptions become accurate only after that behavior exists.🤖 Prompt for AI Agents