From c967f9277b37d897e0a59e3043da3b61e33bfa97 Mon Sep 17 00:00:00 2001 From: HighImKevin Date: Tue, 6 Oct 2026 05:57:10 -0400 Subject: [PATCH] Add the ability to Manage and Schedule KefinTweaks backups --- scripts/configuration.css | 55 ++++ scripts/configuration.js | 592 ++++++++++++++++++++++++++++++++++++-- scripts/utils.js | 322 +++++++++++++++++++++ 3 files changed, 946 insertions(+), 23 deletions(-) diff --git a/scripts/configuration.css b/scripts/configuration.css index 9370a6e..1c33fc1 100644 --- a/scripts/configuration.css +++ b/scripts/configuration.css @@ -296,3 +296,58 @@ div#homeScreen_recentlyReleased_container .listItem:has(#homeScreen_recentlyRele .emby-scroller-container[data-section-id].hideIndicators .cardIndicators { display: none; } + +.manage-config-tabs .manage-tab-btn.active { + background-color: rgba(82, 181, 255, 0.25); + outline: 1px solid rgba(82, 181, 255, 0.5); +} + +[data-modal-id="kefinTweaksManageModal"] [data-name="kefin-modal-content"] { + display: flex; + flex-direction: column; + min-height: 0; +} + +.manage-config-shell { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +.manage-tab-panels { + position: relative; + flex: 1 1 auto; + min-height: 28rem; + height: 100%; +} + +.manage-tab-panel { + position: absolute; + inset: 0; + overflow: auto; + display: none; +} + +.manage-tab-panel.is-active { + display: block; +} + +.manage-io-panel { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + box-sizing: border-box; +} + +.manage-io-textarea { + width: 100%; + flex: 1 1 auto; + min-height: 16rem; + font-family: monospace; + font-size: 0.9em; + line-height: 1.5; + resize: none; + box-sizing: border-box; +} diff --git a/scripts/configuration.js b/scripts/configuration.js index 328b513..b56dfdc 100644 --- a/scripts/configuration.js +++ b/scripts/configuration.js @@ -885,11 +885,8 @@ - - @@ -4766,16 +4763,10 @@ resetBtn.addEventListener('click', () => handleResetConfig(modalInstance)); } - // Export button handler - const exportBtn = modalInstance.dialog?.querySelector('#exportConfigBtn'); - if (exportBtn) { - exportBtn.addEventListener('click', () => handleExportConfig(config)); - } - - // Import button handler - const importBtn = modalInstance.dialog?.querySelector('#importConfigBtn'); - if (importBtn) { - importBtn.addEventListener('click', () => handleImportConfig(modalInstance)); + // Manage button handler (Backups / Export / Import) + const manageBtn = modalInstance.dialog?.querySelector('#manageConfigBtn'); + if (manageBtn) { + manageBtn.addEventListener('click', () => openManageConfigModal(config, modalInstance)); } // Remove duplicate defaults button handler @@ -4826,6 +4817,568 @@ } } + function toastMsg(message, timeout) { + if (window.KefinTweaksToaster?.toast) { + window.KefinTweaksToaster.toast(message, timeout); + } else { + alert(message); + } + } + + function getExportClipboardFlags() { + const isHttps = + (typeof ApiClient !== 'undefined' && + ApiClient._serverAddress && + ApiClient._serverAddress.startsWith('https://')) || + (typeof location !== 'undefined' && location.protocol === 'https:'); + const supportsClipboard = !!(navigator.clipboard && navigator.clipboard.writeText); + return { canUseClipboard: isHttps && supportsClipboard }; + } + + function buildManageExportPanelHtml(canUseClipboard) { + return ` +
+
+ Your current configuration is shown below as JSON. Select and copy this text to back up or share your configuration. + ${canUseClipboard ? '' : ' Clipboard access requires an HTTPS connection, so manual copying is required.'} +
+ + ${canUseClipboard ? ` +
+ +
` : ''} +
+ `; + } + + function buildManageImportPanelHtml() { + return ` +
+
+ Paste your configuration JSON below. This will completely replace your current configuration. +
+ +
+ +
+
+ `; + } + + function backupsSettingsDescription(scheduledEnabled, keepCount) { + const x = Math.max(1, Math.floor(Number(keepCount) || 5)); + if (scheduledEnabled) { + return `KefinTweaks will backup your configuration once per day and keep up to ${x} previous backups. Backups are only created from an admin account that is logged in.`; + } + return 'KefinTweaks will never automatically backup your configuration unless necessary during a migration task or other operations.'; + } + + function buildManageBackupsPanelHtml(settings) { + const scheduledEnabled = settings.scheduledEnabled === true; + const keepCount = Math.max(1, Math.floor(Number(settings.keepCount) || 5)); + const toggleHtml = window.KefinTweaksUI?.buildToggleCard + ? window.KefinTweaksUI.buildToggleCard( + 'manageScheduledBackups', + scheduledEnabled, + 'Scheduled Backups', + backupsSettingsDescription(scheduledEnabled, keepCount) + ) + : ` + +
${backupsSettingsDescription(scheduledEnabled, keepCount)}
+ `; + + return ` +
+
${toggleHtml}
+
+ + +
KefinTweaks will overwrite the oldest scheduled Backup once you get to this limit.
+
+
+

Manage Backups

+
+
Loading backups…
+
+
+
+ `; + } + + function renderBackupsTableRows(backups) { + if (!backups.length) { + return `
No backups found.
`; + } + const rows = backups.map((b) => ` + + + + + +
${escapeHtmlAttr(b.name)}
+
+ ${escapeHtmlAttr(b.kindLabel || 'Manual')} · ${escapeHtmlAttr(b.dateLabel || 'Unknown date')} +
+ + + + + + + `).join(''); + return ` +
+ + + +
+
+ + + + + + + + + ${rows} +
+ + BackupActions
+
+ `; + } + + function escapeHtmlAttr(value) { + return String(value ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + async function refreshManageBackupsTable(host) { + if (!host) return; + host.innerHTML = `
Loading backups…
`; + const listFn = window.KefinTweaksUtils?.listKefinConfigBackups; + if (!listFn) { + host.innerHTML = `
Backup helpers unavailable.
`; + return; + } + const backups = await listFn(); + host.innerHTML = renderBackupsTableRows(backups); + } + + function confirmDeleteBackup(name) { + return confirmDeleteBackups([name]); + } + + function confirmDeleteBackups(names) { + const list = (Array.isArray(names) ? names : []).filter(Boolean); + return new Promise((resolve) => { + if (!list.length) { + resolve(false); + return; + } + const title = list.length === 1 ? 'Delete Backup' : `Delete ${list.length} Backups`; + const body = list.length === 1 + ? `
Remove backup ${escapeHtmlAttr(list[0])}? This cannot be undone.
` + : `
Remove ${list.length} selected backups? This cannot be undone.
+ `; + + if (!window.ModalSystem) { + resolve(window.confirm(list.length === 1 + ? `Delete backup "${list[0]}"?` + : `Delete ${list.length} selected backups?`)); + return; + } + window.ModalSystem.create({ + id: 'manageDeleteBackupConfirm', + title, + content: body, + footer: ` + + + `, + closeOnBackdrop: true, + closeOnEscape: true, + onOpen: (mi) => { + mi.dialogFooter?.querySelector('#manageDeleteBackupConfirmBtn')?.addEventListener('click', () => { + mi.close(); + resolve(true); + }); + mi.dialogFooter?.querySelector('#manageDeleteBackupCancelBtn')?.addEventListener('click', () => { + mi.close(); + resolve(false); + }); + } + }); + }); + } + + function confirmRestoreBackup(name) { + return new Promise((resolve) => { + if (!window.ModalSystem) { + const backupFirst = window.confirm(`Backup current config before restoring "${name}"?`); + resolve(window.confirm(`Restore "${name}"?`) ? { restore: true, backupCurrentFirst: backupFirst } : { restore: false }); + return; + } + window.ModalSystem.create({ + id: 'manageRestoreBackupConfirm', + title: 'Restore Backup', + content: ` +
+ Restore configuration from ${escapeHtmlAttr(name)}? This will overwrite your current active configuration. +
+ `, + footer: ` + + + + `, + closeOnBackdrop: true, + closeOnEscape: true, + onOpen: (mi) => { + mi.dialogFooter?.querySelector('#manageRestoreWithBackupBtn')?.addEventListener('click', () => { + mi.close(); + resolve({ restore: true, backupCurrentFirst: true }); + }); + mi.dialogFooter?.querySelector('#manageRestoreOnlyBtn')?.addEventListener('click', () => { + mi.close(); + resolve({ restore: true, backupCurrentFirst: false }); + }); + mi.dialogFooter?.querySelector('#manageRestoreCancelBtn')?.addEventListener('click', () => { + mi.close(); + resolve({ restore: false }); + }); + } + }); + }); + } + + async function persistBackupsSettings(config, settings) { + config.backups = { + scheduledEnabled: settings.scheduledEnabled === true, + keepCount: Math.max(1, Math.floor(Number(settings.keepCount) || 5)), + lastScheduledAt: settings.lastScheduledAt || config.backups?.lastScheduledAt || null + }; + if (window.KefinTweaksConfig) { + window.KefinTweaksConfig.backups = { ...config.backups }; + } + await KefinTweaksUtils.saveConfigToJavaScriptInjector(config); + if (config.backups.scheduledEnabled) { + await window.KefinTweaksUtils.maybeRunScheduledBackup?.(config); + } + } + + function bindManageBackupsPanel(root, config) { + const settings = window.KefinTweaksUtils?.normalizeBackupsSettings + ? window.KefinTweaksUtils.normalizeBackupsSettings(config) + : { scheduledEnabled: false, keepCount: 5, lastScheduledAt: null }; + + const scheduledCb = root.querySelector('#manageScheduledBackups'); + const keepInput = root.querySelector('#manageBackupKeepCount'); + const descEl = root.querySelector('#manageScheduledBackupsDesc') + || root.querySelector('.kefin-toggle-card-desc'); + const tableHost = root.querySelector('#manageBackupsTableHost'); + + if (window.KefinTweaksUI?.bindToggleCards) { + window.KefinTweaksUI.bindToggleCards(root); + } + + const syncDesc = () => { + if (!descEl) return; + const enabled = scheduledCb?.checked === true; + const keep = Math.max(1, Math.floor(Number(keepInput?.value) || 5)); + descEl.textContent = backupsSettingsDescription(enabled, keep); + }; + + scheduledCb?.addEventListener('change', async () => { + syncDesc(); + try { + await persistBackupsSettings(config, { + ...settings, + scheduledEnabled: scheduledCb.checked === true, + keepCount: Math.max(1, Math.floor(Number(keepInput?.value) || 5)) + }); + toastMsg(`Scheduled backups ${scheduledCb.checked ? 'enabled' : 'disabled'}`); + await refreshManageBackupsTable(tableHost); + } catch (e) { + console.error('[KefinTweaks Configuration] Error saving backup settings:', e); + toastMsg('Failed to save backup settings', '5'); + } + }); + + keepInput?.addEventListener('change', async () => { + const keep = Math.max(1, Math.floor(Number(keepInput.value) || 5)); + keepInput.value = String(keep); + syncDesc(); + try { + await persistBackupsSettings(config, { + ...settings, + scheduledEnabled: scheduledCb?.checked === true, + keepCount: keep + }); + await window.KefinTweaksUtils.pruneKefinConfigBackups?.(keep); + await refreshManageBackupsTable(tableHost); + toastMsg(`Keeping up to ${keep} backups`); + } catch (e) { + console.error('[KefinTweaks Configuration] Error saving keep count:', e); + toastMsg('Failed to save backup keep count', '5'); + } + }); + + tableHost?.addEventListener('click', async (e) => { + const selectAllBtn = e.target.closest?.('#manageBackupsSelectAllBtn'); + const deselectAllBtn = e.target.closest?.('#manageBackupsDeselectAllBtn'); + const deleteSelectedBtn = e.target.closest?.('#manageBackupsDeleteSelectedBtn'); + + const syncSelectionUi = () => { + const selected = tableHost.querySelectorAll('.manage-backup-row-check:checked'); + const all = tableHost.querySelectorAll('.manage-backup-row-check'); + const btn = tableHost.querySelector('#manageBackupsDeleteSelectedBtn'); + if (btn) btn.disabled = selected.length === 0; + const header = tableHost.querySelector('#manageBackupsSelectAllHeader'); + if (header && all.length) { + header.checked = selected.length === all.length; + header.indeterminate = selected.length > 0 && selected.length < all.length; + } + }; + + const setAllChecked = (checked) => { + tableHost.querySelectorAll('.manage-backup-row-check').forEach((cb) => { + cb.checked = checked; + }); + const header = tableHost.querySelector('#manageBackupsSelectAllHeader'); + if (header) { + header.checked = checked; + header.indeterminate = false; + } + syncSelectionUi(); + }; + + if (selectAllBtn) { + setAllChecked(true); + return; + } + if (deselectAllBtn) { + setAllChecked(false); + return; + } + if (deleteSelectedBtn) { + const names = Array.from(tableHost.querySelectorAll('.manage-backup-row-check:checked')) + .map((cb) => cb.getAttribute('data-backup-name')) + .filter(Boolean); + if (!names.length) return; + const confirmed = await confirmDeleteBackups(names); + if (!confirmed) return; + const result = await window.KefinTweaksUtils.deleteKefinConfigBackups?.(names); + if (result?.ok) { + toastMsg(`Deleted ${result.removed} backup${result.removed === 1 ? '' : 's'}`); + await refreshManageBackupsTable(tableHost); + } else { + toastMsg('Failed to delete selected backups', '5'); + } + return; + } + + // Ignore row-checkbox clicks here (handled by change); don't treat as row actions + if (e.target?.classList?.contains('manage-backup-row-check') + || e.target?.id === 'manageBackupsSelectAllHeader') { + return; + } + + const restoreBtn = e.target.closest?.('.manage-backup-restore-btn'); + const deleteBtn = e.target.closest?.('.manage-backup-delete-btn'); + const row = e.target.closest?.('tr[data-backup-name]'); + const name = row?.getAttribute('data-backup-name'); + if (!name) return; + + if (restoreBtn) { + const choice = await confirmRestoreBackup(name); + if (!choice.restore) return; + const ok = await window.KefinTweaksUtils.restoreKefinConfigBackup?.(name, { + backupCurrentFirst: choice.backupCurrentFirst === true + }); + if (ok) { + toastMsg('Configuration restored. Reloading…'); + setTimeout(() => window.location.reload(), 600); + } else { + toastMsg('Failed to restore backup', '5'); + } + return; + } + + if (deleteBtn) { + const confirmed = await confirmDeleteBackup(name); + if (!confirmed) return; + const result = await window.KefinTweaksUtils.deleteKefinConfigBackups?.([name]); + if (result?.ok) { + toastMsg('Backup deleted'); + await refreshManageBackupsTable(tableHost); + } else { + toastMsg('Failed to delete backup', '5'); + } + } + }); + + tableHost?.addEventListener('change', (e) => { + const target = e.target; + if (!target) return; + + if (target.id === 'manageBackupsSelectAllHeader') { + const checked = target.checked === true; + tableHost.querySelectorAll('.manage-backup-row-check').forEach((cb) => { + cb.checked = checked; + }); + target.indeterminate = false; + } else if (!target.classList?.contains('manage-backup-row-check')) { + return; + } + + const selected = tableHost.querySelectorAll('.manage-backup-row-check:checked'); + const all = tableHost.querySelectorAll('.manage-backup-row-check'); + const btn = tableHost.querySelector('#manageBackupsDeleteSelectedBtn'); + if (btn) btn.disabled = selected.length === 0; + const header = tableHost.querySelector('#manageBackupsSelectAllHeader'); + if (header && all.length && target.id !== 'manageBackupsSelectAllHeader') { + header.checked = selected.length === all.length; + header.indeterminate = selected.length > 0 && selected.length < all.length; + } + }); + + refreshManageBackupsTable(tableHost); + } + + function openManageConfigModal(config, parentModalInstance) { + if (!window.ModalSystem) { + alert('Modal system not available'); + return; + } + + const { canUseClipboard } = getExportClipboardFlags(); + const settings = window.KefinTweaksUtils?.normalizeBackupsSettings + ? window.KefinTweaksUtils.normalizeBackupsSettings(config) + : { scheduledEnabled: false, keepCount: 5, lastScheduledAt: null }; + const configJson = JSON.stringify(config, null, 2); + + window.ModalSystem.create({ + id: 'kefinTweaksManageModal', + title: 'Manage', + dialogStyle: { + width: 'min(720px, 95vw)', + height: 'min(780px, 90vh)', + maxHeight: '90vh' + }, + content: ` +
+
+ + + +
+
+
+ ${buildManageBackupsPanelHtml(settings)} +
+
+ ${buildManageExportPanelHtml(canUseClipboard)} +
+
+ ${buildManageImportPanelHtml()} +
+
+
+ `, + footer: ` + + `, + closeOnBackdrop: true, + closeOnEscape: true, + onOpen: (manageModal) => { + const content = manageModal.dialogContent; + manageModal.dialogFooter?.querySelector('#closeManageConfigBtn')?.addEventListener('click', () => manageModal.close()); + + const setTab = (tab) => { + content.querySelectorAll('.manage-tab-btn').forEach((btn) => { + btn.classList.toggle('active', btn.dataset.manageTab === tab); + }); + content.querySelectorAll('.manage-tab-panel').forEach((panel) => { + panel.classList.toggle('is-active', panel.dataset.managePanel === tab); + }); + }; + content.querySelectorAll('.manage-tab-btn').forEach((btn) => { + btn.addEventListener('click', () => setTab(btn.dataset.manageTab)); + }); + + // Export panel + const exportTa = content.querySelector('#manageExportConfigTextarea'); + if (exportTa) { + exportTa.value = configJson; + setTimeout(() => { + exportTa.focus(); + exportTa.select(); + }, 100); + } + content.querySelector('#manageCopyExportConfigBtn')?.addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(configJson); + toastMsg('Configuration copied to clipboard!'); + } catch (copyError) { + console.error('[KefinTweaks Configuration] Error copying config:', copyError); + toastMsg('Error copying configuration to clipboard', '5'); + } + }); + + // Import panel + content.querySelector('#manageConfirmImportBtn')?.addEventListener('click', async () => { + const jsonText = content.querySelector('#manageImportConfigTextarea')?.value.trim() || ''; + if (!jsonText) { + toastMsg('Please paste configuration JSON', '3'); + return; + } + let importedConfig; + try { + importedConfig = JSON.parse(jsonText); + } catch (parseError) { + toastMsg('Invalid JSON format', '5'); + return; + } + if (!importedConfig || typeof importedConfig !== 'object' || Array.isArray(importedConfig)) { + toastMsg('Configuration must be a JSON object', '5'); + return; + } + const confirmed = await showImportConfirmation(); + if (!confirmed) return; + manageModal.close(); + await applyImportedConfig(importedConfig, parentModalInstance || manageModal); + }); + + bindManageBackupsPanel(content.querySelector('[data-manage-panel="backups"]') || content, config); + } + }); + } + // Export configuration via modal (with optional clipboard copy on HTTPS) async function handleExportConfig(config) { try { @@ -4833,14 +5386,7 @@ // Prefer modal-based export so it works even on unsecured servers if (window.ModalSystem) { - const isHttps = - (typeof ApiClient !== 'undefined' && - ApiClient._serverAddress && - ApiClient._serverAddress.startsWith('https://')) || - (typeof location !== 'undefined' && location.protocol === 'https:'); - - const supportsClipboard = !!(navigator.clipboard && navigator.clipboard.writeText); - const canUseClipboard = isHttps && supportsClipboard; + const { canUseClipboard } = getExportClipboardFlags(); window.ModalSystem.create({ id: 'exportConfigModal', diff --git a/scripts/utils.js b/scripts/utils.js index 7ec192d..483ea51 100644 --- a/scripts/utils.js +++ b/scripts/utils.js @@ -2457,6 +2457,318 @@ window.KefinTweaksConfig = ${JSON.stringify(configToSave, null, 2)};`; } } + const KEFIN_CONFIG_ENTRY_NAME = 'KefinTweaks-Config'; + const KEFIN_CONFIG_SCRIPT_MARKER = 'window.KefinTweaksConfig = {'; + const DEFAULT_BACKUP_KEEP_COUNT = 5; + + function isKefinConfigScriptEntry(entry) { + return !!(entry?.Script && String(entry.Script).includes(KEFIN_CONFIG_SCRIPT_MARKER)); + } + + function parseConfigFromScript(script) { + if (!script || typeof script !== 'string') return null; + const prefix = 'window.KefinTweaksConfig = '; + const idx = script.indexOf(prefix); + if (idx < 0) return null; + let jsonText = script.slice(idx + prefix.length).trim(); + if (jsonText.endsWith(';')) jsonText = jsonText.slice(0, -1).trim(); + try { + return JSON.parse(jsonText); + } catch (e) { + WARN('Failed to parse KefinTweaksConfig from script:', e); + return null; + } + } + + function parseBackupTimestamp(name, script) { + const createdMatch = String(script || '').match(/Created:\s*(\d{4}-\d{2}-\d{2}T[^\s\n]+)/i); + if (createdMatch) { + const d = new Date(createdMatch[1]); + if (!Number.isNaN(d.getTime())) return d; + } + const nameMatch = String(name || '').match(/(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2})/); + if (nameMatch) { + const iso = nameMatch[1].replace(/T(\d{2})-(\d{2})-(\d{2})/, 'T$1:$2:$3') + 'Z'; + const d = new Date(iso); + if (!Number.isNaN(d.getTime())) return d; + } + return null; + } + + function buildConfigBackupScript(config, reason) { + const reasonLine = reason ? `// Reason: ${reason}\n` : ''; + return `// KefinTweaks Configuration Backup +${reasonLine}// Created: ${new Date().toISOString()} +// Do not edit manually + +window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; + } + + function makeBackupName(kind = 'manual') { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5); + if (kind === 'scheduled') { + return `KefinTweaks-Config-ScheduledBackup-${timestamp}`; + } + return `KefinTweaks-Config-Backup-${timestamp}`; + } + + function getBackupKindFromName(name) { + const n = String(name || ''); + if (n.includes('ScheduledBackup')) return 'scheduled'; + if (n.includes('SkinConflictsRemoved')) return 'skin-conflict'; + return 'manual'; + } + + function getBackupKindLabel(kind) { + if (kind === 'scheduled') return 'Scheduled'; + if (kind === 'skin-conflict') return 'Skin conflict'; + return 'Manual'; + } + + async function fetchInjectorConfigForBackups() { + const pluginId = await resolvePluginId(JS_INJECTOR_ALIASES); + if (!pluginId) throw new Error('JavaScript Injector plugin not found'); + const injectorConfig = await getPluginConfiguration(pluginId); + if (!injectorConfig.CustomJavaScripts) injectorConfig.CustomJavaScripts = []; + return { pluginId, injectorConfig }; + } + + async function postInjectorConfig(pluginId, injectorConfig) { + const server = ApiClient._serverAddress; + const configUrl = `${server}/Plugins/${pluginId}/Configuration`; + const saveResponse = await fetch(configUrl, { + method: 'POST', + headers: { + Authorization: getAuthHeader(), + 'Content-Type': 'application/json' + }, + body: JSON.stringify(injectorConfig) + }); + if (!saveResponse.ok) { + throw new Error(`Failed to save injector config: ${saveResponse.statusText}`); + } + return true; + } + + async function listKefinConfigBackups() { + try { + const { injectorConfig } = await fetchInjectorConfigForBackups(); + const backups = (injectorConfig.CustomJavaScripts || []) + .filter((entry) => isKefinConfigScriptEntry(entry) && entry.Name !== KEFIN_CONFIG_ENTRY_NAME) + .map((entry) => { + const date = parseBackupTimestamp(entry.Name, entry.Script); + const kind = getBackupKindFromName(entry.Name); + return { + name: entry.Name, + date, + dateLabel: date ? date.toLocaleString() : 'Unknown date', + kind, + kindLabel: getBackupKindLabel(kind), + enabled: entry.Enabled === true, + script: entry.Script + }; + }); + backups.sort((a, b) => { + const at = a.date ? a.date.getTime() : 0; + const bt = b.date ? b.date.getTime() : 0; + return bt - at; + }); + return backups; + } catch (err) { + ERR('Error listing config backups:', err); + return []; + } + } + + async function createKefinConfigBackup(config = null, options = {}) { + const { reason = 'manual', kind } = options; + try { + const configToBackup = config || window.KefinTweaksConfig; + if (!configToBackup) throw new Error('No config available to backup'); + + const backupKind = kind + || (reason === 'scheduled-daily' ? 'scheduled' : 'manual'); + const { pluginId, injectorConfig } = await fetchInjectorConfigForBackups(); + const backupName = makeBackupName(backupKind); + injectorConfig.CustomJavaScripts.push({ + Name: backupName, + Script: buildConfigBackupScript(configToBackup, reason), + Enabled: false, + RequiresAuthentication: false + }); + await postInjectorConfig(pluginId, injectorConfig); + LOG(`Created config backup: ${backupName}`); + return { ok: true, name: backupName }; + } catch (err) { + ERR('Error creating config backup:', err); + return { ok: false, error: err }; + } + } + + async function deleteKefinConfigBackup(name) { + return deleteKefinConfigBackups(name ? [name] : []); + } + + async function deleteKefinConfigBackups(names) { + try { + const nameList = (Array.isArray(names) ? names : []) + .map((n) => String(n || '').trim()) + .filter((n) => n && n !== KEFIN_CONFIG_ENTRY_NAME); + if (!nameList.length) { + throw new Error('No backups selected'); + } + const removeSet = new Set(nameList); + const { pluginId, injectorConfig } = await fetchInjectorConfigForBackups(); + const before = injectorConfig.CustomJavaScripts.length; + injectorConfig.CustomJavaScripts = injectorConfig.CustomJavaScripts.filter( + (entry) => !removeSet.has(entry.Name) + ); + const removed = before - injectorConfig.CustomJavaScripts.length; + if (removed === 0) { + throw new Error('No matching backups found to delete'); + } + await postInjectorConfig(pluginId, injectorConfig); + LOG(`Deleted ${removed} config backup(s)`); + return { ok: true, removed }; + } catch (err) { + ERR('Error deleting config backup(s):', err); + return { ok: false, removed: 0, error: err }; + } + } + + async function pruneKefinConfigBackups(keepCount = DEFAULT_BACKUP_KEEP_COUNT) { + try { + const keep = Math.max(1, Math.floor(Number(keepCount) || DEFAULT_BACKUP_KEEP_COUNT)); + const { pluginId, injectorConfig } = await fetchInjectorConfigForBackups(); + const backups = (injectorConfig.CustomJavaScripts || []) + .filter((entry) => isKefinConfigScriptEntry(entry) && entry.Name !== KEFIN_CONFIG_ENTRY_NAME) + .map((entry) => ({ + entry, + date: parseBackupTimestamp(entry.Name, entry.Script) + })); + backups.sort((a, b) => { + const at = a.date ? a.date.getTime() : 0; + const bt = b.date ? b.date.getTime() : 0; + return bt - at; + }); + if (backups.length <= keep) return { pruned: 0 }; + + const toRemove = new Set(backups.slice(keep).map((b) => b.entry.Name)); + injectorConfig.CustomJavaScripts = injectorConfig.CustomJavaScripts.filter( + (entry) => !toRemove.has(entry.Name) + ); + await postInjectorConfig(pluginId, injectorConfig); + LOG(`Pruned ${toRemove.size} config backup(s); kept ${keep}`); + return { pruned: toRemove.size }; + } catch (err) { + ERR('Error pruning config backups:', err); + return { pruned: 0, error: err }; + } + } + + async function restoreKefinConfigBackup(name, options = {}) { + const { backupCurrentFirst = false } = options; + try { + if (!name || name === KEFIN_CONFIG_ENTRY_NAME) { + throw new Error('Invalid backup name'); + } + const { injectorConfig } = await fetchInjectorConfigForBackups(); + const entry = (injectorConfig.CustomJavaScripts || []).find((e) => e.Name === name); + if (!entry || !isKefinConfigScriptEntry(entry)) { + throw new Error(`Backup not found: ${name}`); + } + const restoredConfig = parseConfigFromScript(entry.Script); + if (!restoredConfig || typeof restoredConfig !== 'object') { + throw new Error('Backup script does not contain a valid KefinTweaksConfig'); + } + + if (backupCurrentFirst) { + const current = window.KefinTweaksConfig || null; + if (current) { + const backupResult = await createKefinConfigBackup(current, { + reason: 'before-restore' + }); + if (!backupResult.ok) { + throw new Error('Failed to backup current config before restore'); + } + } + } + + window.KefinTweaksConfig = restoredConfig; + const ok = await saveConfigToJavaScriptInjector(restoredConfig); + if (!ok) throw new Error('Failed to save restored config'); + LOG(`Restored config from backup: ${name}`); + return true; + } catch (err) { + ERR('Error restoring config backup:', err); + return false; + } + } + + function normalizeBackupsSettings(config) { + const raw = config?.backups && typeof config.backups === 'object' ? config.backups : {}; + return { + scheduledEnabled: raw.scheduledEnabled === true, + keepCount: Math.max(1, Math.floor(Number(raw.keepCount) || DEFAULT_BACKUP_KEEP_COUNT)), + lastScheduledAt: raw.lastScheduledAt || null + }; + } + + function sameCalendarDay(isoA, dateB = new Date()) { + if (!isoA) return false; + const a = new Date(isoA); + if (Number.isNaN(a.getTime())) return false; + return ( + a.getUTCFullYear() === dateB.getUTCFullYear() && + a.getUTCMonth() === dateB.getUTCMonth() && + a.getUTCDate() === dateB.getUTCDate() + ); + } + + async function maybeRunScheduledBackup(config = null) { + try { + const cfg = config || window.KefinTweaksConfig; + if (!cfg) return { ran: false, reason: 'no-config' }; + const settings = normalizeBackupsSettings(cfg); + if (!settings.scheduledEnabled) return { ran: false, reason: 'disabled' }; + if (sameCalendarDay(settings.lastScheduledAt)) { + return { ran: false, reason: 'already-today' }; + } + + const created = await createKefinConfigBackup(cfg, { + reason: 'scheduled-daily', + kind: 'scheduled' + }); + if (!created.ok) return { ran: false, reason: 'create-failed' }; + + await pruneKefinConfigBackups(settings.keepCount); + + cfg.backups = { + ...settings, + lastScheduledAt: new Date().toISOString() + }; + window.KefinTweaksConfig = cfg; + await saveConfigToJavaScriptInjector(cfg); + LOG('Scheduled daily config backup completed'); + return { ran: true, name: created.name }; + } catch (err) { + ERR('Error running scheduled backup:', err); + return { ran: false, reason: 'error', error: err }; + } + } + + // Once per page load: run scheduled backup after login if enabled + (async () => { + try { + const loggedIn = await waitForLogin(20000); + if (!loggedIn || !window.KefinTweaksConfig) return; + await maybeRunScheduledBackup(window.KefinTweaksConfig); + } catch (e) { + /* ignore */ + } + })(); + + let _watchlistTabIndex = null; async function fetchWatchlistTabIndex() { @@ -2996,6 +3308,16 @@ window.KefinTweaksConfig = ${JSON.stringify(configToSave, null, 2)};`; resolvePluginId, getPluginConfiguration, clearPluginIdCache, + listKefinConfigBackups, + createKefinConfigBackup, + deleteKefinConfigBackup, + deleteKefinConfigBackups, + restoreKefinConfigBackup, + pruneKefinConfigBackups, + maybeRunScheduledBackup, + normalizeBackupsSettings, + KEFIN_CONFIG_ENTRY_NAME, + KEFIN_CONFIG_SCRIPT_MARKER, getWatchlistTabIndex, waitForApiClient, waitForLogin,