您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
ルールにマッチするアイテムを既読にして取り除きます。ルールは正規表現で記述でき、複数のルールをツリー状に組み合わせることができます。
当前为
- // ==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);
- });
- });
- }
- })();