Quest Reader

Makes it more convenient to read quests

Verze ze dne 01. 04. 2021. Zobrazit nejnovější verzi.

  1. // ==UserScript==
  2. // @name Quest Reader
  3. // @author naileD
  4. // @namespace QuestReader
  5. // @include https://tgchan.org/kusaba/*
  6. // @include https://tezakia.net/kusaba/*
  7. // @include https://questden.org/kusaba/*
  8. // @include https://talehole.com/kusaba/*
  9. // @include https://thatquestsite.org/kusaba/*
  10. // @description Makes it more convenient to read quests
  11. // @run-at document-start
  12. // @version 39
  13. // @grant none
  14. // @icon data:image/vnd.microsoft.icon;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAACXBIWXMAAAsSAAALEgHS3X78AAAANklEQVQokWNgoBOI2mJKpEomMvQgNAxRPUy4JGjjJJqoZoSrZmBgWOZzGlk/mlKILBMafxAAAE1pG/UEXzMMAAAAAElFTkSuQmCC
  15. // ==/UserScript==
  16. "use strict";
  17. //entry point is more or less at the end of the script
  18.  
  19. //enums
  20. const PostType = { UPDATE: 0, AUTHORCOMMENT: 1, SUGGESTION: 2, COMMENT: 3 };
  21. const ThreadType = { QUEST: 0, DISCUSSION: 1, OTHER: 2 };
  22. const BoardThreadTypes = { //board names and cooresponding types of threads they contain
  23. quest: ThreadType.QUEST,
  24. questarch: ThreadType.QUEST,
  25. graveyard: ThreadType.QUEST,
  26. questdis: ThreadType.DISCUSSION,
  27. meep: ThreadType.OTHER,
  28. moo: ThreadType.OTHER,
  29. draw: ThreadType.OTHER,
  30. tg: ThreadType.OTHER,
  31. };
  32. const BoardDefaultNames = {
  33. quest: "Suggestion",
  34. questarch: "Suggestion",
  35. graveyard: "Suggestion",
  36. questdis: "Anonymous",
  37. meep: "Bob",
  38. moo: "Anonymous",
  39. draw: "Anonymous",
  40. tg: "Anonymous",
  41. };
  42.  
  43. //function that removes questden's prototype library's overrides of native functions where applicable
  44. var restoreNative = (doc) => {
  45. var functionsToRemove = ["Function.bind", "Element.hasAttribute", "Element.getElementsByClassName", "Element.Methods.getElementsByClassName", "HTMLElement.prototype.getElementsByClassName",
  46. "Element.scrollTo", "Element.Methods.scrollTo", "HTMLElement.prototype.scrollTo", "Element.remove", "Element.Methods.remove", "HTMLElement.prototype.remove", "Array.prototype.toJSON"];
  47. var functionsToRestore = ["Object.keys", "Object.values", "Array.prototype.find", "Array.prototype.reverse", "Array.prototype.indexOf", "Array.prototype.entries",
  48. "Array.prototype.map", "Array.prototype.reduce", "Array.from", "String.prototype.endsWith", "String.prototype.sub", "String.prototype.startsWith", "Function.prototype.bind"];
  49. //restoration is done by creating a new window and copying over unmodified functions from that window
  50. var frame = doc.createElement("iframe");
  51. frame.style.display = "none";
  52. frame.style.visibility = "hidden";
  53. doc.documentElement.appendChild(frame);
  54. var safeWindow = frame.contentWindow;
  55. var getObjectAndMethod = (path, root) => {
  56. path = path.split(".");
  57. path.reverse();
  58. var methodName = path.shift();
  59. var object = path.reduceRight((obj, prop) => obj[prop], root);
  60. return { object: object, method: methodName };
  61. }
  62. functionsToRemove.forEach(path => {
  63. var unsafe = getObjectAndMethod(path, doc.defaultView);
  64. delete unsafe.object[unsafe.method];
  65. });
  66. functionsToRestore.forEach(path => {
  67. var unsafe = getObjectAndMethod(path, doc.defaultView);
  68. var safe = getObjectAndMethod(path, safeWindow);
  69. unsafe.object[unsafe.method] = safe.object[safe.method];
  70. });
  71. //sigh; there's some existing functions that rely on the broken functionality of this overriden method, so I can't outright remove the override
  72. doc.getElementsByClassName = function(selector) { return [...Object.getPrototypeOf(this).getElementsByClassName.call(this, selector)]; };
  73. doc._getElementsByXPath = () => { return []; }; //this slow function is now unnecessary since we restored getElementsByClassName
  74. frame.remove();
  75. }
  76.  
  77. //UpdateAnalyzer class
  78. //Input: document of the quest
  79. //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.
  80. //Usage: var results = new UpdateAnalyzer().processQuest(document);
  81. class UpdateAnalyzer {
  82. constructor(options = {}) {
  83. this.regex = UpdateAnalyzer.getRegexes();
  84. this.postCache = null; //Used to transfer posts cache to/from this class. Used for debugging purposes.
  85. this.useCache = options.useCache; //Used for debugging purposes.
  86. this.debug = options.debug;
  87. this.debugAfterDate = options.debugAfterDate;
  88. this.passive = options.passive; //passive mode; treat the thread as a text-quest and ignore any fixes
  89. this.defaultName = options.defaultName || "Suggestion";
  90. }
  91.  
  92. analyzeQuest(questDoc) {
  93. var posts = !this.postCache ? this.getPosts(questDoc) : JSON.parse(this.postCache);
  94. var authorID = posts[0].userID; //authodID is the userID of the first post
  95. this.threadID = posts[0].postID; //threadID is the postID of the first post
  96.  
  97. this.totalFiles = this.getTotalFileCount(posts);
  98. var questFixes = this.getFixes(this.passive ? 0 : this.threadID); //for quests where we can't correctly determine authors and updates, we use a built-in database of fixes
  99. 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); }
  100. var graphData = this.getUserGraphData(posts, questFixes, authorID); //get user names as nodes and edges for building user graph
  101. var users = this.buildUserGraph(graphData.nodes, graphData.edges); //build a basic user graph... whatever that means!
  102. this.author = this.find(users[authorID]);
  103. this.getUserPostAndFileCounts(posts, users, questFixes); //count the amount of posts and files each user made
  104. this.imageQuest = !this.passive && this.isImageQuest(questFixes); //image quest is when the author posts files at least 50% of the time
  105. if (this.debug) console.log(`Image quest: ${this.imageQuest}`);
  106. if (this.imageQuest) { //in case this is an image quest, merge users a bit differently
  107. users = this.buildUserGraph(graphData.nodes, graphData.edges, graphData.strongNodes, authorID); //build the user graph again, but with some restrictions
  108. this.author = this.find(users[authorID]);
  109. this.processFilePosts(posts, users, questFixes); //analyze file names and merge users based on when one file name is predicted from another
  110. this.getUserPostAndFileCounts(posts, users, questFixes); //count the amount of posts and files each user posted
  111. this.mergeCommonFilePosters(posts, users, questFixes); //merge certain file-posting users with the quest author
  112. this.mergeMajorityFilePoster(posts, users, questFixes); //consider a user who posted 50%+ of the files in the thread as the author
  113. }
  114. var postUsers = this.setPostUsers(posts, users, questFixes); //do final user resolution
  115. var postTypes = this.getFinalPostTypes(posts, questFixes); //determine which posts are updates
  116. return { postTypes: postTypes, postUsers: postUsers };
  117. }
  118.  
  119. getPosts(questDoc) {
  120. var posts = new Map(); //dictionary => postID / post object; need to use Map so that the post order is preserved
  121. var headers = questDoc.body.querySelector(":scope > form").getElementsByClassName("postwidth");
  122. for (var i = 0; i < headers.length; i++) {
  123. var postHeaderElement = headers[i];
  124. var postID = postHeaderElement.firstElementChild.name;
  125. postID = parseInt(postID !== "s" ? postID : postHeaderElement.querySelector(`a[name]:not([name="s"])`).name);
  126. var uidElement = postHeaderElement.querySelector(".uid");
  127. var uid = uidElement.textContent.substring(4);
  128. var labelEl = postHeaderElement.querySelector("label");
  129. var subject = labelEl.querySelector(".filetitle");
  130. subject = subject ? subject.textContent.trim() : "";
  131. var trip = labelEl.querySelector(".postertrip");
  132. var name;
  133. if (trip) { //use tripcode instead of name if it exists
  134. name = trip.textContent;
  135. }
  136. else {
  137. name = labelEl.querySelector(".postername").textContent.trim();
  138. name = name == this.defaultName ? "" : name.toLowerCase();
  139. }
  140. var fileName = "";
  141. var fileElement = postHeaderElement.querySelector(".filesize");
  142. if (fileElement) { //try to get the original file name
  143. fileName = fileElement.querySelector("a").href;
  144. var match = fileName.match(this.regex.fileExtension);
  145. var fileExt = match ? match[0] : ""; //don't need .toLowerCase()
  146. if (fileExt == ".png" || fileExt == ".gif" || fileExt == ".jpg" || fileExt == ".jpeg") {
  147. var fileInfo = fileElement.lastChild.textContent.split(", ");
  148. if (fileInfo.length >= 3) {
  149. fileName = fileInfo[2].split("\n")[0];
  150. }
  151. }
  152. else {
  153. fileName = fileName.substr(fileName.lastIndexOf("/") + 1); //couldn't find original file name, use file name from the server instead
  154. }
  155. fileName = fileName.replace(this.regex.fileExtension, ""); //ignore file's extension
  156. }
  157. var contentElement = postHeaderElement.nextElementSibling;
  158. var activeContent = !!contentElement.querySelector("img, iframe"); //does a post contain icons
  159. var postData = { postID: postID, userID: uid, userName: name, fileName: fileName, activeContent: activeContent };
  160. if (this.useCache) {
  161. postData.textUpdate = this.regex.fraction.test(subject) || this.containsQuotes(contentElement);
  162. }
  163. else {
  164. postData.subject = subject;
  165. postData.contentElement = contentElement;
  166. }
  167. if (this.useCache || this.debug || this.debugAfterDate) {
  168. postData.date = Date.parse(labelEl.lastChild.nodeValue);
  169. }
  170. posts.set(postID, postData);
  171. }
  172. var postsArray = [...posts.values(posts)]; //convert to an array
  173. 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
  174. this.postCache = JSON.stringify(postsArray); //Removing Array.prototype.toJSON allows us to safely use JSON.stringify again \o/
  175. }
  176. return postsArray;
  177. }
  178.  
  179. getTotalFileCount(posts) {
  180. var totalFileCount = 0;
  181. posts.forEach(post => { if (post.fileName || post.activeContent) totalFileCount++; });
  182. return totalFileCount;
  183. }
  184.  
  185. isImageQuest(questFixes, ignore) {
  186. if (questFixes.imageQuest !== undefined) {
  187. return questFixes.imageQuest;
  188. }
  189. else {
  190. return (this.author.fileCount / this.author.postCount) >= 0.5;
  191. }
  192. }
  193.  
  194. getUserGraphData(posts, questFixes, authorID) {
  195. var graphData = { nodes: new Set(), strongNodes: new Set(), edges: {} };
  196. posts.forEach(post => {
  197. graphData.nodes.add(post.userID);
  198. if (post.userName) {
  199. graphData.nodes.add(post.userName);
  200. graphData.edges[`${post.userID}${post.userName}`] = { E1: post.userName, E2: post.userID };
  201. }
  202. if (post.fileName || post.activeContent) { //strong nodes are user IDs that posted files
  203. graphData.strongNodes.add(post.userID);
  204. if (post.userName) {
  205. graphData.strongNodes.add(post.userName);
  206. }
  207. if (post.fileName && post.activeContent && post.userID != authorID) { //users that made posts with both file and icons are most likely the author
  208. graphData.edges[`${authorID}${post.userID}`] = { E1: authorID, E2: post.userID, hint: "fileAndIcons" };
  209. }
  210. }
  211. });
  212. for (var missedID in questFixes.missedAuthors) { //add missing links to the author from manual fixes
  213. graphData.edges[`${authorID}${missedID}`] = { E1: authorID, E2: missedID, hint: "missedAuthors" };
  214. graphData.strongNodes.add(missedID);
  215. }
  216. graphData.edges = Object.values(graphData.edges);
  217. return graphData;
  218. }
  219.  
  220. buildUserGraph(nodes, edges, strongNodes, authorID) {
  221. var users = {};
  222. var edgesSet = new Set(edges);
  223. nodes.forEach(node => {
  224. users[node] = this.makeSet(node);
  225. });
  226. if (!strongNodes) {
  227. edgesSet.forEach(edge => this.union(users[edge.E1], users[edge.E2]));
  228. }
  229. else {
  230. edgesSet.forEach(edge => { //merge strong with strong and weak with weak
  231. if ((strongNodes.has(edge.E1) && strongNodes.has(edge.E2)) || (!strongNodes.has(edge.E1) && !strongNodes.has(edge.E2))) {
  232. this.union(users[edge.E1], users[edge.E2]);
  233. edgesSet.delete(edge);
  234. }
  235. });
  236. var author = this.find(users[authorID]);
  237. edgesSet.forEach(edge => { //merge strong with weak, but only for users which aren't the author
  238. if (this.find(users[edge.E1]) != author && this.find(users[edge.E2]) != author) {
  239. this.union(users[edge.E1], users[edge.E2]);
  240. }
  241. });
  242. }
  243. return users;
  244. }
  245.  
  246. processFilePosts(posts, users, questFixes) {
  247. var last2Files = new Map();
  248. var filePosts = posts.filter(post => post.fileName && !questFixes.wrongImageUpdates[post.postID]);
  249. filePosts.forEach(post => {
  250. var postUser = this.find(users[post.userID]);
  251. var postFileName = post.fileName.match(this.regex.lastNumber) ? post.fileName : null; //no use processing files without numbers
  252. if (post.userName && this.find(users[post.userName]) == this.author) {
  253. postUser = this.author;
  254. }
  255. if (!last2Files.has(postUser)) {
  256. last2Files.set(postUser, [ null, null ]);
  257. }
  258. last2Files.get(postUser).shift();
  259. last2Files.get(postUser).push(postFileName);
  260. last2Files.forEach((last2, user) => {
  261. if (user == postUser) {
  262. return;
  263. }
  264. if ((last2[0] !== null && this.fileNamePredicts(last2[0], post.fileName)) || (last2[1] !== null && this.fileNamePredicts(last2[1], post.fileName))) {
  265. if (this.debug || (this.debugAfterDate && this.debugAfterDate < post.date)) {
  266. console.log(`https://questden.org/kusaba/quest/res/${this.threadID}.html#${post.postID} merged (file name) ${postUser.id} with ${user.id} (author: ${this.author.id})`);
  267. }
  268. var mergedUser = this.union(user, postUser);
  269. last2Files.delete(user.parent != user ? user : postUser);
  270. last2Files.get(mergedUser).shift();
  271. last2Files.get(mergedUser).push(postFileName);
  272. if (this.find(this.author) == mergedUser) {
  273. this.author = mergedUser;
  274. }
  275. }
  276. });
  277. });
  278. return true;
  279. }
  280.  
  281. getUserPostAndFileCounts(posts, users, questFixes) {
  282. for (var userID in users) {
  283. users[userID].postCount = 0;
  284. users[userID].fileCount = 0;
  285. }
  286. posts.forEach(post => {
  287. var user = this.decidePostUser(post, users, questFixes);
  288. user.postCount++;
  289. if (post.fileName || post.activeContent) {
  290. user.fileCount++;
  291. }
  292. });
  293. }
  294.  
  295. fileNamePredicts(fileName1, fileName2) {
  296. var match1 = fileName1.match(this.regex.lastNumber);
  297. var match2 = fileName2.match(this.regex.lastNumber);
  298. if (!match1 || !match2) {
  299. return false;
  300. }
  301. var indexDifference = match2.index - match1.index;
  302. if (indexDifference > 1 || indexDifference < -1) {
  303. return false;
  304. }
  305. var numberDifference = parseInt(match2[1]) - parseInt(match1[1]);
  306. if (numberDifference !== 2 && numberDifference !== 1) {
  307. return false;
  308. }
  309. var name1 = fileName1.replace(this.regex.lastNumber, "");
  310. var name2 = fileName2.replace(this.regex.lastNumber, "");
  311. return this.stringsAreSimilar(name1, name2);
  312. }
  313.  
  314. stringsAreSimilar(string1, string2) {
  315. var lengthDiff = string1.length - string2.length;
  316. if (lengthDiff > 1 || lengthDiff < -1) {
  317. return false;
  318. }
  319. var s1 = lengthDiff > 0 ? string1 : string2;
  320. var s2 = lengthDiff > 0 ? string2 : string1;
  321. for (var i = 0, j = 0, diff = 0; i < s1.length; i++, j++) {
  322. if (s1[i] !== s2[j]) {
  323. diff++;
  324. if (diff === 2) {
  325. return false;
  326. }
  327. if (lengthDiff !== 0) {
  328. j--;
  329. }
  330. }
  331. }
  332. return true;
  333. }
  334.  
  335. mergeMajorityFilePoster(posts, users, questFixes) {
  336. if (this.author.fileCount > this.totalFiles / 2) {
  337. return;
  338. }
  339. for (var userID in users) {
  340. if (users[userID].fileCount >= this.totalFiles / 2 && users[userID] != this.author) {
  341. if (this.debug || (this.debugAfterDate && this.debugAfterDate < posts[posts.length - 1].date)) {
  342. console.log(`https://questden.org/kusaba/quest/res/${this.threadID}.html merged majority file poster ${users[userID].id} ${(100 * users[userID].fileCount / this.totalFiles).toFixed(1)}%`);
  343. }
  344. var parent = this.union(this.author, users[userID]);
  345. var child = users[userID].parent != users[userID] ? users[userID] : this.author;
  346. parent.fileCount += child.fileCount;
  347. parent.postCount += child.postCount;
  348. this.author = parent;
  349. return;
  350. }
  351. }
  352. }
  353.  
  354. mergeCommonFilePosters(posts, users, questFixes) {
  355. var merged = [];
  356. var filteredUsers = Object.values(users).filter(user => user.parent == user && user.fileCount >= 3 && user.fileCount / user.postCount > 0.5 && user != this.author);
  357. var usersSet = new Set(filteredUsers);
  358. posts.forEach(post => {
  359. if ((post.fileName || post.activeContent) && !questFixes.wrongImageUpdates[post.postID] && this.isTextPostAnUpdate(post)) {
  360. for (var user of usersSet) {
  361. if (this.find(users[post.userID]) == user) {
  362. if (this.debug || (this.debugAfterDate && this.debugAfterDate < post.date)) {
  363. console.log(`https://questden.org/kusaba/quest/res/${this.threadID}.html new common poster ${users[post.userID].id}`);
  364. }
  365. var parent = this.union(this.author, user);
  366. var child = user.parent != user ? user : this.author;
  367. parent.fileCount += child.fileCount;
  368. parent.postCount += child.postCount;
  369. this.author = parent;
  370. usersSet.delete(user);
  371. break;
  372. }
  373. }
  374. }
  375. });
  376. }
  377.  
  378. setPostUsers(posts, users, questFixes) {
  379. var postUsers = new Map();
  380. posts.forEach(post => {
  381. post.user = this.decidePostUser(post, users, questFixes);
  382. postUsers.set(post.postID, post.user);
  383. });
  384. return postUsers;
  385. }
  386.  
  387. decidePostUser(post, users, questFixes) {
  388. var user = this.find(users[post.userID]);
  389. if (post.userName) {
  390. if (questFixes.ignoreTextPosts[post.userName]) { //choose to the one that isn't the author
  391. if (user == this.author) {
  392. user = this.find(users[post.userName]);
  393. }
  394. }
  395. else if (this.find(users[post.userName]) == this.author) { //choose the one that is the author
  396. user = this.author;
  397. }
  398. }
  399. return user;
  400. }
  401.  
  402. getFinalPostTypes(posts, questFixes) {
  403. // Updates are posts made by the author and, in case of image quests, author posts that contain files or icons
  404. var postTypes = new Map();
  405. posts.forEach(post => {
  406. var postType = PostType.SUGGESTION;
  407. if (post.user == this.author) {
  408. if (post.fileName || post.activeContent) { //image post
  409. if (!questFixes.wrongImageUpdates[post.postID]) {
  410. postType = PostType.UPDATE;
  411. }
  412. else if (!questFixes.ignoreTextPosts[post.userID] && !questFixes.ignoreTextPosts[post.userName]) {
  413. postType = PostType.AUTHORCOMMENT;
  414. }
  415. }
  416. else if (!questFixes.ignoreTextPosts[post.userID] && !questFixes.ignoreTextPosts[post.userName]) { //text post
  417. if (!questFixes.wrongTextUpdates[post.postID] && (!this.imageQuest || this.isTextPostAnUpdate(post))) {
  418. postType = PostType.UPDATE;
  419. }
  420. else {
  421. postType = PostType.AUTHORCOMMENT;
  422. }
  423. }
  424. if (questFixes.missedTextUpdates[post.postID]) {
  425. postType = PostType.UPDATE;
  426. }
  427. }
  428. if (this.debugAfterDate && this.debugAfterDate < post.date) {
  429. if (postType == PostType.SUGGESTION && post.fileName) console.log(`https://questden.org/kusaba/quest/res/${this.threadID}.html#${post.postID} new non-update`);
  430. if (postType == PostType.AUTHORCOMMENT) console.log(`https://questden.org/kusaba/quest/res/${this.threadID}.html#${post.postID} new author comment`);
  431. if (postType == PostType.UPDATE && this.imageQuest && !post.fileName && !post.activeContent) console.log(`https://questden.org/kusaba/quest/res/${this.threadID}.html#${post.postID} new text update`);
  432. }
  433. postTypes.set(post.postID, postType);
  434. });
  435. return postTypes;
  436. }
  437.  
  438. getPostUsers(posts) {
  439. var postUsers = new Map();
  440. posts.forEach(post => { postUsers.set(post.postID, post.user); });
  441. return postUsers;
  442. }
  443.  
  444. isTextPostAnUpdate(post) {
  445. if (post.textUpdate === undefined) {
  446. post.textUpdate = this.regex.fraction.test(post.subject) || this.containsQuotes(post.contentElement);
  447. }
  448. return post.textUpdate;
  449. }
  450.  
  451. containsQuotes(contentElement) {
  452. //extract post's text, but ignore text inside spoilers, links, dice rolls or any sort of brackets
  453. var filteredContentText = "";
  454. contentElement.childNodes.forEach(node => {
  455. if (node.className !== "spoiler" && node.nodeName != "A" && (node.nodeName != "B" || !this.regex.diceRoll.test(node.textContent))) {
  456. filteredContentText += node.textContent;
  457. }
  458. });
  459. filteredContentText = filteredContentText.replace(this.regex.bracketedTexts, "").trim();
  460. //if the post contains dialogue, then it's likely to be an update
  461. var quotedTexts = filteredContentText.match(this.regex.quotedTexts) || [];
  462. for (let q of quotedTexts) {
  463. if (this.regex.endsWithPunctuation.test(q)) {
  464. return true;
  465. }
  466. }
  467. return false;
  468. }
  469.  
  470. makeSet(id) {
  471. var node = { id: id, children: [] };
  472. node.parent = node;
  473. return node;
  474. }
  475.  
  476. find(node) { //find with path halving
  477. while (node.parent != node) {
  478. var curr = node;
  479. node = node.parent;
  480. curr.parent = node.parent;
  481. }
  482. return node;
  483. }
  484.  
  485. union(node1, node2) {
  486. var node1root = this.find(node1);
  487. var node2root = this.find(node2);
  488. if (node1root == node2root) {
  489. return node1root;
  490. }
  491. node2root.parent = node1root;
  492. node1root.children.push(node2root); //having a list of children isn't a part of Union-Find, but it makes debugging much easier
  493. node2root.children.forEach(child => node1root.children.push(child));
  494. return node1root;
  495. }
  496.  
  497. static getRegexes() {
  498. if (!this.regex) { //cache as a static class property
  499. this.regex = {
  500. fileExtension: new RegExp("[.][^.]+$"), //finds ".png" in "image.png"
  501. lastNumber: new RegExp("([0-9]+)(?=[^0-9]*$)"), //finds "50" in "image50.png"
  502. fraction: new RegExp("[0-9][ ]*/[ ]*[0-9]"), //finds "1/4" in "Update 1/4"
  503. diceRoll: new RegExp("^rolled [0-9].* = [0-9]+$"), //finds "rolled 10, 20 = 30"
  504. quotedTexts: new RegExp("[\"“”][^\"“”]*[\"“”]","gu"), //finds text inside quotes
  505. endsWithPunctuation: new RegExp("[.,!?][ ]*[\"“”]$"), //finds if a quote ends with a punctuation
  506. bracketedTexts: new RegExp("(\\([^)]*\\))|(\\[[^\\]]*\\])|(\\{[^}]*\\})|(<[^>]*>)", "gu"), //finds text within various kinds of brackets... looks funny
  507. canonID: new RegExp("^[0-9a-f]{6}$")
  508. };
  509. }
  510. return this.regex;
  511. }
  512.  
  513. getFixes(threadID) {
  514. var fixes = UpdateAnalyzer.getAllFixes()[threadID] || {};
  515. //convert array values to lower case and then into object properties for faster access
  516. for (let prop of [ "missedAuthors", "missedTextUpdates", "wrongTextUpdates", "wrongImageUpdates", "ignoreTextPosts" ]) {
  517. if (!fixes[prop]) {
  518. fixes[prop] = { };
  519. }
  520. else if (Array.isArray(fixes[prop])) {
  521. fixes[prop] = fixes[prop].reduce((acc, el) => { if (!el.startsWith("!")) el = el.toLowerCase(); acc[el] = true; return acc; }, { });
  522. }
  523. }
  524. return fixes;
  525. }
  526.  
  527. // Manual fixes. In some cases it's simply impossible (impractical) to automatically determine which posts are updates. So we fix those rare cases manually.
  528. // list last updated on:
  529. // 2021/04/01
  530.  
  531. //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.
  532. //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.
  533. //(An empty ignoreTextPosts string matches posts with an empty/default poster name)
  534. //missedImageUpdates: Actually, no such fixes exist. All missed image update posts are added through adding author IDs to missedAuthors.
  535. //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.
  536. //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.
  537. //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.
  538. //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.
  539. //(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.)
  540. static getAllFixes() {
  541. if (!this.allFixes) {
  542. this.allFixes = { //cache as a static class property
  543. 12: { missedAuthors: [ "!g9Qfmdqho2" ] },
  544. 26: { ignoreTextPosts: [ "Coriell", "!DHEj4YTg6g" ] },
  545. 101: { wrongTextUpdates: [ "442" ] },
  546. 171: { wrongTextUpdates: [ "1402" ] },
  547. 504: { missedTextUpdates: [ "515", "597", "654", "1139", "1163", "1180", "7994", "9951" ] },
  548. 998: { ignoreTextPosts: [ "" ] },
  549. 1292: { missedAuthors: [ "Chaptermaster II" ], missedTextUpdates: [ "1311", "1315", "1318" ], ignoreTextPosts: [ "" ] },
  550. 1702: { wrongImageUpdates: [ "2829" ] },
  551. 3090: { ignoreTextPosts: [ "", "!94Ud9yTfxQ", "Glaive" ], wrongImageUpdates: [ "3511", "3574", "3588", "3591", "3603", "3612" ] },
  552. 4602: { missedTextUpdates: [ "4630", "6375" ] },
  553. 7173: { missedTextUpdates: [ "8515", "10326" ] },
  554. 8906: { missedTextUpdates: [ "9002", "9009" ] },
  555. 9190: { missedAuthors: [ "!OeZ2B20kbk" ], missedTextUpdates: [ "26073" ] },
  556. 13595: { wrongTextUpdates: [ "18058" ] },
  557. 16114: { missedTextUpdates: [ "20647" ] },
  558. 17833: { ignoreTextPosts: [ "!swQABHZA/E" ] },
  559. 19308: { missedTextUpdates: [ "19425", "19600", "19912" ] },
  560. 19622: { wrongImageUpdates: [ "30710", "30719", "30732", "30765" ] },
  561. 19932: { missedTextUpdates: [ "20038", "20094", "20173", "20252" ] },
  562. 20501: { ignoreTextPosts: [ "bd2eec" ] },
  563. 21601: { missedTextUpdates: [ "21629", "21639" ] },
  564. 21853: { missedTextUpdates: [ "21892", "21898", "21925", "22261", "22266", "22710", "23308", "23321", "23862", "23864", "23900", "24206", "25479", "25497", "25943", "26453", "26787", "26799",
  565. "26807", "26929", "27328", "27392", "27648", "27766", "27809", "29107", "29145" ] },
  566. 22208: { missedAuthors: [ "fb5d8e" ] },
  567. 24530: { wrongImageUpdates: [ "25023" ] },
  568. 25354: { imageQuest: false},
  569. 26933: { missedTextUpdates: [ "26935", "26955", "26962", "26967", "26987", "27015", "28998" ] },
  570. 29636: { missedTextUpdates: [ "29696", "29914", "30025", "30911" ], wrongImageUpdates: [ "30973", "32955", "33107" ] },
  571. 30350: { imageQuest: false, wrongTextUpdates: [ "30595", "32354", "33704" ] },
  572. 30357: { missedTextUpdates: [ "30470", "30486", "30490", "30512", "33512" ] },
  573. 33329: { wrongTextUpdates: [ "43894" ] },
  574. 37304: { ignoreTextPosts: [ "", "GREEN", "!!x2ZmLjZmyu", "Adept", "Cruxador", "!ifOCf11HXk" ] },
  575. 37954: { missedTextUpdates: [ "41649" ] },
  576. 38276: { ignoreTextPosts: [ "!ifOCf11HXk" ] },
  577. 41510: { missedTextUpdates: [ "41550", "41746" ] },
  578. 44240: { missedTextUpdates: [ "44324", "45768", "45770", "48680", "48687" ] },
  579. 45522: { missedTextUpdates: [ "55885" ] },
  580. 45986: { missedTextUpdates: [ "45994", "46019" ] },
  581. 49306: { missedTextUpdates: [ "54246" ] },
  582. 49400: { ignoreTextPosts: [ "!!IzZTIxBQH1" ] },
  583. 49937: { missedTextUpdates: [ "52386" ] },
  584. 53129: { wrongTextUpdates: [ "53505" ] },
  585. 53585: { missedAuthors: [ "b1e366", "aba0a3", "18212a", "6756f8", "f98e0b", "1c48f4", "f4963f", "45afb1", "b94893", "135d9a" ], ignoreTextPosts: [ "", "!7BHo7QtR6I", "Test Pattern", "Rowan", "Insomnia", "!!L1ZwWyZzZ5" ] },
  586. 54766: { missedAuthors: [ "e16ca8" ], ignoreTextPosts: [ "!!IzZTIxBQH1" ] },
  587. 55639: { wrongImageUpdates: [ "56711", "56345", "56379", "56637" ] },
  588. 56194: { wrongTextUpdates: [ "61608" ] },
  589. 59263: { missedTextUpdates: [ "64631" ] },
  590. 62091: { imageQuest: true},
  591. 65742: { missedTextUpdates: [ "66329", "66392", "67033", "67168" ] },
  592. 67058: { missedTextUpdates: [ "67191", "67685" ] },
  593. 68065: { missedAuthors: [ "7452df", "1d8589" ], ignoreTextPosts: [ "!!IzZTIxBQH1" ] },
  594. 70887: { missedAuthors: [ "e53955", "7c9cdd", "2084ff", "064d19", "51efff", "d3c8d2" ], ignoreTextPosts: [ "!!IzZTIxBQH1" ] },
  595. 72794: { wrongTextUpdates: [ "76740" ] },
  596. 74474: { missedAuthors: [ "309964" ] },
  597. 75425: { missedTextUpdates: [ "75450", "75463", "75464", "75472", "75490", "75505", "77245" ] },
  598. 75763: { missedAuthors: [ "068b0e" ], ignoreTextPosts: [ "!!IzZTIxBQH1" ] },
  599. 76892: { missedTextUpdates: [ "86875", "86884", "87047", "88315" ] },
  600. 79146: { missedAuthors: [ "4a3269" ] },
  601. 79654: { missedTextUpdates: [ "83463", "83529" ] },
  602. 79782: { missedTextUpdates: [ "79975", "80045" ] },
  603. 82970: { missedTextUpdates: [ "84734" ] },
  604. 83325: { missedAuthors: [ "076064" ] },
  605. 84134: { imageQuest: false},
  606. 85235: { missedTextUpdates: [ "85257", "85282", "113215", "114739", "151976", "152022", "159250" ] },
  607. 88264: { missedAuthors: [ "3fec76", "714b9c" ] },
  608. 92605: { ignoreTextPosts: [ "" ] },
  609. 94645: { missedTextUpdates: [ "97352" ] },
  610. 95242: { missedTextUpdates: [ "95263" ] },
  611. 96023: { missedTextUpdates: [ "96242" ] },
  612. 96466: { ignoreTextPosts: [ "Reverie" ] },
  613. 96481: { imageQuest: true},
  614. 97014: { missedTextUpdates: [ "97061", "97404", "97915", "98124", "98283", "98344", "98371", "98974", "98976", "98978", "99040", "99674", "99684" ] },
  615. 99095: { wrongImageUpdates: [ "111452" ] },
  616. 99132: { ignoreTextPosts: [ "" ] },
  617. 100346: { missedTextUpdates: [ "100626", "100690", "100743", "100747", "101143", "101199", "101235", "101239" ] },
  618. 101388: { ignoreTextPosts: [ "Glaive" ] },
  619. 102433: { missedTextUpdates: [ "102519", "102559", "102758" ] },
  620. 102899: { missedTextUpdates: [ "102903" ] },
  621. 103435: { missedTextUpdates: [ "104279", "105950" ] },
  622. 103850: { ignoreTextPosts: [ "" ] },
  623. 106656: { wrongTextUpdates: [ "115606" ] },
  624. 107789: { missedTextUpdates: [ "107810", "107849", "107899" ] },
  625. 108599: { wrongImageUpdates: [ "171382", "172922", "174091", "180752", "180758" ] },
  626. 108805: { wrongImageUpdates: [ "110203" ] },
  627. 109071: { missedTextUpdates: [ "109417" ] },
  628. 112133: { missedTextUpdates: [ "134867" ] },
  629. 112414: { missedTextUpdates: [ "112455" ] },
  630. 113768: { missedAuthors: [ "e9a4f7" ] },
  631. 114133: { ignoreTextPosts: [ "" ] },
  632. 115831: { missedTextUpdates: [ "115862" ] },
  633. 119431: { ignoreTextPosts: [ "" ] },
  634. 120384: { missedAuthors: [ "233aab" ] },
  635. 126204: { imageQuest: true, missedTextUpdates: [ "127069", "127089", "161046", "161060", "161563" ] },
  636. 126248: { missedTextUpdates: [ "193064" ] },
  637. 128706: { missedAuthors: [ "2e2f06", "21b50e", "e0478c", "9c87f6", "931351", "e294f1", "749d64", "f3254a" ] },
  638. 131255: { missedTextUpdates: [ "151218" ] },
  639. 137683: { missedTextUpdates: [ "137723" ] },
  640. 139086: { ignoreTextPosts: [ "!TEEDashxDA" ] },
  641. 139513: { missedTextUpdates: [ "139560" ] },
  642. 141257: { missedTextUpdates: [ "141263", "141290", "141513", "146287" ], ignoreTextPosts: [ "" ], wrongImageUpdates: [ "141265" ] },
  643. 146112: { missedAuthors: [ "//_emily" ] },
  644. 153225: { missedTextUpdates: [ "153615", "153875" ] },
  645. 155665: { missedTextUpdates: [ "155670", "155684", "155740" ] },
  646. 156257: { missedTextUpdates: [ "156956" ] },
  647. 157277: { missedAuthors: [ "23c8f1", "8bb533" ] },
  648. 161117: { missedTextUpdates: [ "167255", "168000" ] },
  649. 162089: { missedTextUpdates: [ "167940" ] },
  650. 164793: { missedAuthors: [ "e973f4" ], ignoreTextPosts: [ "!TEEDashxDA" ] },
  651. 165537: { missedAuthors: [ "a9f6ce" ] },
  652. 173621: { ignoreTextPosts: [ "" ] },
  653. 174398: { missedAuthors: [ "bf0d4e", "158c5c" ] },
  654. 176965: { missedTextUpdates: [ "177012" ] },
  655. 177281: { missedTextUpdates: [ "178846" ] },
  656. 181790: { ignoreTextPosts: [ "Mister Brush" ], wrongImageUpdates: [ "182280" ] },
  657. 183194: { ignoreTextPosts: [ "!CRITTerXzI" ], wrongImageUpdates: [ "183207" ] },
  658. 183637: { imageQuest: false, wrongTextUpdates: [ "183736" ] },
  659. 185345: { wrongTextUpdates: [ "185347" ] },
  660. 185579: { missedTextUpdates: [ "188091", "188697", "188731", "188748", "190868" ] },
  661. 186709: { missedTextUpdates: [ "186735" ] },
  662. 188253: { missedTextUpdates: [ "215980", "215984", "222136" ] },
  663. 188571: { missedTextUpdates: [ "188633" ] },
  664. 188970: { ignoreTextPosts: [ "" ] },
  665. 191328: { missedAuthors: [ "f54a9c", "862cf6", "af7d90", "4c1052", "e75bed", "09e145" ] },
  666. 191976: { missedAuthors: [ "20fc85" ] },
  667. 192879: { missedTextUpdates: [ "193009" ] },
  668. 193934: { missedTextUpdates: [ "212768" ] },
  669. 196310: { missedTextUpdates: [ "196401" ] },
  670. 196517: { missedTextUpdates: [ "196733" ] },
  671. 198458: { missedTextUpdates: [ "198505", "198601", "199570" ] },
  672. 200054: { missedAuthors: [ "a4b4e3" ] },
  673. 201427: { missedTextUpdates: [ "201467", "201844" ] },
  674. 203072: { missedTextUpdates: [ "203082", "203100", "206309", "207033", "208766" ] },
  675. 206945: { missedTextUpdates: [ "206950" ] },
  676. 207011: { ignoreTextPosts: [ "!TEEDashxDA" ] },
  677. 207296: { missedTextUpdates: [ "214551" ] },
  678. 207756: { missedTextUpdates: [ "208926" ] },
  679. 209334: { missedTextUpdates: [ "209941" ] },
  680. 210613: { missedTextUpdates: [ "215711", "220853" ] },
  681. 210928: { missedTextUpdates: [ "215900" ] },
  682. 211320: { ignoreTextPosts: [ "Kindling", "Bahu" ], wrongImageUpdates: [ "211587", "215436" ] },
  683. 212584: { missedAuthors: [ "40a8d3" ] },
  684. 212915: { missedTextUpdates: [ "229550" ] },
  685. 217193: { missedAuthors: [ "7f1ecd", "c00244", "7c97d9", "8c0848", "491db1", "c2c011", "e15f89",
  686. "e31d52", "3ce5b4", "c1f2ce", "5f0943", "1dc978", "d65652", "446ab5", "f906a7", "dad664", "231806" ] },
  687. 217269: { imageQuest: false, wrongTextUpdates: [ "217860", "219314" ] },
  688. 218385: { missedAuthors: [ "812dcf" ] },
  689. 220049: { ignoreTextPosts: [ "Slinkoboy" ], wrongImageUpdates: [ "228035", "337790" ] },
  690. 222777: { imageQuest: false},
  691. 224095: { missedTextUpdates: [ "224196", "224300", "224620", "244476" ] },
  692. 233213: { missedTextUpdates: [ "233498" ], ignoreTextPosts: [ "Bahu" ] },
  693. 234437: { missedTextUpdates: [ "234657" ] },
  694. 237125: { missedTextUpdates: [ "237192" ] },
  695. 237665: { imageQuest: true, ignoreTextPosts: [ "" ] },
  696. 238281: { ignoreTextPosts: [ "TK" ] },
  697. 238993: { missedTextUpdates: [ "239018", "239028", "239094" ] },
  698. 240824: { imageQuest: false},
  699. 241467: { missedTextUpdates: [ "241709" ] },
  700. 242200: { missedTextUpdates: [ "246465", "246473", "246513" ] },
  701. 242657: { missedAuthors: [ "2563d4" ] },
  702. 244225: { missedTextUpdates: [ "245099", "245195", "245201" ] },
  703. 244557: { missedTextUpdates: [ "244561" ], ignoreTextPosts: [ "" ] },
  704. 244830: { missedAuthors: [ "e33093" ] },
  705. 247108: { ignoreTextPosts: [ "Bahu" ], wrongImageUpdates: [ "258883", "265446" ] },
  706. 247714: { missedTextUpdates: [ "247852" ] },
  707. 248067: { ignoreTextPosts: [ "" ] },
  708. 248856: { ignoreTextPosts: [ "" ] },
  709. 248880: { imageQuest: true, ignoreTextPosts: [ "", "!qkgg.NzvRY", "!!EyA2IwLwVl", "!I10GFLsZCw", "!k6uRjGDgAQ", "Seven01a19" ] },
  710. 251909: { missedTextUpdates: [ "255400" ] },
  711. 252195: { missedTextUpdates: [ "260890" ] },
  712. 252944: { missedAuthors: [ "Rizzie" ], ignoreTextPosts: [ "", "!!EyA2IwLwVl", "Seven01a19" ] },
  713. 256339: { missedTextUpdates: [ "256359", "256379", "256404", "256440" ] },
  714. 257726: { missedAuthors: [ "917cac" ] },
  715. 258304: { missedTextUpdates: [ "269087" ] },
  716. 261572: { imageQuest: false},
  717. 261837: { missedAuthors: [ "14149d" ] },
  718. 262128: { missedTextUpdates: [ "262166", "262219", "262455", "262500" ] },
  719. 262574: { missedAuthors: [ "b7798b", "0b5a64", "687829", "446f39", "cc1ccd", "9d3d72", "72d5e4", "932db9", "4d7cb4", "9f327a", "940ab2", "a660d0" ], ignoreTextPosts: [ "" ] },
  720. 263831: { imageQuest: false, wrongTextUpdates: [ "264063", "264716", "265111", "268733", "269012", "270598", "271254", "271852", "271855", "274776", "275128", "280425", "280812", "282417", "284354", "291231", "300074", "305150" ] },
  721. 265656: { ignoreTextPosts: [ "Glaive17" ] },
  722. 266542: { missedAuthors: [ "MidKnight", "c2c011", "f5e4b4", "e973f4", "6547ec" ], ignoreTextPosts: [ "", "!TEEDashxDA", "Not Cirr", "Ñ" ] },
  723. 267348: { ignoreTextPosts: [ "" ] },
  724. 269735: { ignoreTextPosts: [ "---" ] },
  725. 270556: { ignoreTextPosts: [ "Bahu" ], wrongImageUpdates: [ "276022" ] },
  726. 273047: { missedAuthors: [ "db463d", "16f0be", "77df62", "b6733e", "d171a3", "3a95e1", "21d450" ] },
  727. 274088: { missedAuthors: [ "4b0cf3" ], missedTextUpdates: [ "294418" ], ignoreTextPosts: [ "" ] },
  728. 274466: { missedAuthors: [ "c9efe3" ] },
  729. 276562: { missedTextUpdates: [ "277108" ] },
  730. 277371: { ignoreTextPosts: [ "!TEEDashxDA" ] },
  731. 278168: { ignoreTextPosts: [ "!TEEDashxDA" ] },
  732. 280381: { ignoreTextPosts: [ "!7BHo7QtR6I" ] },
  733. 280985: { ignoreTextPosts: [ "!TEEDashxDA" ] },
  734. 283246: { imageQuest: false},
  735. 285210: { ignoreTextPosts: [ "", "Weaver" ] },
  736. 287296: { ignoreTextPosts: [ "", "Asplosionz" ] },
  737. 287815: { missedAuthors: [ "Ñ" ] },
  738. 288346: { missedAuthors: [ "383006", "bf1e7e" ], ignoreTextPosts: [ "383006", "bf1e7e" ] },
  739. 289254: { imageQuest: false},
  740. 292033: { wrongTextUpdates: [ "295088" ] },
  741. 293532: { ignoreTextPosts: [ "" ] },
  742. 294351: { ignoreTextPosts: [ "Weaver" ] },
  743. 295374: { ignoreTextPosts: [ "TK" ] },
  744. 295832: { missedAuthors: [ "ac22cd", "7afbc4", "6f11ff" ], missedTextUpdates: [ "313940" ] },
  745. 295949: { missedTextUpdates: [ "296256", "297926", "298549" ] },
  746. 298133: { missedTextUpdates: [ "298187" ] },
  747. 298860: { imageQuest: true, missedTextUpdates: [ "298871", "298877", "298880", "298908" ] },
  748. 299352: { imageQuest: true, missedTextUpdates: [ "299375", "299627", "303689" ] },
  749. 300694: { ignoreTextPosts: [ "TK" ] },
  750. 300751: { missedTextUpdates: [ "316287" ] },
  751. 303859: { ignoreTextPosts: [ "" ] },
  752. 308257: { missedTextUpdates: [ "314653" ] },
  753. 309753: { missedTextUpdates: [ "309864", "309963", "310292", "310944", "310987", "311202", "311219", "311548" ] },
  754. 310586: { missedTextUpdates: [ "310945", "312747", "313144" ] },
  755. 311021: { missedAuthors: [ "049dfa", "f2a6f9" ] },
  756. 312418: { missedTextUpdates: [ "312786", "312790", "312792", "312984", "313185" ] },
  757. 314825: { ignoreTextPosts: [ "TK" ] },
  758. 314940: { missedTextUpdates: [ "314986", "315198", "329923" ] },
  759. 318478: { ignoreTextPosts: [ "Toxoglossa" ] },
  760. 319491: { ignoreTextPosts: [ "Bahu" ] },
  761. 323481: { missedTextUpdates: [ "323843", "324125", "324574" ] },
  762. 323589: { missedTextUpdates: [ "329499" ] },
  763. 327468: { missedTextUpdates: [ "327480", "337008" ] },
  764. 337661: { ignoreTextPosts: [ "", "hisgooddog" ] },
  765. 338579: { ignoreTextPosts: [ "", "Zealo8", "Ñ" ] },
  766. 343078: { wrongImageUpdates: [ "343219" ] },
  767. 343668: { missedTextUpdates: [ "343671" ] },
  768. 348635: { ignoreTextPosts: [ "" ] },
  769. 351064: { missedTextUpdates: [ "351634", "353263", "355326", "356289" ] },
  770. 351264: { missedTextUpdates: [ "353077" ] },
  771. 354201: { imageQuest: true, missedTextUpdates: [ "354340" ] },
  772. 355404: { ignoreTextPosts: [ "Bahu" ] },
  773. 356715: { missedTextUpdates: [ "356722" ] },
  774. 357723: { missedAuthors: [ "7bad01" ], ignoreTextPosts: [ "", "SoqWizard" ] },
  775. 359879: { imageQuest: false},
  776. 359931: { missedAuthors: [ "Dasaki", "Rynh", "Kinasa", "178c80" ], ignoreTextPosts: [ "", "Gnoll", "Lost Planet", "Dasaki", "Slinkoboy" ] },
  777. 360617: { missedAuthors: [ "7a7217" ] },
  778. 363529: { imageQuest: true, ignoreTextPosts: [ "Tenyoken" ] },
  779. 365082: { missedTextUpdates: [ "381411", "382388" ] },
  780. 366944: { missedTextUpdates: [ "367897" ] },
  781. 367145: { wrongTextUpdates: [ "367887" ] },
  782. 367824: { missedTextUpdates: [ "367841", "367858", "367948" ] },
  783. 375293: { ignoreTextPosts: [ "Bahu" ] },
  784. 382864: { ignoreTextPosts: [ "FlynnMerk" ] },
  785. 387602: { ignoreTextPosts: [ "!a1..dIzWW2" ], wrongImageUpdates: [ "390207", "392018", "394748" ] },
  786. 388264: { ignoreTextPosts: [ "" ] },
  787. 392034: { missedAuthors: [ "046f13" ] },
  788. 392868: { missedAuthors: [ "e1359e" ] },
  789. 393082: { ignoreTextPosts: [ "" ] },
  790. 395700: { missedTextUpdates: [ "395701", "395758" ] },
  791. 395817: { ignoreTextPosts: [ "" ] },
  792. 397819: { ignoreTextPosts: [ "Bahu", "K-Dogg" ], wrongImageUpdates: [ "398064" ] },
  793. 400842: { missedAuthors: [ "b0d466" ], ignoreTextPosts: [ "", "!a1..dIzWW2" ], wrongImageUpdates: [ "412172", "412197" ] },
  794. 403418: { missedAuthors: [ "02cbc6" ] },
  795. 404177: { missedTextUpdates: [ "404633" ] },
  796. 409356: { missedTextUpdates: [ "480664", "485493" ], wrongTextUpdates: [ "492824" ] },
  797. 410618: { ignoreTextPosts: [ "kathryn" ], wrongImageUpdates: [ "417836" ] },
  798. 412463: { ignoreTextPosts: [ "" ] },
  799. 413494: { ignoreTextPosts: [ "Bahu" ] },
  800. 420600: { imageQuest: false},
  801. 421477: { imageQuest: false},
  802. 422052: { missedAuthors: [ "!a1..dIzWW2" ] },
  803. 422087: { ignoreTextPosts: [ "Caz" ] },
  804. 422856: { ignoreTextPosts: [ "", "???" ] },
  805. 424198: { missedAuthors: [ "067a04" ], ignoreTextPosts: [ "!a1..dIzWW2" ] },
  806. 425677: { missedTextUpdates: [ "425893", "426741", "431953" ] },
  807. 426019: { ignoreTextPosts: [ "Taskuhecate" ] },
  808. 427135: { ignoreTextPosts: [ "!7BHo7QtR6I" ] },
  809. 427676: { ignoreTextPosts: [ "FRACTAL" ] },
  810. 428027: { ignoreTextPosts: [ "notrottel", "Bahu", "!a1..dIzWW2", "Trout", "Larro", "", "cuoqet" ], wrongImageUpdates: [ "428285", "498295" ] },
  811. 430036: { missedTextUpdates: [ "430062", "430182", "430416" ], ignoreTextPosts: [ "" ] },
  812. 431445: { imageQuest: false, missedAuthors: [ "efbb86" ] },
  813. 435947: { missedTextUpdates: [ "436059" ] },
  814. 437675: { wrongTextUpdates: [ "445770", "449255", "480401" ] },
  815. 437768: { missedTextUpdates: [ "446536" ] },
  816. 438515: { ignoreTextPosts: [ "TK" ] },
  817. 438670: { ignoreTextPosts: [ "" ] },
  818. 441226: { missedAuthors: [ "6a1ec2", "99090a", "7f2d33" ], wrongImageUpdates: [ "441260" ] },
  819. 441745: { missedTextUpdates: [ "443831" ] },
  820. 447830: { imageQuest: false, missedAuthors: [ "fc985a", "f8b208" ], wrongTextUpdates: [ "448476", "450379", "452161" ] },
  821. 448900: { missedAuthors: [ "0c2256" ] },
  822. 449505: { wrongTextUpdates: [ "450499" ] },
  823. 450563: { missedAuthors: [ "!!AwZwHkBGWx", "Oregano" ], ignoreTextPosts: [ "", "chirps", "!!AwZwHkBGWx", "!!AwZwHkBGWx", "Ham" ] },
  824. 452871: { missedAuthors: [ "General Q. Waterbuffalo", "!cZFAmericA" ], missedTextUpdates: [ "456083" ] },
  825. 453480: { ignoreTextPosts: [ "TK" ], wrongImageUpdates: [ "474233" ] },
  826. 453978: { missedTextUpdates: [ "453986" ] },
  827. 454256: { missedTextUpdates: [ "474914", "474957" ] },
  828. 456185: { ignoreTextPosts: [ "TK" ], wrongTextUpdates: [ "472446" ], wrongImageUpdates: [ "592622" ] },
  829. 456798: { missedTextUpdates: [ "516303" ] },
  830. 458432: { missedAuthors: [ "259cce", "34cbef" ] },
  831. 463595: { missedTextUpdates: [ "463711", "465024", "465212", "465633", "467107", "467286" ], wrongTextUpdates: [ "463623" ] },
  832. 464038: { missedAuthors: [ "df885d", "8474cd" ] },
  833. 465919: { missedTextUpdates: [ "465921" ] },
  834. 469321: { missedTextUpdates: [ "469332" ] },
  835. 471304: { missedAuthors: [ "1766db" ] },
  836. 471394: { missedAuthors: [ "Cirr" ] },
  837. 476554: { ignoreTextPosts: [ "Fish is yum" ] },
  838. 478624: { missedAuthors: [ "88c9b2" ] },
  839. 479712: { ignoreTextPosts: [ "" ] },
  840. 481277: { missedTextUpdates: [ "481301", "482210" ], ignoreTextPosts: [ "Santova" ] },
  841. 481491: { missedTextUpdates: [ "481543", "481575", "484069" ], ignoreTextPosts: [ "Zach Leigh", "Santova", "Outaki Shiba" ] },
  842. 482391: { missedTextUpdates: [ "482501", "482838" ] },
  843. 482629: { missedTextUpdates: [ "484220", "484437" ], ignoreTextPosts: [ "Santova", "Tera Nospis" ] },
  844. 483108: { missedAuthors: [ "2de44c" ], missedTextUpdates: [ "483418", "483658" ], ignoreTextPosts: [ "Santova" ] },
  845. 484423: { missedTextUpdates: [ "484470", "486761", "488602" ], ignoreTextPosts: [ "Tera Nospis", "Zach Leigh" ] },
  846. 484606: { missedTextUpdates: [ "486773" ], ignoreTextPosts: [ "Zach Leigh" ] },
  847. 485964: { missedTextUpdates: [ "489145", "489760" ], ignoreTextPosts: [ "Tera Nospis", "Santova" ] },
  848. 489488: { missedTextUpdates: [ "490389" ] },
  849. 489694: { missedAuthors: [ "2c8bbe", "30a140", "8c4b01", "8fbeb2", "2b7d97", "17675d", "782175", "665fcd", "e91794", "52019c", "8ef0aa", "e493a6", "c847bc" ] },
  850. 489830: { missedAuthors: [ "9ee824", "8817a0", "d81bd3", "704658" ] },
  851. 490689: { ignoreTextPosts: [ "Santova" ] },
  852. 491171: { ignoreTextPosts: [ "Santova", "Zach Leigh", "Zack Leigh", "The Creator" ] },
  853. 491314: { missedTextUpdates: [ "491498" ], ignoreTextPosts: [ "" ] },
  854. 492511: { missedAuthors: [ "???" ] },
  855. 493099: { ignoreTextPosts: [ "Zach Leigh", "Santova" ] },
  856. 494015: { ignoreTextPosts: [ "Coda", "drgruff" ] },
  857. 496561: { ignoreTextPosts: [ "Santova", "DJ LaLonde", "Tera Nospis" ] },
  858. 498874: { ignoreTextPosts: [ "Santova" ] },
  859. 499607: { ignoreTextPosts: [ "Santova", "Tera Nospis" ] },
  860. 499980: { ignoreTextPosts: [ "Santova", "Tera Nospis", "DJ LaLonde" ] },
  861. 500015: { missedTextUpdates: [ "500020", "500029", "500274", "501462", "501464", "501809", "505421" ], ignoreTextPosts: [ "suggestion", "Chelz" ] },
  862. 502751: { ignoreTextPosts: [ "suggestion" ] },
  863. 503053: { missedAuthors: [ "!!WzMJSzZzWx", "Shopkeep", "CAI" ] },
  864. 505072: { missedTextUpdates: [ "565461" ] },
  865. 505569: { ignoreTextPosts: [ "!TEEDashxDA" ] },
  866. 505633: { missedTextUpdates: [ "505694", "529582" ] },
  867. 505796: { ignoreTextPosts: [ "Mister-Saturn" ] },
  868. 506555: { ignoreTextPosts: [ "Tera Nospis", "Santova" ] },
  869. 507761: { ignoreTextPosts: [ "", "Rue" ] },
  870. 508294: { missedAuthors: [ "Lisila" ], missedTextUpdates: [ "508618", "508406" ] },
  871. 509510: { missedTextUpdates: [ "509810", "510805", "510812", "510943", "511042", "512430", "514731", "515963" ] },
  872. 510067: { missedTextUpdates: [ "510081" ] },
  873. 511816: { imageQuest: true, missedAuthors: [ "34cf7d" ], missedTextUpdates: [ "512608" ] },
  874. 512417: { ignoreTextPosts: [ "Uplifted" ] },
  875. 512501: { ignoreTextPosts: [ "" ] },
  876. 512569: { wrongImageUpdates: [ "512810" ] },
  877. 513727: { missedTextUpdates: [ "519251" ], ignoreTextPosts: [ "!mYSM8eo.ng" ] },
  878. 514174: { missedTextUpdates: [ "747164" ] },
  879. 515255: { ignoreTextPosts: [ "" ] },
  880. 516595: { imageQuest: true},
  881. 517144: { ignoreTextPosts: [ "" ] },
  882. 518737: { wrongTextUpdates: [ "521408", "522150", "522185", "522231", "535521" ] },
  883. 518843: { ignoreTextPosts: [ "" ] },
  884. 519463: { imageQuest: false},
  885. 521196: { missedTextUpdates: [ "524608" ] },
  886. 526472: { missedTextUpdates: [ "526524", "559848" ] },
  887. 527296: { ignoreTextPosts: [ "Zealo8" ] },
  888. 527546: { ignoreTextPosts: [ "suggestion" ] },
  889. 527753: { missedAuthors: [ "7672c3", "9d78a6", "cb43c1" ] },
  890. 528891: { ignoreTextPosts: [ "drgruff" ] },
  891. 530940: { missedAuthors: [ "2027bb", "feafa5", "0a3b00" ] },
  892. 533990: { missedTextUpdates: [ "537577" ] },
  893. 534197: { ignoreTextPosts: [ "Stella" ] },
  894. 535302: { ignoreTextPosts: [ "mermaid" ] },
  895. 535783: { ignoreTextPosts: [ "drgruff" ] },
  896. 536268: { missedTextUpdates: [ "536296", "538173" ], ignoreTextPosts: [ "Archivemod" ], wrongImageUpdates: [ "537996" ] },
  897. 537343: { missedTextUpdates: [ "539218" ] },
  898. 537647: { missedTextUpdates: [ "537683" ] },
  899. 537867: { missedAuthors: [ "369097" ] },
  900. 539831: { ignoreTextPosts: [ "" ] },
  901. 540147: { ignoreTextPosts: [ "drgruff" ] },
  902. 541026: { imageQuest: false},
  903. 543428: { missedTextUpdates: [ "545458" ] },
  904. 545071: { missedTextUpdates: [ "545081" ] },
  905. 545791: { ignoreTextPosts: [ "" ] },
  906. 545842: { missedTextUpdates: [ "550972" ] },
  907. 548052: { missedTextUpdates: [ "548172" ], ignoreTextPosts: [ "Lucid" ] },
  908. 548899: { missedTextUpdates: [ "548968", "549003" ] },
  909. 549394: { missedTextUpdates: [ "549403" ] },
  910. 553434: { missedTextUpdates: [ "553610", "553635", "553668", "554166" ] },
  911. 553711: { missedTextUpdates: [ "553722", "553728", "554190" ] },
  912. 553760: { missedTextUpdates: [ "554994", "555829", "556570", "556792", "556803", "556804" ] },
  913. 554694: { missedTextUpdates: [ "557011", "560544" ] },
  914. 556435: { missedAuthors: [ "Azathoth" ], missedTextUpdates: [ "607163" ], wrongTextUpdates: [ "561150" ] },
  915. 557051: { missedTextUpdates: [ "557246", "557260", "557599", "559586" ], wrongTextUpdates: [ "557517" ] },
  916. 557633: { imageQuest: true},
  917. 557854: { missedTextUpdates: [ "557910", "557915", "557972", "558082", "558447", "558501", "561834", "561836", "562289", "632102", "632481", "632509", "632471" ] },
  918. 562193: { ignoreTextPosts: [ "" ] },
  919. 563459: { missedTextUpdates: [ "563582" ] },
  920. 564852: { ignoreTextPosts: [ "Trout" ] },
  921. 564860: { missedTextUpdates: [ "565391" ] },
  922. 565909: { ignoreTextPosts: [ "" ] },
  923. 567119: { missedTextUpdates: [ "573494", "586375" ] },
  924. 567138: { missedAuthors: [ "4cf1b6" ] },
  925. 568248: { missedTextUpdates: [ "569818" ] },
  926. 568370: { ignoreTextPosts: [ "" ] },
  927. 568463: { missedTextUpdates: [ "568470", "568473" ] },
  928. 569225: { missedTextUpdates: [ "569289" ] },
  929. 573815: { wrongTextUpdates: [ "575792" ] },
  930. 578213: { missedTextUpdates: [ "578575" ] },
  931. 581741: { missedTextUpdates: [ "581746" ] },
  932. 582268: { missedTextUpdates: [ "587221" ] },
  933. 585201: { ignoreTextPosts: [ "", "Bahustard", "Siphon" ] },
  934. 586024: { ignoreTextPosts: [ "" ] },
  935. 587086: { missedTextUpdates: [ "587245", "587284", "587443", "587454" ] },
  936. 587562: { ignoreTextPosts: [ "Zealo8" ] },
  937. 588902: { missedTextUpdates: [ "589033" ] },
  938. 589725: { imageQuest: false},
  939. 590502: { ignoreTextPosts: [ "" ], wrongTextUpdates: [ "590506" ] },
  940. 590761: { missedTextUpdates: [ "590799" ], ignoreTextPosts: [ "" ] },
  941. 591527: { missedTextUpdates: [ "591547", "591845" ] },
  942. 592273: { imageQuest: false},
  943. 592625: { wrongTextUpdates: [ "730228" ] },
  944. 593047: { missedTextUpdates: [ "593065", "593067", "593068" ] },
  945. 593899: { ignoreTextPosts: [ "mermaid" ] },
  946. 595081: { ignoreTextPosts: [ "", "VoidWitchery" ] },
  947. 595265: { imageQuest: false, wrongTextUpdates: [ "596676", "596717", "621360", "621452", "621466", "621469", "621503" ] },
  948. 596262: { missedTextUpdates: [ "596291", "596611", "597910", "598043", "598145", "600718", "603311" ] },
  949. 596345: { ignoreTextPosts: [ "mermaid" ] },
  950. 596539: { missedTextUpdates: [ "596960", "596972", "596998", "597414", "614375", "614379", "614407", "616640", "668835", "668844", "668906", "668907", "668937", "668941", "669049", "669050",
  951. "669126", "671651" ], ignoreTextPosts: [ "pugbutt" ] },
  952. 598767: { ignoreTextPosts: [ "FRACTAL" ] },
  953. 602894: { ignoreTextPosts: [ "" ] },
  954. 604604: { missedTextUpdates: [ "605127", "606702" ] },
  955. 609653: { missedTextUpdates: [ "610108", "610137" ] },
  956. 611369: { wrongImageUpdates: [ "620890" ] },
  957. 611997: { missedTextUpdates: [ "612102", "612109" ], wrongTextUpdates: [ "617447" ] },
  958. 613977: { missedTextUpdates: [ "614036" ] },
  959. 615246: { missedTextUpdates: [ "638243", "638245", "638246", "638248" ] },
  960. 615752: { ignoreTextPosts: [ "Uplifted" ] },
  961. 617061: { ignoreTextPosts: [ "!TEEDashxDA" ] },
  962. 617484: { missedTextUpdates: [ "617509", "617830" ] },
  963. 618712: { missedTextUpdates: [ "619097", "619821", "620260" ] },
  964. 620830: { missedAuthors: [ "913f0d" ], ignoreTextPosts: [ "", "Sky-jaws" ] },
  965. 623611: { ignoreTextPosts: [ "!5tTWT1eydY" ] },
  966. 623897: { wrongTextUpdates: [ "625412" ] },
  967. 625364: { missedTextUpdates: [ "635199" ] },
  968. 625814: { missedAuthors: [ "330ce5", "f79974", "53688c", "a19cd5", "defceb" ], missedTextUpdates: [ "625990" ], ignoreTextPosts: [ "" ] },
  969. 627139: { ignoreTextPosts: [ "", "Seal" ] },
  970. 628023: { missedTextUpdates: [ "628323", "629276", "629668" ] },
  971. 628357: { ignoreTextPosts: [ "" ] },
  972. 632345: { ignoreTextPosts: [ "!TEEDashxDA" ] },
  973. 632823: { missedTextUpdates: [ "632860", "633225", "633632", "633649", "633723", "634118" ], ignoreTextPosts: [ "" ] },
  974. 633187: { missedTextUpdates: [ "633407", "633444", "634031", "634192", "634462" ] },
  975. 633487: { missedAuthors: [ "8b8b34", "fe7a48", "20ca72", "668d91" ] },
  976. 634122: { ignoreTextPosts: [ "Apollo" ] },
  977. 639549: { ignoreTextPosts: [ "Apollo" ] },
  978. 641286: { missedTextUpdates: [ "641650" ] },
  979. 642667: { missedTextUpdates: [ "643113" ] },
  980. 642726: { missedTextUpdates: [ "648209", "651723" ] },
  981. 643327: { ignoreTextPosts: [ "" ] },
  982. 644179: { missedTextUpdates: [ "647317" ] },
  983. 645426: { missedTextUpdates: [ "651214", "670665", "671751", "672911", "674718", "684082" ] },
  984. 648109: { missedTextUpdates: [ "711809", "711811" ] },
  985. 648646: { missedTextUpdates: [ "648681" ] },
  986. 651220: { missedTextUpdates: [ "653791" ] },
  987. 651382: { missedAuthors: [ "bbfc3d" ] },
  988. 651540: { missedTextUpdates: [ "651629" ] },
  989. 655158: { ignoreTextPosts: [ "" ] },
  990. 662096: { ignoreTextPosts: [ "" ] },
  991. 662196: { missedAuthors: [ "Penelope" ], ignoreTextPosts: [ "", "Brom", "Wire" ] },
  992. 662452: { ignoreTextPosts: [ "" ] },
  993. 662661: { ignoreTextPosts: [ "" ] },
  994. 663088: { missedAuthors: [ "f68a09", "8177e7" ], ignoreTextPosts: [ "", "!5tTWT1eydY", "Wire", "Brom", "Apollo", "Arhra" ] },
  995. 663996: { missedTextUpdates: [ "673890" ] },
  996. 668009: { missedTextUpdates: [ "668227" ] },
  997. 668216: { imageQuest: false},
  998. 669206: { imageQuest: true, missedAuthors: [ "75347e" ] },
  999. 672060: { missedTextUpdates: [ "673216" ] },
  1000. 673444: { ignoreTextPosts: [ "" ] },
  1001. 673575: { missedAuthors: [ "a6f913", "3bc92d" ], ignoreTextPosts: [ "!5tTWT1eydY" ] },
  1002. 673811: { missedTextUpdates: [ "682275", "687221", "687395", "688995" ], ignoreTextPosts: [ "" ] },
  1003. 677271: { missedTextUpdates: [ "677384" ] },
  1004. 678114: { imageQuest: false},
  1005. 678608: { missedTextUpdates: [ "678789" ] },
  1006. 679357: { missedTextUpdates: [ "679359", "679983" ] },
  1007. 680125: { ignoreTextPosts: [ "", "BritishHat" ] },
  1008. 680206: { missedAuthors: [ "Gnuk" ] },
  1009. 681620: { missedAuthors: [ "d9faec" ] },
  1010. 683261: { missedAuthors: [ "3/8 MPP, 4/4 MF" ] },
  1011. 686590: { imageQuest: false},
  1012. 688371: { missedTextUpdates: [ "696249", "696257" ], ignoreTextPosts: [ "", "Chaos", "Ariadne", "Melinoe", "\"F\"ingGenius" ] },
  1013. 691136: { missedTextUpdates: [ "697620" ], ignoreTextPosts: [ "" ], wrongImageUpdates: [ "706696" ] },
  1014. 691255: { ignoreTextPosts: [ "" ] },
  1015. 692093: { missedAuthors: [ "Bergeek" ], ignoreTextPosts: [ "Boxdog" ] },
  1016. 692872: { missedTextUpdates: [ "717187" ] },
  1017. 693509: { missedAuthors: [ "640f86" ] },
  1018. 693648: { missedTextUpdates: [ "694655" ] },
  1019. 694230: { ignoreTextPosts: [ "" ] },
  1020. 700573: { missedTextUpdates: [ "702352", "720330" ], ignoreTextPosts: [ "" ] },
  1021. 701456: { ignoreTextPosts: [ "" ] },
  1022. 702865: { ignoreTextPosts: [ "" ] },
  1023. 705639: { wrongTextUpdates: [ "794696" ] },
  1024. 706303: { missedAuthors: [ "5a8006" ] },
  1025. 706439: { missedTextUpdates: [ "714791" ] },
  1026. 706938: { ignoreTextPosts: [ "" ] },
  1027. 711320: { missedTextUpdates: [ "720646", "724022" ] },
  1028. 712179: { missedTextUpdates: [ "712255", "715182" ] },
  1029. 712785: { ignoreTextPosts: [ "" ] },
  1030. 713042: { missedTextUpdates: [ "713704" ] },
  1031. 714130: { imageQuest: true},
  1032. 714290: { missedTextUpdates: [ "714307", "714311" ] },
  1033. 714858: { ignoreTextPosts: [ "" ] },
  1034. 715796: { ignoreTextPosts: [ "" ] },
  1035. 717114: { missedTextUpdates: [ "717454", "717628" ] },
  1036. 718797: { missedAuthors: [ "FRACTAL on the go" ] },
  1037. 718844: { missedAuthors: [ "kome", "Vik", "Friptag" ], missedTextUpdates: [ "721242" ] },
  1038. 719505: { ignoreTextPosts: [ "" ] },
  1039. 719579: { imageQuest: false},
  1040. 722585: { wrongTextUpdates: [ "724938" ] },
  1041. 726944: { ignoreTextPosts: [ "" ] },
  1042. 727356: { ignoreTextPosts: [ "" ] },
  1043. 727581: { missedTextUpdates: [ "728169" ] },
  1044. 727677: { ignoreTextPosts: [ "Melinoe" ] },
  1045. 728411: { missedTextUpdates: [ "728928" ] },
  1046. 730993: { missedTextUpdates: [ "731061" ] },
  1047. 732214: { imageQuest: true, wrongTextUpdates: [ "732277" ] },
  1048. 734610: { ignoreTextPosts: [ "D3w" ] },
  1049. 736484: { ignoreTextPosts: [ "Roman" ], wrongImageUpdates: [ "750212", "750213", "750214" ] },
  1050. 741609: { missedTextUpdates: [ "754524" ] },
  1051. 743976: { ignoreTextPosts: [ "", "Typo" ] },
  1052. 745694: { ignoreTextPosts: [ "Crunchysaurus" ] },
  1053. 750281: { ignoreTextPosts: [ "Autozero" ] },
  1054. 752572: { missedTextUpdates: [ "752651", "752802", "767190" ] },
  1055. 754415: { missedAuthors: [ "Apollo", "riotmode", "!0iuTMXQYY." ], ignoreTextPosts: [ "", "!5tTWT1eydY", "!0iuTMXQYY.", "Indonesian Gentleman" ] },
  1056. 755378: { missedAuthors: [ "!Ykw7p6s1S." ] },
  1057. 758668: { ignoreTextPosts: [ "LD" ] },
  1058. 767346: { ignoreTextPosts: [ "" ] },
  1059. 768858: { ignoreTextPosts: [ "LD" ] },
  1060. 774368: { missedTextUpdates: [ "774500" ] },
  1061. 774930: { missedTextUpdates: [ "794040" ] },
  1062. 778045: { missedTextUpdates: [ "778427", "779363" ] },
  1063. 779564: { ignoreTextPosts: [ "" ] },
  1064. 784068: { wrongTextUpdates: [ "785618" ] },
  1065. 785044: { wrongTextUpdates: [ "801329" ] },
  1066. 789976: { missedTextUpdates: [ "790596", "793934", "800875", "832472" ] },
  1067. 794320: { wrongTextUpdates: [ "795183" ] },
  1068. 798380: { missedTextUpdates: [ "799784", "800444", "800774", "800817", "801212" ] },
  1069. 799546: { missedTextUpdates: [ "801103", "802351", "802753" ] },
  1070. 799612: { missedTextUpdates: [ "799968", "801579" ] },
  1071. 800605: { missedAuthors: [ "Boris Calija", "3373e2", "2016eb", "a80028" ], ignoreTextPosts: [ "", "Boris Calija" ] },
  1072. 802411: { missedTextUpdates: [ "805002" ] },
  1073. 807972: { wrongTextUpdates: [ "811969" ] },
  1074. 809039: { wrongImageUpdates: [ "817508", "817511" ] },
  1075. 811957: { ignoreTextPosts: [ "via Discord" ] },
  1076. 814448: { missedTextUpdates: [ "817938" ] },
  1077. 817541: { missedAuthors: [ "Raptie" ] },
  1078. 822552: { imageQuest: false},
  1079. 823831: { missedAuthors: [ "Retro-LOPIS" ] },
  1080. 827264: { ignoreTextPosts: [ "LD", "DogFace" ] },
  1081. 830006: { missedAuthors: [ "Amaranth" ] },
  1082. 835062: { ignoreTextPosts: [ "Curves" ] },
  1083. 835750: { missedTextUpdates: [ "836870" ] },
  1084. 836521: { wrongTextUpdates: [ "848748" ] },
  1085. 837514: { ignoreTextPosts: [ "LD" ] },
  1086. 839906: { missedTextUpdates: [ "845724" ] },
  1087. 840029: { missedTextUpdates: [ "840044", "840543" ] },
  1088. 841851: { ignoreTextPosts: [ "Serpens", "Joy" ] },
  1089. 842392: { missedTextUpdates: [ "842434", "842504", "842544" ] },
  1090. 844537: { missedTextUpdates: [ "847326" ] },
  1091. 845168: { ignoreTextPosts: [ "a48264" ] },
  1092. 848887: { imageQuest: true, wrongTextUpdates: [ "851878" ] },
  1093. 854088: { missedTextUpdates: [ "860219" ], ignoreTextPosts: [ "Ursula" ] },
  1094. 854203: { ignoreTextPosts: [ "Zenthis" ] },
  1095. 857294: { wrongTextUpdates: [ "857818" ] },
  1096. 858913: { imageQuest: false},
  1097. 863241: { missedTextUpdates: [ "863519" ] },
  1098. 865754: { missedTextUpdates: [ "875371" ], ignoreTextPosts: [ "???" ] },
  1099. 869242: { ignoreTextPosts: [ "" ] },
  1100. 871667: { missedTextUpdates: [ "884575" ] },
  1101. 876808: { imageQuest: false},
  1102. 879456: { missedTextUpdates: [ "881847" ] },
  1103. 881097: { missedTextUpdates: [ "881292", "882339" ] },
  1104. 881374: { ignoreTextPosts: [ "LD" ] },
  1105. 885481: { imageQuest: false, wrongTextUpdates: [ "886892" ] },
  1106. 890023: { missedAuthors: [ "595acb" ] },
  1107. 892578: { ignoreTextPosts: [ "" ] },
  1108. 897318: { missedTextUpdates: [ "897321", "897624" ] },
  1109. 897846: { missedTextUpdates: [ "897854", "897866" ] },
  1110. 898917: { missedAuthors: [ "Cee (mobile)" ] },
  1111. 900852: { missedTextUpdates: [ "900864" ] },
  1112. 904316: { missedTextUpdates: [ "904356", "904491" ] },
  1113. 907309: { missedTextUpdates: [ "907310" ] },
  1114. 913803: { ignoreTextPosts: [ "Typo" ] },
  1115. 915945: { missedTextUpdates: [ "916021" ] },
  1116. 917513: { missedTextUpdates: [ "917515" ] },
  1117. 918806: { missedTextUpdates: [ "935207" ] },
  1118. 921083: { ignoreTextPosts: [ "LawyerDog" ] },
  1119. 923174: { ignoreTextPosts: [ "Marn", "MarnMobile" ] },
  1120. 924317: { ignoreTextPosts: [ "" ] },
  1121. 924496: { ignoreTextPosts: [ "Alyssa" ], wrongImageUpdates: [ "926049", "951786" ] },
  1122. 926927: { missedTextUpdates: [ "928194" ] },
  1123. 929115: { wrongTextUpdates: [ "959349" ] },
  1124. 929545: { missedTextUpdates: [ "929634" ] },
  1125. 930854: { missedTextUpdates: [ "932282" ] },
  1126. 934026: { missedTextUpdates: [ "934078", "934817" ] },
  1127. 935464: { missedTextUpdates: [ "935544", "935550", "935552", "935880" ] },
  1128. 934706: { missedAuthors: [ "Karma" ], wrongTextUpdates: [ "934721", "934985", "935521" ] },
  1129. 939572: { missedTextUpdates: [ "940402" ] },
  1130. 940835: { missedTextUpdates: [ "941005", "941067", "941137", "941226", "942383", "944236", "945435" ] },
  1131. 944938: { missedTextUpdates: [ "945119" ] },
  1132. 947959: { missedAuthors: [ "5a5548", "Arhra", "Generator" ], ignoreTextPosts: [ "", "5a5548", "Arhra", "Lennoxicon" ] },
  1133. 949128: { ignoreTextPosts: [ "Breven" ] },
  1134. 950800: { missedTextUpdates: [ "955309", "955582", "956789" ] },
  1135. 951319: { missedTextUpdates: [ "951450" ] },
  1136. 954301: { wrongImageUpdates: [ "954628" ] },
  1137. 955263: { missedAuthors: [ "!gPzojzOMZ6", "SSgt. Eingrid" ] },
  1138. 960828: { missedTextUpdates: [ "967578", "967607", "967611", "967616", "967619", "967628", "967636", "967642", "967646", "967776", "967778", "967785", "967789", "967803", "967809", "967813", "967899", "968112", "968119", "968224", "968230", "968238", "968242" ] },
  1139. 964608: { missedTextUpdates: [ "964674" ] },
  1140. 968549: { missedTextUpdates: [ "968841" ] },
  1141. 971193: { missedTextUpdates: [ "971938" ] },
  1142. 971801: { missedTextUpdates: [ "971804", "971825" ] },
  1143. 973331: { missedTextUpdates: [ "973726", "973764" ] },
  1144. 974578: { missedTextUpdates: [ "974901", "974902", "974903", "974906", "975511", "976295", "976296", "976297", "976298" ] },
  1145. 975434: { imageQuest: false },
  1146. 976580: { missedTextUpdates: [ "976604", "976657", "976669", "976841", "983554" ] },
  1147. 978557: { ignoreTextPosts: [ "" ] },
  1148. 982815: { missedTextUpdates: [ "992488" ] },
  1149. 989640: { missedTextUpdates: [ "989941" ] },
  1150. 991564: { missedTextUpdates: [ "991566" ] },
  1151. 1000012: { missedAuthors: [ "Happiness" ] },
  1152. };
  1153. }
  1154. return this.allFixes;
  1155. }
  1156. }
  1157.  
  1158. //More or less standard XMLHttpRequest wrapper
  1159. //Input: url
  1160. //Output: Promise that resolves into the XHR object (or a HTTP error code)
  1161. class Xhr {
  1162. static get(url) {
  1163. return new Promise(function(resolve, reject) {
  1164. const xhr = new XMLHttpRequest();
  1165. xhr.onreadystatechange = function(e) {
  1166. if (xhr.readyState === 4) {
  1167. if (xhr.status === 200) {
  1168. resolve(xhr);
  1169. }
  1170. else {
  1171. reject(xhr.status);
  1172. }
  1173. }
  1174. };
  1175. xhr.ontimeout = function () {
  1176. reject("timeout");
  1177. };
  1178. xhr.open("get", url, true);
  1179. xhr.send();
  1180. });
  1181. }
  1182. }
  1183.  
  1184. //QuestReader class
  1185. //Input: none
  1186. //Output: none
  1187. //Usage: new QuestReader.init(settings);
  1188. //settings: a settings object obtained from the object's onSettingsChanged event, allowing you to store settings
  1189. class QuestReader {
  1190. constructor(doc) {
  1191. this.doc = doc;
  1192. this.boardName = doc.location.pathname.match(new RegExp("/kusaba/([a-z]*)/res/"))[1];
  1193. this.threadType = BoardThreadTypes[this.boardName];
  1194. this.defaultName = BoardDefaultNames[this.boardName];
  1195. this.hasTitle = false;
  1196. this.updates = [];
  1197. this.sequences = [];
  1198. this.defaultSettings = this.getDefaultSettings();
  1199. this.setSettings(this.defaultSettings);
  1200. this.threadID = null;
  1201. this.refClass = null;
  1202. this.posts = null;
  1203. this.firstPostElements = [];
  1204. this.cloneCache = {};
  1205. this.controls = {};
  1206. this.total = { authorComments: 0, suggestions: 0};
  1207. this.author = null;
  1208. this.suggesters = null;
  1209. this.scrollIntervalHandle = null;
  1210. this.replyFormDraggable = null;
  1211. this.dayNames = [ "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
  1212. this.currentDateTime = null;
  1213. this.relativeTime = null;
  1214. //regular expressions
  1215. this.reCanonID = new RegExp("^[0-9a-f]{6}$");
  1216. //events
  1217. this.onSettingsLoad = null;
  1218. this.onSettingsChanged = null;
  1219. this.onWikiDataLoad = null;
  1220. this.onWikiDataChanged = null;
  1221. }
  1222.  
  1223. getDefaultSettings() {
  1224. return {
  1225. currentUpdateIndex: 0,
  1226. viewMode: "all", //all, single, sequence
  1227. showSuggestions: "all", //none, last, all
  1228. showAuthorComments: "all", //none, last, all
  1229. showReferences: this.threadType == ThreadType.QUEST ? "nonupdates" : "all", //none, nonupdates, all
  1230. replyFormLocation: "float", //top, bottom, float
  1231. expandImages: "none", //none, updates, all
  1232. maxImageWidth: 100,
  1233. maxImageHeight: 96,
  1234. stretchImages: false,
  1235. showUpdateInfo: false,
  1236. timestampFormat: "server", //server, local, relative, auto, hide
  1237. colorUsers: false,
  1238. showUserPostCounts: false,
  1239. showUserNav: false,
  1240. showUserMultiPost: false,
  1241. showReplyForm: false,
  1242. moveToLast: false,
  1243. wikiPages: [],
  1244. wikiLastSearchTime: 0,
  1245. };
  1246. }
  1247.  
  1248. init(doc) {
  1249. var results = new UpdateAnalyzer({ passive: this.threadType != ThreadType.QUEST, defaultName: this.defaultName }).analyzeQuest(doc); //run UpdateAnalyzer to determine which posts are updates and what not
  1250. this.threadID = results.postTypes.keys().next().value;
  1251. this.refClass = `ref|${doc.body.querySelector(`input[name="board"]`).value}|${this.threadID}|`; //a variable used for finding and creating post reference links
  1252. this.updates = this.getUpdatePostGroups(results.postTypes, results.postUsers); //organize posts into groups where each group has one update post and its trailing suggestions
  1253. this.sequences = this.getUpdateSequences(); //a list of unique update sequences
  1254. this.loadSettings(); //load settings; YouDontSay.jpg
  1255. this.insertStyling(this.doc); //insert html elements for styling
  1256. this.posts = this.buildPostInfo(this.doc, results.postTypes, results.postUsers); //cache post elements and other post data for faster access
  1257. this.initCloneCache(this.doc); //cache some elements for faster insertion
  1258. this.insertControls(this.doc); //insert html elements for controls
  1259. this.insertEvents(this.doc); //insert our own button events plus some global events
  1260. this.modifyLayout(this.doc); //change the default layout by moving elements around to make them fit better
  1261. this.insertTooltips(); //add some explanations to our controls
  1262. this.refresh({checkHash: "correct", smoothScroll: false}); //hide all posts and show only the relevant ones; enable/disable/update controls
  1263. this.useWiki(); //get data from wiki and use them to set up wiki link, disthread link, and other thread links
  1264. }
  1265.  
  1266. getUpdatePostGroups(postTypes, postUsers) {
  1267. var updatePostGroups = [];
  1268. var currentPostGroup = { updatePostID: 0, suggestions: [], authorComments: [] };
  1269. var postTypesArray = [...postTypes];
  1270. //create post groups
  1271. for (let i = postTypesArray.length - 1; i >= 0; i--) {
  1272. if (postTypesArray[i][1] == PostType.UPDATE) {
  1273. currentPostGroup.updatePostID = postTypesArray[i][0];
  1274. updatePostGroups.unshift(currentPostGroup);
  1275. currentPostGroup = { updatePostID: 0, suggestions: [], authorComments: [] };
  1276. }
  1277. else if (postTypesArray[i][1] == PostType.AUTHORCOMMENT) {
  1278. currentPostGroup.authorComments.unshift(postTypesArray[i][0]);
  1279. this.total.authorComments++;
  1280. }
  1281. else { //PostType.SUGGESTION
  1282. currentPostGroup.suggestions.unshift(postTypesArray[i][0]);
  1283. this.total.suggestions++;
  1284. }
  1285. }
  1286. //create sequence groups
  1287. var currentUpdateSequence = [];
  1288. updatePostGroups.forEach(postGroup => {
  1289. currentUpdateSequence.push(postGroup);
  1290. postGroup.sequence = currentUpdateSequence;
  1291. if (postGroup.suggestions.length > 0) {
  1292. currentUpdateSequence = [];
  1293. }
  1294. });
  1295. //set post suggesters
  1296. var allSuggesters = new Set();
  1297. updatePostGroups.forEach(postGroup => {
  1298. postGroup.suggesters = postGroup.suggestions.reduce((suggesters, el) => {
  1299. var suggester = postUsers.get(el);
  1300. suggesters.add(suggester);
  1301. allSuggesters.add(suggester);
  1302. return suggesters;
  1303. }, new Set());
  1304. postGroup.suggesters = [...postGroup.suggesters];
  1305. });
  1306. this.author = postUsers.get(this.threadID);
  1307. this.suggesters = [...allSuggesters];
  1308. return updatePostGroups;
  1309. }
  1310.  
  1311. getUpdateSequences() {
  1312. var sequences = [];
  1313. this.updates.forEach(update => {
  1314. if (update.sequence !== sequences[sequences.length - 1]) {
  1315. sequences.push(update.sequence);
  1316. }
  1317. });
  1318. return sequences;
  1319. }
  1320.  
  1321. currentUpdate() {
  1322. return this.updates[this.currentUpdateIndex];
  1323. }
  1324.  
  1325. firstUpdate() {
  1326. return this.updates[0];
  1327. }
  1328.  
  1329. lastUpdate() {
  1330. return this.updates[this.updates.length - 1];
  1331. }
  1332.  
  1333. buildPostInfo(doc, postTypes, postUsers) {
  1334. var posts = new Map();
  1335.  
  1336. doc.body.querySelector(":scope > form").querySelectorAll(".postwidth > a[name]").forEach(anchor => {
  1337. if (anchor.name == "s") {
  1338. return;
  1339. }
  1340. var header = anchor.parentElement;
  1341. var inner = header.parentElement;
  1342. var postID = parseInt(anchor.name);
  1343. var outer = inner;
  1344. var outerName = postID === this.threadID ? "FORM" : "TABLE";
  1345. while (outer.nodeName != outerName) {
  1346. outer = outer.parentElement;
  1347. }
  1348. posts.set(postID, {
  1349. outer: outer, inner: inner, header: header, user: postUsers.get(postID), type: postTypes.get(postID), timestampFormat: "server", relativeTime: null, serverDateTimeString: "", utcTime: 0, references: null,
  1350. suggesterIsNew: false, userPrevPost: null, userNextPost: null, insertedReferences: false, insertedUserColors: false, insertedUserNav: false, insertedUserMultiPost: false, expandedThumbnail: false,
  1351. imgInfo: null,
  1352. });
  1353. });
  1354. this.getFirstPostElements(posts);
  1355. this.getPostImageInfo(posts);
  1356. this.getPostReferences(posts);
  1357. if (this.threadType === ThreadType.QUEST) {
  1358. this.getUserMultiPostInfo(posts, postUsers);
  1359. }
  1360. this.getUserNavInfo(posts, postUsers);
  1361. this.setUserPostCounts(posts);
  1362. return posts;
  1363. }
  1364.  
  1365. getFirstPostElements(posts) {
  1366. var child = posts.get(this.threadID).inner.firstElementChild;
  1367. while (child && child.nodeName !== "TABLE") {
  1368. if (child.className === "postwidth" || child.nodeName === "BLOCKQUOTE" || child.className === "pony" || child.className === "unicorn" || child.className === "de-refmap") {
  1369. this.firstPostElements.push(child);
  1370. }
  1371. child = child.nextElementSibling;
  1372. }
  1373. }
  1374.  
  1375. getPostImageInfo(posts) {
  1376. var expandLink = this.doc.body.querySelector(`a[href="#top"]`);
  1377. if (expandLink) {
  1378. var reExpandCode = new RegExp(`expandimg\\('([0-9]+)', '(.*)', '(.*)', '([0-9]+)', '([0-9]+)', '([0-9]+)', '([0-9]+)'\\);`);
  1379. expandLink.onclick.toString().split("\n").forEach(el => {
  1380. var matches = el.match(reExpandCode);
  1381. if (matches) {
  1382. var post = posts.get(+matches[1]);
  1383. if (post) {
  1384. post.imgInfo = { imgSrc: matches[2], thumbSrc: matches[3], imgWidth: matches[4], imgHeight: matches[5], thumbWidth: matches[6], thumbHeight: matches[7], };
  1385. if (!post.imgInfo.imgSrc.startsWith("http")) {
  1386. post.imgInfo.imgSrc = this.doc.location.origin + post.imgInfo.imgSrc;
  1387. post.imgInfo.thumbSrc = this.doc.location.origin + post.imgInfo.thumbSrc;
  1388. }
  1389. }
  1390. }
  1391. });
  1392. }
  1393. }
  1394.  
  1395. getPostReferences(posts) {
  1396. var postLinks = posts.get(this.threadID).outer.querySelectorAll(`blockquote a[class^="${this.refClass}"]`); //this is faster than traversing all posts
  1397. postLinks.forEach(link => {
  1398. var parent = link.parentElement;
  1399. while (parent.nodeName !== "BLOCKQUOTE") {
  1400. parent = parent.parentElement;
  1401. }
  1402. parent = parent.parentElement;
  1403. var linkPostID = parent.id.startsWith("reply") ? parseInt(parent.id.substring(5)) : this.threadID;
  1404. var targetID = parseInt(link.classList[0].substring(this.refClass.length));
  1405. var targetInfo = posts.get(targetID);
  1406. if (!targetInfo) {
  1407. return;
  1408. }
  1409. if (!targetInfo.references) {
  1410. targetInfo.references = [];
  1411. }
  1412. targetInfo.references.push(linkPostID);
  1413. });
  1414. }
  1415.  
  1416. getUserMultiPostInfo(postsInfo, postUsers) {
  1417. this.updates.forEach(update => {
  1418. var suggesterPosts = new Map(); //dictionary <user, postIDs[]>; which suggester made which posts for the current update
  1419. update.suggestions.forEach(postID => {
  1420. var suggester = postUsers.get(postID);
  1421. var posts = suggesterPosts.get(suggester) || [];
  1422. posts.push(postID);
  1423. suggesterPosts.set(suggester, posts);
  1424. });
  1425. suggesterPosts.forEach((posts, suggester) => {
  1426. suggester.isNew = suggester.postCount === posts.length;
  1427. for (var postIndex = 0; postIndex < posts.length; postIndex++) {
  1428. var postInfo = postsInfo.get(posts[postIndex]);
  1429. if (!postInfo) {
  1430. continue;
  1431. }
  1432. postInfo.suggesterIsNew = suggester.postCount === posts.length;
  1433. if (posts.length > 1) {
  1434. postInfo.userMultiPostIndex = postIndex + 1;
  1435. postInfo.userMultiPostLength = posts.length;
  1436. }
  1437. }
  1438. });
  1439. });
  1440. }
  1441.  
  1442. getUserNavInfo(posts, postUsers) {
  1443. var usersPrevPost = new Map();
  1444. posts.forEach((post, postID) => {
  1445. var user = postUsers.get(postID);
  1446. post.userPostCount = user.postCount;
  1447. var prevPostID = usersPrevPost.get(user);
  1448. if (prevPostID) {
  1449. posts.get(prevPostID).userNextPost = postID;
  1450. post.userPrevPost = prevPostID;
  1451. }
  1452. usersPrevPost.set(user, postID);
  1453. });
  1454. usersPrevPost.clear();
  1455. }
  1456.  
  1457. setUserPostCounts(posts, postUsers) {
  1458. posts.forEach((post, postID) => {
  1459. var uidEl = post.header.querySelector(".uid");
  1460. uidEl.setAttribute("postcount", post.userPostCount);
  1461. if (post.suggesterIsNew) {
  1462. uidEl.classList.add("isNew");
  1463. }
  1464. });
  1465. }
  1466.  
  1467. initCloneCache(doc) {
  1468. this.cloneCache.a = doc.createElement("a");
  1469. this.cloneCache.div = doc.createElement("div");
  1470. this.cloneCache.span = doc.createElement("span");
  1471. var fragmentUp = doc.createRange().createContextualFragment(`<a class="qrUserNavDisabled"><svg class="qrNavIcon" viewBox="0 0 24 24"><path d="M3 21.5l9-9 9 9M3 12.5l9-9 9 9"/></svg></a>`);
  1472. var fragmentDown = doc.createRange().createContextualFragment(`<a class="qrUserNavDisabled"><svg class="qrNavIcon" viewBox="0 0 24 24"><path d="M3 12.5l9 9 9-9M3 3.5l9 9 9-9"/></svg></a>`);
  1473. this.cloneCache.upLink = fragmentUp.children[0];
  1474. this.cloneCache.downLink = fragmentDown.children[0];
  1475. this.cloneCache.userMultiPostElement = doc.createElement("span");
  1476. this.cloneCache.userMultiPostElement.className = "qrUserMultiPost";
  1477. }
  1478.  
  1479. refresh(options = {}) {
  1480. var checkHash = options.checkHash !== undefined ? options.checkHash : "false"; //checkHash: if true and the url has a hash, show the update that contains the post ID in the hash and scroll to this post
  1481. var scroll = options.scroll !== undefined ? options.scroll : true; //scroll: if true (default), scroll to the current update
  1482. var smoothScroll = options.smoothScroll !== undefined ? options.smoothScroll : true; //if true (default), use smooth scroll
  1483. var scrollToPostID = null;
  1484. if (this.moveToLast) {
  1485. this.moveToLast = false;
  1486. this.showLast(false);
  1487. }
  1488. if (checkHash !== "false") {
  1489. var hashedPostID = parseInt(this.doc.location.hash.replace("#", ""));
  1490. if (!isNaN(hashedPostID) && hashedPostID != this.threadID && this.posts.has(hashedPostID)) {
  1491. //Clicking on a "new posts" link in the Watched Threads usually takes you to the wrong post in a thread, that it, one post before the actual new post.
  1492. //This usually results in visiting a post before the update, which is wrong -> we should be showing the update instead
  1493. scrollToPostID = hashedPostID;
  1494. var hashedUpdateIndex = this.findUpdateIndex(hashedPostID);
  1495. if (checkHash === "correct" && this.threadType == ThreadType.QUEST) {
  1496. var hashedUpdate = this.updates[hashedUpdateIndex];
  1497. var hashedUpdatePosts = hashedUpdate.authorComments.concat(hashedUpdate.suggestions).sort();
  1498. var isLastPostInTheUpdate = hashedPostID == hashedUpdatePosts[hashedUpdatePosts.length - 1];
  1499. //the correction should only work if it's the one post before the first update of the last sequence
  1500. if (isLastPostInTheUpdate && hashedUpdateIndex >= 0 && hashedUpdateIndex >= this.updates.indexOf(this.lastUpdate().sequence[0]) - 1 && hashedUpdateIndex < this.updates.length - 1) {
  1501. hashedUpdateIndex++;
  1502. scrollToPostID = null;
  1503. }
  1504. }
  1505. this.changeIndex(hashedUpdateIndex, false);
  1506. }
  1507. }
  1508. if (!scrollToPostID && this.threadType == ThreadType.QUEST && this.viewMode == "all" && this.currentUpdate() != this.firstUpdate()) {
  1509. scrollToPostID = this.currentUpdate().updatePostID;
  1510. }
  1511. this.currentDateTime = new Date();
  1512. this.hideAll();
  1513. this.showCurrentUpdates();
  1514. if (checkHash !== "false" && scrollToPostID) {
  1515. this.showPost(scrollToPostID); //in case we want to scroll to a hidden suggestion, we want to show it first
  1516. }
  1517. this.updateControls();
  1518. if (scroll) {
  1519. var scrollToElement = (scrollToPostID && this.posts.has(scrollToPostID)) ? this.posts.get(scrollToPostID).outer : this.controls.controlsTop;
  1520. var scrollOptions = { behavior: smoothScroll ? "smooth" : "auto", block: "start", };
  1521. var scrollFunction = () => { this.doc.defaultView.requestAnimationFrame(() => { scrollToElement.scrollIntoView(scrollOptions); }); };
  1522. if (this.doc.readyState !== "complete") {
  1523. this.doc.defaultView.addEventListener("load", scrollFunction, { once: true });
  1524. }
  1525. else {
  1526. scrollFunction();
  1527. }
  1528. }
  1529. }
  1530.  
  1531. hideAll() {
  1532. this.posts.forEach((post, postID) => {
  1533. if (postID == this.threadID) {
  1534. this.firstPostElements.forEach(el => { el.classList.add("veryhidden"); });
  1535. }
  1536. else {
  1537. post.outer.classList.add("hidden");
  1538. }
  1539. });
  1540. }
  1541.  
  1542. findUpdateIndex(postID) {
  1543. for (var i = 0; i < this.updates.length; i++) {
  1544. if (this.updates[i].updatePostID == postID || this.updates[i].suggestions.indexOf(postID) != -1 || this.updates[i].authorComments.indexOf(postID) != -1) {
  1545. return i;
  1546. }
  1547. }
  1548. return -1;
  1549. }
  1550.  
  1551. showCurrentUpdates() {
  1552. var updatesToShow = {
  1553. single: [ this.currentUpdate() ],
  1554. sequence: this.currentUpdate().sequence,
  1555. all: this.updates,
  1556. };
  1557. var updatesToExpand = {};
  1558. var currentSequenceIndex = this.sequences.indexOf(this.currentUpdate().sequence);
  1559. updatesToExpand.single = [this.updates[this.currentUpdateIndex - 1], this.currentUpdate(), this.updates[this.currentUpdateIndex + 1]].filter(el => !!el);
  1560. updatesToExpand.sequence = [...this.sequences[currentSequenceIndex - 1] || [], ...this.sequences[currentSequenceIndex], ...this.sequences[currentSequenceIndex + 1] || []];
  1561. //expanding images on the fly when in full thread view is a bit janky when navigating up
  1562. updatesToExpand.all = [this.currentUpdate(), this.updates[this.currentUpdateIndex + 1], this.updates[this.currentUpdateIndex + 2]].filter(el => !!el);
  1563. //updatesToExpand.all = this.updates;
  1564.  
  1565. updatesToShow[this.viewMode].forEach(update => this.showUpdate(update));
  1566. updatesToExpand[this.viewMode].forEach(update => this.expandUpdateImages(update));
  1567. }
  1568.  
  1569. expandUpdateImages(update) {
  1570. var postsToExpand = [ update.updatePostID ];
  1571. if (this.expandImages != "updates") {
  1572. postsToExpand = postsToExpand.concat(update.suggestions, update.authorComments);
  1573. }
  1574. postsToExpand.forEach(postID => {
  1575. var post = this.posts.get(postID);
  1576. if (!post || !post.imgInfo) {
  1577. return;
  1578. }
  1579. var img = post.header.querySelector(`#thumb${postID} > img`);
  1580. if (img.previousElementSibling && img.previousElementSibling.nodeName === "CANVAS") {
  1581. img.previousElementSibling.remove(); //remove canvas covering the image
  1582. img.style.removeProperty("display");
  1583. }
  1584. var expanded = this.viewMode == "all" ? img.style.backgroundImage === `url(${post.imgInfo.imgSrc})` : img.src == post.imgInfo.imgSrc;
  1585. if (expanded !== (this.expandImages == "all" || (this.expandImages == "updates" && postID == update.updatePostID))) { //image should be expanded or contracted
  1586. if (!expanded) {
  1587. img.removeAttribute("onmouseover");
  1588. img.removeAttribute("onmouseout");
  1589. if (post.outer.classList.contains("hidden")) { //if it's a hidden post, we'd like to preload the image, but a bit later
  1590. setTimeout(() => { new Image().src = post.imgInfo.imgSrc });
  1591. }
  1592. else if (this.viewMode == "all") {
  1593. img.style.backgroundImage = `url(${post.imgInfo.imgSrc})`;
  1594. }
  1595. else {
  1596. img.src = post.imgInfo.imgSrc;
  1597. }
  1598. }
  1599. else {
  1600. img.src = post.imgInfo.thumbSrc;
  1601. img.width = post.imgInfo.thumbWidth;
  1602. img.height = post.imgInfo.thumbHeight;
  1603. }
  1604. }
  1605. });
  1606. }
  1607.  
  1608. showUpdate(update) {
  1609. this.showPost(update.updatePostID);
  1610. if (this.showSuggestions == "all" || this.showSuggestions == "last" && update == this.lastUpdate()) {
  1611. update.suggestions.forEach(postID => this.showPost(postID));
  1612. }
  1613. if (this.showAuthorComments == "all" || this.showAuthorComments == "last" && update == this.lastUpdate()) {
  1614. update.authorComments.forEach(postID => this.showPost(postID));
  1615. }
  1616. }
  1617.  
  1618. showPost(postID) {
  1619. this.insertPostElements(postID);
  1620. if (postID == this.threadID) {
  1621. this.firstPostElements.forEach(el => { el.classList.remove("veryhidden"); });
  1622. }
  1623. else {
  1624. var post = this.posts.get(postID);
  1625. if (post) {
  1626. post.outer.classList.remove("hidden");
  1627. }
  1628. }
  1629. }
  1630.  
  1631. insertPostElements(postID) {
  1632. var post = this.posts.get(postID);
  1633. if (!post) {
  1634. return;
  1635. }
  1636. if (!post.insertedReferences && (this.showReferences == "all" || (this.showReferences == "nonupdates" && post.type !== PostType.UPDATE))) {
  1637. post.insertedReferences = true;
  1638. if (post.references) {
  1639. this.insertPostReferences(postID, post.references, post.inner);
  1640. }
  1641. }
  1642. if (!post.insertedUserColors && this.colorUsers) {
  1643. post.insertedUserColors = true;
  1644. if (post.user.id !== this.author.id || this.threadType !== ThreadType.QUEST) {
  1645. this.insertUserColors(post);
  1646. }
  1647. }
  1648. if (!post.insertedUserNav && this.showUserNav) {
  1649. post.insertedUserNav = true;
  1650. this.insertUserNav(post);
  1651. }
  1652. if (!post.insertedUserMultiPost && this.showUserMultiPost) {
  1653. post.insertedUserMultiPost = true;
  1654. if (post.userMultiPostIndex) {
  1655. this.insertUserMultiPost(post);
  1656. }
  1657. }
  1658. if (post.timestampFormat != this.timestampFormat || post.relativeTime !== this.relativeTime) {
  1659. post.timestampFormat = this.timestampFormat;
  1660. post.relativeTime = this.relativeTime;
  1661. this.changePostTime(post);
  1662. }
  1663. if (this.viewMode == "all" && post.imgInfo && post.expandedThumbnail !== (this.expandImages === "all" || (this.expandImages == "updates" && post.type === PostType.UPDATE))) {
  1664. post.expandedThumbnail = !post.expandedThumbnail;
  1665. this.resizeThumbnail(post, post.expandedThumbnail);
  1666. }
  1667. }
  1668.  
  1669. resizeThumbnail(post, expandThumbnail) {
  1670. var imgElement = post.header.querySelector("img.thumb");
  1671. if (expandThumbnail) {
  1672. imgElement.src = `data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="${post.imgInfo.imgWidth}px" height="${post.imgInfo.imgHeight}px"></svg>`;
  1673. imgElement.style.backgroundImage = `url(${post.imgInfo.thumbSrc})`;
  1674. imgElement.removeAttribute("onmouseover"); //it's possible these events trigger before expandUpdateImages and restore spoiler images, so gotta remove them here
  1675. imgElement.removeAttribute("onmouseout");
  1676. }
  1677. else {
  1678. imgElement.src = post.imgInfo.thumbSrc;
  1679. imgElement.style.backgroundImage = "";
  1680. imgElement.width = post.imgInfo.thumbWidth;
  1681. imgElement.height = post.imgInfo.thumbHeight;
  1682. }
  1683. }
  1684.  
  1685. insertPostReferences(postID, references, postElement) {
  1686. var links = references.map(id => {
  1687. var newLink = this.cloneCache.a.cloneNode("false");
  1688. newLink.href = `#${id}`;
  1689. newLink.className = `${this.refClass}${id}| qrReference`;
  1690. newLink.textContent = `>>${id}`;
  1691. return newLink;
  1692. });
  1693. var newDiv = this.cloneCache.div.cloneNode("false");
  1694. newDiv.classList.add("qrReferences");
  1695. newDiv.append(...links);
  1696. postElement.querySelector("blockquote").insertAdjacentElement("afterEnd", newDiv);
  1697. if (postID === this.threadID) {
  1698. this.firstPostElements.push(newDiv);
  1699. }
  1700. }
  1701.  
  1702. insertUserColors(post) {
  1703. var uidEl = post.header.querySelector(".uid");
  1704. var span = this.cloneCache.span.cloneNode(false);
  1705. span.className = `qrColoredUid uid${this.getCanonID(post.user)}`;
  1706. span.textContent = uidEl.firstChild.nodeValue.substring(4);
  1707. uidEl.firstChild.nodeValue = "ID: ";
  1708. uidEl.appendChild(span);
  1709. }
  1710.  
  1711. insertUserNav(post) {
  1712. var uidEl = post.header.querySelector(".uid");
  1713. var downLink = this.cloneCache.downLink.cloneNode(true);
  1714. var upLink = this.cloneCache.upLink.cloneNode(true);
  1715. if (post.userPrevPost) {
  1716. upLink.href = `#${post.userPrevPost}`;
  1717. upLink.className = "qrUserNavEnabled";
  1718. }
  1719. if (post.userNextPost) {
  1720. downLink.href = `#${post.userNextPost}`;
  1721. downLink.className = "qrUserNavEnabled";
  1722. }
  1723. uidEl.insertAdjacentElement("afterEnd", downLink);
  1724. uidEl.insertAdjacentElement("afterEnd", upLink);
  1725. }
  1726.  
  1727. insertUserMultiPost(post) {
  1728. var span = this.cloneCache.userMultiPostElement.cloneNode(false);
  1729. span.textContent = `post ${post.userMultiPostIndex}/${post.userMultiPostLength}`;
  1730. post.header.appendChild(span);
  1731. }
  1732.  
  1733. changePostTime(post) {
  1734. var timeNode = post.header.querySelector("label").lastChild;
  1735. if (post.timestampFormat == "hide") {
  1736. post.serverDateTimeString = post.serverDateTimeString || ` ${timeNode.nodeValue.trim()}`;
  1737. timeNode.nodeValue = "";
  1738. return;
  1739. }
  1740. var utcTime = this.getUtcTime(post, timeNode);
  1741. var difference = this.currentDateTime.getTime() - utcTime;
  1742. if (post.relativeTime !== null ? post.relativeTime : post.timestampFormat == "relative" || (post.timestampFormat == "auto" && difference < 86400000)) {
  1743. timeNode.nodeValue = `${this.getRelativeTimeString(difference)} ago`;
  1744. }
  1745. else {
  1746. timeNode.nodeValue = post.timestampFormat == "server" ? post.serverDateTimeString : this.getAbsoluteDateTimeString(new Date(utcTime));
  1747. }
  1748. }
  1749.  
  1750. getUtcTime(post, timeNode) {
  1751. if (!post.utcTime) {
  1752. post.serverDateTimeString = post.serverDateTimeString || ` ${timeNode.nodeValue.trim()}`;
  1753. var d = post.serverDateTimeString;
  1754. var serverTime = Date.UTC(d.substring(1, 5), d.substring(6, 8) - 1, d.substring(9, 11), d.substring(16, 18), d.substring(19));
  1755. post.utcTime = serverTime - this.getServerTimeZoneOffset(new Date(serverTime));
  1756. }
  1757. return post.utcTime;
  1758. }
  1759.  
  1760. getServerTimeZoneOffset(dateTime) {
  1761. var month = dateTime.getUTCMonth();
  1762. var day = dateTime.getUTCDate();
  1763. var dayOfWeek = dateTime.getUTCDay();
  1764. var hour = dateTime.getUTCHours();
  1765. var firstSunday = ((day + 7 - dayOfWeek) % 7) || 7;
  1766. var after2ndSundayInMarch3am = month > 2 || (month == 2 && (day > firstSunday + 7 || ( day == firstSunday + 7 && hour >= 3)));
  1767. var before1stSundayInNovember1am = month < 10 || (month == 10 && (day < firstSunday || ( day == firstSunday && hour < 1)));
  1768. var isDST = after2ndSundayInMarch3am && before1stSundayInNovember1am;
  1769. return (isDST ? -7 : -8) * 3600000;
  1770. }
  1771.  
  1772. getRelativeTimeString(difference) {
  1773. //convert a difference between two dates into a string by finding and using the two most relevant date parts
  1774. //writing this function was fun (read: hell); it's not perfect due to varying numbers of days in months and step years, but it should be good enough
  1775. var names = ["min", "h", "d", "mo", "y"];
  1776. var factors = [60, 24, 30.4375, 12];
  1777. var diffs = factors.reduce((diffs, factor, index) => { diffs.push(diffs[index] / factor); return diffs; }, [ difference / 60000 ]);
  1778. var i = diffs.length - 1;
  1779. for (; i > 0 && diffs[i] < 1; i--) {} //find the most relevant date part -> the first one with a value greater than 1
  1780. if (i > 0) { //truncate + round; 1.76h 105.6min -> 1h 46min
  1781. diffs[i] = Math.trunc(diffs[i]);
  1782. diffs[i - 1] = Math.round(diffs[i - 1] - diffs[i] * factors[i - 1]);
  1783. if (diffs[i - 1] == factors[i - 1]) { //correct the parts if rounded to a factor number; 1h 60min -> 2h 0min
  1784. diffs[i - 1] = 0;
  1785. diffs[i]++;
  1786. }
  1787. }
  1788. else {
  1789. diffs[i] = Math.round(diffs[i]);
  1790. }
  1791. if (i < diffs.length - 1 && diffs[i] == factors[i]) { //correct the parts if rounding caused a transition; 60min -> 1h 0min
  1792. diffs[i] = 0;
  1793. i++;
  1794. diffs[i] = 1;
  1795. }
  1796. return ` ${diffs[i]}${names[i]}${names[i - 1] ? ` ${diffs[i - 1]}${names[i - 1]}` : ""}`;
  1797. }
  1798.  
  1799. getAbsoluteDateTimeString(date) {
  1800. return ` ${date.getFullYear()}/${(date.getMonth() + 1).toString().padStart(2, "0")}/${date.getDate().toString().padStart(2, "0")}` +
  1801. `(${this.dayNames[date.getDay()]})${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}`;
  1802. }
  1803.  
  1804. showFirst() {
  1805. var newUpdateIndex = 0;
  1806. this.changeIndex(newUpdateIndex);
  1807. }
  1808.  
  1809. showLast(refresh = true) {
  1810. var newUpdateIndex = this.viewMode == "sequence" ? this.updates.indexOf(this.sequences[this.sequences.length - 1][0]) : this.updates.length - 1;
  1811. this.changeIndex(newUpdateIndex, refresh);
  1812. }
  1813.  
  1814. showNext() {
  1815. var newUpdateIndex = this.currentUpdateIndex + 1;
  1816. if (this.viewMode == "sequence") { //move to the first update in the next sequence
  1817. var currentSequenceIndex = this.sequences.indexOf(this.currentUpdate().sequence);
  1818. newUpdateIndex = currentSequenceIndex < this.sequences.length - 1 ? this.updates.indexOf(this.sequences[currentSequenceIndex + 1][0]) : this.updates.length;
  1819. }
  1820. this.changeIndex(newUpdateIndex);
  1821. }
  1822.  
  1823. showPrevious() {
  1824. var newUpdateIndex = this.currentUpdateIndex - 1;
  1825. if (this.viewMode == "sequence") {
  1826. var currentSequenceIndex = this.sequences.indexOf(this.currentUpdate().sequence);
  1827. newUpdateIndex = currentSequenceIndex > 0 ? this.updates.indexOf(this.sequences[currentSequenceIndex - 1][0]) : -1;
  1828. }
  1829. this.changeIndex(newUpdateIndex);
  1830. }
  1831.  
  1832. changeIndex(newUpdateIndex, refresh = true) {
  1833. if (newUpdateIndex === this.currentUpdateIndex || newUpdateIndex < 0 || newUpdateIndex > this.updates.length - 1) {
  1834. return;
  1835. }
  1836. var difference = Math.abs(newUpdateIndex - this.currentUpdateIndex);
  1837. if (this.viewMode == "sequence") {
  1838. difference = Math.abs(this.sequences.indexOf(this.updates[newUpdateIndex].sequence) - this.sequences.indexOf(this.currentUpdate().sequence));
  1839. }
  1840. this.currentUpdateIndex = newUpdateIndex;
  1841. if (refresh) {
  1842. this.refresh({ smoothScroll: difference === 1 });
  1843. }
  1844. this.settingsChanged();
  1845. }
  1846.  
  1847. loadSettings() {
  1848. if (this.onSettingsLoad) {
  1849. var e = { threadID: this.threadID, threadType: this.threadType, boardName: this.boardName, settings: null };
  1850. this.onSettingsLoad(e);
  1851. if (e.settings) {
  1852. this.setSettings(this.validateSettings(e.settings));
  1853. }
  1854. }
  1855. }
  1856.  
  1857. setSettings(settings) {
  1858. if (settings) {
  1859. for(var settingName in settings) {
  1860. this[settingName] = settings[settingName];
  1861. }
  1862. }
  1863. }
  1864.  
  1865. validateSettings(settings) {
  1866. if (!settings) {
  1867. return settings;
  1868. }
  1869. if (settings.currentUpdateIndex < 0) settings.currentUpdateIndex = 0;
  1870. if (settings.currentUpdateIndex >= this.updates.length) settings.currentUpdateIndex = this.updates.length - 1;
  1871. for (var prop in settings) {
  1872. if (this[prop] !== undefined && typeof(settings[prop]) !== typeof(this[prop])) {
  1873. settings[prop] = this[prop];
  1874. }
  1875. }
  1876. if (!settings.replyFormLocation) { //replyFormLocation == "float"
  1877. settings.showReplyForm = false;
  1878. }
  1879. return settings;
  1880. }
  1881.  
  1882. settingsChanged() {
  1883. if (this.onSettingsChanged) {
  1884. var settings = {};
  1885. for(var settingName in this.defaultSettings) {
  1886. if (this[settingName] !== this.defaultSettings[settingName] || (Array.isArray(this[settingName]) && this[settingName].length > 0)) {
  1887. settings[settingName] = this[settingName];
  1888. }
  1889. }
  1890. this.onSettingsChanged({ threadID: this.threadID, threadType: this.threadType, boardName: this.boardName, settings: settings });
  1891. }
  1892. }
  1893.  
  1894. toggleSettingsControls(e) {
  1895. e.preventDefault(); //prevent scrolling to the top when clicking the link
  1896. this.controls.settingsControls.classList.toggle("collapsedHeight");
  1897. var label = e.target;
  1898. label.text = this.controls.settingsControls.classList.contains("collapsedHeight") ? "Settings" : "Hide Settings";
  1899. }
  1900.  
  1901. showSettingsPage(e) {
  1902. e.preventDefault();
  1903. var linkContainer = e.target.parentElement;
  1904. if (!linkContainer.classList.contains("qrSettingsNavItem")) {
  1905. return;
  1906. }
  1907. this.controls.settingsControls.querySelector(".qrSettingsNavItemSelected").classList.remove("qrSettingsNavItemSelected");
  1908. this.controls.settingsControls.querySelector(".qrCurrentPage").classList.remove("qrCurrentPage");
  1909. linkContainer.classList.add("qrSettingsNavItemSelected");
  1910. this.controls.settingsControls.querySelector(".qrSettingsPages").children[[...linkContainer.parentElement.children].indexOf(linkContainer)].classList.add("qrCurrentPage");
  1911. }
  1912.  
  1913. toggleReplyForm(e) {
  1914. e.preventDefault();
  1915. this.showReplyForm = !this.controls.replyForm.classList.toggle("hidden");
  1916. this.controls.replyFormRestoreButton.classList.toggle("hidden", this.showReplyForm);
  1917. if (this.replyFormLocation == "float" && !this.controls.replyForm.classList.contains("qrPopout")) {
  1918. this.controls.replyFormPopoutButton.click();
  1919. }
  1920. if (this.showReplyForm) {
  1921. this.controls.replyForm.querySelector(`[name="message"]`).focus();
  1922. }
  1923. this.settingsChanged();
  1924. }
  1925.  
  1926. popoutReplyForm(e) {
  1927. e.preventDefault();
  1928. var floating = this.controls.replyForm.classList.toggle("qrPopout");
  1929. var svgPath;
  1930. if (floating) {
  1931. svgPath = "M20 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6c0-1.1.9-2 2-2h5M15 14h-5v-5M10.5 13.5L20.2 3.8";
  1932. this.controls.replyFormPopoutButton.title = "Stop floating the Reply form";
  1933. if (!this.replyFormDraggable) {
  1934. var rect = this.controls.replyForm.querySelector(".postform").getBoundingClientRect();
  1935. var width = rect.width;
  1936. var height = rect.height;
  1937. if (width === 0) { //some compatibility stuff
  1938. var messageBox = this.controls.replyForm.querySelector(`[name="message"]`);
  1939. if (messageBox.style.width) {
  1940. width = Math.max(messageBox.style.width.replace("px", ""), messageBox.style.minWidth.replace("px", ""));
  1941. height = Math.max(messageBox.style.height.replace("px", ""), messageBox.style.minHeight.replace("px", "")) + 150;
  1942. }
  1943. }
  1944. this.controls.replyForm.style.left = `${this.doc.documentElement.clientWidth - width - 10}px`;
  1945. this.controls.replyForm.style.top = `${(this.doc.defaultView.innerHeight - height) * 0.75}px`;
  1946. }
  1947. this.replyFormDraggable = new this.doc.defaultView.Draggable("postform", { handle: "qrReplyFormHeader" });
  1948. }
  1949. else {
  1950. svgPath = "M18 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8c0-1.1.9-2 2-2h5M15 3h6v6M10 14L20.2 3.8";
  1951. this.controls.replyFormPopoutButton.title = "Pop out the Reply form and have it float";
  1952. this.replyFormDraggable.destroy();
  1953. }
  1954. this.controls.replyFormPopoutButton.firstElementChild.firstElementChild.firstElementChild.setAttribute("d", svgPath);
  1955. }
  1956.  
  1957. changeThread(e) {
  1958. this.doc.location.href = e.target.value;
  1959. }
  1960.  
  1961. updateSettings() {
  1962. this.viewMode = this.controls.viewModeDropdown.value;
  1963. this.showSuggestions = this.controls.showSuggestionsDropdown.value;
  1964. this.showAuthorComments = this.controls.showAuthorCommentsDropdown.value;
  1965. this.expandImages = this.controls.expandImagesDropdown.value;
  1966. this.showUpdateInfo = this.controls.showUpdateInfoCheckbox.checked;
  1967. this.replyFormLocation = this.controls.replyFormLocationDropdown.value;
  1968. this.refresh({scroll: false});
  1969. this.settingsChanged();
  1970. }
  1971.  
  1972. changePostElementSettings(e) {
  1973. if (e.target === this.controls.showReferencesDropdown) {
  1974. this.showReferences = this.controls.showReferencesDropdown.value;
  1975. this.doc.head.querySelector("#qrReferencesCss").innerHTML = this.getReferencesStyleRules();
  1976. }
  1977. else if (e.target === this.controls.colorUsersCheckbox) {
  1978. this.colorUsers = this.controls.colorUsersCheckbox.checked;
  1979. this.doc.head.querySelector("#qrUserColorsCss").innerHTML = this.getUserColorsStyleRules();
  1980. }
  1981. else if (e.target === this.controls.showUserPostCountsCheckbox) {
  1982. this.showUserPostCounts = this.controls.showUserPostCountsCheckbox.checked;
  1983. this.doc.head.querySelector("#qrUserPostCountsCss").innerHTML = this.getUserPostCountsStyleRules();
  1984. }
  1985. else if (e.target === this.controls.showUserNavCheckbox) {
  1986. this.showUserNav = this.controls.showUserNavCheckbox.checked;
  1987. this.doc.head.querySelector("#qrUserNavCss").innerHTML = this.getUserNavStyleRules();
  1988. }
  1989. else if (e.target === this.controls.showUserMultiPostCheckbox) {
  1990. this.showUserMultiPost = this.controls.showUserMultiPostCheckbox.checked;
  1991. this.doc.head.querySelector("#qrUserMultiPostCss").innerHTML = this.getUserMultiPostStyleRules();
  1992. }
  1993. else if (e.target === this.controls.timestampFormatDropdown) {
  1994. this.timestampFormat = this.controls.timestampFormatDropdown.value;
  1995. this.relativeTime = null;
  1996. }
  1997. this.refresh({scroll: false});
  1998. this.settingsChanged();
  1999. }
  2000.  
  2001. changeImageSizeSettings(e) {
  2002. var width = parseInt(this.controls.maxImageWidthTextbox.value);
  2003. var height = parseInt(this.controls.maxImageHeightTextbox.value);
  2004. if (isNaN(width) || width < 0 || width > 100) {
  2005. width = this.maxImageWidth;
  2006. this.controls.maxImageWidthTextbox.value = width;
  2007. }
  2008. if (isNaN(height) || height < 0 || height > 100) {
  2009. height = this.maxImageHeight;
  2010. this.controls.maxImageHeightTextbox.value = height;
  2011. }
  2012. this.maxImageWidth = width;
  2013. this.maxImageHeight = height;
  2014. this.stretchImages = this.controls.stretchImagesCheckbox.checked;
  2015. this.controls.imageWidthLabel.textContent = this.stretchImages ? "Image container width" : "Max image width";
  2016. this.controls.imageHeightLabel.textContent = this.stretchImages ? "Image container height" : "Max image height";
  2017. this.doc.head.querySelector("#qrImageSizeCss").innerHTML = this.getImageSizesStyleRules();
  2018. this.refresh({scroll: false});
  2019. this.settingsChanged();
  2020. }
  2021.  
  2022. async useWiki() {
  2023. if (this.threadType == ThreadType.OTHER) {
  2024. return;
  2025. }
  2026. var wikiUrls = await this.findWikiUrls();
  2027. if (!wikiUrls) {
  2028. return;
  2029. }
  2030. var wikiDatas = [];
  2031. for (let wikiUrl of wikiUrls) {
  2032. var wikiPageName = wikiUrl.substring(wikiUrl.lastIndexOf("/") + 1);
  2033. //cache wiki page names
  2034. if (!this.wikiPages.includes(wikiPageName)) {
  2035. this.wikiPages.push(wikiPageName);
  2036. this.settingsChanged();
  2037. }
  2038. //try get wiki data from cache
  2039. let wikiData = await this.getWikiData(wikiUrl);
  2040. wikiDatas.push(wikiData);
  2041. //cache wiki data
  2042. if (this.onWikiDataChanged !== null) {
  2043. //remember last visited quest thread
  2044. if (this.threadType == ThreadType.QUEST) {
  2045. wikiData.lastVisitedQuestUrl = this.doc.location.href.replace(new RegExp("#[0-9]+$"), ""); //don't want to remember the anchor
  2046. wikiData.lastVisitedTime = Date.now();
  2047. }
  2048. let e = { threadID: this.threadID, threadType: this.threadType, boardName: this.boardName, wikiPageName: wikiPageName, wikiData: wikiData };
  2049. this.onWikiDataChanged(e);
  2050. }
  2051. }
  2052. //use wiki data
  2053. //set up links to the quest wiki
  2054. var wikiTarget = this.wikiPages.length > 1 ? `/w/index.php?search=${this.threadID}&fulltext=1&limit=500`: wikiUrls[0];
  2055. this.controls.wikiLinks.forEach(link => { link.href = wikiTarget; link.style.removeProperty("color"); });
  2056. //set up link to the discussion thread, or quest thread if currently in a discussion thread
  2057. if (this.threadType == ThreadType.QUEST) {
  2058. var disThreadGroup = wikiDatas[0].threadGroups.find(group => group.some(link => link.url.indexOf("/questdis/") >= 0));
  2059. if (disThreadGroup) {
  2060. this.controls.disLinks.forEach(link => { link.href = disThreadGroup[disThreadGroup.length - 1].url; link.parentElement.classList.remove("hidden"); });
  2061. }
  2062. }
  2063. else if (this.threadType == ThreadType.DISCUSSION) {
  2064. var visitedQuests = wikiDatas.filter(wikiData => wikiData.lastVisitedQuestUrl);
  2065. if (visitedQuests.length > 0) {
  2066. var lastVisitedUrl = visitedQuests.sort((a, b) => b.lastVisitedTime - a.lastVisitedTime)[0].lastVisitedQuestUrl;
  2067. this.controls.questLinks.forEach(link => { link.href = lastVisitedUrl; link.parentElement.classList.remove("hidden"); });
  2068. }
  2069. else if (wikiDatas.length === 1) {
  2070. var questUrls = [];
  2071. wikiDatas[0].threadGroups.forEach(group => group.forEach(link => { if (link.url.indexOf("/quest/") >= 0) questUrls.push(link.url); }));
  2072. if (questUrls.length > 0) {
  2073. var lastNumberRegEx = new RegExp("([0-9]+)(?=[^0-9]*$)");
  2074. var highestIdThread = questUrls.reduce((max, url) => { if (parseInt(url.match(lastNumberRegEx)[1]) > parseInt(max.match(lastNumberRegEx)[1])) max = url; return max; });
  2075. this.controls.questLinks.forEach(link => { link.href = highestIdThread; link.parentElement.classList.remove("hidden"); });
  2076. }
  2077. }
  2078. }
  2079. //set up links to the quest threads; if in one of the quest threads, then only show links from the same group
  2080. var currentThreadGroup = wikiDatas[0].threadGroups.find(group => group.some(link => link.url.indexOf(this.threadID) >= 0));
  2081. if (currentThreadGroup) {
  2082. var threadOptionsHtml;
  2083. if (this.threadType == ThreadType.QUEST) {
  2084. threadOptionsHtml = currentThreadGroup.reduce((optionsHtml, thread) => optionsHtml + `<option value="${thread.url}">${thread.name}</option>`, "");
  2085. }
  2086. else {
  2087. var questsOptions = wikiDatas.map(wikiData => {
  2088. var optionsGroups = wikiData.threadGroups.map(group => group.reduce((optionsHtml, thread) => optionsHtml + `<option value="${thread.url}"> ${thread.name}</option>`, ""));
  2089. return `<option disabled>${wikiData.questTitle}</option>` + optionsGroups.join(`<option disabled>────────</option>`);
  2090. });
  2091. threadOptionsHtml = questsOptions.join(`<option disabled></option>`);
  2092. }
  2093. var currentThreadUrl = currentThreadGroup.find(thread => thread.url.indexOf(this.threadID) >= 0).url;
  2094. this.controls.threadLinksDropdowns.forEach(dropdown => {
  2095. dropdown.innerHTML = threadOptionsHtml;
  2096. dropdown.value = currentThreadUrl;
  2097. });
  2098. }
  2099. //update the thread / tab title with the title from wiki
  2100. if (wikiDatas[0].questTitle) {
  2101. if (this.threadType == ThreadType.DISCUSSION) {
  2102. if (wikiDatas.length === 1) {
  2103. this.doc.title = `${wikiDatas[0].questTitle} Discussion`;
  2104. this.controls.logo.textContent = this.doc.title;
  2105. }
  2106. }
  2107. else {
  2108. this.doc.title = `${this.hasTitle ? this.doc.title : wikiDatas[0].questTitle}${wikiDatas[0].byAuthors}`;
  2109. this.controls.logo.textContent = this.doc.title;
  2110. }
  2111. }
  2112. }
  2113.  
  2114. async findWikiUrls() {
  2115. if (this.wikiPages.length > 0 && Date.now() - this.wikiLastSearchTime < 24 * 60 * 60000) { //flush the cache every 24 hours
  2116. return this.wikiPages.map(name => `/wiki/${name}`);
  2117. }
  2118. try {
  2119. this.wikiPages = [];
  2120. var xhr = await Xhr.get(`/w/index.php?search=${this.threadID}&fulltext=1&limit=500`);
  2121. this.wikiLastSearchTime = Date.now();
  2122. }
  2123. catch(e) {
  2124. return;
  2125. }
  2126. //var threadID = xhr.responseURL.match(new RegExp("search=([0-9]+)"))[1];
  2127. var doc = this.doc.implementation.createHTMLDocument(); //we create a HTML document, but don't load the images or scripts therein
  2128. doc.documentElement.innerHTML = xhr.response;
  2129. var results = [...doc.querySelectorAll(".searchmatch")].filter(el => el.textContent == this.threadID);
  2130. if (results.length === 0) {
  2131. return;
  2132. }
  2133. //filter wiki search results to the ones that have the threadID in the quest info box
  2134. var theRightOnes = results.filter(el => {var p = el.previousSibling; return p && p.nodeType == Node.TEXT_NODE && p.textContent.match(new RegExp("[0-9]=$")); });
  2135. if (theRightOnes.length === 0) {
  2136. return;
  2137. }
  2138. return theRightOnes.map(el => el.parentElement.previousElementSibling.querySelector("a").href);
  2139. }
  2140.  
  2141. async getWikiData(wikiUrl) {
  2142. var wikiPageName = wikiUrl.substring(wikiUrl.lastIndexOf("/") + 1);
  2143. if (this.onWikiDataLoad !== null) {
  2144. let e = { threadID: this.threadID, threadType: this.threadType, boardName: this.boardName, wikiPageName: wikiPageName, wikiData: null };
  2145. this.onWikiDataLoad(e);
  2146. if (e.wikiData && e.wikiData.retrieveTime && Date.now() - e.wikiData.retrieveTime < 24 * 60 * 60000) { //flush cache every 24 hours
  2147. return e.wikiData;
  2148. }
  2149. }
  2150. var wikiData = { retrieveTime: Date.now() };
  2151. try {
  2152. var xhr = await Xhr.get(wikiUrl);
  2153. }
  2154. catch(e) {
  2155. this.wikiPages = this.wikiPages.filter(el => el !== wikiPageName);
  2156. this.settingsChanged();
  2157. return;
  2158. }
  2159. //parse quest wiki
  2160. var doc = this.doc.implementation.createHTMLDocument();
  2161. doc.documentElement.innerHTML = xhr.response;
  2162. var links = [...doc.querySelectorAll(".infobox a")];
  2163. links = links.filter(link => link.href.indexOf("image-for") < 0);
  2164. var threadLinks = links.filter(l => l.href.indexOf("/quest/") >= 0 || l.href.indexOf("/questarch/") >= 0 || l.href.indexOf("/graveyard/") >= 0);
  2165. var disThreadLinks = links.filter(l => l.href.indexOf("/questdis/") >= 0);
  2166. //create thread groups, like they're in the wiki
  2167. wikiData.threadGroups = [];
  2168. var groups = new Map();
  2169. [...threadLinks, ...disThreadLinks].forEach(link => {
  2170. var key = link.parentElement.parentElement;
  2171. var group = groups.get(key) || [];
  2172. group.push({name: link.textContent, url: link.href});
  2173. groups.set(key, group);
  2174. });
  2175. groups.forEach((links, key) => wikiData.threadGroups.push(links));
  2176. wikiData.threadGroups.forEach(group => group.forEach(link => {
  2177. if (link.name == "Thread" && group.length === 1) {
  2178. link.name = "Thread 1";
  2179. }
  2180. }));
  2181. //get quest author and title
  2182. var infoboxHeader = doc.querySelector(".infobox big");
  2183. if (infoboxHeader) {
  2184. var children = [...infoboxHeader.childNodes];
  2185. wikiData.questTitle = children.shift().textContent;
  2186. wikiData.byAuthors = children.reduce((acc, el) => {acc += el.textContent; return acc;}, "");
  2187. }
  2188. return wikiData;
  2189. }
  2190.  
  2191. updateControls() {
  2192. var leftDisabled = this.currentUpdate() == this.firstUpdate();
  2193. var rightDisabled = this.currentUpdate() == this.lastUpdate();
  2194. var current = this.currentUpdateIndex + 1;
  2195. var last = this.updates.length;
  2196. var infoUpdate = this.currentUpdate();
  2197. if (this.viewMode == "sequence") {
  2198. leftDisabled = this.currentUpdate().sequence == this.firstUpdate().sequence;
  2199. rightDisabled = this.currentUpdate().sequence == this.lastUpdate().sequence;
  2200. current = this.sequences.indexOf(this.currentUpdate().sequence) + 1;
  2201. last = this.sequences.length;
  2202. infoUpdate = this.currentUpdate().sequence[this.currentUpdate().sequence.length - 1];
  2203. }
  2204. // buttons
  2205. [...this.controls.showFirstButtons, ...this.controls.showPrevButtons].forEach(button => { button.disabled = leftDisabled; });
  2206. [...this.controls.showNextButtons, ...this.controls.showLastButtons].forEach(button => { button.disabled = rightDisabled; });
  2207. // update info
  2208. this.controls.currentPosLabels.forEach(label => { label.textContent = current; label.classList.toggle("qrLastIndex", current == last); });
  2209. this.controls.totalPosLabels.forEach(label => { label.textContent = last; });
  2210. this.controls.updateInfos.forEach(infoContainer => { infoContainer.classList.toggle("hidden", !this.showUpdateInfo); });
  2211. if (this.showUpdateInfo) {
  2212. this.controls.authorCommentsCountLabels[0].textContent = ` A:${this.viewMode !== "all" ? infoUpdate.authorComments.length : this.total.authorComments}`;
  2213. this.controls.authorCommentsCountLabels[1].textContent = ` A:${infoUpdate.authorComments.length}`;
  2214. this.controls.suggestionsCountLabels[0].textContent = ` S:${this.viewMode !== "all" ? infoUpdate.suggestions.length : this.total.suggestions}`;
  2215. this.controls.suggestionsCountLabels[1].textContent = ` S:${infoUpdate.suggestions.length}`;
  2216. this.updateSuggestersLabel(this.controls.suggestersCountLabels[0], this.viewMode === "all" ? this.suggesters : infoUpdate.suggesters);
  2217. this.updateSuggestersLabel(this.controls.suggestersCountLabels[1], infoUpdate.suggesters);
  2218. }
  2219. // settings
  2220. this.controls.viewModeDropdown.value = this.viewMode;
  2221. this.controls.showSuggestionsDropdown.value = this.showSuggestions;
  2222. this.controls.showAuthorCommentsDropdown.value = this.showAuthorComments;
  2223. this.controls.showReferencesDropdown.value = this.showReferences;
  2224. this.controls.replyFormLocationDropdown.value = this.replyFormLocation;
  2225. this.controls.expandImagesDropdown.value = this.expandImages;
  2226. this.controls.maxImageWidthTextbox.value = this.maxImageWidth;
  2227. this.controls.maxImageHeightTextbox.value = this.maxImageHeight;
  2228. this.controls.stretchImagesCheckbox.checked = this.stretchImages;
  2229. this.controls.imageWidthLabel.textContent = this.stretchImages ? "Image container width" : "Max image width";
  2230. this.controls.imageHeightLabel.textContent = this.stretchImages ? "Image container height" : "Max image height";
  2231. this.controls.showUpdateInfoCheckbox.checked = this.showUpdateInfo;
  2232. this.controls.timestampFormatDropdown.value = this.timestampFormat;
  2233. this.controls.colorUsersCheckbox.checked = this.colorUsers;
  2234. this.controls.showUserPostCountsCheckbox.checked = this.showUserPostCounts;
  2235. this.controls.showUserNavCheckbox.checked = this.showUserNav;
  2236. this.controls.showUserMultiPostCheckbox.checked = this.showUserMultiPost;
  2237. // sticky controls when viewing whole thread (only in quest threads)
  2238. if (this.threadType == ThreadType.QUEST) {
  2239. this.controls.navControls[1].classList.toggle("stickyBottom", this.viewMode == "all");
  2240. this.controls.navLinksContainers[1].classList.toggle("qrNavLinksBottom", this.viewMode == "all");
  2241. var areLinksInGrid = this.controls.navLinksContainers[1].parentElement.classList.contains("qrNavControls");
  2242. if (areLinksInGrid && this.viewMode == "all") { //move the links out of the grid so they don't appear in the floating nav controls
  2243. this.controls.navControls[1].insertAdjacentElement("beforeBegin", this.controls.navLinksContainers[1]);
  2244. }
  2245. else if (!areLinksInGrid && this.viewMode != "all") { //move the links back into the grid
  2246. this.controls.navControls[1].insertAdjacentElement("beforeEnd", this.controls.navLinksContainers[1]);
  2247. }
  2248. }
  2249.  
  2250. /* // sentinels for full thread view
  2251. var topOfCurrent = 0;
  2252. var bottomOfCurrent = 0;
  2253. if (this.viewMode == "all") {
  2254. if (this.currentUpdate() != this.firstUpdate()) {
  2255. topOfCurrent = this.posts.get(this.currentUpdate().updatePostID).outer.offsetTop;
  2256. }
  2257. if (this.currentUpdate() != this.lastUpdate()) {
  2258. bottomOfCurrent = this.posts.get(this.updates[this.currentUpdateIndex + 1].updatePostID).outer.offsetTop;
  2259. }
  2260. this.sentinelPreviousEl.style.height = `${topOfCurrent}px`; //end of previous is top of current;
  2261. this.sentinelCurrentEl.style.height = `${bottomOfCurrent}px`; //end of current is the top of next
  2262. }
  2263. this.sentinelPreviousEl.classList.toggle("hidden", this.viewMode != "all" || topOfCurrent === 0);
  2264. this.sentinelCurrentEl.classList.toggle("hidden", this.viewMode != "all" || bottomOfCurrent === 0);
  2265. */
  2266. // reply form juggling
  2267. var isReplyFormAtTop = (this.controls.replymode == this.controls.postarea.previousElementSibling);
  2268. if (this.replyFormLocation != "top" && isReplyFormAtTop) { //move it down
  2269. this.controls.postarea.remove();
  2270. this.doc.body.insertBefore(this.controls.postarea, this.controls.navbar);
  2271. this.controls.controlsTop.previousElementSibling.insertAdjacentHTML("beforeBegin", "<hr>");
  2272. }
  2273. else if (this.replyFormLocation == "top" && !isReplyFormAtTop) { //move it up
  2274. this.controls.postarea.remove();
  2275. this.controls.replymode.insertAdjacentElement("afterEnd", this.controls.postarea);
  2276. this.controls.controlsTop.previousElementSibling.previousElementSibling.remove(); //remove <hr>
  2277. }
  2278. this.controls.replyForm.classList.toggle("hidden" , !this.showReplyForm);
  2279. this.controls.replyFormRestoreButton.classList.toggle("hidden", this.showReplyForm);
  2280.  
  2281. if (this.viewMode == "all" && !this.scrollIntervalHandle) {
  2282. this.scrollIntervalHandle = setInterval(() => { if (this.viewMode == "all" && Date.now() - this.lastScrollTime < 250) { this.handleScroll(); } }, 50);
  2283. }
  2284. else if (this.viewMode != "all" && this.scrollIntervalHandle) {
  2285. clearInterval(this.scrollIntervalHandle);
  2286. this.scrollIntervalHandle = null;
  2287. }
  2288. }
  2289.  
  2290. updateSuggestersLabel(label, suggesters) {
  2291. var anons = suggesters.filter(el => el.children.length === 0);
  2292. var newAnons = anons.filter(el => el.isNew);
  2293. label.textContent = `U:${suggesters.length}`;
  2294. label.title =
  2295. `# of unique suggesters for the visible updates. Of these there are:
  2296. ${suggesters.length - anons.length} named suggesters
  2297. ${anons.length - newAnons.length} unnamed suggesters (familiar)
  2298. ${newAnons.length} unnamed suggesters (new)`;
  2299. }
  2300.  
  2301. insertControls(doc) {
  2302. //cache existing top-level html elements based their class name or id for faster access
  2303. [...doc.body.children].forEach(child => {
  2304. var name = child.classList[0] || child.id;
  2305. if (name) {
  2306. this.controls[name] = child;
  2307. }
  2308. });
  2309. this.controls.navControls = [];
  2310. //top controls
  2311. var delform = this.posts.get(this.threadID).outer;
  2312. var fragment = doc.createRange().createContextualFragment(this.getTopControlsHtml());
  2313. this.controls.controlsTop = fragment.firstElementChild;
  2314. this.controls.navControls.push(this.controls.controlsTop.querySelector(".qrNavControls"));
  2315. delform.parentElement.insertBefore(fragment, delform);
  2316. //bottom nav controls
  2317. fragment = doc.createRange().createContextualFragment(`${this.getNavControlsHtml()}<hr>`);
  2318. this.controls.navControls.push(fragment.firstElementChild);
  2319. delform.insertBefore(fragment, delform.lastElementChild);
  2320. //make reply form collapsable
  2321. this.controls.postarea.insertAdjacentHTML("afterBegin", `<div class="hidden">[<a href="#" id="qrReplyFormRestoreButton">Reply</a>]</div>`);
  2322. this.controls.replyFormRestoreButton = this.controls.postarea.firstElementChild;
  2323. //reply form header
  2324. this.controls.replyForm = doc.querySelector("#postform");
  2325. this.controls.replyForm.querySelector(".postform").insertAdjacentHTML("afterBegin", this.getReplyFormHeaderHtml()); //note that we can't create a <thead> element in a fragment without table
  2326. this.controls.replyFormMinimizeButton = this.controls.replyForm.querySelector("#qrReplyFormMinimizeButton");
  2327. this.controls.replyFormPopoutButton = this.controls.replyForm.querySelector("#qrReplyFormPopoutButton");
  2328.  
  2329. //when viewing full thread, we want to detect and remember where we are; something something IntersectionObserver
  2330. /*doc.body.insertAdjacentHTML("afterBegin", `<div class="sentinel hidden"></div><div class="sentinel hidden"></div>`);
  2331. this.sentinelPreviousEl = doc.body.firstChild;
  2332. this.sentinelCurrentEl = doc.body.firstChild.nextSibling;
  2333. this.sentinelPrevious = new IntersectionObserver((entries, observer) => { this.handleSentinel(entries, observer); }, { rootMargin: "2px" } ); //need to pass the callback like this to keep the context
  2334. this.sentinelCurrent = new IntersectionObserver((entries, observer) => { this.handleSentinel(entries, observer); }, { rootMargin: "2px" } );
  2335. this.sentinelPrevious.observe(this.sentinelPreviousEl);
  2336. this.sentinelCurrent.observe(this.sentinelCurrentEl);*/
  2337.  
  2338. //cache control elements for faster access
  2339. var queries = [".qrNavLinksContainer", ".qrShowFirstButton", ".qrShowPrevButton", ".qrShowNextButton", ".qrShowLastButton", ".qrWikiLink", ".qrQuestLink", ".qrDisLink", ".qrThreadLinksDropdown",
  2340. ".qrNavPosition", ".qrCurrentPosLabel", ".qrTotalPosLabel", ".qrUpdateInfo", ".qrAuthorCommentsCountLabel", ".qrSuggestionsCountLabel", ".qrSuggestersCountLabel", ];
  2341. queries.forEach((query) => {
  2342. var controlGroupName = `${query[3].toLowerCase()}${query.substring(4)}s`;
  2343. this.controls[controlGroupName] = [ this.controls.navControls[0].querySelector(query), this.controls.navControls[1].querySelector(query)];
  2344. });
  2345. this.controls.settingsControls = this.controls.controlsTop.querySelector(".qrSettingsControls");
  2346. this.controls.controlsTop.querySelectorAll("[id]").forEach(el => {
  2347. this.controls[`${el.id[2].toLowerCase()}${el.id.substring(3)}`] = el;
  2348. });
  2349. if (this.threadType !== ThreadType.QUEST) { //hide controls which aren't relevant in non-quest threads
  2350. var controlsToHide = [...this.controls.showFirstButtons, ...this.controls.showPrevButtons, ...this.controls.showNextButtons, ...this.controls.showLastButtons, ...this.controls.navPositions];
  2351. controlsToHide.forEach(el => el.classList.add("transparent"));
  2352. var settingsToHide = [this.controls.viewModeLabel, this.controls.viewModeDropdown,
  2353. this.controls.showSuggestionsLabel, this.controls.showSuggestionsDropdown,
  2354. this.controls.showAuthorCommentsLabel, this.controls.showAuthorCommentsDropdown,
  2355. this.controls.showUpdateInfoLabel, this.controls.showUpdateInfoCheckbox,
  2356. this.controls.showUserMultiPostLabel, this.controls.showUserMultiPostCheckbox,
  2357. this.controls.keyboardShortcutsLabel, this.controls.keyboardShortcutsLabel.nextElementSibling];
  2358. settingsToHide.forEach(el => el.classList.add("hidden"));
  2359. this.controls.showReferencesDropdown.options[1].remove();
  2360. this.controls.expandImagesDropdown.options[1].remove();
  2361. }
  2362. if (this.threadType === ThreadType.OTHER) {
  2363. this.controls.wikiLinks.forEach(el => el.parentElement.classList.add("hidden"));
  2364. this.controls.threadLinksDropdowns.forEach(el => el.classList.add("hidden"));
  2365. }
  2366. }
  2367.  
  2368. /*handleSentinel(entries, observer) {
  2369. console.log(entries[0]);
  2370. var newUpdateIndex = this.currentUpdateIndex;
  2371. if (observer == this.sentinelPrevious && entries[0].isIntersecting) {
  2372. newUpdateIndex--;
  2373. }
  2374. else if (observer == this.sentinelCurrent && !entries[0].isIntersecting) {
  2375. newUpdateIndex++;
  2376. }
  2377. if (newUpdateIndex != this.currentUpdateIndex && newUpdateIndex >= 0 && newUpdateIndex < this.updates.length) {
  2378. this.currentUpdateIndex = newUpdateIndex;
  2379. this.updateControls();
  2380. this.settingsChanged();
  2381. }
  2382. }*/
  2383.  
  2384. insertStyling(doc) {
  2385. doc.head.insertAdjacentHTML("beforeEnd", `<style id="qrMainCss">${this.getMainStyleRules(doc)}</style>`);
  2386. doc.head.insertAdjacentHTML("beforeEnd", `<style id="qrImageSizeCss">${this.getImageSizesStyleRules()}</style>`);
  2387. doc.head.insertAdjacentHTML("beforeEnd", `<style id="qrReferencesCss">${this.getReferencesStyleRules()}</style>`);
  2388. doc.head.insertAdjacentHTML("beforeEnd", `<style id="qrUserColorsCss">${this.getUserColorsStyleRules()}</style>`);
  2389. doc.head.insertAdjacentHTML("beforeEnd", `<style id="qrUserPostCountsCss">${this.getUserPostCountsStyleRules()}</style>`);
  2390. doc.head.insertAdjacentHTML("beforeEnd", `<style id="qrUserNavCss">${this.getUserNavStyleRules()}</style>`);
  2391. doc.head.insertAdjacentHTML("beforeEnd", `<style id="qrUserMultiPostCss">${this.getUserMultiPostStyleRules()}</style>`);
  2392. }
  2393.  
  2394. insertEvents(doc) {
  2395. //events for our controls
  2396. this.controls.settingsToggleButton.addEventListener("click", e => this.toggleSettingsControls(e));
  2397. this.controls.settingsPageNav.addEventListener("click", e => this.showSettingsPage(e));
  2398. this.controls.viewModeDropdown.addEventListener("change", e => this.updateSettings(e));
  2399. this.controls.showSuggestionsDropdown.addEventListener("change", e => this.updateSettings(e));
  2400. this.controls.showAuthorCommentsDropdown.addEventListener("change", e => this.updateSettings(e));
  2401. this.controls.replyFormLocationDropdown.addEventListener("change", e => this.updateSettings(e));
  2402. this.controls.showReferencesDropdown.addEventListener("change", e => this.changePostElementSettings(e));
  2403. this.controls.expandImagesDropdown.addEventListener("change", e => this.updateSettings(e));
  2404. this.controls.maxImageWidthTextbox.addEventListener("change", e => this.changeImageSizeSettings(e));
  2405. this.controls.maxImageHeightTextbox.addEventListener("change", e => this.changeImageSizeSettings(e));
  2406. this.controls.stretchImagesCheckbox.addEventListener("click", e => this.changeImageSizeSettings(e));
  2407. this.controls.showUpdateInfoCheckbox.addEventListener("click", e => this.updateSettings(e));
  2408. this.controls.timestampFormatDropdown.addEventListener("change", e => this.changePostElementSettings(e));
  2409. this.controls.colorUsersCheckbox.addEventListener("click", e => this.changePostElementSettings(e));
  2410. this.controls.showUserPostCountsCheckbox.addEventListener("click", e => this.changePostElementSettings(e));
  2411. this.controls.showUserNavCheckbox.addEventListener("click", e => this.changePostElementSettings(e));
  2412. this.controls.showUserMultiPostCheckbox.addEventListener("click", e => this.changePostElementSettings(e));
  2413. this.controls.replyFormMinimizeButton.addEventListener("click", e => this.toggleReplyForm(e));
  2414. this.controls.replyFormRestoreButton.addEventListener("click", e => this.toggleReplyForm(e));
  2415. this.controls.replyFormPopoutButton.addEventListener("click", e => this.popoutReplyForm(e));
  2416. this.controls.showFirstButtons.forEach(el => el.addEventListener("click", e => this.showFirst(e)));
  2417. this.controls.showPrevButtons.forEach(el => el.addEventListener("click", e => this.showPrevious(e)));
  2418. this.controls.showNextButtons.forEach(el => el.addEventListener("click", e => this.showNext(e)));
  2419. this.controls.showLastButtons.forEach(el => el.addEventListener("click", e => this.showLast(e)));
  2420. this.controls.threadLinksDropdowns.forEach(el => el.addEventListener("change", e => this.changeThread(e)));
  2421.  
  2422. //events for other controls
  2423. this.controls.replyForm.querySelector(`input[type="submit"][value="Reply"]`).addEventListener("click", (e) => {
  2424. var userName = this.controls.replyForm(`input[name="name"]`).value.trim().toLowerCase();
  2425. if(this.author.id === userName || this.author.children.some(child => child.id === userName)) {
  2426. this.moveToLast = true;
  2427. this.settingsChanged();
  2428. }
  2429. });
  2430.  
  2431. //global events
  2432. doc.defaultView.addEventListener("hashchange", (e) => { //if the #hash at the end of url changes, it means the user clicked a post link and we need to show him the update that contains that post
  2433. var oldPostID = parseInt(new URL(e.oldURL).hash.replace("#", ""));
  2434. var newPostID = parseInt(new URL(e.newURL).hash.replace("#", ""));
  2435. if (!isNaN(oldPostID) && oldPostID !== this.threadID && this.posts.has(oldPostID)) {
  2436. this.posts.get(oldPostID).inner.classList.remove("highlight");
  2437. this.posts.get(oldPostID).inner.classList.add("reply");
  2438. }
  2439. if (!isNaN(newPostID) && newPostID !== this.threadID && this.posts.has(newPostID)) {
  2440. this.posts.get(newPostID).inner.classList.add("highlight");
  2441. this.refresh({checkHash: "true"});
  2442. }
  2443. });
  2444.  
  2445. this.lastScrollTime = 0;
  2446. doc.defaultView.addEventListener("wheel", (e) => { //after the wheeling has finished, check if the user
  2447. this.lastScrollTime = Date.now();
  2448. });
  2449.  
  2450. doc.addEventListener("keydown", (e) => {
  2451. if (this.threadType !== ThreadType.QUEST) {
  2452. return;
  2453. }
  2454. var inputTypes = ["text", "password", "number", "email", "tel", "url", "search", "date", "datetime", "datetime-local", "time", "month", "week"];
  2455. if (e.target.tagName === "TEXTAREA" || e.target.tagName === "SELECT" || (e.target.tagName === "INPUT" && inputTypes.indexOf(e.target.type) >= 0)) {
  2456. return; //prevent our keyboard shortcuts when focused on a text input field
  2457. }
  2458. if (e.altKey || e.shiftKey || e.ctrlKey) { //alt+left arrow, or alt+right arrow, for the obvious reasons we don't want to handle those, or any other combination for that matter
  2459. return;
  2460. }
  2461. if (e.key == "ArrowRight") {
  2462. e.preventDefault();
  2463. this.showNext();
  2464. }
  2465. else if (e.key == "ArrowLeft") {
  2466. e.preventDefault();
  2467. this.showPrevious();
  2468. }
  2469. var scrollKeys = ["ArrowUp", "ArrowDown", " ", "PageUp", "PageDown", "Home", "End"]; //it turns out that scrolling the page is possible with stuff other than mouse wheel
  2470. if (scrollKeys.indexOf(e.key) >= 0) {
  2471. this.lastScrollTime = Date.now();
  2472. }
  2473. });
  2474. doc.addEventListener("click", (e) => {
  2475. //clicking on a reflink in post header should show the Reply form and THEN insert text and focus it
  2476. var node = e.target;
  2477. if (node.nodeType === Node.TEXT_NODE) {
  2478. return;
  2479. }
  2480. if (node.nodeName === "A") {
  2481. if (node.parentElement.classList.contains("reflink") && node.parentElement.lastElementChild == node) {
  2482. e.preventDefault();
  2483. if (!this.showReplyForm) {
  2484. this.controls.replyFormRestoreButton.click();
  2485. if (this.replyFormLocation == "float" && !this.controls.replyForm.classList.contains("qrPopout")) {
  2486. this.controls.replyFormPopoutButton.click();
  2487. }
  2488. }
  2489. return doc.defaultView.insert(`>>${node.textContent}\n`);
  2490. }
  2491. }
  2492. //clicking on the post timestamp in the post header should change the time format
  2493. else if (node.nodeName == "LABEL" && node.parentElement.classList.contains("postwidth")) {
  2494. var posterEl = node.querySelector(".postername");
  2495. if (e.offsetX <= posterEl.offsetLeft + posterEl.offsetWidth) {
  2496. return;
  2497. }
  2498. e.preventDefault();
  2499. if (this.relativeTime === null ) {
  2500. var post = this.posts.get(parseInt(node.parentElement.firstElementChild.name));
  2501. var currentDateTime = new Date();
  2502. this.relativeTime = this.timestampFormat == "auto" ? (currentDateTime.getTime() - this.getUtcTime(post, node.lastChild)) >= 86400000 : this.timestampFormat != "relative" && this.timestampFormat != "relativeUpd";
  2503. }
  2504. else {
  2505. this.relativeTime = !this.relativeTime;
  2506. }
  2507. this.refresh({scroll: false});
  2508. this.settingsChanged();
  2509. }
  2510. });
  2511. //should also remove the unnecessary click events;
  2512. this.posts.forEach(post => {
  2513. post.header.querySelector(".reflink").lastElementChild.onclick = null;
  2514. });
  2515. //clicking on style links should rebuild our style
  2516. this.controls.adminbar.querySelectorAll("a").forEach(el => {
  2517. if (!el.title && !el.target) {
  2518. el.addEventListener("click", (e) => {
  2519. doc.head.querySelector("#qrMainCss").innerHTML = this.getMainStyleRules(doc);
  2520. });
  2521. }
  2522. });
  2523. //The site's highlight() function is slow and buggy. In fact, it should be bound to the hashchange event... actually a css pseudo selector :target should've been used for the effect
  2524. //Of course, currently it won't work because the anchors with the IDs are inside headers instead of the elements themselves being anchors
  2525. doc.defaultView.highlight = new Function();
  2526. doc.defaultView.checkhighlight = () => {
  2527. var hashedPostID = parseInt(doc.location.hash.replace("#", ""));
  2528. if (!isNaN(hashedPostID) && hashedPostID != this.threadID && this.posts.has(hashedPostID)) {
  2529. this.posts.get(hashedPostID).inner.classList.add("highlight");
  2530. }
  2531. }
  2532. //similarily the addpreviewevents function is doing it wrong; we should insert only 2 events for the document and use the bubbling instead of inserting hundreds of events
  2533. doc.defaultView.addpreviewevents = new Function();
  2534. doc.addEventListener("mouseover", function(e) {
  2535. if (e.target.nodeName === "A" && e.target.className.startsWith("ref|")) {
  2536. e.view.addreflinkpreview(e);
  2537. }
  2538. });
  2539. doc.addEventListener("mouseout", function(e) {
  2540. if (e.target.nodeName === "A" && e.target.className.startsWith("ref|")) {
  2541. e.view.delreflinkpreview(e);
  2542. }
  2543. });
  2544. }
  2545.  
  2546. handleScroll() {
  2547. //check if the user scrolled to a different update on screen -> mark and save the position (only in whole thread view)
  2548. var indexes = this.getVisibleUpdateIndexes();
  2549. if (indexes == null) {
  2550. return;
  2551. }
  2552. if (this.currentUpdateIndex != indexes[0]) {
  2553. this.currentUpdateIndex = indexes[0];
  2554. this.updateControls();
  2555. this.settingsChanged();
  2556. }
  2557. var updatesToExpand = indexes.map(index => this.updates[index]);
  2558. if (this.updates.length > indexes[indexes.length - 1] + 1) {
  2559. updatesToExpand.push(this.updates[indexes[indexes.length - 1] + 1]);
  2560. }
  2561. updatesToExpand.forEach(update => this.expandUpdateImages(update));
  2562. }
  2563.  
  2564. getVisibleUpdateIndexes() {
  2565. var currentUpdatePost = this.posts.get(this.currentUpdate().updatePostID);
  2566. var currentUpdateIsAboveViewPort = !currentUpdatePost || currentUpdatePost.outer.offsetTop <= this.doc.defaultView.scrollY;
  2567. var topmostVisibleUpdateIndex = this.currentUpdateIndex;
  2568. var el;
  2569. if (currentUpdateIsAboveViewPort) { //search down
  2570. for (; topmostVisibleUpdateIndex < this.updates.length - 1; topmostVisibleUpdateIndex++) {
  2571. el = this.posts.get(this.updates[topmostVisibleUpdateIndex + 1].updatePostID);
  2572. if (!el) {
  2573. continue;
  2574. }
  2575. if (el.outer.offsetTop > this.doc.defaultView.scrollY) {
  2576. break;
  2577. }
  2578. }
  2579. }
  2580. else { //search up
  2581. for (; topmostVisibleUpdateIndex > 0; topmostVisibleUpdateIndex--) {
  2582. el = this.posts.get(this.updates[topmostVisibleUpdateIndex - 1].updatePostID);
  2583. if (!el || el.outer.offsetTop < this.doc.defaultView.scrollY) {
  2584. topmostVisibleUpdateIndex--;
  2585. break;
  2586. }
  2587. }
  2588. }
  2589. var indexes = [ topmostVisibleUpdateIndex ];
  2590. //var bottommostVisibleUpdateIndex = topmostVisibleUpdateIndex;
  2591. var windowOffsetBottom = this.doc.defaultView.scrollY + this.doc.documentElement.clientHeight;
  2592. for (; topmostVisibleUpdateIndex < this.updates.length - 1; topmostVisibleUpdateIndex++) {
  2593. el = this.posts.get(this.updates[topmostVisibleUpdateIndex + 1].updatePostID);
  2594. if (!el || el.outer.offsetTop > windowOffsetBottom) {
  2595. break;
  2596. }
  2597. indexes.push(topmostVisibleUpdateIndex + 1);
  2598. }
  2599. return indexes;
  2600. }
  2601.  
  2602. modifyLayout(doc) {
  2603. //change tab title to quest's title
  2604. var op = this.posts.get(this.threadID);
  2605. var label = op.header.querySelector("label");
  2606. this.hasTitle = !!label.querySelector(".filetitle");
  2607. var title = label.querySelector(".filetitle") || label.querySelector(".postername");
  2608. title = title.textContent.trim();
  2609. doc.title = title !== this.defaultName ? title : "Untitled Quest";
  2610. this.controls.logo.textContent = doc.title;
  2611. //extend vertical size to prevent screen jumping when navigating updates
  2612. this.controls.controlsTop.insertAdjacentHTML("beforeBegin", `<div class="haveOneScreenOfSpaceBelowHereSoItIsPossibleToScroll" />`);
  2613. //extend vertical size so it's possible to scroll to the last update in full thread view
  2614. var lastUpdatePost = this.posts.get(this.lastUpdate().updatePostID);
  2615. if (lastUpdatePost) {
  2616. lastUpdatePost.outer.insertAdjacentHTML("afterBegin", `<div class="haveOneScreenOfSpaceBelowHereSoItIsPossibleToScroll" />`);
  2617. }
  2618. //prevent wrapping posts around the OP; setting clear:left on the 2nd post doesn't work because that element might be hidden
  2619. op.inner.querySelector("blockquote").insertAdjacentHTML("afterEnd", `<div style="clear: left;"></div>`);
  2620. //prevent wrapping text underneath update images by setting update post width to 100%
  2621. this.updates.forEach(update => { //need to be careful to not set the OP (form) to 100% width because it would override BLICK's dumb width settings
  2622. var updatePost = this.posts.get(update.updatePostID);
  2623. if (updatePost) {
  2624. updatePost.inner.classList.add(update === this.firstUpdate() ? "updateOp" : "update");
  2625. }
  2626. });
  2627. //Fix OP header so that the image wraps underneath it, like the other posts
  2628. while(op.header.firstChild.name != this.threadID) {
  2629. op.header.appendChild(op.header.firstChild);
  2630. }
  2631. //hide "Expand all images" link; The link isn't always present
  2632. var expandLink = op.inner.querySelector(`a[href="#top"]`);
  2633. if (expandLink) {
  2634. expandLink.classList.add("hidden");
  2635. }
  2636. //remove the "Report completed threads!" message from the top
  2637. var message = (doc.body.querySelector("center") || doc.createElement("center")).querySelector(".filetitle");
  2638. if (message) {
  2639. message.classList.add("hidden");
  2640. }
  2641. var replyForm = this.controls.replyForm;
  2642. //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
  2643. var replyToPostEl = replyForm.querySelector("#posttypeindicator");
  2644. if (replyToPostEl) {
  2645. [...replyToPostEl.parentElement.childNodes].filter(el => el && el.nodeType == HTMLElement.TEXT_NODE).forEach(el => el.remove());
  2646. replyToPostEl.remove();
  2647. }
  2648. [...replyForm.querySelector(`input[name="name"]`).parentElement.childNodes].forEach(el => { if (el.nodeType == HTMLElement.TEXT_NODE) el.remove(); });
  2649. [...replyForm.querySelector(`input[name="em"]`).parentElement.childNodes].forEach(el => { if (el.nodeType == HTMLElement.TEXT_NODE) el.remove(); });
  2650. [...replyForm.querySelector(`input[name="subject"]`).parentElement.childNodes].forEach(el => { if (el.nodeType == HTMLElement.TEXT_NODE) el.remove(); });
  2651. //move the upload file limitations info into a tooltip
  2652. var filetd = replyForm.querySelector(`input[type="file"]`);
  2653. var fileRulesEl = replyForm.querySelector("td.rules");
  2654. fileRulesEl.classList.add("hidden");
  2655. var fileRules = [...fileRulesEl.querySelectorAll("li")].splice(1, 2);
  2656. fileRules = fileRules.map(el => el.textContent.replace(new RegExp("[ \n]+", "g"), " ").trim()).join("\n");
  2657. filetd.insertAdjacentHTML("afterEnd", `&nbsp<span class="qrTooltip" title="${fileRules}">*</span>`);
  2658. //move the password help line into a tooltip
  2659. var postPasswordEl = replyForm.querySelector(`input[name="postpassword"]`);
  2660. postPasswordEl.nextSibling.remove();
  2661. postPasswordEl.insertAdjacentHTML("afterEnd", `&nbsp<span class="qrTooltip" title="Password for post and file deletion">?</span>`);
  2662. //reply form input placeholders
  2663. (replyForm.querySelector(`[name="name"]`) || {}).placeholder = `Name (leave empty for ${this.defaultName})`;
  2664. (replyForm.querySelector(`[name="em"]`) || {}).placeholder = "Options";
  2665. (replyForm.querySelector(`[name="em"]`) || {}).title = "sage | dice 1d6";
  2666. (replyForm.querySelector(`[name="subject"]`) || {}).placeholder = "Subject";
  2667. (replyForm.querySelector(`[name="message"]`) || {}).placeholder = "Message";
  2668. var embed = replyForm.querySelector(`[name="embed"]`);
  2669. if (embed) {
  2670. embed.parentElement.parentElement.style.display = "none";
  2671. }
  2672. //remove that annoying red strip
  2673. this.controls.replymode.classList.add("hidden");
  2674. }
  2675.  
  2676. insertTooltips() {
  2677. var c = this.controls;
  2678. c.settingsToggleButton.title = `Show/Hide Quest Reader settings`;
  2679. c.settingsPageNav.children[0].title = `General settings`;
  2680. c.settingsPageNav.children[1].title = `Image settings`;
  2681. c.settingsPageNav.children[2].title = `Analytics settings`;
  2682. c.viewModeLabel.title = `Control whether to view the thread as a comic (a set of pages) or not.
  2683. Whole thread: Show the thread at it originally is, with all the update posts visible
  2684. Paged per update: Turn the thread into pages, with a single update post per page
  2685. Paged per sequence: Turn the thread into pages, with a set of sequential update posts per page`;
  2686. c.showSuggestionsLabel.title = `Show quest suggestions. Quest suggestions are non-author posts containing directions for the quest or its characters.`;
  2687. c.showAuthorCommentsLabel.title = `Show author comments. Author comments are author posts which aren't updates.`;
  2688. c.showReferencesLabel.title = `Show links to references underneath each post or not. (References are >>links pointing to the post)`;
  2689. c.timestampFormatLabel.title = `Control the time and format of post timestamps.
  2690. Server time: Show when the post was made in server's timezone.
  2691. Local time: Show when the post was made in local timezone.
  2692. Relative to now: Show how much time has passed since the post was made to right now.
  2693. Auto: Show relative time if the post was made less than a day ago, otherwise show local time.
  2694. Hidden: Hide post timestamps.`;
  2695. c.replyFormLocationLabel.title = `Control the location and the way in which the Reply form is shown.
  2696. At top: The Reply form is located at the top of the page
  2697. At bottom: The Reply form is located at the bottom of the page
  2698. Auto-float: Show a floating Reply form when clicking on any of the post ID links, or the "Reply" link at the bottom`;
  2699. c.expandImagesLabel.title = `Automatically expand images only when they appear on screen`;
  2700. c.stretchImagesLabel.title = `Allow expanding the images beyond their intrinsic resolution to fit an image container.
  2701. The size of the image container is defined by the "Image container width" and "Image container height" settings.`;
  2702. c.showUpdateInfoLabel.title = `Show some info about the update next to the navigation controls.
  2703. The info contains three numbers: The amount of [A]uthor comments, the amount of [S]uggestions, and the amount of [U]nique suggesters for the update.`;
  2704. c.colorUsersLabel.title = `Colorize the poster ID of every suggestion post`;
  2705. c.showUserPostCountsLabel.title = `Show user's post count to the right of every poster ID; if the number is red, it means the user only made posts within one update.`;
  2706. c.showUserNavLabel.title = `Show buttons to the right of every user ID which allow navigating to the user's previous and next post`;
  2707. c.showUserMultiPostLabel.title = `Show a red indication next to posts where the suggester has made more than one suggestion for the update`;
  2708. c.authorCommentsCountLabels.forEach(el => { el.title = `# of author comment posts for the visible updates`; });
  2709. c.suggestionsCountLabels.forEach(el => { el.title = `# of suggestion posts for the visible updates`; });
  2710. c.suggestersCountLabels.forEach(el => { el.title = `# of unique suggesters for the visible updates`; });
  2711. c.wikiLinks.forEach(el => { el.title = `Link to the quest's wiki page`; });
  2712. c.disLinks.forEach(el => { el.title = `Link to the quest's latest discussion thread`; });
  2713. c.questLinks.forEach(el => { el.title = `Link to the quest's last visited or last created quest thread`; });
  2714. c.threadLinksDropdowns.forEach(el => { el.title = `Current quest thread`; });
  2715. c.replyFormMinimizeButton.title = `Hide the Reply form`;
  2716. c.replyFormPopoutButton.title = `Pop out the Reply form and have it float`;
  2717. }
  2718.  
  2719. getTopControlsHtml() {
  2720. return `
  2721. <div class="qrControlsTop">
  2722. <div class="qrSettingsToggle">[<a href="#" id="qrSettingsToggleButton">Settings</a>]</div>
  2723. ${this.getNavControlsHtml()}
  2724. ${this.getSettingsControlsHtml()}
  2725. <hr>
  2726. </div>`;
  2727. }
  2728.  
  2729. getSettingsControlsHtml() {
  2730. return `
  2731. <div class="qrSettingsControls collapsedHeight">
  2732. <span id="qrSettingsPageNav">
  2733. <div class="qrSettingsNavItem qrSettingsNavItemSelected">[<a href="#">General</a>]</div>
  2734. <div class="qrSettingsNavItem">[<a href="#">Images</a>]</div>
  2735. <div class="qrSettingsNavItem">[<a href="#">Analytics</a>]</div>
  2736. </span>
  2737. <div class="qrSettingsPages">
  2738. <div class="qrSettingsPage qrCurrentPage">
  2739. <div id="qrViewModeLabel">Viewing mode</div>
  2740. <select id="qrViewModeDropdown" class="qrSettingsControl"><option value="all">Whole thread</option><option value="single">Paged per update</option><option value="sequence">Paged per sequence</option></select>
  2741. <div id="qrShowReferencesLabel">Show post references</div>
  2742. <select id="qrShowReferencesDropdown" class="qrSettingsControl"><option value="none">Never</option><option value="nonupdates">For non-update posts</option><option value="all">For all</option></select>
  2743. <div id="qrShowSuggestionsLabel">Show suggestions</div>
  2744. <select id="qrShowSuggestionsDropdown" class="qrSettingsControl"><option value="none">Never</option><option value="last">Last update only</option><option value="all">Always</option></select>
  2745. <div id="qrTimestampFormatLabel">Timestamp format</div>
  2746. <select id="qrTimestampFormatDropdown" class="qrSettingsControl">
  2747. <option value="server">Server time</option>
  2748. <option value="local">Local time</option>
  2749. <option value="relative">Relative to now</option>
  2750. <option value="auto">Auto</option>
  2751. <option value="hide">Hidden</option>
  2752. </select>
  2753. <div id="qrShowAuthorCommentsLabel">Show author comments</div>
  2754. <select id="qrShowAuthorCommentsDropdown" class="qrSettingsControl"><option value="none">Never</option><option value="last">Last update only</option><option value="all">Always</option></select>
  2755. <div id="qrReplyFormLocationLabel">Reply form</div>
  2756. <select id="qrReplyFormLocationDropdown" class="qrSettingsControl"><option value="top">At top</option><option value="bottom">At bottom</option><option value="float">Auto-float</option></select>
  2757. <div id="qrKeyboardShortcutsLabel">Keyboard shortcuts</div>
  2758. <div class="qrSettingsControl qrTooltip">?<span class="qrTooltiptext">Left and Right arrow keys will <br>navigate between updates</span></div>
  2759. </div>
  2760. <div class="qrSettingsPage">
  2761. <div id="qrExpandImagesLabel">Expand images</div>
  2762. <select id="qrExpandImagesDropdown" class="qrSettingsControl"><option value="none">Do not</option><option value="updates">For updates</option><option value="all">For all</option></select>
  2763. <div id="qrImageWidthLabel">Max image width</div>
  2764. <div><input type="number" id="qrMaxImageWidthTextbox" class="qrSettingsControl" min=0 max=100 value=100> %</div>
  2765. <div id="qrStretchImagesLabel">Force fit images</div>
  2766. <input type="checkbox" id="qrStretchImagesCheckbox" class="qrSettingsControl">
  2767. <div id="qrImageHeightLabel">Max image height</div>
  2768. <div><input type="number" id="qrMaxImageHeightTextbox" class="qrSettingsControl" min=0 max=100 value=100> %</div>
  2769. </div>
  2770. <div class="qrSettingsPage">
  2771. <div id="qrShowUpdateInfoLabel">Show update info</div>
  2772. <input id="qrShowUpdateInfoCheckbox" type="checkbox" class="qrSettingsControl">
  2773. <div></div>
  2774. <div></div>
  2775. <div id="qrColorUsersLabel">Color ${this.threadType == ThreadType.QUEST ? "suggester" : "user"} IDs</div>
  2776. <input id="qrColorUsersCheckbox" type="checkbox" class="qrSettingsControl">
  2777. <div id="qrShowUserPostCountsLabel">Show user post counts</div>
  2778. <input id="qrShowUserPostCountsCheckbox" type="checkbox" class="qrSettingsControl">
  2779. <div id="qrShowUserNavLabel">Show per-user nav</div>
  2780. <input id="qrShowUserNavCheckbox" type="checkbox" class="qrSettingsControl">
  2781. <div id="qrShowUserMultiPostLabel">Show multi-posts</div>
  2782. <input id="qrShowUserMultiPostCheckbox" type="checkbox" class="qrSettingsControl">
  2783. </div>
  2784. </div>
  2785. </div>`;
  2786. }
  2787.  
  2788. getNavControlsHtml() {
  2789. return `
  2790. <div class="qrNavControls">
  2791. <span></span>
  2792. <span class="qrNavControl"><button class="qrShowFirstButton" type="button">First</button></span>
  2793. <span class="qrNavControl"><button class="qrShowPrevButton" type="button">Prev</button></span>
  2794. <span class="qrNavPosition qrOutline" title="Index of the currently shown update slash the total number of updates">
  2795. <label class="qrCurrentPosLabel">0</label> / <label class="qrTotalPosLabel">0</label>
  2796. </span>
  2797. <span class="qrNavControl"><button class="qrShowNextButton" type="button">Next</button></span>
  2798. <span class="qrNavControl"><button class="qrShowLastButton" type="button">Last</button></span>
  2799. <span>
  2800. <span class="qrUpdateInfo qrOutline">
  2801. <label class="qrAuthorCommentsCountLabel">A: 0</label>
  2802. <label class="qrSuggestionsCountLabel">S: 0</label>
  2803. <label class="qrSuggestersCountLabel">U: 0</label>
  2804. </span>
  2805. </span>
  2806. <span class="qrNavLinksContainer">${this.getLinksHtml()}</span>
  2807. </div>`;
  2808. }
  2809.  
  2810. getLinksHtml() {
  2811. return `
  2812. <span>[<a class="qrWikiLink" style="color: inherit">Wiki</a>]</span>
  2813. <span class="hidden">[<a class="qrQuestLink">Quest</a>]</span>
  2814. <span class="hidden">[<a class="qrDisLink">Discuss</a>]</span>
  2815. <span class="qrThreadsLinks">
  2816. <select class="qrThreadLinksDropdown">
  2817. <option value="thread1">Thread not found in wiki</option>
  2818. </select>
  2819. </span>`;
  2820. }
  2821.  
  2822. getReplyFormHeaderHtml() {
  2823. return `
  2824. <thead id="qrReplyFormHeader">
  2825. <tr>
  2826. <th class="postblock">Reply form</th>
  2827. <th class="qrReplyFormButtons">
  2828. <span><a id="qrReplyFormPopoutButton" href="#"><svg
  2829. xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="square" stroke-linejoin="arcs">
  2830. <g fill="none" fill-rule="evenodd"><path d="M18 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8c0-1.1.9-2 2-2h5M15 3h6v6M10 14L20.2 3.8"/></g>
  2831. </svg></a>
  2832. </span>
  2833. <span><a id="qrReplyFormMinimizeButton" href="#"><svg
  2834. xmlns="http://www.w3.org/2000/svg" width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="square" stroke-linejoin="arcs">
  2835. <line x1="5" y1="19" x2="19" y2="5"></line><line x1="5" y1="5" x2="19" y2="19"></line>
  2836. </svg></a>
  2837. </span>
  2838. </th>
  2839. </tr>
  2840. </thead>`;
  2841. }
  2842.  
  2843. getMainStyleRules(doc) {
  2844. /*var currentSheetRules = doc.head.querySelector(`link[rel*="stylesheet"][title]:not([disabled])`).sheet.cssRules;
  2845. var bodyStyle = [...currentSheetRules].find(rule => rule.selectorText == "html, body");
  2846. var bgc = bodyStyle.style.backgroundColor;
  2847. var fgc = bodyStyle.style.color;*/
  2848. var bgc = doc.defaultView.getComputedStyle(doc.body)["background-color"]; //this is needed for compatibility with BLICK
  2849. var fgc = doc.defaultView.getComputedStyle(doc.body).color;
  2850. return `
  2851. .hidden { display: none; }
  2852. .veryhidden { display: none !important; }
  2853. .transparent { opacity: 0; }
  2854. .qrShowFirstButton, .qrShowLastButton { width: 50px; }
  2855. .qrShowPrevButton, .qrShowNextButton { width: 100px; }
  2856. .qrSettingsToggle { position: absolute; left: 8px; padding-top: 2px; }
  2857. .qrControlsTop { }
  2858. .qrNavControls { display: grid; grid-template-columns: 1fr auto auto auto auto auto auto 1fr; grid-gap: 3px; color: ${fgc}; pointer-events: none; }
  2859. .qrNavControls > * { margin: auto 0px; pointer-events: all; }
  2860. .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}; }
  2861. .qrNavLinksContainer { white-space: nowrap; text-align: right; color: ${fgc}; }
  2862. .qrNavLinksBottom { float: right; clear: both; }
  2863. .qrNavPosition { font-weight: bold; white-space: nowrap; }
  2864. .qrLastIndex { color: crimson; }
  2865. .qrUpdateInfo { white-space: nowrap; }
  2866. .qrSettingsControls { height: 92px; overflow: hidden; transition: all 0.3s; display: grid; grid-template-columns: auto 1fr; }
  2867. #qrSettingsPageNav { display: inline-flex; flex-direction: column; justify-content: space-evenly; }
  2868. .qrSettingsNavItemSelected > a { color: inherit; text-decoration: none; }
  2869. .qrSettingsNavItemSelected::before { content: ">"; }
  2870. .qrSettingsNavItemSelected::after { content: "<"; }
  2871. .qrSettingsPages { position: relative; margin-top: 8px; }
  2872. .qrSettingsPage { display: grid; grid-template-columns: 1fr auto auto 1fr auto auto 1fr; grid-gap: 2px 4px; justify-items: start; align-items: center; align-content: center;
  2873. white-space: nowrap; transition: all 0.3s; opacity: 0; position: absolute; left: 0; right: 0; top: 0; pointer-events: none; }
  2874. .qrCurrentPage { opacity: 1; z-index: 1; pointer-events: initial; }
  2875. .qrSettingsPage > :nth-child(4n+1) { grid-column-start: 2; cursor: default; }
  2876. .qrSettingsPage > :nth-child(4n+3) { grid-column-start: 5; cursor: default; }
  2877. .qrSettingsControl { margin: 0px; }
  2878. select.qrSettingsControl { width: 150px; height: 20px; }
  2879. input.qrSettingsControl[type="number"] { width: 40px; }
  2880. .qrThreadLinksDropdown { max-width: 100px; }
  2881. .collapsedHeight { height: 0px; }
  2882. .qrTooltip { position: relative; border-bottom: 1px dotted; cursor: pointer; }
  2883. .qrTooltip:hover .qrTooltiptext { visibility: visible; }
  2884. .qrTooltip .qrTooltiptext { visibility: hidden; width: max-content; padding: 4px 4px 4px 10px; left: 15px; top: -35px;
  2885. position: absolute; border: dotted 1px; z-index: 1; background-color: ${bgc}; }
  2886. .haveOneScreenOfSpaceBelowHereSoItIsPossibleToScroll { position:absolute; height: 100vh; width: 1px; }
  2887. #qrReplyFormHeader { text-align: center; }
  2888. .postform td:first-child { display: none; }
  2889. .qrReplyFormButtons { position: absolute; right: 0px; }
  2890. .qrReplyFormButtons svg { width: 17px; vertical-align: bottom; }
  2891. .qrPopout { position: fixed; opacity: 0.2 !important; transition: opacity 0.3s; background-color: ${bgc}; border: 1px solid rgba(0, 0, 0, 0.10) !important; }
  2892. .qrPopout:hover { opacity: 1 !important; }
  2893. .qrPopout:focus-within { opacity: 1 !important; }
  2894. .qrPopout #qrReplyFormHeader { cursor: move; }
  2895. .qrReferences { margin: 0.5em 4px 0px 4px; }
  2896. .qrReferences::before { content: "Replies:"; font-size: 0.75em; }
  2897. .qrReference { font-size: 0.75em; margin-left: 4px; text-decoration: none; }
  2898. .stickyBottom { position: sticky; bottom: 0px; padding: 3px 0px; clear: both; }
  2899. .uid[postcount]::after { content: " (" attr(postcount) ")"; }
  2900. .isNew::after { color: crimson; }
  2901. .qrUserNavDisabled { color: grey; pointer-events: none; }
  2902. .qrUserNavEnabled { color: inherit; }
  2903. .qrNavIcon { width: 14px; height: 17px; stroke-width: 2px; fill: none; stroke: currentColor; vertical-align: text-bottom; padding-left: 2px; }
  2904. .qrNavIcon:hover { stroke-width: 3px; }
  2905. .qrUserMultiPost { color: crimson; white-space: nowrap; }
  2906. .update { width: 100%; }
  2907.  
  2908. .postwidth > label { cursor: pointer; }
  2909. .postwidth > label > * { cursor: default; }
  2910. .userdelete { position: relative; }
  2911. .userdelete tbody { position: absolute; right: 0px; top: -3px; text-align: right; }
  2912. body { position: relative; ${this.viewMode !== "all" ? "overflow-anchor: none" : ""} }
  2913. thead span > a { color: inherit; } /* Specificity hack to override the color of the reply form header links, but not overried the hover color */
  2914. #watchedthreadlist { display: grid; grid-template-columns: auto auto 3fr auto 1fr auto auto 0px; color: transparent; }
  2915. #watchedthreadlist > a[href$=".html"] { grid-column-start: 1; }
  2916. #watchedthreadlist > a[href*="html#"] { max-width: 40px; }
  2917. #watchedthreadlist > * { margin: auto 0px; }
  2918. #watchedthreadlist > span { overflow: hidden; white-space: nowrap; }
  2919. #watchedthreadlist > .postername { grid-column-start: 5; }
  2920. #watchedthreadsbuttons { top: 0px; right: 0px; left: unset; bottom: unset; }
  2921. .reflinkpreview { z-index: 1; }
  2922. blockquote { margin-right: 1em; clear: unset; }
  2923. #spoiler { vertical-align: text-top; }
  2924. .postform { position: relative; border-spacing: 0px; }
  2925. .postform :optional { box-sizing: border-box; }
  2926. .postform input[name="name"], .postform input[name="em"], .postform input[name="subject"] { width: 100% !important; }
  2927. .postform input[name="message"] { margin: 0px }
  2928. .postform input[type="submit"] { position: absolute; right: 1px; bottom: 3px; }
  2929. .postform [name="imagefile"] { width: 220px; }
  2930. .postform td:first-child { display: none; }
  2931. .postform tr:nth-child(3) td:last-child { display: grid; grid-template-columns: 1fr auto; }
  2932. #BLICKpreviewbut { margin-right: 57px; }`;
  2933. /*
  2934. Rotating highlight border
  2935. .highlight { border: unset;
  2936. background-image: linear-gradient(90deg, currentColor 50%, transparent 50%), linear-gradient(90deg, currentColor 50%, transparent 50%),
  2937. linear-gradient(0deg, currentColor 50%, transparent 50%), linear-gradient(0deg, currentColor 50%, transparent 50%);
  2938. background-repeat: repeat-x, repeat-x, repeat-y, repeat-y; background-size: 15px 2px, 15px 2px, 2px 15px, 2px 15px; background-position: left top, right bottom, left bottom, right top; animation: rotating-border 1s infinite linear;
  2939. }
  2940. @keyframes rotating-border {
  2941. 0% { background-position: left top, right bottom, left bottom, right top; }
  2942. 100% { background-position: left 15px top, right 15px bottom , left bottom 15px , right top 15px; }
  2943. }
  2944. .logo { clear: unset; }
  2945. blockquote { clear: unset; }
  2946. .sentinel { position: absolute; left: 0px; top: 0px; width: 400px; pointer-events: none; background-color:white; opacity: 0.3; }
  2947. */
  2948. }
  2949.  
  2950. getImageSizesStyleRules() {
  2951. var rules;
  2952. if (this.stretchImages) {
  2953. rules = `width: calc(${this.maxImageWidth}% - 40px); height: unset; max-height: calc(${this.maxImageHeight}vh - 50px); object-fit: contain;`;
  2954. }
  2955. else {
  2956. rules = `width: unset; height: unset; max-width: calc(${this.maxImageWidth}% - 40px); max-height: calc(${this.maxImageHeight}vh - 40px);`;
  2957. }
  2958. return `.thumb[src*="svg+xml"], .thumb[src*="/src/"] { background-size: contain; background-position: center; background-repeat: no-repeat; ${rules} }`;
  2959. }
  2960.  
  2961. getReferencesStyleRules() {
  2962. //none => hide all; nonupdates => hide for updates; all => don't hide anything
  2963. if (this.showReferences == "all") {
  2964. return ``;
  2965. }
  2966. var selector = this.showReferences == "nonupdates" ? ".update > .qrReferences, .updateOp > .qrReferences" : ".qrReferences";
  2967. return `${selector} { display: none; }`;
  2968. }
  2969.  
  2970. getUserPostCountsStyleRules() {
  2971. return this.showUserPostCounts ? "" : `.uid::after { display: none; }`;
  2972. }
  2973.  
  2974. getUserNavStyleRules() {
  2975. return this.showUserNav ? "" : `.qrUserNavDisabled { display: none; } .qrUserNavEnabled { display: none; }`;
  2976. }
  2977.  
  2978. getUserMultiPostStyleRules() {
  2979. return this.showUserMultiPost ? "" : `.qrUserMultiPost { display: none; }`;
  2980. }
  2981.  
  2982. getUserColorsStyleRules() {
  2983. if (!this.colorUsers) {
  2984. return "";
  2985. }
  2986. var colors = [];
  2987. [this.author, ...this.suggesters].forEach(user => {
  2988. var canonID = this.getCanonID(user);
  2989. colors.push(`.uid${canonID} { ${this.getColors(canonID)} }`);
  2990. });
  2991. return `.qrColoredUid { border-style: solid; padding: 0 5px; border-radius: 6px; border-width: 0px 6px; background-clip: padding-box; }
  2992. ${colors.join("\n")}`;
  2993. }
  2994.  
  2995. getCanonID(user) {
  2996. if (this.reCanonID.test(user.id)) {
  2997. return user.id;
  2998. }
  2999. for (let i = 0; i < user.children.length; i++) {
  3000. if (this.reCanonID.test(user.children[i].id)) {
  3001. return user.children[i].id;
  3002. }
  3003. }
  3004. var id = ""; //generate canonID from any string;
  3005. for (let i = 0; id.length < 6; i = ++i % user.id.length) {
  3006. id += user.id.charCodeAt(i).toString(16);
  3007. }
  3008. return id.substring(0, 6);
  3009. }
  3010.  
  3011. getColors(id) {
  3012. id = parseInt(id, 16);
  3013. var hue = ((id & 0xFF) * 390) / 255;
  3014. var saturation = hue <= 360 ? 1 : 0; // 1/12 of the IDs will be grayscale, altho gravitate away from grey
  3015. var lightness1 = ((id >> 8) & 0xFF) / 255;
  3016. var lightness2 = (((id >> 16) & 0xFF) / 255) * 0.75 + 0.125; //between 12.5% and 87.5%
  3017. var textColor = lightness1 > 0.5 ? "black" : "white";
  3018. if (saturation === 1) {
  3019. lightness1 = lightness1 * 0.5 + 0.25; //between 25% and 75%
  3020. //convert to rgb and then to rec601 luma to be able to calculate whether the text should be black or white
  3021. var a = saturation * Math.min(lightness1, 1 - lightness1);
  3022. var rgbFunc = (n, k = (n + hue / 30) % 12) => lightness1 - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
  3023. textColor = rgbFunc(0) * 0.299 + rgbFunc(8) * 0.587 + rgbFunc(4) * 0.114 > 0.5 ? "black" : "white";
  3024. }
  3025. else { //do basic math; if it's light, make it lighter, if it's dark, make it darker
  3026. lightness1 = lightness1 >= 0.5 ? (Math.pow((lightness1 - 0.5) * 2, 1/2)) / 2 + 0.5 : Math.pow(lightness1 * 2, 2) / 2;
  3027. }
  3028. return `background-color: hsl(${hue}, ${saturation * 100}%, ${lightness1 * 100}%); color: ${textColor}; border-color: hsl(${hue}, ${saturation * 100}%, ${lightness2 * 100}%);`;
  3029. }
  3030. }
  3031.  
  3032. var main = async () => {
  3033. //first, let's undo some of the damage that prototype.js did by restoring native functions
  3034. restoreNative(document);
  3035. if (fullDocument !== document) {
  3036. let doc = document.implementation.createHTMLDocument();
  3037. doc.documentElement.innerHTML = (await fullDocument).response;
  3038. fullDocument = doc;
  3039. }
  3040. setTimeout(() => {
  3041. var timeStart = Date.now();
  3042. try {
  3043. if (!document.head.querySelector("#qrMainCss")) { //sanity check; don't run the script if it already ran; happens sometimes in Firefox on refresh?
  3044. var qr = new QuestReader(document);
  3045. document.defaultView.QR = qr;
  3046. qr.onSettingsLoad = (e) => {
  3047. var settingsKeys = {};
  3048. settingsKeys[ThreadType.QUEST] = `qrSettings${e.threadID}`;
  3049. settingsKeys[ThreadType.DISCUSSION] = `qrSettingsDis${e.threadID}`;
  3050. settingsKeys[ThreadType.OTHER] = `qrSettingsBoardThread.${e.boardName}`;
  3051. // get settings from localStorage
  3052. e.settings = document.defaultView.localStorage.getItem(settingsKeys[e.threadType]);
  3053. if (!e.settings) {
  3054. var lastThreadKeys = {};
  3055. lastThreadKeys[ThreadType.QUEST] = `qrLastThreadID`;
  3056. lastThreadKeys[ThreadType.DISCUSSION] = `qrLastDisThreadID`;
  3057. var lastThreadID = document.defaultView.localStorage.getItem(lastThreadKeys[e.threadType]);
  3058. if (lastThreadID) {
  3059. settingsKeys[ThreadType.QUEST] = `qrSettings${lastThreadID}`;
  3060. settingsKeys[ThreadType.DISCUSSION] = `qrSettingsDis${lastThreadID}`;
  3061. e.settings = document.defaultView.localStorage.getItem(settingsKeys[e.threadType]);
  3062. if (e.settings) {
  3063. e.settings = JSON.parse(e.settings);
  3064. delete e.settings.currentUpdateIndex;
  3065. delete e.settings.wikiPages;
  3066. }
  3067. }
  3068. }
  3069. else {
  3070. e.settings = JSON.parse(e.settings);
  3071. }
  3072. };
  3073. qr.onSettingsChanged = (e) => { //on settings changed, save settings to localStorage
  3074. var settingsKeys = {};
  3075. settingsKeys[ThreadType.QUEST] = `qrSettings${e.threadID}`;
  3076. settingsKeys[ThreadType.DISCUSSION] = `qrSettingsDis${e.threadID}`;
  3077. settingsKeys[ThreadType.OTHER] = `qrSettingsBoardThread.${e.boardName}`;
  3078. if (e.threadType !== ThreadType.OTHER) {
  3079. var lastThreadKeys = {};
  3080. lastThreadKeys[ThreadType.QUEST] = `qrLastThreadID`;
  3081. lastThreadKeys[ThreadType.DISCUSSION] = `qrLastDisThreadID`;
  3082. //if there is no data on the last thread, or if the current thread has no settings (newly visited quest), update last visited thread ID
  3083. if (!lastThreadKeys[e.threadType] || !document.defaultView.localStorage.getItem(settingsKeys[e.threadType])) {
  3084. document.defaultView.localStorage.setItem(lastThreadKeys[e.threadType], e.threadID);
  3085. }
  3086. }
  3087. //save settings
  3088. document.defaultView.localStorage.setItem(settingsKeys[e.threadType], JSON.stringify(e.settings));
  3089. };
  3090. qr.onWikiDataLoad = (e) => { //before retrieving quest wiki, check localStorage for cached data and pass it to the class if it exists
  3091. e.wikiData = JSON.parse(document.defaultView.localStorage.getItem(`qrWikiData.${e.wikiPageName}`));
  3092. }
  3093. qr.onWikiDataChanged = (e) => { //after quest wiki is retrieved, cache the data in localStorage
  3094. if (e.wikiData) {
  3095. document.defaultView.localStorage.setItem(`qrWikiData.${e.wikiPageName}`, JSON.stringify(e.wikiData));
  3096. }
  3097. }
  3098. qr.init(fullDocument);
  3099. }
  3100. }
  3101. finally {
  3102. var hideUntilLoaded = document.querySelector("#hideUntilLoaded");
  3103. if (hideUntilLoaded) {
  3104. hideUntilLoaded.remove();
  3105. }
  3106. }
  3107. console.log(`Quest Reader run time = ${Date.now() - timeStart}ms`);
  3108. });
  3109. }
  3110.  
  3111. // START is here
  3112. var path = document.defaultView.location.pathname;
  3113. var pathMatch = path.match(new RegExp("/kusaba/([a-z]*)/res/"));
  3114. if (!pathMatch || BoardThreadTypes[pathMatch[1]] === undefined) {
  3115. return; //don't run the script when viewing non-board URLs
  3116. }
  3117. var partial = path.endsWith("+50.html") || path.endsWith("+100.html");
  3118. var fullDocument = !partial ? document : Xhr.get(path.replace(new RegExp("\\+(50|100)\\.html$"), ".html"));
  3119. if (document.readyState == "loading") {
  3120. //speed up loading by hiding the whole document until the extension is done processing the page; this prevents reflows and unnecessary rendering of stuff that we may change or hide
  3121. var el = document.head || document.documentElement;
  3122. el.insertAdjacentHTML("beforeEnd", `<style id="hideUntilLoaded">body { display: none; }</style>`);
  3123. document.addEventListener("DOMContentLoaded", main, { once: true }); //when parsing the HTML document is done, run the extension
  3124. }
  3125. else {
  3126. main();
  3127. }