Feedly NG Filter

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

当前为 2017-08-14 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Feedly NG Filter
  3. // @id feedlyngfilter
  4. // @description ルールにマッチするアイテムを既読にして取り除きます。ルールは正規表現で記述でき、複数のルールをツリー状に組み合わせることができます。
  5. // @include http://feedly.com/*
  6. // @include https://feedly.com/*
  7. // @grant GM_getValue
  8. // @grant GM_setValue
  9. // @grant GM_addStyle
  10. // @grant GM_xmlhttpRequest
  11. // @grant GM_registerMenuCommand
  12. // @grant GM_unregisterMenuCommand
  13. // @grant GM_log
  14. // @charset utf-8
  15. // @compatibility Firefox
  16. // @run-at document-start
  17. // @jsversion 1.8
  18. // @priority 1
  19. // @homepage https://greasyfork.org/scripts/9030-feedly-ng-filter
  20. // @supportURL https://twitter.com/intent/tweet?text=%40xulapp+
  21. // @icon https://greasyfork.org/system/screenshots/screenshots/000/000/615/original/icon.png
  22. // @screenshot https://greasyfork.org/system/screenshots/screenshots/000/000/614/original/large.png
  23. // @namespace http://twitter.com/xulapp
  24. // @author xulapp
  25. // @license MIT License
  26. // @version 0.9.3
  27. // ==/UserScript==
  28. /* eslint-env greasemonkey, browser */
  29. /* eslint new-cap:0, camelcase:0, no-eval:0 */
  30. /* global GM_unregisterMenuCommand:false, GM_enableMenuCommand:false, GM_disableMenuCommand:false */
  31. 'use strict';
  32.  
  33. (function feedlyNGFilter() {
  34. const notificationDefaults = {
  35. title: 'Feedly NG Filter',
  36. icon: getGMInfo().icon,
  37. tag: 'feedly-ng-filter',
  38. autoClose: 5000,
  39. };
  40.  
  41. const CSS_STYLE_TEXT = String.raw`
  42. .fngf-row {
  43. display: flex;
  44. flex-direction: row;
  45. }
  46.  
  47. .fngf-column {
  48. display: flex;
  49. flex-direction: column;
  50. }
  51.  
  52. .fngf-align-center {
  53. align-items: center;
  54. }
  55.  
  56. .fngf-grow {
  57. flex-grow: 1;
  58. }
  59.  
  60. .fngf-badge {
  61. margin: 0 0.5em;
  62. padding: 0 0.5em;
  63. background-color: #999;
  64. border-radius: 50%;
  65. color: #fff;
  66. }
  67.  
  68. .fngf-menu-btn > .fngf-btn:not(:last-child) {
  69. margin-right: -1px;
  70. }
  71.  
  72. .fngf-btn {
  73. padding: 5px 10px;
  74. border: none;
  75. background-color: #eee;
  76. color: #333;
  77. font: inherit;
  78. font-weight: bold;
  79. outline: none;
  80. }
  81.  
  82. .fngf-btn[disabled] {
  83. background-color: transparent;
  84. color: #ccc;
  85. box-shadow: 0 0 0 1px #eee inset;
  86. }
  87.  
  88. .fngf-btn:not([disabled]):hover,
  89. .fngf-menu-btn:hover > .fngf-btn:not([disabled]) {
  90. box-shadow: 0 0 0 1px #ccc inset;
  91. }
  92.  
  93. .fngf-btn:not([disabled]):active,
  94. .fngf-btn:not([disabled]).active,
  95. .fngf-checkbox > :checked + .fngf-btn {
  96. background-color: #ccc;
  97. }
  98.  
  99. .fngf-dropdown {
  100. display: flex;
  101. align-items: center;
  102. position: relative;
  103. padding-left: 5px;
  104. padding-right: 5px;
  105. }
  106.  
  107. .fngf-dropdown::before {
  108. display: block;
  109. border-top: 5px solid #333;
  110. border-left: 3px solid transparent;
  111. border-right: 3px solid transparent;
  112. content: "";
  113. }
  114.  
  115. .fngf-dropdown-menu {
  116. position: absolute;
  117. right: 0;
  118. top: 100%;
  119. min-width: 100px;
  120. background-color: #fff;
  121. box-shadow: 1px 2px 5px rgba(0, 0, 0, 0.5);
  122. z-index: 1;
  123. }
  124.  
  125. .fngf-dropdown:not(.active) > .fngf-dropdown-menu {
  126. display: none;
  127. }
  128.  
  129. .fngf-dropdown-menu-item {
  130. padding: 10px;
  131. }
  132.  
  133. .fngf-dropdown-menu-item:hover {
  134. background-color: #eee;
  135. }
  136.  
  137. .fngf-checkbox > input[type="checkbox"] {
  138. display: none;
  139. }
  140.  
  141. @keyframes error {
  142. from {
  143. background-color: #ff0;
  144. border-color: #f00;
  145. }
  146. }
  147.  
  148. .fngf-panel-terms-textbox.error {
  149. animation: error 1s;
  150. }
  151.  
  152. .fngf-panel {
  153. position: fixed;
  154. min-width: 320px;
  155. background-color: rgba(255, 255, 255, 0.95);
  156. color: #333;
  157. box-shadow: 1px 2px 5px rgba(0, 0, 0, 0.5);
  158. font-size: 12px;
  159. cursor: default;
  160. -moz-user-select: none;
  161. z-index: 2147483646;
  162. }
  163.  
  164. .fngf-panel input[type="text"] {
  165. padding: 4px;
  166. border: 1px solid #999;
  167. font: inherit;
  168. }
  169.  
  170. .fngf-panel input[type="text"]:focus {
  171. box-shadow: 0 0 0 1px #999 inset;
  172. }
  173.  
  174. .fngf-panel-body {
  175. margin: 10px;
  176. }
  177.  
  178. .fngf-panel.root .fngf-panel-name,
  179. .fngf-panel.root .fngf-panel-terms {
  180. display: none;
  181. }
  182.  
  183. .fngf-panel-terms {
  184. margin: 10px 0;
  185. padding: 10px;
  186. border: 1px solid #999;
  187. white-space: nowrap;
  188. }
  189.  
  190. .fngf-panel-terms > table {
  191. margin: -5px;
  192. border-spacing: 5px;
  193. }
  194.  
  195. .fngf-panel-terms td {
  196. padding: 0;
  197. }
  198.  
  199. .fngf-panel-terms td:nth-child(2) {
  200. width: 100%;
  201. }
  202.  
  203. .fngf-panel-terms-textbox {
  204. width: 100%;
  205. box-sizing: border-box;
  206. }
  207.  
  208. .fngf-panel-rules {
  209. padding: 10px;
  210. border: 1px solid #999;
  211. }
  212.  
  213. .fngf-no-rule:not(:only-child) {
  214. display: none;
  215. }
  216.  
  217. .fngf-panel fieldset {
  218. margin: 0;
  219. padding: 10px;
  220. }
  221.  
  222. .fngf-panel-rule-name {
  223. flex-grow: 1;
  224. }
  225.  
  226. .fngf-panel-btns {
  227. justify-content: space-between;
  228. margin: 10px;
  229. }
  230.  
  231. .fngf-panel-btns > .fngf-btn-group:not(:first-child) {
  232. margin-left: 10px;
  233. }
  234. `;
  235.  
  236. function __(strings, ...values) {
  237. let key = values.map((v, i) => `${strings[i]}{${i}}`).join('') + strings[strings.length - 1];
  238.  
  239. if (!(key in __.data))
  240. throw new Error(`localized string not found: ${key}`);
  241.  
  242. return __.data[key].replace(/\{(\d+)\}/g, (_, cap) => values[cap]);
  243. }
  244.  
  245. Object.defineProperties(__, {
  246. config: {
  247. configurable: true,
  248. writable: true,
  249. value: {
  250. defaultLocale: 'en-US',
  251. },
  252. },
  253.  
  254. locales: {
  255. configurable: true,
  256. writable: true,
  257. value: {},
  258. },
  259.  
  260. data: {
  261. configurable: true,
  262. get() {
  263. return this.locales[this.config.locale];
  264. },
  265. },
  266.  
  267. languages: {
  268. configurable: true,
  269. get() {
  270. return Object.keys(this.locales);
  271. },
  272. },
  273.  
  274. add: {
  275. configurable: true,
  276. writable: true,
  277. value: function add({locale, data}) {
  278. if (locale in this.locales)
  279. throw new Error(`failed to add existing locale: ${locale}`);
  280.  
  281. this.locales[locale] = data;
  282. },
  283. },
  284.  
  285. use: {
  286. configurable: true,
  287. writable: true,
  288. value: function use(locale) {
  289. if (locale in this.locales)
  290. this.config.locale = locale;
  291.  
  292. else if (this.config.defaultLocale)
  293. this.config.locale = this.config.defaultLocale;
  294.  
  295. else
  296. throw new Error(`unknown locale: ${locale}`);
  297. },
  298. },
  299. });
  300.  
  301. __.add({
  302. locale: 'en-US',
  303. data: {
  304. 'Feedly NG Filter': 'Feedly NG Filter',
  305. 'OK': 'OK',
  306. 'Cancel': 'Cancel',
  307. 'Add': 'Add',
  308. 'Copy': 'Copy',
  309. 'Paste': 'Paste',
  310. 'New Filter': 'New Filter',
  311. 'Rule Name': 'Rule Name',
  312. 'No Rules': 'No Rules',
  313. 'Title': 'Title',
  314. 'URL': 'URL',
  315. 'Feed Title': 'Feed Title',
  316. 'Feed URL': 'Feed URL',
  317. 'Author': 'Author',
  318. 'Keywords': 'Keywords',
  319. 'Contents': 'Contents',
  320. 'Ignore Case': 'Ignore Case',
  321. 'Edit': 'Edit',
  322. 'Delete': 'Delete',
  323. 'Hit Count:\t{0}': 'Hit Count:\t{0}',
  324. 'Last Hit:\t{0}': 'Last Hit:\t{0}',
  325. 'NG Setting': 'NG Setting',
  326. 'Setting': 'Setting',
  327. 'Import Configuration': 'Import Configuration',
  328. 'Preferences were successfully imported.': 'Preferences were successfully imported.',
  329. 'Export Configuration': 'Export Configuration',
  330. 'Language': 'Language',
  331. 'NG Settings were modified.\nNew filters take effect after next refresh.': 'NG Settings were modified.\nNew filters take effect after next refresh.',
  332. },
  333. });
  334.  
  335. __.add({
  336. locale: 'ja',
  337. data: {
  338. 'Feedly NG Filter': 'Feedly NG Filter',
  339. 'OK': 'OK',
  340. 'Cancel': 'キャンセル',
  341. 'Add': '追加',
  342. 'Copy': 'コピー',
  343. 'Paste': '貼り付け',
  344. 'New Filter': '新しいフィルタ',
  345. 'Rule Name': 'ルール名',
  346. 'No Rules': 'ルールはありません',
  347. 'Title': 'タイトル',
  348. 'URL': 'URL',
  349. 'Feed Title': 'フィードのタイトル',
  350. 'Feed URL': 'フィードの URL',
  351. 'Author': '著者',
  352. 'Keywords': 'キーワード',
  353. 'Contents': '本文',
  354. 'Ignore Case': '大/小文字を区別しない',
  355. 'Edit': '編集',
  356. 'Delete': '削除',
  357. 'Hit Count:\t{0}': 'ヒット数:\t{0}',
  358. 'Last Hit:\t{0}': '最終ヒット:\t{0}',
  359. 'NG Setting': 'NG 設定',
  360. 'Setting': '設定',
  361. 'Import Configuration': '設定をインポート',
  362. 'Preferences were successfully imported.': '設定をインポートしました',
  363. 'Export Configuration': '設定をエクスポート',
  364. 'Language': '言語',
  365. 'NG Settings were modified.\nNew filters take effect after next refresh.': 'NG 設定を更新しました。\n新しいフィルタは次回読み込み時から有効になります。',
  366. },
  367. });
  368.  
  369. __.use(navigator.language);
  370.  
  371. class Serializer {
  372. static stringify(value, space) {
  373. return JSON.stringify(value, (key, value) => {
  374. if (value instanceof RegExp)
  375. return {
  376. __serialized__: true,
  377. class: 'RegExp',
  378. args: [value.source, value.flags],
  379. };
  380.  
  381. return value;
  382. }, space);
  383. }
  384.  
  385. static parse(text) {
  386. return JSON.parse(text, (key, value) => {
  387. if (value instanceof Object && value.__serialized__)
  388. switch (value.class) {
  389. case 'RegExp':
  390. return new RegExp(...value.args);
  391. }
  392.  
  393. return value;
  394. });
  395. }
  396. }
  397.  
  398. class EventEmitter {
  399. constructor() {
  400. this.listeners = {};
  401. }
  402.  
  403. on(type, listener) {
  404. if (type.trim().includes(' ')) {
  405. type.match(/\S+/g).forEach(t => this.on(t, listener));
  406. return;
  407. }
  408.  
  409. if (!(type in this.listeners))
  410. this.listeners[type] = new Set();
  411.  
  412. const set = this.listeners[type];
  413.  
  414. for (let fn of set.values())
  415. if (EventEmitter.compareListener(fn, listener))
  416. return;
  417.  
  418. set.add(listener);
  419. }
  420.  
  421. once(type, listener) {
  422. return new Promise((resolve, reject) => {
  423. function wrapper(event) {
  424. this.off(wrapper);
  425.  
  426. try {
  427. EventEmitter.applyListener(this, listener, event);
  428. resolve(event);
  429. } catch (e) {
  430. reject(e);
  431. }
  432. }
  433.  
  434. wrapper[EventEmitter.original] = listener;
  435.  
  436. this.on(type, wrapper);
  437. });
  438. }
  439.  
  440. off(type, listener) {
  441. if (!listener || !(type in this.listeners))
  442. return;
  443.  
  444. const set = this.listeners[type];
  445.  
  446. for (let fn of set.values())
  447. if (EventEmitter.compareListener(fn, listener))
  448. set.delete(fn);
  449. }
  450.  
  451. removeAllListeners(type) {
  452. delete this.listeners[type];
  453. }
  454.  
  455. dispatchEvent(event) {
  456. event.timestamp = Date.now();
  457.  
  458. if (event.type in this.listeners)
  459. this.listeners[event.type].forEach(listener => {
  460. try {
  461. EventEmitter.applyListener(this, listener, event);
  462. } catch (e) {
  463. setTimeout(() => {
  464. throw e;
  465. }, 0);
  466. }
  467. });
  468.  
  469. return !event.canceled;
  470. }
  471.  
  472. emit(type, data) {
  473. const event = this.createEvent(type);
  474.  
  475. Object.assign(event, data);
  476.  
  477. return this.dispatchEvent(event);
  478. }
  479.  
  480. createEvent(type) {
  481. return new Event(type, this);
  482. }
  483.  
  484. static compareListener(a, b) {
  485. return a === b || a === b[EventEmitter.original] || a[EventEmitter.original] === b;
  486. }
  487.  
  488. static applyListener(target, listener, ...args) {
  489. if (typeof listener === 'function')
  490. listener.apply(target, args);
  491.  
  492. else
  493. listener.handleEvent(...args);
  494. }
  495. }
  496.  
  497. EventEmitter.original = Symbol('fngf.original');
  498.  
  499. class Event {
  500. constructor(type, target) {
  501. this.type = type;
  502. this.target = target;
  503. this.canceled = false;
  504. this.timestamp = 0;
  505. }
  506.  
  507. preventDefault() {
  508. this.canceled = true;
  509. }
  510. }
  511.  
  512. class DataTransfer extends EventEmitter {
  513. set(type, data) {
  514. this.purge();
  515. this.type = type;
  516. this.data = data;
  517. this.emit(type, {data});
  518. }
  519.  
  520. purge() {
  521. this.emit('purge', {data: this.data});
  522. delete this.data;
  523. }
  524.  
  525. cut(data) {
  526. this.set('cut', data);
  527. }
  528.  
  529. copy(data) {
  530. this.set('copy', data);
  531. }
  532.  
  533. receive() {
  534. const data = this.data;
  535.  
  536. if (this.type === 'cut')
  537. this.purge();
  538.  
  539. return data;
  540. }
  541. }
  542.  
  543. class MenuCommand {
  544. constructor(label, oncommand, disabled) {
  545. this.label = label;
  546. this.oncommand = oncommand;
  547. this.disabled = !!disabled;
  548.  
  549. this.register();
  550. }
  551.  
  552. register() {
  553. if (typeof GM_registerMenuCommand === 'function')
  554. this.uuid = GM_registerMenuCommand(`${__`Feedly NG Filter`} - ${this.label}`, this.oncommand);
  555.  
  556. if (MenuCommand.contextmenu) {
  557. this.menuitem = $el`<menuitem label="${this.label}" @click="${this.oncommand}">`.first;
  558. MenuCommand.contextmenu.appendChild(this.menuitem);
  559. }
  560.  
  561. if (this.disabled)
  562. this.disable();
  563. }
  564.  
  565. unregister() {
  566. if (typeof GM_unregisterMenuCommand === 'function')
  567. GM_unregisterMenuCommand(this.uuid);
  568.  
  569. delete this.uuid;
  570. document.adoptNode(this.menuitem);
  571. }
  572.  
  573. disable() {
  574. if (typeof GM_disableMenuCommand === 'function')
  575. GM_disableMenuCommand(this.uuid);
  576.  
  577. this.menuitem.disabled = true;
  578. }
  579.  
  580. enable() {
  581. if (typeof GM_enableMenuCommand === 'function')
  582. GM_enableMenuCommand(this.uuid);
  583.  
  584. this.menuitem.disabled = false;
  585. }
  586. }
  587.  
  588. MenuCommand.contextmenu = null;
  589.  
  590. class Preference extends EventEmitter {
  591. constructor() {
  592. super();
  593.  
  594. if (Preference._instance)
  595. return Preference._instance;
  596.  
  597. Preference._instance = this;
  598.  
  599. this.dict = {};
  600. }
  601.  
  602. has(key) {
  603. return key in this.dict;
  604. }
  605.  
  606. get(key, def) {
  607. return this.has(key) ? this.dict[key] : def;
  608. }
  609.  
  610. set(key, newValue) {
  611. const prevValue = this.dict[key];
  612.  
  613. if (newValue !== prevValue) {
  614. this.dict[key] = newValue;
  615. this.emit('change', {
  616. key,
  617. prevValue,
  618. newValue,
  619. });
  620. }
  621.  
  622. return newValue;
  623. }
  624.  
  625. del(key) {
  626. if (!this.has(key))
  627. return;
  628.  
  629. const prevValue = this.dict[key];
  630.  
  631. delete this.dict[key];
  632.  
  633. this.emit('delete', {
  634. key,
  635. prevValue,
  636. });
  637. }
  638.  
  639. load(str) {
  640. if (!str)
  641. str = GM_getValue(Preference.prefName, Preference.defaultPref || '({})');
  642.  
  643. let obj;
  644.  
  645. try {
  646. obj = Serializer.parse(str);
  647. } catch (e) {
  648. if (e instanceof SyntaxError)
  649. obj = eval(`(${str})`);
  650. }
  651.  
  652. if (!obj || typeof obj !== 'object')
  653. return;
  654.  
  655. this.dict = {};
  656.  
  657. for (let key in obj)
  658. this.set(key, obj[key]);
  659.  
  660. this.emit('load');
  661. }
  662.  
  663. write() {
  664. this.dict.__version__ = getGMInfo().version;
  665.  
  666. const text = Serializer.stringify(this.dict);
  667.  
  668. GM_setValue(Preference.prefName, text);
  669. }
  670.  
  671. autosave() {
  672. if (this.autosaveReserved)
  673. return;
  674.  
  675. window.addEventListener('unload', () => this.write(), false);
  676. this.autosaveReserved = true;
  677. }
  678.  
  679. exportToFile() {
  680. const blob = new Blob([this.serialize()], {
  681. type: 'application/octet-stream',
  682. });
  683.  
  684. const url = URL.createObjectURL(blob);
  685.  
  686. location.assign(url);
  687. URL.revokeObjectURL(url);
  688. }
  689.  
  690. importFromString(str) {
  691. try {
  692. this.load(str);
  693. } catch (e) {
  694. if (!(e instanceof SyntaxError))
  695. throw e;
  696.  
  697. notify(e);
  698.  
  699. return false;
  700. }
  701.  
  702. notify(__`Preferences were successfully imported.`);
  703.  
  704. return true;
  705. }
  706.  
  707. importFromFile() {
  708. openFilePicker().then(([file]) => {
  709. const reader = new FileReader();
  710.  
  711. reader.addEventListener('load', () => this.importFromString(reader.result), false);
  712. reader.readAsText(file);
  713. });
  714. }
  715.  
  716. toString() {
  717. return '[object Preference]';
  718. }
  719.  
  720. serialize() {
  721. return Serializer.stringify(this.dict);
  722. }
  723. }
  724.  
  725. Preference.prefName = 'settings';
  726.  
  727. class Draggable {
  728. constructor(element, ignore = 'select, button, input, textarea, [tabindex]') {
  729. this.element = element;
  730. this.ignore = ignore;
  731.  
  732. this.attach();
  733. }
  734.  
  735. isDraggableTarget(target) {
  736. if (!target)
  737. return false;
  738.  
  739. if (target === this.element)
  740. return true;
  741.  
  742. return !target.matches(`${this.ignore}, :-moz-any(${this.ignore}) *`);
  743. }
  744.  
  745. attach() {
  746. this.element.addEventListener('mousedown', this, false, false);
  747. }
  748.  
  749. detatch() {
  750. this.element.removeEventListener('mousedown', this, false);
  751. }
  752.  
  753. handleEvent(event) {
  754. const name = `on${event.type}`;
  755.  
  756. if (name in this)
  757. this[name](event);
  758. }
  759.  
  760. onmousedown(event) {
  761. if (event.button !== 0)
  762. return;
  763.  
  764. if (!this.isDraggableTarget(event.target))
  765. return;
  766.  
  767. event.preventDefault();
  768.  
  769. const focused = this.element.querySelector(':focus');
  770.  
  771. if (focused)
  772. focused.blur();
  773.  
  774. this.offsetX = event.pageX - this.element.offsetLeft;
  775. this.offsetY = event.pageY - this.element.offsetTop;
  776.  
  777. document.addEventListener('mousemove', this, true, false);
  778. document.addEventListener('mouseup', this, true, false);
  779. }
  780.  
  781. onmousemove(event) {
  782. event.preventDefault();
  783.  
  784. this.element.style.left = `${event.pageX - this.offsetX}px`;
  785. this.element.style.top = `${event.pageY - this.offsetY}px`;
  786. }
  787.  
  788. onmouseup(event) {
  789. if (event.button !== 0)
  790. return;
  791.  
  792. event.preventDefault();
  793.  
  794. document.removeEventListener('mousemove', this, true);
  795. document.removeEventListener('mouseup', this, true);
  796. }
  797. }
  798.  
  799. class Filter {
  800. constructor(filter = {}) {
  801. this.name = filter.name || '';
  802. this.regexp = {...filter.regexp};
  803. this.children = filter.children ? filter.children.map(f => new Filter(f)) : [];
  804. this.hitcount = filter.hitcount || 0;
  805. this.lasthit = filter.lasthit || 0;
  806. }
  807.  
  808. test(entry) {
  809. let name;
  810.  
  811. for (name in this.regexp)
  812. if (!this.regexp[name].test(entry[name] || ''))
  813. return false;
  814.  
  815. const hit = this.children.length ? this.children.some(filter => filter.test(entry)) : !!name;
  816.  
  817. if (hit && entry.unread) {
  818. this.hitcount++;
  819. this.lasthit = Date.now();
  820. }
  821.  
  822. return hit;
  823. }
  824.  
  825. appendChild(filter) {
  826. if (!(filter instanceof Filter))
  827. return null;
  828.  
  829. this.removeChild(filter);
  830. this.children.push(filter);
  831. this.sortChildren();
  832.  
  833. return filter;
  834. }
  835.  
  836. removeChild(filter) {
  837. if (!(filter instanceof Filter))
  838. return null;
  839.  
  840. const index = this.children.indexOf(filter);
  841.  
  842. if (index !== -1)
  843. this.children.splice(index, 1);
  844.  
  845. return filter;
  846. }
  847.  
  848. sortChildren() {
  849. return this.children.sort((a, b) => b.name < a.name);
  850. }
  851. }
  852.  
  853. class Entry {
  854. constructor(data) {
  855. this.data = data;
  856. }
  857.  
  858. get title() {
  859. const value = $el`<div>${this.data.title || ''}`.first.textContent;
  860.  
  861. Object.defineProperty(this, 'title', {configurable: true, value});
  862.  
  863. return value;
  864. }
  865.  
  866. get id() {
  867. return this.data.id;
  868. }
  869.  
  870. get url() {
  871. return ((this.data.alternate || 0)[0] || 0).href;
  872. }
  873.  
  874. get sourceTitle() {
  875. return this.data.origin.title;
  876. }
  877.  
  878. get sourceURL() {
  879. return this.data.origin.streamId.replace(/^[^/]+\//, '');
  880. }
  881.  
  882. get body() {
  883. return (this.data.content || this.data.summary || 0).content;
  884. }
  885.  
  886. get author() {
  887. return this.data.author;
  888. }
  889.  
  890. get recrawled() {
  891. return this.data.recrawled;
  892. }
  893.  
  894. get published() {
  895. return this.data.published;
  896. }
  897.  
  898. get updated() {
  899. return this.data.updated;
  900. }
  901.  
  902. get keywords() {
  903. return (this.data.keywords || []).join(',');
  904. }
  905.  
  906. get unread() {
  907. return this.data.unread;
  908. }
  909.  
  910. get tags() {
  911. return this.data.tags.map(tag => tag.label);
  912. }
  913. }
  914.  
  915. class Panel extends EventEmitter {
  916. constructor() {
  917. super();
  918.  
  919. this.opened = false;
  920.  
  921. const onSubmit = event => {
  922. event.preventDefault();
  923. event.stopPropagation();
  924. this.apply();
  925. };
  926.  
  927. const onKeyPress = event => {
  928. if (event.keyCode === KeyboardEvent.DOM_VK_ESCAPE)
  929. this.emit('escape');
  930. };
  931.  
  932. const {element, body, buttons} = $el`
  933. <form class="fngf-panel" @submit="${onSubmit}" @keydown="${onKeyPress}" ref="element">
  934. <input type="submit" style="display: none;">
  935. <div class="fngf-panel-body fngf-column" ref="body"></div>
  936. <div class="fngf-panel-btns fngf-row" ref="buttons">
  937. <div class="fngf-btn-group fngf-row">
  938. <button type="button" class="fngf-btn" @click="${() => this.apply()}">${__`OK`}</button>
  939. <button type="button" class="fngf-btn" @click="${() => this.close()}">${__`Cancel`}</button>
  940. </div>
  941. </div>
  942. </form>
  943. `;
  944.  
  945. new Draggable(element);
  946.  
  947. this.dom = {
  948. element,
  949. body,
  950. buttons,
  951. };
  952. }
  953.  
  954. open(anchorElement) {
  955. if (this.opened)
  956. return;
  957.  
  958. if (!this.emit('showing'))
  959. return;
  960.  
  961. if (!anchorElement || anchorElement.nodeType !== 1)
  962. anchorElement = null;
  963.  
  964. document.body.appendChild(this.dom.element);
  965.  
  966. this.opened = true;
  967. this.snapTo(anchorElement);
  968.  
  969. if (anchorElement) {
  970. const onWindowResize = () => this.snapTo(anchorElement);
  971.  
  972. window.addEventListener('resize', onWindowResize, false);
  973. this.on('hidden', () => window.removeEventListener('resize', onWindowResize, false));
  974. }
  975.  
  976. const focused = document.querySelector(':focus');
  977.  
  978. if (focused)
  979. focused.blur();
  980.  
  981. const selector = ':not(.feedlyng-panel) > :-moz-any(button, input, select, textarea, [tabindex])';
  982. const ctrl = Array.from(this.dom.element.querySelectorAll(selector))
  983. .sort((a, b) => (b.tabIndex || 0) < (a.tabIndex || 0))[0];
  984.  
  985. if (ctrl) {
  986. ctrl.focus();
  987.  
  988. if (ctrl.select)
  989. ctrl.select();
  990. }
  991.  
  992. this.emit('shown');
  993. }
  994.  
  995. apply() {
  996. if (this.emit('apply'))
  997. this.close();
  998. }
  999.  
  1000. close() {
  1001. if (!this.opened)
  1002. return;
  1003.  
  1004. if (!this.emit('hiding'))
  1005. return;
  1006.  
  1007. document.adoptNode(this.dom.element);
  1008. this.opened = false;
  1009.  
  1010. this.emit('hidden');
  1011. }
  1012.  
  1013. toggle(anchorElement) {
  1014. if (this.opened)
  1015. this.close();
  1016.  
  1017. else
  1018. this.open(anchorElement);
  1019. }
  1020.  
  1021. moveTo(x, y) {
  1022. this.dom.element.style.left = `${x}px`;
  1023. this.dom.element.style.top = `${y}px`;
  1024. }
  1025.  
  1026. snapTo(anchorElement) {
  1027. const pad = 5;
  1028. let x = pad;
  1029. let y = pad;
  1030.  
  1031. if (anchorElement) {
  1032. let {left, bottom: top} = anchorElement.getBoundingClientRect();
  1033.  
  1034. left += pad;
  1035. top += pad;
  1036.  
  1037. const {width, height} = this.dom.element.getBoundingClientRect();
  1038. const right = left + width + pad;
  1039. const bottom = top + height + pad;
  1040. const {innerWidth, innerHeight} = window;
  1041.  
  1042. if (innerWidth < right)
  1043. left -= right - innerWidth;
  1044.  
  1045. if (innerHeight < bottom)
  1046. top -= bottom - innerHeight;
  1047.  
  1048. x = Math.max(x, left);
  1049. y = Math.max(y, top);
  1050. }
  1051.  
  1052. this.moveTo(x, y);
  1053. }
  1054.  
  1055. getFormData(asElement) {
  1056. const data = {};
  1057. const elements = this.dom.body.querySelectorAll('[name]');
  1058.  
  1059. function getValue(el) {
  1060. if (el.localName === 'input' && (el.type === 'checkbox' || el.type === 'radio'))
  1061. return el.checked;
  1062.  
  1063. return 'value' in el ? el.value : el.getAttribute('value');
  1064. }
  1065.  
  1066. for (let el of elements) {
  1067. const value = asElement ? el : getValue(el);
  1068. const path = el.name.split('.');
  1069. let leaf = path.pop();
  1070.  
  1071. const cd = path.reduce((parent, key) => {
  1072. if (!(key in parent))
  1073. parent[key] = {};
  1074.  
  1075. return parent[key];
  1076. }, data);
  1077.  
  1078. if (leaf.endsWith('[]')) {
  1079. leaf = leaf.slice(0, -2);
  1080.  
  1081. if (!(leaf in cd))
  1082. cd[leaf] = [];
  1083.  
  1084. cd[leaf].push(value);
  1085. } else {
  1086. cd[leaf] = value;
  1087. }
  1088. }
  1089.  
  1090. return data;
  1091. }
  1092.  
  1093. appendContent(element) {
  1094. if (element instanceof Array)
  1095. return element.map(el => this.appendContent(el));
  1096.  
  1097. return this.dom.body.appendChild(element);
  1098. }
  1099.  
  1100. removeContents() {
  1101. this.dom.body.innerHTML = '';
  1102. }
  1103. }
  1104.  
  1105. class FilterListPanel extends Panel {
  1106. constructor(filter, isRoot) {
  1107. super();
  1108.  
  1109. this.filter = filter;
  1110.  
  1111. if (isRoot)
  1112. this.dom.element.classList.add('root');
  1113.  
  1114. const onAdd = () => {
  1115. const filter = new Filter();
  1116.  
  1117. filter.name = __`New Filter`;
  1118. this.on('apply', () => this.filter.appendChild(filter));
  1119. this.appendFilter(filter);
  1120. };
  1121.  
  1122. const onPaste = () => {
  1123. if (!clipboard.data)
  1124. return;
  1125.  
  1126. const filter = new Filter(clipboard.receive());
  1127.  
  1128. this.on('apply', () => this.filter.appendChild(filter));
  1129. this.appendFilter(filter);
  1130. };
  1131.  
  1132. const {btns, paste} = $el`
  1133. <div class="fngf-btn-group fngf-row" ref="btns">
  1134. <button type="button" class="fngf-btn" @click="${onAdd}">${__`Add`}</button>
  1135. <button type="button" class="fngf-btn" @click="${onPaste}" ref="paste" disabled>${__`Paste`}</button>
  1136. </div>
  1137. `;
  1138.  
  1139. function pasteState() {
  1140. paste.disabled = !clipboard.data;
  1141. }
  1142.  
  1143. clipboard.on('copy', pasteState);
  1144. clipboard.on('purge', pasteState);
  1145. pasteState();
  1146.  
  1147. this.dom.buttons.insertBefore(btns, this.dom.buttons.firstChild);
  1148.  
  1149. this.on('escape', () => this.close());
  1150. this.on('showing', this.initContents);
  1151. this.on('apply', this);
  1152. this.on('hidden', () => {
  1153. clipboard.off('copy', pasteState);
  1154. clipboard.off('purge', pasteState);
  1155. });
  1156. }
  1157.  
  1158. initContents() {
  1159. const filter = this.filter;
  1160. const {name, terms, tbody, rules} = $el`
  1161. <div class="fngf-panel-name fngf-row fngf-align-center" ref="name">
  1162. ${__`Rule Name`}&nbsp;
  1163. <input type="text" value="${filter.name}" autocomplete="off" name="name" class="fngf-grow">
  1164. </div>
  1165. <div class="fngf-panel-terms" ref="terms">
  1166. <table>
  1167. <tbody ref="tbody"></tbody>
  1168. </table>
  1169. </div>
  1170. <div class="fngf-panel-rules fngf-column" ref="rules">
  1171. <div class="fngf-panel-rule fngf-row fngf-align-center fngf-no-rule">${__`No Rules`}</div>
  1172. </div>
  1173. `;
  1174.  
  1175. const labels = [
  1176. ['title', __`Title`],
  1177. ['url', __`URL`],
  1178. ['sourceTitle', __`Feed Title`],
  1179. ['sourceURL', __`Feed URL`],
  1180. ['author', __`Author`],
  1181. ['keywords', __`Keywords`],
  1182. ['body', __`Contents`],
  1183. ];
  1184.  
  1185. for (let [type, labelText] of labels) {
  1186. const randomId = `id-${Math.random().toFixed(8)}`;
  1187. const reg = filter.regexp[type];
  1188. const sourceValue = reg ? reg.source.replace(/((?:^|[^\\])(?:\\\\)*)\\(?=\/)/g, '$1') : '';
  1189.  
  1190. tbody.appendChild($el`
  1191. <tr ref="row">
  1192. <td>
  1193. <label for="${randomId}">${labelText}</label>
  1194. </td>
  1195. <td>
  1196. <input type="text" class="fngf-panel-terms-textbox" id="${randomId}" autocomplete="off" name="regexp.${type}.source" value="${sourceValue}">
  1197. </td>
  1198. <td>
  1199. <label class="fngf-checkbox fngf-row" title="${__`Ignore Case`}">
  1200. <input type="checkbox" name="regexp.${type}.ignoreCase" bool:checked="${reg && reg.ignoreCase}">
  1201. <span class="fngf-btn" tabindex="0">i</span>
  1202. </label>
  1203. </td>
  1204. </tr>
  1205. `.row);
  1206. }
  1207.  
  1208. this.appendContent([name, terms, rules]);
  1209. this.dom.rules = rules;
  1210.  
  1211. filter.children.forEach(this.appendFilter, this);
  1212. }
  1213.  
  1214. appendFilter(filter) {
  1215. let panel;
  1216.  
  1217. const updateRow = () => {
  1218. let title = __`Hit Count:\t${filter.hitcount}`;
  1219.  
  1220. if (filter.lasthit) {
  1221. title += '\n';
  1222. title += __`Last Hit:\t${new Date(filter.lasthit).toLocaleString()}`;
  1223. }
  1224.  
  1225. rule.title = title;
  1226. name.textContent = filter.name;
  1227. count.textContent = filter.children.length || '';
  1228. };
  1229.  
  1230. const onEdit = () => {
  1231. if (panel) {
  1232. panel.close();
  1233. return;
  1234. }
  1235.  
  1236. panel = new FilterListPanel(filter);
  1237. panel.on('shown', () => btnEdit.classList.add('active'));
  1238. panel.on('hidden', () => {
  1239. btnEdit.classList.remove('active');
  1240. panel = null;
  1241. });
  1242. panel.on('apply', () => setTimeout(updateRow, 0));
  1243. panel.open(btnEdit);
  1244. };
  1245.  
  1246. const onCopy = () => clipboard.copy(filter);
  1247.  
  1248. const onDelete = () => {
  1249. document.adoptNode(rule);
  1250. this.on('apply', () => this.filter.removeChild(filter));
  1251. };
  1252.  
  1253. const {rule, name, count, btnEdit} = $el`
  1254. <div class="fngf-panel-rule fngf-row fngf-align-center" ref="rule">
  1255. <div class="fngf-panel-rule-name" @dblclick="${onEdit}" ref="name"></div>
  1256. <div class="fngf-panel-rule-count fngf-badge" ref="count"></div>
  1257. <div class="fngf-panel-rule-actions fngf-btn-group fngf-menu-btn fngf-row" ref="buttons">
  1258. <button type="button" class="fngf-btn" @click="${onEdit}" ref="btnEdit">${__`Edit`}</button>
  1259. <div class="fngf-dropdown fngf-btn" tabindex="0">
  1260. <div class="fngf-dropdown-menu fngf-column">
  1261. <div class="fngf-dropdown-menu-item fngf-row" @click="${onCopy}">${__`Copy`}</div>
  1262. <div class="fngf-dropdown-menu-item fngf-row" @click="${onDelete}">${__`Delete`}</div>
  1263. </div>
  1264. </div>
  1265. </div>
  1266. </div>
  1267. `;
  1268.  
  1269. updateRow();
  1270. this.dom.rules.appendChild(rule);
  1271. }
  1272.  
  1273. handleEvent(event) {
  1274. if (event.type !== 'apply')
  1275. return;
  1276.  
  1277. const data = this.getFormData(true);
  1278. const filter = this.filter;
  1279. const regexp = {};
  1280. let hasError = false;
  1281.  
  1282. for (let type in data.regexp) {
  1283. const {source, ignoreCase} = data.regexp[type];
  1284.  
  1285. if (!source.value)
  1286. continue;
  1287.  
  1288. try {
  1289. regexp[type] = new RegExp(source.value, ignoreCase.checked ? 'i' : '');
  1290. } catch (e) {
  1291. if (!(e instanceof SyntaxError))
  1292. throw e;
  1293.  
  1294. hasError = true;
  1295. event.preventDefault();
  1296. source.classList.remove('error');
  1297. source.offsetWidth.valueOf();
  1298. source.classList.add('error');
  1299. }
  1300. }
  1301.  
  1302. if (hasError)
  1303. return;
  1304.  
  1305. const prevSource = Serializer.stringify(filter);
  1306.  
  1307. filter.name = data.name.value;
  1308. filter.regexp = regexp;
  1309.  
  1310. if (Serializer.stringify(filter) !== prevSource) {
  1311. filter.hitcount = 0;
  1312. filter.lasthit = 0;
  1313. }
  1314.  
  1315. filter.sortChildren();
  1316. }
  1317. }
  1318.  
  1319. Preference.defaultPref = Serializer.stringify({
  1320. filter: {
  1321. name: '',
  1322. regexp: {},
  1323. children: [
  1324. {
  1325. name: 'AD',
  1326. regexp: {
  1327. title: /^\W?(?:ADV?|PR)\b/,
  1328. },
  1329. children: [],
  1330. },
  1331. ],
  1332. },
  1333. });
  1334.  
  1335. evalInContent(String.raw`
  1336. (() => {
  1337. const XHR = XMLHttpRequest;
  1338. let uniqueId = 0;
  1339.  
  1340. XMLHttpRequest = function XMLHttpRequest() {
  1341. const req = new XHR();
  1342.  
  1343. req.open = open;
  1344. req.setRequestHeader = setRequestHeader;
  1345. req.addEventListener('readystatechange', onReadyStateChange, false);
  1346.  
  1347. return req;
  1348. };
  1349.  
  1350. function open(method, url, async) {
  1351. this.__url__ = url;
  1352.  
  1353. return XHR.prototype.open.apply(this, arguments);
  1354. }
  1355.  
  1356. function setRequestHeader(header, value) {
  1357. if (header === 'Authorization')
  1358. this.__auth__ = value;
  1359.  
  1360. return XHR.prototype.setRequestHeader.apply(this, arguments);
  1361. }
  1362.  
  1363. function onReadyStateChange() {
  1364. if (this.readyState < 4 || this.status !== 200)
  1365. return;
  1366.  
  1367. if (!/^\/\/(?:cloud\.)?feedly\.com\/v3\/streams\/contents\b/.test(this.__url__))
  1368. return;
  1369.  
  1370. const pongEventType = 'streamcontentloaded_callback' + uniqueId++;
  1371.  
  1372. const data = JSON.stringify({
  1373. type: pongEventType,
  1374. auth: this.__auth__,
  1375. text: this.responseText,
  1376. });
  1377.  
  1378. const event = new MessageEvent('streamcontentloaded', {
  1379. bubbles: true,
  1380. cancelable: false,
  1381. data: data,
  1382. origin: location.href,
  1383. source: null,
  1384. });
  1385.  
  1386. let onPong = ({data}) => Object.defineProperty(this, 'responseText', {configurable: true, value: data});
  1387.  
  1388. document.addEventListener(pongEventType, onPong, false);
  1389. document.dispatchEvent(event);
  1390. document.removeEventListener(pongEventType, onPong, false);
  1391. }
  1392. })();
  1393. `);
  1394.  
  1395. const clipboard = new DataTransfer();
  1396. const pref = new Preference();
  1397.  
  1398. let rootFilterPanel;
  1399. let {contextmenu} = $el`
  1400. <menu type="context" id="feedlyng-contextmenu">
  1401. <menu type="context" label="${__`Feedly NG Filter`}" ref="contextmenu"></menu>
  1402. </menu>
  1403. `;
  1404.  
  1405. MenuCommand.contextmenu = contextmenu;
  1406.  
  1407. pref.on('change', function({key, newValue}) {
  1408. switch (key) {
  1409. case 'filter':
  1410. if (!(newValue instanceof Filter))
  1411. this.set('filter', new Filter(newValue));
  1412.  
  1413. break;
  1414.  
  1415. case 'language':
  1416. __.use(newValue);
  1417. break;
  1418. }
  1419. });
  1420.  
  1421. document.addEventListener('streamcontentloaded', event => {
  1422. const logging = pref.get('logging', true);
  1423. const filter = pref.get('filter');
  1424. const filteredEntryIds = [];
  1425. const {type: pongEventType, auth, text} = JSON.parse(event.data);
  1426. const data = JSON.parse(text);
  1427. let hasUnread = false;
  1428.  
  1429. data.items = data.items.filter(item => {
  1430. const entry = new Entry(item);
  1431.  
  1432. if (!filter.test(entry))
  1433. return true;
  1434.  
  1435. if (logging)
  1436. GM_log(`filtered: "${entry.title || ''}" ${entry.url}`);
  1437.  
  1438. filteredEntryIds.push(entry.id);
  1439.  
  1440. if (entry.unread)
  1441. hasUnread = true;
  1442.  
  1443. return false;
  1444. });
  1445.  
  1446. if (!filteredEntryIds.length)
  1447. return;
  1448.  
  1449. let ev = new MessageEvent(pongEventType, {
  1450. bubbles: true,
  1451. cancelable: false,
  1452. data: JSON.stringify(data),
  1453. origin: location.href,
  1454. source: window,
  1455. });
  1456.  
  1457. document.dispatchEvent(ev);
  1458.  
  1459. if (!hasUnread)
  1460. return;
  1461.  
  1462. sendJSON({
  1463. url: '/v3/markers',
  1464. headers: {
  1465. Authorization: auth,
  1466. },
  1467. data: {
  1468. action: 'markAsRead',
  1469. entryIds: filteredEntryIds,
  1470. type: 'entries',
  1471. },
  1472. });
  1473. }, false);
  1474.  
  1475. document.addEventListener('DOMContentLoaded', () => {
  1476. GM_addStyle(CSS_STYLE_TEXT);
  1477.  
  1478. pref.load();
  1479. pref.autosave();
  1480.  
  1481. registerMenuCommands();
  1482. addSettingsMenuItem();
  1483. }, false);
  1484.  
  1485. document.addEventListener('mousedown', ({target}) => {
  1486. if (target.matches('.fngf-dropdown'))
  1487. target.classList.toggle('active');
  1488.  
  1489. target = closest(target, '.fngf-dropdown');
  1490.  
  1491. if (target)
  1492. return;
  1493.  
  1494. const opened = document.querySelector('.fngf-dropdown.active');
  1495.  
  1496. if (opened)
  1497. opened.classList.remove('active');
  1498. }, true);
  1499.  
  1500. document.addEventListener('click', ({target}) => {
  1501. if (!closest(target, '.fngf-dropdown-menu-item'))
  1502. return;
  1503.  
  1504. target = closest(target, '.fngf-dropdown');
  1505.  
  1506. if (target)
  1507. target.classList.remove('active');
  1508. }, true);
  1509.  
  1510. function getGMInfo() {
  1511. if (getGMInfo.cache)
  1512. return getGMInfo.cache;
  1513.  
  1514. const meta = typeof GM_info === 'undefined' ? '' : GM_info.scriptMetaStr;
  1515. const info = {};
  1516.  
  1517. meta.split('\n')
  1518. .map(line => line.trim())
  1519. .map(line => /@(\S+)\s+(.+)/.exec(line))
  1520. .filter(Boolean)
  1521. .forEach(([, key, value]) => {
  1522. info[key] = value;
  1523. });
  1524.  
  1525. getGMInfo.cache = info;
  1526.  
  1527. return info;
  1528. }
  1529.  
  1530. function $el(strings, ...values) {
  1531. let html = '';
  1532.  
  1533. if (typeof strings === 'string') {
  1534. html = strings;
  1535. } else {
  1536. values.forEach((v, i) => {
  1537. html += strings[i];
  1538.  
  1539. if (v === null || v === undefined)
  1540. return;
  1541.  
  1542. if (v instanceof Node || v instanceof NodeList || v instanceof HTMLCollection || v instanceof Array) {
  1543. html += `<!--${$el.dataPrefix}${i}-->`;
  1544.  
  1545. if (v instanceof Node)
  1546. return;
  1547.  
  1548. const frag = document.createDocumentFragment();
  1549.  
  1550. for (let item of v)
  1551. frag.appendChild(item);
  1552.  
  1553. values[i] = frag;
  1554.  
  1555. return;
  1556. }
  1557.  
  1558. html += v instanceof Object ? i : v;
  1559. });
  1560.  
  1561. html += strings[strings.length - 1];
  1562. }
  1563.  
  1564. const renderer = document.createElement('template');
  1565. const container = document.createElement('body');
  1566. const refs = {};
  1567.  
  1568. renderer.innerHTML = html;
  1569. container.appendChild(renderer.content);
  1570.  
  1571. refs.first = container.firstElementChild;
  1572. refs.last = container.lastElementChild;
  1573.  
  1574. const xpath = document.evaluate(`
  1575. .//*[@ref or @*[starts-with(name(), "@") or contains(name(), ":")]] |
  1576. .//comment()[starts-with(., "${$el.dataPrefix}")]
  1577. `, container, null, 7, null);
  1578.  
  1579. for (let i = 0; i < xpath.snapshotLength; i++) {
  1580. const el = xpath.snapshotItem(i);
  1581.  
  1582. if (el.nodeType === document.COMMENT_NODE) {
  1583. const index = el.data.substring($el.dataPrefix.length);
  1584.  
  1585. el.parentNode.replaceChild(values[index], el);
  1586. continue;
  1587. }
  1588.  
  1589. for (let {name, value} of Array.from(el.attributes)) {
  1590. const data = values[value];
  1591.  
  1592. if (name === 'ref')
  1593. refs[value] = el;
  1594.  
  1595. else if (name.startsWith('@'))
  1596. $el.func(el, name.substring(1), data);
  1597.  
  1598. else if (name === ':class')
  1599. for (let k of Object.keys(data))
  1600. el.classList.toggle(k, data[k]);
  1601.  
  1602. else if (name.startsWith('bool:'))
  1603. el[name.substring(5)] = data;
  1604.  
  1605. else
  1606. continue;
  1607.  
  1608. el.removeAttribute(name);
  1609. }
  1610. }
  1611.  
  1612. return refs;
  1613. }
  1614.  
  1615. $el.dataPrefix = '$el.data:';
  1616.  
  1617. $el.func = (el, type, fn) => {
  1618. if (type)
  1619. el.addEventListener(type, fn, false);
  1620.  
  1621. else
  1622. try {
  1623. fn.call(el, el);
  1624. } catch (e) {}
  1625. };
  1626.  
  1627. function closest(target, selector) {
  1628. while (target && target instanceof Element) {
  1629. if (target.matches(selector))
  1630. return target;
  1631.  
  1632. target = target.parentNode;
  1633. }
  1634.  
  1635. return null;
  1636. }
  1637.  
  1638. function xhr(details) {
  1639. const opt = {...details};
  1640. const {data} = opt;
  1641.  
  1642. if (!opt.method)
  1643. opt.method = data ? 'POST' : 'GET';
  1644.  
  1645. if (data instanceof Object) {
  1646. const arr = [];
  1647. const enc = encodeURIComponent;
  1648.  
  1649. for (let key in data)
  1650. arr.push(`${enc(key)}=${enc(data[key])}`);
  1651.  
  1652. opt.data = arr.join('&');
  1653.  
  1654. if (!opt.headers)
  1655. opt.headers = {};
  1656.  
  1657. opt.headers['Content-Type'] = 'application/x-www-form-urlencoded; charset=utf-8';
  1658. }
  1659.  
  1660. setTimeout(() => GM_xmlhttpRequest(opt), 0);
  1661. }
  1662.  
  1663. function registerMenuCommands() {
  1664. menuCommand(`${__`Setting`}...`, togglePrefPanel);
  1665. menuCommand(`${__`Language`}...`, () => {
  1666. const {langField, select} = $el(`
  1667. <fieldset ref="langField">
  1668. <legend>${__`Language`}</legend>
  1669. <select ref="select"></select>
  1670. </fieldset>
  1671. `);
  1672.  
  1673. __.languages.forEach(lang => {
  1674. const option = $el(`<option value="${lang}">${lang}</option>`).first;
  1675.  
  1676. if (lang === __.config.locale)
  1677. option.selected = true;
  1678.  
  1679. select.appendChild(option);
  1680. });
  1681.  
  1682. const panel = new Panel();
  1683.  
  1684. panel.appendContent(langField);
  1685. panel.on('apply', () => pref.set('language', select.value));
  1686. panel.open();
  1687. });
  1688.  
  1689. menuCommand(`${__`Import Configuration`}...`, () => pref.importFromFile());
  1690. menuCommand(__`Export Configuration`, () => pref.exportToFile());
  1691. }
  1692.  
  1693. function sendJSON(details) {
  1694. const opt = {...details};
  1695. const {data} = opt;
  1696.  
  1697. if (!opt.headers)
  1698. opt.headers = {};
  1699.  
  1700. opt.method = 'POST';
  1701. opt.headers['Content-Type'] = 'application/json; charset=utf-8';
  1702. opt.data = JSON.stringify(data);
  1703.  
  1704. return xhr(opt);
  1705. }
  1706.  
  1707. function evalInContent(code) {
  1708. const script = document.createElement('script');
  1709.  
  1710. script.textContent = code;
  1711. document.documentElement.appendChild(script);
  1712. document.adoptNode(script);
  1713. }
  1714.  
  1715. function togglePrefPanel(anchorElement) {
  1716. if (rootFilterPanel) {
  1717. rootFilterPanel.close();
  1718. return;
  1719. }
  1720.  
  1721. rootFilterPanel = new FilterListPanel(pref.get('filter'), true);
  1722. rootFilterPanel.on('apply', () => notify(__`NG Settings were modified.\nNew filters take effect after next refresh.`));
  1723. rootFilterPanel.on('hidden', () => {
  1724. clipboard.purge();
  1725. rootFilterPanel = null;
  1726. });
  1727. rootFilterPanel.open(anchorElement);
  1728. }
  1729.  
  1730. function onNGSettingCommand({target}) {
  1731. togglePrefPanel(target);
  1732. }
  1733.  
  1734. function addSettingsMenuItem() {
  1735. const feedlyTabs = document.getElementById('feedlyTabs');
  1736.  
  1737. if (!feedlyTabs) {
  1738. setTimeout(addSettingsMenuItem, 100);
  1739. return;
  1740. }
  1741.  
  1742. let prefListener;
  1743.  
  1744. const observer = new MutationObserver(() => {
  1745. if (document.getElementById('feedly-ng-filter-setting'))
  1746. return;
  1747.  
  1748. else if (prefListener)
  1749. pref.off('change', prefListener);
  1750.  
  1751. const {tab, label} = $el`
  1752. <div class="tab" contextmenu="${MenuCommand.contextmenu.parentNode.id}" @click="${onNGSettingCommand}" ref="tab">
  1753. <div class="header target">
  1754. <img class="icon" src="${getGMInfo().icon}">
  1755. <div class="label primary" id="feedly-ng-filter-setting" ref="label"></div>
  1756. </div>
  1757. </div>
  1758. `;
  1759.  
  1760. label.textContent = __`NG Setting`;
  1761.  
  1762. feedlyTabs.appendChild(tab);
  1763. document.body.appendChild(contextmenu.parentNode);
  1764.  
  1765. prefListener = ({key}) => {
  1766. if (key === 'language')
  1767. label.textContent = __`NG Setting`;
  1768. };
  1769.  
  1770. pref.on('change', prefListener);
  1771. });
  1772.  
  1773. observer.observe(feedlyTabs, {
  1774. childList: true,
  1775. });
  1776. }
  1777.  
  1778. function menuCommand(label, fn) {
  1779. return new MenuCommand(label, fn);
  1780. }
  1781.  
  1782. function openFilePicker(multiple) {
  1783. return new Promise(resolve => {
  1784. const {input} = $el`<input type="file" @change="${() => resolve(Array.from(input.files))}" ref="input">`;
  1785.  
  1786. input.multiple = multiple;
  1787. input.click();
  1788. });
  1789. }
  1790.  
  1791. function notify(body, options) {
  1792. options = {body, ...notificationDefaults, ...options};
  1793.  
  1794. return new Promise((resolve, reject) => {
  1795. Notification.requestPermission(status => {
  1796. if (status !== 'granted') {
  1797. reject(status);
  1798. return;
  1799. }
  1800.  
  1801. const n = new Notification(options.title, options);
  1802.  
  1803. if (options.autoClose)
  1804. setTimeout(() => n.close(), options.autoClose);
  1805.  
  1806. resolve(n);
  1807. });
  1808. });
  1809. }
  1810. })();