From 8e6c5fbb06a87bce0f4c0806139d1c1f5f99b6fc Mon Sep 17 00:00:00 2001 From: Donaldino7712 <100215778+Donaldino7712@users.noreply.github.com> Date: Wed, 26 Mar 2025 11:02:33 +0100 Subject: [PATCH] feat(spotifyBackup): rework gist backup --- README.md | 14 +- spotifyBackup/README.md | 6 +- spotifyBackup/spotifyBackup.js | 275 +++++++++++++++++++-------------- 3 files changed, 165 insertions(+), 130 deletions(-) diff --git a/README.md b/README.md index 981318a..ab5d03e 100644 --- a/README.md +++ b/README.md @@ -3,51 +3,39 @@ Please check out the readme for each extension in its folder, it will include installation instructions, usage, and compatibility notes. [![](https://data.jsdelivr.com/v1/package/gh/ohitstom/spicetify-extensions/badge)](https://www.jsdelivr.com/package/gh/ohitstom/spicetify-extensions) -## [spotifyBackup](spotifyBackup/README.md) +## [spotifyBackup](spotifyBackup/README.md) ![Example](spotifyBackup/example.png) - ## [gamepad](gamepad/README.md) - ![Example](gamepad/example.png) ## [immersiveView](immersiveView/README.md) - ![Example](immersiveView/example.png) ## [pixelatedImages](pixelatedImages/README.md) - ![Example](pixelatedImages/example.png) ## [noControls](noControls/README.md) - ![Example](noControls/example.png) ## [playbarClock](playbarClock/README.md) - ![Example](playbarClock/example.png) ## [quickQueue](quickQueue/README.md) - ![Example](quickQueue/example.png) ## [scannables](scannables/README.md) - ![Example](scannables/example.png) ## [sleepTimer](sleepTimer/README.md) - ![Example](sleepTimer/example.png) ## [toggleDJ](toggleDJ/README.md) - ![Example](toggleDJ/example.png) ## [tracksToEdges](tracksToEdges/README.md) - ![Example](tracksToEdges/example.png) ## [volumePercentage](volumePercentage/README.md) - ![Example](volumePercentage/example.png) diff --git a/spotifyBackup/README.md b/spotifyBackup/README.md index b8faffc..8b68dc9 100644 --- a/spotifyBackup/README.md +++ b/spotifyBackup/README.md @@ -15,11 +15,11 @@ _This script allows you to backup / restore application data (settings)._ Navigate to Spotify settings, scroll to "Storage", backup your settings to clipboard - and import them on your secondary device. #### Gist Integration Setup -This part of the extension allows the user to automatically backup and restore to a cloud source - in this example GitHubs Gists. -Please note Gist has a limited file upload of 1MB, if your application data exceeds this value please manually backup and restore your data. +This extension also allows the user to automatically backup and restore to a GitHub Gist. +Please note that Gist has a limited file upload of 1MB. If your application data exceeds this value please manually backup and restore your data. ##### Github Token -There should be no security issues with storing your GitHub access token in localstorage assuming you setup permissions to the token correctly, however if you want to be on the safe side make a throw away GitHub account. +There should be no security issues with storing your GitHub access token in local storage assuming you setup permissions to the token correctly, however if you want to be on the safe side make a throw away GitHub account. 1. Head over to https://github.com/settings/tokens 2. Generate a new **classic** token: diff --git a/spotifyBackup/spotifyBackup.js b/spotifyBackup/spotifyBackup.js index 0489b37..86e1397 100644 --- a/spotifyBackup/spotifyBackup.js +++ b/spotifyBackup/spotifyBackup.js @@ -11,48 +11,68 @@ // Settings Config let config = JSON.parse(localStorage.getItem("spotifyBackup:settings") || "{}"); + let gist = { + data: null, + lastUpdate: null + }; function getConfig(key) { return config[key] ?? null; } function setConfig(key, value, message) { - if (value !== getConfig(key)) { - console.debug(`[spotifyBackup-Config]: ${message ?? key + " ="}`, value); - config[key] = value; - localStorage.setItem("spotifyBackup:settings", JSON.stringify(config)); - } + if (value === getConfig(key)) return; + console.debug(`[spotifyBackup-Config]: ${message ?? key + " ="}`, value); + config[key] = value; + localStorage.setItem("spotifyBackup:settings", JSON.stringify(config)); } // Time related functions - function getCurrentTimestamp() { - return new Date().toISOString(); - } - function timeSince(timestamp) { return new Date() - new Date(timestamp); } - function getLastBackupTimestamp() { - return getConfig("lastBackupTimestamp"); + function setLastBackupTime(time) { + setConfig("lastBackupTimestamp", time ?? new Date().toISOString(), "Last backup timestamp"); } - function setLastBackupTimestamp(timestamp) { - setConfig("lastBackupTimestamp", timestamp, "Last backup timestamp"); - } + // Get the Gist data + async function getGist(force = false) { + // Return null if Gist Integration is disabled + if (!getConfig("gistEnabled")) return { data: null, lastUpdate: null }; + // Return cached data if the last update was less than a minute ago + if (timeSince(gist.lastUpdate) < 60 * 1000 && !force) return gist; - // Backup / Restore functions - async function performBackup() { - if (!getConfig("gistEnabled")) { - Spicetify.Platform.ClipboardAPI.copy(localStorage) - .then(() => { - Spicetify.showNotification("Backup Data Copied"); - setLastBackupTimestamp(getCurrentTimestamp()); - }) - .catch(() => { - Spicetify.showNotification("Failed to backup data.", true); - }); + const response = await fetch(`https://api.github.com/gists/${getConfig("gistId")}`, { + headers: { + Authorization: `token ${getConfig("gistToken")}` + } + }); + + let parsedData = null, + lastUpdate = null; + if (response.ok) { + const data = await response.json(); + lastUpdate = data.updated_at; + parsedData = JSON.parse(data.files["spotify-backup.json"].content); } else { + Spicetify.showNotification("Failed to retrieve Gist content", true); + console.error("Gist API responded with status:", response.status); + } + if (parsedData) gist = { data: parsedData, lastUpdate }; + return gist; + } + + async function backupData() { + // A hacky way to remove the backup settings from the backup + const data = JSON.stringify({ ...localStorage, "spotifyBackup:settings": undefined }); + if (getConfig("gistEnabled")) { + // Check if the data is the same as the last backup + gist = await getGist(); + if (data === JSON.stringify(gist.data)) { + Spicetify.showNotification("There is nothing to backup"); + return; + } try { const response = await fetch(`https://api.github.com/gists/${getConfig("gistId")}`, { method: "PATCH", @@ -63,82 +83,51 @@ body: JSON.stringify({ files: { "spotify-backup.json": { - content: JSON.stringify(localStorage) + content: data } } }) }); if (response.ok) { Spicetify.showNotification("Backup to Gist successful"); - setLastBackupTimestamp(getCurrentTimestamp()); + setLastBackupTime(); } else { Spicetify.showNotification("Failed to backup to Gist", true); + console.error("Gist API responded with status:", response.status); } } catch (error) { Spicetify.showNotification("Failed to backup to Gist", true); - } - } - } - - function checkAndPerformBackup() { - const backupInterval = getConfig("backupInterval"); - if (backupInterval === "off" || !getConfig("gistEnabled")) return; - - const lastBackupTimestamp = getLastBackupTimestamp(); - const now = new Date(); - - if (backupInterval === "startup") { - performBackup(); - } else if (backupInterval === "daily") { - if (!lastBackupTimestamp || timeSince(lastBackupTimestamp) > 24 * 60 * 60 * 1000) { - performBackup(); - } - } - } - - async function handleRestore() { - if (!getConfig("gistEnabled")) { - try { - let parsedBackupData = JSON.parse(await Spicetify.Platform.ClipboardAPI.paste()); - restoreData(parsedBackupData); - } catch (error) { - Spicetify.showNotification("Failed to restore data.", true); - console.error("Local restore failed:", error); + console.error("Error while backing up to Gist:", error); } } else { - try { - const response = await fetch(`https://api.github.com/gists/${getConfig("gistId")}`, { - headers: { - Authorization: `token ${getConfig("gistToken")}` - } + Spicetify.Platform.ClipboardAPI.copy(data) + .then(() => { + Spicetify.showNotification("Copied backup data to clipboard"); + setLastBackupTime(); + }) + .catch(error => { + Spicetify.showNotification("Failed to backup data", true); + console.error("Error while copying data:", error); }); - - if (response.ok) { - const data = await response.json(); - const gistContent = data.files["spotify-backup.json"].content; - let parsedBackupData = JSON.parse(gistContent); - restoreData(parsedBackupData); - Spicetify.showNotification("Restore from Gist successful"); - } else { - Spicetify.showNotification("Failed to retrieve Gist content", true); - console.error("Gist API responded with status:", response.status); - } - } catch (error) { - Spicetify.showNotification("Failed to restore from Gist", true); - console.error("Gist restore failed:", error); - } } } - function restoreData(parsedBackupData) { + async function restoreData() { + gist = await getGist(); + const data = getConfig("gistEnabled") ? gist.data : JSON.parse(await Spicetify.Platform.ClipboardAPI.paste()); + if (!data) { + Spicetify.showNotification("No backup data found", true); + return; + } try { localStorage.clear(); - for (let key in parsedBackupData) { - if (parsedBackupData.hasOwnProperty(key)) { - localStorage.setItem(key, parsedBackupData[key]); - } + for (let key in data) { + // Don't restore the backup settings + if (key === "spotifyBackup:settings") continue; + if (data.hasOwnProperty(key)) localStorage.setItem(key, data[key]); } Spicetify.showNotification("Data restored successfully"); + setLastBackupTime(gist.lastUpdate); window.location.reload(); } catch (error) { Spicetify.showNotification("Failed to restore data.", true); @@ -146,6 +135,34 @@ } } + async function checkForNewBackup() { + if (!getConfig("gistEnabled")) return false; + gist = await getGist(); + const lastUpdate = new Date(gist.lastUpdate); + const lastBackup = new Date(getConfig("lastBackupTimestamp")); + return lastUpdate > lastBackup; + } + + async function startupCheck() { + const backupInterval = getConfig("backupInterval"); + if (backupInterval === "off" || !getConfig("gistEnabled")) return; + + const lastBackupTimestamp = getConfig("lastBackupTimestamp"); + + if ( + backupInterval === "startup" || + (backupInterval === "daily" && (!lastBackupTimestamp || timeSince(lastBackupTimestamp) > 24 * 60 * 60 * 1000)) + ) { + if (await checkForNewBackup()) { + // If the local data is older than the Gist data, restore it + restoreData(); + } else { + // If the local data is newer than the Gist data, back it up + backupData(); + } + } + } + // Basic dialog component const Dialog = Spicetify.React.memo(props => { const [state, setState] = Spicetify.React.useState(true); @@ -168,14 +185,23 @@ }; Spicetify.React.useEffect(() => { - if (state) { - props.onOpen?.(); - } + if (state) props.onOpen?.(); }, [state]); return isForwardRef ? ConfirmDialog(commonProps) : Spicetify.React.createElement(ConfirmDialog, commonProps); }); + function showDialog(props) { + Spicetify.ReactDOM.render( + Spicetify.React.createElement( + Spicetify.ReactComponent.RemoteConfigProvider, + { configuration: Spicetify.Platform.RemoteConfiguration }, + Spicetify.React.createElement(Dialog, props) + ), + document.createElement("div") + ); + } + // Create our own section matching Spotify's const Section = Spicetify.React.memo(() => { const [localStorageSize, setLocalStorageSize] = Spicetify.React.useState(""); @@ -270,7 +296,7 @@ style: { marginRight: "8px" }, - onClick: async () => await performBackup() + onClick: async () => await backupData() }, "Backup" ), @@ -280,24 +306,49 @@ className: "Button-buttonSecondary-small-useBrowserDefaultFocusStyle Button-small-buttonSecondary-useBrowserDefaultFocusStyle Button-small-buttonSecondary-isUsingKeyboard-useBrowserDefaultFocusStyle Button-buttonSecondary-small-isUsingKeyboard-useBrowserDefaultFocusStyle encore-text-body-small-bold x-settings-button", "data-encore-id": "buttonSecondary", + style: { + marginRight: "8px" + }, onClick: async () => { - Spicetify.ReactDOM.render( - Spicetify.React.createElement( - Spicetify.ReactComponent.RemoteConfigProvider, - { configuration: Spicetify.Platform.RemoteConfiguration }, - Spicetify.React.createElement(Dialog, { - titleText: "Are you sure?", - descriptionText: "This will overwrite all your current settings!", - cancelText: "Cancel", - confirmText: "Restore", - onConfirm: handleRestore - }) - ), - document.createElement("div") - ); + showDialog({ + titleText: "Are you sure?", + descriptionText: "This will overwrite all your current settings!", + cancelText: "Cancel", + confirmText: "Restore", + onConfirm: async () => await restoreData() + }); } }, "Restore" + ), + Spicetify.React.createElement( + "button", + { + className: + "Button-buttonSecondary-small-useBrowserDefaultFocusStyle Button-small-buttonSecondary-useBrowserDefaultFocusStyle Button-small-buttonSecondary-isUsingKeyboard-useBrowserDefaultFocusStyle Button-buttonSecondary-small-isUsingKeyboard-useBrowserDefaultFocusStyle encore-text-body-small-bold x-settings-button", + "data-encore-id": "buttonSecondary", + disabled: !gistEnabled, + onClick: async () => { + if (await checkForNewBackup()) { + showDialog({ + titleText: "New backup found!", + descriptionText: "Would you like to restore the backup?\nThis will overwrite all your current settings!", + cancelText: "Cancel", + confirmText: "Restore", + onConfirm: async () => await restoreData() + }); + } else { + showDialog({ + titleText: "No new backup found!", + descriptionText: "Would you like to backup your current settings?", + cancelText: "Cancel", + confirmText: "Backup", + onConfirm: async () => await backupData() + }); + } + } + }, + "Check" ) ]) ] @@ -328,7 +379,7 @@ className: "encore-text encore-text-body-small encore-internal-color-text-subdued", "data-encore-id": "text" }, - `Remote storage for your data (check readme for more info)` + "Remote storage for your data (see README for more info)" ) ]) ), @@ -368,7 +419,7 @@ Spicetify.React.createElement( "label", { className: "encore-text encore-text-body-small encore-internal-color-text-subdued", "data-encore-id": "text" }, - "Backup Interval" + "Check Interval" ) ), Spicetify.React.createElement( @@ -422,17 +473,14 @@ if (name !== "/preferences") return; const checkHeaderInterval = setInterval(() => { - const sections = document.querySelectorAll(".x-settings-section"); - - sections.forEach(section => { - if (section.firstChild.textContent === Spicetify.Locale._dictionary["desktop.settings.storage"]) { - clearInterval(checkHeaderInterval); - - const sectionContainer = document.createElement("div"); - sectionContainer.className = "x-settings-section"; - Spicetify.ReactDOM.render(Spicetify.React.createElement(Section), sectionContainer); - section.parentNode.insertBefore(sectionContainer, section.nextSibling); - } + document.querySelectorAll(".x-settings-section").forEach(section => { + if (section.firstChild.textContent !== Spicetify.Locale._dictionary["desktop.settings.storage"]) return; + + clearInterval(checkHeaderInterval); + const sectionContainer = document.createElement("div"); + sectionContainer.className = "x-settings-section"; + Spicetify.ReactDOM.render(Spicetify.React.createElement(Section), sectionContainer); + section.parentNode.insertBefore(sectionContainer, section.nextSibling); }); }, 1); } @@ -440,9 +488,8 @@ // Hotload useEffect Spicetify.ReactDOM.render(Spicetify.React.createElement(Section), document.createElement("div")); - // Initialize + Listener - checkAndPerformBackup(); - insertOption(Spicetify.Platform.History.location?.pathname); + // Init + startupCheck(); Spicetify.Platform.History.listen(event => { insertOption(event.pathname); });