// ==UserScript==
// @name Nexus Download Collection
// @namespace NDC
// @version 0.8
// @description Download every mods of a collection in a single click
// @author Drigtime
// @match https://next.nexusmods.com/*/collections*
// @icon https://www.google.com/s2/favicons?sz=64&domain=nexusmods.com
// @grant GM.xmlHttpRequest
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM.setValue
// @grant GM.getValue
// @connect next.nexusmods.com
// @connect nexusmods.com
// ==/UserScript==
// MDI : https://pictogrammers.com/library/mdi/
// MDI : https://github.com/MathewSachin/Captura/blob/master/src/Captura.Core/MaterialDesignIcons.cs
/** CORSViaGM BEGINING */
const CORSViaGM = document.body.appendChild(Object.assign(document.createElement('div'), { id: 'CORSViaGM' }))
addEventListener('fetchViaGM', e => GM_fetch(e.detail.forwardingFetch))
CORSViaGM.init = function (window) {
if (!window) throw new Error('The `window` parameter must be passed in!')
window.fetchViaGM = fetchViaGM.bind(window)
// Support for service worker
window.forwardingFetch = new BroadcastChannel('forwardingFetch')
window.forwardingFetch.onmessage = async e => {
const req = e.data
const { url } = req
const res = await fetchViaGM(url, req)
const response = await res.blob()
window.forwardingFetch.postMessage({ type: 'fetchResponse', url, response })
}
window._CORSViaGM?.inited?.done();
const info = '🙉 CORS-via-GM initiated!'
console.info(info)
return info
}
function GM_fetch(p) {
GM_xmlhttpRequest({
...p.init,
url: p.url, method: p.init.method || 'GET',
onload: responseDetails => p.res(new Response(responseDetails.response, responseDetails))
})
}
function fetchViaGM(url, init) {
let _r
const p = new Promise(r => _r = r)
p.res = _r
p.url = url
p.init = init || {}
dispatchEvent(new CustomEvent('fetchViaGM', { detail: { forwardingFetch: p } }))
return p
}
CORSViaGM.init(window);
/** CORSViaGM END */
class NDC {
mods = {
all: [],
mandatory: [],
optional: []
};
constructor(gameId, collectionId) {
this.element = document.createElement('div');
this.gameId = gameId;
this.collectionId = collectionId;
this.pauseBetweenDownload = 5;
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.element.innerHTML = `
<button class="w-full font-montserrat font-semibold text-sm leading-none tracking-wider uppercase flex gap-x-2 justify-center items-center transition-colors relative min-h-9 focus:outline focus:outline-2 focus:outline-accent focus:outline-offset-2 px-2 py-1 cursor-pointer bg-primary-moderate fill-font-primary text-font-primary border-transparent focus:bg-primary-strong hover:bg-primary-subdued rounded">
Fetching mods list...
</button>
`;
const response = await this.fetchMods();
if (!response) {
this.element.innerHTML = '<div class="w-full font-montserrat font-semibold text-sm leading-none tracking-wider uppercase flex gap-x-2 justify-center items-center transition-colors relative min-h-9 focus:outline focus:outline-2 focus:outline-accent focus:outline-offset-2 px-2 py-1 cursor-pointer bg-primary-moderate fill-font-primary text-font-primary border-transparent focus:bg-primary-strong hover:bg-primary-subdued rounded">Failed to fetch mods list</div>';
return;
}
const mods = response.modFiles.sort((a, b) => a.file.mod.name.localeCompare(b.file.mod.name));
let mandatoryMods = mods.filter(mod => !mod.optional);
let 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() {
const response = await fetch("https://next.nexusmods.com/api/graphql", {
"headers": {
"content-type": "application/json",
},
"referrer": `https://next.nexusmods.com/${this.gameId}/collections/${this.collectionId}?tab=mods`,
"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, scanned, size, sizeInBytes, version, mod { adult, author, category, modId, name, pictureUrl, summary, version, game { domainName }, uploader { avatar, memberId, name } } } } } }",
"variables": { "slug": this.collectionId, "viewAdultContent": true },
"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/${this.gameId}/mods/${modFile.file.mod.modId}?tab=files&file_id=${modFile.file.fileId}`;
return modFile;
});
return json.data.collectionRevision;
}
async fetchDownloadLink(mod) {
const url = `${mod.file.url}&nmm=1`;
const response = await fetchViaGM(url, {
headers: {
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"cache-control": "max-age=0",
},
referrer: url,
referrerPolicy: "strict-origin-when-cross-origin",
method: "GET",
mode: "cors",
credentials: "include"
});
const text = await response.text();
if (!response.ok) return { downloadUrl: '', text };
const downloadUrlMatch = text.match(/id="slowDownloadButton".*?data-download-url="([^"]+)"/);
const downloadUrl = downloadUrlMatch ? downloadUrlMatch[1] : '';
return { downloadUrl, text };
}
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 resume the download?\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 <a href="${mod.file.url}" target="_blank" class="text-primary-moderate">${mod.file.name}</a>`);
this.progressBar.incrementProgress();
continue;
}
if (this.progressBar.skipTo) {
if ((this.progressBar.skipToIndex - 1) > index) {
this.console.log(`[${modNumber}] Skipping <a href="${mod.file.url}" target="_blank" class="text-primary-moderate">${mod.file.name}</a>`);
this.progressBar.incrementProgress();
if ((this.progressBar.skipToIndex - 1) === (index + 1)) { // if skip to index is the next index
this.progressBar.skipTo = false;
}
continue;
} else {
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
<a href="${mod.file.url}" target="_blank" class="text-primary-moderate">${mod.file.name}</a>
<button class="text-primary-moderate" title="Copy response to clipboard">
<svg class="w-4 h-4 fill-current" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" role="presentation" style="width: 1rem; height: 1rem;">
<path d="M19,21H8V7H19M19,5H8A2,2 0 0,0 6,7V21A2,2 0 0,0 8,23H19A2,2 0 0,0 21,21V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z" style="fill: currentcolor;"></path>
</svg>
</button>
`, 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. <a href="https://users.nexusmods.com/auth/continue?client_id=nexus&redirect_uri=https://www.nexusmods.com/oauth/callback&response_type=code&referrer=https%3A%2F%2Fwww.nexusmods.com%2F" target="_blank" class="text-primary-moderate">Login</a> 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. <a href="${mod.file.url}" target="_blank" class="text-primary-moderate">Solve captcha</a>`, 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 {
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`;
};
this.console.log(`[${modNumber}] Sending download link to Vortex <a href="${mod.file.url}" target="_blank" class="text-primary-moderate">${mod.file.name}</a><span class="text-xs text-neutral-subdued"> (${convertSize(mod.file.size)})</span>`);
document.location.href = downloadUrl;
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--;
let minutes = Math.floor(remainingTime / 60);
let 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 / 1) + this.pauseBetweenDownload;
let logRow = null;
const startDateTime = new Date().getTime();
await new Promise(resolve => {
const intervalId = setInterval(async () => {
const remainingTime = Math.round((startDateTime + pause * 1000 - new Date().getTime()) / 1000);
const minutes = Math.floor(remainingTime / 60);
const seconds = 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(`<a href="${mod.file.url}" target="_blank" class="text-primary-moderate">${mod.file.name}</a>`, 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 {
constructor(ndc) {
this.element = document.createElement('div');
this.element.classList.add('flex', 'w-100');
this.ndc = ndc;
this.html = `
<button class="w-full font-montserrat font-semibold text-sm leading-none tracking-wider uppercase flex gap-x-2 justify-center items-center transition-colors relative min-h-9 focus:outline focus:outline-2 focus:outline-accent focus:outline-offset-2 px-2 py-1 cursor-pointer bg-primary-moderate fill-font-primary text-font-primary border-transparent focus:bg-primary-strong hover:bg-primary-subdued justify-between rounded-l" id="mainBtn">
Add all mods to vortex
<span class="p-2 bg-surface-low rounded-full text-xs text-white whitespace-nowrap" id="mainModsCount"></span>
</button>
<button class="font-montserrat font-semibold text-sm leading-none tracking-wider uppercase flex gap-x-2 justify-center items-center transition-colors relative min-h-9 focus:outline focus:outline-2 focus:outline-accent focus:outline-offset-2 px-2 py-1 cursor-pointer bg-primary-moderate fill-font-primary text-font-primary border-transparent focus:bg-primary-strong hover:bg-primary-subdued justify-between rounded-r" id="menuBtn">
<svg class="w-4 h-4 fill-current" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" role="presentation" style="width: 1.5rem; height: 1.5rem;"><path d="M7.41,8.58L12,13.17L16.59,8.58L18,10L12,16L6,10L7.41,8.58Z" style="fill: currentcolor;"></path></svg>
</button>
<div class="absolute z-10 min-w-48 py-1 px-0 mt-1 text-base text-gray-600 border-stroke-subdued bg-surface-low border border-gray-200 rounded-md shadow-lg outline-none hidden" id="menu" style="transform: translate(488.453px, 36px);">
<button class="font-montserrat text-sm font-semibold uppercase leading-none tracking-wider first:rounded-t last:rounded-b relative flex w-full items-center gap-x-2 p-2 text-left font-normal hover:bg-surface-mid hover:text-primary-moderate focus:shadow-accent focus:z-10 focus:outline-none text-start justify-between" id="menuBtnMandatory">
Add all mandatory mods
<span class="p-2 bg-primary-moderate rounded-full text-xs text-white whitespace-nowrap" id="menuBtnMandatoryModsCount"></span>
</button>
<button class="font-montserrat text-sm font-semibold uppercase leading-none tracking-wider first:rounded-t last:rounded-b relative flex w-full items-center gap-x-2 p-2 text-left font-normal hover:bg-surface-mid hover:text-primary-moderate focus:shadow-accent focus:z-10 focus:outline-none text-start justify-between" id="menuBtnOptional">
Add all optional mods
<span class="p-2 bg-primary-moderate rounded-full text-xs text-white whitespace-nowrap" id="menuBtnOptionalModsCount"></span>
</button>
<button class="font-montserrat text-sm font-semibold uppercase leading-none tracking-wider first:rounded-t last:rounded-b relative flex w-full items-center gap-x-2 p-2 text-left font-normal hover:bg-surface-mid hover:text-primary-moderate focus:shadow-accent focus:z-10 focus:outline-none text-start justify-between" id="menuBtnSelect">
Select mods
</button>
</div>
`;
this.element.innerHTML = this.html;
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');
const menuBtn = this.element.querySelector('#menuBtn');
const menu = this.element.querySelector('#menu');
menuBtn.addEventListener('click', () => {
const btnGroupOffset = this.element.getBoundingClientRect();
menu.classList.toggle('hidden');
const dropdownMenuOffset = menu.getBoundingClientRect();
menu.style.transform = `translate(${btnGroupOffset.width - dropdownMenuOffset.width}px, ${btnGroupOffset.height}px)`;
});
document.addEventListener('click', (event) => {
const isClickInside = menu.contains(event.target) || menuBtn.contains(event.target);
if (!isClickInside) {
menu.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();
});
}
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.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', 'bg-blur');
this.ndc = ndc;
this.html = `
<div class="bg-surface-mid p-4 rounded-lg w-1/2 max-h-[calc(100vh-3.5rem)] flex flex-col">
<div class="flex justify-between items-center">
<h2 class="font-montserrat font-semibold text-lg leading-none tracking-wider uppercase">Select mods</h2>
<span class="p-2 py-1 bg-primary-moderate rounded-full text-xs text-white whitespace-nowrap" id="selectedModsCount">0 mods selected</span>
</div>
<div class="flex flex-col mt-2 overflow-auto border border-stroke-subdued rounded" style="user-select: none;" id="modsList"></div>
<div class="flex justify-end mt-2">
<button class="font-montserrat font-semibold text-sm leading-none tracking-wider uppercase text-center leading-none flex gap-x-2 justify-center items-center transition-colors relative min-h-9 focus:outline focus:outline-2 focus:outline-focus-subdued focus:outline-offset-2 rounded px-4 py-1 cursor-pointer bg-surface-mid border border-neutral-moderate fill-neutral-moderate text-neutral-moderate aria-expanded:bg-surface-high focus:border-neutral-moderate focus:bg-surface-high hover:border-neutral-moderate hover:bg-surface-high mr-2" id="cancelSelectModsBtn">Cancel</button>
<button class="font-montserrat font-semibold text-sm leading-none tracking-wider uppercase flex gap-x-2 justify-center items-center transition-colors relative min-h-9 focus:outline focus:outline-2 focus:outline-accent focus:outline-offset-2 px-2 py-1 cursor-pointer bg-primary-moderate fill-font-primary text-font-primary border-transparent focus:bg-primary-strong hover:bg-primary-subdued rounded" id="selectModsBtn">Select</button>
</div>
</div>
`;
this.element.innerHTML = this.html;
this.modsList = this.element.querySelector('#modsList');
this.selectedModsCount = this.element.querySelector('#selectedModsCount');
this.selectModsBtn = this.element.querySelector('#selectModsBtn');
this.cancelSelectModsBtn = this.element.querySelector('#cancelSelectModsBtn');
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();
});
}
render() {
for (const [index, mod] of this.ndc.mods.all.entries()) {
const modElement = document.createElement('div');
modElement.classList.add('flex', 'gap-2', 'justify-between', 'items-center', 'border-b', 'border-stroke-subdued', 'p-2');
modElement.innerHTML = `
<div class="flex items-center gap-2">
<input type="checkbox" id="mod_${mod.file.fileId}" class="form-checkbox h-5 w-5 text-primary-moderate">
<label for="mod_${mod.file.fileId}" class="text-white flex items-center gap-2">
${index + 1} | ${mod.file.mod.name} | ${mod.file.name}
<a href="${mod.file.url}" target="_blank" class="text-primary-moderate">
<svg class="w-4 h-4 fill-current inline" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="presentation" style="width: 1rem; height: 1rem;">
<path d="M14,3V5H17.59L7.76,14.83L9.17,16.24L19,6.41V10H21V3M19,19H5V5H12V3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V7A2,2 0 0,0 19,5M16,1H4A2,2 0 0,0 2,3V17H4V3H16V1Z" style="fill: currentcolor;"></path>
</svg>
</a>
</label>
</div>
<span class="p-2 py-1 ${mod.optional ? 'bg-surface-mid border border-neutral-moderate' : 'bg-primary-moderate'} rounded-full text-xs text-white whitespace-nowrap">${mod.optional ? 'Optional' : 'Mandatory'}</span>
`;
this.modsList.appendChild(modElement);
}
// allow to select multiple at once by holding shift key and clicking on the checkbox its label
let lastChecked = null;
const checkboxes = this.element.querySelectorAll('input[type="checkbox"]');
for (const checkbox of checkboxes) {
// add event listener to the checkbox and its label
checkbox.addEventListener('click', (event) => {
if (event.shiftKey && lastChecked) {
const start = Array.from(checkboxes).indexOf(checkbox);
const end = Array.from(checkboxes).indexOf(lastChecked);
const [from, to] = start < end ? [start, end] : [end, start];
for (let i = from; i <= to; i++) {
checkboxes[i].checked = checkbox.checked;
}
}
lastChecked = checkbox;
const selectedModsCount = Array.from(checkboxes).filter(checkbox => checkbox.checked).length;
this.selectedModsCount.innerHTML = `${selectedModsCount} mods selected`;
});
}
// close the modal when clicking outside of it
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 = `
<div class="flex-1 relative w-100 min-h-9 bg-surface-low rounded-l overflow-hidden" id="progressBar">
<div class="absolute top-0 left-0 w-0 h-full bg-primary-moderate" style="transition: width 0.3s ease 0s; width: 0%;" id="progressBarFill"></div>
<div class="absolute top-0 left-0 w-full h-full cursor-pointer grid grid-cols-3 items-center text-white font-montserrat font-semibold text-sm leading-none tracking-wider uppercase" id="progressBarText">
<div class="ml-2" id="progressBarProgress">${this.progress}%</div>
<div class="text-center" id="progressBarTextCenter">Downloading...</div>
<div class="text-right mr-2" id="progressBarTextRight">${this.progress}/${this.modsCount}</div>
</div>
</div>
<div class="flex" id="actionBtnGroup">
<button class="font-montserrat font-semibold text-sm leading-none tracking-wider uppercase flex gap-x-2 justify-center items-center transition-colors relative min-h-9 focus:outline focus:outline-2 focus:outline-accent focus:outline-offset-2 px-2 py-1 cursor-pointer bg-primary-moderate fill-font-primary text-font-primary border-transparent focus:bg-primary-strong hover:bg-primary-subdued justify-between" id="playPauseBtn">
<svg class="w-4 h-4 fill-current" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" role="presentation" style="width: 1.5rem; height: 1.5rem;"><path d="M14,19H18V5H14M6,19H10V5H6V19Z" style="fill: currentcolor;"></path></svg>
</button>
<button class="font-montserrat font-semibold text-sm leading-none tracking-wider uppercase flex gap-x-2 justify-center items-center transition-colors relative min-h-9 focus:outline focus:outline-2 focus:outline-accent focus:outline-offset-2 px-2 py-1 cursor-pointer bg-primary-moderate fill-font-primary text-font-primary border-transparent focus:bg-primary-strong hover:bg-primary-subdued justify-between rounded-r" id="stopBtn">
<svg class="w-4 h-4 fill-current" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" role="presentation" style="width: 1.5rem; height: 1.5rem;"><path d="M18,18H6V6H18V18Z" style="fill: currentcolor;"></path></svg>
</button>
</div>
<div class="flex my-2 justify-between" style="flex-basis: 100%;" id="toolbarContainer">
<div class="flex gap-2 items-center" id="pauseBetweenDownloadInputContainer">
<input class="text-md text-neutral-subdued border-neutral-subdued bg-surface-mid rounded border indent-2 outline-none hover:border-white focus:border-white focus:text-white p-1 w-14" type="number" min="0" placeholder="5" value="${this.ndc.pauseBetweenDownload}" id="pauseBetweenDownloadInput">
<label class="text-white font-montserrat font-semibold text-sm leading-none tracking-wider uppercase" for="pauseBetweenDownloadInput">Extra pause</label>
<svg id="extraPauseInfo" class="w-4 h-4 fill-current" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="presentation" style="width: 1.5rem; height: 1.5rem; cursor: pointer;"><title>information</title><path d="M13,9H11V7H13M13,17H11V11H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z" style="fill: currentcolor;"/></svg>
</div>
<div class="flex gap-2 items-center" id="skipContainer">
<button class="rounded font-montserrat font-semibold text-sm leading-none tracking-wider uppercase flex gap-x-2 justify-center items-center transition-colors relative min-h-9 focus:outline focus:outline-2 focus:outline-accent focus:outline-offset-2 px-2 py-1 cursor-pointer bg-primary-moderate fill-font-primary text-font-primary border-transparent focus:bg-primary-strong hover:bg-primary-subdued justify-between" id="skipNextBtn">
Skip pause
</button>
<button class="rounded font-montserrat font-semibold text-sm leading-none tracking-wider uppercase flex gap-x-2 justify-center items-center transition-colors relative min-h-9 focus:outline focus:outline-2 focus:outline-accent focus:outline-offset-2 px-2 py-1 cursor-pointer bg-primary-moderate fill-font-primary text-font-primary border-transparent focus:bg-primary-strong hover:bg-primary-subdued justify-between" id="skipToIndexBtn">
Skip to index
</button>
<input class="text-md text-neutral-subdued border-neutral-subdued bg-surface-mid rounded border indent-2 outline-none hover:border-white focus:border-white focus:text-white p-1 w-20" type="number" min="0" placeholder="Index" id="skipToIndexInput">
</div>
</div>
`;
this.element.innerHTML = this.html;
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.pauseBetweenDownloadInput = this.element.querySelector('#pauseBetweenDownloadInput');
this.skipNextBtn = this.element.querySelector('#skipNextBtn');
this.skipToIndexBtn = this.element.querySelector('#skipToIndexBtn');
this.skipToIndexInput = this.element.querySelector('#skipToIndexInput');
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.pauseBetweenDownloadInput.addEventListener('change', async (event) => {
this.ndc.pauseBetweenDownload = 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 = 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 ? '<svg class="w-4 h-4 fill-current" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" role="presentation" style="width: 1.5rem; height: 1.5rem;"><path d="M8,5.14V19.14L19,12.14L8,5.14Z" style="fill: currentcolor;"></path></svg>' : '<svg class="w-4 h-4 fill-current" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" role="presentation" style="width: 1.5rem; height: 1.5rem;"><path d="M14,19H18V5H14M6,19H10V5H6V19Z" style="fill: currentcolor;"></path></svg>';
}
updatePauseBetweenDownloadInput() {
this.pauseBetweenDownloadInput.value = this.ndc.pauseBetweenDownload;
}
render() {
this.updateProgressBarFillWidth()
this.updateProgressBarTextProgress()
this.updateProgressBarTextRight()
this.updatePlayPauseBtn()
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 = `
<div class="flex flex-col w-100 gap-3 mt-3">
<button class="w-full font-montserrat font-semibold text-sm leading-none tracking-wider uppercase" id="toggleLogsButton">
Hide logs
</button>
<div class="w-full bg-surface-low rounded overflow-y-auto text-white font-semibold text-sm border border-primary" style="height: 10rem; resize: vertical;" style="font-family: sans-serif;" id="logContainer">
</div>
</div>
`;
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('gap-x-2', '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 = `<span class="mr-1">[${new Date().toLocaleTimeString()}]</span><span class="ndc-log-message">${message}</span>`;
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;
async function handleNextRouterChange() {
if (next.router.state.route === "/[gameDomain]/collections/[collectionSlug]") {
const { gameDomain, collectionSlug, tab } = next.router.query;
if (previousRoute !== `${gameDomain}/${collectionSlug}`) {
previousRoute = `${gameDomain}/${collectionSlug}`;
ndc = new NDC(gameDomain, collectionSlug);
ndc.init();
}
if (tab === "mods") {
document.querySelector("#tabcontent-mods > div > div > div").prepend(ndc.element);
} else {
ndc.element.remove();
}
}
}
// Add an event listener for the hashchange event
next.router.events.on('routeChangeComplete', handleNextRouterChange);
handleNextRouterChange();