diff --git a/src/js/app.js b/src/js/app.js index 655f0ee..4145981 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -16,7 +16,7 @@ async function init() { // zurueck, wenn wirklich etwas entfernt wurde (kein unnoetiger Storage-Write). const cutoff = Date.now() - TRASH_RETENTION_MS; const beforeCount = trash.length; - trash = trash.filter(entry => typeof entry.deletedAt === 'number' && entry.deletedAt >= cutoff); + trash = trash.filter(entry => entry && typeof entry.deletedAt === 'number' && Number.isFinite(entry.deletedAt) && entry.deletedAt >= cutoff); if (trash.length !== beforeCount) await saveTrash(); if (savedSettings) Object.assign(settings, savedSettings); diff --git a/src/js/boards.js b/src/js/boards.js index 78b8f5e..797fa66 100644 --- a/src/js/boards.js +++ b/src/js/boards.js @@ -162,10 +162,18 @@ function createBoardEl(board) { // type:'board', kein originBoardId (Board hat keine Herkunft, Restore legt es direkt in boards[]). // Datensicherheit: ZUERST Trash sichern (saveTrash), DANN Loeschung committen (saveBoards) — // bei Quota-Reject bleibt das Board in boards[], kein Datenverlust. - pushToTrash({ item: board, type: 'board', originBoardId: null }); - await saveTrash(); - boards = boards.filter(b => b.id !== board.id); - await saveBoards(); + const trashEntry = pushToTrash({ item: board, type: 'board', originBoardId: null }); + try { + await saveTrash(); + boards = boards.filter(b => b.id !== board.id); + await saveBoards(); + } catch (err) { + // Save fehlgeschlagen (z.B. Quota genau zwischen den Writes): auf den Vor-Loesch-Stand + // zurueckrollen, damit In-Memory und Storage konsistent bleiben (kein Reload-Duplikat). + trash = trash.filter(t => t !== trashEntry); + if (!boards.some(b => b.id === board.id)) boards.push(board); + console.error('Board-Loeschen fehlgeschlagen, zurueckgerollt:', err && err.message); + } renderBoards(); } }); @@ -277,10 +285,17 @@ function bindBoardListEvents(list, board) { if (removed) { // Datensicherheit: ZUERST den Trash-Klon persistieren, DANN die Loeschung committen. // Falls saveTrash() (Quota) rejectet, ist das Original noch in boards[] -> kein Verlust. - pushToTrash({ item: removed, type: 'bookmark', originBoardId: board.id }); - await saveTrash(); - board.bookmarks = board.bookmarks.filter(b => b.id !== bmId); - await saveBoards(); + const trashEntry = pushToTrash({ item: removed, type: 'bookmark', originBoardId: board.id }); + try { + await saveTrash(); + board.bookmarks = board.bookmarks.filter(b => b.id !== bmId); + await saveBoards(); + } catch (err) { + // Save fehlgeschlagen: auf den Vor-Loesch-Stand zurueckrollen (kein Reload-Duplikat). + trash = trash.filter(t => t !== trashEntry); + if (!board.bookmarks.some(b => b.id === bmId)) board.bookmarks.push(removed); + console.error('Bookmark-Loeschen fehlgeschlagen, zurueckgerollt:', err && err.message); + } } renderBoards(); return; diff --git a/src/js/data.js b/src/js/data.js index 815d99d..7dd0250 100644 --- a/src/js/data.js +++ b/src/js/data.js @@ -82,7 +82,7 @@ function initDataButtons() { // Papierkorb importieren (falls vorhanden) — defensiv validiert. if (Array.isArray(data.trash) && data.trash.length > 0) { const validTrash = data.trash - .filter(e => e && e.item && ['bookmark', 'board'].includes(e.type) && typeof e.deletedAt === 'number') + .filter(e => e && e.item && ['bookmark', 'board'].includes(e.type) && typeof e.deletedAt === 'number' && Number.isFinite(e.deletedAt)) .map(e => ({ type: e.type, originBoardId: typeof e.originBoardId === 'string' ? e.originBoardId : null, @@ -96,6 +96,7 @@ function initDataButtons() { ? e.item.bookmarks .filter(bm => bm && typeof bm.title === 'string' && isSafeUrl(bm.url)) .map(bm => ({ id: bm.id || uid(), title: String(bm.title).slice(0, 200), url: bm.url, desc: String(bm.desc || '').slice(0, 500) })) + .slice(0, 500) : [] } : (isSafeUrl(e.item.url) @@ -104,7 +105,12 @@ function initDataButtons() { })) .filter(e => e.item !== null); if (validTrash.length > 0) { - trash = [...trash, ...validTrash].slice(-TRASH_MAX_ENTRIES); + // Nach deletedAt aufsteigend sortieren, DANN die neuesten TRASH_MAX_ENTRIES behalten. + // Positionsbasiertes slice(-N) wuerde sonst frische lokale Eintraege verdraengen + // statt der aeltesten — Datenverlust, da ein Trash-Eintrag die einzige Kopie ist. + const combined = [...trash, ...validTrash]; + combined.sort((a, b) => a.deletedAt - b.deletedAt); + trash = combined.slice(-TRASH_MAX_ENTRIES); await saveTrash(); } } diff --git a/src/js/settings.js b/src/js/settings.js index 86c60ac..ec94088 100644 --- a/src/js/settings.js +++ b/src/js/settings.js @@ -219,7 +219,7 @@ function createTrashItemEl(entry) { btnRestore.className = 'btn-small'; btnRestore.textContent = t('trash.restore'); btnRestore.title = t('trash.restore_title'); - btnRestore.addEventListener('click', () => restoreTrashEntry(entry)); + btnRestore.addEventListener('click', () => { btnRestore.disabled = true; restoreTrashEntry(entry); }); const btnForever = document.createElement('button'); btnForever.className = 'btn-danger'; @@ -239,6 +239,10 @@ function createTrashItemEl(entry) { * @param {Object} entry */ async function restoreTrashEntry(entry) { + // Re-Entry-Guard: ein zweiter Klick (z.B. waehrend der Inbox-Alert offen ist) wuerde sonst + // das Item ein zweites Mal einfuegen (Duplikat). Nach der ersten Ausfuehrung ist entry + // nicht mehr in trash[]; btnRestore wird zusaetzlich beim ersten Klick disabled. + if (!trash.includes(entry)) return; if (entry.type === 'board') { // Ganzes Board zurueck (inkl. blurred). Falls die id schon existiert (Edge-Case), // neue uid vergeben, damit nichts ueberschrieben wird. diff --git a/src/js/state.js b/src/js/state.js index b1c46ce..cd4a168 100644 --- a/src/js/state.js +++ b/src/js/state.js @@ -75,17 +75,19 @@ async function saveTrash() { * @param {{ item: Object, type: 'bookmark'|'board', originBoardId: (string|null) }} entry */ function pushToTrash({ item, type, originBoardId }) { - trash.push({ + const entry = { item: structuredClone(item), type, originBoardId: originBoardId ?? null, deletedAt: Date.now() - }); + }; + trash.push(entry); // Aelteste zuerst kappen, falls die Obergrenze ueberschritten ist. if (trash.length > TRASH_MAX_ENTRIES) { trash.sort((a, b) => a.deletedAt - b.deletedAt); trash = trash.slice(trash.length - TRASH_MAX_ENTRIES); } + return entry; // fuer Rollback im Delete-Handler bei Save-Fehler (W-b/Quota) } // Page-seitiger Wrapper um das DOM-freie ensureInbox() aus quicksave-core.js.