/// /// /// /// /// /// /// /// /// /// /// /* eslint-disable no-redeclare, no-unused-vars */ /** @type {React} */ const react = Spicetify.React; /** @type {import("react-dom")} */ const reactDOM = Spicetify.ReactDOM; const { URI, React: { useState, useEffect, useCallback }, // @ts-ignore Platform: { History }, } = Spicetify; /* eslint-enable no-redeclare, no-unused-vars */ // eslint-disable-next-line no-redeclare const LOCALSTORAGE_KEYS = { "installedExtensions": "marketplace:installed-extensions", "installedSnippets": "marketplace:installed-snippets", "installedThemes": "marketplace:installed-themes", "activeTab": "marketplace:active-tab", "tabs": "marketplace:tabs", "sortBy": "marketplace:sort-by", // Theme installed store the localsorage key of the theme (e.g. marketplace:installed:NYRI4/Comfy-spicetify/user.css) "themeInstalled": "marketplace:theme-installed", }; // Define a function called "render" to specify app entry point // This function will be used to mount app to main view. // eslint-disable-next-line no-unused-vars function render() { const { location } = Spicetify.Platform.History; // If page state set to display readme, render it // (This location state data comes from Card.openReadme()) if (location.pathname === "/spicetify-marketplace/readme") { return react.createElement(ReadmePage, { title: "Spicetify Marketplace - Readme", data: location.state.data, }); } // Otherwise, render the main Grid else { return react.createElement(Grid, { title: "Spicetify Marketplace" }); } } // Initalize topbar tabs // Data initalized in TabBar.js // eslint-disable-next-line no-redeclare const ALL_TABS = [ { name: "Extensions", enabled: true }, { name: "Themes", enabled: true }, { name: "Snippets", enabled: true }, { name: "Installed", enabled: true }, ]; let tabsString = localStorage.getItem(LOCALSTORAGE_KEYS.tabs); let tabs = []; try { tabs = JSON.parse(tabsString); if (!Array.isArray(tabs)) { throw new Error("Could not parse marketplace tabs key"); } else if (tabs.length === 0) { throw new Error("Empty marketplace tabs key"); } else if (tabs.filter(tab => !tab).length > 0) { throw new Error("Falsey marketplace tabs key"); } } catch { tabs = ALL_TABS; localStorage.setItem(LOCALSTORAGE_KEYS.tabs, JSON.stringify(tabs)); } // Get active theme let schemes = []; let activeScheme = null; try { const installedThemeKey = localStorage.getItem(LOCALSTORAGE_KEYS.themeInstalled); if (installedThemeKey) { const installedThemeDataStr = localStorage.getItem(installedThemeKey); if (!installedThemeDataStr) throw new Error("No installed theme data"); const installedTheme = JSON.parse(installedThemeDataStr); schemes = installedTheme.schemes; activeScheme = installedTheme.activeScheme; } else { console.log("No theme set as installed"); } } catch (err) { console.error(err); } // eslint-disable-next-line no-redeclare const CONFIG = { // Fetch the settings and set defaults. Used in Settings.js visual: { stars: JSON.parse(localStorage.getItem("marketplace:stars")) ?? true, tags: JSON.parse(localStorage.getItem("marketplace:tags")) ?? true, hideInstalled: JSON.parse(localStorage.getItem("marketplace:hideInstalled")) ?? false, colorShift: JSON.parse(localStorage.getItem("marketplace:colorShift")) ?? false, // Legacy from reddit app... type: JSON.parse(localStorage.getItem("marketplace:type")) ?? false, // I was considering adding watchers as "followers" but it looks like the value is a duplicate // of stargazers, and the subscribers_count isn't returned in the main API call we make // https://github.community/t/bug-watchers-count-is-the-duplicate-of-stargazers-count/140865/4 followers: JSON.parse(localStorage.getItem("marketplace:followers")) ?? false, }, tabs, activeTab: localStorage.getItem(LOCALSTORAGE_KEYS.activeTab), theme: { activeThemeKey: localStorage.getItem(LOCALSTORAGE_KEYS.themeInstalled) || null, schemes, activeScheme, }, }; if (!CONFIG.activeTab || !CONFIG.tabs.filter(tab => tab.name === CONFIG.activeTab).length) { CONFIG.activeTab = CONFIG.tabs[0].name; } // Fetches the sorting options, fetched from SortBox.js // eslint-disable-next-line no-redeclare let sortConfig = { by: localStorage.getItem(LOCALSTORAGE_KEYS.sortBy) || "top", }; let cardList = []; let endOfList = false; let lastScroll = 0; let requestQueue = []; let requestPage = null; // Max GitHub API items per page // https://docs.github.com/en/rest/reference/search#search-repositories const ITEMS_PER_REQUEST = 100; let BLACKLIST = []; // eslint-disable-next-line no-redeclare, no-unused-vars let gridUpdateTabs, gridUpdatePostsVisual; class Grid extends react.Component { constructor(props) { super(props); Object.assign(this, props); this.state = { cards: [], tabs: CONFIG.tabs, rest: true, endOfList: endOfList, schemes: CONFIG.theme.schemes, activeScheme: CONFIG.theme.activeScheme, activeThemeKey: CONFIG.theme.activeThemeKey, }; } // TODO: should I put this in Grid state? getInstalledTheme() { const installedThemeKey = localStorage.getItem(LOCALSTORAGE_KEYS.themeInstalled); if (!installedThemeKey) return null; const installedThemeDataStr = localStorage.getItem(installedThemeKey); if (!installedThemeDataStr) return null; const installedTheme = JSON.parse(installedThemeDataStr); return installedTheme; } newRequest(amount) { cardList = []; const queue = []; requestQueue.unshift(queue); this.loadAmount(queue, amount); } /** * @param {Object} item * @param {"extension" | "theme" | "snippet"} type The type of card */ appendCard(item, type) { item.visual = CONFIG.visual; // Set key prop so items don't get stuck when switching tabs item.key = `${CONFIG.activeTab}:${item.title}`; item.type = type; // Pass along the functions to update Grid state on apply item.updateColourSchemes = this.updateColourSchemes.bind(this); item.updateActiveTheme = this.setActiveTheme.bind(this); // This isn't used other than to trigger a re-render item.activeThemeKey = this.state.activeThemeKey; cardList.push(react.createElement(Card, item)); this.setState({ cards: cardList }); } // TODO: this isn't currently used, but it will be used for sorting (based on the SortBox component) updateSort(sortByValue) { if (sortByValue) { sortConfig.by = sortByValue; localStorage.setItem(LOCALSTORAGE_KEYS.sortBy, sortByValue); } requestPage = null; cardList = []; this.setState({ cards: [], rest: false, endOfList: false, }); endOfList = false; this.newRequest(ITEMS_PER_REQUEST); } updateTabs() { this.setState({ tabs: [...CONFIG.tabs], }); } updatePostsVisual() { cardList = cardList.map(card => { return react.createElement(Card, card.props); }); this.setState({ cards: [...cardList] }); } switchTo(value) { CONFIG.activeTab = value; localStorage.setItem(LOCALSTORAGE_KEYS.activeTab, value); cardList = []; requestPage = null; this.setState({ cards: [], rest: false, endOfList: false, }); endOfList = false; this.newRequest(ITEMS_PER_REQUEST); } // This is called from loadAmount in a loop until it has the requested amount of cards or runs out of results // Returns the next page number to fetch, or null if at end // TODO: maybe we should rename `loadPage()`, since it's slightly confusing when we have github pages as well async loadPage(queue) { if (CONFIG.activeTab === "Extensions") { let pageOfRepos = await getExtensionRepos(requestPage); for (const repo of pageOfRepos.items) { let extensions = await fetchExtensionManifest(repo.contents_url, repo.default_branch, repo.stargazers_count); // I believe this stops the requests when switching tabs? if (requestQueue.length > 1 && queue !== requestQueue[0]) { // Stop this queue from continuing to fetch and append to cards list return -1; } if (extensions && extensions.length) { // console.log(`${repo.name} has ${extensions.length} extensions:`, extensions); extensions.forEach((extension) => this.appendCard(extension, "extension")); } } // First result is null or -1 so it coerces to 1 const currentPage = requestPage > -1 && requestPage ? requestPage : 1; // Sets the amount of items that have thus been fetched const soFarResults = ITEMS_PER_REQUEST * (currentPage - 1) + pageOfRepos.page_count; const remainingResults = pageOfRepos.total_count - soFarResults; // If still have more results, return next page number to fetch console.log(`Parsed ${soFarResults}/${pageOfRepos.total_count} extensions`); if (remainingResults > 0) return currentPage + 1; else console.log("No more extension results"); } else if (CONFIG.activeTab === "Installed") { const installedStuff = { theme: getLocalStorageDataFromKey(LOCALSTORAGE_KEYS.installedThemes, []), extension: getLocalStorageDataFromKey(LOCALSTORAGE_KEYS.installedExtensions, []), snippet: getLocalStorageDataFromKey(LOCALSTORAGE_KEYS.installedSnippets, []), }; for (const type in installedStuff) { if (installedStuff[type].length) { installedStuff[type].forEach(async (itemKey) => { // TODO: err handling const extension = JSON.parse(localStorage.getItem(itemKey)); // I believe this stops the requests when switching tabs? if (requestQueue.length > 1 && queue !== requestQueue[0]) { // Stop this queue from continuing to fetch and append to cards list return -1; } // @ts-ignore this.appendCard(extension, type); }); } } // Don't need to return a page number because // installed extension do them all in one go, since it's local } else if (CONFIG.activeTab == "Themes") { let pageOfRepos = await getThemeRepos(requestPage); for (const repo of pageOfRepos.items) { let themes = await fetchThemeManifest(repo.contents_url, repo.default_branch, repo.stargazers_count); // I believe this stops the requests when switching tabs? if (requestQueue.length > 1 && queue !== requestQueue[0]) { // Stop this queue from continuing to fetch and append to cards list return -1; } if (themes && themes.length) { themes.forEach((theme) => this.appendCard(theme, "theme")); } } // First request is null, so coerces to 1 const currentPage = requestPage > -1 && requestPage ? requestPage : 1; // -1 because the page number is 1-indexed const soFarResults = ITEMS_PER_REQUEST * (currentPage - 1) + pageOfRepos.page_count; const remainingResults = pageOfRepos.total_count - soFarResults; console.log(`Parsed ${soFarResults}/${pageOfRepos.total_count} themes`); if (remainingResults > 0) return currentPage + 1; else console.log("No more theme results"); } else if (CONFIG.activeTab == "Snippets") { let snippets = await fetchCssSnippets(); if (requestQueue.length > 1 && queue !== requestQueue[0]) { // Stop this queue from continuing to fetch and append to cards list return -1; } if (snippets && snippets.length) { snippets.forEach((snippet) => this.appendCard(snippet, "snippet")); } } this.setState({ rest: true, endOfList: true }); endOfList = true; return null; } /** * Load a new set of extensions * @param {any} queue An array of the extensions to be loaded * @param {number} [quantity] Amount of extensions to be loaded per page. (Defaults to ITEMS_PER_REQUEST constant) */ async loadAmount(queue, quantity = ITEMS_PER_REQUEST) { this.setState({ rest: false }); quantity += cardList.length; requestPage = await this.loadPage(queue); while ( requestPage && requestPage !== -1 && cardList.length < quantity && !this.state.endOfList ) { requestPage = await this.loadPage(queue); } if (requestPage === -1) { requestQueue = requestQueue.filter(a => a !== queue); return; } // Remove this queue from queue list requestQueue.shift(); this.setState({ rest: true }); } /** * Load more items if there are more items to load. * @returns {void} */ loadMore() { if (this.state.rest && !endOfList) { this.loadAmount(requestQueue[0], ITEMS_PER_REQUEST); } } /** * Update the colour schemes in the state + dropdown, and inject the active one * @param {any} schemes Object with the colour schemes * @param {string} activeScheme The name of the active colour scheme (a key in the schemes object) */ updateColourSchemes(schemes, activeScheme) { console.log("updateColourSchemes", schemes, activeScheme); CONFIG.theme.schemes = schemes; CONFIG.theme.activeScheme = activeScheme; if (schemes && schemes[activeScheme]) { this.injectColourScheme(CONFIG.theme.schemes[activeScheme]); } else { // Reset schemes if none sent this.injectColourScheme(null); } // Save to localstorage const installedThemeKey = localStorage.getItem(LOCALSTORAGE_KEYS.themeInstalled); const installedThemeDataStr = localStorage.getItem(installedThemeKey); const installedThemeData = JSON.parse(installedThemeDataStr); installedThemeData.activeScheme = activeScheme; localStorage.setItem(installedThemeKey, JSON.stringify(installedThemeData)); this.setState({ schemes, activeScheme, }); } // NOTE: Keep in sync with extension.js injectColourScheme (scheme) { // Remove any existing Spicetify scheme const existingColorsCSS = document.querySelector("link[href='colors.css']"); if (existingColorsCSS) existingColorsCSS.remove(); // Remove any existing marketplace scheme const existingMarketplaceSchemeCSS = document.querySelector("style.marketplaceCSS.marketplaceScheme"); if (existingMarketplaceSchemeCSS) existingMarketplaceSchemeCSS.remove(); if (scheme) { // Add new marketplace scheme const schemeTag = document.createElement("style"); schemeTag.classList.add("marketplaceCSS"); schemeTag.classList.add("marketplaceScheme"); let injectStr = ":root {"; const themeIniKeys = Object.keys(scheme); themeIniKeys.forEach((key) => { injectStr += `--spice-${key}: #${scheme[key]};`; injectStr += `--spice-rgb-${key}: ${hexToRGB(scheme[key])};`; }); injectStr += "}"; schemeTag.innerHTML = injectStr; document.head.appendChild(schemeTag); } else { // Re-add default user.css let originalColorsCSS = document.createElement("link"); originalColorsCSS.setAttribute("rel", "stylesheet"); originalColorsCSS.setAttribute("href", "colors.css"); originalColorsCSS.classList.add("userCSS"); document.head.appendChild(originalColorsCSS); } } /** * The componentDidMount() method is called when the component is first loaded. * It checks if the cardList is already loaded. If it is, it checks if the lastScroll value is greater than 0. * If it is, it scrolls to the lastScroll value. If it isn't, it scrolls to the top of the page. * If the cardList isn't loaded, it loads the cardList. */ async componentDidMount() { gridUpdateTabs = this.updateTabs.bind(this); gridUpdatePostsVisual = this.updatePostsVisual.bind(this); const viewPort = document.querySelector(".os-viewport"); this.checkScroll = this.isScrolledBottom.bind(this); viewPort.addEventListener("scroll", this.checkScroll); if (cardList.length) { // Already loaded if (lastScroll > 0) { viewPort.scrollTo(0, lastScroll); } return; } // Load blacklist BLACKLIST = await getBlacklist(); this.newRequest(ITEMS_PER_REQUEST); } /** * When the component is unmounted, remove the scroll event listener. * @returns {void} */ componentWillUnmount() { gridUpdateTabs = gridUpdatePostsVisual = null; const viewPort = document.querySelector(".os-viewport"); lastScroll = viewPort.scrollTop; viewPort.removeEventListener("scroll", this.checkScroll); } /** * If the user has scrolled to the bottom of the page, load more posts. * @param event - The event object that is passed to the callback function. * @returns {void} */ isScrolledBottom(event) { const viewPort = event.target; if ((viewPort.scrollTop + viewPort.clientHeight) >= viewPort.scrollHeight) { // At bottom, load more posts this.loadMore(); } } setActiveTheme(themeKey) { CONFIG.theme.activeThemeKey = themeKey; this.setState({ activeThemeKey: themeKey }); } // TODO: clean this up. It worked when I was using state, but state seems like pointless overhead. getActiveScheme() { return this.state.activeScheme; } render() { return react.createElement("section", { className: "contentSpacing", }, react.createElement("div", { className: "marketplace-header", }, react.createElement("h1", null, this.props.title), // Start of marketplace-header__right react.createElement("div", { className: "marketplace-header__right", }, // Show colour scheme dropdown if there is a theme with schemes installed this.state.activeScheme ? react.createElement(SortBox, { onChange: (value) => this.updateColourSchemes(this.state.schemes, value), // TODO: Make this compatible with the changes to the theme install process: need to create a method to update the scheme options without a full reload. sortBoxOptions: generateSchemesOptions(this.state.schemes), // It doesn't work when I directly use CONFIG.theme.activeScheme in the sortBySelectedFn // because it hardcodes the value into the fn sortBySelectedFn: (a) => a.key === this.getActiveScheme(), }) : null, react.createElement("button", { className: "marketplace-settings-button", id: "marketplace-settings-button", onClick: openConfig, }, SETTINGS_ICON), // End of marketplace-header__right ), // TODO: Add search bar and sort functionality // react.createElement("div", { // className: "searchbar--bar__wrapper", // }, react.createElement("input", { // className: "searchbar-bar", // type: "text", // placeholder: "Search for Extensions?", // })), ), [ // Add a header and grid for each card type if it has any cards { handle: "extension", name: "Extensions" }, { handle: "theme", name: "Themes" }, { handle: "snippet", name: "Snippets" }, ].map((cardType) => { const cardsOfType = cardList.filter((card) => card.props.type === cardType.handle) .map((card) => { // Clone the cards and update the prop to trigger re-render // TODO: is it possible to only re-render the theme cards whose status have changed? const cardElement = react.cloneElement(card, { activeThemeKey: this.state.activeThemeKey, }); return cardElement; }); if (cardsOfType.length) { return [ // Add a header for the card type react.createElement("h2", { className: "marketplace-card-type-heading" }, cardType.name), // Add the grid and cards react.createElement("div", { className: "marketplace-grid main-gridContainer-gridContainer main-gridContainer-fixedWidth", "data-tab": CONFIG.activeTab, style: { "--minimumColumnWidth": "180px", "--column-width": "minmax(var(--minimumColumnWidth),1fr)", "--column-count": "auto-fill", "--grid-gap": "24px", }, }, cardsOfType)]; } else { return null; } }), react.createElement("footer", { style: { margin: "auto", textAlign: "center", }, }, !this.state.endOfList && (this.state.rest ? react.createElement(LoadMoreIcon, { onClick: this.loadMore.bind(this) }) : react.createElement(LoadingIcon)), // Add snippets button if on snippets tab CONFIG.activeTab === "Snippets" ? react.createElement("button", { className: "marketplace-add-snippet-btn main-buttons-button main-button-secondary", onClick: openAddSnippetModal, }, "+Add CSS") : null, ), react.createElement(TopBarContent, { switchCallback: this.switchTo.bind(this), links: CONFIG.tabs, activeLink: CONFIG.activeTab, })); } } // TODO: add sort type, order, etc? // https://docs.github.com/en/github/searching-for-information-on-github/searching-on-github/searching-for-repositories#search-by-topic // https://docs.github.com/en/rest/reference/search#search-repositories /** * Query GitHub for all repos with the "spicetify-extensions" topic * @param {number} page The query page number * @returns Array of search results (filtered through the blacklist) */ async function getExtensionRepos(page = 1) { // www is needed or it will block with "cross-origin" error. let url = `https://api.github.com/search/repositories?q=${encodeURIComponent("topic:spicetify-extensions")}&per_page=${ITEMS_PER_REQUEST}`; // We can test multiple pages with this URL (58 results), as well as broken iamges etc. // let url = `https://api.github.com/search/repositories?q=${encodeURIComponent("topic:spicetify")}`; if (page) url += `&page=${page}`; // Sorting params (not implemented for Marketplace yet) // if (sortConfig.by.match(/top|controversial/) && sortConfig.time) { // url += `&t=${sortConfig.time}` const allRepos = await fetch(url).then(res => res.json()).catch(() => []); if (!allRepos.items) { Spicetify.showNotification("Too Many Requests, Cool Down."); } const filteredResults = { ...allRepos, // Include count of all items on the page, since we're filtering the blacklist below, // which can mess up the paging logic page_count: allRepos.items.length, items: allRepos.items.filter(item => !BLACKLIST.includes(item.html_url)), }; return filteredResults; } // TODO: add try/catch here? // TODO: can we add a return type here? /** * Get the manifest object for a repo * @param {string} user Owner username * @param {string} repo Repo name * @param {string} branch Default branch name (e.g. main or master) * @returns The manifest object */ async function getRepoManifest(user, repo, branch) { const sessionStorageItem = window.sessionStorage.getItem(`${user}-${repo}`); const failedSessionStorageItems = window.sessionStorage.getItem("noManifests"); if (sessionStorageItem) { return JSON.parse(sessionStorageItem); } const url = `https://raw.githubusercontent.com/${user}/${repo}/${branch}/manifest.json`; if (failedSessionStorageItems.includes(url)) { return null; } const manifest = await fetch(url).then(res => res.json()).catch(() => addToSessionStorage([url], "noManifests")); if (manifest) { window.sessionStorage.setItem(`${user}-${repo}`, JSON.stringify(manifest)); } return manifest; } // TODO: can we add a return type here? /** * Fetch extensions from a repo and format data for generating cards * @param {string} contents_url The repo's GitHub API contents_url (e.g. "https://api.github.com/repos/theRealPadster/spicetify-hide-podcasts/contents/{+path}") * @param {string} branch The repo's default branch (e.g. main or master) * @param {number} stars The number of stars the repo has * @returns Extension info for card (or null) */ async function fetchExtensionManifest(contents_url, branch, stars) { try { // TODO: use the original search full_name ("theRealPadster/spicetify-hide-podcasts") or something to get the url better? let manifests; const regex_result = contents_url.match(/https:\/\/api\.github\.com\/repos\/(?.+)\/(?.+)\/contents/); // TODO: err handling? if (!regex_result || !regex_result.groups) return null; const { user, repo } = regex_result.groups; manifests = await getRepoManifest(user, repo, branch); // If the manifest returned is not an array, initialize it as one if (!Array.isArray(manifests)) manifests = [manifests]; // Manifest is initially parsed const parsedManifests = manifests.reduce((accum, manifest) => { const selectedBranch = manifest.branch || branch; const item = { manifest, title: manifest.name, subtitle: manifest.description, authors: processAuthors(manifest.authors, user), user, repo, branch: selectedBranch, imageURL: manifest.preview && manifest.preview.startsWith("http") ? manifest.preview : `https://raw.githubusercontent.com/${user}/${repo}/${selectedBranch}/${manifest.preview}`, extensionURL: manifest.main.startsWith("http") ? manifest.main : `https://raw.githubusercontent.com/${user}/${repo}/${selectedBranch}/${manifest.main}`, readmeURL: manifest.readme && manifest.readme.startsWith("http") ? manifest.readme : `https://raw.githubusercontent.com/${user}/${repo}/${selectedBranch}/${manifest.readme}`, stars, tags: manifest.tags, }; // If manifest is valid, add it to the list if (manifest && manifest.name && manifest.description && manifest.main ) { // Add to list unless we're hiding installed items and it's installed if (!(CONFIG.visual.hideInstalled && localStorage.getItem("marketplace:installed:" + `${user}/${repo}/${manifest.main}`)) ) { accum.push(item); } } // else { // console.error("Invalid manifest:", manifest); // } return accum; }, []); return parsedManifests; } catch (err) { // console.warn(contents_url, err); return null; } } // TODO: can we add a return type here? /** * Fetch themes from a repo and format data for generating cards * @param {string} contents_url The repo's GitHub API contents_url (e.g. "https://api.github.com/repos/theRealPadster/spicetify-hide-podcasts/contents/{+path}") * @param {string} branch The repo's default branch (e.g. main or master) * @param {number} stars The number of stars the repo has * @returns Extension info for card (or null) */ async function fetchThemeManifest(contents_url, branch, stars) { try { let manifests; const regex_result = contents_url.match(/https:\/\/api\.github\.com\/repos\/(?.+)\/(?.+)\/contents/); // TODO: err handling? if (!regex_result || !regex_result.groups) return null; let { user, repo } = regex_result.groups; manifests = await getRepoManifest(user, repo, branch); // If the manifest returned is not an array, initialize it as one if (!Array.isArray(manifests)) manifests = [manifests]; // Manifest is initially parsed const parsedManifests = manifests.reduce((accum, manifest) => { const selectedBranch = manifest.branch || branch; const item = { manifest, title: manifest.name, subtitle: manifest.description, authors: processAuthors(manifest.authors, user), user, repo, branch: selectedBranch, imageURL: manifest.preview && manifest.preview.startsWith("http") ? manifest.preview : `https://raw.githubusercontent.com/${user}/${repo}/${selectedBranch}/${manifest.preview}`, readmeURL: manifest.readme && manifest.readme.startsWith("http") ? manifest.readme : `https://raw.githubusercontent.com/${user}/${repo}/${selectedBranch}/${manifest.readme}`, stars, tags: manifest.tags, // theme stuff cssURL: manifest.usercss.startsWith("http") ? manifest.usercss : `https://raw.githubusercontent.com/${user}/${repo}/${selectedBranch}/${manifest.usercss}`, // TODO: clean up indentation etc schemesURL: manifest.schemes ? ( manifest.schemes.startsWith("http") ? manifest.schemes : `https://raw.githubusercontent.com/${user}/${repo}/${selectedBranch}/${manifest.schemes}` ) : null, include: manifest.include, }; // If manifest is valid, add it to the list if (manifest && manifest.name && manifest.usercss && manifest.description) { accum.push(item); } return accum; }, []); return parsedManifests; } catch (err) { // console.warn(contents_url, err); return null; } } /** * Query the GitHub API for a page of theme repos (having "spicetify-themes" topic) * @param {number} page The page to get (defaults to 1) * @returns Array of GitHub API results, filtered through the blacklist */ async function getThemeRepos(page = 1) { let url = `https://api.github.com/search/repositories?q=${encodeURIComponent("topic:spicetify-themes")}&per_page=${ITEMS_PER_REQUEST}`; // We can test multiple pages with this URL (58 results), as well as broken iamges etc. // let url = `https://api.github.com/search/repositories?q=${encodeURIComponent("topic:spicetify")}`; if (page) url += `&page=${page}`; // Sorting params (not implemented for Marketplace yet) // if (sortConfig.by.match(/top|controversial/) && sortConfig.time) { // url += `&t=${sortConfig.time}` const allThemes = await fetch(url).then(res => res.json()).catch(() => []); if (!allThemes.items) { Spicetify.showNotification("Too Many Requests, Cool Down."); } const filteredResults = { ...allThemes, // Include count of all items on the page, since we're filtering the blacklist below, // which can mess up the paging logic page_count: allThemes.items.length, items: allThemes.items.filter(item => !BLACKLIST.includes(item.html_url)), }; return filteredResults; } function addToSessionStorage(items, key) { if (!items || items == null) return; items.forEach(item => { if (!key) key = `${items.user}-${items.repo}`; // If the key already exists, it will append to it instead of overwriting it const existing = window.sessionStorage.getItem(key); const parsed = existing ? JSON.parse(existing) : []; parsed.push(item); window.sessionStorage.setItem(key, JSON.stringify(parsed)); }); }