mark.taiwangmail.com / Fimfiction Comment Bookmarks

// ==UserScript==
// @name         Fimfiction Comment Bookmarks
// @description  Comment bookmarking on Fimfiction.net
// @version      1.0.8
// @author       Marker
// @license      MIT
// @namespace    https://github.com/marktaiwan/
// @homepageURL  https://github.com/marktaiwan/Fimfiction-Comment-Bookmarks
// @supportURL   https://github.com/marktaiwan/Fimfiction-Comment-Bookmarks/issues
// @include      https://www.fimfiction.net/*
// @grant        none
// @run-at       document-start
// @noframes
// ==/UserScript==

(function () {

'use strict';
const SCRIPT_LABEL = 'markers_comment_bookmarks';
const CATEGORY_LIST = ['story', 'blog', 'user', 'group', 'group_forum'];
const DB_VERSION = 1;
const BC_SUPPORT = ('BroadcastChannel' in window);

/** Modified from https://gist.github.com/MoOx/8614711
 */
function composeElement(obj) {

  /** https://gist.github.com/youssman/745578062609e8acac9f
   * camelToDash('userId') => "user-id"
   */
  function camelToDash(str) {
    return str.replace(/([a-zA-Z])(?=[A-Z])/g, '$1-').toLowerCase();
  }

  let ele;

  if (obj.tag !== undefined) {
    ele = document.createElement(obj.tag);
    if (obj.attributes !== undefined) {
      for (const attr in obj.attributes) {
        if (obj.attributes.hasOwnProperty(attr)) {
          ele.setAttribute(camelToDash(attr), obj.attributes[attr]);
        }
      }
    }
  } else {
    ele = document.createDocumentFragment();
  }
  if (obj.html !== undefined) ele.innerHTML = obj.html;
  if (obj.text) ele.appendChild(document.createTextNode(obj.text));
  if (Array.isArray(obj.children)) {
    for (const child of obj.children) {
      ele.appendChild((child instanceof window.HTMLElement) ? child : composeElement(child));
    }
  }

  return ele;
}
const onReady = (() => {
  const callbacks = [];
  document.addEventListener('DOMContentLoaded', () => callbacks.forEach(fn => fn()), {once: true});
  return (fn) => {
    if (document.readyState == 'loading') {
      callbacks.push(fn);
    } else {
      fn();
    }
  };
})();
function initCSS() {
  const styleElement = document.createElement('style');
  styleElement.id = `${SCRIPT_LABEL}-css`;
  styleElement.type = 'text/css';
  styleElement.innerHTML = ` /* Generated by userscript "FiM Comment Bookmark" */

/* colors */
#${SCRIPT_LABEL}--pop-up-wrapper {
  --window-background: #f7f5f2;
  --comment-body-color: #555;
  --comment-body-background-color: hsla(0, 0%, 50%, 0.01);
  --overlay-gradient: linear-gradient(
    to bottom,
    var(--comment-body-color) 70%,
    var(--comment-body-background-color) 100%
  );
  --subject-font-color: #3073b9;
  --border-color: #e6e6e6;
  --pop-up-background: #fff;
  --title-row-bg: #eee;
  --title-row-bg-hover: #fafafa;
  --button-generic-background: #258bd4;
  --button-generic-background-hightlight: #409cdd;
  --button-generic-color: #fff;

  --category-story: #4caf50;
  --category-blog: #9c27b0;
  --category-forum: #f44336;
  --category-profile: #ff9800;

  --category-story-highlight: #5cb75f;
  --category-blog-highlight: #ae2bc5;
  --category-forum-highlight: #f6574c;
  --category-profile-highlight: #ffa31a;
}
#${SCRIPT_LABEL}--pop-up-wrapper.nightmode {
  --window-background: #222935;
  --comment-body-color: #a3abc3;
  --comment-body-background-color: rgba(255, 255, 255, 0.04);
  --subject-font-color: #52b7ff;
  --border-color: #313b4c;
  --pop-up-background: #29313f;
  --title-row-bg: #2f3848;
  --title-row-bg-hover: #394257;
  --button-generic-background: #5088b5;
  --button-generic-background-hightlight: #457aa5;

  --category-story: #449c47;
  --category-blog: #89229b;
  --category-forum: #f32a1b;
  --category-profile: #e68a00;

  --category-story-highlight: #3d8a3f;
  --category-blog-highlight: #761d86;
  --category-forum-highlight: #e91b0c;
  --category-profile-highlight: #cc7a00;
}
/* /colors */

.${SCRIPT_LABEL}-options-button.active[data-filter-type="story"] {
  background-color: var(--category-story);
}
.${SCRIPT_LABEL}-options-button.active[data-filter-type="blog"] {
  background-color: var(--category-blog);
}
.${SCRIPT_LABEL}-options-button.active[data-filter-type="forum"] {
  background-color: var(--category-forum);
}
.${SCRIPT_LABEL}-options-button.active[data-filter-type="profile"] {
  background-color: var(--category-profile);
}

.${SCRIPT_LABEL}-options-button.active:hover[data-filter-type="story"], .${SCRIPT_LABEL}-options-button:focus[data-filter-type="story"] {
  background-color: var(--category-story-highlight);
}
.${SCRIPT_LABEL}-options-button.active:hover[data-filter-type="blog"], .${SCRIPT_LABEL}-options-button:focus[data-filter-type="blog"] {
  background-color: var(--category-blog-highlight);
}
.${SCRIPT_LABEL}-options-button.active:hover[data-filter-type="forum"], .${SCRIPT_LABEL}-options-button:focus[data-filter-type="forum"] {
  background-color: var(--category-forum-highlight);
}
.${SCRIPT_LABEL}-options-button.active:hover[data-filter-type="profile"], .${SCRIPT_LABEL}-options-button:focus[data-filter-type="profile"] {
  background-color: var(--category-profile-highlight);
}

/* scroller */
#${SCRIPT_LABEL}--pop-up * ::-webkit-scrollbar {
  width: 6px;
  height: 6px;
  background-color: transparent;
}
#${SCRIPT_LABEL}--pop-up * ::-webkit-scrollbar-thumb {
  background-color: #888;
}
#${SCRIPT_LABEL}--pop-up * ::-webkit-scrollbar-track {
  background-color: #ddd;
}
#${SCRIPT_LABEL}--pop-up * ::-webkit-scrollbar-thumb:hover {
  background-color: #a3a3a3;
}
#${SCRIPT_LABEL}--pop-up * ::-webkit-scrollbar-thumb:active {
  background-color: #6d6d6d;
}

.${SCRIPT_LABEL}-flex-grow {
  flex-grow: 1;
}
.${SCRIPT_LABEL}--window-content select, .${SCRIPT_LABEL}--window-content input[type="text"] {
  background-color: var(--pop-up-background);
  color: inherit;
  font-size: inherit;
  border: 1px groove;
  padding: 0px 4px;
}
.${SCRIPT_LABEL}--window-content input[type="text"] {
  padding: 2px 4px;
}
#${SCRIPT_LABEL}--pop-up-wrapper {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 10000;
  display: flex;
  width: 100vw;
  height: 100vh;
  align-items: center;
  justify-content: center;
  background-color: rgba(0,0,0,0.5);
}
#${SCRIPT_LABEL}--pop-up {
  max-width: 1200px;
  width: 90vw;
}
#${SCRIPT_LABEL}--pop-up > div {
  width: 100%;
}
.${SCRIPT_LABEL}--window-content {
  display: flex;
  flex-direction: column;
  overflow: auto;
  padding: 10px 18px;
  height: calc(100vh - 120px);
  background-color: var(--window-background);
}
.${SCRIPT_LABEL}-list {
  display: flex;
  flex-direction: column;
  min-height: 400px;
  border: 1px solid rgba(0,0,0,0.2);
  background-color: var(--pop-up-background);
}
.${SCRIPT_LABEL}-list-header {
  border-bottom: 1px solid var(--border-color);
  padding: 3px 8px;
  line-height: 2;
  font-size: 16px;
  display: flex;
  justify-content: flex-start;
  align-items: baseline;
  flex-wrap: wrap;
}
.${SCRIPT_LABEL}-list-header > * {
  margin: 0px 2px;
}
#${SCRIPT_LABEL}-list-container {
  overflow: auto;
  overflow-anchor: none;
}
#${SCRIPT_LABEL}-list-container > :first-child {
  border-top-style: none;
}
#${SCRIPT_LABEL}-list-container > * {
  border-top: 1px solid var(--border-color);
}
#${SCRIPT_LABEL}-list-container li.comment {
  display: block;
  padding: 8px;
  line-height: 1.5em;
  overflow: hidden;
  position: relative;
  padding-right: 100px;
}
#${SCRIPT_LABEL}-list-container .date>span {
  font-size: 0.9em;
}
#${SCRIPT_LABEL}-list-container .date {
  position: absolute;
  top: 50%;
  right: 10px;
  margin-top: -7px;
  font-size: 0.9em;
}
#${SCRIPT_LABEL}-list-container .comment_information {
  display: inherit;
  padding-right: 50px;
  min-height: 32px;
}

#${SCRIPT_LABEL}-list-container .comment_information > a.subject {
  font-size: 1.2em;
  color: var(--subject-font-color);
}
#${SCRIPT_LABEL}-list-container .comment .comment_information .buttons {
  position: absolute;
  right: 0px;
  user-select: none;
  -moz-user-select: none;
}
#${SCRIPT_LABEL}-list-container .comment:hover .comment_information .buttons {
  opacity: 1;
}
#${SCRIPT_LABEL}-list-container .comment .comment_information .buttons a.disabled {
  opacity: 0.2;
  cursor: default;
}
#${SCRIPT_LABEL}-pagination {
  border-bottom: 1px solid var(--border-color);
  padding: 3px 8px;
  user-select: none;
  -moz-user-select: none;
}
.${SCRIPT_LABEL}-pagination-link {
  margin: 0px 2px;
}
.${SCRIPT_LABEL}-pagination-link.disabled {
  color: unset;
  cursor: unset;
}
.${SCRIPT_LABEL}-pagination-link.disabled:hover {
  text-decoration: none;
}
.${SCRIPT_LABEL}-comment-author-span {
  font-size: 1em;
}
.${SCRIPT_LABEL}-comment-author {
  color: unset;
  white-space: nowrap;
}
.${SCRIPT_LABEL}-divider {
  margin: 0px 4px;
}
.${SCRIPT_LABEL}-snippet-wrapper {
  position: relative;
  background-color: var(--comment-body-background-color);
  box-shadow: inset 1px 1px 5px 0px rgba(0, 0, 0, 0.05);
}
.${SCRIPT_LABEL}-snippet {
  overflow: hidden;
  max-height: 80px;
  white-space: pre-line;
  padding: 0.4em;
  color: var(--comment-body-color);
}
.${SCRIPT_LABEL}-snippet.expanded {
  max-height: unset;
}
.${SCRIPT_LABEL}-snippet.overlay {
  transition: background-color 0.5s cubic-bezier(0.5, 0, 0, 1);
  -webkit-background-clip: text;
  background-clip: text;
  background-image: var(--overlay-gradient);
  background-color: transparent;
  color: transparent;
  cursor: pointer;
  user-select: none;
  -moz-user-select: none;
}
.${SCRIPT_LABEL}-snippet.overlay:hover {
  background-color: var(--comment-body-color);
}
.${SCRIPT_LABEL}-options-bar {
  display: flex;
  flex-wrap: wrap;
  margin-bottom: 4px;
}
.${SCRIPT_LABEL}-options-button {
  color: var(--comment-body-color);
  background-color: var(--pop-up-background);
  cursor: pointer;
  user-select: none;
  -moz-user-select: none;
  font-size: .8125rem;
  font-weight: normal;
  margin: 2px 0px;
  padding: 3px 8px;
  border: 1px solid var(--border-color);
  border-radius: 4px;
  display: inline-block;
  text-align: center;
  line-height: 16px;
  outline: none;
}
.${SCRIPT_LABEL}-options-button.active {
  color: var(--button-generic-color);
  background-color: var(--button-generic-background);
}
.${SCRIPT_LABEL}-options-button.clickable:hover, .${SCRIPT_LABEL}-options-button.active:hover, .${SCRIPT_LABEL}-options-button:focus {
  background-color: var(--button-generic-background-hightlight);
  color: var(--button-generic-color);
}
.${SCRIPT_LABEL}-article-container {
  display: block;
}
.${SCRIPT_LABEL}-article-container.expanded .${SCRIPT_LABEL}-comment-container {
  display: initial;
}
#${SCRIPT_LABEL}-list-container .${SCRIPT_LABEL}-comment-container li.comment{
  padding-left: 24px;
}
.${SCRIPT_LABEL}-title-row {
  padding: 2px 0px;
  font-size: 18px;
  border-left-style: solid;
  border-left-width: 4px;
  background-color: var(--title-row-bg);
  cursor: pointer;
  user-select: none;
  -moz-user-select: none;
}
.${SCRIPT_LABEL}-title-row:hover {
  background-color: var(--title-row-bg-hover);
}
.${SCRIPT_LABEL}-article-container .expand-indicator:before {
  content: '\\f0da';
}
.${SCRIPT_LABEL}-article-container.expanded .expand-indicator:before {
  content: '\\f0d7';
}
.${SCRIPT_LABEL}-preview.quote_container {
  position: initial;
  z-index: initial;
  margin-top: 8px;
}
.preview_active .${SCRIPT_LABEL}-preview.quote_container {
  display: block;
}
#${SCRIPT_LABEL}--pop-up-wrapper.hidden,
.${SCRIPT_LABEL}-article-container .${SCRIPT_LABEL}-comment-container,
.${SCRIPT_LABEL}-comment-container .hidden-when-nested,
.preview_active .${SCRIPT_LABEL}-snippet-wrapper,
.preview_active .${SCRIPT_LABEL}-comment-author-span,
.${SCRIPT_LABEL}-preview.quote_container,
.${SCRIPT_LABEL}-preview.quote_container .buttons {
  display: none;
}
div.corner-stubb {
  border-width: 5px;
  border-style: solid;
  border-right-color: transparent;
  border-bottom-color: transparent;
  top: 0px;
  left: 0px;
  width: 0px;
  height: 0px;
  position: absolute;
}
.${SCRIPT_LABEL}-comment-container div.corner-stubb {
  border-style: none;
}

[data-category-indicator="story"] {
  border-color: var(--category-story);
}
[data-category-indicator="blog"] {
  border-color: var(--category-blog);
}
[data-category-indicator="group_forum"] {
  border-color: var(--category-forum);
}
[data-category-indicator="user"], [data-category-indicator="group"] {
  border-color: var(--category-profile);
}

/* Layout fixes for small screens/mobile view */
@media (max-width: 700px) {
  .${SCRIPT_LABEL}-preview.quote_container .comment_information {
    display: flex;
    padding-right: initial;
  }
  .${SCRIPT_LABEL}-preview.quote_container .comment {
    background-color: unset;
  }
  .quote_container .data {
    margin: 0px;
  }
  #${SCRIPT_LABEL}-list-container .${SCRIPT_LABEL}-comment-container li.comment, #${SCRIPT_LABEL}-list-container li.comment {
    padding: 4px;
  }
  #${SCRIPT_LABEL}-list-container .date {
    display: none;
  }
}`;
  document.head.appendChild(styleElement);
}
function initLocalStorage() {
  if (!localStorage[SCRIPT_LABEL]) localStorage[SCRIPT_LABEL] = '{}';

  // default settings
  const store = JSON.parse(localStorage[SCRIPT_LABEL]);
  const defaults = {
    'comment_collapse': true,
    'comment_pagination': true,
    'sort-method': 'post',
    'sort-direction': 'asc',
    'category_filter': {story: true, blog: true, forum: true, profile: true}
  };

  for (const prop in defaults) {
    if (!store.hasOwnProperty(prop)) setLocalStorage(prop, defaults[prop]);
  }
}
function getLocalStorage(prop) {
  const store = JSON.parse(localStorage[SCRIPT_LABEL]);
  return store[prop];
}
function setLocalStorage(prop, val) {
  const store = JSON.parse(localStorage[SCRIPT_LABEL]);
  store[prop] = val;
  localStorage[SCRIPT_LABEL] = JSON.stringify(store);
  return val;
}
const getDB = (() => {
  let dbPromise = null;

  return () => {
    return (dbPromise) ? dbPromise : dbPromise = new Promise((resolve, reject) => {
      const dbRequest = window.indexedDB.open(SCRIPT_LABEL, DB_VERSION);

      dbRequest.onsuccess = (e) => {
        const dbConnection = e.target.result;
        dbConnection.onclose = () => dbPromise = null;
        dbConnection.onversionchange = () => dbConnection.close();
        resolve(dbConnection);
      };
      dbRequest.onerror = (e) => reject(e.target.error);
      dbRequest.onupgradeneeded = (e) => {
        const db = e.target.result;

        if (e.oldVersion < 1) {

          /* --- Initialize database --- */
          // Record includes: {category, commentId, commentListId, commentTimestamp, bookmarkTimestamp, commentAuthor, commentAuthorId, commentSnippet, chapterName}
          //  commentId from different categories do not share the same counter,
          //  so we cannot use them to sort chronologically and must use the timestamps
          const bookmarkStore = db.createObjectStore('bookmarkStore', {keyPath: ['commentId', 'category']});
          bookmarkStore.createIndex('commentTimestamp', 'commentTimestamp');
          bookmarkStore.createIndex('bookmarkTimestamp', 'bookmarkTimestamp');

          // Record includes: {commentListId, category, title, author, comments[]}
          //  'title' would be the title of the story/blog post/forum thread.
          //  'author' corresponds to the user that created the story/blog post/profile, or corresponds to the group in the case of forum posts.
          const articleStore = db.createObjectStore('articleStore', {keyPath: ['commentListId', 'category']});
          articleStore.createIndex('articleTitle', 'title');
          articleStore.createIndex('articleAuthor', 'author');
        }
      };
    });
  };
})();
async function clearDB() {
  const db = await getDB();
  const tx = db.transaction(db.objectStoreNames, 'readwrite');
  for (const storeName of tx.objectStoreNames) {
    const store = tx.objectStore(storeName);
    const request = store.clear();
    request.onerror = (e) => console.error(e.target.error);
  }
  tx.oncomplete = () => {
    broadcastBookmarkChangeEvent({type: 'dbClear'});
    window.StyledAlert('All bookmarks deleted.');
  };
}
async function exportDB() {
  const db = await getDB();
  const tx = db.transaction(db.objectStoreNames, 'readonly');
  const storeArray = [];

  for (const storeName of tx.objectStoreNames) {
    const store = tx.objectStore(storeName);
    const request = store.getAll();
    request.onsuccess = (e) => storeArray.push({name: storeName, records: e.target.result});
    request.onerror = (e) => console.error(e.target.error);
  }

  tx.oncomplete = () => {
    const blob = new Blob([JSON.stringify({version: db.version, stores: storeArray})], {type: 'application/json'});
    const anchor = document.createElement('a');
    anchor.setAttribute('href', URL.createObjectURL(blob));
    anchor.setAttribute('download', 'FiM Comment Bookmarks.json');
    document.body.appendChild(anchor);  // Won't work in Firefox otherwise
    anchor.click();
    anchor.remove();
  };
  tx.onerror = (e) => console.error(e.target.error);
}
function importDB() {
  const filePicker = composeElement({tag: 'input', attributes: {type: 'file'}});

  filePicker.addEventListener('change', () => {
    const reader = new FileReader();

    reader.readAsText(filePicker.files[0]);
    reader.onload = async (e) => {
      const parsedDB = JSON.parse(e.target.result);

      if (parsedDB.version !== DB_VERSION) {
        throw new Error(`Database version mismatch:
          The script only support imports with the same database version.
          Current version: ${DB_VERSION}
          Imported version: ${parsedDB.version}`);
      }

      const bookmarkStore = parsedDB.stores.find(store => store.name == 'bookmarkStore').records;
      const articleStore = parsedDB.stores.find(store => store.name == 'articleStore').records;

      for (const entry of bookmarkStore) {
        const {commentId, category, commentListId} = entry;
        if (await checkBookmark(commentId, category)) continue;

        const article = articleStore.find(article => (article.commentListId == commentListId && article.category == category));
        await setBookmark(entry, article);
      }

      broadcastBookmarkChangeEvent({type: 'dbImport'});
      window.StyledAlert('Comment bookmarks successfully imported.');
    };
  }, {once: true});

  filePicker.click();
}
function setBookmark(bookmarkEntry, articleRecord) {
  return new Promise(async (resolve, reject) => {
    const {commentId, commentListId, category} = bookmarkEntry;
    articleRecord = {...articleRecord, commentListId, category};
    const oldArticle = await getArticleRecord(commentListId, category);
    articleRecord.comments = (oldArticle) ? oldArticle.comments : [];

    // insert into sorted list
    let index;
    for (index = 0; index < articleRecord.comments.length; ++index) {
      if (commentId < articleRecord.comments[index]) break;
    }
    articleRecord.comments.splice(index, 0, commentId);

    const db = await getDB();
    const tx = db.transaction(['bookmarkStore', 'articleStore'], 'readwrite');
    const bookmarkRequest = tx.objectStore('bookmarkStore').add(bookmarkEntry);
    const articleRequest = tx.objectStore('articleStore').put(articleRecord);

    tx.oncomplete = () => {
      resolve();
    };
    bookmarkRequest.onerror = (e) => {
      console.warn('Bookmark entry already exist in database', bookmarkEntry);
      reject(e.target.error);
    };
    articleRequest.onerror = (e) => reject(e.target.error);
  });
}
async function updateBookmarkSnippet(commentId, category, newComment) {
  const bookmarkEntry = await getBookmark(commentId, category);
  const newSnippet = plaintext(newComment);
  if (bookmarkEntry.commentSnippet == newSnippet) return;

  bookmarkEntry.commentSnippet = newSnippet;
  const db = await getDB();
  const tx = db.transaction('bookmarkStore', 'readwrite');
  tx.objectStore('bookmarkStore')
    .put(bookmarkEntry)
    .onerror = (e) => e.target.error;
  tx.oncomplete = () => broadcastBookmarkChangeEvent({type: 'change', record: {commentId, category}});
}
function deleteBookmark(commentId, category) {
  return new Promise(async (resolve, reject) => {
    const bookmark = await getBookmark(commentId, category);
    if (!bookmark) throw {name: 'InvalidStateError'};

    const article = await getArticleRecord(bookmark.commentListId, category);
    if (!article) throw {name: 'InvalidStateError'};

    // remove commentId from article's comments array
    const indexPosition = article.comments.findIndex(ele => ele == commentId);
    if (indexPosition < 0) {
      console.warn('commentId not found in articleRecord. Attempt to delete bookmark anyway.');
    } else {
      article.comments.splice(indexPosition, 1);
    }

    const db = await getDB();
    const tx = db.transaction(['bookmarkStore', 'articleStore'], 'readwrite');
    const bookmarkRequest = tx.objectStore('bookmarkStore').delete([commentId, category]);
    const articleRequest = (article.comments.length > 0)
      ? tx.objectStore('articleStore').put(article)
      : tx.objectStore('articleStore').delete([article.commentListId, article.category]);

    tx.oncomplete = () => {
      broadcastBookmarkChangeEvent({type: 'remove', record: {commentId, category}});
      resolve();
    };
    bookmarkRequest.onerror = (e) => reject(e.target.error);
    articleRequest.onerror = (e) => reject(e.target.error);
  });
}
function checkBookmark(commentId, category) {
  return new Promise(async (resolve) => {
    const db = await getDB();
    const tx = db.transaction('bookmarkStore', 'readwrite');
    tx.objectStore('bookmarkStore')
      .count(IDBKeyRange.only([commentId, category]))
      .onsuccess = (e) => resolve(e.target.result !== 0);
  });
}
function getBookmark(commentId, category) {
  return new Promise(async (resolve) => {
    const db = await getDB();
    const tx = db.transaction('bookmarkStore', 'readonly');

    tx.objectStore('bookmarkStore')
      .get([commentId, category])
      .onsuccess = (e) => resolve(e.target.result);   // result is 'undefined' if no matching record
  });
}
function getArticleRecord(commentListId, category) {
  return new Promise(async (resolve) => {
    const db = await getDB();
    const tx = db.transaction('articleStore', 'readonly');

    tx.objectStore('articleStore')
      .get([commentListId, category])
      .onsuccess = (e) => resolve(e.target.result);
  });
}
function getAllRecords(store, index) {
  return new Promise(async (resolve) => {
    const db = await getDB();
    const tx = db.transaction(store, 'readonly');

    if (index) {
      tx.objectStore(store)
        .index(index).getAll()
        .onsuccess = (e) => resolve(e.target.result);
    } else {
      tx.objectStore(store)
        .getAll()
        .onsuccess = (e) => resolve(e.target.result);
    }
  });
}
function broadcastBookmarkChangeEvent(data = {}) {

  // notify other open tabs of database change
  if (BC_SUPPORT) {
    const bc = new BroadcastChannel(SCRIPT_LABEL);
    bc.postMessage({lastModified: data});
    bc.close();
  } else {
    // localStorage fallback, dispatch event on current document
    setLocalStorage('db_last_modified', {timestamp: Date.now(), data: data});
    const e = new StorageEvent('storage');
    e.initStorageEvent('storage', false, false, SCRIPT_LABEL);
    window.dispatchEvent(e);
  }
}
const getPageCategory = (() => {
  let category;
  const fn = () => {
    // identiy whether the current page is a forum thread, blog post, or story page by regexing the URL
    const re = new RegExp('^https://www.fimfiction.net/(.+?)/');
    const result = re.exec(document.location.href);
    if (result === null || !CATEGORY_LIST.includes(result[1])) {
      throw new Error('Unexpected category for URL:' + document.location.href);
    }
    if (result[1] == 'group' && document.querySelector('.comment_list[data-type="comments_group_thread"]')) {
      category = 'group_forum';
    } else {
      category = result[1];
    }
    return category;
  };
  return () => category || fn();
})();
function toggleOn(commentId) {
  document.querySelectorAll(`.${SCRIPT_LABEL}-bookmark-button[data-comment-id="${commentId}"]`).forEach(button => {
    button.dataset.bookmarked = '1';
    button.querySelector('i').classList.add('fa-bookmark');
    button.querySelector('i').classList.remove('fa-bookmark-o');
  });
}
function toggleOff(commentId) {
  document.querySelectorAll(`.${SCRIPT_LABEL}-bookmark-button[data-comment-id="${commentId}"]`).forEach(button => {
    button.dataset.bookmarked = '0';
    button.querySelector('i').classList.add('fa-bookmark-o');
    button.querySelector('i').classList.remove('fa-bookmark');
  });
}

const addButton = (() => {

  const callbackId = new WeakMap();

  const io = new IntersectionObserver((entries, observer) => {
    entries.forEach(entry => {
      const comment = entry.target;
      if (entry.isIntersecting) {

        const id = window.requestAnimationFrame(() => {
          callbackId.delete(comment);
          observer.unobserve(comment);
          comment.removeAttribute(`${SCRIPT_LABEL}-observer-pending`);
          execAddButton(comment);
        });
        callbackId.set(comment, id);

      } else {

        const id = callbackId.get(comment);
        window.cancelAnimationFrame(id);
        callbackId.delete(comment);

      }
    });
  }, {rootMargin: '100px'});

  function execAddButton(comment) {
    const commentId = Number.parseInt(comment.dataset.comment_id);
    const category = getPageCategory();

    checkBookmark(commentId, category).then(isBookmarked => {
      if (isBookmarked) {
        toggleOn(commentId);
        updateBookmarkSnippet(commentId, category, comment);
      } else {
        toggleOff(commentId);
      }
    });
  }

  return (comment) => {
    const buttons = comment.querySelector('.comment_information .buttons');
    if (buttons === null || comment.closest(`#${SCRIPT_LABEL}-list-container`)) return; // deleted messages or is bookmark preview

    const commentId = Number.parseInt(comment.dataset.comment_id);
    const category = getPageCategory();
    if (!buttons.querySelector(`.${SCRIPT_LABEL}-bookmark-button`)) {
      const anchor = composeElement({
        tag: 'a',
        attributes: {title: 'Bookmark this comment', class: `${SCRIPT_LABEL}-bookmark-button`, dataCommentId: commentId, dataCommentCategory: category},
        children: [{
          tag: 'i',
          attributes: {class: 'fa fa-fw'}
        },{
          tag: 'span',
          text: 'Bookmark'
        }]
      });

      buttons.insertBefore(anchor, buttons.firstChild);
    }

    if (!comment.hasAttribute(`${SCRIPT_LABEL}-observer-pending`)) {
      comment.setAttribute(`${SCRIPT_LABEL}-observer-pending`, '');
      io.observe(comment);
    }
  };
})();

function commentButtonHandler(event) {
  const button = event.target.closest(`.${SCRIPT_LABEL}-bookmark-button`);

  if (!button || !button.hasAttribute('data-bookmarked')) return;

  const comment = button.closest('.comment');
  const commentList = comment.closest('.comment_list');
  const bookmarkEntry = {};
  const articleRecord = {};
  bookmarkEntry.commentId = Number.parseInt(comment.dataset.comment_id);
  bookmarkEntry.commentListId = Number.parseInt(commentList.dataset.item);
  bookmarkEntry.category = button.dataset.commentCategory;

  if (!CATEGORY_LIST.includes(bookmarkEntry.category) || window.isNaN(bookmarkEntry.commentId)) {
    throw new Error('Unexpected value in ', bookmarkEntry);
  }

  checkBookmark(bookmarkEntry.commentId, bookmarkEntry.category).then(isBookmarked => {
    if (!isBookmarked) {
      try {
        bookmarkEntry.commentTimestamp = Number.parseInt(comment.querySelector('[data-time]').dataset.time) * 1000;
      } catch (exception) {
        if (!(exception instanceof TypeError)) throw exception;

        /*  fallback for when recently made comments lacks the 'data-time' attribute in their timestamp. Oh joy...
         *  e.g. <span title="Tuesday 1st of January 2019 @4:20am">Tuesday</span>
         */
        const monthLookup = {
          January:   '01',
          February:  '02',
          March:     '03',
          April:     '04',
          May:       '05',
          June:      '06',
          July:      '07',
          August:    '08',
          September: '09',
          October:   '10',
          November:  '11',
          December:  '12'};

        const span = comment.querySelector('.meta a>[title]');  // most liable to break
        const rx = new RegExp('^\\w+ (\\d+)(?:st|nd|rd|th) of (\\w+) (\\d+) @(\\d+):(\\d+)(am|pm)$');
        const results = rx.exec(span.getAttribute('title'));
        const day = results[1].padStart(2, '0');
        const month = monthLookup[results[2]];
        const year = results[3];
        let hour = (Number.parseInt(results[4]) + (results[6] == 'pm' ? 12 : 0)).toString().padStart(2, '0');
        const minute = results[5];

        if (hour == '24') {
          // TIL there is no such thing as 2019-01-15T24:58,
          // change that to 2019-01-16T00:58 instead.
          hour = '00';
          const date = new Date(`${year}-${month}-${day}T${hour}:${minute}`);
          date.setDate(date.getDate() + 1); // no need to worry about end-of-month, setDate takes that into account
          bookmarkEntry.commentTimestamp = date.getTime();
        } else {
          bookmarkEntry.commentTimestamp = Date.parse(`${year}-${month}-${day}T${hour}:${minute}`);
        }
      }
      bookmarkEntry.bookmarkTimestamp = Date.now();
      bookmarkEntry.commentAuthor = comment.querySelector('.author .name').textContent;
      bookmarkEntry.commentAuthorId = Number.parseInt(new RegExp('^/user/([0-9]+)/[^/]*$').exec(comment.querySelector('.author .name').getAttribute('href'))[1]);
      bookmarkEntry.commentSnippet = plaintext(comment);
      switch (bookmarkEntry.category) {

        /*  For each category, commentListId corresponds to:
         *    story -> story id / chapter id
         *    group -> group id
         *    group_forum -> forum thread
         *    blog  -> blog post
         *    user  -> user id
         */
        case 'story': {
          const storyName = document.querySelector('meta[property="og:title"]').content;
          let chapterName, storyAuthor;
          if (document.querySelector('article.story_container') !== null) {
            // title page
            const anchor = comment.querySelector('.desktop a');
            if (anchor) {
              chapterName = anchor.textContent;
            } else {
              chapterName = null;
            }
            storyAuthor = document.querySelector('.user-page-header h1 a[href^="/user/"]').textContent;
          } else {
            // chapter page
            bookmarkEntry.commentListId = Number.parseInt(document.querySelector('[data-story-id]').dataset.storyId);
            chapterName = document.querySelector('#chapter_title').textContent;
            storyAuthor = document.querySelector('.story-page-header .author a').textContent;
          }

          bookmarkEntry.chapterName = chapterName;
          articleRecord.title = storyName;
          articleRecord.author = storyAuthor;
          break;
        }
        case 'group': {
          const groupName = document.querySelector('.group_name').textContent.trim();

          articleRecord.title = groupName;
          articleRecord.author = groupName;
          break;
        }
        case 'group_forum': {
          const groupName = document.querySelector('.group_name').textContent.trim();
          const subject = document.querySelectorAll('.group .breadcrumbs a')[1].textContent.trim();

          bookmarkEntry.groupId = Number.parseInt(document.querySelector('.group-page').dataset.groupId);
          articleRecord.title = subject;
          articleRecord.author = groupName;
          break;
        }
        case 'blog': {
          const postTitle = document.querySelector('.blog-post-content-box h1 a[href^="/blog/"]').textContent;
          const blogAuthor = document.querySelector('.blog-page .information_box a[href^="/user/"]').textContent;

          articleRecord.title = postTitle;
          articleRecord.author = blogAuthor;
          break;
        }
        case 'user': {
          const userName = document.querySelector('.user-page-header h1 a[href^="/user/"]').textContent;

          articleRecord.title = userName;
          articleRecord.author = userName;
          break;
        }
        default: {
          throw Error('Unexpected data-comment-category value: ' + bookmarkEntry.category);
        }
      }

      // rudimentary error checking
      for (const prop in bookmarkEntry) {
        const val = bookmarkEntry[prop];
        if (val === undefined || (val === null && prop !== 'chapterName') || (typeof val == 'number' && window.isNaN(val))) {
          console.log(bookmarkEntry);
          throw TypeError('bookmarkEntry contains undefined values');
        }
      }
      for (const prop in articleRecord) {
        const val = articleRecord[prop];
        if (val === undefined || val === null || (typeof val == 'number' && window.isNaN(val))) {
          console.log(articleRecord);
          throw TypeError('articleRecord contains undefined values');
        }
      }

      // everything checks out, update the button
      setBookmark(bookmarkEntry, articleRecord).then(() => {
        const {commentId, category} = bookmarkEntry;
        toggleOn(commentId);
        broadcastBookmarkChangeEvent({type: 'add', record: {commentId, category}});
      });
    } else {
      deleteBookmark(bookmarkEntry.commentId, bookmarkEntry.category)
        .then(() => toggleOff(bookmarkEntry.commentId))
        .catch(e => {
          if (e.name == 'InvalidStateError') {
            console.log(e);
            toggleOff(bookmarkEntry.commentId);
          } else {
            throw e;
          }
        });
    }
  });
}
function plaintext(comment) {
  const body = comment.querySelector('.comment_data').cloneNode(true);

  // remove inline comment previews
  body.querySelectorAll('.comment.inline-quote').forEach(inlineComment => inlineComment.remove());

  // quote links
  for (const quote of body.querySelectorAll('.comment_quote_link')) {
    quote.insertAdjacentText('afterbegin', '@');
    quote.normalize();
  }
  // horizontal rule
  for (const ele of body.querySelectorAll('hr')) {
    ele.parentElement.replaceChild(document.createTextNode('\n------\n'), ele);
  }
  // process emotes, replace all <img> with its 'alt' attribute (e.g. :rainbowhuh:)
  for (const emote of body.querySelectorAll('img.emoticon')) {
    emote.parentElement.replaceChild(document.createTextNode(emote.alt), emote);
  }
  // process image embeds
  for (const ele of body.querySelectorAll('.collapsed-image-container, .user_image')) {
    ele.parentElement.replaceChild(document.createTextNode('[image embed]'), ele);
  }
  // process other embeds
  for (const embed of body.querySelectorAll('.bbcode-embed, .oembed')) {
    let prefix = '';
    let suffix = '';
    let ele;

    if (embed.dataset.origin !== undefined) {

      /*  This should apply to:
       *    YouTube
       *    Gfycat
       *    Streamable
       *    Twitch
       */
      prefix = embed.dataset.origin;
    } else if (embed.dataset.provider == 'soundcloud') {
      prefix = 'SoundCloud';
    } else if ((ele = body.querySelector('.story-card .story_link'))) {
      prefix = 'Fimfiction';
      suffix = ': ' + ele.title;
    }

    if (prefix !== '') prefix += ' ';
    ele = document.createElement('p');
    ele.innerText = `[${prefix}embed${suffix}]`;
    embed.parentElement.replaceChild(ele, embed);
  }
  // headings
  for (const heading of body.querySelectorAll('h1, h2, h3, h4, h5, h6')) {

    /* eslint-disable no-fallthrough */
    /* Yes. They are all meant to fallthrough */
    let prefix = '';
    switch (heading.tagName) {
      case 'H6': prefix += '#';
      case 'H5': prefix += '#';
      case 'H4': prefix += '#';
      case 'H3': prefix += '#';
      case 'H2': prefix += '#';
      case 'H1': prefix += '#';
    }
    /* eslint-enable no-fallthrough */

    heading.insertAdjacentText('afterbegin', prefix + ' ');
    heading.insertAdjacentText('beforeend', '\n');
    heading.normalize();
  }
  // bullet list
  for (const listItem of body.querySelectorAll('ul>li')) {
    listItem.insertAdjacentText('beforebegin', ' - ');
    listItem.insertAdjacentText('beforeend', '\n');
    listItem.normalize();
  }
  // numbered list
  for (const list of body.querySelectorAll('ol')) {
    let i = 1;
    for (const listItem of list.children) {
      listItem.insertAdjacentText('beforebegin', ` ${i++}. `);
      listItem.insertAdjacentText('beforeend', '\n');
      listItem.normalize();
    }
  }
  // linebreak for some elements
  for (const ele of body.querySelectorAll('blockquote, ul, ol, code')) {
    ele.insertAdjacentText('beforebegin', '\n');
    ele.insertAdjacentText('afterend', '\n');
  }
  // process blockquotes, create a treewalker to iterate over all the text nodes in the comment,
  // count the number of <blockquote> until comment root, and prepend '>' accordingly
  let walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT);
  while (walker.nextNode()) {
    const currentNode = walker.currentNode;
    let node, firstTextNodeInLine;

    if (!currentNode.parentElement.matches('blockquote *')) continue;

    node = currentNode;
    firstTextNodeInLine = true;
    while (firstTextNodeInLine && node.parentElement.nodeName != 'BLOCKQUOTE') {
      firstTextNodeInLine = (node == node.parentElement.firstChild || node.previousSibling.nodeName == 'BR');
      node = node.parentElement;
    }

    if (currentNode.nodeValue == ''
      || (!currentNode.parentElement.matches('blockquote > *') && !firstTextNodeInLine)
      || (currentNode != currentNode.parentElement.firstChild
        && currentNode.previousSibling.nodeName != 'BR'
        && !currentNode.parentElement.matches('blockquote > ol, blockquote > ul'))
    ) continue; // Look on my works, and despair.

    node = currentNode.parentElement;
    while (node !== body) {
      if (node.tagName == 'LI') break;
      if (node.tagName == 'BLOCKQUOTE') {
        currentNode.nodeValue = '>' + currentNode.nodeValue;
      }
      node = node.parentElement;
    }
  }
  // double linkbreak after paragraph
  for (const paragraph of body.querySelectorAll('p')) {
    paragraph.insertAdjacentText('afterend', '\n\n');
  }
  // <br>
  for (const br of body.querySelectorAll('br')) {
    br.parentElement.replaceChild(document.createTextNode('\n'), br);
  }

  const textSegments = [];
  walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT);
  while (walker.nextNode()) {
    textSegments.push(walker.currentNode.nodeValue);
  }

  // remove leading and trailing white spaces, and remove excessive repeating linebreaks
  return textSegments.join('').trim()
    .replace(/(\n{3,})/g, '\n\n')
    .replace(/[‘’]/g, '\'').replace(/[“”]/g, '"');   // because FUCK smart quotes
}
function checkNightmode() {
  return (document.getElementById('stylesheetMain').href.indexOf('light') == -1);
}
function overlayClickHandler(e) {
  e.target.classList.add('expanded');
  e.target.classList.remove('overlay');
}
function applySnippetOverlay(ele) {
  if (!ele
    || ele.closest('.comment').classList.contains('preview_active')
    || ele.classList.contains('overlay')
    || ele.classList.contains('expanded')) {
    return;
  }

  const overflow = (ele.scrollHeight > ele.clientHeight || ele.scrollWidth > ele.clientWidth);

  ele.classList.toggle('overlay', overflow);
  if (overflow) {
    ele.addEventListener('click', overlayClickHandler, {once: true});
  } else {
    ele.removeEventListener('click', overlayClickHandler);
  }
}
const composeComment = (() => {
  // This function could potentially be very time intensive.
  // We can save some time by caching the response.
  const cache = new Map();
  document.addEventListener('bookmarkchange', (e) => {
    const eventType = e.detail.type;
    if (eventType == 'remove' || eventType == 'change') {
      const {commentId, category} = e.detail.record;
      const key = `${commentId}${category}`;
      cache.delete(key);
    } else if (eventType == 'dbClear') {
      cache.clear();
    }
  });

  return (entry) => {
    const {category, commentId, commentListId, commentAuthor, commentAuthorId,
      commentSnippet: commentBody, commentTimestamp: unixTimestamp} = entry;
    const key = `${commentId}${category}`;

    if (cache.has(key)) {
      const response = cache.get(key);

      //  resetting stylings
      response.querySelector(`.${SCRIPT_LABEL}-snippet`).classList.remove('expanded');
      response.classList.remove('preview_active');
      const quoteContainer = response.querySelector('.quote_container');
      if (quoteContainer.childElementCount) quoteContainer.firstElementChild.remove();

      return response;
    }

    const fullTimestamp = new Date(unixTimestamp).toString();
    const displayDate = new Intl.DateTimeFormat('en', {month: 'short', day: 'numeric', year: 'numeric'}).format(unixTimestamp);

    let commentTitle = ' ';
    let chapterComponent = '';
    switch (category) {
      case 'story':
        commentTitle += `${(entry.chapterName) ? '- ' : ''}${entry.title} - ${entry.postAuthor}`;
        chapterComponent = (entry.chapterName) ? ' ' + entry.chapterName : '';
        break;
      case 'group':
        commentTitle += `${entry.title}`;
        break;
      case 'group_forum':
        commentTitle += `${entry.title} - ${entry.postAuthor}`;
        break;
      case 'blog':
        commentTitle += `${entry.title} - ${entry.postAuthor}`;
        break;
      case 'user':
        commentTitle += `${entry.title}`;
        break;
    }

    let permalink;
    switch (category) {
      case 'story': case 'blog': case 'user': case 'group':
        permalink = `/${category}/${commentListId}/#comment/${commentId}`;
        break;
      case 'group_forum':
        permalink = `/group/${entry.groupId}/_/thread/${commentListId}/#comment/${commentId}`;
        break;
    }

    const ele = composeElement({
      tag: 'li', attributes: {class: 'comment', dataCommentId: commentId, dataCommentCategory: category},
      children: [{
        tag: 'div', attributes: {class: 'corner-stubb', dataCategoryIndicator: category}
      },{
        tag: 'span', attributes: {class: 'date'},
        children: [{
          tag: 'span',
          attributes: {title: fullTimestamp},
          text: displayDate
        }]
      },{
        tag: 'div', attributes: {class: 'comment_information'},
        children: [{
          tag: 'a', attributes: {href: permalink, class: 'subject'},
          children: [{
            tag: 'span', text: '#'
          },{
            tag: 'span', attributes: {class: 'chapter-component'}, text: chapterComponent
          },{
            tag: 'span', attributes: {class: 'hidden-when-nested'}, text: commentTitle
          }]
        },{
          tag: 'wbr'
        },{
          tag: 'span', attributes: {class: `${SCRIPT_LABEL}-comment-author-span`},
          children: [{
            tag: 'span', attributes: {class: `${SCRIPT_LABEL}-divider`}, text: '·'
          },{
            tag: 'a',
            attributes: {href: `/user/${commentAuthorId}/`, class: `${SCRIPT_LABEL}-comment-author`},
            text: commentAuthor
          }]
        },{
          tag: 'div', attributes: {class: 'buttons'},
          children: [{
            tag: 'a', attributes: {title: 'Toggle live preview'},
            children: [{tag: 'i', attributes: {class: 'fa fa-fw fa-desktop'}}]
          },{
            tag: 'a', attributes: {title: 'Delete bookmark'},
            children: [{tag: 'i', attributes: {class: 'fa fa-fw fa-trash-o'}}]
          }]
        }]
      },{
        tag: 'div', attributes: {class: `${SCRIPT_LABEL}-snippet-wrapper`},
        children: [{tag: 'div', attributes: {class: `${SCRIPT_LABEL}-snippet`}, text: commentBody}]
      },{
        tag: 'div', attributes: {class: `${SCRIPT_LABEL}-preview quote_container`}
      }]
    });

    cache.set(key, ele);
    return ele;
  };
})();
const toggleLivePreview = (() => {
  const responseCache = new Map();

  const makeURL = (commentId, category) => {
    const ajaxCategory = {
      story: 'story_comments',
      group: 'comments_group',
      group_forum: 'comments_group_thread',
      blog: 'blog_posts_comments',
      user: 'comments_user_page'
    };

    return `/ajax/comments/${ajaxCategory[category]}/${commentId}`;
  };

  // remove edited comments from cache
  document.addEventListener('bookmarkchange', (e) => {
    const eventType = e.detail.type;
    if (eventType == 'change' || eventType == 'remove') {
      const {commentId, category} = event.detail.record;
      responseCache.delete(makeURL(commentId, category));
    } else if (eventType == 'dbClear') {
      responseCache.clear();
    }
  });

  const fetchComment = (commentId, category) => {
    return new Promise((resolve, reject) => {
      const cacheMaxAge = 3600; // 3600 seconds == 1 hour
      const fetchURL = makeURL(commentId, category);
      const response = responseCache.get(fetchURL);
      if (response && (Math.floor((Date.now() - response.timestamp) / 1e3) < cacheMaxAge)) {
        resolve(response.body);
      } else {
        window.fetch(fetchURL, {credentials: 'same-origin'})
          .then(response => response.json())
          .then(obj => {
            if (obj.hasOwnProperty('error')) {
              responseCache.delete(fetchURL);
              reject(obj.error);
            } else {
              responseCache.set(fetchURL, {timestamp: Date.now(), body: obj.content});
              resolve(obj.content);
            }
          });
      }
    });
  };
  return (commentBody) => {
    const commentId = Number.parseInt(commentBody.dataset.commentId);
    const category = commentBody.dataset.commentCategory;
    const quoteContainer = commentBody.querySelector('.quote_container');
    const snippet = commentBody.querySelector(`.${SCRIPT_LABEL}-snippet`);

    if (quoteContainer.hasAttribute('fetching')) return;

    if (commentBody.classList.contains('preview_active')) {
      quoteContainer.firstElementChild.remove();
      commentBody.classList.remove('preview_active');
      applySnippetOverlay(snippet);
    } else {
      quoteContainer.setAttribute('fetching', '');
      fetchComment(commentId, category)
        .then(responseHTML => {
          quoteContainer.innerHTML = responseHTML;
          commentBody.classList.add('preview_active');
          quoteContainer.removeAttribute('fetching');
          if (quoteContainer.querySelector('.comment_data')) {
            updateBookmarkSnippet(commentId, category, quoteContainer.firstElementChild);
          }
          window.App.BindAll(quoteContainer);
          window.App.DispatchEvent(quoteContainer, 'loadVisibleImages');
        }).catch(e => {
          console.error(e);
          const btn = commentBody.querySelector('[title="Toggle live preview]"');
          btn.classList.add('disabled');
          btn.title = 'Could not fetch this comment';
          quoteContainer.removeAttribute('fetching');
        });
    }
  };
})();
function updateList(pageNumber) {
  const container = document.getElementById(`${SCRIPT_LABEL}-list-container`);
  if (!container) return;

  const sortMethod = container.dataset.sortMethod;
  const sortDirection = container.dataset.sortDirection;
  const ITEMS_PER_PAGE = 50;


  let bookmarkIndexName;
  switch (sortMethod) {
    case 'post':
      bookmarkIndexName = 'commentTimestamp';
      break;
    case 'bookmark-age':
      bookmarkIndexName = 'bookmarkTimestamp';
      break;
    case 'comment-age':
      bookmarkIndexName = 'commentTimestamp';
      break;
  }

  Promise.all([getAllRecords('bookmarkStore', bookmarkIndexName), getAllRecords('articleStore', 'articleTitle')]).then(records => {
    const [commentRecords, articleRecords] = records;

    const matchRecord = (record, property, query) => (record[property] && record[property].toLowerCase().indexOf(query.toLowerCase()) != -1);

    const temp = {};
    for (const button of document.getElementById(`${SCRIPT_LABEL}-filter`).querySelectorAll('[data-filter-type]')) {
      const type = button.dataset.filterType;
      temp[type] = button.classList.contains('active');
    }

    const categoryFilter = {
      story: temp['story'],
      blog: temp['blog'],
      group_forum: temp['forum'],
      user: temp['profile'],
      group: temp['profile'],
    };

    const textField = document.getElementById(`${SCRIPT_LABEL}-search-input`);
    const searchType = {title: undefined, comment: undefined, author: undefined};
    document.querySelectorAll(`#${SCRIPT_LABEL}-search [data-search-type]`).forEach(button => {
      const fieldName = button.dataset.searchType;
      searchType[fieldName] = button.classList.contains('active');
    });
    const queryString = textField.value;
    const doTextSearch = (new RegExp('\\S').test(queryString) && !Object.values(searchType).every(val => !val));

    const keepArticle = [];
    const matchedComments = commentRecords.map(comment => {
      // denormalize the title for easier filtering in the next step
      const article = articleRecords.find(record => (record.commentListId == comment.commentListId && record.category == comment.category));
      comment.title = article.title;
      comment.postAuthor = article.author;
      return comment;
    })
    .filter(comment => {
      // find the comments we want to display
      if (!categoryFilter[comment.category]) return false;

      return ((!doTextSearch)
        || (searchType.author && matchRecord(comment, 'commentAuthor', queryString))
        || (searchType.comment && matchRecord(comment, 'commentSnippet', queryString))
        || (searchType.title && matchRecord(comment, 'chapterName', queryString))
        || (searchType.title && matchRecord(comment, 'title', queryString))
        || (searchType.title && matchRecord(comment, 'postAuthor', queryString)));
    });

    matchedComments.forEach(({commentListId, category}) => keepArticle.push({commentListId, category}));
    // keep a record of article entries for displaying in 'post' view
    const matchedArticles = (!doTextSearch && Object.values(categoryFilter).every(val => val))
      ? articleRecords
      : articleRecords.filter(article => {
        return (categoryFilter[article.category]
          && keepArticle.find(({commentListId, category}) => (article.commentListId == commentListId && article.category == category)));
      });

    // pagination
    const paginationBar = document.getElementById(`${SCRIPT_LABEL}-pagination`);
    const pageNumberContainer = document.getElementById(`${SCRIPT_LABEL}-numbered-pages`);
    const totalPageCount = (sortMethod == 'post')
      ? Math.ceil((matchedArticles.length / ITEMS_PER_PAGE) || 1)
      : Math.ceil((matchedComments.length / ITEMS_PER_PAGE) || 1);
    const currentPageNumber = Math.min(totalPageCount, (pageNumber || paginationBar.dataset.pageNumber));
    const doPagination = (totalPageCount != 1) && getLocalStorage('comment_pagination');

    const startIndex = (doPagination)
      ? (currentPageNumber - 1) * ITEMS_PER_PAGE
      : 0;
    const endIndex = (doPagination)
      ? currentPageNumber * ITEMS_PER_PAGE
      : totalPageCount * ITEMS_PER_PAGE;

    const prevLink = document.getElementById(`${SCRIPT_LABEL}-prev-page`);
    const nextLink = document.getElementById(`${SCRIPT_LABEL}-next-page`);
    prevLink.classList.toggle('disabled', currentPageNumber == 1);
    nextLink.classList.toggle('disabled', currentPageNumber == totalPageCount);
    prevLink.dataset.goToPage = currentPageNumber - 1;
    nextLink.dataset.goToPage = currentPageNumber + 1;

    while (pageNumberContainer.firstChild) pageNumberContainer.firstChild.remove();

    paginationBar.classList.toggle('hidden', !doPagination);
    if (doPagination) {
      for (let i = 1, dividerL = false, dividerR = false; i <= totalPageCount; ++i) {
        if (i > 2 && i < currentPageNumber - 3) {
          if (!dividerL) {
            pageNumberContainer.appendChild(composeElement({tag: 'span', text: '...'}));
            dividerL = true;
          }
          continue;
        }
        if (i < totalPageCount - 1 && i > currentPageNumber + 3) {
          if (!dividerR) {
            pageNumberContainer.appendChild(composeElement({tag: 'span', text: '...'}));
            dividerR = true;
          }
          continue;
        }
        pageNumberContainer.appendChild(composeElement({
          tag: 'a',
          attributes: {
            class: `${SCRIPT_LABEL}-pagination-link ${(i == currentPageNumber) ? 'disabled' : ''}`,
            dataGoToPage: i
          },
          text: i
        }));
      }
    }

    const frag = document.createDocumentFragment();
    if (sortMethod == 'post') {
      for (let i = startIndex, len = matchedArticles.length; i < endIndex && i < len; ++i) {
        const article = matchedArticles[i];
        const category = article.category;
        const comments = article.comments;

        let displayTitle;
        let displayAuthor;
        switch (category) {
          case 'story': case 'blog': case 'group_forum':
            displayTitle = `${article.title}${(article.author) ? ' - ' : ''}`;
            displayAuthor = article.author;
            break;
          case 'user': case 'group':
            displayTitle = `${article.title}`;
            displayAuthor = '';
            break;
        }
        const articleElement = composeElement({
          tag: 'li', attributes: {class: `${SCRIPT_LABEL}-article-container`},
          children: [{
            tag: 'div', attributes: {class: `${SCRIPT_LABEL}-title-row`, dataCategoryIndicator: category},
            children: [{
              tag: 'span',
              children: [{tag: 'i', attributes: {class: 'fa fa-fw expand-indicator'}}]
            },{
              tag: 'span', text: displayTitle
            },{
              tag: 'span',
              children: [{tag: 'i', text: displayAuthor}]
            }]
          },{
            tag: 'ul', attributes: {class: `${SCRIPT_LABEL}-comment-container`}
          }]
        });

        const commentContainer = articleElement.querySelector(`.${SCRIPT_LABEL}-comment-container`);

        if (sortDirection == 'asc') comments.reverse();
        for (let i = 0, len = comments.length; i < len; ++i) {
          const commentId = comments[i];
          const comment = matchedComments.find(record => (record.commentId == commentId && record.category == category));
          if (comment) commentContainer.appendChild(composeComment(comment));
        }

        frag.appendChild(articleElement);
      }
    } else {
      if (sortDirection == 'asc') matchedComments.reverse();
      for (let i = startIndex, len = matchedComments.length; i < endIndex && i < len; ++i) {
        frag.appendChild(composeComment(matchedComments[i]));
      }
    }

    // don't collapse comments
    if (!getLocalStorage('comment_collapse')) {
      frag.querySelectorAll(`.${SCRIPT_LABEL}-snippet`).forEach(ele => {
        ele.classList.add('expanded');
        ele.classList.remove('overlay');
        ele.removeEventListener('click', overlayClickHandler);
      });
    }

    /* finished iterating through all the records, insert elements into document */
    // clear the container first
    while (container.firstChild) container.firstChild.remove();

    container.appendChild(frag);
    container.scrollTop = 0;

    // attach listeners for snippet expanders, need to be live on the document to calculate if content is overflowing
    if (!document.getElementById(`${SCRIPT_LABEL}--pop-up-wrapper`).classList.contains('hidden') && getLocalStorage('comment_collapse')) {
      container.querySelectorAll(`.${SCRIPT_LABEL}-snippet`).forEach(applySnippetOverlay);
    }

    window.App.binders.forEach(fn => fn(container)); // User card
  });
}
function displayBookmarkPanel() {
  const menu = document.getElementById(`${SCRIPT_LABEL}--pop-up-wrapper`);
  if (!menu) return;

  menu.classList.toggle('nightmode', checkNightmode());   // apply the nightmode styling
  menu.classList.remove('hidden');
  menu.querySelectorAll(`.${SCRIPT_LABEL}-snippet`).forEach(applySnippetOverlay);
}
function initBookmarkPanel() {
  const title = 'Comment Bookmarks';

  const menu = composeElement({tag: 'div', attributes: {id: `${SCRIPT_LABEL}--pop-up-wrapper`, class: 'hidden'}});
  menu.innerHTML = `
<div id="${SCRIPT_LABEL}--pop-up" class="drop-down-pop-up-container">
  <div class="drop-down-pop-up">
    <h1>
      <span><i class="fa fa-folder-open"></i> ${title}</span>
      <a class="close_button"></a>
    </h1>
    <div class="drop-down-pop-up-content">
      <div class="${SCRIPT_LABEL}--window-content">
        <div class="${SCRIPT_LABEL}-flex-wrapper">
          <div class="${SCRIPT_LABEL}-options-bar">
            <div class="${SCRIPT_LABEL}-options-group ${SCRIPT_LABEL}-flex-grow">
              Display setting:
              <button class="${SCRIPT_LABEL}-options-button" id="${SCRIPT_LABEL}-comment-collapse" type="button" title="Collapse all comment snippets by default">Compact view</button>
              <button class="${SCRIPT_LABEL}-options-button" id="${SCRIPT_LABEL}-comment-pagination" type="button" title="Display 50 items per page">Pagination</button>
            </div>
            <div class="${SCRIPT_LABEL}-options-group">
              <button class="${SCRIPT_LABEL}-options-button clickable" id="${SCRIPT_LABEL}-stats" type="button" title="View statistics">Stats</button>
              <button class="${SCRIPT_LABEL}-options-button clickable" id="${SCRIPT_LABEL}-export-button" type="button" title="Backup bookmarks to file">Export</button>
              <button class="${SCRIPT_LABEL}-options-button clickable" id="${SCRIPT_LABEL}-import-button" type="button" title="Import and merge bookmarks from backup">Import</button>
              <button class="${SCRIPT_LABEL}-options-button clickable" id="${SCRIPT_LABEL}-clear-button" type="button" title="Delete all stored bookmarks">Clear</button>
            </div>
          </div>
        </div>
        <div class="${SCRIPT_LABEL}-list">
          <div class="${SCRIPT_LABEL}-flex-wrapper">
            <div class="${SCRIPT_LABEL}-list-header">
              <span>Sort by: </span>
              <select id="${SCRIPT_LABEL}-sorting">
                <option value="post">parent post</option>
                <option value="comment-age">comment date</option>
                <option value="bookmark-age">added date</option>
              </select>
              <select id="${SCRIPT_LABEL}-sorting-direction">
                <option value="asc">newest first</option>
                <option value="desc">oldest first</option>
              </select>
              <span id="${SCRIPT_LABEL}-filter">
                <span>Filter by:</span>
                <button class="${SCRIPT_LABEL}-options-button" title="Story comments" data-filter-type="story">Story</button>
                <button class="${SCRIPT_LABEL}-options-button" title="Blog comments" data-filter-type="blog">Blog</button>
                <button class="${SCRIPT_LABEL}-options-button" title="Forum posts" data-filter-type="forum">Forum</button>
                <button class="${SCRIPT_LABEL}-options-button" title="User and group profile comments" data-filter-type="profile">Profile</button>
              </span>
              <span id="${SCRIPT_LABEL}-search">
                <span>Search:</span>
                <input id="${SCRIPT_LABEL}-search-input" type="text" size="15">
                <button class="${SCRIPT_LABEL}-options-button active" title="Title, chapter name, or the author of the parent post" data-search-type="title">Title</button>
                <button class="${SCRIPT_LABEL}-options-button active" title="Comment body" data-search-type="comment">Comment</button>
                <button class="${SCRIPT_LABEL}-options-button active" title="Author of the comment" data-search-type="author">Commenter</button>
              </span>
            </div>
          </div>
          <div id="${SCRIPT_LABEL}-pagination" data-page-number="1">
            <a id="${SCRIPT_LABEL}-prev-page" class="${SCRIPT_LABEL}-pagination-link">Prev</a>
            <span id="${SCRIPT_LABEL}-numbered-pages"></span>
            <a id="${SCRIPT_LABEL}-next-page" class="${SCRIPT_LABEL}-pagination-link">Next</a>
          </div>
          <ul id="${SCRIPT_LABEL}-list-container">
          </ul>
        </div>
      </div>
    </div>
    <div class="drop-down-pop-up-footer">
      <center>
        <button class="styled_button" style="padding: 5px 40px;">Close</button>
      </center>
    </div>
  </div>
</div>`;

  // Overlays may need to be added or removed due to change to viewport
  const resizeHandler = (() => {
    const interval = 500;
    let lastCall = 0;
    return function () {
      const now = Date.now();
      if (now - lastCall < interval) return;
      menu.querySelectorAll(`.${SCRIPT_LABEL}-snippet`).forEach(applySnippetOverlay);
      lastCall = now;
    };
  })();

  const bindClickHandlers = (() => {
    const callbacks = [];
    menu.addEventListener('click', e => {
      for (const callback of callbacks) {
        const {selector, fn, bubble} = callback;
        const ele = e.target.closest(selector);
        if ((bubble && ele) || e.target.matches(selector)) {
          if (ele.tagName == 'BUTTON') ele.blur();
          fn(ele, e);
        }
      }
    });
    return (selector, fn, bubble = true) => {
      callbacks.push({selector, fn, bubble});
    };
  })();

  /* --- attaching event listeners --- */

  window.addEventListener('resize', resizeHandler);

  // pagination links
  bindClickHandlers(`.${SCRIPT_LABEL}-pagination-link`, link => {
    if (link.classList.contains('disabled')) return;
    const pageNumber = Number.parseInt(link.dataset.goToPage);
    document.getElementById(`${SCRIPT_LABEL}-pagination`).dataset.pageNumber = pageNumber;
    updateList(pageNumber);
  });

  // article container expand/collapse
  bindClickHandlers(`.${SCRIPT_LABEL}-title-row`, target => {
    const container = target.closest(`.${SCRIPT_LABEL}-article-container`);
    container.classList.toggle('expanded');
    container.querySelectorAll(`.${SCRIPT_LABEL}-snippet`).forEach(applySnippetOverlay);
  });

  bindClickHandlers('[title="Toggle live preview"]', btn => {
    const comment = btn.closest('.comment');
    toggleLivePreview(comment);
  });

  // comment delete button
  bindClickHandlers('[title="Delete bookmark"]', btn => {
    const comment = btn.closest('.comment');
    const id = Number.parseInt(comment.dataset.commentId);
    const cat = comment.dataset.commentCategory;

    window.ConfirmPrompt('Are you sure you want to delete this bookmark?', () => {
      deleteBookmark(id, cat)
        .then(() => {
          comment.remove();
          const ele = document.getElementById('comment_' + id);
          if (ele && cat == getPageCategory()) toggleOff(id);
        })
        .catch(exception => {
          if (exception.name == 'InvalidStateError') {
            console.warn(exception);
            comment.remove();
            const ele = document.getElementById('comment_' + id);
            if (ele && cat == getPageCategory()) toggleOff(id);
          } else {
            throw exception;
          }
        });
    });
  });

  // close bookmark panel
  menu.dataset.pendingListUpdate = '0';
  bindClickHandlers(`.close_button, .drop-down-pop-up-footer button, #${SCRIPT_LABEL}--pop-up-wrapper, .subject`, () => {
    window.removeEventListener('resize', resizeHandler);
    document.getElementById(`${SCRIPT_LABEL}-list-container`).scrollTop = 0;
    menu.classList.add('hidden');
    if (menu.dataset.pendingListUpdate == '1') {
      menu.dataset.pendingListUpdate = '0';
      updateList();
    } else {
      menu.querySelectorAll('.expanded').forEach(ele => ele.classList.remove('expanded'));
      menu.querySelectorAll('.preview_active').forEach(ele => ele.classList.remove('preview_active'));
      menu.querySelectorAll('.quote_container > *').forEach(ele => ele.remove());
    }
  }, false);

  // display stats
  bindClickHandlers(`#${SCRIPT_LABEL}-stats`, () => {
    Promise.all([getAllRecords('bookmarkStore'), getAllRecords('articleStore')]).then(records => {
      const [bookmarkStore, articleStore] = records;
      const totalBookmarks = bookmarkStore.length;
      const totalArticles = articleStore.length;

      let storyCount = 0;
      let blogCount = 0;
      let forumCount = 0;
      let profileCount = 0;

      bookmarkStore.forEach(entry => {
        switch (entry.category) {
          case ('story'): ++storyCount;
            break;
          case ('blog'): ++blogCount;
            break;
          case ('group_forum'): ++forumCount;
            break;
          case ('user'): case ('group'): ++profileCount;
            break;
        }
      });

      const title = 'Bookmark Statistics';
      const innerHTML = `
You have bookmarked a total of <b>${totalBookmarks}</b> ${totalBookmarks == 1 ? 'comment' : 'comments'} from <b>${totalArticles}</b> ${totalArticles == 1 ? 'post' : 'posts'}.
<br>
<br>
<ul style="list-style-type: initial; margin-left: 20px;">
  <li><b>${storyCount}</b> from stories</li>
  <li><b>${blogCount}</b> from blog posts</li>
  <li><b>${forumCount}</b> from forum threads</li>
  <li><b>${profileCount}</b> from user and group profiles</li>
</ul>
`;
      window.StyledAlert(innerHTML, title, {align: 'left'});
    });
  });

  // comment collapse
  menu.querySelector(`#${SCRIPT_LABEL}-comment-collapse`).classList.toggle('active', getLocalStorage('comment_collapse'));
  bindClickHandlers(`#${SCRIPT_LABEL}-comment-collapse`, button => {
    button.classList.toggle('active');
    setLocalStorage('comment_collapse', button.classList.contains('active'));
    updateList(1);
  });

  // pagination toggle
  menu.querySelector(`#${SCRIPT_LABEL}-comment-pagination`).classList.toggle('active', getLocalStorage('comment_pagination'));
  bindClickHandlers(`#${SCRIPT_LABEL}-comment-pagination`, button => {
    button.classList.toggle('active');
    setLocalStorage('comment_pagination', button.classList.contains('active'));
    updateList(1);
  });

  // import/export
  bindClickHandlers(`#${SCRIPT_LABEL}-export-button`, () => {
    window.ConfirmPrompt('Export your bookmarks to file?', exportDB);
  });
  bindClickHandlers(`#${SCRIPT_LABEL}-import-button`, () => {
    window.ConfirmPrompt('This will add all bookmarks from the selected backup to your present collection, proceed?', importDB);
  });
  bindClickHandlers(`#${SCRIPT_LABEL}-clear-button`, () => {
    window.ConfirmPrompt('This will delete all currently stored bookmarks, proceed?', () => {
      window.ConfirmPrompt('Are you REALLY sure?', clearDB);
    });
  });

  // don't navigate on relative comment links inside live preview
  bindClickHandlers('.quote_container a[href^="#comment/"]', (target, event) => {
    event.preventDefault();
    event.stopPropagation();
  }, false);

  // comment category filter
  const filter = getLocalStorage('category_filter');
  for (const button of menu.querySelectorAll(`#${SCRIPT_LABEL}-filter [data-filter-type]`)) {
    const type = button.dataset.filterType;
    button.classList.toggle('active', filter[type]);
  }
  bindClickHandlers(`#${SCRIPT_LABEL}-filter [data-filter-type]`, target => {
    const filter = getLocalStorage('category_filter');
    target.classList.toggle('active');
    filter[target.dataset.filterType] = target.classList.contains('active');
    setLocalStorage('category_filter', filter);
    updateList(1);
  });

  // search options
  bindClickHandlers(`#${SCRIPT_LABEL}-search [data-search-type]`, target => {
    const textField = document.getElementById(`${SCRIPT_LABEL}-search-input`);
    target.classList.toggle('active');
    if (new RegExp('\\S').test(textField.value)) updateList(1);  // only update list if textfield's not empty
  });

  const textField = menu.querySelector(`#${SCRIPT_LABEL}-search-input`);
  textField.addEventListener('keydown', e => {
    if (e.key !== 'Escape') return;
    const value = e.target.value;
    e.stopPropagation();
    e.preventDefault();
    if (value !== '') {
      e.target.value = '';
      updateList(1);
    } else {
      e.target.blur();
    }
  });
  textField.addEventListener('focus', e => {
    const textField = e.target;
    textField.setSelectionRange(0, textField.value.length);
    e.stopPropagation();
    e.preventDefault();
  });
  textField.addEventListener('input', (() => {
    const delay = 100;   // ms
    let timeoutId = null;
    return () => {
      window.clearTimeout(timeoutId);
      timeoutId = window.setTimeout(() => {
        updateList(1);
      }, delay);
    };
  })());

  // dropdown menus
  const sortMethod = getLocalStorage('sort-method');
  const sortDirection = getLocalStorage('sort-direction');
  menu.querySelector(`#${SCRIPT_LABEL}-list-container`).dataset.sortMethod = sortMethod;
  menu.querySelector(`#${SCRIPT_LABEL}-list-container`).dataset.sortDirection = sortDirection;

  let select;
  select = menu.querySelector(`#${SCRIPT_LABEL}-sorting`);
  select.value = sortMethod;
  select.addEventListener('change', e => {
    const sortMethod = e.target.value;
    setLocalStorage('sort-method', sortMethod);
    menu.querySelector(`#${SCRIPT_LABEL}-list-container`).dataset.sortMethod = sortMethod;
    updateList(1);
  });
  select = menu.querySelector(`#${SCRIPT_LABEL}-sorting-direction`);
  select.value = sortDirection;
  select.addEventListener('change', e => {
    const sortDirection = e.target.value;
    setLocalStorage('sort-direction', sortDirection);
    menu.querySelector(`#${SCRIPT_LABEL}-list-container`).dataset.sortDirection = sortDirection;
    updateList(1);
  });

  // UI update in response to database change
  document.addEventListener('bookmarkchange', (e) => {
    const eventType = e.detail.type;

    // update the toggle status of any bookmark button on the page,
    // skip on eventType 'change' because it wouldn't affect button toggle state
    if (eventType != 'change') {
      document.querySelectorAll('div.comment').forEach(addButton);
    }

    // don't update if the menu is open
    // reset to display page one on database clear or import
    if (eventType == 'dbClear' || eventType == 'dbImport') {
      updateList(1);
    } else if (menu.classList.contains('hidden')) {
      updateList(); // keep the page number
    } else {
      menu.dataset.pendingListUpdate = '1';
    }
  });

  const dispatchBookmarkChangeEvent = (data) => {
    // only dispatch even when page is visible
    if (document.hidden) {
      document.addEventListener('visibilitychange', () => {
        if (document.visibilityState == 'visible') document.dispatchEvent(new CustomEvent('bookmarkchange', {detail: data}));
      }, {once: true});
    } else {
      document.dispatchEvent(new CustomEvent('bookmarkchange', {detail: data}));
    }
  };

  // updateList across othere instances
  if (BC_SUPPORT) {
    const bc = new BroadcastChannel(SCRIPT_LABEL);
    bc.onmessage = (e) => {
      if (e.data.hasOwnProperty('lastModified')) {
        const data = e.data.lastModified;
        dispatchBookmarkChangeEvent(data);
      }
    };
  } else {
    // fallback by listening to localStorage changes
    window.addEventListener('storage', (() => {
      let store = getLocalStorage('db_last_modified') || setLocalStorage('db_last_modified', {timestamp: 0, data: {}});
      let lastModified = store.timestamp;
      return (e) => {
        if (e.key !== SCRIPT_LABEL) return;

        store = getLocalStorage('db_last_modified');
        if (lastModified < store.timestamp) {
          lastModified = store.timestamp;
          dispatchBookmarkChangeEvent(store.data);
        }
      };
    })());
  }


  const applyScrolltrap = (() => {
    const selectors = [];

    menu.addEventListener('wheel', (e) => {

      // let ele be the closest ancestor that matches one of the selectors
      let ele = null;
      let temp = e.target;
      do {
        ele = (selectors.some(selector => temp.matches(selector))) ? temp : null;
        temp = temp.parentElement;
      } while (temp !== null && temp !== menu && ele === null);

      if (!ele) {
        e.preventDefault();
        e.stopPropagation();
        return;
      }

      const position = ele.scrollTop;
      const scrollBottomPosition = ele.scrollHeight - ele.clientHeight;
      const scrollDirection = e.deltaY;

      if (scrollDirection > 0 && position >= scrollBottomPosition
        || scrollDirection < 0 && position <= 0) {
        e.preventDefault();
        e.stopPropagation();
      }

    });

    return (selector) => {
      selectors.push(selector);
    };
  })();

  // prevent scrolling of background
  applyScrolltrap(`#${SCRIPT_LABEL}-list-container`);
  applyScrolltrap(`.${SCRIPT_LABEL}--window-content`);

  document.querySelector('.content').appendChild(menu);
  updateList();
}
function initUI() {
  const creationObserver = (function () {
    const observedElements = [];
    const callbacks = [];
    const executeCallback = (fn, node) => {
      if (observedElements.some(observedNode => observedNode == node)) return;
      observedElements.push(node);
      fn(node);
    };
    const obs = new MutationObserver(mutationRecords => {
      mutationRecords.forEach(mutation => {
        mutation.addedNodes.forEach(node => {
          if (node.nodeType !== Node.ELEMENT_NODE) return;
          callbacks.forEach(({selector, fn}) => {
            if (node.matches(selector)) executeCallback(fn, node);
            node.querySelectorAll(selector).forEach(childNode => executeCallback(fn, childNode));
          });
        });

      });
    });
    obs.observe(document.body, {childList: true, subtree: true});

    return function (selector, fn) {
      document.querySelectorAll(selector).forEach(node => executeCallback(fn, node));
      callbacks.push({selector, fn});
    };
  })();

  // bookmark panel button
  const menuButton = composeElement({
    tag: 'li',
    children: [{
      tag: 'a',
      attributes: {class: `${SCRIPT_LABEL}-menu-button`},
      children: [{
        tag: 'i',
        attributes: {class: 'fa fa-bookmark'}
      },{
        tag: 'span',
        text: 'Bookmarks'
      }]
    }]
  });
  creationObserver('nav.user_toolbar>ul', function (toolbar) {
    // desktop view
    const button = menuButton.cloneNode(true);
    button.addEventListener('click', displayBookmarkPanel);
    toolbar.insertBefore(button, toolbar.children[toolbar.children.length - 2]);
  });
  creationObserver('.navigation-drawer-list a[href="/search/blog-posts"]', target => {
    // mobile view
    const drawer = target.closest('.navigation-drawer-list');
    const button = menuButton.cloneNode(true);
    button.addEventListener('click', displayBookmarkPanel);
    drawer.insertBefore(button, drawer.firstElementChild);
  });

  creationObserver('.comment_list', list => {
    list.addEventListener('click', commentButtonHandler);
  });

  creationObserver('div.comment', comment => {
    comment.removeAttribute(`${SCRIPT_LABEL}-observer-pending`);
    addButton(comment);
  });

  // override the z-index on the author popup
  // 10000 because that's the z-index of the top navigation bars when it's in fixed mode
  creationObserver('.info-card-container', target => {
    function toggle(card) {
      if (document.getElementById(`${SCRIPT_LABEL}--pop-up-wrapper`).classList.contains('hidden')) {
        card.style.zIndex = '';
      } else {
        card.style.zIndex = '10000';
      }
    }
    new MutationObserver(mutationRecords => {
      mutationRecords.forEach(mutation => {
        toggle(mutation.target);
      });
    }).observe(target, {attributeFilter: ['class']});
    toggle(target);
  });

  // override the z-index on the stats window
  creationObserver('#dimmers .dimmer, body>.drop-down-pop-up-container', target => {
    if (document.getElementById(`${SCRIPT_LABEL}--pop-up-wrapper`).classList.contains('hidden')) return;
    target.style.zIndex = '10000';
  });

  // detect comment edit and apply updateBookmarkSnippet
  creationObserver('.comment_data', target => {
    const processMutationEvent = (mutation) => {
      const comment = mutation.target.closest('.comment');

      // check for edit button and if element is not hidden
      if (comment.querySelector('[data-click="toggleEditComment"]') && !mutation.target.classList.contains('hidden')) {
        const button = comment.querySelector(`.${SCRIPT_LABEL}-bookmark-button`);
        const commentId = Number.parseInt(button.dataset.commentId);
        const category = button.dataset.commentCategory;
        if (button.dataset.bookmarked == '1') {
          updateBookmarkSnippet(commentId, category, comment);
        }
      }

    };
    new MutationObserver(mutationRecords => {
      mutationRecords.forEach(processMutationEvent);
    }).observe(target, {attributeFilter: ['class']});
  });

  initBookmarkPanel();
}

initLocalStorage();
onReady(initCSS);
onReady(initUI);

})();