Greasy Fork is available in English.

Komica NGID

NG id and post on komica

// ==UserScript==
// @name         Komica NGID
// @description  NG id and post on komica
// @namespace    https://github.com/usausausausak
// @match        https://*.komica.org/*/*
// @match        https://*.komica1.org/*/*
// @match        https://*.komica2.net/*/*
// @match        https://komica2.net/*/*
// @match        http://2cat.tk/*/*/*
// @match        https://2cat.tk/*/*/*
// @match        http://gzone-anime.info/UnitedSites/*
// @version      2.1
// @grant        GM.setValue
// @grant        GM.getValue
// @grant        GM.addStyle
// ==/UserScript==
const Komica = {};

(function komicaHostMatcher(exports) {
  'use strict'

  const MATCHER = {
    'komica':      /^([^\.]*\.)?komica[12]?\.(org|net)$/,
    '2cat':        /^2cat\.tk$/,
    'gzone-anime': /^gzone-anime\.info$/,
  };

  function hostMatcher(location) {
    const host = location.host.replace(/:\d+$/, '');
    for (const [name, matcher] of Object.entries(MATCHER)) {
      if (matcher.test(host)) {
        return name;
      }
    }
    return null;
  }

  function hostMatcherOr(location, err) {
    const host = hostMatcher(location);
    return (host) ? host : err;
  }

  Object.entries({
    hostMatcher, hostMatcherOr,
  }).forEach(([key, fn]) => {
    exports[key] = fn;
  });
})(Komica);

(function komicaPostQueryer(exports) {
  'use strict'

  const POST_NO_FROM_POST_EL_ID_REGEXP = /^r(\d+)$/;

  const ID_FROM_NOWID_TEXT_REGEXP = /ID:(.+?)(?:\].*)?$/;
  function idFromNowIdText(nowIdText) {
    const matches = ID_FROM_NOWID_TEXT_REGEXP.exec(nowIdText);
    return (matches) ? matches[1] : null;
  }

  function idWithTailFromNowIdText(nowIdText) {
    const matches = ID_FROM_NOWID_TEXT_REGEXP.exec(nowIdText);
    if (!matches) {
      return null;
    } else {
      // Maybe has a id tail code, but we just ignore that.
      return matches[1].substr(0, 8);
    }
  }

  const QUERYERS_KOMICA = {
    queryThreads: function queryThreadsKomica() {
      return document.getElementsByClassName('thread');
    },
    queryPosts: function queryPostsKomica() {
      return document.getElementsByClassName('post');
    },
    queryNo: function queryNoKomica(post) {
      if (post.dataset) {
        return post.dataset.no;
      } else {
        return null;
      }
    },
    queryId: function queryIdKomica(post) {
      const idEl = post.querySelector('.post-head .id');
      if (idEl) {
        return idEl.dataset.id;
      } else {
        const nowEl = post.querySelector('.post-head .now');
        if (nowEl) {
          return idFromNowIdText(nowEl.innerHTML);
        } else {
          return null;
        }
      }
    },
    queryThreadTitle: function queryThreadTitleKomica(post) {
      const titleEl = post.querySelector('span.title');
      if (titleEl) {
        return titleEl.innerText;
      } else {
        return null;
      }
    },
    queryName: function queryNameKomica(post) {
      const nameEl = post.querySelector('span.name');
      if (nameEl) {
        return nameEl.innerText;
      } else {
        return null;
      }
    },
    queryBody: function queryBodyKomica(post) {
      const bodyEl = post.querySelector('.quote');
      if (bodyEl) {
        return bodyEl.innerText;
      } else {
        return null;
      }
    },
    isThreadPost: function isThreadPostKomica(post) {
      return ((post.classList) && (post.classList.contains('threadpost')));
    },
    isReplyPost: function isReplyPostKomica(post) {
      return ((post.classList) && (post.classList.contains('reply')));
    },
    afterPostNoEl: function afterPostNoElKomica(post) {
      const noEl = post.querySelector('.post-head [data-no]');
      if (noEl) {
        return noEl.nextSibling;
      } else {
        return null;
      }
    },
  };

  const QUERYERS_2CAT = {
    queryThreads: function queryThreads2Cat() {
      return document.getElementsByClassName('threadpost');
    },
    queryPosts: function queryPosts2Cat() {
      return document.querySelectorAll('.threadpost, .reply');
    },
    queryNo: function queryNo2Cat(post) {
      const matches = POST_NO_FROM_POST_EL_ID_REGEXP.exec(post.id);
      if (matches) {
        return matches[1];
      } else {
        return null;
      }
    },
    queryId: function queryId2Cat(post) {
      const postHeadEl = post.querySelector('div:first-child label');
      if (postHeadEl) {
        return idWithTailFromNowIdText(postHeadEl.innerText);
      } else {
        return null;
      }
    },
    queryThreadTitle: function queryThreadTitle2Cat(post) {
      const titleEl = post.querySelector('span.title');
      if (titleEl) {
        return titleEl.innerText;
      } else {
        return null;
      }
    },
    queryName: function queryName2Cat(post) {
      const nameEl = post.querySelector('span.name');
      if (nameEl) {
        return nameEl.innerText;
      } else {
        return null;
      }
    },
    queryBody: function queryBody2Cat(post) {
      const bodyEl = post.querySelector('div:first-child .quote');
      if (bodyEl) {
        return bodyEl.innerText;
      } else {
        return null;
      }
    },
    isThreadPost: function isThreadPost2Cat(post) {
      return ((post.classList) && (post.classList.contains('threadpost')));
    },
    isReplyPost: function isReplyPost2Cat(post) {
      return ((post.classList) && (post.classList.contains('reply')));
    },
    afterPostNoEl: function afterPostNoEl2Cat(post) {
      const noEl = post.querySelector('div:first-child .qlink');
      if (noEl) {
        return noEl.nextSibling;
      } else {
        return null;
      }
    },
  };

  const QUERYERS_GZONE_ANIME = {
    ...QUERYERS_2CAT,
    queryId: function queryIdGzoneAnime(post) {
      const postHeadEl = post.querySelector('span.name').nextSibling;
      if ((postHeadEl) && (postHeadEl.nodeType === 3)) {
        return idFromNowIdText(postHeadEl.nodeValue);
      } else {
        return null;
      }
    },
    queryBody: function queryBodyGzoneAnime(post) {
      const bodyEl = post.querySelector('div:first-child .quote');
      if (bodyEl) {
        const body = bodyEl.innerText;
        const pushPostEl = bodyEl.querySelector('.pushpost');
        if (pushPostEl) {
          return body.substr(0, body.length - pushPostEl.innerText.length);
        } else {
          return body;
        }
      } else {
        return null;
      }
    },
  };

  const NULL_QUERYER = {
    queryThreads: function queryThreadsNull() {
      return [];
    },
    queryPosts: function queryPostsNull() {
      return [];
    },
    queryNo: function queryNoNull(post) {
      return null;
    },
    queryId: function queryIdNull(post) {
      return null;
    },
    queryThreadTitle: function queryThreadTitleNull(post) {
      return null;
    },
    queryName: function queryNameNull(post) {
      return null;
    },
    queryBody: function queryBodyNull(post) {
      return null;
    },
    isThreadPost: function isThreadPostNull(post) {
      return false;
    },
    isReplyPost: function isReplyPostNull(post) {
      return false;
    },
    afterPostNoEl: function afterPostNoElNull(post) {
      return null;
    },
  };

  const MAPPER = {
    'komica': QUERYERS_KOMICA,
    '2cat':   QUERYERS_2CAT,
    'gzone-anime': QUERYERS_GZONE_ANIME,
  };

  function postQueryer(host) {
    const ret = (MAPPER[host]) ? MAPPER[host] : NULL_QUERYER;
    return Object.assign({}, ret);
  }

  exports.postQueryer = postQueryer;
})(Komica);

(function komicaDialog(exports) {
  'use strict'

  const TAG = '[Komica_Dialog]';

  function insertDialog(name, id, namespace) {
    // WORKAROUND: GM4 double insert
    if (document.querySelector(`#${id}`)) {
      return;
    }

    const tabBox = createTabBox(namespace);

    function toggleDialog() {
      dialog.classList.toggle(`${namespace}-dialog-show`);
      if (dialog.classList.contains(`${namespace}-dialog-show`)) {
        tabBox.currentSelected = 0;
      }
    }

    const dialog = document.createElement('div');
    dialog.id = id;
    dialog.className = `${namespace}-dialog`;
    tabBox.appendTo(dialog);

    const footer = document.createElement('div');
    footer.className = `${namespace}-dialog-footer`;
    dialog.appendChild(footer);

    const closeBut = document.createElement('button');
    closeBut.className = `${namespace}-dialog-close-button`;
    closeBut.innerHTML = '閉じる';
    closeBut.addEventListener('click', toggleDialog, false);
    dialog.appendChild(closeBut);

    document.body.insertBefore(dialog, document.body.firstChild);

    // Insert toggle button to top links area.
    const toggleButton = document.createElement('a');
    toggleButton.className = 'text-button';
    toggleButton.innerHTML = name;
    toggleButton.addEventListener('click', toggleDialog, false);

    const anchor = document.querySelector('#toplink a:last-of-type');
    const parent = anchor.parentElement;
    const insertPoint = anchor.nextSibling;
    parent.insertBefore(document.createTextNode('] ['), insertPoint);
    parent.insertBefore(toggleButton, insertPoint);

    return { tabBox, footer };
  }

  function createTabBox(namespace) {
    const eventListener = { onswitch: [] };

    function addEventListener(name, cb) {
      if (!eventListener[name]) {
        // ignore unknown event
        return;
      }
      if (typeof cb === 'function') {
        eventListener[name].push(cb);
      } else {
        console.warn(TAG, 'event listener not a function');
      }
    }

    function emitEvent(name, ...args) {
      try {
        eventListener[name].forEach(cb => cb(...args));
      } catch (e) {
        console.error(TAG, e);
      }
    }

    const tabBox = document.createElement('div');
    tabBox.className = `${namespace}-tabbox-header`;
    const pageBox = document.createElement('div');
    pageBox.className = `${namespace}-tabbox-container`;

    const pages = [];
    let currentSelected = -1;

    function addPage(title = null) {
      const index = pages.length;

      const page = document.createElement('div');
      page.className = `${namespace}-tabbox-page`;
      pageBox.appendChild(page);

      function addTab(title) {
        const tab = document.createElement('div');
        tab.className = `${namespace}-tabbox-tab`;
        tab.innerHTML = title;
        tab.addEventListener('click', () => switchTab(index), false);
        tabBox.appendChild(tab);
        return tab;
      }
      const tab = (title == null) ? null : addTab(title);

      const newPage = { index, page, tab };
      pages.push(newPage);
      return newPage;
    }

    function getPage(index) {
      if ((index < 0) || (index >= pages.length)) {
        console.error(TAG, `invalid tab index: ${index}`);
        return null;
      }

      return pages[index].page;
    }

    function switchTab(index) {
      if ((index < 0) || (index >= pages.length)) {
        console.error(TAG, `invalid tab index: ${index}`);
        return;
      } else if (currentSelected == index) {
        return;
      }

      const prevIndex = currentSelected;
      const { page, tab } = pages[index];

      // emit before show to make time to render
      currentSelected = index;
      emitEvent('onswitch', index, page);

      // hide current tab
      if (prevIndex >= 0) {
        // hide current tab
        const { page, tab } = pages[prevIndex];
        if (tab) {
          tab.classList.remove(`${namespace}-tabbox-selected`);
        }
        page.classList.remove(`${namespace}-tabbox-selected`);
      }

      if (tab) {
        tab.classList.add(`${namespace}-tabbox-selected`);
      }
      page.classList.add(`${namespace}-tabbox-selected`);
    }

    function getCurrentPage() {
      if ((currentSelected < 0) || (currentSelected >= pages.length)) {
        return null;
      } else {
        return pages[currentSelected].page;
      }
    }

    return {
      get currentSelected() { return currentSelected; },
      set currentSelected(index) { switchTab(index); },
      getCurrentPage,
      addPage, getPage,
      appendTo(parent) {
        parent.appendChild(tabBox);
        parent.appendChild(pageBox);
      },
      on(eventName, cb) { addEventListener(`on${eventName}`, cb); },
    };
  }

  exports.insertDialog = insertDialog;
})(Komica);


// from https://greasemonkey.github.io/gm4-polyfill/gm4-polyfill.js

if (typeof GM == 'undefined') {
  this.GM = {};
}

if (typeof GM_addStyle == 'undefined') {
  this.GM_addStyle = (aCss) => {
    'use strict';
    let head = document.getElementsByTagName('head')[0];
    if (head) {
      let style = document.createElement('style');
      style.setAttribute('type', 'text/css');
      style.textContent = aCss;
      head.appendChild(style);
      return style;
    }
    return null;
  };
}

if (typeof GM['addStyle'] == 'undefined') {
  GM['addStyle'] = function(...args) {
    return new Promise((resolve, reject) => {
      try {
        resolve(GM_addStyle.apply(this, args));
      } catch (e) {
        reject(e);
      }
    });
  };
}

(async function () {
  "use strict";

  const TAG = '[Komica_NGID]';

  const DEFAULT_STLYE_VARS = `
:root {
  --ngid-primary-background-color: #FFFFEE;
  --ngid-secondary-background-color: #F0E0D6;
  --ngid-highlight-background-color: #EEAA88;
  --ngid-highlight-color: #800000;
  --ngid-text-button-color: #00E;
  --ngid-text-button-hover-color: #D00;
  --ngid-separator-color: #000;
  --ngid-primary-shadow-color: #5f5059;
  --ngid-warning-color: #D00;
}
`;

  const GLOBAL_STYLE = `
.ngid-destroy {
  display: none;
}

.ngid-transparent-ng {
  display: none;
}

.ngid-ngpost {
  opacity: 0.3;
}

.ngid-text-button {
  cursor: pointer;
  color: var(--ngid-text-button-color);
}

.ngid-text-button:hover {
  color: var(--ngid-text-button-hover-color);
}

.ngid-context-menu {
  display: inline-flex;
  flex-direction: column;
  visibility: hidden;
  position: absolute;
  padding: 5px 10px;
  border-radius: 5px;
  margin-top: calc(-1.7em - 10px);
  transition: margin 100ms;
  width: max-content;
  background-color: var(--ngid-highlight-background-color);
}

.ngid-context:hover .ngid-context-menu {
  visibility: visible;
  margin-top: -1.7em;;
}

.ngid-ngpost .ngid-context-menu {
  color: var(--ngid-warning-color);
}

.popup_area .ngid-context {
  display: none;
}

.ngid-context {
  cursor: pointer;
  display: inline-block;
}

.ngid-context summary {
  list-style: none;
}

.ngid-context summary::-webkit-details-marker {
  display: none;
}

.ngid-context-menu-close-button {
  text-align: center;
  display: none;
}

@media screen and (max-device-width: 600px) {
  .ngid-context-menu  {
    visibility: visible;
    margin: -1.7em 6px 0 6px;
    width: calc(100% - 32px);
    left: 0;
  }

  .ngid-context-menu-close-button {
    display: unset;
    align-self: center;
  }
}
`;
  const DIALOG_STYLE = `
.ngid-dialog {
  visibility: hidden;
  position: fixed;
  top: -10px;
  z-index: 1;
  opacity: 0;
  display: grid;
  grid-template: "h h" min-content "c c" auto "f f" min-content / auto max-content;
  width: 40%;
  height: 50%;
  margin: 0 30%;
  overflow: hidden;
  border-radius: 5px;
  box-shadow: 0 0 15px 5px var(--ngid-primary-shadow-color);
  background-color: var(--ngid-primary-background-color);
  transition: top 100ms, visibility 100ms, opacity 100ms;
}

.ngid-dialog-show {
  visibility: visible;
  opacity: 1;
  top: 30px;
}

.ngid-dialog-footer {
  place-self: center start;
  margin: 10px 20px;
}

.ngid-dialog-close-button {
  place-self: center end;
  margin: 10px 20px;
}

.ngid-tabbox-header {
  grid-area: h;
  display: flex;
  justify-content: center;
  background-color: var(--ngid-secondary-background-color);
}

.ngid-tabbox-tab {
  cursor: pointer;
  flex: 1;
  padding: 7px 12px;
  font-weight: bold;
}

.ngid-tabbox-tab:hover {
  background-color: var(--ngid-highlight-background-color);
  color: var(--ngid-highlight-color);
}

.ngid-tabbox-tab.ngid-tabbox-selected {
  background-color: var(--ngid-highlight-background-color);
  color: var(--ngid-highlight-color);
}

.ngid-tabbox-container {
  grid-area: c;
  display: flex;
  overflow-y: auto;
}

.ngid-tabbox-page {
  width: 0;
  opacity: 0;
  overflow-y: scroll;
  overflow-x: hidden;
  transition: opacity 200ms;
}

.ngid-tabbox-page.ngid-tabbox-selected {
  width: 100%;
  opacity: 1;
  padding: 0 10px;
}

.ngid-listitem {
  cursor: pointer;
  display: flex;
  justify-content: space-between;
  padding: 5px 10px;
  margin: 2px 0;
}

.ngid-listitem:hover {
  background-color: var(--ngid-highlight-background-color);
  color: var(--ngid-highlight-color);
}

.ngid-inputfield {
  display: flex;
  justify-content: center;
  padding: 7px 5px;
  border-bottom: 1px solid var(--ngid-separator-color);
}

.ngid-inputfield input {
  flex: 1;
}

.ngid-lineedit-button {
  margin-left: 10px;
}

.ngid-lineedit-saveview {
  display: flex;
  justify-content: space-between;
  padding: 7px 5px;
}

.ngid-lineedit-textview {
  flex: 1;
}

.ngid-listitem span {
  max-width: 90%;
  overflow-wrap: break-word;
}

@media screen and (max-device-width: 600px) {
  .ngid-dialog {
    width: calc(100% - 20px);
    margin: 0 10px;
  }
}
`;

  // We need diffence style at diffence host.
  const POLYFILL_STYLE = `
#toplink .text-button {
    cursor: pointer;
    color: var(--ngid-text-button-color);
    text-decoration: underline;
}

#toplink .text-button:hover {
    color: var(--ngid-text-button-hover-color);
}

.ngid-context {
    cursor: pointer;
    color: var(--ngid-text-button-color);
    margin-left: 0.2em; /* Nice try! */
}

.ngid-context .text-button:hover {
    color: var(--ngid-text-button-hover-color);
}
`;

  const HOST_SETTINGS = {
    'komica': {
      hostStyle: `
/*
 * All reply posts of the NGed thread post also be NGed.
 */
.ngid-ngthread > .reply,
.ngid-ngpost > *:not(.post-head),
.ngid-ngpost > .post-head > .title,
.ngid-ngpost > .post-head > .name {
    display: none;
}

.ngid-ngimage > .file-text,
.ngid-ngimage > .file-thumb {
    display: none;
}
`,
      darkStyleVars: `
:root {
  --ngid-primary-background-color: #1D1F21;
  --ngid-secondary-background-color: rgb(40, 42, 46);
  --ngid-highlight-background-color: rgb(0, 0, 0);
  --ngid-highlight-color: rgb(178, 148, 187);
  --ngid-text-button-color: #81A2BE;
  --ngid-text-button-hover-color: #FFC685;
  --ngid-separator-color: gray;
  --ngid-primary-shadow-color: rgb(40, 42, 46);
  --ngid-warning-color: #D00;
}
`,
      getStyleVars: function () {
        const [themeCookie] = document.cookie.split(/;\s*/)
          .map(c => c.split(/=/,2))
          .filter(([k, v]) => k == 'theme');

        if ((themeCookie) && (themeCookie[1] == 'dark.css')) {
          return this.darkStyleVars;
        } else {
          return DEFAULT_STLYE_VARS;
        }
      },
      stylePolyfill: false,
      nonStructuredLayout: false,
    },
    '2cat': {
      hostStyle: `
.ngid-context-menu {
    background-color: #AAEEAA;
}

/*
 * Since we can't hide the text node, just leave them out.
 */
.ngid-ngpost .quote,
.ngid-ngpost .title,
.ngid-ngpost .name,
.ngid-ngpost .warn_txt2,
.threadpost.ngid-ngpost > div > a:not(:last-of-type),
.reply.ngid-ngpost > div > a:not(:first-of-type) {
    display: none;
}

.threadpost.ngid-ngimage > div > a:not(:last-of-type),
.reply.ngid-ngimage > div > a:not(:first-of-type) {
    display: none;
}

.ngid-ngpost > div > a.qlink,
.ngid-ngimage > div > a.qlink {
    display: unset;
}
`,
      stylePolyfill: true,
      nonStructuredLayout: true,
    },
    'gzone-anime': {
      hostStyle: `
.ngid-ngpost .quote,
.ngid-ngpost .title,
.ngid-ngpost .name,
.ngid-ngpost .warn_txt2,
.threadpost.ngid-ngpost > a:not(:last-of-type),
.reply.ngid-ngpost > div > a:not(:first-of-type) {
    display: none;
}

.threadpost.ngid-ngimage > a:not(:last-of-type),
.reply.ngid-ngimage > div > a:not(:first-of-type) {
    display: none;
}

.ngid-ngpost a.qlink,
.ngid-ngimage a.qlink {
    display: unset;
}
`,
      stylePolyfill: true,
      nonStructuredLayout: true,
    },
  };

  const hostId = Komica.hostMatcherOr(document.location, 'unknown');
  console.debug(TAG, `We are at the board of host '${hostId}'.`);

  const queryer = Komica.postQueryer(hostId);

  const hostSettings = HOST_SETTINGS[hostId];

  const settings = createSettings(await ngidSettingsInner());

  async function ngidSettingsInner() {
    const tablePrefix = settingsTablePrefix(document.location);

    const settingsInner = {
      ngIds: [], ngNos: [], ngWords: [], ngImages: [],
      options: {},
    };

    for (const key of Object.keys(settingsInner)) {
      const tableName = `${tablePrefix}/${key}`;
      try {
        const value = JSON.parse(await GM.getValue(tableName, ''), settingsJsonReplacer);
        settingsInner[key] = value;
      } catch (e) {
        console.warn(TAG, `fail at read ${key}`);
      }

      if (Array.isArray(settingsInner[key])) {
        console.info(TAG, `${key} have ${settingsInner[key].length} items.`);
      }
    }

    settingsInner.saveNg = async function settingsSaveNg(key) {
      const tableName = `${tablePrefix}/${key}`;
      try {
        const jsonStr = JSON.stringify(settingsInner[key]);
        await GM.setValue(tableName, jsonStr);
      } catch (e) {
        console.error(TAG, e);
      }
    };

    settingsInner.saveOptions = async function settingsSaveOptions() {
      const tableName = `${tablePrefix}/options`;
      try {
        const jsonStr = JSON.stringify(settingsInner.options);
        await GM.setValue(tableName, jsonStr);
      } catch (e) {
        console.error(TAG, e);
      }
    };

    return settingsInner;
  }

  function settingsTablePrefix(loc) {
    const boardName = loc.pathname.split(/\//).slice(0, -1).join('/');
      return loc.host + boardName;
  }

  function settingsJsonReplacer(key, value) {
    if (key === 'creationTime') {
      return new Date(value);
    } else {
      return value;
    }
  }

  async function ngidAddStyle() {
    const styleVars = ((hostSettings) && (hostSettings.getStyleVars))
      ? hostSettings.getStyleVars() : DEFAULT_STLYE_VARS;
    await GM.addStyle(styleVars);

    // Shared style.
    await GM.addStyle(GLOBAL_STYLE);
    await GM.addStyle(DIALOG_STYLE);

    // Host-dependent style.
    if (hostSettings) {
      if (hostSettings.stylePolyfill) {
        await GM.addStyle(POLYFILL_STYLE);
      }

      if (hostSettings.hostStyle) {
        await GM.addStyle(hostSettings.hostStyle);
      }
    }
  }

  function ngidStart() {
    insertSettingDialog(settings);

    // Init all posts' NG state.
    for (const post of queryer.queryPosts()) {
      initPostMeta(post);
    }
    updateNgState();

    // Observing the thread expansion.
    // TODO: Move reusable code to a independent module.
    const threadObserver = new MutationObserver(function (records) {
      const postReplys = records.reduce((total, record) => {
        for (const node of record.addedNodes) {
          if (queryer.isReplyPost(node)) {
            total.push(node);
          }
        }
        return total;
      } , []);
      const replySize = postReplys.length;
      console.log(`Reply size change: ${replySize}`);

      postReplys.forEach(initPostMeta);
      updateNgState();
    });

    for (const thread of queryer.queryThreads()) {
      threadObserver.observe(thread, { childList: true });
    }

    // Binding with the setting update.
    function onSettingChangeCb(key) {
      if (key === 'ngWords') {
        updateNgWordState();
      }
      updateNgState();
    }
    settings.on('add', onSettingChangeCb);
    settings.on('remove', onSettingChangeCb);
    settings.on('clear', onSettingChangeCb);
    settings.on('swap', onSettingChangeCb);

    function onOptionChangeCb() {
      for (const ngPost of document.querySelectorAll('.ngid-ngpost')) {
        if (settings.options.transparentNg) {
          ngPost.classList.add('ngid-transparent-ng');
        } else {
          ngPost.classList.remove('ngid-transparent-ng');
        }
      }
    }
    settings.on('option', onOptionChangeCb);
  }

  const NGID_SETTINGS = [
    {
      title: 'NGID', description: '指定したIDのスレ/レスを隠す',
      key: 'ngIds', prefix: 'ID:', lineEdit: true,
      replacer(value) {
        value = value.replace(/^ID:/, '');
        return value;
      },
    },
    {
      title: 'NGNo', description: '指定したスレ/レスを隠す',
      key: 'ngNos', prefix: 'No.', lineEdit: false,
      replacer(value) {
        value = value.replace(/^No./, '');
        if (value.match(/\D/)) {
          return '';
        }
        return value;
      },
    },
    {
      title: 'NGWord', description: '指定した文字列を含むスレ/レスを隠す',
      key: 'ngWords', prefix: '', lineEdit: true,
      replacer(value) { return value; },
    },
    {
      title: 'NGImage', description: '指定したIDのイラストを隠す',
      key: 'ngImages', prefix: 'ID:', lineEdit: true,
      replacer(value) {
        value = value.replace(/^ID:/, '');
        return value;
      },
    },
  ];

  const NGID_OPTIONS = {
    'transparentNg': { default: false, title: 'NG対象を透明化する' },
  };

  function createSettings(settingsInner) {
    const eventListener = {
      onadd: [], onremove: [], onclear: [], onswap: [],
      onoption: [],
    };

    function addEventListener(name, cb) {
      if (!eventListener[name]) {
        // ignore unknown event
        return;
      }
      if (typeof cb === 'function') {
        eventListener[name].push(cb);
      } else {
        console.warn(TAG, 'event listener not a function');
      }
    }

    function emitEvent(name, ...args) {
      try {
        eventListener[name].forEach(cb => cb(...args));
      } catch (e) {
        console.error(TAG, e);
      }
    }

    function findNg(key, value) {
      if (!Array.isArray(settingsInner[key])) {
        throw new Error('Invalid key');
      }

      return settingsInner[key].find(v => v.value === value);
    }

    async function addNg(key, value) {
      if (!Array.isArray(settingsInner[key])) {
        throw new Error('Invalid key');
      } else if (settingsInner[key].some(v => value === v.value)) {
        return false;
      }

      settingsInner[key].push({ value: value, creationTime: new Date() });
      await settingsInner.saveNg(key);

      emitEvent('onadd', key, value);
      return true;
    }

    async function removeNg(key, value) {
      if (!Array.isArray(settingsInner[key])) {
        throw new Error('Invalid key');
      }

      settingsInner[key] = settingsInner[key].filter(v => v.value !== value);
      await settingsInner.saveNg(key);

      emitEvent('onremove', key, value);
      return true;
    }

    async function clearNg(key, predicate = null) {
      if (!Array.isArray(settingsInner[key])) {
        throw new Error('Invalid key');
      }

      if (typeof predicate === 'function') {
        settingsInner[key] = settingsInner[key].filter(predicate)
      } else {
        settingsInner[key] = [];
      }
      await settingsInner.saveNg(key);

      emitEvent('onclear', key);
    }

    // unsafe
    async function swapNg(key, list) {
      if (!Array.isArray(settingsInner[key])) {
        throw new Error('Invalid key');
      }

      const oldList = settingsInner[key];
      settingsInner[key] = list;
      await settingsInner.saveNg(key);

      emitEvent('onswap', key);

      return oldList;
    }

    async function saveOptions() {
      await settingsInner.saveOptions();
      emitEvent('onoption');
    }

    return {
      get ngIds() { return settingsInner.ngIds.map(v => v.value); },
      get ngNos() { return settingsInner.ngNos.map(v => v.value); },
      get ngWords() { return settingsInner.ngWords.map(v => v.value); },
      get ngImages() { return settingsInner.ngImages.map(v => v.value); },
      findNg, addNg, removeNg, clearNg, swapNg,
      get options() { return settingsInner.options; },
      saveOptions,
      on(eventName, cb) { addEventListener(`on${eventName}`, cb); },
    };
  }

  function insertSettingDialog(settings) {
    const { tabBox, footer } = Komica.insertDialog('NGID', 'ngid-settings-dialog', 'ngid');

    NGID_SETTINGS.forEach(({ title }) => tabBox.addPage(title));

    const optionsPage = tabBox.addPage();

    const options = document.createElement('span');
    options.className = 'ngid-text-button';
    options.innerHTML = '&#x2699; 設定'; // Gear
    options.addEventListener('click', () => {
      tabBox.currentSelected = optionsPage.index;
    });
    footer.appendChild(options);

    function getCurrentPageData() {
      const currentSelected = tabBox.currentSelected;
      if ((currentSelected >= 0) && (currentSelected < NGID_SETTINGS.length)) {
        return NGID_SETTINGS[currentSelected];
      } else {
        return null;
      }
    }

    function createListitem(value, prefix = '') {
      const view = document.createElement('div');
      view.className = 'ngid-listitem';

      const dataBlock = document.createElement('span');
      dataBlock.innerHTML = `${prefix}${value}`;
      view.appendChild(dataBlock);

      const delButton = document.createElement('span');
      delButton.className = 'ngid-text-button';
      delButton.innerHTML = '削除';
      delButton.dataset.value = value;
      delButton.addEventListener('click', removeItemCb, false);
      view.appendChild(delButton);
      return view;
    }

    async function removeItemCb(ev) {
      const currentPage = getCurrentPageData();
      if (currentPage === null) {
        return;
      }

      const button = ev.target;
      await settings.removeNg(currentPage.key, button.dataset.value);
    }

    function createInputField(placeholder, replacer) {
      const view = document.createElement('div');
      view.className = 'ngid-inputfield';

      const textField = document.createElement('input');
      textField.placeholder = placeholder;
      view.appendChild(textField);

      const addButton = document.createElement('button');
      addButton.innerHTML = '追加';
      addButton.addEventListener('click',
        async ev => {
          const currentPage = getCurrentPageData();
          if (currentPage === null) {
            return;
          }

          const value = replacer(textField.value).trim();
          if (value !== '') {
            await settings.addNg(currentPage.key, value);
            textField.value = '';
          }
          textField.focus();
        }, false);
      view.appendChild(addButton);
      return view;
    }

    function renderList(root, currentPage) {
      root.innerHTML = '';

      const { title, description, key, prefix, lineEdit, replacer } = currentPage;

      const inputField = createInputField(description, replacer);
      root.appendChild(inputField);

      if (lineEdit) {
        const editButton = document.createElement('button');
        editButton.classList.add('ngid-lineedit-button');
        editButton.innerHTML = '編集';
        editButton.addEventListener('click',
          () => renderLineEdit(root, currentPage), false);

        inputField.appendChild(editButton);
      }

      // create items list
      const lists = settings[key];
      const items = lists.map(data => createListitem(data, prefix));
      items.reverse();
      items.forEach(item => root.appendChild(item));
    }

    function renderLineEdit(root, currentPage) {
      root.innerHTML = '';

      const { title, description, key, prefix, lineEdit, replacer } = currentPage;

      const textView = document.createElement('textarea');
      textView.classList.add('ngid-lineedit-textview');
      textView.value = settings[key].join('\n');

      const saveView = document.createElement('div');
      saveView.classList.add('ngid-lineedit-saveview');
      saveView.appendChild(document.createTextNode(description));

      const saveButton = document.createElement('button');
      saveButton.innerHTML = '保存';
      saveButton.addEventListener('click',
        async ev => {
          const lists = textView.value.split(/\n/)
            .map(v => replacer(v).trim())
            .filter(v => v.length > 0)
            .map(v => {
              return { value: v, creationTime: new Date() };
            });
          // swapNg will occur render and back to listview
          // unsafe
          await settings.swapNg(key, lists);
        }, false);
      saveView.appendChild(saveButton);

      // We need a block to fillup the page.
      const outerBlock = document.createElement('div');
      outerBlock.style.cssText = 'display: flex; flex-direction: column; height: 100%; width: 100%';
      outerBlock.appendChild(saveView);
      outerBlock.appendChild(textView);

      root.appendChild(outerBlock);
    }

    function createGap() {
      return document.createElement('hr');
    }

    function createCheckbox(optionId, defaultValue, title) {
      const checked = (optionId in settings.options) ? settings.options[optionId] : defaultValue;
      const view = document.createElement('label');
      view.for = `ngid-${optionId}`;
      view.className = 'ngid-listitem';

      const titleBlock = document.createElement('span');
      titleBlock.innerHTML = title;
      view.appendChild(titleBlock);

      const checkbox = document.createElement('input');
      checkbox.type = 'checkbox';
      checkbox.id = `ngid-${optionId}`;
      checkbox.className = 'ngid-text-button';
      checkbox.checked = checked;
      checkbox.addEventListener('change', async () => {
        settings.options[optionId] = checkbox.checked;
        await settings.saveOptions();
      });
      view.appendChild(checkbox);
      return view;
    }

    function renderOptions(root) {
      root.innerHTML = '';

      root.appendChild(createGap());
      for (const [optionId, details] of Object.entries(NGID_OPTIONS)) {

        switch (typeof details.default) {
          case 'boolean':
            root.appendChild(createCheckbox(optionId, details.default, details.title));
            break;
        }
        root.appendChild(createGap());
      }
    }

    function switchTab(pageIdx, root) {
      if (pageIdx < NGID_SETTINGS.length) {
        renderList(root, NGID_SETTINGS[pageIdx]);
      } else {
        renderOptions(root);
      }
    }

    tabBox.on('switch', switchTab);

    // rerender current list if it is openning
    function renderCurrentListCb(key) {
      const currentPage = getCurrentPageData();
      if ((currentPage !== null) && (currentPage.key === key)) {
        const root = tabBox.getCurrentPage();
        renderList(root, currentPage);
      }
    }

    settings.on('add',    renderCurrentListCb);
    settings.on('remove', renderCurrentListCb);
    settings.on('clear',  renderCurrentListCb);
    settings.on('swap',   renderCurrentListCb);
  }

  // Mapping post no to meta dbe ngata.
  //
  // String => PostMetaObject{ id: String, no: String, isThreadPost: bool, isContainsNgWord: bool, contextMenuRoot: HTMLElement }
  const postMetas = {};

  // Init and store the meta data of the `post`.
  //
  // This function maybe called twice for a post due to the thread expanding,
  // but we don't mind and just reinit the post.
  function initPostMeta(post) {
    // Only when we know the post no.
    const postNo = queryer.queryNo(post);
    if (!postNo) {
      return;
    }

    post.dataset.ngidNo = postNo; // For convenience.

    const postMeta = {
      no: postNo,
      id: queryer.queryId(post),
      isThreadPost: queryer.isThreadPost(post),
      isContainsNgWord: isContainsNgWord(post),
      contextMenuRoot: null,
    };

    postMetas[postNo] = postMeta;

    // Insert the context menu root and create the menu.
    const insertPoint = queryer.afterPostNoEl(post);
    if (insertPoint) {
      const parent = insertPoint.parentElement;

      // WORKAROUND: GM4 double insert
      if (parent.querySelector('.ngid-context')) {
        return;
      }

      const contextMenuRoot = document.createElement('details');
      contextMenuRoot.className = 'text-button ngid-context';
      contextMenuRoot.addEventListener('mouseenter', autoToggleContextMenu);
      parent.insertBefore(contextMenuRoot, insertPoint);

      postMeta.contextMenuRoot = contextMenuRoot;

      renderContextMenu(post, postMeta, '');
    }
  }

  function isContainsNgWord(post) {
    const postBody = queryer.queryBody(post) || '';
    const threadTitle = queryer.queryThreadTitle(post) || '';
    return settings.ngWords.some(word => ((postBody.includes(word)) || (threadTitle.includes(word))));
  }

  function autoToggleContextMenu() {
    this.open = true;
  }

  function renderContextMenu(post, postMeta, ngState) {
    const postId = postMeta.id;
    const postNo = postMeta.no;
    const isThreadPost = postMeta.isThreadPost;
    const root = postMeta.contextMenuRoot;

    // Remove the menu body.
    while (root.lastChild) {
      root.removeChild(root.lastChild);
    }

    const menu = document.createElement('div');
    menu.className = 'ngid-context-menu';
    root.appendChild(menu);

    const closeButton = document.createElement('button');
    closeButton.type = 'button';
    closeButton.classList.add('ngid-context-menu-close-button');
    closeButton.innerHTML = 'メニューを閉じる';
    closeButton.addEventListener('click', contentMenuCloseButtonCb);
    menu.appendChild(closeButton);

    const summary = document.createElement('summary');
    summary.innerHTML = '&nbsp;NG';
    root.appendChild(summary);

    const postType = (isThreadPost) ? 'スレ' : 'レス';
    if (ngState === 'ngword') {
      menu.appendChild(document.createTextNode(
        `この${postType}にはNGWordsが含まれている。`));
    } else if (ngState === 'ngid') {
      menu.appendChild(document.createTextNode(
        `このIDはNGIDに指定されている。`));
    } else {
      // Only show buttons of enabled function.
      if (postNo) {
        const ngNoButton = document.createElement('div');
        ngNoButton.className = 'ngid-text-button';
        ngNoButton.dataset.no = postNo;
        if (ngState == 'ngno') {
          ngNoButton.innerHTML = `この${postType}を現す`;
        } else {
          ngNoButton.innerHTML = `この${postType}を隠す`;
        }
        ngNoButton.addEventListener('click', addNgNoButtonCb, false);

        menu.appendChild(ngNoButton);
      }

      if (postId) {
        const ngIdButton = document.createElement('div');
        ngIdButton.className = 'ngid-text-button';
        ngIdButton.dataset.id = postId;
        ngIdButton.innerHTML = `ID:${postId}をNGIDに追加`;
        ngIdButton.addEventListener('click', addNgIdButtonCb, false);
        menu.appendChild(ngIdButton);

        const ngImageButton = document.createElement('div');
        ngImageButton.className = 'ngid-text-button';
        ngImageButton.dataset.id = postId;
        if (isNgImage(post)) {
          ngImageButton.innerHTML = `ID:${postId}のイラストを表す`;
        } else {
          ngImageButton.innerHTML = `ID:${postId}のイラストを隠す`;
        }
        ngImageButton.addEventListener('click', addNgImageButtonCb, false);
        menu.appendChild(ngImageButton);
      }
    }
  }

  function contentMenuCloseButtonCb() {
    this.parentElement.parentElement.open = false;
  }

  async function addNgIdButtonCb(ev) {
    const id = this.dataset.id;
    if (await settings.addNg('ngIds', id)) {
      console.log(`add NGID ${id}`);
    }
  }

  async function addNgNoButtonCb(ev) {
    const no = this.dataset.no;
    if (await settings.addNg('ngNos', no)) {
      console.log(`add NGNO ${no}`);
    } else {
      console.log(`remove NGNO ${no}`);
      await settings.removeNg('ngNos', no);
    }
  }

  async function addNgImageButtonCb(ev) {
    const id = this.dataset.id;
    if (await settings.addNg('ngImages', id)) {
      console.log(TAG, `add NGImage ${id}`);
    } else {
      console.log(`remove NGImage ${id}`);
      await settings.removeNg('ngImages', id);
    }
  }

  function isNgImage(post) {
    return post.classList.contains('ngid-ngimage');
  }

  function updateNgWordState() {
    for (const post of queryer.queryPosts()) {
      const postMeta = postMetas[post.dataset.ngidNo];
      if (postMeta) {
        postMeta.isContainsNgWord = isContainsNgWord(post);
      }
    }
  }

  function updateNgState() {
    for (const post of queryer.queryPosts()) {
      const postMeta = postMetas[post.dataset.ngidNo];
      if (!postMeta) {
        continue;
      }

      const isNgPost = post.classList.contains('ngid-ngpost');
      let ngState = '';
      if (postMeta.isContainsNgWord) {
        ngState = 'ngword';
      } else if (settings.ngIds.includes(postMeta.id)) {
        ngState = 'ngid';
      } else if (settings.ngNos.includes(postMeta.no)) {
        ngState = 'ngno';
      }

      const needNgImage = settings.ngImages.includes(postMeta.id);

      setNgState(post, ngState !== '');
      setNgImage(post, needNgImage);

      // no touch if it isn't and wasn't a NGed post
      if ((isNgPost)
        || (ngState !== '')
        || (isNgImage(post) == needNgImage)) {
        const context = post.querySelector('.ngid-context');
        renderContextMenu(post, postMeta, ngState);
      }
    }

    // A workaround for non-structured layout.
    if (hostSettings.nonStructuredLayout) {
      for (const post of queryer.queryThreads()) {
        const isNgThread = post.classList.contains('ngid-ngpost');
        let el = post.nextSibling;
        while ((el) && (!(el instanceof HTMLHRElement))) {
          if (queryer.isReplyPost(el)) {
            if (isNgThread) {
              el.classList.add('ngid-destroy');
            } else {
              el.classList.remove('ngid-destroy');
            }
          }
          el = el.nextSibling;
        }
      }
    }
  }

  function setNgState(post, isNg) {
    if (isNg) {
      if (post.classList.contains('threadpost')) {
        post.parentElement.classList.add('ngid-ngthread');
      }
      post.classList.add('ngid-ngpost');
      if (settings.options.transparentNg) {
        post.classList.add('ngid-transparent-ng');
      }
    } else {
      if (post.classList.contains('threadpost')) {
        post.parentElement.classList.remove('ngid-ngthread');
      }
      post.classList.remove('ngid-ngpost');
      if (settings.options.transparentNg) {
        post.classList.remove('ngid-transparent-ng');
      }
    }
  }

  function setNgImage(post, isNg) {
    if (isNg) {
      post.classList.add('ngid-ngimage');
    } else {
      post.classList.remove('ngid-ngimage');
    }
  }

  await ngidAddStyle();
  ngidStart();
})();