bagscript

bag script with anti bot features + more

// ==UserScript==
// @name        bagscript
// @description bag script with anti bot features + more
// @version     0.9.4.1
// @license     MIT
// @namespace   9e7f6239-592e-409b-913f-06e11cc5e545
// @include     https://8chan.moe/v/res/*
// @include     https://8chan.se/v/res/*
// @include     https://8chan.moe/barchive/res/*
// @include     https://8chan.se/barchive/res/*
// @include     https://8chan.moe/test/res/*
// @include     https://8chan.se/test/res/*
// @grant       unsafeWindow
// @run-at      document-idle
// ==/UserScript==

// Script settings
const FUN_TEXT_SELECTOR = ".doomText, .moeText, .redText, .pinkText, .diceRoll, .echoText";
const RUDE_FORMATS = ["JPEG", "JPG", "PNG"];
const THREAD_LOCKED_AT = 1500;
const URL_PREFIX = window.location.href.split("/").slice(0, 4).join("/");

// Colors
const BAN_BUTTON_BORDER = "1px solid red";
const RUDE_BORDER = "1px solid red";
const SPOILER_BORDER = "3px solid red";
const THREAD_FOUND_BORDER = "5px solid green";
const THREAD_NOT_FOUND_BORDER = "5px solid red";

// Janny tool settings
const BOT_BAN_DURATION = "3d";
const BOT_BAN_REASON = "bot";

// Debug settings
const DEBUG_TOOLS_VISIBLE = false;
const FORCE_NEXT_THREAD_FAIL = false;

// Tooltips / Info / Etc
const NOT_A_JANNY = "You aren't a janny dumbass."
const BOT_BAN_BUTTON_WARNING = "WARNING: The ban buttons will immediately issue a ban + "
  + "delete by IP for the poster WITH NO CONFIRMATION. The ban reason and duration can be "
  + "set in the script (refresh after modifying). Are you sure you want to turn this on?";

// State
let checkedJannyStatus = false;
let manualBypass;
let defaultSpoilerSrc;
let loggedInAsJanny = false;
const settings = {};
let styleBuilt = false;
let threadsClosed = false;
let menuVisible = false;

// Style
const _bagStyle = document.createElement("style");
_bagStyle.id = "bagStyle";

const bagStyle = document.head.appendChild(_bagStyle).sheet;
bagStyle.disabled = true;

const styleIndex = {
  "postStub": 2,
  "rudePost": 1,
  "rudePostImage": 0,
};

const postsContainer = document.querySelector(".divPosts");

// New post observer
const postObserver = new MutationObserver((mutations) => {
  for (const mutation of mutations) {
    for (const node of mutation.addedNodes) {
      if (node.nodeType === 1) {
        const isPost = node.classList.contains("postCell");
        if (isPost) {
          if (settings.findNextThread && !threadsClosed) {
            const totalPostCount = document.querySelector("#postCount").innerText;
            if (totalPostCount >= THREAD_LOCKED_AT) {
              threadsClosed = true;
              addNextThreadFakePost();
            }
          }

          const id = postId(node);
          unsafeWindow.posting.idsRelation[id].forEach((innerPost) => {
            processAllPostsById(id);
          });

          node.querySelectorAll(".quoteLink").forEach((quoteLink) => {
            const quotedId = quoteLink.innerText.substring(2);
            const quotedPost = document.getElementById(quotedId);
            if (quotedPost) {
              processSinglePost(quotedPost);
            }
          });
        }
      }
    }
  }
});

// Quote hover observer
const hoverObserver = new MutationObserver((mutations) => {
  for (const mutation of mutations) {
    for (const addedNode of mutation.addedNodes) {
      if (addedNode.className === "quoteTooltip") {
        processSinglePost(addedNode, isNiceId(postId(addedNode)));
        return; // should only ever be one hover post
      }
    }
  }
});

// Inline quote hook
const _tooltips_addInlineClick = unsafeWindow.tooltips.addInlineClick;
unsafeWindow.tooltips.addInlineClick = function (quote, innerPost, isBacklink, quoteTarget, sourceId) {
  if (settings.enabled && innerPost) {
    const post = innerPost.parentElement;
    processSinglePost(post, isNiceId(postId(post)));
  }

  _tooltips_addInlineClick(quote, innerPost, isBacklink, quoteTarget, sourceId);
}

// Loading
loadSettings();
loadMenu();

function onEnableScript() {
  loadStyle();

  const initialPosts = postsContainer.children;
  if (initialPosts.length >= THREAD_LOCKED_AT) {
    threadsClosed = true;
    addNextThreadFakePost(0, true);
  }

  processAllPosts();

  checkIfJanny((isJanny) => {
    if (isJanny) {
      let selector = ".jannyTab";
      if (settings.showJannyTools) selector += ", .jannyTools";
      document.querySelectorAll(selector).forEach((e) => e.style.display = "flex");
    }
  });

  postObserver.observe(postsContainer, {childList: true});
  hoverObserver.observe(document.body, {childList: true});
}

if (settings.enabled) {
  onEnableScript();
}

// Post handling
function processAllPosts() {
  for (const id in unsafeWindow.posting.idsRelation) {
    processAllPostsById(id);
  }

  document.querySelectorAll(".inlineQuote").forEach((inlineQuote) => {
    processSinglePost(inlineQuote);
  });

  const hoverPost = document.querySelector(".quoteTooltip");
  if (hoverPost) {
    processSinglePost(hoverPost);
  }
}

function processAllPostsById(id) {
  unsafeWindow.posting.idsRelation[id].forEach((innerPost) => {
    processSinglePost(innerPost.parentElement, isNiceId(id));
  });
}

function processSinglePost(post, isNiceOverride) {
  // Handle spoilers
  const images = post.querySelectorAll(".uploadCell img");
  images.forEach((image) => {
    const isSpoiler = image.src.includes("spoiler") || image.getAttribute("data-spoiler");
    if (isSpoiler) {
      defaultSpoilerSrc ??= image.src;

      if (settings.enabled && settings.revealSpoilers) {
        image.setAttribute("data-spoiler", true);

        const parent = image.parentElement;
        const fileName = parent.href.split("/")[4];
        const noThumbnail = !fileName.includes(".");

        if (noThumbnail) {
          image.src = `/.media/${fileName}`;
        } else {
          image.src = `/.media/t_${fileName.split(".")[0]}`;
        }

        image.style.border = SPOILER_BORDER;
      } else {
        image.src = defaultSpoilerSrc;
        image.style.border = "0";
      }
    }
  });

  const isOp = post.classList.contains("opCell");
  if (isOp) return;

  const isHover = post.classList.contains("quoteTooltip");

  // Handle rude posts
  const isNice = isNiceOverride ? true : isNicePost(post);
  if (!settings.enabled || isNice) {
    post.classList.add("nicePost");

    if (!isHover) {
      const bypassButton = post.querySelector(".bypassButton");
      if (bypassButton) {
        bypassButton.style.display = "none";
      }

      const jannyTools = post.querySelector(".jannyTools");
      if (jannyTools) {
        jannyTools.remove();
      }
    }

  } else {
    post.classList.remove("nicePost");

    if (!isHover) {
      const bypassButton = post.querySelector(".bypassButton");
      if (bypassButton) {
        bypassButton.style.display = "inline";
      } else {
        post.querySelector(".postInfo.title").appendChild(bypassButtonForPost(post));
      }

      addJannyToolsToPost(post);
    }
  }
}

function isNiceId(id) {
  if (!id) return false;

  for (const innerPost of unsafeWindow.posting.idsRelation[id]) {
    if (isNicePost(innerPost.parentElement)) {
      return true;
    }
  }

  return false;
}
function isNicePost(post) {
  if (post.classList.contains("opCell")) {
    return false;
  }

  const id = postId(post);
  if (!id) return false;

  if (manualBypass[id]) return true;

  const innerPosts = unsafeWindow.posting.idsRelation[id];
  if (!innerPosts) return false;

  const idAboveThreshold = innerPosts.length >= settings.postThreshold;
  if (idAboveThreshold) return true;

  if (settings.whitelist.isYou) {
    const postIsByYou = post.querySelector(".youName");
    if (postIsByYou) return true;
  }

  if (settings.whitelist.isOp) {
    const isOp = document.querySelector(".opCell .labelId").innerText === id;
    if (isOp) return true;
  }

  const backlinks = post.querySelector(".postInfo").querySelectorAll(".panelBacklinks > a");
  const aboveBlThreshold = backlinks.length >= settings.backlinkThreshold;
  if (aboveBlThreshold) return true;

  if (settings.whitelist.hasFunText) {
    const hasFunText = post.querySelector(FUN_TEXT_SELECTOR);
    if (hasFunText) return true;
  }

  // Image heuristics
  const images = post.querySelectorAll(".uploadCell img:not(.imgExpanded)");

  if (settings.whitelist.hasNoImages) {
    const noImages = images.length === 0;
    if (noImages) return true;
  }

  let spoilerCount = 0;
  for (const image of images) {
    if (settings.whitelist.hasSpoilerImage) {
      const spoilerImage = image.getAttribute("data-spoiler") === "true"
      if (spoilerImage) spoilerCount++;

    }

    const format = image.parentElement.href?.split("/")?.[4]?.split(".")?.[1]?.toUpperCase();

    if (settings.whitelist.hasAnimatedPng) {
      const mime = image.parentElement.getAttribute("data-filemime");
      if (mime === "image/apng") return true;
      if (mime === "image/png" && !format) return true;
    }

    if (settings.whitelist.hasGoodExtension) {
      if (format) {
        const notRudeImage = !RUDE_FORMATS.includes(format);
        if (notRudeImage) return true;
      }
    }
  }

  if (images.length > 0 && spoilerCount === images.length) return true;

  return false;
}

// Menu
function loadMenu() {
  document.getElementById("bagMenu")?.remove();

  const menuFragment = new DocumentFragment();

  // Menu container
  const menu = document.createElement("div");
  menuFragment.appendChild(menu);
  menu.id = "bagMenu";
  menu.style.bottom = "0px";
  menu.style.color = "var(--navbar-text-color)";
  menu.style.display = "flex";
  menu.style.gap = "1px";
  menu.style.right = "0px";
  menu.style.padding = "1px";
  menu.style.position = "fixed";

  // Menu contents container
  const menuContents = document.createElement("div");
  menu.appendChild(menuContents);
  menuContents.style.backgroundColor = "var(--navbar-text-color)";
  menuContents.style.border = "1px solid var(--navbar-text-color)";
  menuContents.style.display = menuVisible ? "flex" : "none";
  menuContents.style.flexDirection = "column";
  menuContents.style.gap = "1px";

  // Tabs container
  const tabs = document.createElement("div");
  tabs.style.display = "flex";
  tabs.style.gap = "1px";

  buildGeneralTab(tabs, menuContents);
  buildFilterTab(tabs, menuContents);
  buildFinderTab(tabs, menuContents);
  buildJannyTab(tabs, menuContents);
  buildDebugTab(tabs, menuContents);

  menuContents.appendChild(tabs);
  addToggleButton(menu, menuContents);

  document.getElementsByTagName("body")[0].appendChild(menuFragment);
}

function buildGeneralTab(tabsContainer, contentContainer) {
  const generalTab = makeTab("General");
  tabsContainer.appendChild(generalTab);

  const generalTabContainer = makeTabContainer("General");
  contentContainer.appendChild(generalTabContainer);

  // Enable checkbox
  const enableContainer = makeContainer();
  generalTabContainer.appendChild(enableContainer);

  const enableLabel = makeLabel("Enable Script");
  enableContainer.appendChild(enableLabel);

  const enableCheckbox = makeCheckbox(settings.enabled);
  enableContainer.appendChild(enableCheckbox);
  enableCheckbox.onchange = () => {
    settings.enabled = enableCheckbox.checked;
    unsafeWindow.localStorage.setItem("bag_enabled", settings.enabled);

    if (settings.enabled) {
      onEnableScript();
    } else {
      postObserver.disconnect();
      hoverObserver.disconnect();
      bagStyle.disabled = true;
      processAllPosts();
    }
  };

  // Reveal spoilers checkbox
  const revealContainer = makeContainer();
  generalTabContainer.appendChild(revealContainer);

  const revealLabel = makeLabel("Reveal Spoilers");
  revealContainer.appendChild(revealLabel);

  const revealCheckbox = makeCheckbox(settings.revealSpoilers);
  revealContainer.appendChild(revealCheckbox);
  revealCheckbox.onchange = () => {
    settings.revealSpoilers = revealCheckbox.checked;
    setSetting("bag_revealSpoilers", settings.revealSpoilers);

    processAllPosts();
  };

  // Hide stubs checkbox
  const hs = makeContainer();
  generalTabContainer.appendChild(hs);

  const hsLabel = makeLabel("Hide Stubs");
  hs.appendChild(hsLabel);

  const hsCheckbox = makeCheckbox(settings.hideStubs);
  hs.appendChild(hsCheckbox);
  hsCheckbox.onchange = () => {
    settings.hideStubs = hsCheckbox.checked;
    setSetting("bag_hideStubs", settings.hideStubs);

    const display = settings.hideStubs ? "none" : "inline";
    setStyle("postStub", "display", display, true);
  }
}

function buildFilterTab(tabsContainer, contentContainer) {
  const filterTab = makeTab("Filter");
  tabsContainer.appendChild(filterTab);

  const filterTabContainer = makeTabContainer("Filter");
  contentContainer.appendChild(filterTabContainer);

  // Blur input
  const blurContainer = makeContainer();
  filterTabContainer.appendChild(blurContainer);

  const blurLabel = makeLabel("Blur Strength");
  blurContainer.appendChild(blurLabel);

  const blurInput = makeInput(settings.blurStrength);
  blurContainer.appendChild(blurInput);
  blurInput.onchange = () => {
    settings.blurStrength = blurInput.value;
    unsafeWindow.localStorage.setItem("bag_blurStrength", settings.blurStrength);

    const blurLevel = isNaN(settings.blurStrength) ? 0 : settings.blurStrength;
    const newFilter = `blur(${blurLevel}px)`;
    setStyle("rudePostImage", "filter", newFilter);
  };

  // Filter border checkbox
  const filterBorder = makeContainer();
  filterTabContainer.appendChild(filterBorder);

  const filterBorderLabel = makeLabel("Add Border");
  filterBorder.appendChild(filterBorderLabel);

  const filterBorderCheckbox = makeCheckbox(settings.filterBorder);
  filterBorder.appendChild(filterBorderCheckbox);
  filterBorderCheckbox.onchange = () => {
    settings.filterBorder = filterBorderCheckbox.checked;
    setSetting("bag_filterBorder", settings.filterBorder);

    const rudeBorder = settings.filterBorder ? RUDE_BORDER : "0";
    setStyle("rudePost", "border-right", rudeBorder);
  }

  // Hide filtered checkbox
  const hideContainer = makeContainer();
  filterTabContainer.appendChild(hideContainer);

  const hideLabel = makeLabel("Hide Filtered");
  hideContainer.appendChild(hideLabel);

  const hideCheckbox = makeCheckbox(settings.hideFiltered);
  hideContainer.appendChild(hideCheckbox);
  hideCheckbox.onchange = () => {
    settings.hideFiltered = hideCheckbox.checked;
    unsafeWindow.localStorage.setItem("bag_hideFiltered", settings.hideFiltered);

    const rudeDisplay = settings.hideFiltered ? "none" : "inline-block";
    setStyle("rudePost", "display", rudeDisplay);
  };

  // Whitelist label
  const whitelistContainer = makeContainer();
  filterTabContainer.appendChild(whitelistContainer);

  const whitelistLabel = makeLabel("------- Auto Whitelist -------");
  whitelistContainer.appendChild(whitelistLabel);
  whitelistLabel.style.textAlign = "center";
  whitelistLabel.style.width = "100%";

  // Post threshold input
  const thresholdContainer = makeContainer();
  filterTabContainer.appendChild(thresholdContainer);

  const thresholdLabel = makeLabel("ID Post Count");
  thresholdContainer.appendChild(thresholdLabel);

  const thresholdInput = makeInput(settings.postThreshold);
  thresholdContainer.appendChild(thresholdInput);
  thresholdInput.onchange = () => {
    settings.postThreshold = thresholdInput.value;
    unsafeWindow.localStorage.setItem("bag_postThreshold", settings.postThreshold);

    processAllPosts();
  };

  // Backlink threshold input
  const blThresholdContainer = makeContainer();
  filterTabContainer.appendChild(blThresholdContainer);

  const blThresholdLabel = makeLabel("Post Quoted Count");
  blThresholdContainer.appendChild(blThresholdLabel);

  const blThresholdInput = makeInput(settings.backlinkThreshold);
  blThresholdContainer.appendChild(blThresholdInput);
  blThresholdInput.onchange = () => {
    settings.backlinkThreshold = blThresholdInput.value;
    setSetting("bag_backlinkThreshold", settings.backlinkThreshold);

    processAllPosts();
  };

  filterTabContainer.appendChild(makeHeuristicCheckbox("Is (You)", "isYou"));
  filterTabContainer.appendChild(makeHeuristicCheckbox("Is OP", "isOp"));
  filterTabContainer.appendChild(makeHeuristicCheckbox("Has Fun Text", "hasFunText"));
  filterTabContainer.appendChild(makeHeuristicCheckbox("Has No Images", "hasNoImages"));
  filterTabContainer.appendChild(makeHeuristicCheckbox("Has Only Spoiler Images", "hasSpoilerImage"));
  filterTabContainer.appendChild(makeHeuristicCheckbox("Has Good File Ext", "hasGoodExtension"));
  filterTabContainer.appendChild(makeHeuristicCheckbox("Has Animated PNG", "hasAnimatedPng"));
}

function buildFinderTab(tabsContainer, contentContainer) {
  const finderTab = makeTab("Finder");
  tabsContainer.appendChild(finderTab);

  const finderTabContainer = makeTabContainer("Finder");
  contentContainer.appendChild(finderTabContainer);

  // Thread finder checkbox
  const nextThreadContainer = makeContainer();
  finderTabContainer.appendChild(nextThreadContainer);

  const nextThreadLabel = makeLabel("Enable Thread Finder");
  nextThreadContainer.appendChild(nextThreadLabel);

  const nextThreadCheckbox = makeCheckbox(settings.findNextThread);
  nextThreadContainer.appendChild(nextThreadCheckbox);
  nextThreadCheckbox.onchange = () => {
    settings.findNextThread = nextThreadCheckbox.checked;
    setSetting("bag_findNextThread", settings.findNextThread);
  };

  // Thread subject input
  const subjectContainer = makeContainer();
  finderTabContainer.appendChild(subjectContainer);

  const subjectLabel = makeLabel("Thread Subject");
  subjectContainer.append(subjectLabel);

  const subjectInput = makeInput(settings.threadSubject);
  subjectInput.className = "subjectInput";
  subjectInput.size = 10;
  subjectContainer.appendChild(subjectInput);
  subjectInput.onchange = () => {
    settings.threadSubject = subjectInput.value;
    setSetting("bag_threadSubject", settings.threadSubject);
  }
}

function buildJannyTab(tabsContainer, contentContainer) {
  const jannyTab = makeTab("Janny");
  jannyTab.classList.add("jannyTab");
  jannyTab.style.display = "none";
  tabsContainer.appendChild(jannyTab);

  const jannyTabContainer = makeTabContainer("Janny");
  contentContainer.appendChild(jannyTabContainer);

  // Bot ban checkbox
  const jannyToolsContainer = makeContainer();
  jannyTabContainer.appendChild(jannyToolsContainer);

  const jannyToolsLabel = makeLabel("Janny Tools");
  jannyToolsContainer.appendChild(jannyToolsLabel);

  const jannyToolsCheckbox = makeCheckbox(settings.showJannyTools);
  jannyToolsContainer.appendChild(jannyToolsCheckbox);
  jannyToolsCheckbox.onchange = () => {
    if (jannyToolsCheckbox.checked) {
      if (!loggedInAsJanny) {
        alert(NOT_A_JANNY);
        jannyToolsCheckbox.checked = false;
        return;
      }

      if (!confirm(BOT_BAN_BUTTON_WARNING)) {
        jannyToolsCheckbox.checked = false;
        return;
      }
    }

    settings.showJannyTools = jannyToolsCheckbox.checked;
    setSetting("bag_showJannyTools", settings.showJannyTools);

    processAllPosts();
  }

  // Hash ban checkbox
  // Apparently boards can only have 128 hash bans active, no point making this an option unless that changes
  /*
  const hashBanContainer = makeContainer();
  jannyTabContainer.appendChild(hashBanContainer);

  const hashBanLabel = makeLabel("Ban Buttons: Also Hash Ban");
  hashBanContainer.appendChild(hashBanLabel);

  const hashBanCheckbox = makeCheckbox(settings.jannyHashBan);
  hashBanContainer.appendChild(hashBanCheckbox);
  hashBanCheckbox.onchange = () => {
    settings.jannyHashBan = hashBanCheckbox.checked;
    setSetting("bag_jannyHashBan", settings.jannyHashBan);
  }
  */
}

function buildDebugTab(tabsContainer, contentContainer) {
  if (!DEBUG_TOOLS_VISIBLE) return;

  const debugTab = makeTab("Debug");
  tabsContainer.appendChild(debugTab);

  const debugTabContainer = makeTabContainer("Debug");
  contentContainer.appendChild(debugTabContainer);

  const fakePostButton = makeButton();
  debugTabContainer.appendChild(fakePostButton);
  fakePostButton.innerText = "Test Fake Post";
  fakePostButton.style.backgroundColor = "var(--background-color)";
  fakePostButton.onclick = () => {
    const url = `${URL_PREFIX}/res/1289960.html`
    addFakePost(`fake post test\r\n<a href="${url}">${url}</a>`);
  }

  const triggerThreadCheckButton = makeButton();
  debugTabContainer.appendChild(triggerThreadCheckButton);
  triggerThreadCheckButton.innerText = "Test Thread Finder";
  triggerThreadCheckButton.style.backgroundColor = "var(--background-color)";
  triggerThreadCheckButton.onclick = () => {
    addNextThreadFakePost(0, true);
  }

  const clearStorageButton = makeButton();
  debugTabContainer.appendChild(clearStorageButton);
  clearStorageButton.innerText = "Clear Storage";
  clearStorageButton.style.backgroundColor = "var(--background-color)";
  clearStorageButton.onclick = () => {
    Object.keys(localStorage).filter(x => x.startsWith("bag_")).forEach((x) => localStorage.removeItem(x));
    location.reload();
  }
}

// Post helpers
function postId(post) {
  return post?.querySelector('.labelId')?.innerText;
}

function addFakePost(contents) {
  const outer = document.createElement("div");
  document.querySelector(".divPosts").appendChild(outer);
  outer.className = "fakePost";
  outer.style.marginBottom = "0.25em";

  const inner = document.createElement("div");
  outer.appendChild(inner);
  inner.className = "innerPost";

  const message = document.createElement("div");
  inner.appendChild(message);
  message.className = "divMessage";
  message.innerHTML = contents;

  return inner;
}

function addNextThreadFakePost(initialQueryDelay, includeAutoSage) {
  document.querySelector(".nextThread")?.remove();

  const fakePost = addFakePost(`Searching for next ${settings.threadSubject} thread...`);
  fakePost.classList.add("nextThread");

  const fakePostMessage = document.querySelector(".nextThread .divMessage");
  const delay = FORCE_NEXT_THREAD_FAIL ? 500 : 30000;

  setTimeout(async () => {
    const found = FORCE_NEXT_THREAD_FAIL
      ? false
      : await queryNextThread(fakePost, fakePostMessage, includeAutoSage);

    if (!found) {
      fakePostMessage.innerHTML += `\r\nThread not found, retrying in 30s`;

      let retryCount = 8;
      const interval = setInterval(async () => {
        if (retryCount-- < 0) {
          clearInterval(interval);
          fakePostMessage.innerHTML += "\r\nNEXT THREAD NOT FOUND"
          fakePost.style.border = THREAD_NOT_FOUND_BORDER;
          return;
        }

        const retryFound = await queryNextThread(fakePost, fakePostMessage, includeAutoSage);
        if (retryFound) {
          clearInterval(interval);
        } else {
          fakePostMessage.innerHTML += `\r\nThread not found, retrying in 30s`;
        }
      }, delay);
    }
  }, initialQueryDelay ?? 60000);
}

// returns true if no more retries should be attempted
async function queryNextThread(fakePost, fakePostMessage, includeAutoSage) {
  // Try to fix issues people were having where fakePostMessage was undefined even with the fake post present.
  // Not sure what the actual cause is, haven't been able to replicate
  if (!fakePost) fakePost = document.querySelector(".nextThread");
  if (!fakePostMessage) fakePostMessage = document.querySelector(".nextThread .divMessage");

  const catalogUrl = barchiveToV(`${URL_PREFIX}/catalog.json`);
  unsafeWindow.console.log("searching for next thread", catalogUrl);

  const catalog = FORCE_NEXT_THREAD_FAIL
    ? await mockEmptyCatalogResponse()
    : await fetch(catalogUrl);

  if (catalog.ok) {
    const threads = await catalog.json();
    for (const thread of threads) {
      const notAutoSage = includeAutoSage || !thread.autoSage;
      if (notAutoSage && thread.subject?.includes(settings.threadSubject)) {
        // If the most recent found thread is full a new one hasn't been created yet, break early
        const postCount = thread.postCount ?? 1;
        if (postCount === 1500) break;

        const url = barchiveToV(`${URL_PREFIX}/res/${thread.threadId}.html`);
        fakePostMessage.innerHTML = `${thread.subject} [${postCount} posts]:\r\n<a href=${url}>${url}</a>`;
        fakePost.style.border = THREAD_FOUND_BORDER;
        return true;
      }
    }

    return false;
  } else {
    fakePostMessage.innerHTML = "ERROR WHILE LOOKING FOR NEXT THREAD";
    fakePost.style.border = THREAD_NOT_FOUND_BORDER;
    return true;
  }
}

// LocalStorage Helpers
function loadSettings() {
  // State
  manualBypass = getManualBypass();
  settings.activeTab = getStringSetting("bag_activeTab", "General");

  // General settings
  settings.enabled = getBoolSetting("bag_enabled", true);
  settings.revealSpoilers = getBoolSetting("bag_revealSpoilers", false);
  settings.hideStubs = getBoolSetting("bag_hideStubs", false);

  // Filter settings
  settings.postThreshold = getIntSetting("bag_postThreshold", 4);
  settings.backlinkThreshold = getIntSetting("bag_backlinkThreshold", 3);
  settings.blurStrength = getIntSetting("bag_blurStrength", 10);
  settings.filterBorder = getBoolSetting("bag_filterBorder", false);
  settings.hideFiltered = getBoolSetting("bag_hideFiltered", false);

  // Heuristic settings
  settings.whitelist = {};
  settings.whitelist.isYou = getBoolSetting("bag_whitelist_isYou", true);
  settings.whitelist.isOp = getBoolSetting("bag_whitelist_isOp", true);
  settings.whitelist.hasFunText = getBoolSetting("bag_whitelist_hasFunText", true);
  settings.whitelist.hasNoImages = getBoolSetting("bag_whitelist_hasNoImages", true);
  settings.whitelist.hasSpoilerImage = getBoolSetting("bag_whitelist_hasSpoilerImage", true);
  settings.whitelist.hasGoodExtension = getBoolSetting("bag_whitelist_hasGoodExtension", true);
  settings.whitelist.hasAnimatedPng = getBoolSetting("bag_whitelist_hasAnimatedPng", true);

  // Thread finder settings
  settings.findNextThread = getBoolSetting("bag_findNextThread", true);
  settings.threadSubject = getStringSetting("bag_threadSubject", "/bag/");

  // Janny Settings
  settings.showJannyTools = getBoolSetting("bag_showJannyTools", false);
  settings.jannyHashBan = getBoolSetting("bag_jannyHashBan", false);
}

function setSetting(name, value) {
  unsafeWindow.localStorage.setItem(name, value);
}

function getSetting(name) {
  return unsafeWindow.localStorage.getItem(name);
}

function getBoolSetting(name, defaultValue) {
  const value = getSetting(name);
  if (value === null) return defaultValue;
  return value == "true";
}

function getIntSetting(name, defaultValue) {
  const value = getSetting(name);
  if (value === null) return defaultValue;
  return parseInt(value);
}

function getStringSetting(name, defaultValue) {
  const value = getSetting(name);
  if (value === null) return defaultValue;
  return value
}

function getManualBypass() {
  const bypassVar = `bag_bypass_${unsafeWindow.api.threadId}`;
  const bp = getSetting(bypassVar);
  return (!bp) ? {} : JSON.parse(bp);
}

function setManualBypass() {
  const bypassVar = `bag_bypass_${unsafeWindow.api.threadId}`;
  const bypassData = JSON.stringify(manualBypass);
  unsafeWindow.localStorage.setItem(bypassVar, bypassData);
}

// HTML Helpers
function makeContainer() {
  const container = document.createElement("div");
  container.style.alignItems = "center";
  container.style.backgroundColor = "var(--background-color)";
  container.style.display = "flex";
  container.style.gap = "0.25rem";
  container.style.justifyContent = "space-between";
  container.style.padding = "0.1rem";
  return container;
}

function makeLabel(text) {
  const label = document.createElement("div");
  label.innerText = text;
  label.style.color = "var(--text-color)";
  label.style.userSelect = "none";
  return label;
}

function makeCheckbox(initialValue) {
  const checkbox = document.createElement("input");
  checkbox.type = "checkbox";
  checkbox.style.cursor = "pointer";
  checkbox.checked = initialValue;
  return checkbox;
}

function makeHeuristicCheckbox(label, setting) {
  const container = makeContainer();

  const labelElement = makeLabel(label);
  container.appendChild(labelElement);

  const checkbox = makeCheckbox(settings.whitelist[setting]);
  container.appendChild(checkbox);
  checkbox.onchange = () => {
    settings.whitelist[setting] = checkbox.checked;
    localStorage.setItem(`bag_whitelist_${setting}`, settings.whitelist[setting]);

    processAllPosts();
  };

  return container;
}

function makeInput(initialValue) {
  const input = document.createElement("input");
  input.size = 4;
  input.value = initialValue;
  input.style.border = "1px solid var(--navbar-text-color)";
  return input;
}

function makeButton() {
  const button = document.createElement("div");
  button.style.alignItems = "center";
  button.style.color = "var(--link-color)";
  button.style.cursor = "pointer";
  button.style.display = "flex";
  button.style.padding = "0.25rem 0.75rem";
  button.style.userSelect = "none";
  return button;
}

function bypassButtonForPost(post) {
  const id = postId(post);
  if (!id) return;

  const bypassButton = makeButton();
  bypassButton.innerText = "+";
  bypassButton.className = "bypassButton";
  bypassButton.style.border = "1px solid var(--horizon-sep-color)";
  bypassButton.style.display = "inline";
  bypassButton.style.marginLeft = "1rem";

  bypassButton.onclick = () => {
    bypassButton.style.display = "none";
    manualBypass[id] = true;
    setManualBypass();

    processSinglePost(post);
    processAllPostsById(id);
  };

  return bypassButton;
}

function addJannyToolsToPost(post) {
  const innerPost = post.querySelector(".innerPost");
  const shouldShow = loggedInAsJanny && settings.showJannyTools;

  let tools = post.querySelector(".jannyTools");
  if (tools) {
    tools.style.display = shouldShow ? "flex" : "none";
  } else {
    tools = document.createElement("div");
    tools.className = "jannyTools";
    innerPost.appendChild(tools);

    tools.style.display = shouldShow ? "flex" : "none";
    tools.style.paddingTop = "0.25rem";
    tools.style.gap = "1rem";
    tools.style.justifyContent = "flex-end";
    tools.style.width = "100%";

    addBanButtonToTools(tools, post, "Bot Ban", BOT_BAN_REASON, BOT_BAN_DURATION);
  }

  return tools;
}

function addBanButtonToTools(container, post, buttonText, banReason, banLength) {
  const innerPost = post.querySelector(".innerPost");

  // Bot ban button
  let banButton = post.querySelector(".banButton." + banReason);
  if (!banButton) {
    banButton = document.createElement("div");
    banButton.className = `banButton ${banReason}`;
    container.appendChild(banButton);

    banButton.innerText = buttonText;
    banButton.style.border = BAN_BUTTON_BORDER;
    banButton.style.cursor = "pointer";
    banButton.style.display = "block";
    banButton.style.margin = "0";
    banButton.style.padding = "0.25rem";
    banButton.style.userSelect = "none";

    banButton.onclick = () => {
      const postId = innerPost.querySelector("a.linkQuote").innerText;
      const dummy = document.createElement("div");
      postingMenu.applySingleBan(
        "", 3, banReason, false, 0, banLength, false,
        true, "v", api.threadId, postId, innerPost, dummy
      );

      if (settings.jannyHashBan) {
        const imageLinks = post.querySelectorAll(".uploadCell .imgLink");
        imageLinks.forEach((imageLink) => {
          const hashBanReq = {
            hash: imageLink.href.split("/")[4],
            reason: banReason,
            boardUri: api.boardUri
          };

          api.formApiRequest("placeHashBan", hashBanReq, (status, data) => {
            if (status === "ok") {
              console.log("Applied hash ban on " + hashBanReq.hash);
            } else {
              console.error(`${status} : ${JSON.stringify(data)}`);
            }
          });
        })
      }
    }
  }

  return banButton;
}

function addToggleButton(menu, menuContents) {
  const toggleButton = makeButton();
  menu.appendChild(toggleButton);
  toggleButton.innerText = "<<"
  toggleButton.style.alignSelf = "flex-end";
  toggleButton.style.backgroundColor = "var(--background-color)";
  toggleButton.style.border = "1px solid var(--navbar-text-color)";
  toggleButton.onclick = () => {
    menuVisible = !menuVisible;
    menuContents.style.display = menuVisible ? "flex" : "none";
    toggleButton.innerText = menuVisible ? ">>" : "<<";
  }
}

function makeTab(tabName) {
  const isActive = settings.activeTab === tabName;

  const tab = document.createElement("div");
  tab.innerText = tabName;
  tab.className = "bagTab"
  tab.style.backgroundColor = "var(--background-color)";
  tab.style.color = isActive ? "var(--link-color)" : "var(--text-color)";
  tab.style.cursor = "pointer";
  tab.style.flexGrow = "1";
  tab.style.padding = "0.25rem 0.75rem";
  tab.style.userSelect = "none";

  tab.onclick = () => {
    settings.activeTab = tabName;
    setSetting("bag_activeTab", settings.activeTab);

    // Tab
    document.querySelectorAll(".bagTab").forEach((tab) => {
      tab.style.color = "var(--text-color)";
    });

    tab.style.color = "var(--link-color)";

    // Tab container
    document.querySelectorAll(".bagTabContainer").forEach((tabContainer) => {
      tabContainer.style.display = "none";
    });

    document.querySelector(`.bagTabContainer[data-tab="${tabName}"]`).style.display = "flex";
  };

  return tab;
}

function makeTabContainer(tabName) {
  const isActive = settings.activeTab === tabName;

  const tabContainer = document.createElement("div");
  tabContainer.className = "bagTabContainer";
  tabContainer.setAttribute("data-tab", tabName)
  tabContainer.style.display = isActive ? "flex" : "none";
  tabContainer.style.flexDirection = "column"
  tabContainer.style.gap = "1px";

  return tabContainer;
}

// CSS helpers
function loadStyle() {
  if (!styleBuilt) {
    buildStyle();
    styleBuilt = true;
  }

  bagStyle.disabled = false;
}

function buildStyle() {
  const stubsDisplay = settings.hideStubs ? "none" : "inline";
  bagStyle.insertRule(`div.postCell:has(span.unhideButton) { display: ${stubsDisplay} !important; }`);

  const rudeDisplay = settings.hideFiltered ? "none" : "inline-block";
  const rudeBorder = settings.filterBorder ? RUDE_BORDER : "0";
  bagStyle.insertRule(`div:not(.nicePost) > .innerPost:not(.hidden) { border-right: ${rudeBorder}; display: ${rudeDisplay}; }`);

  const blurLevel = isNaN(settings.blurStrength) ? 0 : settings.blurStrength;
  bagStyle.insertRule(`div:not(.nicePost) > .innerPost:not(.hidden) > .panelUploads .uploadCell img:not(.imgExpanded) { filter: blur(${blurLevel}px); }`);
}

function setStyle(name, prop, val, isImportant = false) {
  const important = isImportant ? "important" : undefined;
  bagStyle.rules[styleIndex[name]].style.setProperty(prop, val, important);
}

// Misc helpers
function barchiveToV(url) {
  return url.replace("barchive", "v");
}

function checkIfJanny(callback) {
  if (checkedJannyStatus) {
    if (callback) callback(loggedInAsJanny);
  } else {
    checkedJannyStatus = true;
    api.formApiRequest("account", {}, (status, data) => {
      if (status !== "ok") return;

      loggedInAsJanny =
        data.ownedBoards?.includes(api.boardUri)
        || data.volunteeredBoards?.includes(api.boardUri);

      if (callback) callback(loggedInAsJanny);
    }, true);
  }
}

// Debug/Test helpers
function mockEmptyCatalogResponse() {
  return Promise.resolve({
    ok: true,
    json: () => Promise.resolve([])
  });
}