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

Upload-Verlauf

-
+
+ +
+
diff --git a/renderer/styles.css b/renderer/styles.css index 13af7ac..2c0c16c 100644 --- a/renderer/styles.css +++ b/renderer/styles.css @@ -979,6 +979,17 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; } .history-container { padding: 16px; overflow: auto; flex: 1; background: linear-gradient(180deg, rgba(255,255,255,0.015), transparent 24%); } .history-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; } .history-header h2 { font-size: 18px; } +.history-retention-label { font-size: 12px; color: var(--text-dim); margin-right: 2px; } +.history-retention-select { width: auto; min-width: 150px; padding: 6px 8px; } +.history-cap-notice { + margin: 0 0 10px; + padding: 8px 12px; + font-size: 12px; + color: var(--text-dim); + background: rgba(255,255,255,0.03); + border: 1px solid var(--border); + border-radius: 6px; +} .results-table, .history-table { width: 100%; diff --git a/tests/history-retention.test.js b/tests/history-retention.test.js new file mode 100644 index 0000000..1e32008 --- /dev/null +++ b/tests/history-retention.test.js @@ -0,0 +1,84 @@ +const test = require('node:test'); +const assert = require('node:assert'); +const { applyHistoryRetention, countHistoryRows } = require('../lib/config-store'); + +function batch(timestamp, okRows, extras = {}) { + const results = []; + for (let i = 0; i < okRows; i++) results.push({ status: 'success', hoster: 'voe.sx', download_url: `https://voe.sx/${i}` }); + if (extras.aborted) for (let i = 0; i < extras.aborted; i++) results.push({ status: 'aborted', hoster: 'voe.sx' }); + if (extras.error) for (let i = 0; i < extras.error; i++) results.push({ status: 'error', hoster: 'voe.sx' }); + return { timestamp, files: [{ name: 'clip.mp4', results }] }; +} + +const DAY = 86400000; + +test('countHistoryRows counts only non-aborted, non-error results', () => { + const h = [batch('2026-01-01', 3, { aborted: 2, error: 1 })]; + assert.strictEqual(countHistoryRows(h), 3); +}); + +test('retention "all" returns the array unchanged', () => { + const h = [batch('2026-01-01', 5), batch('2026-01-02', 5)]; + assert.strictEqual(applyHistoryRetention(h, 'all', Date.parse('2026-06-01')), h); +}); + +test('count policy keeps newest whole batches up to the row target', () => { + const h = [batch('2026-01-01', 400), batch('2026-01-02', 400), batch('2026-01-03', 400)]; + const pruned = applyHistoryRetention(h, '1000', Date.parse('2026-06-01')); + assert.strictEqual(pruned.length, 3); + assert.strictEqual(countHistoryRows(pruned), 1200); +}); + +test('count policy drops older batches once target reached (newest first)', () => { + const h = [batch('2026-01-01', 600), batch('2026-01-02', 600), batch('2026-01-03', 600)]; + const pruned = applyHistoryRetention(h, '1000', Date.parse('2026-06-01')); + assert.strictEqual(pruned.length, 2); + assert.deepStrictEqual(pruned.map(b => b.timestamp), ['2026-01-02', '2026-01-03']); +}); + +test('count policy always keeps the newest batch even if it alone exceeds N', () => { + const h = [batch('2026-01-01', 50), batch('2026-01-02', 5000)]; + const pruned = applyHistoryRetention(h, '100', Date.parse('2026-06-01')); + assert.strictEqual(pruned.length, 1); + assert.strictEqual(pruned[0].timestamp, '2026-01-02'); +}); + +test('time policy drops batches older than the cutoff', () => { + const now = Date.parse('2026-06-15T00:00:00Z'); + const h = [ + batch(new Date(now - 10 * DAY).toISOString(), 5), + batch(new Date(now - 3 * DAY).toISOString(), 5), + batch(new Date(now - 1 * DAY).toISOString(), 5) + ]; + const pruned = applyHistoryRetention(h, '7d', now); + assert.strictEqual(pruned.length, 2); +}); + +test('time policy keeps batches with missing or invalid timestamp', () => { + const now = Date.parse('2026-06-15T00:00:00Z'); + const h = [ + batch(undefined, 5), + batch('not-a-date', 5), + batch(new Date(now - 99 * DAY).toISOString(), 5), + batch(new Date(now - 1 * DAY).toISOString(), 5) + ]; + const pruned = applyHistoryRetention(h, '30d', now); + assert.strictEqual(pruned.length, 3); + assert.ok(pruned.includes(h[0])); + assert.ok(pruned.includes(h[1])); + assert.ok(!pruned.includes(h[2])); +}); + +test('count policy shrinks a realistic 41-batch / >1000-row history', () => { + const h = []; + for (let i = 0; i < 41; i++) h.push(batch(`2026-04-${String((i % 28) + 1).padStart(2, '0')}`, 1300)); + assert.strictEqual(countHistoryRows(h), 41 * 1300); + const pruned = applyHistoryRetention(h, '1000', Date.parse('2026-06-01')); + assert.strictEqual(pruned.length, 1); + assert.strictEqual(countHistoryRows(pruned), 1300); +}); + +test('empty history is returned as-is for any policy', () => { + assert.deepStrictEqual(applyHistoryRetention([], '7d', Date.now()), []); + assert.deepStrictEqual(applyHistoryRetention([], '100', Date.now()), []); +});