fix(trash): Daten-Review-Befunde — Import-Cap nach deletedAt sortiert (Verlust-Schutz), Restore-Doppelklick-Guard, Delete-Rollback bei Save-Fehler, NaN/Null-Haertung

This commit is contained in:
2026-06-14 10:18:10 +02:00
parent 9800e6c949
commit 83df926979
5 changed files with 41 additions and 14 deletions
+1 -1
View File
@@ -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);
+23 -8
View File
@@ -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;
+8 -2
View File
@@ -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();
}
}
+5 -1
View File
@@ -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.
+4 -2
View File
@@ -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.