Komica NGID

NG id and post on komica

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey, Greasemonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

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

(function komicaHostMatcher(exports) {
  'use strict'

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

  function hostMatcher(location) {
    const host = location.host.replace(/:\d+$/, '');
    for (const { name, matcher } of 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:([^\s]+)(?:\].*)?/;
  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')));
    },
    postNoEl: function postNoElKomica(post) {
      return post.querySelector('.post-head [data-no]');
    },
  };

  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')));
    },
    postNoEl: function postNoEl2Cat(post) {
      return post.querySelector('div:first-child .qlink');
    },
  };

  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;
    },
    postNoEl: function postNoElNull(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 groups = new Map();
    const pageInfos = [];
    let currentSelected = -1;

    function addPage(title = null, groupTitle = null) {
      const index = pageInfos.length;

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

      function getOrAddGroup(groupTitle) {
        let group = groups.get(groupTitle);
        if (!group) {
          const header = document.createElement('div');
          header.className = `${namespace}-tabbox-group-title`;
          header.innerHTML = groupTitle;
          tabBox.appendChild(header);

          group = document.createElement('div');
          group.className = `${namespace}-tabbox-group`;
          tabBox.appendChild(group);

          groups.set(groupTitle, group);
        }

        return group;
      }

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

      const group = (groupTitle == null) ? null : getOrAddGroup(groupTitle);
      const tab = (title == null) ? null : addTab(title, group ?? tabBox);

      const newInfo = { index, page, tab, group };
      pageInfos.push(newInfo);
      return newInfo;
    }

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

      return pageInfos[index].page;
    }

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

      const prevIndex = currentSelected;
      const { page, tab } = pageInfos[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 } = pageInfos[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 >= pageInfos.length)) {
        return null;
      } else {
        return pageInfos[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 c c" auto "f f b" min-content / min-content auto;
  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 {
  grid-area: f;
  place-self: center end;
  margin: 10px 20px;
}

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

.ngid-tabbox-header {
  grid-area: h;
  display: flex;
  flex-direction: column;
  justify-content: start;
  padding: 3px 6px;
  background-color: var(--ngid-secondary-background-color);
}

.ngid-tabbox-group {
  display: flex;
  flex-direction: column;
  margin-bottom: 12px;
}

.ngid-tabbox-group-title {
  cursor: pointer;
  font-weight: bold;
}

.ngid-tabbox-tab {
  cursor: pointer;
  flex: 1;
  padding: 3px 12px;
}

.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(100vw - 20px);
    margin: 0 10px;
    grid-template: "h h" min-content "c c" auto "f b" min-content / auto max-content;
  }

  .ngid-tabbox-container {
    width: calc(100vw - 20px);
  }

  .ngid-tabbox-group {
    flex-direction: row;
    flex-wrap: wrap;
    width: calc(90vw);
  }
}
`;

  // 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 {
    display: none !important; /* override hidePoliticalPosts */
}

.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);
    function getTableName(key) {
      return (key.startsWith('global')) ? key : `${tablePrefix}/${key}`;
    }

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

    for (const key of Object.keys(settingsInner)) {
      const tableName = getTableName(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 = getTableName(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 = getTableName('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') || (key == 'globalNgWords')) {
        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_DESCIPTORS = [
    {
      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対象を透明化する' },
  };

  const GLOBAL_NGWORD_DESCRIPTOR = {
    title: 'NGWord', description: 'すべての板に適用するNGWord',
    key: 'globalNgWords', prefix: '', lineEdit: true,
    replacer(value) { return value; },
  };

  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); },
      get globalNgWords() { return settingsInner.globalNgWords.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');

    const localGroupTitle = 'この板';
    NGID_DESCIPTORS.forEach(({ title }) => tabBox.addPage(title, localGroupTitle));

    const optionsPageInfo = tabBox.addPage('&#x2699; 設定', localGroupTitle);

    const globalGroupTitle = '共通';
    const globalNgWordPageInfo = tabBox.addPage('NGWord', globalGroupTitle);

    function switchTab(pageIdx, root) {
      switch (pageIdx) {
        case optionsPageInfo.index:
          renderOptions(root);
          break;
        case globalNgWordPageInfo.index:
          renderList(root, GLOBAL_NGWORD_DESCRIPTOR);
          break;
        default:
          if ((pageIdx >= 0) && (pageIdx < NGID_DESCIPTORS.length)) {
            renderList(root, NGID_DESCIPTORS[pageIdx]);
          }
          break;
      }
    }

    tabBox.on('switch', switchTab);

    function getCurrentPageDescriptor() {
      const currentSelected = tabBox.currentSelected;
      switch (currentSelected) {
        case globalNgWordPageInfo.index:
          return GLOBAL_NGWORD_DESCRIPTOR;
        default:
          if ((currentSelected >= 0) && (currentSelected < NGID_DESCIPTORS.length)) {
            return NGID_DESCIPTORS[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 pageDesciptor = getCurrentPageDescriptor();
      if (pageDesciptor === null) {
        return;
      }

      const button = ev.target;
      await settings.removeNg(pageDesciptor.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 pageDesciptor = getCurrentPageDescriptor();
          if (pageDesciptor === null) {
            return;
          }

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

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

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

      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, pageDesciptor), 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, pageDesciptor) {
      root.innerHTML = '';

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

      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());
      }
    }

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

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

  // Mapping post no to meta data.
  //
  // 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.postNoEl(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);
      insertPoint.after(contextMenuRoot);

      postMeta.contextMenuRoot = contextMenuRoot;

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

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

  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();
})();