Feedly NG Filter

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

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

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey, Greasemonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey किंवा Violentmonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

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

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्क्रिप्ट व्यवस्थापक एक्स्टेंशन इंस्टॉल करावे लागेल.

(माझ्याकडे आधीच युझर स्क्रिप्ट व्यवस्थापक आहे, मला इंस्टॉल करू द्या!)

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

(माझ्याकडे आधीच युझर स्टाईल व्यवस्थापक आहे, मला इंस्टॉल करू द्या!)

// ==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://greatest.deepsurf.us/scripts/9030-feedly-ng-filter
// @supportURL     https://twitter.com/intent/tweet?text=%40xulapp+
// @icon           https://greatest.deepsurf.us/system/screenshots/screenshots/000/000/615/original/icon.png
// @screenshot     https://greatest.deepsurf.us/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);
      });
    });
  }
})();