Quest Reader

Makes it more convenient to read quests

当前为 2019-08-23 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name        Quest Reader
// @author      naileD
// @namespace   QuestReader
// @include     *//tgchan.org/kusaba/quest/res/*
// @include     *//tgchan.org/kusaba/questarch/res/*
// @include     *//tgchan.org/kusaba/graveyard/res/*
// @description Makes it more convenient to read quests
// @version     6
// @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, scrollDirection) {
    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.changedImages = false;
	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: this.viewMode === "all" && scrollDirection === "up" && this.changedImages ? "auto" : "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))) {
		this.changedImages = true;
		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))) {
		this.changedImages = true;
		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;
	}
    var direction = newUpdateIndex > this.currentUpdateIndex ? "down" : "up";
	this.currentUpdateIndex = newUpdateIndex;
	this.refresh(false, true, direction);
	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
	var replyForm = document.forms.postform;
	replyForm.classList.toggle("hidden");
	this.showReplyForm = !replyForm.classList.contains("hidden");
	this.settingsChanged();
	var label = e.target;
	label.text = this.showReplyForm ? "Hide form" : "Reply";
  }

  popoutReplyForm(e) {
	e.preventDefault(); //prevent scrolling to the top when clicking the link
	var replyForm = document.forms.postform;
	replyForm.classList.toggle("qrPopout");
	var label = e.target;
	//label.text = this.showReplyForm ? "Hide form" : "Reply";
  }

  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("#qrReplyFormToggle > a").text = this.showReplyForm ? "Hide form" : "Reply";
  }

  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="qrReplyFormToggle">[<a href="#">Hide form</a>]</div>`);
	//reply form popout
	document.querySelector(".postform").insertAdjacentHTML("beforeEnd", `<div id="qrReplyFormPopout" title="Pop out the Reply form and have it float">[<a href="#">${this.getPopoutIconHtml()}</a>]</div>`);

/*    //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],
	  ["#qrReplyFormToggle > a", "click", this.toggleReplyForm],
	  ["#qrReplyFormPopout > a", "click", this.popoutReplyForm],
	];
	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();
    }
	//move the upload file limitations info into a tooltip
	var filetd = replyForm.querySelector(`input[type="file"]`);
	var fileLimitationsInfo = replyForm.querySelector("td.rules");
	filetd.insertAdjacentHTML("afterEnd", `&nbsp<span class="qrTooltip" title="${fileLimitationsInfo.innerText.split("\n").splice(0, 3).join("\n")}">*</span>`);
	fileLimitationsInfo.parentElement.remove(); //classList.add("hidden"); //due to table's border-spacing, hiding the element would still leave 2px at the bottom
    //move the password help line into a tooltip
	var postPasswordEl = replyForm.querySelector(`.postform input[name="postpassword"]`);
	var passwordInfo = postPasswordEl.nextSibling.textContent.trim().replace(new RegExp(`[()]`, "g"), "").capitalize();
	postPasswordEl.nextSibling.remove();
	postPasswordEl.insertAdjacentHTML("afterEnd", `&nbsp<span class="qrTooltip" title="${passwordInfo}">?</span>`);
	//name placeholder
	replyForm.querySelector(`[name="name"]`).placeholder = "Suggestion";
    //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>
`;
  }

  getPopoutIconHtml() {
	return `<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>`;
  }

  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; }
#qrReplyFormPopout { position: absolute; right: 4px; top: 2px; }
#qrReplyFormPopout svg { width: 17px; vertical-align: bottom; }
.qrPopout { position: fixed; bottom: 100px; right: 5px; opacity: 0.2; transition: opacity 0.3s; }
.qrPopout:hover { opacity: 1; }
.qrPopout:focus-within { opacity: 1; }
.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; }
#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; }
.postform { position: relative; }
.postform :optional { box-sizing: border-box; }
.postform input[type="text"]:not([name="subject"]) { width: 150px !important; }
.postform [name="subject"] { width: 100%; }
.postform input[type="submit"] { position: absolute; right: 3px; bottom: 9px; }
.postform [name="imagefile"] { width: 220px; }
#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);