From 2ce82ab998ba5922aa3ecf9d317a08e086cca182 Mon Sep 17 00:00:00 2001 From: Ramon Date: Tue, 14 Jul 2026 16:26:16 +0200 Subject: [PATCH] v0.3.19-beta: verstuur bij het opslaan alleen gewijzigde secties - Nieuwe route PUT /me/data/:section (boards/themes/generalExtra): vervangt alleen die sub-key via jsonb_set, met dezelfde rev/409-controle en snapshot-hook als de volledige PUT (die blijft bestaan als terugval) - Per-sectie size-caps (boards 20MB, themes/generalExtra 2MB) - core.js: DIRTY-set houdt bij wat er gewijzigd is; woordwijzigingen sturen alleen themes+generalExtra, bordwijzigingen alleen boards (sequentieel, rev per respons bijgewerkt); zonder gerichte wijziging volledige save - Twee nieuwe servertests (jsonb_set op de juiste sub-key, onbekende sectie is 404) --- VERSION | 2 +- public/js/board.js | 4 ++-- public/js/core.js | 41 +++++++++++++++++++++++++++++++++------- src/api.js | 23 ++++++++++++++++++++++ test/save-system.test.js | 22 +++++++++++++++++++++ 5 files changed, 82 insertions(+), 10 deletions(-) diff --git a/VERSION b/VERSION index 45c52c3..f9be863 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.18-beta +0.3.19-beta diff --git a/public/js/board.js b/public/js/board.js index 6cdcd38..6457afb 100644 --- a/public/js/board.js +++ b/public/js/board.js @@ -11,7 +11,7 @@ function recordBoardHistory(){ if(!BOARD_HISTORY.length||JSON.stringify(BOARD_HISTORY.at(-1))!==sig){BOARD_HISTORY.push(snap);if(BOARD_HISTORY.length>30)BOARD_HISTORY.shift();BOARD_FUTURE=[];} },250); } -const markBoardChange = ()=>{ if(!RESTORING){ scheduleSave(); recordBoardHistory(); } }; +const markBoardChange = ()=>{ if(!RESTORING){ scheduleSave("boards"); recordBoardHistory(); } }; /* vangnet voor widget-INTERNE wijzigingen (notities typen, mindmap slepen, schrijfblad, spelvoortgang): elke interactie in een widget-body plant een (debounced) save - goedkoop, en dekt alle widgets zonder ze stuk voor stuk @@ -835,7 +835,7 @@ function renderTemplates(){const host=document.getElementById("galleryTemplates" const importInput=document.createElement("input");importInput.type="file";importInput.accept="application/json";importInput.hidden=true;document.body.append(importInput); document.getElementById("boardExport").onclick=()=>{const blob=new Blob([JSON.stringify(serializeBoard(),null,2)],{type:"application/json"}),a=document.createElement("a");a.href=URL.createObjectURL(blob);a.download="teach-bord.json";a.click();setTimeout(()=>URL.revokeObjectURL(a.href),500)}; document.getElementById("boardImport").onclick=()=>importInput.click();importInput.onchange=()=>{const f=importInput.files[0];if(!f)return;if(f.size>2*1024*1024){alert(T("importError"));return}const r=new FileReader();r.onload=()=>{try{const data=JSON.parse(r.result);if(!data||!Array.isArray(data.widgets)||data.widgets.length>100||(data.imgs||[]).length>50)throw Error();restoreBoard(data);markBoardChange()}catch(e){alert(T("importError"))}};r.readAsText(f);importInput.value=""}; -function historyMove(redo){clearTimeout(HISTORY_TIMER);const from=redo?BOARD_FUTURE:BOARD_HISTORY,to=redo?BOARD_HISTORY:BOARD_FUTURE;if((redo&&from.length)||(!redo&&from.length>1)){const current=from.pop();to.push(current);const target=redo?current:from.at(-1);HISTORY_BUSY=true;restoreBoard(structuredClone(target));HISTORY_BUSY=false;scheduleSave()}} +function historyMove(redo){clearTimeout(HISTORY_TIMER);const from=redo?BOARD_FUTURE:BOARD_HISTORY,to=redo?BOARD_HISTORY:BOARD_FUTURE;if((redo&&from.length)||(!redo&&from.length>1)){const current=from.pop();to.push(current);const target=redo?current:from.at(-1);HISTORY_BUSY=true;restoreBoard(structuredClone(target));HISTORY_BUSY=false;scheduleSave("boards")}} document.getElementById("boardUndo").onclick=()=>historyMove(false);document.getElementById("boardRedo").onclick=()=>historyMove(true);document.addEventListener("keydown",e=>{if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==="z"&&!e.target.matches("input,textarea,[contenteditable]")){e.preventDefault();historyMove(e.shiftKey)}}); /* ---------- floating add-button (FAB) ---------- */ diff --git a/public/js/core.js b/public/js/core.js index a82ea1f..2cfb3be 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -2,7 +2,7 @@ "use strict"; /* version — shown until /api/version resolves (or if the fetch fails, e.g. offline). Kept in sync by hand with the VERSION file at the repo root on every release. */ -const VERSION = "0.3.18-beta"; +const VERSION = "0.3.19-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; @@ -438,12 +438,35 @@ let saveInFlight = false; function canPersist(){ return !!currentUser && currentUser.role !== "pupil"; } +/* welke secties er sinds de laatste geslaagde save zijn gewijzigd; een save + verstuurt alleen die secties i.p.v. altijd de complete blob (die MB's kan + zijn door borden met afbeeldingen) */ +const DIRTY = new Set(); +const DATA_SECTIONS = { themes: ()=>THEMES, generalExtra: ()=>GENERAL_EXTRA, boards: ()=>BS }; function persistUser(){ if(!canPersist()) return Promise.resolve(true); - return api("/me/data", { method:"PUT", headers:{ "x-data-rev": String(DATA_REV) }, body:{ - themes: THEMES, generalExtra: GENERAL_EXTRA, boards: BS - }}).then(res=>{ lastSaveError = null; saveConflict = false; if(typeof res.rev === "number") DATA_REV = res.rev; return true; }) - .catch(e=>{ lastSaveError = e.message; saveConflict = e.status === 409; return false; }); + const todo = [...DIRTY].filter(s=>s in DATA_SECTIONS); + const run = async ()=>{ + if(!todo.length || todo.length >= Object.keys(DATA_SECTIONS).length){ + /* alles (of niets specifieks) gewijzigd: volledige save als terugval */ + const res = await api("/me/data", { method:"PUT", headers:{ "x-data-rev": String(DATA_REV) }, body:{ + themes: THEMES, generalExtra: GENERAL_EXTRA, boards: BS + }}); + if(typeof res.rev === "number") DATA_REV = res.rev; + }else{ + /* sequentieel, niet parallel: elke sectie-PUT bumpt de rev en de + volgende moet de nieuwe rev meesturen om geen 409 te krijgen */ + for(const s of todo){ + const res = await api("/me/data/"+s, { method:"PUT", headers:{ "x-data-rev": String(DATA_REV) }, body: DATA_SECTIONS[s]() }); + if(typeof res.rev === "number") DATA_REV = res.rev; + } + } + DIRTY.clear(); + lastSaveError = null; + saveConflict = false; + return true; + }; + return run().catch(e=>{ lastSaveError = e.message; saveConflict = e.status === 409; return false; }); } /* status op de opslaan-knop: idle / saving / saved / error / conflict */ function setSaveStatus(state){ @@ -487,13 +510,17 @@ window.addEventListener("beforeunload", (e)=>{ if(!canPersist()) return; if(saveTimer || saveInFlight || lastSaveError){ e.preventDefault(); e.returnValue = ""; } }); -/* autosave: door alles aan te roepen wat het bord of de woorden wijzigt */ -function scheduleSave(){ +/* autosave: door alles aan te roepen wat het bord of de woorden wijzigt; + de optionele sectie markeert wat er precies vuil is (zie DIRTY) */ +function scheduleSave(section){ + if(section) DIRTY.add(section); if(!canPersist()) return; clearTimeout(saveTimer); saveTimer = setTimeout(doSave, 2500); } function persistWords(){ + DIRTY.add("themes"); + DIRTY.add("generalExtra"); scheduleSave(); } function generalExtraFromData(data){ diff --git a/src/api.js b/src/api.js index fccb047..3a39f19 100644 --- a/src/api.js +++ b/src/api.js @@ -233,6 +233,29 @@ export default async function api(app) { if (Buffer.byteLength(JSON.stringify(data), 'utf8') > 20 * 1024 * 1024) return fail(reply, 413, 'borddata te groot'); return writeUserData(req, reply, data); }); + // Gesectioneerd opslaan: alleen de gewijzigde sub-key vervangen (jsonb_set) + // i.p.v. de hele blob versturen. Zelfde rev/409- en snapshot-gedrag als de + // volledige PUT hierboven, die blijft bestaan voor restore/eerste save. + const DATA_SECTIONS = { boards: 20, themes: 2, generalExtra: 2 }; // cap in MB + app.put('/me/data/:section', { bodyLimit: 21 * 1024 * 1024 }, async (req, reply) => { + need(req, reply); + const section = req.params.section; + if (!(section in DATA_SECTIONS)) return fail(reply, 404, 'onbekende sectie'); + const part = req.body ?? {}; + if (!part || typeof part !== 'object' || Array.isArray(part)) return fail(reply, 400, 'ongeldige data'); + if (Buffer.byteLength(JSON.stringify(part), 'utf8') > DATA_SECTIONS[section] * 1024 * 1024) + return fail(reply, 413, 'data te groot'); + await snapshotUserData(req.user.id); + const rev = revFromRequest(req); + const set = `data = jsonb_set(coalesce(data, '{}'::jsonb), $1, $2::jsonb), data_rev = data_rev + 1, data_updated_at = now()`; + const r = rev === null + ? await pool.query(`UPDATE users SET ${set} WHERE id = $3 RETURNING data_rev`, + [`{${section}}`, JSON.stringify(part), req.user.id]) + : await pool.query(`UPDATE users SET ${set} WHERE id = $3 AND data_rev = $4 RETURNING data_rev`, + [`{${section}}`, JSON.stringify(part), req.user.id, rev]); + if (!r.rows[0]) return fail(reply, 409, 'in een ander tabblad bewerkt'); + return { ok: true, rev: Number(r.rows[0].data_rev) }; + }); // ---- scholen (super) --------------------------------------------------------- app.post('/admin/schools', async (req, reply) => { diff --git a/test/save-system.test.js b/test/save-system.test.js index 871f23a..cadedd7 100644 --- a/test/save-system.test.js +++ b/test/save-system.test.js @@ -77,6 +77,28 @@ test('PUT /me/data zonder rev-header slaat zonder voorwaarde op (legacy)', async await app.close(); }); +test('PUT /me/data/:section vervangt alleen die sub-key via jsonb_set', async () => { + const { app, calls } = await makeApp({ dataRev: 3 }); + const res = await app.inject({ method: 'PUT', url: '/api/me/data/themes', cookies: sessionCookie, + headers: { 'x-data-rev': '3' }, payload: { nl: [], en: [] } }); + assert.equal(res.statusCode, 200, res.body); + assert.equal(res.json().rev, 4); + const upd = calls.find((c) => c.sql.startsWith('UPDATE users SET data =')); + assert.ok(upd.sql.includes('jsonb_set')); + assert.equal(upd.params[0], '{themes}'); + assert.ok(upd.sql.includes('AND data_rev = $4')); + assert.equal(upd.params[3], 3); + await app.close(); +}); + +test('PUT /me/data/:section weigert onbekende secties', async () => { + const { app } = await makeApp({ dataRev: 3 }); + const res = await app.inject({ method: 'PUT', url: '/api/me/data/hackme', cookies: sessionCookie, + headers: { 'x-data-rev': '3' }, payload: {} }); + assert.equal(res.statusCode, 404); + await app.close(); +}); + test('PUT /me/data maakt eerst een snapshot en ruimt oude versies op', async () => { const { app, calls } = await makeApp({ dataRev: 3 }); const res = await app.inject({ method: 'PUT', url: '/api/me/data', cookies: sessionCookie,