// ==UserScript== // @name Nexus Download Collection++ // @namespace NDC // @version 1.0.2 // @description Download every mod in a collection from one panel // @author 1Tdd // @license MIT // @match https://www.nexusmods.com/* // @icon https://www.google.com/s2/favicons?sz=64&domain=nexusmods.com // @compatible chrome // @compatible edge // @compatible firefox // @compatible safari // @compatible brave // @grant GM.setValue // @grant GM.getValue // @grant GM_addStyle // @downloadURL https://update.greasyfork.icu/scripts/563176/Nexus%20Download%20Collection%2B%2B.user.js // @updateURL https://update.greasyfork.icu/scripts/563176/Nexus%20Download%20Collection%2B%2B.meta.js // ==/UserScript== GM_addStyle(` .bottom-auto { bottom: auto; } .left-auto { left: auto; } .right-0 { right: 0px; } .top-0 { top: 0px; } .translate-y-\\[2rem\\] { --tw-translate-y: 2rem; transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); } .min-h-7 { min-height: 1.75rem; } .w-11 { width: 2.75rem; } .w-20{ width:5rem; } .w-32 { width: 8rem; } .w-52 { width: 13rem; } .text-green-600 { --tw-text-opacity: 1; color: rgb(22 163 74 / var(--tw-text-opacity, 1)); } .text-red-600 { --tw-text-opacity: 1; color: rgb(220 38 38 / var(--tw-text-opacity, 1)) } .text-sky-500 { --tw-text-opacity: 1; color: rgb(14 165 233 / var(--tw-text-opacity, 1)); } .backdrop-blur-sm { backdrop-filter: blur(3px); } .backdrop-brightness-50 { backdrop-filter: brightness(50%); } @media (min-width: 768px) { .sm\\:rounded-none { border-radius: 0; } .sm\\:gap-0 { gap: 0; } .sm\\:w-52 { width: 13rem; } .sm\\:justify-start { justify-content: flex-start; } } .bg-ndc-orange { background-color: #FA933C !important; color: #0f0f10 !important; fill: #0f0f10 !important; } .bg-ndc-orange:hover { background-color: #fb923c !important; } `); const convertSize = (sizeInKB) => { const sizeInMB = sizeInKB / 1024; const sizeInGB = sizeInMB / 1024; return sizeInGB >= 1 ? `${sizeInGB.toFixed(2)} GB` : `${sizeInMB.toFixed(2)} MB`; }; const CONSTANTS = { DOWNLOAD_PAUSE_BASE: 5, // Base pause (s) DOWNLOAD_SPEED_EST: 1.5, // Est. speed (MB/s) RATE_LIMIT_THRESHOLD: 200, // Anti-ban limit RATE_LIMIT_PAUSE: 300, // Cooldown (s) RETRY_MAX_ATTEMPTS: 3, // Max retries RETRY_DELAY_MS: 2000, // Base retry delay (ms) HISTORY_KEY: "history", LAUNCHED_DOWNLOAD_KEY: "launchedDownload", API_URL_GRAPHQL: "https://api-router.nexusmods.com/graphql", API_URL_DOWNLOAD_GEN: "https://www.nexusmods.com/Core/Libs/Common/Managers/Downloads?GenerateDownloadUrl", SELECTOR_MAIN_CONTENT: "#mainContent > div > div.relative > div.next-container", }; class NDC { mods = { all: [], mandatory: [], optional: [], }; constructor(gameId, collectionId, revision = null) { this.element = document.createElement("div"); this.element.classList.add('bg-surface-low', 'w-full', 'space-y-3', 'rounded-lg', 'p-4', 'mt-4'); this.gameId = gameId; this.collectionId = collectionId; this.revision = revision; this.pauseBetweenDownload = CONSTANTS.DOWNLOAD_PAUSE_BASE; this.downloadSpeed = CONSTANTS.DOWNLOAD_SPEED_EST; this.downloadMethod = NDCDownloadButton.DOWNLOAD_METHOD_VORTEX; this.downloadButton = new NDCDownloadButton(this); this.progressBar = new NDCProgressBar(this); this.console = new NDCLogConsole(this); } async init() { this.pauseBetweenDownload = await GM.getValue("pauseBetweenDownload", CONSTANTS.DOWNLOAD_PAUSE_BASE); this.downloadSpeed = await GM.getValue("downloadSpeed", CONSTANTS.DOWNLOAD_SPEED_EST); this.downloadMethod = await GM.getValue( "downloadMethod", NDCDownloadButton.DOWNLOAD_METHOD_VORTEX, ); this.element.innerHTML = ` `; const response = await this.fetchMods(); if (!response) { this.element.innerHTML = '
Failed to fetch mods list
'; return; } const mods = response.modFiles.sort((a, b) => a.file.mod.name.localeCompare(b.file.mod.name), ); const mandatoryMods = mods.filter((mod) => !mod.optional); const optionalMods = mods.filter((mod) => mod.optional); this.mods = { all: [...mandatoryMods, ...optionalMods], mandatory: mandatoryMods, optional: optionalMods, }; this.downloadButton.render(); this.element.innerHTML = ""; this.element.appendChild(this.downloadButton.element); this.element.appendChild(this.progressBar.element); this.element.appendChild(this.console.element); } async fetchMods(collectionId = this.collectionId, revision = this.revision) { const response = await fetch(CONSTANTS.API_URL_GRAPHQL, { headers: { "content-type": "application/json", }, referrer: document.location.href, referrerPolicy: "strict-origin-when-cross-origin", body: JSON.stringify({ query: "query CollectionRevisionMods ($revision: Int, $slug: String!, $viewAdultContent: Boolean) { collectionRevision (revision: $revision, slug: $slug, viewAdultContent: $viewAdultContent) { modFiles { fileId, optional, file { fileId, name, uri, size, version, date, mod { adult, modId, name, version, game { domainName, id } } } } } }", variables: { slug: collectionId, viewAdultContent: true, revision: revision }, operationName: "CollectionRevisionMods", }), method: "POST", mode: "cors", credentials: "include", }); if (!response.ok) { return; } const json = await response.json(); if (!json.data.collectionRevision) { return; } json.data.collectionRevision.modFiles = json.data.collectionRevision.modFiles.map((modFile) => { modFile.file.url = `https://www.nexusmods.com/${modFile.file.mod.game.domainName}/mods/${modFile.file.mod.modId}?tab=files&file_id=${modFile.file.fileId}`; return modFile; }); return json.data.collectionRevision; } normalizeImportMatchText(value) { let text = String(value || ""); try { text = decodeURIComponent(text); } catch { // Some filenames contain stray percent characters. Use the raw text. } return text .toLowerCase() .replace(/\\/g, "/") .replace(/[^a-z0-9]+/g, " ") .trim(); } compactImportMatchText(value) { return this.normalizeImportMatchText(value).replace(/\s+/g, ""); } getImportedFileGroupKey(file) { const relativePath = (file.webkitRelativePath || "").replace(/\\/g, "/"); if (!relativePath) return file.name; const parts = relativePath.split("/").filter(Boolean); const modsIndex = parts.findIndex((part) => part.toLowerCase() === "mods"); if (modsIndex !== -1 && parts[modsIndex + 1]) { return parts.slice(0, modsIndex + 2).join("/"); } if (parts.length > 1) { return parts[0]; } return relativePath; } shouldIgnoreImportedFile(file) { const path = `${file.name} ${file.webkitRelativePath || ""}`.toLowerCase(); return path.includes("vortex.deployment.msgpack") || path.includes("__vortex_staging_folder") || file.name.toLowerCase() === "collection.json"; } formatNotMatchedImportedFiles(files) { return [ ...new Set( Array.from(files || []).map((file) => this.getImportedFileGroupKey(file)), ), ].join("\n"); } importedFileMatchesMod(file, mod) { const filePath = `${file.name} ${file.webkitRelativePath || ""}`; const fileText = this.normalizeImportMatchText(filePath); const fileCompact = fileText.replace(/\s+/g, ""); const fileId = String(mod.file.fileId); const modNeedles = [ mod.file.uri, mod.file.name, ] .map((value) => this.compactImportMatchText(value)) .filter((value) => value.length >= 5); if (modNeedles.some((needle) => fileCompact.includes(needle))) { return true; } return new RegExp(`(^|[^0-9])${fileId}([^0-9]|$)`).test(filePath); } matchDownloadedFiles(files) { const fileList = Array.from(files || []); const downloadedModsById = new Map(); const matchedFiles = new Set(); const groupedFiles = fileList.reduce((groups, file) => { const groupKey = this.getImportedFileGroupKey(file); groups[groupKey] ??= []; groups[groupKey].push(file); return groups; }, {}); for (const groupFiles of Object.values(groupedFiles)) { let matchedMod = null; for (const mod of this.mods.all) { if (groupFiles.some((file) => this.importedFileMatchesMod(file, mod))) { matchedMod = mod; break; } } if (matchedMod) { downloadedModsById.set(matchedMod.file.fileId, matchedMod); groupFiles.forEach((file) => matchedFiles.add(file)); } } return { downloadedMods: Array.from(downloadedModsById.values()), notMatchedFiles: fileList.filter( (file) => !matchedFiles.has(file) && !this.shouldIgnoreImportedFile(file), ), }; } getVortexDownloadPageUrl(mod) { return `${mod.file.url}&nmm=1`; } decodeDownloadUrl(url) { if (!url) return ""; return url .trim() .replace(/\\\//g, "/") .replace(/\\u0026/gi, "&") .replace(/&/g, "&") .replace(/&/g, "&") .replace(/"/g, '"') .replace(/'/g, "'"); } extractVortexDownloadUrl(text) { if (!text) return ""; const patterns = [ /\bdownloadUrl\s*[:=]\s*['"`]([^'"`]+)['"`]/i, /\blocation(?:\.href)?\s*=\s*['"`]([^'"`]+)['"`]/i, /['"`](nxm:\/\/[^'"`]+)['"`]/i, /data-download-url\s*=\s*["']([^"']+)["']/i, /data-nxm-url\s*=\s*["']([^"']+)["']/i, /href\s*=\s*["'](nxm:\/\/[^"']+)["']/i, ]; for (const pattern of patterns) { const match = text.match(pattern); const downloadUrl = this.decodeDownloadUrl(match?.[1]); if (downloadUrl) return downloadUrl; } try { const doc = new DOMParser().parseFromString(text, "text/html"); const downloadAttributes = [ "data-download-url", "data-nxm-url", "data-url", "href", ]; for (const attribute of downloadAttributes) { const elements = doc.querySelectorAll(`[${attribute}]`); for (const element of elements) { const downloadUrl = this.decodeDownloadUrl(element.getAttribute(attribute)); if (downloadUrl.startsWith("nxm://")) return downloadUrl; } } } catch (error) { console.warn("[NDC] Unable to parse Vortex download page:", error); } return ""; } async fetchGeneratedDownloadUrl(mod) { const params = new URLSearchParams({ fid: mod.file.fileId, game_id: mod.file.mod.game.id }); const response = await fetch(CONSTANTS.API_URL_DOWNLOAD_GEN, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8" }, body: params }); if (!response.ok) throw new Error("Failed to generate manual download URL"); const data = await response.json(); return data?.url || ""; } buildVortexDownloadUrl(mod, generatedDownloadUrl) { if (!generatedDownloadUrl) return ""; try { const url = new URL(generatedDownloadUrl); const key = url.searchParams.get("key") || url.searchParams.get("md5"); const expires = url.searchParams.get("expires"); const userId = url.searchParams.get("user_id"); if (!key || !expires || !userId) return ""; const gameDomain = mod.file.mod.game.domainName; const modId = mod.file.mod.modId; const fileId = mod.file.fileId; const params = new URLSearchParams({ key, expires, user_id: userId }); return `nxm://${gameDomain}/mods/${modId}/files/${fileId}?${params.toString()}`; } catch (error) { console.warn("[NDC] Unable to build Vortex download URL:", error); return ""; } } isBlockedDownloadPage(text) { return text && ( text.includes('class="replaced-login-link"') || text.includes("Just a moment...") || text.includes("Your access to Nexus Mods has been temporarily suspended") ); } launchVortexDownload(downloadUrl) { if (downloadUrl.startsWith("nxm://")) { window.location.href = downloadUrl; return; } const iframe = document.createElement("iframe"); iframe.style.display = "none"; iframe.src = downloadUrl; document.body.appendChild(iframe); setTimeout(() => iframe.remove(), 30000); } async fetchDownloadLink(mod) { this.bypassNexusAdsCookie(); const getUrl = async () => { const url = this.downloadMethod === NDCDownloadButton.DOWNLOAD_METHOD_VORTEX ? this.getVortexDownloadPageUrl(mod) : mod.file.url; const res = await fetch(url); if (!res.ok) throw new Error(`HTTP Error ${res.status}`); return await res.text(); }; const retryOperation = async (operation, attempts = CONSTANTS.RETRY_MAX_ATTEMPTS) => { for (let i = 0; i < attempts; i++) { try { return await operation(); } catch (err) { if (i === attempts - 1) throw err; const delay = CONSTANTS.RETRY_DELAY_MS * Math.pow(2, i); // Exponential backoff console.warn(`[NDC] Network hiccup. Retrying in ${delay}ms...`); await new Promise(r => setTimeout(r, delay)); } } }; try { const text = await retryOperation(getUrl); let downloadUrl = ""; if (this.downloadMethod === NDCDownloadButton.DOWNLOAD_METHOD_VORTEX) { downloadUrl = this.extractVortexDownloadUrl(text); if (!downloadUrl && !this.isBlockedDownloadPage(text)) { const generatedDownloadUrl = await this.fetchGeneratedDownloadUrl(mod); downloadUrl = this.buildVortexDownloadUrl(mod, generatedDownloadUrl); } if (!downloadUrl && !this.isBlockedDownloadPage(text)) { downloadUrl = this.getVortexDownloadPageUrl(mod); } } else { downloadUrl = await this.fetchGeneratedDownloadUrl(mod); } return { downloadUrl, text }; } catch (error) { console.error("[NDC] Failed to fetch download link:", error); const downloadUrl = this.downloadMethod === NDCDownloadButton.DOWNLOAD_METHOD_VORTEX ? this.getVortexDownloadPageUrl(mod) : ""; return { downloadUrl, text: "", error }; } } bypassNexusAdsCookie() { const now = Math.round(Date.now() / 1000); const expirySeconds = 5 * 60; // 5 minutes in seconds const expiryTimestamp = now + expirySeconds; const expiryDate = new Date(Date.now() + expirySeconds * 1000).toUTCString(); document.cookie = `ab=0|${expiryTimestamp};expires=${expiryDate};domain=nexusmods.com;path=/`; } async downloadMods(mods, type = null) { this.startDownload(mods.length); let history = type ? await GM.getValue(CONSTANTS.HISTORY_KEY, {}) : null; if (history) { history[this.gameId] ??= {}; history[this.gameId][this.collectionId] ??= {}; history[this.gameId][this.collectionId][type] ??= []; const previouslyDownloaded = history[this.gameId][this.collectionId][type]; if (previouslyDownloaded.length > 0) { const skip = await Promise.resolve(window.confirm( `${previouslyDownloaded.length} mods are already saved in history.\n\nOK = skip those mods and download only the missing ones.\nCancel = ignore history and download everything again.` )); if (!skip) { history[this.gameId][this.collectionId][type] = []; await GM.setValue(CONSTANTS.HISTORY_KEY, history); } } } const launchedDownload = await GM.getValue(CONSTANTS.LAUNCHED_DOWNLOAD_KEY, { count: 0, date: Date.now(), }); const failedDownload = []; let forceStop = false; for (const [index, mod] of mods.entries()) { const modNumber = `[${index + 1}/${mods.length}]`; // Format: [1/50] if (launchedDownload.date < Date.now() - (CONSTANTS.RATE_LIMIT_PAUSE * 1000)) { launchedDownload.count = 0; } if (history?.[this.gameId][this.collectionId][type]?.includes(mod.file.fileId)) { this.console.log(`${modNumber} Skipping (Already Downloaded): ${mod.file.name}`, NDCLogConsole.TYPE_INFO); this.progressBar.incrementProgress(); continue; } if (this.progressBar.skipTo) { if (this.progressBar.skipToIndex - 1 > index) { this.console.log(`${modNumber} Skipping (User Request): ${mod.file.name}`, NDCLogConsole.TYPE_INFO); this.progressBar.incrementProgress(); if (this.progressBar.skipToIndex - 1 === index + 1) this.progressBar.skipTo = false; continue; } this.progressBar.skipTo = false; } if (this.progressBar.status === NDCProgressBar.STATUS_STOPPED) { this.console.log("Download Process Stopped by User.", NDCLogConsole.TYPE_INFO); break; } try { const { downloadUrl, text } = await this.fetchDownloadLink(mod); if (!downloadUrl) { if (text && text.includes('class="replaced-login-link"')) { this.console.log("Error: Not logged in! Please login to NexusMods.", NDCLogConsole.TYPE_ERROR); forceStop = true; } else if (text && text.includes("Just a moment...")) { this.console.log("Cloudflare Check detected. Please solve captcha in the opened tab.", NDCLogConsole.TYPE_ERROR); window.open(mod.file.url, '_blank'); forceStop = true; } else if (text && text.includes("Your access to Nexus Mods has been temporarily suspended")) { this.console.log("Nexus Rate Limit Hit! Pausing for 10 mins recommended.", NDCLogConsole.TYPE_ERROR); forceStop = true; } else { this.console.log(`${modNumber} Failed to get link for ${mod.file.name}`, NDCLogConsole.TYPE_ERROR); failedDownload.push(mod); } } else { if (this.downloadMethod === NDCDownloadButton.DOWNLOAD_METHOD_VORTEX) { this.console.log(`${modNumber} Vortex: ${mod.file.name} (${convertSize(mod.file.size)})`, NDCLogConsole.TYPE_SUCCESS); this.launchVortexDownload(downloadUrl); } else { this.console.log(`${modNumber} Browser: ${mod.file.name} (${convertSize(mod.file.size)})`, NDCLogConsole.TYPE_SUCCESS); const a = document.createElement("a"); a.href = downloadUrl; a.download = mod.file.name; a.click(); } this.progressBar.incrementProgress(); if (history) { history[this.gameId][this.collectionId][type].push(mod.file.fileId); } launchedDownload.count++; launchedDownload.date = Date.now(); if (index % 5 === 0 || index === mods.length - 1) { await GM.setValue(CONSTANTS.LAUNCHED_DOWNLOAD_KEY, launchedDownload); if (history) await GM.setValue(CONSTANTS.HISTORY_KEY, history); } } } catch (err) { this.console.log(`${modNumber} Exception: ${err.message}`, NDCLogConsole.TYPE_ERROR); failedDownload.push(mod); } if (forceStop) break; if (index < mods.length - 1) { if (launchedDownload.count >= CONSTANTS.RATE_LIMIT_THRESHOLD) { this.console.log(`Safety limit reached (${CONSTANTS.RATE_LIMIT_THRESHOLD} mods). Pausing for 5 minutes.`, NDCLogConsole.TYPE_WARN); await this.waitWithProgress(CONSTANTS.RATE_LIMIT_PAUSE); launchedDownload.count = 0; await GM.setValue(CONSTANTS.LAUNCHED_DOWNLOAD_KEY, launchedDownload); } const sizeDelay = Math.round(mod.file.size / 1024 / this.downloadSpeed); const pauseDuration = this.pauseBetweenDownload === 0 ? 0 : sizeDelay + this.pauseBetweenDownload; if (pauseDuration > 0) { await this.waitWithProgress(pauseDuration); } } } // End Loop if (history) await GM.setValue(CONSTANTS.HISTORY_KEY, history); if (failedDownload.length) { this.console.log(`Completed with ${failedDownload.length} failures. Retry manually:`, NDCLogConsole.TYPE_WARN); failedDownload.forEach(m => this.console.log(`${m.file.name}`, NDCLogConsole.TYPE_INFO)); } else { this.console.log("All downloads processed successfully!", NDCLogConsole.TYPE_SUCCESS); } this.endDownload(); } async waitWithProgress(seconds) { let remaining = seconds; let logRow = null; while (remaining > 0) { if (this.progressBar.skipPause || this.progressBar.status === NDCProgressBar.STATUS_STOPPED) { if (logRow) logRow.remove(); this.progressBar.skipPause = false; // Reset flag return; } if (this.progressBar.status !== NDCProgressBar.STATUS_PAUSED) { const min = Math.floor(remaining / 60); const sec = remaining % 60; const msg = `Waiting ${min}m ${sec}s...`; if (!logRow) logRow = this.console.log(msg, NDCLogConsole.TYPE_INFO); else logRow.innerHTML = `${new Date().toLocaleTimeString('en-US', { hour12: false })}[INFO]${msg}`; remaining--; } await new Promise(resolve => setTimeout(resolve, 1000)); } if (logRow) logRow.remove(); } startDownload(modsCount) { this.progressBar.setModsCount(modsCount); this.progressBar.setProgress(0); this.progressBar.setStatus(NDCProgressBar.STATUS_DOWNLOADING); this.downloadButton.element.style.display = "none"; this.progressBar.element.style.display = ""; this.console.log("Download started.", NDCLogConsole.TYPE_INFO); } endDownload() { this.progressBar.setStatus(NDCProgressBar.STATUS_FINISHED); this.progressBar.element.style.display = "none"; this.downloadButton.element.style.display = ""; this.console.log("Download finished.", NDCLogConsole.TYPE_INFO); } } class NDCDownloadButton { static DOWNLOAD_METHOD_VORTEX = 0; static DOWNLOAD_METHOD_BROWSER = 1; constructor(ndc) { this.element = document.createElement("div"); this.element.classList.add("flex", "flex-col", "gap-3", "w-100"); this.ndc = ndc; this.html = `
`; this.element.innerHTML = this.html; this.downloadMethods = this.element.querySelectorAll( 'input[name="downloadOption"]', ); this.importDownloadedModsBtn = this.element.querySelector( "#importDownloadedModsBtn", ); this.importDownloadedModsBtnInfo = this.element.querySelector( "#importDownloadedModsBtnInfo", ); this.allBtn = this.element.querySelector("#mainBtn"); this.modsCount = this.element.querySelector("#mainModsCount"); this.mandatoryBtn = this.element.querySelector("#menuBtnMandatory"); this.mandatoryModsCount = this.element.querySelector( "#menuBtnMandatoryModsCount", ); this.optionalBtn = this.element.querySelector("#menuBtnOptional"); this.optionalModsCount = this.element.querySelector( "#menuBtnOptionalModsCount", ); this.selectBtn = this.element.querySelector("#menuBtnSelect"); this.updateBtn = this.element.querySelector("#menuBtnUpdate"); const menuBtn = this.element.querySelector("#menuBtn"); const otherOptionMenu = this.element.querySelector("#otherOptionMenu"); for (const option of this.downloadMethods) { option.addEventListener("change", async () => { this.ndc.downloadMethod = Number.parseInt(option.value); await GM.setValue("downloadMethod", this.ndc.downloadMethod); }); } this.importDownloadedModsBtn.addEventListener("click", () => { const input = document.createElement("input"); input.type = "file"; input.multiple = true; input.webkitdirectory = true; input.directory = true; input.addEventListener("change", async () => { const files = Array.from(input.files || []); if (files.length === 0) return; const { downloadedMods, notMatchedFiles } = this.ndc.matchDownloadedFiles(files); const downloadedModsFileIds = downloadedMods.map((mod) => mod.file.fileId); const history = await GM.getValue("history", {}); if (history[this.ndc.gameId] == null) { history[this.ndc.gameId] = {}; } const gameHistory = history[this.ndc.gameId]; if (gameHistory[this.ndc.collectionId] == null) { gameHistory[this.ndc.collectionId] = {}; } const collectionHistory = gameHistory[this.ndc.collectionId]; collectionHistory.all = [...new Set(downloadedModsFileIds)]; collectionHistory.mandatory = [ ...new Set( downloadedMods .filter((mod) => !mod.optional) .map((mod) => mod.file.fileId), ), ]; collectionHistory.optional = [ ...new Set( downloadedMods .filter((mod) => mod.optional) .map((mod) => mod.file.fileId), ), ]; await GM.setValue("history", history); alert( `Imported ${downloadedMods.length } mods to the history.\n\n${downloadedMods .map((mod) => mod.file.name) .join("\n")}`, ); if (notMatchedFiles.length && downloadedMods.length === 0) { alert( `The following folders/files are not matched with any mods:\n\n${this.ndc.formatNotMatchedImportedFiles(notMatchedFiles)}`, ); } }); input.click(); }); this.importDownloadedModsBtnInfo.addEventListener("click", () => { alert( `Use this to skip mods that are already installed.\n\nSelect your Vortex mods folder when the file picker opens. If your browser does not support folder picking, select all files inside that folder instead.\n\nYour browser may show an upload/load warning. That is normal for file pickers: the script needs permission to read filenames and match them with this collection. The script does not upload those files.\n\nBrowsers do not allow scripts to open a local folder path directly, so you need to choose it manually.\n\nDefault Vortex mods path:\nC:\\Users\\\\AppData\\Roaming\\Vortex\\${this.ndc.gameId}\\mods`, ); }); menuBtn.addEventListener("click", () => { otherOptionMenu.classList.toggle("hidden"); }); document.addEventListener("click", (event) => { const isClickInside = menuBtn.contains(event.target); if (!isClickInside) { otherOptionMenu.classList.add("hidden"); } }); this.allBtn.addEventListener("click", () => this.ndc.downloadMods(this.ndc.mods.all, "all"), ); this.mandatoryBtn.addEventListener("click", () => this.ndc.downloadMods(this.ndc.mods.mandatory, "mandatory"), ); this.optionalBtn.addEventListener("click", () => this.ndc.downloadMods(this.ndc.mods.optional, "optional"), ); this.selectBtn.addEventListener("click", () => { const selectModsModal = new NDCSelectModsModal(this.ndc); document.body.appendChild(selectModsModal.element); selectModsModal.render(); }); this.updateBtn.addEventListener("click", () => { const updateModsModal = new NDCUpdateModsModal(this.ndc); document.body.appendChild(updateModsModal.element); updateModsModal.render(); }); } updateDownloadMethod() { for (const option of this.downloadMethods) { if (Number.parseInt(option.value) === this.ndc.downloadMethod) { option.checked = true; } } } updateModsCount() { this.modsCount.innerHTML = `${this.ndc.mods.mandatory.length + this.ndc.mods.optional.length } mods`; } updateMandatoryModsCount() { this.mandatoryModsCount.innerHTML = `${this.ndc.mods.mandatory.length} mods`; } updateOptionalModsCount() { this.optionalModsCount.innerHTML = `${this.ndc.mods.optional.length} mods`; } render() { this.updateDownloadMethod(); this.updateModsCount(); this.updateMandatoryModsCount(); this.updateOptionalModsCount(); } } class NDCSelectModsModal { constructor(ndc) { this.element = document.createElement("div"); this.element.classList.add( "fixed", "top-0", "left-0", "w-full", "h-full", "z-50", "flex", "justify-center", "items-center", "bg-black/25", "backdrop-brightness-50", ); document.body.style.overflow = "hidden"; this.ndc = ndc; this.html = `

Select mods

0 mods selected
`; this.element.innerHTML = this.html; this.searchMods = this.element.querySelector("#searchMods"); this.sortMods = this.element.querySelector("#sortMods"); this.selectModsSelectAll = this.element.querySelector( "#selectModsSelectAll", ); this.selectModsInvertSelection = this.element.querySelector( "#selectModsInvertSelection", ); this.selectModsDeselectAll = this.element.querySelector( "#selectModsDeselectAll", ); this.modsListMobile = this.element.querySelector("#modsListMobile"); this.selectedModsCount = this.element.querySelector("#selectedModsCount"); this.openSelectModsOptionMenu = this.element.querySelector( "#openSelectModsOptionMenu", ); this.selectModsOptionMenu = this.element.querySelector( "#selectModsOptionMenu", ); this.exportModsSelection = this.element.querySelector( "#exportModsSelection", ); this.importModsSelection = this.element.querySelector( "#importModsSelection", ); this.selectModsImportDownloadedMods = this.element.querySelector( "#selectModsImportDownloadedMods", ); this.selectModsBtn = this.element.querySelector("#selectModsBtn"); this.cancelSelectModsBtn = this.element.querySelector( "#cancelSelectModsBtn", ); this.openSelectModsOptionMenu.addEventListener("click", () => { this.selectModsOptionMenu.classList.toggle("hidden"); }); this.selectModsBtn.addEventListener("click", () => { const selectedMods = []; for (const mod of this.ndc.mods.all) { const checkbox = this.element.querySelector(`#mod_${mod.file.fileId}`); if (checkbox.checked) { selectedMods.push(mod); } } this.close(); this.ndc.downloadMods(selectedMods); }); this.cancelSelectModsBtn.addEventListener("click", () => { this.close(); }); document.addEventListener("click", (event) => { const isClickInside = this.openSelectModsOptionMenu.contains( event.target, ); if (!isClickInside) { this.selectModsOptionMenu.classList.add("hidden"); } }); } updateModList(mods) { this.modsListMobile.innerHTML = ""; for (const [index, mod] of mods.entries()) { const modElementMobile = document.createElement("div"); modElementMobile.classList.add( "border", "border-stroke-subdued", "rounded-lg", "sm:rounded-none", "p-2", "cursor-pointer", "select-none", ); modElementMobile.innerHTML = `
#${index + 1 }
${convertSize(mod.file.size)} ${mod.optional ? "Optional" : "Mandatory" }
${mod.file.mod.name}
${mod.file.name}
`; modElementMobile.addEventListener("click", (event) => { const checkbox = modElementMobile.querySelector( 'input[type="checkbox"]', ); checkbox.checked = !checkbox.checked; const modElement = checkbox.parentNode; modElement.classList.toggle("bg-primary-subdued"); modElement .querySelector(".mod-list-index") .classList.toggle("text-white"); if (event.shiftKey && modElement.parentNode.dataset.lastChecked) { const start = Array.from(modElement.parentNode.children).indexOf( modElement, ); const end = modElement.parentNode.dataset.lastChecked; const checkedState = modElement.parentNode.children[ end ].querySelector('input[type="checkbox"]').checked; for (let i = Math.min(start, end); i <= Math.max(start, end); i++) { const modEl = modElement.parentNode.children[i]; const checkboxEl = modEl.querySelector('input[type="checkbox"]'); checkboxEl.checked = checkedState; modEl.classList.toggle("bg-primary-subdued", checkedState); modEl .querySelector(".mod-list-index") .classList.toggle("text-white", checkedState); } } const index = Array.from(modElement.parentNode.children).indexOf( modElement, ); modElement.parentNode.dataset.lastChecked = index; this.selectedModsCount.firstChild.textContent = `${this.element.querySelectorAll('input[type="checkbox"]:checked').length } mods selected`; }); this.modsListMobile.appendChild(modElementMobile); } } render() { this.updateModList(this.ndc.mods.all); this.element.addEventListener("click", (event) => { if (event.target === this.element) { this.close(); } }); this.searchMods.addEventListener("input", () => { const search = this.searchMods.value.toLowerCase(); for (const mod of this.ndc.mods.all) { const modElement = this.element.querySelector( `#mod_${mod.file.fileId}`, ).parentNode; if ( mod.file.mod.name.toLowerCase().includes(search) || mod.file.name.toLowerCase().includes(search) ) { modElement.style.display = ""; } else { modElement.style.display = "none"; } } }); this.sortMods.addEventListener("change", () => { const sort = this.sortMods.value; const mods = [...this.ndc.mods.all]; switch (sort) { case "mod_name_asc": mods.sort((a, b) => a.file.mod.name.localeCompare(b.file.mod.name)); break; case "mod_name_desc": mods.sort((a, b) => b.file.mod.name.localeCompare(a.file.mod.name)); break; case "file_name_asc": mods.sort((a, b) => a.file.name.localeCompare(b.file.name)); break; case "file_name_desc": mods.sort((a, b) => b.file.name.localeCompare(a.file.name)); break; case "size_asc": mods.sort((a, b) => a.file.size - b.file.size); break; case "size_desc": mods.sort((a, b) => b.file.size - a.file.size); break; } this.updateModList(mods); }); this.selectModsSelectAll.addEventListener("click", () => { for (const mod of this.ndc.mods.all) { const checkbox = this.element.querySelector(`#mod_${mod.file.fileId}`); checkbox.checked = true; const modElement = checkbox.parentNode; modElement.classList.add("bg-primary-subdued"); modElement.querySelector(".mod-list-index").classList.add("text-white"); } this.selectedModsCount.firstChild.textContent = `${this.ndc.mods.all.length} mods selected`; }); this.selectModsInvertSelection.addEventListener("click", () => { for (const mod of this.ndc.mods.all) { const checkbox = this.element.querySelector(`#mod_${mod.file.fileId}`); checkbox.checked = !checkbox.checked; const modElement = checkbox.parentNode; modElement.classList.toggle("bg-primary-subdued"); modElement .querySelector(".mod-list-index") .classList.toggle("text-white"); } this.selectedModsCount.firstChild.textContent = `${this.element.querySelectorAll('input[type="checkbox"]:checked').length} mods selected`; }); this.selectModsDeselectAll.addEventListener("click", () => { for (const mod of this.ndc.mods.all) { const checkbox = this.element.querySelector(`#mod_${mod.file.fileId}`); checkbox.checked = false; const modElement = checkbox.parentNode; modElement.classList.remove("bg-primary-subdued"); modElement .querySelector(".mod-list-index") .classList.remove("text-white"); } this.selectedModsCount.firstChild.textContent = "0 mods selected"; }); this.exportModsSelection.addEventListener("click", () => { if (!this.element.querySelector('input[type="checkbox"]:checked')) { alert("You must select at least one mod to export."); return; } const selectedMods = []; for (const mod of this.ndc.mods.all) { const checkbox = this.element.querySelector(`#mod_${mod.file.fileId}`); if (checkbox.checked) { selectedMods.push(mod); } } const selectedModsText = JSON.stringify(selectedMods, null, 2); const blob = new Blob([selectedModsText], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `ndc_selected_mods_${this.ndc.gameId}_${this.ndc.collectionId }_${Date.now()}.json`; a.click(); URL.revokeObjectURL(url); }); this.importModsSelection.addEventListener("click", () => { const input = document.createElement("input"); input.type = "file"; input.accept = ".json"; input.addEventListener("change", async () => { const file = input.files[0]; const reader = new FileReader(); reader.onload = async () => { const selectedMods = JSON.parse(reader.result); for (const mod of selectedMods) { const checkbox = this.element.querySelector( `#mod_${mod.file.fileId}`, ); if (checkbox == null) { continue; } checkbox.checked = true; const modElement = checkbox.parentNode; modElement.classList.add("bg-primary-subdued"); modElement .querySelector(".mod-list-index") .classList.add("text-white"); } this.selectedModsCount.firstChild.textContent = `${selectedMods.length} mods selected`; }; reader.readAsText(file); }); input.click(); }); this.selectModsImportDownloadedMods.addEventListener("click", () => { const input = document.createElement("input"); input.type = "file"; input.multiple = true; input.webkitdirectory = true; input.directory = true; input.addEventListener("change", async () => { const files = Array.from(input.files || []); if (files.length === 0) return; const { downloadedMods, notMatchedFiles } = this.ndc.matchDownloadedFiles(files); const downloadedFileIds = new Set( downloadedMods.map((mod) => mod.file.fileId), ); const notDownloadedMods = this.ndc.mods.all.filter( (mod) => !downloadedFileIds.has(mod.file.fileId), ); const notDownloadedFileIds = new Set( notDownloadedMods.map((mod) => mod.file.fileId), ); for (const modElement of this.modsListMobile.children) { const checkbox = modElement.querySelector('input[type="checkbox"]'); const modId = Number.parseInt(checkbox.id.split("_")[1]); const shouldSelect = notDownloadedFileIds.has(modId); checkbox.checked = shouldSelect; modElement.classList.toggle("bg-primary-subdued", shouldSelect); modElement .querySelector(".mod-list-index") .classList.toggle("text-white", shouldSelect); } this.selectedModsCount.firstChild.textContent = `${notDownloadedMods.length} mods selected`; if (notDownloadedMods.length === 0) { alert("All mods are already downloaded."); } else { alert( `Selected ${notDownloadedMods.length } mods that are not downloaded yet.`, ); } if (notMatchedFiles.length && downloadedMods.length === 0) { alert( `The following folders/files were not matched with this collection:\n\n${this.ndc.formatNotMatchedImportedFiles(notMatchedFiles)}`, ); } }); input.click(); }); } close() { document.body.style.overflow = ""; this.element.remove(); } } class NDCUpdateModsModal { constructor(ndc) { this.element = document.createElement("div"); this.element.classList.add( "fixed", "top-0", "left-0", "w-full", "h-full", "z-50", "flex", "justify-center", "items-center", "bg-black/25", "backdrop-brightness-50" ); document.body.style.overflow = "hidden"; this.ndc = ndc; this.html = `
`; this.element.innerHTML = this.html; this.currentCollectionRevisionBtn = this.element.querySelector("#currentCollectionRevisionBtn"); this.currentCollectionRevisionList = this.element.querySelector("#currentCollectionRevisionList"); this.newCollectionRevisionBtn = this.element.querySelector("#newCollectionRevisionBtn"); this.newCollectionRevisionList = this.element.querySelector("#newCollectionRevisionList"); this.listOfUpdates = this.element.querySelector("#listOfUpdates"); this.updateModsBtn = this.element.querySelector("#updateModsBtn"); this.cancelUpdateModsBtn = this.element.querySelector( "#cancelUpdateModsBtn", ); this.modsToDownload = []; this.currentRevisionId = null; this.newRevisionId = null; this.updateModsBtn.addEventListener("click", () => { this.ndc.downloadMods(this.modsToDownload); this.close(); }); this.cancelUpdateModsBtn.addEventListener("click", () => { this.close(); }); } async renderListOfUpdates() { if (!this.currentRevisionId || !this.newRevisionId) { this.updateModsBtn.classList.add("hidden"); return; } this.listOfUpdates.classList.remove("hidden"); this.listOfUpdates.classList.remove("overflow-auto"); this.listOfUpdates.innerHTML = `
`; this.updateModsBtn.classList.add("hidden"); const [currentRevision, newRevision] = await Promise.all([ this.ndc.fetchMods(this.ndc.collectionId, parseInt(this.currentRevisionId)), this.ndc.fetchMods(this.ndc.collectionId, parseInt(this.newRevisionId)), ]); const currentMods = currentRevision.modFiles.reduce((acc, mod) => { if (!acc[mod.file.mod.modId]) { acc[mod.file.mod.modId] = []; } acc[mod.file.mod.modId].push(mod); return acc; }, {}); const newMods = newRevision.modFiles.reduce((acc, mod) => { if (!acc[mod.file.mod.modId]) { acc[mod.file.mod.modId] = []; } acc[mod.file.mod.modId].push(mod); return acc; }, {}); const addedMods = []; const updatedMods = []; const removedMods = []; for (const [modId, newModFiles] of Object.entries(newMods)) { const currentModFiles = currentMods[modId] || []; newModFiles.forEach(newModFile => { const currentModFile = currentModFiles.find( modFile => modFile.fileId === newModFile.fileId || modFile.file.name === newModFile.file.name ); if (!currentModFile) { addedMods.push(newModFile); } else if (currentModFile.file.version !== newModFile.file.version) { updatedMods.push(newModFile); } }); const remainingCurrentModFiles = currentModFiles.filter( currentModFile => !newModFiles.some( modFile => modFile.fileId === currentModFile.fileId || modFile.file.name === currentModFile.file.name ) ); removedMods.push(...remainingCurrentModFiles); } this.modsToDownload = [...addedMods, ...updatedMods]; this.listOfUpdates.innerHTML = `

Updated Mods (${updatedMods.length} mods)

${updatedMods.map(mod => `
${mod.file.mod.name}
`).join("")}

Added Mods (${addedMods.length} mods)

${addedMods.map(mod => `
${mod.file.mod.name}
`).join("")}

information Removed Mods (${removedMods.length} mods)

${removedMods.map(mod => `
${mod.file.mod.name}
`).join("")}
`; this.listOfUpdates.classList.add("overflow-auto", "overscroll-contain"); this.updateModsBtn.classList.remove("hidden"); this.listOfUpdates.querySelector("#deletedModsInfo").addEventListener("click", () => { alert("The deleted mods is just for information, the script will not delete any mods from your collection."); }); } async fetchRevisions() { const response = await fetch("https://api-router.nexusmods.com/graphql", { headers: { "content-type": "application/json", }, referrer: document.location.href, referrerPolicy: "strict-origin-when-cross-origin", body: JSON.stringify({ query: "query CollectionRevisions ($domainName: String, $slug: String!) { collection (domainName: $domainName, slug: $slug) { revisions {adultContent, createdAt, discardedAt, id, latest, revisionNumber, revisionStatus, totalSize, modCount, collectionChangelog { description, id}, gameVersions { reference } } } }", variables: { domainName: this.ndc.gameId, slug: this.ndc.collectionId }, operationName: "CollectionRevisions", }), method: "POST", mode: "cors", credentials: "include", }); if (!response.ok) { return; } const json = await response.json(); if (!json.data.collection) { return; } return json.data.collection.revisions; } async render() { const revisions = await this.fetchRevisions(); if (!revisions) { this.element.innerHTML = `

Update collection

An error occurred while fetching the collection revisions. Please try again later.

`; return; } this.revisions = revisions; const createItem = (revision) => { const li = document.createElement("li"); const a = document.createElement("a"); a.className = "group block w-full cursor-pointer space-y-0.5 px-4 py-2.5 outline-none text-neutral-moderate text-left transition-colors hover:text-neutral-strong hover:bg-surface-translucent-mid focus-within:bg-surface-translucent-mid"; const date = new Date(revision.createdAt); const dateStr = date.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }); const sizeGB = (revision.totalSize / (1024 * 1024 * 1024)).toFixed(1) + "GB"; a.innerHTML = `

Revision ${revision.revisionNumber}

${revision.adultContent ? `Adult` : ''}

${sizeGB}

Game version ${revision.gameVersions && revision.gameVersions.length ? revision.gameVersions[0].reference : 'Unknown'}

`; li.appendChild(a); return li; }; const populateList = (listElement, btnElement, isCurrent) => { const ul = listElement.querySelector("ul"); ul.innerHTML = ""; revisions.forEach(rev => { const li = createItem(rev); li.querySelector("a").addEventListener("click", () => { this.selectRevision(rev, btnElement); listElement.classList.add("hidden"); if (isCurrent) { this.currentRevisionId = rev.revisionNumber; } else { this.newRevisionId = rev.revisionNumber; } this.renderListOfUpdates(); }); ul.appendChild(li); }); }; populateList(this.currentCollectionRevisionList, this.currentCollectionRevisionBtn, true); populateList(this.newCollectionRevisionList, this.newCollectionRevisionBtn, false); const toggleList = (list) => { const isHidden = list.classList.contains("hidden"); this.currentCollectionRevisionList.classList.add("hidden"); this.newCollectionRevisionList.classList.add("hidden"); if (isHidden) list.classList.remove("hidden"); }; this.currentCollectionRevisionBtn.addEventListener("click", (e) => { e.stopPropagation(); toggleList(this.currentCollectionRevisionList); }); this.newCollectionRevisionBtn.addEventListener("click", (e) => { e.stopPropagation(); toggleList(this.newCollectionRevisionList); }); document.addEventListener("click", (e) => { if (!this.currentCollectionRevisionList.contains(e.target) && !this.currentCollectionRevisionBtn.contains(e.target)) { this.currentCollectionRevisionList.classList.add("hidden"); } if (!this.newCollectionRevisionList.contains(e.target) && !this.newCollectionRevisionBtn.contains(e.target)) { this.newCollectionRevisionList.classList.add("hidden"); } }); this.element.querySelector(".loadingSpinner").classList.add("hidden"); this.element.querySelector(".elementBody").classList.remove("hidden"); this.element.addEventListener("click", (event) => { if (event.target === this.element) this.close(); }); } selectRevision(revision, btnElement) { const date = new Date(revision.createdAt); const dateStr = date.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' }); const sizeGB = (revision.totalSize / (1024 * 1024 * 1024)).toFixed(1) + "GB"; btnElement.innerHTML = `

Revision ${revision.revisionNumber}

${revision.adultContent ? `Adult` : ''}

${sizeGB}

Game version ${revision.gameVersions && revision.gameVersions.length ? revision.gameVersions[0].reference : 'Unknown'}

`; } close() { document.body.style.overflow = ""; this.element.remove(); } } class NDCProgressBar { static STATUS_DOWNLOADING = 0; static STATUS_PAUSED = 1; static STATUS_FINISHED = 2; static STATUS_STOPPED = 3; static STATUS_TEXT = { [NDCProgressBar.STATUS_DOWNLOADING]: "Downloading...", [NDCProgressBar.STATUS_PAUSED]: "Paused", [NDCProgressBar.STATUS_FINISHED]: "Finished", [NDCProgressBar.STATUS_STOPPED]: "Stopped", }; constructor(ndc) { this.element = document.createElement("div"); this.element.classList.add("flex", "flex-wrap", "w-100"); this.element.style.display = "none"; this.ndc = ndc; this.modsCount = 0; this.progress = 0; this.skipPause = false; this.skipTo = false; this.skipToIndex = 0; this.status = NDCProgressBar.STATUS_DOWNLOADING; this.html = `
${this.progress}%
Downloading...
${this.progress}/${this.modsCount}
information
information
information
`; this.element.innerHTML = this.html; const downloadSpeedInfo = this.element.querySelector("#downloadSpeedInfo"); const extraPauseInfo = this.element.querySelector("#extraPauseInfo"); const skipToIndexInfo = this.element.querySelector("#skipToIndexInfo"); this.progressBarFill = this.element.querySelector("#progressBarFill"); this.progressBarProgress = this.element.querySelector("#progressBarProgress"); this.progressBarTextCenter = this.element.querySelector("#progressBarTextCenter"); this.progressBarTextRight = this.element.querySelector("#progressBarTextRight"); this.playPauseBtn = this.element.querySelector("#playPauseBtn"); this.stopBtn = this.element.querySelector("#stopBtn"); this.downloadSpeedInput = this.element.querySelector("#downloadSpeedInput"); this.pauseBetweenDownloadInput = this.element.querySelector("#pauseBetweenDownloadInput"); this.skipNextBtn = this.element.querySelector("#skipNextBtn"); this.skipToIndexBtn = this.element.querySelector("#skipToIndexBtn"); this.skipToIndexInput = this.element.querySelector("#skipToIndexInput"); downloadSpeedInfo.addEventListener("click", () => { alert( `Used only to estimate the wait between downloads. It does not limit your bandwidth.\n\nEnter your real speed in megabytes per second (MB/s). If your speed test shows megabits per second (Mbps), divide by 8. Example: 100 Mbps is 12.5 MB/s.` ); }); extraPauseInfo.addEventListener("click", () => { alert( `Adds a cooldown after each download launch so Vortex has time to register the file.\n\nLower it if Vortex keeps up. Increase it if Vortex misses downloads or becomes unstable.\n\nSet to 0 to disable the extra pause.` ); }); skipToIndexInfo.addEventListener("click", () => { alert( `Use this to resume an interrupted run.\n\nEnter the mod number you want to start from, then click "Skip to index". Example: enter 50 to skip the first 49 mods.` ); }); this.playPauseBtn.addEventListener("click", () => { const status = this.status === NDCProgressBar.STATUS_DOWNLOADING ? NDCProgressBar.STATUS_PAUSED : NDCProgressBar.STATUS_DOWNLOADING; this.setStatus(status); }); this.stopBtn.addEventListener("click", () => { this.setStatus(NDCProgressBar.STATUS_STOPPED); }); this.downloadSpeedInput.addEventListener("change", async (event) => { this.ndc.downloadSpeed = Number.parseFloat(event.target.value); await GM.setValue("downloadSpeed", this.ndc.downloadSpeed); }); this.pauseBetweenDownloadInput.addEventListener("change", async (event) => { this.ndc.pauseBetweenDownload = Number.parseInt(event.target.value); await GM.setValue("pauseBetweenDownload", this.ndc.pauseBetweenDownload); }); this.skipNextBtn.addEventListener("click", () => { this.skipPause = true; this.setStatus(NDCProgressBar.STATUS_DOWNLOADING); }); this.skipToIndexBtn.addEventListener("click", () => { const index = Number.parseInt(this.skipToIndexInput.value); if (index > this.progress && index <= this.modsCount) { this.skipTo = true; this.skipToIndex = index; this.setStatus(NDCProgressBar.STATUS_DOWNLOADING); } }); } setState(newState) { Object.assign(this, newState); this.render(); } setModsCount(modsCount) { this.setState({ modsCount }); } setProgress(progress) { this.setState({ progress }); } incrementProgress() { this.setState({ progress: this.progress + 1 }); } setStatus(status) { this.setState({ status }); this.progressBarTextCenter.innerHTML = NDCProgressBar.STATUS_TEXT[status]; } getProgressPercent() { return ((this.progress / this.modsCount) * 100).toFixed(2); } updateProgressBarFillWidth() { this.progressBarFill.style.width = `${this.getProgressPercent()}%`; } updateProgressBarTextProgress() { this.progressBarProgress.innerHTML = `${this.getProgressPercent()}%`; } updateProgressBarTextRight() { this.progressBarTextRight.innerHTML = `${this.progress}/${this.modsCount}`; } updatePlayPauseBtn() { this.playPauseBtn.innerHTML = this.status === NDCProgressBar.STATUS_PAUSED ? '' : ''; } updateDownloadSpeedInput() { this.downloadSpeedInput.value = this.ndc.downloadSpeed; } updatePauseBetweenDownloadInput() { this.pauseBetweenDownloadInput.value = this.ndc.pauseBetweenDownload; } render() { this.updateProgressBarFillWidth(); this.updateProgressBarTextProgress(); this.updateProgressBarTextRight(); this.updatePlayPauseBtn(); this.updateDownloadSpeedInput(); this.updatePauseBetweenDownloadInput(); } } class NDCLogConsole { static TYPE_NORMAL = "NORMAL"; static TYPE_ERROR = "ERROR"; static TYPE_INFO = "INFO"; static TYPE_SUCCESS = "SUCCESS"; static TYPE_WARN = "WARN"; constructor(ndc) { this.element = document.createElement("div"); this.element.classList.add("flex", "flex-col", "w-100", "gap-3", "mt-3"); this.ndc = ndc; this.hidden = true; this.html = `
`; this.element.innerHTML = this.html; this.toggle = this.element.querySelector("#toggleLogsButton"); this.logContainer = this.element.querySelector("#logContainer"); this.toggle.addEventListener("click", () => { this.hidden = !this.hidden; this.logContainer.style.display = this.hidden ? "none" : ""; this.toggle.innerHTML = this.hidden ? "Show logs" : "Hide logs"; }); } log(message, type = NDCLogConsole.TYPE_NORMAL) { const rowElement = document.createElement("div"); rowElement.classList.add("flex", "gap-x-1", "px-2", "py-1"); const time = new Date().toLocaleTimeString('en-US', { hour12: false }); let badge = ""; switch (type) { case NDCLogConsole.TYPE_ERROR: badge = `[ERROR]`; rowElement.classList.add("text-red-400"); break; case NDCLogConsole.TYPE_INFO: badge = `[INFO]`; rowElement.classList.add("text-sky-300"); break; case NDCLogConsole.TYPE_SUCCESS: badge = `[OK]`; rowElement.classList.add("text-green-400"); break; case NDCLogConsole.TYPE_WARN: badge = `[WARN]`; rowElement.style.color = "#FA933C"; break; default: badge = `[LOG]`; rowElement.classList.add("text-gray-300"); } rowElement.innerHTML = `${time}${badge}${message}`; rowElement.message = rowElement.querySelector(".ndc-log-message"); this.logContainer.appendChild(rowElement); this.logContainer.scrollTop = this.logContainer.scrollHeight; console.log(`[NDC][${type}] ${message}`); return rowElement; } } let previousRoute = null; let ndc = null; function extractRouteDetails(pathname) { const pathParts = pathname.split("/").filter(Boolean); if (pathParts.length >= 4 && pathParts[2] === "collections") { return { gameDomain: pathParts[1], collectionSlug: pathParts[3], revisionNumber: pathParts.length > 5 ? pathParts[5] : null, }; } return null; } function handleRouteChange() { const pathname = window.location.pathname; const routeDetails = extractRouteDetails(pathname); if (routeDetails) { const { gameDomain, collectionSlug, revisionNumber } = routeDetails; const currentRoute = `${gameDomain}/${collectionSlug}/`; const currentRevision = revisionNumber ? parseInt(revisionNumber, 10) : null; if (previousRoute !== currentRoute || ndc?.revision !== currentRevision) { previousRoute = currentRoute; if (ndc) { ndc.element.remove(); } ndc = new NDC(gameDomain, collectionSlug, currentRevision); ndc.init().then(() => { const container = document.querySelector(CONSTANTS.SELECTOR_MAIN_CONTENT); if (container) { container.append(ndc.element); } }); } } } try { const observer = new MutationObserver(() => { try { handleRouteChange(); } catch (err) { console.error("[NDC] Observer Error:", err); } }); observer.observe(document.body, { childList: true, subtree: true }); } catch (e) { console.error("[NDC] Critical Observer Failure:", e); } handleRouteChange();