/* ==8Kun Baker Tools v0.7.4== We have entered the STORM. Be STRONG! GOD WINS! Pray MEME LOVE! For God and Country! WWG1WGA ==Features:== '''Post Highlighting''' * Highlight posts that are marked notable (I.E. someone has replied and said notable) in light green * Highlight nominating posts in dark green * Highlight nominating posts in posts mentions in light green * Highlight Q Posts in yellow * Highlight Q posts in mentions (I.E. on posts that get (YOU)'ed) * Highlight links to Q Posts in sparkle (Like q's trip) * Highlight previous bread links in blue '''Navigation''' * Cycle through Q Posts * Cycle through (You)'s * Cycle through own posts * Jump To Bottom Link * Jump To Bottom Top link * NEW IN v0.7.0 Jump to last reading location (like when you post and it sends you to bottom, you can jump right back) * Easy access to Breads via Bread List window * Scrollbar navigation shows location of Q/You/Notable/etc posts * NEW IN v0.7.0: Hover over post marker to preview post * Click on a post marker in scrollbar to jump to post '''Filtering''' * Filter to only nominating and notable posts, Q posts, Q replies * Option to blur images until hover * Image blacklist (AKA the NOPE button) * NEW IN v0.7.0: SpamFader with multiple spam detection strategies: * NameFag strategy: Marks namefags as spam * Breadshitter strategy: Marks bread shitters as spam * High post count strategy: Marks those with high post count as spam * Flood fag strategy: Marks those who post in short intervals as spam * Mark user as not spam button * Spam badges tell WHY the algorithm marked as post as spam. TRANSPARENCY! '''Customizable''' * NEW IN v0.7.0: Customizable post highlighting colors * Hide/Show features * Settings saved in localStorage '''Notables''' * Generate notables post * Adds "Notable Nomination" button to posts that opens the Quick Reply box and prefills it with a BAKER NOTABLE Template '''Stats''' * Thread stats overlay with * color coded reply count that goes from green to red as bread ages * UID Count * Post rate chart shows how many posts per min ==To Install:== 1. Copy this source code 2. Go to 8kun 3. Click "Options" in the top right 4. Choose "User JS" tab 5. Paste Baker tools JS 6. WWG1WGA ==Changelog:== '''0.7.4''' * Fix UIDs in stats overlay * NEW Bakers now have an oven timer * Calculate and display estimated remaining time before baking * Based on bakeAtPosts param. default 700 * Uses posts per minute calculation, posts, and bakeAtPosts, to calculate oven timer '''0.7.3''' * Fix previous bread vs next bread link highlighting logic * Update navigation controls colo(u)r to play nicer with dark theme '''0.7.2''' * Use flex layout in bakerwindow controls * Reorder navcontrols * Reduce space of controls in boardlist * Disable spamfader new post event listener on disable * Don't mark q posts as notable * Make windows resizable width, fix table formatting in breadlist * Use boardlist height in current index calcs '''0.7.1''' * Fix notable navigation in boardlist checkbox not working * Differentiate between previous and newer breads when highlighting '''0.7.0''' * Switched color scheme to match other tools * Post Per Minute Graph * Spam Fading with multiple strategies and spam badges to tell why post is spam * Allow customization of post highligting colors * Add go back to last reading location button * Improve Q post detection (all past trip codes) * Add post preview on hover to Scrollbar Navigation * Navigation controls are now aware of current page location * Bugfixes '''0.6.0''' * Navigation bar shows scroll location of q/you/notable posts and allows jumping to posts * Notable navigation controls in baker window and board list * Persistent Image Blacklist (AKA Nope Button) * Many bugfixes '''0.5.2''' * Fixes bread list table population bug '''0.5.0''' * Option to show Q/(YOU)/Own Post navigation controls in the boardlist * Option to hide Notable nomination button * List of research breads * BakerTools settings are now saved in local storage '''0.4.0''' * Option to blur images until hover * Adds a "Notable Nomination" button to posts that opens the Quick Reply box and prefills it with a BAKER NOTABLE Template * Add Q Post navigation links to the Baker Window * Add (You) navigation links to the Baker Window * Add own post navigation links to the Baker Window * Cleaned up baker window design * More code cleanup and linting changes '''0.3.0''' * Highlights Q Posts with white BG -> DARK TO LIGHT! * Highlights Q posts in mentions (I.E. posts that get (YOU)'ed) * Highlights links to Q Posts * Refactored code into classes for easier maint. '''0.2.0''' * Highlight pb links * Thread stats overlay with * color coded reply count that goes from green to red as bread ages * UID Count * Jump To Bottom Link * Jump To Bottom Top link '''0.1.0''' Initial release: * Highlight notables and nominators * Filter to only show notables and nominators * Create notables post Version History: https://pastebin.com/EDmx2iEr 0.7.3 https://pastebin.com/L1p6iRzZ 0.7.2 https://pastebin.com/dN5FhHCv 0.7.1 https://pastebin.com/6XuDuHYu 0.7.0 https://pastebin.com/YTSSmH7t 0.6.0 https://pastebin.com/mPVxr7Lz 0.5.2 https://pastebin.com/nEhm7yyY 0.5.1 https://pastebin.com/i9sF0Rd3 0.4.0 https://pastebin.com/kz9LrcE9 0.3.0 https://pastebin.com/4aEFsPwK 0.2.0 https://pastebin.com/eNmTtzdi 0.1.0 */ (function($) { "use strict"; /* globals $, board_name */ /* exported 8kun */ /** * Functions and vars related to EightKun functionality */ class EightKun { /** * Get reply links in post * @param {Element} post div.post * @return {JQuery} */ static getReplyLinksFromPost(post) { return $(post).find(EightKun.REPLY_SELECTOR) .filter(function(idx, link) { return $(link).text().match(EightKun.REPLY_SHORTLINK_REGEX); }); } /** * Get the post number that is being replied to * @param {Anchor} link * @return {string} */ static getPostNumberFromReplyLink(link) { return $(link).text() .match(EightKun.REPLY_SHORTLINK_REGEX)[1]; } /** * Get time of post * @param {Element} post div.post * @return {number} post time in unixtime */ static getPostTime(post) { return $(post).find('.intro time').attr('unixtime'); } /** * Get date of post * @param {Element} post div.post * @return {number} post time in unixtime */ static getPostDateTime(post) { return $(post).find('.intro time').attr('datetime'); } /** * Get poster id of provided post * @param {Element} post div.post * @return {string} id of poster */ static getPosterId(post) { return $(post).find('p > span.poster_id').first().text(); } /** * Get name from post * @param {Element} post div.post * @return {string} name of post */ static getPostName(post) { return $(post).find('.intro > label > .name').text(); } /** * Get trip from post * @param {Element} post div.post * @return {string} trip of post */ static getPostTrip(post) { return $(post).find('.intro .trip').text(); } /** * Get the opening post of the thread * @return {Element} div.post */ static getOpPost() { return $(EightKun.OP_POST_SELECTOR); } /** * Get poster id of OP * @return {number} poster id */ static getOpPosterId() { return EightKun.getPosterId(EightKun.getOpPost()); } /** * Is the post made by op? * @param {Element} post div.post * @return {boolean} true if op's post */ static isPostFromOp(post) { return EightKun.getPosterId(post) === EightKun.getOpPosterId(); } /** * Get the thread id * @return {number} id of thread */ static getThreadId() { return $('.thread').get(0).id.split('_')[1]; } /** * Use 8kun hide function on post * @param {Element} post div.post */ static hidePost(post) { // TODO: implement it and use in spam and blacklist } /** * Get current board * @return {string} */ static getCurrentBoard() { /* eslint-disable camelcase */ return board_name; } /** * Get post number of post * @param {Element} post div.post * @return {number} Number of the post */ static getPostNumber(post) { return post.id.split('_')[1]; } /** * Get the top boardlist element * @return {Element} div.boardlist */ static getTopBoardlist() { return $(EightKun.TOP_BOARDLIST_SELECTOR).get(0); } } EightKun.POST_SELECTOR = 'div.post'; EightKun.POST_REPLY_SELECTOR = 'div.post.reply'; EightKun.OP_POST_SELECTOR = 'div.post.op'; EightKun.POST_BODY_SELECTOR = '.body'; EightKun.POST_MODIFIED_SELECTOR = '.post_modified'; EightKun.NEW_POST_EVENT = 'new_post'; EightKun.OP_SUBJECT_SELECTOR = '.post.op > p > label > span.subject'; EightKun.REPLY_SELECTOR = 'div.body:first a:not([rel="nofollow"])'; EightKun.REPLY_SHORTLINK_REGEX = /^>>(\d+)$/; EightKun.REPLY_REGEX = /highlightReply\('(.+?)'/; EightKun.BOARDLIST_SELECTOR = `.boardlist`; EightKun.TOP_BOARDLIST_SELECTOR = `${EightKun.BOARDLIST_SELECTOR}:first`; /** * Wrapper for 8kun active_page variable to determine the type of * page the user is on. */ class ActivePage { /** * Are we currently on the thread index page? * @return {boolean} True if on index */ static isIndex() { return window.active_page == ActivePage.Index; } /** * Are we currently on the thread catalog page? * @return {boolean} True if on catalog */ static isCatalog() { return window.active_page == ActivePage.Catalog; } /** * Are we on a thread page? * @return {boolean} True if on thread */ static isThread() { return window.active_page == ActivePage.Thread; } } ActivePage.Index = 'index'; ActivePage.Catalog = 'catalog'; ActivePage.Thread = 'thread'; /* globals $ */ /* exported ColorPicker */ /** * A color picker control that saves to localStorage */ class ColorPicker { /** * Construct color picker * * @param {string} label The label for the control * @param {string} title Mouseover title * @param {string} setting localStorage setting name * @param {string} defaultValue the default color when setting is missing * @param {Function} changeHandler handler for value changes. Passes color */ constructor(label, title, setting, defaultValue, changeHandler) { this.styleId = 'bakertools-colorpickers-styles'; this.class = 'bakertools-colorpicker'; this.labelClass = 'bakertools-colorpicker-label'; this.inputClass = 'bakertools-colorpicker-input'; this.resetButtonClass = 'bakertools-colorpicker-reset'; this.changeHandler = changeHandler; this.label = label; this.title = title; this.setting = setting; this.defaultValue = defaultValue; this.strippedName = label.replace(/(\s|\(|\)|'|"|:)/g, ''); this.defaultValue = defaultValue; this._createStyles(); this._createElement(); } /** * Create the HTML Element */ _createElement() { this.element = $(` <div class='${this.class}'> <label class='${this.labelClass}' for="${this.strippedName}" title="${this.title}" > ${this.label}: </label> </div> `).get(0); this.input = $(` <input type="color" class='${this.inputClass}' id="${this.strippedName}" title="${this.title}" /> `).get(0); $(this.element).append(this.input); $(this.input).change(function(e) { this.setColor(this.input.value); }.bind(this)); this.reset = $(` <button class='${this.resetButtonClass}' title="Reset to Default"> <i class="fa fa-undo"></i> </button> `).get(0); $(this.element).append(this.reset); $(this.reset).click(function(e) { e.preventDefault(); this.setColor(this.defaultValue); }.bind(this)); this.setColor(localStorage.getItem(this.setting) || this.defaultValue); } /** * Set the color * @param {string} color valid css color string */ setColor(color) { localStorage.setItem(this.setting, color); this.input.value = color; this.changeHandler(color); } /** * Get the color * @return {string} color */ getColor() { return this.input.value; } /** * Create styles for the control */ _createStyles() { if ($(`#${this.styleId}`).length) { return; } $('head').append(` <style id='${this.styleId}'> .${this.class} { display: flex; align-items: center; } .${this.class} .${this.labelClass} { flex-grow: 1; } .${this.class} .${this.inputClass} { margin-right: .5em; } .${this.resetButtonClass} { padding: 0; background-color: Transparent; background-repeat: no-repeat; border: none; cursor: pointer; overflow: hidden; outline: none; } </style> `); } } /* global $, debounce, EightKun */ /** * Creates first, prev, next, last navigation controls */ class NavigationControl { /** * Construct navigatio control manager object * * @param {string} label the label for the control * @param {Function} updateFunction Called to get latest data * @param {string} updateEventName Called to get latest data */ constructor(label, updateFunction, updateEventName) { const strippedName = label.replace(/(\s|\(|\)|'|"|:)/g, ''); this.styleId = 'bakertools-navigationcontrol-styles'; this.label = label; this.updateFunction = updateFunction; this.updateEventName = updateEventName; this.list = this.updateFunction(); this.currentIndex = -1; const instanceId = $(NavigationControl.containerClass).length; this.navigationClass = `bakertools-navcontrol-${strippedName}`; this.indexChangeEvent = `bakertools-navcontrol-${strippedName}-index-changed`; this.currentIndexId = `${this.navigationClass}-current-index-${instanceId}`; this.currentIndexClass = `bakertools-navcontrol-current-index`; this.totalClass = `${this.navigationClass}-total`; this.goToFirstClass = `${this.navigationClass}-goto-first`; this.goToPreviousClass = `${this.navigationClass}-goto-prev`; this.goToNextClass = `${this.navigationClass}-goto-next`; this.goToLastClass = `${this.navigationClass}-goto-last`; this._setupStyles(); this._createElement(); this.updateIndexFromCurrentScrollPosition(); this.updateIndexFromCurrentScrollPosition = debounce(this.updateIndexFromCurrentScrollPosition, 500); this._setupListeners(); } // TODO: switch to flexbox layout /** * setup styles for nav control */ _setupStyles() { if ($(`#${this.styleId}`).length) { return; } const boardListNavSelector = `${EightKun.BOARDLIST_SELECTOR} .${NavigationControl.containerClass}`; $('head').append(` <style id='${this.styleId}'> ${boardListNavSelector}:before { content: '['; color: #89A; } ${boardListNavSelector}:after { content: ']'; color: #89A; } ${boardListNavSelector} { color: rgb(20, 137, 183); } </style> `); } /** * Create nav element */ _createElement() { this.element = $(` <span title="Navigate ${this.label}" class="${NavigationControl.containerClass}"> <label for="${this.navigationClass}">${this.label}:</label> <span class="${this.navigationClass} ${NavigationControl.navigationControlClass}"> <i class="fa fa-angle-double-left ${this.goToFirstClass}"></i> <i class="fa fa-angle-left ${this.goToPreviousClass}"></i> <span class="${this.currentIndexClass}" id='${this.currentIndexId}'> ${this.currentIndex+1} </span> : <span class="${this.totalClass}">${this.list.length}</span> <i class="fa fa-angle-right ${this.goToNextClass}"></i> <i class="fa fa-angle-double-right ${this.goToLastClass}"></i> </span> </span> `).get(0); } /** * Setup button event listeners */ _setupListeners() { $(this.element).find('.'+this.goToFirstClass).click(function(e) { this.goToFirstPost(); }.bind(this)); $(this.element).find('.'+this.goToPreviousClass).click(function(e) { this.goToPreviousPost(); }.bind(this)); $(this.element).find('.'+this.goToNextClass).click(function(e) { this.goToNextPost(); }.bind(this)); $(this.element).find('.'+this.goToLastClass).click(function(e) { this.goToLastPost(); }.bind(this)); $(document).on(this.indexChangeEvent, function(e, index) { if (this.currentIndex == index) return; this._setCurrentIndex(index); }.bind(this)); $(document).on(this.updateEventName, function() { this.list = this.updateFunction(); $(this.element).find(`.${this.totalClass}`).text(this.list.length); }.bind(this)); $(document).scroll(this.updateIndexFromCurrentScrollPosition.bind(this)); } /** * Determine the current index based on scroll position */ updateIndexFromCurrentScrollPosition() { const boardListHeight = $(EightKun.getTopBoardlist()).height(); for (let i = 0; i < this.list.length; ++i) { const post = this.list[i]; const boundingRect = post.getBoundingClientRect(); const postTopAboveBottomOfScreen = boundingRect.top < window.innerHeight; const postBottomBelowTopOfScreen = boundingRect.bottom > boardListHeight; const currentPostIsInViewport = postTopAboveBottomOfScreen && postBottomBelowTopOfScreen; if (currentPostIsInViewport) { this._setCurrentIndex(i); break; } const isFirstPost = i === 0; const isBeforeFirstNotable = isFirstPost && !postTopAboveBottomOfScreen; if (isBeforeFirstNotable) { this._setCurrentIndex(-1); break; } const isLastPost = i === (this.list.length - 1); const isPastLastNotable = isLastPost && !postBottomBelowTopOfScreen; if (isPastLastNotable) { this._setCurrentIndex(i + .5); break; } const nextPost = this.list[i+1]; const nextPostBounds = nextPost.getBoundingClientRect(); const nextPostIsBelowBottomOfScreen = nextPostBounds.top >= window.innerHeight; const inBetweenPosts = !postBottomBelowTopOfScreen && nextPostIsBelowBottomOfScreen; if (inBetweenPosts) { this._setCurrentIndex(i + .5); break; } } } /** * Scroll to first post */ goToFirstPost() { if (!this.list.length) { return; } this._setCurrentIndex(0); this.scrollToCurrentPost(); } /** * Scroll to next navigated post */ goToPreviousPost() { if (!this.list.length) { return; } if (this.currentIndex <= 0) { this._setCurrentIndex(this.list.length - 1); } else { this._setCurrentIndex(Math.ceil(this.currentIndex) - 1); } this.scrollToCurrentPost(); } /** * Scroll to next navigated post */ goToNextPost() { if (!this.list.length) { return; } const lastPostIndex = this.list.length - 1; if (this.currentIndex >= lastPostIndex) { this._setCurrentIndex(0); } else { this._setCurrentIndex(Math.floor(this.currentIndex) + 1); } this.scrollToCurrentPost(); } /** * Scroll the last post in this bread into view */ goToLastPost() { if (!this.list.length) { return; } const numPosts = this.list.length; this._setCurrentIndex(numPosts - 1); this.scrollToCurrentPost(); } /** * Scrolls the current selected post into view */ scrollToCurrentPost() { const post = this.list[this.currentIndex]; $(post).get(0).scrollIntoView(); // Trigger events for other views of this data $(document).trigger(this.indexChangeEvent, this.currentIndex); const boardListHeight = $(EightKun.getTopBoardlist()).height(); window.scrollBy(0, -boardListHeight); } /** * Set internal index var and UI * @param {number} index */ _setCurrentIndex(index) { this.currentIndex = index; this._setCurrentIndexControlValue(index + 1); } /** * Sets the value of the current index in the UI * @param {number} val */ _setCurrentIndexControlValue(val) { $('#'+this.currentIndexId).text(val); } } NavigationControl.containerClass = `bakertools-navcontrol-container`; NavigationControl.navigationControlClass = 'bakertools-navigation-control'; /* global EightKun, $, NotableHighlighter */ /** * Wrapper for a post nominated as notable */ class NotablePost { /** * Construct an empty notable post object */ constructor() { this.element = null; this.postNumber = null; this.description = '[DESCRIPTION]'; this.nominatingPosts = []; } /** * Create a notable post from a nominating post * * @param {Element} nominatingPost A post that is nominating a notable * @return {NotablePost} a Notable post or NullNotablePost if it fails */ static fromNominatingPost(nominatingPost) { const notables = []; EightKun.getReplyLinksFromPost(nominatingPost) .each(function(idx, link) { const postNumber = EightKun.getPostNumberFromReplyLink(link); const notablePostElement = $(`#reply_${postNumber}`).get(0); if (window.bakerTools.qPostHighlighter.isQ(notablePostElement)) { return false; } if (!NotablePost.findNotableByPostNumber(postNumber)) { const notable = new NotablePost(); if (notablePostElement) { notable.setElement(notablePostElement); } else { // TODO: set pb description // get the json from the post number notable.postNumber = postNumber; } notable.addNominatingPost(nominatingPost); NotablePost.addToListOfNotables(notable); notables.push(notable); if (notable.element) { // Not pb will need to figure something out $(document).trigger(NotablePost.NEW_NOTABLE_POST_EVENT, notable.element); } } }); return notables; } /** * Add notable to list, and sort list * @param {NotablePost} notable */ static addToListOfNotables(notable) { NotablePost._notables.push(notable); NotablePost._notables.sort(function(n1, n2) { if (n1.postNumber < n2.postNumber) { return -1; } else if ( n1.postNumber > n2.postNumber) { return 1; } return 0; }); } /** * Is this a NullNotablePost * @return {boolean} false */ isNull() { return false; } /** * @return {Array<NotablePost>} Array of the current notables */ static getNotables() { return NotablePost._notables; } /** * Get notable posts as regular 8kun div.post * @return {Array} of div.post */ static getNotablesAsPosts() { return NotablePost._notables .filter((n) => n.element !== null) .map((n) => n.element); } /** * @arg {number} postNumber The post number of notable * @return {NotablePost} */ static findNotableByPostNumber(postNumber) { return NotablePost._notables.find((notable) => notable.postNumber == postNumber); } /** * Set the element of the post * @arg {Element} element */ setElement(element) { this.element = element; this._markAsNotable(this.element); this.description = element.querySelector('.body') .innerText .replace(/\n/g, ' '); this.postNumber = $(this.element).find('.intro .post_no') .text() .replace('No.', ''); } /** * Get the reply shortlink for the post * @return {string} */ shortLink() { return '>>' + this.postNumber; } /** * Add a nominator to the notable * * @param {Element} nominatingPost A .div.post that nominates this post */ addNominatingPost(nominatingPost) { this.nominatingPosts.push(nominatingPost); this._markAsNominator(nominatingPost); this._markNominatorInMentions(nominatingPost); } /** * @arg {Element} nominatorPost .post */ _markAsNominator(nominatorPost) { nominatorPost.classList.add(NotableHighlighter.NOMINATOR_CLASS); } /** * @arg {Element} post .post */ _markAsNotable(post) { post.classList.add(NotableHighlighter.NOTABLE_CLASS); } /** * Gives links to nominators a special style in notable mentions * * @param {Element} nominatingPost A .div.post that is nominating this * notable */ _markNominatorInMentions(nominatingPost) { if (!this.element) { console.info(`Notable post is null - possible pb/lb`); return; } const nominatingPostId = nominatingPost.id.replace('reply_', ''); $(this.element).find('.mentioned-'+nominatingPostId) .addClass(NotableHighlighter.NOMINATOR_CLASS); } } NotablePost._notables = []; NotablePost.NULL = null; // NullNotablePost NotablePost.NEW_NOTABLE_POST_EVENT = 'bakertools-new-notable-post-event'; /* globals EightKun */ /** * Research Bread Class */ class ResearchBread { /** * Get an array of post bodies with dough posts filtered out * @return {NodeList} of .post elements */ static getPostsWithoutDough() { const posts = Array.from(document .querySelectorAll(EightKun.POST_SELECTOR)); const filteredPosts = posts.filter(function(post) { return !post.querySelector(EightKun.POST_BODY_SELECTOR) .innerText.match(ResearchBread.DOUGH_POSTS_REGEX); }); return filteredPosts; } /** * Determine what the bread number is * @return {number} the number of the research bread */ static getBreadNumber() { const breadNumberRegex = /#(.+?) /; return document.querySelector(EightKun.OP_SUBJECT_SELECTOR) .innerText .match(breadNumberRegex)[1] || 'COULD NOT FIND BREAD NUMBER'; } /** * Find the post with the dough * @return {Element} div.post */ static getDoughPost() { const posts = Array.from(document .querySelectorAll(EightKun.POST_SELECTOR)); const dough = posts.find(function(post) { return post.querySelector(EightKun.POST_BODY_SELECTOR) .innerText.toUpperCase().match(ResearchBread.DOUGH_POST_TITLE); }); return dough; } } ResearchBread.BOARD_NAME = 'qresearch'; ResearchBread.WELCOME_POST_TITLE = 'Welcome To Q Research General'; ResearchBread.ANNOUNCEMENTS_POST_TITLE = 'Global Announcements'; ResearchBread.WAR_ROOM_POST_TITLE = 'War Room'; ResearchBread.ARCHIVES_POST_TITLE = 'QPosts Archives'; ResearchBread.DOUGH_POST_TITLE = 'DOUGH'; ResearchBread.DOUGH_POSTS_REGEX = new RegExp( `^(${ResearchBread.WELCOME_POST_TITLE}|` + `${ResearchBread.ANNOUNCEMENTS_POST_TITLE}|` + `${ResearchBread.WAR_ROOM_POST_TITLE}|` + `${ResearchBread.ARCHIVES_POST_TITLE}|` + `${ResearchBread.DOUGH_POST_TITLE}).*`); /* globals $, EightKun, debounce, POST_BACKGROUND_CHANGE_EVENT, BakerWindow */ /* exported ScrollbarNavigation */ /** * Scrollbar navigation */ class ScrollbarNavigation { /** * Construct a scrollbar nav * @param {Array} addPostEvents List of event names that produce posts * to show on scrollbar */ constructor(addPostEvents = []) { this.id = 'bakertools-scrollbar-navigation'; this.showScrollbarNavigationId = 'bakertools-show-scrollbar-nav'; this.width = '20px'; this.posts = []; this.coordsToPost = new Map(); this.addPostEvents = addPostEvents; this.draw = debounce(this.draw, 80); this._setupBakerWindowControls(); this._setupStyles(); this._createElement(); this._readSettings(); this._setupListeners(); } /** * Read settings from localStorage */ _readSettings() { let showScrollBar = JSON.parse(localStorage .getItem(ScrollbarNavigation.SHOW_SCROLLBAR_NAV)); showScrollBar = showScrollBar === null ? true : showScrollBar; this.showScrollBar(showScrollBar); } /** * Add hide/show option to bakerwindow */ _setupBakerWindowControls() { window.bakerTools.mainWindow .addNavigationOption(` <div class='${BakerWindow.CONTROL_GROUP_CLASS}'> <label for="${this.showScrollbarNavigationId}" title="Show scrollbar navigation" > Show Scrollbar Navigation: </label> <input type="checkbox" id="${this.showScrollbarNavigationId}" title="Show scrollbar navigation" /><br /> </div> `); } /** * Setup event listeners */ _setupListeners() { $('#'+this.showScrollbarNavigationId).change(function(e) { this.showScrollBar(e.target.checked); }.bind(this)); $(document).on(EightKun.NEW_POST_EVENT, this.draw.bind(this)); $(window).on('resize', this.draw.bind(this)); $(window).on(POST_BACKGROUND_CHANGE_EVENT, this.draw.bind(this)); $('#'+this.id).click(function(e) { const post = this.findFirstPostUnderMouse(e.clientX, e.clientY); if (post) { $(post).get(0).scrollIntoView(); window.scrollBy(0, -20); } }.bind(this)); $(this.element).mousemove(function(e) { const [post, coords] = this.findFirstPostUnderMouse(e.clientX, e.clientY); const notOverAPost = !post; const hoveringOverADifferentPost = this.hoveringPost && this.hoveringPost != post; if (notOverAPost || hoveringOverADifferentPost) { this.endHover(); } if (this.hovering) { return; } const top = coords.top; if (post) { this.postHover(post, top); } }.bind(this)); $(this.element).mouseout(this.endHover.bind(this)); this.addPostEvents.forEach(function(eventName) { $(document).on(eventName, function(event, posts) { this.addPosts(posts); }.bind(this)); }.bind(this)); } /** * Find the first post that is under the mouse * @param {number} clientX x location of mouse * @param {number} clientY y location of mouse * @return {Array} div.post, [top,bottom] or null if not found */ findFirstPostUnderMouse(clientX, clientY) { let post = null; let coords = null; for (const keyValue of this.coordsToPost) { coords = keyValue[0]; // if (clientY >= (top - 10) && clientY <= (bottom+10)) { if (clientY >= (coords.top) && clientY <= (coords.bottom)) { post = keyValue[1]; break; } } return [post, coords]; } /** * Perform post hover functionality for provided post * @param {Element} post div.post * @param {number} hoverY y location to hover at */ postHover(post, hoverY) { this.hovering = true; this.hoveringPost = post; const $post = $(post); if ($post.is(':visible') && $post.offset().top >= $(window).scrollTop() && $post.offset().top + $post.height() <= $(window).scrollTop() + $(window).height()) { // post is in view $post.addClass('highlighted'); } else { const newPost = $post.clone(); newPost.find('>.reply, >br').remove(); newPost.find('a.post_anchor').remove(); const postNumber = EightKun.getPostNumber(post); newPost.attr('id', 'post-hover-' + postNumber) .attr('data-board', EightKun.getCurrentBoard()) .addClass('post-hover') .css('border-style', 'solid') .css('box-shadow', '1px 1px 1px #999') .css('display', 'block') .css('position', 'absolute') .css('font-style', 'normal') .css('z-index', '100') .css('left', '0') .css('margin-left', '') .addClass('reply') .addClass('post') .appendTo('.thread'); // shrink expanded images newPost.find('div.file img.post-image').css({ 'display': '', 'opacity': '', }); newPost.find('div.file img.full-image').remove(); let previewWidth = newPost.outerWidth(true); const widthDiff = previewWidth - newPost.width(); const scrollNavLeft = $(this.element).offset().left; let left; if (scrollNavLeft < $(document).width() * 0.7) { left = scrollNavLeft + $(this.element).width(); if (left + previewWidth > $(window).width()) { newPost.css('width', $(window).width() - left - widthDiff); } } else { if (previewWidth > scrollNavLeft) { newPost.css('width', scrollNavLeft - widthDiff); previewWidth = scrollNavLeft; } left = scrollNavLeft - previewWidth; } newPost.css('left', left); const scrollTop = $(window).scrollTop(); let top = scrollTop + hoverY; if (top < scrollTop + 15) { top = scrollTop; } else if (top > scrollTop + $(window).height() - newPost.height() - 15) { top = scrollTop + $(window).height() - newPost.height() - 15; } if (newPost.height() > $(window).height()) { top = scrollTop; } newPost.css('top', top); } } /** * End hovering */ endHover() { this.hovering = false; if (!this.hoveringPost) { return; } $(this.hoveringPost).removeClass('highlighted'); if ($(this.hoveringPost).hasClass('hidden')) { $(this.hoveringPost).css('display', 'none'); } $('.post-hover').remove(); } /** * Show/hide scrollbar * @param {boolean} shouldShow Shows if true */ showScrollBar(shouldShow) { $('#'+this.showScrollbarNavigationId).prop('checked', shouldShow); localStorage.setItem(ScrollbarNavigation.SHOW_SCROLLBAR_NAV, shouldShow); if (shouldShow) { $(`#${this.id}`).show(); } else { $(`#${this.id}`).hide(); } } /** * Setup styles for canvas */ _setupStyles() { $('head').append(` <style id='${this.id + '-style'}'> #${this.id} { position: fixed; top: 0; right: 0; height: 100%; width: ${this.width}; background: #000000; background: linear-gradient( 90deg, rgba(0,0,0,1) 0%, rgba(92,92,92,1) 50%, rgba(0,0,0,1) 100% ); } </style> `); } /** * Create the canvas */ _createElement() { $(document.body).append(` <canvas id='${this.id}' width='${this.width}' height='300'> </canvas> `); this.element = $(`#${this.id}`).get(0); } /** * Draw the scrollbar */ draw() { const canvas = this.element; canvas.height = window.innerHeight; const ctx = canvas.getContext('2d'); if (!ctx) { console.info('no ctx - is the element created yet?'); return; } ctx.clearRect(0, 0, canvas.width, canvas.height); const cachedHeight = $(document).height(); const scrollHeight = canvas.height; this.coordsToPost = new Map(); let lastCoords = null; this.posts.forEach(function(post, index) { const color = $(post).css('backgroundColor'); const postRect = post.getBoundingClientRect(); const scrollLocationPercentage = (window.scrollY + postRect.top) / cachedHeight; let drawLocation = scrollLocationPercentage * scrollHeight; const overlappingPrevious = lastCoords && drawLocation <= (lastCoords.bottom + 2); if (overlappingPrevious) { drawLocation = lastCoords.bottom + 4; } const drawHeight = Math.max( (postRect.height / cachedHeight) * scrollHeight, 5, ); const coords = new ScrollbarCoordinates(drawLocation, drawLocation + drawHeight); this.coordsToPost.set(coords, post); ctx.fillStyle = color; ctx.fillRect(0, drawLocation, canvas.width, drawHeight); lastCoords = coords; }.bind(this)); } /** * Add posts to scrollbar * @param {Element|Array} post div.post */ addPosts(post) { if (Array.isArray(post)) { post.forEach((p) => this._addPost(p)); } else { this._addPost(post); } this._sortPosts(); this.draw(); } /** * Add post to post array if not already included * @param {Element} post div.post */ _addPost(post) { if (this.posts.includes(post)) { return; } this.posts.push(post); } /** * Sort posts by time */ _sortPosts() { this.posts.sort(function(p1, p2) { const p1PostTime = EightKun.getPostTime(p1); const p2PostTime = EightKun.getPostTime(p2); if (p1PostTime < p2PostTime) { return -1; } if (p1PostTime > p2PostTime) { return 1; } return 0; }); } } ScrollbarNavigation.SHOW_SCROLLBAR_NAV = 'bakertools-show-scrollbar-nav'; /** * Coordinates on the scrollbar */ class ScrollbarCoordinates { /** * Construct coords * @param {number} top top of rect * @param {number} bottom top of rect */ constructor(top, bottom) { this.top = top; this.bottom = bottom; } } /* exported debounce, POST_BACKGROUND_CHANGE_EVENT */ /** * Returns a function, that, as long as it continues to be invoked, will not * be triggered. The function will be called after it stops being called for * N milliseconds. If `immediate` is passed, trigger the function on the * leading edge, instead of the trailing. * https://davidwalsh.name/javascript-debounce-function * * @param {Function} func * @param {number} wait * @param {boolean} immediate * @return {Function} debounced function */ function debounce(func, wait, immediate) { let timeout; return function(...args) { const context = this; const later = function() { timeout = null; if (!immediate) func.apply(context, args); }; const callNow = immediate && !timeout; clearTimeout(timeout); timeout = setTimeout(later, wait); if (callNow) func.apply(context, args); }; } const POST_BACKGROUND_CHANGE_EVENT = 'bakertools-post-background-change'; /* globals $, EightKun */ /* exported WindowElement */ /** * Class for windows */ class WindowElement { /** * Construct WindowElement * @param {string} windowName * @param {string} linkText */ constructor(windowName, linkText) { this.styleId = 'bakertools-WindowElement-basestyles'; this.id = `bakertools-${windowName}-window`; this.linkText = linkText; this.class = 'bakertools-WindowElement'; this.headerClass = 'bakertools-WindowElement-header'; this.windowCloseId = `bakertools-${windowName}-WindowElement-close`; this.windowCloseClass = `bakertools-WindowElement-close`; this.element = null; this._createWindowStyles(); this._createElement(); this._setupWindowLink(); } /** * Create the window element */ _createElement() { this.element = document.createElement('div'); this.element.id = this.id; $(this.element).addClass(this.class); this.element.innerHTML = ` <header class="${this.headerClass}"> <h3>${this.linkText}</h3> <a id="${this.windowCloseId}" class='${this.windowCloseClass}' href="javascript:void(0)"> <i class="fa fa-times"></i> </a> </header> `; document.body.appendChild(this.element); $(this.element).resizable({ 'handles': 'e, w', }).draggable(); $(this.element).hide(); $('#'+this.windowCloseId).click(function(e) { this.hide(); }.bind(this)); } /* * Create CSS styles needed by the window */ _createWindowStyles() { if ($('#' + this.styleId).length) { return; } $('head').append(` <style id='${this.styleId}'> /* * ui-resizable styles: https://stackoverflow.com/a/11339280 */ .ui-resizable { position: relative;} .ui-resizable-handle { position: absolute; font-size: 0.1px; display: block; } .ui-resizable-disabled .ui-resizable-handle, .ui-resizable-autohide .ui-resizable-handle { display: none; } .ui-resizable-n { cursor: n-resize; height: 7px; width: 100%; top: -5px; left: 0; } .ui-resizable-s { cursor: s-resize; height: 7px; width: 100%; bottom: -5px; left: 0; } .ui-resizable-e { cursor: e-resize; width: 7px; right: -5px; top: 0; height: 100%; } .ui-resizable-w { cursor: w-resize; width: 7px; left: -5px; top: 0; height: 100%; } .ui-resizable-se { cursor: se-resize; width: 12px; height: 12px; right: 1px; bottom: 1px; } .ui-resizable-sw { cursor: sw-resize; width: 9px; height: 9px; left: -5px; bottom: -5px; } .ui-resizable-nw { cursor: nw-resize; width: 9px; height: 9px; left: -5px; top: -5px; } .ui-resizable-ne { cursor: ne-resize; width: 9px; height: 9px; right: -5px; top: -5px; } .${this.class} { width: 300px; background-color: rgb(214, 218, 240); position: fixed; z-index: 100; float: right; right:28.25px; border: 1px solid; } .${this.class} .${this.headerClass} { background: #98E; border: solid 1px; text-align: center; margin: 0px; } .${this.class} .${this.headerClass} h3 { margin: 0; } .${this.class} .${this.windowCloseClass} { top: 0px; right: 0px; position: absolute; margin-right: 3px; font-size: 20px; } .${this.class} details { padding: 5px; } .${this.class} summary { margin: 0 0 8px; font-weight: bold; border-bottom: solid 2px; } </style> `); } /** * Create link for show/hiding window, placed in boardlist bar */ _setupWindowLink() { this.link = document.createElement('a'); this.link.textContent = `[${this.linkText}]`; this.link.style.cssText = 'float: right;'; this.link.title = this.linkText; this.link.href = 'javascript:void(0)'; $(EightKun.getTopBoardlist()).append(this.link); this.link.onclick = this.toggle.bind(this); } /** * Setup timeout for updating bread list */ _setupListeners() { // window.setTimeout(this.updateBreadList, 1000) } /** * Show the window */ show() { $(this.element).css({'top': 15}); $(this.element).show(); } /** * Hide the window */ hide() { $(this.element).hide(); } /** * Is the window visible? * @return {boolean} true if window is visible */ isVisible() { return $(this.element).is(':visible'); } /** * Toggle visibility of window */ toggle() { if (this.isVisible()) { this.hide(); } else { this.show(); } } } /* exported BakerWindow */ /* global NavigationControl, $, WindowElement */ /** * Baker Window */ class BakerWindow extends WindowElement { /** * Construct Baker window element, register listeners */ constructor() { super('baker', 'Baker Tools'); this.bakerWindowStyleId = 'bakertools-bakerwindow-style'; this.bakerWindowOptionsId = 'bakertools-window-options'; this.bakerWindowColorOptionsId = 'bakertools-window-color-options'; this.bakerWindowNavigationOptionsId = 'bakertools-window-navigation-options'; this.bakerWindowNotableOptionsId = 'bakertools-window-notable-options'; this.bakerWindowSpamOptionsId = 'bakertools-window-spam-options'; this.bakerWindowNavigationId = 'bakertools-window-navigation'; this.bakerWindowBakerId = 'bakertools-window-baker'; this.bakerWindowBodyId = 'bakertools-bakerwindow-body'; this._createStyles(); this._createBody(); } /** * Create CSS styles needed by the window */ _createStyles() { if ($('#' + this.bakerWindowStyleId).length) { return; } $('head').append(` <style id='${this.bakerWindowStyleId}'> #${this.id} #${this.bakerWindowNavigationId} .${NavigationControl.containerClass} { display: inline-block; width: 100%; } #${this.id} #${this.bakerWindowNavigationId} .${NavigationControl.navigationControlClass} { float: right; } ${BakerWindow.CONTROL_GROUP_SELECTOR} { display: flex; } ${BakerWindow.CONTROL_GROUP_SELECTOR} label { flex-grow: 1; } </style> `); } /** * Create the actual window HTML element */ _createBody() { $('#'+this.id).append(` <form id="${this.bakerWindowBodyId}"> <details id='${this.bakerWindowOptionsId}' open> <summary>Options</summary> <details id='${this.bakerWindowColorOptionsId}' open> <summary>Colors</summary> </details> <details id='${this.bakerWindowNavigationOptionsId}' open> <summary>Navigation</summary> </details> <details id='${this.bakerWindowNotableOptionsId}' open> <summary>Notables</summary> </details> <details id='${this.bakerWindowSpamOptionsId}' open> <summary>Spam</summary> </details> </details> <details id='${this.bakerWindowNavigationId}' open> <summary>Navigation</summary> </details> <details id='${this.bakerWindowBakerId}' open> <summary>Baker Tools</summary> </details> </form> `); } /** * Add form controls to options section of baker window * @arg {Element} htmlContentString form controls */ addOption(htmlContentString) { $('#'+this.bakerWindowOptionsId).append(htmlContentString); } /** * Add form controls to notable options section of baker window * @arg {Element} htmlContentString form controls */ addNotableOption(htmlContentString) { $('#'+this.bakerWindowNotableOptionsId) .append(htmlContentString); } /** * Add form controls to spam options section of baker window * @arg {Element} htmlContentString form controls */ addSpamOption(htmlContentString) { $('#'+this.bakerWindowSpamOptionsId) .append(htmlContentString); } /** * Add form controls to navigation options section of baker window * @arg {Element} htmlContentString form controls */ addNavigationOption(htmlContentString) { $('#'+this.bakerWindowNavigationOptionsId) .append(htmlContentString); } /** * Add form controls to color options section of baker window * @arg {Element} htmlContentString form controls */ addColorOption(htmlContentString) { $('#'+this.bakerWindowColorOptionsId).append(htmlContentString); } /** * Add html elements to the navigation section of the baker window * @arg {Element} htmlContentString form controls */ addNavigation(htmlContentString) { $('#'+this.bakerWindowNavigationId).append(htmlContentString); } /** * Add html elements to the baker section of the baker window * @arg {Element} htmlContentString form controls */ addBaker(htmlContentString) { $('#'+this.bakerWindowBakerId).append(htmlContentString); } } // end class BakerWindow BakerWindow.CONTROL_GROUP_CLASS = 'bakertools-bakerwindow-control-group'; BakerWindow.CONTROL_GROUP_SELECTOR = `.${BakerWindow.CONTROL_GROUP_CLASS}`; /* global $, BakerWindow */ /** * Blur images until highlighted */ class BlurImages { /** * Construct blur images object and setup styles */ constructor() { this.blurImages = 'bakertools-blur-images'; this.blurImagesStyleId = 'bakertools-blur-images-style'; window.bakerTools.mainWindow.addOption(` <div class='${BakerWindow.CONTROL_GROUP_CLASS}'> <label for="${this.blurImages}">Blur Images Until Hover</label> <input type="checkbox" id="${this.blurImages}" title="Blur images until mouse hover" /></br> </div> `); $('#'+this.blurImages).change(function(e) { this.setBlurImages(e.target.checked); }.bind(this)); this._readSettings(); } /** * Read settings from localStorage */ _readSettings() { this.setBlurImages(JSON.parse( localStorage.getItem( BlurImages.BLUR_IMAGES_SETTING), )); } /** * Set whether or not images are blurred * @param {boolean} blurImages if true, blur images */ setBlurImages(blurImages) { $('#'+this.blurImages).prop('checked', blurImages); localStorage.setItem(BlurImages.BLUR_IMAGES_SETTING, blurImages); if (blurImages) { $(`<style id='${this.blurImagesStyleId}' type='text/css'> .post-image { filter: blur(5px); transition: all 233ms; } .post-image:hover { filter: blur(.5px); transition: all 89ms; } </style>`).appendTo('head'); } else { $(`#${this.blurImagesStyleId}`).remove(); } } } BlurImages.BLUR_IMAGES_SETTING = 'bakertools-blur-images'; /* globals $, WindowElement, ResearchBread */ /* exported BreadList */ /** * Creates a list of breads for navigation comfyness */ class BreadList extends WindowElement { /** * Construct breadlist object */ constructor() { super('breadlist', 'Bread List'); $('#'+this.id).css('height', '400px'); this.breadListWindowHeaderId = 'bakertools-breadlist-window-header'; this.breadListWindowCloseId = 'bakertools-breadlist-window-close'; this.breadListWindowBody = 'bakertools-breadlist-window-body'; this.breadListTable = 'bakertools-breadlist-table'; this.lastUpdatedId = 'bakertools-breadlist-lastupdated'; this._breads = []; ResearchBread.BOARD_NAME = 'qresearch'; this.breadRegex = /(.+)\s+#(\d+):\s+(.+?$)/; // /\(.+\) #\(\d+\): \(.+?$\)/; this.indexPage = `${window.location.protocol}//${window.location.host}` + `/${ResearchBread.BOARD_NAME}/`; this._createBody(); this._setupStyles(); this.updateBreadList(); this._setupListeners(); } /** * setup table styles */ _setupStyles() { // https://stackoverflow.com/questions/21168521/table-fixed-header-and-scrollable-body $('head').append(` <style id='baketools-breadlist-window-styles'> #${this.id} { right: 380px; width: 380px; } #${this.breadListWindowBody} { overflow-y: auto; height: 365px; font-size: .8em; } #${this.breadListTable} { border-collapse: collapse; border-spacing: 0px; margin: 0; width: 100%; } #${this.breadListTable} thead th { position: sticky; top: 0; } #${this.breadListTable} th, #${this.breadListTable} td { border: 1px solid #000; border-top: 0; padding: 1px 2px 1px 2px; margin: 0; } #${this.breadListTable} thead th { box-shadow: 1px 1px 0 #000; } </style> `); } /** * Create the actual window HTML element */ _createBody() { $('#'+this.id).append(` <div id='${this.breadListWindowBody}'> <table id='${this.breadListTable}'> <thead> <tr> <th>Group</th> <th>No.</th> <th>Bread</th> <th>replies</th> </tr> </thead> <tbody> </tbody> </table> </div> <footer> Last Updated: <span id="${this.lastUpdatedId}"></span> </footer> `); } /** * Setup timeout for updating bread list */ _setupListeners() { window.setInterval(function(e) { this.updateBreadList(); }.bind(this), 1000 * 60 * 2.5); // 2.5min update } /** * Get the list of breads */ updateBreadList() { this.breads = []; const promises = []; for (let page = 0; page < 3; page++) { promises.push( $.getJSON(this.indexPage + `${page}.json`, this.parseIndex.bind(this)), ); } Promise.all(promises).then(function() { this.breads.sort(function(a, b) { if (a.lastModified < b.lastModified) return -1; if (a.lastModified == b.lastModified) return 0; if (a.lastModified > b.lastModified) return 1; }).reverse(); this.populateBreadTable(); }.bind(this)); } /** * Parse index json for breads * @param {Object} index */ parseIndex(index) { if (index && index.threads) { index.threads.forEach(function(thread) { const op = thread.posts[0]; const match = op.sub.match(this.breadRegex); if (match) { const researchGroup = match[1]; const breadNumber = match[2]; const breadName = match[3]; this.breads.push(new Bread( ResearchBread.BOARD_NAME, researchGroup, breadNumber, breadName, op.replies, op.no, op.last_modified, )); } }.bind(this)); // Index foreach } // if index and index.threads } /** * Populate the bread list table */ populateBreadTable() { $(`#${this.breadListTable} tbody`).empty(); this.breads.forEach(function(bread) { this._addBread(bread); }.bind(this)); const lastUpdated = new Date(); $('#'+this.lastUpdatedId).text(lastUpdated.toLocaleString()); } /** * Add bread * @param {Bread} bread */ _addBread(bread) { $(`#${this.breadListTable} tbody`).append(` <tr> <td><a href='${bread.url}'>${bread.researchGroup}</a></td> <td><a href='${bread.url}'>${bread.researchNumber}</a></td> <td><a href='${bread.url}'>${bread.breadName}</a></td> <td><a href='${bread.url}'>${bread.replies}</a></td> </tr> `); } } /** * Represents a research bread */ class Bread { /** * Construct a bread * * @param {string} boardName * @param {string} researchGroup * @param {number} researchNumber * @param {string} breadName * @param {number} replies * @param {number} postId * @param {number} lastModified */ constructor(boardName, researchGroup, researchNumber, breadName, replies, postId, lastModified) { this.boardName = boardName; this.researchGroup = researchGroup; this.researchNumber = researchNumber; this.breadName = breadName; this.replies = replies; this.postId = postId; this.lastModified = lastModified; } /** * Get bread url * * @return {string} url to bread */ get url() { return `${window.location.protocol}//${window.location.host}` + `/${this.boardName}/res/${this.postId}.html`; } } /* global $, EightKun, BakerWindow */ /** * Persistent image blacklist (AKA NOPE BUTTON) */ class ImageBlacklist { /** * Construct ImageBlacklist object */ constructor() { this.blacklist = []; this.styleId = 'bakertools-blacklist-style'; this.postBlacklistButtonClass = 'bakertools-blacklist-post'; this.imgBlacklistButtonClass = 'bakertools-blacklist-image'; this.hidePostBlacklistButtonCheckboxId = 'bakertools-hide-post-blacklist-buttons'; this._setupBakerWindowControls(); this._readSettings(); this._setupStyles(); this._setupListeners(); this.removeBlacklistedImages(); this.addBlacklistButtons(); } /** * Add options to baker window */ _setupBakerWindowControls() { window.bakerTools.mainWindow.addSpamOption(` <div class='${BakerWindow.CONTROL_GROUP_CLASS}'> <label for="${this.hidePostBlacklistButtonCheckboxId}" title="Hide post 'Blacklist' buttons" > Hide "Blacklist" buttons </label> <input type="checkbox" id="${this.hidePostBlacklistButtonCheckboxId}" title="Hide post 'Blacklist' buttons" /><br /> </div> `); } /** * Show or hide the post blacklist buttons * * @param {boolean} hide */ hidePostBlacklistButton(hide) { $('#'+this.hidePostBlacklistButtonCheckboxId).prop('checked', hide); localStorage.setItem(ImageBlacklist.HIDE_POST_BLACKLIST_BUTTON_SETTING, hide); const styleId = 'baker-tools-post-blacklist-button-style'; if (hide) { $('head').append(` <style id='${styleId}'> .${this.postBlacklistButtonClass} { display: none; } `); } else { $(`#${styleId}`).remove(); } } /** * Setup styles for blacklist buttons */ _setupStyles() { $('head').append(` <style id='${this.styleId}'> .${this.imgBlacklistButtonClass} { padding: 0px; background-color: Transparent; background-repeat: no-repeat; border: none; overflow: hidden; outline: none; cursor: pointer; } </style> `); } /** * Read settings from localstorage */ _readSettings() { this.loadBlacklist(); const hideBlacklistButton = JSON.parse( localStorage.getItem(ImageBlacklist.HIDE_POST_BLACKLIST_BUTTON_SETTING), ); this.hidePostBlacklistButton(hideBlacklistButton); } /** * Setup new post event listeners */ _setupListeners() { $(document).on(EightKun.NEW_POST_EVENT, function(e, post) { this.addBlacklistButtonToPost(post); }.bind(this)); $('#'+this.hidePostBlacklistButtonCheckboxId).change(function(e) { this.hidePostBlacklistButton(e.target.checked); }.bind(this)); } /** * Load blacklist from localStorage */ loadBlacklist() { this.blacklist = JSON.parse(localStorage.imageBlacklist || '[]'); } /** * Save blacklist to localStorage */ saveBlacklist() { localStorage.imageBlacklist = JSON.stringify(this.blacklist); } /** * Add MD5 of an image to the blacklist * @param {string} md5 md5 hash of image */ addToBlacklist(md5) { if (md5 && -1 === this.blacklist.indexOf(md5)) { this.blacklist.push(md5); } } /** * Blacklist images in post * @param {Element} post */ blacklistPostImages(post) { $(post).find(ImageBlacklist.POST_IMG_SELECTOR).each(function(i, postImage) { const md5 = postImage.getAttribute('data-md5'); this.addToBlacklist(md5); this.deletePostImage(postImage); }.bind(this)); } /** * Remove blacklist images on page load * @return {number} number of images removed */ removeBlacklistedImages() { let removed = 0; $(ImageBlacklist.POST_IMG_SELECTOR).each(function(i, postImage) { if (-1 !== this.blacklist.indexOf(postImage.getAttribute('data-md5'))) { this.deletePostImage(postImage); removed += 1; } }.bind(this)); return removed; } /** * Add blacklist buttons to post images */ addBlacklistButtons() { $('div.post').each(function(i, post) { this.addBlacklistButtonToPost(post); }.bind(this)); } /** * Add blacklist buttons to post * @param {Element} post div.post */ addBlacklistButtonToPost(post) { const postImageCount = $(post) .find(ImageBlacklist.POST_IMG_SELECTOR).length; if (postImageCount == 0) { return; } const postBlacklistButton = document.createElement('button'); $(postBlacklistButton).addClass(this.postBlacklistButtonClass); $(postBlacklistButton).append(` <i class="fa fa-trash" style="color: crimson;"></i> Blacklist all post images`); $(postBlacklistButton).click(function(e) { e.preventDefault(); $(post).hide(); this.blacklistPostImages(post); this.saveBlacklist(); }.bind(this)); $(post).find(EightKun.POST_MODIFIED_SELECTOR).append(postBlacklistButton); $(post).find(ImageBlacklist.POST_IMG_SELECTOR).each(function(i, img) { const imgBlacklistButton = document.createElement('button'); $(imgBlacklistButton).addClass(this.imgBlacklistButtonClass); $(imgBlacklistButton).append(` <i class="fa fa-trash" style="color: crimson;" title="Blacklist image" ></i>`); $(img) .parents('div.file') .find('.fileinfo') .prepend(imgBlacklistButton); $(imgBlacklistButton).click(function(e) { e.preventDefault(); const md5 = img.getAttribute('data-md5'); this.addToBlacklist(md5); this.deletePostImage(img); this.saveBlacklist(); }.bind(this)); }.bind(this)); } /** * Delete post image * @param {Element} image */ deletePostImage(image) { const imageParent = $(image).parent().parent(); $(imageParent).append(` Image blacklisted ${image.getAttribute('data-md5')} `); $(image).remove(); } } ImageBlacklist.POST_IMG_SELECTOR = 'img.post-image'; ImageBlacklist.HIDE_POST_BLACKLIST_BUTTON_SETTING = 'bakertools-hide-post-blacklist-button'; /* global $, EightKun */ /** * Add notable button to posts that opens quick reply * and populates with a template message */ class NominatePostButtons { /** * Construct NPB object and setup listeners */ constructor() { this.nominateButtonClass = 'bakertools-nominate-button'; this.hidePostNotableButtonCheckboxId = 'bakertools-hide-notables'; this.bakerNotableHeader = '==BAKER NOTABLE==\n'; this.notableReasonPlaceholder = '[REASON FOR NOTABLE HERE]'; $('div.post.reply').each(function(i, post) { this._addButtonToPost(post); }.bind(this)); this._setupBakerWindowControls(); this._setupListeners(); this._readSettings(); } /** * Read settings from localStorage */ _readSettings() { this.showNotableNominationButton(JSON.parse( localStorage.getItem( NominatePostButtons.HIDE_NOMINATE_BUTTON_SETTING), )); } /** * Add options to baker window */ _setupBakerWindowControls() { window.bakerTools.mainWindow.addNotableOption(` <div class='${BakerWindow.CONTROL_GROUP_CLASS}'> <label for="${this.hidePostNotableButtonCheckboxId}" title="Hide post 'Notable' buttons" > Hide "Notable" buttons </label> <input type="checkbox" id="${this.hidePostNotableButtonCheckboxId}" title="Hide post 'Notable' buttons" /><br /> </div> `); } /** * Setup event listeners */ _setupListeners() { $(document).on(EightKun.NEW_POST_EVENT, function(e, post) { this._addButtonToPost(post); }.bind(this)); $('#'+this.hidePostNotableButtonCheckboxId).change(function(e) { this.showNotableNominationButton(e.target.checked); }.bind(this)); } /** * Show or hide the notable nomination buttons * * @param {boolean} showNotableNominationButton */ showNotableNominationButton(showNotableNominationButton) { $('#'+this.hidePostNotableButtonCheckboxId).prop('checked', showNotableNominationButton); localStorage.setItem(NominatePostButtons.HIDE_NOMINATE_BUTTON_SETTING, showNotableNominationButton); const styleId = 'baker-tools-notable-button-style'; if (showNotableNominationButton) { $('head').append(` <style id='${styleId}'> .${this.nominateButtonClass} { display: none; } `); } else { $(`#${styleId}`).remove(); } } /** * Add button to the provided post * @param {Element} post */ _addButtonToPost(post) { const button = document.createElement('button'); $(button).addClass(this.nominateButtonClass); $(button).append(` <i class="fa fa-star" style="color: goldenrod;"></i> Notable`); $(button).click(function(e) { const postNumber = $(post) .find('.intro .post_no') .text() .replace('No.', ''); const href = $(post) .find('.intro .post_no') .get(0).href; // 8kun core - adds >>postnumber to- and unhides quickreply window.citeReply(postNumber, href); const quickReplyBody = $('#quick-reply #body'); const oldText = quickReplyBody.val(); quickReplyBody.val(oldText + this.bakerNotableHeader + this.notableReasonPlaceholder); // Don't ask me why i have to do this, ask CodeMonkeyZ // Not sure why citeReply which calls cite needs to set a timeout to // replace the body of the quickreply with itself. We need to combat // that here // setTimeout(function() { // var tmp = $('#quick-reply textarea[name="body"]').val(); // $('#quick-reply textarea[name="body"]').val('').focus().val(tmp); // }, 1); // $(window).on('cite', function(e, id, with_link) { // TODO: Figure this out const self = this; setTimeout(function() { quickReplyBody.select(); quickReplyBody.prop('selectionStart', oldText.length + self.bakerNotableHeader.length); }, 1.2); }.bind(this)); $(post).find(EightKun.POST_MODIFIED_SELECTOR).append(button); } } NominatePostButtons.HIDE_NOMINATE_BUTTON_SETTING = 'bakertools-hide-nominate-button'; /* global $, EightKun, ResearchBread, NotablePost, NavigationControl, ColorPicker, POST_BACKGROUND_CHANGE_EVENT */ /** * Makes notable posts easier to see by highlighting posts that anons nominate * as notable. * * If someone replies to a post and their post contains the word 'notable', * the replied to post will be considered notable. * * Both the notable post and the nominator posts will be highlighted, as well * as the nominator link in the notable's mentions will be highlighted. */ class NotableHighlighter { /** * Construct notablehighlighter object, find and highlight * current notable sand setup listeners */ constructor() { this.styleId = 'bakertools-notable-style'; this.NOMINATING_REGEX = /notable/i; this.showOnlyNotablesCheckboxId = 'bakertools-show-only-notable'; this.createNotablePostButtonId = 'bakertools-create-notable-post'; this.notableEditorId = 'bakertools-notable-editor'; this.showNotableNavigationInBoardListId = 'bakertools-show-notable-nav-in-boardlist'; this._createStyles(); this._setupBakerWindowControls(); this._readSettings(); this.findNominatedNotables(); this._setupListeners(); } /** * Read settings from local storage */ _readSettings() { this.setOnlyShowNotables(JSON.parse( localStorage.getItem( NotableHighlighter.ONLY_SHOW_NOTABLES_SETTING), )); this.showNotableNavigationInBoardList(JSON.parse( localStorage .getItem(NotableHighlighter.SHOW_NOTABLE_NAV_IN_BOARDLIST_SETTING), )); } /** * Create styles that determine how notables are highlighted */ _createStyles() { const nominatorSelector = `${EightKun.POST_REPLY_SELECTOR}.${NotableHighlighter.NOMINATOR_CLASS}`; const notableSelector = `.thread ${EightKun.POST_REPLY_SELECTOR}` + `.${NotableHighlighter.NOTABLE_CLASS}`; $('head').append(` <style id='${this.styleId}'> ${notableSelector} { background-color: ${this.notableColor}; } /* less specificity than notable so it has less preference */ ${nominatorSelector} { background-color: ${this.nominatorColor}; } div.post.reply .mentioned .${NotableHighlighter.NOMINATOR_CLASS} { color: ${this.nominatorMentionLinkColor}; font-weight: bold; font-size: 1.5em; } ${notableSelector}.highlighted { background: #d6bad0; } ${nominatorSelector}.highlighted { background: #d6bad0; } </style> `); } /** * Add controls to the bakerwindow */ _setupBakerWindowControls() { const notablePostsTitle = `Only show, notables, nominators, q, q replied posts`; const notableColorPicker = new ColorPicker( 'Notable Post Color', 'Set background color of notable Posts', NotableHighlighter.NOTABLE_COLOR_SETTTING, NotableHighlighter.DEFAULT_NOTABLE_COLOR, (color) => this.notableColor = color, ); const nominatorColorPicker = new ColorPicker( 'Nominator Color', 'Set background color of nominator posts', NotableHighlighter.NOMINATOR_COLOR_SETTTING, NotableHighlighter.DEFAULT_NOMINATOR_COLOR, (color) => this.nominatorColor = color, ); const nominatorMentionLinkColorPicker = new ColorPicker( 'Nominator Mention Link Color', 'Set color of nominator mention links', NotableHighlighter.NOMINATOR_MENTION_LINK_COLOR_SETTTING, NotableHighlighter.DEFAULT_NOMINATOR_MENTION_LINK_COLOR, (color) => this.nominatorMentionLinkColor = color, ); window.bakerTools.mainWindow.addColorOption(notableColorPicker.element); window.bakerTools.mainWindow.addColorOption(nominatorColorPicker.element); window.bakerTools.mainWindow .addColorOption(nominatorMentionLinkColorPicker.element); window.bakerTools.mainWindow.addNotableOption(` <div class='${BakerWindow.CONTROL_GROUP_CLASS}'> <label for="${this.showOnlyNotablesCheckboxId}" title="${notablePostsTitle}" > Only Show Notable/Nomination Posts: </label> <input type="checkbox" id="${this.showOnlyNotablesCheckboxId}" title="${notablePostsTitle}" /> </div> `); window.bakerTools.mainWindow.addBaker(` <button type="button" id="${this.createNotablePostButtonId}" title="Create notables list post based on current nominated notables" > Create Notable Post </button> <textarea id="${this.notableEditorId}"></textarea> `); window.bakerTools.mainWindow .addNavigationOption(` <div class='${BakerWindow.CONTROL_GROUP_CLASS}'> <label for="${this.showNotableNavigationInBoardListId}" title="Show navigation controls in board list bar" > Show Notable Nav in Board List: </label> <input type="checkbox" id="${this.showNotableNavigationInBoardListId}" title="Show navigation controls in board list bar" /><br /> </div> `); this.navigation = new NavigationControl('Notables', () => NotablePost.getNotablesAsPosts(), NotablePost.NEW_NOTABLE_POST_EVENT); window.bakerTools.mainWindow .addNavigation(this.navigation.element); this.boardListNav = new NavigationControl('Notables', () => NotablePost.getNotablesAsPosts(), NotablePost.NEW_NOTABLE_POST_EVENT); $(EightKun.getTopBoardlist()).append(this.boardListNav.element); $(this.boardListNav.element).hide(); } /** * Set the background color of notable posts * @param {string} color A valid css color value. * E.G. ('#ff00ee', 'rgba()' or 'blue') */ set notableColor(color) { this._notableColor = color; document.getElementById(this.styleId) .sheet.cssRules[0].style.background = color; $(document).trigger(POST_BACKGROUND_CHANGE_EVENT, color); } /** * Get color for notable post backgrounds */ get notableColor() { return this._notableColor; } /** * Set the background color of nominator posts * @param {string} color A valid css color value. * E.G. ('#ff00ee', 'rgba()' or 'blue') */ set nominatorColor(color) { this._nominatorColor = color; document.getElementById(this.styleId) .sheet.cssRules[1].style.background = color; $(document).trigger(POST_BACKGROUND_CHANGE_EVENT, color); } /** * Get color for notable post backgrounds */ get nominatorColor() { return this._nominatorColor; } /** * Set the color of nominator mention links posts * @param {string} color A valid css color value. * E.G. ('#ff00ee', 'rgba()' or 'blue') */ set nominatorMentionLinkColor(color) { this._nominatorMentionLinkColor = color; document.getElementById(this.styleId) .sheet.cssRules[2].style.color = color; } /** * Get color for notable post backgrounds */ get nominatorMentionLinkColor() { return this._nominatorMentionLinkColor; } /** * Setup listeners for new posts, bakerwindow controls, etc */ _setupListeners() { $('#'+this.showOnlyNotablesCheckboxId).change(function(e) { this.setOnlyShowNotables(e.target.checked); }.bind(this)); $('#'+this.createNotablePostButtonId).click(function() { if ($('#'+this.notableEditorId).val()) { if (!confirm(`If you continue, any changes you made will be overwritten!`)) { return; } } $('#'+this.notableEditorId).val(this.createNotablesPost()); }.bind(this)); $(document).on(EightKun.NEW_POST_EVENT, function(e, post) { this.checkNewPostsForNotables(post); }.bind(this)); $('#'+this.showNotableNavigationInBoardListId).change(function(e) { this.showNotableNavigationInBoardList(e.target.checked); }.bind(this)); } /** * Show or hide notable nav control in the boardlist * * @param {boolean} show */ showNotableNavigationInBoardList(show) { $('#'+this.showNotableNavigationInBoardListId).prop('checked', show); localStorage .setItem(NotableHighlighter.SHOW_NOTABLE_NAV_IN_BOARDLIST_SETTING, show); if (show) { $(this.boardListNav.element).show(); } else { $(this.boardListNav.element).hide(); } } /** * Create the notables post for review * @return {string} Returns the notable post string */ createNotablesPost() { const notables = NotablePost.getNotables(); const breadNumber = ResearchBread.getBreadNumber(); let post = `'''#${breadNumber}'''\n\n`; notables.forEach(function(notable) { post += `${notable.shortLink()} ${notable.description}\n\n`; }); return post; } /** * Checks a post for notable nominations * @param {Element} post */ checkNewPostsForNotables(post) { $(post).removeAttr('style'); // TODO: try removing if (this.isNominatingPost(post)) { NotablePost.fromNominatingPost(post); } } /** * Finds posts that are being tagged as notable. * * I.E. Finding any post that has been replied to by a post with the string * "notable" in it. Maybe at somepoint this can be smarter. Q give me some * dwave snow white tech! * * Highlights notable posts in yellow * Highlights nominating posts in pink <3 * Highlights nominating posts in mentions * Add nominee count to post * @return {Array<NotablePost>} */ findNominatedNotables() { const postsWithoutDough = ResearchBread.getPostsWithoutDough(); // ^s to ignore notables review posts const nominatingPosts = postsWithoutDough .filter((post) => this.isNominatingPost(post)); nominatingPosts.forEach(function(nominatingPost) { NotablePost.fromNominatingPost(nominatingPost); }); console.log(NotablePost.getNotables()); return NotablePost.getNotables(); } /** * Is the post nominating a notable * @arg {Element} post .post * @return {boolean} True if post nominates a notable */ isNominatingPost(post) { const postContainsNotable = post.textContent .search(this.NOMINATING_REGEX) != -1; const postIsReplying = EightKun.getReplyLinksFromPost(post).length; return postContainsNotable && postIsReplying; } /** * Toggle whether only the notable/nominee posts are shown or not * @arg {boolean} onlyShowNotables boolean If true, only show * notables/nominators, else show all */ setOnlyShowNotables(onlyShowNotables) { $('#'+this.showOnlyNotablesCheckboxId).prop('checked', onlyShowNotables); localStorage.setItem(NotableHighlighter.ONLY_SHOW_NOTABLES_SETTING, onlyShowNotables); const notableOrNominationPostsSelector = `div.post.${NotableHighlighter.NOTABLE_CLASS}, div.post.${NotableHighlighter.NOMINATOR_CLASS}`; const notableOrNominationPostBreaksSelector = `div.post.${NotableHighlighter.NOTABLE_CLASS}+br, div.post.${NotableHighlighter.NOMINATOR_CLASS}+br`; const onlyShowNotablesStyleId = 'bakertools-only-show-notables'; if (onlyShowNotables) { $(`<style id='${onlyShowNotablesStyleId}' type='text/css'> div.reply:not(.post-hover), div.post+br { display: none !important; visibility: hidden !important; } ${notableOrNominationPostsSelector}, ${notableOrNominationPostBreaksSelector} { display: inline-block !important; visibility: visible !important; } </style>`).appendTo('head'); } else { $(`#${onlyShowNotablesStyleId}`).remove(); // For whatever reason, when the non notable posts are filtered and new // posts come through the auto_update, the posts are created with // style="display:block" which messes up display. Remove style attr // TODO: can we remove this now that we have !important? $(EightKun.POST_SELECTOR).removeAttr('style'); } } /** * Retrieves only show notable ssetting from localStorage * @return {boolean} true if only show notables is turned on */ getOnlyShowNotables() { return localStorage .getItem(NotableHighlighter.ONLY_SHOW_NOTABLES_SETTING); } } NotableHighlighter.NOMINATOR_CLASS = 'bakertools-notable-nominator'; NotableHighlighter.NOTABLE_CLASS = 'bakertools-notable'; NotableHighlighter.ONLY_SHOW_NOTABLES_SETTING = 'bakertools-only-show-notables'; NotableHighlighter.SHOW_NOTABLE_NAV_IN_BOARDLIST_SETTING = 'bakertools-show-notable-nav-in-boardlist'; NotableHighlighter.NOMINATOR_COLOR_SETTTING = 'bakertools-nominator-color'; NotableHighlighter.NOTABLE_COLOR_SETTTING = 'bakertools-notable-color'; NotableHighlighter.NOMINATOR_MENTION_LINK_COLOR_SETTTING = 'bakertools-nominator-metion-link-color'; NotableHighlighter.DEFAULT_NOTABLE_COLOR = '#E5FFCC'; NotableHighlighter.DEFAULT_NOMINATOR_COLOR = '#ACC395'; NotableHighlighter.DEFAULT_NOMINATOR_MENTION_LINK_COLOR = '#00CC00'; /* globals $, EightKun, debounce, BakerWindow */ /* exported PostRateChart */ /** * Displays chart of post/min */ class PostRateChart { /** * Construct a postrate chart */ constructor() { this.containerClass = 'bakertools-postrate-container'; this.chartClass = 'bakertools-postrate-chart'; this.rateClass = 'bakertools-postrate-rate'; this.ovenTimer = 'bakertools-postrate-timer'; this.styleId = 'bakertools-postrate-style'; this.hidePostRateChartId = 'bakertools-postrate-hide-postrate'; this.numberOfPostsForAverage = 10; this.bakeAtPosts = 700; this.numberOfDataPointsShownOnChart = 10; this.postTimes = []; this.postsPerMinuteHistory = []; this._setupStyles(); this._setupBakerWindowControls(); this._createElement(); this._getExistingPostRates(); this._setupListeners(); this.draw(); this.draw = debounce(this.draw, 1000 *2); this._readSettings(); } /** * Read settings from local storage */ _readSettings() { const hidePostRate = JSON.parse(localStorage .getItem(PostRateChart.HIDE_POSTRATE_SETTING)); this.showPostRateChart(!hidePostRate); } /** * Setup chart styles */ _setupStyles() { $('head').append(` <style id='${this.styleId}'> .${this.containerClass} { height: 20px; padding: 0; color: rgb(20, 137, 183); } .${this.chartClass} { border: 1px solid; vertical-align: middle; padding: 1px; } ${EightKun.BOARDLIST_SELECTOR} .${this.containerClass}:before { content: '['; color: #89A; } ${EightKun.BOARDLIST_SELECTOR} .${this.containerClass}:after { content: ']'; color: #89A; } `); } /** * Add controls to the bakerwindow */ _setupBakerWindowControls() { window.bakerTools.mainWindow.addOption(` <div class='${BakerWindow.CONTROL_GROUP_CLASS}'> <label for="${this.hidePostRateChartId}" title="Hide the postrate chart in boardlist" > Hide Post/Min Chart: </label> <input type="checkbox" id="${this.hidePostRateChartId}" title="Hide the postrate chart in boardlist" /> </div> `); } /** * Setup listener to record post times */ _setupListeners() { $(document).on(EightKun.NEW_POST_EVENT, function(idx, post) { this._addDataPointFromPost(post); this.draw(); }.bind(this)); $('#'+this.hidePostRateChartId).change(function(e) { this.showPostRateChart(!e.target.checked); }.bind(this)); } /** * Show or hide post rate chart in the boardlist * * @param {boolean} show */ showPostRateChart(show) { $('#'+this.hidePostRateChartId).prop('checked', !show); localStorage .setItem(PostRateChart.HIDE_POSTRATE_SETTING, !show); if (show) { $(this.element).show(); } else { $(this.element).hide(); } } /** * Create the canvas element */ _createElement() { this.element = $(` <span class='${this.containerClass}' title = 'Posts Per Minute'> <span class='${this.rateClass}'>0</span> ppm <canvas class='${this.chartClass}'></canvas> <span class='${this.ovenTimer}'>0</span> mins </span>`, ).get(0); $(EightKun.getTopBoardlist()).append(this.element); this.canvas = $(this.element).find('canvas').get(0); this.canvas.height = 10; this.canvas.width = 100; } /** * Collect post rate data on posts at page load */ _getExistingPostRates() { $('div.post').each(function(idx, post) { this._addDataPointFromPost(post); }.bind(this)); } /** * Add a data point (aka the time a post was made) * @param {Element} post div.post */ _addDataPointFromPost(post) { this.postTimes.push(EightKun.getPostTime(post)); if (this._isEnoughDataToAverage()) { this._recordPostPerMinute(); } } /** * Return true if theres enough data to perform averaging * @return {boolean} true if enough data */ _isEnoughDataToAverage() { return this.postTimes.length > (this.numberOfPostsForAverage + 1); } /** * Record post per minute with the current set of post times * Calc is done with the last ${this.numberOfPostsForAverage} post times */ _recordPostPerMinute() { const startPostIndex = this.postTimes.length - this.numberOfPostsForAverage - 1; const endPostIndex = this.postTimes.length - 1; const startPostTime = this.postTimes[startPostIndex]; const endPostTime = this.postTimes[endPostIndex]; const postsPerMinute = this.numberOfPostsForAverage / ((endPostTime - startPostTime) / 60); this.postsPerMinuteHistory.push(postsPerMinute); } /** * Draw the post rate chart */ draw() { if (!this.postsPerMinuteHistory.length) { return; } const canvas = this.canvas; const ctx = canvas.getContext('2d'); this._setPostRateText(); const normalizedPostPerMinutes = this._normalizePostPerMinutes(); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.strokeStyle = $(`${EightKun.BOARDLIST_SELECTOR} a`).css('color'); ctx.beginPath(); let x = 0; let y = canvas.height * normalizedPostPerMinutes[0]; ctx.moveTo(x, y); normalizedPostPerMinutes.slice(1).forEach(function(ppm, i) { x = (i+1) * (canvas.width / this.numberOfDataPointsShownOnChart); y = canvas.height * ppm; ctx.lineTo(x, y); }.bind(this)); ctx.stroke(); ctx.closePath(); } /** * Set the text label of current PPM */ _setPostRateText() { const lastIndex = this.postsPerMinuteHistory.length-1; const currentPPM = this.postsPerMinuteHistory[lastIndex].toFixed(2); $(`.${this.rateClass}`).text(currentPPM); const totalPosts = this.postTimes.length - 1; const timeToBake = ((this.bakeAtPosts - totalPosts) / currentPPM).toFixed(2); $(`.${this.ovenTimer}`).text(timeToBake); } /** * Normalize the data points to be within 0-1 range. * Slice the array to only contain the currently drawn slice * @return {Array} */ _normalizePostPerMinutes() { const slicedArray = this.postsPerMinuteHistory.slice(-this.numberOfDataPointsShownOnChart); const maxPPM = Math.max(...slicedArray); const minPPM = Math.min(...slicedArray); const range = maxPPM - minPPM; return slicedArray.map(function(ppm) { return (ppm - minPPM) / range; }); } } PostRateChart.HIDE_POSTRATE_SETTING = 'bakertools-hide-postrate-chart'; /* global $, EightKun, ColorPicker */ /* exported PreviousBreadHighlighter */ /** * Highlights previous bread post links */ class PreviousBreadHighlighter { /** * Construct pb highlighter object, setup listeners */ constructor() { this.styleId = 'bakertools-previous-bread-styles'; this.previousBreadClass = 'bakertools-PreviousBread'; this.newerBreadClass = 'bakertools-NewBread'; this._linkSelector = 'div.body > p.body-line.ltr > a'; this._setupStyles(); this._setupBakerWindowControls(); const links = $(this._linkSelector).filter('[onClick]'); links.each(function(index, link) { this.markLinkIfPreviousBread(link); }.bind(this)); this._setupListeners(); } /** * Setup color picker controls */ _setupBakerWindowControls() { const colorPicker = new ColorPicker( 'Previous Bread Link Color', 'Set the color of links to previous breads', PreviousBreadHighlighter.PREVIOUS_BREAD_LINK_COLOR_SETTING, PreviousBreadHighlighter.DEFAULT_PREVIOUS_BREAD_LINK_COLOR, (color) => this.previousBreadLinkColor = color, ); window.bakerTools.mainWindow.addColorOption(colorPicker.element); const newerBreadColorPicker = new ColorPicker( 'Newer Bread Link Color', 'Set the color of links to newer breads', PreviousBreadHighlighter.NEWER_BREAD_LINK_COLOR_SETTING, PreviousBreadHighlighter.DEFAULT_NEWER_BREAD_LINK_COLOR, (color) => this.newerBreadLinkColor = color, ); window.bakerTools.mainWindow.addColorOption(newerBreadColorPicker.element); } /** * Set the color of pb links * @param {string} color A valid css color value. * E.G. ('#ff00ee', 'rgba()' or 'blue') */ set previousBreadLinkColor(color) { this._previousBreadLinkColor = color; document.getElementById(this.styleId) .sheet.cssRules[0].style.color = color; } /** * Get color of pb links */ get previousBreadLinkColor() { return this._previousBreadLinkColor; } /** * Set the color of nb links * @param {string} color A valid css color value. * E.G. ('#ff00ee', 'rgba()' or 'blue') */ set newerBreadLinkColor(color) { this._newerBreadLinkColor = color; document.getElementById(this.styleId) .sheet.cssRules[2].style.color = color; } /** * Get color of nb links */ get newerBreadLinkColor() { return this._newerBreadLinkColor; } /** * Setup styles for pb links */ _setupStyles() { $('head').append(` <style id='${this.styleId}'> ${EightKun.POST_REPLY_SELECTOR} div.body a.${this.previousBreadClass} { color: ${this.previousBreadLinkColor}; } a.${this.previousBreadClass}::after { content: " (pb)"; } ${EightKun.POST_REPLY_SELECTOR} div.body a.${this.newerBreadClass} { color: ${this.newerBreadLinkColor}; } a.${this.newerBreadClass}::after { content: " (nb)"; } </style> `); } /** * Setup listeners for pb highlighting */ _setupListeners() { $(document).on(EightKun.NEW_POST_EVENT, function(e, post) { $(post).find(this._linkSelector) .each((index, link) => this.markLinkIfPreviousBread(link)); }.bind(this)); } /** * Marks the link if it is pb * * @param {Anchor} link */ markLinkIfPreviousBread(link) { const currentBreadNumber = document.location.pathname .split('/') .slice(-1)[0] .split('.')[0]; const linkBreadNumber = link.href.split('/') .slice(-1)[0] .split('#')[0] .split('.')[0]; const isAReplyLink = $(link) .attr('onclick') .search(EightKun.REPLY_REGEX) != 1; if (isAReplyLink && parseInt(currentBreadNumber, 10) > parseInt(linkBreadNumber, 10)) { $(link).addClass(this.previousBreadClass); } else if (isAReplyLink && parseInt(currentBreadNumber, 10) < parseInt(linkBreadNumber, 10)) { $(link).addClass(this.newerBreadClass); } } } PreviousBreadHighlighter.PREVIOUS_BREAD_LINK_COLOR_SETTING = 'bakertools-previous-bread-link-color'; PreviousBreadHighlighter.DEFAULT_PREVIOUS_BREAD_LINK_COLOR = '#0000CC'; PreviousBreadHighlighter.NEWER_BREAD_LINK_COLOR_SETTING = 'bakertools-newer-bread-link-color'; PreviousBreadHighlighter.DEFAULT_NEWER_BREAD_LINK_COLOR = '#00CC00'; /* global $, EightKun, ResearchBread, NavigationControl, ColorPicker, POST_BACKGROUND_CHANGE_EVENT, BakerWindow */ /** * Highlight Q posts, replies to q, q replies. * Adds navigation to baker window */ class QPostHighlighter { /** * Construct qposthighlighter object and setup listeners */ constructor() { this.styleId = 'bakertools-q-style'; this.qPostClass = 'bakertools-q-post'; this.qReplyClass = 'bakertools-q-reply'; this.qMentionClass = 'bakertools-q-mention'; this.qLinkClass = 'bakertools-q-link'; this.styleId = 'bakertools-q-styles'; this._linkSelector = 'div.body > p.body-line.ltr > a'; this.showQNavigationInBoardListId = 'bakertools-show-q-nav-in-boardlist'; this.currentQTripCode = null; this._setupStyles(); this._setupBakerWindowControls(); this._readSettings(); this._findQPosts(); this._setupListeners(); } /** * Read settings from localStorage */ _readSettings() { this.showQNavigationInBoardList(JSON.parse( localStorage .getItem(QPostHighlighter.SHOW_Q_NAV_IN_BOARDLIST_SETTING), )); } /** * Setup styles for highlighting q posts */ _setupStyles() { $('head').append(` <style id='${this.styleId}'> ${EightKun.POST_REPLY_SELECTOR}.${this.qPostClass} { background: ${this.qPostColor}; display: inline-block !important; visibility: visible !important; } ${EightKun.POST_REPLY_SELECTOR}.${this.qReplyClass} { background: ${this.qYouColor}; display: inline-block !important; visibility: visible !important; } ${EightKun.POST_REPLY_SELECTOR}.${this.qPostClass}.highlighted { background: #d6bad0; } ${EightKun.POST_REPLY_SELECTOR} .intro .${this.qMentionClass}, .${this.qLinkClass} { padding:1px 3px 1px 3px; background-color:black; border-radius:8px; border:1px solid #bbbbee; color:gold; background: linear-gradient(300deg, #ff0000, #ff0000, #ff0000, #bbbbbb, #4444ff); background-size: 800% 800%; -webkit-animation: Patriot 5s ease infinite; -moz-animation: Patriot 5s ease infinite; -o-animation: Patriot 5s ease infinite; animation: Patriot 5s ease infinite; -webkit-text-fill-color: transparent; background: -o-linear-gradient(transparent, transparent); -webkit-background-clip: text; } </style> `); } /** * Set the background color of q posts * @param {string} color A valid css color value. * E.G. ('#ff00ee', 'rgba()' or 'blue') */ set qPostColor(color) { this._qPostColor = color; document.getElementById(this.styleId) .sheet.cssRules[0].style.background = color; $(document).trigger(POST_BACKGROUND_CHANGE_EVENT, color); } /** * Get color for q post backgrounds */ get qPostColor() { return this._qPostColor; } /** * Set the background color of q posts * @param {string} color A valid css color value. * E.G. ('#ff00ee', 'rgba()' or 'blue') */ set qYouColor(color) { this._qYouColor = color; document.getElementById(this.styleId) .sheet.cssRules[1].style.background = color; $(document).trigger(POST_BACKGROUND_CHANGE_EVENT, color); } /** * Get bg color for posts q replies to */ get qYouColor() { return this._qYouColor; } /** * Get Q's current trip code from the bread */ _getCurrentQTripFromBread() { const tripCodeMatch = $(EightKun.getOpPost()) .text() .match(/Q's Trip-code: Q (.+?\s)/); if (!tripCodeMatch) { console.error('Could not find Q\'s tripcode'); return; } this.currentQTripCode = tripCodeMatch[1].split(' ')[0]; } /** * Find current Q posts in bread */ _findQPosts() { const posts = ResearchBread.getPostsWithoutDough(); $(posts).each(function(i, post) { this._doItQ(post); }.bind(this)); } /** * Check if the post is Q * WWG1WGA * * @param {Element} post a div.post */ _doItQ(post) { if (this._markIfQPost(post)) { // Q Post, lets check for q replies const qPostNumber = $(post) .find('.intro .post_no') .text() .replace('No.', ''); const links = $(post) .find(this._linkSelector) .filter('[onClick]'); $(links).each(function(i, link) { const postNumber = link.href.split('#')[1]; // Enlightened post $(`#reply_${postNumber}`).addClass(this.qReplyClass); const metionLinkSelector = `#reply_${postNumber} .intro .mentioned a`; $(metionLinkSelector).each(function(i, mentionAnchor) { const mentionPostNumber = $(mentionAnchor).text().replace('>>', ''); if (mentionPostNumber == qPostNumber) { $(mentionAnchor).addClass(this.qMentionClass); } }.bind(this)); }.bind(this)); } else { // Not Q, but lets check if this post replies to Q const links = $(post).find(this._linkSelector).filter('[onClick]'); $(links).each(function(i, link) { const postNumber = link.href.split('#')[1]; const replyPost = document.querySelector(`#reply_${postNumber}`); // TODO: need to handle pb posts if (this.isQ(replyPost)) { $(link).addClass(this.qLinkClass); } }.bind(this)); } } /** * @arg {Element} post div.post.reply * @return {boolean} true if it is a q post */ _markIfQPost(post) { let isQ = false; if (this.isQ(post)) { isQ = true; $(post).addClass(this.qPostClass); QPostHighlighter.qPosts.push(post); $(document).trigger(QPostHighlighter.NEW_Q_POST_EVENT, post); } return isQ; } /** * Is the post Q? * @param {Element} post a div.post.reply * @return {boolean} true if the post is Q */ isQ(post) { const qTripHistory = QTripCodeHistory.INSTANCE; const dateOfPost = new Date(EightKun.getPostDateTime(post)); const expectedQTripBasedOnDate = qTripHistory.getTripCodeByDate(dateOfPost); if (!expectedQTripBasedOnDate) { console.info(`Could not find Q trip code for date: ${dateOfPost}`); return false; } return EightKun.getPostTrip(post) == expectedQTripBasedOnDate.tripCode; } /** * Add Q post navigation to bakerwindow */ _setupBakerWindowControls() { window.bakerTools.mainWindow .addNavigationOption(` <div class='${BakerWindow.CONTROL_GROUP_CLASS}'> <label for="${this.showQNavigationInBoardListId}" title="Show navigation controls in board list bar" > Show Q Nav in Board List: </label> <input type="checkbox" id="${this.showQNavigationInBoardListId}" title="Show navigation controls in board list bar" /><br /> </div> `); this.navigation = new NavigationControl('Q Posts', () => QPostHighlighter.qPosts, QPostHighlighter.NEW_Q_POST_EVENT); window.bakerTools.mainWindow .addNavigation(this.navigation.element); this.boardListNav = new NavigationControl('Q', () => QPostHighlighter.qPosts, QPostHighlighter.NEW_Q_POST_EVENT); $(EightKun.getTopBoardlist()).append(this.boardListNav.element); $(this.boardListNav.element).hide(); const qColorPicker = new ColorPicker( 'Q Post Color', 'Set background color of Q Posts', QPostHighlighter.Q_POST_COLOR_SETTING, QPostHighlighter.DEFAULT_Q_POST_COLOR, (color) => this.qPostColor = color, ); const qYouColorPicker = new ColorPicker( 'Q (You) Color', 'Set background color of posts Q Replies to', QPostHighlighter.Q_YOU_POST_COLOR_SETTING, QPostHighlighter.DEFAULT_Q_YOU_POST_COLOR, (color) => this.qYouColor = color, ); window.bakerTools.mainWindow.addColorOption(qColorPicker.element); window.bakerTools.mainWindow.addColorOption(qYouColorPicker.element); } /** * Setup listeners for new posts */ _setupListeners() { $(document).on(EightKun.NEW_POST_EVENT, function(e, post) { this._doItQ(post); }.bind(this)); $('#'+this.showQNavigationInBoardListId).change(function(e) { this.showQNavigationInBoardList(e.target.checked); }.bind(this)); } /** * Show or hide q nav control in the boardlist * * @param {boolean} showNavInBoardList */ showQNavigationInBoardList(showNavInBoardList) { $('#'+this.showQNavigationInBoardListId).prop('checked', showNavInBoardList); localStorage.setItem(QPostHighlighter.SHOW_Q_NAV_IN_BOARDLIST_SETTING, showNavInBoardList); if (showNavInBoardList) { $(this.boardListNav.element).show(); } else { $(this.boardListNav.element).hide(); } } } QPostHighlighter.qPosts = []; QPostHighlighter.NEW_Q_POST_EVENT = 'bakertools-new-q-post'; QPostHighlighter.SHOW_Q_NAV_IN_BOARDLIST_SETTING = 'bakertools-show-q-nav-in-boardlist'; QPostHighlighter.Q_YOU_COLOR_SETTING = 'bakertools-q-you-color'; QPostHighlighter.Q_POST_COLOR_SETTING = 'bakertools-q-post-color'; QPostHighlighter.DEFAULT_Q_POST_COLOR = '#FFFFCC'; QPostHighlighter.DEFAULT_Q_YOU_POST_COLOR = '#DDDDDD'; /** * History of Q's tripcodes and their date ranges */ class QTripCodeHistory { /** * Construct the q trip history */ constructor() { // Hat tip to https://8kun.top/qresearch/res/7762733.html#7832643 for Q trip history this.history = [ new QTripCode('!ITPb.qbhqo', new Date('2017-11-10 04:07:15Z'), new Date('2017-12-15 06:04:43Z')), new QTripCode('!UW.yye1fxo', new Date('2017-12-15 06:04:06Z'), new Date('2018-03-24 13:09:02Z')), new QTripCode('!xowAT4Z3VQ', new Date('2018-03-24 13:09:37Z'), new Date('2018-05-04 20:02:22Z')), new QTripCode('!2jsTvXXmXs', new Date('2018-05-04 20:01:19Z'), new Date('2018-05-08 23:46:39Z')), new QTripCode('!4pRcUA0lBE', new Date('2018-05-08 23:47:17Z'), new Date('2018-05-19 22:06:20Z')), new QTripCode('!CbboFOtcZs', new Date('2018-05-19 22:07:06Z'), new Date('2018-08-05 20:12:52Z')), new QTripCode('!A6yxsPKia.', new Date('2018-08-05 20:14:24Z'), new Date('2018-08-10 18:24:24Z')), new QTripCode('!!mG7VJxZNCI', new Date('2018-08-10 18:26:08Z'), new Date('2019-11-25 22:35:45Z')), new QTripCode('!!Hs1Jq13jV6', new Date('2019-12-02 17:55:59Z'), null), ]; } /** * Get Q Tripcode by the provided date * @param {Date} date * @return {QTripCode} */ getTripCodeByDate(date) { let returnTripCode = null; for (const tripCode of this.history) { if (tripCode.isValidForDate(date)) { returnTripCode = tripCode; break; } } return returnTripCode; } /** * Get Q Tripcode by the current * @return {QTripCode} */ getCurrentTripCode() { return this.getTripCodeByDate(new Date()); } } /** * Represents a Tripcode used by Q and the timeframe */ class QTripCode { /** * Create a new QTripCode * @param {string} tripCode * @param {DateTime} startDate * @param {DateTime} endDate */ constructor(tripCode, startDate, endDate) { this.tripCode = tripCode; this.startDate = startDate; this.isCurrentTrip = false; if (!endDate) { this.isCurrentTrip = true; } this.endDate = endDate; } /** * Is this tripcode valid for the provided date? * @param {Date} date * @return {boolean} true if this trip code is valid for the date */ isValidForDate(date) { const dateIsOnOrAfterTripStart = date >= this.startDate; const dateIsOnOrBeforeTripEnd = date <= this.endDate; return dateIsOnOrAfterTripStart && (this.isCurrentTrip || dateIsOnOrBeforeTripEnd); } } QTripCodeHistory.INSTANCE = new QTripCodeHistory(); /* globals $, EightKun, ResearchBread, BakerWindow */ /* exported SpamFader, NameFagStrategy, HighPostCountFagStrategy, * FloodFagStrategy, BreadShitterFagStrategy */ /** * Fade posts that post too fast */ class SpamFader { /** * Construct spamfader * @param {Array} spamDetectionStrategies An array of SpamDetectionStrategy's */ constructor(spamDetectionStrategies) { this.spamDetectionStrategies = spamDetectionStrategies; this.styleId = 'bakertools-spamfader-style'; this.spamClass = 'bakertools-spamfader-spam'; this.disableSpamFaderId = 'bakertools-spamfader-disable'; this.hideSpamBadgesId = 'bakertools-spamfader-hide-spam-badges'; this._createStyles(); this._setupBakerWindowControls(); this._readSettings(); this._spamFadeExistingPosts(); this._setupListeners(); } /** * Create stylesheets */ _createStyles() { $('head').append(` <style id='${this.styleId}'> div.post.post-hover { opacity: 1 !important; } </style> `); } /** * Setup settings UI for spamfading */ _setupBakerWindowControls() { window.bakerTools.mainWindow.addSpamOption(` <div class='${BakerWindow.CONTROL_GROUP_CLASS}'> <label for='${this.disableSpamFaderId}'>Disable SpamFader</label> <input type='checkbox' id='${this.disableSpamFaderId}' /> </div> <div class='${BakerWindow.CONTROL_GROUP_CLASS}'> <label for='${this.hideSpamBadgesId}'>Hide spam badges</label> <input type='checkbox' id='${this.hideSpamBadgesId}'/> </div> `); } /** * Loop through posts for spam */ _spamFadeExistingPosts() { $(EightKun.POST_REPLY_SELECTOR).each(function(i, post) { this._detectSpam(post); }.bind(this)); } /** * Determine if provided post is spam, if so, add spam class * * @param {Element} post div.post */ _detectSpam(post) { const posterStats = SpamFader.getPosterStats(post); posterStats.addPost(post); if (SpamFader.isMarkedAsNotSpam(posterStats)) { return; } this.spamDetectionStrategies.forEach((sds) => sds.isSpam(post)); this._takeSpamAction(posterStats); } /** * Performs the spam action against the poster's posts. * @param {PosterStats} posterStats */ _takeSpamAction(posterStats) { if (!posterStats.isSpam) { return; } if (this.spamAction === SpamFader.FADE) { const opacity = Math.max(SpamFader.MIN_OPACITY, (1 - posterStats.fadeProgress)); posterStats.posts.forEach(function(p) { $(p).css('opacity', opacity); $(p).off('mouseenter mouseleave'); $(p).hover(function() { $(p).animate({opacity: 1.0}, SpamFader.ANIMATION_DURATION); }, function() { $(p).animate({opacity: opacity}, SpamFader.ANIMATION_DURATION); }); }); } else if (this.spamAction === SpamFader.HIDE) { posterStats.posts.forEach(function(p) { EightKun.hidePost(p); }); } } /** * Setup new post listener */ _setupListeners() { this._setupNewPostListener(); $(`#${this.disableSpamFaderId}`).change(function(e) { this.disableSpamFader(e.target.checked); }.bind(this)); $(`#${this.hideSpamBadgesId}`).change(function(e) { this.hideSpamBadges(e.target.checked); }.bind(this)); } /** * Setup listener to check new posts for spam */ _setupNewPostListener() { $(document).on(EightKun.NEW_POST_EVENT, function(e, post) { this._detectSpam(post); }.bind(this)); } /** * Hide the actions of the spamfader. * @param {boolean} disable */ disableSpamFader(disable) { $('#'+this.disableSpamFaderId).prop('checked', disable); localStorage.setItem(SpamFader.DISABLE_SPAM_FADER_SETTING, disable); if (disable) { $(SpamFader.SPAM_BADGES_SELECTOR).hide(); $(SpamFader.NOT_SPAM_BUTTON_SELECTOR).hide(); $(EightKun.POST_REPLY_SELECTOR) .css({'opacity': ''}) .off('mouseenter mouseleave'); $(document).off(EightKun.NEW_POST_EVENT); } else { $(SpamFader.SPAM_BADGES_SELECTOR).show(); $(SpamFader.NOT_SPAM_BUTTON_SELECTOR).show(); SpamFader.posterStats.forEach(this._takeSpamAction.bind(this)); this._setupNewPostListener(); } } /** * Hide spam badges on posts * @param {boolean} hide */ hideSpamBadges(hide) { $('#'+this.hideSpamBadgesId).prop('checked', hide); localStorage.setItem(SpamFader.HIDE_SPAM_BADGES_SETTING, hide); if (hide) { $(SpamFader.SPAM_BADGES_SELECTOR).hide(); } else { $(SpamFader.SPAM_BADGES_SELECTOR).show(); } } /** * Read spamfader settings */ _readSettings() { this.spamAction = localStorage[SpamFader.SPAM_ACTION_SETTING] || SpamFader.FADE; this.hideSpamBadges(JSON.parse( localStorage.getItem( SpamFader.HIDE_SPAM_BADGES_SETTING), )); this.disableSpamFader(JSON.parse( localStorage.getItem( SpamFader.DISABLE_SPAM_FADER_SETTING), )); } /** * Get post stats for post * @param {Element} post div.post * @return {PosterStats} */ static getPosterStats(post) { const posterId = EightKun.getPosterId(post); if (!SpamFader.posterStats.has(posterId)) { SpamFader.posterStats.set(posterId, new PosterStats(posterId)); } return SpamFader.posterStats.get(posterId); } /** * Adds spam badge to the posts by the poster. * Wear them proudly fag! * * @param {PosterStats} posterStats The posterStats object representing * spam fag * @param {string} badge Font-Awesome glyph for badge * @param {string} badgeTitle The title describing the badge */ static addSpamBadge(posterStats, badge, badgeTitle) { posterStats.posts.forEach(function(post) { if (!$(post).find(SpamFader.SPAM_BADGES_SELECTOR).length) { SpamFader.createSpamBadgeSection(post); } const alreadyHasBadge = $(post) .find(SpamFader.SPAM_BADGES_SELECTOR) .find(`.fa-${badge}`).length; if (!alreadyHasBadge) { $(post).find(SpamFader.SPAM_BADGES_SELECTOR).append( `<i class="fa fa-${badge}" title='${badgeTitle}'></i>`, ); } }); } /** * Create section for spam badges * @param {Element} post div.post */ static createSpamBadgeSection(post) { const $postModifiedSection = $(post).find(EightKun.POST_MODIFIED_SELECTOR); const button = $(`<button class='${SpamFader.NOT_SPAM_BUTTON_CLASS}'> <i class="fa fa-undo" title='Not spam'></i>Not Spam </button> `); button.click(function(e) { e.preventDefault(); SpamFader.markNotSpam(post); }); button.appendTo($postModifiedSection); $postModifiedSection.append(` <span class='${SpamFader.SPAM_BADGES_CLASS}'>Spam Badges:</span>`); } /** * Mark poster as not spam. * * @param {Element} post div.post */ static markNotSpam(post) { const stats = SpamFader.getPosterStats(post); stats.markNotSpam(); stats.posts.forEach(function(p) { $(p).css('opacity', 1); $(p).off('mouseenter mouseleave'); $(p).find(SpamFader.SPAM_BADGES_SELECTOR).remove(); $(p).find(`.${SpamFader.NOT_SPAM_BUTTON_CLASS}`).remove(); }); SpamFader.addToNotSpamList(stats); } /** * Save not spam in localstorage * @param {PosterStats} posterStats */ static addToNotSpamList(posterStats) { const threadId = EightKun.getThreadId(); const notSpamList = SpamFader.getNotSpamList(); if (!(threadId in notSpamList)) { notSpamList[threadId] = []; } if (!SpamFader.isMarkedAsNotSpam(posterStats)) { notSpamList[threadId].push(posterStats.posterId); localStorage.setItem(SpamFader.NOT_SPAM_SETTING, JSON.stringify(notSpamList)); } } /** * Has this poster been marked as not spam? * @param {PosterStats} posterStats * @return {boolean} true if not spam */ static isMarkedAsNotSpam(posterStats) { const threadId = EightKun.getThreadId(); const notSpamList = SpamFader.getNotSpamList(); return threadId in notSpamList && notSpamList[threadId].includes(posterStats.posterId); } /** * Get not spam list from localStorage * @return {Array} map of thread to not spam poster ids */ static getNotSpamList() { return JSON.parse( localStorage.getItem(SpamFader.NOT_SPAM_SETTING) || '{}', ); } } SpamFader.posterStats = new Map(); SpamFader.FADE = 'fade'; SpamFader.HIDE = 'hide'; SpamFader.SPAM_BADGES_CLASS = 'bakertools-spam-badges'; SpamFader.SPAM_BADGES_SELECTOR = `.${SpamFader.SPAM_BADGES_CLASS}`; SpamFader.MIN_OPACITY = .2; SpamFader.ANIMATION_DURATION = 200; // milliseconds SpamFader.SPAM_ACTION_SETTING = 'bakertools-spamfader-action'; SpamFader.HIDE_SPAM_BADGES_SETTING = 'bakertools-spamfader-hide-badges'; SpamFader.DISABLE_SPAM_FADER_SETTING = 'bakertools-spamfader-disable'; SpamFader.NOT_SPAM_SETTING = 'bakertools-spamfader-notspam'; SpamFader.NOT_SPAM_BUTTON_CLASS = 'bakertools-spamfader-notspam'; SpamFader.NOT_SPAM_BUTTON_SELECTOR = `.${SpamFader.NOT_SPAM_BUTTON_CLASS}`; /** * Holds spam stats */ class PosterStats { /** * Construct poststats for post * @param {number} posterId id of poster */ constructor(posterId) { this.posts = []; this.posterId = posterId; this.markNotSpam(); } /** * Reset spam indicators */ markNotSpam() { this._spamCertainty = 0; this._fadeProgress = 0; this.floodCount = 0; this.breadShitCount = 0; this.isBreadShitter = false; } /** * Add post to poster's list of post * @param {Element} post div.post */ addPost(post) { if (!this.posts.includes(post)) { this.posts.push(post); } } /** * Set spam certainty property * @param {number} certainty */ set spamCertainty(certainty) { if (certainty > this._spamCertainty) { this._spamCertainty = certainty; } } /** * Get spam spamCertainty * @return {number} 1 represents 100% certainty. */ get spamCertainty() { return this._spamCertainty; } /** * Set fade progress property * @param {number} progress */ set fadeProgress(progress) { if (progress > this._fadeProgress) { this._fadeProgress = progress; } } /** * Get spam fade progress * @return {number} 1 represents 100% progress. */ get fadeProgress() { return this._fadeProgress; } /** * Number of posts by id * @return {number} */ get postCount() { return this.posts.length; } /** * Is this post spam? * @return {boolean} true if spam */ get isSpam() { return this._spamCertainty >= 1; } } /** * Base class for spamDetectionStrategies */ class SpamDetectionStrategy { /** * Determine if the provided post is spam * @param {Element} post div.post * @return {boolean} true if is spam */ isSpam(post) { return false; } } /** * Marks namefags as spam */ class NameFagStrategy extends SpamDetectionStrategy { /** * Construct NameFagStrategy */ constructor() { super(); this.nameRegex = /^Anonymous( \(You\))?$/; this.badge = 'tag'; this.badgeTitle = 'Namefag'; } /** * Returns true if a namefag, sets spamCertainty to 100% for post * to begin fading * @param {Element} post div.post * @return {boolean} true if is namefag spam */ isSpam(post) { const isNameFag = !window.bakerTools.qPostHighlighter.isQ(post) && ( !this.nameRegex.test(EightKun.getPostName(post)) || EightKun.getPostTrip(post) != '' ); if (isNameFag) { const stats = SpamFader.getPosterStats(post); stats.spamCertainty = 1; stats.fadeProgress = .2; stats.isNameFag = true; SpamFader.addSpamBadge(stats, this.badge, this.badgeTitle); } return isNameFag; } } /** * Marks floodfags with high post count as spam */ class HighPostCountFagStrategy extends SpamDetectionStrategy { /** * Construct HighPostCountFagStrategy */ constructor() { super(); this.postCountSpamThreshold = 15; this.postCountHideThreshold = 25; this.badge = 'bullhorn'; this.badgeTitle = 'High Post Count Fag'; } /** * Returns true if the poster has posted more than the threshold * @param {Element} post div.post * @return {boolean} true if spam */ isSpam(post) { if (EightKun.isPostFromOp(post)) { return; } const posterStats = SpamFader.getPosterStats(post); const highCountSpamCertainty = Math.min(1, posterStats.postCount / this.postCountSpamThreshold); posterStats.spamCertainty = highCountSpamCertainty; if (highCountSpamCertainty === 1) { posterStats.isHighPostCountFag = true; SpamFader.addSpamBadge(posterStats, this.badge, this.badgeTitle); } // We already hit spam threshold // Either we have hit threshold count or some other strategy says its spam if (posterStats.isSpam) { // Number of posts needed past threshold to hide const hideCount = this.postCountHideThreshold - this.postCountSpamThreshold; const progressIncrement = 1/hideCount; posterStats.fadeProgress += progressIncrement; } return posterStats.isSpam; } } /** * Marks floodfags with quick succession posts as spam */ class FloodFagStrategy extends SpamDetectionStrategy { /** * Construct flood fag strategy */ constructor() { super(); this.postIntervalConsideredFlooding = 60; // seconds this.floodCountSpamThreshold = 5; this.floodCountHideThreshold = 10; this.badge = 'tint'; this.badgeTitle = 'Floodfag'; } /** * Returns true if a spam * @param {Element} post div.post * @return {boolean} true if is spam */ isSpam(post) { const posterStats = SpamFader.getPosterStats(post); if (EightKun.isPostFromOp(post) || !this.isPostFlooded(posterStats)) { return; } posterStats.floodCount++; const floodSpamCertainty = Math.min(1, posterStats.floodCount / this.floodCountSpamThreshold); posterStats.spamCertainty = floodSpamCertainty; if (floodSpamCertainty === 1) { posterStats.isFloodFag = true; SpamFader.addSpamBadge(posterStats, this.badge, this.badgeTitle); } // We already hit spam threshold // Either we have hit threshold count or some other strategy says its spam if (posterStats.isSpam) { // Number of posts needed past threshold to hide const hideCount = this.floodCountHideThreshold - this.floodCountSpamThreshold; const progressIncrement = 1/hideCount; posterStats.fadeProgress += progressIncrement; } return posterStats.isSpam; } /** * Is this a flooded post? * @param {PosterStats} posterStats * @return {boolean} true if flooded */ isPostFlooded(posterStats) { if (posterStats.posts.length <= 1) { return false; } const currentPost = posterStats.posts.slice(-1)[0]; const previousPost = posterStats.posts.slice(-2)[0]; const previousPostTime = EightKun.getPostTime(previousPost); const currentPostTime = EightKun.getPostTime(currentPost); return (currentPostTime - previousPostTime) <= this.postIntervalConsideredFlooding; } } /** * Marks breadshitters as spam */ class BreadShitterFagStrategy extends SpamDetectionStrategy { // TODO: dont check for bread shitting on non research thread? /** * Construct flood fag strategy */ constructor() { super(); // Let's go easy, maybe its a newfag? this.breadShittingIncrement = .1; this.badge = 'clock-o'; this.badgeTitle = 'Bread shitter'; } /** * Returns true if a spam * @param {Element} post div.post * @return {boolean} true if is spam */ isSpam(post) { const posterStats = SpamFader.getPosterStats(post); if (EightKun.isPostFromOp(post) || !this.isBreadShitter(post)) { return; } posterStats.breadShitCount++; posterStats.isBreadShitter = true; SpamFader.addSpamBadge(posterStats, this.badge, this.badgeTitle); posterStats.spamCertainty = 1; posterStats.fadeProgress += this.breadShittingIncrement; return posterStats.isSpam; } /** * Is this a bread shitting post? * @param {Element} post div.post * @return {boolean} true if bread shitter */ isBreadShitter(post) { const dough = ResearchBread.getDoughPost(); const doughTime = EightKun.getPostTime(dough); const postTime = EightKun.getPostTime(post); return postTime <= doughTime; } } /* exported StatsOverlay */ /* global $, EightKun, QPostHighlighter, NotablePost, debounce */ /** * Overlays bread stats (and some other controls) in the bottom right of the * screen. */ class StatsOverlay { /** * Construct statsoverlay, html element, setup listeners */ constructor() { this.id = 'bakertools-stats-overlay'; this.maxPosts = 750; this.postCountId = 'bakertools-stats-post-count'; this.userCountId = 'bakertools-stats-uid-count'; this.qCountId = 'bakertools-stats-q-count'; this.notableCountId = 'bakertools-stats-notable-count'; this._createStyles(); this._createElement(); this._updateStats(); $(document).on(EightKun.NEW_POST_EVENT, function(e, post) { this._updateStats(); }.bind(this)); } /** * Create styles for stats overlay */ _createStyles() { const sheet = window.document.styleSheets[0]; sheet.insertRule(`#${this.id} { padding: 5px; position: fixed; z-index: 100; float: right; right:28.25px; bottom: 28.25px; }`, sheet.cssRules.length); } /** * Create actual html element for style overlay */ _createElement() { this.element = document.createElement('div'); this.element.id = this.id; this.$goToLast = $(` <a href="javascript:void(0)" alt="last" title="Go to last reading location"> <i class="fa fa-step-backward"></i> </a>`); this.saveLastReadingLocation = debounce(this.saveLastReadingLocation, 450); this.currentReadingLocation = $(window).scrollTop(); $(window).scroll(function() { this.saveLastReadingLocation(); }.bind(this)); this.$goToLast.click(function() { $(window).scrollTop(this.lastReadingLocation); }.bind(this)); $(this.element).append( ` Posts: <span id="${this.postCountId}" ></span> UIDS: <span id="${this.userCountId}" ></span> `); $(this.element).append(this.$goToLast); $(this.element).append(` <a href="#bottom" alt="to-bottom" title="Go to bottom"> <i class="fa fa-angle-double-down"></i> </a> <a href="#top" alt="to-top" title="Go to top"> <i class="fa fa-angle-double-up"></i> </a> <br/> Q's: <span id="${this.qCountId}" ></span> Notables: <span id="${this.notableCountId}"></span> `); document.body.appendChild(this.element); this._setPostCount($('div.post.reply').length); } /** * Save the last spot before scrolling or navigation */ saveLastReadingLocation() { const scrollDistance = Math.abs( this.currentReadingLocation - $(window).scrollTop()); const scrolledMoreThanThirdScreenHeight = scrollDistance > (window.innerHeight / 3); if (!scrolledMoreThanThirdScreenHeight) { return; } this.lastReadingLocation = this.currentReadingLocation; this.currentReadingLocation = $(window).scrollTop(); } /** * Update the stats fields */ _updateStats() { const postCount = $('#thread_stats_posts').text(); if (postCount) { this._setPostCount(postCount); } // TODO: uids dont load at first load. const userCount = $('#thread_stats_uids').text(); if (userCount) { this._setUserCount(userCount); } $('#'+this.userCountId).text(userCount); $('#'+this.qCountId).text(QPostHighlighter.qPosts.length); $('#'+this.notableCountId).text(NotablePost.getNotablesAsPosts().length); } /** * Set user count in overlay * @param {number} count */ _setUserCount(count) { $('#'+this.UserCountId).text(count); } /** * Set post count in overlay * @param {number} count */ _setPostCount(count) { const progress = count/this.maxPosts; let postColor = 'green'; if (progress >= .87) { // ~ 650 posts (100 posts left) postColor = 'red'; } else if (progress >= .5) { postColor = 'goldenrod'; } $('#'+this.postCountId).text(count).css({'color': postColor}); } } // End StatsOverlay class /* global $, NavigationControl, EightKun, ResearchBread, ColorPicker, POST_BACKGROUND_CHANGE_EVENT, BakerWindow */ /** * Highlight posts that (you) * Adds (You) navigation links to baker window */ class YouHighlighter { /** * Construct YN object */ constructor() { this.styleId = 'bakertools-you-styles'; this.yous = []; this.ownPosts = []; this.showYouNavigationInBoardListId = 'bakertools-show-you-nav-in-boardlist'; this.showOwnNavigationInBoardListId = 'bakertools-show-own-nav-in-boardlist'; this._createStyles(); this._setupBakerWindowControls(); this._readSettings(); this._initOwnAndYouPosts(); this._setupListeners(); } /** * Create styles */ _createStyles() { $('head').append(` <style id='${this.styleId}'> ${EightKun.POST_SELECTOR}.${YouHighlighter.YOU_CLASS} { background: ${this.youColor}; } ${EightKun.POST_SELECTOR}.${YouHighlighter.OWN_CLASS} { background: ${this.ownPostColor}; } </style> `); } /** * Read settings from localStorage */ _readSettings() { this.showYouNavigationInBoardList(JSON.parse( localStorage.getItem( YouHighlighter.SHOW_YOU_NAV_IN_BOARDLIST_SETTING), )); this.showOwnNavigationInBoardList(JSON.parse( localStorage.getItem( YouHighlighter.SHOW_OWN_NAV_IN_BOARDLIST_SETTING), )); } /** * Add (you) navigation to bakerwindow */ _setupBakerWindowControls() { const youColorPicker = new ColorPicker( '(You) Post Color', 'Set background color of posts replying to (you)', YouHighlighter.YOU_COLOR_SETTING, YouHighlighter.DEFAULT_YOU_COLOR, (color) => this.youColor = color, ); const ownPostColorPicker = new ColorPicker( 'Own Post Color', 'Set background color your own posts', YouHighlighter.OWN_COLOR_SETTING, YouHighlighter.DEFAULT_OWN_COLOR, (color) => this.ownPostColor = color, ); window.bakerTools.mainWindow.addColorOption(ownPostColorPicker.element); window.bakerTools.mainWindow.addColorOption(youColorPicker.element); this.ownNavigation = new NavigationControl('Own Posts', this.getOwnPosts.bind(this), YouHighlighter.NEW_OWN_POST_EVENT); this.ownBoardListNav = new NavigationControl('Own', this.getOwnPosts.bind(this), YouHighlighter.NEW_OWN_POST_EVENT); $(EightKun.getTopBoardlist()).append(this.ownBoardListNav.element); $(this.ownBoardListNav.element).hide(); window.bakerTools.mainWindow.addNavigationOption(` <div class='${BakerWindow.CONTROL_GROUP_CLASS}'> <label for="${this.showOwnNavigationInBoardListId}" title="Show navigation controls in board list bar" > Show Own Post Nav in Board List: </label> <input type="checkbox" id="${this.showOwnNavigationInBoardListId}" title="Show navigation controls in board list bar" /><br /> </div> `); window.bakerTools.mainWindow.addNavigation(this.ownNavigation.element); this.youNavigation = new NavigationControl(`(You)'s`, this.getYous.bind(this), YouHighlighter.NEW_YOU_POST_EVENT); this.youBoardListNav = new NavigationControl('You', this.getYous.bind(this), YouHighlighter.NEW_YOU_POST_EVENT); $(EightKun.getTopBoardlist()).append(this.youBoardListNav.element); $(this.youBoardListNav.element).hide(); window.bakerTools.mainWindow.addNavigationOption(` <div class='${BakerWindow.CONTROL_GROUP_CLASS}'> <label for="${this.showYouNavigationInBoardListId}" title="Show navigation controls in board list bar" > Show (You) Nav in Board List: </label> <input type="checkbox" id="${this.showYouNavigationInBoardListId}" title="Show navigation controls in board list bar" /><br /> </div> `); window.bakerTools.mainWindow.addNavigation(this.youNavigation.element); } /** * Set the background color of posts replying to (you) * @param {string} color A valid css color value. * E.G. ('#ff00ee', 'rgba()' or 'blue') */ set youColor(color) { this._youColor = color; document.getElementById(this.styleId) .sheet.cssRules[0].style.background = color; $(document).trigger(POST_BACKGROUND_CHANGE_EVENT, color); } /** * Get background color for posts replying to (you) */ get youColor() { return this._youColor; } /** * Set the background color of your own posts * @param {string} color A valid css color value. * E.G. ('#ff00ee', 'rgba()' or 'blue') */ set ownPostColor(color) { this._ownPostColor = color; document.getElementById(this.styleId) .sheet.cssRules[1].style.background = color; $(document).trigger(POST_BACKGROUND_CHANGE_EVENT, color); } /** * Get background color for your own posts */ get ownPostColor() { return this._ownPostColor; } /** * Setup listeners for baker window controls */ _setupListeners() { $('#'+this.showOwnNavigationInBoardListId).change(function(e) { this.showOwnNavigationInBoardList(e.target.checked); }.bind(this)); $('#'+this.showYouNavigationInBoardListId).change(function(e) { this.showYouNavigationInBoardList(e.target.checked); }.bind(this)); $(document).on(EightKun.NEW_POST_EVENT, function(e, post) { if (this.isAYou(post)) { this._addYouPost(post); } if (this.isOwnPost(post)) { this._addOwnPost(post); } }.bind(this)); } /** * Show/hide you nav in boardlist * * @param {boolean} showYouNavInBoardList */ showYouNavigationInBoardList(showYouNavInBoardList) { $('#'+this.showYouNavigationInBoardListId).prop('checked', showYouNavInBoardList); localStorage.setItem(YouHighlighter.SHOW_YOU_NAV_IN_BOARDLIST_SETTING, showYouNavInBoardList); if (showYouNavInBoardList) { $(this.youBoardListNav.element).show(); } else { $(this.youBoardListNav.element).hide(); } } /** * Show/hide own nav in boardlist * * @param {boolean} showOwnNavInBoardList */ showOwnNavigationInBoardList(showOwnNavInBoardList) { $('#'+this.showOwnNavigationInBoardListId).prop('checked', showOwnNavInBoardList); localStorage.setItem(YouHighlighter.SHOW_OWN_NAV_IN_BOARDLIST_SETTING, showOwnNavInBoardList); if (showOwnNavInBoardList) { $(this.ownBoardListNav.element).show(); } else { $(this.ownBoardListNav.element).hide(); } } /** * Get (You)'s * @return {Array} of div.post */ getYous() { return this.yous; } /** * Is the post replying to you * @param {Element} post div.post * @return {boolean} True if post is replying to you */ isAYou(post) { return post.querySelector('.body') .innerHTML .indexOf('<small>(You)</small>') != -1; } /** * Is this your own post * @param {Element} post div.post * @return {boolean} True if post is you */ isOwnPost(post) { return $(post).hasClass('you'); } /** * Add you post and trigger event * @param {Element} post */ _addYouPost(post) { this.yous.push(post); $(post).addClass(YouHighlighter.YOU_CLASS); $(document).trigger(YouHighlighter.NEW_YOU_POST_EVENT, post); } /** * Add own post and trigger event * @param {Element} post */ _addOwnPost(post) { this.ownPosts.push(post); $(post).addClass(YouHighlighter.OWN_CLASS); $(document).trigger(YouHighlighter.NEW_OWN_POST_EVENT, post); } /** * Get own and you posts that are present at page load */ _initOwnAndYouPosts() { const ownPosts = JSON.parse(localStorage.own_posts || '{}'); const board = ResearchBread.BOARD_NAME; $('div.post').each(function(i, post) { const postId = $(post).attr('id').split('_')[1]; if (ownPosts[board] && ownPosts[board].indexOf(postId) !== -1) { this._addOwnPost(post); } EightKun.getReplyLinksFromPost(post).each(function(i, link) { const youPostId = EightKun.getPostNumberFromReplyLink(link); if (ownPosts[board] && ownPosts[board].indexOf(youPostId) !== -1) { this._addYouPost(post); } }.bind(this)); }.bind(this)); window.bakerTools.scrollBar.addPosts(this.ownPosts); window.bakerTools.scrollBar.addPosts(this.yous); } /** * Get own posts * @return {Array} of div.post */ getOwnPosts() { return this.ownPosts; } } YouHighlighter.SHOW_YOU_NAV_IN_BOARDLIST_SETTING = 'bakertools-show-you-nav-in-boardlist'; YouHighlighter.SHOW_OWN_NAV_IN_BOARDLIST_SETTING = 'bakertools-show-own-nav-in-boardlist'; YouHighlighter.NEW_YOU_POST_EVENT = 'bakertools-new-you-post-event'; YouHighlighter.NEW_OWN_POST_EVENT = 'bakertools-new-own-post-event'; YouHighlighter.YOU_CLASS = 'bakertools-you-post'; YouHighlighter.OWN_CLASS = 'bakertools-own-post'; YouHighlighter.OWN_COLOR_SETTING = 'bakertools-own-post-color'; YouHighlighter.YOU_COLOR_SETTING = 'bakertools-you-post-color'; YouHighlighter.DEFAULT_OWN_COLOR = '#F8D2D2'; YouHighlighter.DEFAULT_YOU_COLOR = '#E1B3DA'; /* global ActivePage, $, QPostHighlighter, YouHighlighter, StatsOverlay, NotableHighlighter, BakerWindow, BlurImages, PreviousBreadHighlighter, NominatePostButtons, BreadList, ScrollbarNavigation, NotablePost, ImageBlacklist, PostRateChart, SpamFader */ /** * MAIN */ if (ActivePage.isThread()) { // Only setup the tools if we are on a thread $(document).ready(function() { console.info('Thanks for using bakertools! For God and Country! WWG1WGA'); window.bakerTools = {}; window.bakerTools.mainWindow = new BakerWindow(); window.bakerTools.scrollBar = new ScrollbarNavigation([ NotablePost.NEW_NOTABLE_POST_EVENT, YouHighlighter.NEW_OWN_POST_EVENT, YouHighlighter.NEW_YOU_POST_EVENT, QPostHighlighter.NEW_Q_POST_EVENT, ]); new BlurImages(); window.bakerTools.PreviousBreadHighlighter = new PreviousBreadHighlighter(); window.bakerTools.qPostHighlighter = new QPostHighlighter(); window.bakerTools.notableHighlighter = new NotableHighlighter(); window.bakerTools.youHighlighter = new YouHighlighter(); window.bakerTools.statsOverlay = new StatsOverlay(); new NominatePostButtons(); new BreadList(); new ImageBlacklist(); new PostRateChart(); new SpamFader([new NameFagStrategy(), new HighPostCountFagStrategy(), new FloodFagStrategy(), new BreadShitterFagStrategy()]); }); } }(window.jQuery));