// ==UserScript==
// @name Nexus Download Collection
// @namespace NDC
// @version 0.9.6
// @description Download every mods of a collection in a single click
// @author Drigtime
// @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.xmlHttpRequest
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM.setValue
// @grant GM.getValue
// @grant GM_addStyle
// @connect nexusmods.com
// @downloadURL https://update.greasyfork.icu/scripts/483337/Nexus%20Download%20Collection.user.js
// @updateURL https://update.greasyfork.icu/scripts/483337/Nexus%20Download%20Collection.meta.js
// ==/UserScript==
// MDI : https://pictogrammers.com/library/mdi/
// MDI : https://github.com/MathewSachin/Captura/blob/master/src/Captura.Core/MaterialDesignIcons.cs
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;
}
}
`);
const convertSize = (sizeInKB) => {
// if size is greater than 1GB convert to GB, else in MB
const sizeInMB = sizeInKB / 1024;
const sizeInGB = sizeInMB / 1024;
return sizeInGB >= 1
? `${sizeInGB.toFixed(2)} GB`
: `${sizeInMB.toFixed(2)} MB`;
};
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 = 5;
this.downloadSpeed = 1.5;
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", 5);
this.downloadSpeed = await GM.getValue("downloadSpeed", 1.5);
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) {
// https://graphql.nexusmods.com/#definition-CollectionRevisionMod
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 CollectionRevisionMods ($revision: Int, $slug: String!, $viewAdultContent: Boolean) { collectionRevision (revision: $revision, slug: $slug, viewAdultContent: $viewAdultContent) { externalResources { id, name, resourceType, resourceUrl }, 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;
}
async fetchDownloadLink(mod) {
this.bypassNexusAdsCookie();
let response;
if (this.downloadMethod === NDCDownloadButton.DOWNLOAD_METHOD_VORTEX) {
response = await fetch(`${mod.file.url}&nmm=1`);
} else {
response = await fetch(mod.file.url);
}
const text = await response.text();
if (!response.ok) return { downloadUrl: "", text };
// console.log(text.match(/1\.5MB<\/td>/));
// console.log(text.match(/ | 3MB<\/td>/));
let downloadUrl = "";
if (this.downloadMethod === NDCDownloadButton.DOWNLOAD_METHOD_VORTEX) {
const downloadUrlMatch = text.match(
/id="slowDownloadButton".*?data-download-url="([^"]+)"/,
);
downloadUrl = downloadUrlMatch ? downloadUrlMatch[1].replaceAll('&', '&') : "";
} else {
const generateDownloadUrlResponse = await fetch(
"https://www.nexusmods.com/Core/Libs/Common/Managers/Downloads?GenerateDownloadUrl",
{
headers: {
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
},
body: `fid=${mod.fileId}&game_id=${mod.file.mod.game.id}`,
method: "POST",
},
);
const fileLink = await generateDownloadUrlResponse.json();
downloadUrl = fileLink?.url || "";
}
return { downloadUrl, text };
}
bypassNexusAdsCookie() {
const now = Math.round(Date.now() / 1000);
const expirySeconds = 5 * 60; // 5 minutes in seconds
const expiryTimestamp = now + expirySeconds;
// Set expiry date for the cookie
const expiryDate = new Date(Date.now() + expirySeconds * 1000).toUTCString();
// Create and set the cookie
document.cookie = `ab=0|${expiryTimestamp};expires=${expiryDate};domain=nexusmods.com;path=/`;
}
async downloadMods(mods, type = null) {
this.startDownload(mods.length);
let history = null;
if (type !== null) {
history = await GM.getValue("history", {}); // {"gameId": {"collectionId": {"type": []}}}
// get history for this collection (index is the collectionId)
history[this.gameId] ??= {};
history[this.gameId][this.collectionId] ??= {};
history[this.gameId][this.collectionId][type] ??= [];
if (history[this.gameId][this.collectionId][type].length) {
const confirm = await Promise.resolve(
window.confirm(
`You already downloaded ${history[this.gameId][this.collectionId][type].length
} out of ${mods.length
} mods from this collection.\nDo you want to skip the already downloaded mods ?\nCancel will clear the history and download all mods again.`,
),
);
if (!confirm) {
history[this.gameId][this.collectionId][type] = [];
await GM.setValue("history", history);
}
}
}
const lauchedDownload = await GM.getValue("lauchedDownload", {
count: 0,
date: new Date().getTime(),
});
const failedDownload = [];
let forceStop = false;
for (const [index, mod] of mods.entries()) {
const modNumber = `${(index + 1)
.toString()
.padStart(mods.length.toString().length, "0")}/${mods.length}`;
if (lauchedDownload.date < new Date().getTime() - 1000 * 60 * 5) {
// 5 minutes
lauchedDownload.count = 0;
await GM.setValue("lauchedDownload", lauchedDownload);
}
if (
history?.[this.gameId][this.collectionId][type].includes(mod.fileId)
) {
this.console.log(
`[${modNumber}] Already downloaded ${mod.file.name}`,
);
this.progressBar.incrementProgress();
continue;
}
if (this.progressBar.skipTo) {
if (this.progressBar.skipToIndex - 1 > index) {
this.console.log(
`[${modNumber}] Skipping ${mod.file.name}`,
);
this.progressBar.incrementProgress();
if (this.progressBar.skipToIndex - 1 === index + 1) {
// if skip to index is the next index
this.progressBar.skipTo = false;
}
continue;
}
this.progressBar.skipTo = false;
}
if (this.progressBar.status === NDCProgressBar.STATUS_STOPPED) {
this.console.log("Download stopped.", NDCLogConsole.TYPE_INFO);
break;
}
const { downloadUrl, text } = await this.fetchDownloadLink(mod);
if (downloadUrl === "") {
const logRow = this.console.log(
`
[${modNumber}] Failed to get download link for
${mod.file.name}
`,
NDCLogConsole.TYPE_ERROR,
);
logRow.querySelector("button").addEventListener("click", () => {
navigator.clipboard.writeText(text);
alert("Response copied to clipboard");
});
// check if find .replaced-login-link in the html it is because the user is not connect on nexusmods
if (text.match(/class="replaced-login-link"/)) {
this.console.log(
'You are not connected on NexusMods. Login and try again.',
NDCLogConsole.TYPE_ERROR,
);
forceStop = true;
} else if (text.match(/Just a moment.../)) {
this.console.log(
`You are rate limited by Cloudflare. Click on the link to solve the captcha and try again. Solve captcha`,
NDCLogConsole.TYPE_ERROR,
);
forceStop = true;
} else if (
text.match(/Your access to Nexus Mods has been temporarily suspended/)
) {
this.console.log(
"Du to too many requests, Nexus mods temporarily suspended your account for 10 minutes, try again later.",
NDCLogConsole.TYPE_ERROR,
);
forceStop = true;
} else {
failedDownload.push(mod);
}
} else {
if (this.downloadMethod === NDCDownloadButton.DOWNLOAD_METHOD_VORTEX) {
this.console.log(
`[${modNumber}] Sending download link to Vortex ${mod.file.name
}(${convertSize(
mod.file.size,
)})`,
);
document.location.href = downloadUrl;
} else {
this.console.log(
`[${modNumber}] Downloading ${mod.file.name
}(${convertSize(
mod.file.size,
)})`,
);
const downloadLink = document.createElement("a");
downloadLink.href = downloadUrl;
downloadLink.download = mod.file.name;
downloadLink.click();
}
this.progressBar.incrementProgress();
if (history) {
history[this.gameId][this.collectionId][type] = [
...new Set([
...history[this.gameId][this.collectionId][type],
mod.fileId,
]),
]; // remove duplicate and update history
await GM.setValue("history", history);
}
lauchedDownload.count++;
lauchedDownload.date = new Date().getTime();
await GM.setValue("lauchedDownload", lauchedDownload);
}
if (forceStop) {
this.console.log(
"Download forced to stop due to an error.",
NDCLogConsole.TYPE_ERROR,
);
break;
}
if (index < mods.length - 1) {
if (lauchedDownload.count >= 200) {
// 200 is a safe number of downloads to avoid Nexus bans
let remainingTime = 5 * 60; // 5 minutes
this.console.log(
"Started the download of 200 mods. Waiting 5 minutes before continuing to avoid the temporary 10 minutes ban from Nexus.",
NDCLogConsole.TYPE_INFO,
);
let logRow = null;
await new Promise((resolve) => {
const intervalId = setInterval(async () => {
remainingTime--;
const minutes = Math.floor(remainingTime / 60);
const seconds = remainingTime % 60;
const logMessage = `Waiting for ${minutes} minutes and ${seconds} seconds before continuing...`;
if (!logRow) {
logRow = this.console.log(logMessage, NDCLogConsole.TYPE_INFO);
} else {
logRow.innerHTML = logMessage;
}
if (remainingTime <= 0) {
logRow.remove();
clearInterval(intervalId);
lauchedDownload.count = 0;
await GM.setValue("lauchedDownload", lauchedDownload);
return resolve();
}
}, 1000);
});
}
const pause =
this.pauseBetweenDownload === 0
? 0
: Math.round(mod.file.size / 1024 / this.downloadSpeed) + this.pauseBetweenDownload;
let logRow = null;
const startDateTime = new Date().getTime();
await new Promise((resolve) => {
const intervalId = setInterval(async () => {
const remainingTime = Math.max(
0,
Math.round(
(startDateTime + pause * 1000 - new Date().getTime()) / 1000,
),
);
const minutes = Math.max(0, Math.floor(remainingTime / 60));
const seconds = Math.max(0, remainingTime % 60);
const logMessage = `Waiting ${minutes} minutes and ${seconds} seconds before starting the next download...`;
if (!logRow) {
logRow = this.console.log(logMessage, NDCLogConsole.TYPE_INFO);
} else {
logRow.innerHTML = logMessage;
}
const shouldClearInterval = () => {
clearInterval(intervalId);
logRow.remove();
return resolve();
};
if (
this.progressBar.skipPause ||
this.progressBar.skipTo ||
this.progressBar.status === NDCProgressBar.STATUS_STOPPED
) {
if (this.progressBar.skipPause) {
this.progressBar.skipPause = false;
}
return shouldClearInterval();
}
if (this.progressBar.status === NDCProgressBar.STATUS_PAUSED) {
return;
}
if (new Date().getTime() >= startDateTime + pause * 1000) {
return shouldClearInterval();
}
}, 100);
});
}
if (history && this.progressBar.progress === this.progressBar.modsCount) {
history[this.gameId][this.collectionId][type] = [];
await GM.setValue("history", history);
}
}
if (failedDownload.length) {
this.console.log(
`Failed to download ${failedDownload.length} mods:`,
NDCLogConsole.TYPE_INFO,
);
for (const mod of failedDownload) {
this.console.log(
`${mod.file.name}`,
NDCLogConsole.TYPE_INFO,
);
}
}
this.endDownload();
}
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", () => {
// create a temporary input element to select a folder
const input = document.createElement("input");
input.type = "file";
input.multiple = true;
input.addEventListener("change", async () => {
const files = input.files;
// File name containe at the end information (ex: 10 Male Face Presets-54364-1-1631230510.zip, 54364 is mod.file.mod.modId, 1 is mod.file.version, 1631230510 is mod.file.date)
const downloadedMods = this.ndc.mods.all.filter((mod) => {
for (const file of files) {
// if (file.name.includes(`${mod.file.name}-${mod.file.mod.modId}-${mod.file.version.replace(/\./g, '-')}-${mod.file.date}`)) {
if (file.name.includes(mod.file.uri)) {
return true;
}
}
return false;
});
const notMatchedFiles = [...files].filter(
(file) =>
!downloadedMods.some((mod) => file.name.includes(mod.file.uri)),
);
// get the file id of the downloaded mods
const downloadedModsFileIds = downloadedMods.map((mod) => mod.fileId);
// update the history with the downloaded mods
console.log(downloadedModsFileIds);
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.fileId),
),
];
collectionHistory.optional = [
...new Set(
downloadedMods
.filter((mod) => mod.optional)
.map((mod) => mod.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) {
alert(
`The following files are not matched with any mods:\n\n${notMatchedFiles
.map((file) => file.name)
.join("\n")}`,
);
}
});
input.click();
});
this.importDownloadedModsBtnInfo.addEventListener("click", () => {
alert(
`Importing downloaded mods will allow you to skip the download of mods you already have. \nSelect all the files of the folder where your mods are located and the script will automatically add them to the history so when you start a new download you will be asked if you want to skip the already downloaded mods.\n\nDefault Vortex download path :\n C:\\Users\\YourName\\AppData\\Roaming\\Vortex\\downloads\\${this.ndc.gameId}`,
);
});
menuBtn.addEventListener("click", () => {
otherOptionMenu.classList.toggle("hidden");
});
document.addEventListener("click", (event) => {
const isClickInside =
// otherOptionMenu.contains(event.target) ||
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",
);
this.ndc = ndc;
this.html = `
Select mods
0 mods selected
Index
Mod name
File name
Size
Requirement
`;
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.element.remove();
this.ndc.downloadMods(selectedMods);
});
this.cancelSelectModsBtn.addEventListener("click", () => {
this.element.remove();
});
document.addEventListener("click", (event) => {
const isClickInside = this.openSelectModsOptionMenu.contains(
event.target,
);
// if the click on an option, close the menu
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
}
${mod.file.mod.name}
${mod.file.name}
${convertSize(
mod.file.size,
)}
${mod.optional ? "Optional" : "Mandatory"
}
#${index + 1
}
${convertSize(mod.file.size)}
${mod.optional ? "Optional" : "Mandatory"
}
${mod.file.mod.name}
${mod.file.name}
`;
modElementMobile.addEventListener("click", (event) => {
// if check change color, if shiftkey is pressed, select all between the last checked and this one
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 shift key is pressed and there is a last checked element
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);
}
}
// get the index of the element, and store it to the parent
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);
// close the modal when clicking outside of it
this.element.addEventListener("click", (event) => {
if (event.target === this.element) {
this.element.remove();
}
});
// searchMods
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";
}
}
});
// sortMods
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.addEventListener("change", async () => {
const files = input.files;
const downloadedMods = this.ndc.mods.all.filter((mod) => {
for (const file of files) {
if (file.name.includes(mod.file.uri)) {
return true;
}
}
return false;
}).reduce((acc, mod) => {
acc[mod.file.fileId] = mod;
return acc;
}, {});
// for each checkbox, check if the mod is in the downloadedMods array, if not, check the checkbox
const notDownloadedMods = [];
for (const modElement of this.modsListMobile.childNodes) {
const checkbox = modElement.querySelector('input[type="checkbox"]');
const modId = Number.parseInt(checkbox.id.split("_")[1]);
if (downloadedMods[modId] == null) {
notDownloadedMods.push(downloadedMods[modId]);
checkbox.checked = true;
modElement.classList.add("bg-primary-subdued");
modElement.querySelector(".mod-list-index").classList.add("text-white");
}
}
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.`,
);
}
});
input.click();
});
}
}
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"
);
this.ndc = ndc;
this.html = `
Update collection
`;
this.element.innerHTML = this.html;
this.currentCollectionRevision = this.element.querySelector("#currentCollectionRevision");
this.newCollectionRevision = this.element.querySelector("#newCollectionRevision");
this.listOfUpdates = this.element.querySelector("#listOfUpdates");
this.updateModsBtn = this.element.querySelector("#updateModsBtn");
this.cancelUpdateModsBtn = this.element.querySelector(
"#cancelUpdateModsBtn",
);
this.modsToDownload = [];
this.updateModsBtn.addEventListener("click", () => {
this.ndc.downloadMods(this.modsToDownload);
this.element.remove();
});
this.cancelUpdateModsBtn.addEventListener("click", () => {
this.element.remove();
});
}
async renderListOfUpdates() {
if (!this.currentCollectionRevision.value || !this.newCollectionRevision.value) {
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.currentCollectionRevision.value)),
this.ndc.fetchMods(this.ndc.collectionId, parseInt(this.newCollectionRevision.value)),
]);
// group all mods by modId
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("")}
Removed Mods
(${removedMods.length} mods)
${removedMods.map(mod => ` ${mod.file.mod.name} `).join("")}
`;
this.listOfUpdates.classList.add("overflow-auto");
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() {
// https://graphql.nexusmods.com/#definition-CollectionRevisionMod
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.currentCollectionRevision.innerHTML = this.newCollectionRevision.innerHTML = ``;
revisions.forEach(revision => {
const optionText = `Revision ${revision.revisionNumber} - ${(revision.totalSize / (1024 * 1024)).toFixed(2)} MB - ${(new Date(revision.createdAt)).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" })}`;
this.currentCollectionRevision.appendChild(new Option(optionText, revision.revisionNumber));
this.newCollectionRevision.appendChild(new Option(optionText, revision.revisionNumber));
});
const updateList = async () => await this.renderListOfUpdates();
this.newCollectionRevision.addEventListener("change", updateList);
this.currentCollectionRevision.addEventListener("change", updateList);
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.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, options = {}) {
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}
`;
this.element.innerHTML = this.html;
const downloadSpeedInfo = this.element.querySelector("#downloadSpeedInfo");
const extraPauseInfo = this.element.querySelector("#extraPauseInfo");
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(
`"DL Speed" is the download speed in mb/s.\nIt is used to calculate the pause between downloads.\n\n/!\\/!\\/!\\/!\\/!\\/!\\\nIt doesnt affect the real download speed. It is only used has a reference to calculate the pause between downloads.\n/!\\/!\\/!\\/!\\/!\\/!\\`,
);
});
extraPauseInfo.addEventListener("click", () => {
alert(
`"Extra pause" is the time in seconds the script waits before starting the next download. Without it, downloads begin immediately but Vortex may become unresponsive with large collections.\n\nA supplementary pause is calculated based on the mod file size and download speed (1.5mb/s), noticeable only with large mods.\n\nIf "extra pause" is set to 0, the calculated pause is ignored.`,
);
});
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";
constructor(ndc, options = {}) {
this.element = document.createElement("div");
this.element.classList.add("flex", "flex-col", "w-100", "gap-3", "mt-3");
this.ndc = ndc;
this.hidden = false;
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;
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");
if (type === NDCLogConsole.TYPE_ERROR) {
rowElement.classList.add("text-danger-moderate");
} else if (type === NDCLogConsole.TYPE_INFO) {
rowElement.classList.add("text-info-moderate");
}
rowElement.innerHTML = `[${new Date().toLocaleTimeString()}]${message}`;
rowElement.message = rowElement.querySelector(".ndc-log-message");
this.logContainer.appendChild(rowElement);
this.logContainer.scrollTop = this.logContainer.scrollHeight;
console.log(`${message}`);
return rowElement;
}
clear() {
this.logContainer.innerHTML = "";
}
}
let previousRoute = null;
let ndc = null;
function extractRouteDetails(pathname) {
// games/cyberpunk2077/collections/iszwwe/revisions/464
const pathParts = pathname.split("/").filter(Boolean); // Split and remove empty parts
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("#mainContent > div > div.relative > div.next-container");
if (container) {
container.append(ndc.element);
}
});
}
}
}
// Use MutationObserver to detect changes in the DOM
const observer = new MutationObserver(() => {
handleRouteChange();
});
// Start observing the body for changes
observer.observe(document.body, { childList: true, subtree: true });
// Initial call to handle the current route
handleRouteChange();
|