diff --git a/README.md b/README.md index f883a65..90b5d95 100644 --- a/README.md +++ b/README.md @@ -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 +that same question. + ### Build ```bash diff --git a/docs/wp-storage-plan.md b/docs/wp-storage-plan.md index c42697b..84d42b6 100644 --- a/docs/wp-storage-plan.md +++ b/docs/wp-storage-plan.md @@ -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. - **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 diff --git a/package.json b/package.json index b308b80..ddee09a 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,9 @@ "build": "vite build", "build:wp": "vite build && node scripts/sync-dist.mjs", "preview": "vite preview", - "test": "npm run test:unit && npm run test:plugin && npm run test:zip", + "test": "npm run test:unit && npm run test:sync && npm run test:plugin && npm run test:zip", "test:unit": "node tests/unit.mjs", + "test:sync": "node tests/sync.mjs", "test:plugin": "bash tests/plugin.sh", "test:zip": "bash tests/zip-install.sh" }, diff --git a/src/App.jsx b/src/App.jsx index 071ce12..16f3546 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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,6 +486,12 @@ 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; @@ -375,6 +499,7 @@ export default function App() { }; 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()} /> + + , label: "Offline — changes kept locally" }, expired: { icon: , label: "Session expired" }, stale: { icon: , label: "Reload before saving" }, + conflict: { icon: , label: "Changed on another device" }, viewonly: { icon: , label: "View only" }, }; const s = map[state] || map.saved; diff --git a/src/components/SyncNotice.jsx b/src/components/SyncNotice.jsx new file mode 100644 index 0000000..3144c7e --- /dev/null +++ b/src/components/SyncNotice.jsx @@ -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 ( +
+ {!!pulled && !flags.length && ( +
+ + + Updated {pulled} song{pulled === 1 ? "" : "s"} from another device. + +
+ )} + + {flags.map((flag) => ( +
+ + + {flag.name || "Untitled"}{" "} + {flag.kind === "orphan" + ? "was deleted on another device, but you've edited it here." + : "was also edited on another device."} + + {flag.kind === "orphan" ? ( + <> + + + + ) : ( + <> + + + + )} +
+ ))} +
+ ); +} diff --git a/src/storage.js b/src/storage.js index 28b5194..3144e6c 100644 --- a/src/storage.js +++ b/src/storage.js @@ -76,6 +76,19 @@ function writeBuffer(lib) { // songs never issue writes. let serverSnapshot = new Map(); +// The server's own version token per song (wpId -> token), captured alongside +// serverSnapshot. The server mints these from the stored JSON; the client only +// ever compares them for equality, so there's no canonicalization to keep in +// step across the two languages. Diffing these against /library/state is how we +// notice another tab has written since we last looked. +let serverTokens = new Map(); + +// Songs whose writes are suppressed because reconciling them needs the user: +// edited here *and* upstream, or edited here and trashed upstream. Held by +// wpId. Everything else in the library keeps saving normally — a conflict on +// one song is no reason to stall the rest. +let heldWpIds = new Set(); + // Handles assigned to songs created earlier this session, keyed by song id, so // a re-save that fires before the app has adopted the new wpId into state can't // create a duplicate. Reset on every load. @@ -98,18 +111,31 @@ function stableStringify(v) { return JSON.stringify(v); } +// wpId and wpToken are transport handles, not song data — excluded so neither +// one can read as an edit. function songContent(song) { - const { wpId, ...rest } = song; + const { wpId, wpToken, ...rest } = song; return stableStringify(rest); } +function songPayload(song) { + const { wpId, wpToken, ...rest } = song; + return rest; +} + +// A song's server handle: its adopted wpId, or one assigned to it earlier this +// session but not yet reflected in app state. +function handleOf(song) { + return song.wpId ?? createdIds.get(song.id) ?? null; +} + function wpUrl(path) { // rest_url() yields either ".../wp-json/troche/v1" or "...?rest_route=/troche/v1"; // appending the path extends the route correctly in both permalink modes. return wpConfig.restUrl + path; } -async function wpFetch(path, method, body, keepalive) { +async function wpFetch(path, method, body, keepalive, expectToken) { const res = await fetch(wpUrl(path), { method, credentials: "same-origin", @@ -117,12 +143,28 @@ async function wpFetch(path, method, body, keepalive) { headers: { "Content-Type": "application/json", "X-WP-Nonce": wpConfig.nonce, + // Present only when we know what the server held, so the write can be + // refused if that's no longer true. Omitted (unconditional write) when we + // have no token to offer — an older plugin, or a response we couldn't + // parse — which is exactly the behaviour this had before. + ...(expectToken ? { "X-Troche-Expect-Token": expectToken } : null), }, body: body === undefined ? undefined : JSON.stringify(body), }); return res; } +// Read a response body without letting a parse failure unwind a write that +// already landed. A missing token just means the next probe sees a change and +// re-pulls the song, which is harmless — losing the save is not. +async function readJson(res) { + try { + return await res.json(); + } catch { + return null; + } +} + // Thrown to unwind a save when the session/nonce is no longer valid. class AuthError extends Error {} @@ -132,19 +174,66 @@ class AuthError extends Error {} // state and a fresh load rebuilds the snapshot. class StaleSnapshotError extends Error {} -async function loadFromWp() { +// Thrown when an update targets a song the server no longer has — trashed on +// another machine since our last sync. Distinct from a network failure, which +// is what it used to be reported as: a sync reclassifies the song as an orphan +// and asks whether to keep or discard it. +class MissingSongError extends Error {} + +// Thrown when the server refuses an update because the song moved under us +// (409). Syncing before a save narrows that window but can't close it — the +// check and the write aren't one operation. Handled exactly like a 404: unwind, +// reconcile, and let the song be flagged if it's a genuine conflict. +class StaleTokenError extends Error {} + +async function fetchLibrary() { const res = await wpFetch("/library", "GET"); if (res.status === 401 || res.status === 403) throw new AuthError(); if (!res.ok) throw new Error("load failed: " + res.status); const data = await res.json(); - const songs = Array.isArray(data?.songs) ? data.songs : []; + return Array.isArray(data?.songs) ? data.songs : []; +} + +// The cheap probe: version tokens only, no song content. +async function fetchState() { + const res = await wpFetch("/library/state", "GET"); + if (res.status === 401 || res.status === 403) throw new AuthError(); + if (!res.ok) throw new Error("state failed: " + res.status); + const data = await res.json(); + const tokens = data?.tokens; + return tokens && typeof tokens === "object" ? tokens : {}; +} - // Rebuild the server snapshot (keyed by wpId) from what we just fetched. +// Record "what the server holds" from a freshly fetched library. Builds new +// Maps rather than mutating, so a caller can hold the previous ones as the +// before-picture of a reconcile. +function recordServerState(songs) { serverSnapshot = new Map(); - createdIds = new Map(); + serverTokens = new Map(); for (const s of songs) { - if (s && typeof s.wpId === "number") serverSnapshot.set(s.wpId, songContent(s)); + if (s && typeof s.wpId === "number") { + serverSnapshot.set(s.wpId, songContent(s)); + serverTokens.set(s.wpId, s.wpToken ?? null); + } } +} + +// True if the token map from /library/state disagrees with what we last saw — +// a song added, removed, or rewritten by someone else. +function upstreamMoved(tokens) { + const keys = Object.keys(tokens); + if (keys.length !== serverTokens.size) return true; + return keys.some((k) => serverTokens.get(Number(k)) !== tokens[k]); +} + +async function loadFromWp() { + const songs = await fetchLibrary(); + recordServerState(songs); + // A full load replaces app state wholesale, so session-local bookkeeping goes + // with it. (syncUpstream deliberately keeps both — it reconciles into the + // library the user is already working in.) + createdIds = new Map(); + heldWpIds = new Set(); return songs; } @@ -158,28 +247,41 @@ async function doWpSave(library, keepalive) { for (const song of songs) { const content = songContent(song); - const payload = (({ wpId: _drop, ...rest }) => rest)(song); - // A song's handle is its wpId, or one assigned to it earlier this session. - const wpId = song.wpId ?? createdIds.get(song.id) ?? null; + const payload = songPayload(song); + const wpId = handleOf(song); if (wpId) { seenWpIds.add(wpId); + // Held songs are awaiting a user decision; writing one would be the + // clobber the hold exists to prevent. Counted as seen above so the trash + // diff below doesn't mistake the skip for a deletion. + if (heldWpIds.has(wpId)) continue; if (serverSnapshot.get(wpId) !== content) { - const res = await wpFetch("/songs/" + wpId, "PUT", payload, keepalive); + const res = await wpFetch( + "/songs/" + wpId, + "PUT", + payload, + keepalive, + serverTokens.get(wpId) + ); if (res.status === 401 || res.status === 403) throw new AuthError(); + if (res.status === 404) throw new MissingSongError(); + if (res.status === 409) throw new StaleTokenError(); if (!res.ok) throw new Error("update failed: " + res.status); serverSnapshot.set(wpId, content); + serverTokens.set(wpId, (await readJson(res))?.wpToken ?? null); } } else { const res = await wpFetch("/songs", "POST", payload, keepalive); if (res.status === 401 || res.status === 403) throw new AuthError(); if (!res.ok) throw new Error("create failed: " + res.status); - const created = await res.json(); + const created = await readJson(res); const newId = created?.wpId; if (newId) { assignedIds[song.id] = newId; createdIds.set(song.id, newId); serverSnapshot.set(newId, content); + serverTokens.set(newId, created?.wpToken ?? null); seenWpIds.add(newId); } } @@ -206,6 +308,7 @@ async function doWpSave(library, keepalive) { // A 404 (already gone) is fine; only hard-fail on other errors. if (!res.ok && res.status !== 404) throw new Error("delete failed: " + res.status); serverSnapshot.delete(wpId); + serverTokens.delete(wpId); } return assignedIds; @@ -236,18 +339,23 @@ export async function loadLibrary() { return readBuffer(); } +// Server round-trips run one at a time: they all read and rewrite the snapshot +// maps, so overlapping calls could double-create songs or reconcile against a +// half-updated picture. +let chain = Promise.resolve(); + +function serialize(fn) { + const result = chain.then(fn, fn); + chain = result.catch(() => {}); + return result; +} + // Persist the library. // Local mode: writes the whole blob. -> { ok } // WP mode: mirrors to the buffer, then diffs and saves per-song. // -> { ok, offline, authExpired, readOnly, assignedIds } -// Saves are serialized (see `chain`) so overlapping calls can't double-create. -let chain = Promise.resolve(); - export function saveLibrary(library, opts = {}) { - const run = () => doSave(library, opts); - const result = chain.then(run, run); - chain = result.catch(() => {}); - return result; + return serialize(() => doSave(library, opts)); } async function doSave(library, opts) { @@ -272,11 +380,148 @@ async function doSave(library, opts) { if (e instanceof StaleSnapshotError) { return { ok: false, stale: true }; } + // Trashed elsewhere, or rewritten elsewhere between our sync and our write. + // Either way the library moved under us: reconcile and let that classify it. + if (e instanceof MissingSongError || e instanceof StaleTokenError) { + return { ok: false, diverged: true }; + } // Network error or server hiccup — changes are safe in the buffer. return { ok: false, offline: true }; } } +// Reconcile this tab against the server, for the case where the same library is +// open on another machine. +// +// Cheap path first: one token request, and if nothing moved upstream we're done +// without transferring a single song. When something did move, the per-song +// diff means most of it still isn't a conflict — a song someone else edited +// that this tab hasn't touched can simply be adopted, and one added elsewhere +// can simply be pulled in. Only a song edited in *both* places, or edited here +// and trashed there, needs the user; those get held back from saving until +// they're resolved. +// +// Returns null when there's nothing to report (including offline — a failed +// probe just means we reconcile later; edits stay safe in the buffer). +// -> { library, pulled, conflicts, orphans } | { authExpired: true } | null +export function syncUpstream(library) { + if (!wpMode || !library) return Promise.resolve(null); + return serialize(() => doSync(library)); +} + +async function doSync(library) { + let prevSnapshot; + let prevTokens; + let serverSongs; + + try { + if (!upstreamMoved(await fetchState())) return null; + // Hold the before-picture: "did this tab edit that song?" has to be asked + // against what the server held at our last sync, not what it holds now. + prevSnapshot = serverSnapshot; + prevTokens = serverTokens; + serverSongs = await fetchLibrary(); + recordServerState(serverSongs); + } catch (e) { + if (e instanceof AuthError) return { authExpired: true }; + return null; + } + + const untouchedHere = (song, wpId) => prevSnapshot.get(wpId) === songContent(song); + + const unclaimed = new Map( + serverSongs.filter((s) => typeof s.wpId === "number").map((s) => [s.wpId, s]) + ); + const merged = []; + const conflicts = []; + const orphans = []; + let pulled = 0; + + // Walk the local library first so this tab's song order survives the merge; + // anything genuinely new to us lands at the end. + for (const local of library.songs) { + const wpId = handleOf(local); + if (wpId == null) { + merged.push(local); // created here, never saved — nothing to reconcile + continue; + } + + const server = unclaimed.get(wpId); + + if (!server) { + // Trashed on the other machine. + if (untouchedHere(local, wpId)) { + pulled++; // drop it here too + continue; + } + orphans.push({ wpId, id: local.id, name: local.name }); + heldWpIds.add(wpId); + merged.push(local); + continue; + } + + unclaimed.delete(wpId); + + if (prevTokens.get(wpId) === server.wpToken) { + merged.push(local); // unchanged upstream — this tab's copy stands + continue; + } + if (untouchedHere(local, wpId)) { + merged.push(server); // changed upstream only — adopt it + pulled++; + continue; + } + + conflicts.push({ wpId, id: local.id, name: local.name, theirs: server }); + heldWpIds.add(wpId); + merged.push(local); + } + + for (const server of unclaimed.values()) { + merged.push(server); // added on the other machine + pulled++; + } + + if (!pulled && !conflicts.length && !orphans.length) return null; + + const activeId = merged.some((s) => s.id === library.activeId) + ? library.activeId + : merged[0]?.id ?? null; + + const reconciled = { ...library, songs: merged, activeId }; + // Keep the offline copy current here too, not just on load and save: a + // reconcile can go a long time without a save behind it (nothing local was + // dirty), and going offline in that window shouldn't roll the tab back to a + // library the server has already moved past. + writeBuffer(reconciled); + + return { library: reconciled, pulled, conflicts, orphans }; +} + +// Resolve a conflict or orphan: release the song's save hold so the next save +// acts on whatever the app settled on. Keeping the local copy makes the next +// diff overwrite theirs; taking theirs leaves nothing to write. +export function releaseHold(wpId) { + heldWpIds.delete(wpId); +} + +// Forget a song's server handle so the next save re-creates it as a fresh post. +// For an orphan the user chose to keep: its old wpId names a trashed post that +// PUT would only 404 on, and the app has stripped wpId from the song — but the +// session-local handle would still resolve it, so that has to go too. +export function forgetHandle(songId, wpId) { + createdIds.delete(songId); + heldWpIds.delete(wpId); +} + +// Mirror the library to the offline buffer without touching the server. For the +// changes that need no save to be correct — resolving a conflict in the +// server's favour leaves local already matching it — where waiting for the next +// save would leave the buffer holding a copy the user has discarded. +export function cacheLibrary(library) { + if (library) writeBuffer(library); +} + // Clear the local buffer (standalone "Reset"). No effect on server data. export function clearBuffer() { try { diff --git a/src/styles.js b/src/styles.js index 631fb26..e2f6f1c 100644 --- a/src/styles.js +++ b/src/styles.js @@ -114,12 +114,27 @@ export const css = ` .sa-savestate.offline { color: #b0692c; } .sa-savestate.expired { color: var(--accent); } .sa-savestate.stale { color: var(--accent); } +.sa-savestate.conflict { color: var(--accent); } /* Clickable states (Unsaved → save now, Offline → retry) read as actions. */ .sa-savestate.clickable { cursor: pointer; } .sa-savestate.clickable.pending { color: var(--accent); } .sa-savestate.clickable:hover { text-decoration: underline; } a.sa-savestate.expired:hover { text-decoration: underline; } +/* Sync notice strip (WP mode) — what arrived from another device, and the + per-song choices for anything that needs one. */ +.sa-sync { + display: flex; flex-direction: column; gap: 6px; + padding: 8px 0 2px; +} +.sa-sync-row { + display: flex; align-items: center; gap: 8px; flex-wrap: wrap; + font-size: 13px; color: var(--accent); +} +.sa-sync-row.info { color: var(--ink-dim); } +.sa-sync-text { flex: 1 1 auto; min-width: 180px; } +.sa-sync-row .sa-btn { padding: 4px 10px; font-size: 12px; } + .sa-switcher { display: flex; align-items: center; gap: 8px; font-family: 'Outfit', sans-serif; font-weight: 600; font-size: 20px; diff --git a/src/utils.js b/src/utils.js index dbdc34c..25091c6 100644 --- a/src/utils.js +++ b/src/utils.js @@ -57,6 +57,33 @@ export function normalizeLibrary(library) { return { library: { ...library, songs, activeId }, changed }; } +// Fold a sync's conflicts and orphans into the list of songs awaiting a +// decision, keyed by wpId. +// +// A flagged song that this sync didn't mention stays flagged: dismissing a flag +// is what releases that song's save hold, so dropping one would strand it +// unsaveable. A song the sync *did* mention adopts the new entry — the other +// machine has moved again, and "Use theirs" has to mean their copy as it stands +// now, not the one we first saw (taking the stale copy would push it straight +// back over their newer one). Existing entries keep their position so the +// notice strip doesn't reshuffle under the user; genuinely new ones append. +// +// An orphan arriving for a song already flagged as a conflict supersedes it by +// the same rule — once it's been trashed elsewhere there is no "theirs" left. +export function mergeFlags(current, conflicts = [], orphans = []) { + const incoming = [ + ...conflicts.map((c) => ({ ...c, kind: "conflict" })), + ...orphans.map((o) => ({ ...o, kind: "orphan" })), + ]; + const byWpId = new Map(incoming.map((f) => [f.wpId, f])); + const alreadyFlagged = new Set(current.map((f) => f.wpId)); + + return [ + ...current.map((f) => byWpId.get(f.wpId) ?? f), + ...incoming.filter((f) => !alreadyFlagged.has(f.wpId)), + ]; +} + export function clampNum(v, min, max, fallback) { const n = Number(v); if (Number.isNaN(n)) return fallback; diff --git a/tests/README.md b/tests/README.md index 3d87597..999fee5 100644 --- a/tests/README.md +++ b/tests/README.md @@ -8,8 +8,9 @@ Requires **Node 20+** (same as the build). Run from the repo root. | Command | What it covers | | --- | --- | -| `npm run test:unit` | `normalizeLibrary` — duplicate/missing id repair and macOS-style name de-duping. Pure Node, no WordPress. | -| `npm run test:plugin` | The plugin source in a fresh WordPress: post type, REST CRUD, the login-to-read and cap-to-edit gates, revisions, trash-on-delete. | +| `npm run test:unit` | `normalizeLibrary` — duplicate/missing id repair and macOS-style name de-duping — and `mergeFlags`, which decides what an unresolved sync notice does when the next sync lands. Pure Node, no WordPress. | +| `npm run test:sync` | The WP reconcile path in `storage.js` — what a second open tab adopts silently vs. flags, that flagged songs are held back from saving, and that a write onto a song that moved since is refused rather than landing. Runs against a fake in-process WordPress. | +| `npm run test:plugin` | The plugin source in a fresh WordPress: post type, REST CRUD, the login-to-read and cap-to-edit gates, version tokens and conditional writes, revisions, trash-on-delete. | | `npm run test:zip` | Builds `troche.zip` and installs it into a fresh WordPress the same way a wp-admin upload would, then verifies it unpacked, activated, and runs. | | `npm test` | All of the above. | diff --git a/tests/plugin.php b/tests/plugin.php index 0e8b795..2620a50 100644 --- a/tests/plugin.php +++ b/tests/plugin.php @@ -20,14 +20,20 @@ function check( $label, $cond ) { $cond ? $pass++ : $fail++; file_put_contents( $out, $line . "\n", FILE_APPEND ); } -function troche_rest( $method, $route, $body = null ) { +function troche_rest( $method, $route, $body = null, $headers = array() ) { $req = new WP_REST_Request( $method, $route ); if ( null !== $body ) { $req->set_header( 'Content-Type', 'application/json' ); $req->set_body( wp_json_encode( $body ) ); } + foreach ( $headers as $name => $value ) { + $req->set_header( $name, $value ); + } return rest_do_request( $req ); } +function troche_tokens() { + return (array) troche_rest( 'GET', '/troche/v1/library/state' )->get_data()['tokens']; +} // ---- post type ---- check( 'CPT troche_song registered', post_type_exists( 'troche_song' ) ); @@ -86,6 +92,89 @@ function troche_rest( $method, $route, $body = null ) { check( 'song carries wpId', ( $data['songs'][0]['wpId'] ?? 0 ) === $wp_id ); check( 'unicode key round-trips', 'A♭' === ( $data['songs'][0]['musicalKey'] ?? '' ) ); +// ---- version tokens (upstream-change detection) ---- +$token = $data['songs'][0]['wpToken'] ?? ''; +check( 'song carries wpToken', is_string( $token ) && '' !== $token ); +check( 'create response carries wpToken', ! empty( $r->get_data()['wpToken'] ) ); +check( 'create token matches the library token', ( $r->get_data()['wpToken'] ?? null ) === $token ); + +wp_set_current_user( 0 ); +check( 'GET /library/state logged-out -> 401', 401 === troche_rest( 'GET', '/troche/v1/library/state' )->get_status() ); +wp_set_current_user( $sub_id ); +$state = troche_rest( 'GET', '/troche/v1/library/state' ); +check( 'GET /library/state -> 200', 200 === $state->get_status() ); +$tokens = (array) ( $state->get_data()['tokens'] ?? array() ); +check( 'state lists one token, keyed by wpId', array( (string) $wp_id => $token ) === $tokens ); + +// A save that rewrites identical content must not move the token — otherwise +// every idle tab would see a phantom conflict. +troche_rest( 'PUT', '/troche/v1/songs/' . $wp_id, $song ); +$same = (array) troche_rest( 'GET', '/troche/v1/library/state' )->get_data()['tokens']; +check( 'token stable across an identical re-save', $token === ( $same[ (string) $wp_id ] ?? '' ) ); + +// A real edit must move it. +$edited = $song; +$edited['bpm'] = 140; +troche_rest( 'PUT', '/troche/v1/songs/' . $wp_id, $edited ); +$moved = (array) troche_rest( 'GET', '/troche/v1/library/state' )->get_data()['tokens']; +check( 'token changes when content changes', $token !== ( $moved[ (string) $wp_id ] ?? '' ) ); +check( + 'update response token matches the new state token', + ( troche_rest( 'PUT', '/troche/v1/songs/' . $wp_id, $edited )->get_data()['wpToken'] ?? null ) + === ( $moved[ (string) $wp_id ] ?? '' ) +); + +// wpToken is a transport handle, like wpId — it must never be stored. +$stored_song = json_decode( get_post_field( 'post_content', $wp_id ), true ); +check( 'stored content omits wpToken', ! isset( $stored_song['wpToken'] ) ); + +// ---- conditional writes (X-Troche-Expect-Token) ---- +// The client syncs before saving, but that check and the write aren't one +// operation. This is what stops a save landing on a song that moved in between. +$current = troche_tokens()[ (string) $wp_id ] ?? ''; +$stale_write = $song; +$stale_write['bpm'] = 999; + +$refused = troche_rest( + 'PUT', + '/troche/v1/songs/' . $wp_id, + $stale_write, + array( 'X-Troche-Expect-Token' => 'not-the-current-token' ) +); +check( 'PUT with a stale token -> 409', 409 === $refused->get_status() ); +check( 'refused PUT names the conflict', 'troche_stale_token' === ( $refused->get_data()['code'] ?? '' ) ); +check( 'refused PUT hands back the current token', $current === ( $refused->get_data()['data']['wpToken'] ?? '' ) ); +check( 'refused PUT left the song alone', $current === ( troche_tokens()[ (string) $wp_id ] ?? '' ) ); + +$accepted = troche_rest( + 'PUT', + '/troche/v1/songs/' . $wp_id, + $stale_write, + array( 'X-Troche-Expect-Token' => $current ) +); +check( 'PUT with the current token -> 200', 200 === $accepted->get_status() ); +check( 'accepted PUT landed', 999 === (int) ( troche_rest( 'GET', '/troche/v1/library' )->get_data()['songs'][0]['bpm'] ?? 0 ) ); +check( 'accepted PUT moved the token', $current !== ( troche_tokens()[ (string) $wp_id ] ?? '' ) ); + +// A caller that doesn't track tokens (or a header a proxy stripped) still +// writes, exactly as this endpoint always did. +check( 'PUT with no token header still writes', 200 === troche_rest( 'PUT', '/troche/v1/songs/' . $wp_id, $edited )->get_status() ); + +// ---- /library/state agrees with /library about what exists ---- +// A post the library skips must not appear in state, or every probe would read +// as drift and pull the whole library down again. +$junk_id = wp_insert_post( + array( + 'post_type' => 'troche_song', + 'post_status' => 'publish', + 'post_title' => 'Not JSON', + 'post_content' => 'this is not a song', + ) +); +check( 'unparseable post excluded from library', 1 === count( troche_rest( 'GET', '/troche/v1/library' )->get_data()['songs'] ) ); +check( 'unparseable post excluded from state', ! isset( troche_tokens()[ (string) $junk_id ] ) ); +wp_delete_post( $junk_id, true ); + // ---- wp-admin cap mapping (post-type actions gate on troche_edit, not core post caps) ---- $editor_id = wp_insert_user( array( @@ -125,6 +214,10 @@ function troche_rest( $method, $route, $body = null ) { check( 'DELETE /songs/{id} -> 200', 200 === troche_rest( 'DELETE', '/troche/v1/songs/' . $wp_id )->get_status() ); check( 'post moved to trash (not hard-deleted)', 'trash' === get_post_status( $wp_id ) ); check( 'trashed song excluded from library', 0 === count( troche_rest( 'GET', '/troche/v1/library' )->get_data()['songs'] ) ); +// The client reads a song's absence from state as "trashed elsewhere", so this +// is what makes deletions propagate to the other machine at all. +check( 'trashed song excluded from state', ! isset( troche_tokens()[ (string) $wp_id ] ) ); +check( 'PUT to a trashed song -> 404, not 409', 404 === troche_rest( 'PUT', '/troche/v1/songs/' . $wp_id, $song, array( 'X-Troche-Expect-Token' => 'anything' ) )->get_status() ); // ---- server assigns an id when missing ---- $r = troche_rest( 'POST', '/troche/v1/songs', array( 'name' => 'No Id', 'parts' => array() ) ); diff --git a/tests/sync.mjs b/tests/sync.mjs new file mode 100644 index 0000000..dab66ce --- /dev/null +++ b/tests/sync.mjs @@ -0,0 +1,361 @@ +// Unit tests for the WP sync/reconcile path in src/storage.js — the logic that +// decides, when the same library is open on two machines, what can be adopted +// silently and what has to be handed to the user. +// +// Runs against a fake in-process WordPress: a Map of song posts behind the same +// four REST routes the plugin serves. No browser, no WordPress. +// node tests/sync.mjs # or: npm run test:sync + +let pass = 0; +let fail = 0; +const check = (label, cond) => { + cond ? pass++ : fail++; + console.log((cond ? "PASS" : "FAIL") + ": " + label); +}; + +// ---- fake server ---- + +// Mirrors class-store.php: songs live keyed by post id, tokens are derived from +// stored content (so an identical re-save doesn't move one), wpId/wpToken are +// stripped on the way in and decorated on the way out, and a PUT carrying +// X-Troche-Expect-Token is refused with 409 if the song has moved since. +function makeServer(initial = {}) { + const posts = new Map(Object.entries(initial).map(([k, v]) => [Number(k), v])); + let nextId = 200; + const token = (song) => "t:" + JSON.stringify(song); + const strip = ({ wpId, wpToken, ...rest }) => rest; + + const server = { + posts, + calls: [], + // Expected-token header per PUT, in call order, so a test can assert the + // client offered one at all — a silently omitted header would downgrade + // every write back to unconditional without failing anything else. + expectations: [], + down: false, // network failure + auth: true, + // Direct mutation, standing in for "the other laptop saved". + edit(wpId, patch) { + posts.set(wpId, { ...posts.get(wpId), ...patch }); + }, + add(song) { + const id = ++nextId; + posts.set(id, song); + return id; + }, + trash(wpId) { + posts.delete(wpId); + }, + decorate(wpId) { + const song = posts.get(wpId); + return { ...song, wpId, wpToken: token(song) }; + }, + }; + + globalThis.fetch = async (url, opts = {}) => { + const route = String(url).replace("http://test/troche/v1", ""); + const method = opts.method || "GET"; + server.calls.push(method + " " + route); + const body = opts.body ? JSON.parse(opts.body) : null; + const ok = (status, data) => ({ ok: true, status, json: async () => data }); + const err = (status) => ({ ok: false, status, json: async () => ({}) }); + + if (server.down) throw new TypeError("network error"); + if (!server.auth) return err(403); + + if (route === "/library") { + return ok(200, { + format: "troche", + version: 1, + songs: [...posts.keys()].sort((a, b) => a - b).map((id) => server.decorate(id)), + }); + } + if (route === "/library/state") { + const tokens = {}; + for (const [id, song] of posts) tokens[String(id)] = token(song); + return ok(200, { tokens }); + } + if (route === "/songs" && method === "POST") { + return ok(201, server.decorate(server.add(strip(body)))); + } + const match = route.match(/^\/songs\/(\d+)$/); + if (match) { + const wpId = Number(match[1]); + if (!posts.has(wpId)) return err(404); + if (method === "DELETE") { + posts.delete(wpId); + return ok(200, { deleted: true, wpId }); + } + const expect = (opts.headers || {})["X-Troche-Expect-Token"] ?? null; + server.expectations.push(expect); + if (expect !== null && expect !== token(posts.get(wpId))) return err(409); + posts.set(wpId, strip(body)); + return ok(200, server.decorate(wpId)); + } + throw new Error("unexpected request: " + method + " " + route); + }; + + return server; +} + +// storage.js reads window.trocheWP and computes wpMode at import time, and keeps +// its snapshot maps in module scope — so each scenario gets a fresh instance. +let instance = 0; +async function freshStorage() { + globalThis.window = { trocheWP: { restUrl: "http://test/troche/v1", nonce: "n", canEdit: true } }; + return import("../src/storage.js?case=" + ++instance); +} + +const song = (id, name, bpm = 120) => ({ id, name, bpm, parts: [] }); +const names = (lib) => lib.songs.map((s) => s.name); + +// ---- 1. nothing moved upstream ---- +{ + const server = makeServer({ 1: song("a", "Alpha"), 2: song("b", "Beta") }); + const storage = await freshStorage(); + const lib = await storage.loadLibrary(); + + server.calls.length = 0; + const result = await storage.syncUpstream(lib); + check("quiet server reports nothing to reconcile", result === null); + check( + "quiet sync costs one token request, no content", + server.calls.length === 1 && server.calls[0] === "GET /library/state" + ); +} + +// ---- 2. changed upstream, untouched here -> adopted silently ---- +{ + const server = makeServer({ 1: song("a", "Alpha"), 2: song("b", "Beta") }); + const storage = await freshStorage(); + const lib = await storage.loadLibrary(); + + server.edit(2, { bpm: 155 }); + const result = await storage.syncUpstream(lib); + check("upstream-only edit reconciles", result !== null && !result.conflicts.length); + check("upstream-only edit counts as pulled", result.pulled === 1); + check("upstream-only edit adopts the server value", result.library.songs[1].bpm === 155); + check("upstream-only edit leaves song order alone", String(names(result.library)) === "Alpha,Beta"); +} + +// ---- 3. changed in both places -> conflict, local kept, writes held ---- +{ + const server = makeServer({ 1: song("a", "Alpha"), 2: song("b", "Beta") }); + const storage = await freshStorage(); + const lib = await storage.loadLibrary(); + + server.edit(2, { bpm: 155 }); // the other laptop + const local = { ...lib, songs: [lib.songs[0], { ...lib.songs[1], bpm: 90 }] }; // this one + + const result = await storage.syncUpstream(local); + check("same-song edit is flagged", result.conflicts.length === 1); + check("conflict names the song", result.conflicts[0].name === "Beta"); + check("conflict carries the other copy", result.conflicts[0].theirs.bpm === 155); + check("conflict keeps the local copy in the library", result.library.songs[1].bpm === 90); + check("conflict is not counted as a silent pull", result.pulled === 0); + + server.calls.length = 0; + const saved = await storage.saveLibrary(result.library); + check("save succeeds around a conflict", saved.ok === true); + check("held song is not written", !server.calls.some((c) => c.startsWith("PUT"))); + check("held song is not trashed either", !server.calls.some((c) => c.startsWith("DELETE"))); + check("other laptop's copy survives", server.posts.get(2).bpm === 155); + + // Resolving to "keep mine" releases the hold; the next save overwrites. + storage.releaseHold(result.conflicts[0].wpId); + await storage.saveLibrary(result.library); + check("released song is written on the next save", server.posts.get(2).bpm === 90); +} + +// ---- 4. edits to other songs still save while one is conflicted ---- +{ + const server = makeServer({ 1: song("a", "Alpha"), 2: song("b", "Beta") }); + const storage = await freshStorage(); + const lib = await storage.loadLibrary(); + + server.edit(2, { bpm: 155 }); + const local = { + ...lib, + songs: [{ ...lib.songs[0], bpm: 70 }, { ...lib.songs[1], bpm: 90 }], + }; + + const result = await storage.syncUpstream(local); + await storage.saveLibrary(result.library); + check("a conflict on one song doesn't stall the others", server.posts.get(1).bpm === 70); +} + +// ---- 5. added upstream -> pulled in ---- +{ + const server = makeServer({ 1: song("a", "Alpha") }); + const storage = await freshStorage(); + const lib = await storage.loadLibrary(); + + server.add(song("c", "Gamma")); + const result = await storage.syncUpstream(lib); + check("song added elsewhere is pulled in", String(names(result.library)) === "Alpha,Gamma"); + check("added song counts as pulled", result.pulled === 1); +} + +// ---- 6. trashed upstream, untouched here -> dropped ---- +{ + const server = makeServer({ 1: song("a", "Alpha"), 2: song("b", "Beta") }); + const storage = await freshStorage(); + const lib = await storage.loadLibrary(); + + server.trash(2); + const result = await storage.syncUpstream(lib); + check("song trashed elsewhere is dropped here", String(names(result.library)) === "Alpha"); + check("dropped song raises no flag", result.orphans.length === 0 && result.conflicts.length === 0); +} + +// ---- 7. trashed upstream, edited here -> orphan, writes held ---- +{ + const server = makeServer({ 1: song("a", "Alpha"), 2: song("b", "Beta") }); + const storage = await freshStorage(); + const lib = await storage.loadLibrary(); + + server.trash(2); + const local = { ...lib, songs: [lib.songs[0], { ...lib.songs[1], bpm: 90 }] }; + + const result = await storage.syncUpstream(local); + check("deleted-but-edited song is flagged as an orphan", result.orphans.length === 1); + check("orphan keeps the local copy", result.library.songs.length === 2); + + // An orphan's PUT would 404 — the hold has to stop it reaching the server. + const saved = await storage.saveLibrary(result.library); + check("orphan doesn't fail the save with a 404", saved.ok === true); + + // "Keep mine" drops the dead handle so the song is re-created, not PUT. + const orphan = result.orphans[0]; + storage.forgetHandle(orphan.id, orphan.wpId); + const kept = { + ...result.library, + songs: result.library.songs.map(({ wpId, wpToken, ...rest }) => + rest.id === orphan.id ? rest : { ...rest, wpId, wpToken } + ), + }; + await storage.saveLibrary(kept); + check("kept orphan is re-created as a new song", server.posts.size === 2); +} + +// ---- 8. a song created here but never saved survives a sync ---- +{ + const server = makeServer({ 1: song("a", "Alpha") }); + const storage = await freshStorage(); + const lib = await storage.loadLibrary(); + + server.edit(1, { bpm: 155 }); + const local = { ...lib, songs: [...lib.songs, song("new", "Draft")] }; + + const result = await storage.syncUpstream(local); + check("unsaved local song survives a sync", String(names(result.library)) === "Alpha,Draft"); + check("unsaved local song isn't duplicated", result.library.songs.length === 2); +} + +// ---- 9. a song created this session isn't duplicated by a sync ---- +{ + const server = makeServer({ 1: song("a", "Alpha") }); + const storage = await freshStorage(); + const lib = await storage.loadLibrary(); + + // Save a new song, but don't adopt the returned wpId into the library — + // exactly the window where app state lags the server handle. + const local = { ...lib, songs: [...lib.songs, song("new", "Draft")] }; + const saved = await storage.saveLibrary(local); + check("new song is created on save", saved.ok && Object.keys(saved.assignedIds).length === 1); + + const result = await storage.syncUpstream(local); + check( + "sync before the handle is adopted doesn't duplicate the song", + result === null || result.library.songs.length === 2 + ); +} + +// ---- 10. a song rewritten between our sync and our write is refused ---- +// The sync-before-save narrows this window; the conditional PUT closes it. +{ + const server = makeServer({ 1: song("a", "Alpha"), 2: song("b", "Beta") }); + const storage = await freshStorage(); + const lib = await storage.loadLibrary(); + + const local = { ...lib, songs: [lib.songs[0], { ...lib.songs[1], bpm: 90 }] }; + server.edit(2, { bpm: 155 }); // the other laptop, after our last sync + + const res = await storage.saveLibrary(local); + check("a write onto a moved song is refused", res.ok === false && res.diverged === true); + check("refused write isn't reported as offline", !res.offline); + check("the other laptop's copy is intact", server.posts.get(2).bpm === 155); + check("the PUT carried an expected token", server.expectations.every((t) => !!t)); + + // ...and the follow-up sync turns it into a flag the user can act on. + const result = await storage.syncUpstream(local); + check("the refused write becomes a conflict", result.conflicts.length === 1); + check("conflict names the song", result.conflicts[0].name === "Beta"); +} + +// ---- 11. a song trashed between our sync and our write reports diverged ---- +// Used to surface as "Offline — changes kept locally", which it isn't. +{ + const server = makeServer({ 1: song("a", "Alpha"), 2: song("b", "Beta") }); + const storage = await freshStorage(); + const lib = await storage.loadLibrary(); + + const local = { ...lib, songs: [lib.songs[0], { ...lib.songs[1], bpm: 90 }] }; + server.trash(2); + + const res = await storage.saveLibrary(local); + check("a write onto a trashed song reports diverged", res.diverged === true); + check("a trashed song isn't reported as offline", !res.offline); + + const result = await storage.syncUpstream(local); + check("the failed write becomes an orphan", result.orphans.length === 1); +} + +// ---- 12. an unresolved conflict re-reports with the newer copy ---- +// "Use theirs" has to mean their copy as it stands now; adopting the one we +// first saw would push it straight back over their newer one. +{ + const server = makeServer({ 1: song("a", "Alpha"), 2: song("b", "Beta") }); + const storage = await freshStorage(); + const lib = await storage.loadLibrary(); + + server.edit(2, { bpm: 155 }); + const local = { ...lib, songs: [lib.songs[0], { ...lib.songs[1], bpm: 90 }] }; + const first = await storage.syncUpstream(local); + check("first sync flags the conflict", first.conflicts[0].theirs.bpm === 155); + + server.edit(2, { bpm: 175 }); // they edit again while we sit on the notice + const second = await storage.syncUpstream(first.library); + check("a re-edit re-reports the conflict", second.conflicts.length === 1); + check("the re-report carries their current copy", second.conflicts[0].theirs.bpm === 175); + + // Nothing new upstream: the flag isn't re-reported, but the hold stands. + const third = await storage.syncUpstream(second.library); + check("a quiet sync doesn't re-report a held song", third === null); + await storage.saveLibrary(second.library); + check("the hold survives a quiet sync", server.posts.get(2).bpm === 175); +} + +// ---- 13. probe failures don't take anything down with them ---- +{ + const server = makeServer({ 1: song("a", "Alpha") }); + const storage = await freshStorage(); + const lib = await storage.loadLibrary(); + + server.down = true; + check("an offline probe reconciles nothing", (await storage.syncUpstream(lib)) === null); + + server.down = false; + server.auth = false; + check("an expired session is reported, not swallowed", (await storage.syncUpstream(lib))?.authExpired === true); +} + +// ---- 14. no server, nothing to sync ---- +{ + globalThis.window = {}; // no trocheWP: standalone/localStorage mode + const storage = await import("../src/storage.js?case=local"); + check("syncUpstream is a no-op outside WP mode", (await storage.syncUpstream({ songs: [] })) === null); +} + +console.log(`\n=== ${pass} passed, ${fail} failed ===`); +process.exit(fail ? 1 : 0); diff --git a/tests/unit.mjs b/tests/unit.mjs index 07f1099..3855031 100644 --- a/tests/unit.mjs +++ b/tests/unit.mjs @@ -1,6 +1,6 @@ // Unit tests for pure client logic (no browser, no WordPress). // node tests/unit.mjs # or: npm run test:unit -import { normalizeLibrary } from "../src/utils.js"; +import { normalizeLibrary, mergeFlags } from "../src/utils.js"; let pass = 0; let fail = 0; @@ -49,5 +49,37 @@ check("no double-suffix", !r2.library.songs.some((s) => /\(\d+\) \(\d+\)/.test(s const r3 = normalizeLibrary({ activeId: "a", songs: [{ id: "a", name: "One" }, { id: "b", name: "Two" }] }); check("clean library unchanged", r3.changed === false); +// ---- mergeFlags: the songs a sync says need a decision ---- + +const conflict = (wpId, bpm) => ({ wpId, id: "s" + wpId, name: "S" + wpId, theirs: { bpm } }); +const orphan = (wpId) => ({ wpId, id: "s" + wpId, name: "S" + wpId }); + +let f = mergeFlags([], [conflict(2, 155)], []); +check("a conflict is flagged", f.length === 1 && f[0].kind === "conflict"); +check("a conflict carries their copy", f[0].theirs.bpm === 155); + +// Dismissing a flag is what releases that song's save hold, so a flag the sync +// didn't mention has to survive — dropping it would strand the song unsaveable. +f = mergeFlags(f, [], []); +check("a flag the sync didn't mention survives", f.length === 1 && f[0].wpId === 2); + +// Re-reported: the other machine moved again, so their copy has to move with +// it. Keeping the first one would push a stale version back over their newer. +f = mergeFlags(f, [conflict(2, 175)], []); +check("a re-reported song isn't duplicated", f.length === 1); +check("a re-reported song adopts the newer copy", f[0].theirs.bpm === 175); + +// Trashed elsewhere after the conflict: there's no "theirs" left to take. +f = mergeFlags(f, [], [orphan(2)]); +check("an orphan supersedes a conflict on the same song", f.length === 1 && f[0].kind === "orphan"); + +// Existing entries hold their place; new ones append. +f = mergeFlags([conflict(2, 1), conflict(3, 1)], [conflict(3, 2)], [orphan(9)]); +check( + "flags keep their order, new ones append", + JSON.stringify(f.map((x) => x.wpId)) === JSON.stringify([2, 3, 9]) +); +check("only the re-reported entry changes", f[0].theirs.bpm === 1 && f[1].theirs.bpm === 2); + console.log(`\n=== ${pass} passed, ${fail} failed ===`); process.exit(fail ? 1 : 0); diff --git a/wp-plugin/includes/class-rest-controller.php b/wp-plugin/includes/class-rest-controller.php index 6fcce01..5d3ebc6 100644 --- a/wp-plugin/includes/class-rest-controller.php +++ b/wp-plugin/includes/class-rest-controller.php @@ -3,10 +3,18 @@ * REST endpoints under troche/v1. * * - GET /library Whole library in the envelope format (login required). + * - GET /library/state Per-song version tokens, no content (login required). * - POST /songs Create a song (troche_edit required). * - PUT /songs/{id} Update a song (troche_edit required). * - DELETE /songs/{id} Trash a song (troche_edit required). * + * PUT honours an optional X-Troche-Expect-Token header carrying the version + * token the client last saw; the update is refused with 409 if the song has + * moved since. A custom header rather than If-Match on purpose: an If-Match a + * proxy or cache decides to evaluate itself would fail the save outright, + * whereas a stripped custom header just degrades to the unconditional write + * this endpoint has always done. + * * Auth is cookie + nonce (same-origin); there is no CORS surface. Reads gate on * being logged in; writes gate on the troche_edit capability. * @@ -44,6 +52,16 @@ public function register_routes() { ) ); + register_rest_route( + self::NAMESPACE, + '/library/state', + array( + 'methods' => \WP_REST_Server::READABLE, + 'callback' => array( $this, 'get_state' ), + 'permission_callback' => array( $this, 'can_read' ), + ) + ); + register_rest_route( self::NAMESPACE, '/songs', @@ -111,6 +129,19 @@ public function get_library() { return rest_ensure_response( Store::get_library() ); } + /** + * Return per-song version tokens so a client can tell, in one small + * request, whether anything changed under it since its last sync. + * + * @return \WP_REST_Response + */ + public function get_state() { + $response = rest_ensure_response( Store::get_state() ); + // Freshness is the whole point — never let a cache answer this. + $response->header( 'Cache-Control', 'no-store, max-age=0' ); + return $response; + } + /** * Create a song from the request body. * @@ -151,8 +182,11 @@ private function write( \WP_REST_Request $request, $wp_id ) { ); } + // Only meaningful on an update; a create has nothing to be stale against. + $expect = $wp_id ? $request->get_header( 'x_troche_expect_token' ) : null; + $song = Store::sanitize_song( $body ); - $saved = Store::save_song( $song, $wp_id, get_current_user_id() ); + $saved = Store::save_song( $song, $wp_id, get_current_user_id(), $expect ); if ( is_wp_error( $saved ) ) { return $saved; diff --git a/wp-plugin/includes/class-store.php b/wp-plugin/includes/class-store.php index 65e201b..17ccbd5 100644 --- a/wp-plugin/includes/class-store.php +++ b/wp-plugin/includes/class-store.php @@ -100,13 +100,13 @@ static function () { } /** - * The whole library in the envelope format, each song decorated with its - * post id (`wpId`) as the save handle. Trashed songs are excluded. + * Every live song post, oldest first. Shared by get_library() and + * get_state() so the two can never disagree about what's in the library. * - * @return array { format:string, version:int, songs:array[] } + * @return \WP_Post[] */ - public static function get_library() { - $posts = get_posts( + private static function get_song_posts() { + return get_posts( array( 'post_type' => self::POST_TYPE, 'post_status' => 'publish', @@ -116,15 +116,25 @@ public static function get_library() { 'suppress_filters' => false, ) ); + } + /** + * The whole library in the envelope format, each song decorated with its + * post id (`wpId`) as the save handle and its version token (`wpToken`). + * Trashed songs are excluded. + * + * @return array { format:string, version:int, songs:array[] } + */ + public static function get_library() { $songs = array(); - foreach ( $posts as $post ) { + foreach ( self::get_song_posts() as $post ) { $song = self::decode_song( $post->post_content ); if ( null === $song ) { continue; } - $song['wpId'] = (int) $post->ID; - $songs[] = $song; + $song['wpId'] = (int) $post->ID; + $song['wpToken'] = self::token( $post->post_content ); + $songs[] = $song; } return array( @@ -134,15 +144,58 @@ public static function get_library() { ); } + /** + * Version tokens for every live song, keyed by post id — the cheap "has + * anything moved?" probe a second tab polls before it saves. Carries no + * song content, so it stays small however big the library gets. + * + * @return array { tokens: array } + */ + public static function get_state() { + $tokens = array(); + foreach ( self::get_song_posts() as $post ) { + if ( null === self::decode_song( $post->post_content ) ) { + // Skip unparseable posts, exactly as get_library() does, so the + // two views agree on which songs exist. + continue; + } + $tokens[ (string) $post->ID ] = self::token( $post->post_content ); + } + + return array( 'tokens' => (object) $tokens ); + } + + /** + * A song's version token: a hash of its stored JSON. + * + * Content-derived rather than time-derived on purpose. post_modified_gmt + * only has one-second resolution (two saves in the same second look + * identical) and it moves even when a save rewrites byte-identical content, + * which would show up in another tab as a phantom conflict. Clients only + * ever compare tokens for equality — they never compute one — so the hash + * is free to change shape later. + * + * @param string $content post_content. + * @return string + */ + private static function token( $content ) { + return md5( (string) $content ); + } + /** * Create or update one song. * - * @param array $song Sanitized song object. - * @param int|null $wp_id Existing post id to update, or null to create. - * @param int $user Author id for new posts. + * @param array $song Sanitized song object. + * @param int|null $wp_id Existing post id to update, or null to create. + * @param int $user Author id for new posts. + * @param string|null $expect Version token the caller believes is current. + * When given, the update only lands if the stored + * token still matches; otherwise 409. Null skips + * the check (creates, and callers that don't + * track tokens). * @return array|\WP_Error The saved song (with wpId), or an error. */ - public static function save_song( array $song, $wp_id, $user ) { + public static function save_song( array $song, $wp_id, $user, $expect = null ) { $title = isset( $song['name'] ) && '' !== trim( (string) $song['name'] ) ? (string) $song['name'] : __( 'Untitled Song', 'troche' ); @@ -155,8 +208,8 @@ public static function save_song( array $song, $wp_id, $user ) { $song['id'] = self::generate_id(); } - // The wpId is a server-side handle, not part of the stored envelope. - unset( $song['wpId'] ); + // wpId and wpToken are server-side handles, not part of the stored envelope. + unset( $song['wpId'], $song['wpToken'] ); // wp_insert_post()/wp_update_post() expect slashed input and strip one // level of slashes on the way in; the encoded JSON contains backslashes @@ -177,6 +230,26 @@ public static function save_song( array $song, $wp_id, $user ) { array( 'status' => 404 ) ); } + + // Conditional write. The client syncs before saving, but that check + // and this write aren't one operation — another machine can land a + // save in between. Comparing tokens here, immediately before the + // write, is what makes "your edit never disappears" true rather + // than merely likely. A mismatch is not an error the user needs to + // see: the client reconciles and asks, naming the song. + $current = self::token( $existing->post_content ); + if ( null !== $expect && $expect !== $current ) { + return new \WP_Error( + 'troche_stale_token', + __( 'That song changed somewhere else since you last synced.', 'troche' ), + array( + 'status' => 409, + 'wpId' => (int) $wp_id, + 'wpToken' => $current, + ) + ); + } + $postarr['ID'] = (int) $wp_id; $result = wp_update_post( $postarr, true ); } else { @@ -188,7 +261,15 @@ public static function save_song( array $song, $wp_id, $user ) { return $result; } - $song['wpId'] = (int) $result; + // Token comes from the post as actually stored, not from the string we + // sent: wp_insert_post()/wp_update_post() run content through save + // filters (kses for users without unfiltered_html, among others), and a + // token that didn't survive those filters would read as a conflict on + // the very next poll. + $stored = get_post( (int) $result ); + + $song['wpId'] = (int) $result; + $song['wpToken'] = self::token( $stored ? $stored->post_content : '' ); return $song; } diff --git a/wp-plugin/readme.txt b/wp-plugin/readme.txt index 7a9fa93..b409767 100644 --- a/wp-plugin/readme.txt +++ b/wp-plugin/readme.txt @@ -27,6 +27,9 @@ This plugin serves the app from your WordPress site and adds: the editing capability can change it. * **Autosave and save history.** Edits save automatically, and every save is a WordPress revision you can review and restore from the dashboard. +* **Safe on two devices at once.** If a bandmate changes a song while you have + it open, the app notices and quietly brings the change in. It only asks you + to choose when the same song was edited in both places. * **Import and export.** Move a library in or out as JSON at any time. The app is served at a URL you choose (default `/troche`), styled entirely by