├── .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 |
16 |
29 |
30 | |
31 |
32 |
33 | Subscriptions
34 | No subscriptions
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 | |
52 |
53 |
54 | Description: |
55 | |
56 |
57 |
58 | Feed location: |
59 | |
60 |
61 |
62 | Website: |
63 | |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 | |
72 |
73 |
74 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 |
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 |
39 |
40 |
41 |
42 |
43 | - Body:
44 |
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 = 'ab
c';
8 | const output = 'ab.
';
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 |
--------------------------------------------------------------------------------