Feedly NG Filter

ルールにマッチするアイテムを既読にして取り除きます。ルールは正規表現で記述でき、複数のルールをツリー状に組み合わせることができます。

2016-11-04 या दिनांकाला. सर्वात नवीन आवृत्ती पाहा.

// ==UserScript==
// @name           Feedly NG Filter
// @id             feedlyngfilter
// @description    ルールにマッチするアイテムを既読にして取り除きます。ルールは正規表現で記述でき、複数のルールをツリー状に組み合わせることができます。
// @include        http://feedly.com/*
// @include        https://feedly.com/*
// @grant          GM_getValue
// @grant          GM_setValue
// @grant          GM_addStyle
// @grant          GM_xmlhttpRequest
// @grant          GM_registerMenuCommand
// @grant          GM_unregisterMenuCommand
// @grant          GM_log
// @charset        utf-8
// @compatibility  Firefox
// @run-at         document-start
// @jsversion      1.8
// @priority       1
// @homepage       https://greasyfork.org/scripts/9030-feedly-ng-filter
// @supportURL     https://twitter.com/intent/tweet?text=%40xulapp+
// @icon           https://greasyfork.org/system/screenshots/screenshots/000/000/615/original/icon.png
// @screenshot     https://greasyfork.org/system/screenshots/screenshots/000/000/614/original/large.png
// @namespace      http://twitter.com/xulapp
// @author         xulapp
// @license        MIT License
// @version        0.9.0
// ==/UserScript==
/* eslint-env greasemonkey, browser */
/* eslint new-cap:0, camelcase:0, no-eval:0 */
/* global GM_unregisterMenuCommand:false, GM_enableMenuCommand:false, GM_disableMenuCommand:false */
'use strict';

(function feedlyNGFilter() {
  const notificationDefaults = {
    title: 'Feedly NG Filter',
    icon: getGMInfo().icon,
    tag: 'feedly-ng-filter',
    autoClose: 5000,
  };

  const CSS_STYLE_TEXT = String.raw`
    .fngf-row {
      display: flex;
      flex-direction: row;
    }

    .fngf-column {
      display: flex;
      flex-direction: column;
    }

    .fngf-align-center {
      align-items: center;
    }

    .fngf-grow {
      flex-grow: 1;
    }

    .fngf-badge {
      margin: 0 0.5em;
      padding: 0 0.5em;
      background-color: #999;
      border-radius: 50%;
      color: #fff;
    }

    .fngf-menu-btn > .fngf-btn:not(:last-child) {
      margin-right: -1px;
    }

    .fngf-btn {
      padding: 5px 10px;
      border: none;
      background-color: #eee;
      color: #333;
      font: inherit;
      font-weight: bold;
      outline: none;
    }

    .fngf-btn[disabled] {
      background-color: transparent;
      color: #ccc;
      box-shadow: 0 0 0 1px #eee inset;
    }

    .fngf-btn:not([disabled]):hover,
    .fngf-menu-btn:hover > .fngf-btn:not([disabled]) {
      box-shadow: 0 0 0 1px #ccc inset;
    }

    .fngf-btn:not([disabled]):active,
    .fngf-btn:not([disabled]).active,
    .fngf-checkbox > :checked + .fngf-btn {
      background-color: #ccc;
    }

    .fngf-dropdown {
      display: flex;
      align-items: center;
      position: relative;
      padding-left: 5px;
      padding-right: 5px;
    }

    .fngf-dropdown::before {
      display: block;
      border-top: 5px solid #333;
      border-left: 3px solid transparent;
      border-right: 3px solid transparent;
      content: "";
    }

    .fngf-dropdown-menu {
      position: absolute;
      right: 0;
      top: 100%;
      min-width: 100px;
      background-color: #fff;
      box-shadow: 1px 2px 5px rgba(0, 0, 0, 0.5);
      z-index: 1;
    }

    .fngf-dropdown:not(.active) > .fngf-dropdown-menu {
      display: none;
    }

    .fngf-dropdown-menu-item {
      padding: 10px;
    }

    .fngf-dropdown-menu-item:hover {
      background-color: #eee;
    }

    .fngf-checkbox > input[type="checkbox"] {
      display: none;
    }

    @keyframes error {
      from {
        background-color: #ff0;
        border-color: #f00;
      }
    }

    .fngf-panel-terms-textbox.error {
      animation: error 1s;
    }

    .fngf-panel {
      position: fixed;
      min-width: 320px;
      background-color: rgba(255, 255, 255, 0.95);
      color: #333;
      box-shadow: 1px 2px 5px rgba(0, 0, 0, 0.5);
      font-size: 12px;
      cursor: default;
      -moz-user-select: none;
      z-index: 2147483646;
    }

    .fngf-panel input[type="text"] {
      padding: 4px;
      border: 1px solid #999;
      font: inherit;
    }

    .fngf-panel input[type="text"]:focus {
      box-shadow: 0 0 0 1px #999 inset;
    }

    .fngf-panel-body {
      margin: 10px;
    }

    .fngf-panel.root .fngf-panel-name,
    .fngf-panel.root .fngf-panel-terms {
      display: none;
    }

    .fngf-panel-terms {
      margin: 10px 0;
      padding: 10px;
      border: 1px solid #999;
      white-space: nowrap;
    }

    .fngf-panel-terms > table {
      margin: -5px;
      border-spacing: 5px;
    }

    .fngf-panel-terms td {
      padding: 0;
    }

    .fngf-panel-terms td:nth-child(2) {
      width: 100%;
    }

    .fngf-panel-terms-textbox {
      width: 100%;
      box-sizing: border-box;
    }

    .fngf-panel-rules {
      padding: 10px;
      border: 1px solid #999;
    }

    .fngf-no-rule:not(:only-child) {
      display: none;
    }

    .fngf-panel fieldset {
      margin: 0;
      padding: 10px;
    }

    .fngf-panel-rule-name {
      flex-grow: 1;
    }

    .fngf-panel-btns {
      justify-content: space-between;
      margin: 10px;
    }

    .fngf-panel-btns > .fngf-btn-group:not(:first-child) {
      margin-left: 10px;
    }
  `;

  function __(strings, ...values) {
    let key = values.map((v, i) => `${strings[i]}{${i}}`).join('') + strings[strings.length - 1];

    if (!(key in __.data))
      throw new Error(`localized string not found: ${key}`);

    return __.data[key].replace(/\{(\d+)\}/g, (_, cap) => values[cap]);
  }

  Object.defineProperties(__, {
    config: {
      configurable: true,
      writable: true,
      value: {
        defaultLocale: 'en-US',
      },
    },

    locales: {
      configurable: true,
      writable: true,
      value: {},
    },

    data: {
      configurable: true,
      get() {
        return this.locales[this.config.locale];
      },
    },

    languages: {
      configurable: true,
      get() {
        return Object.keys(this.locales);
      },
    },

    add: {
      configurable: true,
      writable: true,
      value: function add({locale, data}) {
        if (locale in this.locales)
          throw new Error(`failed to add existing locale: ${locale}`);

        this.locales[locale] = data;
      },
    },

    use: {
      configurable: true,
      writable: true,
      value: function use(locale) {
        if (locale in this.locales)
          this.config.locale = locale;

        else if (this.config.defaultLocale)
          this.config.locale = this.config.defaultLocale;

        else
          throw new Error(`unknown locale: ${locale}`);
      },
    },
  });

  __.add({
    locale: 'en-US',
    data: {
      'Feedly NG Filter': 'Feedly NG Filter',
      'OK': 'OK',
      'Cancel': 'Cancel',
      'Add': 'Add',
      'Copy': 'Copy',
      'Paste': 'Paste',
      'New Filter': 'New Filter',
      'Rule Name': 'Rule Name',
      'No Rules': 'No Rules',
      'Title': 'Title',
      'URL': 'URL',
      'Feed Title': 'Feed Title',
      'Feed URL': 'Feed URL',
      'Author': 'Author',
      'Keywords': 'Keywords',
      'Contents': 'Contents',
      'Ignore Case': 'Ignore Case',
      'Edit': 'Edit',
      'Delete': 'Delete',
      'Hit Count:\t{0}': 'Hit Count:\t{0}',
      'Last Hit:\t{0}': 'Last Hit:\t{0}',
      'NG Setting': 'NG Setting',
      'Setting': 'Setting',
      'Import Configuration': 'Import Configuration',
      'Preferences were successfully imported.': 'Preferences were successfully imported.',
      'Export Configuration': 'Export Configuration',
      'Language': 'Language',
      'NG Settings were modified.\nNew filters take effect after next refresh.': 'NG Settings were modified.\nNew filters take effect after next refresh.',
    },
  });

  __.add({
    locale: 'ja',
    data: {
      'Feedly NG Filter': 'Feedly NG Filter',
      'OK': 'OK',
      'Cancel': 'キャンセル',
      'Add': '追加',
      'Copy': 'コピー',
      'Paste': '貼り付け',
      'New Filter': '新しいフィルタ',
      'Rule Name': 'ルール名',
      'No Rules': 'ルールはありません',
      'Title': 'タイトル',
      'URL': 'URL',
      'Feed Title': 'フィードのタイトル',
      'Feed URL': 'フィードの URL',
      'Author': '著者',
      'Keywords': 'キーワード',
      'Contents': '本文',
      'Ignore Case': '大/小文字を区別しない',
      'Edit': '編集',
      'Delete': '削除',
      'Hit Count:\t{0}': 'ヒット数:\t{0}',
      'Last Hit:\t{0}': '最終ヒット:\t{0}',
      'NG Setting': 'NG 設定',
      'Setting': '設定',
      'Import Configuration': '設定をインポート',
      'Preferences were successfully imported.': '設定をインポートしました',
      'Export Configuration': '設定をエクスポート',
      'Language': '言語',
      'NG Settings were modified.\nNew filters take effect after next refresh.': 'NG 設定を更新しました。\n新しいフィルタは次回読み込み時から有効になります。',
    },
  });

  __.use(navigator.language);

  const Serializer = {
    stringify(value, space) {
      return JSON.stringify(value, (key, value) => {
        if (value instanceof RegExp)
          return {
            __serialized__: true,
            class: 'RegExp',
            args: [value.source, value.flags],
          };

        return value;
      }, space);
    },

    parse(text) {
      return JSON.parse(text, (key, value) => {
        if (value instanceof Object && value.__serialized__)
          switch (value.class) {
            case 'RegExp':
              return new RegExp(...value.args);
          }

        return value;
      });
    },
  };

  class EventEmitter {
    constructor() {
      this.listeners = {};
    }

    on(type, listener) {
      if (type.trim().includes(' ')) {
        type.match(/\S+/g).forEach(t => this.on(t, listener));
        return;
      }

      if (!(type in this.listeners))
        this.listeners[type] = new Set();

      const set = this.listeners[type];

      for (let fn of set.values())
        if (EventEmitter.compareListener(fn, listener))
          return;

      set.add(listener);
    }

    once(type, listener) {
      return new Promise((resolve, reject) => {
        function wrapper(event) {
          this.off(wrapper);

          try {
            EventEmitter.applyListener(this, listener, event);
            resolve(event);
          } catch (e) {
            reject(e);
          }
        }

        wrapper[EventEmitter.original] = listener;

        this.on(type, wrapper);
      });
    }

    off(type, listener) {
      if (!listener || !(type in this.listeners))
        return;

      const set = this.listeners[type];

      for (let fn of set.values())
        if (EventEmitter.compareListener(fn, listener))
          set.delete(fn);
    }

    removeAllListeners(type) {
      delete this.listeners[type];
    }

    dispatchEvent(event) {
      event.timestamp = Date.now();

      if (event.type in this.listeners)
        this.listeners[event.type].forEach(listener => {
          try {
            EventEmitter.applyListener(this, listener, event);
          } catch (e) {
            setTimeout(() => {
              throw e;
            }, 0);
          }
        });

      return !event.canceled;
    }

    emit(type, data) {
      const event = this.createEvent(type);

      Object.assign(event, data);

      return this.dispatchEvent(event);
    }

    createEvent(type) {
      return new Event(type, this);
    }

    static compareListener(a, b) {
      return a === b || a === b[EventEmitter.original] || a[EventEmitter.original] === b;
    }

    static applyListener(target, listener, ...args) {
      if (typeof listener === 'function')
        listener.apply(target, args);

      else
        listener.handleEvent(...args);
    }
  }

  EventEmitter.original = Symbol('fngf.original');

  class Event {
    constructor(type, target) {
      this.type = type;
      this.target = target;
      this.canceled = false;
      this.timestamp = 0;
    }

    preventDefault() {
      this.canceled = true;
    }
  }

  class DataTransfer extends EventEmitter {
    set(type, data) {
      this.purge();
      this.type = type;
      this.data = data;
      this.emit(type, {data});
    }

    purge() {
      this.emit('purge', {data: this.data});
      delete this.data;
    }

    cut(data) {
      this.set('cut', data);
    }

    copy(data) {
      this.set('copy', data);
    }

    receive() {
      const data = this.data;

      if (this.type === 'cut')
        this.purge();

      return data;
    }
  }

  class MenuCommand {
    constructor(label, oncommand, disabled) {
      this.label = label;
      this.oncommand = oncommand;
      this.disabled = !!disabled;

      this.register();
    }

    register() {
      if (typeof GM_registerMenuCommand === 'function')
        this.uuid = GM_registerMenuCommand(`${__`Feedly NG Filter`} - ${this.label}`, this.oncommand);

      if (MenuCommand.contextmenu) {
        this.menuitem = $el`<menuitem label="${this.label}" @click="${this.oncommand}">`.first;
        MenuCommand.contextmenu.appendChild(this.menuitem);
      }

      if (this.disabled)
        this.disable();
    }

    unregister() {
      if (typeof GM_unregisterMenuCommand === 'function')
        GM_unregisterMenuCommand(this.uuid);

      delete this.uuid;
      document.adoptNode(this.menuitem);
    }

    disable() {
      if (typeof GM_disableMenuCommand === 'function')
        GM_disableMenuCommand(this.uuid);

      this.menuitem.disabled = true;
    }

    enable() {
      if (typeof GM_enableMenuCommand === 'function')
        GM_enableMenuCommand(this.uuid);

      this.menuitem.disabled = false;
    }
  }

  MenuCommand.contextmenu = null;

  class Preference extends EventEmitter {
    constructor() {
      super();

      if (Preference._instance)
        return Preference._instance;

      Preference._instance = this;

      this.dict = {};
    }

    has(key) {
      return key in this.dict;
    }

    get(key, def) {
      return this.has(key) ? this.dict[key] : def;
    }

    set(key, newValue) {
      const prevValue = this.dict[key];

      if (newValue !== prevValue) {
        this.dict[key] = newValue;
        this.emit('change', {
          key,
          prevValue,
          newValue,
        });
      }

      return newValue;
    }

    del(key) {
      if (!this.has(key))
        return;

      const prevValue = this.dict[key];

      delete this.dict[key];

      this.emit('delete', {
        key,
        prevValue,
      });
    }

    load(str) {
      if (!str)
        str = GM_getValue(Preference.prefName, Preference.defaultPref || '({})');

      let obj;

      try {
        obj = Serializer.parse(str);
      } catch (e) {
        if (e instanceof SyntaxError)
          obj = eval(`(${str})`);
      }

      if (!obj || typeof obj !== 'object')
        return;

      this.dict = {};

      for (let key in obj)
        this.set(key, obj[key]);

      this.emit('load');
    }

    write() {
      this.dict.__version__ = getGMInfo().version;

      const text = Serializer.stringify(this.dict);

      GM_setValue(Preference.prefName, text);
    }

    autosave() {
      if (this.autosaveReserved)
        return;

      window.addEventListener('unload', () => this.write(), false);
      this.autosaveReserved = true;
    }

    exportToFile() {
      const blob = new Blob([this.serialize()], {
        type: 'application/octet-stream',
      });

      const url = URL.createObjectURL(blob);

      location.assign(url);
      URL.revokeObjectURL(url);
    }

    importFromString(str) {
      try {
        this.load(str);
      } catch (e) {
        if (!(e instanceof SyntaxError))
          throw e;

        notify(e);

        return false;
      }

      notify(__`Preferences were successfully imported.`);

      return true;
    }

    importFromFile() {
      openFilePicker().then(([file]) => {
        const reader = new FileReader();

        reader.addEventListener('load', () => this.importFromString(reader.result), false);
        reader.readAsText(file);
      });
    }

    toString() {
      return '[object Preference]';
    }

    serialize() {
      return Serializer.stringify(this.dict);
    }
  }

  Preference.prefName = 'settings';

  class Draggable {
    constructor(element, ignore = 'select, button, input, textarea, [tabindex]') {
      this.element = element;
      this.ignore = ignore;

      this.attach();
    }

    isDraggableTarget(target) {
      if (!target)
        return false;

      if (target === this.element)
        return true;

      return !target.matches(`${this.ignore}, :-moz-any(${this.ignore}) *`);
    }

    attach() {
      this.element.addEventListener('mousedown', this, false, false);
    }

    detatch() {
      this.element.removeEventListener('mousedown', this, false);
    }

    handleEvent(event) {
      const name = `on${event.type}`;

      if (name in this)
        this[name](event);
    }

    onmousedown(event) {
      if (event.button !== 0)
        return;

      if (!this.isDraggableTarget(event.target))
        return;

      event.preventDefault();

      const focused = this.element.querySelector(':focus');

      if (focused)
        focused.blur();

      this.offsetX = event.pageX - this.element.offsetLeft;
      this.offsetY = event.pageY - this.element.offsetTop;

      document.addEventListener('mousemove', this, true, false);
      document.addEventListener('mouseup', this, true, false);
    }

    onmousemove(event) {
      event.preventDefault();

      this.element.style.left = `${event.pageX - this.offsetX}px`;
      this.element.style.top = `${event.pageY - this.offsetY}px`;
    }

    onmouseup(event) {
      if (event.button !== 0)
        return;

      event.preventDefault();

      document.removeEventListener('mousemove', this, true);
      document.removeEventListener('mouseup', this, true);
    }
  }

  class Filter {
    constructor(filter = {}) {
      this.name = filter.name || '';
      this.regexp = Object.assign({}, filter.regexp);
      this.children = filter.children ? filter.children.map(f => new Filter(f)) : [];
      this.hitcount = filter.hitcount || 0;
      this.lasthit = filter.lasthit || 0;
    }

    test(entry) {
      let name;

      for (name in this.regexp)
        if (!this.regexp[name].test(entry[name] || ''))
          return false;

      const hit = this.children.length ? this.children.some(filter => filter.test(entry)) : !!name;

      if (hit && entry.unread) {
        this.hitcount++;
        this.lasthit = Date.now();
      }

      return hit;
    }

    appendChild(filter) {
      if (!(filter instanceof Filter))
        return null;

      this.removeChild(filter);
      this.children.push(filter);
      this.sortChildren();

      return filter;
    }

    removeChild(filter) {
      if (!(filter instanceof Filter))
        return null;

      const index = this.children.indexOf(filter);

      if (index !== -1)
        this.children.splice(index, 1);

      return filter;
    }

    sortChildren() {
      return this.children.sort((a, b) => b.name < a.name);
    }
  }

  class Entry {
    constructor(data) {
      this.data = data;
    }

    get title() {
      const value = $el`<div>${this.data.title || ''}`.first.textContent;

      Object.defineProperty(this, 'title', {configurable: true, value});

      return value;
    }

    get id() {
      return this.data.id;
    }

    get url() {
      return ((this.data.alternate || 0)[0] || 0).href;
    }

    get sourceTitle() {
      return this.data.origin.title;
    }

    get sourceURL() {
      return this.data.origin.streamId.replace(/^[^/]+\//, '');
    }

    get body() {
      return (this.data.content || this.data.summary || 0).content;
    }

    get author() {
      return this.data.author;
    }

    get recrawled() {
      return this.data.recrawled;
    }

    get published() {
      return this.data.published;
    }

    get updated() {
      return this.data.updated;
    }

    get keywords() {
      return (this.data.keywords || []).join(',');
    }

    get unread() {
      return this.data.unread;
    }

    get tags() {
      return this.data.tags.map(tag => tag.label);
    }
  }

  class Panel extends EventEmitter {
    constructor() {
      super();

      this.opened = false;

      const onSubmit = event => {
        event.preventDefault();
        event.stopPropagation();
        this.apply();
      };

      const onKeyPress = event => {
        if (event.keyCode === KeyboardEvent.DOM_VK_ESCAPE)
          this.emit('escape');
      };

      const {element, body, buttons} = $el`
        <form class="fngf-panel" @submit="${onSubmit}" @keydown="${onKeyPress}" ref="element">
          <input type="submit" style="display: none;">
          <div class="fngf-panel-body fngf-column" ref="body"></div>
          <div class="fngf-panel-btns fngf-row" ref="buttons">
            <div class="fngf-btn-group fngf-row">
              <button type="button" class="fngf-btn" @click="${() => this.apply()}">${__`OK`}</button>
              <button type="button" class="fngf-btn" @click="${() => this.close()}">${__`Cancel`}</button>
            </div>
          </div>
        </form>
      `;

      new Draggable(element);

      this.dom = {
        element,
        body,
        buttons,
      };
    }

    open(anchorElement) {
      if (this.opened)
        return;

      if (!this.emit('showing'))
        return;

      if (!anchorElement || anchorElement.nodeType !== 1)
        anchorElement = null;

      document.body.appendChild(this.dom.element);

      this.opened = true;
      this.snapTo(anchorElement);

      if (anchorElement) {
        const onWindowResize = () => this.snapTo(anchorElement);

        window.addEventListener('resize', onWindowResize, false);
        this.on('hidden', () => window.removeEventListener('resize', onWindowResize, false));
      }

      const focused = document.querySelector(':focus');

      if (focused)
        focused.blur();

      const selector = ':not(.feedlyng-panel) > :-moz-any(button, input, select, textarea, [tabindex])';
      const ctrl = Array.from(this.dom.element.querySelectorAll(selector))
        .sort((a, b) => (b.tabIndex || 0) < (a.tabIndex || 0))[0];

      if (ctrl) {
        ctrl.focus();

        if (ctrl.select)
          ctrl.select();
      }

      this.emit('shown');
    }

    apply() {
      if (this.emit('apply'))
        this.close();
    }

    close() {
      if (!this.opened)
        return;

      if (!this.emit('hiding'))
        return;

      document.adoptNode(this.dom.element);
      this.opened = false;

      this.emit('hidden');
    }

    toggle(anchorElement) {
      if (this.opened)
        this.close();

      else
        this.open(anchorElement);
    }

    moveTo(x, y) {
      this.dom.element.style.left = `${x}px`;
      this.dom.element.style.top = `${y}px`;
    }

    snapTo(anchorElement) {
      const pad = 5;
      let x = pad;
      let y = pad;

      if (anchorElement) {
        let {left, bottom: top} = anchorElement.getBoundingClientRect();

        left += pad;
        top += pad;

        const {width, height} = this.dom.element.getBoundingClientRect();
        const right = left + width + pad;
        const bottom = top + height + pad;
        const {innerWidth, innerHeight} = window;

        if (innerWidth < right)
          left -= right - innerWidth;

        if (innerHeight < bottom)
          top -= bottom - innerHeight;

        x = Math.max(x, left);
        y = Math.max(y, top);
      }

      this.moveTo(x, y);
    }

    getFormData(asElement) {
      const data = {};
      const elements = this.dom.body.querySelectorAll('[name]');

      function getValue(el) {
        if (el.localName === 'input' && (el.type === 'checkbox' || el.type === 'radio'))
          return el.checked;

        return 'value' in el ? el.value : el.getAttribute('value');
      }

      for (let el of elements) {
        const value = asElement ? el : getValue(el);
        const path = el.name.split('.');
        let leaf = path.pop();

        const cd = path.reduce((parent, key) => {
          if (!(key in parent))
            parent[key] = {};

          return parent[key];
        }, data);

        if (leaf.endsWith('[]')) {
          leaf = leaf.slice(0, -2);

          if (!(leaf in cd))
            cd[leaf] = [];

          cd[leaf].push(value);
        } else {
          cd[leaf] = value;
        }
      }

      return data;
    }

    appendContent(element) {
      if (element instanceof Array)
        return element.map(el => this.appendContent(el));

      return this.dom.body.appendChild(element);
    }

    removeContents() {
      this.dom.body.innerHTML = '';
    }
  }

  class FilterListPanel extends Panel {
    constructor(filter, isRoot) {
      super();

      this.filter = filter;

      if (isRoot)
        this.dom.element.classList.add('root');

      const onAdd = () => {
        const filter = new Filter();

        filter.name = __`New Filter`;
        this.on('apply', () => this.filter.appendChild(filter));
        this.appendFilter(filter);
      };

      const onPaste = () => {
        if (!clipboard.data)
          return;

        const filter = new Filter(clipboard.receive());

        this.on('apply', () => this.filter.appendChild(filter));
        this.appendFilter(filter);
      };

      const {btns, paste} = $el`
        <div class="fngf-btn-group fngf-row" ref="btns">
          <button type="button" class="fngf-btn" @click="${onAdd}">${__`Add`}</button>
          <button type="button" class="fngf-btn" @click="${onPaste}" ref="paste" disabled>${__`Paste`}</button>
        </div>
      `;

      function pasteState() {
        paste.disabled = !clipboard.data;
      }

      clipboard.on('copy', pasteState);
      clipboard.on('purge', pasteState);
      pasteState();

      this.dom.buttons.insertBefore(btns, this.dom.buttons.firstChild);

      this.on('escape', () => this.close());
      this.on('showing', this.initContents);
      this.on('apply', this);
      this.on('hidden', () => {
        clipboard.off('copy', pasteState);
        clipboard.off('purge', pasteState);
      });
    }

    initContents() {
      const filter = this.filter;
      const {name, terms, tbody, rules} = $el`
        <div class="fngf-panel-name fngf-row fngf-align-center" ref="name">
          ${__`Rule Name`}&nbsp;
          <input type="text" value="${filter.name}" autocomplete="off" name="name" class="fngf-grow">
        </div>
        <div class="fngf-panel-terms" ref="terms">
          <table>
            <tbody ref="tbody"></tbody>
          </table>
        </div>
        <div class="fngf-panel-rules fngf-column" ref="rules">
          <div class="fngf-panel-rule fngf-row fngf-align-center fngf-no-rule">${__`No Rules`}</div>
        </div>
      `;

      const labels = [
        ['title', __`Title`],
        ['url', __`URL`],
        ['sourceTitle', __`Feed Title`],
        ['sourceURL', __`Feed URL`],
        ['author', __`Author`],
        ['keywords', __`Keywords`],
        ['body', __`Contents`],
      ];

      for (let [type, labelText] of labels) {
        const randomId = `id-${Math.random().toFixed(8)}`;
        const reg = filter.regexp[type];
        const sourceValue = reg ? reg.source.replace(/((?:^|[^\\])(?:\\\\)*)\\(?=\/)/g, '$1') : '';

        tbody.appendChild($el`
          <tr ref="row">
            <td>
              <label for="${randomId}">${labelText}</label>
            </td>
            <td>
              <input type="text" class="fngf-panel-terms-textbox" id="${randomId}" autocomplete="off" name="regexp.${type}.source" value="${sourceValue}">
            </td>
            <td>
              <label class="fngf-checkbox fngf-row" title="${__`Ignore Case`}">
                <input type="checkbox" name="regexp.${type}.ignoreCase" bool:checked="${reg && reg.ignoreCase}">
                <span class="fngf-btn" tabindex="0">i</span>
              </label>
            </td>
          </tr>
        `.row);
      }

      this.appendContent([name, terms, rules]);
      this.dom.rules = rules;

      filter.children.forEach(this.appendFilter, this);
    }

    appendFilter(filter) {
      let panel;

      const updateRow = () => {
        let title = __`Hit Count:\t${filter.hitcount}`;

        if (filter.lasthit) {
          title += '\n';
          title += __`Last Hit:\t${new Date(filter.lasthit).toLocaleString()}`;
        }

        rule.title = title;
        name.textContent = filter.name;
        count.textContent = filter.children.length || '';
      };

      const onEdit = () => {
        if (panel) {
          panel.close();
          return;
        }

        panel = new FilterListPanel(filter);
        panel.on('shown', () => btnEdit.classList.add('active'));
        panel.on('hidden', () => {
          btnEdit.classList.remove('active');
          panel = null;
        });
        panel.on('apply', () => setTimeout(updateRow, 0));
        panel.open(btnEdit);
      };

      const onCopy = () => clipboard.copy(filter);

      const onDelete = () => {
        document.adoptNode(rule);
        this.on('apply', () => this.filter.removeChild(filter));
      };

      const {rule, name, count, btnEdit} = $el`
        <div class="fngf-panel-rule fngf-row fngf-align-center" ref="rule">
          <div class="fngf-panel-rule-name" @dblclick="${onEdit}" ref="name"></div>
          <div class="fngf-panel-rule-count fngf-badge" ref="count"></div>
          <div class="fngf-panel-rule-actions fngf-btn-group fngf-menu-btn fngf-row" ref="buttons">
            <button type="button" class="fngf-btn" @click="${onEdit}" ref="btnEdit">${__`Edit`}</button>
            <div class="fngf-dropdown fngf-btn" tabindex="0">
              <div class="fngf-dropdown-menu fngf-column">
                <div class="fngf-dropdown-menu-item fngf-row" @click="${onCopy}">${__`Copy`}</div>
                <div class="fngf-dropdown-menu-item fngf-row" @click="${onDelete}">${__`Delete`}</div>
              </div>
            </div>
          </div>
        </div>
      `;

      updateRow();
      this.dom.rules.appendChild(rule);
    }

    handleEvent(event) {
      if (event.type !== 'apply')
        return;

      const data = this.getFormData(true);
      const filter = this.filter;
      const regexp = {};
      let hasError = false;

      for (let type in data.regexp) {
        const {source, ignoreCase} = data.regexp[type];

        if (!source.value)
          continue;

        try {
          regexp[type] = new RegExp(source.value, ignoreCase.checked ? 'i' : '');
        } catch (e) {
          if (!(e instanceof SyntaxError))
            throw e;

          hasError = true;
          event.preventDefault();
          source.classList.remove('error');
          source.offsetWidth.valueOf();
          source.classList.add('error');
        }
      }

      if (hasError)
        return;

      const prevSource = Serializer.stringify(filter);

      filter.name = data.name.value;
      filter.regexp = regexp;

      if (Serializer.stringify(filter) !== prevSource) {
        filter.hitcount = 0;
        filter.lasthit = 0;
      }

      filter.sortChildren();
    }
  }

  Preference.defaultPref = Serializer.stringify({
    filter: {
      name: '',
      regexp: {},
      children: [
        {
          name: 'AD',
          regexp: {
            title: /^\W?(?:ADV?|PR)\b/,
          },
          children: [],
        },
      ],
    },
  });

  evalInContent(String.raw`
    (() => {
      const XHR = XMLHttpRequest;
      let uniqueId = 0;

      XMLHttpRequest = function XMLHttpRequest() {
        const req = new XHR();

        req.open = open;
        req.setRequestHeader = setRequestHeader;
        req.addEventListener('readystatechange', onReadyStateChange, false);

        return req;
      };

      function open(method, url, async) {
        this.__url__ = url;

        return XHR.prototype.open.apply(this, arguments);
      }

      function setRequestHeader(header, value) {
        if (header === 'Authorization')
          this.__auth__ = value;

        return XHR.prototype.setRequestHeader.apply(this, arguments);
      }

      function onReadyStateChange() {
        if (this.readyState < 4 || this.status !== 200)
          return;

        if (!/^\/\/(?:cloud\.)?feedly\.com\/v3\/streams\/contents\b/.test(this.__url__))
          return;

        const pongEventType = 'streamcontentloaded_callback' + uniqueId++;

        const data = JSON.stringify({
          type: pongEventType,
          auth: this.__auth__,
          text: this.responseText,
        });

        const event = new MessageEvent('streamcontentloaded', {
          bubbles: true,
          cancelable: false,
          data: data,
          origin: location.href,
          source: null,
        });

        let onPong = ({data}) => Object.defineProperty(this, 'responseText', {configurable: true, value: data});

        document.addEventListener(pongEventType, onPong, false);
        document.dispatchEvent(event);
        document.removeEventListener(pongEventType, onPong, false);
      }
    })();
  `);

  const clipboard = new DataTransfer();
  const pref = new Preference();

  let rootFilterPanel;
  let {contextmenu} = $el`
    <menu type="context" id="feedlyng-contextmenu">
      <menu type="context" label="${__`Feedly NG Filter`}" ref="contextmenu"></menu>
    </menu>
  `;

  MenuCommand.contextmenu = contextmenu;

  pref.on('change', function({key, newValue}) {
    switch (key) {
      case 'filter':
        if (!(newValue instanceof Filter))
          this.set('filter', new Filter(newValue));

        break;

      case 'language':
        __.use(newValue);
        break;
    }
  });

  document.addEventListener('streamcontentloaded', event => {
    const logging = pref.get('logging', true);
    const filter = pref.get('filter');
    const filteredEntryIds = [];
    const {type: pongEventType, auth, text} = JSON.parse(event.data);
    const data = JSON.parse(text);
    let hasUnread = false;

    data.items = data.items.filter(item => {
      const entry = new Entry(item);

      if (!filter.test(entry))
        return true;

      if (logging)
        GM_log(`filtered: "${entry.title || ''}" ${entry.url}`);

      filteredEntryIds.push(entry.id);

      if (entry.unread)
        hasUnread = true;

      return false;
    });

    if (!filteredEntryIds.length)
      return;

    let ev = new MessageEvent(pongEventType, {
      bubbles: true,
      cancelable: false,
      data: JSON.stringify(data),
      origin: location.href,
      source: window,
    });

    document.dispatchEvent(ev);

    if (!hasUnread)
      return;

    sendJSON({
      url: '/v3/markers',
      headers: {
        Authorization: auth,
      },
      data: {
        action: 'markAsRead',
        entryIds: filteredEntryIds,
        type: 'entries',
      },
    });
  }, false);

  document.addEventListener('DOMContentLoaded', () => {
    GM_addStyle(CSS_STYLE_TEXT);

    pref.load();
    pref.autosave();

    registerMenuCommands();
    addSettingsMenuItem();
  }, false);

  document.addEventListener('mousedown', ({target}) => {
    if (target.matches('.fngf-dropdown'))
      target.classList.toggle('active');

    target = closest(target, '.fngf-dropdown');

    if (target)
      return;

    const opened = document.querySelector('.fngf-dropdown.active');

    if (opened)
      opened.classList.remove('active');
  }, true);

  document.addEventListener('click', ({target}) => {
    if (!closest(target, '.fngf-dropdown-menu-item'))
      return;

    target = closest(target, '.fngf-dropdown');

    if (target)
      target.classList.remove('active');
  }, true);

  function getGMInfo() {
    if (getGMInfo.cache)
      return getGMInfo.cache;

    const meta = typeof GM_info === 'undefined' ? '' : GM_info.scriptMetaStr;
    const info = {};

    meta.split('\n')
      .map(String.trim)
      .map(line => /@(\S+)\s+(.+)/.exec(line))
      .filter(Boolean)
      .forEach(([, key, value]) => {
        info[key] = value;
      });

    getGMInfo.cache = info;

    return info;
  }

  function $el(strings, ...values) {
    let html = '';

    if (typeof strings === 'string') {
      html = strings;
    } else {
      values.forEach((v, i) => {
        html += strings[i];

        if (v === null || v === undefined)
          return;

        if (v instanceof Node || v instanceof NodeList || v instanceof HTMLCollection || v instanceof Array) {
          html += `<!--${$el.dataPrefix}${i}-->`;

          if (v instanceof Node)
            return;

          const frag = document.createDocumentFragment();

          for (let item of v)
            frag.appendChild(item);

          values[i] = frag;

          return;
        }

        html += v instanceof Object ? i : v;
      });

      html += strings[strings.length - 1];
    }

    const renderer = document.createElement('template');
    const container = document.createElement('body');
    const refs = {};

    renderer.innerHTML = html;
    container.appendChild(renderer.content);

    refs.first = container.firstElementChild;
    refs.last = container.lastElementChild;

    const xpath = document.evaluate(`
      .//*[@ref or @*[starts-with(name(), "@") or contains(name(), ":")]] |
      .//comment()[starts-with(., "${$el.dataPrefix}")]
    `, container, null, 7, null);

    for (let i = 0; i < xpath.snapshotLength; i++) {
      const el = xpath.snapshotItem(i);

      if (el.nodeType === document.COMMENT_NODE) {
        const index = el.data.substring($el.dataPrefix.length);

        el.parentNode.replaceChild(values[index], el);
        continue;
      }

      for (let {name, value} of Array.from(el.attributes)) {
        const data = values[value];

        if (name === 'ref')
          refs[value] = el;

        else if (name.startsWith('@'))
          $el.func(el, name.substring(1), data);

        else if (name === ':class')
          for (let k of Object.keys(data))
            el.classList.toggle(k, data[k]);

        else if (name.startsWith('bool:'))
          el[name.substring(5)] = data;

        else
          continue;

        el.removeAttribute(name);
      }
    }

    return refs;
  }

  $el.dataPrefix = '$el.data:';

  $el.func = (el, type, fn) => {
    if (type)
      el.addEventListener(type, fn, false);

    else
      try {
        fn.call(el, el);
      } catch (e) {}
  };

  function closest(target, selector) {
    while (target && target instanceof Element) {
      if (target.matches(selector))
        return target;

      target = target.parentNode;
    }

    return null;
  }

  function xhr(details) {
    const opt = Object.assign({}, details);
    const {data} = opt;

    if (!opt.method)
      opt.method = data ? 'POST' : 'GET';

    if (data instanceof Object) {
      const arr = [];
      const enc = encodeURIComponent;

      for (let key in data)
        arr.push(`${enc(key)}=${enc(data[key])}`);

      opt.data = arr.join('&');

      if (!opt.headers)
        opt.headers = {};

      opt.headers['Content-Type'] = 'application/x-www-form-urlencoded; charset=utf-8';
    }

    setTimeout(() => GM_xmlhttpRequest(opt), 0);
  }

  function registerMenuCommands() {
    menuCommand(`${__`Setting`}...`, togglePrefPanel);
    menuCommand(`${__`Language`}...`, () => {
      const {langField, select} = $el(`
        <fieldset ref="langField">
          <legend>${__`Language`}</legend>
          <select ref="select"></select>
        </fieldset>
      `);

      __.languages.forEach(lang => {
        const option = $el(`<option value="${lang}">${lang}</option>`).first;

        if (lang === __.config.locale)
          option.selected = true;

        select.appendChild(option);
      });

      const panel = new Panel();

      panel.appendContent(langField);
      panel.on('apply', () => pref.set('language', select.value));
      panel.open();
    });

    menuCommand(`${__`Import Configuration`}...`, () => pref.importFromFile());
    menuCommand(__`Export Configuration`, () => pref.exportToFile());
  }

  function sendJSON(details) {
    const opt = Object.assign({}, details);
    const {data} = opt;

    if (!opt.headers)
      opt.headers = {};

    opt.method = 'POST';
    opt.headers['Content-Type'] = 'application/json; charset=utf-8';
    opt.data = JSON.stringify(data);

    return xhr(opt);
  }

  function evalInContent(code) {
    const script = document.createElement('script');

    script.type = 'application/x-javascript; version=1.8';
    script.textContent = code;
    document.head.appendChild(script);
    document.adoptNode(script);
  }

  function togglePrefPanel(anchorElement) {
    if (rootFilterPanel) {
      rootFilterPanel.close();
      return;
    }

    rootFilterPanel = new FilterListPanel(pref.get('filter'), true);
    rootFilterPanel.on('apply', () => notify(__`NG Settings were modified.\nNew filters take effect after next refresh.`));
    rootFilterPanel.on('hidden', () => {
      clipboard.purge();
      rootFilterPanel = null;
    });
    rootFilterPanel.open(anchorElement);
  }

  function onNGSettingCommand({target}) {
    togglePrefPanel(target);
  }

  function addSettingsMenuItem() {
    const feedlyTabs = document.getElementById('feedlyTabs');

    if (!feedlyTabs) {
      setTimeout(addSettingsMenuItem, 100);
      return;
    }

    let prefListener;
    const observer = new MutationObserver(() => {
      if (prefListener && !document.getElementById('feedly-ng-filter-setting'))
        pref.off('change', prefListener);

      const prefItem = document.querySelector('#feedlyTabs .tab > [data-matching-uri="account"]');

      if (!prefItem)
        return;

      const prefItemTab = prefItem.parentNode;
      const {tab, label} = $el`
        <div class="tab" contextmenu="${MenuCommand.contextmenu.parentNode.id}" @click="${onNGSettingCommand}" ref="tab">
          <div class="header target">
            <img class="icon" src="${getGMInfo().icon}">
            <div class="label primary" id="feedly-ng-filter-setting" ref="label"></div>
          </div>
        </div>
      `;

      label.textContent = __`NG Setting`;

      prefItemTab.parentNode.insertBefore(tab, prefItemTab.nextSibling);
      document.body.appendChild(contextmenu.parentNode);

      prefListener = ({key}) => {
        if (key === 'language')
          label.textContent = __`NG Setting`;
      };

      pref.on('change', prefListener);
    });

    observer.observe(feedlyTabs, {
      childList: true,
    });
  }

  function menuCommand(label, fn) {
    return new MenuCommand(label, fn);
  }

  function openFilePicker(multiple) {
    return new Promise(resolve => {
      const {input} = $el`<input type="file" @change="${() => resolve(Array.from(input.files))}" ref="input">`;

      input.multiple = multiple;
      input.click();
    });
  }

  function notify(body, options) {
    options = Object.assign({body}, notificationDefaults, options);

    return new Promise((resolve, reject) => {
      Notification.requestPermission(status => {
        if (status !== 'granted') {
          reject(status);
          return;
        }

        const n = new Notification(options.title, options);

        if (options.autoClose)
          setTimeout(() => n.close(), options.autoClose);

        resolve(n);
      });
    });
  }
})();