mirror of
https://github.com/ranaldsgift/KefinTweaks.git
synced 2026-11-05 15:52:47 +01:00
Add the ability to Manage and Schedule KefinTweaks backups
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
+569
-23
@@ -885,11 +885,8 @@
|
||||
<button class="emby-button raised" id="changeKefinTweaksSourceBtn" style="padding: 0.75em 2em; font-size: 1em;" title="Switch between the Latest, Development, Version specific branches or point to your own self hosted location!">
|
||||
<span>Plugin Settings</span>
|
||||
</button>
|
||||
<button class="emby-button raised" id="exportConfigBtn" style="padding: 0.75em 2em; font-size: 1em;">
|
||||
<span>Export</span>
|
||||
</button>
|
||||
<button class="emby-button raised" id="importConfigBtn" style="padding: 0.75em 2em; font-size: 1em;">
|
||||
<span>Import</span>
|
||||
<button class="emby-button raised" id="manageConfigBtn" style="padding: 0.75em 2em; font-size: 1em;" title="Manage backups, export, and import">
|
||||
<span>Manage</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 `
|
||||
<div class="manage-io-panel">
|
||||
<div class="listItemBodyText" style="margin-bottom: 1em; flex-shrink: 0;">
|
||||
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.'}
|
||||
</div>
|
||||
<textarea id="manageExportConfigTextarea" class="fld emby-textarea manage-io-textarea" rows="16"
|
||||
readonly></textarea>
|
||||
${canUseClipboard ? `
|
||||
<div style="margin-top: 1em; flex-shrink: 0;">
|
||||
<button type="button" class="emby-button raised button-submit" id="manageCopyExportConfigBtn"
|
||||
style="padding: 0.75em 2em; font-size: 1em; font-weight: 500;">
|
||||
<span>Copy to Clipboard</span>
|
||||
</button>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function buildManageImportPanelHtml() {
|
||||
return `
|
||||
<div class="manage-io-panel">
|
||||
<div class="listItemBodyText" style="margin-bottom: 1em; flex-shrink: 0;">
|
||||
Paste your configuration JSON below. This will completely replace your current configuration.
|
||||
</div>
|
||||
<textarea id="manageImportConfigTextarea" class="fld emby-textarea manage-io-textarea" rows="16"
|
||||
placeholder="Paste your configuration JSON here..."></textarea>
|
||||
<div style="margin-top: 1em; flex-shrink: 0;">
|
||||
<button type="button" class="emby-button raised button-submit" id="manageConfirmImportBtn"
|
||||
style="padding: 0.75em 2em; font-size: 1em; font-weight: 500;">
|
||||
<span>Import</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
: `
|
||||
<label class="checkboxContainer" style="display:flex;align-items:center;gap:0.75em;">
|
||||
<input type="checkbox" id="manageScheduledBackups" ${scheduledEnabled ? 'checked' : ''}>
|
||||
<span>Scheduled Backups</span>
|
||||
</label>
|
||||
<div class="listItemBodyText secondary" id="manageScheduledBackupsDesc">${backupsSettingsDescription(scheduledEnabled, keepCount)}</div>
|
||||
`;
|
||||
|
||||
return `
|
||||
<div class="manage-backups-settings" style="display:flex;flex-direction:column;gap:1.25em;">
|
||||
<div>${toggleHtml}</div>
|
||||
<div>
|
||||
<label class="listItemBodyText" for="manageBackupKeepCount" style="display:block;margin-bottom:0.35em;">Number of Backups to Keep</label>
|
||||
<input type="number" id="manageBackupKeepCount" class="fld emby-input" min="1" step="1" value="${keepCount}" style="max-width:8em;">
|
||||
<div class="listItemBodyText secondary" style="margin-top:0.35em;">KefinTweaks will overwrite the oldest scheduled Backup once you get to this limit.</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="listItemBodyText" style="margin:0 0 0.75em;">Manage Backups</h3>
|
||||
<div id="manageBackupsTableHost">
|
||||
<div class="listItemBodyText secondary">Loading backups…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderBackupsTableRows(backups) {
|
||||
if (!backups.length) {
|
||||
return `<div class="listItemBodyText secondary">No backups found.</div>`;
|
||||
}
|
||||
const rows = backups.map((b) => `
|
||||
<tr data-backup-name="${escapeHtmlAttr(b.name)}" data-backup-kind="${escapeHtmlAttr(b.kind || 'manual')}">
|
||||
<td style="padding:0.5em 0.4em;vertical-align:middle;width:2.5em;">
|
||||
<input type="checkbox" class="checkbox manage-backup-row-check" data-backup-name="${escapeHtmlAttr(b.name)}" aria-label="Select ${escapeHtmlAttr(b.name)}">
|
||||
</td>
|
||||
<td style="padding:0.5em 0.75em;vertical-align:middle;">
|
||||
<div class="listItemBodyText" style="word-break:break-all;">${escapeHtmlAttr(b.name)}</div>
|
||||
<div class="listItemBodyText secondary" style="font-size:0.9em;">
|
||||
${escapeHtmlAttr(b.kindLabel || 'Manual')} · ${escapeHtmlAttr(b.dateLabel || 'Unknown date')}
|
||||
</div>
|
||||
</td>
|
||||
<td style="padding:0.5em 0.75em;white-space:nowrap;text-align:right;vertical-align:middle;">
|
||||
<button type="button" class="emby-button paper-icon-button-light manage-backup-restore-btn" title="Restore" aria-label="Restore">
|
||||
<span class="material-icons" aria-hidden="true">settings_backup_restore</span>
|
||||
</button>
|
||||
<button type="button" class="emby-button paper-icon-button-light manage-backup-delete-btn" title="Delete" aria-label="Delete">
|
||||
<span class="material-icons" aria-hidden="true">delete</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
return `
|
||||
<div style="display:flex;flex-wrap:wrap;gap:0.5em;align-items:center;margin-bottom:0.75em;">
|
||||
<button type="button" class="emby-button raised" id="manageBackupsSelectAllBtn" style="padding:0.4em 1em;">Select all</button>
|
||||
<button type="button" class="emby-button raised" id="manageBackupsDeselectAllBtn" style="padding:0.4em 1em;">Deselect all</button>
|
||||
<button type="button" class="emby-button raised" id="manageBackupsDeleteSelectedBtn" style="padding:0.4em 1em;" disabled>Delete selected</button>
|
||||
</div>
|
||||
<div style="overflow:auto;max-height:320px;border:1px solid rgba(255,255,255,0.1);border-radius:4px;">
|
||||
<table style="width:100%;border-collapse:collapse;">
|
||||
<thead>
|
||||
<tr style="border-bottom:1px solid rgba(255,255,255,0.1);text-align:left;">
|
||||
<th style="padding:0.5em 0.4em;width:2.5em;" class="listItemBodyText">
|
||||
<input type="checkbox" class="checkbox" id="manageBackupsSelectAllHeader" title="Select all" aria-label="Select all">
|
||||
</th>
|
||||
<th style="padding:0.5em 0.75em;" class="listItemBodyText">Backup</th>
|
||||
<th style="padding:0.5em 0.75em;text-align:right;" class="listItemBodyText">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function escapeHtmlAttr(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
async function refreshManageBackupsTable(host) {
|
||||
if (!host) return;
|
||||
host.innerHTML = `<div class="listItemBodyText secondary">Loading backups…</div>`;
|
||||
const listFn = window.KefinTweaksUtils?.listKefinConfigBackups;
|
||||
if (!listFn) {
|
||||
host.innerHTML = `<div class="listItemBodyText secondary">Backup helpers unavailable.</div>`;
|
||||
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
|
||||
? `<div class="listItemBodyText">Remove backup <strong>${escapeHtmlAttr(list[0])}</strong>? This cannot be undone.</div>`
|
||||
: `<div class="listItemBodyText" style="margin-bottom:0.75em;">Remove <strong>${list.length}</strong> selected backups? This cannot be undone.</div>
|
||||
<ul style="margin:0;padding-left:1.25em;max-height:180px;overflow:auto;">
|
||||
${list.map((n) => `<li class="listItemBodyText secondary" style="word-break:break-all;">${escapeHtmlAttr(n)}</li>`).join('')}
|
||||
</ul>`;
|
||||
|
||||
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: `
|
||||
<button class="emby-button raised button-submit" id="manageDeleteBackupConfirmBtn" style="padding:0.75em 2em;margin-right:1em;">Delete</button>
|
||||
<button class="emby-button raised" id="manageDeleteBackupCancelBtn" style="padding:0.75em 2em;">Cancel</button>
|
||||
`,
|
||||
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: `
|
||||
<div class="listItemBodyText" style="margin-bottom:1em;">
|
||||
Restore configuration from <strong>${escapeHtmlAttr(name)}</strong>? This will overwrite your current active configuration.
|
||||
</div>
|
||||
`,
|
||||
footer: `
|
||||
<button class="emby-button raised button-submit" id="manageRestoreWithBackupBtn" style="padding:0.75em 1.5em;margin-right:0.5em;">Backup before Restore</button>
|
||||
<button class="emby-button raised button-submit" id="manageRestoreOnlyBtn" style="padding:0.75em 1.5em;margin-right:0.5em;">Restore</button>
|
||||
<button class="emby-button raised" id="manageRestoreCancelBtn" style="padding:0.75em 1.5em;">Cancel</button>
|
||||
`,
|
||||
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: `
|
||||
<div class="manage-config-shell">
|
||||
<div class="manage-config-tabs" style="display:flex;gap:0.5em;margin-bottom:1em;border-bottom:1px solid rgba(255,255,255,0.1);padding-bottom:0.5em;flex-shrink:0;">
|
||||
<button type="button" class="emby-button raised manage-tab-btn active" data-manage-tab="backups" style="padding:0.5em 1.25em;">Backups</button>
|
||||
<button type="button" class="emby-button raised manage-tab-btn" data-manage-tab="export" style="padding:0.5em 1.25em;">Export</button>
|
||||
<button type="button" class="emby-button raised manage-tab-btn" data-manage-tab="import" style="padding:0.5em 1.25em;">Import</button>
|
||||
</div>
|
||||
<div class="manage-tab-panels">
|
||||
<div class="manage-tab-panel is-active" data-manage-panel="backups">
|
||||
${buildManageBackupsPanelHtml(settings)}
|
||||
</div>
|
||||
<div class="manage-tab-panel" data-manage-panel="export">
|
||||
${buildManageExportPanelHtml(canUseClipboard)}
|
||||
</div>
|
||||
<div class="manage-tab-panel" data-manage-panel="import">
|
||||
${buildManageImportPanelHtml()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
footer: `
|
||||
<button class="emby-button raised" id="closeManageConfigBtn" style="padding:0.75em 2em;font-size:1em;">
|
||||
<span>Close</span>
|
||||
</button>
|
||||
`,
|
||||
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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user