Verbeter grafische kwaliteit van kleurplaten (v0.4.67-beta) #5

Open
bes-r wants to merge 208 commits from bes-r/coloring-quality-v0.4.67 into main AGit
5 changed files with 82 additions and 10 deletions
Showing only changes of commit 2ce82ab998 - Show all commits

View file

@ -1 +1 @@
0.3.18-beta
0.3.19-beta

View file

@ -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) ---------- */

View file

@ -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:{
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
}}).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; });
}});
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){

View file

@ -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) => {

View file

@ -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,