// ==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.3
// ==/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);
class Serializer {
static 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);
}
static 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 = {...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`}
<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(line => line.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 = {...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 = {...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.textContent = code;
document.documentElement.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 (document.getElementById('feedly-ng-filter-setting'))
return;
else if (prefListener)
pref.off('change', prefListener);
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`;
feedlyTabs.appendChild(tab);
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 = {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);
});
});
}
})();