Greasy Fork is available in English.

Quest Reader

Makes it more convenient to read and author quests

// ==UserScript==
// @name        Quest Reader
// @author      naileD
// @namespace   QuestReader
// @match       https://tgchan.org/kusaba/*
// @match       https://tezakia.net/kusaba/*
// @match       https://questden.org/kusaba/*
// @match       https://talehole.com/kusaba/*
// @match       https://thatquestsite.org/kusaba/*
// @description Makes it more convenient to read and author quests
// @run-at      document-start
// @version     2022.04.14
// @grant       none
// @license     Unlicense
// @icon        data:image/vnd.microsoft.icon;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAACXBIWXMAAAsSAAALEgHS3X78AAAANklEQVQokWNgoBOI2mJKpEomMvQgNAxRPUy4JGjjJJqoZoSrZmBgWOZzGlk/mlKILBMafxAAAE1pG/UEXzMMAAAAAElFTkSuQmCC
// ==/UserScript==
"use strict";
//entry point is more or less at the end of the script

//enums
const PostType = { UPDATE: 0, AUTHORCOMMENT: 1, SUGGESTION: 2, COMMENT: 3 };
const ThreadType = { QUEST: 0, DISCUSSION: 1, OTHER: 2 };
const BoardThreadTypes = { //board names and cooresponding types of threads they contain
  quest: ThreadType.QUEST,
  questarch: ThreadType.QUEST,
  graveyard: ThreadType.QUEST,
  questdis: ThreadType.DISCUSSION,
  meep: ThreadType.OTHER,
  moo: ThreadType.OTHER,
  draw: ThreadType.OTHER,
  tg: ThreadType.OTHER,
};
const BoardDefaultNames = {
  quest: "Suggestion",
  questarch: "Suggestion",
  graveyard: "Suggestion",
  questdis: "Anonymous",
  meep: "Bob",
  moo: "Anonymous",
  draw: "Anonymous",
  tg: "Anonymous",
};

//function that removes questden's prototype library's overrides of native functions where applicable
var restoreNative = (doc) => {
  var functionsToRemove = ["Function.bind", "Element.hasAttribute", "Element.getElementsByClassName", "Element.Methods.getElementsByClassName", "HTMLElement.prototype.getElementsByClassName",
                           "Element.scrollTo", "Element.Methods.scrollTo", "HTMLElement.prototype.scrollTo", "Element.remove", "Element.Methods.remove", "HTMLElement.prototype.remove", "Array.prototype.toJSON"];
  var functionsToRestore = ["Object.keys", "Object.values", "Array.prototype.find", "Array.prototype.reverse", "Array.prototype.indexOf", "Array.prototype.entries",
                            "Array.prototype.map", "Array.prototype.reduce", "Array.from", "String.prototype.endsWith", "String.prototype.sub", "String.prototype.startsWith"]; //restoring "Function.prototype.bind" causes "problems" in Firefox
  //restoration is done by creating a new window and copying over unmodified functions from that window
  var frame = doc.createElement("iframe");
  frame.style.display = "none";
  frame.style.visibility = "hidden";
  doc.documentElement.appendChild(frame);
  var safeWindow = frame.contentWindow;
  var getObjectAndMethod = (path, root) => {
    path = path.split(".");
    path.reverse();
    var methodName = path.shift();
    var object = path.reduceRight((obj, prop) => obj[prop], root);
    return { object: object, method: methodName };
  }
  functionsToRemove.forEach(path => {
    var unsafe = getObjectAndMethod(path, doc.defaultView);
    delete unsafe.object[unsafe.method];
  });
  functionsToRestore.forEach(path => {
    var unsafe = getObjectAndMethod(path, doc.defaultView);
    var safe = getObjectAndMethod(path, safeWindow);
    unsafe.object[unsafe.method] = safe.object[safe.method];
  });
  //sigh; there's some existing functions that rely on the broken functionality of this overriden method, so I can't outright remove the override
  doc.getElementsByClassName = function(selector) { return [...Object.getPrototypeOf(this).getElementsByClassName.call(this, selector)]; };
  doc._getElementsByXPath = () => { return []; }; //this slow function is now unnecessary since we restored getElementsByClassName
  frame.remove();
}

//UpdateAnalyzer class
//Input: document of the quest
//Output: a Map object with all the quest's posts, where keys are post IDs and values are post types. The post types are Update (0), AuthorComment (1), Suggestion (2), Comment (3); There's no comments... yet.
//Usage: var results = new UpdateAnalyzer().processQuest(document);
class UpdateAnalyzer {
  constructor(options = {}) {
    this.regex = UpdateAnalyzer.getRegexes();
    this.postCache = null; //Used to transfer posts cache to/from this class. Used for debugging purposes.
    this.useCache = options.useCache; //Used for debugging purposes.
    this.debug = options.debug;
    this.debugAfterDate = options.debugAfterDate;
    this.passive = options.passive; //passive mode; treat the thread as a text-quest and ignore any fixes
    this.defaultName = options.defaultName || "Suggestion";
  }

  analyzeQuest(questDoc) {
    var posts = !this.postCache ? this.getPosts(questDoc) : JSON.parse(this.postCache);
    var authorID = posts[0].userID; //authodID is the userID of the first post
    this.threadID = posts[0].postID; //threadID is the postID of the first post

    this.totalFiles = this.getTotalFileCount(posts);
    var questFixes = this.getFixes(this.passive ? 0 : this.threadID); //for quests where we can't correctly determine authors and updates, we use a built-in database of fixes
    if (this.debug && (questFixes.imageQuest !== undefined || Object.values(questFixes).some(fix => Object.values(fix).length > 0))) { console.log(`Quest has manual fixes`); console.log(questFixes); }
    var graphData = this.getUserGraphData(posts, questFixes, authorID); //get user names as nodes and edges for building user graph
    var users = this.buildUserGraph(graphData.nodes, graphData.edges); //build a basic user graph... whatever that means!
    this.author = this.find(users[authorID]);
    this.getUserPostAndFileCounts(posts, users, questFixes); //count the amount of posts and files each user made
    this.imageQuest = !this.passive && this.isImageQuest(questFixes); //image quest is when the author posts files at least 50% of the time; also, all collaborations are image quests
    if (this.debug) console.log(`Image quest: ${this.imageQuest}`);
    if (this.imageQuest) { //in case this is an image quest, merge users a bit differently
      users = this.buildUserGraph(graphData.nodes, graphData.edges, graphData.strongNodes, authorID); //build the user graph again, but with some restrictions
      this.author = this.find(users[authorID]);
      this.processFilePosts(posts, users, questFixes); //analyze file names and merge users based on when one file name is predicted from another
      this.getUserPostAndFileCounts(posts, users, questFixes); //count the amount of posts and files each user posted
      this.mergeMajorityFilePoster(posts, users, questFixes); //consider a user who posted 50%+ of the files in the thread as the author
      this.mergeCommonFilePosters(posts, users, questFixes); //merge certain file-posting users with the quest author
    }
    var postUsers = this.setPostUsers(posts, users, questFixes); //do final user resolution
    var postTypes = this.getFinalPostTypes(posts, questFixes); //determine which posts are updates
    return { postTypes: postTypes, postUsers: postUsers };
  }

  getPosts(questDoc) {
    var posts = new Map(); //dictionary => postID / post object; need to use Map so that the post order is preserved
    var headers = questDoc.body.querySelector(":scope > form").getElementsByClassName("postwidth");
    for (var i = 0; i < headers.length; i++) {
      var postHeaderElement = headers[i];
      var postID = postHeaderElement.firstElementChild.name;
      postID = parseInt(postID !== "s" ? postID : postHeaderElement.querySelector(`a[name]:not([name="s"])`).name);
      var uidElement = postHeaderElement.querySelector(".uid");
      var uid = uidElement.textContent.substring(4);
      var labelEl = postHeaderElement.querySelector("label");
      var subject = labelEl.querySelector(".filetitle");
      subject = subject ? subject.textContent.trim() : "";
      var trip = labelEl.querySelector(".postertrip");
      var name;
      if (trip) { //use tripcode instead of name if it exists
        name = trip.textContent;
      }
      else {
        name = labelEl.querySelector(".postername").textContent.trim();
        name = name == this.defaultName ? "" : name.toLowerCase();
      }
      var fileName = "";
      var fileElement = postHeaderElement.querySelector(".filesize");
      if (fileElement) { //try to get the original file name
        fileName = fileElement.querySelector("a").href;
        var match = fileName.match(this.regex.fileExtension);
        var fileExt = match ? match[0] : ""; //don't need .toLowerCase()
        if (fileExt == ".png" || fileExt == ".gif" || fileExt == ".jpg" || fileExt == ".jpeg") {
          var fileInfo = fileElement.lastChild.textContent.split(", ");
          if (fileInfo.length >= 3) {
            fileName = fileInfo[2].split("\n")[0];
          }
        }
        else {
          fileName = fileName.substr(fileName.lastIndexOf("/") + 1); //couldn't find original file name, use file name from the server instead
        }
        fileName = fileName.replace(this.regex.fileExtension, ""); //ignore file's extension
      }
      var contentElement = postHeaderElement.nextElementSibling;
      var activeContent = !!contentElement.querySelector("img, iframe"); //does a post contain icons
      var postData = { postID: postID, userID: uid, userName: name, fileName: fileName, activeContent: activeContent };
      if (this.useCache) {
        postData.textUpdate = this.regex.fraction.test(subject) || this.containsQuotes(contentElement);
      }
      else {
        postData.subject = subject;
        postData.contentElement = contentElement;
      }
      if (this.useCache || this.debug || this.debugAfterDate) {
        postData.date = Date.parse(labelEl.lastChild.nodeValue);
      }
      posts.set(postID, postData);
    }
    var postsArray = [...posts.values(posts)]; //convert to an array
    if (this.useCache) { //We stringify the object into JSON and then encode it into a Uint8Array to save space, otherwise the database would be too large
      this.postCache = JSON.stringify(postsArray); //Removing Array.prototype.toJSON allows us to safely use JSON.stringify again \o/
    }
    return postsArray;
  }

  getTotalFileCount(posts) {
    var totalFileCount = 0;
    posts.forEach(post => { if (post.fileName || post.activeContent) totalFileCount++; });
    return totalFileCount;
  }

  isImageQuest(questFixes, ignore) {
    if (questFixes.imageQuest !== undefined) {
      return questFixes.imageQuest;
    }
    else {
      return questFixes.collaboration || (this.author.fileCount / this.author.postCount) >= 0.5;
    }
  }

  getUserGraphData(posts, questFixes, authorID) {
    var graphData = { nodes: new Set(), strongNodes: new Set(), edges: {} };
    posts.forEach(post => {
      graphData.nodes.add(post.userID);
      if (post.userName) {
        graphData.nodes.add(post.userName);
        graphData.edges[`${post.userID}${post.userName}`] = { E1: post.userName, E2: post.userID };
      }
      if (post.fileName || post.activeContent) { //strong nodes are user IDs that posted files
        graphData.strongNodes.add(post.userID);
        if (post.userName) {
          graphData.strongNodes.add(post.userName);
        }
        if (post.fileName && post.activeContent && post.userID != authorID && !questFixes.collaboration) { //users that made posts with both file and icons are most likely the author
          graphData.edges[`${authorID}${post.userID}`] = { E1: authorID, E2: post.userID, hint: "fileAndIcons" };
        }
      }
    });
    for (var missedID in questFixes.missedAuthors) { //add missing links to the author from manual fixes
      graphData.edges[`${authorID}${missedID}`] = { E1: authorID, E2: missedID, hint: "missedAuthors" };
      graphData.strongNodes.add(missedID);
    }
    graphData.edges = Object.values(graphData.edges);
    return graphData;
  }

  buildUserGraph(nodes, edges, strongNodes, authorID) {
    var users = {};
    var edgesSet = new Set(edges);
    nodes.forEach(node => {
      users[node] = this.makeSet(node);
    });
    if (!strongNodes) {
      edgesSet.forEach(edge => this.union(users[edge.E1], users[edge.E2]));
    }
    else {
      edgesSet.forEach(edge => { //merge strong with strong and weak with weak
        if ((strongNodes.has(edge.E1) && strongNodes.has(edge.E2)) || (!strongNodes.has(edge.E1) && !strongNodes.has(edge.E2))) {
          this.union(users[edge.E1], users[edge.E2]);
          edgesSet.delete(edge);
        }
      });
      var author = this.find(users[authorID]);
      edgesSet.forEach(edge => { //merge strong with weak, but only for users which aren't the author
        if (this.find(users[edge.E1]) != author && this.find(users[edge.E2]) != author) {
          this.union(users[edge.E1], users[edge.E2]);
        }
      });
    }
    return users;
  }

  processFilePosts(posts, users, questFixes) {
    if (questFixes.collaboration) {
      return;
    }
    var last2Files = new Map();
    var filePosts = posts.filter(post => post.fileName && !questFixes.wrongImageUpdates[post.postID]);
    filePosts.forEach(post => {
      var postUser = this.find(users[post.userID]);
      var postFileName = post.fileName.match(this.regex.lastNumber) ? post.fileName : null; //no use processing files without numbers
      if (post.userName && this.find(users[post.userName]) == this.author) {
        postUser = this.author;
      }
      if (!last2Files.has(postUser)) {
        last2Files.set(postUser, [ null, null ]);
      }
      last2Files.get(postUser).shift();
      last2Files.get(postUser).push(postFileName);
      last2Files.forEach((last2, user) => {
        if (user == postUser) {
          return;
        }
        if ((last2[0] !== null && this.fileNamePredicts(last2[0], post.fileName)) || (last2[1] !== null && this.fileNamePredicts(last2[1], post.fileName))) {
          if (this.debug || (this.debugAfterDate && this.debugAfterDate < post.date)) {
            console.log(`https://questden.org/kusaba/quest/res/${this.threadID}.html#${post.postID} merged (file name) ${postUser.id} with ${user.id} (author: ${this.author.id})`);
          }
          var mergedUser = this.union(user, postUser);
          last2Files.delete(user.parent != user ? user : postUser);
          last2Files.get(mergedUser).shift();
          last2Files.get(mergedUser).push(postFileName);
          if (this.find(this.author) == mergedUser) {
            this.author = mergedUser;
          }
        }
      });
    });
  }

  getUserPostAndFileCounts(posts, users, questFixes) {
    for (var userID in users) {
      users[userID].postCount = 0;
      users[userID].fileCount = 0;
    }
    posts.forEach(post => {
      var user = this.decidePostUser(post, users, questFixes);
      user.postCount++;
      if (post.fileName || post.activeContent) {
        user.fileCount++;
      }
    });
  }

  fileNamePredicts(fileName1, fileName2) {
    var match1 = fileName1.match(this.regex.lastNumber);
    var match2 = fileName2.match(this.regex.lastNumber);
    if (!match1 || !match2) {
      return false;
    }
    var indexDifference = match2.index - match1.index;
    if (indexDifference > 1 || indexDifference < -1) {
      return false;
    }
    var numberDifference = parseInt(match2[1]) - parseInt(match1[1]);
    if (numberDifference !== 2 && numberDifference !== 1) {
      return false;
    }
    var name1 = fileName1.replace(this.regex.lastNumber, "");
    var name2 = fileName2.replace(this.regex.lastNumber, "");
    return this.stringsAreSimilar(name1, name2);
  }

  stringsAreSimilar(string1, string2) {
    var lengthDiff = string1.length - string2.length;
    if (lengthDiff > 1 || lengthDiff < -1) {
      return false;
    }
    var s1 = lengthDiff > 0 ? string1 : string2;
    var s2 = lengthDiff > 0 ? string2 : string1;
    for (var i = 0, j = 0, diff = 0; i < s1.length; i++, j++) {
      if (s1[i] !== s2[j]) {
        diff++;
        if (diff === 2) {
          return false;
        }
        if (lengthDiff !== 0) {
          j--;
        }
      }
    }
    return true;
  }

  mergeMajorityFilePoster(posts, users, questFixes) {
    if (this.author.fileCount > this.totalFiles / 2 || questFixes.collaboration) {
      return;
    }
    for (var userID in users) {
      if (users[userID].fileCount >= this.totalFiles / 2 && users[userID] != this.author) {
        if (this.debug || (this.debugAfterDate && this.debugAfterDate < posts[posts.length - 1].date)) {
          console.log(`https://questden.org/kusaba/quest/res/${this.threadID}.html merged majority file poster ${users[userID].id} ${(100 * users[userID].fileCount / this.totalFiles).toFixed(1)}%`);
        }
        var parent = this.union(this.author, users[userID]);
        var child = users[userID].parent != users[userID] ? users[userID] : this.author;
        parent.fileCount += child.fileCount;
        parent.postCount += child.postCount;
        this.author = parent;
        return;
      }
    }
  }

  mergeCommonFilePosters(posts, users, questFixes) {
    if (questFixes.collaboration) {
      return;
    }
    var merged = [];
    var filteredUsers = Object.values(users).filter(user => user.parent == user && user.fileCount >= 3 && user.fileCount / user.postCount > 0.5 && user != this.author);
    if (filteredUsers.length === 0) {
      return;
    }
    var usersSet = new Set(filteredUsers);
    posts.forEach(post => {
      if ((post.fileName || post.activeContent) && !questFixes.wrongImageUpdates[post.postID] && this.isTextPostAnUpdate(post)) {
        for (var user of usersSet) {
          if (this.find(users[post.userID]) == user) {
            if (this.debug || (this.debugAfterDate && this.debugAfterDate < post.date)) {
              console.log(`https://questden.org/kusaba/quest/res/${this.threadID}.html new common poster ${user.id}`);
            }
            var parent = this.union(this.author, user);
            var child = user.parent != user ? user : this.author;
            parent.fileCount += child.fileCount;
            parent.postCount += child.postCount;
            this.author = parent;
            usersSet.delete(user);
            break;
          }
        }
      }
    });
  }

  setPostUsers(posts, users, questFixes) {
    var postUsers = new Map();
    posts.forEach(post => {
      post.user = this.decidePostUser(post, users, questFixes);
      postUsers.set(post.postID, post.user);
    });
    return postUsers;
  }

  decidePostUser(post, users, questFixes) {
    var user = this.find(users[post.userID]);
    //if a post has both a username and an ID, it's sometimes possible that the name belongs to one user, while the ID belongs to a different user...
    //in this case, the post's owner needs to be decided -> it should be the author, unless the username is ignored as per manual fixes
    if (post.userName) {
      if (questFixes.ignoreTextPosts[post.userName]) {
        if (user == this.author) {
          user = this.find(users[post.userName]);
        }
      }
      else if (this.find(users[post.userName]) == this.author) {
        user = this.author;
      }
    }
    return user;
  }

  getFinalPostTypes(posts, questFixes) {
    // Updates are posts made by the author and, in case of image quests, author posts that contain files or icons
    var postTypes = new Map();
    posts.forEach(post => {
      var postType = PostType.SUGGESTION;
      if (post.user == this.author) {
        if (post.fileName || post.activeContent) { //image post
          if (!questFixes.wrongImageUpdates[post.postID]) {
            postType = PostType.UPDATE;
          }
          else if (!questFixes.ignoreTextPosts[post.userID] && !questFixes.ignoreTextPosts[post.userName]) {
            postType = PostType.AUTHORCOMMENT;
          }
        }
        else if (!questFixes.ignoreTextPosts[post.userID] && !questFixes.ignoreTextPosts[post.userName]) { //text post
          if (!questFixes.wrongTextUpdates[post.postID] && (!this.imageQuest || this.isTextPostAnUpdate(post))) {
            postType = PostType.UPDATE;
          }
          else {
            postType = PostType.AUTHORCOMMENT;
          }
        }
      }
      else if (questFixes.collaboration && (post.fileName || post.activeContent) && !questFixes.wrongImageUpdates[post.postID]) { //in collaborations, all image posts are updates
        postType = PostType.UPDATE;
      }
      if (questFixes.missedTextUpdates[post.postID]) {
        postType = PostType.UPDATE;
      }
      if (this.debugAfterDate && this.debugAfterDate < post.date) {
        if (postType == PostType.SUGGESTION && post.fileName) console.log(`https://questden.org/kusaba/quest/res/${this.threadID}.html#${post.postID} new non-update`);
        if (postType == PostType.AUTHORCOMMENT) console.log(`https://questden.org/kusaba/quest/res/${this.threadID}.html#${post.postID} new author comment`);
        if (postType == PostType.UPDATE && this.imageQuest && !post.fileName && !post.activeContent) console.log(`https://questden.org/kusaba/quest/res/${this.threadID}.html#${post.postID} new text update`);
      }
      postTypes.set(post.postID, postType);
    });
    return postTypes;
  }

  getPostUsers(posts) {
    var postUsers = new Map();
    posts.forEach(post => { postUsers.set(post.postID, post.user); });
    return postUsers;
  }

  isTextPostAnUpdate(post) {
    if (post.textUpdate === undefined) {
      post.textUpdate = this.regex.fraction.test(post.subject) || this.containsQuotes(post.contentElement);
    }
    return post.textUpdate;
  }

  containsQuotes(contentElement) {
    //extract post's text, but ignore text inside spoilers, links, dice rolls or any sort of brackets
    var filteredContentText = "";
    contentElement.childNodes.forEach(node => {
      if (node.className !== "spoiler" && node.nodeName != "A" && (node.nodeName != "B" || !this.regex.diceRoll.test(node.textContent))) {
        filteredContentText += node.textContent;
      }
    });
    filteredContentText = filteredContentText.replace(this.regex.bracketedTexts, "").trim();
    //if the post contains dialogue, then it's likely to be an update
    var quotedTexts = filteredContentText.match(this.regex.quotedTexts) || [];
    for (let q of quotedTexts) {
      if (this.regex.endsWithPunctuation.test(q)) {
        return true;
      }
    }
    return false;
  }

  makeSet(id) {
    var node = { id: id, children: [] };
    node.parent = node;
    return node;
  }

  find(node) { //find with path halving
    while (node.parent != node) {
      var curr = node;
      node = node.parent;
      curr.parent = node.parent;
    }
    return node;
  }

  union(node1, node2) {
    var node1root = this.find(node1);
    var node2root = this.find(node2);
    if (node1root == node2root) {
      return node1root;
    }
    node2root.parent = node1root;
    node1root.children.push(node2root); //having a list of children isn't a part of Union-Find, but it makes debugging much easier
    node2root.children.forEach(child => node1root.children.push(child));
    return node1root;
  }

  static getRegexes() {
    if (!this.regex) { //cache as a static class property
      this.regex = {
        fileExtension: new RegExp("[.][^.]+$"), //finds ".png" in "image.png"
        lastNumber: new RegExp("([0-9]+)(?=[^0-9]*$)"), //finds "50" in "image50.png"
        fraction: new RegExp("[0-9][ ]*/[ ]*[0-9]"), //finds "1/4" in "Update 1/4"
        diceRoll: new RegExp("^rolled [0-9].* = [0-9]+$"), //finds "rolled 10, 20 = 30"
        quotedTexts: new RegExp("[\"“”][^\"“”]*[\"“”]","gu"), //finds text inside quotes
        endsWithPunctuation: new RegExp("[.,!?][ ]*[\"“”]$"), //finds if a quote ends with a punctuation
        bracketedTexts: new RegExp("(\\([^)]*\\))|(\\[[^\\]]*\\])|(\\{[^}]*\\})|(<[^>]*>)", "gu"), //finds text within various kinds of brackets... looks funny
        canonID: new RegExp("^[0-9a-f]{6}$")
      };
    }
    return this.regex;
  }

  getFixes(threadID) {
    var fixes = UpdateAnalyzer.getAllFixes()[threadID] || {};
    //convert array values to lower case and then into object properties for faster access
    for (let prop of [ "missedAuthors", "missedTextUpdates", "wrongTextUpdates", "wrongImageUpdates", "ignoreTextPosts" ]) {
      if (!fixes[prop]) {
        fixes[prop] = { };
      }
      else if (Array.isArray(fixes[prop])) {
        fixes[prop] = fixes[prop].reduce((acc, el) => { if (!el.startsWith("!")) el = el.toLowerCase(); acc[el] = true; return acc; }, { });
      }
    }
    return fixes;
  }

  // Manual fixes. In some cases it's simply impossible (impractical) to automatically determine which posts are updates. So we fix those rare cases manually.
  // list last updated on:
  // 2022/04/14

  //missedAuthors: User IDs which should be linked to the author. Either because the automation failed, or the quest has guest authors / is a collaboration. Guest authors also usually need an entry under ignoreTextPosts.
  //ignoreTextPosts: User IDs of which text posts should not be set as author comments. It happens when a suggester shares an ID with the author and this suggester makes a text post. Or if the guest authors make suggestions.
  //(An empty ignoreTextPosts string matches posts with an empty/default poster name)
  //missedImageUpdates: Actually, no such fixes exist. All missed image update posts are added through adding author IDs to missedAuthors.
  //missedTextUpdates: Post IDs of text-only posts which are not author comments, but quest updates. It happens when authors make text updates in image quests. Or forget to attach an image to the update post.
  //wrongImageUpdates: Post IDs of image posts which are not quest updates. It happens when a suggester shares an ID with the author(s) and this suggester makes an image post. Or a guest author posts a non-update image post.
  //wrongTextUpdates: Post IDs of text-only posts which were misidentified as updates. It happens when an author comment contains a valid quote and the script accidentally thinks some dialogue is going on.
  //imageQuest: Forcefully set quest type. It happens when the automatically-determined quest type is incorrect. Either because of too many image updates in a text quest, or text updates in an image quest.
  //(Also, if most of the author's text posts in an image quest are updates, then it's sometimes simpler to set the quest as a text quest, rather than picking them out one by one.)
  //collaboration: Consider all image posts in this quest as updates. Useful if a quest has multiple authors. TODO: Check if it's possible to automatically detect if a quest is a collaboration
  static getAllFixes() {
    if (!this.allFixes) {
      this.allFixes = { //cache as a static class property
        12: { missedAuthors: [ "!g9Qfmdqho2" ] },
        26: { ignoreTextPosts: [ "Coriell", "!DHEj4YTg6g" ] },
        101: { wrongTextUpdates: [ "442" ] },
        171: { wrongTextUpdates: [ "1402" ] },
        504: { missedTextUpdates: [ "515", "597", "654", "1139", "1163", "1180", "7994", "9951" ] },
        998: { ignoreTextPosts: [ "" ] },
        1292: { missedAuthors: [ "Chaptermaster II" ], missedTextUpdates: [ "1311", "1315", "1318" ], ignoreTextPosts: [ "" ] },
        1702: { wrongImageUpdates: [ "2829" ] },
        3090: { ignoreTextPosts: [ "", "!94Ud9yTfxQ", "Glaive" ], wrongImageUpdates: [ "3511", "3574", "3588", "3591", "3603", "3612" ] },
        4602: { missedTextUpdates: [ "4630", "6375" ] },
        7173: { missedTextUpdates: [ "8515", "10326" ] },
        8906: { missedTextUpdates: [ "9002", "9009" ] },
        9190: { missedAuthors: [ "!OeZ2B20kbk" ], missedTextUpdates: [ "26073" ] },
        13595: { wrongTextUpdates: [ "18058" ] },
        16114: { missedTextUpdates: [ "20647" ] },
        17833: { ignoreTextPosts: [ "!swQABHZA/E" ] },
        19308: { missedTextUpdates: [ "19425", "19600", "19912" ] },
        19622: { wrongImageUpdates: [ "30710", "30719", "30732", "30765" ] },
        19932: { missedTextUpdates: [ "20038", "20094", "20173", "20252" ] },
        20501: { ignoreTextPosts: [ "bd2eec" ] },
        21601: { missedTextUpdates: [ "21629", "21639" ] },
        21853: { missedTextUpdates: [ "21892", "21898", "21925", "22261", "22266", "22710", "23308", "23321", "23862", "23864", "23900", "24206", "25479", "25497", "25943", "26453", "26787", "26799",
                                     "26807", "26929", "27328", "27392", "27648", "27766", "27809", "29107", "29145" ] },
        22208: { missedAuthors: [ "fb5d8e" ] },
        24530: { wrongImageUpdates: [ "25023" ] },
        25354: { imageQuest: false},
        26933: { missedTextUpdates: [ "26935", "26955", "26962", "26967", "26987", "27015", "28998" ] },
        29636: { missedTextUpdates: [ "29696", "29914", "30025", "30911" ], wrongImageUpdates: [ "30973", "32955", "33107" ] },
        30350: { imageQuest: false, wrongTextUpdates: [ "30595", "32354", "33704" ] },
        30357: { missedTextUpdates: [ "30470", "30486", "30490", "30512", "33512" ] },
        33329: { wrongTextUpdates: [ "43894" ] },
        37304: { collaboration: true, ignoreTextPosts: [ "", "Adept", "GREEN" ] },
        37954: { missedTextUpdates: [ "41649" ] },
        38276: { ignoreTextPosts: [ "!ifOCf11HXk" ] },
        41510: { missedTextUpdates: [ "41550", "41746" ] },
        44240: { missedTextUpdates: [ "44324", "45768", "45770", "48680", "48687" ] },
        45522: { missedTextUpdates: [ "55885" ] },
        45986: { missedTextUpdates: [ "45994", "46019" ] },
        49306: { missedTextUpdates: [ "54246" ] },
        49400: { ignoreTextPosts: [ "!!IzZTIxBQH1" ] },
        49937: { missedTextUpdates: [ "52386" ] },
        53129: { wrongTextUpdates: [ "53505" ] },
        53585: { collaboration: true },
        54766: { missedAuthors: [ "e16ca8" ], ignoreTextPosts: [ "!!IzZTIxBQH1" ] },
        55639: { wrongImageUpdates: [ "56711", "56345", "56379", "56637" ] },
        56194: { wrongTextUpdates: [ "61608" ] },
        59263: { missedTextUpdates: [ "64631" ] },
        62091: { imageQuest: true},
        65742: { missedTextUpdates: [ "66329", "66392", "67033", "67168" ] },
        67058: { missedTextUpdates: [ "67191", "67685" ] },
        68065: { missedAuthors: [ "7452df", "1d8589" ], ignoreTextPosts: [ "!!IzZTIxBQH1" ] },
        70887: { missedAuthors: [ "e53955", "7c9cdd", "2084ff", "064d19", "51efff", "d3c8d2" ], ignoreTextPosts: [ "!!IzZTIxBQH1" ] },
        72794: { wrongTextUpdates: [ "76740" ] },
        74474: { missedAuthors: [ "309964" ] },
        75425: { missedTextUpdates: [ "75450", "75463", "75464", "75472", "75490", "75505", "77245" ] },
        75763: { missedAuthors: [ "068b0e" ], ignoreTextPosts: [ "!!IzZTIxBQH1" ] },
        76892: { missedTextUpdates: [ "86875", "86884", "87047", "88315" ] },
        79146: { missedAuthors: [ "4a3269" ] },
        79654: { missedTextUpdates: [ "83463", "83529" ] },
        79782: { missedTextUpdates: [ "79975", "80045" ] },
        82970: { missedTextUpdates: [ "84734" ] },
        83325: { missedAuthors: [ "076064" ] },
        84134: { imageQuest: false},
        85235: { missedTextUpdates: [ "85257", "85282", "113215", "114739", "151976", "152022", "159250" ] },
        88264: { missedAuthors: [ "3fec76", "714b9c" ] },
        92605: { ignoreTextPosts: [ "" ] },
        94645: { missedTextUpdates: [ "97352" ] },
        95242: { missedTextUpdates: [ "95263" ] },
        96023: { missedTextUpdates: [ "96242" ] },
        96466: { ignoreTextPosts: [ "Reverie" ] },
        96481: { imageQuest: true},
        97014: { missedTextUpdates: [ "97061", "97404", "97915", "98124", "98283", "98344", "98371", "98974", "98976", "98978", "99040", "99674", "99684" ] },
        99095: { wrongImageUpdates: [ "111452" ] },
        99132: { ignoreTextPosts: [ "" ] },
        100346: { missedTextUpdates: [ "100626", "100690", "100743", "100747", "101143", "101199", "101235", "101239" ] },
        101388: { ignoreTextPosts: [ "Glaive" ] },
        102433: { missedTextUpdates: [ "102519", "102559", "102758" ] },
        102899: { missedTextUpdates: [ "102903" ] },
        103435: { missedTextUpdates: [ "104279", "105950" ] },
        103850: { ignoreTextPosts: [ "" ] },
        106656: { wrongTextUpdates: [ "115606" ] },
        107789: { missedTextUpdates: [ "107810", "107849", "107899" ] },
        108599: { wrongImageUpdates: [ "171382", "172922", "174091", "180752", "180758" ] },
        108805: { wrongImageUpdates: [ "110203" ] },
        109071: { missedTextUpdates: [ "109417" ] },
        112133: { missedTextUpdates: [ "134867" ] },
        112414: { missedTextUpdates: [ "112455" ] },
        113768: { missedAuthors: [ "e9a4f7" ] },
        114133: { ignoreTextPosts: [ "" ] },
        115831: { missedTextUpdates: [ "115862" ] },
        119431: { ignoreTextPosts: [ "" ] },
        120384: { missedAuthors: [ "233aab" ] },
        126204: { imageQuest: true, missedTextUpdates: [ "127069", "127089", "161046", "161060", "161563" ] },
        126248: { missedTextUpdates: [ "193064" ] },
        128706: { missedAuthors: [ "2e2f06", "21b50e", "e0478c", "9c87f6", "931351", "e294f1", "749d64", "f3254a" ] },
        131255: { missedTextUpdates: [ "151218" ] },
        137683: { missedTextUpdates: [ "137723" ] },
        139086: { ignoreTextPosts: [ "!TEEDashxDA" ] },
        139513: { missedTextUpdates: [ "139560" ] },
        141257: { missedTextUpdates: [ "141263", "141290", "141513", "146287" ], ignoreTextPosts: [ "" ], wrongImageUpdates: [ "141265" ] },
        146112: { imageQuest: true, missedAuthors: [ "//_emily" ], ignoreTextPosts: [ "f609ed" ] },
        153225: { missedTextUpdates: [ "153615", "153875" ] },
        155665: { missedTextUpdates: [ "155670", "155684", "155740" ] },
        156257: { missedTextUpdates: [ "156956" ] },
        157277: { missedAuthors: [ "23c8f1", "8bb533" ] },
        161117: { missedTextUpdates: [ "167255", "168000" ] },
        162089: { missedTextUpdates: [ "167940" ] },
        164793: { missedAuthors: [ "e973f4" ], ignoreTextPosts: [ "!TEEDashxDA" ] },
        165537: { missedAuthors: [ "a9f6ce" ] },
        173621: { ignoreTextPosts: [ "" ] },
        174398: { missedAuthors: [ "bf0d4e", "158c5c" ] },
        176965: { missedTextUpdates: [ "177012" ] },
        177281: { missedTextUpdates: [ "178846" ] },
        181790: { ignoreTextPosts: [ "Mister Brush" ], wrongImageUpdates: [ "182280" ] },
        183194: { ignoreTextPosts: [ "!CRITTerXzI" ], wrongImageUpdates: [ "183207" ] },
        183637: { imageQuest: false, wrongTextUpdates: [ "183736" ] },
        185345: { wrongTextUpdates: [ "185347" ] },
        185579: { missedTextUpdates: [ "188091", "188697", "188731", "188748", "190868" ] },
        186709: { missedTextUpdates: [ "186735" ] },
        188253: { missedTextUpdates: [ "215980", "215984", "222136" ] },
        188571: { missedTextUpdates: [ "188633" ] },
        188970: { ignoreTextPosts: [ "" ] },
        191328: { missedAuthors: [ "f54a9c", "862cf6", "af7d90", "4c1052", "e75bed", "09e145" ] },
        191976: { missedAuthors: [ "20fc85" ] },
        192879: { missedTextUpdates: [ "193009" ] },
        193934: { missedTextUpdates: [ "212768" ] },
        196310: { missedTextUpdates: [ "196401" ] },
        196517: { missedTextUpdates: [ "196733" ] },
        198458: { missedTextUpdates: [ "198505", "198601", "199570" ] },
        200054: { missedAuthors: [ "a4b4e3" ] },
        201427: { missedTextUpdates: [ "201467", "201844" ] },
        203072: { missedTextUpdates: [ "203082", "203100", "206309", "207033", "208766" ] },
        206945: { missedTextUpdates: [ "206950" ] },
        207011: { ignoreTextPosts: [ "!TEEDashxDA" ] },
        207296: { missedTextUpdates: [ "214551" ] },
        207756: { missedTextUpdates: [ "208926" ] },
        209334: { missedTextUpdates: [ "209941" ] },
        210613: { missedTextUpdates: [ "215711", "220853" ] },
        210928: { missedTextUpdates: [ "215900" ] },
        211320: { ignoreTextPosts: [ "Kindling", "Bahu" ], wrongImageUpdates: [ "211587", "215436" ] },
        212584: { missedAuthors: [ "40a8d3" ] },
        212915: { missedTextUpdates: [ "229550" ] },
        217193: { collaboration: true },
        217269: { imageQuest: false, wrongTextUpdates: [ "217860", "219314" ] },
        218385: { missedAuthors: [ "812dcf" ] },
        220049: { ignoreTextPosts: [ "Slinkoboy" ], wrongImageUpdates: [ "228035", "337790" ] },
        222777: { imageQuest: false},
        224095: { missedTextUpdates: [ "224196", "224300", "224620", "244476" ] },
        233213: { missedTextUpdates: [ "233498" ], ignoreTextPosts: [ "Bahu" ] },
        234437: { missedTextUpdates: [ "234657" ] },
        237125: { missedTextUpdates: [ "237192" ] },
        237665: { imageQuest: true, ignoreTextPosts: [ "" ] },
        238281: { ignoreTextPosts: [ "TK" ] },
        238993: { missedTextUpdates: [ "239018", "239028", "239094" ] },
        240824: { imageQuest: false},
        241467: { missedTextUpdates: [ "241709" ] },
        242200: { missedTextUpdates: [ "246465", "246473", "246513" ] },
        242657: { missedAuthors: [ "2563d4" ] },
        244225: { missedTextUpdates: [ "245099", "245195", "245201" ] },
        244557: { missedTextUpdates: [ "244561" ], ignoreTextPosts: [ "" ] },
        244830: { missedAuthors: [ "e33093" ] },
        247108: { ignoreTextPosts: [ "Bahu" ], wrongImageUpdates: [ "258883", "265446" ] },
        247714: { missedTextUpdates: [ "247852" ] },
        248067: { ignoreTextPosts: [ "" ] },
        248856: { ignoreTextPosts: [ "" ] },
        248880: { imageQuest: true, ignoreTextPosts: [ "", "!qkgg.NzvRY", "!!EyA2IwLwVl", "!I10GFLsZCw", "!k6uRjGDgAQ", "Seven01a19" ] },
        251909: { missedTextUpdates: [ "255400" ] },
        252195: { missedTextUpdates: [ "260890" ] },
        252944: { missedAuthors: [ "Rizzie" ], ignoreTextPosts: [ "", "!!EyA2IwLwVl", "Seven01a19" ] },
        256339: { missedTextUpdates: [ "256359", "256379", "256404", "256440" ] },
        257726: { missedAuthors: [ "917cac" ] },
        258304: { missedTextUpdates: [ "269087" ] },
        261572: { imageQuest: false},
        261837: { missedAuthors: [ "14149d" ] },
        262128: { missedTextUpdates: [ "262166", "262219", "262455", "262500" ] },
        262574: { collaboration: true },
        263831: { imageQuest: false, wrongTextUpdates: [ "264063", "264716", "265111", "268733", "269012", "270598", "271254", "271852", "271855", "274776", "275128", "280425", "280812", "282417", "284354", "291231", "300074", "305150" ] },
        265656: { ignoreTextPosts: [ "Glaive17" ] },
        266542: { collaboration: true, wrongImageUpdates: [ "266587" ], ignoreTextPosts: [ "fa43b7", "Not Cirr" ] },
        267348: { ignoreTextPosts: [ "" ] },
        269735: { ignoreTextPosts: [ "---" ] },
        270556: { ignoreTextPosts: [ "Bahu" ], wrongImageUpdates: [ "276022" ] },
        273047: { missedAuthors: [ "db463d", "16f0be", "77df62", "b6733e", "d171a3", "3a95e1", "21d450" ] },
        274088: { missedAuthors: [ "4b0cf3" ], missedTextUpdates: [ "294418" ], ignoreTextPosts: [ "" ] },
        274466: { missedAuthors: [ "c9efe3" ] },
        276562: { missedTextUpdates: [ "277108" ] },
        277371: { ignoreTextPosts: [ "!TEEDashxDA" ] },
        278168: { ignoreTextPosts: [ "!TEEDashxDA" ] },
        280381: { ignoreTextPosts: [ "!7BHo7QtR6I" ] },
        280985: { ignoreTextPosts: [ "!TEEDashxDA" ] },
        283246: { imageQuest: false},
        285210: { ignoreTextPosts: [ "", "Weaver" ] },
        287296: { ignoreTextPosts: [ "", "Asplosionz" ] },
        287815: { missedAuthors: [ "Ñ" ] },
        288346: { collaboration: true, imageQuest: false },
        289254: { imageQuest: false},
        292033: { wrongTextUpdates: [ "295088" ] },
        293532: { ignoreTextPosts: [ "" ] },
        294351: { ignoreTextPosts: [ "Weaver" ] },
        295374: { ignoreTextPosts: [ "TK" ] },
        295832: { missedAuthors: [ "ac22cd", "7afbc4", "6f11ff" ], missedTextUpdates: [ "313940" ] },
        295949: { missedTextUpdates: [ "296256", "297926", "298549" ] },
        298133: { missedTextUpdates: [ "298187" ] },
        298860: { imageQuest: true, missedTextUpdates: [ "298871", "298877", "298880", "298908" ] },
        299352: { imageQuest: true, missedTextUpdates: [ "299375", "299627", "303689" ] },
        300694: { ignoreTextPosts: [ "TK" ] },
        300751: { missedTextUpdates: [ "316287" ] },
        303859: { ignoreTextPosts: [ "" ] },
        308257: { missedTextUpdates: [ "314653" ] },
        309753: { missedTextUpdates: [ "309864", "309963", "310292", "310944", "310987", "311202", "311219", "311548" ] },
        310586: { missedTextUpdates: [ "310945", "312747", "313144" ] },
        311021: { missedAuthors: [ "049dfa", "f2a6f9" ] },
        312418: { missedTextUpdates: [ "312786", "312790", "312792", "312984", "313185" ] },
        314825: { ignoreTextPosts: [ "TK" ] },
        314940: { missedTextUpdates: [ "314986", "315198", "329923" ] },
        318478: { ignoreTextPosts: [ "Toxoglossa" ] },
        319491: { ignoreTextPosts: [ "Bahu" ] },
        323481: { missedTextUpdates: [ "323843", "324125", "324574" ] },
        323589: { missedTextUpdates: [ "329499" ] },
        327468: { missedTextUpdates: [ "327480", "337008" ] },
        337661: { ignoreTextPosts: [ "", "hisgooddog" ] },
        338579: { ignoreTextPosts: [ "", "Zealo8", "Ñ" ] },
        343078: { wrongImageUpdates: [ "343219" ] },
        343668: { missedTextUpdates: [ "343671" ] },
        348635: { ignoreTextPosts: [ "" ] },
        351064: { missedTextUpdates: [ "351634", "353263", "355326", "356289" ] },
        351264: { missedTextUpdates: [ "353077" ] },
        354201: { imageQuest: true, missedTextUpdates: [ "354340" ] },
        355404: { ignoreTextPosts: [ "Bahu" ] },
        356715: { missedTextUpdates: [ "356722" ] },
        357723: { collaboration: true, wrongImageUpdates: [ "358563" ] },
        359879: { imageQuest: false},
        359931: { collaboration: true, wrongImageUpdates: [ "360933", "361338", "361425", "362100" ] },
        360617: { missedAuthors: [ "7a7217" ] },
        363529: { imageQuest: true, ignoreTextPosts: [ "Tenyoken" ] },
        365082: { missedTextUpdates: [ "381411", "382388" ] },
        366944: { missedTextUpdates: [ "367897" ] },
        367145: { wrongTextUpdates: [ "367887" ] },
        367824: { missedTextUpdates: [ "367841", "367858", "367948" ] },
        375293: { ignoreTextPosts: [ "Bahu" ] },
        382864: { ignoreTextPosts: [ "FlynnMerk" ] },
        387602: { ignoreTextPosts: [ "!a1..dIzWW2" ], wrongImageUpdates: [ "390207", "392018", "394748" ] },
        388264: { ignoreTextPosts: [ "" ] },
        392034: { missedAuthors: [ "046f13" ] },
        392868: { missedAuthors: [ "e1359e" ] },
        393082: { ignoreTextPosts: [ "" ] },
        395700: { missedTextUpdates: [ "395701", "395758" ] },
        395817: { ignoreTextPosts: [ "" ] },
        397819: { ignoreTextPosts: [ "Bahu", "K-Dogg" ], wrongImageUpdates: [ "398064" ] },
        400842: { missedAuthors: [ "b0d466" ], ignoreTextPosts: [ "", "!a1..dIzWW2" ], wrongImageUpdates: [ "412172", "412197" ] },
        403418: { missedAuthors: [ "02cbc6" ] },
        404177: { missedTextUpdates: [ "404633" ] },
        409356: { missedTextUpdates: [ "480664", "485493" ], wrongTextUpdates: [ "492824" ] },
        410618: { ignoreTextPosts: [ "kathryn" ], wrongImageUpdates: [ "417836" ] },
        412463: { ignoreTextPosts: [ "" ] },
        413494: { ignoreTextPosts: [ "Bahu" ] },
        420600: { imageQuest: false},
        421477: { imageQuest: false},
        422052: { collaboration: true },
        422087: { ignoreTextPosts: [ "Caz" ] },
        422856: { ignoreTextPosts: [ "", "???" ] },
        424198: { collaboration: true },
        425677: { missedTextUpdates: [ "425893", "426741", "431953" ] },
        426019: { ignoreTextPosts: [ "Taskuhecate" ] },
        427135: { ignoreTextPosts: [ "!7BHo7QtR6I" ] },
        427676: { ignoreTextPosts: [ "FRACTAL" ] },
        428027: { collaboration: true, ignoreTextPosts: [ "Trout" ] },
        430036: { missedTextUpdates: [ "430062", "430182", "430416" ], ignoreTextPosts: [ "" ] },
        431445: { imageQuest: false, missedAuthors: [ "efbb86" ] },
        435947: { missedTextUpdates: [ "436059" ] },
        437675: { wrongTextUpdates: [ "445770", "449255", "480401" ] },
        437768: { missedTextUpdates: [ "446536" ] },
        438515: { ignoreTextPosts: [ "TK" ] },
        438670: { ignoreTextPosts: [ "" ] },
        441226: { collaboration: true, wrongImageUpdates: [ "441260" ] },
        441745: { missedTextUpdates: [ "443831" ] },
        447830: { imageQuest: false, missedAuthors: [ "fc985a", "f8b208" ], wrongTextUpdates: [ "448476", "450379", "452161" ] },
        448900: { missedAuthors: [ "0c2256" ] },
        449505: { wrongTextUpdates: [ "450499" ] },
        450563: { collaboration: true },
        452871: { missedAuthors: [ "General Q. Waterbuffalo", "!cZFAmericA" ], missedTextUpdates: [ "456083" ] },
        453480: { ignoreTextPosts: [ "TK" ], wrongImageUpdates: [ "474233" ] },
        453978: { missedTextUpdates: [ "453986" ] },
        454256: { missedTextUpdates: [ "474914", "474957" ] },
        456185: { ignoreTextPosts: [ "TK" ], wrongTextUpdates: [ "472446" ], wrongImageUpdates: [ "592622" ] },
        456798: { missedTextUpdates: [ "516303" ] },
        458432: { collaboration: true },
        463595: { missedTextUpdates: [ "463711", "465024", "465212", "465633", "467107", "467286" ], wrongTextUpdates: [ "463623" ] },
        464038: { missedAuthors: [ "df885d", "8474cd" ] },
        465919: { missedTextUpdates: [ "465921" ] },
        469321: { missedTextUpdates: [ "469332" ] },
        471304: { missedAuthors: [ "1766db" ] },
        471394: { missedAuthors: [ "Cirr" ] },
        476554: { ignoreTextPosts: [ "Fish is yum" ] },
        478624: { missedAuthors: [ "88c9b2" ] },
        479712: { ignoreTextPosts: [ "" ] },
        481277: { missedTextUpdates: [ "481301", "482210" ], ignoreTextPosts: [ "Santova" ] },
        481491: { missedTextUpdates: [ "481543", "481575", "484069" ], ignoreTextPosts: [ "Zach Leigh", "Santova", "Outaki Shiba" ] },
        482391: { missedTextUpdates: [ "482501", "482838" ] },
        482629: { missedTextUpdates: [ "484220", "484437" ], ignoreTextPosts: [ "Santova", "Tera Nospis" ] },
        483108: { missedAuthors: [ "2de44c" ], missedTextUpdates: [ "483418", "483658" ], ignoreTextPosts: [ "Santova" ] },
        484423: { missedTextUpdates: [ "484470", "486761", "488602" ], ignoreTextPosts: [ "Tera Nospis", "Zach Leigh" ] },
        484606: { missedTextUpdates: [ "486773" ], ignoreTextPosts: [ "Zach Leigh" ] },
        485964: { missedTextUpdates: [ "489145", "489760" ], ignoreTextPosts: [ "Tera Nospis", "Santova" ] },
        489488: { missedTextUpdates: [ "490389" ] },
        489694: { missedAuthors: [ "2c8bbe", "30a140", "8c4b01", "8fbeb2", "2b7d97", "17675d", "782175", "665fcd", "e91794", "52019c", "8ef0aa", "e493a6", "c847bc" ] },
        489830: { missedAuthors: [ "9ee824", "8817a0", "d81bd3", "704658" ] },
        490689: { ignoreTextPosts: [ "Santova" ] },
        491171: { ignoreTextPosts: [ "Santova", "Zach Leigh", "Zack Leigh", "The Creator" ] },
        491314: { missedTextUpdates: [ "491498" ], ignoreTextPosts: [ "" ] },
        492511: { missedAuthors: [ "???" ] },
        493099: { ignoreTextPosts: [ "Zach Leigh", "Santova" ] },
        494015: { ignoreTextPosts: [ "Coda", "drgruff" ] },
        496561: { ignoreTextPosts: [ "Santova", "DJ LaLonde", "Tera Nospis" ] },
        498874: { ignoreTextPosts: [ "Santova" ] },
        499607: { ignoreTextPosts: [ "Santova", "Tera Nospis" ] },
        499980: { ignoreTextPosts: [ "Santova", "Tera Nospis", "DJ LaLonde" ] },
        500015: { missedTextUpdates: [ "500020", "500029", "500274", "501462", "501464", "501809", "505421" ], ignoreTextPosts: [ "suggestion", "Chelz" ] },
        502751: { ignoreTextPosts: [ "suggestion" ] },
        503053: { missedAuthors: [ "!!WzMJSzZzWx", "Shopkeep", "CAI" ] },
        505072: { missedTextUpdates: [ "565461" ] },
        505569: { ignoreTextPosts: [ "!TEEDashxDA" ] },
        505633: { missedTextUpdates: [ "505694", "529582" ] },
        505796: { ignoreTextPosts: [ "Mister-Saturn" ] },
        506555: { ignoreTextPosts: [ "Tera Nospis", "Santova" ] },
        507761: { ignoreTextPosts: [ "", "Rue" ] },
        508294: { missedAuthors: [ "Lisila" ], missedTextUpdates: [ "508618", "508406" ], wrongImageUpdates: [ "508331" ] },
        509510: { missedTextUpdates: [ "509810", "510805", "510812", "510943", "511042", "512430", "514731", "515963" ] },
        510067: { missedTextUpdates: [ "510081" ] },
        511816: { imageQuest: true, missedAuthors: [ "34cf7d" ], missedTextUpdates: [ "512608" ] },
        512417: { ignoreTextPosts: [ "Uplifted" ] },
        512501: { ignoreTextPosts: [ "" ] },
        512569: { wrongImageUpdates: [ "512810" ] },
        513727: { missedTextUpdates: [ "519251" ], ignoreTextPosts: [ "!mYSM8eo.ng" ] },
        514174: { missedTextUpdates: [ "747164" ] },
        515255: { ignoreTextPosts: [ "" ] },
        516595: { imageQuest: true},
        517144: { ignoreTextPosts: [ "" ] },
        518737: { wrongTextUpdates: [ "521408", "522150", "522185", "522231", "535521" ] },
        518843: { ignoreTextPosts: [ "" ] },
        519463: { imageQuest: false},
        521196: { missedTextUpdates: [ "524608" ] },
        526472: { missedTextUpdates: [ "526524", "559848" ] },
        527296: { ignoreTextPosts: [ "Zealo8" ] },
        527546: { ignoreTextPosts: [ "suggestion" ] },
        527753: { collaboration: true },
        528891: { ignoreTextPosts: [ "drgruff" ] },
        530940: { missedAuthors: [ "2027bb", "feafa5", "0a3b00" ] },
        533990: { missedTextUpdates: [ "537577" ] },
        534197: { ignoreTextPosts: [ "Stella" ] },
        535302: { ignoreTextPosts: [ "mermaid" ] },
        535783: { ignoreTextPosts: [ "drgruff" ] },
        536268: { missedTextUpdates: [ "536296", "538173" ], ignoreTextPosts: [ "Archivemod" ], wrongImageUpdates: [ "537996" ] },
        537343: { missedTextUpdates: [ "539218" ] },
        537647: { missedTextUpdates: [ "537683" ] },
        537867: { missedAuthors: [ "369097" ] },
        539831: { ignoreTextPosts: [ "" ] },
        540147: { ignoreTextPosts: [ "drgruff" ] },
        541026: { imageQuest: false},
        543428: { missedTextUpdates: [ "545458" ] },
        545071: { missedTextUpdates: [ "545081" ] },
        545791: { ignoreTextPosts: [ "" ] },
        545842: { missedTextUpdates: [ "550972" ] },
        548052: { missedTextUpdates: [ "548172" ], ignoreTextPosts: [ "Lucid" ] },
        548899: { missedTextUpdates: [ "548968", "549003" ] },
        549394: { missedTextUpdates: [ "549403" ] },
        553434: { missedTextUpdates: [ "553610", "553635", "553668", "554166" ] },
        553711: { missedTextUpdates: [ "553722", "553728", "554190" ] },
        553760: { missedTextUpdates: [ "554994", "555829", "556570", "556792", "556803", "556804" ] },
        554694: { missedTextUpdates: [ "557011", "560544" ] },
        556435: { missedAuthors: [ "Azathoth" ], missedTextUpdates: [ "607163" ], wrongTextUpdates: [ "561150" ] },
        557051: { missedTextUpdates: [ "557246", "557260", "557599", "559586" ], wrongTextUpdates: [ "557517" ] },
        557633: { imageQuest: true},
        557854: { missedTextUpdates: [ "557910", "557915", "557972", "558082", "558447", "558501", "561834", "561836", "562289", "632102", "632481", "632509", "632471" ] },
        562193: { ignoreTextPosts: [ "" ] },
        563459: { missedTextUpdates: [ "563582" ] },
        564852: { collaboration: true },
        564860: { missedTextUpdates: [ "565391" ] },
        565909: { ignoreTextPosts: [ "" ] },
        567119: { missedTextUpdates: [ "573494", "586375" ] },
        567138: { missedAuthors: [ "4cf1b6" ] },
        568248: { missedTextUpdates: [ "569818" ] },
        568370: { ignoreTextPosts: [ "" ] },
        568463: { missedTextUpdates: [ "568470", "568473" ] },
        569225: { missedTextUpdates: [ "569289" ] },
        573815: { wrongTextUpdates: [ "575792" ] },
        578213: { missedTextUpdates: [ "578575" ] },
        581741: { missedTextUpdates: [ "581746" ] },
        582268: { missedTextUpdates: [ "587221" ] },
        585201: { ignoreTextPosts: [ "", "Bahustard", "Siphon" ] },
        586024: { ignoreTextPosts: [ "" ] },
        587086: { missedTextUpdates: [ "587245", "587284", "587443", "587454" ] },
        587562: { ignoreTextPosts: [ "Zealo8" ] },
        588902: { missedTextUpdates: [ "589033" ] },
        589725: { imageQuest: false},
        590502: { ignoreTextPosts: [ "" ], wrongTextUpdates: [ "590506" ] },
        590761: { missedTextUpdates: [ "590799" ], ignoreTextPosts: [ "" ] },
        591527: { missedTextUpdates: [ "591547", "591845" ] },
        592273: { imageQuest: false},
        592625: { wrongTextUpdates: [ "730228" ] },
        593047: { missedTextUpdates: [ "593065", "593067", "593068" ] },
        593899: { ignoreTextPosts: [ "mermaid" ] },
        595081: { ignoreTextPosts: [ "", "VoidWitchery" ] },
        595265: { imageQuest: false, wrongTextUpdates: [ "596676", "596717", "621360", "621452", "621466", "621469", "621503" ] },
        596262: { missedTextUpdates: [ "596291", "596611", "597910", "598043", "598145", "600718", "603311" ] },
        596345: { ignoreTextPosts: [ "mermaid" ] },
        596539: { missedTextUpdates: [ "596960", "596972", "596998", "597414", "614375", "614379", "614407", "616640", "668835", "668844", "668906", "668907", "668937", "668941", "669049", "669050",
                                      "669126", "671651" ], ignoreTextPosts: [ "pugbutt" ] },
        598767: { ignoreTextPosts: [ "FRACTAL" ] },
        602894: { ignoreTextPosts: [ "" ] },
        604604: { missedTextUpdates: [ "605127", "606702" ] },
        609653: { missedTextUpdates: [ "610108", "610137" ] },
        611369: { wrongImageUpdates: [ "620890" ] },
        611997: { missedTextUpdates: [ "612102", "612109" ], wrongTextUpdates: [ "617447" ] },
        613977: { missedTextUpdates: [ "614036" ] },
        615246: { missedTextUpdates: [ "638243", "638245", "638246", "638248" ] },
        615752: { ignoreTextPosts: [ "Uplifted" ] },
        617061: { ignoreTextPosts: [ "!TEEDashxDA" ] },
        617484: { missedTextUpdates: [ "617509", "617830" ] },
        618712: { missedTextUpdates: [ "619097", "619821", "620260" ] },
        620830: { collaboration: true },
        623611: { ignoreTextPosts: [ "!5tTWT1eydY" ] },
        623897: { wrongTextUpdates: [ "625412" ] },
        625364: { missedTextUpdates: [ "635199" ] },
        625814: { collaboration: true, missedTextUpdates: [ "625990" ] },
        627139: { ignoreTextPosts: [ "", "Seal" ] },
        628023: { missedTextUpdates: [ "628323", "629276", "629668" ] },
        628357: { ignoreTextPosts: [ "" ] },
        632345: { ignoreTextPosts: [ "!TEEDashxDA" ] },
        632823: { missedTextUpdates: [ "632860", "633225", "633632", "633649", "633723", "634118" ], ignoreTextPosts: [ "" ] },
        633187: { missedTextUpdates: [ "633407", "633444", "634031", "634192", "634462" ] },
        633487: { missedAuthors: [ "8b8b34", "fe7a48", "20ca72", "668d91" ] },
        634122: { ignoreTextPosts: [ "Apollo" ] },
        639549: { ignoreTextPosts: [ "Apollo" ] },
        641286: { missedTextUpdates: [ "641650" ] },
        642667: { missedTextUpdates: [ "643113" ] },
        642726: { missedTextUpdates: [ "648209", "651723" ] },
        643327: { ignoreTextPosts: [ "" ] },
        644179: { missedTextUpdates: [ "647317" ] },
        645426: { missedTextUpdates: [ "651214", "670665", "671751", "672911", "674718", "684082" ] },
        648109: { missedTextUpdates: [ "711809", "711811" ] },
        648646: { missedTextUpdates: [ "648681" ] },
        651220: { missedTextUpdates: [ "653791" ] },
        651382: { missedAuthors: [ "bbfc3d" ] },
        651540: { missedTextUpdates: [ "651629" ] },
        655158: { ignoreTextPosts: [ "" ] },
        662096: { ignoreTextPosts: [ "" ] },
        662196: { collaboration: true },
        662452: { ignoreTextPosts: [ "" ] },
        662661: { ignoreTextPosts: [ "" ] },
        663088: { collaboration: true, ignoreTextPosts: [ "Apollo" ] },
        663996: { missedTextUpdates: [ "673890" ] },
        668009: { missedTextUpdates: [ "668227" ] },
        668216: { imageQuest: false},
        669206: { imageQuest: true, missedAuthors: [ "75347e" ] },
        672060: { missedTextUpdates: [ "673216" ] },
        673444: { ignoreTextPosts: [ "" ] },
        673575: { collaboration: true },
        673811: { missedTextUpdates: [ "682275", "687221", "687395", "688995" ], ignoreTextPosts: [ "" ] },
        677271: { missedTextUpdates: [ "677384" ] },
        678114: { imageQuest: false},
        678608: { missedTextUpdates: [ "678789" ] },
        679357: { missedTextUpdates: [ "679359", "679983" ] },
        680125: { ignoreTextPosts: [ "", "BritishHat" ] },
        681620: { missedAuthors: [ "d9faec" ] },
        683261: { missedAuthors: [ "3/8 MPP, 4/4 MF" ] },
        686590: { imageQuest: false},
        688371: { missedTextUpdates: [ "696249", "696257" ], ignoreTextPosts: [ "", "Chaos", "Ariadne", "Melinoe", "\"F\"ingGenius" ] },
        691136: { missedTextUpdates: [ "697620" ], ignoreTextPosts: [ "" ], wrongImageUpdates: [ "706696" ] },
        691255: { ignoreTextPosts: [ "" ] },
        692093: { collaboration: true, ignoreTextPosts: [ "Boxdog" ] },
        692872: { missedTextUpdates: [ "717187" ] },
        693509: { missedAuthors: [ "640f86" ], ignoreTextPosts: [ "640f86" ] },
        693648: { missedTextUpdates: [ "694655" ] },
        694230: { ignoreTextPosts: [ "" ] },
        700573: { missedTextUpdates: [ "702352", "720330" ], ignoreTextPosts: [ "" ] },
        701456: { ignoreTextPosts: [ "" ] },
        702865: { ignoreTextPosts: [ "" ] },
        705639: { wrongTextUpdates: [ "794696" ] },
        706303: { missedAuthors: [ "5a8006" ] },
        706439: { missedTextUpdates: [ "714791" ] },
        706938: { ignoreTextPosts: [ "" ] },
        711320: { missedTextUpdates: [ "720646", "724022" ] },
        712179: { missedTextUpdates: [ "712255", "715182" ] },
        712785: { ignoreTextPosts: [ "" ] },
        713042: { missedTextUpdates: [ "713704" ] },
        714130: { imageQuest: true},
        714290: { missedTextUpdates: [ "714307", "714311" ] },
        714858: { ignoreTextPosts: [ "" ] },
        715796: { ignoreTextPosts: [ "" ] },
        717114: { missedTextUpdates: [ "717454", "717628" ] },
        718797: { missedAuthors: [ "FRACTAL on the go" ] },
        718844: { collaboration: true, missedTextUpdates: [ "720240", "721242" ] },
        719505: { ignoreTextPosts: [ "" ] },
        719579: { imageQuest: false},
        722585: { wrongTextUpdates: [ "724938" ] },
        726944: { ignoreTextPosts: [ "" ] },
        727356: { ignoreTextPosts: [ "" ] },
        727581: { missedTextUpdates: [ "728169" ] },
        727677: { ignoreTextPosts: [ "Melinoe" ] },
        728411: { missedTextUpdates: [ "728928" ] },
        730993: { missedTextUpdates: [ "731061" ] },
        732214: { imageQuest: true, wrongTextUpdates: [ "732277" ] },
        734610: { ignoreTextPosts: [ "D3w" ] },
        736484: { ignoreTextPosts: [ "Roman" ], wrongImageUpdates: [ "750212", "750213", "750214" ] },
        741609: { missedTextUpdates: [ "754524" ] },
        743976: { ignoreTextPosts: [ "", "Typo" ] },
        745694: { ignoreTextPosts: [ "Crunchysaurus" ] },
        750281: { ignoreTextPosts: [ "Autozero" ] },
        752572: { missedTextUpdates: [ "752651", "752802", "767190" ] },
        754415: { collaboration: true, ignoreTextPosts: [ "!5tTWT1eydY" ] },
        755378: { missedAuthors: [ "!Ykw7p6s1S." ] },
        758668: { ignoreTextPosts: [ "LD" ] },
        767346: { ignoreTextPosts: [ "" ] },
        768858: { ignoreTextPosts: [ "LD" ] },
        774368: { missedTextUpdates: [ "774500" ] },
        774930: { missedTextUpdates: [ "794040" ] },
        778045: { missedTextUpdates: [ "778427", "779363" ] },
        779564: { ignoreTextPosts: [ "" ] },
        784068: { wrongTextUpdates: [ "785618" ] },
        785044: { wrongTextUpdates: [ "801329" ] },
        789976: { missedTextUpdates: [ "790596", "793934", "800875", "832472" ] },
        794320: { wrongTextUpdates: [ "795183" ] },
        798380: { missedTextUpdates: [ "799784", "800444", "800774", "800817", "801212" ] },
        799546: { missedTextUpdates: [ "801103", "802351", "802753" ] },
        799612: { missedTextUpdates: [ "799968", "801579" ] },
        800605: { collaboration: true },
        802411: { missedTextUpdates: [ "805002" ] },
        807972: { wrongTextUpdates: [ "811969" ] },
        809039: { wrongImageUpdates: [ "817508", "817511" ] },
        811957: { ignoreTextPosts: [ "via Discord" ] },
        814448: { missedTextUpdates: [ "817938" ] },
        817541: { missedAuthors: [ "Raptie" ] },
        822552: { imageQuest: false},
        823831: { missedAuthors: [ "Retro-LOPIS" ] },
        827264: { ignoreTextPosts: [ "LD", "DogFace" ] },
        830006: { collaboration: true },
        835062: { ignoreTextPosts: [ "Curves" ] },
        835750: { missedTextUpdates: [ "836870" ] },
        836521: { wrongTextUpdates: [ "848748" ] },
        837514: { ignoreTextPosts: [ "LD" ] },
        839906: { missedTextUpdates: [ "845724" ] },
        840029: { missedTextUpdates: [ "840044", "840543" ] },
        841851: { ignoreTextPosts: [ "Serpens", "Joy" ] },
        842392: { missedTextUpdates: [ "842434", "842504", "842544" ] },
        844537: { missedTextUpdates: [ "847326" ] },
        845168: { ignoreTextPosts: [ "a48264" ] },
        848887: { imageQuest: true, wrongTextUpdates: [ "851878" ] },
        854088: { missedTextUpdates: [ "860219" ], ignoreTextPosts: [ "Ursula" ] },
        854203: { ignoreTextPosts: [ "Zenthis" ] },
        857294: { wrongTextUpdates: [ "857818" ] },
        858913: { imageQuest: false},
        863241: { missedTextUpdates: [ "863519" ] },
        865754: { missedTextUpdates: [ "875371" ], ignoreTextPosts: [ "???" ] },
        869242: { ignoreTextPosts: [ "" ] },
        871667: { missedTextUpdates: [ "884575" ] },
        876808: { imageQuest: false},
        879456: { missedTextUpdates: [ "881847" ] },
        881097: { missedTextUpdates: [ "881292", "882339" ] },
        881374: { ignoreTextPosts: [ "LD" ] },
        885481: { imageQuest: false, wrongTextUpdates: [ "886892" ] },
        890023: { missedAuthors: [ "595acb" ] },
        892578: { ignoreTextPosts: [ "" ] },
        897318: { missedTextUpdates: [ "897321", "897624" ] },
        897846: { missedTextUpdates: [ "897854", "897866" ] },
        898917: { missedAuthors: [ "Cee (mobile)" ] },
        900852: { missedTextUpdates: [ "900864" ] },
        904316: { missedTextUpdates: [ "904356", "904491" ] },
        907309: { missedTextUpdates: [ "907310" ] },
        913803: { ignoreTextPosts: [ "Typo" ] },
        915945: { missedTextUpdates: [ "916021" ] },
        917513: { missedTextUpdates: [ "917515" ] },
        918806: { missedTextUpdates: [ "935207" ] },
        921083: { ignoreTextPosts: [ "LawyerDog" ] },
        923174: { ignoreTextPosts: [ "Marn", "MarnMobile" ] },
        924317: { ignoreTextPosts: [ "" ] },
        924496: { ignoreTextPosts: [ "Alyssa" ], wrongImageUpdates: [ "926049", "951786" ] },
        926927: { missedTextUpdates: [ "928194" ] },
        929115: { wrongTextUpdates: [ "959349" ] },
        929545: { missedTextUpdates: [ "929634" ] },
        930854: { missedTextUpdates: [ "932282" ] },
        934026: { missedTextUpdates: [ "934078", "934817" ] },
        935464: { missedTextUpdates: [ "935544", "935550", "935552", "935880" ] },
        934706: { missedAuthors: [ "Karma" ], wrongTextUpdates: [ "934721", "934985", "935521" ] },
        939572: { missedTextUpdates: [ "940402" ] },
        940835: { missedTextUpdates: [ "941005", "941067", "941137", "941226", "942383", "944236", "945435" ] },
        944938: { missedTextUpdates: [ "945119" ] },
        947959: { collaboration: true, wrongImageUpdates: [ "948044", "948045" ] },
        949128: { ignoreTextPosts: [ "Breven" ] },
        950800: { missedTextUpdates: [ "955309", "955582", "956789", "1020563" ] },
        951319: { missedTextUpdates: [ "951450" ] },
        954301: { wrongImageUpdates: [ "954628" ] },
        960828: { missedTextUpdates: [ "967578", "967607", "967611", "967616", "967619", "967628", "967636", "967642", "967646", "967776", "967778", "967785", "967789", "967803", "967809", "967813", "967899", "968112", "968119", "968224", "968230", "968238", "968242" ] },
        964608: { missedTextUpdates: [ "964674" ] },
        968549: { missedTextUpdates: [ "968841" ] },
        971193: { missedTextUpdates: [ "971938", "997943" ] },
        971801: { missedTextUpdates: [ "971804", "971825" ] },
        973331: { missedTextUpdates: [ "973726", "973764" ] },
        974578: { missedTextUpdates: [ "974901", "974902", "974903", "974906", "975511", "976295", "976296", "976297", "976298" ] },
        975434: { imageQuest: false },
        976580: { missedTextUpdates: [ "976604", "976657", "976669", "976841", "983554" ] },
        978557: { ignoreTextPosts: [ "" ] },
        982815: { missedTextUpdates: [ "992488" ] },
        989640: { missedTextUpdates: [ "989941" ] },
        991564: { missedTextUpdates: [ "991566" ] },
        994099: { collaboration: true },
        1001663: { collaboration: true, ignoreTextPosts: [ "" ] },
        1002454: { ignoreTextPosts: [ "" ] },
        1004042: { missedAuthors: [ "b35aa3", "Kiocoatl" ] },
        1010895: { missedTextUpdates: [ "1010926", "1011004", "1011011" ] },
        1013611: { wrongImageUpdates: [ "1014242" ], ignoreTextPosts: [ "tippler", "" ] },
        1014176: { ignoreTextPosts: [ "Edmango" ] },
        1014571: { missedTextUpdates: [ "1025238" ] },
        1015802: { ignoreTextPosts: [ "Edmango" ] },
        1023251: { imageQuest: false },
        1023844: { ignoreTextPosts: [ "good ol wife" ] },
        1028352: { wrongTextUpdates: [ "1028424", "1028426", "1028771", "1028772", "1028957", "1028958" ] },
        1028430: { ignoreTextPosts: [ "" ], wrongTextUpdates: [ "1028530" ] },
        2000012: { missedAuthors: [ "Happiness" ] },
      };
    }
    return this.allFixes;
  }
}

//More or less standard XMLHttpRequest wrapper
//Input: url
//Output: Promise that resolves into the XHR object (or a HTTP error code)
class Xhr {
  static get(url) {
    return new Promise(function(resolve, reject) {
      const xhr = new XMLHttpRequest();
      xhr.onreadystatechange = function(e) {
        if (xhr.readyState === 4) {
          if (xhr.status === 200) {
            resolve(xhr);
          }
          else {
            reject(xhr.status);
          }
        }
      };
      xhr.ontimeout = function () {
        reject("timeout");
      };
      xhr.open("get", url, true);
      xhr.send();
    });
  }
}

//QuestReader class
//Input: none
//Output: none
//Usage: new QuestReader.init(settings);
//settings: a settings object obtained from the object's onSettingsChanged event, allowing you to store settings
class QuestReader {
  constructor(doc) {
    this.doc = doc;
    this.boardName = doc.location.pathname.match(new RegExp("/kusaba/([a-z]*)/res/"))[1];
    this.threadType = BoardThreadTypes[this.boardName];
    this.defaultName = BoardDefaultNames[this.boardName];
    this.hasTitle = false;
    this.updates = [];
    this.sequences = [];
    this.defaultSettings = this.getDefaultSettings();
    this.setSettings(this.defaultSettings);
    this.threadID = null;
    this.refClass = null;
    this.posts = null;
    this.firstPostElements = [];
    this.cloneCache = {};
    this.controls = {};
    this.total = { authorComments: 0, suggestions: 0};
    this.author = null;
    this.suggesters = null;
    this.scrollIntervalHandle = null;
    this.replyFormDraggable = null;
    this.dayNames = [ "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
    this.currentDateTime = null;
    this.relativeTime = null;
    //regular expressions
    this.reCanonID = new RegExp("^[0-9a-f]{6}$");
    //events
    this.onSettingsLoad = null;
    this.onSettingsChanged = null;
    this.onWikiDataLoad = null;
    this.onWikiDataChanged = null;
  }

  getDefaultSettings() {
    return {
      currentUpdateIndex: 0,
      viewMode: "all", //all, single, sequence
      showSuggestions: "all", //none, last, all
      showAuthorComments: "all", //none, last, all
      showReferences: this.threadType == ThreadType.QUEST ? "nonupdates" : "all", //none, nonupdates, all
      replyFormLocation: "float", //top, bottom, float
      expandImages: "none", //none, updates, all
      maxImageWidth: 100,
      maxImageHeight: 96,
      stretchImages: false,
      showUpdateInfo: false,
      timestampFormat: "server", //server, local, relative, auto, hide
      colorUsers: false,
      showUserPostCounts: false,
      showUserNav: false,
      showUserMultiPost: false,
      showReplyForm: false,
      moveToLast: false,
      wikiPages: [],
      wikiLastSearchTime: 0,
    };
  }

  init(doc) {
    var results = new UpdateAnalyzer({ passive: this.threadType != ThreadType.QUEST, defaultName: this.defaultName, debug: true }).analyzeQuest(doc); //run UpdateAnalyzer to determine which posts are updates and what not
    this.threadID = results.postTypes.keys().next().value;
    this.refClass = `ref|${doc.body.querySelector(`input[name="board"]`).value}|${this.threadID}|`; //a variable used for finding and creating post reference links
    this.updates = this.getUpdatePostGroups(results.postTypes, results.postUsers); //organize posts into groups where each group has one update post and its trailing suggestions
    this.sequences = this.getUpdateSequences(); //a list of unique update sequences
    this.loadSettings(); //load settings; YouDontSay.jpg
    this.insertStyling(this.doc); //insert html elements for styling
    this.posts = this.buildPostInfo(this.doc, results.postTypes, results.postUsers); //cache post elements and other post data for faster access
    this.initCloneCache(this.doc); //cache some elements for faster insertion
    this.insertControls(this.doc); //insert html elements for controls
    this.insertEvents(this.doc); //insert our own button events plus some global events
    this.modifyLayout(this.doc); //change the default layout by moving elements around to make them fit better
    this.insertTooltips(); //add some explanations to our controls
    this.refresh({checkHash: "correct", smoothScroll: false}); //hide all posts and show only the relevant ones; enable/disable/update controls
    this.useWiki(); //get data from wiki and use them to set up wiki link, disthread link, and other thread links
  }

  getUpdatePostGroups(postTypes, postUsers) {
    var updatePostGroups = [];
    var currentPostGroup = { updatePostID: 0, suggestions: [], authorComments: [] };
    var postTypesArray = [...postTypes];
    //create post groups
    for (let i = postTypesArray.length - 1; i >= 0; i--) {
      if (postTypesArray[i][1] == PostType.UPDATE) {
        currentPostGroup.updatePostID = postTypesArray[i][0];
        updatePostGroups.unshift(currentPostGroup);
        currentPostGroup = { updatePostID: 0, suggestions: [], authorComments: [] };
      }
      else if (postTypesArray[i][1] == PostType.AUTHORCOMMENT) {
        currentPostGroup.authorComments.unshift(postTypesArray[i][0]);
        this.total.authorComments++;
      }
      else { //PostType.SUGGESTION
        currentPostGroup.suggestions.unshift(postTypesArray[i][0]);
        this.total.suggestions++;
      }
    }
    //create sequence groups
    var currentUpdateSequence = [];
    updatePostGroups.forEach(postGroup => {
      currentUpdateSequence.push(postGroup);
      postGroup.sequence = currentUpdateSequence;
      if (postGroup.suggestions.length > 0) {
        currentUpdateSequence = [];
      }
    });
    //set post suggesters
    var allSuggesters = new Set();
    updatePostGroups.forEach(postGroup => {
      postGroup.suggesters = postGroup.suggestions.reduce((suggesters, el) => {
        var suggester = postUsers.get(el);
        suggesters.add(suggester);
        allSuggesters.add(suggester);
        return suggesters;
      }, new Set());
      postGroup.suggesters = [...postGroup.suggesters];
    });
    this.author = postUsers.get(this.threadID);
    this.suggesters = [...allSuggesters];
    return updatePostGroups;
  }

  getUpdateSequences() {
    var sequences = [];
    this.updates.forEach(update => {
      if (update.sequence !== sequences[sequences.length - 1]) {
        sequences.push(update.sequence);
      }
    });
    return sequences;
  }

  currentUpdate() {
    return this.updates[this.currentUpdateIndex];
  }

  firstUpdate() {
    return this.updates[0];
  }

  lastUpdate() {
    return this.updates[this.updates.length - 1];
  }

  currentSequenceIndex() {
    return this.sequences.indexOf(this.currentUpdate().sequence);
  }

  buildPostInfo(doc, postTypes, postUsers) {
    var posts = new Map();

    doc.body.querySelector(":scope > form").querySelectorAll(".postwidth > a[name]").forEach(anchor => {
      if (anchor.name == "s") {
        return;
      }
      var header = anchor.parentElement;
      var inner = header.parentElement;
      var postID = parseInt(anchor.name);
      var outer = inner;
      var outerName = postID === this.threadID ? "FORM" : "TABLE";
      while (outer.nodeName != outerName) {
        outer = outer.parentElement;
      }
      posts.set(postID, {
        outer: outer, inner: inner, header: header, user: postUsers.get(postID), type: postTypes.get(postID), timestampFormat: "server", relativeTime: null, serverDateTimeString: "", utcTime: 0, references: null,
        suggesterIsNew: false, userPrevPost: null, userNextPost: null, insertedReferences: false, insertedUserColors: false, insertedUserNav: false, insertedUserMultiPost: false, expandedThumbnail: false,
        imgInfo: null,
      });
    });
    this.getFirstPostElements(posts);
    this.getPostImageInfo(posts);
    this.getPostReferences(posts);
    if (this.threadType === ThreadType.QUEST) {
      this.getUserMultiPostInfo(posts, postUsers);
    }
    this.getUserNavInfo(posts, postUsers);
    this.setUserPostCounts(posts);
    return posts;
  }

  getFirstPostElements(posts) {
    var child = posts.get(this.threadID).inner.firstElementChild;
    while (child && child.nodeName !== "TABLE") {
      if (child.className === "postwidth" || child.nodeName === "BLOCKQUOTE" || child.className === "pony" || child.className === "unicorn" || child.className === "de-refmap") {
        this.firstPostElements.push(child);
      }
      child = child.nextElementSibling;
    }
  }

  getPostImageInfo(posts) {
    var reExpandCode = new RegExp(`expandimg\\('([0-9]+)', '(.*)', '(.*)', '([0-9]+)', '([0-9]+)', '([0-9]+)', '([0-9]+)'\\);`);
    var fileLinks = document.querySelectorAll(".filesize a");
    fileLinks.forEach(fileLink => {
      var expandString = fileLink.getAttribute("onclick") || "";
      var matches = expandString.match(reExpandCode);
      if (matches) {
        var post = posts.get(+matches[1]);
        if (post) {
          post.imgInfo = { imgSrc: matches[2], thumbSrc: matches[3], imgWidth: matches[4], imgHeight: matches[5], thumbWidth: matches[6], thumbHeight: matches[7], };
          if (!post.imgInfo.imgSrc.startsWith("http")) {
            post.imgInfo.imgSrc = this.doc.location.origin + post.imgInfo.imgSrc;
            post.imgInfo.thumbSrc = this.doc.location.origin + post.imgInfo.thumbSrc;
          }
        }
      }
    });
  }

  getPostReferences(posts) {
    var postLinks = posts.get(this.threadID).outer.querySelectorAll(`blockquote a[class^="${this.refClass}"]`); //this is faster than traversing all posts
    postLinks.forEach(link => {
      var parent = link.parentElement;
      while (parent.nodeName !== "BLOCKQUOTE") {
        parent = parent.parentElement;
      }
      parent = parent.parentElement;
      var linkPostID = parent.id.startsWith("reply") ? parseInt(parent.id.substring(5)) : this.threadID;
      var targetID = parseInt(link.classList[0].substring(this.refClass.length));
      var targetInfo = posts.get(targetID);
      if (!targetInfo) {
        return;
      }
      if (!targetInfo.references) {
        targetInfo.references = [];
      }
      targetInfo.references.push(linkPostID);
    });
  }

  getUserMultiPostInfo(postsInfo, postUsers) {
    this.updates.forEach(update => {
      var suggesterPosts = new Map(); //dictionary <user, postIDs[]>; which suggester made which posts for the current update
      update.suggestions.forEach(postID => {
        var suggester = postUsers.get(postID);
        var posts = suggesterPosts.get(suggester) || [];
        posts.push(postID);
        suggesterPosts.set(suggester, posts);
      });
      suggesterPosts.forEach((posts, suggester) => {
        suggester.isNew = suggester.postCount === posts.length;
        for (var postIndex = 0; postIndex < posts.length; postIndex++) {
          var postInfo = postsInfo.get(posts[postIndex]);
          if (!postInfo) {
            continue;
          }
          postInfo.suggesterIsNew = suggester.postCount === posts.length;
          if (posts.length > 1) {
            postInfo.userMultiPostIndex = postIndex + 1;
            postInfo.userMultiPostLength = posts.length;
          }
        }
      });
    });
  }

  getUserNavInfo(posts, postUsers) {
    var usersPrevPost = new Map();
    posts.forEach((post, postID) => {
      var user = postUsers.get(postID);
      post.userPostCount = user.postCount;
      var prevPostID = usersPrevPost.get(user);
      if (prevPostID) {
        posts.get(prevPostID).userNextPost = postID;
        post.userPrevPost = prevPostID;
      }
      usersPrevPost.set(user, postID);
    });
    usersPrevPost.clear();
  }

  setUserPostCounts(posts, postUsers) {
    posts.forEach((post, postID) => {
      var uidEl = post.header.querySelector(".uid");
      uidEl.setAttribute("postcount", post.userPostCount);
      if (post.suggesterIsNew) {
        uidEl.classList.add("isNew");
      }
    });
  }

  initCloneCache(doc) {
    this.cloneCache.a = doc.createElement("a");
    this.cloneCache.div = doc.createElement("div");
    this.cloneCache.span = doc.createElement("span");
    var fragmentUp = doc.createRange().createContextualFragment(`<a class="qrUserNavDisabled"><svg class="qrNavIcon" viewBox="0 0 24 24"><path d="M3 21.5l9-9 9 9M3 12.5l9-9 9 9"/></svg></a>`);
    var fragmentDown = doc.createRange().createContextualFragment(`<a class="qrUserNavDisabled"><svg class="qrNavIcon" viewBox="0 0 24 24"><path d="M3 12.5l9 9 9-9M3 3.5l9 9 9-9"/></svg></a>`);
    this.cloneCache.upLink = fragmentUp.children[0];
    this.cloneCache.downLink = fragmentDown.children[0];
    this.cloneCache.userMultiPostElement = doc.createElement("span");
    this.cloneCache.userMultiPostElement.className = "qrUserMultiPost";
  }

  refresh(options = {}) {
    var checkHash = options.checkHash !== undefined ? options.checkHash : "false"; //checkHash: if true and the url has a hash, show the update that contains the post ID in the hash and scroll to this post
    var scroll = options.scroll !== undefined ? options.scroll : true; //scroll: if true (default), scroll to the current update
    var smoothScroll = options.smoothScroll !== undefined ? options.smoothScroll : true; //if true (default), use smooth scroll
    var scrollToPostID = null;
    if (this.moveToLast) {
      this.moveToLast = false;
      this.showLast(false);
    }
    if (checkHash !== "false") {
      var hashedPostID = parseInt(this.doc.location.hash.replace("#", ""));
      if (!isNaN(hashedPostID) && hashedPostID != this.threadID && this.posts.has(hashedPostID)) {
        //Clicking on a "new posts" link in the Watched Threads usually takes you to the wrong post in a thread, that it, one post before the actual new post.
        //This usually results in visiting a post before the update, which is wrong -> we should be showing the update instead
        scrollToPostID = hashedPostID;
        var hashedUpdateIndex = this.findUpdateIndex(hashedPostID);
        if (checkHash === "correct" && this.threadType == ThreadType.QUEST) {
          var hashedUpdate = this.updates[hashedUpdateIndex];
          var hashedUpdatePosts = hashedUpdate.authorComments.concat(hashedUpdate.suggestions).sort();
          var isLastPostInTheUpdate = hashedPostID == hashedUpdatePosts[hashedUpdatePosts.length - 1];
          //the correction should only work if it's the one post before the first update of the last sequence
          if (isLastPostInTheUpdate && hashedUpdateIndex >= 0 && hashedUpdateIndex >= this.updates.indexOf(this.lastUpdate().sequence[0]) - 1 && hashedUpdateIndex < this.updates.length - 1) {
            hashedUpdateIndex++;
            scrollToPostID = null;
          }
        }
        this.changeIndex(hashedUpdateIndex, false);
      }
    }
    if (!scrollToPostID && this.threadType == ThreadType.QUEST && this.viewMode == "all" && this.currentUpdate() != this.firstUpdate()) {
      scrollToPostID = this.currentUpdate().updatePostID;
    }
    this.currentDateTime = new Date();
    this.hideAll();
    this.showCurrentUpdates();
    if (checkHash !== "false" && scrollToPostID) {
      this.showPost(scrollToPostID); //in case we want to scroll to a hidden suggestion, we want to show it first
    }
    this.updateControls();
    if (scroll) {
      var scrollToElement = (scrollToPostID && this.posts.has(scrollToPostID)) ? this.posts.get(scrollToPostID).outer : this.controls.controlsTop;
      var scrollOptions = { behavior: smoothScroll ? "smooth" : "auto", block: "start", };
      var scrollFunction = () => { this.doc.defaultView.requestAnimationFrame(() => { scrollToElement.scrollIntoView(scrollOptions); }); };
      if (this.doc.readyState !== "complete") {
        this.doc.defaultView.addEventListener("load", scrollFunction, { once: true });
      }
      else {
        scrollFunction();
      }
    }
  }

  hideAll() {
    this.posts.forEach((post, postID) => {
      if (postID == this.threadID) {
        this.firstPostElements.forEach(el => { el.classList.add("veryhidden"); });
      }
      else {
        post.outer.classList.add("hidden");
      }
    });
  }

  findUpdateIndex(postID) {
    for (var i = 0; i < this.updates.length; i++) {
      if (this.updates[i].updatePostID == postID || this.updates[i].suggestions.indexOf(postID) != -1 || this.updates[i].authorComments.indexOf(postID) != -1) {
        return i;
      }
    }
    return -1;
  }

  showCurrentUpdates() {
    var updatesToShow = {
      single: [ this.currentUpdate() ],
      sequence: this.currentUpdate().sequence,
      all: this.updates,
    };
    var updatesToExpand = {};
    updatesToExpand.single = [this.updates[this.currentUpdateIndex - 1], this.currentUpdate(), this.updates[this.currentUpdateIndex + 1]].filter(el => !!el);
    updatesToExpand.sequence = [...this.sequences[this.currentSequenceIndex() - 1] || [], ...this.sequences[this.currentSequenceIndex()], ...this.sequences[this.currentSequenceIndex() + 1] || []];
    //expanding images on the fly when in full thread view is a bit janky when navigating up
    updatesToExpand.all = [this.currentUpdate(), this.updates[this.currentUpdateIndex + 1], this.updates[this.currentUpdateIndex + 2]].filter(el => !!el);
    //updatesToExpand.all = this.updates;

    updatesToShow[this.viewMode].forEach(update => this.showUpdate(update));
    updatesToExpand[this.viewMode].forEach(update => this.expandUpdateImages(update));
  }

  expandUpdateImages(update) {
    var postsToExpand = [ update.updatePostID ];
    if (this.expandImages != "updates") {
      postsToExpand = postsToExpand.concat(update.suggestions, update.authorComments);
    }
    postsToExpand.forEach(postID => {
      var post = this.posts.get(postID);
      if (!post || !post.imgInfo) {
        return;
      }
      var img = post.header.querySelector(`#thumb${postID} > img`);
      if (img.previousElementSibling && img.previousElementSibling.nodeName === "CANVAS") {
        img.previousElementSibling.remove(); //remove canvas covering the image
        img.style.removeProperty("display");
      }
      var expanded = this.viewMode == "all" ? img.style.backgroundImage === `url(${post.imgInfo.imgSrc})` : img.src == post.imgInfo.imgSrc;
      if (expanded !== (this.expandImages == "all" || (this.expandImages == "updates" && postID == update.updatePostID))) { //image should be expanded or contracted
        if (!expanded) {
          img.removeAttribute("onmouseover");
          img.removeAttribute("onmouseout");
          if (post.outer.classList.contains("hidden")) { //if it's a hidden post, we'd like to preload the image, but a bit later
            setTimeout(() => { new Image().src = post.imgInfo.imgSrc });
          }
          else if (this.viewMode == "all") {
            img.style.backgroundImage = `url(${post.imgInfo.imgSrc})`;
          }
          else {
            img.src = post.imgInfo.imgSrc;
          }
        }
        else {
          img.src = post.imgInfo.thumbSrc;
          img.width = post.imgInfo.thumbWidth;
          img.height = post.imgInfo.thumbHeight;
        }
      }
    });
  }

  showUpdate(update) {
    this.showPost(update.updatePostID);
    if (this.showSuggestions == "all" || this.showSuggestions == "last" && update == this.lastUpdate()) {
      update.suggestions.forEach(postID => this.showPost(postID));
    }
    if (this.showAuthorComments == "all" || this.showAuthorComments == "last" && update == this.lastUpdate()) {
      update.authorComments.forEach(postID => this.showPost(postID));
    }
  }

  showPost(postID) {
    this.insertPostElements(postID);
    if (postID == this.threadID) {
      this.firstPostElements.forEach(el => { el.classList.remove("veryhidden"); });
    }
    else {
      var post = this.posts.get(postID);
      if (post) {
        post.outer.classList.remove("hidden");
      }
    }
  }

  insertPostElements(postID) {
    var post = this.posts.get(postID);
    if (!post) {
      return;
    }
    if (!post.insertedReferences && (this.showReferences == "all" || (this.showReferences == "nonupdates" && post.type !== PostType.UPDATE))) {
      post.insertedReferences = true;
      if (post.references) {
        this.insertPostReferences(postID, post.references, post.inner);
      }
    }
    if (!post.insertedUserColors && this.colorUsers) {
      post.insertedUserColors = true;
      if (this.threadType !== ThreadType.QUEST || (post.user.id !== this.author.id && post.type !== PostType.UPDATE)) {
        this.insertUserColors(post);
      }
    }
    if (!post.insertedUserNav && this.showUserNav) {
      post.insertedUserNav = true;
      this.insertUserNav(post);
    }
    if (!post.insertedUserMultiPost && this.showUserMultiPost) {
      post.insertedUserMultiPost = true;
      if (post.userMultiPostIndex) {
        this.insertUserMultiPost(post);
      }
    }
    if (post.timestampFormat != this.timestampFormat || post.relativeTime !== this.relativeTime) {
      post.timestampFormat = this.timestampFormat;
      post.relativeTime = this.relativeTime;
      this.changePostTime(post);
    }
    if (this.viewMode == "all" && post.imgInfo && post.expandedThumbnail !== (this.expandImages === "all" || (this.expandImages == "updates" && post.type === PostType.UPDATE))) {
      post.expandedThumbnail = !post.expandedThumbnail;
      this.resizeThumbnail(post, post.expandedThumbnail);
    }
  }

  resizeThumbnail(post, expandThumbnail) {
    var imgElement = post.header.querySelector("img.thumb");
    if (expandThumbnail) {
      imgElement.src = `data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="${post.imgInfo.imgWidth}px" height="${post.imgInfo.imgHeight}px"></svg>`;
      imgElement.style.backgroundImage = `url(${post.imgInfo.thumbSrc})`;
      imgElement.removeAttribute("onmouseover"); //it's possible these events trigger before expandUpdateImages and restore spoiler images, so gotta remove them here
      imgElement.removeAttribute("onmouseout");
    }
    else {
      imgElement.src = post.imgInfo.thumbSrc;
      imgElement.style.backgroundImage = "";
      imgElement.width = post.imgInfo.thumbWidth;
      imgElement.height = post.imgInfo.thumbHeight;
    }
  }

  insertPostReferences(postID, references, postElement) {
    var links = references.map(id => {
      var newLink = this.cloneCache.a.cloneNode("false");
      newLink.href = `#${id}`;
      newLink.className = `${this.refClass}${id}| qrReference`;
      newLink.textContent = `>>${id}`;
      return newLink;
    });
    var newDiv = this.cloneCache.div.cloneNode("false");
    newDiv.classList.add("qrReferences");
    newDiv.append(...links);
    postElement.querySelector("blockquote").insertAdjacentElement("afterEnd", newDiv);
    if (postID === this.threadID) {
      this.firstPostElements.push(newDiv);
    }
  }

  insertUserColors(post) {
    var uidEl = post.header.querySelector(".uid");
    var span = this.cloneCache.span.cloneNode(false);
    span.className = `qrColoredUid uid${this.getCanonID(post.user)}`;
    span.textContent = uidEl.firstChild.nodeValue.substring(4);
    uidEl.firstChild.nodeValue = "ID: ";
    uidEl.appendChild(span);
  }

  insertUserNav(post) {
    var uidEl = post.header.querySelector(".uid");
    var downLink = this.cloneCache.downLink.cloneNode(true);
    var upLink = this.cloneCache.upLink.cloneNode(true);
    if (post.userPrevPost) {
      upLink.href = `#${post.userPrevPost}`;
      upLink.className = "qrUserNavEnabled";
    }
    if (post.userNextPost) {
      downLink.href = `#${post.userNextPost}`;
      downLink.className = "qrUserNavEnabled";
    }
    uidEl.insertAdjacentElement("afterEnd", downLink);
    uidEl.insertAdjacentElement("afterEnd", upLink);
  }

  insertUserMultiPost(post) {
    var span = this.cloneCache.userMultiPostElement.cloneNode(false);
    span.textContent = `post ${post.userMultiPostIndex}/${post.userMultiPostLength}`;
    post.header.appendChild(span);
  }

  changePostTime(post) {
    var timeNode = post.header.querySelector("label").lastChild;
    if (post.timestampFormat == "hide") {
      post.serverDateTimeString = post.serverDateTimeString || ` ${timeNode.nodeValue.trim()}`;
      timeNode.nodeValue = "";
      return;
    }
    var utcTime = this.getUtcTime(post, timeNode);
    var difference = this.currentDateTime.getTime() - utcTime;
    if (post.relativeTime !== null ? post.relativeTime : post.timestampFormat == "relative" || (post.timestampFormat == "auto" && difference < 86400000)) {
      var date = new Date(utcTime);
      timeNode.nodeValue = `${this.getRelativeTimeString(difference, difference >= 86400000)} ago${difference >= 86400000 ? ` at ${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}` : ""}`;
    }
    else {
      timeNode.nodeValue = post.timestampFormat == "server" ? post.serverDateTimeString : this.getAbsoluteDateTimeString(new Date(utcTime));
    }
  }

  getUtcTime(post, timeNode) {
    if (!post.utcTime) {
      post.serverDateTimeString = post.serverDateTimeString || ` ${timeNode.nodeValue.trim()}`;
      var d = post.serverDateTimeString;
      var serverTime = Date.UTC(d.substring(1, 5), d.substring(6, 8) - 1, d.substring(9, 11), d.substring(16, 18), d.substring(19));
      post.utcTime = serverTime - this.getServerTimeZoneOffset(new Date(serverTime));
    }
    return post.utcTime;
  }

  getServerTimeZoneOffset(dateTime) {
    var month = dateTime.getUTCMonth();
    var day = dateTime.getUTCDate();
    var dayOfWeek = dateTime.getUTCDay();
    var hour = dateTime.getUTCHours();
    var firstSunday = ((day + 7 - dayOfWeek) % 7) || 7;
    var after2ndSundayInMarch3am = month > 2 || (month == 2 && (day > firstSunday + 7 || ( day == firstSunday + 7 && hour >= 3)));
    var before1stSundayInNovember1am = month < 10 || (month == 10 && (day < firstSunday || ( day == firstSunday && hour < 1)));
    var isDST = after2ndSundayInMarch3am && before1stSundayInNovember1am;
    return (isDST ? -7 : -8) * 3600000;
  }

  getRelativeTimeString(difference, onlyDate) {
    //convert a difference between two dates into a string by finding and using the two most relevant date parts
    //writing this function was fun (read: hell); it's not perfect due to varying numbers of days in months and step years, but it should be good enough
    var names = onlyDate ? ["d", "mo", "y"] : ["min", "h", "d", "mo", "y"];
    var factors = onlyDate ? [30.4375, 12] : [60, 24, 30.4375, 12];
    var baseDifference = onlyDate ? difference / (60000 * 60 * 24) : difference / 60000;
    var diffs = factors.reduce((diffs, factor, index) => { diffs.push(diffs[index] / factor); return diffs; }, [ baseDifference ]);
    var i = diffs.length - 1;
    for (; i > 0 && diffs[i] < 1; i--) {} //find the most relevant date part -> the first one with a value greater than 1
    if (i > 0) { //truncate + round; 1.76h 105.6min -> 1h 46min
      diffs[i] = Math.trunc(diffs[i]);
      diffs[i - 1] = Math.round(diffs[i - 1] - diffs[i] * factors[i - 1]);
      if (diffs[i - 1] == factors[i - 1]) { //correct the parts if rounded to a factor number; 1h 60min -> 2h 0min
        diffs[i - 1] = 0;
        diffs[i]++;
      }
    }
    else {
      diffs[i] = onlyDate ? Math.trunc(diffs[i]) : Math.round(diffs[i]);
    }
    if (i < diffs.length - 1 && diffs[i] == factors[i]) { //correct the parts if rounding caused a transition; 60min -> 1h 0min
      diffs[i] = 0;
      i++;
      diffs[i] = 1;
    }
    return ` ${diffs[i]}${names[i]}${names[i - 1] ? ` ${diffs[i - 1]}${names[i - 1]}` : ""}`;
  }

  getAbsoluteDateTimeString(date) {
    return ` ${date.getFullYear()}/${(date.getMonth() + 1).toString().padStart(2, "0")}/${date.getDate().toString().padStart(2, "0")}` +
      `(${this.dayNames[date.getDay()]})${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}`;
  }

  showFirst() {
    var newUpdateIndex = 0;
    this.changeIndex(newUpdateIndex);
    if (this.doc.activeElement.disabled === true) { //Firefox bug: Keydown events don't trigger if focused input is disabled
      (event.target == this.controls.showFirstButtons[0] ? this.controls.showNextButtons[0] : this.controls.showNextButtons[1]).focus();
    }
  }

  showLast(refresh = true) {
    var newUpdateIndex = this.viewMode == "sequence" ? this.updates.indexOf(this.sequences[this.sequences.length - 1][0]) : this.updates.length - 1;
    this.changeIndex(newUpdateIndex, refresh);
    if (this.doc.activeElement.disabled === true) { //Firefox bug: Keydown events don't trigger if focused input is disabled
      (event.target == this.controls.showLastButtons[0] ? this.controls.showPrevButtons[0] : this.controls.showPrevButtons[1]).focus();
    }
  }

  showNext() {
    var newUpdateIndex = this.currentUpdateIndex + 1;
    if (this.viewMode == "sequence") { //move to the first update in the next sequence
      newUpdateIndex = this.currentSequenceIndex() < this.sequences.length - 1 ? this.updates.indexOf(this.sequences[this.currentSequenceIndex() + 1][0]) : this.updates.length;
    }
    this.changeIndex(newUpdateIndex);
  }

  showPrevious() {
    var newUpdateIndex = this.currentUpdateIndex - 1;
    if (this.viewMode == "sequence") {
      newUpdateIndex = this.currentSequenceIndex() > 0 ? this.updates.indexOf(this.sequences[this.currentSequenceIndex() - 1][0]) : -1;
    }
    this.changeIndex(newUpdateIndex);
  }

  changeIndex(newUpdateIndex, refresh = true) {
    if (newUpdateIndex === this.currentUpdateIndex || newUpdateIndex < 0 || newUpdateIndex > this.updates.length - 1) {
      return;
    }
    var difference = Math.abs(newUpdateIndex - this.currentUpdateIndex);
    if (this.viewMode == "sequence") {
      difference = Math.abs(this.sequences.indexOf(this.updates[newUpdateIndex].sequence) - this.currentUpdateSequence);
    }
    this.currentUpdateIndex = newUpdateIndex;
    if (refresh) {
      this.refresh({ smoothScroll: difference === 1 });
    }
    this.settingsChanged();
    //Firefox bug: Keydown events don't trigger if focused input is disabled (for instance, if clicked on the Last button) => change focus to some other button
    if (this.doc.activeElement.disabled === true) {
      var buttonToFocus = null;
      if (this.currentUpdateIndex === 0) {
        if ([...this.controls.showFirstButtons, ...this.controls.showPrevButtons].includes(event.target)) {
          buttonToFocus = this.controls.showNextButtons[0];
        }
      }
      else if (this.currentUpdateIndex === this.updates.length - 1 || (this.viewMode == "sequence" && this.currentSequenceIndex() === this.sequences.length - 1)) {
        if ([...this.controls.showLastButtons, ...this.controls.showNextButtons].includes(event.target)) {
          buttonToFocus = this.controls.showPrevButtons[0];
        }
      }
      if (buttonToFocus != null) {
        buttonToFocus.focus();
      }
    }
  }

  loadSettings() {
    if (this.onSettingsLoad) {
      var e = { threadID: this.threadID, threadType: this.threadType, boardName: this.boardName, settings: null };
      this.onSettingsLoad(e);
      if (e.settings) {
        this.setSettings(this.validateSettings(e.settings));
      }
    }
  }

  setSettings(settings) {
    if (settings) {
      for(var settingName in settings) {
        this[settingName] = settings[settingName];
      }
    }
  }

  validateSettings(settings) {
    if (!settings) {
      return settings;
    }
    if (settings.currentUpdateIndex < 0) settings.currentUpdateIndex = 0;
    if (settings.currentUpdateIndex >= this.updates.length) settings.currentUpdateIndex = this.updates.length - 1;
    for (var prop in settings) {
      if (this[prop] !== undefined && typeof(settings[prop]) !== typeof(this[prop])) {
        settings[prop] = this[prop];
      }
    }
    if (!settings.replyFormLocation) { //replyFormLocation == "float"
      settings.showReplyForm = false;
    }
    return settings;
  }

  settingsChanged() {
    if (this.onSettingsChanged) {
      var settings = {};
      for(var settingName in this.defaultSettings) {
        if (this[settingName] !== this.defaultSettings[settingName] || (Array.isArray(this[settingName]) && this[settingName].length > 0)) {
          settings[settingName] = this[settingName];
        }
      }
      this.onSettingsChanged({ threadID: this.threadID, threadType: this.threadType, boardName: this.boardName, settings: settings });
    }
  }

  toggleSettingsControls(e) {
    e.preventDefault(); //prevent scrolling to the top when clicking the link
    this.controls.settingsControls.classList.toggle("collapsedHeight");
    var label = e.target;
    label.text = this.controls.settingsControls.classList.contains("collapsedHeight") ? "Settings" : "Hide Settings";
  }

  showSettingsPage(e) {
    e.preventDefault();
    var linkContainer = e.target.parentElement;
    if (!linkContainer.classList.contains("qrSettingsNavItem")) {
      return;
    }
    this.controls.settingsControls.querySelector(".qrSettingsNavItemSelected").classList.remove("qrSettingsNavItemSelected");
    this.controls.settingsControls.querySelector(".qrCurrentPage").classList.remove("qrCurrentPage");
    linkContainer.classList.add("qrSettingsNavItemSelected");
    this.controls.settingsControls.querySelector(".qrSettingsPages").children[[...linkContainer.parentElement.children].indexOf(linkContainer)].classList.add("qrCurrentPage");
  }

  toggleReplyForm(e) {
    e.preventDefault();
    this.showReplyForm = !this.controls.replyForm.classList.toggle("hidden");
    this.controls.replyFormRestoreButton.classList.toggle("hidden", this.showReplyForm);
    if (this.replyFormLocation == "float" && !this.controls.replyForm.classList.contains("qrPopout")) {
      this.controls.replyFormPopoutButton.click();
    }
    if (this.showReplyForm) {
      this.controls.replyForm.querySelector(`[name="message"]`).focus();
    }
    this.settingsChanged();
  }

  popoutReplyForm(e) {
    e.preventDefault();
    var floating = this.controls.replyForm.classList.toggle("qrPopout");
    var svgPath;
    if (floating) {
      svgPath = "M20 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h5M15 14h-5v-5M10.5 13.5L20.2 3.8";
      this.controls.replyFormPopoutButton.title = "Stop floating the Reply form";
      if (!this.replyFormDraggable) {
        var rect = this.controls.replyForm.querySelector(".postform").getBoundingClientRect();
        var width = rect.width;
        var height = rect.height;
        if (width === 0) { //some compatibility stuff
          var messageBox = this.controls.replyForm.querySelector(`[name="message"]`);
          if (messageBox.style.width) {
            width = Math.max(messageBox.style.width.replace("px", ""), messageBox.style.minWidth.replace("px", ""));
            height = Math.max(messageBox.style.height.replace("px", ""), messageBox.style.minHeight.replace("px", "")) + 150;
          }
        }
        this.controls.replyForm.style.left = `${this.doc.documentElement.clientWidth - width - 10}px`;
        this.controls.replyForm.style.top = `${(this.doc.defaultView.innerHeight - height) * 0.75}px`;
      }
      this.replyFormDraggable = new this.doc.defaultView.Draggable("postform", { handle: "qrReplyFormHeader" });
    }
    else {
      svgPath = "M18 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8c0-1.1.9-2 2-2h5M15 3h6v6M10 14L20.2 3.8";
      this.controls.replyFormPopoutButton.title = "Pop out the Reply form and have it float";
      this.replyFormDraggable.destroy();
    }
    this.controls.replyFormPopoutButton.firstElementChild.firstElementChild.firstElementChild.setAttribute("d", svgPath);
  }

  changeThread(e) {
    this.doc.location.href = e.target.value;
  }

  updateSettings() {
    this.viewMode = this.controls.viewModeDropdown.value;
    this.showSuggestions = this.controls.showSuggestionsDropdown.value;
    this.showAuthorComments = this.controls.showAuthorCommentsDropdown.value;
    this.expandImages = this.controls.expandImagesDropdown.value;
    this.showUpdateInfo = this.controls.showUpdateInfoCheckbox.checked;
    this.replyFormLocation = this.controls.replyFormLocationDropdown.value;
    this.refresh({scroll: false});
    this.settingsChanged();
  }

  changePostElementSettings(e) {
    if (e.target === this.controls.showReferencesDropdown) {
      this.showReferences = this.controls.showReferencesDropdown.value;
      this.doc.head.querySelector("#qrReferencesCss").innerHTML = this.getReferencesStyleRules();
    }
    else if (e.target === this.controls.colorUsersCheckbox) {
      this.colorUsers = this.controls.colorUsersCheckbox.checked;
      this.doc.head.querySelector("#qrUserColorsCss").innerHTML = this.getUserColorsStyleRules();
    }
    else if (e.target === this.controls.showUserPostCountsCheckbox) {
      this.showUserPostCounts = this.controls.showUserPostCountsCheckbox.checked;
      this.doc.head.querySelector("#qrUserPostCountsCss").innerHTML = this.getUserPostCountsStyleRules();
    }
    else if (e.target === this.controls.showUserNavCheckbox) {
      this.showUserNav = this.controls.showUserNavCheckbox.checked;
      this.doc.head.querySelector("#qrUserNavCss").innerHTML = this.getUserNavStyleRules();
    }
    else if (e.target === this.controls.showUserMultiPostCheckbox) {
      this.showUserMultiPost = this.controls.showUserMultiPostCheckbox.checked;
      this.doc.head.querySelector("#qrUserMultiPostCss").innerHTML = this.getUserMultiPostStyleRules();
    }
    else if (e.target === this.controls.timestampFormatDropdown) {
      this.timestampFormat = this.controls.timestampFormatDropdown.value;
      this.relativeTime = null;
    }
    this.refresh({scroll: false});
    this.settingsChanged();
  }

  changeImageSizeSettings(e) {
    var width = parseInt(this.controls.maxImageWidthTextbox.value);
    var height = parseInt(this.controls.maxImageHeightTextbox.value);
    if (isNaN(width) || width < 0 || width > 100) {
      width = this.maxImageWidth;
      this.controls.maxImageWidthTextbox.value = width;
    }
    if (isNaN(height) || height < 0 || height > 100) {
      height = this.maxImageHeight;
      this.controls.maxImageHeightTextbox.value = height;
    }
    this.maxImageWidth = width;
    this.maxImageHeight = height;
    this.stretchImages = this.controls.stretchImagesCheckbox.checked;
    this.controls.imageWidthLabel.textContent = this.stretchImages ? "Image container width" : "Max image width";
    this.controls.imageHeightLabel.textContent = this.stretchImages ? "Image container height" : "Max image height";
    this.doc.head.querySelector("#qrImageSizeCss").innerHTML = this.getImageSizesStyleRules();
    this.refresh({scroll: false});
    this.settingsChanged();
  }

  async useWiki() {
    if (this.threadType == ThreadType.OTHER) {
      return;
    }
    var wikiUrls = await this.findWikiUrls();
    if (!wikiUrls) {
      return;
    }
    var wikiDatas = [];
    for (let wikiUrl of wikiUrls) {
      var wikiPageName = wikiUrl.substring(wikiUrl.lastIndexOf("/") + 1);
      //cache wiki page names
      if (!this.wikiPages.includes(wikiPageName)) {
        this.wikiPages.push(wikiPageName);
        this.settingsChanged();
      }
      //try get wiki data from cache
      let wikiData = await this.getWikiData(wikiUrl);
      wikiDatas.push(wikiData);
      //cache wiki data
      if (this.onWikiDataChanged !== null) {
        //remember last visited quest thread
        if (this.threadType == ThreadType.QUEST) {
          wikiData.lastVisitedQuestUrl = this.doc.location.href.replace(new RegExp("#[0-9]+$"), ""); //don't want to remember the anchor
          wikiData.lastVisitedTime = Date.now();
        }
        let e = { threadID: this.threadID, threadType: this.threadType, boardName: this.boardName, wikiPageName: wikiPageName, wikiData: wikiData };
        this.onWikiDataChanged(e);
      }
    }
    //use wiki data
    //set up links to the quest wiki
    var wikiTarget = this.wikiPages.length > 1 ? `/w/index.php?search=${this.threadID}&fulltext=1&limit=500`: wikiUrls[0];
    this.controls.wikiLinks.forEach(link => { link.href = wikiTarget; link.style.removeProperty("color"); });
    //set up link to the discussion thread, or quest thread if currently in a discussion thread
    if (this.threadType == ThreadType.QUEST) {
      var disThreadGroup = wikiDatas[0].threadGroups.find(group => group.some(link => link.url.indexOf("/questdis/") >= 0));
      if (disThreadGroup) {
        this.controls.disLinks.forEach(link => { link.href = disThreadGroup[disThreadGroup.length - 1].url; link.parentElement.classList.remove("hidden"); });
      }
    }
    else if (this.threadType == ThreadType.DISCUSSION) {
      var visitedQuests = wikiDatas.filter(wikiData => wikiData.lastVisitedQuestUrl);
      if (visitedQuests.length > 0) {
        var lastVisitedUrl = visitedQuests.sort((a, b) => b.lastVisitedTime - a.lastVisitedTime)[0].lastVisitedQuestUrl;
        this.controls.questLinks.forEach(link => { link.href = lastVisitedUrl; link.parentElement.classList.remove("hidden"); });
      }
      else if (wikiDatas.length === 1) {
        var questUrls = [];
        wikiDatas[0].threadGroups.forEach(group => group.forEach(link => { if (link.url.indexOf("/quest/") >= 0) questUrls.push(link.url); }));
        if (questUrls.length > 0) {
          var lastNumberRegEx = new RegExp("([0-9]+)(?=[^0-9]*$)");
          var highestIdThread = questUrls.reduce((max, url) => { if (parseInt(url.match(lastNumberRegEx)[1]) > parseInt(max.match(lastNumberRegEx)[1])) max = url; return max; });
          this.controls.questLinks.forEach(link => { link.href = highestIdThread; link.parentElement.classList.remove("hidden"); });
        }
      }
    }
    //set up links to the quest threads; if in one of the quest threads, then only show links from the same group
    var currentThreadGroup = wikiDatas[0].threadGroups.find(group => group.some(link => link.url.indexOf(this.threadID) >= 0));
    if (currentThreadGroup) {
      var threadOptionsHtml;
      if (this.threadType == ThreadType.QUEST) {
        threadOptionsHtml = currentThreadGroup.reduce((optionsHtml, thread) => optionsHtml + `<option value="${thread.url}">${thread.name}</option>`, "");
      }
      else {
        var questsOptions = wikiDatas.map(wikiData => {
          var optionsGroups = wikiData.threadGroups.map(group => group.reduce((optionsHtml, thread) => optionsHtml + `<option value="${thread.url}">  ${thread.name}</option>`, ""));
          return `<option disabled>${wikiData.questTitle}</option>` + optionsGroups.join(`<option disabled>────────</option>`);
        });
        threadOptionsHtml = questsOptions.join(`<option disabled></option>`);
      }
      var currentThreadUrl = currentThreadGroup.find(thread => thread.url.indexOf(this.threadID) >= 0).url;
      this.controls.threadLinksDropdowns.forEach(dropdown => {
        dropdown.innerHTML = threadOptionsHtml;
        dropdown.value = currentThreadUrl;
      });
    }
    //update the thread / tab title with the title from wiki
    if (wikiDatas[0].questTitle) {
      if (this.threadType == ThreadType.DISCUSSION) {
        if (wikiDatas.length === 1) {
          this.doc.title = `${wikiDatas[0].questTitle} Discussion`;
          this.controls.logo.textContent = this.doc.title;
        }
      }
      else {
        this.doc.title = `${this.hasTitle ? this.doc.title : wikiDatas[0].questTitle}${wikiDatas[0].byAuthors}`;
        this.controls.logo.textContent = this.doc.title;
      }
    }
  }

  async findWikiUrls() {
    if (this.wikiPages.length > 0 && Date.now() - this.wikiLastSearchTime < 24 * 60 * 60000) { //flush the cache every 24 hours
      return this.wikiPages.map(name => `/wiki/${name}`);
    }
    try {
      this.wikiPages = [];
      var xhr = await Xhr.get(`/w/index.php?search=${this.threadID}&fulltext=1&limit=500`);
      this.wikiLastSearchTime = Date.now();
    }
    catch(e) {
      return;
    }
    //var threadID = xhr.responseURL.match(new RegExp("search=([0-9]+)"))[1];
    var doc = this.doc.implementation.createHTMLDocument(); //we create a HTML document, but don't load the images or scripts therein
    doc.documentElement.innerHTML = xhr.response;
    var results = [...doc.querySelectorAll(".searchmatch")].filter(el => el.textContent == this.threadID);
    if (results.length === 0) {
      return;
    }
    //filter wiki search results to the ones that have the threadID in the quest info box
    var theRightOnes = results.filter(el => {var p = el.previousSibling; return p && p.nodeType == Node.TEXT_NODE && p.textContent.match(new RegExp("[0-9]=$")); });
    if (theRightOnes.length === 0) {
      return;
    }
    return theRightOnes.map(el => el.parentElement.previousElementSibling.querySelector("a").href);
  }

  async getWikiData(wikiUrl) {
    var wikiPageName = wikiUrl.substring(wikiUrl.lastIndexOf("/") + 1);
    if (this.onWikiDataLoad !== null) {
      let e = { threadID: this.threadID, threadType: this.threadType, boardName: this.boardName, wikiPageName: wikiPageName, wikiData: null };
      this.onWikiDataLoad(e);
      if (e.wikiData && e.wikiData.retrieveTime && Date.now() - e.wikiData.retrieveTime < 24 * 60 * 60000) { //flush cache every 24 hours
        return e.wikiData;
      }
    }
    var wikiData = { retrieveTime: Date.now() };
    try {
      var xhr = await Xhr.get(wikiUrl);
    }
    catch(e) {
      this.wikiPages = this.wikiPages.filter(el => el !== wikiPageName);
      this.settingsChanged();
      return;
    }
    //parse quest wiki
    var doc = this.doc.implementation.createHTMLDocument();
    doc.documentElement.innerHTML = xhr.response;
    var links = [...doc.querySelectorAll(".infobox a")];
    links = links.filter(link => link.href.indexOf("image-for") < 0);
    var threadLinks = links.filter(l => l.href.indexOf("/quest/") >= 0 || l.href.indexOf("/questarch/") >= 0 || l.href.indexOf("/graveyard/") >= 0);
    var disThreadLinks = links.filter(l => l.href.indexOf("/questdis/") >= 0);
    //create thread groups, like they're in the wiki
    wikiData.threadGroups = [];
    var groups = new Map();
    [...threadLinks, ...disThreadLinks].forEach(link => {
      var key = link.parentElement.parentElement;
      var group = groups.get(key) || [];
      group.push({name: link.textContent, url: link.href});
      groups.set(key, group);
    });
    groups.forEach((links, key) => wikiData.threadGroups.push(links));
    wikiData.threadGroups.forEach(group => group.forEach(link => {
      if (link.name == "Thread" && group.length === 1) {
        link.name = "Thread 1";
      }
    }));
    //get quest author and title
    var infoboxHeader = doc.querySelector(".infobox big");
    if (infoboxHeader) {
      var children = [...infoboxHeader.childNodes];
      wikiData.questTitle = children.shift().textContent;
      wikiData.byAuthors = children.reduce((acc, el) => {acc += el.textContent; return acc;}, "");
    }
    return wikiData;
  }

  updateControls() {
    var leftDisabled = this.currentUpdate() == this.firstUpdate();
    var rightDisabled = this.currentUpdate() == this.lastUpdate();
    var current = this.currentUpdateIndex + 1;
    var last = this.updates.length;
    var infoUpdate = this.currentUpdate();
    if (this.viewMode == "sequence") {
      leftDisabled = this.currentUpdate().sequence == this.firstUpdate().sequence;
      rightDisabled = this.currentUpdate().sequence == this.lastUpdate().sequence;
      current = this.currentSequenceIndex() + 1;
      last = this.sequences.length;
      infoUpdate = this.currentUpdate().sequence[this.currentUpdate().sequence.length - 1];
    }
    // buttons
    [...this.controls.showFirstButtons, ...this.controls.showPrevButtons].forEach(button => { button.disabled = leftDisabled; });
    [...this.controls.showNextButtons, ...this.controls.showLastButtons].forEach(button => { button.disabled = rightDisabled; });
    // update info
    this.controls.currentPosLabels.forEach(label => { label.textContent = current; label.classList.toggle("qrLastIndex", current == last); });
    this.controls.totalPosLabels.forEach(label => { label.textContent = last; });
    this.controls.updateInfos.forEach(infoContainer => { infoContainer.classList.toggle("hidden", !this.showUpdateInfo); });
    if (this.showUpdateInfo) {
      this.controls.authorCommentsCountLabels[0].textContent = ` A:${this.viewMode !== "all" ? infoUpdate.authorComments.length : this.total.authorComments}`;
      this.controls.authorCommentsCountLabels[1].textContent = ` A:${infoUpdate.authorComments.length}`;
      this.controls.suggestionsCountLabels[0].textContent = ` S:${this.viewMode !== "all" ? infoUpdate.suggestions.length : this.total.suggestions}`;
      this.controls.suggestionsCountLabels[1].textContent = ` S:${infoUpdate.suggestions.length}`;
      this.updateSuggestersLabel(this.controls.suggestersCountLabels[0], this.viewMode === "all" ? this.suggesters : infoUpdate.suggesters);
      this.updateSuggestersLabel(this.controls.suggestersCountLabels[1], infoUpdate.suggesters);
    }
    // settings
    this.controls.viewModeDropdown.value = this.viewMode;
    this.controls.showSuggestionsDropdown.value = this.showSuggestions;
    this.controls.showAuthorCommentsDropdown.value = this.showAuthorComments;
    this.controls.showReferencesDropdown.value = this.showReferences;
    this.controls.replyFormLocationDropdown.value = this.replyFormLocation;
    this.controls.expandImagesDropdown.value = this.expandImages;
    this.controls.maxImageWidthTextbox.value = this.maxImageWidth;
    this.controls.maxImageHeightTextbox.value = this.maxImageHeight;
    this.controls.stretchImagesCheckbox.checked = this.stretchImages;
    this.controls.imageWidthLabel.textContent = this.stretchImages ? "Image container width" : "Max image width";
    this.controls.imageHeightLabel.textContent = this.stretchImages ? "Image container height" : "Max image height";
    this.controls.showUpdateInfoCheckbox.checked = this.showUpdateInfo;
    this.controls.timestampFormatDropdown.value = this.timestampFormat;
    this.controls.colorUsersCheckbox.checked = this.colorUsers;
    this.controls.showUserPostCountsCheckbox.checked = this.showUserPostCounts;
    this.controls.showUserNavCheckbox.checked = this.showUserNav;
    this.controls.showUserMultiPostCheckbox.checked = this.showUserMultiPost;
    // sticky controls when viewing whole thread (only in quest threads)
    if (this.threadType == ThreadType.QUEST) {
      this.controls.navControls[1].classList.toggle("stickyBottom", this.viewMode == "all");
      this.controls.navLinksContainers[1].classList.toggle("qrNavLinksBottom", this.viewMode == "all");
      var areLinksInGrid = this.controls.navLinksContainers[1].parentElement.classList.contains("qrNavControls");
      if (areLinksInGrid && this.viewMode == "all") { //move the links out of the grid so they don't appear in the floating nav controls
        this.controls.navControls[1].insertAdjacentElement("beforeBegin", this.controls.navLinksContainers[1]);
      }
      else if (!areLinksInGrid && this.viewMode != "all") { //move the links back into the grid
        this.controls.navControls[1].insertAdjacentElement("beforeEnd", this.controls.navLinksContainers[1]);
      }
    }

/*    // sentinels for full thread view
    var topOfCurrent = 0;
    var bottomOfCurrent = 0;
    if (this.viewMode == "all") {
      if (this.currentUpdate() != this.firstUpdate()) {
        topOfCurrent = this.posts.get(this.currentUpdate().updatePostID).outer.offsetTop;
      }
      if (this.currentUpdate() != this.lastUpdate()) {
        bottomOfCurrent = this.posts.get(this.updates[this.currentUpdateIndex + 1].updatePostID).outer.offsetTop;
      }
      this.sentinelPreviousEl.style.height = `${topOfCurrent}px`; //end of previous is top of current;
      this.sentinelCurrentEl.style.height = `${bottomOfCurrent}px`; //end of current is the top of next
    }
    this.sentinelPreviousEl.classList.toggle("hidden", this.viewMode != "all" || topOfCurrent === 0);
    this.sentinelCurrentEl.classList.toggle("hidden", this.viewMode != "all" || bottomOfCurrent === 0);
*/
    // reply form juggling
    var isReplyFormAtTop = (this.controls.replymode == this.controls.postarea.previousElementSibling);
    if (this.replyFormLocation != "top" && isReplyFormAtTop) { //move it down
      this.controls.postarea.remove();
      this.doc.body.insertBefore(this.controls.postarea, this.controls.navbar);
      this.controls.controlsTop.previousElementSibling.insertAdjacentHTML("beforeBegin", "<hr>");
    }
    else if (this.replyFormLocation == "top" && !isReplyFormAtTop) { //move it up
      this.controls.postarea.remove();
      this.controls.replymode.insertAdjacentElement("afterEnd", this.controls.postarea);
      this.controls.controlsTop.previousElementSibling.previousElementSibling.remove(); //remove <hr>
    }
    this.controls.replyForm.classList.toggle("hidden" , !this.showReplyForm);
    this.controls.replyFormRestoreButton.classList.toggle("hidden", this.showReplyForm);

    if (this.viewMode == "all" && !this.scrollIntervalHandle) {
      this.scrollIntervalHandle = setInterval(() => { if (this.viewMode == "all" && Date.now() - this.lastScrollTime < 250) { this.handleScroll(); } }, 50);
    }
    else if (this.viewMode != "all" && this.scrollIntervalHandle) {
      clearInterval(this.scrollIntervalHandle);
      this.scrollIntervalHandle = null;
    }
  }

  updateSuggestersLabel(label, suggesters) {
    var anons = suggesters.filter(el => el.children.length === 0);
        var newAnons = anons.filter(el => el.isNew);
        label.textContent = `U:${suggesters.length}`;
        label.title =
`# of unique suggesters for the visible updates. Of these there are:
${suggesters.length - anons.length} named suggesters
${anons.length - newAnons.length} unnamed suggesters (familiar)
${newAnons.length} unnamed suggesters (new)`;
  }

  insertControls(doc) {
    //cache existing top-level html elements based their class name or id for faster access
    [...doc.body.children].forEach(child => {
      var name = child.classList[0] || child.id;
      if (name) {
        this.controls[name] = child;
      }
    });
    this.controls.navControls = [];
    //top controls
    var delform = this.posts.get(this.threadID).outer;
    var fragment = doc.createRange().createContextualFragment(this.getTopControlsHtml());
    this.controls.controlsTop = fragment.firstElementChild;
    this.controls.navControls.push(this.controls.controlsTop.querySelector(".qrNavControls"));
    delform.parentElement.insertBefore(fragment, delform);
    //bottom nav controls
    fragment = doc.createRange().createContextualFragment(`${this.getNavControlsHtml()}<hr>`);
    this.controls.navControls.push(fragment.firstElementChild);
    delform.insertBefore(fragment, delform.lastElementChild);
    //make reply form collapsable
    this.controls.postarea.insertAdjacentHTML("afterBegin", `<div class="hidden">[<a href="#" id="qrReplyFormRestoreButton">Reply</a>]</div>`);
    this.controls.replyFormRestoreButton = this.controls.postarea.firstElementChild;
    //reply form header
    this.controls.replyForm = doc.querySelector("#postform");
    this.controls.replyForm.querySelector(".postform").insertAdjacentHTML("afterBegin", this.getReplyFormHeaderHtml()); //note that we can't create a <thead> element in a fragment without table
    this.controls.replyFormMinimizeButton = this.controls.replyForm.querySelector("#qrReplyFormMinimizeButton");
    this.controls.replyFormPopoutButton = this.controls.replyForm.querySelector("#qrReplyFormPopoutButton");

    //when viewing full thread, we want to detect and remember where we are; something something IntersectionObserver
    /*doc.body.insertAdjacentHTML("afterBegin", `<div class="sentinel hidden"></div><div class="sentinel hidden"></div>`);
    this.sentinelPreviousEl = doc.body.firstChild;
    this.sentinelCurrentEl = doc.body.firstChild.nextSibling;
    this.sentinelPrevious = new IntersectionObserver((entries, observer) => { this.handleSentinel(entries, observer); }, { rootMargin: "2px" } ); //need to pass the callback like this to keep the context
    this.sentinelCurrent = new IntersectionObserver((entries, observer) => { this.handleSentinel(entries, observer); }, { rootMargin: "2px" } );
    this.sentinelPrevious.observe(this.sentinelPreviousEl);
    this.sentinelCurrent.observe(this.sentinelCurrentEl);*/

    //cache control elements for faster access
    var queries = [".qrNavLinksContainer", ".qrShowFirstButton", ".qrShowPrevButton", ".qrShowNextButton", ".qrShowLastButton", ".qrWikiLink", ".qrQuestLink", ".qrDisLink", ".qrThreadLinksDropdown",
                   ".qrNavPosition", ".qrCurrentPosLabel", ".qrTotalPosLabel", ".qrUpdateInfo", ".qrAuthorCommentsCountLabel", ".qrSuggestionsCountLabel", ".qrSuggestersCountLabel", ];
    queries.forEach((query) => {
      var controlGroupName = `${query[3].toLowerCase()}${query.substring(4)}s`;
      this.controls[controlGroupName] = [ this.controls.navControls[0].querySelector(query), this.controls.navControls[1].querySelector(query)];
    });
    this.controls.settingsControls = this.controls.controlsTop.querySelector(".qrSettingsControls");
    this.controls.controlsTop.querySelectorAll("[id]").forEach(el => {
      this.controls[`${el.id[2].toLowerCase()}${el.id.substring(3)}`] = el;
    });
    if (this.threadType !== ThreadType.QUEST) { //hide controls which aren't relevant in non-quest threads
      var controlsToHide = [...this.controls.showFirstButtons, ...this.controls.showPrevButtons, ...this.controls.showNextButtons, ...this.controls.showLastButtons, ...this.controls.navPositions];
      controlsToHide.forEach(el => el.classList.add("transparent"));
      var settingsToHide = [this.controls.viewModeLabel, this.controls.viewModeDropdown,
                            this.controls.showSuggestionsLabel, this.controls.showSuggestionsDropdown,
                            this.controls.showAuthorCommentsLabel, this.controls.showAuthorCommentsDropdown,
                            this.controls.showUpdateInfoLabel, this.controls.showUpdateInfoCheckbox,
                            this.controls.showUserMultiPostLabel, this.controls.showUserMultiPostCheckbox,
                            this.controls.keyboardShortcutsLabel, this.controls.keyboardShortcutsLabel.nextElementSibling];
      settingsToHide.forEach(el => el.classList.add("hidden"));
      this.controls.showReferencesDropdown.options[1].remove();
      this.controls.expandImagesDropdown.options[1].remove();
    }
    if (this.threadType === ThreadType.OTHER) {
      this.controls.wikiLinks.forEach(el => el.parentElement.classList.add("hidden"));
      this.controls.threadLinksDropdowns.forEach(el => el.classList.add("hidden"));
    }
  }

  /*handleSentinel(entries, observer) {
    console.log(entries[0]);
    var newUpdateIndex = this.currentUpdateIndex;
    if (observer == this.sentinelPrevious && entries[0].isIntersecting) {
      newUpdateIndex--;
    }
    else if (observer == this.sentinelCurrent && !entries[0].isIntersecting) {
      newUpdateIndex++;
    }
    if (newUpdateIndex != this.currentUpdateIndex && newUpdateIndex >= 0 && newUpdateIndex < this.updates.length) {
      this.currentUpdateIndex = newUpdateIndex;
      this.updateControls();
      this.settingsChanged();
    }
  }*/

  insertStyling(doc) {
    doc.head.insertAdjacentHTML("beforeEnd", `<style id="qrMainCss">${this.getMainStyleRules(doc)}</style>`);
    doc.head.insertAdjacentHTML("beforeEnd", `<style id="qrImageSizeCss">${this.getImageSizesStyleRules()}</style>`);
    doc.head.insertAdjacentHTML("beforeEnd", `<style id="qrReferencesCss">${this.getReferencesStyleRules()}</style>`);
    doc.head.insertAdjacentHTML("beforeEnd", `<style id="qrUserColorsCss">${this.getUserColorsStyleRules()}</style>`);
    doc.head.insertAdjacentHTML("beforeEnd", `<style id="qrUserPostCountsCss">${this.getUserPostCountsStyleRules()}</style>`);
    doc.head.insertAdjacentHTML("beforeEnd", `<style id="qrUserNavCss">${this.getUserNavStyleRules()}</style>`);
    doc.head.insertAdjacentHTML("beforeEnd", `<style id="qrUserMultiPostCss">${this.getUserMultiPostStyleRules()}</style>`);
  }

  insertEvents(doc) {
    //events for our controls
    this.controls.settingsToggleButton.addEventListener("click", e => this.toggleSettingsControls(e));
    this.controls.settingsPageNav.addEventListener("click", e => this.showSettingsPage(e));
    this.controls.viewModeDropdown.addEventListener("change", e => this.updateSettings(e));
    this.controls.showSuggestionsDropdown.addEventListener("change", e => this.updateSettings(e));
    this.controls.showAuthorCommentsDropdown.addEventListener("change", e => this.updateSettings(e));
    this.controls.replyFormLocationDropdown.addEventListener("change", e => this.updateSettings(e));
    this.controls.showReferencesDropdown.addEventListener("change", e => this.changePostElementSettings(e));
    this.controls.expandImagesDropdown.addEventListener("change", e => this.updateSettings(e));
    this.controls.maxImageWidthTextbox.addEventListener("change", e => this.changeImageSizeSettings(e));
    this.controls.maxImageHeightTextbox.addEventListener("change", e => this.changeImageSizeSettings(e));
    this.controls.stretchImagesCheckbox.addEventListener("click", e => this.changeImageSizeSettings(e));
    this.controls.showUpdateInfoCheckbox.addEventListener("click", e => this.updateSettings(e));
    this.controls.timestampFormatDropdown.addEventListener("change", e => this.changePostElementSettings(e));
    this.controls.colorUsersCheckbox.addEventListener("click", e => this.changePostElementSettings(e));
    this.controls.showUserPostCountsCheckbox.addEventListener("click", e => this.changePostElementSettings(e));
    this.controls.showUserNavCheckbox.addEventListener("click", e => this.changePostElementSettings(e));
    this.controls.showUserMultiPostCheckbox.addEventListener("click", e => this.changePostElementSettings(e));
    this.controls.replyFormMinimizeButton.addEventListener("click", e => this.toggleReplyForm(e));
    this.controls.replyFormRestoreButton.addEventListener("click", e => this.toggleReplyForm(e));
    this.controls.replyFormPopoutButton.addEventListener("click", e => this.popoutReplyForm(e));
    this.controls.showFirstButtons.forEach(el => el.addEventListener("click", e => this.showFirst(e)));
    this.controls.showPrevButtons.forEach(el => el.addEventListener("click", e => this.showPrevious(e)));
    this.controls.showNextButtons.forEach(el => el.addEventListener("click", e => this.showNext(e)));
    this.controls.showLastButtons.forEach(el => el.addEventListener("click", e => this.showLast(e)));
    this.controls.threadLinksDropdowns.forEach(el => el.addEventListener("change", e => this.changeThread(e)));

    //events for other controls
    this.controls.replyForm.querySelector(`input[type="submit"][value="Reply"]`).addEventListener("click", (e) => {
      var userName = this.controls.replyForm.querySelector(`input[name="name"]`).value.trim().toLowerCase();
      if(this.author.id === userName || this.author.children.some(child => child.id === userName)) {
        this.moveToLast = true;
        this.settingsChanged();
      }
    });

    //global events
    doc.defaultView.addEventListener("hashchange", (e) => { //if the #hash at the end of url changes, it means the user clicked a post link and we need to show him the update that contains that post
      var oldPostID = parseInt(new URL(e.oldURL).hash.replace("#", ""));
      var newPostID = parseInt(new URL(e.newURL).hash.replace("#", ""));
      if (!isNaN(oldPostID) && oldPostID !== this.threadID && this.posts.has(oldPostID)) {
        this.posts.get(oldPostID).inner.classList.remove("highlight");
        this.posts.get(oldPostID).inner.classList.add("reply");
      }
      if (!isNaN(newPostID) && newPostID !== this.threadID && this.posts.has(newPostID)) {
        this.posts.get(newPostID).inner.classList.add("highlight");
        this.refresh({checkHash: "true"});
      }
    });

    this.lastScrollTime = 0;
    doc.defaultView.addEventListener("wheel", (e) => { //after the wheeling has finished, check if the user
      this.lastScrollTime = Date.now();
    });

    doc.addEventListener("keydown", (e) => {
      if (this.threadType !== ThreadType.QUEST) {
        return;
      }
      var inputTypes = ["text", "password", "number", "email", "tel", "url", "search", "date", "datetime", "datetime-local", "time", "month", "week"];
      if (e.target.tagName === "TEXTAREA" || e.target.tagName === "SELECT" || (e.target.tagName === "INPUT" && inputTypes.indexOf(e.target.type) >= 0)) {
        return; //prevent our keyboard shortcuts when focused on a text input field
      }
      if (e.altKey || e.shiftKey || e.ctrlKey) { //alt+left arrow, or alt+right arrow, for the obvious reasons we don't want to handle those, or any other combination for that matter
        return;
      }
      if (e.key == "ArrowRight") {
        e.preventDefault();
        this.showNext();
      }
      else if (e.key == "ArrowLeft") {
        e.preventDefault();
        this.showPrevious();
      }
      var scrollKeys = ["ArrowUp", "ArrowDown", " ", "PageUp", "PageDown", "Home", "End"]; //it turns out that scrolling the page is possible with stuff other than mouse wheel
      if (scrollKeys.indexOf(e.key) >= 0) {
        this.lastScrollTime = Date.now();
      }
    });
    doc.addEventListener("click", (e) => {
      //clicking on a reflink in post header should show the Reply form and THEN insert text and focus it
      var node = e.target;
      if (node.nodeType === Node.TEXT_NODE) {
        return;
      }
      if (node.nodeName === "A") {
        if (node.parentElement.classList.contains("reflink") && node.parentElement.lastElementChild == node) {
          e.preventDefault();
          if (!this.showReplyForm) {
            this.controls.replyFormRestoreButton.click();
            if (this.replyFormLocation == "float" && !this.controls.replyForm.classList.contains("qrPopout")) {
              this.controls.replyFormPopoutButton.click();
            }
          }
          return doc.defaultView.insert(`>>${node.textContent}\n`);
        }
      }
      //clicking on the post timestamp in the post header should change the time format
      else if (node.nodeName == "LABEL" && node.parentElement.classList.contains("postwidth")) {
        var posterEl = node.querySelector(".postername");
        if (e.offsetX <= posterEl.offsetLeft + posterEl.offsetWidth) {
          return;
        }
        e.preventDefault();
        if (this.relativeTime === null ) {
          var post = this.posts.get(parseInt(node.parentElement.firstElementChild.name));
          var currentDateTime = new Date();
          this.relativeTime = this.timestampFormat == "auto" ? (currentDateTime.getTime() - this.getUtcTime(post, node.lastChild)) >= 86400000 : this.timestampFormat != "relative" && this.timestampFormat != "relativeUpd";
        }
        else {
          this.relativeTime = !this.relativeTime;
        }
        this.refresh({scroll: false});
        this.settingsChanged();
      }
    });
    //should also remove the unnecessary click events;
    this.posts.forEach(post => {
      post.header.querySelector(".reflink").lastElementChild.onclick = null;
    });
    //clicking on style links should rebuild our style
    this.controls.adminbar.querySelectorAll("a").forEach(el => {
      if (!el.title && !el.target) {
        el.addEventListener("click", (e) => {
          doc.head.querySelector("#qrMainCss").innerHTML = this.getMainStyleRules(doc);
        });
      }
    });
    //The site's highlight() function is slow and buggy. In fact, it should be bound to the hashchange event... actually a css pseudo selector :target should've been used for the effect
    //Of course, currently it won't work because the anchors with the IDs are inside headers instead of the elements themselves being anchors
    doc.defaultView.highlight = new Function();
    doc.defaultView.checkhighlight = () => {
      var hashedPostID = parseInt(doc.location.hash.replace("#", ""));
      if (!isNaN(hashedPostID) && hashedPostID != this.threadID && this.posts.has(hashedPostID)) {
        this.posts.get(hashedPostID).inner.classList.add("highlight");
      }
    }
    //similarily the addpreviewevents function is doing it wrong; we should insert only 2 events for the document and use the bubbling instead of inserting hundreds of events
    doc.defaultView.addpreviewevents = new Function();
    doc.addEventListener("mouseover", function(e) {
      if (e.target.nodeName === "A" && e.target.className.startsWith("ref|")) {
        e.view.addreflinkpreview(e);
      }
    });
    doc.addEventListener("mouseout", function(e) {
      if (e.target.nodeName === "A" && e.target.className.startsWith("ref|")) {
        e.view.delreflinkpreview(e);
      }
    });
  }

  handleScroll() {
    //check if the user scrolled to a different update on screen -> mark and save the position (only in whole thread view)
    var indexes = this.getVisibleUpdateIndexes();
    if (indexes == null) {
      return;
    }
    if (this.currentUpdateIndex != indexes[0]) {
      this.currentUpdateIndex = indexes[0];
      this.updateControls();
      this.settingsChanged();
    }
    var updatesToExpand = indexes.map(index => this.updates[index]);
    if (this.updates.length > indexes[indexes.length - 1] + 1) {
      updatesToExpand.push(this.updates[indexes[indexes.length - 1] + 1]);
    }
    updatesToExpand.forEach(update => this.expandUpdateImages(update));
  }

  getVisibleUpdateIndexes() {
    var currentUpdatePost = this.posts.get(this.currentUpdate().updatePostID);
    var currentUpdateIsAboveViewPort = !currentUpdatePost || currentUpdatePost.outer.offsetTop <= this.doc.defaultView.scrollY;
    var topmostVisibleUpdateIndex = this.currentUpdateIndex;
    var el;
    if (currentUpdateIsAboveViewPort) { //search down
      for (; topmostVisibleUpdateIndex < this.updates.length - 1; topmostVisibleUpdateIndex++) {
        el = this.posts.get(this.updates[topmostVisibleUpdateIndex + 1].updatePostID);
        if (!el) {
          continue;
        }
        if (el.outer.offsetTop > this.doc.defaultView.scrollY) {
          break;
        }
      }
    }
    else { //search up
      for (; topmostVisibleUpdateIndex > 0; topmostVisibleUpdateIndex--) {
        el = this.posts.get(this.updates[topmostVisibleUpdateIndex - 1].updatePostID);
        if (!el || el.outer.offsetTop < this.doc.defaultView.scrollY) {
          topmostVisibleUpdateIndex--;
          break;
        }
      }
    }
    var indexes = [ topmostVisibleUpdateIndex ];
    //var bottommostVisibleUpdateIndex = topmostVisibleUpdateIndex;
    var windowOffsetBottom = this.doc.defaultView.scrollY + this.doc.documentElement.clientHeight;
    for (; topmostVisibleUpdateIndex < this.updates.length - 1; topmostVisibleUpdateIndex++) {
      el = this.posts.get(this.updates[topmostVisibleUpdateIndex + 1].updatePostID);
      if (!el || el.outer.offsetTop > windowOffsetBottom) {
        break;
      }
      indexes.push(topmostVisibleUpdateIndex + 1);
    }
    return indexes;
  }

  modifyLayout(doc) {
    //change tab title to quest's title
    var op = this.posts.get(this.threadID);
    var label = op.header.querySelector("label");
    this.hasTitle = !!label.querySelector(".filetitle");
    var title = label.querySelector(".filetitle") || label.querySelector(".postername");
    title = title.textContent.trim();
    doc.title = title !== this.defaultName ? title : "Untitled Quest";
    this.controls.logo.textContent = doc.title;
    //extend vertical size to prevent screen jumping when navigating updates
    this.controls.controlsTop.insertAdjacentHTML("beforeBegin", `<div class="haveOneScreenOfSpaceBelowHereSoItIsPossibleToScroll" />`);
    //extend vertical size so it's possible to scroll to the last update in full thread view
    var lastUpdatePost = this.posts.get(this.lastUpdate().updatePostID);
    if (lastUpdatePost) {
      lastUpdatePost.outer.insertAdjacentHTML("afterBegin", `<div class="haveOneScreenOfSpaceBelowHereSoItIsPossibleToScroll" />`);
    }
    //prevent wrapping posts around the OP; setting clear:left on the 2nd post doesn't work because that element might be hidden
    op.inner.querySelector("blockquote").insertAdjacentHTML("afterEnd", `<div style="clear: left;"></div>`);
    //prevent wrapping text underneath update images by setting update post width to 100%
    this.updates.forEach(update => { //need to be careful to not set the OP (form) to 100% width because it would override BLICK's dumb width settings
      var updatePost = this.posts.get(update.updatePostID);
      if (updatePost) {
        updatePost.inner.classList.add(update === this.firstUpdate() ? "updateOp" : "update");
      }
    });
    //Fix OP header so that the image wraps underneath it, like the other posts
    while(op.header.firstChild.name != this.threadID) {
      op.header.appendChild(op.header.firstChild);
    }
    //hide "Expand all images" link; The link isn't always present
    var expandLink = op.inner.querySelector(`a[href="#top"]`);
    if (expandLink) {
      expandLink.classList.add("hidden");
    }
    //remove the "Report completed threads!" message from the top
    var message = (doc.body.querySelector("center") || doc.createElement("center")).querySelector(".filetitle");
    if (message) {
      message.classList.add("hidden");
    }
    var replyForm = this.controls.replyForm;
    //remove the (Reply to #) text since it's obvious that we're replying to the thread that we're viewing, plus other text in the line
    var replyToPostEl = replyForm.querySelector("#posttypeindicator");
    if (replyToPostEl) {
      [...replyToPostEl.parentElement.childNodes].filter(el => el && el.nodeType == HTMLElement.TEXT_NODE).forEach(el => el.remove());
      replyToPostEl.remove();
    }
    [...replyForm.querySelector(`input[name="name"]`).parentElement.childNodes].forEach(el => { if (el.nodeType == HTMLElement.TEXT_NODE) el.remove(); });
    [...replyForm.querySelector(`input[name="em"]`).parentElement.childNodes].forEach(el => { if (el.nodeType == HTMLElement.TEXT_NODE) el.remove(); });
    [...replyForm.querySelector(`input[name="subject"]`).parentElement.childNodes].forEach(el => { if (el.nodeType == HTMLElement.TEXT_NODE) el.remove(); });
    //move the upload file limitations info into a tooltip
    var filetd = replyForm.querySelector(`input[type="file"]`);
    var fileRulesEl = replyForm.querySelector("td.rules");
    fileRulesEl.classList.add("hidden");
    var fileRules = [...fileRulesEl.querySelectorAll("li")].splice(1, 2);
    fileRules = fileRules.map(el => el.textContent.replace(new RegExp("[ \n]+", "g"), " ").trim()).join("\n");
    filetd.insertAdjacentHTML("afterEnd", `&nbsp<span class="qrTooltip" title="${fileRules}">*</span>`);
    //move the password help line into a tooltip
    var postPasswordEl = replyForm.querySelector(`input[name="postpassword"]`);
    postPasswordEl.nextSibling.remove();
    postPasswordEl.insertAdjacentHTML("afterEnd", `&nbsp<span class="qrTooltip" title="Password for post and file deletion">?</span>`);
    //reply form input placeholders
    (replyForm.querySelector(`[name="name"]`) || {}).placeholder = `Name (leave empty for ${this.defaultName})`;
    (replyForm.querySelector(`[name="em"]`) || {}).placeholder = "Options";
    (replyForm.querySelector(`[name="em"]`) || {}).title = "sage | dice 1d6";
    (replyForm.querySelector(`[name="subject"]`) || {}).placeholder = "Subject";
    (replyForm.querySelector(`[name="message"]`) || {}).placeholder = "Message";
    var embed = replyForm.querySelector(`[name="embed"]`);
    if (embed) {
      embed.parentElement.parentElement.style.display = "none";
    }
    //remove that annoying red strip
    this.controls.replymode.classList.add("hidden");
  }

  insertTooltips() {
    var c = this.controls;
    c.settingsToggleButton.title = `Show/Hide Quest Reader settings`;
    c.settingsPageNav.children[0].title = `General settings`;
    c.settingsPageNav.children[1].title = `Image settings`;
    c.settingsPageNav.children[2].title = `Analytics settings`;
    c.viewModeLabel.title = `Control whether to view the thread as a comic (a set of pages) or not.
Whole thread: Show the thread at it originally is, with all the update posts visible
Paged per update: Turn the thread into pages, with a single update post per page
Paged per sequence: Turn the thread into pages, with a set of sequential update posts per page`;
    c.showSuggestionsLabel.title = `Show quest suggestions. Quest suggestions are non-author posts containing directions for the quest or its characters.`;
    c.showAuthorCommentsLabel.title = `Show author comments. Author comments are author posts which aren't updates.`;
    c.showReferencesLabel.title = `Show links to references underneath each post or not. (References are >>links pointing to the post)`;
    c.timestampFormatLabel.title = `Control the time and format of post timestamps.
Server time: Show when the post was made in server's timezone.
Local time: Show when the post was made in local timezone.
Relative to now: Show how much time has passed since the post was made to right now.
Auto: Show relative time if the post was made less than a day ago, otherwise show local time.
Hidden: Hide post timestamps.`;
    c.replyFormLocationLabel.title = `Control the location and the way in which the Reply form is shown.
At top: The Reply form is located at the top of the page
At bottom: The Reply form is located at the bottom of the page
Auto-float: Show a floating Reply form when clicking on any of the post ID links, or the "Reply" link at the bottom`;
    c.expandImagesLabel.title = `Automatically expand images only when they appear on screen`;
    c.stretchImagesLabel.title = `Allow expanding the images beyond their intrinsic resolution to fit an image container.
The size of the image container is defined by the "Image container width" and "Image container height" settings.`;
    c.showUpdateInfoLabel.title = `Show some info about the update next to the navigation controls.
The info contains three numbers: The amount of [A]uthor comments, the amount of [S]uggestions, and the amount of [U]nique suggesters for the update.`;
    c.colorUsersLabel.title = `Colorize the poster ID of every suggestion post`;
    c.showUserPostCountsLabel.title = `Show user's post count to the right of every poster ID; if the number is red, it means the user only made posts within one update.`;
    c.showUserNavLabel.title = `Show buttons to the right of every user ID which allow navigating to the user's previous and next post`;
    c.showUserMultiPostLabel.title = `Show a red indication next to posts where the suggester has made more than one suggestion for the update`;
    c.authorCommentsCountLabels.forEach(el => { el.title = `# of author comment posts for the visible updates`; });
    c.suggestionsCountLabels.forEach(el => { el.title = `# of suggestion posts for the visible updates`; });
    c.suggestersCountLabels.forEach(el => { el.title = `# of unique suggesters for the visible updates`; });
    c.wikiLinks.forEach(el => { el.title = `Link to the quest's wiki page`; });
    c.disLinks.forEach(el => { el.title = `Link to the quest's latest discussion thread`; });
    c.questLinks.forEach(el => { el.title = `Link to the quest's last visited or last created quest thread`; });
    c.threadLinksDropdowns.forEach(el => { el.title = `Current quest thread`; });
    c.replyFormMinimizeButton.title = `Hide the Reply form`;
    c.replyFormPopoutButton.title = `Pop out the Reply form and have it float`;
  }

  getTopControlsHtml() {
    return `
<div class="qrControlsTop">
  <div class="qrSettingsToggle">[<a href="#" id="qrSettingsToggleButton">Settings</a>]</div>
  ${this.getNavControlsHtml()}
  ${this.getSettingsControlsHtml()}
  <hr>
</div>`;
  }

  getSettingsControlsHtml() {
    return `
<div class="qrSettingsControls collapsedHeight">
  <span id="qrSettingsPageNav">
    <div class="qrSettingsNavItem qrSettingsNavItemSelected">[<a href="#">General</a>]</div>
    <div class="qrSettingsNavItem">[<a href="#">Images</a>]</div>
    <div class="qrSettingsNavItem">[<a href="#">Analytics</a>]</div>
  </span>
  <div class="qrSettingsPages">
    <div class="qrSettingsPage qrCurrentPage">
      <div id="qrViewModeLabel">Viewing mode</div>
      <select id="qrViewModeDropdown" class="qrSettingsControl"><option value="all">Whole thread</option><option value="single">Paged per update</option><option value="sequence">Paged per sequence</option></select>
      <div id="qrShowReferencesLabel">Show post references</div>
      <select id="qrShowReferencesDropdown" class="qrSettingsControl"><option value="none">Never</option><option value="nonupdates">For non-update posts</option><option value="all">For all</option></select>
      <div id="qrShowSuggestionsLabel">Show suggestions</div>
      <select id="qrShowSuggestionsDropdown" class="qrSettingsControl"><option value="none">Never</option><option value="last">Last update only</option><option value="all">Always</option></select>
      <div id="qrTimestampFormatLabel">Timestamp format</div>
      <select id="qrTimestampFormatDropdown" class="qrSettingsControl">
        <option value="server">Server time</option>
        <option value="local">Local time</option>
        <option value="relative">Relative to now</option>
        <option value="auto">Auto</option>
        <option value="hide">Hidden</option>
      </select>
      <div id="qrShowAuthorCommentsLabel">Show author comments</div>
      <select id="qrShowAuthorCommentsDropdown" class="qrSettingsControl"><option value="none">Never</option><option value="last">Last update only</option><option value="all">Always</option></select>
      <div id="qrReplyFormLocationLabel">Reply form</div>
      <select id="qrReplyFormLocationDropdown" class="qrSettingsControl"><option value="top">At top</option><option value="bottom">At bottom</option><option value="float">Auto-float</option></select>
      <div id="qrKeyboardShortcutsLabel">Keyboard shortcuts</div>
      <div class="qrSettingsControl qrTooltip">?<span class="qrTooltiptext">Left and Right arrow keys will <br>navigate between updates</span></div>
    </div>
    <div class="qrSettingsPage">
      <div id="qrExpandImagesLabel">Expand images</div>
      <select id="qrExpandImagesDropdown" class="qrSettingsControl"><option value="none">Do not</option><option value="updates">For updates</option><option value="all">For all</option></select>
      <div id="qrImageWidthLabel">Max image width</div>
      <div><input type="number" id="qrMaxImageWidthTextbox" class="qrSettingsControl" min=0 max=100 value=100> %</div>
      <div id="qrStretchImagesLabel">Force fit images</div>
      <input type="checkbox" id="qrStretchImagesCheckbox" class="qrSettingsControl">
      <div id="qrImageHeightLabel">Max image height</div>
      <div><input type="number" id="qrMaxImageHeightTextbox" class="qrSettingsControl" min=0 max=100 value=100> %</div>
    </div>
    <div class="qrSettingsPage">
      <div id="qrShowUpdateInfoLabel">Show update info</div>
      <input id="qrShowUpdateInfoCheckbox" type="checkbox" class="qrSettingsControl">
      <div></div>
      <div></div>
      <div id="qrColorUsersLabel">Color ${this.threadType == ThreadType.QUEST ? "suggester" : "user"} IDs</div>
      <input id="qrColorUsersCheckbox" type="checkbox" class="qrSettingsControl">
      <div id="qrShowUserPostCountsLabel">Show user post counts</div>
      <input id="qrShowUserPostCountsCheckbox" type="checkbox" class="qrSettingsControl">
      <div id="qrShowUserNavLabel">Show per-user nav</div>
      <input id="qrShowUserNavCheckbox" type="checkbox" class="qrSettingsControl">
      <div id="qrShowUserMultiPostLabel">Show multi-posts</div>
      <input id="qrShowUserMultiPostCheckbox" type="checkbox" class="qrSettingsControl">
    </div>
  </div>
</div>`;
  }

  getNavControlsHtml() {
    return `
<div class="qrNavControls">
  <span></span>
  <span class="qrNavControl"><button class="qrShowFirstButton" type="button">First</button></span>
  <span class="qrNavControl"><button class="qrShowPrevButton" type="button">Prev</button></span>
  <span class="qrNavPosition qrOutline" title="Index of the currently shown update slash the total number of updates">
    <label class="qrCurrentPosLabel">0</label> / <label class="qrTotalPosLabel">0</label>
  </span>
  <span class="qrNavControl"><button class="qrShowNextButton" type="button">Next</button></span>
  <span class="qrNavControl"><button class="qrShowLastButton" type="button">Last</button></span>
  <span>
    <span class="qrUpdateInfo qrOutline">
      <label class="qrAuthorCommentsCountLabel">A: 0</label>
      <label class="qrSuggestionsCountLabel">S: 0</label>
      <label class="qrSuggestersCountLabel">U: 0</label>
    </span>
  </span>
  <span class="qrNavLinksContainer">${this.getLinksHtml()}</span>
</div>`;
  }

  getLinksHtml() {
    return `
<span>[<a class="qrWikiLink" style="color: inherit">Wiki</a>]</span>
<span class="hidden">[<a class="qrQuestLink">Quest</a>]</span>
<span class="hidden">[<a class="qrDisLink">Discuss</a>]</span>
<span class="qrThreadsLinks">
  <select class="qrThreadLinksDropdown">
    <option value="thread1">Thread not found in wiki</option>
  </select>
</span>`;
  }

  getReplyFormHeaderHtml() {
     return `
<thead id="qrReplyFormHeader">
  <tr>
    <th class="postblock">Reply form</th>
    <th class="qrReplyFormButtons">
      <span><a id="qrReplyFormPopoutButton" href="#"><svg
        xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="square" stroke-linejoin="arcs">
          <g fill="none" fill-rule="evenodd"><path d="M18 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8c0-1.1.9-2 2-2h5M15 3h6v6M10 14L20.2 3.8"/></g>
        </svg></a>
      </span>
      <span><a id="qrReplyFormMinimizeButton" href="#"><svg
        xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="square" stroke-linejoin="arcs">
          <line x1="5" y1="19" x2="19" y2="5"></line><line x1="5" y1="5" x2="19" y2="19"></line>
        </svg></a>
      </span>
    </th>
  </tr>
</thead>`;
  }

  getMainStyleRules(doc) {
    /*var currentSheetRules = doc.head.querySelector(`link[rel*="stylesheet"][title]:not([disabled])`).sheet.cssRules;
    var bodyStyle = [...currentSheetRules].find(rule => rule.selectorText == "html, body");
    var bgc = bodyStyle.style.backgroundColor;
    var fgc = bodyStyle.style.color;*/
    var bgc = doc.defaultView.getComputedStyle(doc.body)["background-color"]; //this is needed for compatibility with BLICK
    var fgc = doc.defaultView.getComputedStyle(doc.body).color;
    return `
.hidden { display: none; }
.veryhidden { display: none !important; }
.transparent { opacity: 0; }
.qrShowFirstButton, .qrShowLastButton { width: 50px; }
.qrShowPrevButton, .qrShowNextButton { width: 100px; }
.qrSettingsToggle { position: absolute; left: 8px; padding-top: 2px; }
.qrControlsTop { }
.qrNavControls { display: grid; grid-template-columns: 1fr auto auto auto auto auto auto 1fr; grid-gap: 3px; color: ${fgc}; pointer-events: none; }
.qrNavControls > * { margin: auto 0px; pointer-events: all; }
.qrOutline { text-shadow: 2px 2px 2px ${bgc}, 2px 0px 2px ${bgc}, 2px -2px 2px ${bgc}, 0px -2px 2px ${bgc}, -2px -2px 2px ${bgc}, -2px 0px 2px ${bgc}, -2px 2px 2px ${bgc}, 0px 2px 2px ${bgc}, 0px 0px 2px ${bgc}; }
.qrNavLinksContainer { white-space: nowrap; text-align: right; color: ${fgc}; }
.qrNavLinksBottom { float: right; clear: both; }
.qrNavPosition { font-weight: bold; white-space: nowrap; }
.qrLastIndex { color: crimson; }
.qrUpdateInfo { white-space: nowrap; }
.qrSettingsControls { height: 100px;  overflow: hidden; transition: all 0.3s; display: grid; grid-template-columns: auto 1fr; }
#qrSettingsPageNav { display: inline-flex; flex-direction: column; justify-content: space-evenly; }
.qrSettingsNavItemSelected > a { color: inherit; text-decoration: none; }
.qrSettingsNavItemSelected::before { content: ">"; }
.qrSettingsNavItemSelected::after { content: "<"; }
.qrSettingsPages { position: relative; margin-top: 8px; }
.qrSettingsPage { display: grid; grid-template-columns: 1fr auto auto 1fr auto auto 1fr; grid-gap: 2px 4px; justify-items: start; align-items: center; align-content: center;
                  white-space: nowrap; transition: all 0.3s; opacity: 0; position: absolute; left: 0; right: 0; top: 0; pointer-events: none; }
.qrCurrentPage { opacity: 1; z-index: 1; pointer-events: initial; }
.qrSettingsPage > :nth-child(4n+1) { grid-column-start: 2; cursor: default; }
.qrSettingsPage > :nth-child(4n+3) { grid-column-start: 5; cursor: default; }
.qrSettingsControl { margin: 0px; }
select.qrSettingsControl { width: 150px; height: 22px; }
input.qrSettingsControl[type="number"] { width: 40px; }
.qrThreadLinksDropdown { max-width: 100px; }
.collapsedHeight { height: 0px; }
.qrTooltip { position: relative; border-bottom: 1px dotted; cursor: pointer; }
.qrTooltip:hover .qrTooltiptext { visibility: visible; }
.qrTooltip .qrTooltiptext { visibility: hidden; width: max-content; padding: 4px 4px 4px 10px; left: 15px; top: -35px;
position: absolute; border: dotted 1px; z-index: 1; background-color: ${bgc}; }
.haveOneScreenOfSpaceBelowHereSoItIsPossibleToScroll { position:absolute; height: 100vh; width: 1px; }
#qrReplyFormHeader { text-align: center; }
.postform td:first-child { display: none; }
.qrReplyFormButtons { position: absolute; right: 0px; }
.qrReplyFormButtons svg { width: 17px; vertical-align: bottom; }
.qrPopout { position: fixed; opacity: 0.2 !important; transition: opacity 0.3s; background-color: ${bgc}; border: 1px solid rgba(0, 0, 0, 0.10) !important; }
.qrPopout:hover { opacity: 1 !important; }
.qrPopout:focus-within { opacity: 1 !important; }
.qrPopout #qrReplyFormHeader { cursor: move; }
.qrReferences { margin: 0.5em 4px 0px 4px; }
.qrReferences::before { content: "Replies:"; font-size: 0.75em; }
.qrReference { font-size: 0.75em; margin-left: 4px; text-decoration: none; }
.stickyBottom { position: sticky; bottom: 0px; padding: 3px 0px; clear: both; }
.uid[postcount]::after { content: " (" attr(postcount) ")"; }
.isNew::after { color: crimson; }
.qrUserNavDisabled { color: grey; pointer-events: none; }
.qrUserNavEnabled { color: inherit; }
.qrNavIcon { width: 14px; height: 17px; stroke-width: 2px; fill: none; stroke: currentColor; vertical-align: text-bottom; padding-left: 2px; }
.qrNavIcon:hover { stroke-width: 3px; }
.qrUserMultiPost { color: crimson; white-space: nowrap; }
.update { width: 100%; }

.postwidth > label { cursor: pointer; }
.postwidth > label > * { cursor: default; }
.userdelete { position: relative; }
.userdelete tbody { position: absolute; right: 0px; top: -3px; text-align: right; }
body { position: relative; ${this.viewMode !== "all" ? "overflow-anchor: none" : ""} }
thead span > a { color: inherit; } /* Specificity hack to override the color of the reply form header links, but not overried the hover color */
#watchedthreadlist { display: grid; grid-template-columns: auto auto 3fr auto 1fr auto auto 0px; color: transparent; }
#watchedthreadlist > a[href$=".html"] { grid-column-start: 1; }
#watchedthreadlist > a[href*="html#"] { max-width: 40px; }
#watchedthreadlist > * { margin: auto 0px; }
#watchedthreadlist > span { overflow: hidden; white-space: nowrap; }
#watchedthreadlist > .postername { grid-column-start: 5; }
div#watchedthreadsbuttons { top: 0px; right: 0px; left: unset; bottom: unset; }
.reflinkpreview { z-index: 1; }
blockquote { margin-right: 1em; clear: unset; }
#spoiler { vertical-align: text-top; }
.postform { position: relative; border-spacing: 0px; }
.postform :optional { box-sizing: border-box; }
.postform input[name="name"], .postform input[name="em"], .postform input[name="subject"] { width: 100% !important; }
.postform input[name="message"] { margin: 0px }
.postform input[type="submit"] { position: absolute; right: 1px; bottom: 3px; }
.postform [name="imagefile"] { width: 220px; }
.postform td:first-child { display: none; }
.postform tr:nth-child(3) td:last-child { display: grid; grid-template-columns: 1fr auto; }
#BLICKpreviewbut { margin-right: 57px; }`;
/*
Rotating highlight border
.highlight { border: unset;
  background-image: linear-gradient(90deg, currentColor 50%, transparent 50%), linear-gradient(90deg, currentColor 50%, transparent 50%),
  linear-gradient(0deg, currentColor 50%, transparent 50%), linear-gradient(0deg, currentColor 50%, transparent 50%);
  background-repeat: repeat-x, repeat-x, repeat-y, repeat-y; background-size: 15px 2px, 15px 2px, 2px 15px, 2px 15px; background-position: left top, right bottom, left bottom, right top; animation: rotating-border 1s infinite linear;
}
@keyframes rotating-border {
  0% { background-position: left top, right bottom, left bottom, right top; }
  100% { background-position: left 15px top, right 15px bottom , left bottom 15px , right   top 15px; }
}
.logo { clear: unset; }
blockquote { clear: unset; }
.sentinel { position: absolute; left: 0px; top: 0px; width: 400px; pointer-events: none; background-color:white; opacity: 0.3; }
*/
  }

  getImageSizesStyleRules() {
    var rules;
    if (this.stretchImages) {
      rules = `width: calc(${this.maxImageWidth}% - 40px); height: unset; max-height: calc(${this.maxImageHeight}vh - 50px); object-fit: contain;`;
    }
    else {
      rules = `width: unset; height: unset; max-width: calc(${this.maxImageWidth}% - 40px); max-height: calc(${this.maxImageHeight}vh - 40px);`;
    }
    return `.thumb[src*="svg+xml"], .thumb[src*="/src/"] { background-size: contain; background-position: center; background-repeat: no-repeat; ${rules} }`;
  }

  getReferencesStyleRules() {
    //none => hide all; nonupdates => hide for updates; all => don't hide anything
    if (this.showReferences == "all") {
      return ``;
    }
    var selector = this.showReferences == "nonupdates" ? ".update > .qrReferences, .updateOp > .qrReferences" : ".qrReferences";
    return `${selector} { display: none; }`;
  }

  getUserPostCountsStyleRules() {
    return this.showUserPostCounts ? "" : `.uid::after { display: none; }`;
  }

  getUserNavStyleRules() {
    return this.showUserNav ? "" : `.qrUserNavDisabled { display: none; } .qrUserNavEnabled { display: none; }`;
  }

  getUserMultiPostStyleRules() {
    return this.showUserMultiPost ? "" : `.qrUserMultiPost { display: none; }`;
  }

  getUserColorsStyleRules() {
    if (!this.colorUsers) {
      return "";
    }
    var colors = [];
    [this.author, ...this.suggesters].forEach(user => {
      var canonID = this.getCanonID(user);
      colors.push(`.uid${canonID} { ${this.getColors(canonID)} }`);
    });
    return `.qrColoredUid { border-style: solid; padding: 0 5px; border-radius: 6px; border-width: 0px 6px; background-clip: padding-box; }
${colors.join("\n")}`;
  }

  getCanonID(user) {
    if (this.reCanonID.test(user.id)) {
      return user.id;
    }
    for (let i = 0; i < user.children.length; i++) {
      if (this.reCanonID.test(user.children[i].id)) {
        return user.children[i].id;
      }
    }
    var id = ""; //generate canonID from any string;
    for (let i = 0; id.length < 6; i = ++i % user.id.length) {
      id += user.id.charCodeAt(i).toString(16);
    }
    return id.substring(0, 6);
  }

  getColors(id) {
    id = parseInt(id, 16);
    var hue = ((id & 0xFF) * 390) / 255;
    var saturation = hue <= 360 ? 1 : 0; // 1/12 of the IDs will be grayscale, altho gravitate away from grey
    var lightness1 = ((id >> 8) & 0xFF) / 255;
    var lightness2 = (((id >> 16) & 0xFF) / 255) * 0.75 + 0.125; //between 12.5% and 87.5%
    var textColor = lightness1 > 0.5 ? "black" : "white";
    if (saturation === 1) {
      lightness1 = lightness1 * 0.5 + 0.25; //between 25% and 75%
      //convert to rgb and then to rec601 luma to be able to calculate whether the text should be black or white
      var a = saturation * Math.min(lightness1, 1 - lightness1);
      var rgbFunc = (n, k = (n + hue / 30) % 12) => lightness1 - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
      textColor = rgbFunc(0) * 0.299 + rgbFunc(8) * 0.587 + rgbFunc(4) * 0.114 > 0.5 ? "black" : "white";
    }
    else { //do basic math; if it's light, make it lighter, if it's dark, make it darker
      lightness1 = lightness1 >= 0.5 ? (Math.pow((lightness1 - 0.5) * 2, 1/2)) / 2 + 0.5 : Math.pow(lightness1 * 2, 2) / 2;
    }
    return `background-color: hsl(${hue}, ${saturation * 100}%, ${lightness1 * 100}%); color: ${textColor}; border-color: hsl(${hue}, ${saturation * 100}%, ${lightness2 * 100}%);`;
  }
}

var main = async () => {
  //first, let's undo some of the damage that prototype.js did by restoring native functions
  restoreNative(document);
  if (fullDocument !== document) {
    let doc = document.implementation.createHTMLDocument();
    doc.documentElement.innerHTML = (await fullDocument).response;
    fullDocument = doc;
  }
  setTimeout(() => {
    var timeStart = Date.now();
    try {
      if (!document.head.querySelector("#qrMainCss")) { //sanity check; don't run the script if it already ran; happens sometimes in Firefox on refresh?
        var qr = new QuestReader(document);
        document.defaultView.QR = qr;
        qr.onSettingsLoad = (e) => {
          var settingsKeys = {};
          settingsKeys[ThreadType.QUEST] = `qrSettings${e.threadID}`;
          settingsKeys[ThreadType.DISCUSSION] = `qrSettingsDis${e.threadID}`;
          settingsKeys[ThreadType.OTHER] = `qrSettingsBoardThread.${e.boardName}`;
          // get settings from localStorage
          e.settings = document.defaultView.localStorage.getItem(settingsKeys[e.threadType]);
          if (!e.settings) {
            var lastThreadKeys = {};
            lastThreadKeys[ThreadType.QUEST] = `qrLastThreadID`;
            lastThreadKeys[ThreadType.DISCUSSION] = `qrLastDisThreadID`;
            var lastThreadID = document.defaultView.localStorage.getItem(lastThreadKeys[e.threadType]);
            if (lastThreadID) {
              settingsKeys[ThreadType.QUEST] = `qrSettings${lastThreadID}`;
              settingsKeys[ThreadType.DISCUSSION] = `qrSettingsDis${lastThreadID}`;
              e.settings = document.defaultView.localStorage.getItem(settingsKeys[e.threadType]);
              if (e.settings) {
                e.settings = JSON.parse(e.settings);
                delete e.settings.currentUpdateIndex;
                delete e.settings.wikiPages;
              }
            }
          }
          else {
            e.settings = JSON.parse(e.settings);
          }
        };
        qr.onSettingsChanged = (e) => { //on settings changed, save settings to localStorage
          var settingsKeys = {};
          settingsKeys[ThreadType.QUEST] = `qrSettings${e.threadID}`;
          settingsKeys[ThreadType.DISCUSSION] = `qrSettingsDis${e.threadID}`;
          settingsKeys[ThreadType.OTHER] = `qrSettingsBoardThread.${e.boardName}`;
          if (e.threadType !== ThreadType.OTHER) {
            var lastThreadKeys = {};
            lastThreadKeys[ThreadType.QUEST] = `qrLastThreadID`;
            lastThreadKeys[ThreadType.DISCUSSION] = `qrLastDisThreadID`;
            //if there is no data on the last thread, or if the current thread has no settings (newly visited quest), update last visited thread ID
            if (!lastThreadKeys[e.threadType] || !document.defaultView.localStorage.getItem(settingsKeys[e.threadType])) {
              document.defaultView.localStorage.setItem(lastThreadKeys[e.threadType], e.threadID);
            }
          }
          //save settings
          document.defaultView.localStorage.setItem(settingsKeys[e.threadType], JSON.stringify(e.settings));
        };
        qr.onWikiDataLoad = (e) => { //before retrieving quest wiki, check localStorage for cached data and pass it to the class if it exists
          e.wikiData = JSON.parse(document.defaultView.localStorage.getItem(`qrWikiData.${e.wikiPageName}`));
        }
        qr.onWikiDataChanged = (e) => { //after quest wiki is retrieved, cache the data in localStorage
          if (e.wikiData) {
            document.defaultView.localStorage.setItem(`qrWikiData.${e.wikiPageName}`, JSON.stringify(e.wikiData));
          }
        }
        qr.init(fullDocument);
      }
    }
    finally {
      var hideUntilLoaded = document.querySelector("#hideUntilLoaded");
      if (hideUntilLoaded) {
        hideUntilLoaded.remove();
      }
    }
    console.log(`Quest Reader run time = ${Date.now() - timeStart}ms`);
  });
}

// START is here
var path = document.defaultView.location.pathname;
var pathMatch = path.match(new RegExp("/kusaba/([a-z]*)/res/"));
if (!pathMatch || BoardThreadTypes[pathMatch[1]] === undefined) {
  return; //don't run the script when viewing non-board URLs
}
var partial = path.endsWith("+50.html") || path.endsWith("+100.html");
var fullDocument = !partial ? document : Xhr.get(path.replace(new RegExp("\\+(50|100)\\.html$"), ".html"));
if (document.readyState == "loading") {
  //speed up loading by hiding the whole document until the extension is done processing the page; this prevents reflows and unnecessary rendering of stuff that we may change or hide
  var styleHtml = `<style id="hideUntilLoaded">body { display: none; }</style>`;
  var el = document.head || document.documentElement;
  if (el) {
    el.insertAdjacentHTML("beforeEnd", styleHtml);
  }
  else { //Chrome: It's possible the script gets executed even before the HTML element is added to the document, so we need to wait until it gets added and THEN hide the document
    new MutationObserver((mutations, observer) => { mutations.forEach(mutation => { (mutation.addedNodes || []).forEach(addedNode => {
      if (addedNode.tagName === "HTML") {
        addedNode.insertAdjacentHTML("beforeEnd", styleHtml);
        observer.disconnect();
      }
    }); }); }).observe(document, { attributes: false, childList: true, subtree: false });
  }
  document.addEventListener("DOMContentLoaded", main, { once: true }); //when parsing the HTML document is done, run the extension
}
else {
  main();
}