├── .gitattributes ├── .gitignore ├── background.html ├── css ├── fonts.css ├── options-page │ ├── about.css │ ├── display-settings.css │ ├── feed-list.css │ ├── general-settings.css │ ├── nav-menu.css │ ├── options-page.css │ └── subscription-form.css └── slideshow-page.css ├── fonts ├── ArchivoNarrow-Regular.ttf ├── CartoGothicStd-Book.otf ├── Inter.var.woff2 ├── LeagueMono-Regular.ttf ├── Merriweather-Regular.ttf ├── Montserrat-Bold.ttf ├── Montserrat-Regular.ttf ├── NotoSans-Regular.ttf ├── OpenSans-Regular.ttf ├── PathwayGothicOne-Regular.ttf ├── PlayfairDisplaySC-Regular.ttf ├── Raleway.woff ├── Roboto-Regular.ttf ├── et-book-roman-line-figures.ttf ├── fanwood-webfont.ttf ├── leaguespartan-bold.ttf ├── montserrat-license.txt └── noto-sans-regular-license.txt ├── images ├── CCXXXXXXI_by_aqueous.jpg ├── article-options.png ├── bgfons-paper_texture318.jpg ├── designova-subtle-carbon.png ├── dominik-kiss-grid.png ├── feed-icon-64x64.png ├── logo │ ├── 128x128BW.png │ ├── 128x128Blue.png │ ├── 128x128Orange.png │ ├── 128x128WB.png │ ├── 16x16BW.png │ ├── 16x16Blue.png │ ├── 16x16Orange.png │ ├── 16x16WB.png │ ├── 48x48BW.png │ ├── 48x48Blue.png │ ├── 48x48Orange.png │ ├── 48x48WB.png │ └── GithubRead.me.png ├── paper-backgrounds-vintage-white.jpg ├── pickering-texturetastic-gray.png ├── reusage-recycled-paper-white-first.png ├── rss-large.png ├── rss_icon_trans.gif ├── rss_icon_trans.png ├── subtle-patterns-beige-paper.png ├── subtle-patterns-cream-paper.png ├── subtle-patterns-exclusive-paper.png ├── subtle-patterns-groove-paper.png ├── subtle-patterns-handmade-paper.png ├── subtle-patterns-paper-1.png ├── subtle-patterns-paper-2.png ├── subtle-patterns-paper.png ├── subtle-patterns-rice-paper-2.png ├── subtle-patterns-rice-paper-3.png ├── subtle-patterns-soft-wallpaper.png ├── subtle-patterns-white-wall.png ├── subtle-patterns-witewall-3.png ├── thomas-zucx-noise-lines.png ├── unsubscribe.gif ├── white-construction-paper1.jpg └── yvrelle_towel_white.jpg ├── license.md ├── manifest.json ├── options.html ├── readme.md ├── slideshow.html ├── src ├── control │ ├── browser-action-control.js │ ├── config-control.js │ ├── cron-control.js │ ├── db-control.js │ └── theme-control.js ├── db │ ├── connection.js │ ├── count-resources.js │ ├── create-resource.js │ ├── db.js │ ├── delete-resource.js │ ├── errors.js │ ├── get-resource.js │ ├── get-resources.js │ ├── migrations.js │ ├── open.js │ ├── patch-resource.js │ ├── put-resource.js │ └── resource-utils.js ├── lib │ ├── assert.js │ ├── better-fetch.js │ ├── boilerplate.js │ ├── coerce-element.js │ ├── color.js │ ├── css-parse-url.js │ ├── deadline.js │ ├── dom-filters │ │ ├── color-contrast.js │ │ ├── css-background-image-filter.js │ │ ├── dom-filters.js │ │ ├── frameset.js │ │ ├── lazily-loaded-images.js │ │ ├── lonestar-filter.js │ │ ├── misnested-elements.js │ │ ├── over-emphasis.js │ │ ├── remove-empty-attributes.js │ │ ├── remove-unreachable-image-elements.js │ │ ├── responsive-images.js │ │ ├── set-all-image-element-dimensions.js │ │ ├── single-item-lists.js │ │ └── url-resolver.js │ ├── download-xml-document.js │ ├── fade-element.js │ ├── favicon.js │ ├── feed-parser.js │ ├── fetch-html.js │ ├── fetch-image-element.js │ ├── filter-controls.js │ ├── filter-empty-properties.js │ ├── filter-publisher.js │ ├── filter-unprintables.js │ ├── format-date.js │ ├── get-path-extension.js │ ├── image-utils.js │ ├── indexeddb-utils.js │ ├── is-hidden-inline.js │ ├── local-storage-utils.js │ ├── mime-utils.js │ ├── node-is-leaf.js │ ├── open-tab.js │ ├── opml.js │ ├── parse-html.js │ ├── parse-opml.js │ ├── parse-xml.js │ ├── remove-html.js │ ├── set-base-uri.js │ ├── srcset-utils.js │ ├── third-party │ │ ├── he.js │ │ ├── parse-srcset.js │ │ └── tinycolor-min.js │ ├── truncate-html.js │ ├── unwrap-element.js │ └── url-sniffer.js ├── service │ ├── archive-resources.js │ ├── db-service.js │ ├── import-entry.js │ ├── import-feed.js │ ├── import-opml.js │ ├── poll-feeds.js │ ├── refresh-feed-icons.js │ ├── subscribe.js │ ├── unsubscribe.js │ └── utils │ │ ├── lookup-feed-favicon.js │ │ └── show-notification.js ├── test │ ├── archive-resources-test.js │ ├── basic-image.png │ ├── better-fetch-test.html │ ├── better-fetch-tests.js │ ├── coerce-element-test.js │ ├── color-contrast-filter-test.js │ ├── color-test.js │ ├── count-resources-test.js │ ├── create-resource-test.js │ ├── css-background-image-filter-test.js │ ├── database-utils.js │ ├── delete-resource-test.js │ ├── dom-filter-tests.js │ ├── favicon-fetch-image-test.png │ ├── favicon-tests.js │ ├── feed-parser-test.js │ ├── fetch-html-test.js │ ├── fetch-image-element-test.js │ ├── filter-publisher-test.js │ ├── filter-unprintables-tests.js │ ├── get-path-extension-test.js │ ├── get-resource-test.js │ ├── get-resources-test.js │ ├── image-dimensions-filter-tests.js │ ├── import-entry-tests.js │ ├── import-opml-test.js │ ├── indexeddb-utils-test.js │ ├── migrations-tests.js │ ├── mime-utils-test.js │ ├── opml-test.js │ ├── patch-resource-test.js │ ├── put-resource-test.js │ ├── recording-channel.js │ ├── remove-html-test.js │ ├── resource-utils-tests.js │ ├── set-base-uri-test.js │ ├── subscribe-test-feed.xml │ ├── subscribe-test.js │ ├── test-page.js │ ├── test-registry.js │ ├── truncate-html-tests.js │ ├── unwrap-element-tests.js │ └── url-sniffer-test.js └── view │ ├── background-page.js │ ├── cli.js │ ├── options-page │ ├── about.js │ ├── display-settings-form.js │ ├── feed-list.js │ ├── general-settings-form.js │ ├── nav-menu.js │ ├── options-page.js │ └── subscription-form.js │ └── slideshow-page.js └── test.html /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Mac crap 2 | .DS_Store 3 | .icloud 4 | 5 | # local experiment files that should be ignored 6 | experimental/ 7 | 8 | # internal documentation methods should be ignored 9 | *.txt 10 | 11 | # node stuff 12 | node_modules/ 13 | .eslintrc.json 14 | package-lock.json 15 | package.json 16 | -------------------------------------------------------------------------------- /background.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /css/fonts.css: -------------------------------------------------------------------------------- 1 | /* See https://rsms.me/inter/inter.css */ 2 | @font-face { 3 | font-family: 'Inter'; 4 | src: url('/fonts/Inter.var.woff2') format('woff2'); 5 | } 6 | 7 | @font-face { 8 | font-family: Merriweather Regular; 9 | src: url('/fonts/Merriweather-Regular.ttf') format('truetype'); 10 | } 11 | 12 | @font-face { 13 | font-family: Edward Tufte Roman; 14 | src: url('/fonts/et-book-roman-line-figures.ttf') format('truetype'); 15 | } 16 | 17 | @font-face { 18 | font-family: Fanwood; 19 | src: url('/fonts/fanwood-webfont.ttf') format('truetype'); 20 | } 21 | 22 | @font-face { 23 | font-family: League Mono Regular; 24 | src: url('/fonts/LeagueMono-Regular.ttf') format('truetype'); 25 | } 26 | 27 | @font-face { 28 | font-family: League Spartan; 29 | src: url('/fonts/leaguespartan-bold.ttf') format('truetype'); 30 | } 31 | 32 | @font-face { 33 | font-family: ArchivoNarrow-Regular; 34 | src: url('/fonts/ArchivoNarrow-Regular.ttf') format('truetype'); 35 | } 36 | 37 | @font-face { 38 | /* Free from http://www.fontex.org/download/carto-gothic-std.otf */ 39 | font-family: CartoGothicStd; 40 | src: url('/fonts/CartoGothicStd-Book.otf') format('opentype'); 41 | } 42 | 43 | @font-face { 44 | font-family: Montserrat; 45 | src: url('/fonts/Montserrat-Regular.ttf') format('truetype'); 46 | } 47 | 48 | @font-face { 49 | font-family: Noto Sans; 50 | src: url('/fonts/NotoSans-Regular.ttf') format('truetype'); 51 | } 52 | 53 | @font-face { 54 | font-family: Open Sans Regular; 55 | src: url('/fonts/OpenSans-Regular.ttf') format('truetype'); 56 | } 57 | 58 | @font-face { 59 | font-family: PathwayGothicOne; 60 | src: url('/fonts/PathwayGothicOne-Regular.ttf') format('truetype'); 61 | } 62 | @font-face { 63 | font-family: PlayfairDisplaySC; 64 | src: url('/fonts/PlayfairDisplaySC-Regular.ttf'); 65 | } 66 | 67 | @font-face { 68 | font-family: Roboto Regular; 69 | src: url('/fonts/Roboto-Regular.ttf'); 70 | } 71 | -------------------------------------------------------------------------------- /css/options-page/about.css: -------------------------------------------------------------------------------- 1 | #about { 2 | display: none; 3 | } 4 | -------------------------------------------------------------------------------- /css/options-page/display-settings.css: -------------------------------------------------------------------------------- 1 | 2 | #section-display-settings { 3 | display: none; 4 | } 5 | 6 | #options-table tr td { 7 | user-select: none; 8 | -webkit-user-select: none; 9 | } 10 | 11 | #options-table td { 12 | padding-left: 0; 13 | padding-right:30px; 14 | padding-top: 0; 15 | padding-bottom: 10px; 16 | } 17 | 18 | #options-table input { 19 | width: 225px; 20 | } 21 | 22 | #options-table select { 23 | margin-left: 0; 24 | } 25 | -------------------------------------------------------------------------------- /css/options-page/feed-list.css: -------------------------------------------------------------------------------- 1 | #section-subscriptions { 2 | display:none; 3 | } 4 | 5 | #feedlist { 6 | font-family: 'Segoe UI', Tahoma, sans-serif; 7 | font-size: 94%; 8 | list-style-type: none; 9 | padding-left: 0; 10 | } 11 | 12 | #feedlist li { 13 | /* Truncate feed title if needed */ 14 | text-overflow: ellipsis; 15 | overflow: hidden; 16 | 17 | cursor: pointer; 18 | padding-left: 5px; 19 | padding-top: 5px; 20 | padding-bottom: 5px; 21 | padding-right: 8px; 22 | border-bottom: 1px solid #cccccc; 23 | -webkit-user-select: none; 24 | } 25 | 26 | #feedlist li[inactive] { 27 | color: #888888; 28 | } 29 | 30 | #feedlist li:hover { 31 | background-color: rgb(50, 80, 184); 32 | color:#efefef; 33 | } 34 | 35 | #feedlist li a { 36 | margin-right: 10px; 37 | } 38 | 39 | #feedlist li img { 40 | max-width: 26px; 41 | margin-right: 8px; 42 | } 43 | 44 | #nosubs { 45 | display: none; 46 | } 47 | 48 | #section-feed-details { 49 | display: none; 50 | } 51 | 52 | #feed-details-table { 53 | width: 100%; 54 | font-family: 'Segoe UI', Tahoma, sans-serif; 55 | } 56 | 57 | #feed-details-table td { 58 | padding: 10px; 59 | } 60 | -------------------------------------------------------------------------------- /css/options-page/general-settings.css: -------------------------------------------------------------------------------- 1 | #section-general-settings { 2 | display:none; 3 | } 4 | 5 | #general-settings-table { 6 | font-family: 'Segoe UI', Tahoma, sans-serif; 7 | padding: 0; 8 | margin: 0; 9 | border-spacing: 10px; 10 | } 11 | 12 | #general-settings-table td { 13 | padding: 5px; 14 | } 15 | -------------------------------------------------------------------------------- /css/options-page/nav-menu.css: -------------------------------------------------------------------------------- 1 | td#nav-cell-container { 2 | font-size: 130%; 3 | width: 180px; 4 | margin: 0; 5 | padding: 0; 6 | border: 0; 7 | vertical-align: top; 8 | border-right: 2px solid #bbbbbb; 9 | } 10 | 11 | ul#navigation-menu { 12 | list-style-type:none; 13 | font-family: 'Segoe UI', Tahoma, sans-serif; 14 | color: rgb(48, 57, 66); 15 | margin-top: 0; 16 | margin-right: 0; 17 | margin-bottom: 0; 18 | padding-top: 10px; 19 | padding-left: 0; 20 | padding-bottom: 10px; 21 | padding-right: 0; 22 | } 23 | 24 | ul#navigation-menu li { 25 | -webkit-border-start: 6px solid transparent; 26 | -webkit-padding-start: 10px; 27 | -webkit-user-select: none; 28 | cursor: pointer; 29 | line-height: 2em; 30 | } 31 | 32 | ul#navigation-menu li.navigation-item-selected { 33 | -webkit-border-start-color: blue; 34 | cursor: default; 35 | pointer-events: none; 36 | } 37 | 38 | #check-for-updates { 39 | margin-left: 20px; 40 | } 41 | -------------------------------------------------------------------------------- /css/options-page/options-page.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | margin: 0; 3 | padding: 0; 4 | font-size: 100%; 5 | } 6 | 7 | #f-it { 8 | position: absolute; 9 | width: 100%; 10 | border-spacing: 0; 11 | height: 100%; 12 | } 13 | 14 | #section-layout-container { 15 | vertical-align: top; 16 | padding-left: 6px; 17 | border-left: 2px solid #efefef; 18 | padding-right: 10px; 19 | } 20 | 21 | .options-section { 22 | margin-top: 0; 23 | margin-left: 1px; 24 | padding-top: 10px; 25 | padding-left: 0; 26 | } 27 | 28 | form { 29 | padding: 0; 30 | margin: 0; 31 | } 32 | 33 | .option-text { 34 | font-family: 'Segoe UI', Tahoma, sans-serif; 35 | } 36 | 37 | h1 { 38 | color: rgb(48, 57, 66); 39 | margin-top: 0; 40 | font-weight: 400; 41 | font-family: 'Segoe UI', Tahoma, sans-serif; 42 | padding-bottom: 8px; 43 | border-bottom: 1px solid #cccccc; 44 | } 45 | 46 | h2 { 47 | color: rgb(48, 57, 66); 48 | margin-top: 0; 49 | font-weight: 400; 50 | font-family: 'Segoe UI', Tahoma, sans-serif; 51 | padding-bottom: 8px; 52 | border-bottom: 1px solid #cccccc; 53 | } 54 | -------------------------------------------------------------------------------- /css/options-page/subscription-form.css: -------------------------------------------------------------------------------- 1 | #section-add-subscription { 2 | display: none; 3 | } 4 | 5 | #subscribe-url { 6 | width: 500px; 7 | } 8 | 9 | #submon { 10 | display: block; 11 | background-color: #ffffff; 12 | color: #000000; 13 | font-size: 14pt; 14 | position: fixed; 15 | float: left; 16 | top: 100px; 17 | left: 20%; 18 | right: 20%; 19 | padding: 30px; 20 | border: 1px solid #444444; 21 | } 22 | -------------------------------------------------------------------------------- /fonts/ArchivoNarrow-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/fonts/ArchivoNarrow-Regular.ttf -------------------------------------------------------------------------------- /fonts/CartoGothicStd-Book.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/fonts/CartoGothicStd-Book.otf -------------------------------------------------------------------------------- /fonts/Inter.var.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/fonts/Inter.var.woff2 -------------------------------------------------------------------------------- /fonts/LeagueMono-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/fonts/LeagueMono-Regular.ttf -------------------------------------------------------------------------------- /fonts/Merriweather-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/fonts/Merriweather-Regular.ttf -------------------------------------------------------------------------------- /fonts/Montserrat-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/fonts/Montserrat-Bold.ttf -------------------------------------------------------------------------------- /fonts/Montserrat-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/fonts/Montserrat-Regular.ttf -------------------------------------------------------------------------------- /fonts/NotoSans-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/fonts/NotoSans-Regular.ttf -------------------------------------------------------------------------------- /fonts/OpenSans-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/fonts/OpenSans-Regular.ttf -------------------------------------------------------------------------------- /fonts/PathwayGothicOne-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/fonts/PathwayGothicOne-Regular.ttf -------------------------------------------------------------------------------- /fonts/PlayfairDisplaySC-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/fonts/PlayfairDisplaySC-Regular.ttf -------------------------------------------------------------------------------- /fonts/Raleway.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/fonts/Raleway.woff -------------------------------------------------------------------------------- /fonts/Roboto-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/fonts/Roboto-Regular.ttf -------------------------------------------------------------------------------- /fonts/et-book-roman-line-figures.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/fonts/et-book-roman-line-figures.ttf -------------------------------------------------------------------------------- /fonts/fanwood-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/fonts/fanwood-webfont.ttf -------------------------------------------------------------------------------- /fonts/leaguespartan-bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/fonts/leaguespartan-bold.ttf -------------------------------------------------------------------------------- /images/CCXXXXXXI_by_aqueous.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/images/CCXXXXXXI_by_aqueous.jpg -------------------------------------------------------------------------------- /images/article-options.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/images/article-options.png -------------------------------------------------------------------------------- /images/bgfons-paper_texture318.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/images/bgfons-paper_texture318.jpg -------------------------------------------------------------------------------- /images/designova-subtle-carbon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/images/designova-subtle-carbon.png -------------------------------------------------------------------------------- /images/dominik-kiss-grid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/images/dominik-kiss-grid.png -------------------------------------------------------------------------------- /images/feed-icon-64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/images/feed-icon-64x64.png -------------------------------------------------------------------------------- /images/logo/128x128BW.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/images/logo/128x128BW.png -------------------------------------------------------------------------------- /images/logo/128x128Blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/images/logo/128x128Blue.png -------------------------------------------------------------------------------- /images/logo/128x128Orange.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/images/logo/128x128Orange.png -------------------------------------------------------------------------------- /images/logo/128x128WB.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/images/logo/128x128WB.png -------------------------------------------------------------------------------- /images/logo/16x16BW.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/images/logo/16x16BW.png -------------------------------------------------------------------------------- /images/logo/16x16Blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/images/logo/16x16Blue.png -------------------------------------------------------------------------------- /images/logo/16x16Orange.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/images/logo/16x16Orange.png -------------------------------------------------------------------------------- /images/logo/16x16WB.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/images/logo/16x16WB.png -------------------------------------------------------------------------------- /images/logo/48x48BW.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/images/logo/48x48BW.png -------------------------------------------------------------------------------- /images/logo/48x48Blue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/images/logo/48x48Blue.png -------------------------------------------------------------------------------- /images/logo/48x48Orange.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/images/logo/48x48Orange.png -------------------------------------------------------------------------------- /images/logo/48x48WB.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/images/logo/48x48WB.png -------------------------------------------------------------------------------- /images/logo/GithubRead.me.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/images/logo/GithubRead.me.png -------------------------------------------------------------------------------- /images/paper-backgrounds-vintage-white.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/images/paper-backgrounds-vintage-white.jpg -------------------------------------------------------------------------------- /images/pickering-texturetastic-gray.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/images/pickering-texturetastic-gray.png -------------------------------------------------------------------------------- /images/reusage-recycled-paper-white-first.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/images/reusage-recycled-paper-white-first.png -------------------------------------------------------------------------------- /images/rss-large.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/images/rss-large.png -------------------------------------------------------------------------------- /images/rss_icon_trans.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/images/rss_icon_trans.gif -------------------------------------------------------------------------------- /images/rss_icon_trans.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/images/rss_icon_trans.png -------------------------------------------------------------------------------- /images/subtle-patterns-beige-paper.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/images/subtle-patterns-beige-paper.png -------------------------------------------------------------------------------- /images/subtle-patterns-cream-paper.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/images/subtle-patterns-cream-paper.png -------------------------------------------------------------------------------- /images/subtle-patterns-exclusive-paper.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/images/subtle-patterns-exclusive-paper.png -------------------------------------------------------------------------------- /images/subtle-patterns-groove-paper.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/images/subtle-patterns-groove-paper.png -------------------------------------------------------------------------------- /images/subtle-patterns-handmade-paper.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/images/subtle-patterns-handmade-paper.png -------------------------------------------------------------------------------- /images/subtle-patterns-paper-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/images/subtle-patterns-paper-1.png -------------------------------------------------------------------------------- /images/subtle-patterns-paper-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/images/subtle-patterns-paper-2.png -------------------------------------------------------------------------------- /images/subtle-patterns-paper.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/images/subtle-patterns-paper.png -------------------------------------------------------------------------------- /images/subtle-patterns-rice-paper-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/images/subtle-patterns-rice-paper-2.png -------------------------------------------------------------------------------- /images/subtle-patterns-rice-paper-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/images/subtle-patterns-rice-paper-3.png -------------------------------------------------------------------------------- /images/subtle-patterns-soft-wallpaper.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/images/subtle-patterns-soft-wallpaper.png -------------------------------------------------------------------------------- /images/subtle-patterns-white-wall.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/images/subtle-patterns-white-wall.png -------------------------------------------------------------------------------- /images/subtle-patterns-witewall-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/images/subtle-patterns-witewall-3.png -------------------------------------------------------------------------------- /images/thomas-zucx-noise-lines.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/images/thomas-zucx-noise-lines.png -------------------------------------------------------------------------------- /images/unsubscribe.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/images/unsubscribe.gif -------------------------------------------------------------------------------- /images/white-construction-paper1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/images/white-construction-paper1.jpg -------------------------------------------------------------------------------- /images/yvrelle_towel_white.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/images/yvrelle_towel_white.jpg -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | Copyright 2019 Josh Froelich. All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without modification, 4 | are permitted provided that the following conditions are met: 5 | 6 | * Redistributions of source code must retain the above copyright notice, this 7 | list of conditions and the following disclaimer. 8 | * Redistributions in binary form must reproduce the above copyright notice, this 9 | list of conditions and the following disclaimer in the documentation and/or 10 | other materials provided with the distribution. 11 | * Neither the name of the copyright holders nor the names of its contributors 12 | may be used to endorse or promote products derived from this software without 13 | specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 16 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 17 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 18 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 19 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 20 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 21 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 22 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 23 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 24 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 25 | 26 | ## Third-party code license information: 27 | * tinycolor.js - MIT License 28 | * parse-srcset.js - MIT License 29 | * he.js - MIT License 30 | 31 | ## Third-party font license information: 32 | * Edward Tufte Roman https://github.com/edwardtufte/et-book (MIT Licensed) 33 | * Fanwood https://www.theleagueofmoveabletype.com 34 | * Inter https://rsms.me/inter SIL Open Font License 1.1 35 | * League Mono Regular https://www.theleagueofmoveabletype.com 36 | * League Spartan https://www.theleagueofmoveabletype.com 37 | * Merriweather Open Font License 38 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "6", 3 | "manifest_version": 2, 4 | "minimum_chrome_version" : "70", 5 | "name": "RSS Reader", 6 | "author": "Josh Froelich", 7 | "homepage_url": "https://github.com/jfroelich/rss-reader", 8 | "description": "A simple RSS reader extension for Chrome", 9 | "browser_action": { 10 | "default_title": "RSS Reader" 11 | }, 12 | "icons": { "16": "/images/logo/16x16Blue.png", 13 | "48": "/images/logo/48x48Blue.png", 14 | "128": "/images/logo/128x128Blue.png" }, 15 | "background": { 16 | "persistent": false, 17 | "page": "background.html" 18 | }, 19 | "options_page": "options.html", 20 | "permissions": [ 21 | "alarms", 22 | "http://*/*", 23 | "https://*/*", 24 | "downloads", 25 | "idle", 26 | "notifications", 27 | "tabs", 28 | "unlimitedStorage" 29 | ], 30 | "optional_permissions": [ 31 | "background" 32 | ], 33 | "content_security_policy": 34 | "script-src 'self'; object-src 'self' https://www.youtube.com; frame-src http: https:" 35 | } 36 | -------------------------------------------------------------------------------- /options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Reader Options 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
31 | 32 |
33 |

Subscriptions

34 |

No subscriptions

35 |
    36 |
    37 | 38 |
    39 | 40 |
    41 |

    42 | 43 | 44 |

    45 | 46 | 47 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 |
    48 | 49 | 50 | 51 |
    Description:
    Feed location:
    Website:
    66 |
    67 | 68 |
    69 |
    70 |
    71 |
    72 | 73 | 74 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | Proje ismi 2 | 3 | A simple Chrome extension for viewing RSS feeds. This is a hobby project I occasionally work on for fun. MIT licensed. 4 | 5 | ## Install guide 6 | 1. Download to a folder 7 | 2. Get Chrome beta/dev version 8 | 3. Enable Chrome dev mode to allow loading of unpacked extensions 9 | 4. Go to Chrome extensions page 10 | 5. Load unpacked extension, load the folder 11 | 12 | ## Initial configuration and use 13 | * Setup display settings in options 14 | * Subscribe to feeds by feed url in options 15 | * Open the view and check for updates, or wait for polling to occur 16 | * Click badge icon to view slideshow and browse articles 17 | 18 | ## Contributions 19 | Thanks to [Yasujizr](https://github.com/Yasujizr) for contributing the nice looking icons. 20 | -------------------------------------------------------------------------------- /slideshow.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | RSS Reader 4 | 5 | 6 | 7 | 8 | 9 | 10 |
    11 |
    12 |
    13 |
    14 |

    Loading ... please wait.

    15 | 16 |
    17 | 18 |
    19 | 20 |
    21 |
    There are no articles to display. 22 |
      23 |
    • Try subscribing to a new feed in settings!
    • 24 |
    • Check for new feeds using the Refresh button in the main menu
    • 25 |
    26 |
    27 |
    28 |
    29 | 30 | 36 | 37 |
    38 | 45 |
    46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /src/control/browser-action-control.js: -------------------------------------------------------------------------------- 1 | import * as DBService from '/src/service/db-service.js'; 2 | import * as localStorageUtils from '/src/lib/local-storage-utils.js'; 3 | import { INDEFINITE } from '/src/lib/deadline.js'; 4 | import openTab from '/src/lib/open-tab.js'; 5 | 6 | export default class BrowserActionControl { 7 | static onClicked() { 8 | const reuseNewtab = localStorageUtils.readBoolean('reuse_newtab'); 9 | openTab('slideshow.html', reuseNewtab).catch(console.warn); 10 | } 11 | 12 | static async onStartup() { 13 | const conn = await DBService.open(INDEFINITE); 14 | await BrowserActionControl.refreshBadge(conn); 15 | conn.close(); 16 | } 17 | 18 | static async onInstalled() { 19 | const conn = await DBService.open(INDEFINITE); 20 | await BrowserActionControl.refreshBadge(conn); 21 | conn.close(); 22 | } 23 | 24 | static async onMessage(event) { 25 | if (event.isTrusted && event.data) { 26 | const message = event.data; 27 | if ((message.type === 'resource-created' && message.resourceType === 'entry') || 28 | (message.type === 'resource-updated' && message.resourceType === 'entry') || 29 | message.type === 'resource-deleted') { 30 | const conn = await DBService.open(INDEFINITE); 31 | await BrowserActionControl.refreshBadge(conn); 32 | conn.close(); 33 | } 34 | } 35 | } 36 | 37 | static onMessageError(event) { 38 | console.warn(event); 39 | } 40 | 41 | static async refreshBadge(conn) { 42 | const count = await DBService.countUnreadEntries(conn); 43 | const text = count > 999 ? '1k+' : `${count}`; 44 | chrome.browserAction.setBadgeText({ text }); 45 | } 46 | 47 | constructor() { 48 | this.channel = undefined; 49 | } 50 | 51 | init(bindOnclicked, bindOnInstalled, bindOnStartup) { 52 | this.channel = new BroadcastChannel('reader'); 53 | this.channel.addEventListener('message', BrowserActionControl.onMessage); 54 | this.channel.addEventListener('messageerror', BrowserActionControl.onMessageError); 55 | 56 | if (bindOnclicked) { 57 | chrome.browserAction.onClicked.addListener(BrowserActionControl.onClicked); 58 | } 59 | 60 | if (bindOnInstalled) { 61 | chrome.runtime.onInstalled.addListener(BrowserActionControl.onInstalled); 62 | } 63 | 64 | if (bindOnStartup) { 65 | chrome.runtime.onStartup.addListener(BrowserActionControl.onStartup); 66 | } 67 | } 68 | 69 | closeChannel() { 70 | this.channel.close(); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/control/cron-control.js: -------------------------------------------------------------------------------- 1 | import * as DBService from '/src/service/db-service.js'; 2 | import * as favicon from '/src/lib/favicon.js'; 3 | import * as localStorageUtils from '/src/lib/local-storage-utils.js'; 4 | import { PollFeedsArgs, pollFeeds } from '/src/service/poll-feeds.js'; 5 | import archiveResources from '/src/service/archive-resources.js'; 6 | import refreshFeedIcons from '/src/service/refresh-feed-icons.js'; 7 | 8 | // TODO: decide whether this should be a service or a control 9 | 10 | const HALF_DAY_MINUTES = 60 * 12; 11 | const ONE_WEEK_MINUTES = 60 * 24 * 7; 12 | const ONE_HOUR_MINUTES = 60; 13 | const FORTNIGHT_MINUTES = ONE_WEEK_MINUTES * 2; 14 | 15 | const deprecatedAlarmNames = [ 16 | 'remove-entries-missing-urls', 'remove-untyped-objects', 17 | 'remove-orphaned-entries', 'test-install-binding-alarms', 18 | 'db-remove-orphaned-entries', 'cleanup-refresh-badge-lock' 19 | ]; 20 | 21 | const alarms = [ 22 | { name: 'archive', period: HALF_DAY_MINUTES }, 23 | { name: 'poll', period: ONE_HOUR_MINUTES }, 24 | { name: 'refresh-feed-icons', period: FORTNIGHT_MINUTES }, 25 | { name: 'compact-favicon-db', period: ONE_WEEK_MINUTES } 26 | ]; 27 | 28 | export default function CronControl() { } 29 | 30 | CronControl.prototype.init = function () { 31 | chrome.alarms.onAlarm.addListener(this.onAlarm.bind(this)); 32 | chrome.runtime.onInstalled.addListener(this.onInstalled.bind(this)); 33 | }; 34 | 35 | CronControl.prototype.onAlarm = async function (alarm) { 36 | console.debug('Alarm wokeup:', alarm.name); 37 | localStorageUtils.writeString('last_alarm', alarm.name); 38 | 39 | if (alarm.name === 'archive') { 40 | const conn = await DBService.open(); 41 | await archiveResources(conn); 42 | conn.close(); 43 | } else if (alarm.name === 'poll') { 44 | await this.onPollAlarm(); 45 | } else if (alarm.name === 'refresh-feed-icons') { 46 | const proms = [DBService.open(), favicon.open()]; 47 | const [conn, iconn] = await Promise.all(proms); 48 | await refreshFeedIcons(conn, iconn); 49 | conn.close(); 50 | iconn.close(); 51 | } else if (alarm.name === 'compact-favicon-db') { 52 | const conn = await favicon.open(); 53 | await favicon.compact(conn); 54 | conn.close(); 55 | } else { 56 | console.warn('Unhandled alarm', alarm.name); 57 | } 58 | }; 59 | 60 | CronControl.prototype.onPollAlarm = async function () { 61 | const idlePollSeconds = localStorageUtils.readInt('idle_poll_secs'); 62 | if (Number.isInteger(idlePollSeconds) && idlePollSeconds > 0 && 63 | localStorageUtils.readBoolean('only_poll_if_idle')) { 64 | const idleStates = ['locked', 'idle']; 65 | const idleState = await queryIdleState(idlePollSeconds); 66 | if (!idleStates.includes(idleState)) { 67 | console.debug('Canceling poll-feeds alarm as not idle for %d seconds', idlePollSeconds); 68 | return; 69 | } 70 | } 71 | 72 | const promises = [DBService.open(), favicon.open()]; 73 | const [conn, iconn] = await Promise.all(promises); 74 | const pollArgs = new PollFeedsArgs(); 75 | pollArgs.conn = conn; 76 | pollArgs.iconn = iconn; 77 | pollArgs.ignoreRecencyCheck = false; 78 | pollArgs.notify = true; 79 | await pollFeeds(pollArgs); 80 | conn.close(); 81 | iconn.close(); 82 | }; 83 | 84 | CronControl.prototype.onInstalled = function (event) { 85 | if (event.reason === 'install') { 86 | this.createAlarms(); 87 | } else { 88 | for (const name of deprecatedAlarmNames) { 89 | this.removeAlarm(name).catch(console.warn); 90 | } 91 | } 92 | }; 93 | 94 | CronControl.prototype.removeAlarm = function (name) { 95 | return new Promise((resolve) => { 96 | chrome.alarms.clear(name, (cleared) => { resolve({ name, cleared }); }); 97 | }); 98 | }; 99 | 100 | CronControl.prototype.createAlarms = function () { 101 | for (const alarm of alarms) { 102 | chrome.alarms.create(alarm.name, { periodInMinutes: alarm.period }); 103 | } 104 | }; 105 | 106 | function queryIdleState(seconds) { 107 | return new Promise(resolve => chrome.idle.queryState(seconds, resolve)); 108 | } 109 | -------------------------------------------------------------------------------- /src/control/db-control.js: -------------------------------------------------------------------------------- 1 | import * as DBService from '/src/service/db-service.js'; 2 | import { INDEFINITE } from '/src/lib/deadline.js'; 3 | 4 | export default function DbControl() { } 5 | 6 | DbControl.prototype.init = function () { 7 | chrome.runtime.onInstalled.addListener(this.onInstalled.bind(this)); 8 | }; 9 | 10 | DbControl.prototype.onInstalled = async function (event) { 11 | if (event.reason === 'install') { 12 | // This is one of the earliest, if not the earliest, calls to open the database once the 13 | // extension is installed or updated, so we want to allow for the extra time it takes to 14 | // complete the upgrade, so we do not impose a timeout in this case. 15 | const conn = await DBService.open(INDEFINITE); 16 | conn.close(); 17 | } 18 | }; 19 | -------------------------------------------------------------------------------- /src/db/connection.js: -------------------------------------------------------------------------------- 1 | // Represents a connection to the database 2 | export default function Connection() { 3 | this.conn = undefined; 4 | this.channel = undefined; 5 | } 6 | 7 | Connection.prototype.close = function () { 8 | this.conn.close(); 9 | 10 | // Treat channel as optional 11 | if (this.channel) { 12 | this.channel.close(); 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /src/db/count-resources.js: -------------------------------------------------------------------------------- 1 | import assert from '/src/lib/assert.js'; 2 | 3 | export default function countResources(conn, query) { 4 | return new Promise((resolve, reject) => { 5 | // Currently this only supports counting unread entries 6 | assert(query.read === 0); 7 | assert(query.type === 'entry'); 8 | 9 | const transaction = conn.conn.transaction('resources'); 10 | const resourcesStore = transaction.objectStore('resources'); 11 | const typeReadIndex = resourcesStore.index('type-read'); 12 | const request = typeReadIndex.count(['entry', 0]); 13 | request.onsuccess = () => resolve(request.result); 14 | request.onerror = () => reject(request.error); 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /src/db/create-resource.js: -------------------------------------------------------------------------------- 1 | import * as resourceUtils from '/src/db/resource-utils.js'; 2 | import assert from '/src/lib/assert.js'; 3 | import filterEmptyProperties from '/src/lib/filter-empty-properties.js'; 4 | 5 | // TODO: support new resource type "enclosure" that is child of type entry 6 | 7 | // Store a new object in the database and get its key path. Returns a promise that resolves to the 8 | // new, automatically-generated unique id of the resource. 9 | // 10 | // Throws various errors such as when passed invalid input, or when there was problem communicating 11 | // with the database. 12 | export default function createResource(conn, resource) { 13 | return new Promise(executor.bind(null, conn, resource)); 14 | } 15 | 16 | function executor(conn, resource, resolve, reject) { 17 | assert(resource && typeof resource === 'object'); 18 | assert(resource.id === undefined); 19 | assert(resource.type === 'feed' || resource.type === 'entry'); 20 | 21 | // Feed resources must have at least one url. 22 | if (resource.type === 'feed') { 23 | assert(resource.urls && resource.urls.length); 24 | } 25 | 26 | // Feeds are by default active when created unless explicitly inactive 27 | if (resource.type === 'feed' && resource.active === undefined) { 28 | resource.active = 1; 29 | } 30 | 31 | if (resource.type === 'entry' && resource.read === undefined) { 32 | resource.read = 0; 33 | } 34 | 35 | if (resource.type === 'entry' && resource.archived === undefined) { 36 | resource.archived = 0; 37 | } 38 | 39 | resource.created_date = new Date(); 40 | resource.updated_date = undefined; 41 | 42 | resourceUtils.normalize(resource); 43 | resourceUtils.sanitize(resource); 44 | resourceUtils.validate(resource); 45 | filterEmptyProperties(resource); 46 | 47 | const transaction = conn.conn.transaction('resources', 'readwrite'); 48 | transaction.oncomplete = transactionOncomplete.bind(transaction, resource, conn.channel, resolve); 49 | transaction.onerror = event => reject(event.target.error); 50 | 51 | const resourcesStore = transaction.objectStore('resources'); 52 | const putRequest = resourcesStore.put(resource); 53 | putRequest.onsuccess = () => { 54 | resource.id = putRequest.result; 55 | }; 56 | } 57 | 58 | function transactionOncomplete(resource, channel, callback) { 59 | if (channel) { 60 | channel.postMessage({ 61 | type: 'resource-created', 62 | id: resource.id, 63 | resourceType: resource.type 64 | }); 65 | } 66 | 67 | callback(resource.id); 68 | } 69 | -------------------------------------------------------------------------------- /src/db/db.js: -------------------------------------------------------------------------------- 1 | // Rule 10.3 of airbnb style guide says we should not use the export one-liner 2 | 3 | import { ConstraintError, NotFoundError } from '/src/db/errors.js'; 4 | import { isValidId, setURL } from '/src/db/resource-utils.js'; 5 | import Connection from '/src/db/connection.js'; 6 | import countResources from '/src/db/count-resources.js'; 7 | import createResource from '/src/db/create-resource.js'; 8 | import deleteResource from '/src/db/delete-resource.js'; 9 | import getResource from '/src/db/get-resource.js'; 10 | import getResources from '/src/db/get-resources.js'; 11 | import open, { defaultUpgradeNeededHandler, defaultVersion } from '/src/db/open.js'; 12 | import patchResource from '/src/db/patch-resource.js'; 13 | import putResource from '/src/db/put-resource.js'; 14 | 15 | export { countResources }; 16 | export { createResource }; 17 | export { deleteResource }; 18 | export { getResource }; 19 | export { getResources }; 20 | export { isValidId }; 21 | export { open }; 22 | export { defaultUpgradeNeededHandler }; 23 | export { defaultVersion }; 24 | export { patchResource }; 25 | export { putResource }; 26 | export { setURL }; 27 | export { Connection }; 28 | export { ConstraintError }; 29 | export { NotFoundError }; 30 | -------------------------------------------------------------------------------- /src/db/delete-resource.js: -------------------------------------------------------------------------------- 1 | import * as resourceUtils from '/src/db/resource-utils.js'; 2 | import assert from '/src/lib/assert.js'; 3 | 4 | // TODO: support deleting enclosures? 5 | 6 | // Deletes a resource and its immediate children. Note that grandchildren are completely ignored 7 | // (e.g. after this, grand child resources now have a parent id property value that contains an 8 | // outdated id value). 9 | export default function deleteResource(conn, id, reason) { 10 | return new Promise(deleteResourceExecutor.bind(this, conn, id, reason)); 11 | } 12 | 13 | function deleteResourceExecutor(conn, id, reason, resolve, reject) { 14 | assert(resourceUtils.isValidId(id)); 15 | 16 | const dependentIds = []; 17 | 18 | const transaction = conn.conn.transaction('resources', 'readwrite'); 19 | transaction.onerror = event => reject(event.target.error); 20 | transaction.oncomplete = transactionOncomplete.bind(transaction, id, reason, dependentIds, 21 | conn.channel, resolve); 22 | 23 | const resourcesStore = transaction.objectStore('resources'); 24 | resourcesStore.delete(id); 25 | 26 | const cursorRequest = resourcesStore.openCursor(); 27 | cursorRequest.onsuccess = cursorRequestOnsuccess.bind(cursorRequest, id, dependentIds); 28 | } 29 | 30 | function cursorRequestOnsuccess(id, dependentIds, event) { 31 | const cursor = event.target.result; 32 | if (cursor) { 33 | const resource = cursor.value; 34 | if (resource.parent === id) { 35 | dependentIds.push(resource.id); 36 | cursor.delete(); 37 | } 38 | 39 | cursor.continue(); 40 | } 41 | } 42 | 43 | function transactionOncomplete(id, reason, dependentIds, channel, callback) { 44 | if (channel) { 45 | channel.postMessage({ type: 'resource-deleted', id, reason }); 46 | 47 | for (const dependentId of dependentIds) { 48 | channel.postMessage({ 49 | type: 'resource-deleted', id: dependentId, reason, parent: id 50 | }); 51 | } 52 | } 53 | 54 | callback(dependentIds); 55 | } 56 | -------------------------------------------------------------------------------- /src/db/errors.js: -------------------------------------------------------------------------------- 1 | export class ConstraintError extends Error { 2 | constructor(message = 'Constraint error') { 3 | super(message); 4 | } 5 | } 6 | 7 | // This error should occur when something that was expected to exist in the database was not found. 8 | export class NotFoundError extends Error { 9 | constructor(message = 'Not found error') { 10 | super(message); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/db/get-resource.js: -------------------------------------------------------------------------------- 1 | import * as resourceUtils from '/src/db/resource-utils.js'; 2 | import Connection from '/src/db/connection.js'; 3 | import assert from '/src/lib/assert.js'; 4 | 5 | export default function getResource(conn, query) { 6 | return new Promise(getResourceExecutor.bind(this, conn, query)); 7 | } 8 | 9 | function getResourceExecutor(conn, query, resolve, reject) { 10 | assert(conn instanceof Connection); 11 | assert(query.mode === 'id' || query.mode === 'url'); 12 | 13 | if (query.mode === 'id') { 14 | assert(resourceUtils.isValidId(query.id)); 15 | assert(!query.keyOnly); 16 | } else if (query.mode === 'url') { 17 | assert(query.url instanceof URL); 18 | } 19 | 20 | const transaction = conn.conn.transaction('resources'); 21 | transaction.onerror = event => reject(event.target.error); 22 | 23 | const resourcesStore = transaction.objectStore('resources'); 24 | let request; 25 | if (query.mode === 'url') { 26 | const index = resourcesStore.index('urls'); 27 | const { href } = query.url; 28 | request = query.keyOnly ? index.getKey(href) : index.get(href); 29 | } else if (query.mode === 'id') { 30 | request = resourcesStore.get(query.id); 31 | } 32 | 33 | request.onsuccess = requestOnsuccess.bind(request, query, resolve); 34 | } 35 | 36 | function requestOnsuccess(query, callback, event) { 37 | const { result } = event.target; 38 | 39 | if (typeof result !== 'undefined') { 40 | if (query.keyOnly) { 41 | // key only match 42 | callback({ id: result }); 43 | } else { 44 | // full match 45 | callback(result); 46 | } 47 | } else { 48 | // no match 49 | callback(); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/db/get-resources.js: -------------------------------------------------------------------------------- 1 | import assert from '/src/lib/assert.js'; 2 | 3 | export default function getResources(conn, query) { 4 | return new Promise(getResourcesExecutor.bind(this, conn, query)); 5 | } 6 | 7 | function getResourcesExecutor(conn, query, resolve, reject) { 8 | assert(query && typeof query === 'object'); 9 | 10 | const modes = [ 11 | 'all', 'feeds', 'active-feeds', 'viewable-entries', 'archivable-entries' 12 | ]; 13 | assert(modes.includes(query.mode)); 14 | assert(isValidOffset(query.offset)); 15 | assert(isValidLimit(query.limit)); 16 | 17 | const resources = []; 18 | 19 | const transaction = conn.conn.transaction('resources'); 20 | transaction.oncomplete = transactionOncomplete.bind(transaction, resources, query, resolve); 21 | transaction.onerror = event => reject(event.target.error); 22 | 23 | const store = transaction.objectStore('resources'); 24 | 25 | const request = openCursorRequest(query, store); 26 | 27 | const context = {}; 28 | context.mode = query.mode; 29 | context.skipCount = 0; 30 | context.resources = resources; 31 | context.offset = query.offset; 32 | context.limit = query.limit; 33 | 34 | request.onsuccess = requestOnsuccess.bind(request, context); 35 | } 36 | 37 | function requestOnsuccess(context, event) { 38 | const cursor = event.target.result; 39 | if (!cursor) { 40 | return; 41 | } 42 | 43 | const resource = cursor.value; 44 | 45 | // If the resource does not match the query then skip past it entirely. This does not contribute 46 | // to the offset calculation. 47 | if (!resourceMatchesQuery(context.mode, resource)) { 48 | cursor.continue(); 49 | return; 50 | } 51 | 52 | // Keep advancing until we reached offset 53 | if (context.offset > 0 && context.skipCount < context.offset) { 54 | context.skipCount += 1; 55 | cursor.continue(); 56 | return; 57 | } 58 | 59 | context.resources.push(resource); 60 | 61 | if (context.limit > 0) { 62 | if (context.resources.length < context.limit) { 63 | cursor.continue(); 64 | } else { 65 | // done (limit reached) 66 | } 67 | } else { 68 | // unlimited, always continue 69 | cursor.continue(); 70 | } 71 | } 72 | 73 | function resourceMatchesQuery(mode, resource) { 74 | if (mode === 'all') { 75 | return true; 76 | } 77 | 78 | if (mode === 'feeds') { 79 | return resource.type === 'feed'; 80 | } 81 | 82 | if (mode === 'viewable-entries') { 83 | return resource.type === 'entry' && resource.archived === 0 && 84 | resource.read === 0; 85 | } 86 | 87 | if (mode === 'archivable-entries') { 88 | return resource.type === 'entry' && resource.archived === 0 && 89 | resource.read === 1; 90 | } 91 | 92 | if (mode === 'active-feeds') { 93 | return resource.type === 'feed' && resource.active === 1; 94 | } 95 | 96 | return false; 97 | } 98 | 99 | function openCursorRequest(query, store) { 100 | if (query.mode === 'all') { 101 | return store.openCursor(); 102 | } 103 | 104 | if (query.mode === 'feeds') { 105 | const index = store.index('type'); 106 | return index.openCursor('feed'); 107 | } 108 | 109 | if (query.mode === 'active-feeds') { 110 | const index = store.index('type'); 111 | return index.openCursor('feed'); 112 | } 113 | 114 | if (query.mode === 'viewable-entries' || 115 | query.mode === 'archivable-entries') { 116 | const index = store.index('type'); 117 | return index.openCursor('entry'); 118 | } 119 | 120 | throw new TypeError(`Invalid mode ${query.mode}`); 121 | } 122 | 123 | function transactionOncomplete(resources, query, callback) { 124 | if (query.titleSort) { 125 | resources.sort(compareResourceTitles); 126 | } 127 | 128 | callback(resources); 129 | } 130 | 131 | function compareResourceTitles(a, b) { 132 | const s1 = a.title ? a.title.toLowerCase() : ''; 133 | const s2 = b.title ? b.title.toLowerCase() : ''; 134 | return indexedDB.cmp(s1, s2); 135 | } 136 | 137 | function isValidOffset(offset) { 138 | return offset === null || offset === undefined || isNaN(offset) || 139 | (Number.isInteger(offset) && offset >= 0); 140 | } 141 | 142 | function isValidLimit(limit) { 143 | return limit === null || limit === undefined || isNaN(limit) || 144 | (Number.isInteger(limit) && limit >= 0); 145 | } 146 | -------------------------------------------------------------------------------- /src/db/open.js: -------------------------------------------------------------------------------- 1 | import * as indexedDBUtils from '/src/lib/indexeddb-utils.js'; 2 | import * as migrations from '/src/db/migrations.js'; 3 | import { Deadline } from '/src/lib/deadline.js'; 4 | import Connection from '/src/db/connection.js'; 5 | import assert from '/src/lib/assert.js'; 6 | 7 | export const defaultName = 'reader'; 8 | export const defaultVersion = 35; 9 | export const defaultChannelName = 'reader'; 10 | export const defaultTimeout = new Deadline(5000); 11 | 12 | // Asynchronously connect to the app's indexedDB database 13 | // @param timeout {Deadline} optional 14 | // @return {Promise} a promise that resolves to a connection {Connection} 15 | export default async function open(timeout = defaultTimeout) { 16 | assert(timeout instanceof Deadline); 17 | 18 | const conn = new Connection(); 19 | const channel = new BroadcastChannel(defaultChannelName); 20 | 21 | function upgradeNeededHandler(event) { 22 | defaultUpgradeNeededHandler(channel, event); 23 | } 24 | 25 | conn.channel = channel; 26 | conn.conn = await indexedDBUtils.open(defaultName, defaultVersion, upgradeNeededHandler, timeout); 27 | return conn; 28 | } 29 | 30 | export function defaultUpgradeNeededHandler(channel, event) { 31 | migrations.migrate20(event, channel); 32 | migrations.migrate21(event, channel); 33 | migrations.migrate22(event, channel); 34 | migrations.migrate23(event, channel); 35 | migrations.migrate24(event, channel); 36 | migrations.migrate25(event, channel); 37 | migrations.migrate26(event, channel); 38 | migrations.migrate27(event, channel); 39 | migrations.migrate28(event, channel); 40 | migrations.migrate29(event, channel); 41 | migrations.migrate30(event, channel); 42 | migrations.migrate31(event, channel); 43 | migrations.migrate32(event, channel); 44 | migrations.migrate33(event, channel); 45 | migrations.migrate34(event, channel); 46 | migrations.migrate35(event, channel); 47 | } 48 | -------------------------------------------------------------------------------- /src/db/patch-resource.js: -------------------------------------------------------------------------------- 1 | import * as resourceUtils from '/src/db/resource-utils.js'; 2 | import { NotFoundError } from '/src/db/errors.js'; 3 | import Connection from '/src/db/connection.js'; 4 | import assert from '/src/lib/assert.js'; 5 | import filterEmptyProperties from '/src/lib/filter-empty-properties.js'; 6 | 7 | export default function patchResource(conn, props) { 8 | return new Promise(executor.bind(this, conn, props)); 9 | } 10 | 11 | function executor(conn, props, resolve, reject) { 12 | assert(conn instanceof Connection); 13 | assert(props && typeof props === 'object'); 14 | assert(resourceUtils.isValidId(props.id)); 15 | 16 | // resource.type is immutable from the POV of patching 17 | assert(!('type' in props)); 18 | 19 | resourceUtils.normalize(props); 20 | resourceUtils.sanitize(props); 21 | resourceUtils.validate(props); 22 | 23 | const resourceTypeInfo = { type: undefined }; 24 | 25 | const transaction = conn.conn.transaction('resources', 'readwrite'); 26 | transaction.oncomplete = transactionOncomplete.bind(transaction, props.id, resourceTypeInfo, 27 | conn.channel, resolve); 28 | transaction.onerror = event => reject(event.target.error); 29 | 30 | const resourcesStore = transaction.objectStore('resources'); 31 | const getRequest = resourcesStore.get(props.id); 32 | getRequest.onsuccess = getRequestOnsuccess.bind(getRequest, props, resourceTypeInfo, reject); 33 | } 34 | 35 | function transactionOncomplete(id, resourceTypeInfo, channel, callback) { 36 | if (channel) { 37 | channel.postMessage({ 38 | id, 39 | type: 'resource-updated', 40 | resourceType: resourceTypeInfo.type 41 | }); 42 | } 43 | 44 | callback(); 45 | } 46 | 47 | 48 | function getRequestOnsuccess(props, resourceTypeInfo, reject, event) { 49 | // Note that throwing from this scope is problematic because it leads to uncaught exceptions, 50 | // so explicitly reject instead. 51 | 52 | const resource = event.target.result; 53 | 54 | if (!resource) { 55 | const message = `No resource found for id ${props.id}`; 56 | reject(new NotFoundError(message)); 57 | return; 58 | } 59 | 60 | // Upon loading the matching resource from the database we now can learn its type 61 | resourceTypeInfo.type = resource.type; 62 | 63 | // Transform props producing noop transitions 64 | if (props.read === resource.read) { 65 | delete props.read; 66 | } 67 | 68 | if (props.archived === resource.archived) { 69 | delete props.archived; 70 | } 71 | 72 | if (props.active === resource.active) { 73 | delete props.active; 74 | } 75 | 76 | // Impute missing derived properties 77 | if (props.read === 1 && !props.read_date) { 78 | props.read_date = new Date(); 79 | } else if (props.read === 0) { 80 | props.read_date = undefined; 81 | } 82 | 83 | if (props.archived === 1 && !props.archived_date) { 84 | props.archived_date = new Date(); 85 | } else if (props.archived === 0) { 86 | props.archived_date = undefined; 87 | } 88 | 89 | if (props.active === 1) { 90 | delete props.deactivation_date; 91 | delete props.deactivation_reason; 92 | } else if (!props.deactivation_date) { 93 | props.deactivation_date = new Date(); 94 | } 95 | 96 | // Apply transitions 97 | 98 | // These properties are not modifiable. 99 | const immutablePropertyNames = ['id', 'updated_date', 'type']; 100 | 101 | let dirtiedPropertyCount = 0; 102 | 103 | for (const prop in props) { 104 | if (immutablePropertyNames.includes(prop)) { 105 | continue; 106 | } 107 | 108 | const value = props[prop]; 109 | if (value === undefined) { 110 | delete resource[prop]; 111 | } else { 112 | resource[prop] = value; 113 | } 114 | 115 | dirtiedPropertyCount += 1; 116 | } 117 | 118 | if (dirtiedPropertyCount) { 119 | filterEmptyProperties(resource); 120 | resource.updated_date = new Date(); 121 | event.target.source.put(resource); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/db/put-resource.js: -------------------------------------------------------------------------------- 1 | import * as resourceUtils from '/src/db/resource-utils.js'; 2 | import Connection from '/src/db/connection.js'; 3 | import assert from '/src/lib/assert.js'; 4 | import filterEmptyProperties from '/src/lib/filter-empty-properties.js'; 5 | 6 | export default function putResource(conn, resource) { 7 | return new Promise(executor.bind(this, conn, resource)); 8 | } 9 | 10 | function executor(conn, resource, resolve, reject) { 11 | assert(conn instanceof Connection); 12 | assert(resource && typeof resource === 'object'); 13 | assert(resourceUtils.isValidId(resource.id)); 14 | assert(resource.type === 'feed' || resource.type === 'entry'); 15 | assert(resource.type === 'entry' || (resource.urls && resource.urls.length)); 16 | assert(resource.type === 'feed' || resourceUtils.isValidId(resource.parent)); 17 | 18 | resourceUtils.normalize(resource); 19 | resourceUtils.sanitize(resource); 20 | resourceUtils.validate(resource); 21 | filterEmptyProperties(resource); 22 | resource.updated_date = new Date(); 23 | 24 | const transaction = conn.conn.transaction('resources', 'readwrite'); 25 | transaction.oncomplete = transactionOncomplete.bind(transaction, resource, conn.channel, resolve); 26 | transaction.onerror = event => reject(event.target.error); 27 | transaction.objectStore('resources').put(resource); 28 | } 29 | 30 | function transactionOncomplete(resource, channel, callback) { 31 | if (channel) { 32 | channel.postMessage({ 33 | type: 'resource-updated', 34 | id: resource.id, 35 | resourceType: resource.type 36 | }); 37 | } 38 | callback(resource); 39 | } 40 | -------------------------------------------------------------------------------- /src/lib/assert.js: -------------------------------------------------------------------------------- 1 | export class AssertionError extends Error { 2 | constructor(message = 'Assertion error') { 3 | super(message); 4 | } 5 | } 6 | 7 | export default function assert(condition, message) { 8 | if (!condition) { 9 | throw new AssertionError(message); 10 | } 11 | } 12 | 13 | // Return whether the error is equivalent to an assertion error 14 | export function isAssertError(error) { 15 | return error instanceof AssertionError || error instanceof ReferenceError; 16 | } 17 | -------------------------------------------------------------------------------- /src/lib/better-fetch.js: -------------------------------------------------------------------------------- 1 | import * as mime from '/src/lib/mime-utils.js'; 2 | import { Deadline, INDEFINITE } from '/src/lib/deadline.js'; 3 | import assert, { isAssertError } from '/src/lib/assert.js'; 4 | 5 | // Extends native fetch with a timeout, response type checking, explicit options set for privacy, 6 | // and translates the TypeError that native fetch throws when the network is unavailable into a 7 | // custom NetworkError error type. 8 | export async function betterFetch(url, options = {}) { 9 | assert(url instanceof URL); 10 | assert(options && typeof options === 'object'); 11 | 12 | const defaultOptions = { 13 | credentials: 'omit', 14 | method: 'get', 15 | mode: 'cors', 16 | cache: 'default', 17 | redirect: 'follow', 18 | referrer: 'no-referrer', 19 | referrerPolicy: 'no-referrer' 20 | }; 21 | 22 | const mergedOptions = Object.assign({}, defaultOptions, options); 23 | 24 | let timeout = INDEFINITE; 25 | if ('timeout' in mergedOptions) { 26 | // eslint-disable-next-line prefer-destructuring 27 | timeout = mergedOptions.timeout; 28 | assert(timeout instanceof Deadline); 29 | delete mergedOptions.timeout; 30 | } 31 | 32 | // Avoid passing a non-standard option to fetch 33 | let types; 34 | if (Array.isArray(mergedOptions.types)) { 35 | // eslint-disable-next-line prefer-destructuring 36 | types = mergedOptions.types; 37 | assert(Array.isArray(types)); 38 | delete mergedOptions.types; 39 | } 40 | 41 | const fetchPromise = fetch(url.href, mergedOptions); 42 | 43 | let response; 44 | try { 45 | if (timeout.isDefinite()) { 46 | response = await Promise.race([fetchPromise, timedResolve(timeout)]); 47 | } else { 48 | response = await fetchPromise; 49 | } 50 | } catch (error) { 51 | if (isAssertError(error)) { 52 | throw error; 53 | } else { 54 | // fetch throws a TypeError when offline (network unreachable), which we translate into a 55 | // network error. This should not be confused with a 404 error. 56 | throw new NetworkError(error.message); 57 | } 58 | } 59 | 60 | // response is defined when fetch wins the race, and undefined when timedResolve wins the race 61 | if (!response) { 62 | throw new TimeoutError(`Timed out trying to fetch ${url.href}`); 63 | } 64 | 65 | if (!response.ok) { 66 | const message = `${mergedOptions.method.toUpperCase()} ${url.href 67 | } failed with status ${response.status}`; 68 | throw new FetchError(message); 69 | } 70 | 71 | if (types && types.length) { 72 | const contentType = response.headers.get('Content-Type'); 73 | const mimeType = mime.parseContentType(contentType); 74 | if (mimeType && !types.includes(mimeType)) { 75 | const message = `Unacceptable type ${mimeType} for url ${url.href}`; 76 | throw new AcceptError(message); 77 | } 78 | } 79 | 80 | return response; 81 | } 82 | 83 | function timedResolve(delay = INDEFINITE) { 84 | return new Promise(resolve => setTimeout(resolve, delay.toInt())); 85 | } 86 | 87 | export class NetworkError extends Error { 88 | constructor(message = 'Unknown network error') { 89 | super(message); 90 | } 91 | } 92 | 93 | // This error indicates the response was not successful (returned something not in the [200-299] 94 | // status range). 95 | export class FetchError extends Error { 96 | constructor(message = 'Error fetching url') { 97 | super(message); 98 | } 99 | } 100 | 101 | // When the response is not acceptable 102 | export class AcceptError extends Error { 103 | constructor(message = 'Unacceptable response type') { 104 | super(message); 105 | } 106 | } 107 | 108 | // This error indicates a fetch operation took too long 109 | export class TimeoutError extends Error { 110 | constructor(message = 'Timeout') { 111 | super(message); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/lib/coerce-element.js: -------------------------------------------------------------------------------- 1 | import assert, { AssertionError } from '/src/lib/assert.js'; 2 | 3 | // Change an element from one element type to another by renaming it. Returns the new element that 4 | // replaced the old element, or the original element if no change was made (e.g. because the new 5 | // name is not different). 6 | // 7 | // Event listeners are not retained, and are not destroyed. 8 | // 9 | // No change is made when the coerced element is an orphan (when the coerced element has no 10 | // parentNode). 11 | // 12 | // Throws an assertion error if shallow validation of the new name fails, which indicates that the 13 | // new name is either null, undefined, an empty string, not a string, or has a space. This kind of 14 | // error indicates a programmer error as this function should never be called with such an argument. 15 | // This explicit check is not fully redundant with document.createElement's own checks, as this 16 | // check also prevents document.createElement(null) from creating an element with the name "null". 17 | // 18 | // Throws an assertion error if the runtime engine (e.g. v8) considers the tag name to be invalid. 19 | // For example, if the tag name is an unknown element that has invalid characters. See: 20 | // https://html.spec.whatwg.org/multipage/custom-elements.html#valid-custom-element-name 21 | export default function coerceElement(element, newName) { 22 | assert(element instanceof Element); 23 | 24 | // Very explicitly treat certain name inputs as programmer errors, even though this is redundant 25 | // with createElement's validation. 26 | assert(newName && typeof newName === 'string' && !newName.includes(' ')); 27 | 28 | if (element.localName === newName.toLowerCase()) { 29 | return element; 30 | } 31 | 32 | // Create the new element within the same document as the original element. Capture and translate 33 | // the dom exception that may occur if the name is invalid into a runtime assertion error. 34 | let newElement; 35 | try { 36 | newElement = element.ownerDocument.createElement(newName); 37 | } catch (error) { 38 | if (error instanceof DOMException) { 39 | throw new AssertionError(error.message); 40 | } else { 41 | // unknown error 42 | throw error; 43 | } 44 | } 45 | 46 | const parent = element.parentNode; 47 | if (!parent) { 48 | return element; 49 | } 50 | 51 | const { nextSibling } = element; 52 | 53 | element.remove(); 54 | 55 | const names = element.getAttributeNames(); 56 | for (const name of names) { 57 | newElement.setAttribute(name, element.getAttribute(name)); 58 | } 59 | 60 | // Move the child nodes provided the new element is not a void element. 61 | const voidElementLocalNames = [ 62 | 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 63 | 'param', 'source', 'track', 'wbr' 64 | ]; 65 | 66 | if (!voidElementLocalNames.includes(newElement.localName)) { 67 | // Because we already detached the old element and have not yet attached the new element and 68 | // both elements belong to the same document, there is no benefit to using a document fragment, 69 | // so we just move the nodes directly. 70 | for (let node = element.firstChild; node; node = element.firstChild) { 71 | newElement.append(node); 72 | } 73 | } 74 | 75 | return parent.insertBefore(newElement, nextSibling); 76 | } 77 | -------------------------------------------------------------------------------- /src/lib/css-parse-url.js: -------------------------------------------------------------------------------- 1 | // TODO: so this could obviously be much improved this is just very first horrible draft. 2 | // TODO: write the regex so as to properly exclude quotes instead of doing a second pass 3 | // TODO: minimally validate the url before output, e.g. do not produce obviously invalid urls like 4 | // a value containing an intermediate space, or trailing space, or invalid characters 5 | // TODO: match multiple urls 6 | // TODO: maybe revise as something like parseCSSBackgroundImagePropertyValue, maybe at least rename 7 | // it to something similar 8 | 9 | 10 | // The parse function expects as input a string representing the raw value of a css property, such 11 | // as the property value in the following CSS property expression: 12 | // background-image: url("http://www.example.com"). This looks for what is in those parens, less 13 | // quotes, and returns that string. 14 | // 15 | // Currently ignores multiple urls. Does no validation of quotes. Does no validation of the url. 16 | export default function parse(cssText) { 17 | const pattern = /url\(\s*([^\s)]+)\s*\)/ig; 18 | const matches = pattern.exec(cssText); 19 | 20 | // match 0 is the full match. match 1 is the first subgroup. 21 | 22 | if (matches && matches.length > 1) { 23 | let result = matches[1]; 24 | 25 | // HACK: rather than think about how to write the regex to exclude quotes this just strips 26 | // them in a second pass. Also note, this strips them from anywhere, as in, including middle 27 | // ones. 28 | result = result.replace(/["']/g, ''); 29 | 30 | return result; 31 | } 32 | 33 | return undefined; 34 | } 35 | -------------------------------------------------------------------------------- /src/lib/deadline.js: -------------------------------------------------------------------------------- 1 | import assert from '/src/lib/assert.js'; 2 | 3 | // A deadline represents the latest time by which something should be completed, such as a timeout 4 | // value. A deadline can also represent an initial delay before starting something. 5 | export class Deadline { 6 | // value should be a positive integer representing an amount of time in milliseconds 7 | constructor(value) { 8 | assert(Number.isInteger(value)); 9 | assert(value >= 0); 10 | 11 | this.value = value; 12 | } 13 | 14 | isDefinite() { 15 | return this.value > 0; 16 | } 17 | 18 | toInt() { 19 | return this.value; 20 | } 21 | 22 | toString() { 23 | return `${this.value}`; 24 | } 25 | } 26 | 27 | export const INDEFINITE = new Deadline(0); 28 | -------------------------------------------------------------------------------- /src/lib/dom-filters/css-background-image-filter.js: -------------------------------------------------------------------------------- 1 | import cssParseURL from '/src/lib/css-parse-url.js'; 2 | 3 | // Look for elements in the document that have a background image specified by a style attribute and 4 | // no descendant foreground image. For such elements, remove the background image property from the 5 | // style attribute and append a child foreground image with the same url. 6 | // 7 | // This currently takes a very simplified view of how an element's background image is specified. 8 | // This assumes an element has only one background image specified. This assumes the image is not 9 | // obscured. This assumes css properties are not inherited because getComputedStyle is unavailable 10 | // since this assumes the document is inert. This assumes the background image is specified by the 11 | // backgroundImage property and not the background property. 12 | export default function filterDocument(document) { 13 | const elements = document.querySelectorAll('[style]'); 14 | for (const element of elements) { 15 | if (element.style && element.style.backgroundImage && !element.querySelector('img')) { 16 | const url = cssParseURL(element.style.backgroundImage); 17 | if (url) { 18 | // Remove original style information from the parent 19 | element.style.backgroundImage = ''; 20 | if (!element.style.length) { 21 | element.removeAttribute('style'); 22 | } 23 | 24 | // Introduce a new child image element (arbitrarily as last element) 25 | const foregroundImage = document.createElement('img'); 26 | foregroundImage.setAttribute('src', url); 27 | element.append(foregroundImage); 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/lib/dom-filters/frameset.js: -------------------------------------------------------------------------------- 1 | // Removes frame-related content from a document, including frameset, frame, and noframes, but 2 | // excluding iframes. The default message is appended to the body element when applying this filter 3 | // results in producing an otherwise empty body element (when frames actually removed). 4 | export default function transform(document, defaultMessage = 'Framed content not supported') { 5 | const framesetElement = document.querySelector('frameset'); 6 | 7 | // Ensure nothing frame-related is left even without a frameset and regardless of location in the 8 | // hierarchy. 9 | if (!framesetElement) { 10 | const elements = document.querySelectorAll('frame, noframes'); 11 | for (const element of elements) { 12 | element.remove(); 13 | } 14 | return; 15 | } 16 | 17 | // If there is a frameset, first look for an existing body element. Do not use the document.body 18 | // shortcut because it will match frameset. This is trying to handle the malformed html case. If 19 | // there is a body, clear it out so that it is setup for reuse. If there is no body, create one in 20 | // replace of the original frameset. 21 | let bodyElement = document.querySelectorAll('body'); 22 | if (bodyElement) { 23 | // There is both a frameset and a body, which is malformed html. Keep the body and pitch the 24 | // frameset. 25 | framesetElement.remove(); 26 | 27 | // If a body element existed in addition to the frameset element, clear it out. This is 28 | // malformed html. 29 | let child = bodyElement.firstChild; 30 | while (child) { 31 | bodyElement.removeChild(child); 32 | child = bodyElement.firstChild; 33 | } 34 | } else { 35 | // There is a frameset and there is no body element. Removing the frameset will leave the 36 | // document without a body. Since we have a frameset and no body, create a new body element in 37 | // place of the frameset. This will detach the existing frameset. This assumes there is only one 38 | // frameset. 39 | bodyElement = document.createElement('body'); 40 | 41 | const newChild = bodyElement; 42 | const oldChild = framesetElement; 43 | document.documentElement.replaceChild(newChild, oldChild); 44 | } 45 | 46 | // noframes, if present, should be nested within frameset in well-formed html. Now look for 47 | // noframes elements within the detached frameset, and if found, move their contents into the body 48 | // element. I am not sure if there should only be one noframes element or multiple are allowed, so 49 | // just look for all. 50 | const noframesElements = framesetElement.querySelectorAll('noframes'); 51 | for (const noframesElement of noframesElements) { 52 | for (let node = noframesElement.firstChild; node; node = noframesElement.firstChild) { 53 | bodyElement.append(node); 54 | } 55 | } 56 | 57 | // Ensure nothing frame related remains, as a minimal guarantee, given the possibility of 58 | // malformed html. This also handles the multiple framesets malformed case. 59 | const elements = document.querySelectorAll('frame, frameset, noframes'); 60 | for (const element of elements) { 61 | element.remove(); 62 | } 63 | 64 | // Avoid producing an empty body without an explanation. Note that we know something frame-related 65 | // happened because we would have exited earlier without a frameset, so this is not going to 66 | // affect to the empty-body case in a frameless document. 67 | if (!bodyElement.firstChild) { 68 | bodyElement.append(defaultMessage); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/lib/dom-filters/lazily-loaded-images.js: -------------------------------------------------------------------------------- 1 | import * as imageUtils from '/src/lib/image-utils.js'; 2 | 3 | // Popular names of alternate image element attributes used for lazy-loading. This is not an 4 | // exhaustive list. This list should be extended as needed. 5 | const lazyAttributeNames = [ 6 | 'big-src', 'load-src', 'data-src', 'data-src-full16x9', 'data-src-large', 'data-original-desktop', 7 | 'data-baseurl', 'data-flickity-lazyload', 'data-lazy', 'data-path', 'data-image-src', 8 | 'data-original', 'data-adaptive-image', 'data-imgsrc', 'data-default-src', 'data-hi-res-src' 9 | ]; 10 | 11 | // Scans the document for image elements that are missing a conventional source url but contain a 12 | // non-standard alternative attribute indicating the image's source url. For each qualifying image, 13 | // the filter sets the image's source url to the alternative url and removes the alternative 14 | // attribute. 15 | // 16 | // This filter ignores srcset descriptors. That is a concern of a different filter. Here, specifying 17 | // an image's srcset attribute does not matter with regard to whether an image is considered lazy. 18 | // 19 | // This filter should occur before canonicalizing urls, because it may set attributes that need to 20 | // be canonicalized that previously did not exist. 21 | export default function transform(document) { 22 | const images = document.querySelectorAll('img'); 23 | for (const image of images) { 24 | if (!imageUtils.imageHasSource(image)) { 25 | const attributeNames = image.getAttributeNames(); 26 | for (const name of lazyAttributeNames) { 27 | if (attributeNames.includes(name)) { 28 | const value = image.getAttribute(name); 29 | if (isValidURLString(value)) { 30 | image.removeAttribute(name); 31 | image.setAttribute('src', value); 32 | break; 33 | } 34 | } 35 | } 36 | } 37 | } 38 | } 39 | 40 | // Very minimal validation, the value just has to "look like" a url 41 | function isValidURLString(value) { 42 | return typeof value === 'string' && value.length > 1 && value.length <= 3000 && 43 | !value.trim().includes(' '); 44 | } 45 | -------------------------------------------------------------------------------- /src/lib/dom-filters/misnested-elements.js: -------------------------------------------------------------------------------- 1 | import unwrapElement from '/src/lib/unwrap-element.js'; 2 | 3 | // Searches the document for misnested elements and tries to fix each occurrence. 4 | export default function removeMisnestedElements(document) { 5 | // Remove horizontal rules embedded within lists 6 | const hrsWithinLists = document.querySelectorAll('ul > hr, ol > hr, dl > hr'); 7 | for (const hr of hrsWithinLists) { 8 | hr.remove(); 9 | } 10 | 11 | // Remove invalid double anchors 12 | const nestedAnchors = document.querySelectorAll('a a'); 13 | for (const descendantAnchor of nestedAnchors) { 14 | unwrapElement(descendantAnchor); 15 | } 16 | 17 | // Remove all captions outside of figure 18 | const captions = document.querySelectorAll('figcaption'); 19 | for (const caption of captions) { 20 | if (!caption.parentNode.closest('figure')) { 21 | caption.remove(); 22 | } 23 | } 24 | 25 | // Remove all source elements not located within an expected ancestor. 26 | const sources = document.querySelectorAll('source'); 27 | for (const source of sources) { 28 | if (!source.parentNode.closest('audio, picture, video')) { 29 | source.remove(); 30 | } 31 | } 32 | 33 | // display-block within display-block-inline 34 | const blockSelector = 'blockquote, h1, h2, h3, h4, h5, h6, p'; 35 | const inlineSelector = 'a, span, b, strong, i'; 36 | 37 | const blocks = document.querySelectorAll(blockSelector); 38 | for (const block of blocks) { 39 | const ancestor = block.closest(inlineSelector); 40 | if (ancestor && ancestor.parentNode) { 41 | ancestor.parentNode.insertBefore(block, ancestor); 42 | for (let node = block.firstChild; node; node = block.firstChild) { 43 | ancestor.append(node); 44 | } 45 | block.append(ancestor); 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/lib/dom-filters/over-emphasis.js: -------------------------------------------------------------------------------- 1 | import assert from '/src/lib/assert.js'; 2 | import unwrapElement from '/src/lib/unwrap-element.js'; 3 | 4 | // Removes the emphasis from emphasized content that contains too many characters. Emphasis should 5 | // be used to differentiate a small piece of content from the rest of the content. When there is too 6 | // much text the emphasis becomes stylistic, the value the emphasis provides is reduced, and the 7 | // text becomes difficult to read. This filter's goal is to increase readability. 8 | // 9 | // The threshold parameter represents the minimum number of characters (exclusive) of text before 10 | // the text is considered over-emphasized. Note that whitespace is condensed before comparing text 11 | // length against the threshold. The parameter defaults to a reasonably big number. In practice this 12 | // parameter should have a value between 200 and 1000. 13 | export default function filter(document, threshold = 500) { 14 | assert(typeof threshold === 'number'); 15 | assert(Number.isInteger(threshold)); 16 | assert(threshold > 0); 17 | 18 | // Secretly fix redundant nesting and multimodal emphasis 19 | // TODO: revisit whether this belongs here or in some other filter 20 | const nestedSelector = 'strong strong, strong b, b strong, b b, u u, u em, em u, em em'; 21 | const redundantChildElements = document.querySelectorAll(nestedSelector); 22 | for (const element of redundantChildElements) { 23 | unwrapElement(element); 24 | } 25 | 26 | const emphasizedSelector = 'b, big, em, i, strong, mark, u'; 27 | const emphasizedElements = document.querySelectorAll(emphasizedSelector); 28 | for (const element of emphasizedElements) { 29 | const adjustedTextContent = element.textContent.replace(/\s+/, ''); 30 | if (adjustedTextContent.length > threshold) { 31 | unwrapElement(element); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/lib/dom-filters/remove-empty-attributes.js: -------------------------------------------------------------------------------- 1 | // Adapted from https://github.com/kangax/html-minifier/issues/63 2 | const booleanAttributeNames = [ 3 | 'allowfullscreen', 'async', 'autofocus', 'autoplay', 'checked', 'compact', 'controls', 'declare', 4 | 'default', 'defaultchecked', 'defaultmuted', 'defaultselected', 'defer', 'disabled', 'draggable', 5 | 'enabled', 'formnovalidate', 'hidden', 'indeterminate', 'inert', 'ismap', 'itemscope', 'loop', 6 | 'multiple', 'muted', 'nohref', 'noresize', 'noshade', 'novalidate', 'nowrap', 'open', 7 | 'pauseonexit', 'readonly', 'required', 'reversed', 'scoped', 'seamless', 'selected', 'sortable', 8 | 'spellcheck', 'translate', 'truespeed', 'typemustmatch', 'visible' 9 | ]; 10 | 11 | export default function removeEmptyAttributes(document) { 12 | const elements = document.querySelectorAll('*'); 13 | for (const element of elements) { 14 | const names = element.getAttributeNames(); 15 | for (const name of names) { 16 | if (!booleanAttributeNames.includes(name)) { 17 | const value = element.getAttribute(name); 18 | if (!value || !value.trim()) { 19 | element.removeAttribute(name); 20 | } 21 | } 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/lib/dom-filters/responsive-images.js: -------------------------------------------------------------------------------- 1 | import * as srcsetUtils from '/src/lib/srcset-utils.js'; 2 | 3 | // Set the src/width/height attributes for images that only provide srcset 4 | export default function transform(document) { 5 | const images = document.querySelectorAll('img[srcset]:not([src])'); 6 | for (const image of images) { 7 | transformImage(image); 8 | } 9 | } 10 | 11 | function transformImage(image) { 12 | const descriptors = srcsetUtils.parse(image.getAttribute('srcset')); 13 | const chosenDescriptor = chooseDescriptor(descriptors); 14 | 15 | if (!chosenDescriptor) { 16 | return; 17 | } 18 | 19 | // To prevent skew we have to remove the original dimensions 20 | image.removeAttribute('width'); 21 | image.removeAttribute('height'); 22 | 23 | image.removeAttribute('srcset'); 24 | 25 | image.setAttribute('src', chosenDescriptor.url); 26 | if (chosenDescriptor.w) { 27 | image.setAttribute('width', `${chosenDescriptor.w}`); 28 | } 29 | if (chosenDescriptor.h) { 30 | image.setAttribute('height', `${chosenDescriptor.h}`); 31 | } 32 | } 33 | 34 | // Select the most appropriate descriptor from the set of descriptors. This is not a spec compliant 35 | // implementation. This is just a simple implementation that works quickly. Is not guaranteed to 36 | // return a descriptor. 37 | function chooseDescriptor(descriptors) { 38 | let chosenDescriptor = null; 39 | for (const desc of descriptors) { 40 | if (desc.url) { 41 | if (desc.w || desc.h) { 42 | chosenDescriptor = desc; 43 | break; 44 | } else if (!chosenDescriptor) { 45 | chosenDescriptor = desc; // continue searching 46 | } 47 | } 48 | } 49 | return chosenDescriptor; 50 | } 51 | -------------------------------------------------------------------------------- /src/lib/dom-filters/set-all-image-element-dimensions.js: -------------------------------------------------------------------------------- 1 | import { Deadline, INDEFINITE } from '/src/lib/deadline.js'; 2 | import assert, { isAssertError } from '/src/lib/assert.js'; 3 | import fetchImageElement from '/src/lib/fetch-image-element.js'; 4 | import getPathExtension from '/src/lib/get-path-extension.js'; 5 | 6 | // Asynchronously tries to set width and height attributes for all images in the document. 7 | // 8 | // In order to minimize network activity this checks for whether the reachability filter has run 9 | // by looking for whether the reachability filter left behind information about images. 10 | // 11 | // This should be run after any telemetry filter because this does network requests which expose 12 | // presence. 13 | // 14 | // Assumes the document has a valid base uri. 15 | export default function setAllImageElementDimensions(doc, timeout = INDEFINITE) { 16 | assert(doc instanceof Document); 17 | assert(timeout instanceof Deadline); 18 | 19 | // Concurrently traverse and possibly update each image. 20 | const images = doc.querySelectorAll('img'); 21 | const promises = []; 22 | for (const image of images) { 23 | promises.push(processImage(image, timeout)); 24 | } 25 | // Return a promise that resolves when all images have been processed. 26 | return Promise.all(promises); 27 | } 28 | 29 | // Attempt to set the width and height of an image element. This first uses some basic heuristics, 30 | // and if necessary, falls back to doing a network request. If everything fails then the image is 31 | // left as is. 32 | async function processImage(image, timeout) { 33 | if (image.hasAttribute('width') && image.hasAttribute('height')) { 34 | return; 35 | } 36 | 37 | // Check for whether the reachability filter has run 38 | if (image.hasAttribute('data-reachable-width') && image.hasAttribute('data-reachable-height')) { 39 | image.setAttribute('width', image.getAttribute('data-reachable-width')); 40 | image.setAttribute('height', image.getAttribute('data-reachable-height')); 41 | return; 42 | } 43 | 44 | // When the image is missing width or height attributes, is inert or live, but has a style 45 | // attribute specifying width and height, then the width and height properties are initialized 46 | // through the CSS. There is no need to manually parse the css style properties because that was 47 | // done when the document was created. This heuristic only works for inline style, not dimensions 48 | // specified via a style element or a linked stylesheet. 49 | if (image.width > 0 && image.height > 0) { 50 | image.setAttribute('width', image.width); 51 | image.setAttribute('height', image.height); 52 | return; 53 | } 54 | 55 | if (!image.src) { 56 | return; 57 | } 58 | 59 | let url; 60 | try { 61 | // When accessing by property, this uses document.baseURI 62 | url = new URL(image.src); 63 | } catch (error) { 64 | return; 65 | } 66 | 67 | // Check characters in url 68 | const pairs = [{ w: 'w', h: 'h' }, { w: 'width', h: 'height' }]; 69 | const exts = ['jpg', 'gif', 'svg', 'bmp', 'png']; 70 | if (url.protocol !== 'data:' && exts.includes(getPathExtension(url.pathname))) { 71 | for (const pair of pairs) { 72 | const width = parseInt(url.searchParams.get(pair.w), 10); 73 | const height = parseInt(url.searchParams.get(pair.h), 10); 74 | if (width > 0 && height > 0) { 75 | image.setAttribute('width', width); 76 | image.setAttribute('height', height); 77 | return; 78 | } 79 | } 80 | } 81 | 82 | try { 83 | const fetchedImage = await fetchImageElement(url, timeout); 84 | image.setAttribute('width', fetchedImage.width); 85 | image.setAttribute('height', fetchedImage.height); 86 | } catch (error) { 87 | if (isAssertError(error)) { 88 | throw error; 89 | } else { 90 | // Ignore 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/lib/dom-filters/single-item-lists.js: -------------------------------------------------------------------------------- 1 | import unwrapElement from '/src/lib/unwrap-element.js'; 2 | 3 | // Scan the document for lists that contain either no elements or only a single list item element. 4 | // If such a list is found, then unwrap the list by moving its single item's content into the 5 | // position before the list element itself and then removing the list element. 6 | export default function filter(document) { 7 | const listElements = document.querySelectorAll('ul, ol, dl'); 8 | for (const list of listElements) { 9 | if (isEmptyOrSingleItemList(list)) { 10 | unwrapElement(list); 11 | } 12 | } 13 | } 14 | 15 | // Returns whether the list is empty or has only one child list item element. A list is empty if 16 | // it either has no nodes at all, or has child nodes but no elements. The meaning of empty here 17 | // is only for purposes of deciding whether to unwrap. This could be more terse but I find the logic 18 | // to get confusing when written that way. This is simple to maintain. 19 | function isEmptyOrSingleItemList(list) { 20 | // The list is empty if it has no child nodes. firstChild is undefined in that case. 21 | if (!list.firstChild) { 22 | return true; 23 | } 24 | 25 | // firstElementChild is undefined when there are no child elements. If the list has child nodes, 26 | // but not child elements, then consider the list virtually empty. 27 | if (!list.firstElementChild) { 28 | return true; 29 | } 30 | 31 | // The list has one or more child nodes and elements. If the first element has a sibling element 32 | // then the list is not a singleton. 33 | if (list.firstElementChild.nextElementSibling) { 34 | return false; 35 | } 36 | 37 | // There is exactly one child element of the list. If the one child element is a list item 38 | // element then the list is a singleton. If the one child element is not a list item element, then 39 | // it is some intermediate kind of element (like a nested form), so we cannot be sure, so treat 40 | // as not singleton. 41 | // 42 | // NOTE: we do not care about mismatch between definition lists and ordered/unordered lists such 43 | // as (e.g.
    ). In general, this is just harmless author error that we tolerate. 44 | const listItemElementNames = ['dd', 'dt', 'li']; 45 | return listItemElementNames.includes(list.firstElementChild.localName); 46 | } 47 | -------------------------------------------------------------------------------- /src/lib/dom-filters/url-resolver.js: -------------------------------------------------------------------------------- 1 | import * as srcsetUtils from '/src/lib/srcset-utils.js'; 2 | 3 | // TODO: somehow also do the srcset resolution in the first pass, e.g. store * as value in 4 | // elementAttributeMap and that means it is special handling 5 | 6 | // A mapping between names of elements and names of attributes that have urls 7 | const elementAttributeMap = { 8 | a: 'href', 9 | applet: 'codebase', 10 | area: 'href', 11 | audio: 'src', 12 | base: 'href', 13 | blockquote: 'cite', 14 | body: 'background', 15 | button: 'formaction', 16 | del: 'cite', 17 | embed: 'src', 18 | frame: 'src', 19 | head: 'profile', 20 | html: 'manifest', 21 | iframe: 'src', 22 | form: 'action', 23 | img: 'src', 24 | input: 'src', 25 | ins: 'cite', 26 | link: 'href', 27 | 'model-viewer': 'src', 28 | object: 'data', 29 | q: 'cite', 30 | script: 'src', 31 | source: 'src', 32 | track: 'src', 33 | video: 'src' 34 | }; 35 | 36 | // Resolves all element attribute values that contain urls in document. Assumes the document has a 37 | // valid base uri. 38 | export default function filter(document) { 39 | const baseURL = new URL(document.baseURI); 40 | 41 | // In the first pass, select all mapped elements present anywhere in the document, and resolve 42 | // attribute values per element 43 | const keys = Object.keys(elementAttributeMap); 44 | const selector = keys.map(key => `${key}[${elementAttributeMap[key]}]`).join(','); 45 | const elements = document.querySelectorAll(selector); 46 | 47 | for (const element of elements) { 48 | const attributeName = elementAttributeMap[element.localName]; 49 | if (attributeName) { 50 | const attributeValue = element.getAttribute(attributeName); 51 | if (attributeValue) { 52 | try { 53 | const url = new URL(attributeValue, baseURL); 54 | if (url.href !== attributeValue) { 55 | element.setAttribute(attributeName, url.href); 56 | } 57 | } catch (error) { 58 | // Ignore 59 | } 60 | } 61 | } 62 | } 63 | 64 | // In the second pass, resolve srcset attributes 65 | const srcsetSelector = 'img[srcset], source[srcset]'; 66 | const srcsetElements = document.querySelectorAll(srcsetSelector); 67 | for (const element of srcsetElements) { 68 | const descriptors = srcsetUtils.parse(element.getAttribute('srcset')); 69 | 70 | let changeCount = 0; 71 | for (const desc of descriptors) { 72 | try { 73 | const url = new URL(desc.url, baseURL); 74 | if (url.href.length !== desc.url.length) { 75 | desc.url = url.href; 76 | changeCount += 1; 77 | } 78 | } catch (error) { 79 | // Ignore 80 | } 81 | } 82 | 83 | if (changeCount) { 84 | const newValue = srcsetUtils.serialize(descriptors); 85 | if (newValue) { 86 | element.setAttribute('srcset', newValue); 87 | } 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/lib/download-xml-document.js: -------------------------------------------------------------------------------- 1 | // Given an opml document, converts it into a file and then triggers the download of that file in 2 | // the browser. 3 | export default function downloadXMLDocument(xmlDocument, fileName) { 4 | const serializer = new XMLSerializer(); 5 | const xmlString = serializer.serializeToString(xmlDocument); 6 | const blob = new Blob([xmlString], { type: 'application/xml' }); 7 | 8 | const anchor = document.createElement('a'); 9 | anchor.setAttribute('download', fileName || ''); 10 | const url = URL.createObjectURL(blob); 11 | anchor.setAttribute('href', url); 12 | anchor.click(); 13 | URL.revokeObjectURL(url); 14 | } 15 | -------------------------------------------------------------------------------- /src/lib/fade-element.js: -------------------------------------------------------------------------------- 1 | export default function fadeElement(element, durationSecs, delaySecs) { 2 | return new Promise((resolve, reject) => { 3 | if (!element) { 4 | return reject(new Error(`Invalid element ${element}`)); 5 | } 6 | 7 | if (!element.style) { 8 | return reject(new Error('Cannot fade element without a style property')); 9 | } 10 | 11 | durationSecs = isNaN(durationSecs) ? 1 : durationSecs; 12 | delaySecs = isNaN(delaySecs) ? 0 : delaySecs; 13 | 14 | const { style } = element; 15 | if (style.display === 'none') { 16 | // If the element is hidden, it may not have an opacity set. When fading in the element by 17 | // setting opacity to 1, it has to change from 0 to work. 18 | style.opacity = '0'; 19 | 20 | // If the element is hidden, and its opacity is 0, make it eventually visible 21 | style.display = 'block'; 22 | } else { 23 | // If the element is visible, and we plan to hide it by setting its opacity to 0, it has to 24 | // change from opacity 1 for fade to work 25 | style.opacity = '1'; 26 | } 27 | 28 | element.addEventListener('webkitTransitionEnd', resolve, { once: true }); 29 | style.transition = `opacity ${durationSecs}s ease ${delaySecs}s`; 30 | style.opacity = style.opacity === '1' ? '0' : '1'; 31 | 32 | return undefined; 33 | }); 34 | } 35 | -------------------------------------------------------------------------------- /src/lib/fetch-html.js: -------------------------------------------------------------------------------- 1 | import { betterFetch } from '/src/lib/better-fetch.js'; 2 | 3 | export default function fetchHTML(url, options = {}) { 4 | // We may be modifying options, so clone to avoid side effects 5 | // TODO: use the destructuring clone technique over Object.assign per AirBnB style guide 6 | const opts = Object.assign({}, options); 7 | 8 | const types = ['text/html']; 9 | if (opts.allowText) { 10 | types.push('text/plain'); 11 | // Delete non-standard options in case the eventual native call to fetch would barf 12 | delete opts.allowText; 13 | } 14 | opts.types = types; 15 | 16 | return betterFetch(url, opts); 17 | } 18 | -------------------------------------------------------------------------------- /src/lib/fetch-image-element.js: -------------------------------------------------------------------------------- 1 | import { Deadline, INDEFINITE } from '/src/lib/deadline.js'; 2 | import { FetchError, NetworkError, TimeoutError } from '/src/lib/better-fetch.js'; 3 | import assert from '/src/lib/assert.js'; 4 | 5 | // Return a promise that resolves to an image element. 6 | // 7 | // @param url {URL} the url of the image to fetch 8 | // @param timeout {Deadline} the maximum time, in milliseconds, to wait before considering the fetch 9 | // to take too long. optional. if not specified or indefinite (0) then no timeout is imposed. 10 | // 11 | // Throws various errors such as bad inputs, unable to fetch, able to fetch but no image found, 12 | // operation took too long 13 | export default async function fetchImageElement(url, timeout = INDEFINITE) { 14 | assert(url instanceof URL); 15 | assert(timeout instanceof Deadline); 16 | 17 | // Try to behave in a manner similar to better-fetch, which throws a network error when unable to 18 | // fetch (e.g. no network (offline)). This is different than being online and pinging a url that 19 | // does not exist (a 404 error). 20 | if (!navigator.onLine) { 21 | throw new NetworkError(`Failed to fetch ${url.href}`); 22 | } 23 | 24 | const fetchPromise = new Promise((resolve, reject) => { 25 | const proxy = new Image(); 26 | proxy.src = url.href; 27 | 28 | // If the image is in the browser's cache, then its dimensions are already known, and its width 29 | // and height properties will already be initialized. complete is true at least in certain 30 | // browsers in this case, so there is no need to wait until the image is loaded. 31 | if (proxy.complete) { 32 | resolve(proxy); 33 | return; 34 | } 35 | 36 | proxy.onload = () => resolve(proxy); 37 | 38 | // The error produced by the browser is opaque, uninformative, and all around useless, so we 39 | // substitute in an error that is informative. 40 | proxy.onerror = () => reject(new FetchError(`Failed to fetch ${url.href}`)); 41 | }); 42 | 43 | let image; 44 | if (timeout.isDefinite()) { 45 | image = await Promise.race([fetchPromise, timedResolve(timeout)]); 46 | } else { 47 | image = await fetchPromise; 48 | } 49 | 50 | if (!image) { 51 | throw new TimeoutError(`Timed out fetching ${url.href}`); 52 | } 53 | 54 | return image; 55 | } 56 | 57 | // Caution: using 0 means indefinite under the Deadline scheme, which suggests a promise that never 58 | // resolves, but this will merrily trigger a near immediate timer using 0 (depending on the implicit 59 | // per-browser minimum delay) that resolves very quickly. The caller should be careful of this 60 | // counter intuitive design. 61 | function timedResolve(delay) { 62 | return new Promise(resolve => setTimeout(resolve, delay.toInt())); 63 | } 64 | -------------------------------------------------------------------------------- /src/lib/filter-controls.js: -------------------------------------------------------------------------------- 1 | export default function filterControls(value) { 2 | return value.replace(/[\x00-\x1F\x7F-\x9F]+/g, ''); 3 | } 4 | -------------------------------------------------------------------------------- /src/lib/filter-empty-properties.js: -------------------------------------------------------------------------------- 1 | export default function filterEmptyProperties(value) { 2 | if (!value) { 3 | return; 4 | } 5 | 6 | if (typeof value !== 'object') { 7 | return; 8 | } 9 | 10 | const keys = Object.keys(value); 11 | for (const key of keys) { 12 | const pv = value[key]; 13 | if (pv === null || pv === '' || pv === undefined) { 14 | delete value[key]; 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/lib/filter-publisher.js: -------------------------------------------------------------------------------- 1 | import assert from '/src/lib/assert.js'; 2 | 3 | // Returns a new string where the publisher information has been stripped. For example, in the 4 | // string "Florida man shoots self - Your Florida News", the algorithm would hopefully identify the 5 | // publisher as "Your Florida news" and then return the string "Florida man shoots self" where the 6 | // publisher has been filtered. delimiters is an optional array of delimiting characters that split 7 | // the title between content and publisher. minTitleLength is a threshold below which any filtering 8 | // is rejected. 9 | export default function filterPublisher(title, delimiters, minTitleLength) { 10 | assert(typeof title === 'string'); 11 | 12 | const defaultDelimiters = ['-', '|', ':']; 13 | if (!Array.isArray(delimiters)) { 14 | delimiters = defaultDelimiters; 15 | } 16 | 17 | const defaultMinTitleLength = 20; 18 | if (isNaN(minTitleLength)) { 19 | minTitleLength = defaultMinTitleLength; 20 | } else { 21 | assert(minTitleLength >= 0); 22 | } 23 | 24 | if (title.length < minTitleLength) { 25 | return title; 26 | } 27 | 28 | if (delimiters.length < 1) { 29 | return title; 30 | } 31 | 32 | const tokens = tokenizeWords(title); 33 | 34 | // Basically just assume there is no point to looking if we are only dealing with 3 tokens or 35 | // less. This is a tentative conclusion. Note that delimiters are individual tokens here, and 36 | // multiple consecutive delimiters will constitute only one token. Note that this also implicitly 37 | // handles the 0 tokens case. 38 | if (tokens.length < 4) { 39 | return title; 40 | } 41 | 42 | let delimiterIndex = -1; 43 | for (let i = tokens.length - 2; i > -1; i -= 1) { 44 | const token = tokens[i]; 45 | if (delimiters.includes(token)) { 46 | delimiterIndex = i; 47 | break; 48 | } 49 | } 50 | 51 | if (delimiterIndex === -1) { 52 | return title; 53 | } 54 | 55 | // Regardless of the number of words in the full title, if the publisher we find has too many 56 | // words, the delimiter probably did not delimit the publisher. 57 | if (tokens.length - delimiterIndex - 1 > 5) { 58 | return title; 59 | } 60 | 61 | // If there are more publisher words than non-publisher words in the title, then we should not 62 | // filter out the publisher, because this indicates a false positive identification of the 63 | // delimiter, most of the time, empirically. 64 | const nonPublisherWordCount = delimiterIndex; 65 | const publisherWordCount = tokens.length - delimiterIndex - 1; 66 | if (nonPublisherWordCount < publisherWordCount) { 67 | return title; 68 | } 69 | 70 | const nonPublisherTokens = tokens.slice(0, delimiterIndex); 71 | return nonPublisherTokens.join(' '); 72 | } 73 | 74 | // Split a string into smaller strings based on intermediate whitespace. Throws an error if string 75 | // is not a String object. Returns an array. 76 | function tokenizeWords(string) { 77 | // The implicit trim avoids producing empty tokens. The input might already be trimmed but we 78 | // cannot rely on that so we have to accept the overhead. 79 | return string.trim().split(/\s+/g); 80 | } 81 | -------------------------------------------------------------------------------- /src/lib/filter-unprintables.js: -------------------------------------------------------------------------------- 1 | export default function filterUnprintables(value) { 2 | // \t \u0009 9, \n \u000a 10, \f \u000c 12, \r \u000d 13 3 | // The regex matches 0-8, 11, and 14-31, all inclusive 4 | return value.replace(/[\u0000-\u0008\u000b\u000e-\u001F]+/g, ''); 5 | } 6 | -------------------------------------------------------------------------------- /src/lib/format-date.js: -------------------------------------------------------------------------------- 1 | // Return a date as a formatted string. This is an opinionated implementation that is intended to be 2 | // very simple. This tries to recover from errors and not throw. 3 | export default function formatDate(date) { 4 | if (!(date instanceof Date)) { 5 | return 'Invalid date'; 6 | } 7 | 8 | // When using native date parsing and encountering an error, rather than throw that error, a 9 | // date object is created with a NaN time property. Which would be ok but the format call below 10 | // then throws if the time property is NaN 11 | if (isNaN(date.getTime())) { 12 | return 'Invalid date'; 13 | } 14 | 15 | // The try/catch is just paranoia for now. This previously threw when date contained time NaN. 16 | const formatter = new Intl.DateTimeFormat(); 17 | try { 18 | return formatter.format(date); 19 | } catch (error) { 20 | console.debug(error); 21 | return 'Invalid date'; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/lib/get-path-extension.js: -------------------------------------------------------------------------------- 1 | // Find and return the extension in a path. maxExtensionLength is an optional integer that defaults 2 | // to 10. Return undefined if unable to find extension, the found extension is too long, or the 3 | // found extension is not alphanumeric. Undefined behavior for invalid input. 4 | export default function getPathExtension(path, maxExtensionLength = 10) { 5 | // All (valid) paths start with a /. The shortest valid path that has an extension is "/.a", which 6 | // is 3 characters. Therefore, bail if too short. It is better to explicitly check here then start 7 | // doing character searches. 8 | 9 | // This also implicitly bails on empty string case. It is important to bail on the empty string 10 | // case to deal with lastIndexOf returning its second argument when the first is empty. Although, 11 | // this is not an issue at the moment given the filename workaround. 12 | if (path.length < 3) { 13 | return undefined; 14 | } 15 | 16 | // We want to find the last position of the period only after the last slash, to avoid matching 17 | // periods preceding slashes, so we start by finding the last slash. Start from the beginning of 18 | // the string because the leading slash may be the only slash. 19 | const slashIndex = path.lastIndexOf('/'); 20 | 21 | // All valid paths begin with a slash, but we do not know if the path is valid, so we might not 22 | // see a slash, which we tolerate rather than treat as programmer error. 23 | if (slashIndex < 0) { 24 | return undefined; 25 | } 26 | 27 | const filename = path.substring(slashIndex + 1); 28 | const dotIndex = filename.lastIndexOf('.'); 29 | 30 | if (dotIndex < 0) { 31 | return undefined; 32 | } 33 | 34 | // Before grabbing the extension string, calculate its length, the -1 is to account for the period 35 | // itself. 36 | const extensionLength = filename.length - dotIndex - 1; 37 | 38 | // When the filename ends with a period, the extension length is 0. Avoid treating this as a 39 | // found extension. 40 | if (!extensionLength) { 41 | return undefined; 42 | } 43 | 44 | // bail if extension has too many characters, do this before the substring call below to avoid it. 45 | if (extensionLength > maxExtensionLength) { 46 | return undefined; 47 | } 48 | 49 | // The +1 excludes the period 50 | const extension = filename.substring(dotIndex + 1); 51 | 52 | if (isAlphanumeric(extension)) { 53 | return extension; 54 | } 55 | 56 | return undefined; 57 | } 58 | 59 | // Return whether the given value is an alphanumeric string 60 | function isAlphanumeric(value) { 61 | // Check for the presence of any character that is not alphabetical or numerical, then return the 62 | // inverse. If at least one character is not alphanumeric, then the value is not, otherwise it is. 63 | // Note we make no assumptions about value type (e.g. null/undefined/not-a-string) or validity 64 | // (e.g. empty string), that is all left to the caller. 65 | return !/[^\p{L}\d]/u.test(value); 66 | } 67 | -------------------------------------------------------------------------------- /src/lib/image-utils.js: -------------------------------------------------------------------------------- 1 | import unwrapElement from '/src/lib/unwrap-element.js'; 2 | 3 | // Returns true if the image element has at least one source 4 | export function imageHasSource(image) { 5 | if (hasAttributeValue(image, 'src') || hasAttributeValue(image, 'srcset')) { 6 | return true; 7 | } 8 | 9 | const picture = image.closest('picture'); 10 | if (picture) { 11 | const sources = picture.getElementsByTagName('source'); 12 | for (const source of sources) { 13 | if (hasAttributeValue(source, 'src') || hasAttributeValue(source, 'srcset')) { 14 | return true; 15 | } 16 | } 17 | } 18 | 19 | return false; 20 | } 21 | 22 | function hasAttributeValue(element, attributeName) { 23 | const value = element.getAttribute(attributeName); 24 | return !!((value && value.trim())); 25 | } 26 | 27 | // Detach an image along with some of its dependencies 28 | export function removeImage(image) { 29 | if (!image.parentNode) { 30 | return; 31 | } 32 | 33 | const figure = image.parentNode.closest('figure'); 34 | if (figure) { 35 | const captions = figure.querySelectorAll('figcaption'); 36 | for (const caption of captions) { 37 | caption.remove(); 38 | } 39 | 40 | unwrapElement(figure); 41 | } 42 | 43 | const picture = image.parentNode.closest('picture'); 44 | if (picture) { 45 | const sources = picture.querySelectorAll('source'); 46 | for (const source of sources) { 47 | source.remove(); 48 | } 49 | 50 | unwrapElement(picture); 51 | } 52 | 53 | image.remove(); 54 | } 55 | -------------------------------------------------------------------------------- /src/lib/indexeddb-utils.js: -------------------------------------------------------------------------------- 1 | import { Deadline, INDEFINITE } from '/src/lib/deadline.js'; 2 | import assert from '/src/lib/assert.js'; 3 | 4 | // Opens a connection to an indexedDB database. The primary benefits over using indexedDB.open 5 | // directly are that this works as a promise, enables a timeout, and translates blocked events into 6 | // errors (and still closes if ever open). 7 | // 8 | // WARNING: An upgrade can still happen in the event of a rejection. For now, I am not trying to 9 | // prevent that as an implicit side effect, although it is possible to abort the versionchange 10 | // transaction from within the upgrade listener. If I wanted to do that I would wrap the call to the 11 | // listener here with a function that first checks if blocked/timedOut and if so aborts the 12 | // transaction and closes, otherwise forwards to the listener. 13 | export async function open(name, version, onupgrade, timeout = INDEFINITE) { 14 | assert(typeof name === 'string'); 15 | assert(timeout instanceof Deadline); 16 | 17 | let timedOut = false; 18 | let timer = null; 19 | 20 | const openPromise = new Promise((resolve, reject) => { 21 | let blocked = false; 22 | const request = indexedDB.open(name, version); 23 | request.onupgradeneeded = onupgrade; 24 | 25 | request.onsuccess = function (event) { 26 | const conn = event.target.result; 27 | 28 | // If we blocked, we rejected the promise earlier so just exit. The extra rejection here is 29 | // irrelevant. 30 | if (blocked) { 31 | console.debug('Closing connection "%s" that unblocked', conn.name); 32 | conn.close(); 33 | reject(); 34 | return; 35 | } 36 | 37 | // If we timed out, settle the promise. The settle mode is irrelevant. 38 | if (timedOut) { 39 | console.debug('Closing connection "%s" after timeout %s', conn.name, timeout); 40 | conn.close(); 41 | resolve(); 42 | return; 43 | } 44 | 45 | resolve(conn); 46 | }; 47 | 48 | request.onblocked = function (event) { 49 | const conn = event.target.result; 50 | blocked = true; 51 | const error = new BlockError(`Blocked connecting to ${conn}` ? conn.name : 'undefined'); 52 | reject(error); 53 | }; 54 | 55 | request.onerror = () => reject(request.error); 56 | }); 57 | 58 | let connectionPromise; 59 | if (timeout.isDefinite()) { 60 | const timeoutPromise = new Promise((resolve) => { 61 | timer = setTimeout(resolve, timeout.toInt()); 62 | }); 63 | connectionPromise = Promise.race([openPromise, timeoutPromise]); 64 | } else { 65 | connectionPromise = openPromise; 66 | } 67 | 68 | const conn = await connectionPromise; 69 | if (!conn) { 70 | timedOut = true; 71 | throw new TimeoutError(`Failed to connect to database "${name}" within ${timeout} ms`); 72 | } 73 | 74 | clearTimeout(timer); 75 | return conn; 76 | } 77 | 78 | export function remove(name) { 79 | return new Promise((resolve, reject) => { 80 | const request = indexedDB.deleteDatabase(name); 81 | request.onsuccess = resolve; 82 | request.onerror = () => reject(request.error); 83 | }); 84 | } 85 | 86 | export class TimeoutError extends Error { 87 | constructor(message = 'Operation timed out') { 88 | super(message); 89 | } 90 | } 91 | 92 | export class BlockError extends Error { 93 | constructor(message = 'Connection blocked') { 94 | super(message); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/lib/is-hidden-inline.js: -------------------------------------------------------------------------------- 1 | import assert from '/src/lib/assert.js'; 2 | 3 | // Returns true if an element is hidden. This function is inexact and inspects various element 4 | // properties to make a guess. 5 | export default function isHiddenElement(element) { 6 | if (element.matches('input[type="hidden"]')) { 7 | return true; 8 | } 9 | 10 | const { style } = element; 11 | if (!style || !style.length) { 12 | return false; 13 | } 14 | 15 | // TODO: do not hardcode the minimum, it should be a parameter 16 | const minOpacity = new CSSUnitValue(0.3, 'number'); 17 | return style.display === 'none' || style.visibility === 'hidden' || 18 | elementIsNearTransparent(element, minOpacity) || 19 | elementIsOffscreen(element); 20 | } 21 | 22 | // Returns true if the element's opacity is specified inline and less than the threshold. 23 | // TODO: explicitly test 24 | export function elementIsNearTransparent(element, minOpacity) { 25 | assert(minOpacity instanceof CSSUnitValue); 26 | const opacity = element.attributeStyleMap.get('opacity'); 27 | return opacity && opacity <= minOpacity; 28 | } 29 | 30 | // Returns true if the element is positioned off screen. Heuristic guess. Probably several false 31 | // negatives, and a few false positives. The cost of guessing wrong is not too high. This is 32 | // inaccurate. 33 | // TODO: write tests that verify this behavior, this feels really inaccurate 34 | export function elementIsOffscreen(element) { 35 | const { style } = element; 36 | if (style && style.position === 'absolute') { 37 | const left = element.attributeStyleMap.get('left'); 38 | if (left && left.value < 0) { 39 | return true; 40 | } 41 | } 42 | 43 | return false; 44 | } 45 | -------------------------------------------------------------------------------- /src/lib/local-storage-utils.js: -------------------------------------------------------------------------------- 1 | // TODO: get rid of the hacks, libraries are not supposed to be app specific 2 | 3 | export function rename(oldName, newName) { 4 | const value = readString(oldName); 5 | if (typeof value !== 'undefined') { 6 | writeString(newName, value); 7 | } 8 | remove(oldName); 9 | } 10 | 11 | export function hasKey(key) { 12 | return typeof localStorage[key] !== 'undefined'; 13 | } 14 | 15 | export function readBoolean(key) { 16 | return typeof localStorage[key] !== 'undefined'; 17 | } 18 | 19 | export function writeBoolean(key, value) { 20 | if (value) { 21 | localStorage[key] = '1'; 22 | } else { 23 | delete localStorage[key]; 24 | } 25 | } 26 | 27 | export function readInt(key) { 28 | const stringValue = localStorage[key]; 29 | if (stringValue) { 30 | const integerValue = parseInt(stringValue, 10); 31 | if (!isNaN(integerValue)) { 32 | return integerValue; 33 | } 34 | } 35 | 36 | return NaN; 37 | } 38 | 39 | export function writeInt(key, value) { 40 | localStorage[key] = `${value}`; 41 | } 42 | 43 | export function readFloat(key) { 44 | return parseFloat(localStorage[key], 10); 45 | } 46 | 47 | export function writeFloat(key, value) { 48 | localStorage[key] = `${value}`; 49 | } 50 | 51 | export function readString(key) { 52 | return localStorage[key]; 53 | } 54 | 55 | export function writeString(key, value) { 56 | localStorage[key] = value; 57 | } 58 | 59 | export function readArray(key) { 60 | // TODO: eventually think of how to persist in localStorage and remove this hack 61 | if (key === 'inaccessible_content_descriptors') { 62 | const descriptors = [ 63 | { pattern: /forbes\.com$/i, reason: 'interstitial-advert' }, 64 | { pattern: /productforums\.google\.com$/i, reason: 'script-generated' }, 65 | { pattern: /groups\.google\.com$/i, reason: 'script-generated' }, 66 | { pattern: /nytimes\.com$/i, reason: 'paywall' }, 67 | { pattern: /wsj\.com$/i, reason: 'paywall' } 68 | ]; 69 | 70 | return descriptors; 71 | } 72 | 73 | // TODO: eventually think of how to persist in localStorage and remove this hack 74 | if (key === 'rewrite_rules') { 75 | const rules = []; 76 | 77 | rules.push((url) => { 78 | if (url.hostname === 'news.google.com' && url.pathname === '/news/url') { 79 | const param = url.searchParams.get('url'); 80 | try { 81 | return new URL(param); 82 | } catch (error) { 83 | // ignore 84 | } 85 | } 86 | 87 | return undefined; 88 | }); 89 | 90 | rules.push((url) => { 91 | if (url.hostname === 'techcrunch.com' && url.searchParams.has('ncid')) { 92 | const output = new URL(url.href); 93 | output.searchParams.delete('ncid'); 94 | return output; 95 | } 96 | 97 | return undefined; 98 | }); 99 | 100 | rules.push((url) => { 101 | if (url.hostname === 'l.facebook.com' && url.pathname === '/l.php') { 102 | const param = url.searchParams.get('u'); 103 | try { 104 | return new URL(param); 105 | } catch (error) { 106 | // ignore 107 | } 108 | } 109 | 110 | return undefined; 111 | }); 112 | 113 | return rules; 114 | } 115 | 116 | const value = localStorage[key]; 117 | return value ? JSON.parse(value) : []; 118 | } 119 | 120 | export function writeArray(key, value) { 121 | localStorage[key] = JSON.stringify(value); 122 | } 123 | 124 | export function remove(key) { 125 | delete localStorage[key]; 126 | } 127 | 128 | export function removeAll(keys) { 129 | for (const key of keys) { 130 | delete localStorage[key]; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/lib/mime-utils.js: -------------------------------------------------------------------------------- 1 | // Provides helpers for interacting with purported mime-type values. A mime-type is a specialized 2 | // type of string. Normal form characteristics are: no intermediate whitespace, and lowercase. 3 | 4 | // These are approximations 5 | export const MIN_LENGTH = 7; 6 | export const MAX_LENGTH = 100; 7 | 8 | // Returns whether the mime type value is superficially valid 9 | export function isValid(value) { 10 | return typeof value === 'string' && value.length > MIN_LENGTH && 11 | value.length < MAX_LENGTH && value.includes('/') && !value.includes(' '); 12 | } 13 | 14 | // Given a Content-Type HTTP header, returns the mime type as a string. Returns undefined when the 15 | // input is bad (e.g. null/undefined) or when the input does not appear to contain a valid mime 16 | // type. 17 | export function parseContentType(value) { 18 | if (typeof value !== 'string') { 19 | return undefined; 20 | } 21 | 22 | // Strip the character encoding, if present 23 | const semicolonIndex = value.indexOf(';'); 24 | if (semicolonIndex > -1) { 25 | value = value.substring(0, semicolonIndex); 26 | } 27 | 28 | // Normalize whitespace and case 29 | value = value.replace(/\s+/g, '').toLowerCase(); 30 | 31 | return isValid(value) ? value : undefined; 32 | } 33 | -------------------------------------------------------------------------------- /src/lib/node-is-leaf.js: -------------------------------------------------------------------------------- 1 | // Returns whether the input node is a leaf node. A leaf node is basically an element that has no 2 | // child nodes, or whose child nodes are only leaves, or a whitespace text node, or a comment node. 3 | // This is a recursive function. 4 | export default function nodeIsLeaf(node) { 5 | const exceptionalElementNames = [ 6 | 'area', 'audio', 'base', 'col', 'command', 'br', 'canvas', 'col', 'hr', 'iframe', 'img', 7 | 'input', 'keygen', 'meta', 'nobr', 'param', 'path', 'source', 'sbg', 'textarea', 'track', 8 | 'video', 'wbr' 9 | ]; 10 | 11 | switch (node.nodeType) { 12 | case Node.ELEMENT_NODE: { 13 | if (exceptionalElementNames.includes(node.localName)) { 14 | return false; 15 | } 16 | 17 | for (let child = node.firstChild; child; child = child.nextSibling) { 18 | if (!nodeIsLeaf(child)) { 19 | return false; 20 | } 21 | } 22 | 23 | break; 24 | } 25 | case Node.TEXT_NODE: 26 | return !node.nodeValue.trim(); 27 | case Node.COMMENT_NODE: 28 | return true; 29 | default: 30 | return false; 31 | } 32 | 33 | return true; 34 | } 35 | -------------------------------------------------------------------------------- /src/lib/open-tab.js: -------------------------------------------------------------------------------- 1 | // Opens a tab. If the tab is already open, then this focuses on that tab. If reuseNewtab is true 2 | // and the newtab is open in another tab, then this switches to that newtab tab and replaces it with 3 | // the given url. Otherwise, this creates a new tab with the given url. 4 | // 5 | // This implementation is tightly coupled to chrome extension apis. 6 | export default async function openTab(relativeURLString, reuseNewtab) { 7 | const urlString = chrome.extension.getURL(relativeURLString); 8 | const viewTab = await findTab(urlString); 9 | if (viewTab) { 10 | chrome.tabs.update(viewTab.id, { active: true }); 11 | return; 12 | } 13 | 14 | if (reuseNewtab) { 15 | const newtab = await findTab('chrome://newtab/'); 16 | if (newtab) { 17 | chrome.tabs.update(newtab.id, { active: true, url: urlString }); 18 | return; 19 | } 20 | } 21 | 22 | // Try to minimize use of chrome api where possible. 23 | open(urlString, '_blank'); 24 | } 25 | 26 | function findTab(urlString) { 27 | return new Promise((resolve) => { 28 | chrome.tabs.query({ url: urlString }, (tabs) => { 29 | resolve((tabs && tabs.length) ? tabs[0] : undefined); 30 | }); 31 | }); 32 | } 33 | -------------------------------------------------------------------------------- /src/lib/opml.js: -------------------------------------------------------------------------------- 1 | // Simple OPML library 2 | 3 | export function createDocument(documentTitle) { 4 | const doc = document.implementation.createDocument(null, 'opml', null); 5 | doc.documentElement.setAttribute('version', '2.0'); 6 | 7 | const headElement = doc.createElement('head'); 8 | doc.documentElement.append(headElement); 9 | 10 | if (documentTitle) { 11 | const titleElement = doc.createElement('title'); 12 | titleElement.append(documentTitle); 13 | headElement.append(titleElement); 14 | } 15 | 16 | const currentDate = new Date(); 17 | const currentDateUTCString = currentDate.toUTCString(); 18 | 19 | const dateCreatedElement = doc.createElement('datecreated'); 20 | dateCreatedElement.textContent = currentDateUTCString; 21 | headElement.append(dateCreatedElement); 22 | 23 | const dateModifiedElement = doc.createElement('datemodified'); 24 | dateModifiedElement.textContent = currentDateUTCString; 25 | headElement.append(dateModifiedElement); 26 | 27 | const docsElement = doc.createElement('docs'); 28 | docsElement.textContent = 'http://dev.opml.org/spec2.html'; 29 | headElement.append(docsElement); 30 | 31 | const bodyElement = doc.createElement('body'); 32 | doc.documentElement.append(bodyElement); 33 | return doc; 34 | } 35 | 36 | export function appendOutlines(document, outlines) { 37 | // document.body does not work for xml 38 | const bodyElement = document.querySelector('body'); 39 | for (const outline of outlines) { 40 | const element = document.createElement('outline'); 41 | element.setAttribute('type', outline.type || ''); 42 | element.setAttribute('xmlUrl', outline.xmlUrl || ''); 43 | element.setAttribute('title', outline.title || ''); 44 | element.setAttribute('description', outline.description || ''); 45 | element.setAttribute('htmlUrl', outline.htmlUrl || ''); 46 | bodyElement.append(element); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/lib/parse-html.js: -------------------------------------------------------------------------------- 1 | import assert from '/src/lib/assert.js'; 2 | 3 | // Parses a string into an html document. When html is a fragment, it will be inserted into a new 4 | // document using a default template provided by the browser, that includes a document element and 5 | // usually a body. If not a fragment, then it is merged into a document with a default template. 6 | export default function parseHTML(htmlString) { 7 | assert(typeof htmlString === 'string'); 8 | 9 | const parser = new DOMParser(); 10 | const document = parser.parseFromString(htmlString, 'text/html'); 11 | 12 | const parserErrorElement = document.querySelector('parsererror'); 13 | if (parserErrorElement) { 14 | const message = condenseWhitespace(parserErrorElement.textContent); 15 | throw new HTMLParseError(message); 16 | } 17 | 18 | return document; 19 | } 20 | 21 | function condenseWhitespace(value) { 22 | return value.replace(/\s\s+/g, ' '); 23 | } 24 | 25 | export class HTMLParseError extends Error { 26 | constructor(message = 'HTML parse error') { 27 | super(message); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/lib/parse-opml.js: -------------------------------------------------------------------------------- 1 | // TODO: recouple with parseXML, this should merely decorate it with the extra check 2 | 3 | export default function parseOPML(xmlString) { 4 | const parser = new DOMParser(); 5 | const document = parser.parseFromString(xmlString, 'application/xml'); 6 | const error = document.querySelector('parsererror'); 7 | if (error) { 8 | const message = condenseWhitespace(error.textContent); 9 | throw new OPMLParseError(message); 10 | } 11 | 12 | // Need to normalize localName when document is xml-flagged 13 | const name = document.documentElement.localName.toLowerCase(); 14 | if (name !== 'opml') { 15 | throw new OPMLParseError(`Document element is not opml: ${name}`); 16 | } 17 | 18 | return document; 19 | } 20 | 21 | export class OPMLParseError extends Error { 22 | constructor(message = 'OPML parse error') { 23 | super(message); 24 | } 25 | } 26 | 27 | function condenseWhitespace(value) { 28 | return value.replace(/\s\s+/g, ' '); 29 | } 30 | -------------------------------------------------------------------------------- /src/lib/parse-xml.js: -------------------------------------------------------------------------------- 1 | // TODO: this should be revised to assume that the input value is a string so as to remain 2 | // consistent with other parse function modules like parse-html and parse-opml. 3 | 4 | export default function parseXML(value) { 5 | if (typeof value !== 'string') { 6 | throw new TypeError('value is not a string'); 7 | } 8 | 9 | const parser = new DOMParser(); 10 | const doc = parser.parseFromString(value, 'application/xml'); 11 | const error = doc.querySelector('parsererror'); 12 | if (error) { 13 | throw new ParseError(error.textContent); 14 | } 15 | 16 | return doc; 17 | } 18 | 19 | export class ParseError extends Error { 20 | constructor(message = 'Parse error') { 21 | super(message); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/lib/remove-html.js: -------------------------------------------------------------------------------- 1 | import parseHTML from '/src/lib/parse-html.js'; 2 | 3 | // Return a new string consisting of the input string less any html tags. Certain html entities such 4 | // as are decoded within the output because this function internally parses the html into a 5 | // document object and then serializes it back to a string, and this deserialization/serialization 6 | // sequence is lossy. In addition, when the input is a full document, text located outside the body 7 | // element is not included, because this only examines text within the body. 8 | // 9 | // Throws an error when the input is not a string. 10 | // Throws an error when the input html is malformed (and therefore unsafe to use). 11 | export default function removeHTML(htmlString) { 12 | const document = parseHTML(htmlString); 13 | return document.documentElement.textContent; 14 | } 15 | -------------------------------------------------------------------------------- /src/lib/set-base-uri.js: -------------------------------------------------------------------------------- 1 | import assert from '/src/lib/assert.js'; 2 | 3 | // Sets the url as the base url of the document, such that document.baseURI will reflect the url. If 4 | // overwrite is true then this ignores existing base elements in the document. 5 | export default function setBaseURI(doc, url, overwrite) { 6 | assert(doc instanceof Document); 7 | assert(url instanceof URL); 8 | 9 | if (url.href.startsWith('chrome-extension')) { 10 | throw new Error(`Refusing to set baseURI to extension url ${url.href}`); 11 | } 12 | 13 | let head = doc.querySelector('head'); 14 | const body = doc.querySelector('body'); 15 | 16 | if (overwrite) { 17 | // There must be no more than one base element per document. 18 | const bases = doc.querySelectorAll('base'); 19 | for (const base of bases) { 20 | base.remove(); 21 | } 22 | 23 | const base = doc.createElement('base'); 24 | base.setAttribute('href', url.href); 25 | 26 | if (head) { 27 | // Insert the base as the first or only element within head 28 | head.insertBefore(base, head.firstElementChild); 29 | } else { 30 | head = doc.createElement('head'); 31 | // Appending to new head while it is still detached is better performance in case document is 32 | // live 33 | head.append(base); 34 | // Insert the head before the body (fallback to append if body not found) 35 | doc.documentElement.insertBefore(head, body); 36 | } 37 | 38 | return; 39 | } 40 | 41 | let base = doc.querySelector('base[href]'); 42 | if (!base) { 43 | base = doc.createElement('base'); 44 | base.setAttribute('href', url.href); 45 | if (head) { 46 | head.insertBefore(base, head.firstElementChild); 47 | } else { 48 | head = doc.createElement('head'); 49 | head.append(base); 50 | doc.documentElement.insertBefore(head, body); 51 | } 52 | 53 | return; 54 | } 55 | 56 | // The spec states that "[t]he href content attribute, if specified, must contain a valid URL 57 | // potentially surrounded by spaces." Rather than explicitly trim, we pass along extraneous 58 | // whitespace to the URL constructor, which tolerates it. So long as we pass the base parameter 59 | // to the URL constructor, the URL constructor also tolerates when the first 60 | // parameter is null or undefined. 61 | const hrefValue = base.getAttribute('href'); 62 | const canonicalURL = new URL(hrefValue, url); 63 | 64 | const comparableHref = hrefValue ? hrefValue.trim() : ''; 65 | if (canonicalURL.href !== comparableHref) { 66 | // Canonicalization resulted in a material value change. The value change could be as simple as 67 | // removing spaces, adding a trailing slash, or as complex as making a relative base url 68 | // absolute with respect to the input url, or turning an empty value into a full url. So we 69 | // update this first base. 70 | base.setAttribute('href', canonicalURL.href); 71 | } else { 72 | // If there was no material change to the value after canonicalization, this means the existing 73 | // base href value is canonical. Since we are not overwriting at this point, we respect the 74 | // existing value. Fallthrough. 75 | } 76 | 77 | // Per the spec, "[t]here must be no more than one base element per document." Now that we know 78 | // which of the existing base elements will be retained, we remove the others to make the document 79 | // more spec compliant. 80 | const bases = doc.querySelectorAll('base'); 81 | for (const otherBase of bases) { 82 | if (otherBase !== base) { 83 | otherBase.remove(); 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/lib/srcset-utils.js: -------------------------------------------------------------------------------- 1 | import '/src/lib/third-party/parse-srcset.js'; 2 | 3 | // Parses a value into an array of descriptors. The value should be a string representing the 4 | // contents of an html element srcset attribute value. If the input is bad or no descriptors are 5 | // found then parse returns an empty array. 6 | export function parse(value) { 7 | return (value && typeof value === 'string') ? parseSrcset(value) : []; 8 | } 9 | 10 | // Convert an array of descriptors into a string. Throws an error if descriptors is not an array. 11 | export function serialize(descriptors) { 12 | const buf = []; 13 | for (const descriptor of descriptors) { 14 | const dbuf = [descriptor.url]; 15 | if (descriptor.d) { 16 | dbuf.push(' '); 17 | dbuf.push(descriptor.d); 18 | dbuf.push('x'); 19 | } else if (descriptor.w) { 20 | dbuf.push(' '); 21 | dbuf.push(descriptor.w); 22 | dbuf.push('w'); 23 | } else if (descriptor.h) { 24 | dbuf.push(' '); 25 | dbuf.push(descriptor.h); 26 | dbuf.push('h'); 27 | } 28 | 29 | const descriptorString = dbuf.join(''); 30 | buf.push(descriptorString); 31 | } 32 | 33 | return buf.join(', '); 34 | } 35 | -------------------------------------------------------------------------------- /src/lib/truncate-html.js: -------------------------------------------------------------------------------- 1 | const ELLIPSIS = '\u2026'; 2 | 3 | // TODO: recouple with parseHTML 4 | 5 | export default function truncateHTML(htmlString, position, suffix = ELLIPSIS) { 6 | if (!Number.isInteger(position)) { 7 | throw new TypeError('position must be an integer'); 8 | } 9 | 10 | if (position < 0) { 11 | throw new TypeError('position must be greater than or equal to 0'); 12 | } 13 | 14 | const parser = new DOMParser(); 15 | const doc = parser.parseFromString(htmlString, 'text/html'); 16 | const parserErrorElement = doc.querySelector('parsererror'); 17 | if (parserErrorElement) { 18 | return 'Unsafe or malformed HTML'; 19 | } 20 | 21 | const it = doc.createNodeIterator(doc.body, NodeFilter.SHOW_TEXT); 22 | let totalLength = 0; 23 | 24 | for (let node = it.nextNode(); node; node = it.nextNode()) { 25 | const value = node.nodeValue; 26 | const valueLength = value.length; 27 | if (totalLength + valueLength >= position) { 28 | const remainingLength = position - totalLength; 29 | node.nodeValue = value.substr(0, remainingLength) + suffix; 30 | break; 31 | } else { 32 | totalLength += valueLength; 33 | } 34 | } 35 | 36 | for (let node = it.nextNode(); node; node = it.nextNode()) { 37 | node.remove(); 38 | } 39 | 40 | if (/ createFeed(conn, feed)); 35 | return Promise.all(promises); 36 | } 37 | 38 | export function deleteFeed(conn, id, reason) { 39 | return db.deleteResource(conn, id, reason); 40 | } 41 | 42 | export function deleteEntry(conn, id, reason) { 43 | return db.deleteResource(conn, id, reason); 44 | } 45 | 46 | export function getFeed(conn, query) { 47 | return db.getResource(conn, query); 48 | } 49 | 50 | export function getEntry(conn, query) { 51 | return db.getResource(conn, query); 52 | } 53 | 54 | export function getEntries(conn, query) { 55 | // TODO: 'all' is hack to support archive-entries test 56 | const supportedModes = ['viewable-entries', 'archivable-entries', 'all']; 57 | assert(supportedModes.includes(query.mode)); 58 | 59 | return db.getResources(conn, query); 60 | } 61 | 62 | export function getFeeds(conn, query) { 63 | const supportedModes = ['feeds', 'active-feeds']; 64 | assert(supportedModes.includes(query.mode)); 65 | 66 | return db.getResources(conn, query); 67 | } 68 | 69 | export function patchEntry(conn, props) { 70 | return db.patchResource(conn, props); 71 | } 72 | 73 | export function patchFeed(conn, props) { 74 | return db.patchResource(conn, props); 75 | } 76 | 77 | export function putEntry(conn, entry) { 78 | return db.putResource(conn, entry); 79 | } 80 | 81 | export function putFeed(conn, feed) { 82 | return db.putResource(conn, feed); 83 | } 84 | -------------------------------------------------------------------------------- /src/service/import-opml.js: -------------------------------------------------------------------------------- 1 | import * as DBService from '/src/service/db-service.js'; 2 | import * as db from '/src/db/db.js'; 3 | import parseOPML from '/src/lib/parse-opml.js'; 4 | 5 | // Create and store feed objects in the database based on urls extracted from zero or more opml 6 | // files. files should be a FileList or an Array. 7 | export default async function importOPML(conn, files) { 8 | console.log('Importing %d OPML files', files.length); 9 | 10 | // Grab urls from each of the files. Per-file errors, including assertion errors, are logged not 11 | // thrown. 12 | const promises = Array.prototype.map.call(files, 13 | file => findURLsInFile(file).catch(console.warn)); 14 | const results = await Promise.all(promises); 15 | 16 | // Flatten results into a simple array of urls 17 | const urls = []; 18 | for (const result of results) { 19 | if (result) { 20 | for (const url of result) { 21 | urls.push(url); 22 | } 23 | } 24 | } 25 | 26 | // Filter dups 27 | // TODO: revert to using Set style 28 | const urlSet = []; 29 | const seenHrefs = []; 30 | for (const url of urls) { 31 | if (!seenHrefs.includes(url.href)) { 32 | urlSet.push(url); 33 | seenHrefs.push(url.href); 34 | } 35 | } 36 | 37 | const feeds = urlSet.map((url) => { 38 | const feed = {}; 39 | feed.active = 1; 40 | feed.type = 'feed'; 41 | db.setURL(feed, url); 42 | return feed; 43 | }); 44 | 45 | return DBService.createFeeds(conn, feeds); 46 | } 47 | 48 | // Return an array of outline urls (as URL objects) from OPML outline elements found in the 49 | // plaintext representation of the given file 50 | async function findURLsInFile(file) { 51 | return findOutlineURLs(parseOPML(await readFileFullTextAsync(file))); 52 | } 53 | 54 | // Return an array of outline urls (as URL objects) from outlines found in an OPML document 55 | function findOutlineURLs(doc) { 56 | // Assume the document is semi-well-formed. As a compromise between a deep strict validation and 57 | // no validation at all, use the CSS restricted-parent selector syntax. We also do some filtering 58 | // of outlines here up front to only those with a type attribute because it is slightly better 59 | // performance and less code. 60 | const outlines = doc.querySelectorAll('opml > body > outline[type]'); 61 | 62 | // Although I've never witnessed it in the wild, apparently OPML outline elements can represent 63 | // non-feed data. Use this pattern to restrict the outlines considered to those properly 64 | // configured. 65 | const feedFormatPattern = /^\s*(rss|rdf|feed)\s*$/i; 66 | 67 | const urls = []; 68 | for (const outline of outlines) { 69 | const type = outline.getAttribute('type'); 70 | if (feedFormatPattern.test(type)) { 71 | const xmlUrlAttributeValue = outline.getAttribute('xmlUrl'); 72 | try { 73 | urls.push(new URL(xmlUrlAttributeValue)); 74 | } catch (error) { 75 | // Ignore the error, skip the url 76 | } 77 | } 78 | } 79 | 80 | return urls; 81 | } 82 | 83 | function readFileFullTextAsync(file) { 84 | return new Promise((resolve, reject) => { 85 | const reader = new FileReader(); 86 | reader.readAsText(file); 87 | reader.onload = () => resolve(reader.result); 88 | reader.onerror = () => reject(reader.error); 89 | }); 90 | } 91 | -------------------------------------------------------------------------------- /src/service/poll-feeds.js: -------------------------------------------------------------------------------- 1 | import * as DBService from '/src/service/db-service.js'; 2 | import * as localStorageUtils from '/src/lib/local-storage-utils.js'; 3 | import { Deadline } from '/src/lib/deadline.js'; 4 | import { ImportFeedArgs, importFeed } from '/src/service/import-feed.js'; 5 | import assert, { isAssertError } from '/src/lib/assert.js'; 6 | import showNotification from '/src/service/utils/show-notification.js'; 7 | 8 | export function PollFeedsArgs() { 9 | this.ignoreRecencyCheck = false; 10 | this.recencyPeriod = 5 * 60 * 1000; 11 | this.fetchFeedTimeout = new Deadline(5000); 12 | this.fetchHTMLTimeout = new Deadline(5000); 13 | this.fetchImageTimeout = new Deadline(3000); 14 | this.deactivation_threshold = 10; 15 | this.notify = true; 16 | this.conn = undefined; 17 | this.iconn = undefined; 18 | this.rewriteRules = localStorageUtils.readArray('rewrite_rules'); 19 | this.inaccessible_content_descriptors = localStorageUtils.readArray('inaccessible_content_descriptors'); 20 | } 21 | 22 | export async function pollFeeds(args) { 23 | console.log('Polling feeds...'); 24 | 25 | // Cancel the run if the last run was too recent 26 | if (args.recencyPeriod && !args.ignoreRecencyCheck) { 27 | const stamp = localStorageUtils.readInt('last_poll_timestamp'); 28 | if (!isNaN(stamp)) { 29 | const now = new Date(); 30 | const stampDate = new Date(stamp); 31 | const millisElapsed = now - stampDate; 32 | assert(millisElapsed >= 0); 33 | if (millisElapsed < args.recencyPeriod) { 34 | console.debug('Polled too recently', millisElapsed); 35 | return 0; 36 | } 37 | } 38 | } 39 | 40 | localStorage.last_poll_timestamp = `${Date.now()}`; 41 | 42 | const feeds = await DBService.getFeeds(args.conn, { mode: 'active-feeds', titleSort: false }); 43 | console.debug('Loaded %d active feeds for polling', feeds.length); 44 | 45 | // Start concurrently polling each feed resource 46 | const promises = feeds.map((feed) => { 47 | const ifa = new ImportFeedArgs(); 48 | ifa.feed = feed; 49 | ifa.conn = args.conn; 50 | ifa.iconn = args.iconn; 51 | ifa.rewriteRules = args.rewriteRules; 52 | ifa.inaccessibleContentDescriptors = args.inaccessible_content_descriptors; 53 | ifa.create = false; 54 | ifa.fetchFeedTimeout = args.fetchFeedTimeout; 55 | ifa.fetchHTMLTimeout = args.fetchHTMLTimeout; 56 | ifa.feedStoredCallback = undefined; 57 | return pollFeedNoexcept(ifa); 58 | }); 59 | // Wait for all concurrent polls to complete 60 | const importFeedResults = await Promise.all(promises); 61 | 62 | // Calculate the total number of entries added across all feeds. 63 | let entryAddCountTotal = 0; 64 | for (const entryAddCount of importFeedResults) { 65 | entryAddCountTotal += entryAddCount; 66 | } 67 | 68 | if (args.notify && entryAddCountTotal > 0) { 69 | showNotification(`Added ${entryAddCountTotal} articles`); 70 | } 71 | 72 | console.log('Poll feeds completed'); 73 | return entryAddCountTotal; 74 | } 75 | 76 | // Wrap the call to import-feed, trap all errors except assertion errors. 77 | async function pollFeedNoexcept(importFeedArgs) { 78 | let result; 79 | try { 80 | result = await importFeed(importFeedArgs); 81 | } catch (error) { 82 | if (isAssertError(error)) { 83 | throw error; 84 | } else { 85 | const { feed } = importFeedArgs; 86 | const url = feed.urls[feed.urls.length - 1]; 87 | console.warn('Error polling feed', url, error); 88 | return 0; 89 | } 90 | } 91 | 92 | return result.entryAddCount; 93 | } 94 | -------------------------------------------------------------------------------- /src/service/refresh-feed-icons.js: -------------------------------------------------------------------------------- 1 | import * as DBService from '/src/service/db-service.js'; 2 | import lookupFeedFavicon from '/src/service/utils/lookup-feed-favicon.js'; 3 | 4 | export default async function refreshFeedIcons(conn, iconn) { 5 | const mode = 'active-feeds'; 6 | const titleSort = false; 7 | const feeds = await DBService.getFeeds(conn, { mode, titleSort }); 8 | const promises = feeds.map(feed => refreshFeedIcon(feed, conn, iconn)); 9 | return Promise.all(promises); 10 | } 11 | 12 | async function refreshFeedIcon(feed, conn, iconn) { 13 | const iconURL = await lookupFeedFavicon(feed, iconn); 14 | const iconURLString = iconURL ? iconURL.href : undefined; 15 | if (feed.favicon_url !== iconURLString) { 16 | return DBService.patchFeed(conn, { id: feed.id, favicon_url: iconURLString }); 17 | } 18 | 19 | return undefined; 20 | } 21 | -------------------------------------------------------------------------------- /src/service/subscribe.js: -------------------------------------------------------------------------------- 1 | import * as db from '/src/db/db.js'; 2 | import { INDEFINITE } from '/src/lib/deadline.js'; 3 | import { ImportFeedArgs, importFeed } from '/src/service/import-feed.js'; 4 | import showNotification from '/src/service/utils/show-notification.js'; 5 | 6 | // Subscribes to a feed. Imports the feed and its entries into the database. Throws an error if 7 | // already subscribed or if something goes wrong. This resolves when both the feed and the entries 8 | // are fully imported. The callback is invoked with the feed once it is stored, earlier. 9 | export default async function subscribe(conn, iconn, url, timeout = INDEFINITE, notify, 10 | feedStoredCallback) { 11 | const resource = {}; 12 | resource.type = 'feed'; 13 | db.setURL(resource, url); 14 | 15 | const args = new ImportFeedArgs(); 16 | args.conn = conn; 17 | args.iconn = iconn; 18 | args.feed = resource; 19 | args.create = true; 20 | args.fetchFeedTimeout = timeout; 21 | args.feedStoredCallback = feedStoredCallback; 22 | 23 | await importFeed(args); 24 | 25 | if (notify) { 26 | const feedTitle = resource.title || resource.urls[resource.urls.length - 1]; 27 | showNotification(`Subscribed to ${feedTitle}`, resource.favicon_url); 28 | } 29 | 30 | return resource; 31 | } 32 | -------------------------------------------------------------------------------- /src/service/unsubscribe.js: -------------------------------------------------------------------------------- 1 | import * as DBService from '/src/service/db-service.js'; 2 | 3 | export default function unsubscribe(conn, feedId) { 4 | return DBService.deleteFeed(conn, feedId, 'unsubscribe'); 5 | } 6 | -------------------------------------------------------------------------------- /src/service/utils/lookup-feed-favicon.js: -------------------------------------------------------------------------------- 1 | import { Deadline, INDEFINITE } from '/src/lib/deadline.js'; 2 | import { LookupRequest, lookup } from '/src/lib/favicon.js'; 3 | import assert from '/src/lib/assert.js'; 4 | 5 | // Return the {URL} url of the favicon associated with the given feed. Throws an error if the feed 6 | // has an invalid link property url, an invalid location url, or if the lookup itself encounters an 7 | // error. 8 | // 9 | // @param feed {Object} an object in the model format, required 10 | // @param iconn {IDBDatabase} optional, an open connection to the favicon db, if not specified then 11 | // a cacheless lookup is performed 12 | // @param timeout {Deadline} optional, the maximum number of milliseconds to wait when sending an 13 | // http request to fetch the corresponding html document to check if it contains favicon 14 | // information, defaults to indefinite (untimed) 15 | // @return {Promise} returns a promise that resolves to the {URL} url of the favicon 16 | export default function lookupFeedFavicon(feed, iconn, timeout = INDEFINITE) { 17 | assert(typeof feed === 'object'); 18 | assert(iconn === undefined || iconn instanceof IDBDatabase); 19 | assert(timeout instanceof Deadline); 20 | 21 | // Build the lookup url, preferring the feed's link, and falling back to the origin of the feed 22 | // xml file's location. 23 | let lookupURL; 24 | if (feed.link) { 25 | lookupURL = new URL(feed.link); 26 | } else if (feed.urls && feed.urls.length) { 27 | const tailURL = new URL(feed.urls[feed.urls.length - 1]); 28 | lookupURL = new URL(tailURL.origin); 29 | } else { 30 | const error = new Error('Cannot build lookup url for feed'); 31 | return Promise.reject(error); 32 | } 33 | 34 | const request = new LookupRequest(); 35 | request.conn = iconn; 36 | request.url = lookupURL; 37 | request.timeout = timeout; 38 | return lookup(request); 39 | } 40 | -------------------------------------------------------------------------------- /src/service/utils/show-notification.js: -------------------------------------------------------------------------------- 1 | import * as localStorageUtils from '/src/lib/local-storage-utils.js'; 2 | import openTab from '/src/lib/open-tab.js'; 3 | 4 | // TODO: decouple from local storage utils, accept reuseNewtab as a parameter 5 | 6 | const defaultIcon = chrome.extension.getURL('/images/rss_icon_trans.gif'); 7 | 8 | export default function showNotification(message = '', icon = defaultIcon) { 9 | const enabled = localStorageUtils.readBoolean('notifications_enabled'); 10 | if (!enabled) { 11 | return; 12 | } 13 | 14 | const title = 'RSS Reader'; 15 | 16 | // Instantiating a notification shows it 17 | const notification = new Notification(title, { body: message, icon }); 18 | notification.addEventListener('click', () => { 19 | // Work around a strange issue in older Chrome 20 | try { 21 | const hwnd = window.open(); 22 | hwnd.close(); 23 | } catch (error) { 24 | console.error(error); 25 | return; 26 | } 27 | 28 | const reuseNewtab = localStorageUtils.readBoolean('reuse_newtab'); 29 | openTab('slideshow.html', reuseNewtab).catch(console.warn); 30 | }); 31 | } 32 | -------------------------------------------------------------------------------- /src/test/archive-resources-test.js: -------------------------------------------------------------------------------- 1 | import * as DBService from '/src/service/db-service.js'; 2 | import * as databaseUtils from '/src/test/database-utils.js'; 3 | import * as indexedDBUtils from '/src/lib/indexeddb-utils.js'; 4 | import TestRegistry from '/src/test/test-registry.js'; 5 | import archiveResources from '/src/service/archive-resources.js'; 6 | import assert from '/src/lib/assert.js'; 7 | 8 | // Exercise typical execution of archive-entries 9 | async function archiveResourcesTest() { 10 | const databaseNamePrefix = 'archive-resources-test'; 11 | await databaseUtils.removeDatabasesForPrefix(databaseNamePrefix); 12 | const databaseName = databaseUtils.createUniqueDatabaseName(databaseNamePrefix); 13 | 14 | const conn = await databaseUtils.createTestDatabase(databaseName); 15 | 16 | const createPromises = []; 17 | for (let i = 0; i < 5; i += 1) { 18 | const resource = { 19 | title: `title ${i}`, content: 'foo', read: 1, type: 'entry' 20 | }; 21 | createPromises.push(DBService.createEntry(conn, resource)); 22 | } 23 | 24 | const ids = await Promise.all(createPromises); 25 | 26 | // pseudo advance clock so that all entries expire, we have to do it this way because we cannot 27 | // exercise control over resource.created_date, createEntry auto sets it 28 | await new Promise(resolve => setTimeout(resolve, 50)); 29 | 30 | // Combined with the fake advance just prior, at least one millis will have elapsed so we 31 | // know that each resource created-date check will be considered expired 32 | const maxAge = 1; 33 | 34 | // Intentionally choose a size smaller than num archivable to enter the repeated read case 35 | // 2 goes into 5 2.5 times, so we will get 3 iterations 36 | const batchSize = 2; 37 | 38 | await archiveResources(conn, batchSize, maxAge); 39 | 40 | const resources = await DBService.getEntries(conn, { mode: 'all' }); 41 | assert(resources.length === 5); 42 | 43 | for (const resource of resources) { 44 | assert(ids.includes(resource.id)); 45 | assert(resource.archived === 1); 46 | assert(resource.content === undefined); 47 | assert(resource.archived_date instanceof Date); 48 | } 49 | 50 | // TODO: test running a second time and verifying nothing happens because all archivable 51 | // resources were archived 52 | 53 | conn.close(); 54 | await indexedDBUtils.remove(conn.conn.name); 55 | } 56 | 57 | TestRegistry.registerTest(archiveResourcesTest); 58 | -------------------------------------------------------------------------------- /src/test/basic-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/src/test/basic-image.png -------------------------------------------------------------------------------- /src/test/better-fetch-test.html: -------------------------------------------------------------------------------- 1 | Hello World 2 | -------------------------------------------------------------------------------- /src/test/better-fetch-tests.js: -------------------------------------------------------------------------------- 1 | import { AcceptError, NetworkError, betterFetch } from '/src/lib/better-fetch.js'; 2 | import TestRegistry from '/src/test/test-registry.js'; 3 | import assert from '/src/lib/assert.js'; 4 | 5 | async function betterFetchOrdinaryTest() { 6 | // Exercise an ordinary case of the function on a local file and assert that it basically runs 7 | // without error. 8 | const path = '/src/test/better-fetch-test.html'; 9 | const urlString = chrome.extension.getURL(path); 10 | const url = new URL(urlString); 11 | const response = await betterFetch(url); 12 | 13 | const fullText = await response.text(); 14 | assert(fullText === 'Hello World\n', fullText); 15 | } 16 | 17 | // Verify that fetching a file of a particular type along with a response type constraint on that 18 | // type succeeds 19 | async function betterFetchGoodTypeTest() { 20 | const path = '/src/test/better-fetch-test.html'; 21 | const urlString = chrome.extension.getURL(path); 22 | const url = new URL(urlString); 23 | 24 | const options = {}; 25 | options.types = ['text/html']; 26 | 27 | await betterFetch(url, options); 28 | } 29 | 30 | // Verify that fetching with a response type constraint that does not allow for the given type 31 | // produces the expected error 32 | async function betterFetchBadTypeTest() { 33 | const path = '/src/test/better-fetch-test.html'; 34 | const urlString = chrome.extension.getURL(path); 35 | const url = new URL(urlString); 36 | 37 | const options = {}; 38 | options.types = ['text/plain']; 39 | 40 | let fetchError; 41 | try { 42 | await betterFetch(url, options); 43 | } catch (error) { 44 | fetchError = error; 45 | } 46 | 47 | assert(fetchError instanceof AcceptError); 48 | } 49 | 50 | // Verify that fetching a local file that does not exist produces a network error 51 | async function betterFetchLocal404Test() { 52 | const path = '/src/src/lib/this-file-does-not-exist.html'; 53 | const urlString = chrome.extension.getURL(path); 54 | const url = new URL(urlString); 55 | 56 | let fetchError; 57 | 58 | try { 59 | await betterFetch(url); 60 | } catch (error) { 61 | fetchError = error; 62 | } 63 | 64 | assert(fetchError instanceof NetworkError); 65 | } 66 | 67 | TestRegistry.registerTest(betterFetchOrdinaryTest); 68 | TestRegistry.registerTest(betterFetchGoodTypeTest); 69 | TestRegistry.registerTest(betterFetchBadTypeTest); 70 | TestRegistry.registerTest(betterFetchLocal404Test); 71 | -------------------------------------------------------------------------------- /src/test/coerce-element-test.js: -------------------------------------------------------------------------------- 1 | import TestRegistry from '/src/test/test-registry.js'; 2 | import assert from '/src/lib/assert.js'; 3 | import coerceElement from '/src/lib/coerce-element.js'; 4 | import parseHTML from '/src/lib/parse-html.js'; 5 | 6 | // TODO: assert that p still exists to test that coerce does not affect elements it should not 7 | // affect (surprise side effects) 8 | // TODO: test that child nodes remain in the expected place, including both elements and text nodes 9 | // (surprises side effects) 10 | // TODO: test error cases? what errors? invalid input? 11 | 12 | function coerceElementTest() { 13 | const input = '

    '; 14 | const doc = parseHTML(input); 15 | 16 | // Replace the as with bs 17 | const anchors = doc.querySelectorAll('a'); 18 | for (const a of anchors) { 19 | coerceElement(a, 'b'); 20 | } 21 | 22 | // Assert that a was replaced with b and that no extra junk was inserted 23 | let expected = '

    '; 24 | assert(doc.documentElement.outerHTML === expected); 25 | 26 | // Now replace the bs with cs. Assert that a second call does not somehow work unexpectedly due 27 | // to side effects. Basically assert there are no consequential side effects. In addition, test 28 | // that coerce works with fictional elements, as c is not a standard element. 29 | const bolds = doc.querySelectorAll('b'); 30 | for (const b of bolds) { 31 | coerceElement(b, 'c'); 32 | } 33 | expected = '

    '; 34 | assert(doc.documentElement.outerHTML === expected); 35 | } 36 | 37 | TestRegistry.registerTest(coerceElementTest); 38 | -------------------------------------------------------------------------------- /src/test/color-contrast-filter-test.js: -------------------------------------------------------------------------------- 1 | // import assert from '/src/lib/assert.js'; 2 | // import colorContrastFilter from '/src/lib/dom-filters/color-contrast.js'; 3 | import TestRegistry from '/src/test/test-registry.js'; 4 | import parseHTML from '/src/lib/parse-html.js'; 5 | 6 | // function colorContrastFilterTest() { 7 | // TODO: implement (and register in test registry once implemented) 8 | // } 9 | 10 | function colorParseTypedCSSTest() { 11 | // TODO: expiriment with typed CSS OM 12 | // TODO: investigate element.computedStyleMap(), looks like it still does not work with 13 | // non-attached 14 | // TODO: actually assert things of course 15 | 16 | const input = '
    white
    '; 17 | const doc = parseHTML(input); 18 | const element = doc.querySelector('div'); 19 | console.debug(element.style.backgroundColor); 20 | console.debug(element.attributeStyleMap.get('background-color')); 21 | 22 | const map = element.computedStyleMap(); 23 | console.dir(map); 24 | console.debug(map.get('background-color')); 25 | } 26 | 27 | TestRegistry.registerTest(colorParseTypedCSSTest); 28 | -------------------------------------------------------------------------------- /src/test/color-test.js: -------------------------------------------------------------------------------- 1 | import * as color from '/src/lib/color.js'; 2 | import TestRegistry from '/src/test/test-registry.js'; 3 | import assert from '/src/lib/assert.js'; 4 | 5 | function colorTest() { 6 | // Check the named colors are valid 7 | assert(color.isValid(color.BLACK)); 8 | assert(color.isValid(color.WHITE)); 9 | assert(color.isValid(color.TRANSPARENT)); 10 | 11 | // exercise basic packing 12 | let r = 0; let b = 0; let g = 0; let 13 | a = 0; 14 | let value = color.pack(r, g, b, a); 15 | 16 | // pack should have produced a valid color 17 | assert(color.isValid(value)); 18 | 19 | // unpacking a component should produce a valid component and be lossless 20 | r = color.getRed(value); 21 | assert(r === 0); 22 | assert(color.isValidComponent(r)); 23 | 24 | // non-zero values should also work 25 | r = 100; 26 | b = 50; 27 | g = 30; 28 | a = 10; 29 | value = color.pack(r, g, b, a); 30 | assert(color.isValid(value)); 31 | 32 | // unpacking of non-zero values should be lossless and produce valid components 33 | assert(color.getRed(value) === r); 34 | assert(color.getBlue(value) === b); 35 | assert(color.getGreen(value) === g); 36 | assert(color.isValidComponent(color.getRed(value))); 37 | assert(color.isValidComponent(color.getBlue(value))); 38 | assert(color.isValidComponent(color.getGreen(value))); 39 | 40 | // TODO: test lerp 41 | // TODO: test blend 42 | } 43 | 44 | TestRegistry.registerTest(colorTest); 45 | -------------------------------------------------------------------------------- /src/test/count-resources-test.js: -------------------------------------------------------------------------------- 1 | import * as databaseUtils from '/src/test/database-utils.js'; 2 | import * as indexedDBUtils from '/src/lib/indexeddb-utils.js'; 3 | import TestRegistry from '/src/test/test-registry.js'; 4 | import assert from '/src/lib/assert.js'; 5 | import countResources from '/src/db/count-resources.js'; 6 | import createResource from '/src/db/create-resource.js'; 7 | 8 | async function countResourcesTest() { 9 | const databaseNamePrefix = 'count-resources-test'; 10 | await databaseUtils.removeDatabasesForPrefix(databaseNamePrefix); 11 | const databaseName = databaseUtils.createUniqueDatabaseName(databaseNamePrefix); 12 | const conn = await databaseUtils.createTestDatabase(databaseName); 13 | 14 | // Verify counting nothing is 0 15 | let count = await countResources(conn, { read: 0, type: 'entry' }); 16 | assert(count === 0); 17 | 18 | const createPromises = []; 19 | for (let i = 0; i < 8; i += 1) { 20 | const resource = { read: (i > 2 ? 1 : 0), title: `test ${i}`, type: 'entry' }; 21 | createPromises.push(createResource(conn, resource)); 22 | } 23 | await Promise.all(createPromises); 24 | 25 | count = await countResources(conn, { read: 0, type: 'entry' }); 26 | assert(count === 3); 27 | 28 | conn.close(); 29 | await indexedDBUtils.remove(conn.conn.name); 30 | } 31 | 32 | TestRegistry.registerTest(countResourcesTest); 33 | -------------------------------------------------------------------------------- /src/test/create-resource-test.js: -------------------------------------------------------------------------------- 1 | import * as databaseUtils from '/src/test/database-utils.js'; 2 | import * as indexedDBUtils from '/src/lib/indexeddb-utils.js'; 3 | import * as resourceUtils from '/src/db/resource-utils.js'; 4 | import TestRegistry from '/src/test/test-registry.js'; 5 | import assert from '/src/lib/assert.js'; 6 | import createResource from '/src/db/create-resource.js'; 7 | import getResource from '/src/db/get-resource.js'; 8 | 9 | async function createResourceTest() { 10 | const databaseNamePrefix = 'create-resource-test'; 11 | await databaseUtils.removeDatabasesForPrefix(databaseNamePrefix); 12 | const databaseName = databaseUtils.createUniqueDatabaseName(databaseNamePrefix); 13 | 14 | const conn = await databaseUtils.createTestDatabase(databaseName); 15 | 16 | const resource = {}; 17 | resource.type = 'feed'; 18 | const url = new URL('a://b.c'); 19 | resourceUtils.setURL(resource, url); 20 | 21 | const id = await createResource(conn, resource); 22 | assert(resourceUtils.isValidId(id)); 23 | 24 | let match = await getResource(conn, { mode: 'id', id, keyOnly: false }); 25 | assert(match); 26 | 27 | match = await getResource(conn, { mode: 'url', url, keyOnly: true }); 28 | assert(match); 29 | 30 | // Creating a feed without a url is an error 31 | delete resource.urls; 32 | let expectedError; 33 | try { 34 | await createResource(conn, resource); 35 | } catch (error) { 36 | expectedError = error; 37 | } 38 | assert(expectedError); 39 | 40 | // Creating a feed that has an id but is otherwise valid is an error 41 | resourceUtils.setURL(resource, url); 42 | resource.id = id; 43 | expectedError = undefined; 44 | try { 45 | await createResource(conn, resource); 46 | } catch (error) { 47 | expectedError = error; 48 | } 49 | assert(expectedError); 50 | 51 | // Creating a duplicate resource is an error 52 | expectedError = undefined; 53 | delete resource.id; 54 | try { 55 | await createResource(conn, resource); 56 | } catch (error) { 57 | expectedError = error; 58 | } 59 | assert(expectedError); 60 | 61 | conn.close(); 62 | await indexedDBUtils.remove(conn.conn.name); 63 | } 64 | 65 | TestRegistry.registerTest(createResourceTest); 66 | -------------------------------------------------------------------------------- /src/test/css-background-image-filter-test.js: -------------------------------------------------------------------------------- 1 | import TestRegistry from '/src/test/test-registry.js'; 2 | import assert from '/src/lib/assert.js'; 3 | import backgroundImageFilter from '/src/lib/dom-filters/css-background-image-filter.js'; 4 | 5 | function backgroundImageFilterTest() { 6 | // Create a simple dom, then run the filter on it 7 | 8 | const document = createDocument('Background image filter test'); 9 | 10 | const url = 'https://www.example.com/example.gif'; 11 | const containerWithBackgroundImage = document.createElement('div'); 12 | containerWithBackgroundImage.style.backgroundImage = `url("${url}")`; 13 | document.body.append(containerWithBackgroundImage); 14 | 15 | backgroundImageFilter(document); 16 | 17 | const image = document.querySelector(`img[src="${url}"]`); 18 | assert(image); 19 | } 20 | 21 | function backgroundImageFilterWinterIsComingTest() { 22 | const document = createDocument('Background image filter test'); 23 | document.body.innerHTML = ``; 30 | 31 | backgroundImageFilter(document); 32 | } 33 | 34 | function createDocument(title) { 35 | return document.implementation.createHTMLDocument(title); 36 | } 37 | 38 | TestRegistry.registerTest(backgroundImageFilterTest); 39 | TestRegistry.registerTest(backgroundImageFilterWinterIsComingTest); 40 | -------------------------------------------------------------------------------- /src/test/database-utils.js: -------------------------------------------------------------------------------- 1 | import * as db from '/src/db/db.js'; 2 | import { INDEFINITE } from '/src/lib/deadline.js'; 3 | import { open, remove } from '/src/lib/indexeddb-utils.js'; 4 | import RecordingChannel from '/src/test/recording-channel.js'; 5 | import assert from '/src/lib/assert.js'; 6 | 7 | // A global counter that resides in memory. 8 | let nameCounter = 0; 9 | const databaseNames = []; 10 | 11 | // Create a unique database name given a prefix 12 | export function createUniqueDatabaseName(prefix) { 13 | assert(typeof prefix === 'string'); 14 | const name = `${prefix}-${nameCounter}`; 15 | nameCounter += 1; 16 | databaseNames.push(name); 17 | console.debug('Created database name:', name); 18 | return name; 19 | } 20 | 21 | // Find any database names that start with the prefix and remove them. Returns a promise that 22 | // resolves when all remove operations complete. 23 | export function removeDatabasesForPrefix(prefix) { 24 | const previousNames = findDatabaseFullNames(prefix); 25 | const promises = previousNames.map((name) => { 26 | console.debug('Removing database with name', name); 27 | return remove(name); 28 | }); 29 | return Promise.all(promises); 30 | } 31 | 32 | // Given a database name prefix, finds the full database names in the list of database names 33 | // previously created that start with the prefix. 34 | export function findDatabaseFullNames(prefix) { 35 | return databaseNames.filter(name => name.startsWith(prefix)); 36 | } 37 | 38 | // Open a database connection for testing purposes. If an upgrade handler is not specified then this 39 | // uses the same upgrade handler as db.open 40 | export async function createTestDatabase(name, version = db.defaultVersion, 41 | upgrade_handler = db.defaultUpgradeNeededHandler) { 42 | // A custom name is required in the test context. We also impose a non-zero length guard just 43 | // because that is reasonable. This would be caught later by the open call but I like being 44 | // explicit. We could also impose that it is not equal to the app's live channel, but I decided 45 | // not to in case a test wants to exercise the live channel. 46 | assert(name && typeof name === 'string'); 47 | 48 | const conn = new db.Connection(); 49 | 50 | // Presumably, all tests use some channel other than the app's default channel to avoid tests 51 | // having any unexpected side effect on the live app. Note that for now all tests use the same 52 | // channel name, so keep this in mind if running tests concurrently. 53 | // TODO: channel-name should be a parameter and then set here so tests can isolate the channel in 54 | // order to make stronger assertions. 55 | conn.channel = new RecordingChannel(); 56 | 57 | // Presumably, the test context does not care about the time it takes to open a connection. This 58 | // is also the default for open, but I like being explicit. 59 | const timeout = INDEFINITE; 60 | 61 | // Wrap the handler instead of using bind so as to maintain the current context of the handler 62 | // function in the case it is bound to something other than the default context. 63 | const handlerWrapperFunction = (event) => { 64 | // channel comes first as an artifact of prior implementation that used bind to create a 65 | // partial. we no longer use bind, but keeping it this way in case we revert to bind. just note 66 | // the awkward parameter order. 67 | upgrade_handler(conn.channel, event); 68 | }; 69 | 70 | conn.conn = await open(name, version, handlerWrapperFunction, timeout); 71 | return conn; 72 | } 73 | -------------------------------------------------------------------------------- /src/test/delete-resource-test.js: -------------------------------------------------------------------------------- 1 | import * as databaseUtils from '/src/test/database-utils.js'; 2 | import * as indexedDBUtils from '/src/lib/indexeddb-utils.js'; 3 | import TestRegistry from '/src/test/test-registry.js'; 4 | import assert from '/src/lib/assert.js'; 5 | import createResource from '/src/db/create-resource.js'; 6 | import deleteResource from '/src/db/delete-resource.js'; 7 | import getResource from '/src/db/get-resource.js'; 8 | 9 | async function deleteResourceTest() { 10 | const databaseNamePrefix = 'delete-resource-test'; 11 | await databaseUtils.removeDatabasesForPrefix(databaseNamePrefix); 12 | const databaseName = databaseUtils.createUniqueDatabaseName(databaseNamePrefix); 13 | 14 | const conn = await databaseUtils.createTestDatabase(databaseName); 15 | 16 | const feed = { type: 'feed', urls: ['a://b.c'] }; 17 | const feedId = await createResource(conn, feed); 18 | const entry = { type: 'entry', parent: feedId }; 19 | await createResource(conn, entry); 20 | 21 | await deleteResource(conn, feedId, 'test'); 22 | 23 | const match = await getResource(conn, { mode: 'id', id: feedId }); 24 | assert(!match); 25 | 26 | assert(conn.channel.messages.length); 27 | 28 | conn.close(); 29 | await indexedDBUtils.remove(conn.conn.name); 30 | } 31 | 32 | TestRegistry.registerTest(deleteResourceTest); 33 | -------------------------------------------------------------------------------- /src/test/favicon-fetch-image-test.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfroelich/rss-reader/b7c777a79e22ae9a9659a224a9364aeca5449ea9/src/test/favicon-fetch-image-test.png -------------------------------------------------------------------------------- /src/test/feed-parser-test.js: -------------------------------------------------------------------------------- 1 | // import assert from '/src/lib/assert.js'; 2 | // import * as feedParser from '/src/lib/feed-parser.js'; 3 | // import TestRegistry from '/src/test/test-registry.js'; 4 | 5 | // TODO: implement. note that this should run by loading a local resource, or create a document in 6 | // memory during the test 7 | 8 | // export default function parseFeedTest() { 9 | 10 | // } 11 | -------------------------------------------------------------------------------- /src/test/fetch-html-test.js: -------------------------------------------------------------------------------- 1 | // import assert from '/src/lib/assert.js'; 2 | // import fetchHTML from '/src/lib/fetch-html.js'; 3 | // import TestRegistry from '/src/test/test-registry.js'; 4 | 5 | // TODO: run on a local resource 6 | // TODO: cannot accept param 7 | // TODO: assert something, do not just log 8 | 9 | // async function fetchHTMLTest() { 10 | // let url_string = undefined; 11 | // let timeout = undefined; 12 | // const request_url = new URL(url_string); 13 | // const response = await fetchHTML(request_url, {timeout: timeout}); 14 | // console.dir(response); 15 | // const response_text = await response.text(); 16 | // console.log(response_text); 17 | // return response; 18 | // } 19 | -------------------------------------------------------------------------------- /src/test/fetch-image-element-test.js: -------------------------------------------------------------------------------- 1 | // import TestRegistry from '/src/test/test-registry.js'; 2 | // import assert from '/src/lib/assert.js'; 3 | 4 | // async function fetchImageElementTest() { 5 | // TODO: implement me 6 | // } 7 | -------------------------------------------------------------------------------- /src/test/filter-publisher-test.js: -------------------------------------------------------------------------------- 1 | import TestRegistry from '/src/test/test-registry.js'; 2 | import assert from '/src/lib/assert.js'; 3 | import filterPublisher from '/src/lib/filter-publisher.js'; 4 | 5 | function filterPublisherTest() { 6 | const pairs = []; 7 | 8 | // normal use 9 | pairs.push({ 10 | input: 'Hello World Hello World - Big News Org', 11 | output: 'Hello World Hello World' 12 | }); 13 | 14 | // title without delimiter 15 | pairs.push({ input: 'Hello World', output: 'Hello World' }); 16 | // starting with delimiter 17 | pairs.push({ input: ' - Hello World', output: ' - Hello World' }); 18 | // ending with delimiter 19 | pairs.push({ input: 'Hello World - ', output: 'Hello World - ' }); 20 | // non-default delimiter 21 | pairs.push({ input: 'Hello ; World', output: 'Hello ; World' }); 22 | // double delimiter 23 | pairs.push({ 24 | input: 'Hello - World Hello abcd - World', 25 | output: 'Hello - World Hello abcd' 26 | }); 27 | // mixed double delimiter 28 | pairs.push({ 29 | input: 'Hello : World Hello abcd - World', 30 | output: 'Hello : World Hello abcd' 31 | }); 32 | // short title 33 | pairs.push({ 34 | input: 'Hello World - Big News Org', 35 | output: 'Hello World - Big News Org' 36 | }); 37 | // even shorter title 38 | pairs.push({ input: 'a - Big News Org', output: 'a - Big News Org' }); 39 | 40 | // short title long publisher 41 | pairs.push({ 42 | input: 'a - BigNewsOrgBigNewsOrgBigNewsOrg', 43 | output: 'a - BigNewsOrgBigNewsOrgBigNewsOrg' 44 | }); 45 | 46 | // short title long publisher multiword 47 | pairs.push({ 48 | input: 'a - BBBBBBBig NNNNNNNews OOOOOOOrg', 49 | output: 'a - BBBBBBBig NNNNNNNews OOOOOOOrg' 50 | }); 51 | 52 | // long title short publisher 53 | pairs.push({ 54 | input: 'AAAAAAAAAAAAAABBBBBBBBBBCCCCCCCCCCCCCCC - D', 55 | output: 'AAAAAAAAAAAAAABBBBBBBBBBCCCCCCCCCCCCCCC - D' 56 | }); 57 | 58 | // too many words after delim 59 | pairs.push({ 60 | input: 'Hello World Hello World - Too Many Words In Tail Found', 61 | output: 'Hello World Hello World - Too Many Words In Tail Found' 62 | }); 63 | 64 | for (const { input, output } of pairs) { 65 | assert(filterPublisher(input) === output); 66 | } 67 | } 68 | 69 | TestRegistry.registerTest(filterPublisherTest); 70 | -------------------------------------------------------------------------------- /src/test/filter-unprintables-tests.js: -------------------------------------------------------------------------------- 1 | import TestRegistry from '/src/test/test-registry.js'; 2 | import assert from '/src/lib/assert.js'; 3 | import filterUnprintables from '/src/lib/filter-unprintables.js'; 4 | 5 | function filterUnprintablesTest() { 6 | for (let i = 0; i < 9; i += 1) { 7 | assert(filterUnprintables(String.fromCharCode(i)).length === 0); 8 | } 9 | 10 | assert(filterUnprintables('\t').length === 1); // 9 11 | assert(filterUnprintables('\n').length === 1); // 10 12 | assert(filterUnprintables(String.fromCharCode(11)).length === 0); 13 | assert(filterUnprintables('\f').length === 1); // 12 14 | assert(filterUnprintables('\r').length === 1); // 13 15 | 16 | const spaceCode = ' '.charCodeAt(0); 17 | for (let i = 14; i < spaceCode; i += 1) { 18 | assert(filterUnprintables(String.fromCharCode(i)).length === 0); 19 | } 20 | 21 | assert(filterUnprintables(' ').length === 1); 22 | assert(filterUnprintables('Hello').length === 5); 23 | assert(filterUnprintables('World').length === 5); 24 | assert(filterUnprintables('Hello\nWorld').length === 11); 25 | assert(filterUnprintables('Hello\u0000World').length === 10); 26 | assert(filterUnprintables('text').length === 15); 27 | } 28 | 29 | TestRegistry.registerTest(filterUnprintablesTest); 30 | -------------------------------------------------------------------------------- /src/test/get-path-extension-test.js: -------------------------------------------------------------------------------- 1 | import TestRegistry from '/src/test/test-registry.js'; 2 | import assert from '/src/lib/assert.js'; 3 | import getPathExtension from '/src/lib/get-path-extension.js'; 4 | 5 | function getPathExtensionTest() { 6 | // Exercise the normal case 7 | let result = getPathExtension('/b.html'); 8 | assert(result === 'html', `result: ${result}`); 9 | 10 | // invalid path (missing leading slash) should fail 11 | result = getPathExtension('foo'); 12 | assert(!result); 13 | 14 | // no extension should fail 15 | result = getPathExtension('/foo'); 16 | assert(!result); 17 | 18 | // Should fail without error when there is a trailing period 19 | result = getPathExtension('/b.'); 20 | assert(!result); 21 | 22 | result = getPathExtension('/.htaccess'); 23 | assert(result === 'htaccess'); 24 | 25 | // Should fail without error when the extension has too many characters, here we use the explicit 26 | // value despite it being equal to the default for the moment, to insulate against change to the 27 | // default and use express expression 28 | result = getPathExtension('/b.01234567890123456789asdf', 10); 29 | assert(!result); 30 | 31 | // period in path not filename 32 | result = getPathExtension('/a.b/c'); 33 | assert(!result, `result: ${result}`); 34 | } 35 | 36 | TestRegistry.registerTest(getPathExtensionTest); 37 | -------------------------------------------------------------------------------- /src/test/get-resource-test.js: -------------------------------------------------------------------------------- 1 | import * as databaseUtils from '/src/test/database-utils.js'; 2 | import * as indexedDBUtils from '/src/lib/indexeddb-utils.js'; 3 | import TestRegistry from '/src/test/test-registry.js'; 4 | import assert from '/src/lib/assert.js'; 5 | import createResource from '/src/db/create-resource.js'; 6 | import getResource from '/src/db/get-resource.js'; 7 | 8 | async function getResourceTest() { 9 | const databaseNamePrefix = 'get-resource-test'; 10 | await databaseUtils.removeDatabasesForPrefix(databaseNamePrefix); 11 | const databaseName = databaseUtils.createUniqueDatabaseName(databaseNamePrefix); 12 | const conn = await databaseUtils.createTestDatabase(databaseName); 13 | 14 | const resource = { type: 'entry', title: 'test' }; 15 | const id = await createResource(conn, resource); 16 | const match = await getResource(conn, { mode: 'id', id }); 17 | assert(match); 18 | assert(match.id === id); 19 | assert(match.type === 'entry'); 20 | assert(match.title === 'test'); 21 | 22 | conn.close(); 23 | await indexedDBUtils.remove(conn.conn.name); 24 | } 25 | 26 | TestRegistry.registerTest(getResourceTest); 27 | -------------------------------------------------------------------------------- /src/test/get-resources-test.js: -------------------------------------------------------------------------------- 1 | import * as databaseUtils from '/src/test/database-utils.js'; 2 | import * as indexedDBUtils from '/src/lib/indexeddb-utils.js'; 3 | import TestRegistry from '/src/test/test-registry.js'; 4 | import assert from '/src/lib/assert.js'; 5 | import createResource from '/src/db/create-resource.js'; 6 | import getResources from '/src/db/get-resources.js'; 7 | 8 | async function getResourcesTest() { 9 | const databaseNamePrefix = 'get-resources-test'; 10 | await databaseUtils.removeDatabasesForPrefix(databaseNamePrefix); 11 | const databaseName = databaseUtils.createUniqueDatabaseName(databaseNamePrefix); 12 | const conn = await databaseUtils.createTestDatabase(databaseName); 13 | 14 | const createPromises = []; 15 | for (let i = 0; i < 5; i += 1) { 16 | const title = `test${i}`; 17 | const type = 'entry'; 18 | createPromises.push(createResource(conn, { title, type })); 19 | } 20 | await Promise.all(createPromises); 21 | 22 | const mode = 'all'; 23 | const resources = await getResources(conn, { mode }); 24 | assert(resources.length === 5); 25 | 26 | conn.close(); 27 | await indexedDBUtils.remove(conn.conn.name); 28 | } 29 | 30 | TestRegistry.registerTest(getResourcesTest); 31 | -------------------------------------------------------------------------------- /src/test/import-entry-tests.js: -------------------------------------------------------------------------------- 1 | import * as localStorageUtils from '/src/lib/local-storage-utils.js'; 2 | import { rewriteURL } from '/src/service/import-entry.js'; 3 | import TestRegistry from '/src/test/test-registry.js'; 4 | import assert from '/src/lib/assert.js'; 5 | 6 | // Exercise the rewrite-url helper 7 | async function importEntryTests() { 8 | const rules = localStorageUtils.readArray('rewrite_rules'); 9 | 10 | let a = new URL('https://www.google.com'); 11 | let b = rewriteURL(a, rules); 12 | assert(b.href === a.href); 13 | 14 | a = new URL('https://news.google.com/news/url'); 15 | a.searchParams.set('url', 'https://www.google.com'); 16 | b = rewriteURL(a, rules); 17 | assert(b.href === 'https://www.google.com/', 'google news'); 18 | 19 | a = new URL('https://techcrunch.com'); 20 | a.searchParams.set('ncid', '1234'); 21 | b = rewriteURL(a, rules); 22 | assert(b.href === 'https://techcrunch.com/', 'techcrunch'); 23 | 24 | a = new URL('https://news.google.com/news/url'); 25 | a.searchParams.set('url', 'https://techcrunch.com/foo?ncid=2'); 26 | b = rewriteURL(a, rules); 27 | assert(b.href === 'https://techcrunch.com/foo', `cyclical ${b.href}`); 28 | } 29 | 30 | TestRegistry.registerTest(importEntryTests); 31 | -------------------------------------------------------------------------------- /src/test/import-opml-test.js: -------------------------------------------------------------------------------- 1 | import * as databaseUtils from '/src/test/database-utils.js'; 2 | import * as db from '/src/db/db.js'; 3 | import * as indexedDBUtils from '/src/lib/indexeddb-utils.js'; 4 | import TestRegistry from '/src/test/test-registry.js'; 5 | import assert from '/src/lib/assert.js'; 6 | import importOPML from '/src/service/import-opml.js'; 7 | 8 | async function importOPMLTest() { 9 | const databaseNamePrefix = 'import-opml-test'; 10 | await databaseUtils.removeDatabasesForPrefix(databaseNamePrefix); 11 | const databaseName = databaseUtils.createUniqueDatabaseName(databaseNamePrefix); 12 | 13 | const conn = await databaseUtils.createTestDatabase(databaseName); 14 | 15 | const opmlString = ` 16 | 17 | `; 18 | 19 | // Mimic a File by creating a Blob, as File implements the Blob interface 20 | const file = new Blob([opmlString], { type: 'application/xml' }); 21 | file.name = 'file.xml'; 22 | 23 | const results = await importOPML(conn, [file]); 24 | 25 | assert(results); 26 | assert(results.length === 1); 27 | assert(db.isValidId(results[0])); 28 | assert(conn.channel.messages.length); 29 | assert(conn.channel.messages[0].type === 'resource-created'); 30 | assert(conn.channel.messages[0].id === 1); 31 | 32 | conn.close(); 33 | await indexedDBUtils.remove(conn.conn.name); 34 | } 35 | 36 | TestRegistry.registerTest(importOPMLTest); 37 | -------------------------------------------------------------------------------- /src/test/mime-utils-test.js: -------------------------------------------------------------------------------- 1 | import * as mime from '/src/lib/mime-utils.js'; 2 | import TestRegistry from '/src/test/test-registry.js'; 3 | import assert from '/src/lib/assert.js'; 4 | 5 | function mimeUtilsTest() { 6 | const a = assert; 7 | const pct = mime.parseContentType; 8 | 9 | // constants tests (compensate for lack of static assertions) 10 | a(mime.MIN_LENGTH < mime.MAX_LENGTH); 11 | a(mime.MIN_LENGTH >= 0); 12 | a(mime.MAX_LENGTH >= 0); 13 | 14 | // no input 15 | a(!pct()); 16 | 17 | // unsupported types 18 | a(!pct(1234)); 19 | a(!pct(false)); 20 | a(!pct(null)); 21 | a(!pct([])); 22 | a(!pct({})); 23 | 24 | // short input 25 | a(!pct('')); 26 | a(!pct('a')); 27 | 28 | // long input 29 | let longString = ''; 30 | for (let i = 0; i < 100; i += 1) { 31 | longString += 'abc'; 32 | } 33 | a(!pct(longString)); 34 | 35 | // case normalization 36 | a(pct('TEXT/HTML') === 'text/html'); 37 | a(pct('TeXt/HTmL') === 'text/html'); 38 | 39 | // no semicolon, no character encoding 40 | a(pct('text/html') === 'text/html'); 41 | 42 | // TODO: if header values in http responses are supposed to be a single line, then maybe it is 43 | // more correct to not support line breaks 44 | 45 | // extra trimmable whitespace 46 | a(pct(' \t\ntext/html \n\t ') === 'text/html'); 47 | // typical input with character encoding without leading space 48 | a(pct('text/html;charset=UTF-8') === 'text/html'); 49 | // typical input with space leading character encoding 50 | a(pct('text/html; charset=UTF-8') === 'text/html'); 51 | 52 | // extra intermediate whitespace 53 | a(pct('text / html') === 'text/html'); 54 | // extra intermediate and wrapping whitespace 55 | a(pct(' text / html ') === 'text/html'); 56 | 57 | // fictional mime type 58 | a(pct('foofoo/barbar') === 'foofoo/barbar'); 59 | 60 | // duplicate slash 61 | // TODO: eventually the parse function should be revised to return undefined, for now this test 62 | // just documents this pathological case 63 | a(pct('text/src/lib/html/foo') === 'text/src/lib/html/foo'); 64 | 65 | // duplicate semicolon 66 | // TODO: eventually the parse function should be revised to return undefined, for now just 67 | // document this case 68 | a(pct('text/html;;charset=UTF-8') === 'text/html'); 69 | 70 | // isValid tests 71 | a(mime.isValid('text/html')); 72 | a(mime.isValid('text/xml')); 73 | a(!mime.isValid(false)); 74 | a(!mime.isValid(true)); 75 | a(!mime.isValid(-4321)); 76 | a(!mime.isValid('asdf')); 77 | a(!mime.isValid('a b c')); 78 | a(!mime.isValid('a b c / 123')); 79 | a(!mime.isValid('text\\xml')); 80 | } 81 | 82 | TestRegistry.registerTest(mimeUtilsTest); 83 | -------------------------------------------------------------------------------- /src/test/opml-test.js: -------------------------------------------------------------------------------- 1 | import * as DBService from '/src/service/db-service.js'; 2 | import * as databaseUtils from '/src/test/database-utils.js'; 3 | import * as db from '/src/db/db.js'; 4 | import * as indexedDBUtils from '/src/lib/indexeddb-utils.js'; 5 | import * as opml from '/src/lib/opml.js'; 6 | import TestRegistry from '/src/test/test-registry.js'; 7 | import assert from '/src/lib/assert.js'; 8 | 9 | // Exercise the typical usage of export-opml 10 | async function exportOPMLTest() { 11 | const databaseNamePrefix = 'export-opml-test'; 12 | await databaseUtils.removeDatabasesForPrefix(databaseNamePrefix); 13 | const databaseName = databaseUtils.createUniqueDatabaseName(databaseNamePrefix); 14 | const conn = await databaseUtils.createTestDatabase(databaseName); 15 | 16 | // Insert some test feeds 17 | const resources = []; 18 | for (let i = 0; i < 3; i += 1) { 19 | const resource = {}; 20 | db.setURL(resource, new URL(`a://b.c${i}`)); 21 | resource.type = 'feed'; 22 | resource.feed_format = 'DBService'; 23 | resource.title = `feed-title-${i}`; 24 | resource.description = `feed-description-${i}`; 25 | resource.link = `https://www.example.com/${i}`; 26 | resources.push(resource); 27 | } 28 | 29 | const promises = []; 30 | for (const resource of resources) { 31 | promises.push(DBService.createFeed(conn, resource)); 32 | } 33 | await Promise.all(promises); 34 | 35 | // Practice similar steps to what the UI would do. Load the resources back from the database and 36 | // convert them into outlines 37 | const readResources = await DBService.getFeeds(conn, { mode: 'feeds' }); 38 | const outlines = readResources.map((resource) => { 39 | const outline = {}; 40 | outline.type = resource.feed_format; 41 | 42 | // Although the model dictates all feeds have a url, make no assumption here. Grab the last url 43 | // of the array as the representation of the resource's current url. 44 | if (resource.urls && resource.urls.length) { 45 | outline.xmlUrl = resource.urls[resource.urls.length - 1]; 46 | } 47 | 48 | outline.title = resource.title; 49 | outline.description = resource.description; 50 | outline.htmlUrl = resource.link; 51 | return outline; 52 | }); 53 | 54 | // It is implied but this should not throw an exception 55 | const document = await opml.createDocument('test-title'); 56 | opml.appendOutlines(document, outlines); 57 | 58 | // export-opml should generate a Document object 59 | assert(document instanceof Document); 60 | 61 | // The title should be set if specified 62 | const titleElement = document.querySelector('title'); 63 | assert(titleElement); 64 | assert(titleElement.textContent === 'test-title'); 65 | 66 | // The correct number of outlines should have been generated 67 | const outlineElements = document.querySelectorAll('outline'); 68 | assert(outlineElements.length === resources.length); 69 | 70 | // For each feed that has a url, it should have a corresponding outline based on the outline's 71 | // xmlurl attribute value. 72 | for (const feed of resources) { 73 | const url = new URL(feed.urls[feed.urls.length - 1]); 74 | const selector = `outline[xmlUrl="${url.href}"]`; 75 | const outline = document.querySelector(selector); 76 | assert(outline instanceof Element); 77 | } 78 | 79 | conn.close(); 80 | await indexedDBUtils.remove(conn.conn.name); 81 | } 82 | 83 | TestRegistry.registerTest(exportOPMLTest); 84 | -------------------------------------------------------------------------------- /src/test/patch-resource-test.js: -------------------------------------------------------------------------------- 1 | import * as databaseUtils from '/src/test/database-utils.js'; 2 | import * as indexedDBUtils from '/src/lib/indexeddb-utils.js'; 3 | import TestRegistry from '/src/test/test-registry.js'; 4 | import assert from '/src/lib/assert.js'; 5 | import createResource from '/src/db/create-resource.js'; 6 | import getResource from '/src/db/get-resource.js'; 7 | import patchResource from '/src/db/patch-resource.js'; 8 | 9 | async function patchResourceTest() { 10 | const databaseNamePrefix = 'patch-resource-test'; 11 | await databaseUtils.removeDatabasesForPrefix(databaseNamePrefix); 12 | const databaseName = databaseUtils.createUniqueDatabaseName(databaseNamePrefix); 13 | const conn = await databaseUtils.createTestDatabase(databaseName); 14 | 15 | const id = await createResource(conn, { type: 'entry', read: 0 }); 16 | let match = await getResource(conn, { mode: 'id', id }); 17 | assert(match); 18 | assert(match.read === 0); 19 | 20 | await patchResource(conn, { id, read: 1 }); 21 | match = await getResource(conn, { mode: 'id', id }); 22 | assert(match); 23 | assert(match.read === 1); 24 | 25 | conn.close(); 26 | await indexedDBUtils.remove(conn.conn.name); 27 | } 28 | 29 | TestRegistry.registerTest(patchResourceTest); 30 | -------------------------------------------------------------------------------- /src/test/put-resource-test.js: -------------------------------------------------------------------------------- 1 | import * as databaseUtils from '/src/test/database-utils.js'; 2 | import * as indexedDBUtils from '/src/lib/indexeddb-utils.js'; 3 | import TestRegistry from '/src/test/test-registry.js'; 4 | import assert from '/src/lib/assert.js'; 5 | import createResource from '/src/db/create-resource.js'; 6 | import getResource from '/src/db/get-resource.js'; 7 | import putResource from '/src/db/put-resource.js'; 8 | 9 | async function putResourceTest() { 10 | const databaseNamePrefix = 'put-resource-test'; 11 | await databaseUtils.removeDatabasesForPrefix(databaseNamePrefix); 12 | const databaseName = databaseUtils.createUniqueDatabaseName(databaseNamePrefix); 13 | const conn = await databaseUtils.createTestDatabase(databaseName); 14 | 15 | const title = 'first'; 16 | const type = 'entry'; 17 | const parent = 1; // fake parent resource id 18 | const id = await createResource(conn, { title, type, parent }); 19 | const mode = 'id'; 20 | let resource = await getResource(conn, { mode, id }); 21 | assert(resource); 22 | assert(resource.title === 'first'); 23 | 24 | resource.title = 'second'; 25 | await putResource(conn, resource); 26 | resource = await getResource(conn, { mode, id }); 27 | assert(resource); 28 | assert(resource.id === id); 29 | assert(resource.title === 'second'); 30 | 31 | conn.close(); 32 | await indexedDBUtils.remove(conn.conn.name); 33 | } 34 | 35 | TestRegistry.registerTest(putResourceTest); 36 | -------------------------------------------------------------------------------- /src/test/recording-channel.js: -------------------------------------------------------------------------------- 1 | // A simple mock implementation of a channel that just records locally sent messages 2 | export default function RecordingChannel() { 3 | this.messages = []; 4 | this.name = 'recording-channel'; 5 | } 6 | 7 | RecordingChannel.prototype.postMessage = function (message) { 8 | this.messages.push(message); 9 | }; 10 | 11 | RecordingChannel.prototype.close = function () { 12 | // noop 13 | }; 14 | -------------------------------------------------------------------------------- /src/test/remove-html-test.js: -------------------------------------------------------------------------------- 1 | import TestRegistry from '/src/test/test-registry.js'; 2 | import assert from '/src/lib/assert.js'; 3 | import removeHTML from '/src/lib/remove-html.js'; 4 | 5 | // TODO: Text with an entity that is not kept 6 | // TODO: test malformed html that causes error 7 | // TODO: test text outside of body 8 | // TODO: whitespace normalization 9 | 10 | function removeHTMLTest() { 11 | // No html tags or entities undergoes no change 12 | let input = 'some text without html'; 13 | let output = removeHTML(input); 14 | assert(input === output); 15 | 16 | // One html tag, no entities, tag is removed 17 | input = '

    paragraph

    '; 18 | output = removeHTML(input); 19 | assert(output === 'paragraph'); 20 | 21 | // A couple html tags, no entities 22 | input = '

    bold afterspace

    '; 23 | output = removeHTML(input); 24 | assert(output === 'bold afterspace'); 25 | 26 | // Text with an entity that is lost by transformation. represents a space. 27 | input = 'before after'; 28 | output = removeHTML(input); 29 | assert(output === 'before after'); 30 | 31 | // Text with an entity that is lost by transformation 32 | input = 'before©after'; 33 | output = removeHTML(input); 34 | assert(output === 'before©after'); 35 | } 36 | 37 | TestRegistry.registerTest(removeHTMLTest); 38 | -------------------------------------------------------------------------------- /src/test/resource-utils-tests.js: -------------------------------------------------------------------------------- 1 | import * as resourceUtils from '/src/db/resource-utils.js'; 2 | import TestRegistry from '/src/test/test-registry.js'; 3 | import assert from '/src/lib/assert.js'; 4 | 5 | function isValidIdTest() { 6 | assert(!resourceUtils.isValidId(-1)); 7 | assert(!resourceUtils.isValidId(0)); 8 | assert(!resourceUtils.isValidId('hello')); 9 | assert(!resourceUtils.isValidId(true)); 10 | assert(!resourceUtils.isValidId(false)); 11 | assert(resourceUtils.isValidId(1)); 12 | assert(resourceUtils.isValidId(123456789)); 13 | } 14 | 15 | function appendURLTest() { 16 | // Append a url 17 | const resource = {}; 18 | let appended = resourceUtils.setURL(resource, new URL('a://b.c1')); 19 | assert(appended === true); 20 | assert(resource.urls); 21 | assert(resource.urls.length === 1); 22 | 23 | // Append a second url 24 | const url2 = new URL('a://b.c2'); 25 | appended = resourceUtils.setURL(resource, url2); 26 | assert(appended); 27 | assert(resource.urls); 28 | assert(resource.urls.length === 2); 29 | 30 | // Append a duplicate 31 | appended = resourceUtils.setURL(resource, url2); 32 | assert(!appended); 33 | assert(resource.urls.length === 2); 34 | } 35 | 36 | function normalizeResourceTest() { 37 | let resource = {}; 38 | // test when missing fields 39 | resourceUtils.normalize(resource); 40 | // should not have somehow introduced values where none existed 41 | assert(resource.author === undefined); 42 | assert(resource.title === undefined); 43 | assert(resource.content === undefined); 44 | 45 | // wrong property type should raise error 46 | resource.author = 1234; 47 | let expectedError; 48 | try { 49 | resourceUtils.normalize(resource); 50 | } catch (error) { 51 | expectedError = error; 52 | } 53 | assert(expectedError instanceof Error); 54 | 55 | // test basic strings where no change expected 56 | resource = {}; 57 | resource.author = 'foo'; 58 | resource.title = 'bar'; 59 | resource.content = 'baz'; 60 | 61 | // should run without error 62 | resourceUtils.normalize(resource); 63 | 64 | // values should be the same 65 | assert(resource.author === 'foo'); 66 | assert(resource.title === 'bar'); 67 | assert(resource.content === 'baz'); 68 | 69 | // Now test a case where a value is modified as a result of normalization 70 | // https://unicode.org/reports/tr15/ 71 | resource = {}; 72 | assert('Å' === '\u212b'); // not normalized 73 | assert('Å' === '\u00c5'); // normalized 74 | resource.author = '\u212b'; 75 | resourceUtils.normalize(resource); 76 | assert(resource.author === '\u00c5', escapeUnicodeString(resource.author)); 77 | 78 | // test idempotency 79 | resourceUtils.normalize(resource); 80 | assert(resource.author === '\u00c5', escapeUnicodeString(resource.author)); 81 | } 82 | 83 | // https://stackoverflow.com/questions/21014476 84 | function escapeUnicodeString(string) { 85 | return string.replace(/[^\0-~]/g, ch => `\\u${(`000${ch.charCodeAt(0).toString(16)}`).slice(-4)}`); 86 | } 87 | 88 | function sanitizeTest() { 89 | const resource = {}; 90 | let content = 'hello world'; 91 | resource.content = content; 92 | resourceUtils.sanitize(resource); 93 | assert(resource.content === content); 94 | 95 | // Verify line breaks are retained in content prop 96 | content = 'hello\nworld'; 97 | resource.content = content; 98 | resourceUtils.sanitize(resource); 99 | const expected = 'hello\nworld'; 100 | assert(resource.content === expected, resource.content); 101 | } 102 | 103 | TestRegistry.registerTest(isValidIdTest); 104 | TestRegistry.registerTest(appendURLTest); 105 | TestRegistry.registerTest(normalizeResourceTest); 106 | TestRegistry.registerTest(sanitizeTest); 107 | -------------------------------------------------------------------------------- /src/test/set-base-uri-test.js: -------------------------------------------------------------------------------- 1 | import TestRegistry from '/src/test/test-registry.js'; 2 | import assert from '/src/lib/assert.js'; 3 | import setBaseURI from '/src/lib/set-base-uri.js'; 4 | 5 | function setBaseURITest() { 6 | // If a document has no base elements, and overwrite is true, then this should add a base element 7 | // and that should become the baseURI value, and there should only be one base element 8 | let title = 'no existing base and overwrite'; 9 | let doc = document.implementation.createHTMLDocument(title); 10 | let url = new URL('http://www.example.com'); 11 | setBaseURI(doc, url, true); 12 | assert(doc.baseURI === url.href); 13 | assert(doc.querySelectorAll('base').length === 1); 14 | 15 | // If a document has no base elements, and overwrite is false, then this should add a base element 16 | // and that should become the baseURI value. 17 | title = 'no existing base and not overwrite'; 18 | doc = document.implementation.createHTMLDocument(title); 19 | url = new URL('http://www.example.com'); 20 | setBaseURI(doc, url, false); 21 | assert(doc.baseURI === url.href); 22 | assert(doc.getElementsByTagName('base').length === 1); 23 | 24 | // If a document has a base element, and that base element has a canonical href value, and 25 | // overwrite is false then this should be a no-op. 26 | title = 'existing base with canonical href'; 27 | doc = document.implementation.createHTMLDocument(title); 28 | let base = doc.createElement('base'); 29 | base.setAttribute('href', 'http://www.example1.com/'); 30 | doc.head.append(base); 31 | assert(doc.baseURI === 'http://www.example1.com/'); 32 | url = new URL('http://www.example2.com'); 33 | setBaseURI(doc, url, false); 34 | // After the change, is the document in the expected state 35 | assert(doc.baseURI === 'http://www.example1.com/'); 36 | assert(doc.getElementsByTagName('base').length === 1); 37 | 38 | // If a document has a base element, and that base element has an href value that is not 39 | // canonical, then this should resolve the href value to the url, and replace the href value, and 40 | // cause the baseURI to be the canonical resolved value. 41 | title = 'existing base with non-canonical href'; 42 | doc = document.implementation.createHTMLDocument(title); 43 | base = doc.createElement('base'); 44 | base.setAttribute('href', '/path'); 45 | doc.head.append(base); 46 | // Before the change, baseURI is the result of resolving the relative url to the extension's base 47 | // url (because that is the 'page' executing the script that created the document without a base 48 | // element). 49 | url = new URL('http://www.example.com'); 50 | setBaseURI(doc, url); 51 | assert(doc.baseURI === 'http://www.example.com/path'); 52 | 53 | // If a document has a base element, and that base element has an href value that is not 54 | // canonical, and that relative url has invalid syntax, then this should still resolve the url as 55 | // before, but the invalid portion will get trimmed and url encoded as a path within the new base 56 | // url 57 | // NOTE: this actually is not desired behavior, but it is expected behavior in the current 58 | // implementation 59 | title = 'existing base with non-canonical invalid href'; 60 | doc = document.implementation.createHTMLDocument(title); 61 | base = doc.createElement('base'); 62 | base.setAttribute('href', ' \t\r\n foo bar '); 63 | doc.head.append(base); 64 | url = new URL('http://www.example.com'); 65 | setBaseURI(doc, url); 66 | assert(doc.baseURI === 'http://www.example.com/foo%20%20bar'); 67 | } 68 | 69 | TestRegistry.registerTest(setBaseURITest); 70 | -------------------------------------------------------------------------------- /src/test/subscribe-test-feed.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | test feed 4 | https://www.example.com/ 5 | test description 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/test/subscribe-test.js: -------------------------------------------------------------------------------- 1 | import * as databaseUtils from '/src/test/database-utils.js'; 2 | import * as db from '/src/db/db.js'; 3 | import * as indexedDBUtils from '/src/lib/indexeddb-utils.js'; 4 | import { INDEFINITE } from '/src/lib/deadline.js'; 5 | import TestRegistry from '/src/test/test-registry.js'; 6 | import assert from '/src/lib/assert.js'; 7 | import subscribe from '/src/service/subscribe.js'; 8 | 9 | async function subscribeTest() { 10 | const databaseNamePrefix = 'subscribe-test'; 11 | await databaseUtils.removeDatabasesForPrefix(databaseNamePrefix); 12 | const databaseName = databaseUtils.createUniqueDatabaseName(databaseNamePrefix); 13 | 14 | const conn = await databaseUtils.createTestDatabase(databaseName); 15 | 16 | // Setup subscribe parameters 17 | 18 | const path = '/src/test/subscribe-test-feed.xml'; 19 | const localURLString = chrome.extension.getURL(path); 20 | const url = new URL(localURLString); 21 | 22 | let callbackCalled = false; 23 | 24 | function feedStoredCallback() { 25 | callbackCalled = true; 26 | } 27 | 28 | let iconn; 29 | const fetchFeedTimeout = INDEFINITE; 30 | const notify = false; 31 | 32 | // Rethrow subscribe exceptions just like assertion failures by omitting try/catch. 33 | const resource = await subscribe(conn, iconn, url, fetchFeedTimeout, notify, feedStoredCallback); 34 | 35 | // subscribe should yield a resource object 36 | assert(resource && typeof resource === 'object'); 37 | 38 | // the produced resource should have the proper type value 39 | assert(resource.type === 'feed'); 40 | 41 | // the produced resource should have a well-formed identifier 42 | assert(db.isValidId(resource.id)); 43 | 44 | // subscribe should have invoked the feed-created callback 45 | assert(callbackCalled); 46 | 47 | // The created resource should contain one or more urls, including the initial url input to 48 | // subscribe 49 | assert(resource.urls.length); 50 | assert(resource.urls.includes(url.href)); 51 | 52 | // subscribing to a new feed should create the resource in the active state 53 | assert(resource.active === 1); 54 | 55 | // subscribing should have dispatched one or more messages 56 | assert(conn.channel.messages.length); 57 | 58 | conn.close(); 59 | await indexedDBUtils.remove(conn.conn.name); 60 | } 61 | 62 | TestRegistry.registerTest(subscribeTest); 63 | -------------------------------------------------------------------------------- /src/test/test-registry.js: -------------------------------------------------------------------------------- 1 | // NOTE: a Set would be more appropriate but just complicate things 2 | const testFunctions = []; 3 | 4 | function registerTest(testFunction) { 5 | if (typeof testFunction !== 'function') { 6 | throw new TypeError(`Test function is not a function: ${testFunction}`); 7 | } 8 | 9 | if (!testFunction.name) { 10 | throw new TypeError('Test function must have a name (no anonymous functions)'); 11 | } 12 | 13 | if (testFunctions.includes(testFunction)) { 14 | console.warn('Ignoring duplicate test function registration:', testFunction.name); 15 | return; 16 | } 17 | 18 | testFunctions.push(testFunction); 19 | } 20 | 21 | function getTests() { 22 | return testFunctions; 23 | } 24 | 25 | function findTestByName(name) { 26 | for (const testFunction of testFunctions) { 27 | if (testFunction.name === name) { 28 | return testFunction; 29 | } 30 | } 31 | 32 | return undefined; 33 | } 34 | 35 | export default { 36 | findTestByName, 37 | getTests, 38 | registerTest 39 | }; 40 | -------------------------------------------------------------------------------- /src/test/truncate-html-tests.js: -------------------------------------------------------------------------------- 1 | import TestRegistry from '/src/test/test-registry.js'; 2 | import assert from '/src/lib/assert.js'; 3 | import truncateHTML from '/src/lib/truncate-html.js'; 4 | 5 | export default function truncateHTMLTest() { 6 | const e = '.'; 7 | const input = 'a

    b

    c'; 8 | const output = 'a

    b.

    '; 9 | assert(truncateHTML(input, 2, e) === output); 10 | } 11 | 12 | TestRegistry.registerTest(truncateHTMLTest); 13 | -------------------------------------------------------------------------------- /src/test/url-sniffer-test.js: -------------------------------------------------------------------------------- 1 | import * as urlSniffer from '/src/lib/url-sniffer.js'; 2 | import TestRegistry from '/src/test/test-registry.js'; 3 | import assert from '/src/lib/assert.js'; 4 | 5 | function urlSnifferTest() { 6 | // Local aliases 7 | const { BINARY_CLASS } = urlSniffer; 8 | const { TEXT_CLASS } = urlSniffer; 9 | const { UNKNOWN_CLASS } = urlSniffer; 10 | 11 | // expected binary output 12 | let input = new URL('http://www.example.com/example.pdf'); 13 | let result = urlSniffer.classify(input); 14 | assert(result === BINARY_CLASS); 15 | 16 | // test with sub folder, expect to find binary 17 | input = new URL('http://www.example.com/folder/example.pdf'); 18 | result = urlSniffer.classify(input); 19 | assert(result === BINARY_CLASS); 20 | 21 | // test with multiple periods, expect to find binary 22 | input = new URL('http://www.example.com/folder/e.x.a.m.p.le.pdf'); 23 | result = urlSniffer.classify(input); 24 | assert(result === BINARY_CLASS); 25 | 26 | // expected text output 27 | input = new URL('http://www.example.com/test.txt'); 28 | result = urlSniffer.classify(input); 29 | assert(result === TEXT_CLASS); 30 | 31 | // expected unknown output 32 | input = new URL('http://www.example.com/test.asdf'); 33 | result = urlSniffer.classify(input); 34 | assert(result === UNKNOWN_CLASS); 35 | 36 | // data uri without explicit content type 37 | input = new URL('data:foo'); 38 | result = urlSniffer.classify(input); 39 | assert(result === TEXT_CLASS); 40 | 41 | // data uri with explicit content type of type text 42 | input = new URL('data:text/plain;base64,SGVsbG8sIFdvcmxkIQ%3D%3D'); 43 | result = urlSniffer.classify(input); 44 | assert(result === TEXT_CLASS); 45 | 46 | // data uri with explicit content type of type text (but not text/plain) 47 | input = new URL('data:text/html,%3Ch1%3EHello%2C%20World!%3C%2Fh1%3E'); 48 | result = urlSniffer.classify(input); 49 | assert(result === TEXT_CLASS); 50 | 51 | // data uri with an explicit binary content type 52 | input = new URL('data:image/png;base64,junk='); 53 | result = urlSniffer.classify(input); 54 | assert(result === BINARY_CLASS); 55 | 56 | // test finding of mime type of data uri with explicit binary content type 57 | input = new URL('data:image/png;base64,junk='); 58 | result = urlSniffer.dataURIFindMimeType(input); 59 | assert(result === 'image/png'); 60 | 61 | // test failure to find mime type of data uri defaults to the default 62 | input = new URL('data:foo'); 63 | result = urlSniffer.dataURIFindMimeType(input); 64 | assert(result === 'text/plain'); 65 | 66 | // expected output is type text 67 | input = 'text/plain'; 68 | result = urlSniffer.mimeTypeIsBinary(input); 69 | assert(result === TEXT_CLASS); 70 | 71 | // expected output is type text (exception to application super type) 72 | input = 'application/xml'; 73 | result = urlSniffer.mimeTypeIsBinary(input); 74 | assert(result === TEXT_CLASS); 75 | 76 | // expected output is type binary, application 77 | input = 'application/octet-stream'; 78 | result = urlSniffer.mimeTypeIsBinary(input); 79 | assert(result === BINARY_CLASS); 80 | 81 | // expected output is type binary, audio 82 | input = 'audio/mp3'; 83 | result = urlSniffer.mimeTypeIsBinary(input); 84 | assert(result === BINARY_CLASS); 85 | 86 | // expected output is unknown (mime type must be long enough to be valid) 87 | input = 'foofoo/barbar'; 88 | result = urlSniffer.mimeTypeIsBinary(input); 89 | assert(result === UNKNOWN_CLASS); 90 | } 91 | 92 | TestRegistry.registerTest(urlSnifferTest); 93 | -------------------------------------------------------------------------------- /src/view/background-page.js: -------------------------------------------------------------------------------- 1 | import BrowserActionControl from '/src/control/browser-action-control.js'; 2 | import ConfigControl from '/src/control/config-control.js'; 3 | import CronControl from '/src/control/cron-control.js'; 4 | import DbControl from '/src/control/db-control.js'; 5 | 6 | const configControl = new ConfigControl(); 7 | configControl.init(true); 8 | 9 | const dbControl = new DbControl(); 10 | dbControl.init(); 11 | 12 | const browserActionControl = new BrowserActionControl(); 13 | browserActionControl.init(true, true, true); 14 | 15 | const cronControl = new CronControl(); 16 | cronControl.init(); 17 | -------------------------------------------------------------------------------- /src/view/options-page/about.js: -------------------------------------------------------------------------------- 1 | export default function About() {} 2 | 3 | About.prototype.init = function (parent) { 4 | const heading = document.createElement('h1'); 5 | heading.append('About'); 6 | parent.append(heading); 7 | 8 | const manifest = chrome.runtime.getManifest(); 9 | 10 | let p = document.createElement('p'); 11 | p.setAttribute('class', 'option-text'); 12 | p.textContent = `Name: ${manifest.name || ''}`; 13 | parent.append(p); 14 | 15 | p = document.createElement('p'); 16 | p.setAttribute('class', 'option-text'); 17 | p.textContent = `Author: ${manifest.author || ''}`; 18 | parent.append(p); 19 | 20 | p = document.createElement('p'); 21 | p.setAttribute('class', 'option-text'); 22 | p.textContent = `Description: ${manifest.description || ''}`; 23 | parent.append(p); 24 | 25 | p = document.createElement('p'); 26 | p.setAttribute('class', 'option-text'); 27 | p.textContent = `Version: ${manifest.version || ''}`; 28 | parent.append(p); 29 | 30 | p = document.createElement('p'); 31 | p.setAttribute('class', 'option-text'); 32 | p.textContent = 'Homepage: '; 33 | if (manifest.homepage_url) { 34 | const anchor = document.createElement('a'); 35 | anchor.setAttribute('target', '_blank'); 36 | anchor.setAttribute('href', manifest.homepage_url); 37 | anchor.textContent = manifest.homepage_url; 38 | p.append(anchor); 39 | } else { 40 | p.textContent += 'unknown'; 41 | } 42 | 43 | parent.append(p); 44 | 45 | p = document.createElement('p'); 46 | p.setAttribute('class', 'option-text'); 47 | p.textContent = 'See the LICENSE file on GitHub for license, eula, privacy policy, and attributions.'; 48 | parent.append(p); 49 | }; 50 | -------------------------------------------------------------------------------- /src/view/options-page/general-settings-form.js: -------------------------------------------------------------------------------- 1 | import * as localStorageUtils from '/src/lib/local-storage-utils.js'; 2 | 3 | export default function GeneralSettingsForm() { } 4 | 5 | GeneralSettingsForm.prototype.init = async function (parent) { 6 | const heading = document.createElement('h1'); 7 | heading.textContent = 'General Settings'; 8 | parent.append(heading); 9 | 10 | const table = document.createElement('table'); 11 | table.setAttribute('id', 'general-settings-table'); 12 | 13 | let row = document.createElement('tr'); 14 | let cell = document.createElement('td'); 15 | cell.setAttribute('colspan', '2'); 16 | cell.setAttribute('class', 'option-text'); 17 | let input = document.createElement('input'); 18 | input.setAttribute('type', 'checkbox'); 19 | input.setAttribute('id', 'enable-notifications'); 20 | 21 | input.onclick = function inputOnclick(event) { 22 | localStorageUtils.writeBoolean('notifications_enabled', event.target.checked); 23 | }; 24 | 25 | input.checked = localStorageUtils.readBoolean('notifications_enabled'); 26 | 27 | let label = document.createTextNode('Enable notifications'); 28 | cell.append(input); 29 | cell.append(label); 30 | row.append(cell); 31 | table.append(row); 32 | 33 | row = document.createElement('tr'); 34 | cell = document.createElement('td'); 35 | cell.setAttribute('colspan', '2'); 36 | cell.setAttribute('class', 'option-text'); 37 | input = document.createElement('input'); 38 | input.setAttribute('type', 'checkbox'); 39 | input.setAttribute('id', 'enable-background'); 40 | 41 | // background is configured as an optional permission in the extension's manifest, so it is 42 | // addable and removable 43 | // TODO: this should be using a configuration variable and instead the permission should be 44 | // permanently defined. 45 | input.checked = await hasPermission('background'); 46 | input.onclick = (event) => { 47 | if (event.target.checked) { 48 | requestPermission('background'); 49 | } else { 50 | removePermission('background'); 51 | } 52 | }; 53 | 54 | label = document.createTextNode('Permit this extension to check for updates in the background if Chrome is configured to allow background processing.'); 55 | cell.append(input); 56 | cell.append(label); 57 | row.append(cell); 58 | table.append(row); 59 | 60 | row = document.createElement('tr'); 61 | cell = document.createElement('td'); 62 | cell.setAttribute('colspan', '2'); 63 | cell.setAttribute('class', 'option-text'); 64 | input = document.createElement('input'); 65 | input.setAttribute('type', 'checkbox'); 66 | input.setAttribute('id', 'enable-idle-check'); 67 | 68 | input.checked = localStorageUtils.readBoolean('only_poll_if_idle'); 69 | input.onclick = event => localStorageUtils.writeBoolean('only_poll_if_idle', event.target.checked); 70 | 71 | label = document.createTextNode('Only check for updates when my device is idle'); 72 | cell.append(input); 73 | cell.append(label); 74 | row.append(cell); 75 | table.append(row); 76 | 77 | parent.append(table); 78 | }; 79 | 80 | function requestPermission(name) { 81 | return new Promise(resolve => chrome.permissions.request({ permissions: [name] }, resolve)); 82 | } 83 | 84 | function removePermission(name) { 85 | return new Promise(resolve => chrome.permissions.remove({ permissions: [name] }, resolve)); 86 | } 87 | 88 | function hasPermission(name) { 89 | return new Promise(resolve => chrome.permissions.contains({ permissions: [name] }, resolve)); 90 | } 91 | -------------------------------------------------------------------------------- /src/view/options-page/nav-menu.js: -------------------------------------------------------------------------------- 1 | export default function NavMenu() { 2 | this.onclick = undefined; 3 | this.currentItem = undefined; 4 | } 5 | 6 | // TODO: use single listener on the menu itself 7 | NavMenu.prototype.init = function () { 8 | const menuItems = document.querySelectorAll('#navigation-menu li'); 9 | for (const item of menuItems) { 10 | item.onclick = this.itemOnclick.bind(this); 11 | } 12 | }; 13 | 14 | // The listener is attached to the item, but that may not be what triggered the click event of 15 | // event.target, so use currentTarget to get the element where the listener is attached 16 | NavMenu.prototype.itemOnclick = function (event) { 17 | this.onclick(event.currentTarget); 18 | }; 19 | -------------------------------------------------------------------------------- /src/view/options-page/options-page.js: -------------------------------------------------------------------------------- 1 | import About from '/src/view/options-page/about.js'; 2 | import BrowserActionControl from '/src/control/browser-action-control.js'; 3 | import DisplaySettingsForm from '/src/view/options-page/display-settings-form.js'; 4 | import FeedList from '/src/view/options-page/feed-list.js'; 5 | import GeneralSettingsForm from '/src/view/options-page/general-settings-form.js'; 6 | import NavMenu from '/src/view/options-page/nav-menu.js'; 7 | import SubscriptionForm from '/src/view/options-page/subscription-form.js'; 8 | 9 | let currentSection; 10 | 11 | const navMenu = new NavMenu(); 12 | navMenu.init(); 13 | navMenu.onclick = function navMenuOnclick(item) { 14 | showSection(item); 15 | }; 16 | 17 | const feedList = new FeedList(); 18 | feedList.init(document.getElementById('section-subscriptions')); 19 | feedList.onappendCallback = function feedListOnappendCallback() { 20 | feedCountUpdate(); 21 | }; 22 | 23 | feedList.onclickCallback = function feedListOnclickCallback() { 24 | showSectionByElementId('mi-feed-details'); 25 | // For longer feed lists, details will be out of view, so we need to scroll back to the top 26 | scrollTo(0, 0); 27 | }; 28 | 29 | feedList.onremoveCallback = () => feedCountUpdate(); 30 | feedList.unsubscribeCallback = () => showSectionByElementId('subs-list-section'); 31 | feedList.activateCallback = () => showSectionByElementId('subs-list-section'); 32 | feedList.deactivateCallback = () => showSectionByElementId('subs-list-section'); 33 | 34 | const subscriptionForm = new SubscriptionForm(); 35 | subscriptionForm.init(document.getElementById('section-add-subscription')); 36 | subscriptionForm.onsubscribe = function subscriptionFormOnsubscribe(feed) { 37 | feedList.appendFeed(feed); 38 | showSectionByElementId('subs-list-section'); 39 | }; 40 | 41 | const displaySettingsForm = new DisplaySettingsForm(); 42 | displaySettingsForm.init(document.getElementById('section-display-settings')); 43 | 44 | const generalSettingsForm = new GeneralSettingsForm(); 45 | generalSettingsForm.init(document.getElementById('section-general-settings')); 46 | 47 | const about = new About(); 48 | about.init(document.getElementById('about')); 49 | 50 | 51 | const browserActionControl = new BrowserActionControl(); 52 | browserActionControl.init(); 53 | 54 | function showSection(menuItemElement) { 55 | if (menuItemElement && menuItemElement !== navMenu.currentItem) { 56 | if (navMenu.currentItem) { 57 | navMenu.currentItem.classList.remove('navigation-item-selected'); 58 | } 59 | if (currentSection) { 60 | currentSection.style.display = 'none'; 61 | } 62 | menuItemElement.classList.add('navigation-item-selected'); 63 | const sectionId = menuItemElement.getAttribute('section'); 64 | const sectionElement = document.getElementById(sectionId); 65 | 66 | if (!sectionElement) { 67 | console.error('No section element found with id', sectionId); 68 | return; 69 | } 70 | 71 | sectionElement.style.display = 'block'; 72 | navMenu.currentItem = menuItemElement; 73 | currentSection = sectionElement; 74 | } 75 | } 76 | 77 | function showSectionByElementId(id) { 78 | showSection(document.getElementById(id)); 79 | } 80 | 81 | function feedCountUpdate() { 82 | const count = feedList.count(); 83 | const element = document.getElementById('subscription-count'); 84 | element.textContent = ` ${count > 50 ? '(50+)' : `(${count})`}`; 85 | } 86 | 87 | showSectionByElementId('subs-list-section'); 88 | -------------------------------------------------------------------------------- /test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 19 | 20 | 21 | 22 | 23 | 24 | --------------------------------------------------------------------------------