Quest Reader

Makes it more convenient to read quests

目前为 2019-08-23 提交的版本。查看 最新版本

// ==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     8
// @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
	}
	this.setPostUsers(posts, users, questFixes); //do final user resolution
	var postTypes = this.getFinalPostTypes(posts, questFixes); //determine which posts are updates
	return postTypes;
  }

  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) {
	posts.forEach(post => {
	  post.user = this.decidePostUser(post, users, questFixes);
	});
  }

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

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

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

  init(settings) {
	var updateAnalyzer = new UpdateAnalyzer();
	var postTypes = updateAnalyzer.analyzeQuest(document); //run UpdateAnalyzer to determine which posts are updates and what not
	this.threadID = updateAnalyzer.threadID;
	this.updates = this.getUpdatePostGroups(postTypes); //organize posts into groups; 1 update per group
	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); //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 = anchor.name;
	  var parent = anchor.parentElement;
	  while (parent && parent.nodeName != "TABLE" && parent.nodeName != "FORM" && parent.classList != "de-oppost") {
		parent = parent.parentElement;
	  }
	  if (parent) {
        var key = parseInt(postID);
        if (!this.elementsCache.has(key)) {
          this.elementsCache.set(key, parent);
        }
	  }
	});
  }

  getUpdatePostGroups(postTypes) {
	var updatePostGroups = [];
	var currentPostGroup = { updatePostID: 0, suggestions: [], authorComments: [] };
	var postTypesArray = [...postTypes];
	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]);
	  }
	  else {
		currentPostGroup.suggestions.unshift(postTypesArray[i][0]);
	  }
	}
	var currentUpdateSequence = [];
	updatePostGroups.forEach(postGroup => {
	  currentUpdateSequence.push(postGroup);
	  postGroup.sequence = currentUpdateSequence;
	  if (postGroup.suggestions.length > 0) {
		currentUpdateSequence = [];
	  }
	});
	return updatePostGroups;
  }

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

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

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

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

  refresh(checkHash = false, scroll = true) {
    //checkHash: if true and the url has a hash, show the update that contains the post ID in the hash, and scroll to this update
    //scroll: if true, will scroll to the current update
    var scrollToPostID = null;
    if (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) : document.querySelector(".qrControlsTop");
      var scrollOptions = { behavior: "smooth", block: "start", };
	  setTimeout(() => { scrollToElement.scrollIntoView(scrollOptions); }, 0);
	}
  }

  hideAll() {
	for (var key of this.elementsCache.keys()) {
	  var el = this.elementsCache.get(key);
	  if (key == this.threadID) {
		[...el.children].forEach(opPostChildEl => {
		  if (opPostChildEl.className === "postwidth" || opPostChildEl.nodeName === "BLOCKQUOTE" || opPostChildEl.nodeName === "A" || opPostChildEl.className === "de-refmap") {
			opPostChildEl.classList.add("hidden");
		  }
		});
	  }
	  else {
		el.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) {
	var el = this.elementsCache.get(postID);
	if (postID == this.threadID) {
	  [...el.children].forEach(childEl => {
		if (childEl.classList.contains("postwidth") || childEl.nodeName === "BLOCKQUOTE") {
		  childEl.classList.remove("hidden");
		}
	  });
	}
	else {
	  el.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
    var settingsEl = document.querySelector(".qrSettingsControls");
	settingsEl.classList.toggle("collapsedHeight");
	var label = e.target;
	label.text = settingsEl.classList.contains("collapsedHeight") ? "Settings" : "Hide Settings";
  }

  toggleReplyForm(e) {
	e.preventDefault(); //prevent scrolling to the top when clicking the link
    this.showReplyForm = !document.forms.postform.classList.toggle("hidden");
    document.querySelector("#qrReplyFormRestore").classList.toggle("hidden", this.showReplyForm);
	this.settingsChanged();
  }

  popoutReplyForm(e) {
	e.preventDefault(); //prevent scrolling to the top when clicking the link
	var replyForm = document.forms.postform;
	var floating = replyForm.classList.toggle("qrPopout");
	if (floating) {
      if (!this.replyFormDraggable) {
        var rect = document.querySelector(".postform").getBoundingClientRect();
        replyForm.style.left = `${document.documentElement.clientWidth - rect.width - 10}px`;
        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 = document.getElementById("qrShowUpdatesDropdown").value;
	this.showSuggestions = document.getElementById("qrShowSuggestionsDropdown").value;
	this.showAuthorComments = document.getElementById("qrShowAuthorCommentsDropdown").value;
	this.replyFormLocation = document.getElementById("qrReplyFormLocationDropdown").value;
	this.expandImages = document.getElementById("qrExpandImagesDropdown").value;
	this.showUpdateInfo = document.getElementById("qrShowUpdateInfoCheckbox").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 suggestionsCount;
	var authorCommentsCount;
    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;
	  suggestionsCount = this.currentUpdate().sequence[this.currentUpdate().sequence.length - 1].suggestions.length;
	  authorCommentsCount = this.currentUpdate().sequence[this.currentUpdate().sequence.length - 1].authorComments.length;
	}
	else {
	  leftDisabled = this.currentUpdate() == this.firstUpdate();
	  rightDisabled = this.currentUpdate() == this.lastUpdate();
	  current = this.currentUpdateIndex + 1;
	  last = this.updates.length;
	  suggestionsCount = this.currentUpdate().suggestions.length;
	  authorCommentsCount = this.currentUpdate().authorComments.length;
	}
	// buttons
	document.querySelectorAll("#qrShowFirstButton, #qrShowPrevButton").forEach(button => { button.disabled = leftDisabled; });
	document.querySelectorAll("#qrShowNextButton, #qrShowLastButton").forEach(button => { button.disabled = rightDisabled; });
	// update info
	document.querySelectorAll(".qrCurrentPos").forEach(label => { label.textContent = current; label.classList.toggle("crimson", current == last); });
	document.querySelectorAll(".qrTotalPos").forEach(label => { label.textContent = last; });
	document.querySelectorAll(".qrUpdateInfo").forEach(infoContainer => { infoContainer.classList.toggle("hidden", !this.showUpdateInfo); });
	document.querySelectorAll("#qrSuggestionsCount").forEach(label => { label.textContent = `S:${suggestionsCount}`; });
	document.querySelectorAll("#qrAuthorCommentsCount").forEach(label => { label.textContent = ` A:${authorCommentsCount}`; });
    // settings
	document.getElementById("qrShowUpdatesDropdown").value = this.viewMode;
	document.getElementById("qrShowSuggestionsDropdown").value = this.showSuggestions;
	document.getElementById("qrShowAuthorCommentsDropdown").value = this.showAuthorComments;
	document.getElementById("qrReplyFormLocationDropdown").value = this.replyFormLocation;
	document.getElementById("qrExpandImagesDropdown").value = this.expandImages;
	document.getElementById("qrShowUpdateInfoCheckbox").checked = this.showUpdateInfo;
    // sticky controls when viewing whole thread
	document.querySelectorAll(".qrNavControls")[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 postarea = document.querySelector(".postarea");
	var replymode = document.querySelector(".replymode");
	var isReplyFormAtTop = (replymode == postarea.previousElementSibling);
	if (this.replyFormLocation == "bottom" && isReplyFormAtTop) { //move it down
	  postarea.remove();
	  document.body.insertBefore(postarea, document.querySelectorAll(".navbar")[1]);
	  document.querySelector(".qrControlsTop").previousElementSibling.insertAdjacentHTML("beforeBegin", "<hr>");
	}
	else if (this.replyFormLocation == "top" && !isReplyFormAtTop) { //move it up
	  postarea.remove();
	  replymode.insertAdjacentElement("afterEnd", postarea);
	  document.querySelector(".qrControlsTop").previousElementSibling.previousElementSibling.remove(); //remove <hr>
	}
	document.forms.postform.classList.toggle("hidden", !this.showReplyForm);
    document.querySelector("#qrReplyFormRestore").classList.toggle("hidden", this.showReplyForm);
  }

  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="qrReplyFormRestore" 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);
*/
  }
/*
  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],
	  ["#qrReplyFormRestore > 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);
	  }
	});

	var lastScrollTime = 0;
	document.defaultView.addEventListener("wheel", (e) => { //after the wheeling has finished, check if the user
	  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) {
		lastScrollTime = Date.now();
	  }
	});

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

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

  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
	document.querySelector(".qrControlsTop").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");
	  }
	});
    //remove the "Report completed threads!" message from the top
    var message = document.querySelector("body > center .filetitle");
    if (message) {
      message.classList.add("hidden");
    }
	var replyForm = document.forms.postform;
	//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.innerText.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
    document.querySelector(".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="qrSuggestionsCount" title="# of suggestion posts for the visible update.">S: 0</label>
    <label id="qrAuthorCommentsCount" title="# of author comment posts for the visible update.">A: 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);