Quest Reader

Makes it more convenient to read quests

Устаревшая версия за 30.08.2019. Перейдите к последней версии.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey, Greasemonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Userscripts.

Чтобы установить этот скрипт, сначала вы должны установить расширение браузера, например Tampermonkey.

Чтобы установить этот скрипт, вы должны установить расширение — менеджер скриптов.

(у меня уже есть менеджер скриптов, дайте мне установить скрипт!)

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

(у меня уже есть менеджер стилей, дайте мне установить скрипт!)

// ==UserScript==
// @name        Quest Reader
// @author      naileD
// @namespace   QuestReader
// @include     *//tgchan.org/kusaba/quest/res/*
// @include     *//tgchan.org/kusaba/questarch/res/*
// @include     *//tgchan.org/kusaba/graveyard/res/*
// @description Makes it more convenient to read quests
// @version     17
// @grant       none
// @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

//enum
const PostType = { UPDATE: 0, AUTHORCOMMENT: 1, SUGGESTION: 2, COMMENT: 3 };

//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();
    if (options) {
      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;
    }
  }

  analyzeQuest(questDoc) {
    var posts = !this.postCache ? this.getPosts(questDoc) : JSON.parse(new TextDecoder().decode(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.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.isImageQuest(questFixes); //image quest is when the author posts files at least 50% of the time
    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.mergeCommonFilePosters(posts, users, questFixes); //merge certain file-posting users with the quest author
      this.mergeMajorityFilePoster(posts, users, questFixes); //consider a user who posted 50%+ of the files in the thread as the 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 defaultName = "Suggestion";
    var posts = {}; //dictionary => postID / post object
    questDoc.querySelectorAll(".postwidth").forEach(postHeaderElement => { //querySelectorAll is FASTER than getElementsByClassName when DOM is large
      var postID = parseInt(postHeaderElement.querySelector("span[id^=dnb]").id.split("-")[2]);
      if (posts[postID]) { //checking this may seem unnecessary, but it's required for compatibility with some imageboard scripts
        return;
      }
      var uid, name, trip, subject, fileElement, fileExt, fileName = "", activeContent, contentElement;
      var uidElement = postHeaderElement.querySelector(".uid");
      uid = uidElement.textContent.substring(4);
      trip = postHeaderElement.querySelector(".postertrip");
      if (trip) { //use tripcode instead of name if it exists
        name = trip.textContent;
      }
      else {
        name = postHeaderElement.querySelector(".postername").textContent.trim();
        name = name == defaultName ? "" : name.toLowerCase();
      }
      subject = postHeaderElement.querySelector(".filetitle");
      subject = subject ? subject.textContent.trim() : "";
      fileElement = postHeaderElement.querySelector(".filesize");
      if (fileElement) { //try to get the original file name
        fileName = fileElement.getElementsByTagName("a")[0].href;
        var match = fileName.match(this.regex.fileExtension);
        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
      }
      contentElement = postHeaderElement.nextElementSibling;
      activeContent = contentElement.querySelector("img, iframe") ? true : false; //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(postHeaderElement.querySelector("label").lastChild.nodeValue);
      }
      posts[postID] = postData;
    });
    var postsArray = Object.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 = new TextEncoder().encode(Object.toJSON ? Object.toJSON(postsArray) : JSON.stringify(postsArray)); //JSON.stringify stringifies twice when used on array. Another TGchan's protoaculous bug.
    }
    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 (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.userID, E2: post.userName };
      }
      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) { //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) {
    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://tgchan.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;
          }
        }
      });
    });
    return true;
  }

  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) {
      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://tgchan.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) {
    var merged = [];
    var filteredUsers = Object.values(users).filter(user => user.parent == user && user.fileCount >= 3 && user.fileCount / user.postCount > 0.5 && user != this.author);
    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://tgchan.org/kusaba/quest/res/${this.threadID}.html new common poster ${users[post.userID].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 (post.userName) {
      if (questFixes.ignoreTextPosts[post.userName]) { //choose to the one that isn't the author
        if (user == this.author) {
          user = this.find(users[post.userName]);
        }
      }
      else if (this.find(users[post.userName]) == this.author) { //choose the one that is the 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;
          }
        }
        if (questFixes.missedTextUpdates[post.postID]) {
          postType = PostType.UPDATE;
        }
      }
      if (this.debugAfterDate && this.debugAfterDate < post.date) {
        if (postType == PostType.SUGGESTION && post.fileName) console.log(`https://tgchan.org/kusaba/quest/res/${this.threadID}.html#${post.postID} new non-update`);
        if (postType == PostType.AUTHORCOMMENT) console.log(`https://tgchan.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://tgchan.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])) { //can't use Array.reduce() because tgchan's js library protoaculous destroyed it
        fixes[prop] = fixes[prop].reduceRight((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:
  // 2019/08/10

  //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.)
  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: { ignoreTextPosts: [ "", "GREEN", "!!x2ZmLjZmyu", "Adept", "Cruxador", "!ifOCf11HXk" ] },
        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: { missedAuthors: [ "b1e366", "aba0a3", "18212a", "6756f8", "f98e0b", "1c48f4", "f4963f", "45afb1", "b94893", "135d9a" ], ignoreTextPosts: [ "", "!7BHo7QtR6I", "Test Pattern", "Rowan", "Insomnia", "!!L1ZwWyZzZ5" ] },
        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: { missedAuthors: [ "//_emily" ] },
        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: { missedAuthors: [ "7f1ecd", "c00244", "7c97d9", "8c0848", "491db1", "c2c011", "e15f89",
                                  "e31d52", "3ce5b4", "c1f2ce", "5f0943", "1dc978", "d65652", "446ab5", "f906a7", "dad664", "231806" ] },
        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: { missedAuthors: [ "b7798b", "0b5a64", "687829", "446f39", "cc1ccd", "9d3d72", "72d5e4", "932db9", "4d7cb4", "9f327a", "940ab2", "a660d0" ], ignoreTextPosts: [ "" ] },
        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: { missedAuthors: [ "MidKnight", "c2c011", "f5e4b4", "e973f4", "6547ec" ], ignoreTextPosts: [ "", "!TEEDashxDA", "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: { missedAuthors: [ "383006", "bf1e7e" ], ignoreTextPosts: [ "383006", "bf1e7e" ] },
        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: { missedAuthors: [ "7bad01" ], ignoreTextPosts: [ "", "SoqWizard" ] },
        359879: { imageQuest: false},
        359931: { missedAuthors: [ "Dasaki", "Rynh", "Kinasa", "178c80" ], ignoreTextPosts: [ "", "Gnoll", "Lost Planet", "Dasaki", "Slinkoboy" ] },
        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: { missedAuthors: [ "!a1..dIzWW2" ] },
        422087: { ignoreTextPosts: [ "Caz" ] },
        422856: { ignoreTextPosts: [ "", "???" ] },
        424198: { missedAuthors: [ "067a04" ], ignoreTextPosts: [ "!a1..dIzWW2" ] },
        425677: { missedTextUpdates: [ "425893", "426741", "431953" ] },
        426019: { ignoreTextPosts: [ "Taskuhecate" ] },
        427135: { ignoreTextPosts: [ "!7BHo7QtR6I" ] },
        427676: { ignoreTextPosts: [ "FRACTAL" ] },
        428027: { ignoreTextPosts: [ "notrottel", "Bahu", "!a1..dIzWW2", "Trout", "Larro", "", "cuoqet" ], wrongImageUpdates: [ "428285", "498295" ] },
        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: { missedAuthors: [ "6a1ec2", "99090a", "7f2d33" ], wrongImageUpdates: [ "441260" ] },
        441745: { missedTextUpdates: [ "443831" ] },
        447830: { imageQuest: false, missedAuthors: [ "fc985a", "f8b208" ], wrongTextUpdates: [ "448476", "450379", "452161" ] },
        448900: { missedAuthors: [ "0c2256" ] },
        449505: { wrongTextUpdates: [ "450499" ] },
        450563: { missedAuthors: [ "!!AwZwHkBGWx", "Oregano" ], ignoreTextPosts: [ "", "chirps", "!!AwZwHkBGWx", "!!AwZwHkBGWx", "Ham" ] },
        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: { missedAuthors: [ "259cce", "34cbef" ] },
        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" ] },
        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: { missedAuthors: [ "7672c3", "9d78a6", "cb43c1" ] },
        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: { ignoreTextPosts: [ "Trout" ] },
        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: { missedAuthors: [ "913f0d" ], ignoreTextPosts: [ "", "Sky-jaws" ] },
        623611: { ignoreTextPosts: [ "!5tTWT1eydY" ] },
        623897: { wrongTextUpdates: [ "625412" ] },
        625364: { missedTextUpdates: [ "635199" ] },
        625814: { missedAuthors: [ "330ce5", "f79974", "53688c", "a19cd5", "defceb" ], missedTextUpdates: [ "625990" ], ignoreTextPosts: [ "" ] },
        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: { missedAuthors: [ "Penelope" ], ignoreTextPosts: [ "", "Brom", "Wire" ] },
        662452: { ignoreTextPosts: [ "" ] },
        662661: { ignoreTextPosts: [ "" ] },
        663088: { missedAuthors: [ "f68a09", "8177e7" ], ignoreTextPosts: [ "", "!5tTWT1eydY", "Wire", "Brom", "Apollo", "Arhra" ] },
        663996: { missedTextUpdates: [ "673890" ] },
        668009: { missedTextUpdates: [ "668227" ] },
        668216: { imageQuest: false},
        669206: { imageQuest: true, missedAuthors: [ "75347e" ] },
        672060: { missedTextUpdates: [ "673216" ] },
        673444: { ignoreTextPosts: [ "" ] },
        673575: { missedAuthors: [ "a6f913", "3bc92d" ], ignoreTextPosts: [ "!5tTWT1eydY" ] },
        673811: { missedTextUpdates: [ "682275", "687221", "687395", "688995" ], ignoreTextPosts: [ "" ] },
        677271: { missedTextUpdates: [ "677384" ] },
        678114: { imageQuest: false},
        678608: { missedTextUpdates: [ "678789" ] },
        679357: { missedTextUpdates: [ "679359", "679983" ] },
        680125: { ignoreTextPosts: [ "", "BritishHat" ] },
        680206: { missedAuthors: [ "Gnuk" ] },
        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: { missedAuthors: [ "Bergeek" ], ignoreTextPosts: [ "Boxdog" ] },
        692872: { missedTextUpdates: [ "717187" ] },
        693509: { missedAuthors: [ "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: { missedAuthors: [ "kome", "Vik", "Friptag" ], missedTextUpdates: [ "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: { missedAuthors: [ "Apollo", "riotmode", "!0iuTMXQYY." ], ignoreTextPosts: [ "", "!5tTWT1eydY", "!0iuTMXQYY.", "Indonesian Gentleman" ] },
        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: { missedAuthors: [ "Boris Calija", "3373e2", "2016eb", "a80028" ], ignoreTextPosts: [ "", "Boris Calija" ] },
        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: { missedAuthors: [ "Amaranth" ] },
        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" ] },
        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" ] },
        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: [ "" ] },
        926927: { missedTextUpdates: [ "928194" ] },
        929545: { missedTextUpdates: [ "929634" ] },
        930854: { missedTextUpdates: [ "932282" ] },
        934026: { missedTextUpdates: [ "934078", "934817" ] },
        935464: { missedTextUpdates: [ "935544", "935550", "935552", "935880" ] },
        939572: { missedTextUpdates: [ "940402" ] },
        940835: { missedTextUpdates: [ "941005", "941067", "941137", "941226" ] },
        1000012: { 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() {
    this.updates = [];
    this.sequences = [];
    this.onSettingsChanged = null;
    this.defaultSettings = this.getDefaultSettings();
    this.setSettings(this.defaultSettings);
    this.elementsCache = new Map();
    this.elementsCacheOP = [];
    this.controls = {};
    this.postReferences = new Map();
  }

  getDefaultSettings() {
    return {
      currentUpdateIndex: 0,
      viewMode: "all", //all, single, sequence
      showSuggestions: "all", //none, last, all
      showAuthorComments: "all", //none, last, all
      showReferences: "nonupdates", //none, nonupdates, all
      replyFormLocation: "top", //top, bottom
      expandImages: "none", //none, updates, all
      maxImageWidth: 100,
      maxImageHeight: 96,
      stretchImages: false,
      showUpdateInfo: false,
      showReplyForm: true,
      moveToLast: false,
    };
  }

  init(settings) {
    var updateAnalyzer = new UpdateAnalyzer();
    var results = updateAnalyzer.analyzeQuest(document); //run UpdateAnalyzer to determine which posts are updates and what not
    this.threadID = updateAnalyzer.threadID;
    this.updates = this.getUpdatePostGroups(results.postTypes, results.postUsers); //organize posts into groups where each group has one update post and the following suggestions
    this.sequences = this.getUpdateSequences(); //a list of unique update sequences
    this.setSettings(this.validateSettings(settings)); //load settings
    this.insertStyling(); //insert html elements for styling
    this.cacheElements(); //cache post elements for faster access
    this.addPostReferences(); //insert elements showing references to each post
    this.insertControls(); //insert html elements for controls
    this.insertEvents(); //insert our own button events plus some global events
    this.modifyLayout(); //change the default layout by moving elements around to make them fit better
    this.refresh(true); //hide all posts and show only the relevant ones; enable/disable/update controls
    this.getLinksFromWiki(); //get quest's wiki link, disthread link, and other thread links
  }

  cacheElements() {
    document.querySelectorAll(".postwidth > a[name]").forEach(anchor => {
      if (anchor.name == "s") {
        return;
      }
      var postID = parseInt(anchor.name);
      var parent = anchor.parentElement.parentElement;
      if (postID === this.threadID) {
        if (!this.elementsCache.has(postID)) {
          this.elementsCache.set(postID, parent);
        }
        var child = parent.firstElementChild;
        while (child && child.nodeName !== "TABLE") {
          if (child.className === "postwidth" || child.nodeName === "BLOCKQUOTE" || child.className === "pony" || child.className === "unicorn" || child.className === "de-refmap" || child.className == "qrReferences") {
            this.elementsCacheOP.push(child);
          }
          child = child.nextElementSibling;
        }
      }
      else {
        while (parent.nodeName != "TABLE") {
          parent = parent.parentElement;
        }
        if (!this.elementsCache.has(postID)) {
          this.elementsCache.set(postID, parent);
        }
      }
    });
  }

  addPostReferences() {
    var boardName = document.querySelector(`input[name="board"]`).value;
    var refClass = `ref|${boardName}|${this.threadID}|`;
    var postLinks = document.querySelectorAll(`blockquote a[class^="${refClass}"]`);
    var references = new Map();
    //build references map
    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(refClass.length));
      if (!this.elementsCache.has(targetID)) {
        return;
      }
      var postReferences = references.get(targetID) || [];
      postReferences.push(linkPostID);
      references.set(targetID, postReferences);
    });
    //insert reference elements
    references.forEach((references, postID) => {
      var el = this.elementsCache.get(postID);
      var links = references.map(id => {
        var newLink = document.createElement("a");
        newLink.href = `#${id}`;
        newLink.className = `${refClass}${id}| qrReference`;
        newLink.textContent = `>>${id}`;
        newLink.addEventListener("mouseover", (e) => { document.defaultView.addreflinkpreview(e); });
        newLink.addEventListener("mouseout", (e) => { document.defaultView.delreflinkpreview(e); });
        return newLink;
      });
      var newDiv = document.createElement("div");
      newDiv.classList.add("qrReferences");
      newDiv.append(...links);
      el.querySelector("blockquote").insertAdjacentElement("afterEnd", newDiv);
      if (postID === this.threadID) {
        this.elementsCacheOP.push(newDiv);
      }
    });
  }

  getUpdatePostGroups(postTypes, postUsers) {
    var updatePostGroups = [];
    var currentPostGroup = { updatePostID: 0, suggestions: [], authorComments: [] };
    var postTypesArray = [...postTypes];
    this.total = { authorComments: 0, suggestions: 0};
    //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.reduceRight((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.allSuggesters = [...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];
  }

  refresh(checkHash = false, scroll = true) {
    //checkHash: if true and the url has a hash, show the update that contains the post ID in the hash, and scroll to this update
    //scroll: if true, will scroll to the current update
    var scrollToPostID = null;
    if (this.moveToLast) {
      this.currentUpdateIndex = this.viewMode == "sequence" ? this.updates.indexOf(this.sequences[this.sequences.length - 1][0]) : this.updates.length - 1;
      this.settingsChanged();
      this.moveToLast = false;
    }
    if (checkHash && document.defaultView.location.hash) {
      scrollToPostID = parseInt(document.defaultView.location.hash.replace("#", ""));
      this.currentUpdateIndex = this.findUpdate(scrollToPostID);
      this.settingsChanged();
    }
    else if (this.viewMode == "all" && this.currentUpdate() != this.firstUpdate()) {
      scrollToPostID = this.currentUpdate().updatePostID;
    }
    this.hideAll();
    this.showCurrentUpdates();
    if (checkHash && 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.elementsCache.get(scrollToPostID) : this.controls.controlsTop;
      var scrollOptions = { behavior: "smooth", block: "start", };
      if (document.readyState !== "complete") {
        document.defaultView.addEventListener("load", () => { scrollToElement.scrollIntoView(scrollOptions); });
      }
      else {
        document.defaultView.requestAnimationFrame(() => { scrollToElement.scrollIntoView(scrollOptions); });
      }
    }
  }

  hideAll() {
    for (var key of this.elementsCache.keys()) {
      if (key == this.threadID) {
        this.elementsCacheOP.forEach(el => { el.classList.add("hidden"); });
      }
      else {
        this.elementsCache.get(key).classList.add("hidden");
      }
    }
  }

  findUpdate(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;
      }
    }
  }

  showCurrentUpdates() {
    var updatesToShow = {
      single: [ this.currentUpdate() ],
      sequence: this.currentUpdate().sequence,
      all: this.updates,
    };
    var updatesToExpand = {};
    var currentSequenceIndex = this.sequences.indexOf(this.currentUpdate().sequence);
    updatesToExpand.single = [this.updates[this.currentUpdateIndex - 1], this.currentUpdate(), this.updates[this.currentUpdateIndex + 1]].filter(el => !!el);
    updatesToExpand.sequence = [...this.sequences[currentSequenceIndex - 1] || [], ...this.sequences[currentSequenceIndex], ...this.sequences[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]].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 el = this.elementsCache.get(postID);
      var link = el.querySelector(".postwidth > .filesize > a");
      //var fileTypes = [ ".png", ".jpg", ".jpeg", ".gif" ];
      //if (!link || fileTypes.indexOf((link.href.match(this.regex.fileExtension) || [])[0].toLowerCase()) < 0) {
      if (!link || !link.onclick) { //if there is no link, or the link doesn't have an onclick event set, then we know it's not an expandable image
        return;
      }
      var img = el.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 = img.src === link.href;
      if (!expanded && (this.expandImages == "all" || (this.expandImages == "updates" && postID == update.updatePostID))) {
        img.setAttribute("thumbsrc", img.src);
        img.removeAttribute("onmouseover");
        img.removeAttribute("onmouseout");
        img.src = link.href;
      }
      // contract images as well
      else if (expanded && (this.expandImages == "none" || (this.expandImages == "updates" && postID != update.updatePostID))) {
        if (img.hasAttribute("thumbsrc") && !img.getAttribute("thumbsrc").endsWith("spoiler.png")) {
          img.src = img.getAttribute("thumbsrc");
        }
        else {
          link.click();
        }
      }
    });
  }

  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) {
    if (postID == this.threadID) {
      this.elementsCacheOP.forEach(el => { el.classList.remove("hidden"); });
    }
    else {
      this.elementsCache.get(postID).classList.remove("hidden");
    }
  }

  showFirst() {
    var newUpdateIndex = 0;
    this.changeIndex(newUpdateIndex);
  }

  showLast() {
    var newUpdateIndex = this.viewMode == "sequence" ? this.updates.indexOf(this.sequences[this.sequences.length - 1][0]) : this.updates.length - 1;
    this.changeIndex(newUpdateIndex);
  }

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

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

  changeIndex(newUpdateIndex) {
    if (newUpdateIndex === this.currentUpdateIndex || newUpdateIndex < 0 || newUpdateIndex > this.updates.length - 1) {
      return;
    }
    this.currentUpdateIndex = newUpdateIndex;
    this.refresh(false, true);
    this.settingsChanged();
  }

  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];
      }
    }
    return settings;
  }

  settingsChanged() {
    if (this.onSettingsChanged) {
      var settings = {};
      for(var settingName in this.defaultSettings) {
        if (this[settingName] !== this.defaultSettings[settingName]) {
          settings[settingName] = this[settingName];
        }
      }
      this.onSettingsChanged(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();
    document.querySelector(".qrSettingsNavItemSelected").classList.remove("qrSettingsNavItemSelected");
    document.querySelector(".qrCurrentPage").classList.remove("qrCurrentPage");
    var link = e.target.parentElement;
    link.classList.add("qrSettingsNavItemSelected");
    document.querySelector(".qrSettingsPages").children[[...link.parentElement.children].indexOf(link)].classList.add("qrCurrentPage");
  }

  toggleReplyForm(e) {
    e.preventDefault();
    this.showReplyForm = !this.controls.replyForm.classList.toggle("hidden");
    this.controls.replyFormRestoreButton.classList.toggle("hidden", this.showReplyForm);
    this.settingsChanged();
  }

  popoutReplyForm(e) {
    e.preventDefault();
    var floating = this.controls.replyForm.classList.toggle("qrPopout");
    if (floating) {
      if (!this.replyFormDraggable) {
        var rect = this.controls.replyForm.querySelector(".postform").getBoundingClientRect();
        this.controls.replyForm.style.left = `${document.documentElement.clientWidth - rect.width - 10}px`;
        this.controls.replyForm.style.top = `${(document.defaultView.innerHeight - rect.height) * 0.75}px`;
      }
      this.replyFormDraggable = new document.defaultView.Draggable("postform", { handle: "qrReplyFormHeader" });
    }
    else {
      this.replyFormDraggable.destroy();
    }
  }

  changeThread(e) {
    document.defaultView.location.href = e.target.value;
  }

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

  changeShowReferences(e) {
    if (this.showReferences == this.controls.showReferencesDropdown.value) {
       return;
    }
    this.showReferences = this.controls.showReferencesDropdown.value;
    document.querySelector("#qrReferencesCss").remove();
    document.body.insertAdjacentHTML("beforeEnd", this.getReferencesStyleHtml());
    this.refresh(false, 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 === true;
    this.controls.imageWidthLabel.textContent = this.stretchImages ? "Image container width" : "Max image width";
    this.controls.imageHeightLabel.textContent = this.stretchImages ? "Image container height" : "Max image height";
    document.querySelector("#qrImageSizeCss").remove();
    document.body.insertAdjacentHTML("beforeEnd", this.getImageSizesStyleHtml());
    this.refresh(false, false);
    this.settingsChanged();
  }

  getLinksFromWiki() {
    Xhr.get(`/w/index.php?search=${this.threadID}&fulltext=1&limit=500`).then(xhr => {
      var threadID = xhr.responseURL.match(new RegExp("search=([0-9]+)"))[1];
      let doc = document.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 == threadID);
      if (results.length === 0) {
        return;
      }
      //filter wiki search results to the one that has the threadID in the quest info box
      var theRightOne = results.filter(el => {var p = el.previousSibling; return p && p.nodeType == Node.TEXT_NODE && p.textContent.match(new RegExp("[0-9]=$")); });
      if (theRightOne.length === 0) {
        return;
      }
      var wikiUrl = theRightOne[0].parentElement.previousElementSibling.querySelector("a").href;
      document.querySelectorAll(".qrWikiLink").forEach(link => { link.href = wikiUrl; link.style.removeProperty("color"); });
      Xhr.get(wikiUrl).then(xhr => {
        //parse quest wiki
        let doc = document.implementation.createHTMLDocument();
        doc.documentElement.innerHTML = xhr.response;
        var links = [...doc.querySelectorAll(".infobox a")];
        //get latest disthread link
        var disThreadLinks = links.filter(l => l.href.indexOf("/questdis/") >= 0);
        if (disThreadLinks.length > 0) {
          var disThreadUrl = disThreadLinks[disThreadLinks.length - 1].href;
          document.querySelectorAll(".qrDisLink").forEach(link => { link.href = disThreadUrl; link.style.removeProperty("color"); });
        }
        //get quest threads
        var threadLinks = links.filter(l => l.href.indexOf("/quest/") >= 0 || l.href.indexOf("/questarch/") >= 0 || l.href.indexOf("/graveyard/") >= 0).filter(l => l.href.indexOf("image-for") < 0);
        var currentThreadLink = threadLinks.find(link => link.href.indexOf(this.threadID) >= 0);
        if (currentThreadLink) {
          //only links within the same box
          threadLinks = [...currentThreadLink.parentElement.parentElement.querySelectorAll("a")];
          threadLinks = threadLinks.filter(l => l.href.indexOf("/quest/") >= 0 || l.href.indexOf("/questarch/") >= 0 || l.href.indexOf("/graveyard/") >= 0).filter(l => l.href.indexOf("image-for") < 0);
          var threadOptionsHtml = threadLinks.reverse().reduceRight((acc, link) => {
            var threadName = (link.textContent == "Thread" && threadLinks.length === 1) ? "Thread 1" : link.textContent;
            acc += `<option value="${link.href}">${threadName}</option>`;
            return acc;
          }, "");
          document.querySelectorAll("#qrThreadLinksDropdown").forEach(dropdown => {
            dropdown.innerHTML = threadOptionsHtml;
            dropdown.value = currentThreadLink.href;
          });
        }
        //get quest author and title
        var infoboxHeader = doc.querySelector(".infobox big");
        if (infoboxHeader) {
          var children = [...infoboxHeader.childNodes];
          var questTitle = children.shift().textContent;
          var byAuthors = children.reverse().reduceRight((acc, el) => {acc += el.textContent; return acc;}, "");
          document.title = `${this.hasTitle ? document.title : questTitle}${byAuthors}`;
          document.querySelector(".logo").textContent = document.title;
        }
      });
    });
  }

  updateControls() {
    var leftDisabled = true;
    var rightDisabled = true;
    var current = 1;
    var last = 1;
    var infoUpdate;
    if (this.viewMode == "sequence") {
      leftDisabled = this.currentUpdate().sequence == this.firstUpdate().sequence;
      rightDisabled = this.currentUpdate().sequence == this.lastUpdate().sequence;
      current = this.sequences.indexOf(this.currentUpdate().sequence) + 1;
      last = this.sequences.length;
      infoUpdate = this.currentUpdate().sequence[this.currentUpdate().sequence.length - 1];
    }
    else {
      leftDisabled = this.currentUpdate() == this.firstUpdate();
      rightDisabled = this.currentUpdate() == this.lastUpdate();
      current = this.currentUpdateIndex + 1;
      last = this.updates.length;
      infoUpdate = this.currentUpdate();
    }
    // 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("crimson", 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.forEach(label => { label.textContent = ` A:${this.viewMode !== "all" ? infoUpdate.authorComments.length : this.total.authorComments}`; });
      this.controls.suggestionsCountLabels.forEach(label => { label.textContent = `S:${this.viewMode !== "all" ? infoUpdate.suggestions.length : this.total.suggestions}`; });
      var suggesters = this.viewMode === "all" ? this.allSuggesters : infoUpdate.suggesters;
      this.controls.suggestersCountLabels.forEach(label => this.updateSuggestersLabel(label, suggesters));
    }
    // settings
    this.controls.showUpdatesDropdown.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;
    // sticky controls when viewing whole thread
    this.controls.navControls[1].classList.toggle("stickyBottom", this.viewMode == "all");
/*    // sentinels for full thread view
    var topOfCurrent = 0;
    var bottomOfCurrent = 0;
    if (this.viewMode == "all") {
      if (this.currentUpdate() != this.firstUpdate()) {
        topOfCurrent = this.elementsCache.get(this.currentUpdate().updatePostID).offsetTop;
      }
      if (this.currentUpdate() != this.lastUpdate()) {
        bottomOfCurrent = this.elementsCache.get(this.updates[this.currentUpdateIndex + 1].updatePostID).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 == "bottom" && isReplyFormAtTop) { //move it down
      this.controls.postArea.remove();
      document.body.insertBefore(this.controls.postArea, document.querySelectorAll(".navbar")[1]);
      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); //force toggling seems to be slow
    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 spAnons = anons.filter(el => el.postCount === 1);
        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 - spAnons.length} unnamed IDs with a total post count of 2+
${spAnons.length} unnamed IDs with a total post count of 1`;
  }

  insertControls() {
    //top controls
    document.querySelector("body > form").insertAdjacentHTML("beforebegin", this.getTopControlsHtml());
    //bottom nav controls
    var del = document.querySelector(".userdelete");
    if (del.parentElement.nodeName == "DIV") {
      del = del.parentElement;
    }
    del.insertAdjacentHTML("beforebegin", this.getBottomControlsHtml());
    //make reply form collapsable
    document.querySelector(".postarea").insertAdjacentHTML("afterBegin", `<div id="qrReplyFormRestoreButton" class="hidden">[<a href="#">Reply</a>]</div>`);
    //reply form header
    document.querySelector(".postform").insertAdjacentHTML("afterBegin", this.getReplyFormHeaderHtml());

    //when viewing full thread, we want to detect and remember where we are; something something IntersectionObserver
    /*document.body.insertAdjacentHTML("afterBegin", `<div class="sentinel hidden"></div><div class="sentinel hidden"></div>`);
    this.sentinelPreviousEl = document.body.firstChild;
    this.sentinelCurrentEl = document.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);*/

    var controlNames = ["navControls", "showFirstButtons", "showPrevButtons", "showNextButtons", "showLastButtons",
                        "currentPosLabels", "totalPosLabels", "updateInfos", "authorCommentsCountLabels", "suggestionsCountLabels", "suggestersCountLabels",];
    var queries = [".qrNavControls", "#qrShowFirstButton", "#qrShowPrevButton", "#qrShowNextButton", "#qrShowLastButton",
                   ".qrCurrentPos", ".qrTotalPos", ".qrUpdateInfo", "#qrAuthorCommentsCount", "#qrSuggestionsCount", "#qrSuggestersCount",];
    controlNames.forEach((name, index) => {
      this.controls[name] = [...document.querySelectorAll(queries[index])];
    });

    controlNames = ["showUpdatesDropdown", "showSuggestionsDropdown", "showAuthorCommentsDropdown", "showReferencesDropdown", "replyFormLocationDropdown",
                    "expandImagesDropdown", "imageWidthLabel", "maxImageWidthTextbox", "imageHeightLabel", "maxImageHeightTextbox", "stretchImagesCheckbox",
                    "showUpdateInfoCheckbox", "replyFormRestoreButton", "controlsTop", "settingsControls", "postArea", "replyMode", "replyForm"];
    queries = ["#qrShowUpdatesDropdown", "#qrShowSuggestionsDropdown", "#qrShowAuthorCommentsDropdown", "#qrShowReferencesDropdown", "#qrReplyFormLocationDropdown",
               "#qrExpandImagesDropdown", "#qrImageWidthLabel", "#qrMaxImageWidthTextbox", "#qrImageHeightLabel", "#qrMaxImageHeightTextbox", "#qrStretchImagesCheckbox",
               "#qrShowUpdateInfoCheckbox", "#qrReplyFormRestoreButton", ".qrControlsTop", ".qrSettingsControls", ".postarea", ".replymode", "#postform"];
    controlNames.forEach((name, index) => {
      this.controls[name] = document.querySelector(queries[index]);
    });
  }

  /*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() {
    document.head.insertAdjacentHTML("beforeEnd", this.getStylingHtml());
    document.head.insertAdjacentHTML("beforeEnd", this.getImageSizesStyleHtml());
    document.head.insertAdjacentHTML("beforeEnd", this.getReferencesStyleHtml());
  }

  insertEvents() {
    var events = [ //events for our controls
      ["#qrSettingsToggle > a", "click", this.toggleSettingsControls],
      ["#qrGeneralSettingsLink", "click", this.showSettingsPage],
      ["#qrImageSettingsLink", "click", this.showSettingsPage],
      ["#qrShowUpdatesDropdown", "change", this.updateSettings],
      ["#qrShowSuggestionsDropdown", "change", this.updateSettings],
      ["#qrShowAuthorCommentsDropdown", "change", this.updateSettings],
      ["#qrReplyFormLocationDropdown", "change", this.updateSettings],
      ["#qrShowReferencesDropdown", "change", this.changeShowReferences],
      ["#qrExpandImagesDropdown", "change", this.updateSettings],
      ["#qrMaxImageWidthTextbox", "change", this.changeImageSizeSettings],
      ["#qrMaxImageHeightTextbox", "change", this.changeImageSizeSettings],
      ["#qrStretchImagesCheckbox", "click", this.changeImageSizeSettings],
      ["#qrThreadLinksDropdown", "change", this.changeThread],
      ["#qrShowUpdateInfoCheckbox", "click", this.updateSettings],
      ["#qrShowFirstButton", "click", this.showFirst],
      ["#qrShowPrevButton", "click", this.showPrevious],
      ["#qrShowNextButton", "click", this.showNext],
      ["#qrShowLastButton", "click", this.showLast],
      ["#qrReplyFormRestoreButton > a", "click", this.toggleReplyForm],
      ["#qrReplyFormPopout", "click", this.popoutReplyForm],
      ["#qrReplyFormMinimize", "click", this.toggleReplyForm],
    ];
    events.forEach(params => {
      document.querySelectorAll(params[0]).forEach(el => {
        el.addEventListener(params[1], (e) => { params[2].call(this, e); }); //need to pass "this" as context, otherwise it gets set to the caller (window); for some strange reason, the arrow notation isn't doing its job
      });
    });

    //events for other controls
    document.querySelector(`#postform input[type="submit"][value="Reply"]`).addEventListener("click", (e) => {
      var userName = document.querySelector(`#postform 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
    document.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
      if (document.defaultView.location.hash) {
        this.refresh(true);
      }
    });

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

    document.addEventListener("keydown", (e) => {
      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();
      }
    });
  }

  handleScroll() {
    //check if the user scrolled to a different update on screen -> mark and save the position (only in whole thread view)
    var lastUpdateAboveViewPort = null;
    for (var postID of this.elementsCache.keys()) {
      var el = this.elementsCache.get(postID);
      if (el.offsetTop !== 0) {
        if (el.offsetTop > document.defaultView.scrollY) {
          break;
        }
        lastUpdateAboveViewPort = postID;
      }
    }
    var newUpdateIndex = lastUpdateAboveViewPort === null ? 0 : this.findUpdate(lastUpdateAboveViewPort);
    if (this.currentUpdateIndex != newUpdateIndex) {
      this.currentUpdateIndex = newUpdateIndex;
      this.updateControls();
      this.settingsChanged();
      var updatesToExpand = [this.currentUpdate(), this.updates[this.currentUpdateIndex + 1]].filter(update => !!update);
      updatesToExpand.forEach(update => this.expandUpdateImages(update));
    }
  }

  modifyLayout() {
    var op = this.elementsCache.get(this.threadID);
    //change tab title to quest's title
    var label = op.querySelector(".postwidth > label");
    this.hasTitle = !!label.querySelector(".filetitle");
    var title = label.querySelector(".filetitle") || label.querySelector(".postername");
    title = title.textContent.trim();
    document.title = title !== "Suggestion" ? title : "Untitled Quest";
    //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
    this.elementsCache.get(this.lastUpdate().updatePostID).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.querySelector("blockquote").insertAdjacentHTML("afterEnd", `<div style="clear: left;"></div>`);
    //prevent wrapping text underneath update images
    this.updates.forEach(update => {
      var updateEl = this.elementsCache.get(update.updatePostID);
      if (update !== this.firstUpdate()) {
        updateEl.querySelector(".reply, .highlight").classList.add("update");
      }
      else { //need to be careful to not set the OP (form) to 100% width because it would override BLICK's own (bugged) width settings
        updateEl.classList.add("updateOp");
      }
    });
    //Fix OP header so that the image wraps underneath it, like the other posts
    var opImage = op.querySelector(`#thumb${this.threadID}`);
    if (opImage && opImage.parentElement.nextElementSibling.name == this.threadID) {
      var anchor = opImage.parentElement.nextElementSibling;
      var elementsToMove = [];
      for (let i = 0; i < 3; i++) {
        elementsToMove.unshift(anchor.previousElementSibling);
        anchor.previousElementSibling.remove();
      }
      elementsToMove.forEach(el => anchor.parentElement.appendChild(el));
    }
    //hide "Expand all images" link
    op.querySelector(`a[href="#top"]`).classList.add("hidden");
    //remove the "Report completed threads!" message from the top
    var message = document.querySelector("body > center .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();
    }
    var subjectEl = replyForm.querySelector(`input[name="subject"]`);
    [...subjectEl.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(0, 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 Suggestion)";
    (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");
  }

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

  getBottomControlsHtml() {
    return `
<links class="qrLinksBottom">
  ${this.getLinksHtml()}
</links>
<div class="qrNavControls">
  ${this.getNavControlsHtml()}
</div>
<hr>
`;
  }

  getSettingsControlsHtml() {
    return `
<div class="qrSettingsControls collapsedHeight">
  <span class="qrSettingsNav">
    <div class="qrSettingsNavItem qrSettingsNavItemSelected">[<a href="#" id="qrGeneralSettingsLink">General</a>]</div>
    <div class="qrSettingsNavItem">[<a href="#" id="qrImageSettingsLink">Images</a>]</div>
    <div class="qrSettingsNavItem">[<a href="#" id="qrImageSettingsLink">Analytics</a>]</div>
  </span>
  <div class="qrSettingsPages">
    <div class="qrSettingsPage qrCurrentPage">
      <div>Viewing mode</div>
      <select id="qrShowUpdatesDropdown" class="qrSettingsControl"><option value="all">Whole thread</option><option value="single">Paged per update</option><option value="sequence">Paged per sequence</option></select>
      <div>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>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>Reply form</div>
      <select id="qrReplyFormLocationDropdown" class="qrSettingsControl"><option value="top">At top</option><option value="bottom">At bottom</option></select>
      <div>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>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>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>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>Show update info</div>
      <input type="checkbox" id="qrShowUpdateInfoCheckbox" class="qrSettingsControl">
    </div>
  </div>
</div>
`;
  }

  getNavControlsHtml() {
    return `
<span></span>
<span class="qrNavControl"><button class="qrNavButton" id="qrShowFirstButton" type="button">First</button></span>
<span class="qrNavControl"><button class="qrNavButton" id="qrShowPrevButton" type="button">Prev</button></span>
<span id="qrNavPosition" class="qrOutline" title="Index of the currently shown update slash the total number of updates">
  <label class="qrCurrentPos">0</label> / <label class="qrTotalPos">0</label>
</span>
<span class="qrNavControl"><button class="qrNavButton" id="qrShowNextButton" type="button">Next</button></span>
<span class="qrNavControl"><button class="qrNavButton" id="qrShowLastButton" type="button">Last</button></span>
<span>
  <span class="qrUpdateInfo qrOutline">
    <label id="qrAuthorCommentsCount" title="# of author comment posts for the visible updates">A: 0</label>
    <label id="qrSuggestionsCount" title="# of suggestion posts for the visible updates">S: 0</label>
    <label id="qrSuggestersCount" title="# of unique suggesters for the visible updates">U: 0</label>
  </span>
</span>
`;
  }

  getLinksHtml() {
    return `
<span>[<a class="qrWikiLink" style="color: inherit" title="Link to the quest's wiki page (if available)">Wiki</a>]</span>
<span>[<a class="qrDisLink" style="color: inherit" title="Link to the quest's latest discussion thread">Discuss</a>]</span>
<span class="qrThreadsLinks" title="List of quest's threads">
  <select id="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 title="Minimize the Reply form">[&nbsp;<a id="qrReplyFormMinimize" href="#">_</a>&nbsp;]</span>
      <span title="Pop out the Reply form and have it float">[<a id="qrReplyFormPopout" href="#"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" fill="currentColor">
<path d="M928 128H352c-52.8 0-96 43.2-96 96v96H96c-52.8 0-96 43.2-96 96v384c0 52.8 43.2 96 96 96h576c52.8 0 96-43.2 96-96v-96h160c52.8 0 96-43.2
 96-96V224c0-52.8-43.198-96-96-96zM704 800c0 17.346-14.654 32-32 32H96c-17.346 0-32-14.654-32-32V416c0-17.346 14.654-32 32-32h160v224c0 52.8 43.2
 96 96 96h352v96z m256-192c0 17.346-14.654 32-32 32H352c-17.346 0-32-14.654-32-32V224c0-17.346 14.654-32 32-32h576c17.346 0 32 14.654 32 32v384z">
</path></svg></a>]</span>
    </th>
  </tr>
</thead>`;
  }

  getStylingHtml() {
    var bgc = document.defaultView.getComputedStyle(document.body)["background-color"];
    var fgc = document.defaultView.getComputedStyle(document.body).color;
    return `
<style id="qrCss">
.hidden { display: none; }
.crimson { color: crimson; }
#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}; }
.qrLinksBottom { position: absolute; right: 8px; white-space: nowrap; color: ${fgc}; }
.qrLinksTop { white-space: nowrap; text-align: right; }
#qrNavPosition { font-weight: bold; white-space: nowrap; }
.qrUpdateInfo { white-space: nowrap; }
.qrSettingsControls { height: 68px;  overflow: hidden; transition: all 0.3s; display: grid; grid-template-columns: auto 1fr; }
.qrSettingsNav { 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; }
.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; bottom: 0; }
.qrCurrentPage { opacity: 1; z-index: 1; }
.qrSettingsPage > :nth-child(4n+1) { grid-column-start: 2; }
.qrSettingsPage > :nth-child(4n+3) { grid-column-start: 5; }
.qrSettingsControl { margin: 0px; }
select.qrSettingsControl { width: 150px; }
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: inherit; border: 1px solid rgba(0, 0, 0, 0.10); }
.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; }
.update { width: 100%; }
.userdelete { float:unset; position: absolute; right: 2px; }
body { position: relative;  overflow-anchor: none; }
#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; }
#watchedthreadsbuttons { top: 0px; right: 0px; left: unset; bottom: unset; }
.reflinkpreview { z-index: 1; }
blockquote { margin-right: 1em; }
#spoiler { vertical-align: text-top; }
.postarea { background-color: inherit; }
.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[type="submit"] { position: absolute; right: 1px; bottom: 3px; }
.postform [name="imagefile"] { width: 220px; }
.postform td:first-child { display: none; }
thead span > a { color: inherit; }
#BLICKpreviewbut { margin-right: 57px; }
</style>`;
/*
.qrNavControls { white-space: nowrap; text-align: center; pointer-events: none; background-color: transparent !important; }
.qrNavControls > * { pointer-events: initial; outline: 2px solid ${backgroundColor}; background-color: ${backgroundColor}; box-shadow: 0px 1px 0px 3px ${backgroundColor}; }
#qrNavPosition { display: inline-block; font-weight: bold; padding: 2px 6px 1px 6px; line-height: 1em; }
.qrUpdateInfo { position: absolute; right: 8px; padding-top: 2px; }
#watchedthreads { position: unset; margin: 2px 0px 2px 2px; float: right; height: unset !important; }
#watchedthreadsdraghandle { white-space: nowrap; overflow: hidden; }
#watchedthreadsbuttons { position: unset; }
#watchedthreadlist { display: grid; grid-template-columns: 40px 0px 0px 1fr auto 0px; overflow: hidden; color: transparent; }
#watchedthreadlist > * { margin: auto 0px; }
#watchedthreadlist > .filetitle { grid-column-start: 3; grid-column-end: 6; overflow: hidden; white-space: nowrap; }
#watchedthreadlist > .postername { grid-column-start: 3; white-space: nowrap; }
#watchedthreadlist > a[href*="html#"] { text-align: right; }
.logo { clear: unset; }
blockquote { clear: unset; }
.sentinel { position: absolute; left: 0px; top: 0px; width: 400px; pointer-events: none; background-color:white; opacity: 0.3; }
*/
  }

  getImageSizesStyleHtml() {
    var rules;
    if (this.stretchImages) {
      rules = `width: calc(${this.maxImageWidth}% - 40px); 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 `
<style id="qrImageSizeCss">
  .thumb[src*="/src/"] { ${rules} }
</style>`;
  }

  getReferencesStyleHtml() {
    //none => hide all; nonupdates => hide for updates; all => don't hide anything
    if (this.showReferences == "all") {
      return `<style id="qrReferencesCss"></style>`;
    }
    var selector = this.showReferences == "nonupdates" ? ".update > .qrReferences, .updateOp > .qrReferences" : ".qrReferences";
    return `
<style id="qrReferencesCss">
   ${selector} { display: none; }
</style>`;
  }
}
// START is here

if (document.defaultView.QR) { //sanity check; don't run the script if it already ran
  return;
}
if (document.defaultView.location.href.endsWith("+50.html") || document.defaultView.location.href.endsWith("+100.html")) {
  return; //also, don't run the script when viewing partial thread
}

// for compatibility with certain other extensions, this extension runs last
setTimeout(() => {
  var timeStart = Date.now();
  // get settings from localStorage
  var threadID = document.postform.replythread.value;
  var lastThreadID = null;
  var settings = document.defaultView.localStorage.getItem(`qrSettings${threadID}`);
  if (!settings) {
    lastThreadID = document.defaultView.localStorage.getItem("qrLastThreadID");
    if (lastThreadID) {
      settings = document.defaultView.localStorage.getItem(`qrSettings${lastThreadID}`);
      if (settings) {
        settings = JSON.parse(settings);
        settings.currentUpdateIndex = 0;
      }
    }
  }
  else {
    settings = JSON.parse(settings);
  }
  document.defaultView.QR = new QuestReader();
  document.defaultView.QR.init(settings);
  document.defaultView.QR.onSettingsChanged = (settings) => {
    if (!lastThreadID || lastThreadID != threadID) {
      document.defaultView.localStorage.setItem("qrLastThreadID", threadID.toString());
      lastThreadID = threadID;
    }
    document.defaultView.localStorage.setItem(`qrSettings${threadID}`, JSON.stringify(settings));
  };
  console.log(`Quest Reader run time = ${Date.now() - timeStart}ms`);
}, 0);