Quest Reader

Makes it more convenient to read quests

Version au 24/08/2019. Voir la dernière version.

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

// ==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     11
// @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.setSettings(this.getDefaultSettings());
	this.elementsCache = new Map();
    this.elementsCacheOP = [];
    this.controls = {};
  }

  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.cacheElements(); //cache post elements for faster access
    this.insertControls(); //insert html elements for controls
	this.insertStyling(); //insert html elements for styling
	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.setSettings(this.validateSettings(settings)); //load settings
	this.refresh(true, true, false); //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") {
            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);
        }
      }
	});
  }

  getUpdatePostGroups(postTypes, postUsers) {
	var updatePostGroups = [];
	var currentPostGroup = { updatePostID: 0, suggestions: [], authorComments: [] };
	var postTypesArray = [...postTypes];
    this.total = { authorComments: 0, suggestions: 0, suggesters: 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.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, smooth = 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 (checkHash && document.defaultView.location.hash) {
	  scrollToPostID = parseInt(document.defaultView.location.hash.replace("#", ""));
	  this.currentUpdateIndex = this.findUpdate(scrollToPostID);
    }
    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 ? "smooth" : "auto", block: "start", };
	  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();
  }

  getDefaultSettings() {
	return {
	  currentUpdateIndex: 0,
	  viewMode: "all", //all, single, sequence
	  showSuggestions: "all", //none, last, all
	  showAuthorComments: "all", //none, last, all
	  replyFormLocation: "top", //top, bottom
	  expandImages: "none", //none, updates, all
	  showUpdateInfo: false, //false, true
	  showReplyForm: true, //false, true
	};
  }

  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 (typeof(settings[prop]) !== typeof(this[prop])) {
		settings[prop] = this[prop];
	  }
	}
	return settings;
  }

  settingsChanged() {
	if (this.onSettingsChanged) {
	  var settings = {
		currentUpdateIndex: this.currentUpdateIndex,
		viewMode: this.viewMode,
		showSuggestions: this.showSuggestions,
		showAuthorComments: this.showAuthorComments,
		replyFormLocation: this.replyFormLocation,
		expandImages: this.expandImages,
		showUpdateInfo: this.showUpdateInfo,
		showReplyForm: this.showReplyForm,
	  };
	  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";
  }

  toggleReplyForm(e) {
	e.preventDefault(); //prevent scrolling to the top when clicking the link
    this.showReplyForm = !this.controls.replyForm.classList.toggle("hidden");
    this.controls.replyFormRestoreButton.classList.toggle("hidden", this.showReplyForm);
	this.settingsChanged();
  }

  popoutReplyForm(e) {
	e.preventDefault(); //prevent scrolling to the top when clicking the link
	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();
  }

  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 = leftDisabled; });
	// 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}`; });
      this.controls.suggestersCountLabels.forEach(label => {
        var suggesters = this.viewMode === "all" ? this.allSuggesters : infoUpdate.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`});
    }

    // settings
	this.controls.showUpdatesDropdown.value = this.viewMode;
	this.controls.showSuggestionsDropdown.value = this.showSuggestions;
	this.controls.showAuthorCommentsDropdown.value = this.showAuthorComments;
	this.controls.replyFormLocationDropdown.value = this.replyFormLocation;
	this.controls.expandImagesDropdown.value = this.expandImages;
	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;
    }
  }

  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", "replyFormLocationDropdown", "expandImagesDropdown", "showUpdateInfoCheckbox", "replyFormRestoreButton",
                    "controlsTop", "settingsControls", "postArea", "replyMode", "replyForm"];
    queries = ["#qrShowUpdatesDropdown", "#qrShowSuggestionsDropdown", "#qrShowAuthorCommentsDropdown", "#qrReplyFormLocationDropdown", "#qrExpandImagesDropdown", "#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.body.insertAdjacentHTML("beforeend", this.getStylingHtml());
  }

  insertEvents() {
	var events = [ //events for our controls
	  ["#qrSettingsToggle > a", "click", this.toggleSettingsControls],
	  ["#qrShowUpdatesDropdown", "change", this.updateSettings],
	  ["#qrShowSuggestionsDropdown", "change", this.updateSettings],
	  ["#qrShowAuthorCommentsDropdown", "change", this.updateSettings],
	  ["#qrReplyFormLocationDropdown", "change", this.updateSettings],
	  ["#qrExpandImagesDropdown", "change", this.updateSettings],
	  ["#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
	  });
	});

	// 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) { //alt+left arrow, or alt+right arrow, for the obvious reasons we don't want to handle those
		return;
	  }
	  if (e.key == "ArrowRight") {
		e.preventDefault();
		this.showNext();
	  }
	  else if (e.key == "ArrowLeft") {
		e.preventDefault();
		this.showPrevious();
	  }
	  // I'm not sure if binding Home and End would be a desirable behavior considering how rarely the two buttons are normally used
	  /*else if (this.viewMode !== "all" && e.key == "Home") {
		e.preventDefault();
	    this.showFirst();
	  }
	  else if (this.viewMode !== "all" && e.key == "End") {
		e.preventDefault();
	    this.showLast();
	  }*/
	  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");
	  }
	});
    //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="subject"]`) || {}).placeholder = "Subject";
    (replyForm.querySelector(`[name="message"]`) || {}).placeholder = "Message";
    (replyForm.querySelector(`[name="embed"]`) || {}).placeholder = "Embed media";
    //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">
  <div class="qrSettingsPage">
    <div style="grid-column-start: 2;">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 style="grid-column-start: 5;">Reply form</div>
    <select id="qrReplyFormLocationDropdown" class="qrSettingsControl"><option value="top">At top</option><option value="bottom">At bottom</option></select>
    <div style="grid-column-start: 2;">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 style="grid-column-start: 5;">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 style="grid-column-start: 2;">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 style="grid-column-start: 5;">Show update info</div>
    <div><input type="checkbox" id="qrShowUpdateInfoCheckbox" class="qrSettingsControl"></div>
    <div style="grid-column-start: 2;">Keyboard shortcuts</div>
    <div><span class="qrSettingsControl qrTooltip">?<span class="qrTooltiptext">Left and Right arrow keys will <br>navigate between updates</span></span></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>
.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: 84px;  overflow: hidden; transition: all 0.3s; }
.qrSettingsPage { display: grid; grid-template-columns: 1fr auto auto 1fr auto auto 1fr; padding-top:4px; white-space: nowrap; }
.qrSettingsControl { margin-left: 4px; }
select.qrSettingsControl { width: 150px; }
#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; }
.stickyBottom { position: sticky; bottom: 0px; padding: 3px 0px; }
.update { width: 100%; }
.thumb:not([src$="spoiler.png"]) { width: unset; height: unset; max-width: calc(100% - 40px); max-height: calc(100vh - 100px); }
.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; }
.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; }
#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; }
*/
  }
}

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);