Verbeter grafische kwaliteit van kleurplaten (v0.4.67-beta) #5
5 changed files with 170 additions and 16 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.3.16-beta
|
0.3.17-beta
|
||||||
|
|
|
||||||
16
db/009_save_system.sql
Normal file
16
db/009_save_system.sql
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
-- Opslaansysteem: optimistische concurrency + versiegeschiedenis.
|
||||||
|
-- data_rev telt elke wijziging van users.data; een client stuurt de rev mee
|
||||||
|
-- die hij geladen heeft en de update slaagt alleen als die nog actueel is
|
||||||
|
-- (anders 409: een ander tabblad/digibord schreef eerder). user_data_versions
|
||||||
|
-- bewaart periodieke snapshots van de vorige data zodat een leerkracht een
|
||||||
|
-- eerdere versie kan terugzetten.
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS data_rev BIGINT NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS data_updated_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_data_versions (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
data JSONB NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_data_versions ON user_data_versions(user_id, created_at DESC);
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
"use strict";
|
"use strict";
|
||||||
/* version — shown until /api/version resolves (or if the fetch fails, e.g. offline).
|
/* 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. */
|
Kept in sync by hand with the VERSION file at the repo root on every release. */
|
||||||
const VERSION = "0.3.16-beta";
|
const VERSION = "0.3.17-beta";
|
||||||
(function(){
|
(function(){
|
||||||
const tag = document.getElementById("verTag");
|
const tag = document.getElementById("verTag");
|
||||||
tag.textContent = "v"+VERSION;
|
tag.textContent = "v"+VERSION;
|
||||||
|
|
@ -189,7 +189,8 @@ const I18N = {
|
||||||
errPass:"Wachtwoord: minimaal 4 tekens.",
|
errPass:"Wachtwoord: minimaal 4 tekens.",
|
||||||
errCrypto:"Inloggen werkt niet in deze browser.",
|
errCrypto:"Inloggen werkt niet in deze browser.",
|
||||||
hello:"Hoi", boardSaved:"Opgeslagen ✓", loginFirst:"Eerst inloggen",
|
hello:"Hoi", boardSaved:"Opgeslagen ✓", loginFirst:"Eerst inloggen",
|
||||||
saving:"Opslaan…", saveFailed:"⚠ Niet opgeslagen"
|
saving:"Opslaan…", saveFailed:"⚠ Niet opgeslagen",
|
||||||
|
saveConflict:"⚠ Elders bewerkt", saveConflictHint:"In een ander tabblad bewerkt. Klik om de nieuwste versie te laden (jouw niet-opgeslagen wijzigingen hier vervallen)."
|
||||||
},
|
},
|
||||||
en:{
|
en:{
|
||||||
addWidget:"Widget", chooseWidget:"Choose a widget", saveBoard:"Save board",
|
addWidget:"Widget", chooseWidget:"Choose a widget", saveBoard:"Save board",
|
||||||
|
|
@ -351,7 +352,8 @@ const I18N = {
|
||||||
errPass:"Password: at least 4 characters.",
|
errPass:"Password: at least 4 characters.",
|
||||||
errCrypto:"Login does not work in this browser.",
|
errCrypto:"Login does not work in this browser.",
|
||||||
hello:"Hi", boardSaved:"Saved ✓", loginFirst:"Log in first",
|
hello:"Hi", boardSaved:"Saved ✓", loginFirst:"Log in first",
|
||||||
saving:"Saving…", saveFailed:"⚠ Not saved"
|
saving:"Saving…", saveFailed:"⚠ Not saved",
|
||||||
|
saveConflict:"⚠ Edited elsewhere", saveConflictHint:"Edited in another tab. Click to load the latest version (your unsaved changes here will be lost)."
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let LANG = "nl";
|
let LANG = "nl";
|
||||||
|
|
@ -398,7 +400,7 @@ try{ localStorage.removeItem("teach.token"); }catch(e){}
|
||||||
let currentUser = null; /* {id, username, displayName, role, schoolId, classId} */
|
let currentUser = null; /* {id, username, displayName, role, schoolId, classId} */
|
||||||
|
|
||||||
async function api(path, opts={}){
|
async function api(path, opts={}){
|
||||||
const headers = { "Content-Type": "application/json" };
|
const headers = { "Content-Type": "application/json", ...(opts.headers||{}) };
|
||||||
const r = await fetch("/api" + path, {
|
const r = await fetch("/api" + path, {
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
method: opts.method || (opts.body ? "POST" : "GET"),
|
method: opts.method || (opts.body ? "POST" : "GET"),
|
||||||
|
|
@ -406,7 +408,11 @@ async function api(path, opts={}){
|
||||||
body: opts.body ? JSON.stringify(opts.body) : undefined
|
body: opts.body ? JSON.stringify(opts.body) : undefined
|
||||||
});
|
});
|
||||||
const j = await r.json().catch(()=>({}));
|
const j = await r.json().catch(()=>({}));
|
||||||
if(!r.ok) throw new Error(j.error || ("fout " + r.status));
|
if(!r.ok){
|
||||||
|
const err = new Error(j.error || ("fout " + r.status));
|
||||||
|
err.status = r.status;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
return j;
|
return j;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -418,17 +424,22 @@ async function api(path, opts={}){
|
||||||
==========================================================*/
|
==========================================================*/
|
||||||
let saveTimer = null;
|
let saveTimer = null;
|
||||||
let lastSaveError = null;
|
let lastSaveError = null;
|
||||||
|
/* rev van de data zoals geladen van de server; gaat als x-data-rev mee met
|
||||||
|
elke save zodat de server een verouderde schrijver kan weigeren (409) */
|
||||||
|
let DATA_REV = 0;
|
||||||
|
let saveConflict = false;
|
||||||
|
let saveInFlight = false;
|
||||||
function canPersist(){
|
function canPersist(){
|
||||||
return !!currentUser && currentUser.role !== "pupil";
|
return !!currentUser && currentUser.role !== "pupil";
|
||||||
}
|
}
|
||||||
function persistUser(){
|
function persistUser(){
|
||||||
if(!canPersist()) return Promise.resolve(true);
|
if(!canPersist()) return Promise.resolve(true);
|
||||||
return api("/me/data", { method:"PUT", body:{
|
return api("/me/data", { method:"PUT", headers:{ "x-data-rev": String(DATA_REV) }, body:{
|
||||||
themes: THEMES, generalExtra: GENERAL_EXTRA, boards: BS
|
themes: THEMES, generalExtra: GENERAL_EXTRA, boards: BS
|
||||||
}}).then(()=>{ lastSaveError = null; return true; })
|
}}).then(res=>{ lastSaveError = null; saveConflict = false; if(typeof res.rev === "number") DATA_REV = res.rev; return true; })
|
||||||
.catch(e=>{ lastSaveError = e.message; return false; });
|
.catch(e=>{ lastSaveError = e.message; saveConflict = e.status === 409; return false; });
|
||||||
}
|
}
|
||||||
/* status op de opslaan-knop: idle / saving / saved / error */
|
/* status op de opslaan-knop: idle / saving / saved / error / conflict */
|
||||||
function setSaveStatus(state){
|
function setSaveStatus(state){
|
||||||
const btn = document.getElementById("btnSave");
|
const btn = document.getElementById("btnSave");
|
||||||
const lbl = btn.querySelector("span");
|
const lbl = btn.querySelector("span");
|
||||||
|
|
@ -436,6 +447,11 @@ function setSaveStatus(state){
|
||||||
btn.title = "";
|
btn.title = "";
|
||||||
if(state==="saving"){ lbl.textContent = T("saving"); }
|
if(state==="saving"){ lbl.textContent = T("saving"); }
|
||||||
else if(state==="saved"){ lbl.textContent = T("boardSaved"); btn.classList.add("ok"); }
|
else if(state==="saved"){ lbl.textContent = T("boardSaved"); btn.classList.add("ok"); }
|
||||||
|
else if(state==="conflict"){
|
||||||
|
lbl.textContent = T("saveConflict");
|
||||||
|
btn.classList.add("err");
|
||||||
|
btn.title = T("saveConflictHint");
|
||||||
|
}
|
||||||
else if(state==="error"){
|
else if(state==="error"){
|
||||||
lbl.textContent = T("saveFailed");
|
lbl.textContent = T("saveFailed");
|
||||||
btn.classList.add("err");
|
btn.classList.add("err");
|
||||||
|
|
@ -446,15 +462,25 @@ function setSaveStatus(state){
|
||||||
let saveStatusTimer = null;
|
let saveStatusTimer = null;
|
||||||
async function doSave(){
|
async function doSave(){
|
||||||
if(!canPersist()) return false;
|
if(!canPersist()) return false;
|
||||||
|
clearTimeout(saveTimer);
|
||||||
|
saveTimer = null;
|
||||||
stashCurrent();
|
stashCurrent();
|
||||||
setSaveStatus("saving");
|
setSaveStatus("saving");
|
||||||
|
saveInFlight = true;
|
||||||
const ok = await persistUser();
|
const ok = await persistUser();
|
||||||
|
saveInFlight = false;
|
||||||
clearTimeout(saveStatusTimer);
|
clearTimeout(saveStatusTimer);
|
||||||
setSaveStatus(ok ? "saved" : "error");
|
setSaveStatus(ok ? "saved" : (saveConflict ? "conflict" : "error"));
|
||||||
/* een fout blijft staan tot een geslaagde save; succes ebt weg */
|
/* een fout blijft staan tot een geslaagde save; succes ebt weg */
|
||||||
if(ok) saveStatusTimer = setTimeout(()=>setSaveStatus("idle"), 2000);
|
if(ok) saveStatusTimer = setTimeout(()=>setSaveStatus("idle"), 2000);
|
||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
|
/* niet weg-navigeren met onopgeslagen werk: geplande/lopende save of een
|
||||||
|
mislukte laatste save betekent dat de server nog niet alles heeft */
|
||||||
|
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 */
|
/* autosave: door alles aan te roepen wat het bord of de woorden wijzigt */
|
||||||
function scheduleSave(){
|
function scheduleSave(){
|
||||||
if(!canPersist()) return;
|
if(!canPersist()) return;
|
||||||
|
|
@ -478,7 +504,9 @@ async function hydrateFromServer(){
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
stopPupilView();
|
stopPupilView();
|
||||||
const d = (await api("/me/data")).data || {};
|
const res = await api("/me/data");
|
||||||
|
const d = res.data || {};
|
||||||
|
DATA_REV = typeof res.rev === "number" ? res.rev : 0;
|
||||||
THEMES = themesFromData(d);
|
THEMES = themesFromData(d);
|
||||||
GENERAL_EXTRA = generalExtraFromData(d);
|
GENERAL_EXTRA = generalExtraFromData(d);
|
||||||
BS = boardsFromData(d);
|
BS = boardsFromData(d);
|
||||||
|
|
@ -663,6 +691,17 @@ document.getElementById("btnSave").addEventListener("click", async ()=>{
|
||||||
loginWrap.classList.add("open");
|
loginWrap.classList.add("open");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
/* na een conflict (409) niet blind overschrijven: de klik haalt eerst de
|
||||||
|
nieuwste serverversie op, daarna kan er weer gewoon opgeslagen worden */
|
||||||
|
if(saveConflict){
|
||||||
|
saveConflict = false;
|
||||||
|
lastSaveError = null;
|
||||||
|
clearTimeout(saveTimer);
|
||||||
|
saveTimer = null;
|
||||||
|
await hydrateFromServer();
|
||||||
|
setSaveStatus("idle");
|
||||||
|
return;
|
||||||
|
}
|
||||||
clearTimeout(saveTimer); /* expliciet opslaan gaat vóór de geplande autosave */
|
clearTimeout(saveTimer); /* expliciet opslaan gaat vóór de geplande autosave */
|
||||||
await doSave();
|
await doSave();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
29
src/api.js
29
src/api.js
|
|
@ -153,16 +153,37 @@ export default async function api(app) {
|
||||||
// ---- eigen app-data (borden, thema's, woorden) ------------------------------
|
// ---- eigen app-data (borden, thema's, woorden) ------------------------------
|
||||||
app.get('/me/data', async (req, reply) => {
|
app.get('/me/data', async (req, reply) => {
|
||||||
need(req, reply);
|
need(req, reply);
|
||||||
const r = await pool.query('SELECT data FROM users WHERE id = $1', [req.user.id]);
|
const r = await pool.query('SELECT data, data_rev FROM users WHERE id = $1', [req.user.id]);
|
||||||
return { data: r.rows[0]?.data ?? {} };
|
return { data: r.rows[0]?.data ?? {}, rev: Number(r.rows[0]?.data_rev ?? 0) };
|
||||||
});
|
});
|
||||||
|
// Optimistische concurrency: de client stuurt de rev mee die hij geladen
|
||||||
|
// heeft (x-data-rev); de update slaagt alleen als die nog actueel is, anders
|
||||||
|
// 409 (een ander tabblad/digibord schreef eerder). Zonder header geen check
|
||||||
|
// (oude clients blijven werken, laatste schrijver wint dan zoals voorheen).
|
||||||
|
const revFromRequest = (req) => {
|
||||||
|
const h = req.headers['x-data-rev'];
|
||||||
|
if (h === undefined) return null;
|
||||||
|
const n = Number(h);
|
||||||
|
return Number.isInteger(n) && n >= 0 ? n : null;
|
||||||
|
};
|
||||||
|
const writeUserData = async (req, reply, data) => {
|
||||||
|
const rev = revFromRequest(req);
|
||||||
|
const r = rev === null
|
||||||
|
? await pool.query(
|
||||||
|
'UPDATE users SET data = $1, data_rev = data_rev + 1, data_updated_at = now() WHERE id = $2 RETURNING data_rev',
|
||||||
|
[data, req.user.id])
|
||||||
|
: await pool.query(
|
||||||
|
'UPDATE users SET data = $1, data_rev = data_rev + 1, data_updated_at = now() WHERE id = $2 AND data_rev = $3 RETURNING data_rev',
|
||||||
|
[data, 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) };
|
||||||
|
};
|
||||||
app.put('/me/data', { bodyLimit: 21 * 1024 * 1024 }, async (req, reply) => {
|
app.put('/me/data', { bodyLimit: 21 * 1024 * 1024 }, async (req, reply) => {
|
||||||
need(req, reply);
|
need(req, reply);
|
||||||
const data = req.body ?? {};
|
const data = req.body ?? {};
|
||||||
if (!data || typeof data !== 'object' || Array.isArray(data)) return fail(reply, 400, 'ongeldige borddata');
|
if (!data || typeof data !== 'object' || Array.isArray(data)) return fail(reply, 400, 'ongeldige borddata');
|
||||||
if (Buffer.byteLength(JSON.stringify(data), 'utf8') > 20 * 1024 * 1024) return fail(reply, 413, 'borddata te groot');
|
if (Buffer.byteLength(JSON.stringify(data), 'utf8') > 20 * 1024 * 1024) return fail(reply, 413, 'borddata te groot');
|
||||||
await pool.query('UPDATE users SET data = $1 WHERE id = $2', [data, req.user.id]);
|
return writeUserData(req, reply, data);
|
||||||
return { ok: true };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- scholen (super) ---------------------------------------------------------
|
// ---- scholen (super) ---------------------------------------------------------
|
||||||
|
|
|
||||||
78
test/save-system.test.js
Normal file
78
test/save-system.test.js
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import Fastify from 'fastify';
|
||||||
|
import cookie from '@fastify/cookie';
|
||||||
|
import rateLimit from '@fastify/rate-limit';
|
||||||
|
import api from '../src/api.js';
|
||||||
|
|
||||||
|
// Ingelogde leerkracht via een gemockte sessie: elke query die de sessie-join
|
||||||
|
// doet levert de gebruiker, de data-update legt zijn SQL+params vast zodat de
|
||||||
|
// test de rev-voorwaarde en het 409-pad kan controleren.
|
||||||
|
function makeApp({ dataRev = 3, updateSucceeds = true } = {}) {
|
||||||
|
const calls = [];
|
||||||
|
const pool = {
|
||||||
|
async query(sql, params = []) {
|
||||||
|
calls.push({ sql, params });
|
||||||
|
if (sql.includes('FROM sessions s JOIN users u')) return { rows: [{
|
||||||
|
id: 7, username: 'docent', display_name: 'Docent', role: 'teacher',
|
||||||
|
school_id: 2, class_id: null, data: {}, data_rev: dataRev,
|
||||||
|
}] };
|
||||||
|
if (sql.includes('FROM user_roles')) return { rows: [] };
|
||||||
|
if (sql.startsWith('SELECT data, data_rev')) return { rows: [{ data: { boards: {} }, data_rev: dataRev }] };
|
||||||
|
if (sql.startsWith('UPDATE users SET data =')) {
|
||||||
|
return updateSucceeds ? { rows: [{ data_rev: dataRev + 1 }] } : { rows: [] };
|
||||||
|
}
|
||||||
|
return { rows: [] };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return (async () => {
|
||||||
|
const app = Fastify({ trustProxy: 2 });
|
||||||
|
await app.register(cookie);
|
||||||
|
await app.register(rateLimit, { global: false });
|
||||||
|
app.decorate('pg', pool);
|
||||||
|
await app.register(api, { prefix: '/api' });
|
||||||
|
await app.ready();
|
||||||
|
return { app, calls };
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessionCookie = { teach_session: 'x'.repeat(64) };
|
||||||
|
|
||||||
|
test('GET /me/data geeft data én rev terug', async () => {
|
||||||
|
const { app } = await makeApp({ dataRev: 3 });
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/me/data', cookies: sessionCookie });
|
||||||
|
assert.equal(res.statusCode, 200, res.body);
|
||||||
|
assert.deepEqual(res.json(), { data: { boards: {} }, rev: 3 });
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PUT /me/data met actuele rev slaagt en geeft de nieuwe rev', async () => {
|
||||||
|
const { app, calls } = await makeApp({ dataRev: 3 });
|
||||||
|
const res = await app.inject({ method: 'PUT', url: '/api/me/data', cookies: sessionCookie,
|
||||||
|
headers: { 'x-data-rev': '3' }, payload: { boards: {} } });
|
||||||
|
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('AND data_rev = $3'));
|
||||||
|
assert.equal(upd.params[2], 3);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PUT /me/data met verouderde rev geeft 409', async () => {
|
||||||
|
const { app } = await makeApp({ dataRev: 5, updateSucceeds: false });
|
||||||
|
const res = await app.inject({ method: 'PUT', url: '/api/me/data', cookies: sessionCookie,
|
||||||
|
headers: { 'x-data-rev': '3' }, payload: { boards: {} } });
|
||||||
|
assert.equal(res.statusCode, 409, res.body);
|
||||||
|
assert.match(res.json().error, /ander tabblad/);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('PUT /me/data zonder rev-header slaat zonder voorwaarde op (legacy)', async () => {
|
||||||
|
const { app, calls } = await makeApp({ dataRev: 3 });
|
||||||
|
const res = await app.inject({ method: 'PUT', url: '/api/me/data', cookies: sessionCookie,
|
||||||
|
payload: { boards: {} } });
|
||||||
|
assert.equal(res.statusCode, 200, res.body);
|
||||||
|
const upd = calls.find((c) => c.sql.startsWith('UPDATE users SET data ='));
|
||||||
|
assert.ok(!upd.sql.includes('AND data_rev'));
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue