diff --git a/lib/config-store.js b/lib/config-store.js index d6f372a..9b43075 100644 --- a/lib/config-store.js +++ b/lib/config-store.js @@ -63,6 +63,7 @@ const DEFAULTS = { webhookMention: '', // optional Discord ping target: user-id, role:id, @here, @everyone autoRetryRounds: 0, // 0 = off; 1-5 automatic retry rounds for transient failures after batch end autoRetryDelayMin: 5, // base delay in minutes between auto-retry rounds (linear backoff: round N waits N*delay) + historyRetention: 'all', // 'all' | '7d' | '30d' | '90d' | '1000' | '100' — storage cap for upload history // NOTE: logMode is intentionally NOT in DEFAULTS. If it were, the deep-merge // would seed logMode='single' for every load, which would beat (and silently // erase) the legacy sessionLog:true → "daily" migration. normalizeLogMode in @@ -102,6 +103,67 @@ const DEFAULTS = { history: [] }; +const HISTORY_RETENTION_OPTIONS = [ + { value: 'all', label: 'Alles behalten' }, + { value: '7d', label: 'Letzte 7 Tage' }, + { value: '30d', label: 'Letzte 30 Tage' }, + { value: '90d', label: 'Letzte 90 Tage' }, + { value: '1000', label: 'Letzte 1000 Uploads' }, + { value: '100', label: 'Letzte 100 Uploads' } +]; + +function batchTimestampMs(batch) { + const raw = batch && batch.timestamp; + if (raw === null || raw === undefined || raw === '') return null; + const ms = typeof raw === 'number' ? raw : Date.parse(raw); + return Number.isFinite(ms) ? ms : null; +} + +function batchRowCount(batch) { + let n = 0; + const files = (batch && batch.files) || []; + for (const file of files) { + for (const result of (file.results || [])) { + if (result.status === 'aborted' || result.status === 'error') continue; + n++; + } + } + return n; +} + +function countHistoryRows(history) { + let n = 0; + for (const batch of (history || [])) n += batchRowCount(batch); + return n; +} + +function applyHistoryRetention(history, retention, nowMs) { + if (!Array.isArray(history) || history.length === 0) return history; + const policy = String(retention || 'all'); + if (policy === 'all') return history; + + if (/^\d+d$/.test(policy)) { + const days = parseInt(policy, 10); + if (!Number.isFinite(days) || days <= 0) return history; + const cutoff = nowMs - days * 86400000; + return history.filter(b => { + const ts = batchTimestampMs(b); + return ts === null || ts >= cutoff; + }); + } + + const maxRows = parseInt(policy, 10); + if (!Number.isFinite(maxRows) || maxRows <= 0) return history; + const keptReversed = []; + let acc = 0; + for (let i = history.length - 1; i >= 0; i--) { + keptReversed.push(history[i]); + acc += batchRowCount(history[i]); + if (acc >= maxRows) break; + } + return keptReversed.reverse(); +} + class ConfigStore { constructor(app) { const dir = app && app.isPackaged @@ -301,10 +363,32 @@ class ConfigStore { return this._enqueueWrite(() => { const config = this.load(); config.history.push(entry); + const retention = (config.globalSettings && config.globalSettings.historyRetention) || 'all'; + config.history = applyHistoryRetention(config.history, retention, Date.now()); return this._atomicWrite(this._serializeForDisk(config)); }); } + pruneHistory(retention, opts = {}) { + const dryRun = !!opts.dryRun; + return this._enqueueWrite(() => { + const config = this.load(); + const beforeBatches = config.history.length; + const beforeRows = countHistoryRows(config.history); + const pruned = applyHistoryRetention(config.history, retention, Date.now()); + const result = { + removedBatches: beforeBatches - pruned.length, + removedRows: beforeRows - countHistoryRows(pruned), + keptBatches: pruned.length, + keptRows: countHistoryRows(pruned) + }; + if (dryRun) return result; + config.history = pruned; + if (config.globalSettings) config.globalSettings.historyRetention = String(retention || 'all'); + return this._atomicWrite(this._serializeForDisk(config)).then(() => result); + }); + } + clearHistory() { return this._enqueueWrite(() => { const config = this.load(); @@ -318,3 +402,6 @@ module.exports = ConfigStore; module.exports.HOSTER_ACCOUNT_TEMPLATES = HOSTER_ACCOUNT_TEMPLATES; module.exports.HOSTER_NAMES = HOSTER_NAMES; module.exports.HOSTER_ADD_OPTIONS = HOSTER_ADD_OPTIONS; +module.exports.HISTORY_RETENTION_OPTIONS = HISTORY_RETENTION_OPTIONS; +module.exports.applyHistoryRetention = applyHistoryRetention; +module.exports.countHistoryRows = countHistoryRows; diff --git a/main.js b/main.js index eea8e61..4d38bf0 100644 --- a/main.js +++ b/main.js @@ -1297,6 +1297,12 @@ ipcMain.handle('get-history', () => { return configStore.loadHistory(); }); +ipcMain.handle('prune-history', async (_event, payload) => { + const retention = payload && payload.retention; + const dryRun = !!(payload && payload.dryRun); + return configStore.pruneHistory(retention, { dryRun }); +}); + ipcMain.handle('save-text-file', async (_event, defaultName, content, filters) => { const safeName = String(defaultName || `export-${new Date().toISOString().slice(0, 10)}.txt`); const safeFilters = Array.isArray(filters) && filters.length diff --git a/preload.js b/preload.js index b80a328..1aed2c7 100644 --- a/preload.js +++ b/preload.js @@ -6,6 +6,7 @@ contextBridge.exposeInMainWorld('api', { saveConfig: (config) => ipcRenderer.invoke('save-config', config), getHistory: () => ipcRenderer.invoke('get-history'), clearHistory: () => ipcRenderer.invoke('clear-history'), + pruneHistory: (retention, opts) => ipcRenderer.invoke('prune-history', { retention, dryRun: !!(opts && opts.dryRun) }), exportHistory: (format) => ipcRenderer.invoke('export-history', format), saveTextFile: (defaultName, content, filters) => ipcRenderer.invoke('save-text-file', defaultName, content, filters), diff --git a/renderer/app.js b/renderer/app.js index 6d19903..620cb0b 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -4122,6 +4122,8 @@ async function loadHistory() { const history = await window.api.getHistory(); window._historyForStats = history || []; _invalidateHosterLifetimeCache(); + const retSel = document.getElementById('historyRetentionSelect'); + if (retSel) retSel.value = (config.globalSettings && config.globalSettings.historyRetention) || 'all'; const container = document.getElementById('historyContainer'); if (!history || history.length === 0) { @@ -4304,13 +4306,29 @@ function renderRecentUploadsPanel() { if (!wasAppendOnly) updateRecentSortHeaders(); } +const HISTORY_RENDER_CAP = 2000; + function renderHistoryTable(container) { if (!container || !historyRowsData.length) { if (container) container.innerHTML = '
Noch keine Uploads.
'; + const emptyNotice = document.getElementById('historyCapNotice'); + if (emptyNotice) emptyNotice.style.display = 'none'; return; } - const rows = sortHistoryRows(historyRowsData); + const total = historyRowsData.length; + const working = total > HISTORY_RENDER_CAP ? historyRowsData.slice(-HISTORY_RENDER_CAP) : historyRowsData; + const notice = document.getElementById('historyCapNotice'); + if (notice) { + if (total > HISTORY_RENDER_CAP) { + notice.style.display = ''; + notice.textContent = `Zeige neueste ${HISTORY_RENDER_CAP.toLocaleString('de-DE')} von ${total.toLocaleString('de-DE')} Einträgen. Der vollständige Verlauf bleibt gespeichert und ist über „Verlauf exportieren“ verfügbar.`; + } else { + notice.style.display = 'none'; + } + } + + const rows = sortHistoryRows(working); const headerCell = (key, label) => { const active = historySortState.key === key; const dir = active ? (historySortState.direction === 'asc' ? '▲' : '▼') : '↕'; @@ -4498,6 +4516,29 @@ function setupListeners() { }); document.getElementById('exportHistoryBtn').addEventListener('click', exportHistory); + const historyRetentionSelect = document.getElementById('historyRetentionSelect'); + if (historyRetentionSelect) { + historyRetentionSelect.addEventListener('change', async () => { + const value = historyRetentionSelect.value; + const prev = (config.globalSettings && config.globalSettings.historyRetention) || 'all'; + if (value !== 'all') { + const preview = await window.api.pruneHistory(value, { dryRun: true }); + if (preview && preview.removedRows > 0) { + const ok = confirm(`${preview.removedRows.toLocaleString('de-DE')} Verlaufseinträge werden dauerhaft entfernt.\n\nFortfahren?`); + if (!ok) { historyRetentionSelect.value = prev; return; } + } + } + const globalSettings = { ...(config.globalSettings || {}), historyRetention: value }; + config.globalSettings = globalSettings; + await window.api.saveGlobalSettings(globalSettings).catch(() => {}); + if (value !== 'all') { + const res = await window.api.pruneHistory(value); + if (res && res.removedRows > 0) showCopyToast(`Verlauf gekürzt: ${res.removedRows.toLocaleString('de-DE')} entfernt`); + } + loadHistory(); + }); + } + // Auto health check toggle const autoToggle = document.getElementById('autoHealthCheckToggle'); if (autoToggle) { diff --git a/renderer/index.html b/renderer/index.html index 3f9e568..6495e54 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -257,11 +257,21 @@