Merge pull request #2065 from PR0M3TH3AN/ai/subscription-history-feature-13920162172659924347

Add Subscription History & Backup/Restore
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2026-02-01 22:37:02 -05:00
commit d018e21ad4
6 files changed
+633 -10

No files matched your search

+27 -9
View File
@@ -1087,15 +1087,33 @@
></p>
<div class="flex items-center justify-between gap-3">
<h3 class="text-base font-semibold text-text">Subscriptions</h3>
<button
id="subscriptionsRefreshBtn"
type="button"
class="btn-ghost btn-icon"
data-size="sm"
aria-label="Refresh subscriptions"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"></path><path d="M16 21h5v-5"></path></svg>
</button>
<div class="flex items-center gap-1">
<button
id="subscriptionsHistoryBtn"
type="button"
class="btn-ghost focus-ring text-xs px-2"
aria-label="View history"
>
History
</button>
<button
id="subscriptionsBackupBtn"
type="button"
class="btn-ghost focus-ring text-xs px-2"
aria-label="Backup now"
>
Backup
</button>
<button
id="subscriptionsRefreshBtn"
type="button"
class="btn-ghost btn-icon"
data-size="sm"
aria-label="Refresh subscriptions"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"></path><path d="M16 21h5v-5"></path></svg>
</button>
</div>
</div>
<div
id="subscriptionsEmpty"
+111
View File
@@ -0,0 +1,111 @@
<div
id="subscriptionHistoryModal"
class="bv-modal hidden"
role="dialog"
aria-modal="true"
aria-labelledby="subscriptionHistoryTitle"
tabindex="-1"
data-ds="new"
>
<div class="bv-modal-backdrop"></div>
<div
class="bv-modal__panel w-full max-w-4xl h-4/5 rounded-xl border border-border/50 bg-surface shadow-2xl overflow-hidden flex flex-col"
tabindex="-1"
>
<!-- Modal Header -->
<div class="modal-header border-b border-border/60 p-4 flex items-center justify-between bg-surface-2">
<h2 id="subscriptionHistoryTitle" class="text-lg font-bold text-text">Subscription History</h2>
<button id="closeSubscriptionHistoryModal" class="modal-close btn-ghost btn-icon p-1 rounded-md text-muted hover:text-text focus-ring">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- Modal Body -->
<div class="flex flex-1 overflow-hidden">
<!-- Left Column: List -->
<div class="w-full md:w-1/3 border-r border-border/60 flex flex-col bg-surface-1">
<div class="p-3 border-b border-border/60 bg-surface-2/50">
<p class="text-xs text-muted font-medium uppercase tracking-wider">Backups & Versions</p>
</div>
<div class="flex-1 overflow-y-auto p-2" id="subscriptionHistoryListContainer">
<ul id="subscriptionHistoryList" class="space-y-1"></ul>
<div id="subscriptionHistoryLoading" class="hidden p-4 text-center">
<span class="status-spinner status-spinner--inline"></span>
<span class="text-sm text-muted ml-2">Loading history...</span>
</div>
<div id="subscriptionHistoryEmpty" class="hidden p-8 text-center flex flex-col items-center justify-center h-full text-muted">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 mb-3 opacity-20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="text-sm">No backups found.</p>
</div>
</div>
</div>
<!-- Right Column: Details -->
<div class="hidden md:flex md:w-2/3 flex-col bg-surface relative">
<!-- Empty State for Details -->
<div id="subscriptionHistoryDetailsEmpty" class="absolute inset-0 flex flex-col items-center justify-center text-muted p-8 text-center">
<div class="p-4 rounded-full bg-surface-2 mb-4">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
</div>
<p class="text-base font-medium text-text">Select a backup to view details</p>
<p class="text-sm mt-1 max-w-xs">You can preview the channels in a backup before restoring it.</p>
</div>
<!-- Content State -->
<div id="subscriptionHistoryDetailsContent" class="hidden flex-col h-full w-full">
<div class="p-4 border-b border-border/60 bg-surface flex items-center justify-between gap-4">
<div>
<h3 id="subscriptionHistorySelectedDate" class="text-base font-bold text-text"></h3>
<p id="subscriptionHistorySelectedMeta" class="text-xs text-muted"></p>
</div>
<div class="flex items-center gap-2">
<span id="subscriptionHistorySelectedBadge" class="badge" data-variant="surface"></span>
</div>
</div>
<div class="flex-1 overflow-y-auto p-4">
<div id="subscriptionHistoryDecrypting" class="hidden flex flex-col items-center justify-center h-40 text-muted">
<span class="status-spinner mb-2"></span>
<span class="text-sm">Decrypting content...</span>
</div>
<ul id="subscriptionHistorySelectedChannels" class="grid grid-cols-1 sm:grid-cols-2 gap-3 pb-4"></ul>
</div>
<div class="p-4 border-t border-border/60 bg-surface-2/30 flex items-center justify-between gap-3">
<div class="text-xs text-muted">
Actions apply to this specific backup.
</div>
<div class="flex gap-3">
<button id="subscriptionHistoryRebroadcastBtn" type="button" class="btn-ghost focus-ring text-sm">
Rebroadcast
</button>
<button id="subscriptionHistoryRestoreBtn" type="button" class="btn focus-ring text-sm">
Restore as Active
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
+15
View File
@@ -33,6 +33,7 @@ export const NOTE_TYPES = Object.freeze({
ZAP_RECEIPT: "zapReceipt",
WATCH_HISTORY: "watchHistory",
SUBSCRIPTION_LIST: "subscriptionList",
SUBSCRIPTION_BACKUP: "subscriptionBackup",
USER_BLOCK_LIST: "userBlockList",
HASHTAG_PREFERENCES: "hashtagPreferences",
DM_RELAY_LIST: "dmRelayList",
@@ -520,6 +521,20 @@ const BASE_SCHEMAS = {
"NIP-04/NIP-44 encrypted JSON array of NIP-51 tag tuples (e.g., [['p', <hex>], …]).",
},
},
[NOTE_TYPES.SUBSCRIPTION_BACKUP]: {
type: NOTE_TYPES.SUBSCRIPTION_BACKUP,
label: "Subscription list backup",
kind: 30000,
identifierTag: {
name: "d",
},
appendTags: [["encrypted", "nip44_v2"]],
content: {
format: "encrypted-tag-list",
description:
"NIP-04/NIP-44 encrypted JSON array of NIP-51 tag tuples (e.g., [['p', <hex>], …]).",
},
},
[NOTE_TYPES.USER_BLOCK_LIST]: {
type: NOTE_TYPES.USER_BLOCK_LIST,
label: "User block list (legacy)",
+96 -1
View File
@@ -1133,7 +1133,7 @@ class SubscriptionsManager {
* as kind=30000 with ["d", "subscriptions"] to be replaceable.
* The published event includes an ["encrypted", "<scheme>"] tag.
*/
async publishSubscriptionList(userPubkey) {
async publishSubscriptionList(userPubkey, { isBackup = false } = {}) {
if (!userPubkey) {
throw new Error("No pubkey => cannot publish subscription list.");
}
@@ -1258,6 +1258,17 @@ class SubscriptionsManager {
encryption: encryptionTagValue,
});
if (isBackup) {
const backupId = `subscriptions-backup-${new Date().toISOString().replace(/[:.]/g, "-")}`;
// Override d tag
const dTagIndex = evt.tags.findIndex(t => t[0] === 'd');
if (dTagIndex >= 0) {
evt.tags[dTagIndex][1] = backupId;
} else {
evt.tags.push(['d', backupId]);
}
}
let signedEvent;
try {
signedEvent = await signer.signEvent(evt);
@@ -2065,6 +2076,90 @@ class SubscriptionsManager {
});
}
async createBackup(userPubkey) {
const backupEvent = await this.publishSubscriptionList(userPubkey, {
isBackup: true,
});
return backupEvent;
}
async fetchHistory(userPubkey) {
if (!userPubkey) return [];
const normalizedUserPubkey = normalizeHexPubkey(userPubkey);
// Fetch all kind 30000 events for this user to find backups and old versions
const events = await nostrClient.fetchEvents({
kinds: [SUBSCRIPTION_SET_KIND],
authors: [normalizedUserPubkey],
limit: 50, // Reasonable limit for history
});
return events
.filter((e) => {
const dTag = e.tags.find((t) => t[0] === "d")?.[1];
return (
dTag === SUBSCRIPTION_LIST_IDENTIFIER ||
(typeof dTag === "string" && dTag.startsWith("subscriptions-backup-"))
);
})
.sort((a, b) => b.created_at - a.created_at);
}
async restoreBackup(event, userPubkey) {
if (!event || !userPubkey) return;
const decryptResult = await this.decryptSubscriptionEvent(event, userPubkey);
if (!decryptResult.ok) {
throw decryptResult.error || new Error("Failed to decrypt backup");
}
const decryptedStr = decryptResult.plaintext;
const normalized = parseSubscriptionPlaintext(decryptedStr);
// Update local state
this.subscribedPubkeys = new Set(normalized);
this.saveToCache(userPubkey);
// Publish as current list
await this.publishSubscriptionList(userPubkey);
this.emitter.emit("change", {
action: "restore",
subscribedPubkeys: Array.from(this.subscribedPubkeys),
});
await this.refreshActiveFeed({ reason: "restore-backup" });
}
async rebroadcastBackup(event, userPubkey) {
if (!event || !userPubkey) return;
const dTag = event.tags.find(t => t[0] === 'd')?.[1];
if (!dTag) throw new Error("Invalid backup event: missing d tag");
let signer = getActiveSigner();
if (!signer) {
signer = await nostrClient.ensureActiveSignerForPubkey(userPubkey);
}
const newEvent = {
kind: event.kind,
created_at: Math.floor(Date.now() / 1000),
tags: event.tags,
content: event.content,
pubkey: userPubkey
};
const signedEvent = await signer.signEvent(newEvent);
await publishEventToRelays(
nostrClient.pool,
sanitizeRelayList(nostrClient.writeRelays),
signedEvent
);
return signedEvent;
}
convertEventToVideo(evt) {
return sharedConvertEventToVideo(evt);
}
+32
View File
@@ -43,6 +43,7 @@ import {
getLinkPreviewSettings,
setLinkPreviewAutoFetch,
} from "../utils/linkPreviewSettings.js";
import { SubscriptionHistoryController } from "./subscriptionHistoryController.js";
const noop = () => {};
@@ -1436,6 +1437,7 @@ export class ProfileModalController {
this.addAccountButtonState = null;
this.adminEmptyMessages = new Map();
this.hashtagPreferencesUnsubscribe = null;
this.subscriptionHistoryController = new SubscriptionHistoryController();
if (
this.subscriptionsService &&
@@ -1622,6 +1624,10 @@ export class ProfileModalController {
document.getElementById("subscriptionsEmpty") || null;
this.profileSubscriptionsRefreshBtn =
document.getElementById("subscriptionsRefreshBtn") || null;
this.profileSubscriptionsHistoryBtn =
document.getElementById("subscriptionsHistoryBtn") || null;
this.profileSubscriptionsBackupBtn =
document.getElementById("subscriptionsBackupBtn") || null;
this.friendList = document.getElementById("friendsList") || null;
this.friendListEmpty = document.getElementById("friendsEmpty") || null;
this.friendInput = document.getElementById("friendsInput") || null;
@@ -5667,6 +5673,32 @@ export class ProfileModalController {
});
}
if (this.profileSubscriptionsHistoryBtn) {
this.profileSubscriptionsHistoryBtn.addEventListener("click", () => {
const pubkey = this.normalizeHexPubkey(this.getActivePubkey());
if (pubkey) {
this.subscriptionHistoryController.show(pubkey);
} else {
this.showError("Please log in to view subscription history.");
}
});
}
if (this.profileSubscriptionsBackupBtn) {
this.profileSubscriptionsBackupBtn.addEventListener("click", () => {
const pubkey = this.normalizeHexPubkey(this.getActivePubkey());
if (pubkey) {
if (confirm("Create a backup of your current subscription list?")) {
this.subscriptionHistoryController.handleCreateBackup(pubkey).then(() => {
this.showSuccess("Backup created.");
});
}
} else {
this.showError("Please log in to backup subscriptions.");
}
});
}
if (this.walletUriInput instanceof HTMLElement) {
this.walletUriInput.addEventListener("focus", () => {
this.revealSecretInputValue(this.walletUriInput);
+352
View File
@@ -0,0 +1,352 @@
import { subscriptions } from "../subscriptions.js";
import { profileCache } from "../state/profileCache.js";
import { formatTimeAgo } from "../utils/formatters.js";
import { userLogger } from "../utils/logger.js";
import { getApplication } from "../applicationContext.js";
export class SubscriptionHistoryController {
constructor(options = {}) {
this.modal = null;
this.activePubkey = null;
this.selectedEvent = null;
this.historyEvents = [];
this.initialized = false;
this.callbacks = {
onClose: options.onClose || (() => {}),
};
}
async load() {
if (this.initialized) return;
try {
const response = await fetch("components/subscription-history-modal.html");
if (!response.ok) throw new Error("HTML not found");
const html = await response.text();
const template = document.createElement("template");
template.innerHTML = html;
// Append to body if not already present (avoid duplicates)
if (!document.getElementById("subscriptionHistoryModal")) {
document.body.appendChild(template.content);
}
this.modal = document.getElementById("subscriptionHistoryModal");
this.bindElements();
this.initialized = true;
} catch (error) {
userLogger.error("Failed to load subscription history modal:", error);
}
}
bindElements() {
if (!this.modal) return;
this.listContainer = this.modal.querySelector("#subscriptionHistoryList");
this.detailsContainer = this.modal.querySelector(
"#subscriptionHistoryDetailsContent"
);
this.detailsEmpty = this.modal.querySelector(
"#subscriptionHistoryDetailsEmpty"
);
this.loadingIndicator = this.modal.querySelector(
"#subscriptionHistoryLoading"
);
this.emptyIndicator = this.modal.querySelector("#subscriptionHistoryEmpty");
this.decryptingIndicator = this.modal.querySelector(
"#subscriptionHistoryDecrypting"
);
this.selectedChannelsList = this.modal.querySelector(
"#subscriptionHistorySelectedChannels"
);
this.selectedDate = this.modal.querySelector(
"#subscriptionHistorySelectedDate"
);
this.selectedMeta = this.modal.querySelector(
"#subscriptionHistorySelectedMeta"
);
this.selectedBadge = this.modal.querySelector(
"#subscriptionHistorySelectedBadge"
);
this.closeBtn = this.modal.querySelector("#closeSubscriptionHistoryModal");
this.restoreBtn = this.modal.querySelector("#subscriptionHistoryRestoreBtn");
this.rebroadcastBtn = this.modal.querySelector(
"#subscriptionHistoryRebroadcastBtn"
);
this.closeBtn?.addEventListener("click", () => this.hide());
this.restoreBtn?.addEventListener("click", () => this.handleRestore());
this.rebroadcastBtn?.addEventListener("click", () =>
this.handleRebroadcast()
);
this.modal
.querySelector(".bv-modal-backdrop")
?.addEventListener("click", () => this.hide());
}
async show(pubkey) {
if (!this.initialized) await this.load();
if (!this.modal) return;
this.activePubkey = pubkey;
this.modal.classList.remove("hidden");
this.modal.setAttribute("aria-hidden", "false");
// Ensure modal-open class is added to body for scroll locking if design system requires it
document.body.classList.add("modal-open");
this.fetchHistory();
}
hide() {
if (!this.modal) return;
this.modal.classList.add("hidden");
this.modal.setAttribute("aria-hidden", "true");
// Only remove if no other modals are open, ideally.
// But for simplicity:
document.body.classList.remove("modal-open");
this.selectedEvent = null;
// Reset view
this.detailsEmpty.classList.remove("hidden");
this.detailsContainer.classList.add("hidden");
this.callbacks.onClose();
}
async fetchHistory() {
if (!this.loadingIndicator) return;
this.loadingIndicator.classList.remove("hidden");
this.listContainer.innerHTML = "";
this.emptyIndicator.classList.add("hidden");
try {
this.historyEvents = await subscriptions.fetchHistory(this.activePubkey);
this.renderList();
} catch (error) {
userLogger.error("Failed to fetch subscription history:", error);
this.emptyIndicator.textContent = "Failed to load history.";
this.emptyIndicator.classList.remove("hidden");
} finally {
this.loadingIndicator.classList.add("hidden");
}
}
renderList() {
this.listContainer.innerHTML = "";
if (!this.historyEvents.length) {
this.emptyIndicator.classList.remove("hidden");
return;
}
this.historyEvents.forEach((event) => {
const item = document.createElement("li");
item.className =
"card p-3 cursor-pointer hover:bg-surface-2 transition-colors border-l-4 border-transparent rounded-r-md";
const dTag = event.tags.find((t) => t[0] === "d")?.[1];
const isBackup = dTag && dTag.startsWith("subscriptions-backup-");
const date = new Date(event.created_at * 1000);
const dateStr = date.toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
const timeAgo = formatTimeAgo(event.created_at);
item.innerHTML = `
<div class="flex justify-between items-start">
<div>
<p class="text-sm font-medium text-text">${
isBackup ? "Backup" : "Standard List"
}</p>
<p class="text-xs text-muted">${dateStr}</p>
</div>
<span class="text-xs text-muted whitespace-nowrap ml-2">${timeAgo}</span>
</div>
`;
item.addEventListener("click", () => {
this.listContainer.querySelectorAll("li").forEach((li) => {
li.classList.remove("bg-surface-2", "border-accent");
li.classList.add("border-transparent");
});
item.classList.add("bg-surface-2", "border-accent");
item.classList.remove("border-transparent");
this.selectEvent(event);
});
this.listContainer.appendChild(item);
});
}
async selectEvent(event) {
this.selectedEvent = event;
this.detailsEmpty.classList.add("hidden");
this.detailsContainer.classList.remove("hidden");
this.selectedChannelsList.innerHTML = "";
this.decryptingIndicator.classList.remove("hidden");
const date = new Date(event.created_at * 1000);
this.selectedDate.textContent = date.toLocaleString(undefined, {
dateStyle: "full",
timeStyle: "short",
});
const dTag = event.tags.find((t) => t[0] === "d")?.[1];
const isBackup = dTag?.startsWith("subscriptions-backup-");
this.selectedBadge.textContent = isBackup ? "Backup" : "Active / Standard";
this.selectedBadge.dataset.variant = isBackup ? "accent" : "surface";
try {
const decryptResult = await subscriptions.decryptSubscriptionEvent(
event,
this.activePubkey
);
if (!decryptResult.ok) {
throw new Error("Decryption failed");
}
let pubkeys = [];
try {
const parsed = JSON.parse(decryptResult.plaintext);
if (Array.isArray(parsed)) {
// [['p', hex], ...]
pubkeys = parsed
.filter((t) => Array.isArray(t) && t[0] === "p")
.map((t) => t[1]);
}
} catch (e) {
userLogger.warn("Failed to parse plaintext JSON", e);
}
this.selectedMeta.textContent = `${pubkeys.length} channels`;
this.renderChannels(pubkeys);
} catch (error) {
userLogger.error("Failed to decrypt event:", error);
this.selectedChannelsList.innerHTML = `<p class="text-critical text-sm text-center w-full mt-4">Decryption failed or content is invalid.</p>`;
this.selectedMeta.textContent = "Unknown content";
} finally {
this.decryptingIndicator.classList.add("hidden");
}
}
async renderChannels(pubkeys) {
this.selectedChannelsList.innerHTML = "";
const app = getApplication();
if (app && typeof app.batchFetchProfiles === "function") {
try {
await app.batchFetchProfiles(pubkeys);
} catch (e) {
userLogger.warn("Failed to batch fetch profiles", e);
}
}
pubkeys.forEach((pubkey) => {
const profile = profileCache.getProfile(pubkey);
const name = profile?.name || profile?.display_name || pubkey.slice(0, 8);
const avatar = profile?.picture || "assets/svg/default-profile.svg";
const item = document.createElement("li");
item.className =
"flex items-center gap-3 p-2 rounded-md hover:bg-surface-2 border border-transparent hover:border-border/50 transition-colors";
const img = document.createElement("img");
img.src = avatar;
img.className = "w-8 h-8 rounded-full object-cover bg-surface-2";
img.onerror = function () {
this.src = "assets/svg/default-profile.svg";
};
const div = document.createElement("div");
div.className = "overflow-hidden";
const pName = document.createElement("p");
pName.className = "text-sm font-medium truncate text-text";
pName.textContent = name;
const pKey = document.createElement("p");
pKey.className = "text-xs text-muted truncate font-mono";
pKey.textContent = `${pubkey.slice(0, 10)}...`;
div.appendChild(pName);
div.appendChild(pKey);
item.appendChild(img);
item.appendChild(div);
this.selectedChannelsList.appendChild(item);
});
}
async handleRestore() {
if (!this.selectedEvent) return;
if (
!confirm(
"Are you sure you want to restore this list? It will overwrite your current subscriptions."
)
)
return;
this.restoreBtn.disabled = true;
this.restoreBtn.textContent = "Restoring...";
try {
await subscriptions.restoreBackup(this.selectedEvent, this.activePubkey);
// alert("Subscriptions restored successfully!"); // alert blocks, toast is better if available, but alert is fine for now
this.hide();
} catch (error) {
userLogger.error("Restore failed:", error);
alert("Failed to restore: " + error.message);
} finally {
this.restoreBtn.disabled = false;
this.restoreBtn.textContent = "Restore as Active";
}
}
async handleRebroadcast() {
if (!this.selectedEvent) return;
this.rebroadcastBtn.disabled = true;
this.rebroadcastBtn.textContent = "Broadcasting...";
try {
await subscriptions.rebroadcastBackup(
this.selectedEvent,
this.activePubkey
);
// Refresh to show potentially new event
this.fetchHistory();
} catch (error) {
userLogger.error("Rebroadcast failed:", error);
alert("Failed to rebroadcast: " + error.message);
} finally {
this.rebroadcastBtn.disabled = false;
this.rebroadcastBtn.textContent = "Rebroadcast";
}
}
async handleCreateBackup(pubkey) {
if (!pubkey) return;
try {
await subscriptions.createBackup(pubkey);
// If modal is open, refresh
if (
this.modal &&
!this.modal.classList.contains("hidden") &&
this.activePubkey === pubkey
) {
this.fetchHistory();
} else {
// Show success feedback
// Using a simple alert or console log if UI controller doesn't pass a toast function
// userLogger.info("Backup created");
}
} catch (error) {
userLogger.error("Backup failed:", error);
alert("Backup failed: " + error.message);
}
}
}