Feedly NG Filter

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

  1. // ==UserScript==
  2. // @name Feedly NG Filter
  3. // @namespace https://github.com/matzkoh
  4. // @version 1.1.0
  5. // @description ルールにマッチするアイテムを既読にして取り除きます。ルールは正規表現で記述でき、複数のルールをツリー状に組み合わせることができます。
  6. // @author matzkoh
  7. // @include https://feedly.com/*
  8. // @icon https://raw.githubusercontent.com/matzkoh/userscripts/master/packages/feedly-ng-filter/images/icon.png
  9. // @screenshot https://raw.githubusercontent.com/matzkoh/userscripts/master/packages/feedly-ng-filter/images/screenshot.png
  10. // @run-at document-start
  11. // @grant GM_getValue
  12. // @grant GM_setValue
  13. // @grant GM_addStyle
  14. // @grant GM_xmlhttpRequest
  15. // @grant GM_registerMenuCommand
  16. // @grant GM_unregisterMenuCommand
  17. // @grant GM_log
  18. // ==/UserScript==
  19.  
  20. var $parcel$global =
  21. typeof globalThis !== 'undefined'
  22. ? globalThis
  23. : typeof self !== 'undefined'
  24. ? self
  25. : typeof window !== 'undefined'
  26. ? window
  27. : typeof global !== 'undefined'
  28. ? global
  29. : {}
  30. var $parcel$modules = {}
  31. var $parcel$inits = {}
  32.  
  33. var parcelRequire = $parcel$global['parcelRequire7c40']
  34. if (parcelRequire == null) {
  35. parcelRequire = function (id) {
  36. if (id in $parcel$modules) {
  37. return $parcel$modules[id].exports
  38. }
  39. if (id in $parcel$inits) {
  40. var init = $parcel$inits[id]
  41. delete $parcel$inits[id]
  42. var module = { id: id, exports: {} }
  43. $parcel$modules[id] = module
  44. init.call(module.exports, module, module.exports)
  45. return module.exports
  46. }
  47. var err = new Error("Cannot find module '" + id + "'")
  48. err.code = 'MODULE_NOT_FOUND'
  49. throw err
  50. }
  51.  
  52. parcelRequire.register = function register(id, init) {
  53. $parcel$inits[id] = init
  54. }
  55.  
  56. $parcel$global['parcelRequire7c40'] = parcelRequire
  57. }
  58. parcelRequire.register('cG8Vr', function (module, exports) {
  59. const notificationDefaults = {
  60. title: 'Feedly NG Filter',
  61. icon: GM_info.script.icon,
  62. tag: 'feedly-ng-filter',
  63. autoClose: 5000,
  64. }
  65. const CSS_STYLE_TEXT =
  66. ".fngf-row {\n display: flex;\n flex-direction: row;\n}\n\n.fngf-column {\n display: flex;\n flex-direction: column;\n}\n\n.fngf-align-center {\n align-items: center;\n}\n\n.fngf-grow {\n flex-grow: 1;\n}\n\n.fngf-badge {\n padding: 0 0.5em;\n margin: 0 0.5em;\n color: #fff;\n background-color: #999;\n border-radius: 50%;\n}\n\n.fngf-btn {\n padding: 5px 10px;\n font: inherit;\n font-weight: bold;\n color: #333;\n background-color: #eee;\n border: none;\n outline: none;\n}\n\n.fngf-menu-btn > .fngf-btn:not(:last-child) {\n margin-right: -1px;\n}\n\n.fngf-btn[disabled] {\n color: #ccc;\n background-color: transparent;\n box-shadow: 0 0 0 1px #eee inset;\n}\n\n.fngf-btn:not([disabled]):active,\n.fngf-btn:not([disabled]).active,\n.fngf-checkbox > :checked + .fngf-btn {\n background-color: #ccc;\n}\n\n.fngf-btn:not([disabled]):hover,\n.fngf-menu-btn:hover > .fngf-btn:not([disabled]) {\n box-shadow: 0 0 0 1px #ccc inset;\n}\n\n.fngf-dropdown {\n position: relative;\n display: flex;\n align-items: center;\n padding-right: 5px;\n padding-left: 5px;\n\n &::before {\n display: block;\n content: '';\n border-top: 5px solid #333;\n border-right: 3px solid transparent;\n border-left: 3px solid transparent;\n }\n}\n\n.fngf-dropdown-menu {\n position: absolute;\n top: 100%;\n right: 0;\n z-index: 1;\n min-width: 100px;\n background-color: #fff;\n box-shadow: 1px 2px 5px #0008;\n}\n\n.fngf-dropdown:not(.active) {\n > .fngf-dropdown-menu {\n display: none;\n }\n}\n\n.fngf-dropdown-menu-item {\n padding: 10px;\n\n &:hover {\n background-color: #eee;\n }\n}\n\n.fngf-checkbox > input[type='checkbox'] {\n display: none;\n}\n\n.fngf-only:not(:only-child) {\n display: none;\n}\n" +
  67. "@keyframes error {\n from {\n background-color: #ff0;\n border-color: #f00;\n }\n}\n\n.fngf-panel {\n position: fixed;\n z-index: 2147483646;\n display: grid;\n grid-gap: 10px;\n min-width: 320px;\n padding: 10px;\n font-size: 12px;\n color: #333;\n cursor: default;\n user-select: none;\n background-color: #fffe;\n box-shadow: 1px 2px 5px #0008;\n}\n\n.fngf-panel-body {\n display: grid;\n grid-gap: 10px;\n}\n\n.fngf-panel input[type='text'] {\n padding: 4px;\n font: inherit;\n border: 1px solid #999;\n\n &:focus {\n box-shadow: 0 0 0 1px #999 inset;\n }\n}\n\n.fngf-panel-terms {\n display: grid;\n grid-template-columns: auto 1fr auto;\n grid-gap: 5px;\n align-items: center;\n width: 400px;\n padding: 10px;\n white-space: nowrap;\n border: 1px solid #999;\n}\n\n.fngf-panel.root .fngf-panel-name,\n.fngf-panel.root .fngf-panel-terms {\n display: none;\n}\n\n.fngf-panel-terms-textbox.error {\n animation: error 1s;\n}\n\n.fngf-panel-rules {\n padding: 10px;\n border: 1px solid #999;\n}\n\n.fngf-panel fieldset {\n padding: 10px;\n margin: 0;\n}\n\n.fngf-panel-rule-name {\n flex-grow: 1;\n}\n\n.fngf-panel-buttons {\n justify-content: space-between;\n\n > .fngf-btn-group:not(:first-child) {\n margin-left: 10px;\n }\n}\n"
  68. function __(strings1, ...values1) {
  69. let key1 = values1.map((v1, i1) => `${strings1[i1]}{${i1}}`).join('') + strings1[strings1.length - 1]
  70. if (!(key1 in __.data)) throw new Error(`localized string not found: ${key1}`)
  71. return __.data[key1].replace(/\{(\d+)\}/g, (_1, cap1) => values1[cap1])
  72. }
  73. Object.defineProperties(__, {
  74. config: {
  75. configurable: true,
  76. writable: true,
  77. value: {
  78. defaultLocale: 'en-US',
  79. },
  80. },
  81. locales: {
  82. configurable: true,
  83. writable: true,
  84. value: {},
  85. },
  86. data: {
  87. configurable: true,
  88. get() {
  89. return this.locales[this.config.locale]
  90. },
  91. },
  92. languages: {
  93. configurable: true,
  94. get() {
  95. return Object.keys(this.locales)
  96. },
  97. },
  98. add: {
  99. configurable: true,
  100. writable: true,
  101. value: function add1({ locale: locale1, data: data1 }) {
  102. if (locale1 in this.locales) throw new Error(`failed to add existing locale: ${locale1}`)
  103. this.locales[locale1] = data1
  104. },
  105. },
  106. use: {
  107. configurable: true,
  108. writable: true,
  109. value: function use1(locale1) {
  110. if (locale1 in this.locales) this.config.locale = locale1
  111. else if (this.config.defaultLocale) this.config.locale = this.config.defaultLocale
  112. else throw new Error(`unknown locale: ${locale1}`)
  113. },
  114. },
  115. })
  116. __.add({
  117. locale: 'en-US',
  118. data: {
  119. 'Feedly NG Filter': 'Feedly NG Filter',
  120. OK: 'OK',
  121. Cancel: 'Cancel',
  122. Add: 'Add',
  123. Copy: 'Copy',
  124. Paste: 'Paste',
  125. 'New Filter': 'New Filter',
  126. 'Rule Name': 'Rule Name',
  127. 'No Rules': 'No Rules',
  128. Title: 'Title',
  129. URL: 'URL',
  130. 'Feed Title': 'Feed Title',
  131. 'Feed URL': 'Feed URL',
  132. Author: 'Author',
  133. Keywords: 'Keywords',
  134. Contents: 'Contents',
  135. 'Ignore Case': 'Ignore Case',
  136. Edit: 'Edit',
  137. Delete: 'Delete',
  138. 'Hit Count: {0}': 'Hit Count: {0}',
  139. 'Last Hit: {0}': 'Last Hit: {0}',
  140. 'NG Setting': 'NG Setting',
  141. Setting: 'Setting',
  142. 'Import Configuration': 'Import Configuration',
  143. 'Preferences were successfully imported.': 'Preferences were successfully imported.',
  144. 'Export Configuration': 'Export Configuration',
  145. Language: 'Language',
  146. 'NG Settings were modified.\nNew filters take effect after next refresh.':
  147. 'NG Settings were modified.\nNew filters take effect after next refresh.',
  148. },
  149. })
  150. __.add({
  151. locale: 'ja',
  152. data: {
  153. 'Feedly NG Filter': 'Feedly NG Filter',
  154. OK: 'OK',
  155. Cancel: 'キャンセル',
  156. Add: '追加',
  157. Copy: 'コピー',
  158. Paste: '貼り付け',
  159. 'New Filter': '新しいフィルタ',
  160. 'Rule Name': 'ルール名',
  161. 'No Rules': 'ルールはありません',
  162. Title: 'タイトル',
  163. URL: 'URL',
  164. 'Feed Title': 'フィードのタイトル',
  165. 'Feed URL': 'フィードの URL',
  166. Author: '著者',
  167. Keywords: 'キーワード',
  168. Contents: '本文',
  169. 'Ignore Case': '大/小文字を区別しない',
  170. Edit: '編集',
  171. Delete: '削除',
  172. 'Hit Count: {0}': 'ヒット数: {0}',
  173. 'Last Hit: {0}': '最終ヒット: {0}',
  174. 'NG Setting': 'NG 設定',
  175. Setting: '設定',
  176. 'Import Configuration': '設定をインポート',
  177. 'Preferences were successfully imported.': '設定をインポートしました',
  178. 'Export Configuration': '設定をエクスポート',
  179. Language: '言語',
  180. 'NG Settings were modified.\nNew filters take effect after next refresh.':
  181. 'NG 設定を更新しました。\n新しいフィルタは次回読み込み時から有効になります。',
  182. },
  183. })
  184. __.use(navigator.language)
  185. class Serializer {
  186. static stringify(value1, space1) {
  187. return JSON.stringify(
  188. value1,
  189. (key1, value1) => {
  190. if (value1 instanceof RegExp)
  191. return {
  192. __serialized__: true,
  193. class: 'RegExp',
  194. args: [value1.source, value1.flags],
  195. }
  196. return value1
  197. },
  198. space1,
  199. )
  200. }
  201. static parse(text1) {
  202. return JSON.parse(text1, (key1, value1) => {
  203. if (value1?.__serialized__)
  204. switch (value1.class) {
  205. case 'RegExp':
  206. return new RegExp(...value1.args)
  207. }
  208. return value1
  209. })
  210. }
  211. }
  212. class EventEmitter {
  213. constructor() {
  214. this.listeners = {}
  215. }
  216. on(type1, listener1) {
  217. if (type1.trim().includes(' ')) {
  218. type1.match(/\S+/g).forEach(t1 => this.on(t1, listener1))
  219. return
  220. }
  221. if (!(type1 in this.listeners)) this.listeners[type1] = new Set()
  222. const set1 = this.listeners[type1]
  223. for (const fn1 of set1.values()) {
  224. if (EventEmitter.compareListener(fn1, listener1)) return
  225. }
  226. set1.add(listener1)
  227. }
  228. async once(type1, listener1) {
  229. return new Promise((resolve1, reject1) => {
  230. function wrapper1(event1) {
  231. this.off(wrapper1)
  232. try {
  233. EventEmitter.applyListener(this, listener1, event1)
  234. resolve1(event1)
  235. } catch (e1) {
  236. reject1(e1)
  237. }
  238. }
  239. wrapper1[EventEmitter.original] = listener1
  240. this.on(type1, wrapper1)
  241. })
  242. }
  243. off(type1, listener1) {
  244. if (!listener1 || !(type1 in this.listeners)) return
  245. const set1 = this.listeners[type1]
  246. for (const fn1 of set1.values()) if (EventEmitter.compareListener(fn1, listener1)) set1.delete(fn1)
  247. }
  248. removeAllListeners(type1) {
  249. delete this.listeners[type1]
  250. }
  251. dispatchEvent(event1) {
  252. event1.timestamp = Date.now()
  253. if (event1.type in this.listeners)
  254. this.listeners[event1.type].forEach(listener1 => {
  255. try {
  256. EventEmitter.applyListener(this, listener1, event1)
  257. } catch (e1) {
  258. setTimeout(() => {
  259. throw e1
  260. }, 0)
  261. }
  262. })
  263. return !event1.canceled
  264. }
  265. emit(type1, data1) {
  266. const event1 = this.createEvent(type1)
  267. Object.assign(event1, data1)
  268. return this.dispatchEvent(event1)
  269. }
  270. createEvent(type1) {
  271. return new Event(type1, this)
  272. }
  273. static compareListener(a1, b1) {
  274. return a1 === b1 || a1 === b1[EventEmitter.original] || a1[EventEmitter.original] === b1
  275. }
  276. static applyListener(target1, listener1, ...args1) {
  277. if (typeof listener1 === 'function') listener1.apply(target1, args1)
  278. else listener1.handleEvent(...args1)
  279. }
  280. }
  281. EventEmitter.original = Symbol('fngf.original')
  282. class Event {
  283. constructor(type1, target1) {
  284. this.type = type1
  285. this.target = target1
  286. this.canceled = false
  287. this.timestamp = 0
  288. }
  289. preventDefault() {
  290. this.canceled = true
  291. }
  292. }
  293. class DataTransfer extends EventEmitter {
  294. set(type1, data1) {
  295. this.purge()
  296. this.type = type1
  297. this.data = data1
  298. this.emit(type1, {
  299. data: data1,
  300. })
  301. }
  302. purge() {
  303. this.emit('purge', {
  304. data: this.data,
  305. })
  306. delete this.data
  307. }
  308. cut(data1) {
  309. this.set('cut', data1)
  310. }
  311. copy(data1) {
  312. this.set('copy', data1)
  313. }
  314. receive() {
  315. const data1 = this.data
  316. if (this.type === 'cut') this.purge()
  317. return data1
  318. }
  319. }
  320. class MenuCommand {
  321. constructor(label1, oncommand1) {
  322. this.label = label1
  323. this.oncommand = oncommand1
  324. }
  325. register() {
  326. if (typeof GM_registerMenuCommand === 'function')
  327. this.uuid = GM_registerMenuCommand(`${__`Feedly NG Filter`} - ${this.label}`, this.oncommand)
  328. if (MenuCommand.contextmenu) {
  329. this.menuitem = $el`<menuitem label="${this.label}" @click="${this.oncommand}">`.first
  330. MenuCommand.contextmenu.appendChild(this.menuitem)
  331. }
  332. }
  333. unregister() {
  334. if (typeof GM_unregisterMenuCommand === 'function') GM_unregisterMenuCommand(this.uuid)
  335. delete this.uuid
  336. document.adoptNode(this.menuitem)
  337. }
  338. static register(...args1) {
  339. const c1 = new MenuCommand(...args1)
  340. c1.register()
  341. return c1
  342. }
  343. }
  344. MenuCommand.contextmenu = null
  345. class Preference extends EventEmitter {
  346. constructor() {
  347. super()
  348. if (Preference._instance) return Preference._instance
  349. Preference._instance = this
  350. this.dict = {}
  351. }
  352. has(key1) {
  353. return key1 in this.dict
  354. }
  355. get(key1, def1) {
  356. return this.has(key1) ? this.dict[key1] : def1
  357. }
  358. set(key1, newValue1) {
  359. const prevValue1 = this.dict[key1]
  360. if (newValue1 !== prevValue1) {
  361. this.dict[key1] = newValue1
  362. this.emit('change', {
  363. key: key1,
  364. prevValue: prevValue1,
  365. newValue: newValue1,
  366. })
  367. }
  368. return newValue1
  369. }
  370. del(key1) {
  371. if (!this.has(key1)) return
  372. const prevValue1 = this.dict[key1]
  373. delete this.dict[key1]
  374. this.emit('delete', {
  375. key: key1,
  376. prevValue: prevValue1,
  377. })
  378. }
  379. load(str) {
  380. str ||= GM_getValue(Preference.prefName, Preference.defaultPref || '({})')
  381. let obj
  382. try {
  383. obj = Serializer.parse(str)
  384. } catch (e) {
  385. if (e instanceof SyntaxError) obj = eval(`(${str})`)
  386. }
  387. if (!obj || typeof obj !== 'object') return
  388. this.dict = {}
  389. for (const key in obj) this.set(key, obj[key])
  390. this.emit('load')
  391. }
  392. write() {
  393. this.dict.__version__ = GM_info.script.version
  394. GM_setValue(Preference.prefName, Serializer.stringify(this.dict))
  395. }
  396. autosave() {
  397. if (this.autosaveReserved) return
  398. window.addEventListener('unload', () => this.write(), false)
  399. this.autosaveReserved = true
  400. }
  401. exportToFile() {
  402. const blob1 = new Blob([this.serialize()], {
  403. type: 'application/octet-stream',
  404. })
  405. const url1 = URL.createObjectURL(blob1)
  406. location.assign(url1)
  407. URL.revokeObjectURL(url1)
  408. }
  409. importFromString(str1) {
  410. try {
  411. this.load(str1)
  412. } catch (e1) {
  413. if (!(e1 instanceof SyntaxError)) throw e1
  414. notify(e1)
  415. return false
  416. }
  417. notify(__`Preferences were successfully imported.`)
  418. return true
  419. }
  420. importFromFile() {
  421. openFilePicker().then(([file1]) => {
  422. const reader1 = new FileReader()
  423. reader1.addEventListener('load', () => this.importFromString(reader1.result), false)
  424. reader1.readAsText(file1)
  425. })
  426. }
  427. toString() {
  428. return '[object Preference]'
  429. }
  430. serialize() {
  431. return Serializer.stringify(this.dict)
  432. }
  433. }
  434. Preference.prefName = 'settings'
  435. class Draggable {
  436. constructor(element1, ignore1 = 'select, button, input, textarea, [tabindex]') {
  437. this.element = element1
  438. this.ignore = ignore1
  439. this.attach()
  440. }
  441. isDraggableTarget(target1) {
  442. if (!target1) return false
  443. if (target1 === this.element) return true
  444. return !target1.matches(`${this.ignore}, :-webkit-any(${this.ignore}) *`)
  445. }
  446. attach() {
  447. this.element.addEventListener('mousedown', this, false, false)
  448. }
  449. detach() {
  450. this.element.removeEventListener('mousedown', this, false)
  451. }
  452. handleEvent(event1) {
  453. const name1 = `on${event1.type}`
  454. if (name1 in this) this[name1](event1)
  455. }
  456. onmousedown(event1) {
  457. if (event1.button !== 0) return
  458. if (!this.isDraggableTarget(event1.target)) return
  459. event1.preventDefault()
  460. this.element.querySelector(':focus')?.blur()
  461. this.offsetX = event1.pageX - this.element.offsetLeft
  462. this.offsetY = event1.pageY - this.element.offsetTop
  463. document.addEventListener('mousemove', this, true, false)
  464. document.addEventListener('mouseup', this, true, false)
  465. }
  466. onmousemove(event1) {
  467. event1.preventDefault()
  468. this.element.style.left = `${event1.pageX - this.offsetX}px`
  469. this.element.style.top = `${event1.pageY - this.offsetY}px`
  470. }
  471. onmouseup(event1) {
  472. if (event1.button === 0) {
  473. event1.preventDefault()
  474. document.removeEventListener('mousemove', this, true)
  475. document.removeEventListener('mouseup', this, true)
  476. }
  477. }
  478. }
  479. class Filter {
  480. constructor(filter1 = {}) {
  481. this.name = filter1.name || ''
  482. this.regexp = {
  483. ...filter1.regexp,
  484. }
  485. this.children = filter1.children?.map(f1 => new Filter(f1)) || []
  486. this.hitcount = filter1.hitcount || 0
  487. this.lasthit = filter1.lasthit || 0
  488. }
  489. test(entry1) {
  490. let name1
  491. for (name1 in this.regexp) {
  492. if (!this.regexp[name1].test(entry1[name1] || '')) return false
  493. }
  494. const hit1 = this.children.length ? this.children.some(filter1 => filter1.test(entry1)) : !!name1
  495. if (hit1 && entry1.unread) {
  496. this.hitcount++
  497. this.lasthit = Date.now()
  498. }
  499. return hit1
  500. }
  501. appendChild(filter1) {
  502. if (!(filter1 instanceof Filter)) return null
  503. this.removeChild(filter1)
  504. this.children.push(filter1)
  505. this.sortChildren()
  506. return filter1
  507. }
  508. removeChild(filter1) {
  509. if (!(filter1 instanceof Filter)) return null
  510. const index1 = this.children.indexOf(filter1)
  511. if (index1 !== -1) this.children.splice(index1, 1)
  512. return filter1
  513. }
  514. sortChildren() {
  515. return this.children.sort((a1, b1) => b1.name < a1.name)
  516. }
  517. }
  518. class Entry {
  519. constructor(data1) {
  520. this.data = data1
  521. }
  522. get title() {
  523. const value1 = $el`<div>${this.data.title || ''}`.first.textContent
  524. Object.defineProperty(this, 'title', {
  525. configurable: true,
  526. value: value1,
  527. })
  528. return value1
  529. }
  530. get id() {
  531. return this.data.id
  532. }
  533. get url() {
  534. return this.data.alternate?.[0]?.href
  535. }
  536. get sourceTitle() {
  537. return this.data.origin.title
  538. }
  539. get sourceURL() {
  540. return this.data.origin.streamId.replace(/^[^/]+\//, '')
  541. }
  542. get body() {
  543. return (this.data.content || this.data.summary)?.content
  544. }
  545. get author() {
  546. return this.data.author
  547. }
  548. get recrawled() {
  549. return this.data.recrawled
  550. }
  551. get published() {
  552. return this.data.published
  553. }
  554. get updated() {
  555. return this.data.updated
  556. }
  557. get keywords() {
  558. return this.data.keywords?.join(',') || ''
  559. }
  560. get unread() {
  561. return this.data.unread
  562. }
  563. get tags() {
  564. return this.data.tags.map(tag1 => tag1.label)
  565. }
  566. }
  567. class Panel extends EventEmitter {
  568. constructor() {
  569. super()
  570. this.opened = false
  571. const onSubmit1 = event1 => {
  572. event1.preventDefault()
  573. event1.stopPropagation()
  574. this.apply()
  575. }
  576. const onKeyPress1 = event1 => {
  577. if (event1.keyCode === KeyboardEvent.DOM_VK_ESCAPE) this.emit('escape')
  578. }
  579. const {
  580. element: element1,
  581. body: body1,
  582. buttons: buttons1,
  583. } = $el`
  584. <form class="fngf-panel" @submit="${onSubmit1}" @keydown="${onKeyPress1}" ref="element">
  585. <input type="submit" style="display: none;">
  586. <div class="fngf-panel-body fngf-column" ref="body"></div>
  587. <div class="fngf-panel-buttons fngf-row" ref="buttons">
  588. <div class="fngf-btn-group fngf-row">
  589. <button type="button" class="fngf-btn" @click="${() => this.apply()}">${__`OK`}</button>
  590. <button type="button" class="fngf-btn" @click="${() => this.close()}">${__`Cancel`}</button>
  591. </div>
  592. </div>
  593. </form>
  594. `
  595. new Draggable(element1)
  596. this.dom = {
  597. element: element1,
  598. body: body1,
  599. buttons: buttons1,
  600. }
  601. }
  602. open(anchorElement1) {
  603. if (this.opened) return
  604. if (!this.emit('showing')) return
  605. if (anchorElement1?.nodeType !== 1) anchorElement1 = null
  606. document.body.appendChild(this.dom.element)
  607. this.opened = true
  608. this.snapTo(anchorElement1)
  609. if (anchorElement1) {
  610. const onWindowResize1 = () => this.snapTo(anchorElement1)
  611. window.addEventListener('resize', onWindowResize1, false)
  612. this.on('hidden', () => window.removeEventListener('resize', onWindowResize1, false))
  613. }
  614. document.querySelector(':focus')?.blur()
  615. const selector1 = ':not(.feedlyng-panel) > :-webkit-any(button, input, select, textarea, [tabindex])'
  616. const ctrl1 = Array.from(this.dom.element.querySelectorAll(selector1)).sort(
  617. (a1, b1) => (b1.tabIndex || 0) < (a1.tabIndex || 0),
  618. )[0]
  619. if (ctrl1) {
  620. ctrl1.focus()
  621. if (ctrl1.select) ctrl1.select()
  622. }
  623. this.emit('shown')
  624. }
  625. apply() {
  626. if (this.emit('apply')) this.close()
  627. }
  628. close() {
  629. if (!this.opened) return
  630. if (!this.emit('hiding')) return
  631. document.adoptNode(this.dom.element)
  632. this.opened = false
  633. this.emit('hidden')
  634. }
  635. toggle(anchorElement1) {
  636. if (this.opened) this.close()
  637. else this.open(anchorElement1)
  638. }
  639. moveTo(x1, y1) {
  640. this.dom.element.style.left = `${x1}px`
  641. this.dom.element.style.top = `${y1}px`
  642. }
  643. snapTo(anchorElement1) {
  644. const pad1 = 5
  645. let x1 = pad1
  646. let y1 = pad1
  647. if (anchorElement1) {
  648. let { left: left1, bottom: top1 } = anchorElement1.getBoundingClientRect()
  649. left1 += pad1
  650. top1 += pad1
  651. const { width: width1, height: height1 } = this.dom.element.getBoundingClientRect()
  652. const right1 = left1 + width1 + pad1
  653. const bottom1 = top1 + height1 + pad1
  654. const { innerWidth: innerWidth1, innerHeight: innerHeight1 } = window
  655. if (innerWidth1 < right1) left1 -= right1 - innerWidth1
  656. if (innerHeight1 < bottom1) top1 -= bottom1 - innerHeight1
  657. x1 = Math.max(x1, left1)
  658. y1 = Math.max(y1, top1)
  659. }
  660. this.moveTo(x1, y1)
  661. }
  662. getFormData(asElement1) {
  663. const data1 = {}
  664. const elements1 = this.dom.body.querySelectorAll('[name]')
  665. function getValue1(el1) {
  666. if (el1.localName === 'input' && (el1.type === 'checkbox' || el1.type === 'radio')) return el1.checked
  667. return 'value' in el1 ? el1.value : el1.getAttribute('value')
  668. }
  669. for (const el1 of elements1) {
  670. const value1 = asElement1 ? el1 : getValue1(el1)
  671. const path1 = el1.name.split('.')
  672. let leaf1 = path1.pop()
  673. const cd1 = path1.reduce((parent1, key1) => {
  674. if (!(key1 in parent1)) parent1[key1] = {}
  675. return parent1[key1]
  676. }, data1)
  677. if (leaf1.endsWith('[]')) {
  678. leaf1 = leaf1.slice(0, -2)
  679. if (!(leaf1 in cd1)) cd1[leaf1] = []
  680. cd1[leaf1].push(value1)
  681. } else cd1[leaf1] = value1
  682. }
  683. return data1
  684. }
  685. appendContent(element1) {
  686. if (element1 instanceof Array) return element1.map(el1 => this.appendContent(el1))
  687. return this.dom.body.appendChild(element1)
  688. }
  689. removeContents() {
  690. this.dom.body.innerHTML = ''
  691. }
  692. }
  693. class FilterListPanel extends Panel {
  694. constructor(filter1, isRoot1) {
  695. super()
  696. this.filter = filter1
  697. if (isRoot1) this.dom.element.classList.add('root')
  698. const onAdd1 = () => {
  699. const filter1 = new Filter()
  700. filter1.name = __`New Filter`
  701. this.on('apply', () => this.filter.appendChild(filter1))
  702. this.appendFilter(filter1)
  703. }
  704. const onPaste1 = () => {
  705. if (!clipboard.data) return
  706. const filter1 = new Filter(clipboard.receive())
  707. this.on('apply', () => this.filter.appendChild(filter1))
  708. this.appendFilter(filter1)
  709. }
  710. const { buttons: buttons1, paste: paste1 } = $el`
  711. <div class="fngf-btn-group fngf-row" ref="buttons">
  712. <button type="button" class="fngf-btn" @click="${onAdd1}">${__`Add`}</button>
  713. <button type="button" class="fngf-btn" @click="${onPaste1}" ref="paste" disabled>${__`Paste`}</button>
  714. </div>
  715. `
  716. function pasteState1() {
  717. paste1.disabled = !clipboard.data
  718. }
  719. clipboard.on('copy', pasteState1)
  720. clipboard.on('purge', pasteState1)
  721. pasteState1()
  722. this.dom.buttons.insertBefore(buttons1, this.dom.buttons.firstChild)
  723. this.on('escape', () => this.close())
  724. this.on('showing', this.initContents)
  725. this.on('apply', this)
  726. this.on('hidden', () => {
  727. clipboard.off('copy', pasteState1)
  728. clipboard.off('purge', pasteState1)
  729. })
  730. }
  731. initContents() {
  732. const filter1 = this.filter
  733. const {
  734. name: name1,
  735. terms: terms1,
  736. rules: rules1,
  737. } = $el`
  738. <div class="fngf-panel-name fngf-row fngf-align-center" ref="name">
  739. ${__`Rule Name`}&nbsp;
  740. <input type="text" value="${filter1.name}" autocomplete="off" name="name" class="fngf-grow">
  741. </div>
  742. <div class="fngf-panel-terms" ref="terms"></div>
  743. <div class="fngf-panel-rules fngf-column" ref="rules">
  744. <div class="fngf-panel-rule fngf-row fngf-align-center fngf-only">${__`No Rules`}</div>
  745. </div>
  746. `
  747. const labels1 = [
  748. ['title', __`Title`],
  749. ['url', __`URL`],
  750. ['sourceTitle', __`Feed Title`],
  751. ['sourceURL', __`Feed URL`],
  752. ['author', __`Author`],
  753. ['keywords', __`Keywords`],
  754. ['body', __`Contents`],
  755. ]
  756. for (const [type1, labelText1] of labels1) {
  757. const randomId1 = `id-${Math.random().toFixed(8)}`
  758. const reg1 = filter1.regexp[type1]
  759. const sourceValue1 = reg1 ? reg1.source.replace(/((?:^|[^\\])(?:\\\\)*)\\(?=\/)/g, '$1') : ''
  760. terms1.appendChild($el`
  761. <label for="${randomId1}">${labelText1}</label>
  762. <input type="text" class="fngf-panel-terms-textbox" id="${randomId1}" autocomplete="off" name="regexp.${type1}.source" value="${sourceValue1}">
  763. <label class="fngf-checkbox fngf-row" title="${__`Ignore Case`}">
  764. <input type="checkbox" name="regexp.${type1}.ignoreCase" bool:checked="${reg1?.ignoreCase}">
  765. <span class="fngf-btn" tabindex="0">i</span>
  766. </label>
  767. `)
  768. }
  769. this.appendContent([name1, terms1, rules1])
  770. this.dom.rules = rules1
  771. filter1.children.forEach(this.appendFilter, this)
  772. }
  773. appendFilter(filter1) {
  774. let panel1
  775. const updateRow1 = () => {
  776. let title1 = __`Hit Count:\t${filter1.hitcount}`
  777. if (filter1.lasthit) {
  778. title1 += '\n'
  779. title1 += __`Last Hit:\t${new Date(filter1.lasthit).toLocaleString()}`
  780. }
  781. rule1.title = title1
  782. name1.textContent = filter1.name
  783. count1.textContent = filter1.children.length || ''
  784. }
  785. const onEdit1 = () => {
  786. if (panel1) {
  787. panel1.close()
  788. return
  789. }
  790. panel1 = new FilterListPanel(filter1)
  791. panel1.on('shown', () => btnEdit1.classList.add('active'))
  792. panel1.on('hidden', () => {
  793. btnEdit1.classList.remove('active')
  794. panel1 = null
  795. })
  796. panel1.on('apply', () => setTimeout(updateRow1, 0))
  797. panel1.open(btnEdit1)
  798. }
  799. const onCopy1 = () => clipboard.copy(filter1)
  800. const onDelete1 = () => {
  801. document.adoptNode(rule1)
  802. this.on('apply', () => this.filter.removeChild(filter1))
  803. }
  804. const {
  805. rule: rule1,
  806. name: name1,
  807. count: count1,
  808. btnEdit: btnEdit1,
  809. } = $el`
  810. <div class="fngf-panel-rule fngf-row fngf-align-center" ref="rule">
  811. <div class="fngf-panel-rule-name" @dblclick="${onEdit1}" ref="name"></div>
  812. <div class="fngf-panel-rule-count fngf-badge" ref="count"></div>
  813. <div class="fngf-panel-rule-actions fngf-btn-group fngf-menu-btn fngf-row" ref="buttons">
  814. <button type="button" class="fngf-btn" @click="${onEdit1}" ref="btnEdit">${__`Edit`}</button>
  815. <div class="fngf-dropdown fngf-btn" tabindex="0">
  816. <div class="fngf-dropdown-menu fngf-column">
  817. <div class="fngf-dropdown-menu-item" @click="${onCopy1}">${__`Copy`}</div>
  818. <div class="fngf-dropdown-menu-item" @click="${onDelete1}">${__`Delete`}</div>
  819. </div>
  820. </div>
  821. </div>
  822. </div>
  823. `
  824. updateRow1()
  825. this.dom.rules.appendChild(rule1)
  826. }
  827. handleEvent(event1) {
  828. if (event1.type !== 'apply') return
  829. const data1 = this.getFormData(true)
  830. const filter1 = this.filter
  831. const regexp1 = {}
  832. let hasError1 = false
  833. for (const type1 in data1.regexp) {
  834. const { source: source1, ignoreCase: ignoreCase1 } = data1.regexp[type1]
  835. if (!source1.value) continue
  836. try {
  837. regexp1[type1] = new RegExp(source1.value, ignoreCase1.checked ? 'i' : '')
  838. } catch (e1) {
  839. if (!(e1 instanceof SyntaxError)) throw e1
  840. hasError1 = true
  841. event1.preventDefault()
  842. source1.classList.remove('error')
  843. source1.offsetWidth.valueOf()
  844. source1.classList.add('error')
  845. }
  846. }
  847. if (hasError1) return
  848. const prevSource1 = Serializer.stringify(filter1)
  849. filter1.name = data1.name.value
  850. filter1.regexp = regexp1
  851. if (Serializer.stringify(filter1) !== prevSource1) {
  852. filter1.hitcount = 0
  853. filter1.lasthit = 0
  854. }
  855. filter1.sortChildren()
  856. }
  857. }
  858. Preference.defaultPref = Serializer.stringify({
  859. filter: {
  860. name: '',
  861. regexp: {},
  862. children: [
  863. {
  864. name: 'AD',
  865. regexp: {
  866. title: /^\W?(?:ADV?|PR)\b/,
  867. },
  868. children: [],
  869. },
  870. ],
  871. },
  872. })
  873. evalInContent(() => {
  874. let uniqueId1 = 0
  875. const _fetch1 = window.fetch
  876. window.fetch = async function fetch1(url1, init1) {
  877. const res1 = await _fetch1.call(this, url1, init1)
  878. if (!/^(?:https?:)?\/\/(?:(?:api|cloud)\.)?feedly\.com\/v3\/streams\/contents\b/.test(url1)) return res1
  879. const auth1 = init1.headers.Authorization
  880. const _text1 = res1.text
  881. res1.text = async function text1() {
  882. const text1 = await _text1.call(this)
  883. const pongEventType1 = 'streamcontentloaded_callback' + uniqueId1++
  884. const data1 = JSON.stringify({
  885. type: pongEventType1,
  886. auth: auth1,
  887. text: text1,
  888. })
  889. const event1 = new MessageEvent('streamcontentloaded', {
  890. bubbles: true,
  891. cancelable: false,
  892. data: data1,
  893. origin: location.href,
  894. source: null,
  895. })
  896. let result1
  897. const onPong1 = ({ data: data1 }) => {
  898. result1 = data1
  899. }
  900. document.addEventListener(pongEventType1, onPong1, false)
  901. document.dispatchEvent(event1)
  902. document.removeEventListener(pongEventType1, onPong1, false)
  903. return result1 ?? text1
  904. }
  905. return res1
  906. }
  907. })
  908. const clipboard = new DataTransfer()
  909. const pref = new Preference()
  910. let rootFilterPanel
  911. let { contextmenu } = $el`
  912. <menu type="context" id="feedlyng-contextmenu">
  913. <menu type="context" label="${__`Feedly NG Filter`}" ref="contextmenu"></menu>
  914. </menu>
  915. `
  916. MenuCommand.contextmenu = contextmenu
  917. pref.on('change', function ({ key: key1, newValue: newValue1 }) {
  918. switch (key1) {
  919. case 'filter':
  920. if (!(newValue1 instanceof Filter)) this.set('filter', new Filter(newValue1))
  921. break
  922. case 'language':
  923. __.use(newValue1)
  924. break
  925. }
  926. })
  927. document.addEventListener(
  928. 'streamcontentloaded',
  929. event1 => {
  930. const logging1 = pref.get('logging', true)
  931. const filter1 = pref.get('filter')
  932. const filteredEntryIds1 = []
  933. const { type: pongEventType1, auth: auth1, text: text1 } = JSON.parse(event1.data)
  934. const data1 = JSON.parse(text1)
  935. let hasUnread1 = false
  936. data1.items = data1.items.filter(item1 => {
  937. const entry1 = new Entry(item1)
  938. if (!filter1.test(entry1)) return true
  939. if (logging1) GM_log(`filtered: "${entry1.title || ''}" ${entry1.url}`)
  940. filteredEntryIds1.push(entry1.id)
  941. if (entry1.unread) hasUnread1 = true
  942. return false
  943. })
  944. if (!filteredEntryIds1.length) return
  945. let ev1 = new MessageEvent(pongEventType1, {
  946. bubbles: true,
  947. cancelable: false,
  948. data: JSON.stringify(data1),
  949. origin: location.href,
  950. source: unsafeWindow,
  951. })
  952. document.dispatchEvent(ev1)
  953. if (!hasUnread1) return
  954. sendJSON({
  955. url: '/v3/markers',
  956. headers: {
  957. Authorization: auth1,
  958. },
  959. data: {
  960. action: 'markAsRead',
  961. entryIds: filteredEntryIds1,
  962. type: 'entries',
  963. },
  964. })
  965. },
  966. false,
  967. )
  968. document.addEventListener(
  969. 'DOMContentLoaded',
  970. () => {
  971. GM_addStyle(CSS_STYLE_TEXT)
  972. pref.load()
  973. pref.autosave()
  974. registerMenuCommands()
  975. addSettingsMenuItem()
  976. },
  977. false,
  978. )
  979. document.addEventListener(
  980. 'mousedown',
  981. ({ target: target1 }) => {
  982. if (target1.matches('.fngf-dropdown')) target1.classList.toggle('active')
  983. if (!target1.closest('.fngf-dropdown'))
  984. document.querySelector('.fngf-dropdown.active')?.classList.remove('active')
  985. },
  986. true,
  987. )
  988. document.addEventListener(
  989. 'click',
  990. ({ target: target1 }) => {
  991. if (target1.closest('.fngf-dropdown-menu-item')) target1.closest('.fngf-dropdown')?.classList.remove('active')
  992. },
  993. true,
  994. )
  995. function $el(strings1, ...values1) {
  996. let html1 = ''
  997. if (typeof strings1 === 'string') html1 = strings1
  998. else {
  999. values1.forEach((v1, i1) => {
  1000. html1 += strings1[i1]
  1001. if (v1 === null || v1 === undefined) return
  1002. if (v1 instanceof Node || v1 instanceof NodeList || v1 instanceof HTMLCollection || v1 instanceof Array) {
  1003. html1 += `<!--${$el.dataPrefix}${i1}-->`
  1004. if (v1 instanceof Node) return
  1005. values1[i1] = document.createDocumentFragment()
  1006. for (const item1 of v1) values1[i1].appendChild(item1)
  1007. return
  1008. }
  1009. html1 += v1 instanceof Object ? i1 : v1
  1010. })
  1011. html1 += strings1[strings1.length - 1]
  1012. }
  1013. const renderer1 = document.createElement('template')
  1014. const container1 = document.createElement('body')
  1015. const refs1 = document.createDocumentFragment()
  1016. renderer1.innerHTML = html1
  1017. container1.appendChild(renderer1.content)
  1018. refs1.first = container1.firstElementChild
  1019. refs1.last = container1.lastElementChild
  1020. const exp1 = `
  1021. .//*[@ref or @*[starts-with(name(), "@") or contains(name(), ":")]] |
  1022. .//comment()[starts-with(., "${$el.dataPrefix}")]
  1023. `
  1024. const xpath1 = document.evaluate(exp1, container1, null, 7, null)
  1025. for (let i1 = 0; i1 < xpath1.snapshotLength; i1++) {
  1026. const el1 = xpath1.snapshotItem(i1)
  1027. if (el1.nodeType === document.COMMENT_NODE) {
  1028. const index1 = el1.data.substring($el.dataPrefix.length)
  1029. el1.parentNode.replaceChild(values1[index1], el1)
  1030. continue
  1031. }
  1032. for (const { name: name1, value: value1 } of Array.from(el1.attributes)) {
  1033. const data1 = values1[value1]
  1034. if (name1 === 'ref') refs1[value1] = el1
  1035. else if (name1.startsWith('@')) $el.func(el1, name1.substring(1), data1)
  1036. else if (name1 === ':class') for (const k1 of Object.keys(data1)) el1.classList.toggle(k1, data1[k1])
  1037. else if (name1.startsWith('bool:')) el1[name1.substring(5)] = data1
  1038. else continue
  1039. el1.removeAttribute(name1)
  1040. }
  1041. }
  1042. Array.from(container1.childNodes).forEach(node1 => refs1.appendChild(node1))
  1043. return refs1
  1044. }
  1045. $el.dataPrefix = '$el.data:'
  1046. $el.func = (el1, type1, fn1) => {
  1047. if (type1) el1.addEventListener(type1, fn1, false)
  1048. else
  1049. try {
  1050. fn1.call(el1, el1)
  1051. } catch (e1) {
  1052. console.error(e1)
  1053. }
  1054. }
  1055. function xhr(details1) {
  1056. const opt1 = {
  1057. ...details1,
  1058. }
  1059. const { data: data1 } = opt1
  1060. opt1.method ||= data1 ? 'POST' : 'GET'
  1061. if (data1 instanceof Object) {
  1062. opt1.headers ||= {}
  1063. opt1.headers['Content-Type'] = 'application/x-www-form-urlencoded; charset=utf-8'
  1064. opt1.data = Object.entries(data1)
  1065. .map(kv1 => kv1.map(encodeURIComponent).join('='))
  1066. .join('&')
  1067. }
  1068. setTimeout(() => GM_xmlhttpRequest(opt1), 0)
  1069. }
  1070. function registerMenuCommands() {
  1071. MenuCommand.register(`${__`Setting`}...`, togglePrefPanel)
  1072. MenuCommand.register(`${__`Language`}...`, () => {
  1073. const { langField: langField1, select: select1 } = $el(`
  1074. <fieldset ref="langField">
  1075. <legend>${__`Language`}</legend>
  1076. <select ref="select"></select>
  1077. </fieldset>
  1078. `)
  1079. __.languages.forEach(lang1 => {
  1080. const option1 = $el(`<option value="${lang1}">${lang1}</option>`).first
  1081. if (lang1 === __.config.locale) option1.selected = true
  1082. select1.appendChild(option1)
  1083. })
  1084. const panel1 = new Panel()
  1085. panel1.appendContent(langField1)
  1086. panel1.on('apply', () => pref.set('language', select1.value))
  1087. panel1.open()
  1088. })
  1089. MenuCommand.register(`${__`Import Configuration`}...`, () => pref.importFromFile())
  1090. MenuCommand.register(__`Export Configuration`, () => pref.exportToFile())
  1091. }
  1092. function sendJSON(details1) {
  1093. const opt1 = {
  1094. ...details1,
  1095. }
  1096. const { data: data1 } = opt1
  1097. opt1.headers ||= {}
  1098. opt1.method = 'POST'
  1099. opt1.headers['Content-Type'] = 'application/json; charset=utf-8'
  1100. opt1.data = JSON.stringify(data1)
  1101. return xhr(opt1)
  1102. }
  1103. function evalInContent(code1) {
  1104. const script1 = document.createElement('script')
  1105. script1.textContent = typeof code1 === 'function' ? `(${code1})()` : code1
  1106. document.documentElement.appendChild(script1)
  1107. document.adoptNode(script1)
  1108. }
  1109. function togglePrefPanel(anchorElement1) {
  1110. if (rootFilterPanel) {
  1111. rootFilterPanel.close()
  1112. return
  1113. }
  1114. rootFilterPanel = new FilterListPanel(pref.get('filter'), true)
  1115. rootFilterPanel.on('apply', () =>
  1116. notify(__`NG Settings were modified.\nNew filters take effect after next refresh.`),
  1117. )
  1118. rootFilterPanel.on('hidden', () => {
  1119. clipboard.purge()
  1120. rootFilterPanel = null
  1121. })
  1122. rootFilterPanel.open(anchorElement1)
  1123. }
  1124. function onNGSettingCommand({ target: target1 }) {
  1125. togglePrefPanel(target1)
  1126. }
  1127. function addSettingsMenuItem() {
  1128. if (!document.getElementById('filtertab')) {
  1129. setTimeout(addSettingsMenuItem, 100)
  1130. return
  1131. }
  1132. let prefListener1
  1133. function onMutation1() {
  1134. if (document.getElementById('feedly-ng-filter-setting')) return
  1135. const nativeFilterItem1 = document.getElementById('filtertab')
  1136. if (!nativeFilterItem1) return
  1137. if (prefListener1) pref.off('change', prefListener1)
  1138. const { tab: tab1, label: label1 } = $el`
  1139. <div class="tab" contextmenu="${MenuCommand.contextmenu.parentNode.id}" @click="${onNGSettingCommand}" ref="tab">
  1140. <div class="header target">
  1141. <img class="icon" src="${GM_info.script.icon}" style="cursor: pointer;">
  1142. <div class="label nonEmpty" id="feedly-ng-filter-setting" ref="label"></div>
  1143. </div>
  1144. </div>
  1145. `
  1146. label1.textContent = __`NG Setting`
  1147. nativeFilterItem1.parentNode.insertBefore(tab1, nativeFilterItem1.nextSibling)
  1148. document.body.appendChild(contextmenu.parentNode)
  1149. prefListener1 = ({ key: key1 }) => {
  1150. if (key1 === 'language') label1.textContent = __`NG Setting`
  1151. }
  1152. pref.on('change', prefListener1)
  1153. }
  1154. new MutationObserver(onMutation1).observe(document.getElementById('feedlyTabs'), {
  1155. childList: true,
  1156. subtree: true,
  1157. })
  1158. onMutation1()
  1159. }
  1160. async function openFilePicker(multiple1) {
  1161. return new Promise(resolve1 => {
  1162. const input1 = $el`<input type="file" @change="${() => resolve1(Array.from(input1.files))}">`.first
  1163. input1.multiple = multiple1
  1164. input1.click()
  1165. })
  1166. }
  1167. async function notify(body1, options1) {
  1168. options1 = {
  1169. body: body1,
  1170. ...notificationDefaults,
  1171. ...options1,
  1172. }
  1173. return new Promise((resolve1, reject1) => {
  1174. Notification.requestPermission(status1 => {
  1175. if (status1 !== 'granted') {
  1176. reject1(status1)
  1177. return
  1178. }
  1179. const n1 = new Notification(options1.title, options1)
  1180. if (options1.autoClose) setTimeout(() => n1.close(), options1.autoClose)
  1181. resolve1(n1)
  1182. })
  1183. })
  1184. }
  1185. })
  1186.  
  1187. parcelRequire('cG8Vr')