530196ddf7
Beim Trash-Import sortierte combined.sort+slice(-N) rein nach deletedAt: brachte ein Backup neuere Eintraege mit, fielen aeltere LOKALE Eintraege aus dem Cap — und die sind die einzige Kopie der geloeschten Daten (Datenverlust). Jetzt haben lokale Eintraege Vorrang (alle behalten, sind bereits auf TRASH_MAX_ENTRIES gekappt), Importe fuellen nur den Rest mit den neuesten auf.
219 lines
9.6 KiB
JavaScript
219 lines
9.6 KiB
JavaScript
/* =============================================
|
|
HELLION NEWTAB — data.js
|
|
JSON Export / Import (Backup & Restore)
|
|
============================================= */
|
|
|
|
function initDataButtons() {
|
|
const btnExport = document.getElementById('btnExportJSON');
|
|
const btnImport = document.getElementById('btnImportJSON');
|
|
const jsonInput = document.getElementById('jsonImportInput');
|
|
if (!btnExport || !btnImport) return;
|
|
|
|
/**
|
|
* Prueft ob eine URL ein sicheres Protokoll hat.
|
|
* Blockiert javascript:, data:, vbscript: etc.
|
|
* @param {string} url
|
|
* @returns {boolean}
|
|
*/
|
|
function isSafeUrl(url) {
|
|
try {
|
|
const u = new URL(url);
|
|
return ['http:', 'https:', 'ftp:'].includes(u.protocol);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Validiert eine freie Layout-Position (LAYOUT-04). Liefert { x, y } nur bei
|
|
* endlichen Zahlen, sonst null — dann gridded ensureBoardPositions das Board neu.
|
|
* Ohne das wuerde ein Import jede vom Nutzer gesetzte Board-Position verwerfen.
|
|
* @param {*} pos
|
|
* @returns {{x:number,y:number}|null}
|
|
*/
|
|
function safePos(pos) {
|
|
return pos && Number.isFinite(pos.x) && Number.isFinite(pos.y) ? { x: pos.x, y: pos.y } : null;
|
|
}
|
|
|
|
// Export (inkl. Notes)
|
|
btnExport.addEventListener('click', async () => {
|
|
const widgetData = await Store.get('widgetStates');
|
|
const data = {
|
|
version: '2.3.0',
|
|
exported: new Date().toISOString(),
|
|
boards,
|
|
settings,
|
|
trash,
|
|
notes: widgetData && Array.isArray(widgetData.notes) ? widgetData.notes : [],
|
|
calculator: widgetData && widgetData.calculator ? widgetData.calculator.history || [] : [],
|
|
timerPresets: widgetData && widgetData.timer ? widgetData.timer.presets || [] : []
|
|
};
|
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `hellion-newtab-backup-${new Date().toISOString().slice(0, 10)}.json`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
});
|
|
|
|
// Import
|
|
btnImport.addEventListener('click', () => jsonInput.click());
|
|
jsonInput.addEventListener('change', async e => {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
try {
|
|
const data = JSON.parse(await file.text());
|
|
if (!Array.isArray(data.boards)) throw new Error(t('data.invalid_format'));
|
|
const validBoards = data.boards
|
|
.filter(b => b && typeof b.title === 'string' && Array.isArray(b.bookmarks))
|
|
.map(b => {
|
|
const board = {
|
|
id: b.id || uid(),
|
|
title: String(b.title).slice(0, 100),
|
|
blurred: !!b.blurred,
|
|
bookmarks: b.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)
|
|
}))
|
|
};
|
|
// Freies Layout (LAYOUT-04): valide Position uebernehmen, sonst gridded ensureBoardPositions neu.
|
|
const pos = safePos(b.pos);
|
|
if (pos) board.pos = pos;
|
|
return board;
|
|
});
|
|
if (validBoards.length === 0) throw new Error(t('data.no_boards'));
|
|
const ok = await HellionDialog.confirm(
|
|
t('data.import_confirm', { count: validBoards.length }),
|
|
{ type: 'info', title: t('data.import_confirm.title') }
|
|
);
|
|
if (!ok) return;
|
|
boards = [...boards, ...validBoards];
|
|
await saveBoards();
|
|
renderBoards();
|
|
|
|
// 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' && Number.isFinite(e.deletedAt))
|
|
.map(e => ({
|
|
type: e.type,
|
|
originBoardId: typeof e.originBoardId === 'string' ? e.originBoardId : null,
|
|
deletedAt: e.deletedAt,
|
|
item: e.type === 'board'
|
|
? {
|
|
id: e.item.id || uid(),
|
|
title: String(e.item.title || '').slice(0, 100),
|
|
blurred: !!e.item.blurred,
|
|
// Position erhalten, damit ein wiederhergestelltes Board an seinem Platz landet.
|
|
...(safePos(e.item.pos) ? { pos: safePos(e.item.pos) } : {}),
|
|
bookmarks: Array.isArray(e.item.bookmarks)
|
|
? 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)
|
|
? { id: e.item.id || uid(), title: String(e.item.title || '').slice(0, 200), url: e.item.url, desc: String(e.item.desc || '').slice(0, 500) }
|
|
: null)
|
|
}))
|
|
.filter(e => e.item !== null);
|
|
if (validTrash.length > 0) {
|
|
// Lokale Eintraege sind die EINZIGE Kopie ihrer geloeschten Daten -> Vorrang. Importierte
|
|
// stammen aus einem Backup, das der Nutzer noch besitzt -> nachrangig. Daher: erst ALLE
|
|
// lokalen behalten (pushToTrash kappt sie bereits auf TRASH_MAX_ENTRIES), dann mit den
|
|
// NEUESTEN importierten bis zur Obergrenze auffuellen. Ein frischer Import verdraengt so
|
|
// keine aelteren lokalen Sole-Copies mehr (frueheres sort+slice(-N) konnte das, data-loss).
|
|
const room = Math.max(0, TRASH_MAX_ENTRIES - trash.length);
|
|
const keptImports = validTrash
|
|
.slice()
|
|
.sort((a, b) => b.deletedAt - a.deletedAt) // neueste Importe zuerst
|
|
.slice(0, room);
|
|
// Am Ende nach deletedAt aufsteigend fuer eine stabile Anzeige-Reihenfolge.
|
|
trash = [...trash, ...keptImports].sort((a, b) => a.deletedAt - b.deletedAt);
|
|
await saveTrash();
|
|
}
|
|
}
|
|
|
|
// Notes importieren (falls vorhanden)
|
|
let notesImported = 0;
|
|
const existingWidgets = await Store.get('widgetStates') || {};
|
|
if (Array.isArray(data.notes) && data.notes.length > 0) {
|
|
const existingNotes = Array.isArray(existingWidgets.notes) ? existingWidgets.notes : [];
|
|
const importNotes = data.notes
|
|
.filter(n => n && n.id && n.template)
|
|
.map(n => ({
|
|
id: n.id,
|
|
template: ['note', 'checklist'].includes(n.template) ? n.template : 'note',
|
|
title: String(n.title || '').slice(0, 200),
|
|
content: String(n.content || '').slice(0, 5000),
|
|
x: typeof n.x === 'number' ? n.x : 120,
|
|
y: typeof n.y === 'number' ? n.y : 80,
|
|
width: typeof n.width === 'number' ? n.width : 280,
|
|
height: typeof n.height === 'number' ? n.height : 220,
|
|
open: n.open !== false,
|
|
checklistItems: Array.isArray(n.checklistItems) ? n.checklistItems : []
|
|
}));
|
|
// Limit beachten
|
|
const spaceLeft = Notes.MAX_NOTES - existingNotes.length;
|
|
const toImport = importNotes.slice(0, spaceLeft);
|
|
if (toImport.length > 0) {
|
|
const merged = [...existingNotes, ...toImport];
|
|
existingWidgets.notes = merged;
|
|
notesImported = toImport.length;
|
|
}
|
|
}
|
|
|
|
// Calculator-History importieren (falls vorhanden)
|
|
let calcImported = false;
|
|
if (Array.isArray(data.calculator) && data.calculator.length > 0) {
|
|
const calcHistory = data.calculator.filter(h => h && typeof h.expr === 'string' && typeof h.result === 'string');
|
|
if (calcHistory.length > 0) {
|
|
if (!existingWidgets.calculator) {
|
|
existingWidgets.calculator = { x: 400, y: 120, width: 280, height: 400, open: false, history: [] };
|
|
}
|
|
existingWidgets.calculator.history = calcHistory.slice(0, Calculator.MAX_HISTORY);
|
|
calcImported = true;
|
|
}
|
|
}
|
|
|
|
// Timer-Presets importieren (falls vorhanden)
|
|
let timerImported = false;
|
|
if (Array.isArray(data.timerPresets) && data.timerPresets.length > 0) {
|
|
const validPresets = data.timerPresets.filter(p => p && typeof p.name === 'string' && typeof p.seconds === 'number');
|
|
if (validPresets.length > 0) {
|
|
if (!existingWidgets.timer) {
|
|
existingWidgets.timer = { x: 600, y: 80, width: 260, height: 360, open: false, presets: [] };
|
|
}
|
|
existingWidgets.timer.presets = validPresets.slice(0, Timer.MAX_PRESETS);
|
|
timerImported = true;
|
|
}
|
|
}
|
|
|
|
// Gemeinsam speichern
|
|
await Store.set('widgetStates', existingWidgets);
|
|
|
|
// Widget-Module neu aus Storage laden (kein direkter Zugriff auf Internals)
|
|
if (notesImported > 0) await Notes.init();
|
|
if (calcImported) await Calculator.load();
|
|
if (timerImported) await Timer.load();
|
|
|
|
const noteMsg = notesImported > 0 ? t('data.notes_suffix', { count: notesImported }) : '';
|
|
const calcMsg = calcImported ? t('data.calc_suffix') : '';
|
|
const timerMsg = timerImported ? t('data.timer_suffix') : '';
|
|
await HellionDialog.alert(
|
|
t('data.import_success', { boards: validBoards.length, notes: noteMsg, calc: calcMsg, timer: timerMsg }),
|
|
{ type: 'success', title: t('data.import_success.title') }
|
|
);
|
|
} catch (err) {
|
|
await HellionDialog.alert(t('data.import_error', { error: err.message }), { type: 'danger', title: t('data.import_error.title') });
|
|
}
|
|
e.target.value = '';
|
|
});
|
|
}
|