// ==UserScript==
// @name Zibzab's GameDox/Rom Upload Helper
// @namespace http://tampermonkey.net/
// @version 1.7
// @description try to take over the world :)
// @author BestGrapeLeaves
// @match https://gazellegames.net/upload.php?groupid=*
// @match https://gazellegames.net/torrents.php?id=*
// @icon https://i.imgur.com/UFOk0Iu.png
// @grant GM_xmlhttpRequest
// @grant GM_listValues
// @grant GM_deleteValue
// @grant GM_setValue
// @grant GM_getValue
// @connect datomatic.no-intro.org
// @license MIT
// ==/UserScript==
// cache class based on GM_setValue, GM_getValue, GM_deleteValue, GM_listValues global functions. With expiration.
class GMCache {
constructor(name) {
this.cache = {};
this.name = name;
}
getKeyName(key) {
return `zibzabhelper.cache${this.name}.${key}`;
}
get(key, fallback) {
const whenNotFound = () =>
typeof fallback === "function" ? fallback() : fallback;
const res = GM_getValue(this.getKeyName(key));
if (res === undefined) {
return whenNotFound();
}
const { value, expires } = res;
if (expires && expires < Date.now()) {
this.delete(key);
return whenNotFound();
}
return value;
}
set(key, value, ttl) {
const expires = Date.now() + ttl;
GM_setValue(this.getKeyName(key), { value, expires });
}
delete(key) {
GM_deleteValue(this.getKeyName(key));
}
cleanUp() {
const keys = GM_listValues();
keys.forEach((key) => {
if (key.startsWith(this.getKeyName(""))) {
const { expires } = GM_getValue(key);
if (expires < Date.now()) {
GM_deleteValue(key);
}
}
});
}
}
// Code is a spaghetti mess, don't read it. Do something else with your time.
(function () {
"use strict";
const noIntroCache = new GMCache("no-intro");
const PARENS_TAGS_REGEX = /\(.*?\)/g;
const NO_INTRO_TAGS_REGEX =
/\((Unl|Proto|Sample|Aftermarket|Homebrew)\)|\(Rev \d+\)|\(v[\d\.]+\)|\(Beta(?: \d+)?\)/;
const GAME_DOX_INSERT = `[align=center] pdf pages
[/align]
`;
const genRomInsert = (
url = "xxx",
filename = "xxx"
) => `[align=center]${filename} matches [url=${url}]No-Intro checksum[/url]
Compressed with [url=https://sourceforge.net/projects/trrntzip/]torrentzip.[/url][/align]
`;
const regionToLanguage = {
USA: "English",
Europe: "English",
Japan: "Japanese",
World: "English",
"USA, Europe": "English",
Other: "English",
Korea: "Korean",
Taiwan: "Chinese",
};
const twoLetterLanguageCodeToGGn = {
en: "English",
de: "German",
fr: "French",
cz: "Czech",
zh: "Chinese",
it: "Italian",
ja: "Japanese",
ko: "Korean",
pl: "Polish",
pt: "Portuguese",
ru: "Russian",
es: "Spanish",
};
const parseLanguage = (region, possiblyLanguages) => {
if (possiblyLanguages === undefined) {
return regionToLanguage[region] || "Other";
}
const twoLetterCodes = possiblyLanguages
.split(",")
.map((l) => l.trim().toLowerCase());
const isLanguages = twoLetterCodes.every((l) => l.length === 2);
if (!isLanguages || twoLetterCodes.length === 0) {
return regionToLanguage[region] || "Other";
}
if (twoLetterCodes.length > 1) {
return "Multi-Language";
}
return twoLetterLanguageCodeToGGn[twoLetterCodes[0]] || "Other";
};
function noIntroLinkForTorrentId(torrentId) {
const links = $(`#torrent_${torrentId} #description a`);
return links
.map(function () {
return $(this).attr("href");
})
.get()
.map((link) => {
const url = new URL(link);
url.protocol = "https:"; // Rarely descriptions have the http protocol
return url.toString();
})
.find(link => link.startsWith("https://datomatic.no-intro.org/"))
}
function fetchNoIntro(url) {
return new Promise((resolve, reject) => {
const cached = noIntroCache.get(url);
if (cached) {
console.log("Using cached no-intro data", url, cached);
resolve({ ...cached, cached: true });
return;
}
GM_xmlhttpRequest({
method: "GET",
url,
timeout: 5000,
onload: ({ responseText }) => {
try {
const parser = new DOMParser();
const scraped = parser.parseFromString(responseText, "text/html");
// HTML is great
const dumpsTitle = [
...scraped.querySelectorAll("td.TableTitle"),
].find((td) => td.innerText.trim() === "Dump(s)");
if (!dumpsTitle) {
window.GMPARSER = scraped;
console.err('zibzab dumps title not found, set parser as global: GMPARSER', responseText);
new Error("No dump's title found");
}
const filename =
dumpsTitle.parentElement.parentElement.parentElement.nextElementSibling
.querySelector(
"table > tbody > tr:nth-child(2) > td:last-child"
)
.innerText.trim();
const title = scraped
.querySelector("tr.romname_section > td")
.innerText.trim();
const parenMatches = title
.match(/\(.+?\)/g)
.map((p) => p.slice(1, -1));
const [region, possiblyLanguages] = parenMatches;
const matchedGGnRegion =
[
"USA",
"Europe",
"Japan",
"Asia",
"Australia",
"France",
"Germany",
"Spain",
"Italy",
"UK",
"Netherlands",
"Sweden",
"Russia",
"China",
"Korea",
"Hong Kong",
"Taiwan",
"Brazil",
"Canada",
"Japan, USA",
"Japan, Europe",
"USA, Europe",
"Europe, Australia",
"Japan, Asia",
"UK, Australia",
"World",
"Region-Free",
"Other",
].find((r) => r === region) || "Other";
const matchedGGnLanguage = parseLanguage(
matchedGGnRegion,
possiblyLanguages
);
const res = { filename, matchedGGnRegion, matchedGGnLanguage };
// One hour seems appropriate
noIntroCache.set(url, res, 1000 * 60 * 60);
resolve({ ...res, cached: false });
} catch (err) {
console.error("zibzab helper failed to parse no-intro:", err);
reject(
new Error(
"Failed to parse no-intro :/\nPlease report to BestGrapeLeaves,\nthe error was logged to the browser console"
)
);
}
},
ontimeout: () => {
reject(new Error("Request to no-intro timed out after 5 seconds"));
},
});
});
}
// We are fetching files for checking, might as well reduce load and save to dom
function fetchTorrentFilesWithoutShowing(torrentId) {
const fromDOM = () =>
$(
`#files_${torrentId} > table > tbody > tr:not(.colhead_dark) > td:first-child`
)
.map(function () {
return $(this).text();
})
.get();
return new Promise((resolve) => {
if ($("#files_" + torrentId).raw().innerHTML === "") {
// $('#files_' + torrentId).gshow().raw().innerHTML = '<h4>Loading...</h4>';
ajax.get(
"torrents.php?action=torrentfilelist&torrentid=" + torrentId,
function (response) {
$("#files_" + torrentId).ghide();
$("#files_" + torrentId).raw().innerHTML = response;
resolve(fromDOM());
}
);
} else {
resolve(fromDOM());
}
});
}
async function checkForTrumpPossibility(torrentId) {
const url = noIntroLinkForTorrentId(torrentId);
if (!url) {
return { trumpable: false, cached: false };
}
let info;
try {
info = await fetchNoIntro(url);
} catch (err) {
return {
trumpable: true,
cached: true, // Might as well wait if an error occurred
inditermint:
"Couldn't determine if the torrent is trumpable -\nFailed fetching No-Intro:\n" +
err.message,
};
}
const expectedFilename =
info.filename.split(".").slice(0, -1).join(".") + ".zip";
const files = await fetchTorrentFilesWithoutShowing(torrentId);
if (files.length !== 1) {
return {
trumpable: true,
expectedFilename,
cached: info.cached,
inditermint:
"Couldn't determine if the torrent is trumpable -\nMultiple/No zip files found in torrent",
};
}
const actualFilename = files[0];
if (expectedFilename !== actualFilename) {
return {
trumpable: true,
expectedFilename,
actualFilename,
cached: info.cached,
};
}
return {
trumpable: false,
expectedFilename,
actualFilename,
cached: info.cached,
};
}
function getNoIntroTorrentsOnPage() {
return $('a[title="Permalink"]')
.map(function () {
const torrentId = $(this)
.attr("href")
.replace(/.*?\?torrentid=/, "");
console.log("ac", torrentId);
const noIntroLink = noIntroLinkForTorrentId(torrentId);
if (!noIntroLink) {
return false;
}
return { torrentId, a: $(this), noIntroLink };
})
.get()
.filter((x) => x);
}
// Add Copy button, stolen boilerplate shamelessly from trump helper script
function insertAddCopyHelpers() {
getNoIntroTorrentsOnPage().forEach(({ torrentId, a, noIntroLink }) => {
const editionInfo = a
.parents(".group_torrent")
.parent()
.prev()
.find(".group_torrent > td > strong")
.text();
// Convert to upload format of edition info
const [editionYear, ...rest] = editionInfo.split(" - ");
const editionName = rest.join(" - ");
const formatedEditionInfo = `${editionName} (${editionYear})`;
const groupId = window.location.href.replace(/.*?\?id=/, "");
const params = new URLSearchParams(url.search);
params.set("groupid", groupId);
params.set("edition", formatedEditionInfo);
params.set("no-intro", noIntroLink);
const addCopyButton = $(
`<a href="upload.php?${params.toString()}" title="Add Copy" id="ac_${torrentId}">AC</a>`
);
$([" | ", addCopyButton]).insertAfter(a);
});
}
async function insertTrumpSuggestions(torrents) {
const checkForTrumpsButton = $("#check-for-no-intro-trumps-button");
checkForTrumpsButton.prop("disabled", true);
checkForTrumpsButton.css("background-color", "pink");
checkForTrumpsButton.css("color", "darkslategray");
checkForTrumpsButton.css("box-shadow", "none");
let trumps = 0;
let prevCached = false;
for (let i = 0; i < torrents.length; i++) {
// timeout to avoid rate limiting
if (!prevCached) {
await new Promise((resolve) => setTimeout(resolve, 500));
}
const torrent = torrents[i];
checkForTrumpsButton.val(
`Checking For Trumps ${i + 1}/${torrents.length}...`
);
const insert = (details, inditermint) => {
const trumpNotice = $(
`<div>
</div>`
);
const trumpNoticeDetails = $(
`<div style="font-weight: normal; color: white;">${details}</div>`
).hide();
const trumpNoticeTitle = $(`
<span style="color: ${
inditermint ? "pink" : "hotpink"
}; font-size: 14px; font-weight: bold;">${
inditermint
? "Unable to determine if torrent is trumpable:"
: "This torrent can be trumped!"
}</span>`);
const trumpNoticeLinks = $(
`<div class="trump-notice-links" id="trump-notice-links-${torrent.torrentId}" style="font-weight: normal; font-size: 11px; display: inline; margin: 5px;"></div>`
);
const trumpNoticeToggleDetailsLink = $(
`<span style="cursor: pointer;">[Expand]</span>`
);
trumpNoticeToggleDetailsLink.click(() => {
const collapsed = trumpNoticeToggleDetailsLink.text() === "[Expand]";
if (collapsed) {
trumpNoticeToggleDetailsLink.text("[Collapse]");
trumpNoticeDetails.show();
} else {
trumpNoticeToggleDetailsLink.text("[Expand]");
trumpNoticeDetails.hide();
}
});
trumpNoticeLinks.append(trumpNoticeToggleDetailsLink);
trumpNoticeTitle.append(trumpNoticeLinks);
trumpNotice.append(trumpNoticeTitle);
trumpNotice.append(trumpNoticeDetails);
let currentlyAdaptedToSmallScreen;
const placeTrumpNotice = () => {
console.log("adapting", window.innerWidth);
if (window.innerWidth <= 800) {
if (currentlyAdaptedToSmallScreen) {
return;
}
currentlyAdaptedToSmallScreen = true;
$(`#torrent${torrent.torrentId}`).css("border-bottom", "none");
trumpNotice.css("margin-left", "25px");
trumpNotice.detach();
trumpNotice.insertAfter(`#torrent${torrent.torrentId}`);
} else {
if (currentlyAdaptedToSmallScreen === false) {
return;
}
currentlyAdaptedToSmallScreen = false;
$(`#torrent${torrent.torrentId}`).css("border-bottom", "");
trumpNotice.css("margin-left", "0px");
trumpNotice.detach();
trumpNotice.appendTo(
`#torrent${torrent.torrentId} > td:first-child`
);
}
};
placeTrumpNotice();
$(window).resize(placeTrumpNotice);
};
const res = await checkForTrumpPossibility(torrent.torrentId);
if (!res.trumpable) {
continue;
}
if (res.inditermint) {
insert(res.inditermint, true);
continue;
}
trumps++;
const pre = (text, bgColor) =>
`<pre style="
padding: 0px;
margin: 0;
background-color: ${bgColor};
color: black;
font-weight: bold;
font-size: 12px;
padding-left: 3px;
padding-right: 3px;
width: fit-content;
">${text}</pre>`;
insert(
`The filename in the torrent is: ${pre(
res.actualFilename,
"lightcoral"
)} but the desired filename, based on <i>No-Intro</i> is: ${pre(
res.expectedFilename,
"lightgreen"
)}`
);
prevCached = res.cached;
}
if (trumps === 0) {
checkForTrumpsButton.val("No Trumps Found");
} else if (trumps === 1) {
checkForTrumpsButton.val("1 Trump Found");
} else {
checkForTrumpsButton.val(`${trumps} Trumps Found`);
}
}
function insertTrumpButtonAndMaybeCheck() {
const torrents = getNoIntroTorrentsOnPage();
if (torrents.length === 0) {
return;
}
const checkForTrumpsButton = $(
`<input id="check-for-no-intro-trumps-button" type="button" value="Check for No-Intro Trumps" style="background: hotpink; color: black; font-weight: bold; margin-left: 10px;"/>`
);
$(".torrent_table > tbody > tr:first-child > td:first-child")
.first()
.append(checkForTrumpsButton);
if (torrents.length <= 4) {
insertTrumpSuggestions(torrents);
}
checkForTrumpsButton.click((e) => {
e.stopImmediatePropagation();
insertTrumpSuggestions(torrents);
});
}
// No Intro Button
function makeNoIntro(filename) {
const tags = filename
? filename
.match(PARENS_TAGS_REGEX)
.filter((p) => NO_INTRO_TAGS_REGEX.test(p))
.join(" ")
: "";
// Release type = ROM
$("select#miscellaneous").val("ROM").change();
// It is a special edition
if (!$("input#remaster").prop("checked")) {
$("input#remaster").prop("checked", true);
Remaster();
}
// Not a scene release
$("#ripsrc_home").prop("checked", true);
// Update title
updateReleaseTitle($("#title").raw().value + " " + tags);
// Get url params
const params = new URLSearchParams(window.location.search);
// Set correct edition (fallback to guessing)
const setEdition = (edition) => {
try {
$("#groupremasters").val(edition).change();
GroupRemaster();
} catch {
// group remaster always throws (regardless of the userscript)
}
};
const editionInfo = params.get("edition");
$("#groupremasters > option").each(function () {
const title = $(this).text().toLowerCase();
console.log("checking", title);
if (editionInfo && title === editionInfo.toLowerCase()) {
setEdition($(this).val());
return false; // This breaks out of the jquery loop
} else {
if (title.includes("no-intro") || title.includes("nointro")) {
setEdition($(this).val());
}
}
});
// Trigger no-intro link scraper
const noIntroLink = params.get("no-intro");
if (noIntroLink) {
$("#no-intro-url-input").val(noIntroLink).change();
}
}
function noIntroUI() {
// elements
const noIntroContainer = $(
`<tr id="no-intro-url" name="no-intro-url">
<td class="label">No-Intro Link</td>
</tr>`
);
const noIntroInput = $(
'<input type="text" id="no-intro-url-input" name="no-intro-url-input" size="70%" class="input_tog" value="">'
);
const noIntroError = $(
'<p id="no-intro-url-error" name="no-intro-url-error" style="color: red; white-space:pre-line;"></p>'
).hide();
const noIntroLoading = $(
'<p id="no-intro-url-loading" name="no-intro-url-loading" style="color: green;">Loading...</p>'
).hide();
// structure
const td = $("<td></td>");
td.append(noIntroInput);
td.append(noIntroError);
td.append(noIntroLoading);
noIntroContainer.append(td);
// utils
const error = (msg) => {
noIntroError.text(msg);
noIntroError.show();
};
const loading = (isLoading) => {
if (isLoading) {
noIntroLoading.show();
} else {
noIntroLoading.hide();
}
};
return { loading, error, noIntroContainer, noIntroInput, noIntroError };
}
function torrentViewPage() {
insertAddCopyHelpers();
insertTrumpButtonAndMaybeCheck();
}
function uploadPage() {
// Insert No Intro button
const nointro = $('<input type="button" value="No-Intro"></input>');
nointro.click(() => makeNoIntro($("#file").val()));
nointro.insertAfter("#file");
// Link parser UI
const { noIntroContainer, noIntroInput, noIntroError, error, loading } =
noIntroUI();
async function submitNoInput() {
noIntroError.hide();
const url = noIntroInput.val();
if (justChecked === url) {
return;
}
if (!url.startsWith("https://datomatic.no-intro.org/")) {
error("Invalid URL");
return;
}
justChecked = url;
loading(true);
try {
const { filename, matchedGGnLanguage, matchedGGnRegion } =
await fetchNoIntro(url);
$("textarea#release_desc").val(genRomInsert(url, filename));
$("select#region").val(matchedGGnRegion);
$("select#language").val(matchedGGnLanguage);
} catch (err) {
error(err.message || err || "An unexpected error has occurred");
} finally {
loading(false);
}
}
// watch link input
let justChecked = "";
noIntroInput.on("paste", (e) => {
e.preventDefault();
const text = e.originalEvent.clipboardData.getData("text/plain");
noIntroInput.val(text);
submitNoInput();
});
noIntroInput.change(submitNoInput);
// React to release type change
$("select#miscellaneous").change(function () {
const selected = $("select#miscellaneous option:selected").text();
if (selected === "GameDOX") {
noIntroContainer.detach();
$("input#release_title").val(
$("input#release_title").val() + " - Manual"
);
$("select#gamedox").val("Guide").change();
$("select#format").val("PDF").change();
$("input#scan").click();
Scan();
$("textarea#release_desc").val(
$("textarea#release_desc").val() + GAME_DOX_INSERT
);
} else if (selected === "ROM") {
noIntroContainer.insertBefore("#regionrow");
$("textarea#release_desc").val(genRomInsert());
} else {
noIntroContainer.detach();
}
});
}
if (window.location.pathname === "/torrents.php") {
torrentViewPage();
} else if (window.location.pathname === "/upload.php") {
uploadPage();
}
})();