您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Makes it more convenient to read and author quests
// ==UserScript== // @name Quest Reader // @author naileD // @namespace QuestReader // @match https://tgchan.org/kusaba/* // @match https://tezakia.net/kusaba/* // @match https://questden.org/kusaba/* // @match https://talehole.com/kusaba/* // @match https://thatquestsite.org/kusaba/* // @description Makes it more convenient to read and author quests // @run-at document-start // @version 2022.04.14 // @grant none // @license Unlicense // @icon data:image/vnd.microsoft.icon;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAAACXBIWXMAAAsSAAALEgHS3X78AAAANklEQVQokWNgoBOI2mJKpEomMvQgNAxRPUy4JGjjJJqoZoSrZmBgWOZzGlk/mlKILBMafxAAAE1pG/UEXzMMAAAAAElFTkSuQmCC // ==/UserScript== "use strict"; //entry point is more or less at the end of the script //enums const PostType = { UPDATE: 0, AUTHORCOMMENT: 1, SUGGESTION: 2, COMMENT: 3 }; const ThreadType = { QUEST: 0, DISCUSSION: 1, OTHER: 2 }; const BoardThreadTypes = { //board names and cooresponding types of threads they contain quest: ThreadType.QUEST, questarch: ThreadType.QUEST, graveyard: ThreadType.QUEST, questdis: ThreadType.DISCUSSION, meep: ThreadType.OTHER, moo: ThreadType.OTHER, draw: ThreadType.OTHER, tg: ThreadType.OTHER, }; const BoardDefaultNames = { quest: "Suggestion", questarch: "Suggestion", graveyard: "Suggestion", questdis: "Anonymous", meep: "Bob", moo: "Anonymous", draw: "Anonymous", tg: "Anonymous", }; //function that removes questden's prototype library's overrides of native functions where applicable var restoreNative = (doc) => { var functionsToRemove = ["Function.bind", "Element.hasAttribute", "Element.getElementsByClassName", "Element.Methods.getElementsByClassName", "HTMLElement.prototype.getElementsByClassName", "Element.scrollTo", "Element.Methods.scrollTo", "HTMLElement.prototype.scrollTo", "Element.remove", "Element.Methods.remove", "HTMLElement.prototype.remove", "Array.prototype.toJSON"]; var functionsToRestore = ["Object.keys", "Object.values", "Array.prototype.find", "Array.prototype.reverse", "Array.prototype.indexOf", "Array.prototype.entries", "Array.prototype.map", "Array.prototype.reduce", "Array.from", "String.prototype.endsWith", "String.prototype.sub", "String.prototype.startsWith"]; //restoring "Function.prototype.bind" causes "problems" in Firefox //restoration is done by creating a new window and copying over unmodified functions from that window var frame = doc.createElement("iframe"); frame.style.display = "none"; frame.style.visibility = "hidden"; doc.documentElement.appendChild(frame); var safeWindow = frame.contentWindow; var getObjectAndMethod = (path, root) => { path = path.split("."); path.reverse(); var methodName = path.shift(); var object = path.reduceRight((obj, prop) => obj[prop], root); return { object: object, method: methodName }; } functionsToRemove.forEach(path => { var unsafe = getObjectAndMethod(path, doc.defaultView); delete unsafe.object[unsafe.method]; }); functionsToRestore.forEach(path => { var unsafe = getObjectAndMethod(path, doc.defaultView); var safe = getObjectAndMethod(path, safeWindow); unsafe.object[unsafe.method] = safe.object[safe.method]; }); //sigh; there's some existing functions that rely on the broken functionality of this overriden method, so I can't outright remove the override doc.getElementsByClassName = function(selector) { return [...Object.getPrototypeOf(this).getElementsByClassName.call(this, selector)]; }; doc._getElementsByXPath = () => { return []; }; //this slow function is now unnecessary since we restored getElementsByClassName frame.remove(); } //UpdateAnalyzer class //Input: document of the quest //Output: a Map object with all the quest's posts, where keys are post IDs and values are post types. The post types are Update (0), AuthorComment (1), Suggestion (2), Comment (3); There's no comments... yet. //Usage: var results = new UpdateAnalyzer().processQuest(document); class UpdateAnalyzer { constructor(options = {}) { this.regex = UpdateAnalyzer.getRegexes(); this.postCache = null; //Used to transfer posts cache to/from this class. Used for debugging purposes. this.useCache = options.useCache; //Used for debugging purposes. this.debug = options.debug; this.debugAfterDate = options.debugAfterDate; this.passive = options.passive; //passive mode; treat the thread as a text-quest and ignore any fixes this.defaultName = options.defaultName || "Suggestion"; } analyzeQuest(questDoc) { var posts = !this.postCache ? this.getPosts(questDoc) : JSON.parse(this.postCache); var authorID = posts[0].userID; //authodID is the userID of the first post this.threadID = posts[0].postID; //threadID is the postID of the first post this.totalFiles = this.getTotalFileCount(posts); var questFixes = this.getFixes(this.passive ? 0 : this.threadID); //for quests where we can't correctly determine authors and updates, we use a built-in database of fixes if (this.debug && (questFixes.imageQuest !== undefined || Object.values(questFixes).some(fix => Object.values(fix).length > 0))) { console.log(`Quest has manual fixes`); console.log(questFixes); } var graphData = this.getUserGraphData(posts, questFixes, authorID); //get user names as nodes and edges for building user graph var users = this.buildUserGraph(graphData.nodes, graphData.edges); //build a basic user graph... whatever that means! this.author = this.find(users[authorID]); this.getUserPostAndFileCounts(posts, users, questFixes); //count the amount of posts and files each user made this.imageQuest = !this.passive && this.isImageQuest(questFixes); //image quest is when the author posts files at least 50% of the time; also, all collaborations are image quests if (this.debug) console.log(`Image quest: ${this.imageQuest}`); if (this.imageQuest) { //in case this is an image quest, merge users a bit differently users = this.buildUserGraph(graphData.nodes, graphData.edges, graphData.strongNodes, authorID); //build the user graph again, but with some restrictions this.author = this.find(users[authorID]); this.processFilePosts(posts, users, questFixes); //analyze file names and merge users based on when one file name is predicted from another this.getUserPostAndFileCounts(posts, users, questFixes); //count the amount of posts and files each user posted this.mergeMajorityFilePoster(posts, users, questFixes); //consider a user who posted 50%+ of the files in the thread as the author this.mergeCommonFilePosters(posts, users, questFixes); //merge certain file-posting users with the quest author } var postUsers = this.setPostUsers(posts, users, questFixes); //do final user resolution var postTypes = this.getFinalPostTypes(posts, questFixes); //determine which posts are updates return { postTypes: postTypes, postUsers: postUsers }; } getPosts(questDoc) { var posts = new Map(); //dictionary => postID / post object; need to use Map so that the post order is preserved var headers = questDoc.body.querySelector(":scope > form").getElementsByClassName("postwidth"); for (var i = 0; i < headers.length; i++) { var postHeaderElement = headers[i]; var postID = postHeaderElement.firstElementChild.name; postID = parseInt(postID !== "s" ? postID : postHeaderElement.querySelector(`a[name]:not([name="s"])`).name); var uidElement = postHeaderElement.querySelector(".uid"); var uid = uidElement.textContent.substring(4); var labelEl = postHeaderElement.querySelector("label"); var subject = labelEl.querySelector(".filetitle"); subject = subject ? subject.textContent.trim() : ""; var trip = labelEl.querySelector(".postertrip"); var name; if (trip) { //use tripcode instead of name if it exists name = trip.textContent; } else { name = labelEl.querySelector(".postername").textContent.trim(); name = name == this.defaultName ? "" : name.toLowerCase(); } var fileName = ""; var fileElement = postHeaderElement.querySelector(".filesize"); if (fileElement) { //try to get the original file name fileName = fileElement.querySelector("a").href; var match = fileName.match(this.regex.fileExtension); var fileExt = match ? match[0] : ""; //don't need .toLowerCase() if (fileExt == ".png" || fileExt == ".gif" || fileExt == ".jpg" || fileExt == ".jpeg") { var fileInfo = fileElement.lastChild.textContent.split(", "); if (fileInfo.length >= 3) { fileName = fileInfo[2].split("\n")[0]; } } else { fileName = fileName.substr(fileName.lastIndexOf("/") + 1); //couldn't find original file name, use file name from the server instead } fileName = fileName.replace(this.regex.fileExtension, ""); //ignore file's extension } var contentElement = postHeaderElement.nextElementSibling; var activeContent = !!contentElement.querySelector("img, iframe"); //does a post contain icons var postData = { postID: postID, userID: uid, userName: name, fileName: fileName, activeContent: activeContent }; if (this.useCache) { postData.textUpdate = this.regex.fraction.test(subject) || this.containsQuotes(contentElement); } else { postData.subject = subject; postData.contentElement = contentElement; } if (this.useCache || this.debug || this.debugAfterDate) { postData.date = Date.parse(labelEl.lastChild.nodeValue); } posts.set(postID, postData); } var postsArray = [...posts.values(posts)]; //convert to an array if (this.useCache) { //We stringify the object into JSON and then encode it into a Uint8Array to save space, otherwise the database would be too large this.postCache = JSON.stringify(postsArray); //Removing Array.prototype.toJSON allows us to safely use JSON.stringify again \o/ } return postsArray; } getTotalFileCount(posts) { var totalFileCount = 0; posts.forEach(post => { if (post.fileName || post.activeContent) totalFileCount++; }); return totalFileCount; } isImageQuest(questFixes, ignore) { if (questFixes.imageQuest !== undefined) { return questFixes.imageQuest; } else { return questFixes.collaboration || (this.author.fileCount / this.author.postCount) >= 0.5; } } getUserGraphData(posts, questFixes, authorID) { var graphData = { nodes: new Set(), strongNodes: new Set(), edges: {} }; posts.forEach(post => { graphData.nodes.add(post.userID); if (post.userName) { graphData.nodes.add(post.userName); graphData.edges[`${post.userID}${post.userName}`] = { E1: post.userName, E2: post.userID }; } if (post.fileName || post.activeContent) { //strong nodes are user IDs that posted files graphData.strongNodes.add(post.userID); if (post.userName) { graphData.strongNodes.add(post.userName); } if (post.fileName && post.activeContent && post.userID != authorID && !questFixes.collaboration) { //users that made posts with both file and icons are most likely the author graphData.edges[`${authorID}${post.userID}`] = { E1: authorID, E2: post.userID, hint: "fileAndIcons" }; } } }); for (var missedID in questFixes.missedAuthors) { //add missing links to the author from manual fixes graphData.edges[`${authorID}${missedID}`] = { E1: authorID, E2: missedID, hint: "missedAuthors" }; graphData.strongNodes.add(missedID); } graphData.edges = Object.values(graphData.edges); return graphData; } buildUserGraph(nodes, edges, strongNodes, authorID) { var users = {}; var edgesSet = new Set(edges); nodes.forEach(node => { users[node] = this.makeSet(node); }); if (!strongNodes) { edgesSet.forEach(edge => this.union(users[edge.E1], users[edge.E2])); } else { edgesSet.forEach(edge => { //merge strong with strong and weak with weak if ((strongNodes.has(edge.E1) && strongNodes.has(edge.E2)) || (!strongNodes.has(edge.E1) && !strongNodes.has(edge.E2))) { this.union(users[edge.E1], users[edge.E2]); edgesSet.delete(edge); } }); var author = this.find(users[authorID]); edgesSet.forEach(edge => { //merge strong with weak, but only for users which aren't the author if (this.find(users[edge.E1]) != author && this.find(users[edge.E2]) != author) { this.union(users[edge.E1], users[edge.E2]); } }); } return users; } processFilePosts(posts, users, questFixes) { if (questFixes.collaboration) { return; } var last2Files = new Map(); var filePosts = posts.filter(post => post.fileName && !questFixes.wrongImageUpdates[post.postID]); filePosts.forEach(post => { var postUser = this.find(users[post.userID]); var postFileName = post.fileName.match(this.regex.lastNumber) ? post.fileName : null; //no use processing files without numbers if (post.userName && this.find(users[post.userName]) == this.author) { postUser = this.author; } if (!last2Files.has(postUser)) { last2Files.set(postUser, [ null, null ]); } last2Files.get(postUser).shift(); last2Files.get(postUser).push(postFileName); last2Files.forEach((last2, user) => { if (user == postUser) { return; } if ((last2[0] !== null && this.fileNamePredicts(last2[0], post.fileName)) || (last2[1] !== null && this.fileNamePredicts(last2[1], post.fileName))) { if (this.debug || (this.debugAfterDate && this.debugAfterDate < post.date)) { console.log(`https://questden.org/kusaba/quest/res/${this.threadID}.html#${post.postID} merged (file name) ${postUser.id} with ${user.id} (author: ${this.author.id})`); } var mergedUser = this.union(user, postUser); last2Files.delete(user.parent != user ? user : postUser); last2Files.get(mergedUser).shift(); last2Files.get(mergedUser).push(postFileName); if (this.find(this.author) == mergedUser) { this.author = mergedUser; } } }); }); } getUserPostAndFileCounts(posts, users, questFixes) { for (var userID in users) { users[userID].postCount = 0; users[userID].fileCount = 0; } posts.forEach(post => { var user = this.decidePostUser(post, users, questFixes); user.postCount++; if (post.fileName || post.activeContent) { user.fileCount++; } }); } fileNamePredicts(fileName1, fileName2) { var match1 = fileName1.match(this.regex.lastNumber); var match2 = fileName2.match(this.regex.lastNumber); if (!match1 || !match2) { return false; } var indexDifference = match2.index - match1.index; if (indexDifference > 1 || indexDifference < -1) { return false; } var numberDifference = parseInt(match2[1]) - parseInt(match1[1]); if (numberDifference !== 2 && numberDifference !== 1) { return false; } var name1 = fileName1.replace(this.regex.lastNumber, ""); var name2 = fileName2.replace(this.regex.lastNumber, ""); return this.stringsAreSimilar(name1, name2); } stringsAreSimilar(string1, string2) { var lengthDiff = string1.length - string2.length; if (lengthDiff > 1 || lengthDiff < -1) { return false; } var s1 = lengthDiff > 0 ? string1 : string2; var s2 = lengthDiff > 0 ? string2 : string1; for (var i = 0, j = 0, diff = 0; i < s1.length; i++, j++) { if (s1[i] !== s2[j]) { diff++; if (diff === 2) { return false; } if (lengthDiff !== 0) { j--; } } } return true; } mergeMajorityFilePoster(posts, users, questFixes) { if (this.author.fileCount > this.totalFiles / 2 || questFixes.collaboration) { return; } for (var userID in users) { if (users[userID].fileCount >= this.totalFiles / 2 && users[userID] != this.author) { if (this.debug || (this.debugAfterDate && this.debugAfterDate < posts[posts.length - 1].date)) { console.log(`https://questden.org/kusaba/quest/res/${this.threadID}.html merged majority file poster ${users[userID].id} ${(100 * users[userID].fileCount / this.totalFiles).toFixed(1)}%`); } var parent = this.union(this.author, users[userID]); var child = users[userID].parent != users[userID] ? users[userID] : this.author; parent.fileCount += child.fileCount; parent.postCount += child.postCount; this.author = parent; return; } } } mergeCommonFilePosters(posts, users, questFixes) { if (questFixes.collaboration) { return; } var merged = []; var filteredUsers = Object.values(users).filter(user => user.parent == user && user.fileCount >= 3 && user.fileCount / user.postCount > 0.5 && user != this.author); if (filteredUsers.length === 0) { return; } var usersSet = new Set(filteredUsers); posts.forEach(post => { if ((post.fileName || post.activeContent) && !questFixes.wrongImageUpdates[post.postID] && this.isTextPostAnUpdate(post)) { for (var user of usersSet) { if (this.find(users[post.userID]) == user) { if (this.debug || (this.debugAfterDate && this.debugAfterDate < post.date)) { console.log(`https://questden.org/kusaba/quest/res/${this.threadID}.html new common poster ${user.id}`); } var parent = this.union(this.author, user); var child = user.parent != user ? user : this.author; parent.fileCount += child.fileCount; parent.postCount += child.postCount; this.author = parent; usersSet.delete(user); break; } } } }); } setPostUsers(posts, users, questFixes) { var postUsers = new Map(); posts.forEach(post => { post.user = this.decidePostUser(post, users, questFixes); postUsers.set(post.postID, post.user); }); return postUsers; } decidePostUser(post, users, questFixes) { var user = this.find(users[post.userID]); //if a post has both a username and an ID, it's sometimes possible that the name belongs to one user, while the ID belongs to a different user... //in this case, the post's owner needs to be decided -> it should be the author, unless the username is ignored as per manual fixes if (post.userName) { if (questFixes.ignoreTextPosts[post.userName]) { if (user == this.author) { user = this.find(users[post.userName]); } } else if (this.find(users[post.userName]) == this.author) { user = this.author; } } return user; } getFinalPostTypes(posts, questFixes) { // Updates are posts made by the author and, in case of image quests, author posts that contain files or icons var postTypes = new Map(); posts.forEach(post => { var postType = PostType.SUGGESTION; if (post.user == this.author) { if (post.fileName || post.activeContent) { //image post if (!questFixes.wrongImageUpdates[post.postID]) { postType = PostType.UPDATE; } else if (!questFixes.ignoreTextPosts[post.userID] && !questFixes.ignoreTextPosts[post.userName]) { postType = PostType.AUTHORCOMMENT; } } else if (!questFixes.ignoreTextPosts[post.userID] && !questFixes.ignoreTextPosts[post.userName]) { //text post if (!questFixes.wrongTextUpdates[post.postID] && (!this.imageQuest || this.isTextPostAnUpdate(post))) { postType = PostType.UPDATE; } else { postType = PostType.AUTHORCOMMENT; } } } else if (questFixes.collaboration && (post.fileName || post.activeContent) && !questFixes.wrongImageUpdates[post.postID]) { //in collaborations, all image posts are updates postType = PostType.UPDATE; } if (questFixes.missedTextUpdates[post.postID]) { postType = PostType.UPDATE; } if (this.debugAfterDate && this.debugAfterDate < post.date) { if (postType == PostType.SUGGESTION && post.fileName) console.log(`https://questden.org/kusaba/quest/res/${this.threadID}.html#${post.postID} new non-update`); if (postType == PostType.AUTHORCOMMENT) console.log(`https://questden.org/kusaba/quest/res/${this.threadID}.html#${post.postID} new author comment`); if (postType == PostType.UPDATE && this.imageQuest && !post.fileName && !post.activeContent) console.log(`https://questden.org/kusaba/quest/res/${this.threadID}.html#${post.postID} new text update`); } postTypes.set(post.postID, postType); }); return postTypes; } getPostUsers(posts) { var postUsers = new Map(); posts.forEach(post => { postUsers.set(post.postID, post.user); }); return postUsers; } isTextPostAnUpdate(post) { if (post.textUpdate === undefined) { post.textUpdate = this.regex.fraction.test(post.subject) || this.containsQuotes(post.contentElement); } return post.textUpdate; } containsQuotes(contentElement) { //extract post's text, but ignore text inside spoilers, links, dice rolls or any sort of brackets var filteredContentText = ""; contentElement.childNodes.forEach(node => { if (node.className !== "spoiler" && node.nodeName != "A" && (node.nodeName != "B" || !this.regex.diceRoll.test(node.textContent))) { filteredContentText += node.textContent; } }); filteredContentText = filteredContentText.replace(this.regex.bracketedTexts, "").trim(); //if the post contains dialogue, then it's likely to be an update var quotedTexts = filteredContentText.match(this.regex.quotedTexts) || []; for (let q of quotedTexts) { if (this.regex.endsWithPunctuation.test(q)) { return true; } } return false; } makeSet(id) { var node = { id: id, children: [] }; node.parent = node; return node; } find(node) { //find with path halving while (node.parent != node) { var curr = node; node = node.parent; curr.parent = node.parent; } return node; } union(node1, node2) { var node1root = this.find(node1); var node2root = this.find(node2); if (node1root == node2root) { return node1root; } node2root.parent = node1root; node1root.children.push(node2root); //having a list of children isn't a part of Union-Find, but it makes debugging much easier node2root.children.forEach(child => node1root.children.push(child)); return node1root; } static getRegexes() { if (!this.regex) { //cache as a static class property this.regex = { fileExtension: new RegExp("[.][^.]+$"), //finds ".png" in "image.png" lastNumber: new RegExp("([0-9]+)(?=[^0-9]*$)"), //finds "50" in "image50.png" fraction: new RegExp("[0-9][ ]*/[ ]*[0-9]"), //finds "1/4" in "Update 1/4" diceRoll: new RegExp("^rolled [0-9].* = [0-9]+$"), //finds "rolled 10, 20 = 30" quotedTexts: new RegExp("[\"“”][^\"“”]*[\"“”]","gu"), //finds text inside quotes endsWithPunctuation: new RegExp("[.,!?][ ]*[\"“”]$"), //finds if a quote ends with a punctuation bracketedTexts: new RegExp("(\\([^)]*\\))|(\\[[^\\]]*\\])|(\\{[^}]*\\})|(<[^>]*>)", "gu"), //finds text within various kinds of brackets... looks funny canonID: new RegExp("^[0-9a-f]{6}$") }; } return this.regex; } getFixes(threadID) { var fixes = UpdateAnalyzer.getAllFixes()[threadID] || {}; //convert array values to lower case and then into object properties for faster access for (let prop of [ "missedAuthors", "missedTextUpdates", "wrongTextUpdates", "wrongImageUpdates", "ignoreTextPosts" ]) { if (!fixes[prop]) { fixes[prop] = { }; } else if (Array.isArray(fixes[prop])) { fixes[prop] = fixes[prop].reduce((acc, el) => { if (!el.startsWith("!")) el = el.toLowerCase(); acc[el] = true; return acc; }, { }); } } return fixes; } // Manual fixes. In some cases it's simply impossible (impractical) to automatically determine which posts are updates. So we fix those rare cases manually. // list last updated on: // 2022/04/14 //missedAuthors: User IDs which should be linked to the author. Either because the automation failed, or the quest has guest authors / is a collaboration. Guest authors also usually need an entry under ignoreTextPosts. //ignoreTextPosts: User IDs of which text posts should not be set as author comments. It happens when a suggester shares an ID with the author and this suggester makes a text post. Or if the guest authors make suggestions. //(An empty ignoreTextPosts string matches posts with an empty/default poster name) //missedImageUpdates: Actually, no such fixes exist. All missed image update posts are added through adding author IDs to missedAuthors. //missedTextUpdates: Post IDs of text-only posts which are not author comments, but quest updates. It happens when authors make text updates in image quests. Or forget to attach an image to the update post. //wrongImageUpdates: Post IDs of image posts which are not quest updates. It happens when a suggester shares an ID with the author(s) and this suggester makes an image post. Or a guest author posts a non-update image post. //wrongTextUpdates: Post IDs of text-only posts which were misidentified as updates. It happens when an author comment contains a valid quote and the script accidentally thinks some dialogue is going on. //imageQuest: Forcefully set quest type. It happens when the automatically-determined quest type is incorrect. Either because of too many image updates in a text quest, or text updates in an image quest. //(Also, if most of the author's text posts in an image quest are updates, then it's sometimes simpler to set the quest as a text quest, rather than picking them out one by one.) //collaboration: Consider all image posts in this quest as updates. Useful if a quest has multiple authors. TODO: Check if it's possible to automatically detect if a quest is a collaboration static getAllFixes() { if (!this.allFixes) { this.allFixes = { //cache as a static class property 12: { missedAuthors: [ "!g9Qfmdqho2" ] }, 26: { ignoreTextPosts: [ "Coriell", "!DHEj4YTg6g" ] }, 101: { wrongTextUpdates: [ "442" ] }, 171: { wrongTextUpdates: [ "1402" ] }, 504: { missedTextUpdates: [ "515", "597", "654", "1139", "1163", "1180", "7994", "9951" ] }, 998: { ignoreTextPosts: [ "" ] }, 1292: { missedAuthors: [ "Chaptermaster II" ], missedTextUpdates: [ "1311", "1315", "1318" ], ignoreTextPosts: [ "" ] }, 1702: { wrongImageUpdates: [ "2829" ] }, 3090: { ignoreTextPosts: [ "", "!94Ud9yTfxQ", "Glaive" ], wrongImageUpdates: [ "3511", "3574", "3588", "3591", "3603", "3612" ] }, 4602: { missedTextUpdates: [ "4630", "6375" ] }, 7173: { missedTextUpdates: [ "8515", "10326" ] }, 8906: { missedTextUpdates: [ "9002", "9009" ] }, 9190: { missedAuthors: [ "!OeZ2B20kbk" ], missedTextUpdates: [ "26073" ] }, 13595: { wrongTextUpdates: [ "18058" ] }, 16114: { missedTextUpdates: [ "20647" ] }, 17833: { ignoreTextPosts: [ "!swQABHZA/E" ] }, 19308: { missedTextUpdates: [ "19425", "19600", "19912" ] }, 19622: { wrongImageUpdates: [ "30710", "30719", "30732", "30765" ] }, 19932: { missedTextUpdates: [ "20038", "20094", "20173", "20252" ] }, 20501: { ignoreTextPosts: [ "bd2eec" ] }, 21601: { missedTextUpdates: [ "21629", "21639" ] }, 21853: { missedTextUpdates: [ "21892", "21898", "21925", "22261", "22266", "22710", "23308", "23321", "23862", "23864", "23900", "24206", "25479", "25497", "25943", "26453", "26787", "26799", "26807", "26929", "27328", "27392", "27648", "27766", "27809", "29107", "29145" ] }, 22208: { missedAuthors: [ "fb5d8e" ] }, 24530: { wrongImageUpdates: [ "25023" ] }, 25354: { imageQuest: false}, 26933: { missedTextUpdates: [ "26935", "26955", "26962", "26967", "26987", "27015", "28998" ] }, 29636: { missedTextUpdates: [ "29696", "29914", "30025", "30911" ], wrongImageUpdates: [ "30973", "32955", "33107" ] }, 30350: { imageQuest: false, wrongTextUpdates: [ "30595", "32354", "33704" ] }, 30357: { missedTextUpdates: [ "30470", "30486", "30490", "30512", "33512" ] }, 33329: { wrongTextUpdates: [ "43894" ] }, 37304: { collaboration: true, ignoreTextPosts: [ "", "Adept", "GREEN" ] }, 37954: { missedTextUpdates: [ "41649" ] }, 38276: { ignoreTextPosts: [ "!ifOCf11HXk" ] }, 41510: { missedTextUpdates: [ "41550", "41746" ] }, 44240: { missedTextUpdates: [ "44324", "45768", "45770", "48680", "48687" ] }, 45522: { missedTextUpdates: [ "55885" ] }, 45986: { missedTextUpdates: [ "45994", "46019" ] }, 49306: { missedTextUpdates: [ "54246" ] }, 49400: { ignoreTextPosts: [ "!!IzZTIxBQH1" ] }, 49937: { missedTextUpdates: [ "52386" ] }, 53129: { wrongTextUpdates: [ "53505" ] }, 53585: { collaboration: true }, 54766: { missedAuthors: [ "e16ca8" ], ignoreTextPosts: [ "!!IzZTIxBQH1" ] }, 55639: { wrongImageUpdates: [ "56711", "56345", "56379", "56637" ] }, 56194: { wrongTextUpdates: [ "61608" ] }, 59263: { missedTextUpdates: [ "64631" ] }, 62091: { imageQuest: true}, 65742: { missedTextUpdates: [ "66329", "66392", "67033", "67168" ] }, 67058: { missedTextUpdates: [ "67191", "67685" ] }, 68065: { missedAuthors: [ "7452df", "1d8589" ], ignoreTextPosts: [ "!!IzZTIxBQH1" ] }, 70887: { missedAuthors: [ "e53955", "7c9cdd", "2084ff", "064d19", "51efff", "d3c8d2" ], ignoreTextPosts: [ "!!IzZTIxBQH1" ] }, 72794: { wrongTextUpdates: [ "76740" ] }, 74474: { missedAuthors: [ "309964" ] }, 75425: { missedTextUpdates: [ "75450", "75463", "75464", "75472", "75490", "75505", "77245" ] }, 75763: { missedAuthors: [ "068b0e" ], ignoreTextPosts: [ "!!IzZTIxBQH1" ] }, 76892: { missedTextUpdates: [ "86875", "86884", "87047", "88315" ] }, 79146: { missedAuthors: [ "4a3269" ] }, 79654: { missedTextUpdates: [ "83463", "83529" ] }, 79782: { missedTextUpdates: [ "79975", "80045" ] }, 82970: { missedTextUpdates: [ "84734" ] }, 83325: { missedAuthors: [ "076064" ] }, 84134: { imageQuest: false}, 85235: { missedTextUpdates: [ "85257", "85282", "113215", "114739", "151976", "152022", "159250" ] }, 88264: { missedAuthors: [ "3fec76", "714b9c" ] }, 92605: { ignoreTextPosts: [ "" ] }, 94645: { missedTextUpdates: [ "97352" ] }, 95242: { missedTextUpdates: [ "95263" ] }, 96023: { missedTextUpdates: [ "96242" ] }, 96466: { ignoreTextPosts: [ "Reverie" ] }, 96481: { imageQuest: true}, 97014: { missedTextUpdates: [ "97061", "97404", "97915", "98124", "98283", "98344", "98371", "98974", "98976", "98978", "99040", "99674", "99684" ] }, 99095: { wrongImageUpdates: [ "111452" ] }, 99132: { ignoreTextPosts: [ "" ] }, 100346: { missedTextUpdates: [ "100626", "100690", "100743", "100747", "101143", "101199", "101235", "101239" ] }, 101388: { ignoreTextPosts: [ "Glaive" ] }, 102433: { missedTextUpdates: [ "102519", "102559", "102758" ] }, 102899: { missedTextUpdates: [ "102903" ] }, 103435: { missedTextUpdates: [ "104279", "105950" ] }, 103850: { ignoreTextPosts: [ "" ] }, 106656: { wrongTextUpdates: [ "115606" ] }, 107789: { missedTextUpdates: [ "107810", "107849", "107899" ] }, 108599: { wrongImageUpdates: [ "171382", "172922", "174091", "180752", "180758" ] }, 108805: { wrongImageUpdates: [ "110203" ] }, 109071: { missedTextUpdates: [ "109417" ] }, 112133: { missedTextUpdates: [ "134867" ] }, 112414: { missedTextUpdates: [ "112455" ] }, 113768: { missedAuthors: [ "e9a4f7" ] }, 114133: { ignoreTextPosts: [ "" ] }, 115831: { missedTextUpdates: [ "115862" ] }, 119431: { ignoreTextPosts: [ "" ] }, 120384: { missedAuthors: [ "233aab" ] }, 126204: { imageQuest: true, missedTextUpdates: [ "127069", "127089", "161046", "161060", "161563" ] }, 126248: { missedTextUpdates: [ "193064" ] }, 128706: { missedAuthors: [ "2e2f06", "21b50e", "e0478c", "9c87f6", "931351", "e294f1", "749d64", "f3254a" ] }, 131255: { missedTextUpdates: [ "151218" ] }, 137683: { missedTextUpdates: [ "137723" ] }, 139086: { ignoreTextPosts: [ "!TEEDashxDA" ] }, 139513: { missedTextUpdates: [ "139560" ] }, 141257: { missedTextUpdates: [ "141263", "141290", "141513", "146287" ], ignoreTextPosts: [ "" ], wrongImageUpdates: [ "141265" ] }, 146112: { imageQuest: true, missedAuthors: [ "//_emily" ], ignoreTextPosts: [ "f609ed" ] }, 153225: { missedTextUpdates: [ "153615", "153875" ] }, 155665: { missedTextUpdates: [ "155670", "155684", "155740" ] }, 156257: { missedTextUpdates: [ "156956" ] }, 157277: { missedAuthors: [ "23c8f1", "8bb533" ] }, 161117: { missedTextUpdates: [ "167255", "168000" ] }, 162089: { missedTextUpdates: [ "167940" ] }, 164793: { missedAuthors: [ "e973f4" ], ignoreTextPosts: [ "!TEEDashxDA" ] }, 165537: { missedAuthors: [ "a9f6ce" ] }, 173621: { ignoreTextPosts: [ "" ] }, 174398: { missedAuthors: [ "bf0d4e", "158c5c" ] }, 176965: { missedTextUpdates: [ "177012" ] }, 177281: { missedTextUpdates: [ "178846" ] }, 181790: { ignoreTextPosts: [ "Mister Brush" ], wrongImageUpdates: [ "182280" ] }, 183194: { ignoreTextPosts: [ "!CRITTerXzI" ], wrongImageUpdates: [ "183207" ] }, 183637: { imageQuest: false, wrongTextUpdates: [ "183736" ] }, 185345: { wrongTextUpdates: [ "185347" ] }, 185579: { missedTextUpdates: [ "188091", "188697", "188731", "188748", "190868" ] }, 186709: { missedTextUpdates: [ "186735" ] }, 188253: { missedTextUpdates: [ "215980", "215984", "222136" ] }, 188571: { missedTextUpdates: [ "188633" ] }, 188970: { ignoreTextPosts: [ "" ] }, 191328: { missedAuthors: [ "f54a9c", "862cf6", "af7d90", "4c1052", "e75bed", "09e145" ] }, 191976: { missedAuthors: [ "20fc85" ] }, 192879: { missedTextUpdates: [ "193009" ] }, 193934: { missedTextUpdates: [ "212768" ] }, 196310: { missedTextUpdates: [ "196401" ] }, 196517: { missedTextUpdates: [ "196733" ] }, 198458: { missedTextUpdates: [ "198505", "198601", "199570" ] }, 200054: { missedAuthors: [ "a4b4e3" ] }, 201427: { missedTextUpdates: [ "201467", "201844" ] }, 203072: { missedTextUpdates: [ "203082", "203100", "206309", "207033", "208766" ] }, 206945: { missedTextUpdates: [ "206950" ] }, 207011: { ignoreTextPosts: [ "!TEEDashxDA" ] }, 207296: { missedTextUpdates: [ "214551" ] }, 207756: { missedTextUpdates: [ "208926" ] }, 209334: { missedTextUpdates: [ "209941" ] }, 210613: { missedTextUpdates: [ "215711", "220853" ] }, 210928: { missedTextUpdates: [ "215900" ] }, 211320: { ignoreTextPosts: [ "Kindling", "Bahu" ], wrongImageUpdates: [ "211587", "215436" ] }, 212584: { missedAuthors: [ "40a8d3" ] }, 212915: { missedTextUpdates: [ "229550" ] }, 217193: { collaboration: true }, 217269: { imageQuest: false, wrongTextUpdates: [ "217860", "219314" ] }, 218385: { missedAuthors: [ "812dcf" ] }, 220049: { ignoreTextPosts: [ "Slinkoboy" ], wrongImageUpdates: [ "228035", "337790" ] }, 222777: { imageQuest: false}, 224095: { missedTextUpdates: [ "224196", "224300", "224620", "244476" ] }, 233213: { missedTextUpdates: [ "233498" ], ignoreTextPosts: [ "Bahu" ] }, 234437: { missedTextUpdates: [ "234657" ] }, 237125: { missedTextUpdates: [ "237192" ] }, 237665: { imageQuest: true, ignoreTextPosts: [ "" ] }, 238281: { ignoreTextPosts: [ "TK" ] }, 238993: { missedTextUpdates: [ "239018", "239028", "239094" ] }, 240824: { imageQuest: false}, 241467: { missedTextUpdates: [ "241709" ] }, 242200: { missedTextUpdates: [ "246465", "246473", "246513" ] }, 242657: { missedAuthors: [ "2563d4" ] }, 244225: { missedTextUpdates: [ "245099", "245195", "245201" ] }, 244557: { missedTextUpdates: [ "244561" ], ignoreTextPosts: [ "" ] }, 244830: { missedAuthors: [ "e33093" ] }, 247108: { ignoreTextPosts: [ "Bahu" ], wrongImageUpdates: [ "258883", "265446" ] }, 247714: { missedTextUpdates: [ "247852" ] }, 248067: { ignoreTextPosts: [ "" ] }, 248856: { ignoreTextPosts: [ "" ] }, 248880: { imageQuest: true, ignoreTextPosts: [ "", "!qkgg.NzvRY", "!!EyA2IwLwVl", "!I10GFLsZCw", "!k6uRjGDgAQ", "Seven01a19" ] }, 251909: { missedTextUpdates: [ "255400" ] }, 252195: { missedTextUpdates: [ "260890" ] }, 252944: { missedAuthors: [ "Rizzie" ], ignoreTextPosts: [ "", "!!EyA2IwLwVl", "Seven01a19" ] }, 256339: { missedTextUpdates: [ "256359", "256379", "256404", "256440" ] }, 257726: { missedAuthors: [ "917cac" ] }, 258304: { missedTextUpdates: [ "269087" ] }, 261572: { imageQuest: false}, 261837: { missedAuthors: [ "14149d" ] }, 262128: { missedTextUpdates: [ "262166", "262219", "262455", "262500" ] }, 262574: { collaboration: true }, 263831: { imageQuest: false, wrongTextUpdates: [ "264063", "264716", "265111", "268733", "269012", "270598", "271254", "271852", "271855", "274776", "275128", "280425", "280812", "282417", "284354", "291231", "300074", "305150" ] }, 265656: { ignoreTextPosts: [ "Glaive17" ] }, 266542: { collaboration: true, wrongImageUpdates: [ "266587" ], ignoreTextPosts: [ "fa43b7", "Not Cirr" ] }, 267348: { ignoreTextPosts: [ "" ] }, 269735: { ignoreTextPosts: [ "---" ] }, 270556: { ignoreTextPosts: [ "Bahu" ], wrongImageUpdates: [ "276022" ] }, 273047: { missedAuthors: [ "db463d", "16f0be", "77df62", "b6733e", "d171a3", "3a95e1", "21d450" ] }, 274088: { missedAuthors: [ "4b0cf3" ], missedTextUpdates: [ "294418" ], ignoreTextPosts: [ "" ] }, 274466: { missedAuthors: [ "c9efe3" ] }, 276562: { missedTextUpdates: [ "277108" ] }, 277371: { ignoreTextPosts: [ "!TEEDashxDA" ] }, 278168: { ignoreTextPosts: [ "!TEEDashxDA" ] }, 280381: { ignoreTextPosts: [ "!7BHo7QtR6I" ] }, 280985: { ignoreTextPosts: [ "!TEEDashxDA" ] }, 283246: { imageQuest: false}, 285210: { ignoreTextPosts: [ "", "Weaver" ] }, 287296: { ignoreTextPosts: [ "", "Asplosionz" ] }, 287815: { missedAuthors: [ "Ñ" ] }, 288346: { collaboration: true, imageQuest: false }, 289254: { imageQuest: false}, 292033: { wrongTextUpdates: [ "295088" ] }, 293532: { ignoreTextPosts: [ "" ] }, 294351: { ignoreTextPosts: [ "Weaver" ] }, 295374: { ignoreTextPosts: [ "TK" ] }, 295832: { missedAuthors: [ "ac22cd", "7afbc4", "6f11ff" ], missedTextUpdates: [ "313940" ] }, 295949: { missedTextUpdates: [ "296256", "297926", "298549" ] }, 298133: { missedTextUpdates: [ "298187" ] }, 298860: { imageQuest: true, missedTextUpdates: [ "298871", "298877", "298880", "298908" ] }, 299352: { imageQuest: true, missedTextUpdates: [ "299375", "299627", "303689" ] }, 300694: { ignoreTextPosts: [ "TK" ] }, 300751: { missedTextUpdates: [ "316287" ] }, 303859: { ignoreTextPosts: [ "" ] }, 308257: { missedTextUpdates: [ "314653" ] }, 309753: { missedTextUpdates: [ "309864", "309963", "310292", "310944", "310987", "311202", "311219", "311548" ] }, 310586: { missedTextUpdates: [ "310945", "312747", "313144" ] }, 311021: { missedAuthors: [ "049dfa", "f2a6f9" ] }, 312418: { missedTextUpdates: [ "312786", "312790", "312792", "312984", "313185" ] }, 314825: { ignoreTextPosts: [ "TK" ] }, 314940: { missedTextUpdates: [ "314986", "315198", "329923" ] }, 318478: { ignoreTextPosts: [ "Toxoglossa" ] }, 319491: { ignoreTextPosts: [ "Bahu" ] }, 323481: { missedTextUpdates: [ "323843", "324125", "324574" ] }, 323589: { missedTextUpdates: [ "329499" ] }, 327468: { missedTextUpdates: [ "327480", "337008" ] }, 337661: { ignoreTextPosts: [ "", "hisgooddog" ] }, 338579: { ignoreTextPosts: [ "", "Zealo8", "Ñ" ] }, 343078: { wrongImageUpdates: [ "343219" ] }, 343668: { missedTextUpdates: [ "343671" ] }, 348635: { ignoreTextPosts: [ "" ] }, 351064: { missedTextUpdates: [ "351634", "353263", "355326", "356289" ] }, 351264: { missedTextUpdates: [ "353077" ] }, 354201: { imageQuest: true, missedTextUpdates: [ "354340" ] }, 355404: { ignoreTextPosts: [ "Bahu" ] }, 356715: { missedTextUpdates: [ "356722" ] }, 357723: { collaboration: true, wrongImageUpdates: [ "358563" ] }, 359879: { imageQuest: false}, 359931: { collaboration: true, wrongImageUpdates: [ "360933", "361338", "361425", "362100" ] }, 360617: { missedAuthors: [ "7a7217" ] }, 363529: { imageQuest: true, ignoreTextPosts: [ "Tenyoken" ] }, 365082: { missedTextUpdates: [ "381411", "382388" ] }, 366944: { missedTextUpdates: [ "367897" ] }, 367145: { wrongTextUpdates: [ "367887" ] }, 367824: { missedTextUpdates: [ "367841", "367858", "367948" ] }, 375293: { ignoreTextPosts: [ "Bahu" ] }, 382864: { ignoreTextPosts: [ "FlynnMerk" ] }, 387602: { ignoreTextPosts: [ "!a1..dIzWW2" ], wrongImageUpdates: [ "390207", "392018", "394748" ] }, 388264: { ignoreTextPosts: [ "" ] }, 392034: { missedAuthors: [ "046f13" ] }, 392868: { missedAuthors: [ "e1359e" ] }, 393082: { ignoreTextPosts: [ "" ] }, 395700: { missedTextUpdates: [ "395701", "395758" ] }, 395817: { ignoreTextPosts: [ "" ] }, 397819: { ignoreTextPosts: [ "Bahu", "K-Dogg" ], wrongImageUpdates: [ "398064" ] }, 400842: { missedAuthors: [ "b0d466" ], ignoreTextPosts: [ "", "!a1..dIzWW2" ], wrongImageUpdates: [ "412172", "412197" ] }, 403418: { missedAuthors: [ "02cbc6" ] }, 404177: { missedTextUpdates: [ "404633" ] }, 409356: { missedTextUpdates: [ "480664", "485493" ], wrongTextUpdates: [ "492824" ] }, 410618: { ignoreTextPosts: [ "kathryn" ], wrongImageUpdates: [ "417836" ] }, 412463: { ignoreTextPosts: [ "" ] }, 413494: { ignoreTextPosts: [ "Bahu" ] }, 420600: { imageQuest: false}, 421477: { imageQuest: false}, 422052: { collaboration: true }, 422087: { ignoreTextPosts: [ "Caz" ] }, 422856: { ignoreTextPosts: [ "", "???" ] }, 424198: { collaboration: true }, 425677: { missedTextUpdates: [ "425893", "426741", "431953" ] }, 426019: { ignoreTextPosts: [ "Taskuhecate" ] }, 427135: { ignoreTextPosts: [ "!7BHo7QtR6I" ] }, 427676: { ignoreTextPosts: [ "FRACTAL" ] }, 428027: { collaboration: true, ignoreTextPosts: [ "Trout" ] }, 430036: { missedTextUpdates: [ "430062", "430182", "430416" ], ignoreTextPosts: [ "" ] }, 431445: { imageQuest: false, missedAuthors: [ "efbb86" ] }, 435947: { missedTextUpdates: [ "436059" ] }, 437675: { wrongTextUpdates: [ "445770", "449255", "480401" ] }, 437768: { missedTextUpdates: [ "446536" ] }, 438515: { ignoreTextPosts: [ "TK" ] }, 438670: { ignoreTextPosts: [ "" ] }, 441226: { collaboration: true, wrongImageUpdates: [ "441260" ] }, 441745: { missedTextUpdates: [ "443831" ] }, 447830: { imageQuest: false, missedAuthors: [ "fc985a", "f8b208" ], wrongTextUpdates: [ "448476", "450379", "452161" ] }, 448900: { missedAuthors: [ "0c2256" ] }, 449505: { wrongTextUpdates: [ "450499" ] }, 450563: { collaboration: true }, 452871: { missedAuthors: [ "General Q. Waterbuffalo", "!cZFAmericA" ], missedTextUpdates: [ "456083" ] }, 453480: { ignoreTextPosts: [ "TK" ], wrongImageUpdates: [ "474233" ] }, 453978: { missedTextUpdates: [ "453986" ] }, 454256: { missedTextUpdates: [ "474914", "474957" ] }, 456185: { ignoreTextPosts: [ "TK" ], wrongTextUpdates: [ "472446" ], wrongImageUpdates: [ "592622" ] }, 456798: { missedTextUpdates: [ "516303" ] }, 458432: { collaboration: true }, 463595: { missedTextUpdates: [ "463711", "465024", "465212", "465633", "467107", "467286" ], wrongTextUpdates: [ "463623" ] }, 464038: { missedAuthors: [ "df885d", "8474cd" ] }, 465919: { missedTextUpdates: [ "465921" ] }, 469321: { missedTextUpdates: [ "469332" ] }, 471304: { missedAuthors: [ "1766db" ] }, 471394: { missedAuthors: [ "Cirr" ] }, 476554: { ignoreTextPosts: [ "Fish is yum" ] }, 478624: { missedAuthors: [ "88c9b2" ] }, 479712: { ignoreTextPosts: [ "" ] }, 481277: { missedTextUpdates: [ "481301", "482210" ], ignoreTextPosts: [ "Santova" ] }, 481491: { missedTextUpdates: [ "481543", "481575", "484069" ], ignoreTextPosts: [ "Zach Leigh", "Santova", "Outaki Shiba" ] }, 482391: { missedTextUpdates: [ "482501", "482838" ] }, 482629: { missedTextUpdates: [ "484220", "484437" ], ignoreTextPosts: [ "Santova", "Tera Nospis" ] }, 483108: { missedAuthors: [ "2de44c" ], missedTextUpdates: [ "483418", "483658" ], ignoreTextPosts: [ "Santova" ] }, 484423: { missedTextUpdates: [ "484470", "486761", "488602" ], ignoreTextPosts: [ "Tera Nospis", "Zach Leigh" ] }, 484606: { missedTextUpdates: [ "486773" ], ignoreTextPosts: [ "Zach Leigh" ] }, 485964: { missedTextUpdates: [ "489145", "489760" ], ignoreTextPosts: [ "Tera Nospis", "Santova" ] }, 489488: { missedTextUpdates: [ "490389" ] }, 489694: { missedAuthors: [ "2c8bbe", "30a140", "8c4b01", "8fbeb2", "2b7d97", "17675d", "782175", "665fcd", "e91794", "52019c", "8ef0aa", "e493a6", "c847bc" ] }, 489830: { missedAuthors: [ "9ee824", "8817a0", "d81bd3", "704658" ] }, 490689: { ignoreTextPosts: [ "Santova" ] }, 491171: { ignoreTextPosts: [ "Santova", "Zach Leigh", "Zack Leigh", "The Creator" ] }, 491314: { missedTextUpdates: [ "491498" ], ignoreTextPosts: [ "" ] }, 492511: { missedAuthors: [ "???" ] }, 493099: { ignoreTextPosts: [ "Zach Leigh", "Santova" ] }, 494015: { ignoreTextPosts: [ "Coda", "drgruff" ] }, 496561: { ignoreTextPosts: [ "Santova", "DJ LaLonde", "Tera Nospis" ] }, 498874: { ignoreTextPosts: [ "Santova" ] }, 499607: { ignoreTextPosts: [ "Santova", "Tera Nospis" ] }, 499980: { ignoreTextPosts: [ "Santova", "Tera Nospis", "DJ LaLonde" ] }, 500015: { missedTextUpdates: [ "500020", "500029", "500274", "501462", "501464", "501809", "505421" ], ignoreTextPosts: [ "suggestion", "Chelz" ] }, 502751: { ignoreTextPosts: [ "suggestion" ] }, 503053: { missedAuthors: [ "!!WzMJSzZzWx", "Shopkeep", "CAI" ] }, 505072: { missedTextUpdates: [ "565461" ] }, 505569: { ignoreTextPosts: [ "!TEEDashxDA" ] }, 505633: { missedTextUpdates: [ "505694", "529582" ] }, 505796: { ignoreTextPosts: [ "Mister-Saturn" ] }, 506555: { ignoreTextPosts: [ "Tera Nospis", "Santova" ] }, 507761: { ignoreTextPosts: [ "", "Rue" ] }, 508294: { missedAuthors: [ "Lisila" ], missedTextUpdates: [ "508618", "508406" ], wrongImageUpdates: [ "508331" ] }, 509510: { missedTextUpdates: [ "509810", "510805", "510812", "510943", "511042", "512430", "514731", "515963" ] }, 510067: { missedTextUpdates: [ "510081" ] }, 511816: { imageQuest: true, missedAuthors: [ "34cf7d" ], missedTextUpdates: [ "512608" ] }, 512417: { ignoreTextPosts: [ "Uplifted" ] }, 512501: { ignoreTextPosts: [ "" ] }, 512569: { wrongImageUpdates: [ "512810" ] }, 513727: { missedTextUpdates: [ "519251" ], ignoreTextPosts: [ "!mYSM8eo.ng" ] }, 514174: { missedTextUpdates: [ "747164" ] }, 515255: { ignoreTextPosts: [ "" ] }, 516595: { imageQuest: true}, 517144: { ignoreTextPosts: [ "" ] }, 518737: { wrongTextUpdates: [ "521408", "522150", "522185", "522231", "535521" ] }, 518843: { ignoreTextPosts: [ "" ] }, 519463: { imageQuest: false}, 521196: { missedTextUpdates: [ "524608" ] }, 526472: { missedTextUpdates: [ "526524", "559848" ] }, 527296: { ignoreTextPosts: [ "Zealo8" ] }, 527546: { ignoreTextPosts: [ "suggestion" ] }, 527753: { collaboration: true }, 528891: { ignoreTextPosts: [ "drgruff" ] }, 530940: { missedAuthors: [ "2027bb", "feafa5", "0a3b00" ] }, 533990: { missedTextUpdates: [ "537577" ] }, 534197: { ignoreTextPosts: [ "Stella" ] }, 535302: { ignoreTextPosts: [ "mermaid" ] }, 535783: { ignoreTextPosts: [ "drgruff" ] }, 536268: { missedTextUpdates: [ "536296", "538173" ], ignoreTextPosts: [ "Archivemod" ], wrongImageUpdates: [ "537996" ] }, 537343: { missedTextUpdates: [ "539218" ] }, 537647: { missedTextUpdates: [ "537683" ] }, 537867: { missedAuthors: [ "369097" ] }, 539831: { ignoreTextPosts: [ "" ] }, 540147: { ignoreTextPosts: [ "drgruff" ] }, 541026: { imageQuest: false}, 543428: { missedTextUpdates: [ "545458" ] }, 545071: { missedTextUpdates: [ "545081" ] }, 545791: { ignoreTextPosts: [ "" ] }, 545842: { missedTextUpdates: [ "550972" ] }, 548052: { missedTextUpdates: [ "548172" ], ignoreTextPosts: [ "Lucid" ] }, 548899: { missedTextUpdates: [ "548968", "549003" ] }, 549394: { missedTextUpdates: [ "549403" ] }, 553434: { missedTextUpdates: [ "553610", "553635", "553668", "554166" ] }, 553711: { missedTextUpdates: [ "553722", "553728", "554190" ] }, 553760: { missedTextUpdates: [ "554994", "555829", "556570", "556792", "556803", "556804" ] }, 554694: { missedTextUpdates: [ "557011", "560544" ] }, 556435: { missedAuthors: [ "Azathoth" ], missedTextUpdates: [ "607163" ], wrongTextUpdates: [ "561150" ] }, 557051: { missedTextUpdates: [ "557246", "557260", "557599", "559586" ], wrongTextUpdates: [ "557517" ] }, 557633: { imageQuest: true}, 557854: { missedTextUpdates: [ "557910", "557915", "557972", "558082", "558447", "558501", "561834", "561836", "562289", "632102", "632481", "632509", "632471" ] }, 562193: { ignoreTextPosts: [ "" ] }, 563459: { missedTextUpdates: [ "563582" ] }, 564852: { collaboration: true }, 564860: { missedTextUpdates: [ "565391" ] }, 565909: { ignoreTextPosts: [ "" ] }, 567119: { missedTextUpdates: [ "573494", "586375" ] }, 567138: { missedAuthors: [ "4cf1b6" ] }, 568248: { missedTextUpdates: [ "569818" ] }, 568370: { ignoreTextPosts: [ "" ] }, 568463: { missedTextUpdates: [ "568470", "568473" ] }, 569225: { missedTextUpdates: [ "569289" ] }, 573815: { wrongTextUpdates: [ "575792" ] }, 578213: { missedTextUpdates: [ "578575" ] }, 581741: { missedTextUpdates: [ "581746" ] }, 582268: { missedTextUpdates: [ "587221" ] }, 585201: { ignoreTextPosts: [ "", "Bahustard", "Siphon" ] }, 586024: { ignoreTextPosts: [ "" ] }, 587086: { missedTextUpdates: [ "587245", "587284", "587443", "587454" ] }, 587562: { ignoreTextPosts: [ "Zealo8" ] }, 588902: { missedTextUpdates: [ "589033" ] }, 589725: { imageQuest: false}, 590502: { ignoreTextPosts: [ "" ], wrongTextUpdates: [ "590506" ] }, 590761: { missedTextUpdates: [ "590799" ], ignoreTextPosts: [ "" ] }, 591527: { missedTextUpdates: [ "591547", "591845" ] }, 592273: { imageQuest: false}, 592625: { wrongTextUpdates: [ "730228" ] }, 593047: { missedTextUpdates: [ "593065", "593067", "593068" ] }, 593899: { ignoreTextPosts: [ "mermaid" ] }, 595081: { ignoreTextPosts: [ "", "VoidWitchery" ] }, 595265: { imageQuest: false, wrongTextUpdates: [ "596676", "596717", "621360", "621452", "621466", "621469", "621503" ] }, 596262: { missedTextUpdates: [ "596291", "596611", "597910", "598043", "598145", "600718", "603311" ] }, 596345: { ignoreTextPosts: [ "mermaid" ] }, 596539: { missedTextUpdates: [ "596960", "596972", "596998", "597414", "614375", "614379", "614407", "616640", "668835", "668844", "668906", "668907", "668937", "668941", "669049", "669050", "669126", "671651" ], ignoreTextPosts: [ "pugbutt" ] }, 598767: { ignoreTextPosts: [ "FRACTAL" ] }, 602894: { ignoreTextPosts: [ "" ] }, 604604: { missedTextUpdates: [ "605127", "606702" ] }, 609653: { missedTextUpdates: [ "610108", "610137" ] }, 611369: { wrongImageUpdates: [ "620890" ] }, 611997: { missedTextUpdates: [ "612102", "612109" ], wrongTextUpdates: [ "617447" ] }, 613977: { missedTextUpdates: [ "614036" ] }, 615246: { missedTextUpdates: [ "638243", "638245", "638246", "638248" ] }, 615752: { ignoreTextPosts: [ "Uplifted" ] }, 617061: { ignoreTextPosts: [ "!TEEDashxDA" ] }, 617484: { missedTextUpdates: [ "617509", "617830" ] }, 618712: { missedTextUpdates: [ "619097", "619821", "620260" ] }, 620830: { collaboration: true }, 623611: { ignoreTextPosts: [ "!5tTWT1eydY" ] }, 623897: { wrongTextUpdates: [ "625412" ] }, 625364: { missedTextUpdates: [ "635199" ] }, 625814: { collaboration: true, missedTextUpdates: [ "625990" ] }, 627139: { ignoreTextPosts: [ "", "Seal" ] }, 628023: { missedTextUpdates: [ "628323", "629276", "629668" ] }, 628357: { ignoreTextPosts: [ "" ] }, 632345: { ignoreTextPosts: [ "!TEEDashxDA" ] }, 632823: { missedTextUpdates: [ "632860", "633225", "633632", "633649", "633723", "634118" ], ignoreTextPosts: [ "" ] }, 633187: { missedTextUpdates: [ "633407", "633444", "634031", "634192", "634462" ] }, 633487: { missedAuthors: [ "8b8b34", "fe7a48", "20ca72", "668d91" ] }, 634122: { ignoreTextPosts: [ "Apollo" ] }, 639549: { ignoreTextPosts: [ "Apollo" ] }, 641286: { missedTextUpdates: [ "641650" ] }, 642667: { missedTextUpdates: [ "643113" ] }, 642726: { missedTextUpdates: [ "648209", "651723" ] }, 643327: { ignoreTextPosts: [ "" ] }, 644179: { missedTextUpdates: [ "647317" ] }, 645426: { missedTextUpdates: [ "651214", "670665", "671751", "672911", "674718", "684082" ] }, 648109: { missedTextUpdates: [ "711809", "711811" ] }, 648646: { missedTextUpdates: [ "648681" ] }, 651220: { missedTextUpdates: [ "653791" ] }, 651382: { missedAuthors: [ "bbfc3d" ] }, 651540: { missedTextUpdates: [ "651629" ] }, 655158: { ignoreTextPosts: [ "" ] }, 662096: { ignoreTextPosts: [ "" ] }, 662196: { collaboration: true }, 662452: { ignoreTextPosts: [ "" ] }, 662661: { ignoreTextPosts: [ "" ] }, 663088: { collaboration: true, ignoreTextPosts: [ "Apollo" ] }, 663996: { missedTextUpdates: [ "673890" ] }, 668009: { missedTextUpdates: [ "668227" ] }, 668216: { imageQuest: false}, 669206: { imageQuest: true, missedAuthors: [ "75347e" ] }, 672060: { missedTextUpdates: [ "673216" ] }, 673444: { ignoreTextPosts: [ "" ] }, 673575: { collaboration: true }, 673811: { missedTextUpdates: [ "682275", "687221", "687395", "688995" ], ignoreTextPosts: [ "" ] }, 677271: { missedTextUpdates: [ "677384" ] }, 678114: { imageQuest: false}, 678608: { missedTextUpdates: [ "678789" ] }, 679357: { missedTextUpdates: [ "679359", "679983" ] }, 680125: { ignoreTextPosts: [ "", "BritishHat" ] }, 681620: { missedAuthors: [ "d9faec" ] }, 683261: { missedAuthors: [ "3/8 MPP, 4/4 MF" ] }, 686590: { imageQuest: false}, 688371: { missedTextUpdates: [ "696249", "696257" ], ignoreTextPosts: [ "", "Chaos", "Ariadne", "Melinoe", "\"F\"ingGenius" ] }, 691136: { missedTextUpdates: [ "697620" ], ignoreTextPosts: [ "" ], wrongImageUpdates: [ "706696" ] }, 691255: { ignoreTextPosts: [ "" ] }, 692093: { collaboration: true, ignoreTextPosts: [ "Boxdog" ] }, 692872: { missedTextUpdates: [ "717187" ] }, 693509: { missedAuthors: [ "640f86" ], ignoreTextPosts: [ "640f86" ] }, 693648: { missedTextUpdates: [ "694655" ] }, 694230: { ignoreTextPosts: [ "" ] }, 700573: { missedTextUpdates: [ "702352", "720330" ], ignoreTextPosts: [ "" ] }, 701456: { ignoreTextPosts: [ "" ] }, 702865: { ignoreTextPosts: [ "" ] }, 705639: { wrongTextUpdates: [ "794696" ] }, 706303: { missedAuthors: [ "5a8006" ] }, 706439: { missedTextUpdates: [ "714791" ] }, 706938: { ignoreTextPosts: [ "" ] }, 711320: { missedTextUpdates: [ "720646", "724022" ] }, 712179: { missedTextUpdates: [ "712255", "715182" ] }, 712785: { ignoreTextPosts: [ "" ] }, 713042: { missedTextUpdates: [ "713704" ] }, 714130: { imageQuest: true}, 714290: { missedTextUpdates: [ "714307", "714311" ] }, 714858: { ignoreTextPosts: [ "" ] }, 715796: { ignoreTextPosts: [ "" ] }, 717114: { missedTextUpdates: [ "717454", "717628" ] }, 718797: { missedAuthors: [ "FRACTAL on the go" ] }, 718844: { collaboration: true, missedTextUpdates: [ "720240", "721242" ] }, 719505: { ignoreTextPosts: [ "" ] }, 719579: { imageQuest: false}, 722585: { wrongTextUpdates: [ "724938" ] }, 726944: { ignoreTextPosts: [ "" ] }, 727356: { ignoreTextPosts: [ "" ] }, 727581: { missedTextUpdates: [ "728169" ] }, 727677: { ignoreTextPosts: [ "Melinoe" ] }, 728411: { missedTextUpdates: [ "728928" ] }, 730993: { missedTextUpdates: [ "731061" ] }, 732214: { imageQuest: true, wrongTextUpdates: [ "732277" ] }, 734610: { ignoreTextPosts: [ "D3w" ] }, 736484: { ignoreTextPosts: [ "Roman" ], wrongImageUpdates: [ "750212", "750213", "750214" ] }, 741609: { missedTextUpdates: [ "754524" ] }, 743976: { ignoreTextPosts: [ "", "Typo" ] }, 745694: { ignoreTextPosts: [ "Crunchysaurus" ] }, 750281: { ignoreTextPosts: [ "Autozero" ] }, 752572: { missedTextUpdates: [ "752651", "752802", "767190" ] }, 754415: { collaboration: true, ignoreTextPosts: [ "!5tTWT1eydY" ] }, 755378: { missedAuthors: [ "!Ykw7p6s1S." ] }, 758668: { ignoreTextPosts: [ "LD" ] }, 767346: { ignoreTextPosts: [ "" ] }, 768858: { ignoreTextPosts: [ "LD" ] }, 774368: { missedTextUpdates: [ "774500" ] }, 774930: { missedTextUpdates: [ "794040" ] }, 778045: { missedTextUpdates: [ "778427", "779363" ] }, 779564: { ignoreTextPosts: [ "" ] }, 784068: { wrongTextUpdates: [ "785618" ] }, 785044: { wrongTextUpdates: [ "801329" ] }, 789976: { missedTextUpdates: [ "790596", "793934", "800875", "832472" ] }, 794320: { wrongTextUpdates: [ "795183" ] }, 798380: { missedTextUpdates: [ "799784", "800444", "800774", "800817", "801212" ] }, 799546: { missedTextUpdates: [ "801103", "802351", "802753" ] }, 799612: { missedTextUpdates: [ "799968", "801579" ] }, 800605: { collaboration: true }, 802411: { missedTextUpdates: [ "805002" ] }, 807972: { wrongTextUpdates: [ "811969" ] }, 809039: { wrongImageUpdates: [ "817508", "817511" ] }, 811957: { ignoreTextPosts: [ "via Discord" ] }, 814448: { missedTextUpdates: [ "817938" ] }, 817541: { missedAuthors: [ "Raptie" ] }, 822552: { imageQuest: false}, 823831: { missedAuthors: [ "Retro-LOPIS" ] }, 827264: { ignoreTextPosts: [ "LD", "DogFace" ] }, 830006: { collaboration: true }, 835062: { ignoreTextPosts: [ "Curves" ] }, 835750: { missedTextUpdates: [ "836870" ] }, 836521: { wrongTextUpdates: [ "848748" ] }, 837514: { ignoreTextPosts: [ "LD" ] }, 839906: { missedTextUpdates: [ "845724" ] }, 840029: { missedTextUpdates: [ "840044", "840543" ] }, 841851: { ignoreTextPosts: [ "Serpens", "Joy" ] }, 842392: { missedTextUpdates: [ "842434", "842504", "842544" ] }, 844537: { missedTextUpdates: [ "847326" ] }, 845168: { ignoreTextPosts: [ "a48264" ] }, 848887: { imageQuest: true, wrongTextUpdates: [ "851878" ] }, 854088: { missedTextUpdates: [ "860219" ], ignoreTextPosts: [ "Ursula" ] }, 854203: { ignoreTextPosts: [ "Zenthis" ] }, 857294: { wrongTextUpdates: [ "857818" ] }, 858913: { imageQuest: false}, 863241: { missedTextUpdates: [ "863519" ] }, 865754: { missedTextUpdates: [ "875371" ], ignoreTextPosts: [ "???" ] }, 869242: { ignoreTextPosts: [ "" ] }, 871667: { missedTextUpdates: [ "884575" ] }, 876808: { imageQuest: false}, 879456: { missedTextUpdates: [ "881847" ] }, 881097: { missedTextUpdates: [ "881292", "882339" ] }, 881374: { ignoreTextPosts: [ "LD" ] }, 885481: { imageQuest: false, wrongTextUpdates: [ "886892" ] }, 890023: { missedAuthors: [ "595acb" ] }, 892578: { ignoreTextPosts: [ "" ] }, 897318: { missedTextUpdates: [ "897321", "897624" ] }, 897846: { missedTextUpdates: [ "897854", "897866" ] }, 898917: { missedAuthors: [ "Cee (mobile)" ] }, 900852: { missedTextUpdates: [ "900864" ] }, 904316: { missedTextUpdates: [ "904356", "904491" ] }, 907309: { missedTextUpdates: [ "907310" ] }, 913803: { ignoreTextPosts: [ "Typo" ] }, 915945: { missedTextUpdates: [ "916021" ] }, 917513: { missedTextUpdates: [ "917515" ] }, 918806: { missedTextUpdates: [ "935207" ] }, 921083: { ignoreTextPosts: [ "LawyerDog" ] }, 923174: { ignoreTextPosts: [ "Marn", "MarnMobile" ] }, 924317: { ignoreTextPosts: [ "" ] }, 924496: { ignoreTextPosts: [ "Alyssa" ], wrongImageUpdates: [ "926049", "951786" ] }, 926927: { missedTextUpdates: [ "928194" ] }, 929115: { wrongTextUpdates: [ "959349" ] }, 929545: { missedTextUpdates: [ "929634" ] }, 930854: { missedTextUpdates: [ "932282" ] }, 934026: { missedTextUpdates: [ "934078", "934817" ] }, 935464: { missedTextUpdates: [ "935544", "935550", "935552", "935880" ] }, 934706: { missedAuthors: [ "Karma" ], wrongTextUpdates: [ "934721", "934985", "935521" ] }, 939572: { missedTextUpdates: [ "940402" ] }, 940835: { missedTextUpdates: [ "941005", "941067", "941137", "941226", "942383", "944236", "945435" ] }, 944938: { missedTextUpdates: [ "945119" ] }, 947959: { collaboration: true, wrongImageUpdates: [ "948044", "948045" ] }, 949128: { ignoreTextPosts: [ "Breven" ] }, 950800: { missedTextUpdates: [ "955309", "955582", "956789", "1020563" ] }, 951319: { missedTextUpdates: [ "951450" ] }, 954301: { wrongImageUpdates: [ "954628" ] }, 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" ] }, 964608: { missedTextUpdates: [ "964674" ] }, 968549: { missedTextUpdates: [ "968841" ] }, 971193: { missedTextUpdates: [ "971938", "997943" ] }, 971801: { missedTextUpdates: [ "971804", "971825" ] }, 973331: { missedTextUpdates: [ "973726", "973764" ] }, 974578: { missedTextUpdates: [ "974901", "974902", "974903", "974906", "975511", "976295", "976296", "976297", "976298" ] }, 975434: { imageQuest: false }, 976580: { missedTextUpdates: [ "976604", "976657", "976669", "976841", "983554" ] }, 978557: { ignoreTextPosts: [ "" ] }, 982815: { missedTextUpdates: [ "992488" ] }, 989640: { missedTextUpdates: [ "989941" ] }, 991564: { missedTextUpdates: [ "991566" ] }, 994099: { collaboration: true }, 1001663: { collaboration: true, ignoreTextPosts: [ "" ] }, 1002454: { ignoreTextPosts: [ "" ] }, 1004042: { missedAuthors: [ "b35aa3", "Kiocoatl" ] }, 1010895: { missedTextUpdates: [ "1010926", "1011004", "1011011" ] }, 1013611: { wrongImageUpdates: [ "1014242" ], ignoreTextPosts: [ "tippler", "" ] }, 1014176: { ignoreTextPosts: [ "Edmango" ] }, 1014571: { missedTextUpdates: [ "1025238" ] }, 1015802: { ignoreTextPosts: [ "Edmango" ] }, 1023251: { imageQuest: false }, 1023844: { ignoreTextPosts: [ "good ol wife" ] }, 1028352: { wrongTextUpdates: [ "1028424", "1028426", "1028771", "1028772", "1028957", "1028958" ] }, 1028430: { ignoreTextPosts: [ "" ], wrongTextUpdates: [ "1028530" ] }, 2000012: { missedAuthors: [ "Happiness" ] }, }; } return this.allFixes; } } //More or less standard XMLHttpRequest wrapper //Input: url //Output: Promise that resolves into the XHR object (or a HTTP error code) class Xhr { static get(url) { return new Promise(function(resolve, reject) { const xhr = new XMLHttpRequest(); xhr.onreadystatechange = function(e) { if (xhr.readyState === 4) { if (xhr.status === 200) { resolve(xhr); } else { reject(xhr.status); } } }; xhr.ontimeout = function () { reject("timeout"); }; xhr.open("get", url, true); xhr.send(); }); } } //QuestReader class //Input: none //Output: none //Usage: new QuestReader.init(settings); //settings: a settings object obtained from the object's onSettingsChanged event, allowing you to store settings class QuestReader { constructor(doc) { this.doc = doc; this.boardName = doc.location.pathname.match(new RegExp("/kusaba/([a-z]*)/res/"))[1]; this.threadType = BoardThreadTypes[this.boardName]; this.defaultName = BoardDefaultNames[this.boardName]; this.hasTitle = false; this.updates = []; this.sequences = []; this.defaultSettings = this.getDefaultSettings(); this.setSettings(this.defaultSettings); this.threadID = null; this.refClass = null; this.posts = null; this.firstPostElements = []; this.cloneCache = {}; this.controls = {}; this.total = { authorComments: 0, suggestions: 0}; this.author = null; this.suggesters = null; this.scrollIntervalHandle = null; this.replyFormDraggable = null; this.dayNames = [ "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; this.currentDateTime = null; this.relativeTime = null; //regular expressions this.reCanonID = new RegExp("^[0-9a-f]{6}$"); //events this.onSettingsLoad = null; this.onSettingsChanged = null; this.onWikiDataLoad = null; this.onWikiDataChanged = null; } getDefaultSettings() { return { currentUpdateIndex: 0, viewMode: "all", //all, single, sequence showSuggestions: "all", //none, last, all showAuthorComments: "all", //none, last, all showReferences: this.threadType == ThreadType.QUEST ? "nonupdates" : "all", //none, nonupdates, all replyFormLocation: "float", //top, bottom, float expandImages: "none", //none, updates, all maxImageWidth: 100, maxImageHeight: 96, stretchImages: false, showUpdateInfo: false, timestampFormat: "server", //server, local, relative, auto, hide colorUsers: false, showUserPostCounts: false, showUserNav: false, showUserMultiPost: false, showReplyForm: false, moveToLast: false, wikiPages: [], wikiLastSearchTime: 0, }; } init(doc) { var results = new UpdateAnalyzer({ passive: this.threadType != ThreadType.QUEST, defaultName: this.defaultName, debug: true }).analyzeQuest(doc); //run UpdateAnalyzer to determine which posts are updates and what not this.threadID = results.postTypes.keys().next().value; this.refClass = `ref|${doc.body.querySelector(`input[name="board"]`).value}|${this.threadID}|`; //a variable used for finding and creating post reference links this.updates = this.getUpdatePostGroups(results.postTypes, results.postUsers); //organize posts into groups where each group has one update post and its trailing suggestions this.sequences = this.getUpdateSequences(); //a list of unique update sequences this.loadSettings(); //load settings; YouDontSay.jpg this.insertStyling(this.doc); //insert html elements for styling this.posts = this.buildPostInfo(this.doc, results.postTypes, results.postUsers); //cache post elements and other post data for faster access this.initCloneCache(this.doc); //cache some elements for faster insertion this.insertControls(this.doc); //insert html elements for controls this.insertEvents(this.doc); //insert our own button events plus some global events this.modifyLayout(this.doc); //change the default layout by moving elements around to make them fit better this.insertTooltips(); //add some explanations to our controls this.refresh({checkHash: "correct", smoothScroll: false}); //hide all posts and show only the relevant ones; enable/disable/update controls this.useWiki(); //get data from wiki and use them to set up wiki link, disthread link, and other thread links } getUpdatePostGroups(postTypes, postUsers) { var updatePostGroups = []; var currentPostGroup = { updatePostID: 0, suggestions: [], authorComments: [] }; var postTypesArray = [...postTypes]; //create post groups for (let i = postTypesArray.length - 1; i >= 0; i--) { if (postTypesArray[i][1] == PostType.UPDATE) { currentPostGroup.updatePostID = postTypesArray[i][0]; updatePostGroups.unshift(currentPostGroup); currentPostGroup = { updatePostID: 0, suggestions: [], authorComments: [] }; } else if (postTypesArray[i][1] == PostType.AUTHORCOMMENT) { currentPostGroup.authorComments.unshift(postTypesArray[i][0]); this.total.authorComments++; } else { //PostType.SUGGESTION currentPostGroup.suggestions.unshift(postTypesArray[i][0]); this.total.suggestions++; } } //create sequence groups var currentUpdateSequence = []; updatePostGroups.forEach(postGroup => { currentUpdateSequence.push(postGroup); postGroup.sequence = currentUpdateSequence; if (postGroup.suggestions.length > 0) { currentUpdateSequence = []; } }); //set post suggesters var allSuggesters = new Set(); updatePostGroups.forEach(postGroup => { postGroup.suggesters = postGroup.suggestions.reduce((suggesters, el) => { var suggester = postUsers.get(el); suggesters.add(suggester); allSuggesters.add(suggester); return suggesters; }, new Set()); postGroup.suggesters = [...postGroup.suggesters]; }); this.author = postUsers.get(this.threadID); this.suggesters = [...allSuggesters]; return updatePostGroups; } getUpdateSequences() { var sequences = []; this.updates.forEach(update => { if (update.sequence !== sequences[sequences.length - 1]) { sequences.push(update.sequence); } }); return sequences; } currentUpdate() { return this.updates[this.currentUpdateIndex]; } firstUpdate() { return this.updates[0]; } lastUpdate() { return this.updates[this.updates.length - 1]; } currentSequenceIndex() { return this.sequences.indexOf(this.currentUpdate().sequence); } buildPostInfo(doc, postTypes, postUsers) { var posts = new Map(); doc.body.querySelector(":scope > form").querySelectorAll(".postwidth > a[name]").forEach(anchor => { if (anchor.name == "s") { return; } var header = anchor.parentElement; var inner = header.parentElement; var postID = parseInt(anchor.name); var outer = inner; var outerName = postID === this.threadID ? "FORM" : "TABLE"; while (outer.nodeName != outerName) { outer = outer.parentElement; } posts.set(postID, { outer: outer, inner: inner, header: header, user: postUsers.get(postID), type: postTypes.get(postID), timestampFormat: "server", relativeTime: null, serverDateTimeString: "", utcTime: 0, references: null, suggesterIsNew: false, userPrevPost: null, userNextPost: null, insertedReferences: false, insertedUserColors: false, insertedUserNav: false, insertedUserMultiPost: false, expandedThumbnail: false, imgInfo: null, }); }); this.getFirstPostElements(posts); this.getPostImageInfo(posts); this.getPostReferences(posts); if (this.threadType === ThreadType.QUEST) { this.getUserMultiPostInfo(posts, postUsers); } this.getUserNavInfo(posts, postUsers); this.setUserPostCounts(posts); return posts; } getFirstPostElements(posts) { var child = posts.get(this.threadID).inner.firstElementChild; while (child && child.nodeName !== "TABLE") { if (child.className === "postwidth" || child.nodeName === "BLOCKQUOTE" || child.className === "pony" || child.className === "unicorn" || child.className === "de-refmap") { this.firstPostElements.push(child); } child = child.nextElementSibling; } } getPostImageInfo(posts) { var reExpandCode = new RegExp(`expandimg\\('([0-9]+)', '(.*)', '(.*)', '([0-9]+)', '([0-9]+)', '([0-9]+)', '([0-9]+)'\\);`); var fileLinks = document.querySelectorAll(".filesize a"); fileLinks.forEach(fileLink => { var expandString = fileLink.getAttribute("onclick") || ""; var matches = expandString.match(reExpandCode); if (matches) { var post = posts.get(+matches[1]); if (post) { post.imgInfo = { imgSrc: matches[2], thumbSrc: matches[3], imgWidth: matches[4], imgHeight: matches[5], thumbWidth: matches[6], thumbHeight: matches[7], }; if (!post.imgInfo.imgSrc.startsWith("http")) { post.imgInfo.imgSrc = this.doc.location.origin + post.imgInfo.imgSrc; post.imgInfo.thumbSrc = this.doc.location.origin + post.imgInfo.thumbSrc; } } } }); } getPostReferences(posts) { var postLinks = posts.get(this.threadID).outer.querySelectorAll(`blockquote a[class^="${this.refClass}"]`); //this is faster than traversing all posts postLinks.forEach(link => { var parent = link.parentElement; while (parent.nodeName !== "BLOCKQUOTE") { parent = parent.parentElement; } parent = parent.parentElement; var linkPostID = parent.id.startsWith("reply") ? parseInt(parent.id.substring(5)) : this.threadID; var targetID = parseInt(link.classList[0].substring(this.refClass.length)); var targetInfo = posts.get(targetID); if (!targetInfo) { return; } if (!targetInfo.references) { targetInfo.references = []; } targetInfo.references.push(linkPostID); }); } getUserMultiPostInfo(postsInfo, postUsers) { this.updates.forEach(update => { var suggesterPosts = new Map(); //dictionary <user, postIDs[]>; which suggester made which posts for the current update update.suggestions.forEach(postID => { var suggester = postUsers.get(postID); var posts = suggesterPosts.get(suggester) || []; posts.push(postID); suggesterPosts.set(suggester, posts); }); suggesterPosts.forEach((posts, suggester) => { suggester.isNew = suggester.postCount === posts.length; for (var postIndex = 0; postIndex < posts.length; postIndex++) { var postInfo = postsInfo.get(posts[postIndex]); if (!postInfo) { continue; } postInfo.suggesterIsNew = suggester.postCount === posts.length; if (posts.length > 1) { postInfo.userMultiPostIndex = postIndex + 1; postInfo.userMultiPostLength = posts.length; } } }); }); } getUserNavInfo(posts, postUsers) { var usersPrevPost = new Map(); posts.forEach((post, postID) => { var user = postUsers.get(postID); post.userPostCount = user.postCount; var prevPostID = usersPrevPost.get(user); if (prevPostID) { posts.get(prevPostID).userNextPost = postID; post.userPrevPost = prevPostID; } usersPrevPost.set(user, postID); }); usersPrevPost.clear(); } setUserPostCounts(posts, postUsers) { posts.forEach((post, postID) => { var uidEl = post.header.querySelector(".uid"); uidEl.setAttribute("postcount", post.userPostCount); if (post.suggesterIsNew) { uidEl.classList.add("isNew"); } }); } initCloneCache(doc) { this.cloneCache.a = doc.createElement("a"); this.cloneCache.div = doc.createElement("div"); this.cloneCache.span = doc.createElement("span"); 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>`); 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>`); this.cloneCache.upLink = fragmentUp.children[0]; this.cloneCache.downLink = fragmentDown.children[0]; this.cloneCache.userMultiPostElement = doc.createElement("span"); this.cloneCache.userMultiPostElement.className = "qrUserMultiPost"; } refresh(options = {}) { 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 var scroll = options.scroll !== undefined ? options.scroll : true; //scroll: if true (default), scroll to the current update var smoothScroll = options.smoothScroll !== undefined ? options.smoothScroll : true; //if true (default), use smooth scroll var scrollToPostID = null; if (this.moveToLast) { this.moveToLast = false; this.showLast(false); } if (checkHash !== "false") { var hashedPostID = parseInt(this.doc.location.hash.replace("#", "")); if (!isNaN(hashedPostID) && hashedPostID != this.threadID && this.posts.has(hashedPostID)) { //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. //This usually results in visiting a post before the update, which is wrong -> we should be showing the update instead scrollToPostID = hashedPostID; var hashedUpdateIndex = this.findUpdateIndex(hashedPostID); if (checkHash === "correct" && this.threadType == ThreadType.QUEST) { var hashedUpdate = this.updates[hashedUpdateIndex]; var hashedUpdatePosts = hashedUpdate.authorComments.concat(hashedUpdate.suggestions).sort(); var isLastPostInTheUpdate = hashedPostID == hashedUpdatePosts[hashedUpdatePosts.length - 1]; //the correction should only work if it's the one post before the first update of the last sequence if (isLastPostInTheUpdate && hashedUpdateIndex >= 0 && hashedUpdateIndex >= this.updates.indexOf(this.lastUpdate().sequence[0]) - 1 && hashedUpdateIndex < this.updates.length - 1) { hashedUpdateIndex++; scrollToPostID = null; } } this.changeIndex(hashedUpdateIndex, false); } } if (!scrollToPostID && this.threadType == ThreadType.QUEST && this.viewMode == "all" && this.currentUpdate() != this.firstUpdate()) { scrollToPostID = this.currentUpdate().updatePostID; } this.currentDateTime = new Date(); this.hideAll(); this.showCurrentUpdates(); if (checkHash !== "false" && scrollToPostID) { this.showPost(scrollToPostID); //in case we want to scroll to a hidden suggestion, we want to show it first } this.updateControls(); if (scroll) { var scrollToElement = (scrollToPostID && this.posts.has(scrollToPostID)) ? this.posts.get(scrollToPostID).outer : this.controls.controlsTop; var scrollOptions = { behavior: smoothScroll ? "smooth" : "auto", block: "start", }; var scrollFunction = () => { this.doc.defaultView.requestAnimationFrame(() => { scrollToElement.scrollIntoView(scrollOptions); }); }; if (this.doc.readyState !== "complete") { this.doc.defaultView.addEventListener("load", scrollFunction, { once: true }); } else { scrollFunction(); } } } hideAll() { this.posts.forEach((post, postID) => { if (postID == this.threadID) { this.firstPostElements.forEach(el => { el.classList.add("veryhidden"); }); } else { post.outer.classList.add("hidden"); } }); } findUpdateIndex(postID) { for (var i = 0; i < this.updates.length; i++) { if (this.updates[i].updatePostID == postID || this.updates[i].suggestions.indexOf(postID) != -1 || this.updates[i].authorComments.indexOf(postID) != -1) { return i; } } return -1; } showCurrentUpdates() { var updatesToShow = { single: [ this.currentUpdate() ], sequence: this.currentUpdate().sequence, all: this.updates, }; var updatesToExpand = {}; updatesToExpand.single = [this.updates[this.currentUpdateIndex - 1], this.currentUpdate(), this.updates[this.currentUpdateIndex + 1]].filter(el => !!el); updatesToExpand.sequence = [...this.sequences[this.currentSequenceIndex() - 1] || [], ...this.sequences[this.currentSequenceIndex()], ...this.sequences[this.currentSequenceIndex() + 1] || []]; //expanding images on the fly when in full thread view is a bit janky when navigating up updatesToExpand.all = [this.currentUpdate(), this.updates[this.currentUpdateIndex + 1], this.updates[this.currentUpdateIndex + 2]].filter(el => !!el); //updatesToExpand.all = this.updates; updatesToShow[this.viewMode].forEach(update => this.showUpdate(update)); updatesToExpand[this.viewMode].forEach(update => this.expandUpdateImages(update)); } expandUpdateImages(update) { var postsToExpand = [ update.updatePostID ]; if (this.expandImages != "updates") { postsToExpand = postsToExpand.concat(update.suggestions, update.authorComments); } postsToExpand.forEach(postID => { var post = this.posts.get(postID); if (!post || !post.imgInfo) { return; } var img = post.header.querySelector(`#thumb${postID} > img`); if (img.previousElementSibling && img.previousElementSibling.nodeName === "CANVAS") { img.previousElementSibling.remove(); //remove canvas covering the image img.style.removeProperty("display"); } var expanded = this.viewMode == "all" ? img.style.backgroundImage === `url(${post.imgInfo.imgSrc})` : img.src == post.imgInfo.imgSrc; if (expanded !== (this.expandImages == "all" || (this.expandImages == "updates" && postID == update.updatePostID))) { //image should be expanded or contracted if (!expanded) { img.removeAttribute("onmouseover"); img.removeAttribute("onmouseout"); if (post.outer.classList.contains("hidden")) { //if it's a hidden post, we'd like to preload the image, but a bit later setTimeout(() => { new Image().src = post.imgInfo.imgSrc }); } else if (this.viewMode == "all") { img.style.backgroundImage = `url(${post.imgInfo.imgSrc})`; } else { img.src = post.imgInfo.imgSrc; } } else { img.src = post.imgInfo.thumbSrc; img.width = post.imgInfo.thumbWidth; img.height = post.imgInfo.thumbHeight; } } }); } showUpdate(update) { this.showPost(update.updatePostID); if (this.showSuggestions == "all" || this.showSuggestions == "last" && update == this.lastUpdate()) { update.suggestions.forEach(postID => this.showPost(postID)); } if (this.showAuthorComments == "all" || this.showAuthorComments == "last" && update == this.lastUpdate()) { update.authorComments.forEach(postID => this.showPost(postID)); } } showPost(postID) { this.insertPostElements(postID); if (postID == this.threadID) { this.firstPostElements.forEach(el => { el.classList.remove("veryhidden"); }); } else { var post = this.posts.get(postID); if (post) { post.outer.classList.remove("hidden"); } } } insertPostElements(postID) { var post = this.posts.get(postID); if (!post) { return; } if (!post.insertedReferences && (this.showReferences == "all" || (this.showReferences == "nonupdates" && post.type !== PostType.UPDATE))) { post.insertedReferences = true; if (post.references) { this.insertPostReferences(postID, post.references, post.inner); } } if (!post.insertedUserColors && this.colorUsers) { post.insertedUserColors = true; if (this.threadType !== ThreadType.QUEST || (post.user.id !== this.author.id && post.type !== PostType.UPDATE)) { this.insertUserColors(post); } } if (!post.insertedUserNav && this.showUserNav) { post.insertedUserNav = true; this.insertUserNav(post); } if (!post.insertedUserMultiPost && this.showUserMultiPost) { post.insertedUserMultiPost = true; if (post.userMultiPostIndex) { this.insertUserMultiPost(post); } } if (post.timestampFormat != this.timestampFormat || post.relativeTime !== this.relativeTime) { post.timestampFormat = this.timestampFormat; post.relativeTime = this.relativeTime; this.changePostTime(post); } if (this.viewMode == "all" && post.imgInfo && post.expandedThumbnail !== (this.expandImages === "all" || (this.expandImages == "updates" && post.type === PostType.UPDATE))) { post.expandedThumbnail = !post.expandedThumbnail; this.resizeThumbnail(post, post.expandedThumbnail); } } resizeThumbnail(post, expandThumbnail) { var imgElement = post.header.querySelector("img.thumb"); if (expandThumbnail) { 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>`; imgElement.style.backgroundImage = `url(${post.imgInfo.thumbSrc})`; imgElement.removeAttribute("onmouseover"); //it's possible these events trigger before expandUpdateImages and restore spoiler images, so gotta remove them here imgElement.removeAttribute("onmouseout"); } else { imgElement.src = post.imgInfo.thumbSrc; imgElement.style.backgroundImage = ""; imgElement.width = post.imgInfo.thumbWidth; imgElement.height = post.imgInfo.thumbHeight; } } insertPostReferences(postID, references, postElement) { var links = references.map(id => { var newLink = this.cloneCache.a.cloneNode("false"); newLink.href = `#${id}`; newLink.className = `${this.refClass}${id}| qrReference`; newLink.textContent = `>>${id}`; return newLink; }); var newDiv = this.cloneCache.div.cloneNode("false"); newDiv.classList.add("qrReferences"); newDiv.append(...links); postElement.querySelector("blockquote").insertAdjacentElement("afterEnd", newDiv); if (postID === this.threadID) { this.firstPostElements.push(newDiv); } } insertUserColors(post) { var uidEl = post.header.querySelector(".uid"); var span = this.cloneCache.span.cloneNode(false); span.className = `qrColoredUid uid${this.getCanonID(post.user)}`; span.textContent = uidEl.firstChild.nodeValue.substring(4); uidEl.firstChild.nodeValue = "ID: "; uidEl.appendChild(span); } insertUserNav(post) { var uidEl = post.header.querySelector(".uid"); var downLink = this.cloneCache.downLink.cloneNode(true); var upLink = this.cloneCache.upLink.cloneNode(true); if (post.userPrevPost) { upLink.href = `#${post.userPrevPost}`; upLink.className = "qrUserNavEnabled"; } if (post.userNextPost) { downLink.href = `#${post.userNextPost}`; downLink.className = "qrUserNavEnabled"; } uidEl.insertAdjacentElement("afterEnd", downLink); uidEl.insertAdjacentElement("afterEnd", upLink); } insertUserMultiPost(post) { var span = this.cloneCache.userMultiPostElement.cloneNode(false); span.textContent = `post ${post.userMultiPostIndex}/${post.userMultiPostLength}`; post.header.appendChild(span); } changePostTime(post) { var timeNode = post.header.querySelector("label").lastChild; if (post.timestampFormat == "hide") { post.serverDateTimeString = post.serverDateTimeString || ` ${timeNode.nodeValue.trim()}`; timeNode.nodeValue = ""; return; } var utcTime = this.getUtcTime(post, timeNode); var difference = this.currentDateTime.getTime() - utcTime; if (post.relativeTime !== null ? post.relativeTime : post.timestampFormat == "relative" || (post.timestampFormat == "auto" && difference < 86400000)) { var date = new Date(utcTime); timeNode.nodeValue = `${this.getRelativeTimeString(difference, difference >= 86400000)} ago${difference >= 86400000 ? ` at ${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}` : ""}`; } else { timeNode.nodeValue = post.timestampFormat == "server" ? post.serverDateTimeString : this.getAbsoluteDateTimeString(new Date(utcTime)); } } getUtcTime(post, timeNode) { if (!post.utcTime) { post.serverDateTimeString = post.serverDateTimeString || ` ${timeNode.nodeValue.trim()}`; var d = post.serverDateTimeString; var serverTime = Date.UTC(d.substring(1, 5), d.substring(6, 8) - 1, d.substring(9, 11), d.substring(16, 18), d.substring(19)); post.utcTime = serverTime - this.getServerTimeZoneOffset(new Date(serverTime)); } return post.utcTime; } getServerTimeZoneOffset(dateTime) { var month = dateTime.getUTCMonth(); var day = dateTime.getUTCDate(); var dayOfWeek = dateTime.getUTCDay(); var hour = dateTime.getUTCHours(); var firstSunday = ((day + 7 - dayOfWeek) % 7) || 7; var after2ndSundayInMarch3am = month > 2 || (month == 2 && (day > firstSunday + 7 || ( day == firstSunday + 7 && hour >= 3))); var before1stSundayInNovember1am = month < 10 || (month == 10 && (day < firstSunday || ( day == firstSunday && hour < 1))); var isDST = after2ndSundayInMarch3am && before1stSundayInNovember1am; return (isDST ? -7 : -8) * 3600000; } getRelativeTimeString(difference, onlyDate) { //convert a difference between two dates into a string by finding and using the two most relevant date parts //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 var names = onlyDate ? ["d", "mo", "y"] : ["min", "h", "d", "mo", "y"]; var factors = onlyDate ? [30.4375, 12] : [60, 24, 30.4375, 12]; var baseDifference = onlyDate ? difference / (60000 * 60 * 24) : difference / 60000; var diffs = factors.reduce((diffs, factor, index) => { diffs.push(diffs[index] / factor); return diffs; }, [ baseDifference ]); var i = diffs.length - 1; for (; i > 0 && diffs[i] < 1; i--) {} //find the most relevant date part -> the first one with a value greater than 1 if (i > 0) { //truncate + round; 1.76h 105.6min -> 1h 46min diffs[i] = Math.trunc(diffs[i]); diffs[i - 1] = Math.round(diffs[i - 1] - diffs[i] * factors[i - 1]); if (diffs[i - 1] == factors[i - 1]) { //correct the parts if rounded to a factor number; 1h 60min -> 2h 0min diffs[i - 1] = 0; diffs[i]++; } } else { diffs[i] = onlyDate ? Math.trunc(diffs[i]) : Math.round(diffs[i]); } if (i < diffs.length - 1 && diffs[i] == factors[i]) { //correct the parts if rounding caused a transition; 60min -> 1h 0min diffs[i] = 0; i++; diffs[i] = 1; } return ` ${diffs[i]}${names[i]}${names[i - 1] ? ` ${diffs[i - 1]}${names[i - 1]}` : ""}`; } getAbsoluteDateTimeString(date) { return ` ${date.getFullYear()}/${(date.getMonth() + 1).toString().padStart(2, "0")}/${date.getDate().toString().padStart(2, "0")}` + `(${this.dayNames[date.getDay()]})${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}`; } showFirst() { var newUpdateIndex = 0; this.changeIndex(newUpdateIndex); if (this.doc.activeElement.disabled === true) { //Firefox bug: Keydown events don't trigger if focused input is disabled (event.target == this.controls.showFirstButtons[0] ? this.controls.showNextButtons[0] : this.controls.showNextButtons[1]).focus(); } } showLast(refresh = true) { var newUpdateIndex = this.viewMode == "sequence" ? this.updates.indexOf(this.sequences[this.sequences.length - 1][0]) : this.updates.length - 1; this.changeIndex(newUpdateIndex, refresh); if (this.doc.activeElement.disabled === true) { //Firefox bug: Keydown events don't trigger if focused input is disabled (event.target == this.controls.showLastButtons[0] ? this.controls.showPrevButtons[0] : this.controls.showPrevButtons[1]).focus(); } } showNext() { var newUpdateIndex = this.currentUpdateIndex + 1; if (this.viewMode == "sequence") { //move to the first update in the next sequence newUpdateIndex = this.currentSequenceIndex() < this.sequences.length - 1 ? this.updates.indexOf(this.sequences[this.currentSequenceIndex() + 1][0]) : this.updates.length; } this.changeIndex(newUpdateIndex); } showPrevious() { var newUpdateIndex = this.currentUpdateIndex - 1; if (this.viewMode == "sequence") { newUpdateIndex = this.currentSequenceIndex() > 0 ? this.updates.indexOf(this.sequences[this.currentSequenceIndex() - 1][0]) : -1; } this.changeIndex(newUpdateIndex); } changeIndex(newUpdateIndex, refresh = true) { if (newUpdateIndex === this.currentUpdateIndex || newUpdateIndex < 0 || newUpdateIndex > this.updates.length - 1) { return; } var difference = Math.abs(newUpdateIndex - this.currentUpdateIndex); if (this.viewMode == "sequence") { difference = Math.abs(this.sequences.indexOf(this.updates[newUpdateIndex].sequence) - this.currentUpdateSequence); } this.currentUpdateIndex = newUpdateIndex; if (refresh) { this.refresh({ smoothScroll: difference === 1 }); } this.settingsChanged(); //Firefox bug: Keydown events don't trigger if focused input is disabled (for instance, if clicked on the Last button) => change focus to some other button if (this.doc.activeElement.disabled === true) { var buttonToFocus = null; if (this.currentUpdateIndex === 0) { if ([...this.controls.showFirstButtons, ...this.controls.showPrevButtons].includes(event.target)) { buttonToFocus = this.controls.showNextButtons[0]; } } else if (this.currentUpdateIndex === this.updates.length - 1 || (this.viewMode == "sequence" && this.currentSequenceIndex() === this.sequences.length - 1)) { if ([...this.controls.showLastButtons, ...this.controls.showNextButtons].includes(event.target)) { buttonToFocus = this.controls.showPrevButtons[0]; } } if (buttonToFocus != null) { buttonToFocus.focus(); } } } loadSettings() { if (this.onSettingsLoad) { var e = { threadID: this.threadID, threadType: this.threadType, boardName: this.boardName, settings: null }; this.onSettingsLoad(e); if (e.settings) { this.setSettings(this.validateSettings(e.settings)); } } } setSettings(settings) { if (settings) { for(var settingName in settings) { this[settingName] = settings[settingName]; } } } validateSettings(settings) { if (!settings) { return settings; } if (settings.currentUpdateIndex < 0) settings.currentUpdateIndex = 0; if (settings.currentUpdateIndex >= this.updates.length) settings.currentUpdateIndex = this.updates.length - 1; for (var prop in settings) { if (this[prop] !== undefined && typeof(settings[prop]) !== typeof(this[prop])) { settings[prop] = this[prop]; } } if (!settings.replyFormLocation) { //replyFormLocation == "float" settings.showReplyForm = false; } return settings; } settingsChanged() { if (this.onSettingsChanged) { var settings = {}; for(var settingName in this.defaultSettings) { if (this[settingName] !== this.defaultSettings[settingName] || (Array.isArray(this[settingName]) && this[settingName].length > 0)) { settings[settingName] = this[settingName]; } } this.onSettingsChanged({ threadID: this.threadID, threadType: this.threadType, boardName: this.boardName, settings: settings }); } } toggleSettingsControls(e) { e.preventDefault(); //prevent scrolling to the top when clicking the link this.controls.settingsControls.classList.toggle("collapsedHeight"); var label = e.target; label.text = this.controls.settingsControls.classList.contains("collapsedHeight") ? "Settings" : "Hide Settings"; } showSettingsPage(e) { e.preventDefault(); var linkContainer = e.target.parentElement; if (!linkContainer.classList.contains("qrSettingsNavItem")) { return; } this.controls.settingsControls.querySelector(".qrSettingsNavItemSelected").classList.remove("qrSettingsNavItemSelected"); this.controls.settingsControls.querySelector(".qrCurrentPage").classList.remove("qrCurrentPage"); linkContainer.classList.add("qrSettingsNavItemSelected"); this.controls.settingsControls.querySelector(".qrSettingsPages").children[[...linkContainer.parentElement.children].indexOf(linkContainer)].classList.add("qrCurrentPage"); } toggleReplyForm(e) { e.preventDefault(); this.showReplyForm = !this.controls.replyForm.classList.toggle("hidden"); this.controls.replyFormRestoreButton.classList.toggle("hidden", this.showReplyForm); if (this.replyFormLocation == "float" && !this.controls.replyForm.classList.contains("qrPopout")) { this.controls.replyFormPopoutButton.click(); } if (this.showReplyForm) { this.controls.replyForm.querySelector(`[name="message"]`).focus(); } this.settingsChanged(); } popoutReplyForm(e) { e.preventDefault(); var floating = this.controls.replyForm.classList.toggle("qrPopout"); var svgPath; if (floating) { 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"; this.controls.replyFormPopoutButton.title = "Stop floating the Reply form"; if (!this.replyFormDraggable) { var rect = this.controls.replyForm.querySelector(".postform").getBoundingClientRect(); var width = rect.width; var height = rect.height; if (width === 0) { //some compatibility stuff var messageBox = this.controls.replyForm.querySelector(`[name="message"]`); if (messageBox.style.width) { width = Math.max(messageBox.style.width.replace("px", ""), messageBox.style.minWidth.replace("px", "")); height = Math.max(messageBox.style.height.replace("px", ""), messageBox.style.minHeight.replace("px", "")) + 150; } } this.controls.replyForm.style.left = `${this.doc.documentElement.clientWidth - width - 10}px`; this.controls.replyForm.style.top = `${(this.doc.defaultView.innerHeight - height) * 0.75}px`; } this.replyFormDraggable = new this.doc.defaultView.Draggable("postform", { handle: "qrReplyFormHeader" }); } else { 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"; this.controls.replyFormPopoutButton.title = "Pop out the Reply form and have it float"; this.replyFormDraggable.destroy(); } this.controls.replyFormPopoutButton.firstElementChild.firstElementChild.firstElementChild.setAttribute("d", svgPath); } changeThread(e) { this.doc.location.href = e.target.value; } updateSettings() { this.viewMode = this.controls.viewModeDropdown.value; this.showSuggestions = this.controls.showSuggestionsDropdown.value; this.showAuthorComments = this.controls.showAuthorCommentsDropdown.value; this.expandImages = this.controls.expandImagesDropdown.value; this.showUpdateInfo = this.controls.showUpdateInfoCheckbox.checked; this.replyFormLocation = this.controls.replyFormLocationDropdown.value; this.refresh({scroll: false}); this.settingsChanged(); } changePostElementSettings(e) { if (e.target === this.controls.showReferencesDropdown) { this.showReferences = this.controls.showReferencesDropdown.value; this.doc.head.querySelector("#qrReferencesCss").innerHTML = this.getReferencesStyleRules(); } else if (e.target === this.controls.colorUsersCheckbox) { this.colorUsers = this.controls.colorUsersCheckbox.checked; this.doc.head.querySelector("#qrUserColorsCss").innerHTML = this.getUserColorsStyleRules(); } else if (e.target === this.controls.showUserPostCountsCheckbox) { this.showUserPostCounts = this.controls.showUserPostCountsCheckbox.checked; this.doc.head.querySelector("#qrUserPostCountsCss").innerHTML = this.getUserPostCountsStyleRules(); } else if (e.target === this.controls.showUserNavCheckbox) { this.showUserNav = this.controls.showUserNavCheckbox.checked; this.doc.head.querySelector("#qrUserNavCss").innerHTML = this.getUserNavStyleRules(); } else if (e.target === this.controls.showUserMultiPostCheckbox) { this.showUserMultiPost = this.controls.showUserMultiPostCheckbox.checked; this.doc.head.querySelector("#qrUserMultiPostCss").innerHTML = this.getUserMultiPostStyleRules(); } else if (e.target === this.controls.timestampFormatDropdown) { this.timestampFormat = this.controls.timestampFormatDropdown.value; this.relativeTime = null; } this.refresh({scroll: false}); this.settingsChanged(); } changeImageSizeSettings(e) { var width = parseInt(this.controls.maxImageWidthTextbox.value); var height = parseInt(this.controls.maxImageHeightTextbox.value); if (isNaN(width) || width < 0 || width > 100) { width = this.maxImageWidth; this.controls.maxImageWidthTextbox.value = width; } if (isNaN(height) || height < 0 || height > 100) { height = this.maxImageHeight; this.controls.maxImageHeightTextbox.value = height; } this.maxImageWidth = width; this.maxImageHeight = height; this.stretchImages = this.controls.stretchImagesCheckbox.checked; this.controls.imageWidthLabel.textContent = this.stretchImages ? "Image container width" : "Max image width"; this.controls.imageHeightLabel.textContent = this.stretchImages ? "Image container height" : "Max image height"; this.doc.head.querySelector("#qrImageSizeCss").innerHTML = this.getImageSizesStyleRules(); this.refresh({scroll: false}); this.settingsChanged(); } async useWiki() { if (this.threadType == ThreadType.OTHER) { return; } var wikiUrls = await this.findWikiUrls(); if (!wikiUrls) { return; } var wikiDatas = []; for (let wikiUrl of wikiUrls) { var wikiPageName = wikiUrl.substring(wikiUrl.lastIndexOf("/") + 1); //cache wiki page names if (!this.wikiPages.includes(wikiPageName)) { this.wikiPages.push(wikiPageName); this.settingsChanged(); } //try get wiki data from cache let wikiData = await this.getWikiData(wikiUrl); wikiDatas.push(wikiData); //cache wiki data if (this.onWikiDataChanged !== null) { //remember last visited quest thread if (this.threadType == ThreadType.QUEST) { wikiData.lastVisitedQuestUrl = this.doc.location.href.replace(new RegExp("#[0-9]+$"), ""); //don't want to remember the anchor wikiData.lastVisitedTime = Date.now(); } let e = { threadID: this.threadID, threadType: this.threadType, boardName: this.boardName, wikiPageName: wikiPageName, wikiData: wikiData }; this.onWikiDataChanged(e); } } //use wiki data //set up links to the quest wiki var wikiTarget = this.wikiPages.length > 1 ? `/w/index.php?search=${this.threadID}&fulltext=1&limit=500`: wikiUrls[0]; this.controls.wikiLinks.forEach(link => { link.href = wikiTarget; link.style.removeProperty("color"); }); //set up link to the discussion thread, or quest thread if currently in a discussion thread if (this.threadType == ThreadType.QUEST) { var disThreadGroup = wikiDatas[0].threadGroups.find(group => group.some(link => link.url.indexOf("/questdis/") >= 0)); if (disThreadGroup) { this.controls.disLinks.forEach(link => { link.href = disThreadGroup[disThreadGroup.length - 1].url; link.parentElement.classList.remove("hidden"); }); } } else if (this.threadType == ThreadType.DISCUSSION) { var visitedQuests = wikiDatas.filter(wikiData => wikiData.lastVisitedQuestUrl); if (visitedQuests.length > 0) { var lastVisitedUrl = visitedQuests.sort((a, b) => b.lastVisitedTime - a.lastVisitedTime)[0].lastVisitedQuestUrl; this.controls.questLinks.forEach(link => { link.href = lastVisitedUrl; link.parentElement.classList.remove("hidden"); }); } else if (wikiDatas.length === 1) { var questUrls = []; wikiDatas[0].threadGroups.forEach(group => group.forEach(link => { if (link.url.indexOf("/quest/") >= 0) questUrls.push(link.url); })); if (questUrls.length > 0) { var lastNumberRegEx = new RegExp("([0-9]+)(?=[^0-9]*$)"); var highestIdThread = questUrls.reduce((max, url) => { if (parseInt(url.match(lastNumberRegEx)[1]) > parseInt(max.match(lastNumberRegEx)[1])) max = url; return max; }); this.controls.questLinks.forEach(link => { link.href = highestIdThread; link.parentElement.classList.remove("hidden"); }); } } } //set up links to the quest threads; if in one of the quest threads, then only show links from the same group var currentThreadGroup = wikiDatas[0].threadGroups.find(group => group.some(link => link.url.indexOf(this.threadID) >= 0)); if (currentThreadGroup) { var threadOptionsHtml; if (this.threadType == ThreadType.QUEST) { threadOptionsHtml = currentThreadGroup.reduce((optionsHtml, thread) => optionsHtml + `<option value="${thread.url}">${thread.name}</option>`, ""); } else { var questsOptions = wikiDatas.map(wikiData => { var optionsGroups = wikiData.threadGroups.map(group => group.reduce((optionsHtml, thread) => optionsHtml + `<option value="${thread.url}"> ${thread.name}</option>`, "")); return `<option disabled>${wikiData.questTitle}</option>` + optionsGroups.join(`<option disabled>────────</option>`); }); threadOptionsHtml = questsOptions.join(`<option disabled></option>`); } var currentThreadUrl = currentThreadGroup.find(thread => thread.url.indexOf(this.threadID) >= 0).url; this.controls.threadLinksDropdowns.forEach(dropdown => { dropdown.innerHTML = threadOptionsHtml; dropdown.value = currentThreadUrl; }); } //update the thread / tab title with the title from wiki if (wikiDatas[0].questTitle) { if (this.threadType == ThreadType.DISCUSSION) { if (wikiDatas.length === 1) { this.doc.title = `${wikiDatas[0].questTitle} Discussion`; this.controls.logo.textContent = this.doc.title; } } else { this.doc.title = `${this.hasTitle ? this.doc.title : wikiDatas[0].questTitle}${wikiDatas[0].byAuthors}`; this.controls.logo.textContent = this.doc.title; } } } async findWikiUrls() { if (this.wikiPages.length > 0 && Date.now() - this.wikiLastSearchTime < 24 * 60 * 60000) { //flush the cache every 24 hours return this.wikiPages.map(name => `/wiki/${name}`); } try { this.wikiPages = []; var xhr = await Xhr.get(`/w/index.php?search=${this.threadID}&fulltext=1&limit=500`); this.wikiLastSearchTime = Date.now(); } catch(e) { return; } //var threadID = xhr.responseURL.match(new RegExp("search=([0-9]+)"))[1]; var doc = this.doc.implementation.createHTMLDocument(); //we create a HTML document, but don't load the images or scripts therein doc.documentElement.innerHTML = xhr.response; var results = [...doc.querySelectorAll(".searchmatch")].filter(el => el.textContent == this.threadID); if (results.length === 0) { return; } //filter wiki search results to the ones that have the threadID in the quest info box var theRightOnes = results.filter(el => {var p = el.previousSibling; return p && p.nodeType == Node.TEXT_NODE && p.textContent.match(new RegExp("[0-9]=$")); }); if (theRightOnes.length === 0) { return; } return theRightOnes.map(el => el.parentElement.previousElementSibling.querySelector("a").href); } async getWikiData(wikiUrl) { var wikiPageName = wikiUrl.substring(wikiUrl.lastIndexOf("/") + 1); if (this.onWikiDataLoad !== null) { let e = { threadID: this.threadID, threadType: this.threadType, boardName: this.boardName, wikiPageName: wikiPageName, wikiData: null }; this.onWikiDataLoad(e); if (e.wikiData && e.wikiData.retrieveTime && Date.now() - e.wikiData.retrieveTime < 24 * 60 * 60000) { //flush cache every 24 hours return e.wikiData; } } var wikiData = { retrieveTime: Date.now() }; try { var xhr = await Xhr.get(wikiUrl); } catch(e) { this.wikiPages = this.wikiPages.filter(el => el !== wikiPageName); this.settingsChanged(); return; } //parse quest wiki var doc = this.doc.implementation.createHTMLDocument(); doc.documentElement.innerHTML = xhr.response; var links = [...doc.querySelectorAll(".infobox a")]; links = links.filter(link => link.href.indexOf("image-for") < 0); var threadLinks = links.filter(l => l.href.indexOf("/quest/") >= 0 || l.href.indexOf("/questarch/") >= 0 || l.href.indexOf("/graveyard/") >= 0); var disThreadLinks = links.filter(l => l.href.indexOf("/questdis/") >= 0); //create thread groups, like they're in the wiki wikiData.threadGroups = []; var groups = new Map(); [...threadLinks, ...disThreadLinks].forEach(link => { var key = link.parentElement.parentElement; var group = groups.get(key) || []; group.push({name: link.textContent, url: link.href}); groups.set(key, group); }); groups.forEach((links, key) => wikiData.threadGroups.push(links)); wikiData.threadGroups.forEach(group => group.forEach(link => { if (link.name == "Thread" && group.length === 1) { link.name = "Thread 1"; } })); //get quest author and title var infoboxHeader = doc.querySelector(".infobox big"); if (infoboxHeader) { var children = [...infoboxHeader.childNodes]; wikiData.questTitle = children.shift().textContent; wikiData.byAuthors = children.reduce((acc, el) => {acc += el.textContent; return acc;}, ""); } return wikiData; } updateControls() { var leftDisabled = this.currentUpdate() == this.firstUpdate(); var rightDisabled = this.currentUpdate() == this.lastUpdate(); var current = this.currentUpdateIndex + 1; var last = this.updates.length; var infoUpdate = this.currentUpdate(); if (this.viewMode == "sequence") { leftDisabled = this.currentUpdate().sequence == this.firstUpdate().sequence; rightDisabled = this.currentUpdate().sequence == this.lastUpdate().sequence; current = this.currentSequenceIndex() + 1; last = this.sequences.length; infoUpdate = this.currentUpdate().sequence[this.currentUpdate().sequence.length - 1]; } // buttons [...this.controls.showFirstButtons, ...this.controls.showPrevButtons].forEach(button => { button.disabled = leftDisabled; }); [...this.controls.showNextButtons, ...this.controls.showLastButtons].forEach(button => { button.disabled = rightDisabled; }); // update info this.controls.currentPosLabels.forEach(label => { label.textContent = current; label.classList.toggle("qrLastIndex", current == last); }); this.controls.totalPosLabels.forEach(label => { label.textContent = last; }); this.controls.updateInfos.forEach(infoContainer => { infoContainer.classList.toggle("hidden", !this.showUpdateInfo); }); if (this.showUpdateInfo) { this.controls.authorCommentsCountLabels[0].textContent = ` A:${this.viewMode !== "all" ? infoUpdate.authorComments.length : this.total.authorComments}`; this.controls.authorCommentsCountLabels[1].textContent = ` A:${infoUpdate.authorComments.length}`; this.controls.suggestionsCountLabels[0].textContent = ` S:${this.viewMode !== "all" ? infoUpdate.suggestions.length : this.total.suggestions}`; this.controls.suggestionsCountLabels[1].textContent = ` S:${infoUpdate.suggestions.length}`; this.updateSuggestersLabel(this.controls.suggestersCountLabels[0], this.viewMode === "all" ? this.suggesters : infoUpdate.suggesters); this.updateSuggestersLabel(this.controls.suggestersCountLabels[1], infoUpdate.suggesters); } // settings this.controls.viewModeDropdown.value = this.viewMode; this.controls.showSuggestionsDropdown.value = this.showSuggestions; this.controls.showAuthorCommentsDropdown.value = this.showAuthorComments; this.controls.showReferencesDropdown.value = this.showReferences; this.controls.replyFormLocationDropdown.value = this.replyFormLocation; this.controls.expandImagesDropdown.value = this.expandImages; this.controls.maxImageWidthTextbox.value = this.maxImageWidth; this.controls.maxImageHeightTextbox.value = this.maxImageHeight; this.controls.stretchImagesCheckbox.checked = this.stretchImages; this.controls.imageWidthLabel.textContent = this.stretchImages ? "Image container width" : "Max image width"; this.controls.imageHeightLabel.textContent = this.stretchImages ? "Image container height" : "Max image height"; this.controls.showUpdateInfoCheckbox.checked = this.showUpdateInfo; this.controls.timestampFormatDropdown.value = this.timestampFormat; this.controls.colorUsersCheckbox.checked = this.colorUsers; this.controls.showUserPostCountsCheckbox.checked = this.showUserPostCounts; this.controls.showUserNavCheckbox.checked = this.showUserNav; this.controls.showUserMultiPostCheckbox.checked = this.showUserMultiPost; // sticky controls when viewing whole thread (only in quest threads) if (this.threadType == ThreadType.QUEST) { this.controls.navControls[1].classList.toggle("stickyBottom", this.viewMode == "all"); this.controls.navLinksContainers[1].classList.toggle("qrNavLinksBottom", this.viewMode == "all"); var areLinksInGrid = this.controls.navLinksContainers[1].parentElement.classList.contains("qrNavControls"); if (areLinksInGrid && this.viewMode == "all") { //move the links out of the grid so they don't appear in the floating nav controls this.controls.navControls[1].insertAdjacentElement("beforeBegin", this.controls.navLinksContainers[1]); } else if (!areLinksInGrid && this.viewMode != "all") { //move the links back into the grid this.controls.navControls[1].insertAdjacentElement("beforeEnd", this.controls.navLinksContainers[1]); } } /* // sentinels for full thread view var topOfCurrent = 0; var bottomOfCurrent = 0; if (this.viewMode == "all") { if (this.currentUpdate() != this.firstUpdate()) { topOfCurrent = this.posts.get(this.currentUpdate().updatePostID).outer.offsetTop; } if (this.currentUpdate() != this.lastUpdate()) { bottomOfCurrent = this.posts.get(this.updates[this.currentUpdateIndex + 1].updatePostID).outer.offsetTop; } this.sentinelPreviousEl.style.height = `${topOfCurrent}px`; //end of previous is top of current; this.sentinelCurrentEl.style.height = `${bottomOfCurrent}px`; //end of current is the top of next } this.sentinelPreviousEl.classList.toggle("hidden", this.viewMode != "all" || topOfCurrent === 0); this.sentinelCurrentEl.classList.toggle("hidden", this.viewMode != "all" || bottomOfCurrent === 0); */ // reply form juggling var isReplyFormAtTop = (this.controls.replymode == this.controls.postarea.previousElementSibling); if (this.replyFormLocation != "top" && isReplyFormAtTop) { //move it down this.controls.postarea.remove(); this.doc.body.insertBefore(this.controls.postarea, this.controls.navbar); this.controls.controlsTop.previousElementSibling.insertAdjacentHTML("beforeBegin", "<hr>"); } else if (this.replyFormLocation == "top" && !isReplyFormAtTop) { //move it up this.controls.postarea.remove(); this.controls.replymode.insertAdjacentElement("afterEnd", this.controls.postarea); this.controls.controlsTop.previousElementSibling.previousElementSibling.remove(); //remove <hr> } this.controls.replyForm.classList.toggle("hidden" , !this.showReplyForm); this.controls.replyFormRestoreButton.classList.toggle("hidden", this.showReplyForm); if (this.viewMode == "all" && !this.scrollIntervalHandle) { this.scrollIntervalHandle = setInterval(() => { if (this.viewMode == "all" && Date.now() - this.lastScrollTime < 250) { this.handleScroll(); } }, 50); } else if (this.viewMode != "all" && this.scrollIntervalHandle) { clearInterval(this.scrollIntervalHandle); this.scrollIntervalHandle = null; } } updateSuggestersLabel(label, suggesters) { var anons = suggesters.filter(el => el.children.length === 0); var newAnons = anons.filter(el => el.isNew); label.textContent = `U:${suggesters.length}`; label.title = `# of unique suggesters for the visible updates. Of these there are: ${suggesters.length - anons.length} named suggesters ${anons.length - newAnons.length} unnamed suggesters (familiar) ${newAnons.length} unnamed suggesters (new)`; } insertControls(doc) { //cache existing top-level html elements based their class name or id for faster access [...doc.body.children].forEach(child => { var name = child.classList[0] || child.id; if (name) { this.controls[name] = child; } }); this.controls.navControls = []; //top controls var delform = this.posts.get(this.threadID).outer; var fragment = doc.createRange().createContextualFragment(this.getTopControlsHtml()); this.controls.controlsTop = fragment.firstElementChild; this.controls.navControls.push(this.controls.controlsTop.querySelector(".qrNavControls")); delform.parentElement.insertBefore(fragment, delform); //bottom nav controls fragment = doc.createRange().createContextualFragment(`${this.getNavControlsHtml()}<hr>`); this.controls.navControls.push(fragment.firstElementChild); delform.insertBefore(fragment, delform.lastElementChild); //make reply form collapsable this.controls.postarea.insertAdjacentHTML("afterBegin", `<div class="hidden">[<a href="#" id="qrReplyFormRestoreButton">Reply</a>]</div>`); this.controls.replyFormRestoreButton = this.controls.postarea.firstElementChild; //reply form header this.controls.replyForm = doc.querySelector("#postform"); this.controls.replyForm.querySelector(".postform").insertAdjacentHTML("afterBegin", this.getReplyFormHeaderHtml()); //note that we can't create a <thead> element in a fragment without table this.controls.replyFormMinimizeButton = this.controls.replyForm.querySelector("#qrReplyFormMinimizeButton"); this.controls.replyFormPopoutButton = this.controls.replyForm.querySelector("#qrReplyFormPopoutButton"); //when viewing full thread, we want to detect and remember where we are; something something IntersectionObserver /*doc.body.insertAdjacentHTML("afterBegin", `<div class="sentinel hidden"></div><div class="sentinel hidden"></div>`); this.sentinelPreviousEl = doc.body.firstChild; this.sentinelCurrentEl = doc.body.firstChild.nextSibling; this.sentinelPrevious = new IntersectionObserver((entries, observer) => { this.handleSentinel(entries, observer); }, { rootMargin: "2px" } ); //need to pass the callback like this to keep the context this.sentinelCurrent = new IntersectionObserver((entries, observer) => { this.handleSentinel(entries, observer); }, { rootMargin: "2px" } ); this.sentinelPrevious.observe(this.sentinelPreviousEl); this.sentinelCurrent.observe(this.sentinelCurrentEl);*/ //cache control elements for faster access var queries = [".qrNavLinksContainer", ".qrShowFirstButton", ".qrShowPrevButton", ".qrShowNextButton", ".qrShowLastButton", ".qrWikiLink", ".qrQuestLink", ".qrDisLink", ".qrThreadLinksDropdown", ".qrNavPosition", ".qrCurrentPosLabel", ".qrTotalPosLabel", ".qrUpdateInfo", ".qrAuthorCommentsCountLabel", ".qrSuggestionsCountLabel", ".qrSuggestersCountLabel", ]; queries.forEach((query) => { var controlGroupName = `${query[3].toLowerCase()}${query.substring(4)}s`; this.controls[controlGroupName] = [ this.controls.navControls[0].querySelector(query), this.controls.navControls[1].querySelector(query)]; }); this.controls.settingsControls = this.controls.controlsTop.querySelector(".qrSettingsControls"); this.controls.controlsTop.querySelectorAll("[id]").forEach(el => { this.controls[`${el.id[2].toLowerCase()}${el.id.substring(3)}`] = el; }); if (this.threadType !== ThreadType.QUEST) { //hide controls which aren't relevant in non-quest threads var controlsToHide = [...this.controls.showFirstButtons, ...this.controls.showPrevButtons, ...this.controls.showNextButtons, ...this.controls.showLastButtons, ...this.controls.navPositions]; controlsToHide.forEach(el => el.classList.add("transparent")); var settingsToHide = [this.controls.viewModeLabel, this.controls.viewModeDropdown, this.controls.showSuggestionsLabel, this.controls.showSuggestionsDropdown, this.controls.showAuthorCommentsLabel, this.controls.showAuthorCommentsDropdown, this.controls.showUpdateInfoLabel, this.controls.showUpdateInfoCheckbox, this.controls.showUserMultiPostLabel, this.controls.showUserMultiPostCheckbox, this.controls.keyboardShortcutsLabel, this.controls.keyboardShortcutsLabel.nextElementSibling]; settingsToHide.forEach(el => el.classList.add("hidden")); this.controls.showReferencesDropdown.options[1].remove(); this.controls.expandImagesDropdown.options[1].remove(); } if (this.threadType === ThreadType.OTHER) { this.controls.wikiLinks.forEach(el => el.parentElement.classList.add("hidden")); this.controls.threadLinksDropdowns.forEach(el => el.classList.add("hidden")); } } /*handleSentinel(entries, observer) { console.log(entries[0]); var newUpdateIndex = this.currentUpdateIndex; if (observer == this.sentinelPrevious && entries[0].isIntersecting) { newUpdateIndex--; } else if (observer == this.sentinelCurrent && !entries[0].isIntersecting) { newUpdateIndex++; } if (newUpdateIndex != this.currentUpdateIndex && newUpdateIndex >= 0 && newUpdateIndex < this.updates.length) { this.currentUpdateIndex = newUpdateIndex; this.updateControls(); this.settingsChanged(); } }*/ insertStyling(doc) { doc.head.insertAdjacentHTML("beforeEnd", `<style id="qrMainCss">${this.getMainStyleRules(doc)}</style>`); doc.head.insertAdjacentHTML("beforeEnd", `<style id="qrImageSizeCss">${this.getImageSizesStyleRules()}</style>`); doc.head.insertAdjacentHTML("beforeEnd", `<style id="qrReferencesCss">${this.getReferencesStyleRules()}</style>`); doc.head.insertAdjacentHTML("beforeEnd", `<style id="qrUserColorsCss">${this.getUserColorsStyleRules()}</style>`); doc.head.insertAdjacentHTML("beforeEnd", `<style id="qrUserPostCountsCss">${this.getUserPostCountsStyleRules()}</style>`); doc.head.insertAdjacentHTML("beforeEnd", `<style id="qrUserNavCss">${this.getUserNavStyleRules()}</style>`); doc.head.insertAdjacentHTML("beforeEnd", `<style id="qrUserMultiPostCss">${this.getUserMultiPostStyleRules()}</style>`); } insertEvents(doc) { //events for our controls this.controls.settingsToggleButton.addEventListener("click", e => this.toggleSettingsControls(e)); this.controls.settingsPageNav.addEventListener("click", e => this.showSettingsPage(e)); this.controls.viewModeDropdown.addEventListener("change", e => this.updateSettings(e)); this.controls.showSuggestionsDropdown.addEventListener("change", e => this.updateSettings(e)); this.controls.showAuthorCommentsDropdown.addEventListener("change", e => this.updateSettings(e)); this.controls.replyFormLocationDropdown.addEventListener("change", e => this.updateSettings(e)); this.controls.showReferencesDropdown.addEventListener("change", e => this.changePostElementSettings(e)); this.controls.expandImagesDropdown.addEventListener("change", e => this.updateSettings(e)); this.controls.maxImageWidthTextbox.addEventListener("change", e => this.changeImageSizeSettings(e)); this.controls.maxImageHeightTextbox.addEventListener("change", e => this.changeImageSizeSettings(e)); this.controls.stretchImagesCheckbox.addEventListener("click", e => this.changeImageSizeSettings(e)); this.controls.showUpdateInfoCheckbox.addEventListener("click", e => this.updateSettings(e)); this.controls.timestampFormatDropdown.addEventListener("change", e => this.changePostElementSettings(e)); this.controls.colorUsersCheckbox.addEventListener("click", e => this.changePostElementSettings(e)); this.controls.showUserPostCountsCheckbox.addEventListener("click", e => this.changePostElementSettings(e)); this.controls.showUserNavCheckbox.addEventListener("click", e => this.changePostElementSettings(e)); this.controls.showUserMultiPostCheckbox.addEventListener("click", e => this.changePostElementSettings(e)); this.controls.replyFormMinimizeButton.addEventListener("click", e => this.toggleReplyForm(e)); this.controls.replyFormRestoreButton.addEventListener("click", e => this.toggleReplyForm(e)); this.controls.replyFormPopoutButton.addEventListener("click", e => this.popoutReplyForm(e)); this.controls.showFirstButtons.forEach(el => el.addEventListener("click", e => this.showFirst(e))); this.controls.showPrevButtons.forEach(el => el.addEventListener("click", e => this.showPrevious(e))); this.controls.showNextButtons.forEach(el => el.addEventListener("click", e => this.showNext(e))); this.controls.showLastButtons.forEach(el => el.addEventListener("click", e => this.showLast(e))); this.controls.threadLinksDropdowns.forEach(el => el.addEventListener("change", e => this.changeThread(e))); //events for other controls this.controls.replyForm.querySelector(`input[type="submit"][value="Reply"]`).addEventListener("click", (e) => { var userName = this.controls.replyForm.querySelector(`input[name="name"]`).value.trim().toLowerCase(); if(this.author.id === userName || this.author.children.some(child => child.id === userName)) { this.moveToLast = true; this.settingsChanged(); } }); //global events 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 var oldPostID = parseInt(new URL(e.oldURL).hash.replace("#", "")); var newPostID = parseInt(new URL(e.newURL).hash.replace("#", "")); if (!isNaN(oldPostID) && oldPostID !== this.threadID && this.posts.has(oldPostID)) { this.posts.get(oldPostID).inner.classList.remove("highlight"); this.posts.get(oldPostID).inner.classList.add("reply"); } if (!isNaN(newPostID) && newPostID !== this.threadID && this.posts.has(newPostID)) { this.posts.get(newPostID).inner.classList.add("highlight"); this.refresh({checkHash: "true"}); } }); this.lastScrollTime = 0; doc.defaultView.addEventListener("wheel", (e) => { //after the wheeling has finished, check if the user this.lastScrollTime = Date.now(); }); doc.addEventListener("keydown", (e) => { if (this.threadType !== ThreadType.QUEST) { return; } var inputTypes = ["text", "password", "number", "email", "tel", "url", "search", "date", "datetime", "datetime-local", "time", "month", "week"]; if (e.target.tagName === "TEXTAREA" || e.target.tagName === "SELECT" || (e.target.tagName === "INPUT" && inputTypes.indexOf(e.target.type) >= 0)) { return; //prevent our keyboard shortcuts when focused on a text input field } if (e.altKey || e.shiftKey || e.ctrlKey) { //alt+left arrow, or alt+right arrow, for the obvious reasons we don't want to handle those, or any other combination for that matter return; } if (e.key == "ArrowRight") { e.preventDefault(); this.showNext(); } else if (e.key == "ArrowLeft") { e.preventDefault(); this.showPrevious(); } var scrollKeys = ["ArrowUp", "ArrowDown", " ", "PageUp", "PageDown", "Home", "End"]; //it turns out that scrolling the page is possible with stuff other than mouse wheel if (scrollKeys.indexOf(e.key) >= 0) { this.lastScrollTime = Date.now(); } }); doc.addEventListener("click", (e) => { //clicking on a reflink in post header should show the Reply form and THEN insert text and focus it var node = e.target; if (node.nodeType === Node.TEXT_NODE) { return; } if (node.nodeName === "A") { if (node.parentElement.classList.contains("reflink") && node.parentElement.lastElementChild == node) { e.preventDefault(); if (!this.showReplyForm) { this.controls.replyFormRestoreButton.click(); if (this.replyFormLocation == "float" && !this.controls.replyForm.classList.contains("qrPopout")) { this.controls.replyFormPopoutButton.click(); } } return doc.defaultView.insert(`>>${node.textContent}\n`); } } //clicking on the post timestamp in the post header should change the time format else if (node.nodeName == "LABEL" && node.parentElement.classList.contains("postwidth")) { var posterEl = node.querySelector(".postername"); if (e.offsetX <= posterEl.offsetLeft + posterEl.offsetWidth) { return; } e.preventDefault(); if (this.relativeTime === null ) { var post = this.posts.get(parseInt(node.parentElement.firstElementChild.name)); var currentDateTime = new Date(); this.relativeTime = this.timestampFormat == "auto" ? (currentDateTime.getTime() - this.getUtcTime(post, node.lastChild)) >= 86400000 : this.timestampFormat != "relative" && this.timestampFormat != "relativeUpd"; } else { this.relativeTime = !this.relativeTime; } this.refresh({scroll: false}); this.settingsChanged(); } }); //should also remove the unnecessary click events; this.posts.forEach(post => { post.header.querySelector(".reflink").lastElementChild.onclick = null; }); //clicking on style links should rebuild our style this.controls.adminbar.querySelectorAll("a").forEach(el => { if (!el.title && !el.target) { el.addEventListener("click", (e) => { doc.head.querySelector("#qrMainCss").innerHTML = this.getMainStyleRules(doc); }); } }); //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 //Of course, currently it won't work because the anchors with the IDs are inside headers instead of the elements themselves being anchors doc.defaultView.highlight = new Function(); doc.defaultView.checkhighlight = () => { var hashedPostID = parseInt(doc.location.hash.replace("#", "")); if (!isNaN(hashedPostID) && hashedPostID != this.threadID && this.posts.has(hashedPostID)) { this.posts.get(hashedPostID).inner.classList.add("highlight"); } } //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 doc.defaultView.addpreviewevents = new Function(); doc.addEventListener("mouseover", function(e) { if (e.target.nodeName === "A" && e.target.className.startsWith("ref|")) { e.view.addreflinkpreview(e); } }); doc.addEventListener("mouseout", function(e) { if (e.target.nodeName === "A" && e.target.className.startsWith("ref|")) { e.view.delreflinkpreview(e); } }); } handleScroll() { //check if the user scrolled to a different update on screen -> mark and save the position (only in whole thread view) var indexes = this.getVisibleUpdateIndexes(); if (indexes == null) { return; } if (this.currentUpdateIndex != indexes[0]) { this.currentUpdateIndex = indexes[0]; this.updateControls(); this.settingsChanged(); } var updatesToExpand = indexes.map(index => this.updates[index]); if (this.updates.length > indexes[indexes.length - 1] + 1) { updatesToExpand.push(this.updates[indexes[indexes.length - 1] + 1]); } updatesToExpand.forEach(update => this.expandUpdateImages(update)); } getVisibleUpdateIndexes() { var currentUpdatePost = this.posts.get(this.currentUpdate().updatePostID); var currentUpdateIsAboveViewPort = !currentUpdatePost || currentUpdatePost.outer.offsetTop <= this.doc.defaultView.scrollY; var topmostVisibleUpdateIndex = this.currentUpdateIndex; var el; if (currentUpdateIsAboveViewPort) { //search down for (; topmostVisibleUpdateIndex < this.updates.length - 1; topmostVisibleUpdateIndex++) { el = this.posts.get(this.updates[topmostVisibleUpdateIndex + 1].updatePostID); if (!el) { continue; } if (el.outer.offsetTop > this.doc.defaultView.scrollY) { break; } } } else { //search up for (; topmostVisibleUpdateIndex > 0; topmostVisibleUpdateIndex--) { el = this.posts.get(this.updates[topmostVisibleUpdateIndex - 1].updatePostID); if (!el || el.outer.offsetTop < this.doc.defaultView.scrollY) { topmostVisibleUpdateIndex--; break; } } } var indexes = [ topmostVisibleUpdateIndex ]; //var bottommostVisibleUpdateIndex = topmostVisibleUpdateIndex; var windowOffsetBottom = this.doc.defaultView.scrollY + this.doc.documentElement.clientHeight; for (; topmostVisibleUpdateIndex < this.updates.length - 1; topmostVisibleUpdateIndex++) { el = this.posts.get(this.updates[topmostVisibleUpdateIndex + 1].updatePostID); if (!el || el.outer.offsetTop > windowOffsetBottom) { break; } indexes.push(topmostVisibleUpdateIndex + 1); } return indexes; } modifyLayout(doc) { //change tab title to quest's title var op = this.posts.get(this.threadID); var label = op.header.querySelector("label"); this.hasTitle = !!label.querySelector(".filetitle"); var title = label.querySelector(".filetitle") || label.querySelector(".postername"); title = title.textContent.trim(); doc.title = title !== this.defaultName ? title : "Untitled Quest"; this.controls.logo.textContent = doc.title; //extend vertical size to prevent screen jumping when navigating updates this.controls.controlsTop.insertAdjacentHTML("beforeBegin", `<div class="haveOneScreenOfSpaceBelowHereSoItIsPossibleToScroll" />`); //extend vertical size so it's possible to scroll to the last update in full thread view var lastUpdatePost = this.posts.get(this.lastUpdate().updatePostID); if (lastUpdatePost) { lastUpdatePost.outer.insertAdjacentHTML("afterBegin", `<div class="haveOneScreenOfSpaceBelowHereSoItIsPossibleToScroll" />`); } //prevent wrapping posts around the OP; setting clear:left on the 2nd post doesn't work because that element might be hidden op.inner.querySelector("blockquote").insertAdjacentHTML("afterEnd", `<div style="clear: left;"></div>`); //prevent wrapping text underneath update images by setting update post width to 100% 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 var updatePost = this.posts.get(update.updatePostID); if (updatePost) { updatePost.inner.classList.add(update === this.firstUpdate() ? "updateOp" : "update"); } }); //Fix OP header so that the image wraps underneath it, like the other posts while(op.header.firstChild.name != this.threadID) { op.header.appendChild(op.header.firstChild); } //hide "Expand all images" link; The link isn't always present var expandLink = op.inner.querySelector(`a[href="#top"]`); if (expandLink) { expandLink.classList.add("hidden"); } //remove the "Report completed threads!" message from the top var message = (doc.body.querySelector("center") || doc.createElement("center")).querySelector(".filetitle"); if (message) { message.classList.add("hidden"); } var replyForm = this.controls.replyForm; //remove the (Reply to #) text since it's obvious that we're replying to the thread that we're viewing, plus other text in the line var replyToPostEl = replyForm.querySelector("#posttypeindicator"); if (replyToPostEl) { [...replyToPostEl.parentElement.childNodes].filter(el => el && el.nodeType == HTMLElement.TEXT_NODE).forEach(el => el.remove()); replyToPostEl.remove(); } [...replyForm.querySelector(`input[name="name"]`).parentElement.childNodes].forEach(el => { if (el.nodeType == HTMLElement.TEXT_NODE) el.remove(); }); [...replyForm.querySelector(`input[name="em"]`).parentElement.childNodes].forEach(el => { if (el.nodeType == HTMLElement.TEXT_NODE) el.remove(); }); [...replyForm.querySelector(`input[name="subject"]`).parentElement.childNodes].forEach(el => { if (el.nodeType == HTMLElement.TEXT_NODE) el.remove(); }); //move the upload file limitations info into a tooltip var filetd = replyForm.querySelector(`input[type="file"]`); var fileRulesEl = replyForm.querySelector("td.rules"); fileRulesEl.classList.add("hidden"); var fileRules = [...fileRulesEl.querySelectorAll("li")].splice(1, 2); fileRules = fileRules.map(el => el.textContent.replace(new RegExp("[ \n]+", "g"), " ").trim()).join("\n"); filetd.insertAdjacentHTML("afterEnd", ` <span class="qrTooltip" title="${fileRules}">*</span>`); //move the password help line into a tooltip var postPasswordEl = replyForm.querySelector(`input[name="postpassword"]`); postPasswordEl.nextSibling.remove(); postPasswordEl.insertAdjacentHTML("afterEnd", ` <span class="qrTooltip" title="Password for post and file deletion">?</span>`); //reply form input placeholders (replyForm.querySelector(`[name="name"]`) || {}).placeholder = `Name (leave empty for ${this.defaultName})`; (replyForm.querySelector(`[name="em"]`) || {}).placeholder = "Options"; (replyForm.querySelector(`[name="em"]`) || {}).title = "sage | dice 1d6"; (replyForm.querySelector(`[name="subject"]`) || {}).placeholder = "Subject"; (replyForm.querySelector(`[name="message"]`) || {}).placeholder = "Message"; var embed = replyForm.querySelector(`[name="embed"]`); if (embed) { embed.parentElement.parentElement.style.display = "none"; } //remove that annoying red strip this.controls.replymode.classList.add("hidden"); } insertTooltips() { var c = this.controls; c.settingsToggleButton.title = `Show/Hide Quest Reader settings`; c.settingsPageNav.children[0].title = `General settings`; c.settingsPageNav.children[1].title = `Image settings`; c.settingsPageNav.children[2].title = `Analytics settings`; c.viewModeLabel.title = `Control whether to view the thread as a comic (a set of pages) or not. Whole thread: Show the thread at it originally is, with all the update posts visible Paged per update: Turn the thread into pages, with a single update post per page Paged per sequence: Turn the thread into pages, with a set of sequential update posts per page`; c.showSuggestionsLabel.title = `Show quest suggestions. Quest suggestions are non-author posts containing directions for the quest or its characters.`; c.showAuthorCommentsLabel.title = `Show author comments. Author comments are author posts which aren't updates.`; c.showReferencesLabel.title = `Show links to references underneath each post or not. (References are >>links pointing to the post)`; c.timestampFormatLabel.title = `Control the time and format of post timestamps. Server time: Show when the post was made in server's timezone. Local time: Show when the post was made in local timezone. Relative to now: Show how much time has passed since the post was made to right now. Auto: Show relative time if the post was made less than a day ago, otherwise show local time. Hidden: Hide post timestamps.`; c.replyFormLocationLabel.title = `Control the location and the way in which the Reply form is shown. At top: The Reply form is located at the top of the page At bottom: The Reply form is located at the bottom of the page Auto-float: Show a floating Reply form when clicking on any of the post ID links, or the "Reply" link at the bottom`; c.expandImagesLabel.title = `Automatically expand images only when they appear on screen`; c.stretchImagesLabel.title = `Allow expanding the images beyond their intrinsic resolution to fit an image container. The size of the image container is defined by the "Image container width" and "Image container height" settings.`; c.showUpdateInfoLabel.title = `Show some info about the update next to the navigation controls. 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.`; c.colorUsersLabel.title = `Colorize the poster ID of every suggestion post`; 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.`; c.showUserNavLabel.title = `Show buttons to the right of every user ID which allow navigating to the user's previous and next post`; c.showUserMultiPostLabel.title = `Show a red indication next to posts where the suggester has made more than one suggestion for the update`; c.authorCommentsCountLabels.forEach(el => { el.title = `# of author comment posts for the visible updates`; }); c.suggestionsCountLabels.forEach(el => { el.title = `# of suggestion posts for the visible updates`; }); c.suggestersCountLabels.forEach(el => { el.title = `# of unique suggesters for the visible updates`; }); c.wikiLinks.forEach(el => { el.title = `Link to the quest's wiki page`; }); c.disLinks.forEach(el => { el.title = `Link to the quest's latest discussion thread`; }); c.questLinks.forEach(el => { el.title = `Link to the quest's last visited or last created quest thread`; }); c.threadLinksDropdowns.forEach(el => { el.title = `Current quest thread`; }); c.replyFormMinimizeButton.title = `Hide the Reply form`; c.replyFormPopoutButton.title = `Pop out the Reply form and have it float`; } getTopControlsHtml() { return ` <div class="qrControlsTop"> <div class="qrSettingsToggle">[<a href="#" id="qrSettingsToggleButton">Settings</a>]</div> ${this.getNavControlsHtml()} ${this.getSettingsControlsHtml()} <hr> </div>`; } getSettingsControlsHtml() { return ` <div class="qrSettingsControls collapsedHeight"> <span id="qrSettingsPageNav"> <div class="qrSettingsNavItem qrSettingsNavItemSelected">[<a href="#">General</a>]</div> <div class="qrSettingsNavItem">[<a href="#">Images</a>]</div> <div class="qrSettingsNavItem">[<a href="#">Analytics</a>]</div> </span> <div class="qrSettingsPages"> <div class="qrSettingsPage qrCurrentPage"> <div id="qrViewModeLabel">Viewing mode</div> <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> <div id="qrShowReferencesLabel">Show post references</div> <select id="qrShowReferencesDropdown" class="qrSettingsControl"><option value="none">Never</option><option value="nonupdates">For non-update posts</option><option value="all">For all</option></select> <div id="qrShowSuggestionsLabel">Show suggestions</div> <select id="qrShowSuggestionsDropdown" class="qrSettingsControl"><option value="none">Never</option><option value="last">Last update only</option><option value="all">Always</option></select> <div id="qrTimestampFormatLabel">Timestamp format</div> <select id="qrTimestampFormatDropdown" class="qrSettingsControl"> <option value="server">Server time</option> <option value="local">Local time</option> <option value="relative">Relative to now</option> <option value="auto">Auto</option> <option value="hide">Hidden</option> </select> <div id="qrShowAuthorCommentsLabel">Show author comments</div> <select id="qrShowAuthorCommentsDropdown" class="qrSettingsControl"><option value="none">Never</option><option value="last">Last update only</option><option value="all">Always</option></select> <div id="qrReplyFormLocationLabel">Reply form</div> <select id="qrReplyFormLocationDropdown" class="qrSettingsControl"><option value="top">At top</option><option value="bottom">At bottom</option><option value="float">Auto-float</option></select> <div id="qrKeyboardShortcutsLabel">Keyboard shortcuts</div> <div class="qrSettingsControl qrTooltip">?<span class="qrTooltiptext">Left and Right arrow keys will <br>navigate between updates</span></div> </div> <div class="qrSettingsPage"> <div id="qrExpandImagesLabel">Expand images</div> <select id="qrExpandImagesDropdown" class="qrSettingsControl"><option value="none">Do not</option><option value="updates">For updates</option><option value="all">For all</option></select> <div id="qrImageWidthLabel">Max image width</div> <div><input type="number" id="qrMaxImageWidthTextbox" class="qrSettingsControl" min=0 max=100 value=100> %</div> <div id="qrStretchImagesLabel">Force fit images</div> <input type="checkbox" id="qrStretchImagesCheckbox" class="qrSettingsControl"> <div id="qrImageHeightLabel">Max image height</div> <div><input type="number" id="qrMaxImageHeightTextbox" class="qrSettingsControl" min=0 max=100 value=100> %</div> </div> <div class="qrSettingsPage"> <div id="qrShowUpdateInfoLabel">Show update info</div> <input id="qrShowUpdateInfoCheckbox" type="checkbox" class="qrSettingsControl"> <div></div> <div></div> <div id="qrColorUsersLabel">Color ${this.threadType == ThreadType.QUEST ? "suggester" : "user"} IDs</div> <input id="qrColorUsersCheckbox" type="checkbox" class="qrSettingsControl"> <div id="qrShowUserPostCountsLabel">Show user post counts</div> <input id="qrShowUserPostCountsCheckbox" type="checkbox" class="qrSettingsControl"> <div id="qrShowUserNavLabel">Show per-user nav</div> <input id="qrShowUserNavCheckbox" type="checkbox" class="qrSettingsControl"> <div id="qrShowUserMultiPostLabel">Show multi-posts</div> <input id="qrShowUserMultiPostCheckbox" type="checkbox" class="qrSettingsControl"> </div> </div> </div>`; } getNavControlsHtml() { return ` <div class="qrNavControls"> <span></span> <span class="qrNavControl"><button class="qrShowFirstButton" type="button">First</button></span> <span class="qrNavControl"><button class="qrShowPrevButton" type="button">Prev</button></span> <span class="qrNavPosition qrOutline" title="Index of the currently shown update slash the total number of updates"> <label class="qrCurrentPosLabel">0</label> / <label class="qrTotalPosLabel">0</label> </span> <span class="qrNavControl"><button class="qrShowNextButton" type="button">Next</button></span> <span class="qrNavControl"><button class="qrShowLastButton" type="button">Last</button></span> <span> <span class="qrUpdateInfo qrOutline"> <label class="qrAuthorCommentsCountLabel">A: 0</label> <label class="qrSuggestionsCountLabel">S: 0</label> <label class="qrSuggestersCountLabel">U: 0</label> </span> </span> <span class="qrNavLinksContainer">${this.getLinksHtml()}</span> </div>`; } getLinksHtml() { return ` <span>[<a class="qrWikiLink" style="color: inherit">Wiki</a>]</span> <span class="hidden">[<a class="qrQuestLink">Quest</a>]</span> <span class="hidden">[<a class="qrDisLink">Discuss</a>]</span> <span class="qrThreadsLinks"> <select class="qrThreadLinksDropdown"> <option value="thread1">Thread not found in wiki</option> </select> </span>`; } getReplyFormHeaderHtml() { return ` <thead id="qrReplyFormHeader"> <tr> <th class="postblock">Reply form</th> <th class="qrReplyFormButtons"> <span><a id="qrReplyFormPopoutButton" href="#"><svg 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"> <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> </svg></a> </span> <span><a id="qrReplyFormMinimizeButton" href="#"><svg 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"> <line x1="5" y1="19" x2="19" y2="5"></line><line x1="5" y1="5" x2="19" y2="19"></line> </svg></a> </span> </th> </tr> </thead>`; } getMainStyleRules(doc) { /*var currentSheetRules = doc.head.querySelector(`link[rel*="stylesheet"][title]:not([disabled])`).sheet.cssRules; var bodyStyle = [...currentSheetRules].find(rule => rule.selectorText == "html, body"); var bgc = bodyStyle.style.backgroundColor; var fgc = bodyStyle.style.color;*/ var bgc = doc.defaultView.getComputedStyle(doc.body)["background-color"]; //this is needed for compatibility with BLICK var fgc = doc.defaultView.getComputedStyle(doc.body).color; return ` .hidden { display: none; } .veryhidden { display: none !important; } .transparent { opacity: 0; } .qrShowFirstButton, .qrShowLastButton { width: 50px; } .qrShowPrevButton, .qrShowNextButton { width: 100px; } .qrSettingsToggle { position: absolute; left: 8px; padding-top: 2px; } .qrControlsTop { } .qrNavControls { display: grid; grid-template-columns: 1fr auto auto auto auto auto auto 1fr; grid-gap: 3px; color: ${fgc}; pointer-events: none; } .qrNavControls > * { margin: auto 0px; pointer-events: all; } .qrOutline { text-shadow: 2px 2px 2px ${bgc}, 2px 0px 2px ${bgc}, 2px -2px 2px ${bgc}, 0px -2px 2px ${bgc}, -2px -2px 2px ${bgc}, -2px 0px 2px ${bgc}, -2px 2px 2px ${bgc}, 0px 2px 2px ${bgc}, 0px 0px 2px ${bgc}; } .qrNavLinksContainer { white-space: nowrap; text-align: right; color: ${fgc}; } .qrNavLinksBottom { float: right; clear: both; } .qrNavPosition { font-weight: bold; white-space: nowrap; } .qrLastIndex { color: crimson; } .qrUpdateInfo { white-space: nowrap; } .qrSettingsControls { height: 100px; overflow: hidden; transition: all 0.3s; display: grid; grid-template-columns: auto 1fr; } #qrSettingsPageNav { display: inline-flex; flex-direction: column; justify-content: space-evenly; } .qrSettingsNavItemSelected > a { color: inherit; text-decoration: none; } .qrSettingsNavItemSelected::before { content: ">"; } .qrSettingsNavItemSelected::after { content: "<"; } .qrSettingsPages { position: relative; margin-top: 8px; } .qrSettingsPage { display: grid; grid-template-columns: 1fr auto auto 1fr auto auto 1fr; grid-gap: 2px 4px; justify-items: start; align-items: center; align-content: center; white-space: nowrap; transition: all 0.3s; opacity: 0; position: absolute; left: 0; right: 0; top: 0; pointer-events: none; } .qrCurrentPage { opacity: 1; z-index: 1; pointer-events: initial; } .qrSettingsPage > :nth-child(4n+1) { grid-column-start: 2; cursor: default; } .qrSettingsPage > :nth-child(4n+3) { grid-column-start: 5; cursor: default; } .qrSettingsControl { margin: 0px; } select.qrSettingsControl { width: 150px; height: 22px; } input.qrSettingsControl[type="number"] { width: 40px; } .qrThreadLinksDropdown { max-width: 100px; } .collapsedHeight { height: 0px; } .qrTooltip { position: relative; border-bottom: 1px dotted; cursor: pointer; } .qrTooltip:hover .qrTooltiptext { visibility: visible; } .qrTooltip .qrTooltiptext { visibility: hidden; width: max-content; padding: 4px 4px 4px 10px; left: 15px; top: -35px; position: absolute; border: dotted 1px; z-index: 1; background-color: ${bgc}; } .haveOneScreenOfSpaceBelowHereSoItIsPossibleToScroll { position:absolute; height: 100vh; width: 1px; } #qrReplyFormHeader { text-align: center; } .postform td:first-child { display: none; } .qrReplyFormButtons { position: absolute; right: 0px; } .qrReplyFormButtons svg { width: 17px; vertical-align: bottom; } .qrPopout { position: fixed; opacity: 0.2 !important; transition: opacity 0.3s; background-color: ${bgc}; border: 1px solid rgba(0, 0, 0, 0.10) !important; } .qrPopout:hover { opacity: 1 !important; } .qrPopout:focus-within { opacity: 1 !important; } .qrPopout #qrReplyFormHeader { cursor: move; } .qrReferences { margin: 0.5em 4px 0px 4px; } .qrReferences::before { content: "Replies:"; font-size: 0.75em; } .qrReference { font-size: 0.75em; margin-left: 4px; text-decoration: none; } .stickyBottom { position: sticky; bottom: 0px; padding: 3px 0px; clear: both; } .uid[postcount]::after { content: " (" attr(postcount) ")"; } .isNew::after { color: crimson; } .qrUserNavDisabled { color: grey; pointer-events: none; } .qrUserNavEnabled { color: inherit; } .qrNavIcon { width: 14px; height: 17px; stroke-width: 2px; fill: none; stroke: currentColor; vertical-align: text-bottom; padding-left: 2px; } .qrNavIcon:hover { stroke-width: 3px; } .qrUserMultiPost { color: crimson; white-space: nowrap; } .update { width: 100%; } .postwidth > label { cursor: pointer; } .postwidth > label > * { cursor: default; } .userdelete { position: relative; } .userdelete tbody { position: absolute; right: 0px; top: -3px; text-align: right; } body { position: relative; ${this.viewMode !== "all" ? "overflow-anchor: none" : ""} } thead span > a { color: inherit; } /* Specificity hack to override the color of the reply form header links, but not overried the hover color */ #watchedthreadlist { display: grid; grid-template-columns: auto auto 3fr auto 1fr auto auto 0px; color: transparent; } #watchedthreadlist > a[href$=".html"] { grid-column-start: 1; } #watchedthreadlist > a[href*="html#"] { max-width: 40px; } #watchedthreadlist > * { margin: auto 0px; } #watchedthreadlist > span { overflow: hidden; white-space: nowrap; } #watchedthreadlist > .postername { grid-column-start: 5; } div#watchedthreadsbuttons { top: 0px; right: 0px; left: unset; bottom: unset; } .reflinkpreview { z-index: 1; } blockquote { margin-right: 1em; clear: unset; } #spoiler { vertical-align: text-top; } .postform { position: relative; border-spacing: 0px; } .postform :optional { box-sizing: border-box; } .postform input[name="name"], .postform input[name="em"], .postform input[name="subject"] { width: 100% !important; } .postform input[name="message"] { margin: 0px } .postform input[type="submit"] { position: absolute; right: 1px; bottom: 3px; } .postform [name="imagefile"] { width: 220px; } .postform td:first-child { display: none; } .postform tr:nth-child(3) td:last-child { display: grid; grid-template-columns: 1fr auto; } #BLICKpreviewbut { margin-right: 57px; }`; /* Rotating highlight border .highlight { border: unset; background-image: linear-gradient(90deg, currentColor 50%, transparent 50%), linear-gradient(90deg, currentColor 50%, transparent 50%), linear-gradient(0deg, currentColor 50%, transparent 50%), linear-gradient(0deg, currentColor 50%, transparent 50%); 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; } @keyframes rotating-border { 0% { background-position: left top, right bottom, left bottom, right top; } 100% { background-position: left 15px top, right 15px bottom , left bottom 15px , right top 15px; } } .logo { clear: unset; } blockquote { clear: unset; } .sentinel { position: absolute; left: 0px; top: 0px; width: 400px; pointer-events: none; background-color:white; opacity: 0.3; } */ } getImageSizesStyleRules() { var rules; if (this.stretchImages) { rules = `width: calc(${this.maxImageWidth}% - 40px); height: unset; max-height: calc(${this.maxImageHeight}vh - 50px); object-fit: contain;`; } else { rules = `width: unset; height: unset; max-width: calc(${this.maxImageWidth}% - 40px); max-height: calc(${this.maxImageHeight}vh - 40px);`; } return `.thumb[src*="svg+xml"], .thumb[src*="/src/"] { background-size: contain; background-position: center; background-repeat: no-repeat; ${rules} }`; } getReferencesStyleRules() { //none => hide all; nonupdates => hide for updates; all => don't hide anything if (this.showReferences == "all") { return ``; } var selector = this.showReferences == "nonupdates" ? ".update > .qrReferences, .updateOp > .qrReferences" : ".qrReferences"; return `${selector} { display: none; }`; } getUserPostCountsStyleRules() { return this.showUserPostCounts ? "" : `.uid::after { display: none; }`; } getUserNavStyleRules() { return this.showUserNav ? "" : `.qrUserNavDisabled { display: none; } .qrUserNavEnabled { display: none; }`; } getUserMultiPostStyleRules() { return this.showUserMultiPost ? "" : `.qrUserMultiPost { display: none; }`; } getUserColorsStyleRules() { if (!this.colorUsers) { return ""; } var colors = []; [this.author, ...this.suggesters].forEach(user => { var canonID = this.getCanonID(user); colors.push(`.uid${canonID} { ${this.getColors(canonID)} }`); }); return `.qrColoredUid { border-style: solid; padding: 0 5px; border-radius: 6px; border-width: 0px 6px; background-clip: padding-box; } ${colors.join("\n")}`; } getCanonID(user) { if (this.reCanonID.test(user.id)) { return user.id; } for (let i = 0; i < user.children.length; i++) { if (this.reCanonID.test(user.children[i].id)) { return user.children[i].id; } } var id = ""; //generate canonID from any string; for (let i = 0; id.length < 6; i = ++i % user.id.length) { id += user.id.charCodeAt(i).toString(16); } return id.substring(0, 6); } getColors(id) { id = parseInt(id, 16); var hue = ((id & 0xFF) * 390) / 255; var saturation = hue <= 360 ? 1 : 0; // 1/12 of the IDs will be grayscale, altho gravitate away from grey var lightness1 = ((id >> 8) & 0xFF) / 255; var lightness2 = (((id >> 16) & 0xFF) / 255) * 0.75 + 0.125; //between 12.5% and 87.5% var textColor = lightness1 > 0.5 ? "black" : "white"; if (saturation === 1) { lightness1 = lightness1 * 0.5 + 0.25; //between 25% and 75% //convert to rgb and then to rec601 luma to be able to calculate whether the text should be black or white var a = saturation * Math.min(lightness1, 1 - lightness1); var rgbFunc = (n, k = (n + hue / 30) % 12) => lightness1 - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); textColor = rgbFunc(0) * 0.299 + rgbFunc(8) * 0.587 + rgbFunc(4) * 0.114 > 0.5 ? "black" : "white"; } else { //do basic math; if it's light, make it lighter, if it's dark, make it darker lightness1 = lightness1 >= 0.5 ? (Math.pow((lightness1 - 0.5) * 2, 1/2)) / 2 + 0.5 : Math.pow(lightness1 * 2, 2) / 2; } return `background-color: hsl(${hue}, ${saturation * 100}%, ${lightness1 * 100}%); color: ${textColor}; border-color: hsl(${hue}, ${saturation * 100}%, ${lightness2 * 100}%);`; } } var main = async () => { //first, let's undo some of the damage that prototype.js did by restoring native functions restoreNative(document); if (fullDocument !== document) { let doc = document.implementation.createHTMLDocument(); doc.documentElement.innerHTML = (await fullDocument).response; fullDocument = doc; } setTimeout(() => { var timeStart = Date.now(); try { if (!document.head.querySelector("#qrMainCss")) { //sanity check; don't run the script if it already ran; happens sometimes in Firefox on refresh? var qr = new QuestReader(document); document.defaultView.QR = qr; qr.onSettingsLoad = (e) => { var settingsKeys = {}; settingsKeys[ThreadType.QUEST] = `qrSettings${e.threadID}`; settingsKeys[ThreadType.DISCUSSION] = `qrSettingsDis${e.threadID}`; settingsKeys[ThreadType.OTHER] = `qrSettingsBoardThread.${e.boardName}`; // get settings from localStorage e.settings = document.defaultView.localStorage.getItem(settingsKeys[e.threadType]); if (!e.settings) { var lastThreadKeys = {}; lastThreadKeys[ThreadType.QUEST] = `qrLastThreadID`; lastThreadKeys[ThreadType.DISCUSSION] = `qrLastDisThreadID`; var lastThreadID = document.defaultView.localStorage.getItem(lastThreadKeys[e.threadType]); if (lastThreadID) { settingsKeys[ThreadType.QUEST] = `qrSettings${lastThreadID}`; settingsKeys[ThreadType.DISCUSSION] = `qrSettingsDis${lastThreadID}`; e.settings = document.defaultView.localStorage.getItem(settingsKeys[e.threadType]); if (e.settings) { e.settings = JSON.parse(e.settings); delete e.settings.currentUpdateIndex; delete e.settings.wikiPages; } } } else { e.settings = JSON.parse(e.settings); } }; qr.onSettingsChanged = (e) => { //on settings changed, save settings to localStorage var settingsKeys = {}; settingsKeys[ThreadType.QUEST] = `qrSettings${e.threadID}`; settingsKeys[ThreadType.DISCUSSION] = `qrSettingsDis${e.threadID}`; settingsKeys[ThreadType.OTHER] = `qrSettingsBoardThread.${e.boardName}`; if (e.threadType !== ThreadType.OTHER) { var lastThreadKeys = {}; lastThreadKeys[ThreadType.QUEST] = `qrLastThreadID`; lastThreadKeys[ThreadType.DISCUSSION] = `qrLastDisThreadID`; //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 if (!lastThreadKeys[e.threadType] || !document.defaultView.localStorage.getItem(settingsKeys[e.threadType])) { document.defaultView.localStorage.setItem(lastThreadKeys[e.threadType], e.threadID); } } //save settings document.defaultView.localStorage.setItem(settingsKeys[e.threadType], JSON.stringify(e.settings)); }; qr.onWikiDataLoad = (e) => { //before retrieving quest wiki, check localStorage for cached data and pass it to the class if it exists e.wikiData = JSON.parse(document.defaultView.localStorage.getItem(`qrWikiData.${e.wikiPageName}`)); } qr.onWikiDataChanged = (e) => { //after quest wiki is retrieved, cache the data in localStorage if (e.wikiData) { document.defaultView.localStorage.setItem(`qrWikiData.${e.wikiPageName}`, JSON.stringify(e.wikiData)); } } qr.init(fullDocument); } } finally { var hideUntilLoaded = document.querySelector("#hideUntilLoaded"); if (hideUntilLoaded) { hideUntilLoaded.remove(); } } console.log(`Quest Reader run time = ${Date.now() - timeStart}ms`); }); } // START is here var path = document.defaultView.location.pathname; var pathMatch = path.match(new RegExp("/kusaba/([a-z]*)/res/")); if (!pathMatch || BoardThreadTypes[pathMatch[1]] === undefined) { return; //don't run the script when viewing non-board URLs } var partial = path.endsWith("+50.html") || path.endsWith("+100.html"); var fullDocument = !partial ? document : Xhr.get(path.replace(new RegExp("\\+(50|100)\\.html$"), ".html")); if (document.readyState == "loading") { //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 var styleHtml = `<style id="hideUntilLoaded">body { display: none; }</style>`; var el = document.head || document.documentElement; if (el) { el.insertAdjacentHTML("beforeEnd", styleHtml); } else { //Chrome: It's possible the script gets executed even before the HTML element is added to the document, so we need to wait until it gets added and THEN hide the document new MutationObserver((mutations, observer) => { mutations.forEach(mutation => { (mutation.addedNodes || []).forEach(addedNode => { if (addedNode.tagName === "HTML") { addedNode.insertAdjacentHTML("beforeEnd", styleHtml); observer.disconnect(); } }); }); }).observe(document, { attributes: false, childList: true, subtree: false }); } document.addEventListener("DOMContentLoaded", main, { once: true }); //when parsing the HTML document is done, run the extension } else { main(); }