Feedly NG Filter

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

目前為 2015-09-28 提交的版本,檢視 最新版本

// ==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_notification
// @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.7.0
// ==/UserScript==


(function feedlyNGFilter() {
	const CSS_STYLE_TEXT = String.raw`
		.unselectable {
			-moz-user-select: none;
		}
		.goog-inline-block {
			display: inline-block;
			position: relative;
		}
		.jfk-button {
			min-width: 54px;
			height: 27px;
			margin-right: 16px;
			padding: 0 8px;
			border-radius: 2px 2px 2px 2px;
			line-height: 27px;
			font-size: 11px;
			font-weight: bold;
			white-space: nowrap;
			text-align: center;
			outline: 0 none;
			cursor: default;
		}
		.jfk-button-standard {
			background-image: linear-gradient(to bottom, #f5f5f5, #f1f1f1);
			color: #444;
			border: 1px solid rgba(0, 0, 0, 0.1);
		}
		.jfk-button-standard:hover {
			border: 1px solid #c6c6c6;
			background-image: linear-gradient(to bottom, #f8f8f8, #f1f1f1);
			color: #333;
		}
		.jfk-button-standard:focus {
			border: 1px solid #4d90fe;
		}
		.jfk-button-standard:active {
			box-shadow: inset 0 1px 2px rgba(0, 0, 0, .1);
		}
		.jfk-button-standard.jfk-button-disabled {
			border: 1px solid rgba(0, 0, 0, 0.05);
			background: none;
			color: #b8b8b8;
		}
		.goog-flat-menu-button {
			min-width: 46px;
			margin: 0 2px;
			padding: 0 18px 0 6px;
			border: 1px solid #dcdcdc;
			border-radius: 2px 2px 2px 2px;
			background-image: linear-gradient(to bottom, #f5f5f5, #f1f1f1);
			color: #444;
			font-size: 11px;
			font-weight: bold;
			line-height: 27px;
			list-style: none outside none;
			text-align: center;
			text-decoration: none;
			vertical-align: middle;
			outline: medium none;
			cursor: default;
		}
		.goog-flat-menu-button-open,
		.goog-flat-menu-button:active {
			border: 1px solid #ccc;
			background-image: linear-gradient(to bottom, #eee, #e0e0e0);
			box-shadow:inset 0 1px 2px rgba(0, 0, 0, .1);
			color: #333;
			z-index: 2
		}
		.goog-flat-menu-button-collapse-left {
			min-width: 0;
			margin-left: -1px;
			padding-left: 0;
			border-bottom-left-radius: 0;
			border-top-left-radius: 0;
			vertical-align: top;
		}
		.jfk-button-collapse-left,
		.jfk-button-collapse-right {
			z-index: 1;
		}
		.jfk-button-collapse-right {
			margin-right: 0;
			border-top-right-radius: 0;
			border-bottom-right-radius: 0;
		}
		.goog-flat-menu-button-caption {
			vertical-align: top;
			white-space: nowrap;
		}
		.goog-flat-menu-button-dropdown {
			position: absolute;
			right: 5px;
			top: 12px;
			width: 0;
			height: 0;
			border-color: #777 transparent;
			border-style: solid;
			border-width: 4px 4px 0;
		}
		.goog-menu {
			position: absolute
			margin: 0;
			padding: 6px 0;
			border: 1px solid rgba(0, 0, 0, .2);
			background: #fff;
			font-size: 13px;
			outline: none;
			box-shadow: 0 2px 4px rgba(0, 0, 0, .2);
			transition: opacity .218s;
			cursor: default;
		}
		.goog-menuitem {
			position: relative;
			margin: 0;
			padding: 6px 7em 6px 30px;
			white-space: nowrap;
			color: #333;
			list-style: none;
			cursor: pointer;
		}
		.goog-menuitem:hover {
			padding-top: 5px;
			padding-bottom: 5px
			border-color: #eee;
			border-style: dotted;
			border-width: 1px 0;
			background-color: #eee;
			color: #333;
		}
		.feedlyng-menu-button-container > .goog-menu-button {
			margin-left: -2px;
		}
		.feedlyng.goog-menu {
			position: absolute;
			z-index: 2147483646;
		}
		.feedlyng .goog-menuitem:hover {
			background-color: #eeeeee;
		}
		#feedlyng-open-panel {
			float: left;
		}
		.feedlyng-panel {
			position: fixed;
			background-color: #ffffff;
			color: #333333;
			box-shadow: 1px 2px 5px rgba(0, 0, 0, 0.5);
			z-index: 2147483646;
		}
		.feedlyng-panel :-moz-any(label, legend) {
			cursor: default;
		}
		.feedlyng-panel input[type="text"] {
			padding: 2px;
			border: 1px solid #b2b2b2;
		}
		.feedlyng-panel-body {
			margin: 8px;
		}
		.feedlyng-panel-body > fieldset {
			margin: 8px 0;
		}
		.feedlyng-panel.root > .feedlyng-panel-body > :-moz-any(.feedlyng-panel-name, fieldset) {
			display: none;
		}
		.feedlyng-panel-terms {
			border-spacing: 2px;
		}
		.feedlyng-panel-terms > tbody > tr > td {
			padding: 0;
			white-space: nowrap;
		}
		.feedlyng-panel-terms :-moz-any(input, label) {
			margin: 0;
			vertical-align: middle;
		}
		@-moz-keyframes error {
			0% {
				background-color: #ffff00;
				border-color: #ff0000;
			}
		}
		.feedlyng-panel-terms-textbox.error {
			animation: error 1s;
		}
		.feedlyng-panel-terms-textbox-label {
			display: block;
			font-size: 90%;
			text-align: right;
		}
		.feedlyng-panel-terms-textbox-label:after {
			content: ":";
		}
		.feedlyng-panel-terms-checkbox-label {
			padding: 0 8px;
		}
		.feedlyng-panel-rules {
			display: table;
		}
		.feedlyng-panel-rule {
			display: table-row;
		}
		.feedlyng-panel-rule:hover {
			background-color: #eeeeee;
		}
		.feedlyng-panel-rule > div {
			display: table-cell;
			white-space: nowrap;
		}
		.feedlyng-panel-rule-name {
			width: 100%;
			padding-left: 16px;
			cursor: default;
		}
		.feedlyng-panel-rule-count {
			padding: 0 8px;
			font-weight: bold;
			cursor: default;
		}
		.feedlyng-panel-buttons {
			margin: 8px;
			text-align: right;
			white-space: nowrap;
		}
		.feedlyng-panel-addfilter {
			float: left;
			margin-right: 8px;
		}
		.feedlyng-panel-pastefilter {
			float: left;
			margin-right: 16px;
		}
		.feedlyng-panel-ok {
			margin-right: 8px;
		}
		.feedlyng-panel-cancel {
			margin-right: 0;
		}
	`;

	function __(strings, ...values) {
		var 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) => {
			return values[cap];
		});
	}

	Object.defineProperties(__, {
		'config': {
			configurable: true,
			value: {
				defaultLocale: 'en-US',
			},
		},
		'locales': {
			configurable: true,
			value: {},
		},
		'data': {
			configurable: true,
			get() {
				return this.locales[this.config.locale];
			},
		},
		'languages': {
			configurable: true,
			get() {
				return Object.keys(this.locales);
			},
		},
		'add': {
			configurable: 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,
			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',
			'{0} Rules': '{0} Rules',
			'Title': 'Title',
			'URL': 'URL',
			'Feed Title': 'Feed Title',
			'Feed URL': 'Feed URL',
			'Author': 'Author',
			'Contents': 'Contents',
			'Ignore Case': 'Ignore Case',
			'Edit': 'Edit',
			'Delete': 'Delete',
			'Hit Count': 'Hit Count:\t{0}',
			'Last Hit': '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': '新しいフィルタ',
			'{0} Rules': '{0}のルール',
			'Title': 'タイトル',
			'URL': 'URL',
			'Feed Title': 'フィードのタイトル',
			'Feed URL': 'フィードの URL',
			'Author': '著者',
			'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);

	function Class(sup, pro) {
		if (sup && typeof sup === 'object')
			pro = sup, sup = Object;

		var con = Object.getOwnPropertyDescriptor(pro, 'constructor');
		if (!con)
			con = {value: Function(), writable: true, configurable: true};

		if (con.configurable) {
			con.enumerable = false;
			Object.defineProperty(pro, 'constructor', con);
		}

		con = pro.constructor;
		con.prototype = pro;
		con.superclass = sup;
		Object.setPrototypeOf(con, Class.prototype);
		Object.setPrototypeOf(pro, sup && sup.prototype);

		return Proxy.createFunction(con, function() con.createInstance(arguments));
	}

	Class = Class(Function, {
		constructor: Class,
		$super: function $super() {
			var sup = this.superclass;
			var method = sup.prototype[$super.caller === this ? 'constructor' : $super.caller.name];

			return Function.prototype.call.apply(method, arguments);
		},
		isSubClass: function isSubClass(cls) {
			return this.prototype instanceof cls;
		},
		createInstance: function createInstance(args) {
			var instance = Object.create(this.prototype);
			var result = this.apply(instance, args || []);

			return result instanceof Object ? result : instance;
		},
		toString: function toString() {
			var arr = [];
			var cls = this;
			do {
				arr.push(cls.name);
			} while (cls = cls.superclass);

			return '[object Class [class ' + arr.join(', ') + ']]';
		},

		getOwnPropertyDescriptor: function(name) Object.getOwnPropertyDescriptor(this, name),
		getPropertyDescriptor: function(name) Object.getPropertyDescriptor(this, name),
		getOwnPropertyNames: function(name) Object.getOwnPropertyNames(this, name),
		getPropertyNames: function(name) Object.getPropertyNames(this, name),
		defineProperty: function(name) Object.defineProperty(this, name),
		delete: function(name) delete this[name],
		fix: function() {
			if (!Object.isFrozen(this))
				return void 0;

			var res = {};
			Object.getOwnPropertyNames(this).forEach((name) => res[name] = Object.getOwnPropertyDescriptor(this, name));

			return res;
		},
		has: function(name) name in this,
		hasOwn: function(name) Object.prototype.hasOwnProperty.call(this, name),
		get: function(receiver, name) {
			if (name in this)
				return this[name];

			var method = this.prototype[name];

			if (typeof method === 'function')
				return Function.prototype.call.bind(method);

			return void 0;
		},
		set: function(receiver, name, val) this[name] = val,
		enumerate: function() [name for (name in this)],
		keys: function() Object.keys(this),
	});

	var Subject = Class({
		constructor: function Subject() {
			this.listeners = {};
		},
		on: function on(type, listener) {
			type += '';

			if (type.trim().indexOf(' ') !== -1) {
				type.match(/\S+/g).forEach(function(t) this.on(t, listener), this);
				return;
			}

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

			var arr = this.listeners[type];
			var index = arr.indexOf(listener);

			if (index === -1)
				arr.push(listener);
		},
		once: function once(type, listener) {
			function onetimeListener() {
				this.removeListener(onetimeListener);

				return listener.apply(this, arguments);
			}

			this.on(type, onetimeListener);

			return onetimeListener;
		},
		removeListener: function removeListener(type, listener) {
			if (!(type in this.listeners))
				return;

			var arr = this.listeners[type];
			var index = arr.indexOf(listener);

			if (index !== -1)
				arr.splice(index, 1);
		},
		removeAllListeners: function removeAllListeners(type) {
			delete this.listeners[type];
		},
		dispatchEvent: function dispatchEvent(event) {
			event.timeStamp = Date.now();
			if (event.type in this.listeners) {
				this.listeners[event.type].concat().forEach(function(listener) {
					try {
						if (typeof listener === 'function')
							listener.call(this, event);

						else
							listener.handleEvent(event);

					} catch (e) {
						setTimeout(function() { throw e; }, 0);
					}
				}, this);
			}

			return !event.canceled;
		},
		emit: function emit(type, data) {
			var event = this.createEvent(type);
			if (data instanceof Object)
				extend(event, data);

			return this.dispatchEvent(event);
		},
		createEvent: function createEvent(type) {
			return new Event(type, this);
		},
	});

	var Event = Class({
		constructor: function Event(type, target) {
			this.type = type;
			this.target = target;
		},
		canceled: false,
		timeStamp: null,
		preventDefault: function preventDefault() {
			this.canceled = true;
		},
	});

	var DataTransfer = Class(Subject, {
		constructor: function DataTransfer() {
			DataTransfer.$super(this);
		},
		set: function set(type, data) {
			this.purge();
			this.type = type;
			this.data = data;
			this.emit(type, {data: data});
		},
		purge: function purge() {
			this.emit('purge', {data: this.data});
			delete this.data;
		},
		setForCut: function setForCut(data) {
			this.set('cut', data);
		},
		setForCopy: function setForCopy(data) {
			this.set('copy', data);
		},
		receive: function receive() {
			var data = this.data;
			if (this.type === 'cut')
				this.purge();

			return data;
		},
	});

	var MenuCommand = Class({
		constructor: function MenuCommand(label, oncommand, disabled) {
			this.label = label;
			this.oncommand = oncommand;
			this.disabled = !!disabled;

			this.register();
		},
		register: function register() {
			this.uuid = GM_registerMenuCommand(this.label, this.oncommand);

			if (MenuCommand.contextmenu) {
				this.menuitem = document.createElement('menuitem');
				this.menuitem.label = this.label;
				this.menuitem.addEventListener('click', this.oncommand, false);
				MenuCommand.contextmenu.appendChild(this.menuitem);
			}

			if (this.disabled)
				this.disable();
		},
		unregister: function unregister() {
			if (typeof GM_unregisterMenuCommand === 'function')
				GM_unregisterMenuCommand(this.uuid);

			document.adoptNode(this.menuitem);
		},
		disable: function disable() {
			if (typeof GM_disableMenuCommand === 'function')
				GM_disableMenuCommand(this.uuid);

			this.menuitem.disabled = true;
		},
		enable: function enable() {
			if (typeof GM_enableMenuCommand === 'function')
				GM_enableMenuCommand(this.uuid);

			this.menuitem.disabled = false;
		},
	});
	MenuCommand.contextmenu = null;

	var Preference = Class(Subject, {
		constructor: function Preference() {
			if (Preference._instance)
				return Preference._instance;

			Preference.$super(this);
			Preference._instance = this;

			this.dict = {};
		},
		has: function has(key) key in this.dict,
		get: function get(key, def) this.has(key) ? this.dict[key] : def,
		set: function set(key, value) {
			var prev = this.dict[key];
			if (value !== prev) {
				this.dict[key] = value;
				this.emit('change', {
					propertyName: key,
					prevValue: prev,
					newValue: value,
				});
			}

			return value;
		},
		del: function del(key) {
			if (!this.has(key))
				return;

			var prev = this.dict[key];
			delete this.dict[key];

			this.emit('delete', {
				propertyName: key,
				prevValue: prev,
			});
		},
		load: function load(str) {
			if (!str)
				str = GM_getValue(Preference.prefName, Preference.defaultPref || '({})');

			var obj = eval('(' + str + ')');
			if (!obj || typeof obj !== 'object')
				return;

			this.dict = {};

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

			this.emit('load');
		},
		write: function write() {
			GM_setValue(Preference.prefName, this.toSource());
		},
		autoSave: function autoSave() {
			if (autoSave.reserved)
				return;

			window.addEventListener('unload', () => this.write(), false);
			autoSave.reserved = true;
		},
		exportToFile: function exportToFile() {
			var blob = new Blob([this.toSource()], {
				type: 'application/octet-stream',
			});

			var url = URL.createObjectURL(blob);
			location.href = url;
			URL.revokeObjectURL(url);
		},
		importFromString: function importFromString(str) {
			try {
				this.load(str);
			} catch (e if e instanceof SyntaxError) {
				showMessage(e, 'warning');
				return false;
			}

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

			return true;
		},
		importFromFile: function importFromFile() {
			openFilePicker(files => {
				if (!files)
					return;

				var r = FileReader();
				r.addEventListener('load', () => this.importFromString(r.result), false);
				r.readAsText(files[0]);
			});
		},
		toString: function toString() '[object Preference]',
		toSource: function toSource() this.dict.toSource(),
	});

	Preference.prefName = 'settings';

	var draggable = Class({
		constructor: function draggable(element) {
			this.element = element;
			element.addEventListener('mousedown', this, false, false);
		},
		isDraggableTarget: function isDraggableTarget(target) {
			if (!target)
				return false;

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

			return !target.mozMatchesSelector(':-moz-any(select, button, input, textarea, [tabindex]), :-moz-any(select, button, input, textarea, [tabindex]) *');
		},
		detatch: function detatch() {
			this.element.removeEventListener('mousedown', this, false);
		},
		handleEvent: function handleEvent(event) {
			var name = 'on' + event.type;
			if (name in this)
				this[name](event);
		},
		onmousedown: function onMouseDown(event) {
			if (event.button !== 0)
				return;

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

			event.preventDefault();

			var 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: function onMouseMove(event) {
			event.preventDefault();

			this.element.style.left = event.pageX - this.offsetX + 'px';
			this.element.style.top = event.pageY - this.offsetY + 'px';
		},
		onmouseup: function onMouseUp(event) {
			if (event.button !== 0)
				return;

			event.preventDefault();

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

	var Filter = Class({
		constructor: function Filter(filter) {
			if (!(this instanceof Filter))
				return Filter.createInstance(arguments);

			if (!(filter instanceof Object))
				filter = {};

			this.name = filter.name || '';
			this.regexp = extend({}, filter.regexp || {});
			this.children = filter.children ? filter.children.map(Filter) : [];
			this.hitcount = filter.hitcount || 0;
			this.lasthit = filter.lasthit || 0;
		},
		test: function test(entry) {
			for (var [name, reg] in Iterator(this.regexp))
				if (!reg.test(entry[name] || ''))
					return false;

			var hit = this.children.length ? this.children.some(filter => filter.test(entry)) : !!reg;
			if (hit) {
				this.hitcount++;
				this.lasthit = Date.now();
			}

			return hit;
		},
		appendChild: function appendChild(filter) {
			if (!(filter instanceof this.constructor))
				return null;

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

			return filter;
		},
		removeChild: function removeChild(filter) {
			if (!(filter instanceof this.constructor))
				return null;

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

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

	var Entry = Class({
		constructor: function Entry(data) {
			this.data = data;
		},
		get title() {
			var div = document.createElement('div');
			div.innerHTML = this.data.title || '';
			Object.defineProperty(this, 'title', {configurable: true, value: div.textContent});

			return this.title;
		},
		get id()          this.data.id,
		get url()         ((this.data.alternate || 0)[0] || 0).href,
		get sourceTitle() this.data.origin.title,
		get sourceURL()   this.data.origin.streamId.replace(/^[^/]+\//, ''),
		get body()        (this.data.content || this.data.summary || 0).content,
		get author()      this.data.author,
		get recrawled()   this.data.recrawled,
		get published()   this.data.published,
		get updated()     this.data.updated,
		get keywords()    this.data.keywords,
		get unread()      this.data.unread,
		get tags()        this.data.tags.map(tag => tag.label),
	});

	var Panel = Class(Subject, {
		constructor: function Panel() {
			Panel.$super(this);

			var panel = document.createElement('form');
			panel.classList.add('feedlyng-panel');
			draggable(panel);
			panel.addEventListener('submit', event => {
				event.preventDefault();
				event.stopPropagation();
				this.apply();
			}, false);

			var submit = document.createElement('input');
			submit.type = 'submit';
			submit.style.display = 'none';

			var body = document.createElement('div');
			body.classList.add('feedlyng-panel-body');

			var buttons = document.createElement('div');
			buttons.classList.add('feedlyng-panel-buttons');

			var ok = createGoogButton(__`OK`, () => this.apply());
			ok.classList.add('feedlyng-panel-ok');

			var cancel = createGoogButton(__`Cancel`, () => this.close());
			cancel.classList.add('feedlyng-panel-cancel');

			panel.appendChild(submit);
			panel.appendChild(body);
			panel.appendChild(buttons);
			buttons.appendChild(ok);
			buttons.appendChild(cancel);

			this.dom = {
				element: panel,
				body: body,
				buttons: buttons,
			};
		},
		get opened() !!this.dom.element.parentNode,
		open: function 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.snapTo(anchorElement);

			if (anchorElement) {
				let onWindowResize = this.snapTo.bind(this, anchorElement);
				window.addEventListener('resize', onWindowResize, false);
				this.on('hidden', window.removeEventListener.bind(window, 'resize', onWindowResize, false));
			}

			var focused = document.querySelector(':focus');
			if (focused)
				focused.blur();

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

			if (tab) {
				tab.focus();
				if (tab.select)
					tab.select();
			}

			this.emit('shown');
		},
		apply: function apply() {
			if (this.emit('apply'))
				this.close();
		},
		close: function close() {
			if (!this.opened)
				return;

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

			document.adoptNode(this.dom.element);

			this.emit('hidden');
		},
		toggle: function toggle(anchorElement) {
			if (this.opened)
				this.close();

			else
				this.open(anchorElement);
		},
		moveTo: function moveTo(x, y) {
			this.dom.element.style.left = x + 'px';
			this.dom.element.style.top = y + 'px';
		},
		snapTo: function snapTo(anchorElement) {
			var pad = 5;
			var x = pad;
			var y = pad;

			if (anchorElement) {
				var {left, bottom: top} = anchorElement.getBoundingClientRect();
				left += pad;
				top += pad;

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

				var {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: function getFormData(asElement) {
			var data = {};

			Array.slice(this.dom.body.querySelectorAll('[name]')).forEach((elem) => {
				var value;

				if (asElement) {
					value = elem;

				} else {
					if (elem.localName.toLowerCase() === 'input' && (elem.type === 'checkbox' || elem.type === 'radio'))
						value = elem.checked;

					else
						value = 'value' in elem ? elem.value : elem.getAttribute('value');
				}

				var path = elem.name.split('.');
				var leaf = path.pop();
				var cd = path.reduce((parent, dirName) => {
					if (!(dirName in parent))
						parent[dirName] = {};

					return parent[dirName];
				}, data);

				var reg = /\[\]$/;

				if (reg.test(leaf)) {
					leaf = leaf.replace(reg, '');
					if (!(leaf in cd))
						cd[leaf] = [];

					cd[leaf].push(value);

				} else {
					cd[leaf] = value;
				}
			});

			return data;
		},
		appendContent: function appendContent(element) {
			if (element instanceof Array)
				return element.map(appendContent, this);

			return this.dom.body.appendChild(element);
		},
		removeContents: function removeContents() {
			var range = node.ownerDocument.createRange();
			range.selectNodeContents(this.dom.body);
			range.deleteContents();
			range.detach();
		},
	});

	var FilterListPanel = Class(Panel, {
		constructor: function FilterListPanel(filter, isRoot) {
			FilterListPanel.$super(this);
			this.filter = filter;

			var self = this;

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

			var add = createGoogButton(__`Add`, () => {
				var f = new Filter();
				f.name = __`New Filter`;
				this.on('apply', () => this.filter.appendChild(f));
				this.appendFilter(f);
			});
			add.classList.add('feedlyng-panel-addfilter');
			this.dom.buttons.insertBefore(add, this.dom.buttons.firstChild);

			var paste = createGoogButton(__`Paste`, () => {
				if (!clipboard.data)
					return;

				var f = new Filter(clipboard.receive());

				this.on('apply', () => this.filter.appendChild(f));
				this.appendFilter(f);
			});
			paste.classList.add('feedlyng-panel-pastefilter');
			if (!clipboard.data)
				paste.classList.add('jfk-button-disabled');

			clipboard.on('copy', onCopy);
			clipboard.on('purge', onPurge);

			function onCopy() {
				paste.classList.remove('jfk-button-disabled');
			}

			function onPurge() {
				paste.classList.add('jfk-button-disabled');
			}

			this.dom.buttons.insertBefore(paste, add.nextSibling);

			this.on('showing', this.initContents);
			this.on('apply', this);
			this.on('hidden', () => {
				clipboard.removeListener('copy', onCopy);
				clipboard.removeListener('purge', onPurge);
			});
		},
		initContents: function initContents() {
			var filter = this.filter;

			var nameTextbox = document.createElement('input');
			nameTextbox.classList.add('feedlyng-panel-name');
			nameTextbox.type = 'text';
			nameTextbox.name = 'name';
			nameTextbox.size = '32';
			nameTextbox.autocomplete = 'off';
			nameTextbox.value = filter.name;

			var terms = document.createElement('fieldset');
			var legend = document.createElement('legend');
			legend.textContent = __`${filter.name} Rules`;

			var table = document.createElement('table');
			table.classList.add('feedlyng-panel-terms');

			var tbody = document.createElement('tbody');
			for (let [type, labelText] in Iterator({
				title:       __`Title`,
				url:         __`URL`,
				sourceTitle: __`Feed Title`,
				sourceURL:   __`Feed URL`,
				author:      __`Author`,
				body:        __`Contents`,
			})) {
				let row = document.createElement('tr');

				let left = document.createElement('td');
				let center = document.createElement('td');
				let right = document.createElement('td');

				let textbox = document.createElement('input');
				textbox.classList.add('feedlyng-panel-terms-textbox');
				textbox.type = 'text';
				textbox.name = 'regexp.' + type + '.source';
				textbox.size = '32';
				textbox.autocomplete = 'off';

				if (type in filter.regexp)
					textbox.value = filter.regexp[type].source.replace(/((?:^|[^\\])(?:\\\\)*)\\(?=\/)/g, '$1');

				let label = createLabel(textbox, labelText);
				label.classList.add('feedlyng-panel-terms-textbox-label');

				let ic = document.createElement('input');
				ic.classList.add('feedlyng-panel-terms-checkbox');
				ic.type = 'checkbox';
				ic.name = 'regexp.' + type + '.ignoreCase';

				if (type in filter.regexp)
					ic.checked = filter.regexp[type].ignoreCase;

				let icl = createLabel(ic, 'i');
				icl.classList.add('feedlyng-panel-terms-checkbox-label');
				icl.title = __`Ignore Case`;

				tbody.appendChild(row);
				row.appendChild(left);
				left.appendChild(label);
				row.appendChild(center);
				center.appendChild(textbox);
				row.appendChild(right);
				right.appendChild(ic);
				right.appendChild(icl);
			}

			var rules = document.createElement('div');
			rules.classList.add('feedlyng-panel-rules');

			terms.appendChild(legend);
			terms.appendChild(table);
			table.appendChild(tbody);
			this.appendContent([nameTextbox, terms, rules]);

			this.dom.rules = rules;
			filter.children.forEach(this.appendFilter, this);
		},
		appendFilter: function appendFilter(filter) {
			var panel;

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

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

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

				panel = new FilterListPanel(filter);
				panel.on('shown', () => edit.querySelector('.jfk-button').classList.add('jfk-button-checked'));
				panel.on('hidden', () => {
					edit.querySelector('.jfk-button').classList.remove('jfk-button-checked');
					panel = null;
				});
				panel.on('apply', setTimeout.bind(null, updateRow, 0));
				panel.open(this);
			};

			var onCopy = () => clipboard.setForCopy(filter);

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

			var rule = document.createElement('div');
			rule.classList.add('feedlyng-panel-rule');

			if (filter.children.length)
				rule.classList.add('parent');

			var name = document.createElement('div');
			name.classList.add('feedlyng-panel-rule-name');
			name.addEventListener('dblclick', onEdit, true);

			var count = document.createElement('div');
			count.classList.add('feedlyng-panel-rule-count');

			var buttons = document.createElement('div');
			buttons.classList.add('feedlyng-panel-rule-buttons');

			var edit = createGoogMenuButton(__`Edit`, onEdit, [[__`Copy`, onCopy], [__`Delete`, onDelete]]);
			edit.classList.add('feedlyng-panel-rule-edit');

			updateRow();

			rule.appendChild(name);
			rule.appendChild(count);
			rule.appendChild(buttons);
			buttons.appendChild(edit);
			this.dom.rules.appendChild(rule);
		},
		handleEvent: function handleEvent(event) {
			if (event.type !== 'apply')
				return;

			var data = this.getFormData(true);
			var filter = this.filter;

			var regexp = {};
			var error = false;

			for (let [type, {source, ignoreCase}] in Iterator(data.regexp)) {
				if (!source.value)
					continue;

				try {
					regexp[type] = RegExp(source.value, ignoreCase.checked ? 'i' : '');

				} catch (e if e instanceof SyntaxError) {
					error = true;
					event.preventDefault();
					source.classList.remove('error');
					source.offsetWidth;
					source.classList.add('error');
				}
			}

			if (error)
				return;

			var prevSource = filter.toSource();
			filter.name = data.name.value;
			filter.regexp = regexp;

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

			filter.sortChildren();
		},
	});

	var GoogMenu = Class({
		constructor: function GoogMenu(anchorElement, items) {
			this.items = items;
			this.anchorElement = anchorElement;
			anchorElement.addEventListener('mousedown', this, false);
		},
		get opened() !!((this.dom || 0).element || 0).parentNode,
		init: function init() {
			var menu = document.createElement('div');
			menu.className = 'feedlyng goog-menu goog-menu-vertical';
			menu.addEventListener('click', this, false);

			this.items.forEach((item) => {
				var menuitem = document.createElement('div');

				if (typeof item === 'string') {
					if (/^-+$/.test(item))
						menuitem.className = 'goog-menuseparator';

				} else {
					var [label, fn] = item;
					menuitem.className = 'goog-menuitem';

					var content = document.createElement('div');
					content.className = 'goog-menuitem-content';
					content.textContent = label;
					menuitem.appendChild(content);

					if (fn)
						menuitem.addEventListener('click', fn, false);
				}

				menu.appendChild(menuitem);
			});

			this.dom = {
				element: menu,
			};
		},
		open: function open() {
			if (this.opened)
				return;

			var {right, bottom} = this.anchorElement.getBoundingClientRect();
			var menu = this.dom.element;
			document.body.appendChild(menu);
			menu.style.left = right - menu.offsetWidth + 'px';
			menu.style.top = bottom + 'px';

			this.anchorElement.classList.add('goog-flat-menu-button-open');

			document.addEventListener('mousedown', this, true);
			document.addEventListener('blur', this, true);
		},
		close: function close() {
			document.removeEventListener('mousedown', this, true);
			document.removeEventListener('blur', this, true);
			document.adoptNode(this.dom.element);
			this.anchorElement.classList.remove('goog-flat-menu-button-open');
		},
		handleEvent: function handleEvent({type, target, currentTarget}) {
			switch (type) {
			case 'blur':
				if (target === document)
					this.close();

				return;

			case 'click':
				if (target.mozMatchesSelector('.goog-menuitem, .goog-menuitem *'))
					this.close();

				return;

			case 'mousedown':
				var pos = this.anchorElement.compareDocumentPosition(target);

				if (currentTarget === document && (!pos || pos & target.DOCUMENT_POSITION_CONTAINED_BY))
					return;

				if (this.opened) {
					if (!target.mozMatchesSelector('.goog-menu *'))
						this.close();

				} else {
					if (!this.dom)
						this.init();

					this.open();
				}

				return;
			}
		},
	});

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

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

			XMLHttpRequest = function XMLHttpRequest() {
				var 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;

				var pongEventType = 'streamcontentloaded_callback' + uniqueId++;

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

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

				var onPong = ({data}) => Object.defineProperty(this, 'responseText', {configurable: true, value: data});
				document.addEventListener(pongEventType, onPong, false);
				document.dispatchEvent(event);
				document.removeEventListener(pongEventType, onPong, false);
			}
		})();
	`);

	document.addEventListener('streamcontentloaded', function(event) {
		var {type: pongEventType, auth, text} = JSON.parse(event.data);
		var data = JSON.parse(text);

		var logging = pref.get('logging', true);
		var filter = pref.get('filter');
		var filteredEntryIds = [];
		var hasUnread = false;

		data.items = data.items.filter((item) => {
			var 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;

		var data = JSON.stringify(data);

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

		} catch (e if e instanceof TypeError) {
			var ev = document.createEvent('MessageEvent');
			ev.initMessageEvent(pongEventType, true, false, data, location.href, '', null);
		}

		document.dispatchEvent(ev);

		if (!hasUnread)
			return;

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

	var contextmenu = document.createElement('menu');
	contextmenu.type = 'context';
	contextmenu.id = 'feedlyng-contextmenu';
	MenuCommand.contextmenu = contextmenu;

	var rootFilterPanel;
	var settingsMenuItem;
	var clipboard = new DataTransfer();
	var pref = new Preference();
	pref.on('change', function({propertyName, newValue}) {
		switch (propertyName) {
		case 'filter':
			if (!Filter.prototype.isPrototypeOf(newValue))
				this.set('filter', new Filter(newValue));

			break;

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

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

		pref.load();
		pref.autoSave();

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

	function registerMenuCommands() {
		menuCommand(__`Setting` + '...', togglePrefPanel);
		menuCommand(__`Language` + '...', function() {
			var langField = document.createElement('fieldset');

			var title = document.createElement('legend');
			title.textContent = __`Language`;

			var select = document.createElement('select');
			__.languages.forEach((lang) => {
				var option = document.createElement('option');
				option.value = lang;
				option.textContent = lang;

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

				select.appendChild(option);
			});

			langField.appendChild(title);
			langField.appendChild(select);

			var p = new Panel();
			p.appendContent(langField);
			p.on('apply', () => pref.set('language', select.value));
			p.open();
		});

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

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

		rootFilterPanel = new FilterListPanel(pref.get('filter'), true);
		rootFilterPanel.on('apply', () => showMessage(__`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 createGoogButton(text, fn) {
		var button = document.createElement('div');
		button.className = 'goog-inline-block jfk-button jfk-button-standard unselectable';
		button.tabIndex = 0;
		button.textContent = text;

		if (fn) {
			button.addEventListener('click', fn, false);
			button.addEventListener('keydown', function({which}) {
				if (which === 13)
					fn.apply(this, arguments);
			}, false);
		}

		return button;
	}

	function createGoogMenuButton(text, fn, arr) {
		var container = document.createElement('div');
		container.className = 'goog-inline-block';

		var button = createGoogButton(text, fn);
		button.classList.add('jfk-button-collapse-right');

		var options = document.createElement('div');
		options.className = 'goog-inline-block goog-flat-menu-button goog-flat-menu-button-collapse-left unselectable';
		options.tabIndex = 0;

		container.appendChild(button);
		container.appendChild(options);
		options.insertAdjacentHTML('beforeend', '<div class="goog-inline-block goog-flat-menu-button-caption">&nbsp;</div>');
		options.insertAdjacentHTML('beforeend', '<div class="goog-inline-block goog-flat-menu-button-dropdown"></div>');

		new GoogMenu(options, arr);

		return container;
	}

	function showMessage(str, type) {
		if (typeof GM_notification === 'function')
			GM_notification(str);
	}

	function addSettingsMenuItem() {
		var feedlyTabs = document.getElementById('feedlyTabs');
		if (!feedlyTabs) {
			setTimeout(addSettingsMenuItem, 100);
			return;
		}

		var prefListener;
		var observer = new MutationObserver(function mutationCallback() {
			if (!document.getElementById('feedly-ng-filter-setting'))
				pref.removeListener('change', prefListener);

			var prefItem = document.querySelector('#feedlyTabs .tab > .label[data-uri="preferences"]');

			if (!prefItem)
				return;

			var prefItemTab = prefItem.parentNode;

			var tab = document.createElement('div');
			tab.className = 'tab';
			tab.setAttribute('contextmenu', MenuCommand.contextmenu.id);
			tab.addEventListener('click', onNGSettingCommand, false);

			var label = document.createElement('div');
			label.id = 'feedly-ng-filter-setting';
			label.className = 'label primary iconless';
			label.textContent = __`NG Setting`;

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

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

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

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

	function menuCommand(label, fn) {
		return new MenuCommand(__`Feedly NG Filter` + ' - ' + label, fn);
	}

	function xhr(details) {
		var opt = extend({}, details);
		var {data} = opt;

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

		if (data instanceof Object) {
			opt.data = [pair.map(encodeURIComponent).join('=') for (pair in Iterator(data))].join('&');

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

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

		setTimeout(GM_xmlhttpRequest, 0, opt);
	}

	function sendJSON(details) {
		var opt = extend({}, details);
		var {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) {
		var script = document.createElement('script');
		script.textContent = code;
		document.adoptNode(document.head.appendChild(script));
	}

	function openFilePicker(callback, multiple) {
		var canceled = true;
		var input = document.createElement('input');
		input.type = 'file';
		input.multiple = multiple;
		input.addEventListener('change', () => {
			canceled = false;
			callback(Array.slice(input.files));
		}, false);
		input.click();

		if (canceled)
			setTimeout(callback, 0, null);
	}

	function createLabel(element, text) {
		var label = document.createElement('label');
		if (1 < arguments.length)
			label.textContent = text;

		var id = element.id;
		if (!id) {
			if (!('id' in createLabel))
				createLabel.id = 0;

			id = 'id_for_label_' + createLabel.id++;
			element.id = id;
		}

		label.htmlFor = id;
		return label;
	}

	function extend(dst, src) {
		for (let [key, value] in Iterator(src))
			dst[key] = value;

		return dst;
	}
})();