get paid to paste

8kun Bakertools v0.7.4

/*
==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));

Pasted: Apr 13, 2021, 2:34:44 pm
Views: 37