- Long pastes into Observe/Interpret/Apply are tagged as pasted; notes show Mine / N pasted and an own-words share. Pastes follow text through merges. - The "-> Claude" buttons on the Script stage ask first when a chunk lacks enough of the user's own (non-pasted) words. - Weekly recall card on Home: write a passage from memory, compare with notes, self-rate; recalls are saved on the chunk and synced. - Recall history on Home with an eight-week strip and the full list. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
321 lines
14 KiB
JavaScript
321 lines
14 KiB
JavaScript
// The signed-in user's saved projects: opening, renaming, deleting, and keeping
|
|
// this device's copies in step with the server.
|
|
import { useEffect, useRef, useState } from 'react';
|
|
import { useToast } from '../components/Toasts.jsx';
|
|
import { clearSyncBase, getSyncBase, setSyncBase } from '../utils/syncBase.js';
|
|
import { loadProjectIndex } from '../utils/bibleUtils.jsx';
|
|
import { knownOnServer, pendingDeletes as pendingDeleteIds, unsyncedStudies } from '../utils/syncQueue.js';
|
|
import {
|
|
saveProjectToStorage,
|
|
deleteProjectFromStorage,
|
|
loadProjectById,
|
|
} from '../utils/projectStorage.js';
|
|
import {
|
|
saveRemoteProject,
|
|
deleteRemoteProject,
|
|
listRemoteProjects,
|
|
loadRemoteProject,
|
|
} from '../syncService.js';
|
|
|
|
export function useProjectLibrary({
|
|
authUser,
|
|
project,
|
|
setProject,
|
|
setProjectIndex,
|
|
setCurrentPage,
|
|
}) {
|
|
const { toast } = useToast();
|
|
// Deleted projects waiting out their "Undo" window before the server copy is removed too
|
|
const pendingDeletes = useRef(new Map()); // id -> timer
|
|
const [staleLocalProjects, setStaleLocalProjects] = useState([]); // projects where server is newer
|
|
const [autoRestoredCount, setAutoRestoredCount] = useState(null); // brief "synced from another device" toast
|
|
// Studies another account invited you to edit: id -> the owner's email (shown on the card)
|
|
const [sharedStudies, setSharedStudies] = useState({});
|
|
const sharedFrom = (list) => Object.fromEntries(list.filter((e) => e.shared).map((e) => [e.id, e.ownerEmail ?? '']));
|
|
|
|
// Studies saved here that the server hasn't accepted yet (saved offline, or the tab closed mid-sync).
|
|
// A study another device changed meanwhile is left alone; opening it offers the keep-mine/keep-theirs choice.
|
|
const pushPending = async () => {
|
|
for (const id of unsyncedStudies.list()) {
|
|
const local = loadProjectById(id);
|
|
if (!local) { unsyncedStudies.remove(id); continue; }
|
|
const result = await saveRemoteProject(local, { baseEdited: getSyncBase(id) });
|
|
if (result.ok) {
|
|
const serverEdited = result.data?.lastEdited ?? local.lastEdited;
|
|
setSyncBase(id, serverEdited);
|
|
unsyncedStudies.remove(id);
|
|
knownOnServer.add(id);
|
|
try { saveProjectToStorage({ ...local, lastEdited: serverEdited }); } catch { /* already saved */ }
|
|
} else if (result.status === 409) {
|
|
toast(`“${local.title}” changed on another device. Open it to choose which version to keep.`, { tone: 'error' });
|
|
}
|
|
}
|
|
setProjectIndex(loadProjectIndex());
|
|
};
|
|
const retryPendingDeletes = async () => {
|
|
for (const id of pendingDeleteIds.list()) {
|
|
if (pendingDeletes.current.has(id)) continue; // still inside its Undo window
|
|
const result = await deleteRemoteProject(id);
|
|
if (result.ok) { pendingDeleteIds.remove(id); knownOnServer.remove(id); }
|
|
}
|
|
};
|
|
|
|
// Once we know who's signed in, reconcile the local project index against the server.
|
|
// Runs on every authUser change (including logout -> different login) so a previous
|
|
// account's stale suggestions never linger after switching users. Projects that exist
|
|
// on the server but not on this device (e.g. a brand new browser) are pulled down
|
|
// automatically instead of requiring a manual "Restore" click per project.
|
|
useEffect(() => {
|
|
if (!authUser) {
|
|
setStaleLocalProjects([]);
|
|
return;
|
|
}
|
|
let cancelled = false;
|
|
const localIndex = loadProjectIndex();
|
|
listRemoteProjects().then(async (result) => {
|
|
if (!result.ok || cancelled) return;
|
|
// Deletes that never reached the server (offline, tab closed) go first, so those studies aren't pulled back.
|
|
// (the server's list above may still include them, so note which they are before they're cleared)
|
|
const gone = new Set(pendingDeleteIds.list());
|
|
await retryPendingDeletes();
|
|
const serverList = result.data.filter((e) => !gone.has(e.id));
|
|
setSharedStudies(sharedFrom(serverList));
|
|
const localMap = new Map(localIndex.map((e) => [e.id, e]));
|
|
// What this device has seen of each study on the server: a save made on top of anything older is
|
|
// checked against it, so a newer copy from another device is never overwritten silently.
|
|
for (const e of serverList) {
|
|
const local = localMap.get(e.id);
|
|
setSyncBase(e.id, local ? Math.min(e.lastEdited ?? 0, local.lastEdited ?? 0) : e.lastEdited);
|
|
}
|
|
|
|
// A study this device knew the server had, that the server no longer has, was deleted somewhere else.
|
|
const onServer = new Set(serverList.map((e) => e.id));
|
|
const unsynced = new Set(unsyncedStudies.list());
|
|
const deletedElsewhere = localIndex.filter((e) => knownOnServer.has(e.id) && !onServer.has(e.id) && !unsynced.has(e.id) && !gone.has(e.id));
|
|
deletedElsewhere.forEach((e) => {
|
|
const copy = loadProjectById(e.id);
|
|
deleteProjectFromStorage(e.id);
|
|
clearSyncBase(e.id);
|
|
knownOnServer.remove(e.id);
|
|
localMap.delete(e.id);
|
|
toast(`“${e.title}” was deleted on another device`, {
|
|
action: {
|
|
label: 'Keep it',
|
|
onClick: () => { if (copy) { saveProjectToStorage(copy); unsyncedStudies.add(copy.id); setProjectIndex(loadProjectIndex()); pushPending(); } },
|
|
},
|
|
});
|
|
});
|
|
if (deletedElsewhere.length > 0) setProjectIndex(loadProjectIndex());
|
|
knownOnServer.replace(serverList.map((e) => e.id));
|
|
|
|
const missing = serverList.filter((e) => !localMap.has(e.id));
|
|
if (missing.length > 0) {
|
|
const fetched = await Promise.all(missing.map((e) => loadRemoteProject(e.id)));
|
|
fetched.filter((r) => r.ok).forEach((r) => { saveProjectToStorage(r.data); setSyncBase(r.data.id, r.data.lastEdited); });
|
|
if (cancelled) return;
|
|
const restoredCount = fetched.filter((r) => r.ok).length;
|
|
if (restoredCount > 0) {
|
|
setProjectIndex(loadProjectIndex());
|
|
setAutoRestoredCount(restoredCount);
|
|
window.setTimeout(() => setAutoRestoredCount(null), 4000);
|
|
}
|
|
}
|
|
|
|
const stale = serverList.filter((e) => {
|
|
const local = localMap.get(e.id);
|
|
return local && (e.lastEdited ?? 0) > (local.lastEdited ?? 0) && !unsynced.has(e.id);
|
|
});
|
|
if (!cancelled) setStaleLocalProjects(stale);
|
|
pushPending();
|
|
});
|
|
return () => { cancelled = true; };
|
|
}, [authUser]);
|
|
|
|
// Another device (or a co-teacher) may have saved a study since you opened this page: look again when you come
|
|
// back to the tab, and every couple of minutes while it's open. A newer copy is offered, never forced on you.
|
|
const refreshStale = async () => {
|
|
const result = await listRemoteProjects();
|
|
if (!result.ok) return;
|
|
const local = new Map(loadProjectIndex().map((e) => [e.id, e]));
|
|
const unsynced = new Set(unsyncedStudies.list());
|
|
setSharedStudies(sharedFrom(result.data));
|
|
setStaleLocalProjects(result.data.filter((e) => {
|
|
const mine = local.get(e.id);
|
|
return mine && (e.lastEdited ?? 0) > (mine.lastEdited ?? 0) && !unsynced.has(e.id);
|
|
}));
|
|
};
|
|
useEffect(() => {
|
|
if (!authUser) return undefined;
|
|
let last = Date.now();
|
|
const maybe = () => { if (document.visibilityState === 'visible' && Date.now() - last > 30_000) { last = Date.now(); refreshStale(); } };
|
|
document.addEventListener('visibilitychange', maybe);
|
|
const timer = window.setInterval(maybe, 120_000);
|
|
return () => { document.removeEventListener('visibilitychange', maybe); window.clearInterval(timer); };
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [authUser]);
|
|
|
|
// Back online: send what the server missed.
|
|
useEffect(() => {
|
|
const online = () => { if (authUser) { retryPendingDeletes(); pushPending(); } };
|
|
window.addEventListener('online', online);
|
|
return () => window.removeEventListener('online', online);
|
|
}, [authUser]);
|
|
|
|
const resumeProject = (id) => {
|
|
const loaded = loadProjectById(id);
|
|
if (!loaded) return;
|
|
setProject(loaded);
|
|
const loadedChunks = loaded.chapters.flatMap((ch) => ch.chunks);
|
|
const hasSelectedChunk = loaded.selectedChunkId
|
|
&& loadedChunks.some((c) => c.id === loaded.selectedChunkId);
|
|
setCurrentPage(hasSelectedChunk ? 'study' : 'setup');
|
|
};
|
|
|
|
// Opens a study on one particular chunk (from the Reader's "In your studies").
|
|
const openStudyChunk = (id, chunkId) => {
|
|
const loaded = loadProjectById(id);
|
|
if (!loaded) return;
|
|
setProject({ ...loaded, selectedChunkId: chunkId });
|
|
setCurrentPage('study');
|
|
};
|
|
|
|
const renameProjectInStorage = (id, title) => {
|
|
const loaded = loadProjectById(id);
|
|
if (!loaded) return;
|
|
const updated = { ...loaded, title, lastEdited: Date.now() };
|
|
saveProjectToStorage(updated);
|
|
setProjectIndex(loadProjectIndex());
|
|
unsyncedStudies.add(id);
|
|
saveRemoteProject(updated, { baseEdited: getSyncBase(id) }).then((result) => {
|
|
if (result.ok) {
|
|
const serverEdited = result.data?.lastEdited ?? updated.lastEdited;
|
|
setSyncBase(id, serverEdited);
|
|
unsyncedStudies.remove(id);
|
|
try { saveProjectToStorage({ ...updated, lastEdited: serverEdited }); setProjectIndex(loadProjectIndex()); } catch { /* already saved */ }
|
|
}
|
|
else if (result.status === 409) toast('That study changed on another device. Open it to choose which version to keep.', { tone: 'error' });
|
|
});
|
|
if (project?.id === id) {
|
|
setProject((current) => (current ? { ...current, title } : current));
|
|
}
|
|
};
|
|
|
|
// Puts a study in a series ("Romans", "Advent 2026"), or takes it out (an empty name).
|
|
const setProjectSeries = (id, series) => {
|
|
const loaded = loadProjectById(id);
|
|
if (!loaded) return;
|
|
const name = String(series ?? '').trim().slice(0, 80);
|
|
if ((loaded.series ?? '') === name) return;
|
|
const updated = { ...loaded, series: name, lastEdited: Date.now() };
|
|
saveProjectToStorage(updated);
|
|
unsyncedStudies.add(id);
|
|
setProjectIndex(loadProjectIndex());
|
|
if (project?.id === id) setProject((current) => (current ? { ...current, series: name } : current));
|
|
saveRemoteProject(updated, { baseEdited: getSyncBase(id) }).then((result) => {
|
|
if (result.ok) {
|
|
const serverEdited = result.data?.lastEdited ?? updated.lastEdited;
|
|
setSyncBase(id, serverEdited);
|
|
unsyncedStudies.remove(id);
|
|
try { saveProjectToStorage({ ...updated, lastEdited: serverEdited }); setProjectIndex(loadProjectIndex()); } catch { /* already saved */ }
|
|
} else if (result.status === 409) toast('That study changed on another device. Open it to choose which version to keep.', { tone: 'error' });
|
|
});
|
|
};
|
|
|
|
// Saves a weekly recall (what you remembered, and how it went) on one chunk of a stored study.
|
|
const saveChunkRecall = (id, chunkId, recall) => {
|
|
const loaded = loadProjectById(id);
|
|
if (!loaded) return;
|
|
const updated = {
|
|
...loaded,
|
|
lastEdited: Date.now(),
|
|
chapters: loaded.chapters.map((ch) => ({
|
|
...ch,
|
|
chunks: ch.chunks.map((c) => (c.id === chunkId ? { ...c, recalls: [...(c.recalls ?? []), recall] } : c)),
|
|
})),
|
|
};
|
|
saveProjectToStorage(updated);
|
|
unsyncedStudies.add(id);
|
|
setProjectIndex(loadProjectIndex());
|
|
if (project?.id === id) setProject(updated);
|
|
saveRemoteProject(updated, { baseEdited: getSyncBase(id) }).then((result) => {
|
|
if (result.ok) {
|
|
const serverEdited = result.data?.lastEdited ?? updated.lastEdited;
|
|
setSyncBase(id, serverEdited);
|
|
unsyncedStudies.remove(id);
|
|
try { saveProjectToStorage({ ...updated, lastEdited: serverEdited }); setProjectIndex(loadProjectIndex()); } catch { /* already saved */ }
|
|
} else if (result.status === 409) toast('That study changed on another device. Open it to choose which version to keep.', { tone: 'error' });
|
|
});
|
|
};
|
|
|
|
// Deleting is immediate here, with an Undo for a few seconds; only after that is the server copy
|
|
// removed (and straight away if the tab is closed first, so a deleted project never comes back).
|
|
const finishDelete = (id) => {
|
|
if (!pendingDeletes.current.has(id)) return;
|
|
window.clearTimeout(pendingDeletes.current.get(id));
|
|
pendingDeletes.current.delete(id);
|
|
deleteRemoteProject(id).then((result) => { // stays on the pending list until the server confirms
|
|
if (result.ok) { pendingDeleteIds.remove(id); knownOnServer.remove(id); }
|
|
});
|
|
};
|
|
useEffect(() => {
|
|
const flush = () => [...pendingDeletes.current.keys()].forEach(finishDelete);
|
|
window.addEventListener('pagehide', flush);
|
|
return () => window.removeEventListener('pagehide', flush);
|
|
}, []);
|
|
|
|
const deleteProject = (id) => {
|
|
const loaded = loadProjectById(id);
|
|
if (!loaded) return;
|
|
deleteProjectFromStorage(id);
|
|
clearSyncBase(id);
|
|
unsyncedStudies.remove(id);
|
|
pendingDeleteIds.add(id);
|
|
setProjectIndex(loadProjectIndex());
|
|
pendingDeletes.current.set(id, window.setTimeout(() => finishDelete(id), 8000));
|
|
if (sharedStudies[id] !== undefined) {
|
|
// A study someone else owns: this only removes you from it (they keep it). A new invite is needed to rejoin.
|
|
toast(`You left “${loaded.title}”. Ask for a new invite if you want back in.`);
|
|
return;
|
|
}
|
|
toast(`Deleted “${loaded.title}”`, {
|
|
action: {
|
|
label: 'Undo',
|
|
onClick: () => {
|
|
window.clearTimeout(pendingDeletes.current.get(id));
|
|
pendingDeletes.current.delete(id);
|
|
pendingDeleteIds.remove(id);
|
|
unsyncedStudies.add(id); // re-sent in case the server copy was already removed
|
|
saveProjectToStorage(loaded);
|
|
setProjectIndex(loadProjectIndex());
|
|
},
|
|
},
|
|
});
|
|
};
|
|
|
|
const pullLatestFromServer = async (id) => {
|
|
const result = await loadRemoteProject(id);
|
|
if (!result.ok) {
|
|
toast('Could not pull the latest version from the server.', { tone: 'error' });
|
|
return;
|
|
}
|
|
saveProjectToStorage(result.data);
|
|
setSyncBase(id, result.data.lastEdited);
|
|
setProjectIndex(loadProjectIndex());
|
|
setStaleLocalProjects((prev) => prev.filter((e) => e.id !== id));
|
|
};
|
|
|
|
return {
|
|
sharedStudies,
|
|
refreshLibrary: refreshStale,
|
|
staleLocalProjects,
|
|
autoRestoredCount,
|
|
resumeProject,
|
|
openStudyChunk,
|
|
renameProjectInStorage,
|
|
setProjectSeries,
|
|
saveChunkRecall,
|
|
deleteProject,
|
|
pullLatestFromServer,
|
|
};
|
|
}
|