From 41734e00857a3db216348976d1f47632713f4736 Mon Sep 17 00:00:00 2001 From: HighImKevin Date: Fri, 16 Oct 2026 03:16:55 -0400 Subject: [PATCH] Update script dependencies, add Create Backup option, add Prompt to Update --- injector.js | 309 +++++++++++++++++++++++++++++++++- kefinTweaks-default-config.js | 6 +- kefinTweaks-plugin.js | 6 +- scripts/configuration.js | 66 ++++++++ scripts/kefinTweaks-loader.js | 13 +- scripts/toaster.js | 61 ++++++- scripts/utils.js | 50 +++++- 7 files changed, 486 insertions(+), 25 deletions(-) diff --git a/injector.js b/injector.js index c433c8c..c89263e 100644 --- a/injector.js +++ b/injector.js @@ -426,7 +426,7 @@ name: 'cardBuilder', script: 'cardBuilder.js', css: 'cardBuilder.css', - dependencies: ['apiHelper', 'userHelper'], + dependencies: ['apiHelper', 'userHelper', 'websocketHelper'], description: 'Core card building functionality (required by other scripts)' }, { @@ -513,11 +513,18 @@ dependencies: [], description: 'Watchlist statistics charts and activity analytics' }, + { + name: 'watchlist-configuration', + script: '../configuration/watchlist-configuration.js', + css: null, + dependencies: ['modal', 'ui'], + description: 'Configuration UI for Watchlist menu placements' + }, { name: 'watchlist', script: 'watchlist.js', css: 'watchlist.css', - dependencies: ['cardBuilder', 'localStorageCache', 'modal', 'utils', 'statistics'], + dependencies: ['cardBuilder', 'localStorageCache', 'modal', 'utils', 'statistics', 'watchlist-configuration', 'websocketHelper'], thirdParty: ['chartjs'], description: 'Adds watchlist functionality throughout Jellyfin interface' }, @@ -1157,7 +1164,7 @@ } async function loadConfigurationJS() { - const configDependencyNames = ['modal', 'toaster', 'utils', 'homeScreenConfig2', 'ui', 'homeScreen-migration', 'homeScreen-configuration', 'search-configuration', 'seriesEpisodes-configuration', 'seriesInfo-configuration', 'versionPreferences-configuration', 'skinManager-configuration', 'customMenuLinks-configuration', 'thumbnailScrubber-configuration', 'watchTogether-configuration', 'userManager-configuration', 'apiHelper', 'sectionHelper']; + const configDependencyNames = ['modal', 'toaster', 'utils', 'homeScreenConfig2', 'ui', 'homeScreen-migration', 'homeScreen-configuration', 'search-configuration', 'seriesEpisodes-configuration', 'seriesInfo-configuration', 'versionPreferences-configuration', 'skinManager-configuration', 'customMenuLinks-configuration', 'thumbnailScrubber-configuration', 'watchTogether-configuration', 'watchlist-configuration', 'userManager-configuration', 'apiHelper', 'sectionHelper']; for (const depName of configDependencyNames) { const depScript = SCRIPT_DEFINITIONS.find(script => script.name === depName); if (depScript) { @@ -1507,6 +1514,300 @@ } } + function normalizeReleaseTag(tag) { + const t = String(tag || '').trim(); + if (!t) return ''; + return t.startsWith('v') || t.startsWith('V') ? `v${t.slice(1)}` : `v${t}`; + } + + function normalizeUpdateState(config) { + if (!config || typeof config !== 'object') return { dismissed: true, latestVersion: '' }; + if (!config.updateState || typeof config.updateState !== 'object') { + config.updateState = { dismissed: true, latestVersion: '' }; + } else { + if (typeof config.updateState.dismissed !== 'boolean') { + config.updateState.dismissed = true; + } + if (typeof config.updateState.latestVersion !== 'string') { + config.updateState.latestVersion = ''; + } else { + config.updateState.latestVersion = normalizeReleaseTag(config.updateState.latestVersion) + || config.updateState.latestVersion; + } + } + return config.updateState; + } + + function isGithubJsdelivrKefinRoot(root) { + return /cdn\.jsdelivr\.net\/gh\/ranaldsgift\/KefinTweaks@/i.test(String(root || '')); + } + + function isSymbolicLatestRoot(root) { + return /@latest(\/|$)/i.test(String(root || '')); + } + + function parseGithubRootRef(root) { + const s = String(root || ''); + const floating = s.match(/@(latest|main|master|experimental)(\/|$)/i); + if (floating) { + const name = floating[1].toLowerCase(); + return { kind: name === 'master' ? 'main' : name, value: name === 'master' ? 'main' : name }; + } + const semver = s.match(/@(v?\d+\.\d+\.\d+)(\/|$)/i); + if (semver) { + return { kind: 'semver', value: normalizeReleaseTag(semver[1]) }; + } + const sha = s.match(/@([0-9a-f]{40})(\/|$)/i); + if (sha) { + return { kind: 'sha', value: sha[1].toLowerCase() }; + } + return null; + } + + function parseShaFromRoot(root) { + const m = String(root || '').match(/@([0-9a-f]{40})(\/|$)/i); + return m ? m[1].toLowerCase() : null; + } + + async function waitForGlobal(getter, timeoutMs = 15000, intervalMs = 250) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const value = getter(); + if (value) return value; + } catch (_) { /* ignore */ } + await new Promise((r) => setTimeout(r, intervalMs)); + } + return null; + } + + async function persistConfigFromStartup(config) { + const saveFn = await waitForGlobal( + () => window.KefinTweaksUtils?.saveConfigToJavaScriptInjector, + 20000 + ); + if (!saveFn) { + console.warn('[KefinTweaks Startup] saveConfigToJavaScriptInjector unavailable; updateState not persisted'); + return false; + } + window.KefinTweaksConfig = config; + try { + return !!(await saveFn(config)); + } catch (e) { + console.warn('[KefinTweaks Startup] Failed to persist config:', e); + return false; + } + } + + async function fetchGithubJson(url) { + const response = await fetch(url); + if (!response.ok) return null; + return response.json(); + } + + async function fetchLatestGithubRelease() { + const data = await fetchGithubJson('https://api.github.com/repos/ranaldsgift/KefinTweaks/releases/latest'); + if (!data?.tag_name) return null; + const publishedAt = data.published_at ? new Date(data.published_at) : null; + if (!publishedAt || Number.isNaN(publishedAt.getTime())) return null; + return { + tag: normalizeReleaseTag(data.tag_name), + publishedAt + }; + } + + async function fetchReleasePublishedAt(tag) { + const normalized = normalizeReleaseTag(tag); + if (!normalized) return null; + const data = await fetchGithubJson( + `https://api.github.com/repos/ranaldsgift/KefinTweaks/releases/tags/${encodeURIComponent(normalized)}` + ); + if (!data?.published_at) return null; + const d = new Date(data.published_at); + return Number.isNaN(d.getTime()) ? null : d; + } + + async function fetchCommitDate(shaOrRef) { + if (!shaOrRef) return null; + const data = await fetchGithubJson( + `https://api.github.com/repos/ranaldsgift/KefinTweaks/commits/${encodeURIComponent(shaOrRef)}` + ); + const iso = data?.commit?.committer?.date || data?.commit?.author?.date; + if (!iso) return null; + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? null : d; + } + + async function resolveInstalledCommitDate(root, config, branchName) { + const resolvedSha = parseShaFromRoot(config?.kefinTweaksRootResolved) + || parseShaFromRoot(resolvedRootCache); + if (resolvedSha) { + const fromResolved = await fetchCommitDate(resolvedSha); + if (fromResolved) return fromResolved; + } + return fetchCommitDate(branchName || 'main'); + } + + /** + * True when the install should treat GitHub Latest as a "new update" vs current pin/tip. + */ + async function isGithubInstallBehindLatest(root, config, latest) { + const ref = parseGithubRootRef(root); + if (!ref) return true; + + if (ref.kind === 'semver') { + if (ref.value === latest.tag) return false; + const installedPublished = await fetchReleasePublishedAt(ref.value); + if (!installedPublished) return true; + return installedPublished.getTime() < latest.publishedAt.getTime(); + } + + if (ref.kind === 'sha') { + const commitDate = await fetchCommitDate(ref.value); + if (!commitDate) return true; + return commitDate.getTime() < latest.publishedAt.getTime(); + } + + if (ref.kind === 'main' || ref.kind === 'experimental') { + const tipDate = await resolveInstalledCommitDate(root, config, ref.kind); + if (!tipDate) return true; + // Tip newer than Latest ⇒ following branch tip, not a release-update prompt + if (tipDate.getTime() >= latest.publishedAt.getTime()) return false; + return true; + } + + return true; + } + + function buildConcreteLatestRoot(latestTag) { + const tag = normalizeReleaseTag(latestTag); + return `https://cdn.jsdelivr.net/gh/ranaldsgift/KefinTweaks@${tag}/`; + } + + async function showPinnedVersionUpdateToast(config, latestTag) { + const toastFn = await waitForGlobal(() => window.KefinTweaksToaster?.toast, 20000); + if (!toastFn) { + console.warn('[KefinTweaks Startup] Toaster unavailable; skipping update toast'); + return; + } + + const tag = normalizeReleaseTag(latestTag); + const githubInstall = isGithubJsdelivrKefinRoot(config?.kefinTweaksRoot); + + const dismissAndSave = async () => { + normalizeUpdateState(config); + config.updateState.dismissed = true; + if (tag) config.updateState.latestVersion = tag; + await persistConfigFromStartup(config); + }; + + const handle = toastFn( + 'An update for KefinTweaks is available.', + '0', + false, + { + buttons: [ + { + label: 'Update', + onClick: async () => { + try { + normalizeUpdateState(config); + config.updateState.dismissed = true; + if (tag) config.updateState.latestVersion = tag; + + if (githubInstall && tag) { + const nextRoot = buildConcreteLatestRoot(tag); + config.kefinTweaksRoot = nextRoot; + config.kefinTweaksRootResolved = nextRoot; + await persistConfigFromStartup(config); + handle?.dismiss?.(); + toastFn( + 'KefinTweaks updated. Please refresh the page for the changes to take effect.', + '8' + ); + } else { + await persistConfigFromStartup(config); + handle?.dismiss?.(); + const url = tag + ? `https://github.com/ranaldsgift/KefinTweaks/releases/tag/${encodeURIComponent(tag)}` + : 'https://github.com/ranaldsgift/KefinTweaks/releases/latest'; + try { + window.open(url, '_blank', 'noopener'); + } catch (_) { /* ignore */ } + } + } catch (e) { + console.error('[KefinTweaks Startup] Update action failed:', e); + } + } + }, + { + label: 'Dismiss', + onClick: async () => { + try { + await dismissAndSave(); + handle?.dismiss?.(); + } catch (e) { + console.error('[KefinTweaks Startup] Dismiss action failed:', e); + } + } + } + ] + } + ); + } + + async function checkPinnedVersionUpdate() { + const config = window.KefinTweaksConfig; + const root = config?.kefinTweaksRoot; + if (!root) return; + + // Floating @latest already auto-resolves to GitHub Latest — skip this prompt flow + if (isSymbolicLatestRoot(root)) { + normalizeUpdateState(config); + return; + } + + const state = normalizeUpdateState(config); + let latest; + try { + latest = await fetchLatestGithubRelease(); + } catch (e) { + console.warn('[KefinTweaks Startup] Failed to fetch latest release:', e); + return; + } + if (!latest?.tag) return; + + const known = normalizeReleaseTag(state.latestVersion); + if (known !== latest.tag) { + let markNew = false; + if (isGithubJsdelivrKefinRoot(root)) { + try { + markNew = await isGithubInstallBehindLatest(root, config, latest); + } catch (e) { + console.warn('[KefinTweaks Startup] Install-vs-latest compare failed; treating as new:', e); + markNew = true; + } + } else { + // Self-hosted / custom root + markNew = true; + } + + if (markNew) { + state.dismissed = false; + state.latestVersion = latest.tag; + await persistConfigFromStartup(config); + } else { + // Same or newer install relative to Latest — remember tag without prompting + state.latestVersion = latest.tag; + await persistConfigFromStartup(config); + } + } + + if (!state.dismissed) { + await showPinnedVersionUpdateToast(config, latest.tag); + } + } + // Startup task - runs only for admin users async function startupTask() { console.log('[KefinTweaks Startup] Starting startup task...'); @@ -1528,6 +1829,8 @@ try { // Fire-and-forget: remove leftover Watchlist Custom Tab (best-effort) //removeWatchlistFromCustomTabPlugin(); + + await checkPinnedVersionUpdate(); console.log('[KefinTweaks Startup] Startup task completed successfully'); } catch (error) { diff --git a/kefinTweaks-default-config.js b/kefinTweaks-default-config.js index 69f9877..6b36803 100644 --- a/kefinTweaks-default-config.js +++ b/kefinTweaks-default-config.js @@ -64,5 +64,9 @@ window.KefinTweaksDefaultConfig = { "defaultSkin": null, "themes": [], "customMenuLinks": [], - "optionalIncludes": [] + "optionalIncludes": [], + "updateState": { + "dismissed": true, + "latestVersion": "" + } }; diff --git a/kefinTweaks-plugin.js b/kefinTweaks-plugin.js index e4283f1..1bd3765 100644 --- a/kefinTweaks-plugin.js +++ b/kefinTweaks-plugin.js @@ -1006,7 +1006,7 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; } resolvedHashEl.style.display = 'block'; - resolvedHashEl.textContent = 'Resolved commit: …'; + resolvedHashEl.textContent = 'Version: …'; let sha = extractShaFromRoot(currentConfig?.kefinTweaksRootResolved); const symbolicMatchesSelection = @@ -1029,9 +1029,9 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; if (sha) { const short = sha.length > 12 ? sha.slice(0, 12) : sha; - resolvedHashEl.textContent = `Resolved commit: ${short}`; + resolvedHashEl.textContent = `Version: ${short}`; } else { - resolvedHashEl.textContent = 'Resolved commit: unavailable'; + resolvedHashEl.textContent = 'Version: unavailable'; } } diff --git a/scripts/configuration.js b/scripts/configuration.js index d749a13..17ac4ef 100644 --- a/scripts/configuration.js +++ b/scripts/configuration.js @@ -4944,6 +4944,7 @@ `).join(''); return `
+ @@ -4989,6 +4990,54 @@ return confirmDeleteBackups([name]); } + function confirmCreateBackup() { + return new Promise((resolve) => { + if (!window.ModalSystem) { + const label = window.prompt('Optional backup name (leave blank for default):', '') || ''; + resolve(window.confirm('Create a configuration backup now?') + ? { create: true, label: String(label).trim() } + : { create: false }); + return; + } + window.ModalSystem.create({ + id: 'manageCreateBackupConfirm', + title: 'Create Backup', + content: ` +
+ Create a backup of your current KefinTweaks configuration? +
+ + +
+ When set, the backup is named KefinTweaks-Config-Backup-[Name]-Timestamp. Leave blank for the default name. +
+ `, + footer: ` + + + `, + closeOnBackdrop: true, + closeOnEscape: true, + onOpen: (mi) => { + const input = mi.dialogContent?.querySelector('#manageCreateBackupName'); + setTimeout(() => input?.focus(), 100); + mi.dialogFooter?.querySelector('#manageCreateBackupConfirmBtn')?.addEventListener('click', () => { + const label = (input?.value || '').trim(); + mi.close(); + resolve({ create: true, label }); + }); + mi.dialogFooter?.querySelector('#manageCreateBackupCancelBtn')?.addEventListener('click', () => { + mi.close(); + resolve({ create: false }); + }); + } + }); + }); + } + function confirmDeleteBackups(names) { const list = (Array.isArray(names) ? names : []).filter(Boolean); return new Promise((resolve) => { @@ -5147,6 +5196,7 @@ }); tableHost?.addEventListener('click', async (e) => { + const createBtn = e.target.closest?.('#manageBackupsCreateBtn'); const selectAllBtn = e.target.closest?.('#manageBackupsSelectAllBtn'); const deselectAllBtn = e.target.closest?.('#manageBackupsDeselectAllBtn'); const deleteSelectedBtn = e.target.closest?.('#manageBackupsDeleteSelectedBtn'); @@ -5175,6 +5225,22 @@ syncSelectionUi(); }; + if (createBtn) { + const choice = await confirmCreateBackup(); + if (!choice?.create) return; + const result = await window.KefinTweaksUtils?.createKefinConfigBackup?.(config, { + reason: 'manual', + kind: 'manual', + label: choice.label || '' + }); + if (result?.ok) { + toastMsg(`Backup created: ${result.name}`); + await refreshManageBackupsTable(tableHost); + } else { + toastMsg('Failed to create backup', '5'); + } + return; + } if (selectAllBtn) { setAllChecked(true); return; diff --git a/scripts/kefinTweaks-loader.js b/scripts/kefinTweaks-loader.js index 197e853..05a5b0b 100644 --- a/scripts/kefinTweaks-loader.js +++ b/scripts/kefinTweaks-loader.js @@ -257,13 +257,6 @@ dependencies: ['moviesCache', 'seriesCache'], description: 'Library cache facade for KefinTweaks' }, - { - name: 'externalList', - script: 'externalList.js', - css: null, - dependencies: ['libraryCache', 'libraryCacheUtils', 'apiHelper'], - description: 'MDBList external list matching for home sections' - }, { name: 'skinManager', script: 'skinManager.js', @@ -290,7 +283,7 @@ name: 'cardBuilder', script: 'cardBuilder.js', css: 'cardBuilder.css', - dependencies: ['apiHelper', 'userHelper'], + dependencies: ['apiHelper', 'userHelper', 'websocketHelper'], description: 'Core card building functionality (required by other scripts)' }, { @@ -388,7 +381,7 @@ name: 'watchlist', script: 'watchlist.js', css: 'watchlist.css', - dependencies: ['cardBuilder', 'localStorageCache', 'modal', 'utils', 'statistics', 'watchlist-configuration'], + dependencies: ['cardBuilder', 'localStorageCache', 'modal', 'utils', 'statistics', 'watchlist-configuration', 'websocketHelper'], thirdParty: ['chartjs'], description: 'Adds watchlist functionality throughout Jellyfin interface' }, @@ -396,7 +389,7 @@ name: 'homeScreen', script: 'homeScreen3.js', css: 'homeScreen.css', - dependencies: ['cardBuilder', 'localStorageCache', 'utils', 'userHelper', 'homeScreenConfig2', 'homeScreen-configuration', 'peopleCache', 'studiosCache', 'moviesCache', 'seriesCache', 'libraryCache', 'libraryCacheUtils', 'externalList', 'indexedDBCache', 'homeScreenConfigCommunity', 'dataHelper', 'apiHelper', 'sectionHelper', 'homeScreen-migration', 'homeScreen-user-configuration', 'homeScreenSectionConfigure', 'homeScreenPin'], + dependencies: ['cardBuilder', 'localStorageCache', 'utils', 'userHelper', 'homeScreenConfig2', 'homeScreen-configuration', 'peopleCache', 'studiosCache', 'moviesCache', 'seriesCache', 'libraryCache', 'libraryCacheUtils', 'indexedDBCache', 'homeScreenConfigCommunity', 'dataHelper', 'apiHelper', 'sectionHelper', 'homeScreen-migration', 'homeScreen-user-configuration', 'homeScreenSectionConfigure', 'homeScreenPin'], priority: true, // Load immediately after dependencies to reduce UI disruption description: 'Adds custom home screen sections' }, diff --git a/scripts/toaster.js b/scripts/toaster.js index cd986c4..377e48e 100644 --- a/scripts/toaster.js +++ b/scripts/toaster.js @@ -56,6 +56,35 @@ .toast.kefin-toast-rich .kefin-toast-action .material-icons { font-size: 1.25rem; } + .toast.kefin-toast-rich.kefin-toast-with-buttons { + flex-wrap: wrap; + align-items: flex-start; + } + .toast.kefin-toast-rich .kefin-toast-buttons { + flex: 1 1 100%; + display: flex; + flex-wrap: wrap; + gap: 0.5em; + margin-top: 0.15em; + } + .toast.kefin-toast-rich .kefin-toast-text-btn { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.35em 0.9em; + margin: 0; + border: 1px solid rgba(255, 255, 255, 0.28); + border-radius: 4px; + background: rgba(255, 255, 255, 0.08); + color: inherit; + font: inherit; + font-weight: 500; + cursor: pointer; + } + .toast.kefin-toast-rich .kefin-toast-text-btn:hover { + background: rgba(255, 255, 255, 0.16); + } `; document.head.appendChild(style); } @@ -119,7 +148,10 @@ function buildToastContent(toastElement, message, options) { const link = options?.link; const action = options?.action; - const rich = !!(link || action); + const buttons = Array.isArray(options?.buttons) + ? options.buttons.filter((b) => b && typeof b.label === 'string' && typeof b.onClick === 'function') + : []; + const rich = !!(link || action || buttons.length); if (!rich) { toastElement.textContent = message; return; @@ -127,6 +159,9 @@ ensureToastStyles(); toastElement.classList.add('kefin-toast-rich'); + if (buttons.length) { + toastElement.classList.add('kefin-toast-with-buttons'); + } const messageEl = document.createElement('span'); messageEl.className = 'kefin-toast-message'; @@ -202,6 +237,28 @@ }); toastElement.appendChild(btn); } + + if (buttons.length) { + const row = document.createElement('div'); + row.className = 'kefin-toast-buttons'; + buttons.forEach((spec) => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'kefin-toast-text-btn'; + btn.textContent = spec.label; + btn.addEventListener('click', (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + try { + spec.onClick(); + } catch (e) { + ERR('Toast button action failed:', e); + } + }); + row.appendChild(btn); + }); + toastElement.appendChild(row); + } } /** @@ -209,7 +266,7 @@ * @param {string} message - The message to display * @param {string|number} duration - Duration in seconds * @param {boolean} autoHide - Whether to automatically hide the toast after duration (default: true) - * @param {{ link?: { label: string, href?: string, onClick?: Function }, action?: { icon?: string, label?: string, onClick: Function } }} [options] + * @param {{ link?: { label: string, href?: string, onClick?: Function }, action?: { icon?: string, label?: string, onClick: Function }, buttons?: Array<{ label: string, onClick: Function }> }} [options] * @returns {{ dismiss: Function, element: HTMLElement }|undefined} */ function toast(message, duration = '3', autoHide = true, options = null) { diff --git a/scripts/utils.js b/scripts/utils.js index 0f6275d..49c7026 100644 --- a/scripts/utils.js +++ b/scripts/utils.js @@ -2500,6 +2500,27 @@ window.KefinTweaksConfig = ${JSON.stringify(configToSave, null, 2)};`; } } + function padBackupTimePart(n) { + return String(n).padStart(2, '0'); + } + + /** Local wall-clock stamp for backup filenames (matches toLocaleString calendar day). */ + function formatBackupTimestampLocal(date = new Date()) { + let d = date instanceof Date ? date : new Date(date); + if (Number.isNaN(d.getTime())) d = new Date(); + return [ + d.getFullYear(), + padBackupTimePart(d.getMonth() + 1), + padBackupTimePart(d.getDate()) + ].join('-') + + 'T' + + [ + padBackupTimePart(d.getHours()), + padBackupTimePart(d.getMinutes()), + padBackupTimePart(d.getSeconds()) + ].join('-'); + } + function parseBackupTimestamp(name, script) { const createdMatch = String(script || '').match(/Created:\s*(\d{4}-\d{2}-\d{2}T[^\s\n]+)/i); if (createdMatch) { @@ -2508,8 +2529,9 @@ window.KefinTweaksConfig = ${JSON.stringify(configToSave, null, 2)};`; } 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); + // Filenames use local wall-clock; do not append Z (that caused UTC→local day skew). + const localIso = nameMatch[1].replace(/T(\d{2})-(\d{2})-(\d{2})/, 'T$1:$2:$3'); + const d = new Date(localIso); if (!Number.isNaN(d.getTime())) return d; } return null; @@ -2524,11 +2546,27 @@ ${reasonLine}// Created: ${new Date().toISOString()} window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; } - function makeBackupName(kind = 'manual') { - const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5); + function sanitizeBackupLabel(label) { + let s = String(label || '').trim(); + if (!s) return ''; + s = s + .replace(/[\\/<>:"|?*\u0000-\u001f;]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, ''); + if (s.length > 48) s = s.slice(0, 48).replace(/-+$/g, ''); + return s; + } + + function makeBackupName(kind = 'manual', options = {}) { + const timestamp = formatBackupTimestampLocal(new Date()); + const label = sanitizeBackupLabel(options.label || options.name || ''); if (kind === 'scheduled') { return `KefinTweaks-Config-ScheduledBackup-${timestamp}`; } + if (label) { + return `KefinTweaks-Config-Backup-${label}-${timestamp}`; + } return `KefinTweaks-Config-Backup-${timestamp}`; } @@ -2601,7 +2639,7 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; } async function createKefinConfigBackup(config = null, options = {}) { - const { reason = 'manual', kind } = options; + const { reason = 'manual', kind, label, name } = options; try { const configToBackup = config || window.KefinTweaksConfig; if (!configToBackup) throw new Error('No config available to backup'); @@ -2609,7 +2647,7 @@ window.KefinTweaksConfig = ${JSON.stringify(config, null, 2)};`; const backupKind = kind || (reason === 'scheduled-daily' ? 'scheduled' : 'manual'); const { pluginId, injectorConfig } = await fetchInjectorConfigForBackups(); - const backupName = makeBackupName(backupKind); + const backupName = makeBackupName(backupKind, { label: label || name }); injectorConfig.CustomJavaScripts.push({ Name: backupName, Script: buildConfigBackupScript(configToBackup, reason),