Help to save the mod documentations to local disk. Simplify mod page, files tab, posts tab, forum tab, article page. Download mod gallery. ($browser_download_path\$game\$mod_version_date) Show requirements, changelogs, file descriptions and spoilers, replace thumbnails to original, replace embedded YouTube videos to links, remove unnecessary contents. After saving those pages by SingleFile, you can show/hide requirements, changelogs, spoilers, real file names downloaded, etc.
// ==UserScript==
// @name Mod Documentations Utility by sylin527
// @description Help to save the mod documentations to local disk. Simplify mod page, files tab, posts tab, forum tab, article page. Download mod gallery. ($browser_download_path\$game\$mod_version_date) Show requirements, changelogs, file descriptions and spoilers, replace thumbnails to original, replace embedded YouTube videos to links, remove unnecessary contents. After saving those pages by SingleFile, you can show/hide requirements, changelogs, spoilers, real file names downloaded, etc.
// @version 0.2.6.20260430
// @author sylin527
// @namespace https://www.nexusmods.com
// @runAt document-idle
// @match https://www.nexusmods.com/*/mods/*
// @match https://www.nexusmods.com/*/articles/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_download
// @grant unsafeWindow
// @icon https://www.nexusmods.com/favicon.ico
// @license GPL-3.0
// ==/UserScript==
//#region src/website.ts
const websiteUrl = "https://www.nexusmods.com";
const apiV1Url = "https://api.nexusmods.com/v1";
//#endregion
//#region src/api/mod_api.ts
/**
* @param gameDomainName 如: `skyrimspecialedition`
* @param modId
* @param apikey
* @returns
*/
async function getFiles(gameDomainName, modId, apiKey) {
return await (await fetch(`${apiV1Url}/games/${gameDomainName}/mods/${modId}/files.json`, { headers: { apikey: apiKey } })).json();
}
function generateModUrl(gameDomainName, modId) {
return `${websiteUrl}/${gameDomainName}/mods/${modId}`;
}
function generateFileUrl(gameDomainName, modId, fileId) {
return `${websiteUrl}/${gameDomainName}/mods/${modId}?tab=files&file_id=${fileId}`;
}
const widgetsUrl = `${websiteUrl}/Core/Libs/Common/Widgets`;
//#endregion
//#region src/site_shared.ts
/**
* `header#head`
* @returns
*/
function getHeader() {
return document.getElementById("head");
}
/**
* `div#mainContent`
* @returns
*/
function getMainContentDiv() {
return document.getElementById("mainContent");
}
/**
* base info + description tab
*
* Contains game id and mod id.
* 在 mod url, 有 `<section id="section" class="modpage" data-game-id="1704" data-mod-id="1089">`
* 在 nexusmods url, 有 `<section class="static homeindex">`
*/
let _section = null;
function getSection() {
!_section && (_section = getMainContentDiv().querySelector(":scope > section"));
return _section;
}
/**
* 比如 mod, article 的标题 div
*
* `div#pagetitle`
*/
let _pageTitleDiv = null;
function getPageTitleDiv() {
_pageTitleDiv ||= _pageTitleDiv = document.getElementById("pagetitle");
return _pageTitleDiv;
}
/**
* 比如 mod, article 的标题
*
* `div#pagetitle > h1`
*/
function getPageTitle() {
return getPageTitleDiv().querySelector(":scope > h1").innerText;
}
/**
* 比如 mod, article 的 endorse 容器
*
* `div#pagetitle > ul.modactions`
*/
let _modActionsUl = null;
function getModActionsUl() {
_modActionsUl ||= _modActionsUl = getPageTitleDiv().querySelector(":scope > ul.modactions");
return _modActionsUl;
}
function getCommentContainerDiv() {
return document.getElementById("comment-container");
}
function getCommentContainerComponent(commentContainerDiv = getCommentContainerDiv()) {
if (!commentContainerDiv) return null;
const headNavDiv = commentContainerDiv.querySelector(":scope > div.head-nav");
const bottomNavDiv = commentContainerDiv.querySelector(":scope > div.bottom-nav");
const allCommentLis = commentContainerDiv.querySelectorAll(":scope > ol > li.comment");
const stickyCommentLis = [];
const authorCommentLis = [];
const otherCommentLis = [];
for (const commentLi of allCommentLis) {
const classList = commentLi.classList;
if (classList.contains("comment-sticky")) stickyCommentLis.push(commentLi);
else if (classList.contains("comment-author")) authorCommentLis.push(commentLi);
else otherCommentLis.push(commentLi);
}
return {
commentContainerDiv,
get commentCount() {
return parseInt(document.getElementById("comment-count").getAttribute("data-comment-count"));
},
headNavDiv,
bottomNavDiv,
stickyCommentLis,
authorCommentLis,
otherCommentLis
};
}
function getCommentContentTextDiv(commentLi) {
return commentLi.querySelector(":scope > div.comment-content > div.comment-content-text");
}
/**
* `footer#rj-footer`
*/
function getFooter() {
return document.getElementById("rj-footer");
}
function getBackToTop() {
return document.getElementById("rj-back-to-top");
}
//#endregion
//#region src/ui.ts
const { body: bodyElement, head: headElement } = document;
/**
* title element cache
*/
const titleElement = headElement.querySelector("title");
const primaryColor = "#8197ec";
const primaryHoverColor = "#a4b7ff";
const highlightColor = "#d98f40";
const highlightHoverColor = "#ce7f45";
const containerManager = {
containers: [],
removeAll() {
this.containers.forEach(({ element }) => element.remove());
},
showAll() {
this.containers.forEach((container) => container.show());
},
hideAll() {
this.containers.forEach((container) => container.hide());
},
add(container) {
this.containers.push(container);
},
addBlock(element) {
this.containers.push({
element,
show: () => element.style.display = "block",
hide: () => element.style.display = "none"
});
},
addInline(element) {
this.containers.push({
element,
show: () => element.style.display = "inline",
hide: () => element.style.display = "none"
});
}
};
function getActionContainerId() {
return "sylin527ActionContainer";
}
function insertActionContainerStyle() {
const newStyle = document.createElement("style");
headElement.appendChild(newStyle);
const sheet = newStyle.sheet;
const containerId = getActionContainerId();
/**
* 设 `top: 56px` 是因 Mod page 的 `<header>` 的 `height: 56px`
* 设 `background: transparent;` 以避免突兀
*/
let ruleIndex = sheet.insertRule(`
#${containerId} {
display: block;
position: fixed;
right: 5px;
top: 56px;
font-size: 13px;
font-weight: 400;
background: transparent;
z-index: 999;
direction: rtl;
}
`);
sheet.insertRule(`
#${containerId} div.action-container {
height: 42px;
padding: 3px 0;
}
`, ++ruleIndex);
sheet.insertRule(`
#${containerId} button.action-btn {
padding: 8px;
height: 36px;
font-size: 13px;
margin: 6px 0px;
line-height: unset;
display: inline-block;
}
`, ++ruleIndex);
sheet.insertRule(`
#${containerId} span.action-msg {
background-color: rgba(51, 51, 51, 0.5);
color: rgb(255, 47, 151);
padding: 8px;
border-radius: 4px;
display: inline-block;
margin: 0 7px;
visibility: hidden;
}
`, ++ruleIndex);
}
function createActionContainer() {
const containerId = getActionContainerId();
let container = document.getElementById(containerId);
if (null === container) {
container = document.createElement("div");
container.setAttribute("id", containerId);
container.style.zIndex = "999";
insertActionContainerStyle();
}
return container;
}
let _actionContainer = null;
function insertActionContainer() {
if (!_actionContainer) {
_actionContainer = createActionContainer();
bodyElement.append(_actionContainer);
containerManager.addBlock(_actionContainer);
}
return _actionContainer;
}
function showActionContainer() {
if (_actionContainer) _actionContainer.style.display = "block";
}
function createActionComponent(name$1) {
const containerDiv = document.createElement("div");
containerDiv.className = "action-container";
const actionButton = document.createElement("button");
actionButton.innerText = name$1;
actionButton.className = "rj-btn-secondary action-btn";
containerDiv.append(actionButton);
return {
element: containerDiv,
actionButton
};
}
function createActionWithMessageComponent(name$1) {
const { element, actionButton } = createActionComponent(name$1);
const messageSpan = document.createElement("span");
messageSpan.className = "action-msg";
element.append(actionButton, messageSpan);
return {
element,
actionButton,
messageSpan
};
}
//#endregion
//#region src/mod_page/tabs_shared.ts
/**
* 不描述 description, files, images 等 tab 专有的信息.
* 仅描述 tabs shared 的信息, 如 game name, mod name, version, gallery 等等.
*/
function getModUrlRegExp() {
return /^((https|http):\/\/(www.)?nexusmods.com\/[a-z0-9]+\/mods\/[0-9]+)/;
}
function isModUrl(url) {
return getModUrlRegExp().test(url);
}
let _gameDomainName = null;
function getGameDomainName() {
_gameDomainName ||= _gameDomainName = new URL(location.href).pathname.split("/")[1];
return _gameDomainName;
}
let _modId = null;
function getModId() {
_modId ||= _modId = parseInt(getSection().getAttribute("data-mod-id"));
return _modId;
}
function getFeaturedBelowDiv() {
return getSection().querySelector(":scope > div.wrap > div:nth-of-type(2).wrap");
}
let _breadcrumbUl = null;
function getBreadcrumbUl() {
_breadcrumbUl ||= _breadcrumbUl = document.getElementById("breadcrumb");
return _breadcrumbUl;
}
let _gameName = null;
function getGameName() {
_gameName ||= getBreadcrumbUl().querySelector(":scope > li:nth-of-type(2)").innerText;
return _gameName;
}
/**
* `div#feature`
*
* 如果 modder 设定了 feature, 则有 `div#feature`,
* 反之没有 `div#feature`, 有 `div#nofeature`
*/
let _featureDiv = null;
function getFeatureDiv() {
_featureDiv ||= _featureDiv = document.getElementById("feature");
return _featureDiv;
}
function getModStatsUl() {
return getPageTitleDiv().querySelector(":scope > ul.stats");
}
function getModActionsComponent() {
const modActionsUl = getModActionsUl();
return {
element: modActionsUl,
get addMediaLi() {
return document.getElementById("action-media");
},
get trackLi() {
return modActionsUl.querySelector(":scope > li[id^=action-track]");
},
get untrackLi() {
return modActionsUl.querySelector(":scope > li[id^=action-untrack]");
},
get downloadLabelLi() {
return modActionsUl.querySelector(":scope > li.dllabel");
},
get vortexLi() {
return document.getElementById("action-nmm");
},
get manualDownloadLi() {
return document.getElementById("action-manual");
}
};
}
let _modName = null;
function getModName() {
if (!_modName) {
/**
* 如 `<meta property="og:title" content="Aspens Ablaze">`
* Aspens Ablaze 是 mod 名
*/
const meta = headElement.querySelector(`meta[property="og:title"]`);
if (meta) _modName = meta.getAttribute("content");
else _modName = getBreadcrumbUl().querySelector(":scope > li:last-child").innerText;
}
return _modName;
}
/**
* `div#pagetitle > ul.stats.clearfix > li.stat-version > div.statitem > div.stat`
*/
let _modVersionDiv = null;
function getModVersionDiv() {
_modVersionDiv ||= _modVersionDiv = getModStatsUl().querySelector(":scope > li.stat-version > div.statitem > div.stat");
return _modVersionDiv;
}
/**
* Mod version can be empty string???
*/
let _modVersion = null;
function getModVersion() {
if (!_modVersion) {
_modVersion = getModVersionDiv().innerText.trim();
if (_modVersion !== "" && parseInt(_modVersion).toString() === _modVersion) _modVersion = "v" + _modVersion;
}
return _modVersion;
}
function getFileInfoDiv() {
return document.getElementById("fileinfo");
}
/**
* `div#sidebargallery`
* @returns
*/
function getModGalleryDiv() {
return document.getElementById("sidebargallery");
}
function getThumbnailGalleryUl() {
const modGalleryDiv = getModGalleryDiv();
return modGalleryDiv ? modGalleryDiv.querySelector(":scope > ul.thumbgallery") : null;
}
function getThumbnailComponent(thumbnailLi) {
return {
element: thumbnailLi,
get figure() {
return thumbnailLi.querySelector(":scope > figure");
},
get anchor() {
return this.figure.querySelector(":scope > a");
},
get img() {
return this.anchor.querySelector(":scope > img");
},
originalImageSrc: thumbnailLi.getAttribute("data-src"),
title: thumbnailLi.getAttribute("data-sub-html"),
src: thumbnailLi.getAttribute(" data-exthumbimage")
};
}
let _modVersionWithDate = null;
function getModVersionWithDate() {
if (!_modVersionWithDate) {
const dateTimeElement = getFileInfoDiv().querySelector(":scope > div.timestamp:nth-of-type(1) > time");
const date = new Date(parseInt(dateTimeElement.getAttribute("data-date") + "000"));
_modVersionWithDate = `${getModVersion()} (${date.getFullYear().toString().substring(2)}.${date.getMonth() + 1}.${date.getDate()})`;
}
return _modVersionWithDate;
}
function getTabsDiv() {
return getFeaturedBelowDiv().querySelector(":scope > div:nth-of-type(2) > div.tabs");
}
let _modTabsUl = null;
function getModTabsUl() {
_modTabsUl ||= _modTabsUl = getTabsDiv().querySelector(":scope > ul.modtabs");
return _modTabsUl;
}
/**
* `div.tabcontent.tabcontent-mod-page`
*
* 设 `tabContentDiv` 为 `div.tabcontent.tabcontent-mod-page`
* 切换 tab 时不会刷新 `tabContentDiv`,
* 会修改 `tabContentDiv` 的 `innerHTML`
*/
let _tabContentDiv = null;
function getTabContentDiv() {
return _tabContentDiv ||= _tabContentDiv = bodyElement.querySelector("div.tabcontent.tabcontent-mod-page");
}
function getCurrentTab() {
return getModTabsUl().querySelector(":scope > li > a.selected > span.tab-label").innerText.toLowerCase();
}
function getTabFromTabLi(tabLi) {
return tabLi.querySelector(":scope > a[data-target] > span.tab-label").innerText.toLowerCase();
}
/**
* 用于点击 tab 时, 知道点击了哪个 tab.
*
* 可用于控制对应的 action component 的显示/隐藏.
*
* @param callback
*/
function clickTabLi(callback) {
const tabLis = getModTabsUl().querySelectorAll(":scope > li[id^=mod-page-tab]");
for (const tabLi of tabLis) tabLi.addEventListener("click", (event) => {
callback(getTabFromTabLi(tabLi), event);
});
}
//#endregion
//#region src/mod_page/files_tab.ts
/**
* https://www.nexusmods.com/skyrimspecialedition/mods/14449?tab=files
* https://www.nexusmods.com/skyrimspecialedition/mods/14449/?tab=files 可能是历史原因, 导致多了一个 '/'
* 可能是历史原因, 甚至有时候切换 tab 时, url 不变...
*/
/**
* 暂且这样判断吧
* @returns
*/
function isFilesTab() {
return getCurrentTab() === "files" && getModFilesDiv() !== null && getArchivedFilesContainerDiv() === null;
}
/**
* `div#mod_files`
*/
function getModFilesDiv() {
return document.getElementById("mod_files");
}
/**
* 获取 `div.premium-banner.container`
*/
function getPremiumBannerDiv() {
return getTabContentDiv().querySelector("div.premium-banner.container");
}
function getAllSortByDivs() {
const modFilesDiv = getModFilesDiv();
return modFilesDiv ? modFilesDiv.querySelectorAll("div.file-category-header > div:nth-of-type(1)") : null;
}
function getAllFileHeaderDts() {
const modFilesDiv = getModFilesDiv();
return modFilesDiv ? modFilesDiv.querySelectorAll("dl.accordion > dt") : null;
}
function getAllFileDescriptionDds() {
const modFilesDiv = getModFilesDiv();
return modFilesDiv ? modFilesDiv.querySelectorAll("dl.accordion > dd") : null;
}
function getDownloadButtonContainerDiv(fileDescriptionDd) {
return fileDescriptionDd.querySelector("div.tabbed-block:nth-of-type(2)");
}
/**
* @param headerDtOrDescriptionDd header `<dt>` 或 description `<dd>` 都有属性 `data-id`
* @returns
*/
function getFileId(headerDtOrDescriptionDd) {
return parseInt(headerDtOrDescriptionDd.getAttribute("data-id"));
}
function getFileDescriptionDiv(fileDescriptionDd) {
return fileDescriptionDd.querySelector("div.files-description");
}
function getFileDescriptionComponent(fileDescriptionDd) {
const fileId = getFileId(fileDescriptionDd);
const fileDescriptionDiv = getFileDescriptionDiv(fileDescriptionDd);
const downloadButtonContainerDiv = getDownloadButtonContainerDiv(fileDescriptionDd);
const previewFileDiv = fileDescriptionDd.querySelector("div.tabbed-block:last-child");
const realFilename = previewFileDiv.querySelector("a").getAttribute("data-url");
downloadButtonContainerDiv.querySelector("ul > li:last-child > a");
return {
fileId,
fileDescriptionDiv,
downloadButtonContainerDiv,
previewFileDiv,
realFilename
};
}
function getOldFilesComponent() {
const element = document.getElementById("file-container-old-files");
if (!element) return null;
const categoryHeaderDiv = element.querySelector(":scope > div.file-category-header");
return {
element,
categoryHeaderDiv,
get headerH2() {
return categoryHeaderDiv.querySelector(":scope > h2:first-child");
},
get sortByContainerDiv() {
return categoryHeaderDiv.querySelector(":scope > div:last-child");
}
};
}
function getFileArchiveSection() {
return document.getElementById("files-tab-footer");
}
//#endregion
//#region src/mod_page/archived_files_tab.ts
/**
* eg. `https://www.nexusmods.com/skyrimspecialedition/mods/2182/?tab=files&category=archived`
*/
function isArchivedFilesUrl(url) {
const searchParams = new URL(url).searchParams;
return isModUrl(url) && searchParams.get("tab") === "files" && searchParams.get("category") === "archived";
}
function getArchivedFilesContainerDiv() {
return document.getElementById("file-container-archived-files");
}
/**
* 暂且这样判断吧
* @returns
*/
function isArchivedFilesTab() {
return getCurrentTab() === "files" && getModFilesDiv() !== null && getArchivedFilesContainerDiv() !== null;
}
//#endregion
//#region src/mod_page/description_tab.ts
function isDescriptionTab() {
return getCurrentTab() === "description";
}
/**
* 容器 of 官方预设的描述模板
* tab 内容未加载完成时, 返回 null
*/
function getTabDescriptionContainerDiv() {
return getTabContentDiv().querySelector(":scope > div.container.tab-description");
}
/**
* `briefOverview` cache
*/
function getBriefOverview() {
const tabDescriptionContainerDiv = getTabDescriptionContainerDiv();
if (!tabDescriptionContainerDiv) return null;
return tabDescriptionContainerDiv.querySelector(":scope > p:nth-of-type(1)").innerText.trimEnd();
}
/**
* Has "Share", "Report Abuse" buttons.
* @returns
*/
function getActionsUl() {
const tabDescriptionContainerDiv = getTabDescriptionContainerDiv();
return tabDescriptionContainerDiv ? tabDescriptionContainerDiv.querySelector(":scope > ul.actions") : null;
}
function getDescriptionDl() {
const tabDescriptionContainerDiv = getTabDescriptionContainerDiv();
return tabDescriptionContainerDiv ? tabDescriptionContainerDiv.querySelector(":scope > div.accordionitems > dl.accordion") : null;
}
function getDescriptionDtDdMap() {
const descriptionDl = getDescriptionDl();
if (!descriptionDl) return null;
const descriptionDtDdMap = /* @__PURE__ */ new Map();
const ddDts = Array.from(descriptionDl.children).filter((child) => child.tagName === "DT" || child.tagName === "DD");
for (let i = 0; i < ddDts.length; i = i + 2) descriptionDtDdMap.set(ddDts[i], ddDts[i + 1]);
return descriptionDtDdMap;
}
function getPermissionDescriptionComponent() {
const descriptionDtDdMap = getDescriptionDtDdMap();
if (!descriptionDtDdMap) return null;
for (const [dt, dd] of descriptionDtDdMap) if (dt.innerText.trim().startsWith("Permissions and credits")) {
const tabbedBlockDivs = dd.querySelectorAll(":scope > div.tabbed-block");
let permissionDiv = null, authorNotesDiv = null, authorNotesContentP = null, fileCreditsDiv = null, fileCreditsContentP = null, donationDiv = null;
for (const tabbedBlockDiv of tabbedBlockDivs) switch (tabbedBlockDiv.querySelector(":scope > h3").innerText) {
case "Credits and distribution permission":
permissionDiv = tabbedBlockDiv;
break;
case "Author notes":
authorNotesDiv = tabbedBlockDiv;
authorNotesContentP = authorNotesDiv.querySelector(":scope > p");
break;
case "File credits":
fileCreditsDiv = tabbedBlockDiv;
fileCreditsContentP = fileCreditsDiv.querySelector(":scope > p");
break;
case "Donation Points system":
donationDiv = tabbedBlockDiv;
break;
}
return {
titleDt: dt,
descriptionDd: dd,
permissionDiv,
authorNotesDiv,
authorNotesContentP,
fileCreditsDiv,
fileCreditsContentP,
donationDiv
};
}
return null;
}
/**
* 容器 of 作者自定义的描述
* tab 内容未加载完成时, 返回 null
*/
function getModDescriptionContainerDiv() {
return getTabContentDiv().querySelector(":scope > div.container.mod_description_container");
}
//#endregion
//#region src/util.ts
/**
* cache codes of other file
* @see `file:///D:/Workspaces/@lyne408/ecmascript_lib/src/path_util.ts`
*
* @param pathArg
* @returns
*/
function replaceIllegalChars(pathArg) {
const replacerMap = {
"?": "?",
"*": "*",
":": ":",
"<": "<",
">": ">",
"\"": """,
"/": " ∕ ",
"\\": " ⧵ ",
"|": "|"
};
pathArg = pathArg.trim();
return pathArg.replace(/(\?)|(\*)|(:)|(<)|(>)|(")|(\/)|(\\)|(\|)/g, (found) => replacerMap[found]);
}
/**
* cache codes of other file
* @param targetNode
* @param callback
* @returns
*/
function observeDirectChildNodes(targetNode, callback) {
const observer = new MutationObserver((mutationList) => {
callback(mutationList, observer);
});
observer.observe(targetNode, {
childList: true,
attributes: false,
subtree: false
});
return observer;
}
/**
* cache codes of other file
* @param targetNode
* @param callback
* @returns
*/
function observeAddDirectChildNodes(targetNode, callback) {
return observeDirectChildNodes(targetNode, (mutationList, observer) => {
for (let index = 0; index < mutationList.length; index++) if (mutationList[index].addedNodes.length > 0) {
callback(mutationList, observer);
break;
}
});
}
//#endregion
//#region ../../../../Workspaces/@lyne408/userscript_lib/src/value.ts
function setValue(name$1, value) {
return GM_setValue(name$1, value);
}
function getValue(name$1) {
return GM_getValue(name$1);
}
//#endregion
//#region ../../../../Workspaces/@lyne408/userscript_lib/src/download.ts
/**
* `@grant GM_download`
* `saveAs = false`
* @param argObj
* @returns
*/
const downloadFile = async (argObj) => {
argObj.saveAs = argObj.saveAs ? argObj.saveAs : false;
return new Promise((resolve) => {
GM_download({
...argObj,
onload() {
resolve(Object.assign(argObj, { success: true }));
},
onerror(error) {
resolve(Object.assign(argObj, {
success: false,
error
}));
},
ontimeout() {
resolve(Object.assign(argObj, {
success: false,
error: "timeout"
}));
},
onprogress: argObj.onprogress
});
});
};
/**
* 适合下载到多个目录
*
* `@grant GM_download`
*
* simultaneous = 3, saveAs = false, directory = './'
*
* [lyne408] 下载 URL.createObjectURL() 的返回值 (如 `URL.createObjectURL(blob)`) 可能会遇到 error,
* 如 error `{ id: "fa04ee91-710e-4905-9b85-a9f016552f3d", error: "not_whitelisted" }`.
* not_whitelisted - the requested file extension is not whitelisted.
* 曾遇到不允许下载 JSON, HTML 文件. 在 Tampmonkey 的 Settings -> Download BETA -> Whitelisted File Extensions 里添加.
* @param items
* @param simultaneous
* @param eachSuccess
* @param eachFail
* @returns
*/
const downloadFiles = async (argObj) => {
argObj.saveAs = argObj.saveAs ? argObj.saveAs : false;
argObj.simultaneous = argObj.simultaneous ? argObj.simultaneous : 3;
const { items, simultaneous, successEach, failEach, onProgressEach } = argObj;
const itemsParts = [];
for (let i = 0; i < items.length; i = i + simultaneous) itemsParts.push(items.slice(i, i + simultaneous));
const successes = [];
const fails = [];
await Promise.all(itemsParts.map(async (itemsPart) => {
for (const item of itemsPart) {
const { url, name: name$1 } = item;
const downloadResult = await downloadFile({
url,
name: name$1,
...argObj,
onprogress: (progressRes) => {
typeof onProgressEach === "function" && onProgressEach(item, progressRes);
}
});
if (downloadResult.success) {
successes.push(item);
typeof successEach === "function" && successEach(item);
} else {
fails.push(item);
typeof failEach === "function" && failEach(item, downloadResult.error);
}
}
}));
return {
successes,
fails
};
};
//#endregion
//#region ../../../../Workspaces/@lyne408/ecmascript_lib/src/time_util.ts
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
//#endregion
//#region src/mod_page/tabs_shared_actions.ts
function createCopyModNameAndVersionComponent() {
const { actionButton, messageSpan, element } = createActionWithMessageComponent("Copy Mod Name And Version");
messageSpan.innerText = "Copied";
actionButton.addEventListener("click", () => {
navigator.clipboard.writeText(`${getModName()} ${getModVersionWithDate()}`).then(() => {
messageSpan.style.visibility = "visible";
setTimeout(() => messageSpan.style.visibility = "hidden", 1e3);
}, () => console.log("%c[Error] Copy failed.", "color: red"));
});
return element;
}
/**
* @param currentTab
*
* 因 Firefox 保存书签时, 若书签名包含换行, 直接省略换行符
*
* 这里替换 brief overview 中的换行为空格
*/
function tweakTitleInner(currentTab) {
if (currentTab === "description") {
let briefOverview = getBriefOverview();
briefOverview = briefOverview ? briefOverview.replaceAll(/\r\n|\n/g, " ") : "";
titleElement.innerText = `${getModName()} ${getModVersionWithDate()}: ${briefOverview}`;
} else titleElement.innerText = `${getModName()} ${getModVersionWithDate()} tab=${currentTab}`;
}
/**
* tweak title after open mod urls (mod page or any tabs) or click tabs
*/
function tweakTitleAfterClickingTab() {
let oldTab = getCurrentTab();
tweakTitleInner(oldTab);
clickTabLi(async (clickedTab) => {
if (oldTab !== clickedTab) {
if (clickedTab === "description" && getBriefOverview() === null) await clickedTabContentLoaded();
oldTab = clickedTab;
tweakTitleInner(clickedTab);
}
});
}
function hideModActionsSylin527NotUse() {
const { addMediaLi, downloadLabelLi, vortexLi, manualDownloadLi } = getModActionsComponent();
addMediaLi && (addMediaLi.style.display = "none");
downloadLabelLi && (downloadLabelLi.style.display = "none");
manualDownloadLi && (manualDownloadLi.style.display = "none");
vortexLi && (vortexLi.style.display = "none");
}
/**
* 从逻辑来讲, 应该是 UI 逻辑分离, 暂时懒得写了
* 如果有 thumb gallery, 添加按钮, 并 bind event
* 点击按钮的逻辑: 页面最宽为 body 宽度, 所有 thumbnails 都以原图显示在页面
*
* 作者不上传 mod 图片时, 应该没有 `#sidebargallery > ul.thumbgallery`
*
* 如果没有 gallery, 返回 null
*/
function createShowAllGalleryThumbnailsComponent() {
let hasShowAll = false;
const { actionButton, element } = createActionComponent("Show All Thumbnails");
actionButton.addEventListener("click", async () => {
if (hasShowAll) {
window.dispatchEvent(new Event("resize"));
await delay(500);
hasShowAll = false;
actionButton.innerText = "Show All Thumbnails";
return;
}
const thumbGalleryUl = getThumbnailGalleryUl();
if (!thumbGalleryUl) return;
thumbGalleryUl.style.height = "max-content";
thumbGalleryUl.style.width = "auto";
thumbGalleryUl.style.zIndex = "99999";
const thumbLis = thumbGalleryUl.querySelectorAll(":scope > li.thumb");
for (const thumbLi of thumbLis) {
const { figure, anchor, img } = getThumbnailComponent(thumbLi);
thumbLi.style.height = "auto";
thumbLi.style.width = "auto";
thumbLi.style.marginBottom = "7px";
figure.style.height = "auto";
anchor.style.top = "0";
anchor.style.transform = "unset";
img.style.maxHeight = "unset";
}
hasShowAll = true;
actionButton.innerText = "Hide Extra Thumbnails";
});
return element;
}
/**
* 默认是选中的
* 返回的对象的属性 checked 是一个 getter
*/
function insertCheckboxToThumbnails() {
const thumbGalleryUl = getThumbnailGalleryUl();
if (!thumbGalleryUl) return null;
const componentsWithCheckedProperty = [];
const thumbLis = thumbGalleryUl.querySelectorAll(":scope > li.thumb");
for (const thumbLi of thumbLis) {
const component = getThumbnailComponent(thumbLi);
const { figure } = component;
const input = document.createElement("input");
input.setAttribute("type", "checkbox");
input.setAttribute("style", "position: absolute; top: 4px; right: 4px; width: 20px; height: 20px; cursor: pointer;");
input.addEventListener("click", (event) => {
event.stopPropagation();
}, { capture: true });
figure.appendChild(input);
componentsWithCheckedProperty.push(Object.defineProperty(component, "checked", {
get() {
return input.checked;
},
set(value) {
input.checked = value;
}
}));
}
return componentsWithCheckedProperty;
}
let hasSelectAll = false;
function createSelectAllImagesComponent(components) {
const { actionButton, element } = createActionComponent("Select All Images");
actionButton.addEventListener("click", () => {
if (!hasSelectAll) {
for (const component of components) component.checked = true;
hasSelectAll = true;
actionButton.innerText = "Deselect All Images";
} else {
for (const component of components) component.checked = false;
hasSelectAll = false;
actionButton.innerText = "Select All Images";
}
});
return element;
}
/**
* @param components
* @param relativeDirectory will `replaceIllegalChars()`
* @param eachSuccess
* @param eachFail
* @returns
*/
function downloadSelectedImages(components, relativeDirectory, eachSuccess, eachFail) {
const allThumbnailCount = components.length;
const digits = allThumbnailCount.toString().length;
const checkedImages = [];
for (let i = 0; i < allThumbnailCount; i++) {
const { checked, originalImageSrc, title } = components[i];
if (checked) {
const extWithDot = originalImageSrc.substring(originalImageSrc.lastIndexOf("."));
const name$1 = `${relativeDirectory}/${(i + 1).toString().padStart(digits, "0")}_${replaceIllegalChars(title)}${extWithDot}`;
checkedImages.push({
url: originalImageSrc,
name: name$1
});
}
}
return downloadFiles({
items: checkedImages,
simultaneous: 3,
successEach: eachSuccess,
failEach: eachFail
});
}
function createDownloadSelectedImagesComponent() {
const fragment = document.createDocumentFragment();
const { actionButton: downloadButton, messageSpan, element: downloadDiv } = createActionWithMessageComponent("Download Selected Images");
fragment.append(downloadDiv);
const modGalleryDiv = getModGalleryDiv();
if (!(isModUrl(location.href) && modGalleryDiv)) {
downloadButton.innerText = "Download Selected Images (Gallery Not Found)";
downloadButton.style.display = "none";
return fragment;
}
const componentsHasCheckedProperty = insertCheckboxToThumbnails();
const selectButton = createSelectAllImagesComponent(componentsHasCheckedProperty);
fragment.insertBefore(selectButton, downloadDiv);
downloadButton.addEventListener("click", () => {
messageSpan.style.visibility = "visible";
const selectedCount = componentsHasCheckedProperty.filter(({ checked }) => checked).length;
if (selectedCount === 0) {
messageSpan.innerText;
return;
}
const downloadedCountSpan = document.createElement("span");
downloadedCountSpan.innerText = "0";
const failedCountSpan = document.createElement("span");
failedCountSpan.innerText = "0";
messageSpan.innerText = "";
messageSpan.append(`Selected: ${selectedCount}`, " ", "Downloaded: ", downloadedCountSpan, " ", "Failed: ", failedCountSpan);
let downloadedCount = 0;
let failedCount = 0;
downloadSelectedImages(componentsHasCheckedProperty, `${getGameName()}/${getModName()} ${getModVersionWithDate()}`, () => {
downloadedCount++;
downloadedCountSpan.innerText = downloadedCount.toString();
downloadedCount === selectedCount && (messageSpan.innerText = `Done: ${selectedCount}/${selectedCount}`);
}, () => {
failedCount++;
failedCountSpan.innerText = failedCount.toString();
});
});
return fragment;
}
/**
* 如果有 div#feature, 清除其 style 属性, 更改其 id 为 nofeature 以使用 nofeature 样式.
*
* 对比 div#feature, div#nofeature 样式为: 清除背景, 减小 height.
*
* 2023-10-20 最新的 SSE mod page 变化, 需要额外清除 header img
*/
function removeFeature() {
const featureDiv = getFeatureDiv();
if (!featureDiv) return;
featureDiv.removeAttribute("style");
featureDiv.querySelector(":scope > div.header-img")?.remove();
featureDiv.setAttribute("id", "nofeature");
}
/**
* 不改变页面结构, 仅移除不需部分
* @param element
*/
function mainContentDivAsTopElement() {
bodyElement.style.marginTop = "0";
getHeader()?.remove();
removeFeature();
getModGalleryDiv()?.remove();
getFooter()?.remove();
getBackToTop()?.remove();
}
function removeModGallery() {
getModGalleryDiv()?.remove();
}
/**
* 点击 tab 时, Nexusmods 先删除原 tabContentDiv.querySelectorAll("div"), 再新增
*
* 点 tab 后, 有一个 MutationRecord[], 一般含有两个 MutationRecord.
* 第一个 MutationRecord 是删除 tabContentDiv 下的原 tab 的 childNodes
* 第二个 MutationRecord 是增加新 tab 的 的childNodes 到 ContentDiv
*/
function clickedTabContentLoaded() {
return new Promise((resolve) => {
observeAddDirectChildNodes(getTabContentDiv(), (mutationList, observer) => {
console.log("tabContentDiv add childNodes mutationList:", mutationList);
observer.disconnect();
resolve(0);
});
});
}
async function controlComponentDisplayAfterClickingTab(component, isShow) {
const style = component.style;
async function _inner(currentTab) {
await isShow(currentTab) ? style.display = "block" : style.display = "none";
}
await _inner(getCurrentTab());
clickTabLi(async (clickedTab) => {
await _inner(clickedTab);
});
}
//#endregion
//#region src/site_shared_actions.ts
/**
* 从 Userscript 的 Storage 里获取
* @returns
*/
function isSylin527() {
const value = getValue("isSylin527");
return typeof value === "boolean" ? value : false;
}
function getSpoilerToggleInputClassName() {
return "sylin527_spoiler_toggle_input";
}
function getSpoilerToggleTextClassName() {
return "sylin527_spoiler_toggle_text";
}
let hasInsertedShowSpoilerToggleStyle = false;
function insertShowSpoilerToggleStyle() {
if (hasInsertedShowSpoilerToggleStyle) return;
const newStyle = document.createElement("style");
headElement.appendChild(newStyle);
const sheet = newStyle.sheet;
const spoilerToggleInputCN = getSpoilerToggleInputClassName();
const spoilerToggleTextCN = getSpoilerToggleTextClassName();
let ruleIndex = sheet.insertRule(`
input.${spoilerToggleInputCN},
input.${spoilerToggleInputCN} ~ i.${spoilerToggleTextCN},
input.${spoilerToggleInputCN} ~ i.${spoilerToggleTextCN}::after {
border: 0;
cursor: pointer;
box-sizing: border-box;
display: inline-block;
height: 27px;
width: 60px;
z-index: 999;
position: relative;
vertical-align: middle;
text-align: center;
}
`);
sheet.insertRule(`
input.${spoilerToggleInputCN} {
margin-left: 1px;
z-index: 987654321;
opacity: 0;
}
`, ++ruleIndex);
sheet.insertRule(`
input.${spoilerToggleInputCN} ~ i.${spoilerToggleTextCN} {
font-style: normal;
margin: 0;
padding: 0;
margin-left: -60px;
}
`, ++ruleIndex);
sheet.insertRule(`
input.${spoilerToggleInputCN} ~ i.${spoilerToggleTextCN}::after {
content: attr(unchecked_text);
background-color: ${primaryColor};
font-size: 12px;
color: #E6E6E6;
border-radius: 3px;
font-weight: 400;
line-height: 27px;
margin: 0;
padding: 0;
}
`, ++ruleIndex);
sheet.insertRule(`
input.${spoilerToggleInputCN}:checked ~ i.${spoilerToggleTextCN}::after {
content: attr(checked_text);
background-color: ${highlightColor};
}
`, ++ruleIndex);
sheet.insertRule(`
input.${spoilerToggleInputCN}:checked ~ div.bbc_spoiler_content {
display: none;
}
`, ++ruleIndex);
sheet.insertRule(`
div.bbc_spoiler_content {
display: block;
}
`, ++ruleIndex);
hasInsertedShowSpoilerToggleStyle = true;
}
function showSpoilers(container) {
insertShowSpoilerToggleStyle();
const spoilers = container.querySelectorAll("div.bbc_spoiler");
for (let i = 0; i < spoilers.length; i++) {
const spoiler = spoilers[i];
spoiler.querySelector("div.bbc_spoiler_show")?.remove();
const input = document.createElement("input");
input.className = getSpoilerToggleInputClassName();
input.setAttribute("type", "checkbox");
const iElement = document.createElement("i");
iElement.setAttribute("class", `bbc_spoiler_show ${getSpoilerToggleTextClassName()}`);
iElement.setAttribute("checked_text", "Show");
iElement.setAttribute("unchecked_text", "Hide");
const content = spoiler.querySelector("div.bbc_spoiler_content");
spoiler.insertBefore(input, content);
spoiler.insertBefore(iElement, content);
content.removeAttribute("style");
}
}
/**
* youtube 嵌入式链接 换成 外链接
* 如 <div class="youtube_container"><iframe class="youtube_video" src="https://www.youtube.com/embed/KuO6ortp0ZY" ...></iframe></div>
* 换成 <a src="https://www.youtube.com/watch?v=KuO6ortp0ZY">https://www.youtube.com/watch?v=KuO6ortp0ZY</a>
*
* 技术需求: 替换元素, 文档位置不变
*/
/**
* 获取 Youtube video iframe 的标题需要跨域, 暂不操作
* @param container
* @returns
*/
function replaceYoutubeVideosToAnchor(container) {
const youtubeIframes = Array.from(container.querySelectorAll("iframe.youtube_video"));
if (youtubeIframes.length === 0) return;
for (const iframe of youtubeIframes) {
const parts = iframe.getAttribute("src").split("/");
const videoId = parts[parts.length - 1];
const watchA = document.createElement("a");
const watchUrl = `https://www.youtube.com/watch?v=${videoId}`;
watchA.style.display = "block";
watchA.setAttribute("href", watchUrl);
watchA.innerText = watchUrl;
const parent = iframe.parentNode;
const grandparent = parent.parentNode;
grandparent && grandparent.replaceChild(watchA, parent);
}
}
function replaceThumbnailUrlsToImageUrls(container) {
const imgs = container.querySelectorAll("img");
for (let i = 0; i < imgs.length; i++) {
const src = imgs[i].src;
if (src.startsWith("https://staticdelivery.nexusmods.com") && src.includes("thumbnails")) imgs[i].src = src.replace("thumbnails/", "");
}
}
function removeModActions() {
getModActionsUl().remove();
}
function simplifyDescriptionContent(contentContainerElement) {
replaceYoutubeVideosToAnchor(contentContainerElement);
replaceThumbnailUrlsToImageUrls(contentContainerElement);
showSpoilers(contentContainerElement);
}
function simplifyComments() {
const commentContainerComponent = getCommentContainerComponent();
if (!commentContainerComponent) return;
const { stickyCommentLis, authorCommentLis } = commentContainerComponent;
for (const stickyCommentLi of stickyCommentLis) simplifyDescriptionContent(getCommentContentTextDiv(stickyCommentLi));
for (const authorCommentLi of authorCommentLis) simplifyDescriptionContent(getCommentContentTextDiv(authorCommentLi));
}
function removeAdAndPremiumTips() {
document.querySelectorAll(`
#pw-oop-flex_container,
.ads-top,
.ads-bottom,
.premium-banner,
#pw-oop-left_rail,
#pw-oop-right_rail,
.ads-bottom,
.gg-overlay,
.gg-overlay-reset,
#pw-oop-bottom_rail,
#adBanner,
#adBlockingBanner,
div[id^='google_ads_iframe_'],
div:has(> div[id^='google_ads_iframe_']),
#nonPremiumBanner,
#freeTrialBanner,
.new-new-premium-banner,
.new-new-premium-banner__inner,
div:has(> a[href='https://users.nexusmods.com/account/billing/premium'])
`).forEach((element) => {
element.remove();
});
}
//#endregion
//#region src/mod_page/files_tab_actions.ts
function addShowRealFilenameToggle() {
const modFilesDiv = getModFilesDiv();
if (!modFilesDiv) return;
const input = document.createElement("input");
const toggleInputClassName = "sylin527_real_filenames_toggle_input";
input.className = toggleInputClassName;
input.setAttribute("type", "checkbox");
input.checked = false;
const i = document.createElement("i");
const toggleTextClassName = "sylin527_real_filenames_toggle_text";
i.className = toggleTextClassName;
i.setAttribute("unchecked_text", "Hide Real Filenames");
i.setAttribute("checked_text", "Show Real Filenames");
modFilesDiv.insertBefore(i, modFilesDiv.firstChild);
modFilesDiv.insertBefore(input, modFilesDiv.firstChild);
const style = document.createElement("style");
style.innerHTML = `
input.${toggleInputClassName},
input.${toggleInputClassName} ~ i.${toggleTextClassName},
input.${toggleInputClassName} ~ i.${toggleTextClassName}::after {
border: 0;
cursor: pointer;
box-sizing: border-box;
display: block;
height: 40px;
width: 300px;
z-index: 999;
position: relative;
}
/* input[type=checkbox] 全透明, 但 z-index 最大 */
input.${toggleInputClassName} {
margin: 0 auto;
z-index: 987654321;
opacity: 0;
}
input.${toggleInputClassName} ~ i.${toggleTextClassName} {
font-style: normal;
font-size: 18px;
text-align: center;
line-height: 40px;
border-radius: 5px;
font-weight: 400;
margin: -40px auto -60px auto;
}
/* input[type=checkbox] unchecked 时, 显示了所有的文件 */
input.${toggleInputClassName} ~ i.${toggleTextClassName}::after {
background-color: ${primaryColor};
content: attr(unchecked_text);
border-radius: 3px;
}
/* 因为 input[type=checkbox] 的 z-index 值最大, 所以 :hover 用在此 input 上 */
input.${toggleInputClassName}:hover ~ i.${toggleTextClassName}::after {
background-color: ${primaryHoverColor};
}
/* input[type=checkbox] checked 时, 隐藏了所有的文件 */
input.${toggleInputClassName}:checked ~ i.${toggleTextClassName}::after {
background-color: ${highlightColor};
content: attr(checked_text);
}
input.${toggleInputClassName}:hover:checked ~ i.${toggleTextClassName}::after {
background-color: ${highlightHoverColor};
}
/* 由于 SingFile 默认移除隐藏的内容, 必须先显示. 需要时在隐藏 */
input.${toggleInputClassName}:checked ~ div dd p.${getRealFilenamePClassName()} {
display: none;
}
`;
document.head.appendChild(style);
}
function removePremiumBanner() {
getPremiumBannerDiv()?.remove();
}
function removeAllSortBys() {
const divs = getAllSortByDivs();
divs && Array.from(divs).forEach((sortByDiv) => sortByDiv.remove());
}
function simplifyAllFileHeaders() {
const fileHeaderDts = getAllFileHeaderDts();
if (!fileHeaderDts) return;
for (const fileHeaderDt of fileHeaderDts) fileHeaderDt.style.background = "#2d2d2d";
}
function getRealFilenamePClassName() {
return "sylin527_real_filename_p";
}
let hasInsertedRealFilenamePStyle = false;
function insertRealFilenamePStyle() {
if (hasInsertedRealFilenamePStyle) return;
const newStyle = document.createElement("style");
headElement.appendChild(newStyle);
newStyle.sheet?.insertRule(`
p.${getRealFilenamePClassName()} {
color: #8197ec;
margin-top: 20xp;
}
`, 0);
hasInsertedRealFilenamePStyle = true;
}
function createRealFilenameP(realFilename, fileUrl) {
const realFilenameP = document.createElement("p");
realFilenameP.className = getRealFilenamePClassName();
const newFileUrlAnchor = document.createElement("a");
newFileUrlAnchor.href = fileUrl;
newFileUrlAnchor.innerText = realFilename;
realFilenameP.appendChild(newFileUrlAnchor);
return realFilenameP;
}
/**
* Simplify File Description
*/
function simplifyAllFileDescriptions$1() {
const fileDescriptionDds = getAllFileDescriptionDds();
if (!fileDescriptionDds) return;
insertRealFilenamePStyle();
const gameDomainName = getGameDomainName();
const modId = getModId();
for (const fileDescriptionDd of fileDescriptionDds) {
const { fileDescriptionDiv, downloadButtonContainerDiv, previewFileDiv, realFilename, fileId } = getFileDescriptionComponent(fileDescriptionDd);
simplifyDescriptionContent(fileDescriptionDiv);
downloadButtonContainerDiv.remove();
fileDescriptionDiv.append(createRealFilenameP(realFilename, generateFileUrl(gameDomainName, modId, fileId)));
previewFileDiv.remove();
fileDescriptionDd.style.display = "block";
}
addShowRealFilenameToggle();
}
function insertRemoveOldFilesComponent() {
const oldFilesComponent = getOldFilesComponent();
if (!oldFilesComponent) return null;
const removeButton = document.createElement("button");
removeButton.className = "rj-btn-secondary";
removeButton.innerText = "Remove";
const { element, categoryHeaderDiv, sortByContainerDiv } = oldFilesComponent;
categoryHeaderDiv.insertBefore(removeButton, sortByContainerDiv);
categoryHeaderDiv.insertBefore(document.createTextNode(" "), removeButton);
removeButton.addEventListener("click", () => {
element.remove();
});
containerManager.addInline(removeButton);
}
function simplifyFilesTab() {
removeAdAndPremiumTips();
removePremiumBanner();
removeAllSortBys();
simplifyAllFileHeaders();
simplifyAllFileDescriptions$1();
getFileArchiveSection()?.remove();
mainContentDivAsTopElement();
}
function createSimplifyFilesTabComponent() {
const { actionButton, element } = createActionComponent("Simplify Files Tab");
actionButton.addEventListener("click", () => {
simplifyFilesTab();
containerManager.hideAll();
});
isFilesTab() ? insertRemoveOldFilesComponent() : element.style.display = "none";
controlComponentDisplayAfterClickingTab(element, async (clickedTab) => {
showActionContainer();
const bFilesTab = clickedTab === "files" && await clickedTabContentLoaded() === 0 && isFilesTab();
bFilesTab && insertRemoveOldFilesComponent();
return bFilesTab;
});
return element;
}
//#endregion
//#region src/mod_page/archived_files_tab_actions.ts
/**
* 无默认值
* @returns
*/
function getApiKey() {
return getValue("apikey");
}
/**
* If not configure `apikey` value, return null
* @param gameDomainName
* @param modId
* @returns
*/
async function getArchivedFileIdRealFilenameMap(gameDomainName, modId) {
const apiKey = getApiKey();
if (!apiKey || apiKey === "") return null;
const { files } = await getFiles(gameDomainName, modId, apiKey);
const map = /* @__PURE__ */ new Map();
for (const { file_id, category_id, file_name } of files) category_id === 7 && map.set(file_id, file_name);
return map;
}
/**
* Simplify File Description
*/
async function simplifyAllFileDescriptions() {
const fileDescriptionDds = getAllFileDescriptionDds();
if (!fileDescriptionDds) return;
insertRealFilenamePStyle();
const gameDomainName = getGameDomainName();
const modId = getModId();
const oldFileIdRealFilenameMap = await getArchivedFileIdRealFilenameMap(gameDomainName, modId);
for (const fileDescriptionDd of fileDescriptionDds) {
const fileDescriptionDiv = getFileDescriptionDiv(fileDescriptionDd);
simplifyDescriptionContent(fileDescriptionDiv);
getDownloadButtonContainerDiv(fileDescriptionDd).remove();
const fileId = getFileId(fileDescriptionDd);
const realFilename = oldFileIdRealFilenameMap ? oldFileIdRealFilenameMap.get(fileId) : "File Link";
fileDescriptionDiv.append(createRealFilenameP(realFilename, generateFileUrl(gameDomainName, modId, fileId)));
fileDescriptionDd.style.display = "block";
}
addShowRealFilenameToggle();
}
function tweakTitleIfArchivedFilesTab() {
isArchivedFilesUrl(location.href) && (titleElement.innerText = `${getModName()} ${getModVersionWithDate()} tab=archived_files`);
}
function createSimplifyArchivedFilesTabComponent() {
const { actionButton, element } = createActionComponent("Simplify Archived Files Tab");
actionButton.addEventListener("click", async () => {
removePremiumBanner();
removeAdAndPremiumTips();
removeAllSortBys();
simplifyAllFileHeaders();
await simplifyAllFileDescriptions();
containerManager.hideAll();
});
!isArchivedFilesTab() && (element.style.display = "none");
controlComponentDisplayAfterClickingTab(element, async (clickedTab) => {
showActionContainer();
return clickedTab === "files" && await clickedTabContentLoaded() === 0 && isArchivedFilesTab();
});
return element;
}
//#endregion
//#region src/article_page/article_page.ts
function getArticleUrlRegExp() {
return /^((https|http):\/\/(www.)?nexusmods.com\/[a-z0-9]+\/articles\/[0-9]+)/;
}
function isArticleUrl(url) {
return getArticleUrlRegExp().test(url);
}
function getArticleContainerDiv() {
return getSection().querySelector("div.container");
}
function getArticleElement() {
return getArticleContainerDiv().querySelector(":scope > article");
}
function getArticleInformation() {
const fileInfoDiv = document.getElementById("fileinfo");
if (!fileInfoDiv) return null;
const sideItems = fileInfoDiv.querySelectorAll(":scope > div.sideitem");
for (const sideItem of sideItems) sideItem.querySelector("time");
const addedOnTime = sideItems[0]?.querySelector(":scope > time");
const addedOn = addedOnTime ? addedOnTime.dateTime : "";
const editedOnTime = sideItems[1]?.querySelector(":scope > time");
const editedOn = editedOnTime ? editedOnTime.dateTime : "";
const authorLink = sideItems[2]?.querySelector(":scope > a");
const authorName = authorLink?.innerText.trim() || "";
const authorUserIdMatch = authorLink?.href.match(/\/users\/(\d+)/);
return {
addedOn,
editedOn,
author: {
name: authorName,
userId: authorUserIdMatch ? parseInt(authorUserIdMatch[1]) : 0
}
};
}
//#endregion
//#region ../../../../Workspaces/@lyne408/ecmascript_lib/src/date_util.ts
/**
* 高精度日期格式化函数
* 支持:年(Y)、月(M)、日(D)、小时(H/h)、分钟(m)、秒(s)、毫秒 (SSS), 微秒 (SSSSSS)
* @param date - 输入日期:Date对象、时间戳(number) 或 ISO字符串
* @param format - 格式化模板,如 "YYYY-MM-DD HH:mm:ss.SSSSSS"
* @returns 格式化后的字符串
*/
function formatDate(date, format = "YYYY-MM-DD HH:mm:ss.SSS") {
const d = new Date(date);
if (isNaN(d.getTime())) throw new Error("Invalid date");
const ms = d.getMilliseconds().toString().padStart(3, "0");
let us = "000";
if (typeof date === "string") {
const microMatch = date.match(/\.\d{3}(\d{1,3})/);
if (microMatch) us = microMatch[1].padEnd(3, "0");
}
const precisionStr = ms + us;
const year = d.getFullYear();
const hours = d.getHours();
const matches = {
YYYY: year,
YY: String(year).slice(-2),
MM: String(d.getMonth() + 1).padStart(2, "0"),
M: d.getMonth() + 1,
DD: String(d.getDate()).padStart(2, "0"),
D: d.getDate(),
HH: String(hours).padStart(2, "0"),
H: hours,
hh: String(hours % 12 || 12).padStart(2, "0"),
h: hours % 12 || 12,
mm: String(d.getMinutes()).padStart(2, "0"),
m: d.getMinutes(),
ss: String(d.getSeconds()).padStart(2, "0"),
s: d.getSeconds(),
A: hours >= 12 ? "PM" : "AM",
a: hours >= 12 ? "pm" : "am"
};
return format.replace(/YYYY|YY|MM|M|DD|D|HH|H|hh|h|mm|m|ss|s|A|a|S+/g, (match) => {
if (match.startsWith("S")) return precisionStr.substring(0, match.length);
return String(matches[match] ?? match);
});
}
/**
* 'YY.M.D'
* @param date
* @returns
*/
function formatToShortDate(date) {
return formatDate(date, "YY.M.D");
}
//#endregion
//#region src/article_page/article_page_actions.ts
function simplifyArticlePage() {
removeAdAndPremiumTips();
simplifyComments();
simplifyDescriptionContent(getArticleElement());
const info = getArticleInformation();
if (info && info.editedOn) {
const shortDate = formatToShortDate(new Date(info.editedOn));
titleElement.innerText = `Article - ${getPageTitle()} ${shortDate}`;
} else titleElement.innerText = `Article - ${getPageTitle()}`;
removeModActions();
mainContentDivAsTopElement();
}
function createSimplifyArticlePageComponent() {
const { actionButton, element } = createActionComponent("Simplify Article Page");
actionButton.addEventListener("click", () => {
simplifyArticlePage();
containerManager.hideAll();
});
return element;
}
//#endregion
//#region src/mod_page/description_tab_actions.ts
function showAllDescriptionDds() {
const dtDdMap = getDescriptionDtDdMap();
if (!dtDdMap || dtDdMap.size === 0) return;
const newStyle = document.createElement("style");
headElement.appendChild(newStyle);
const sheet = newStyle.sheet;
const accordionToggle = "sylin527_show_accordion_toggle";
let ruleIndex = sheet.insertRule(`
input.${accordionToggle} {
cursor: pointer;
display: block;
height: 43.5px;
margin: -44.5px 0 1px 0;
width: 100%;
z-index: 999;
position: relative;
opacity: 0;
}
`);
sheet.insertRule(`
input.${accordionToggle}:checked ~ dd{
display: none;
}
`, ++ruleIndex);
for (const [dt, dd] of dtDdMap) {
dt.style.background = "#2d2d2d";
dd.style.display = "block";
dd.removeAttribute("style");
const newPar = document.createElement("div");
const toggle = document.createElement("input");
toggle.setAttribute("class", accordionToggle);
toggle.setAttribute("type", "checkbox");
dd.parentElement.insertBefore(toggle, dd);
newPar.append(dt, toggle, dd);
getDescriptionDl()?.append(newPar);
}
}
function simplifyTabDescription() {
getActionsUl()?.remove();
if (getDescriptionDl()) {
const permissionDescriptionComponent = getPermissionDescriptionComponent();
if (permissionDescriptionComponent) {
const { authorNotesContentP, fileCreditsContentP } = permissionDescriptionComponent;
authorNotesContentP && simplifyDescriptionContent(authorNotesContentP);
fileCreditsContentP && simplifyDescriptionContent(fileCreditsContentP);
}
showAllDescriptionDds();
}
}
function simplifyModDescription() {
const modDescriptionContainerDiv = getModDescriptionContainerDiv();
if (modDescriptionContainerDiv) simplifyDescriptionContent(modDescriptionContainerDiv);
}
/**
* 目的: 点击 description tab 后, url 为 mod url, 而不是 description tab url
* 便于书签管理
*/
function setLocationToModUrlIfDescriptionTab() {
const modUrl = generateModUrl(getGameDomainName(), getModId());
getCurrentTab() === "description" && history.replaceState(null, "", modUrl);
clickTabLi(async (clickedTab) => {
showActionContainer();
clickedTab === "description" && await clickedTabContentLoaded() === 0 && history.replaceState(null, "", modUrl);
});
}
//#endregion
//#region src/mod_page/file_tab.ts
function isFileUrl(url) {
const searchParams = new URL(url).searchParams;
return isModUrl(url) && searchParams.get("tab") === "files" && searchParams.has("file_id");
}
function getFileIdFromUrl(url) {
const fileId = new URL(url).searchParams.get("file_id");
return fileId ? parseInt(fileId) : null;
}
//#endregion
//#region src/mod_page/file_tab_actions.ts
function tweakTitleIfFileTab() {
isFileUrl(location.href) && (titleElement.innerText = `${getModName()} ${getModVersionWithDate()} file=${getFileIdFromUrl(location.href)}`);
}
//#endregion
//#region src/mod_page/forum_tab.ts
function getModTopicsDiv() {
return document.getElementById("tab-modtopics");
}
/**
* ForumTab
* @returns
*/
function getTopicsTabH2() {
return document.getElementById("topics_tab_h2");
}
function isForumTab() {
return getCurrentTab() === "forum" && getTopicsTabH2() !== null;
}
function getTopicTable() {
return document.getElementById("mod_forum_topics");
}
function getAllTopicAnchors() {
const topicTable = getTopicTable();
if (!topicTable) return null;
const topicAnchorsOfTHead = topicTable.tHead.querySelectorAll(":scope > tr > td.table-topic > a.go-to-topic");
const topicAnchorsOfTBody = topicTable.tBodies[0].querySelectorAll(":scope > tr > td.table-topic > a.go-to-topic");
return Array.from(topicAnchorsOfTHead).concat(Array.from(topicAnchorsOfTBody));
}
function clickTopicAnchor(callback) {
const allTopicAnchors = getAllTopicAnchors();
if (allTopicAnchors) for (const topicAnchor of allTopicAnchors) topicAnchor.addEventListener("click", (event) => {
callback(topicAnchor, event);
});
}
//#endregion
//#region src/mod_page/forum_topic_tab.ts
/**
* `<h2 id="comment-count">Intentional clipping setup for No Grass In Objects (1 comment)</h2>`
*/
function getTopicTitle() {
const h2 = document.getElementById("comment-count");
if (!h2) return "";
const titleWithCommentCount = h2.innerText;
const lastLeftParenthesisIndex = titleWithCommentCount.lastIndexOf("(");
return titleWithCommentCount.substring(0, lastLeftParenthesisIndex - 1);
}
/**
* 可以通过打开 ForumTopicUrl 来进入 ForumTopicTab
* 切换 ForumTopicTab 与 ForumTab 不需要重载页面
* @returns
*/
function isForumTopicTab() {
return getCurrentTab() === "forum" && getTopicsTabH2() === null;
}
//#endregion
//#region src/mod_page/forum_tab_actions.ts
/**
* 点击 topicAnchor 后, Nexusmods 修改 modTopicsDiv 的 ChildNodes,
* 没有修改 `div.tabcontent.tabcontent-mod-page` 的 ChildNodes
* @returns
*/
function modTopicsDivAddedDirectChildNodes() {
return new Promise((resolve) => {
observeAddDirectChildNodes(getModTopicsDiv(), (mutationList, observer) => {
observer.disconnect();
resolve(0);
});
});
}
//#endregion
//#region src/mod_page/posts_tab.ts
function isPostsTab() {
return getCurrentTab() === "posts";
}
//#endregion
//#region src/mod_page/posts_tab_actions.ts
function hasStickyOrAuthorComments() {
const commentContainerComponent = getCommentContainerComponent();
if (!commentContainerComponent) return false;
const { authorCommentLis, stickyCommentLis } = commentContainerComponent;
return authorCommentLis.length + stickyCommentLis.length > 0;
}
function createSimplifyPostsTabComponent() {
const { actionButton, element } = createActionComponent("Simplify Posts Tab");
actionButton.addEventListener("click", () => {
removeAdAndPremiumTips();
simplifyComments();
containerManager.hideAll();
mainContentDivAsTopElement();
});
if (!isPostsTab() || isPostsTab() && !hasStickyOrAuthorComments()) element.style.display = "none";
controlComponentDisplayAfterClickingTab(element, async (clickedTab) => {
showActionContainer();
return clickedTab === "posts" && await clickedTabContentLoaded() === 0 && hasStickyOrAuthorComments();
});
return element;
}
//#endregion
//#region src/mod_page/forum_topic_tab_actions.ts
function createSimplifyForumTopicTabComponent() {
const { actionButton, element } = createActionComponent("Simplify Forum Topic Tab");
actionButton.addEventListener("click", () => {
removeAdAndPremiumTips();
titleElement.innerText = replaceIllegalChars(getTopicTitle());
simplifyComments();
containerManager.hideAll();
mainContentDivAsTopElement();
});
(!isForumTopicTab() || isForumTopicTab() && !hasStickyOrAuthorComments()) && (element.style.display = "none");
function _addClickTopicAnchorEvent() {
clickTopicAnchor(async () => {
await modTopicsDivAddedDirectChildNodes();
isForumTopicTab() && hasStickyOrAuthorComments() && (element.style.display = "block");
});
}
isForumTab() && _addClickTopicAnchorEvent();
clickTabLi(async (clickedTab) => {
element.style.display = "none";
showActionContainer();
clickedTab === "forum" && await clickedTabContentLoaded() === 0 && isForumTab() && _addClickTopicAnchorEvent();
});
return element;
}
//#endregion
//#region src/mod_page/mod_page_actions.ts
/**
* mod url page (has description tab)
* @returns
*/
function createSimplifyModPageComponent() {
const { actionButton, element } = createActionComponent("Simplify Mod Page");
actionButton.addEventListener("click", () => {
removeAdAndPremiumTips();
removeFeature();
removeModActions();
removeModGallery();
simplifyTabDescription();
simplifyModDescription();
titleElement.innerText = `${getModName()} ${getModVersionWithDate()}`;
containerManager.hideAll();
mainContentDivAsTopElement();
});
!isDescriptionTab() && (element.style.display = "none");
/**
* 似乎 mod page loaded (description tab) 加载之后,
* `description tab <li>` 还是被 Nexusmods 的 JavaScript 代码 `click` 了一下,
* 但没有刷新 tab content, 也就没有 childList MutationRecord.
* 这时候再 `await clickedTabContentLoaded()` 就得不到返回值了.
* 会导致首次点击其它的 tab, `Simplify Mod Page` button 还是显示.
*/
clickTabLi(async (clickedTab) => {
element.style.display = "none";
showActionContainer();
clickedTab === "description" && await clickedTabContentLoaded() === 0 && isDescriptionTab() && (element.style.display = "block");
});
return element;
}
//#endregion
//#region src/userscripts/userscripts_shared.ts
const author = "sylin527";
//#endregion
//#region src/userscripts/mod_documentation_utility/userscript.header.ts
const name = `Mod Documentations Utility by ${author}`;
const version = "0.2.6.20260430";
//#endregion
//#region src/userscripts/mod_documentation_utility/userscript.main.ts
/**
* 仅初始化 `apikey` 为 `''`
*
* 没有初始化 `isSylin527`
*/
function initStorage() {
!getApiKey() && setValue("apikey", "");
}
function init() {
initStorage();
const href = location.href;
const actionContainer = insertActionContainer();
if (isModUrl(href)) {
tweakTitleAfterClickingTab();
setLocationToModUrlIfDescriptionTab();
actionContainer.append(createCopyModNameAndVersionComponent(), createShowAllGalleryThumbnailsComponent(), createDownloadSelectedImagesComponent());
if (isSylin527()) hideModActionsSylin527NotUse();
actionContainer.append(createSimplifyModPageComponent(), createSimplifyFilesTabComponent(), createSimplifyArchivedFilesTabComponent(), createSimplifyPostsTabComponent(), createSimplifyForumTopicTabComponent());
tweakTitleIfFileTab();
tweakTitleIfArchivedFilesTab();
} else if (isArticleUrl(href)) actionContainer.appendChild(createSimplifyArticlePageComponent());
}
function main() {
init();
const scriptInfo = `Load userscript: ${name} ${version}`;
console.log("%c [Info] " + scriptInfo, "color: green");
console.log("%c [Info] URL: " + location.href, "color: green");
}
main();
//#endregion